# 消除掉各種會出問題的狀況 如何消除掉那種實際上可以避免的狀況(或者說是「使用上的錯誤」,像是在關閉檔案之後又想要寫入檔案,或是在物件完成初始化之前就去調用那個物件裡的方法) ## 設計出不容易誤用的程式(介面) 比如說,你寫了某個函式,其中有兩個參數是陣列值,或許你會希望這兩個陣列,總是具有相同的大小 ```cpp= void showAuthorRoyalties( const vector<string> & titles, const vector<double> & royalties) { assert(titles.size() == royalties.size()); for (int index = 0; index < titles.size(); ++index) { printf("%s,%f\n", titles[index].c_str(), royalties[index]); } } ``` 如果相關參數有彼此對不上的問題 ,最好的情況下也必須等到執行程式碼時 才能發現問題,因為編譯階段根本看不出這類的問題 其實最好的做法,就是把界面設計成不可能接受錯誤的用法 你可以把多個陣列合併成一個,這樣就能消除掉多個陣列大小 對不上的問題 ```cpp= struct TitleInfo { string m_title; float m_royalty; }; void showAuthorRoyalties(const vector<TitleInfo> & titleInfos) { for (const TitleInfo & titleInfo : titleInfos) { printf("%s,%f\n", titleInfo.m_title.c_str(), titleInfo.m_royalty); } } ``` ## 時間點最重要 如果想建立出不會出問題的介面,其中一個很關鍵的重點,就是儘早偵測出問題 執行階段偵測出問題 - 還可以接受 編譯階段偵測出問題 - 太棒了 讓你無法寫出錯誤的東西 - 完美 ## 一個比較複雜的例子 ```cpp= struct Params { Params( const Matrix & matrix, const Sphere & sphereBounds, ViewKind viewKind, DrawStyle drawStyle, TimeStyle timeStyle, const Time & timeExpires, string tagName, const OffsetPolys & offsetPolys, const LineWidth & lineWidth, const CustomView & customView, const BufferStrategy & bufferStrategy, const XRay & xRay, const HitTestContext * hitTestContext, bool exclude, bool pulse, bool faceCamera); void drawSphere(Point point, float radius, Color color); }; ``` 如果我們想在某個角色的頭上畫一個簡單的球體(就像我們之前想標示出有哪些 NPC 看到了玩家一樣),或許可以把程式碼寫成下面這樣 ```cpp= void markCharacterPosition(const Character * character) { Params params( Matrix(Identity), Sphere(), ViewKind::World, DrawStyle::Wireframe, TimeStyle::Update, Time(), string(), OffsetPolys(), LineWidth(), CustomView(), BufferStrategy(), XRay(), nullptr, false, false, false); params.drawSphere( character->getPosition() + Vector(0.0, 0.0, 2.0), 0.015, Color(Red)); } ``` 問題 1. 你絕對記不住這16個參數的順序 2. 列表最後面的四個布林參數,究竟是什麼意思? 3. 要再添加或移除構建函式裡的參數會非常痛苦,而且很容易出錯 4. 使用起來很不方便,而且隨著時間的推移 ,還會越來越不方便 最常見的解法就是把構建過程分解成好幾個步驟 ```cpp= void markCharacterPosition(const Character * character) { Params params; params.setXRay(0.5); params.commit(); params.drawSphere( character->getPosition() + Vector(0.0, 0.0, 2.0), 0.015, Color(Red)); } ``` 但這個設計又引入了另一個新的問題點,因為有好幾個執行步驟,就會產生執行順序的問題。 如果我們並沒有按照順序來調用這些函式 (例如 commit 之後 又去調用 setXRay,或是在 commit 之前調用 drawsphere),由於我們並沒有定義這樣會發生什麼事,所以編輯器和編譯器全都幫不上什麼忙。實際上一直要等到執行階段,才能偵測出這樣的錯誤 你或許可以用某種約定慣例的做法,來協助大家避免掉這種執行順序上的錯誤,不過這並不是我們所能採用的最佳做法 ## 最理想就是讓執行順序不可能出錯 其中一種做法就是把兩個階段切分成兩個物件,先構建參數,再利用參數來進行繪製。 ```cpp= void markCharacterPosition(const Character * character) { Params params; params.setXRay(0.5); params.setDrawStyle(DrawStyle::Solid); params.setPulse(true); Draw draw(params); draw.drawSphere( character->getPosition() + Vector(0.0, 0.0, 2.0), 0.015, Color(Red)); } ``` 在這樣的結構下,執行的順序就被隱含進來了。你一定要先有一個 Params 物件,才能去建立Draw物件,所以當然會先建立 Params ```cpp= void markCharacterPosition(const Character * character) { const Params params = Params() .setXRay(0.5) .setDrawStyle(DrawStyle::Solid) .setPulse(true); Draw draw(params); draw.drawSphere( character->getPosition() + Vector(0.0, 0.0, 2.0), 0.015, Color(Red)); } ``` 由於所有 Params 相關的操作,全都是在我們這一整串方法中進行的,所以我們可以利用 C++ 的 const 關鍵字,讓這個 Params物件變成一個常量。這也就表示,一旦構建完成之後,編譯器就不會再讓我們去對它進行修改了。 如果不只是變得更清楚,而是真的變成不可能這麼做,那就更好了 ```cpp= void markCharacterPosition(const Character * character) { Draw draw = Params() .setXRay(0.5) .setDrawStyle(DrawStyle::Solid) .setPulse(true); draw.drawSphere( character->getPosition() + Vector(0.0, 0.0, 2.0), 0.015, Color(Red)); } ``` 它根本沒機會讓我們不小心又改動到參數。這確 實是非常緊湊的程式碼,像這樣寫的話,我們就等於是透過了設計,排除掉會出問題的情況了 ## 「狀態」的協調控制 需求:讓角色進入「無敵」的狀態 - 當進入過場動畫時 - 喝下無敵藥水時 ```cpp= void playCelebrationCutScene() { Character * player = getPlayer(); player->setInvulnerable(true); playCutScene("where's chewie's medal.cut"); player->setInvulnerable(false); } void chugInvulnerabilityPotion() { Character * player = getPlayer(); player->setInvulnerable(true); sleepUntil(now() + 5.0); player->setInvulnerable(false); } ``` 這樣的設計很容易導致我們犯下使用上的錯誤 如果在進入遊戲過場動畫時,玩家正好也打開了無敵藥水的軟木塞,那我們可就麻煩了 1. 一開始播放過場動畫,setInvulnerable 就會把玩家設定為無敵 2. 然後又因為喝下了藥水,所以 setInvulnerable 又會被再次調用 3. 實際上,此時並不會有什麼變化,因為這時候玩家已經無敵了。 4. 過了五秒鐘之後藥水的效力消失,於是程式碼又會去調用setInvulnerable(false) 5. 但這時候還在播放過場動畫 嘗試解法,讓玩家回到進入無敵前的狀態 ```cpp= void playCelebrationCutScene() { Character * player = getPlayer(); bool wasInvulnerable = player->isInvulnerable(); player->setInvulnerable(true); playCutScene("where's chewie's medal.cut"); player->setInvulnerable(wasInvulnerable); } void chugInvulnerabilityPotion() { Character * player = getPlayer(); bool wasInvulnerable = player->isInvulnerable(); player->setInvulnerable(true); sleepUntil(now() + 5.0); player->setInvulnerable(wasInvulnerable); } ``` 這個解法同樣會出問題 舉例來說,如果玩家在過場動畫開始之前,先喝了無敵藥水,後來藥水在播放過場動畫期間失去作用 ,這時候還原到喝藥水之前的狀態就不對了。 我們可以考慮把每一段無敵相關的程式碼,各自切開來處理 。如果我們針對每一段相關的程式碼,個別去維護相應的無敵標記,這些程式碼就不會糾纏在一起了 ```cpp= void playCelebrationCutScene() { Character * player = getPlayer(); player->setInvulnerable(InvulnerabilityReason::CutScene, true); playCutScene("it's anti-fur bias, that's what it is.cut"); player->setInvulnerable(InvulnerabilityReason::CutScene, false); } void chugInvulnerabilityPotion() { Character * player = getPlayer(); player->setInvulnerable(InvulnerabilityReason::Potion, true); sleepUntil(now() + 5.0); player->setInvulnerable(InvulnerabilityReason::Potion, false); } ``` 如果採用這樣的做法,我們就必須去檢查所有的無敵標記,而不能只檢查單 一個標記。如果其中有任何一個標記被設為無敵,玩家就是處於無敵的狀 態 這種做法是可行的,不過還是需要遵守一定的紀律。如果有人太懶惰,在不 同段的程式碼裡重複使用同一個 InvulnerabilityReason ,還是有可能會把程式搞掛掉 我們也可以考慮透過持續追蹤無敵計數值的方式,來消除掉糾纏不清的問 題。只要有任何程式碼把角色設為無敵,那就是無敵。這樣就可以得出一個非常簡單的「推入- 彈出 」(push-pop )模型 ```cpp= void playCelebrationCutScene() { Character * player = getPlayer(); player->pushInvulnerability(); playCutScene("I'm getting my own ship.cut"); player->popInvulnerability(); } void chugInvulnerabilityPotion() { Character * player = getPlayer(); player->pushInvulnerability(); sleepUntil(now() + 5.0); player->popInvulnerability(); } ``` 像這樣的「推入-彈出」模型,確實是可行的。一旦你習慣這種慣用的做法就很容易理解了。這樣的做法也很容易進行擴展,每次要寫新的程式碼來進行無敵相關的操作,都不會影響到其他的程式碼,因為不同的程式碼都可以獨立去推入和彈出無敵的計數值,而不會破壞到任何重要的東西。 不過如果你的程式碼忘了去調用 popInvulnerability,這個角色就會永遠保持無敵狀態 像這種使用上的錯誤,最好還是要完全消除掉。最簡單的方式,就是把「推入-彈出」的操作,包裝在構建函式和解構函式內。然後編譯器就會再次成為我們的好朋友: ```cpp= struct InvulnerableToken { InvulnerableToken(Character * character) : m_character(character) { m_character->pushInvulnerability(); } ~InvulnerableToken() { m_character->popInvulnerability(); } Character * m_character; }; void playCelebrationCutScene() { Character * player = getPlayer(); InvulnerableToken invulnerable(player); playCutScene("see you later, losers.cut"); } void chugInvulnerabilityPotion() { Character * player = getPlayer(); InvulnerableToken invulnerable(player); sleepUntil(now() + 5.0); } ``` ## 小結 雖然我們無法讓設計完全防呆,但我們所擋下的每一件蠢事,確實可以讓我們的系統更加可靠。因此,你打從一開始就要盡可能找機會,從你的設計裡消除掉那些會出問題的狀況。