程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> 由struct的靜態構造函數說起

由struct的靜態構造函數說起

編輯:關於C++

最近才知道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相關問題的一點心得,如果你看完這篇文章還存在疑問或者我有寫解釋的不對地 方請留言。我非常希望能和對底層感興趣的朋友探討。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved