最近發現 C# 中 struct 和 class 的靜態構造函數(.cctor)觸發規則存在顯著差異。class 在首次使用類型時觸發,而 struct 僅訪問實例字段不會觸發,只有訪問靜態字段、調用構造函數或實例/靜態方法才會觸發。本文通過 IL 反編譯深度剖析其底層機制。
class 使用 newobj:在堆上分配內存,調用構造函數,返回引用。struct 使用 initobj:對棧上已分配的值類型實例進行零初始化,不調用任何構造函數(這也是 struct 不能有無參構造函數、字段不能有默認值的原因)。call 直接調用該構造函數。call:靜態調用,不檢查 this 是否為 null。callvirt:虛調用,先檢查 this 非 null,再查虛表調用。class:非虛實例方法實際用 call 但編譯器插入 cmp [ecx], ecx 檢查 null;虛方法用 callvirt;靜態方法用 call;基類調用用 call 避免遞歸。
struct:自身定義的方法(靜態/實例)均用 call,因值類型不可能為 null。但覆寫基類虛方法(GetHashCode、ToString、Equals)時,IL 會生成 constrained. 前綴 + callvirt。
thisType 為引用類型:解引用後 callvirt。thisType 為值類型且實現了該虛方法:直接 call 實現方法(無裝箱)。thisType 為值類型但未實現該虛方法:裝箱後 callvirt 基類實現。因此,忘記覆寫 GetHashCode/ToString 會導致隱式裝箱,造成性能損耗並觸發 GC。實測 1000 萬次調用未覆寫 GetHashCode 導致 Gen 0 GC 127 次,內存增長 250KB+;覆寫後零分配。
| 操作 | class | struct |
|---|---|---|
| 首次訪問靜態字段 | 觸發 | 觸發 |
| 首次調用靜態方法 | 觸發 | 觸發 |
| 首次調用實例方法 | 觸發 | 觸發 |
| 首次實例化 | 觸發 | 觸發(帶參構造) |
| 首次訪問實例字段 | 觸發 | 不觸發 |
| 調用默認構造函數 | 觸發 | 不觸發(initobj 零初始化) |
| 調用未覆寫的基類虛方法 | 觸發 | 不觸發(裝箱調用基類,struct .cctor 未運行) |
理解 initobj/newobj、call/callvirt/constrained. 的差異,有助於編寫高性能、無意外副作用的值類型代碼。關鍵建議:struct 應始終覆寫 GetHashCode、ToString、Equals,避免隱式裝箱。
原文出處:柳大的 CSDN 博客 Blog.CSDN.net/Poechant