最近才知道struct和class的靜態構造函數的觸發規則是不同的,不像class在第一次使用類的時候觸 發靜態構造函數。如果只訪問struct實例的字段是不會觸發靜態構造函數調用的。通過測試發現當訪問靜 態字段,struct本身的函數(靜態和實例)和帶參數的構造函數就會引起靜態構造函數的執行。而調用默 認構造和未覆寫的基類虛函數是不會的。為什麼呢?
讓我們先來看看class和struct在調用構造函數時的區別。class使用newobj指令而struct使用initobj 指令來構造對象。newobj在堆上申請一塊內存並調用相應的構造函數進行初始化,然後將對象地址返回給 計算棧。initobj則是從本地變量表中載入已經分配出來的struct實例然後初始化struct的各字段。這個 初始化過程是CLR內部執行的,而不像class編譯器會給class添加一個默認構造函數(這就是為什麼 struct不能給字段添加默認值的原因。但在類中如果給字段添加了默認值編譯器就會自動在構造函數中添 加字段賦值操作)。如果給struct中定義了一個有參數的構造函數,那麼系統就不會使用initobj指令, 而是直接用call指令調用帶參數的構造函數。
我們最常見最常用的調用函數的指令是call和callvirt。對於靜態函數使用call指令,對於class使用 callvirt指令(不論class中的函數是不是虛的)。只有子類調用父類的函數的時候(避免遞歸調用)以 及構造函數中(由編譯器添加保證父類字段被初始化)使用call指令。而對於struct我們發現只要調用的 函數是struct本身定義的都是使用call指令。call和callvirt指令的差別在於,call會把調用的函數當作 靜態函數看待,而不會關心調用當前函數時實例指針(this)是否為空。這就是struct調用函數時為什麼 都是call因為struct實例是不可能被置為null的。實際上class在調用非虛函數時實際上也是使用call的 只是多做了一步驗證——this是否為空,讓我們來驗證一下。
class Class_Test
{
public void Test1() {}
public virtual void Test2() {}
public static void Test3() {}
public override string ToString()
{
return base.ToString();
}
}
Class_Test c = new Class_Test ();
c.Test1();
c.Test2();
Class_Test.Test3();
string str = c.ToString();
對應的匯編如下:
c.Test1(); //實例非虛函數
0000006b mov ecx,esi //將this放到ecx中,ecx在.net函數 調用規則中保存第一個參數
0000006d cmp dword ptr [ecx],ecx //驗證this是否為 空,空指針的話dword ptr [ecx]就會報錯
0000006f call FFEEC130 //調用函數
00000074 nop
c.Test2(); //實例虛函數
00000075 mov ecx,esi
00000077 mov eax,dword ptr [ecx] //得到方法表地址,引用類型在堆上的開始4個字節是方法表地址
00000079 call dword ptr [eax+38h] //因為是虛函數每次調用的時候都要計算要調 用的函數地址
0000007c nop
Class_Test.Test3(); //靜態函數
00000083 call FFEEC140 //調用函數
00000088 nop
public override string ToString() //子類調用父類函數
{
//省略前面的匯編
return base.ToString(); //如果使用callvirt就會 死循環
00000026 mov ecx,edi //從ecx中得到 this
00000028 call 77A00F68 //調用函數
0000002d mov esi,eax //.net函數調用規則中eax保存返回值
0000002f mov ebx,esi
00000031 nop
00000032 jmp 00000034
}
通過上邊的匯編我們可以看出class調用非虛函數時本質上使用了call指令,而調用父類函數時就是直 接使用call,並且因為在實例函數中所以不需要驗證this是否為空。這裡說點題外話,在IL中我們經常會 看到執行函數時將本地變量加載到計算棧中或者將計算棧中的結果保存到本地變量中這不是很慢的操作嗎 ?實際上在大多數情況下是通過esi,edi這些寄存器來當緩存的,如果局部變量比較多才會保存到相應的 棧上。從這裡我們又印證了事實,.net的線程棧在每次執行函數時所創建的棧幀包含參數表,本地變量表 ,返回地址和計算棧。
繼續說call指令的問題,我前面說了struct本身定義的都是使用call指令調用的如果你親自動手實驗 的就會發現我說不對。如果struct覆寫了基類的函數(GetHashCode,ToString)在調用是IL會使用callvirt 來調用,我真的錯了嗎?
struct Struct_Test
{
bool _a ;
int _b;
int _c;
public Struct_Test(bool a, int c, int b)
{
this._a = a;
this._b = b;
this._c = c;
}
public void Test() {}
public override string ToString()
{
return string.Format("{0}, {1}, {2}", this._a, this._b, this._c);
}
}
Struct_Test s = new Struct_Test(true, 15, 20);
string str = s.ToString();
IL_0001: ldloca.s s
IL_0003: ldc.i4.1
IL_0004: ldc.i4.s 15
IL_0006: ldc.i4.s 20
IL_0008: call instance void Test_Console.Struct_Test::.ctor(bool, int32, int32)
IL_000d: nop
IL_000e: ldloca.s s
IL_0010: constrained. Test_Console.Struct_Test
IL_0016: callvirt instance string [mscorlib]System.Object::ToString()
IL_001b: stloc.1
如果你仔細觀察會發現在callvirt調用的上面有這麼一條指令constrained。讓我們看看msdn裡讓人頭 暈的解釋:
如果 callvirtmethod 指令前面帶有前綴 constrainedthisType,該指令將按照以下步驟執行:
如果 thisType 為引用類型(相對於值類型),則 ptr 被取消引用,並作為“this”指針傳遞到 method 的callvirt。
如果 thisType 為值類型,而且 thisType 實現 method,則 ptr 作為“this”指針在不作任何修改 的狀態下傳遞到 callmethod 指令,以便 thisType 實現 method。
如果 thisType 為值類型,而且 thisType 不實現 method,則將取消對 ptr 的引用,對它進行裝箱 ,然後將它作為“this”指針傳遞到 callvirtmethod 指令。
說白了就是:如果值類型在調用一個虛函數時如果改虛函數是該值類型實現的那麼就以call形式調用 ,如果沒有實現就以callvirt形式調用,並且要對值類型裝箱。關於constrained更詳細的分析請看這裡 。下面使用簡易的方法來驗證這個結論:
Struct_Test s = new Struct_Test(true, 15, 20);
Console.WriteLine (GC.GetTotalMemory(false));
int hash = 0;
for (int i = 0; i < 10000000; ++i)
{
hash = s.GetHashCode();
}
Console.WriteLine(GC.GetTotalMemory(false));
Console.WriteLine (GC.CollectionCount(0));
運行結果為:
141200
399104
127
從上面的結果可以看到如果沒有覆寫虛函數確實引起了裝箱。讓我在對比一下與調用ToString時的不 同,s.ToString()請看反匯編;
s.ToString();
0000003d lea ecx,[ebp-44h]
00000040 call FFE4C0B0
00000045 nop
s.GetHashCode();
00000046 mov ecx,7C3810h //Struct_Test方法表地址
0000004b call FFE31FAC //在堆上 分配空間
00000050 mov ebx,eax
00000052 lea edi, [ebx+4]
00000055 cmp ecx,dword ptr [edi]
00000057 lea esi,[ebp-44h] //將棧上數據拷貝到堆上
0000005a movq xmm0,mmword ptr [esi]
0000005e movq mmword ptr [edi],xmm0
00000062 add esi,8
00000065 add edi,8
00000068 movs dword ptr es:[edi],dword ptr [esi]
00000069 mov ecx,ebx
0000006b mov eax,dword ptr [ecx] //虛函數調用
0000006d call dword ptr [eax+30h]
所以我們使用struct要小心不要因為忘記了覆寫虛函數而造成不必要的性能損失。而且在這裡因為沒 有調用Struct_Test本身的函數所以不會觸發靜態構造的執行。最後說一下struct在調用函數的時候首先 要得到this指針,比如IL_000e: ldloca.s s。大家注意看這裡不是ldloc所以對於Struct_Test的函數 調用來說第一個參數是ref Struct_Test,感覺ref的這個參數修飾用在這裡才是最能體現價值的。
以上是我研究struct相關問題的一點心得,如果你看完這篇文章還存在疑問或者我有寫解釋的不對地 方請留言。我非常希望能和對底層感興趣的朋友探討。