More Effective C# 筆記 #1 資料型別
2020-12-14 00:57:00
4k words / 16-minute read
目錄 Table of contents
  1. 前言
  2. 使用屬性(Property)取代public欄位(Field)
    1. In Unity
  3. 可變動的資料優先使用隱含式屬性
    1. In Unity
  4. 實值型別(value types)優先使其具不可變性
  5. 區分實值(value)與參考(reference)型別
    1. Memory overhead
    2. Boxing
  6. 了解多種相等概念之間的關係
    1. ReferenceEquals(object left, object right)
    2. Equals(object left, object right)
    3. Equals(object right)
  7. 了解GetHashCode()的陷阱
    1. GetHashCode到底該不該覆寫?

前言

本篇內容大多來自於*《More Effective C# 寫出良好C#程式的50個具體做法 第二版》Ch.1 處理資料型別*,加上一些網路上查詢的相關內容學習紀錄。

主要目的是了解C#語言的一些細節與效能陷阱,以便撰寫更好的程式碼。

使用屬性(Property)取代public欄位(Field)

  • 屬性(Property): 抽象的存取介面,本身不包含資料,僅提供getter和setter的method。可以把它直接當成getter & setter methods來看待。
  • 欄位(Field): 實體的資料內容。
punlic class GameObject {
    private string m_Name; // private field

    public string Name { // public property
        get { // getter
            return m_Name;
        } 
        set { // setter
            m_Name = value;
        } 
    }

    public string name; // public field <---應避免使用,改以public proerpty的方式取代
}
  • 實作索引子(Indexer)
    public class Foo {
        private int[] m_Array = new int[100]; // private field
        
        public int this[int index] { // Indexer, e.g. foo[0] = 0;
            get => m_Array[index];
            set => m_Array[index] = value;
        }
        
        public int this[int x, int y] { // 2-dims Indexer, e.g. foo[0, 0] = 0;
            get => m_Array[x] + m_Array[y];
            set => m_Array[x] = m_Array[y] = value;
        }
        
        public int this[int x, string name] { // with string parameters.
            get => DoSomething(x, name);
        }
    }
  • 使用屬性的好處:
    1. 抽象化資料內容不外露,能自由定義存取時的行為。
    2. 分別定義getter和setter的存取範圍(public/protected/private)
    3. 可以使用virtual、abstract關鍵字,能被繼承覆寫。
    4. 能定義在interface內(視同method)
  • 使用屬性的限制:
    1. 不能用ref或out關鍵字把屬性傳遞給方法。
  • 屬性對外的使用方式與欄位相同,但是會產生不同的中繼語言MSIL指令(instruction),即使他們在使用語法與資料來源是相容的,但是他們的機械碼是不相容的。這將會被迫更新一個已經deploy的assembly。
  • 效能方面,當JIT編譯器內嵌屬性存取子時,屬性與欄位的效能是一樣的。縱使沒有內嵌,效能差異也只有一個function call,這個差異幾乎可以忽視。

In Unity

  • 在Unity內,我們一般會很習慣直接用public field來expose我們的成員變數到editor inspector上顯示。
  • 當我們打算以屬性來取代欄位時,可以透過[SerializeField]來expose私有欄位。
    • 並且m_的命名開頭能夠被Unity忽略掉,算是一種unity-supported的coding style。
    public float health; // public field which is bad.
    
    [SerializeField] // private field with this attribute can be exposed to inspector.
    private float m_Health; // "m_" will not be displayed in Unity inspector.
    
    public float Health { // public property
        get => m_Health;
        set => m_Health = value;
    }

可變動的資料優先使用隱含式屬性

  • 隱含式屬性 Auto-Implemented property: 編譯器會自動建立支援欄位(backing field),由於支援欄位的名稱是由編譯器產生,你無法直接使用支援欄位。
    public class GameObject {
        public string Name { get; set; } // Auto-Implemented property
        // equals
        private string m_Name;
        public string Name {
            get => m_Name;
            set => m_Name = value;
        }
    }
    • 使用隱含式屬性的好處:
      1. 撰寫方便+可讀性較佳
      2. 限制使用者無法直接存取欄位
      3. 當有需要把隱含式屬性用具體的實作取代時(如setter需要做資料驗證),你將會是針對你的類別做機器碼相容的改變。
    • 缺點:
             * 對於Serialization的attribute不友善

In Unity

  • 直接在隱含式屬性上加[SerializeField]是沒有效果的,是要改成使用[field: SerializeField]才有效。
  • 缺點是在inspector的顯示名稱很難看,建議不要在這種情況下使用隱含式比較好。
  • 但一般來說如果你有不需要被Serialized的屬性的話用隱含式就很方便了。
       [field: SerializeField]
    public float Health { get; set; } // auto-implemented property

實值型別(value types)優先使其具不可變性

  • 不可變的型別(immutable types): 型別在建立後,它們成為常數。
  • 實質型別以struct實作,使其符合純資料的組合(composition)。
  • example: 可變動的Address結構
    public sturct Address {
        private string state;
        private int zipCode;
        
        public string Line1 { get; set; }
        public string Line2 { get; set; }
        public string City { get; set; }
        public string State {
            get => state;
            set {
                ValidateState(value);
                state = value;
            }
        }
    
        public int ZipCode {
            get => zipCode;
            set {
                ValidateZip(value);
                zipCode = value;
            }
        }
    }
    使用範例:
    // Use case
    Address a1 = new Address();
    a1.Line1 = "AAA";
    a1.City = "Taipei";
    a1.ZipCode = "12345"; // Assume it is valid zip.
    // Edit.
    a1.City = "New Taipei City"; // Now zip is invalid.
    • 內部狀態改變代表有可能違反了物件的不變性(immutable),在你替換City欄位時,a1已經成為一個錯誤的資料,他的ZipCode不再合法。
    • 違反物件的不變性更會導致多執行緒發生問題。
  • 因此好的做法應該是要讓Address物件成為一個不可變的struct,把所有public properties改成唯讀,如下:
    public sturct Address {
        public string Line1 { get; }
        public string Line2 { get; }
        public string City { get; }
        public int ZipCode { get; }
    
        public Address(string line1, string line2, string city, int zipCode) {
            Line1 = line1;
            Line2 = line2;
            City = city;
            ValidateZip(zipCode);
            ZipCode = zipCode;
        }    
    }
    如此一來,每當你要更換資料內容時,都必須重新new一個出來,這樣就能維持資料的不變性。
  • 注意可能會有reference type的field,在ctor時要deep clone而非直接assgin。
    // 有漏洞
    public struct PhoneList {
        private readonly Phone[] phones;
    
        public PhoneList(Phone[] ph) {
            phones = ph; // Phone[] 是reference type,因此在外部更改也會影響內部的值。
        }
    
        public IEnumerable<Phone> Phones => phones;
    }
    
    // 正確的做法
    public struct PhoneList {
        private readonly ImmutableList<Phone> phones;
    
        public PhoneList(Phone[] ph) {
            phones = ph.ToImmutableList(); // 會額外生成一個不可變的List(clone)
        }
    
        public IEnumerable<Phone> Phones => phones;
    }

區分實值(value)與參考(reference)型別

實值型別(value types) 參考型別(reference types)
Struct Class
記憶體位置 Stack 物件記憶體存放於Heap,Stack上僅存其指標
繼承與多型 無法繼承,可實作Interface 支援任何繼承與實作
NULL 永遠都有數值,並沒有NULL 可為NULL(視同空指標)
建構子Constructor 不能自行定義無參數建構子,但任何struct都保證會有default的無參數建構子(所有欄位塞0),且在定義建構子中要求對所有欄位成員賦值。 可自由定義
Assign operator 完全複製實值 複製指標(因此還是會指向同一塊heap記憶體)
Memory overhead No(Unless boxing) yes
GC No GC.Allocate (Unless boxing) Yes

Memory overhead

  • 由於參考型別相當於用指標去參考指定物件,勢必得分配記憶體給這個「指標」使用,一般來說與int的長度相同為32bits=4Bytes,而memory overhead就是指這個額外的指標記憶體花費。

  • 舉例來說,分別以struct和class來製作兩種型別:

    public struct FooStruct {
        public int intValue;
    }
    public class FooClass {
        public int intValue;
    }
    

    然後用分配一個長度為100的array,它們實際上分配的記憶體如下

    FooStruct[] structArray = new FooStruct[100]; // Memory = 陣列型別的指標記憶體(4Bytes) + 100 * FooStruct的記憶體(等同int的記憶體=4Bytes)
    FooClass[] classArray = new FooClass[100]; // Memory = 陣列型別的指標記憶體(4Bytes) + 100 * FooClass指標記憶體(4Bytes)

    雖然這樣看起來兩者的記憶體使用量是一樣的,但事實上structArray這時候已經有值了(為0),而classArray存的全是空指標,因此還需要下面步驟兩者才算相等:

    // Assign每一個陣列元素
    for(int i = 0; i < 100; i++) {
        classArray[i] = new FooClass();
    }
    // 跑完後的Memory使用量為: 陣列型別的指標記憶體(4Bytes) + 100 * FooClass指標記憶體(4Bytes) + 100 * FooClass的記憶體(等同int的記憶體=4Bytes)

    此時我們就可以說classArray的memory overhead接近50%(100 / 201),所謂overhead的部分就是指標所額外占用的比率(陣列使用的指標不計)。

  • 參考資料

Boxing

  • Boxing是指將實質型別(value type)轉換為object類型或是由這個實值型別實作之任何介面。
  • 直接用程式碼來表示:
int i = 123;      // a value type
object o = i;     // boxing (implicit casting value type to System.Object)
int j = (int)o;   // unboxing (explicit casting from System.Object to value type)
  • 這樣的過程相當於new一個只包著int i的class,有可能有效能上的隱憂。

  • 一些非常容易發生的boxing:

    1. String.Format
    public static string Format(string format, params object[] args)

    可以注意到參數args的型別是object,因此一般在使用上可能會如下:

    float value = 1f;
    string.Format("value is {0}", value); // value is 1.0

    後面的value本來是float實值型別,但是它將會被boxing成object type。
    如果要避免boxing可以提前用ToString()方法來轉型成string (string本身就是reference type沒有boxing的問題,可以參考這篇討論)

    $“value is {value}” 字串插值這個語法糖在大多數情況是等同於String.Format(),編譯器會根據使用情境來優化選擇方法。 詳情參閱: $ - 字串插值(C# 引用)

    1. 拿value type當作Dictionary的key(尤其是Enum)
    enum ConnectionType {
        Signal,
        Reference,
        Parent
    }
    Dictionary<ConnectionType, GameObject> connectionTo; // enum as dictionary key

    這樣會產生boxing的原因是hash-based collection是透過Object.GetHashCode()來產生hash,所以它一律都會把key值轉回object來呼叫GetHashCode()方法。

    想要避免boxing可以透過繼承IEqualityComparer來建立一個自定義的Comparer來避免做Object.GetHashCode()的行為,如下:

    public class ConnectionTypeComparer : IEqualityComparer<ConnectionType> {
        public bool Equals(ConnectionType x, ConnectionType y) {
            return x == y;
        }
    
        public int GetHashCode(ConnectionType x) {
            return (int)x;
        }
    }
    // 用dictionary建構子替換default的comparer
    connectionTo = Dictionary<ConnectionType, GameObject>(new ConnectionTypeComparer()); 

    又或者可以透過struct包裝value reference的key並實作IEquatable介面來使其呼叫覆寫的GetHashCode(),這部分的原理詳情可參考這篇: Collections Without the Boxing

    struct IntStruct : IEquatable<IntStruct>
    {
        public int Value;
    
        public bool Equals(IntStruct other)
        {
            return Value == other.Value;
        }
    
        public override int GetHashCode()
        {
            return Value.GetHashCode();
        }
    }
  • 基本上就是多注意generic function,通常會有boxing的隱憂

  • 參考

了解多種相等概念之間的關係

public class System.Object {
    public static bool ReferenceEquals(object left, object right);
    public static bool Equals(object left, object right);
    public virtual bool Equals(object right);
    public static bool operator ==(MyClass left, MyClass right);
}

ReferenceEquals(object left, object right)

  • 判斷兩者的物件識別(object identity)相同,即兩者參考的記憶體對象是否相同。
    • 用這個方法檢測實值型別的相等永遠傳回false。
    int i = 5;
    int j = 5;
    // value types never reference equals.
    if (Object.ReferenceEquals(i, j)) {
        // Never happends.
    } else {
        // Always happens.
    }
    // Even if itself
    if (Object.ReferenceEquals(i, i) {
        // Never happends.
    } else {
        // Always happens.
    }
    因為boxing的緣故,即使你以一個實值型別和自己相比也會回傳false。

Equals(object left, object right)

  • 一個靜態泛型(generic)的比較相等方法,具體實作與以下類似:
    // Check object identity
    if (Object.ReferenceEquals(object left, object right)) {
        return true;
    }
    // Return false if one of them is null.
    if (Object.ReferenceEquals(left, null) ||
        Object.ReferenceEquals(right, null)) {
        return false;    
    }
    // return left's Equals() method.
    return left.Equals(right);
    • 可以發現這個方法完全沒有實際的比較,具體的比較內容是仰賴ReferenceEquals和實體(intance)方法Equals(object right)來實作。

Equals(object right)

  • 可繼承覆寫的實體的方法
  • 預設沒繼承的Object.Equals(object right)的功能與Object.ReferenceEquals完全一樣
  • 實值型別其實都是繼承自System.ValueType,包含所有struct,而ValueType是已經有覆寫(override)這個Equals方法了。
  • 但記住,因為它是所有實值型別的基底,為了做正確的ValueType Equals檢測它會仰賴Refelction來比較所有欄位(field)的值是否一致。
  • 因此基本上你應該要為所有struct型別覆寫Equals以得到最好的效能
  • 當你想要讓你的參考型別以實值語法(Value semantic)來比較相等時,才會需要覆寫Equals方法
  • 如string就是一個典型的參考型別+實值語法的類別。
  • 當你要實作時,記住你需要尊重相等的數學性質: 反身性(reflexive)、對稱性(symmetric)和遞移性(transitive)。
    • 反身性(reflexive): 任何物件和自身相等,不管是哪一種型別,a == a 永遠都為true。
    • 對稱性(symmetric): 次序不影響結果,如果 a == b 為true,則b == a 為true。
    • 遞移性(transitive): 如果 a == b && b == c 為true,則 a == c 為true。
  • IEquatable<T>提供了強型別(strong-type)的參數,在Collection類別中可以省去Type檢查以及boxing,細節可以參考 Collections Without the Boxing 以及 If You Implement Iequatable T You Still Must Override Object S Equals And Gethashcode
    • 所以基本上,每當你想要覆寫Object.Equals時都應該實作IEquatable<T>以獲得最大效能。
  • 一個典型的class覆寫Equals範例如下:
    public class Foo : IEquatable<Foo> {
        public override bool Equals(objet right) {
            // Check null.
            if (object.ReferenceEquals(right, null)) {
                return false;
            }
            // Check object identity
            if (object.ReferenceEquals(this, right)) {
                return false;
            }
            // Check type
            if (this.GetType() != right.GetType()) {
                return false;
            }
    
            return this.Equals(right as Foo);
        }
    
        // IEquatable<Foo> implementation.
        public bool Equals(Foo other) {
            // 省略
            return true;
        }
    
    }
    注意Type檢查的部分,一般來說可能會寫成下面這種:
    // 有問題的寫法
    Foo rightAsFoo = right as Foo;
    if (rightAsFoo == null) {
    	return false;
    }
    return this.Equals(rightAsFoo);
    這種情況可能會發生一個很嚴重的問題,當一個繼承關係的兩個類別進行比較時如下:
    class Foo {}
    class Boo : Foo {}
    
    Foo foo = new Foo();
    Boo boo = new Boo();
    
    foo.Equals(boo); // correct.
    boo.Equals(foo); // always return false, because foo as Boo == null.
    會破壞相等的對稱性,因此進行精準的強型別檢查是必須的。
  • operator ==()
    • 當你建立一個實值型別時,就應當重新定義運算子==(),原因跟Equals相同。

了解GetHashCode()的陷阱

  • 每當你打算覆寫GetHashCode()時,應當依循以下三個規則:
    1. 如果兩個物件是相等的(如Equals方法的結果),則它們必須產生相同的雜湊值。(但不代表相同雜湊值就一定Equals)
    2. 對於任何物件A,A.GetHashCode()必須是不會因實體而改變。無論呼叫A的任何一個方法,GetHashCode()都必須傳回相同的值。(不得在外部修改,確保能在collection中正確的存取)
    3. 雜湊函式必須使所有典型的輸入集均勻的分佈在所有整數上。理想上,你會想避免產生的值集中在某些值的附近以避免bucket內的物件過多。(不強制,但這關乎到hashmap的效率)
  • 預設的System.Object.GetHashCode使用System.Object類別中的一個internal欄位來產生雜湊值(相當於參考指標)。
  • 預設的System.ValyeType.GetHashCode為所有實值型別提供了預期行為。它依據型別中定義的第一個欄位來傳回雜湊碼(透過Reflection),看以下例子:
    public struct MyStruct {
        public string msg;
        public int id;
        public DateTime date;
    }
    則MyStruct的雜湊碼就是由msg欄位所產生的雜湊碼。因此以下程式碼:
    MyStruct s = new MyStruct();
    s.msg = "Hello world";
    return s.GetHashCode() == s.msg.GetHashCode(); // true.
    會回傳true。
    但這也代表一件事,所有訊息一樣的MyStruct物件都會有一樣的雜湊碼,這樣一來hash-based collection的效率就會降低(都被放在同一個bucket內)。
    換個情形來看,如果只是改個位置:
    public struct MyStruct {
        public DateTime date;
        public string msg;
        public int id;
    }

此時的MyStruct仰賴date產出HashCode,這樣一來同一天的所有訊息都會有同一個hash效能會大大降低(變O(N)),完全失去hash的優點。

GetHashCode到底該不該覆寫?

  • 對於參考型別的物件
    • 一般來說System.Object.GetHashCode就足夠使用了,但是如果有覆寫Equalsoperator ==()時則要注意規則1.是否有遵循。
    • 萬一真的要覆寫的話,記得規則2.的內容,不能因為欄位的值變動了而改動HashCode。
  • 對於實值型別的物件
    • 基本上不建議使用其預設的hash(Reflection + 分散效果差)。
    • 為了遵循規則2.,最好是做成不可改變的型別(immutable)或是hash的參考對象是不可改變的。
    • 為了效率起見遵循規則3.,要找到適當產生hash的方法是足夠分散且不碰撞的。