[讀書心得]Runtime Type Fundamentals from CLR via C#

當呼叫某一個型別的 static function ,或是 某一個 isntance 的 virtual function, non-virtual function 時,
CLR 與 JIT 是如何在 stack 與 heap 之間,找到並執行相關的 function 內容呢?

前言

標題有點鳥,不過主要就是要講 CLR via C# (我看的是第三版,最新版本已經出到第四版了)書中 chapter 4: Type Fundametals 中,有一段解釋 runtime 時,呼叫 virtual function, non-virtual function, static function 等不同類型的 function 時,在 2 個 class 有繼承關係時,其 thread stack 與 heap 的變化。

這一段是整本書我最喜歡的段落之一,也算的上是啟蒙我對 type 認知最重要的一段說明。

這一篇文章,就借花獻佛一下,摘要說明一下下列幾點的影響:

  1. 宣告變數的型別
  2. 執行個體 ( instance ) 的型別
  3. 呼叫 instance function
  4. 呼叫 non-virtual function
  5. 呼叫 virtual function 與 override function
  6. 呼叫 static function

說明 memory 的圖示,都是源自於 CLR via C# 一書。

閱讀此章節,請務必先行理解繼承、多型、virtual、override、static 、heap、stack 的基本定義。

 

類別定義

首先有兩個 class ,分別為 Employee 與 Manager。其類別關係為 Manager 繼承 Employee ,如下圖所示:

employee and manager class diagram

兩個類別的程式碼如下所示:

    internal class Employee
    {
        public Int32 GetYearsEmployed()
        {
            return 5;
        }

        public virtual String GetProgressReport()
        {
            return "Employee's GetProgressReport";
        }

        public static Employee Lookup(String name)
        {
            return new Manager { Name = name };
        }
    }

    internal class Manager : Employee
    {
        public string Name { get; set; }

        public override string GetProgressReport()
        {
            return "Manager overrides GetProgressReport";
        }
    }

 

Employee 上有幾個重點:

  1. non-virtual function: GetyearsEmployed() ,代表子類無法覆寫。
  2. virtual function: GetProgressReport() ,代表子類可能覆寫。
  3. static function: Lookup(),代表這個 function 是專屬於 Employee 這個 type 的 function ,另外一個要注意的是,回傳的型別是 Employee ,也就代表回傳的執行個體,可以是 Employee 的子類(例如範例中的 Manager)

Manager 上的重點:

  1. 繼承自 Employee
  2. 覆寫 GetProgressReport() : 按照多型的概念,當呼叫宣告為 Employee 變數的 GetProgressReport() 方法時,會依照該變數的 instance 型別為 Employee 或 Manager,來決定要呼叫哪一個類別的方法內容。

接下來的說明,有興趣閱讀原文的朋友,可以參考書中 chapter 4 的 「How Things Relate at Runtime」,這邊只是用我的理解來做說明。

 

Runtime Memory 使用說明

先來看一下 context 的程式:

        void M3(string[] args)
        {
            Employee e;
            Int32 year;
            e = new Manager();
            e = Employee.Lookup("Joe");
            year = e.GetYearsEmployed();
            e.GetProgressReport();
        }

接下來,我們將說明每一行程式碼,其 memory 的使用情況。

 

Heap 剛建立,Thread Stack for M3 Function

image

Figure 4-6 ( from CLR via C# )

一開始, thread stack 跟 heap 都是空的,接著要來執行 M3() 這個 function 了。

 

準備呼叫 M3() ,Employee 與 Manager 的 Type Object 被初始化

image

Figure 4-7 ( from CLR via C# )

當 JIT 要將 M3() 方法轉成 IL 時,會發現裡面有使用到 Manager 與 Employee 這兩個 type (當然還包括了 Int32 與 String 兩個 type ,但這不是這邊的重點,就不多做說明),接著 JIT 會確認參考到的 assemblies 有沒有 load 進來這些 type 。

所以,這兩個 type object 會被載入 heap 中。

每一個 type object 都會有 type object pointer 與 sync block index 兩個 members ,如果該 type 有定義 static fields 的話,也會隨著 type object 被載入到 heap 中。以下簡單說明一下這幾個東西:

  1. Type object pointer: 用來指向這個 instance ( 別忘了 type object 也是一種 instance ,它的 type 為 System.Type ) 的 type 位址
  2. Sync block index: 在 multi-thread 中用來控制同步的東西 (簡單的說,可以讓 multi-thread 透過它排隊)
  3. Static field: 跟著 type object 的生命週期,因為每一個 type object 只會有一份,所以 static 可以拿來做 singleton, process 的全域變數等應用,相對的也要小心重入 (re-entry) 造成的問題。
  4. Method table: 定義這個 type object 中,擁有哪一些 method ,可以看到 Manager 的 type object 上,只有定義了 GenProgressReport 這個 override method ,而 Employee type object 則有定義 3 個 method 。

當 CLR 確定 M3() 要使用到的 type object 都已經準備好後,M3() 也已經經過 JIT 編譯後, CLR 允許 thread 開始執行 M3 的 native code 。

 

開始執行 M3() ,初始化區域變數預設值

image

Figure 4-8 ( from CLR via C# )

首先,先看到 M3() 中的第一行與第二行,宣告了 2 個區域變數,分別為 e 與 year , CLR 會自動給這些區域變數預設值為 null 或 0 (reference type 為 null, value type 為 0)。

這時,還只有 stack 上有配置這 2 個區域變數, heap 還沒被 reference 或被 M3() 使用到。

 

初始化物件的變化: e = new Manager();

image

Figure 4-9 ( from CLR via C# )

接著透過 new operator 來呼叫 Manager 的建構式,此時會回傳 Manager object (執行個體)的位址,並存放在 e 的變數中,也就是在 stack 中, e 的內容是存放剛剛初始化的 manager object 在 heap 中的記憶體位址。

而就程式碼看起來,就只是把一個被初始化的 manager instance assign 給 e 這個變數。

簡單記法: reference type 是在 stack 上存位址, value type 則是在 stack 上存內容

因此,可以看到 stack 上, e 的內容即關聯到 heap 上,剛剛初始化完成的那個 manager object 。而因為這個 instance 的型別是 Manager ,所以這個 manager object 的 type object pointer 會指到 manager type object 的位置。

 

呼叫靜態方法: e = Employee.Lookup("Joe");

image

Figure 4-10 ( from CLR via C# )

接著,呼叫 Employee 上的靜態方法: Lookup(String name) ,並將回傳結果 assign 給 e 變化,這樣會產生什麼變化呢?

  1. 呼叫靜態方法時, JIT compiler 會先找到這個靜態方法的 type object ,然後從 type object 的 method table 中找到這個方法,將此方法內容即時編譯(如果之前還沒經過 JIT 編譯過,才需要即時編譯,若已經編譯過,會有記錄),執行編譯後的內容。
  2. 這邊的內容是初始化一個 manager 物件,將 Name 屬性設為 Joe ,接著回傳這個 Name 為 Joe 的 manager object 的位址,塞給 stack 中的 e 。

這時可以發現,原本上一行在 M3() 中初始化的 manager object 沒有其他地方參考到它了,但是它仍會存在一段時間,等待 gc 起來之後,再依據 gc 的演算法來回收這一個 heap 的記憶體。

到這邊,e 這個區域變數,已經指到透過 Employee.Lookup("Joe") 所回傳在 heap 中的 manager object 位址。

 

呼叫 non-virtual function: year = e.GetYearsEmployed();

image

Figure 4-11 ( from CLR via C# )

當呼叫 e.GetYearsEmployed() 時,此方法並未被宣告為 virtual (這邊稱為 non-virtual),也就代表不會有子類去覆寫這個方法,所以 JIT compiler 會先找到這個變數的宣告型別,也就是 e 的型別,在這邊為 Employee 。

接著尋找 Employee 的 type object 中,是否存在著 GetYearsEmployed() 這個方法,若不存在,則 JIT complier 會一路往其父類別找,預設最終會找到 Object 。

原因很簡單,雖然變數型別宣告為 Employee ,但是呼叫 e 這個執行個體的方法,這個方法可能是因為繼承鏈上的父類擁有, Employee 上才能被呼叫。(例如任何類別預設都繼承 Object ,所以任何執行個體預設都可以呼叫 Object 的方法,如 ToString() ,若繼承鏈上都沒有其他 ToString() 的方法,那麼最終 JIT compiler 就會呼叫 Object type object 上 method table 中的 ToString 方法。)

JIT compiler 能一路往父類別尋找,是因為每一個 type object 上,有一個 field 指向 base type (圖上沒有標示),因此可以一路找到最原始的 Object 。

找到這個 non-virtual 的執行個體方法後,一樣,若有需要 JIT compiler 會即時編譯這個方法內容,然後執行 JIT 之後的程式碼。

以這例子來說,GetYearsEmployed() 會回傳 5 ,5 是 int ,是 value type ,因此 5 這個值會被 assign 到 stack 上 year 的內容中。

 

呼叫 virtual function: e.GenProgressReport();

image

Figure 4-12 ( from CLR via C# )

接著呼叫 Employee 上定義的 virtual function: GenProgressReport() ,當呼叫的是 virtual function 時,此時 JIT compiler 會額外產生一些 code 來處理,處理什麼呢?

JIT compiler 會找到該變數 e 這個 instance 的執行個體型別為何,也就是透過在 stack 上 e 所存放的位址,找到了在 heap 中的執行個體,並透過 type object pointer 找到了 manager type object ,這個時候會尋找 manager type object 上的 method table ,確定是否有 GenProgressReport 這個方法,在這個例子中,因為 Manager 有 override GenProgressReport() ,因此 JIT compiler 找到後,會用同樣的方式來執行 JIT 後的程式碼。

要注意的是,倘若 Employee.Lookup() 所回傳的執行個體型別,若為 Employee 時,這邊 JIT compiler 會找到的就應該是 Employee type object 上的 GenProgressReport 方法,而非 Manager type object 的方法。

書上雖沒有提到,若 Manager 沒有 override GenProgressReport() 的情況。不過我想,若 Manager type object 的 method table 找不到方法時, JIT 會用 non-virtual 的方式,往 base type 的 type object 一路尋找,直到 Object 的 type object 。
簡單的說,也就是宣告為 virtual 所影響的,是 JIT 會先找到 instance 的 type object,以此為起點,尋找 type object 或繼承鏈上 type object 的 method table 。這樣要找到實際的方法內容,會比 non-virtual 花的功夫更多。因為 non-virtual 是直接從宣告的型別開始找,不必考慮 instance 的型別是否為子類的型別。

 

補充:Type Object 的 type object pointer 指到哪?

image

Figure 4-13 ( from CLR via C# )

大家已經知道一般的 object 其 type object pointer ,就是指到該 instance 所對應的型別位置。那麼, type object 的 type object pointer 又指去哪呢?答案是 System.Type 這個 type object ,也就是這些 type object 的型別,其實都是屬於 System.Type 的一種。而最後 Type 的 type object ,其 type object pointer 則是指到自己身上。

說了這麼多 type ,大家應該很容易聯想到 System.Object 上所定義的 non-virtual instance method: GetType() 吧。

沒錯!每一個 instance 都可以呼叫 GetType() ,而這個方法定義在 System.Object 中,其內容就是回傳 instance 的 type object pointer 所指到的 type object ,所以其回傳型別為 System.Type ,因為 type object 的 type object pointer 指到 System.Type 的 type object 位置。

雖然像繞口令一樣,不過了解了 heap 中 object 的關係後,也就沒這麼難懂了。

 

結論

CLR via C# 真的是一本不得不看的好書,這一篇文章其實翻譯居多,只是鑑於這本書很多讀者都因為一開始晦澀難懂而啃不下去,加上英文跟簡體中文的內容描述,可能都不是很直覺,所以筆者在這邊再反芻一次,也再強調一次,這一個段落真的說明了太多有趣的東西,是我看書之前不知道的,看完真的獲益良多。

如果各位讀者對於這個段落中的一些基本元素還不是很瞭解,建議務必要搞清楚,雖然不懂也可以寫程式,但打開了這一扇窗,你會看到相當寬廣的天空啊。

哪一些元素要知道,簡單列出如下:

  1. stack 與 heap
  2. static 與 instance
  3. type 與 instance
  4. value type 與 reference type
  5. 繼承
  6. 多型
  7. virtual 與 non-virtual
  8. JIT compiler 的角色與功能

我也是從這一個段落才理解了,什麼叫做 static ,為什麼稱為 static ,雖然書中沒有明講,但了解了其 runtime 運作原理,自然會理解 static 這個命名的由來。

對敏捷開發有興趣的朋友,可以參考我的粉絲專頁:91敏捷開發之路

對 TDD 課程有興趣的朋友,課程內容、大綱與學員心得,可以參考 skilltree 的公開課程:自動測試與 TDD 實務開發

若需要聯絡我,可以透過粉絲專頁私訊或是側欄的關於我。