1. 程式人生 > >自定義的==運算子,我們應該保留嗎?

自定義的==運算子,我們應該保留嗎?

當你在Unity裡做這樣子的操作時:

if (myGameObject == null) {}

Unity針對==運算子做了一些特殊處理。並非像大部分人預期的那樣,我們實現了一個特別的==運算子。

這服務於兩個目的:

1. 僅在Editor時[1],當一個MonoBehaviour擁有一個欄位時,我們不是設定這些欄位為“真的null”,而是設定成“假的null”物件。我們自定義的==運算子可以檢測出某物是不是假的null物件,並作出相應的行為。因為這是一個外來的初始化,這就允許我們在假的null物件裡儲存一些資訊,這些額外的資訊可以在你在其上呼叫方法時提供給你更加上下文相關的資訊,或者在你請求物件的屬性時提供更多資訊。如果沒有這些技巧,你將只能得到一個NullReferenceException,一個堆疊軌跡,但是你對於哪個GameObject擁有這個含null欄位的MonoBehaviour毫無頭緒。在這個技巧的幫助下,我們能在Inspector介面高亮GameObject,並且能提供給你更多查錯方向:“看起來像是你正在訪問這個MonoBehaviour裡一個未初始化的欄位。使用Inspector介面讓這個欄位指向某物吧。”

第二個目的則是相對更復雜一些。

2. 當你得到一個“GameObject”[2]型別的C#物件時,它裡面幾乎什麼東西也沒有。這是因為Unity是一個C/C++引擎。所有關於這個GameObject的實際的資訊(它的名字,它擁有的元件列表,它的HideFlags等)都儲存在C++那一邊。C#物件唯一擁有的東西就是一個指向本地物件的指標。我們將這些C#物件稱之為“包裝器物件”。這些C++物件,像是GameObject以及其他任何繼承自UnityEngine.Object的東西的生命週期都是顯式地管理的。當你載入一個新的場景時,這些物體就會被銷燬。或者當你呼叫 Object.Destroy(myObject);

時,這些物體就會被銷燬。C#物件的生命週期則是以C#的方式藉助垃圾回收器進行管理。這意味著有可能存在一個還存活著的C#包裝器物件,但它包裝的C++物件卻早已經被銷燬掉了。如果你將此C#物件與null進行比較,我們自定義的==運算子在這種情況下就會返回“true”,儘管實際上C#變數並不是null。

雖然這兩種用例相當合理,但是自定義的null比較同樣引入了一些負面影響。

  • 它是違反直覺的。
  • 比較兩個UnityEngine.Object物件比你預期的要慢一些。
  • 自定義的==運算子不是執行緒安全的,所以你不能在主執行緒之外比較兩個物件。(這個問題我們將會修復)。
  • 它與??運算子的行為不一致。??運算子也執行null檢查,但是它執行的是純C#的null檢查,並且不能繞過以使用我們自定義的null檢查。

仔細檢查這些優點與缺點,如果我們從零開始重建我們的API,我們可能選擇不做自定義的null檢查,而是提供一個myObject.destroyed屬性,這樣你就能使用該屬性檢查該物體是否已死亡,並承認一個事實,這個事實就是當你在一個null欄位上呼叫函式時我們不再提供更好的錯誤訊息。

我們正在考慮的是我們是否應該改變這個。這件事是我們永不完結的任務的一小步。我們的任務是在“修復並清理舊事物”和“不要打破舊工程”之間找出一個平衡點。在本例中,我們想知道你們的想法。對於Unity5,我們的工作成果是讓Unity能自動地更新你的指令碼(對此的更多內容將會是隨後的博文內容)。不幸的是,對於這種情況,我們沒法自動地升級你的指令碼。(因為我們無法區分“這是個舊指令碼,它實際想要的是舊的行為”和“這是個新指令碼,它實際想要的是新的行為”)。

我們傾向於“移除自定義的==運算子”,但是猶豫不定,因為這將會改變你的工程中所有的null檢查的含義。比如某個物件不是“真的null”,但是是一個已經銷燬的物件,null檢查在過去是返回true。如果我們移除自定義==運算子,那麼就會返回false。如果你想檢查你的變數是不是指向一個已經被銷燬的物件,你需要把程式碼改成“if(myObject.destroyed){}”才行。我們對此有點緊張不安,因為如果你們沒有讀過這篇博文,很可能你讀過,很容易就會意識不到這個改變的行為,特別是大部分人根本就沒有意識到存在這麼一個自定義的null檢查。[3]

如果我們改變它,我們就會在Unity5裡這麼做,因為我們希望使用者在處理非主版本號更新時的痛苦上限能稍低一些。

你希望我們怎麼做?給你更清晰的體驗,代價就是需要改變你的工程中的null檢查;還是繼續保持當前的樣子?

[1] 我們僅在Editor環境下才這麼做。這就是為什麼你在呼叫GetComponent查詢不存在的元件時,你會看到C#記憶體分配,這是因為我們在新申請的假null物體裡生成了這條自定義的警告字串。這樣子的記憶體分配在編譯版本的遊戲中並不會出現。這是一個很好的例子,可以解釋為什麼你總是應該去分析實際的單機player和移動端player,而不是分析editor,因為我們在editor下做了大量額外的保障、安全、使用檢查以使你的生活更簡單一些,代價就是損失一些效能。當需要分析效能和記憶體分配時,千萬別分析editor,一定要分析編譯出來的遊戲。

[2] 不僅僅是GameObject是這樣子,所有從UnityEngine.Object繼承的東西都是這樣子。

[3] 有趣的故事:當我優化GetComponent<T>的效能,實現某種transform元件的快取機制時,我遇到了這個問題,我看不到任何效能提升。然後 @jonasechterhoff 看了一下這個問題,得出了相同的結論。快取程式碼就像下面這樣子:

private Transform m_CachedTransform;
public Transform transform
{
    get
    {
        if (m_CachedTransform == null)
            m_CachedTransform = InternalGetTransform();
        return m_CachedTransform;
    }
}

結果發現我們自己兩位工程師忽視了null檢查代價是比預期的更高,null檢查正是從快取中看不到速度提升的原因。這就引出了“好吧,如果我們都忽視了它,那麼會有多少使用者忽視了它?”,從而有了這篇博文(笑臉符號)