2017年11月10日 星期五

如何避免你的程式大爆炸了?

原文連結

在1960/10/24,蘇聯拜科努爾太空發射場發生了一場大爆炸。在準備飛行的期間,火箭意外爆炸了,造成了火災及無數的破壞。超過70個人在這天死亡。USSR的戰略火箭計劃大大的退步。這場大災難被命名為Nedelin,這個計劃死去的執行負責人。

我們重新檢視造成這場大災難的步驟:

1. 工程師在非常緊湊的排程下工作

2. 因為時間不夠,許多安全程序都被省略或者沒正確執行。

3. 許多意外及缺失在發射準備期間被忽略或沒仔細分析過程

4. 在爆炸當天,為了加速發射流程,許多測試被迫不照順序同時執行。

5. 因為火箭啟動的延遲,工程師沒有正常理由就直接在正式環境作業。

6. 當天有人看到發射按鈕沒在原位而將它擺回原始位置。

7. 其它元件也被放在錯誤的地方,造成系統執行發射命令並且點燃了第二階段引擊,使得沒準備好的火箭爆炸了。

對照遊戲開發,我列出了以下狀況


Initialization, update and deinitialization patterns
Bad input data
Too much control exposed in data
Dereferencing null pointer
Big classes
Division
Vector normalization

同時我也提供了如何防範錯誤的做法及範例。

防衛性程式及合約式設計(Design by Contract)

防衛性程式
不該相信輸入的資料,而是預先檢查資料的正確性並給予安全的數值。
下例:即使不正確的參數,仍然給予安全的答案:零。

float GetSquareRoot(float Argument)
{
    if (Argument <= 0.0f)
    {
        return 0.0f;
    }
    return sqrt(Argument);
}

合約式設計:
設定程式與用戶關聯性
程式必須設定前置條件、後置條件和其不變性。

條件可被下列方式呈現:
assert
comment
etc. (a set of test)

下例:使用註解(comment)及assert設定程式的合約
// Argument needs to be equal to or greater than zero.
float GetSquareRoot(float Argument)
{
assert(Argument >= 0.0f );
return sqrt(Argument);
}


這兩項方法都能幫助我們寫出更良好的程式。
無論如何,我相信遊戲程設要能其特別的狀況而被特別對待。

1. 最大化執行速度是遊戲的重點

防衛性程式無法用在程式的每個地方,因為這會拖慢程式速度,且額外的程式會造成其它錯誤。我們的遊戲就遭遇過執行速度的問題。同時太多的防衛性程式使錯誤不容易發現(被防衛性程式擋下)。

2. 程式在變更迅速的環境下被完成。

遊戲程設包含了快速開發、創意驗証。但是因為要求速度會造成未來維護的問題。

3. 程式必須同時能用於產品及測開發階段

當我們在製做新的功能時,編輯器的新工具或改良也在同時進行,我們的第一個使用者是其它團隊成員、設計師、美術人員。他們也在製作遊戲及使用我們的工具。這表示了我們的程式同時被開發團隊及消費者所使用,即使在釋出給玩家之前。用assert強制程式對於工程師是好方法,但是同時會中斷其它成員的工作流程。

另外你要面對的問題可能和你遊戲、團隊、工作模式有關。Rendering的工作就會以速度為優先考量,而AI的工作就會需要更多穩定的程式。

我們將會介紹遊戲開發會遇到的常見幾種狀況。

Initialization, update and deinitialization patterns

讓我們看這個RAII(Resource Acquisition Is Initialization)的實作
class MyClass
{
public:
    MyClass()
    {
        m_Data1 = new DataClass1;
        m_Data2 = new DataClass2;
    }
    ~MyClass()
    {
        delete m_Data1;
        delete m_Data2;
    }

    void Update(float FrameTime)
    {
        m_Data1->Update(FrameTime);
        m_Data2->Update(FrameTime);
    }

private:
    DataClass1* m_Data1;
    DataClass2* m_Data2;
};
我們在constructor創造2個物件,然後在destructor刪除它們。並且包含了一個常見的Update函式,會在每個frame被呼叫一次。

現在讓我們把記憶體管理移出constructor、destructor,並且創造一個函式來控制這些資源。這是為了避免在同一個地方執行太多的操作。通常資源管理是個佔效能的操作,所以我們會希望在遊戲中能方便的控制它們。我們創造2個函式Initialize and Deinitialize來管理記憶體。

這是新的程式碼:


class MyClass
{
public:
    MyClass() { }
    ~MyClass() { }

    void Initialize()
    {
        m_Data1 = new DataClass1;
        m_Data2 = new DataClass2;
    }

    void Deinitialize()
    {
        delete m_Data1;
        delete m_Data2;
    }

    void Update(float FrameTime)
    {
        m_Data1->Update(FrameTime);
        m_Data2->Update(FrameTime);
    }

private:
    DataClass1* m_Data1;
    DataClass2* m_Data2;
};

這程式仍然十分單純,但這有許多的問題。

1 這沒有適當的initialization在constructor中
2 如果Initialize被呼叫了兩次會發生什麼事?這有可能造成memory leak
3 在Deinitialize也有可能會發生相似的問題
4 另外個問題關於Update,若沒有先呼叫Initialize而直接執行Update會發生什麼事情?會因空指標或被初始化物件造成crash.
5 若複製這個物件,有可能造成多次刪除的問題。

我們這裡沒有特別做合約式設計,我們只是考慮這個類別有可能的使用狀況。

為了使此程式穩定、減少出錯。我們需要

1 提供適當的initialization給我們的成員變數
2 加上bool參數m_Initialized 使memory leak、及多次刪除不會發生。
3 正常狀況下,若指標已被釋放,我們應將指標位置清除
4 由destructor呼叫Deinitialize,避免使用者忘記呼叫。
5 定義private copy constructor and copy operator, 防止它們被複製。

更改後如下:

class MyClass
{
public:
    MyClass();
    ~MyClass();

    void Initialize();
    void Deinitialize();

    void Update(float FrameTime);

private:
    MyClass(const MyClass&);
    MyClass& operator=(const MyClass&);

    bool m_Initialized;
    DataClass1* m_Data1;
    DataClass2* m_Data2;
};

MyClass::MyClass()
    : m_Initialized(false)
    , m_Data1(nullptr)
    , m_Data2(nullptr)
{
}

MyClass::~MyClass()
{
    //Prevent a memory leak if the client 

    //does not call Deinitialize.
    Deinitialize();
}

void MyClass::Initialize()
{
    if (m_Initialized)
        return;

    m_Data1 = new DataClass1;
    m_Data2 = new DataClass2;

    m_Initialized = true;
}

void MyClass::Deinitialize()
{
    if (!m_Initialized)
        return;

    delete m_Data1;
    m_Data1 = nullptr;

    delete m_Data2;
    m_Data2 = nullptr;

    m_Initialized = false;
}

void MyClass::Update(float FrameTime)
{
    if (!m_Initialized)
        return;

    // Defensive approach to check the pointers separately.
    if (!m_Data1 || !m_Data2)
        return;

    m_Data1->Update(FrameTime);
    m_Data2->Update(FrameTime);
}

Bad input data
(壞掉的輸入資料)

壞掉的輸入資料從資訊產業誕生的那一天就有了。檔案遺失或例外參數都會造成遊戲或編輯器當掉。

通常工程師會特別注意這問題。我們的程式需要為了參數遺失,特別寫預設的參數,像是預設的貼圖或可視的物件來提醒輸入錯誤,給予錯誤訊息及記錄也是有效的方法。沒有程式應該直接掛點。

在多數的遊戲引擊,我們可以很輕易地給予編輯器參數。讓我們可以利用這參數在一行指令就生成數個物件。

int NumberOfProjectilesToSpawn;

通常我們只會給他0,1,5這種數量。但有時使用者會犯錯或給與一個超大的數值像是100萬、或負數?那時候我們的程式碼還可以正常運作嗎?

好的方法是去考量現況決定參數的預設值及範圍。另外一種方案,如果我們想要不被限制的超大參數,我們提醒使用者輸入的參數可能會造成問題。

在防衛性程式,我們可以很清楚的區分有危險的參數。我們將所有input視為不可信的,但有些被驗證的內部資料是可以相信的。例如,我們認定所有外部檔案、編輯器輸入參數、玩家輸入參數、網路上參數都是有不可信的。驗證及修正函式需要將這些參數轉成可信的參數。

void SetNumberOfProjectilesToSpawn(int NumberOfProjectilesToSpawn)
{
    m_NumberOfProjectilesToSpawn = NumberOfProjectilesToSpawn;
    ValidateAndCorrectParameters();
}

void ValidateAndCorrectParameters()
{
    m_NumberOfProjectilesToSpawn = clamp(m_NumberOfProjectilesToSpawn, 0, 8);
}

在遊戲開發中,越早驗證是越好。例如:在編輯器輸入參數時就驗證,在存成檔案之前,或是在檔案一讀進來。越早確認使其更容易減少執行遊戲不必要的測試及效能衝擊。

Too much control exposed in data
(太多對資料操作的方式)

由壞掉的輸入資料所造成的問題再更進一步思考,我們所有的系統都是資料導向的。這些系統能被設定或在沒有工程師的狀況下被操作,完全透過所輸入的資料。

通常會將所有操作及遊戲特色給每個組員,尤其是非工程師。但是工程師必須問問自己,我們真的可以提供所有組合參數嗎?或者反而造成更多例外狀況?

創造一個真正資料導向的系統是非常困難及花時間的。

當我們提供任一個參數,這都會成為整個巨大資料導向系統的一部份。很自然的,我們會加入一個介於程式與資料之間中間層。

讓我們看這用來控制敵人的簡單結構

struct EnemyBehavior
{
    float m_WalkSpeed;
    float m_TurningRate;
    bool m_JumpAllowed;
    float m_JumpSpeed;
    // …
};

這個程式提供m_WalkSpeed 的小參數,也同時提供m_TurningRate 大參數。當m_WalkSpeed 很大時跳躍的功能仍然可以正常運作嗎?

一個較安全的方案是準備一組預先設定好的參數組,而這些參數都是可正常執行的。使用者只允許使用這組先定好的參數。

enum EnemyBehaviorPreset
{
    EnemyBehaviorPreset_TightCorridors,
    EnemyBehaviorPreset_IndoorHalls,
    EnemyBehaviorPreset_OutdoorOpenSpace
};

我們只提供一個參數讓使用都可以在編輯器使用。

EnemyBehaviorPreset m_EnemyBehavior;

這個方法雖然限制使用者的自由,但是對工程師有更佳的穩定及維護性。

Dereferencing null pointer
釋放空指標

Dereferencing null pointer是造成遊戲或編輯器當掉的一項常見因素。

在Unreal Engine 2 and 3,其中一項程式語言UnrealScript的設計目標就是防止這類問題。並且創造不需指標的開發環境。存取物件references在UnrealScript一直是安全的,即使它們沒有指向任何物件。

因為執行速度是很重要的,所以我們不能在所有釋放指標的地方做檢查。無論如何每個部門處理指標的方針也許不一樣。繪圖工程師會犧牲防衛機制去換取速度,但是AI或gameplay則會希望更多的防衛機制,尤於gameplay時常更動。所以如何處理指標會按其環境而定。

-Avoiding pointers(避免使用指標)

第一、我們可以避免使用指標當函式的參數且用C++參照(references)取代,下例即是說明如何取代指標

void MyFunction(ControlData* InputData)
{
    if (InputData)
    {
        int parameter1 = InputData->GetParameter1();
        // …
    }
}

改成用reference的版本

void MyFunction(ControlData& InputData)
{
    int parameter1 = InputData.GetParameter1();
    // …
}

這樣使用者在用這個函式時就會提供適合的參數,並確認及釋放指標。

-Public and private methods(公開及私有函式)

實際上,有項不好的慣例是在公開的函式傳入的指標當作參數,

若這函式是屬於公開介面的話,我建議要檢查這函式是否為空。

void MyClass::AnalyzeInputData(Data* Input)
{
    if (!Input)
        return;
    // …
}

若函式是屬於私有實作的一部份,我會傾向將先前的程式移除。在這例子,函式有註解標明、或是函式會發出警告訊息去確認指標狀況。

class MyClass
{
public:
    // Method always tests if the pointer argument is null.
    void AnalyzeInputData(Data* Input);

private:
    void UpdateObject_NoTestForZeroPointer(ObjectClass* Object);

    // Method does not check validity of pointer arguments:
    void UpdateObject(ObjectClassA* Object1, ObjectClassB* Object2);
};

另外在私有函式UpdateObject及UpdateObject_NoTestForZeroPointer的開頭,我們可以利用assert驗證指標

// Method does not check validity of pointer arguments:
void MyClass::UpdateObject(ObjectClassA* Object1, ObjectClassB* Object2)
{
    assert(Object1);
    assert(Object2);
    // …
}

無論如何這不是最實際的做法,若我們的assert中斷成員在使用編輯器。要改進這個方法,我們可以創造一個防禦性軟驗證。

-“Soft” assertions

軟驗證應該允許程式繼續執行,但仍會回報問題給開發者

void MyClass::UpdateObject_NoTestForZeroPointer(ObjectClass* Object)
{
#ifdef CONTRACT_SAFE_CHECK
    reportIfNull(Object); // Soft assert
    if (!Object)
        return;
#endif
    // …
}

這方法通知程式人員不正常的狀況(將訊息送到資料庫)且不會中斷執行。利用#ifdef設定是否要執行這項檢查。開發設定應效能分析(profiling)及正式環境可執行。

編譯前的參數也要考慮到是否在編輯器執行或是遊戲內會造成其它問題。

Big classes

有些class成長速度太快了。所以有些原始檔會變得比其它檔案還大。若你去檢查你正在執行的專案,你會發現有些檔案很明顯地較其它檔案大上不少。這些檔案通常是負責角色、玩家或是遊戲管理者(manager)。這並不令人驚訝。當我們增加新的特色進遊戲時,即使是最小的步驟,我們也會將它加入現有的class底下。這種增加速度是會造成問題的。這使得我們很難去維護preconditions, postconditions and invariants。維護大型的類別是很困難的,且他們也會造成更多問題。從Initialization, update and deinitialization patterns要發現問題會比較簡單。通常他們包含了許多沒有分類好的子元素。這有許多可能的方法可以解決這個問題。我在這展示其中一個方法。

若我需要在現有的class中加入一項新的元素,我開始評估是否該為其建立nested class。下例在ObjectBehavior 內新增成員參數及函數去為了完成Special Action 1。

class ObjectBehavior
{
public:
    // …
private:
    // …
    // Begin Special Action 1.
    bool m_ShouldStartSpecialAction1;
    float m_TimeLeftToStartSpecialAction1;
    float m_SpecialAction1Parameter;
    void InitializeSpecialAction1();
    void DeinitializeSpecialAction1();
    void UpdateSpecialAction1();
    // End Special Action 1.
    // …
};

以nested class方式實作:
以子類別
class ObjectBehavior
{
public:
    // …
private:
    // …
    class SpecialAction1
    {
public:
        void Initialize();
        void Deinitialize();
        void Update(ObjectBehavior* ParentObject);
    private:
        bool m_ShouldStart;
        float m_TimeLeftToStart;
        float m_Parameter;
    };

    SpecialAction1 m_SpecialAction1;
    // …
};

哪一個方法比較好?我們適度的包裝了新的參數在獨立的私有類別中。只有SpecialAction1 nested class被存取到。這使得類別在未來可以更容易被區分出來。

除法(Division

電玩遊戲用了大量的浮點數運算。例如,我們經常使用除法在我們的程式內。這是非常基本的數學運算,但這仍然要注意不要讓除數為0。若我們除數為0時,會造成浮點數的例外狀況,或者浮點數的例外被擋掉了,算出來的結果會是無限(INF)或非數字(NaN)。

請也考慮到下面的狀況。當兩個數字都是合法的浮點數,但是結果會是INF,因為他們超過32-bit浮點數可表現範圍。

float f1 = 100000000000000000000.0f; // 1.0e20f
float f2 = 0.0000000000000000001f; // 1.0e-19f
float f3 = f1 / f2;

f3即為INF。另一個例子:

float f4 = 0.0f;
float f5 = 0.0f;
float f6 = f4 / f5;

參數f6是NaN

我們可以繼續執行程式,因為INF是個合法且可計算的特別記號。無論如何,在多數狀況下,我們不會想去處理這種狀況。許多函式庫會因這些例外狀況中斷。

Unreal Engine 4 提供了時別的函式去偵測浮點數太接近零。

bool FMath::IsNearlyZero(float Value, float ErrorTolerance = SMALL_NUMBER);

在 C# Unity Engine可以被寫成這樣:

bool IsNearlyZero(float Value)
{
    return Mathf.Abs( Value ) <= 0.00000001f;
}

我們可以在做除法之前先確認我們的除數。

float denominator = b – a + c;
if (!IsNearlyZero(denominator))
{
    result = d / denominator;
}

要確認特別的浮點數,我們擁有以下函式:

float.IsInfinity(float) and float.IsNaN(float) in Unity Engine
FMath::IsFinite(float) in Unreal Engine 4

同時我們經常忘記某些特別狀況會用到除法。請考慮以下的update函數。

void Object::UpdateLogic(float FrameTime)
{
    Vector3 Velocity = (GetCurrentPosition() – m_PreviousPosition) / FrameTime;
    // …
}

我們可以很簡單的假設FrameTime總是大於0。當然程式會在FrameTime為零時出錯。這是有機會發生的,例如當遊戲在製作時我們相要將時間暫停,但是update仍然被呼叫。在編輯器,我們通常不會更新world logic,但在某些特別的狀況,我們會為了模擬而更某些物件的邏輯。這簡單的程式會因為這些特例而掛掉。

Vector normalization

在Vector normalization會出現某些例子上。通常是計算3d數學時。normalization是為了計算出單位向量的方法,會將其向量除以自身長度。這些函式都非常單純,我們再次回想起了除法的例外。當向量為零時或非常接近零時會出錯。

在Unreal Engine 4,我們有以下兩個函式:

FVector::GetUnsafeNormal() – 這是不會做額外檢查的normalization,但比較快速。

FVector::GetSafeNormal() – 這是會檢查除數是否為零,若長度接近零時,結果回傳零。

有趣的是它用很直接的寫在函式名稱上提醒使用者。

Unity Engine在C#則是只有提供安全的normalization

Vector3.normalized

Vector3.Normalize()

所以它會回傳單位向量或是零向量。

“Soft” methods

我同時也會提供些通用的方式來提升程式品質。

測試你自己的程式碼:
自動化測試、
視覺化除錯及文字記錄、
採用有意義的命名、
程式檢閱(code review)、
靜態程設分析工具、
程式文件

總結:

在這文章中,我展示了遊戲程式設計上常見的錯誤及一般問題的來源。我們知道追求安全及穩定的工程方法並非新的挑戰。這也不是簡單的工作,尤其在特殊的狀況下,像是遊戲開發。主要困難來自於去知道、預測及決定什麼地方該用防禦性程式及該用多少的安全措施才足夠。

將高安全性軟體工程的做法完全放到遊戲開發是不明智的。例如:太空計劃。無論如何,這可以用來了解人們在這些產業是如何完成他們的目標。

NASA太空中心主管及Saturn V rocket首席工程師,特別喜歡實作大型及安全的軟體工程。這些通常會在開始時花不少時間,但最終帶領美國完成了阿波羅11的成功。第一個上太空的人也安全的回來了。Saturn V 也是唯一帶著人類離開地球的火箭。


沒有留言:

張貼留言