目錄 Table of contents
前言
本篇內容大多來自於*《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); } }
- 使用屬性的好處:
- 抽象化資料內容不外露,能自由定義存取時的行為。
- 分別定義getter和setter的存取範圍(public/protected/private)
- 可以使用virtual、abstract關鍵字,能被繼承覆寫。
- 能定義在interface內(視同method)
- 使用屬性的限制:
- 不能用ref或out關鍵字把屬性傳遞給方法。
- 屬性對外的使用方式與欄位相同,但是會產生不同的中繼語言MSIL指令(instruction),即使他們在使用語法與資料來源是相容的,但是他們的機械碼是不相容的。這將會被迫更新一個已經deploy的assembly。
- 效能方面,當JIT編譯器內嵌屬性存取子時,屬性與欄位的效能是一樣的。縱使沒有內嵌,效能差異也只有一個function call,這個差異幾乎可以忽視。
- 內嵌屬性存取子: 意旨getter方法只是很單純的return一個field的值,這樣編譯器就以inline的方式最佳化,所以沒有function call。
- https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/classes-and-structs/using-properties
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; } }
- 使用隱含式屬性的好處:
- 撰寫方便+可讀性較佳
- 限制使用者無法直接存取欄位
- 當有需要把隱含式屬性用具體的實作取代時(如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改成唯讀,如下:
如此一來,每當你要更換資料內容時,都必須重新new一個出來,這樣就能維持資料的不變性。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; } }
- 注意可能會有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:
- 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# 引用)
- 拿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 Boxingstruct 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。
因為boxing的緣故,即使你以一個實值型別和自己相比也會回傳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. }
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範例如下:
注意Type檢查的部分,一般來說可能會寫成下面這種: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; } }
這種情況可能會發生一個很嚴重的問題,當一個繼承關係的兩個類別進行比較時如下:// 有問題的寫法 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()時,應當依循以下三個規則:
- 如果兩個物件是相等的(如Equals方法的結果),則它們必須產生相同的雜湊值。(但不代表相同雜湊值就一定Equals)
- 對於任何物件A,A.GetHashCode()必須是不會因實體而改變。無論呼叫A的任何一個方法,GetHashCode()都必須傳回相同的值。(不得在外部修改,確保能在collection中正確的存取)
- 雜湊函式必須使所有典型的輸入集均勻的分佈在所有整數上。理想上,你會想避免產生的值集中在某些值的附近以避免bucket內的物件過多。(不強制,但這關乎到hashmap的效率)
- 預設的
System.Object.GetHashCode
使用System.Object類別中的一個internal欄位來產生雜湊值(相當於參考指標)。 - 預設的
System.ValyeType.GetHashCode
為所有實值型別提供了預期行為。它依據型別中定義的第一個欄位來傳回雜湊碼(透過Reflection),看以下例子:
則MyStruct的雜湊碼就是由public struct MyStruct { public string msg; public int id; public DateTime date; }
msg
欄位所產生的雜湊碼。因此以下程式碼:
會回傳true。MyStruct s = new MyStruct(); s.msg = "Hello world"; return s.GetHashCode() == s.msg.GetHashCode(); // 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
就足夠使用了,但是如果有覆寫Equals
或operator ==()
時則要注意規則1.是否有遵循。 - 萬一真的要覆寫的話,記得規則2.的內容,不能因為欄位的值變動了而改動HashCode。
- 一般來說
- 對於實值型別的物件
- 基本上不建議使用其預設的hash(Reflection + 分散效果差)。
- 為了遵循規則2.,最好是做成不可改變的型別(immutable)或是hash的參考對象是不可改變的。
- 為了效率起見遵循規則3.,要找到適當產生hash的方法是足夠分散且不碰撞的。