C#與CLR學習筆記(3)—— 類型

目錄

1 基類型與類型轉換

1.1 System.Object的方法

1.2 使用 new 創建對象的過程

1.3 類型轉換

1.4 使用 is 和 as 進行類型轉換

1.5 命名空間

2 類型、對象、棧、堆在運行時的相互關係

類型對象指針


1 基類型與類型轉換

1.1 System.Object的方法

訪問限制 方法 說明
public Equals  
public GetHashCode 返回對象值的哈希碼。如果重寫了Equals方法,GetHashCode方法一般也需要重寫,因爲哈希容器使用HashCode作爲索引來查找對象。
public ToString 默認返回類型的完整名稱 this.GetType().FullName
public GetType  
protected MenberwiseClone 淺表複製
protected Finalize 在被GC認爲是垃圾後,在該對象的內存被實際回收之前,調用這個虛方法。需要在回收內存前執行清理工作的類型應重寫該方法

1.2 使用 new 創建對象的過程

CLR 要求所有的對象都要用 new 操作符創建。new操作符會做如下工作:

(1)計算該類型及其所有基類型的所有實例字段所需要的字節數。每個對象在堆上還有有一些額外的空間開銷,包括 “類型對象指針” 和 “同步索引塊” ,CLR用他們來管理對象(見下文)。對象的大小包括了這些額外的成員開銷。

(2)從託管堆中分配要求的字節數,分配的所有字節都設爲0。

(3)初始化對象的 “類型對象指針” 和 “同步索引塊” 成員(見下文)。

(4)調用類型的實例構造器,傳入構造函數中的參數。編譯器在構造函數中自動(在函數開頭)生成代碼來調用基類的默認無參構造函數。每個類型(基類)的構造函數負責初始化該類型定義的實例字段。最終,調用到最底層的 System.Object 的構造函數,Object 沒有實例字段,因此它什麼都不做,簡單地返回。

如果基類有多個構造函數,我們也可以使用 :base() 顯示地指定調用哪一個構造函數。

關於構造函數的執行順序:構造函數的執行是從基類到子類從上到下一層層地執行的,因爲子類構造函數沒有實例化基類字段的參數,除非通過base()顯示地繼承父類的構造函數並傳入參數。這個base()是在左花括號之前的,先於本身的構造函數執行。因此肯定是基類的構造函數先執行。

new 執行了上述操作後,返回一個引用(或指針),指向新建的對象。

1.3 類型轉換

子類可以隱式地轉化爲其某個基類,這個不必說了,因爲這個過程是類型安全的,沒有信息的損失,CLR 仍會記住子類的實際類型。

但是一個基類可不可以轉爲它的子類呢?答案是不一定。要看這個基類地來源,如果它來源於子類,那麼它就可以被顯示的轉爲其子類,否則就不行。

在某些情況下,編譯器並不能知道要轉換的基類是不是其子類(例如基類作爲參數時),因此能夠編譯通過,但在運行時,CLR會覈實基類的身份,看它 “骨子裏” 是不是要轉成的子類,若不是,則拋出異常,因爲確實無法轉換呀!

看個例子:

定義一個基類 Employee,和一個子類 Manager:

public class Employee
{
    // 工號
    public int No { get; set; }
}

public class Manager : Employee
{
    // 手下的員工數量
    public int EmployeeNum { get; set; }
}

下面的代碼中,有兩處顯示轉換,將基類 Employee 轉爲 子類 Manager。這段編譯能通過。但是運行時,第(1)處的顯示轉換能正常運行,(2)處的轉換會拋出強制轉換失敗的異常。原因如上文所述,基類對象 e1 骨子裏就是子類對象(e1 由子類對象隱式轉換而來,隱式轉換不會有信息損失,CLR 仍認識它是子類對象),而基類對象 e2 則是徹頭徹尾的基類,自然無法轉換爲其子類。

Manager m0 = new Manager();
m0.EmployeeNum = 10;
Employee e1 = m0;
Employee e2 = new Employee();
Manager m1 = (Manager) e1; // (1) 正常執行
Manager m2 = (Manager) e2; // (2) 拋異常
Console.WriteLine($"m1.EmployeeNum: {m1.EmployeeNum}");
Console.WriteLine($"m2.EmployeeNum: {m2.EmployeeNum}");

但是,我有一個問題:從語法角度上講,爲什麼顯示轉換需要顯示地在被轉換的類型前面用括號標明目標類型呢?這好像是多此一舉,因爲 CLR 會自動檢查類型的 “真實類型”,如果類型相互符合,自動轉就行(像隱式轉換那樣),如果不符合拋個異常就行了,沒有必要讓程序猿多寫個括號和類型名吧。後來又一想,可能是爲了語法的統一吧。對於值類型的強制轉換,因爲可能會造成精度損失,故要求程序猿寫明目標類型以達到提醒的目的;另一方面,強制轉換 本質上也是一種運算符(參見 自定義強制類型轉換 相關內容),因此有必要寫明運算符。 基類和子類之間的強制類型轉換的含義與上兩種情況的強制轉換 本質上是不同的,而形式相同,我猜可能是爲了統一語法規則吧。

1.4 使用 is 和 as 進行類型轉換

爲了確保基類轉子類能安全的進行,我們可以先用 is 操作符來檢查兩個類型是否兼容,若是再去強制轉型:

public void Transfer(Object o)
{
    if (o is Employee)
    {
        Employee e = (Emloyee) o;
    }
}

上述代碼中,is 和 (Employee) 進行了兩次對象o的類型檢查,影響了執行效率。爲此,C# 提供了 as 操作符,來簡化編程,同時提高執行效率:

Employee e = o as Employee;
if (e != null)
{
    ... ...
}

上述代碼中,CLR 只檢查一次 o 是否兼容 Employee,如是, as 直接返回這個對象的非空引用;若否,as 返回 null。檢查一個對象是否是 null 比檢查對象類型快的多,因此,可以提高效率。

1.5 命名空間

命名空間並沒有太複雜的含義,它僅僅是在類型名稱前加上一些前綴,使得類型名稱變長,來區分可能同名的類型。

命名空間和程序集沒有關係。同一個命名空間可以在不同的程序集中實現

2 類型、對象、棧、堆在運行時的相互關係

前面的文章中已經大致討論了 CLR 執行代碼的大致過程。這裏結合類型,詳細討論一下其工作原理。

首先,關於棧和堆的概念見這篇文章

下文以如下代碼爲例進行討論:

internal class Employee 
{
    public Int32 GetYearsEmployed() { ... }
    public virtual String GetProgressReport() { ... }
    public static Employee Lookup(String name) { ... }
}

internal sealed class Manager : Employee 
{
    public override String GetProgressReport() { ... }
}

Windows 在執行一個託管程序集時,首先進程會加載CLR。進程也會創建多個線程。線程創建時會被分配 1M 的空間。

假設我們現在有一個方法 M3() 要執行,當前的內存狀態爲:

下面開始執行M3方法。

(1)CLR 在執行方法時,會檢查方法引用的所有的類型。CLR 首先檢查這些類型所在的程序集有沒有被加載到進程中。

(2)然後,CLR 根據程序集的元數據,提取用到的類型的有關信息,在堆中創建對應的 System.Type 實例對象,存放類型信息。這個對象稱爲 “類型對象” 。類型對象中,包含有 類型對象指針(Type object pointer)、同步塊索引(sync block index)、靜態字段 以及 方法表

在初始化這個類型對象時,方法表中的每個方法是指向 JITCompiler 函數的,它負責對方法的代碼進行 JIT 編譯。

這時的內存狀態:

(3)CLR 根據程序集元數據獲取方法的 IL 代碼,然後進行 JIT 編譯(如果是第一次調用該方法),編譯成本機代碼,存放在內存中,然後,修改 堆中類型對象的方法表中的方法,使它指向編譯後的這段本機代碼。因此,JIT只需進行一次編譯。(編譯後的本機代碼存放在哪裏???暫時未知

(4)代碼編譯完之後,CLR開始執行代碼。CLR 內部機制會在方法開頭添加一些功能,來爲方法創建一個棧幀,並將局部變量壓入棧幀中。同時,CLR 自動將所有局部變量初始化爲 0 或 null。(CLR是如何知道棧幀中局部變量的位置和類型的???JVM的棧幀模型

(5)然後,M3 創建了一個 Manager 對象。使用 new 操作符創建一個類型的實例時,會在堆上創建該類型的一個實例對象。這個實例對象也有 類型對象指針、同步塊索引,此外還包含該類及其父類定義的所有實例字段。實際上,在調用類型的構造函數(本質上就是設置實例字段)之前,CLR 會初始化 同步塊索引 以及 各個實例字段,將實例字段設爲 0 或 nul,並且初始化該對象的 類型對象指針,將其指向對應的 類型對象(見上文第1章)。此時內存狀態如下圖所示:

         類型對象指針:指向類型對象的存儲地址。

         同步塊索引:與多線程情況下對象同步機制相關的變量,參考這篇文章

(6)調用靜態方法 Lookup(string)。調用靜態方法時,CLR會找到該靜態方法所屬類型的 類型對象,在其方法表中找到該方法,進行JIT編譯(第一次調用),再調用編譯好的代碼。

假設 Lookup("Joe") 返回的是一個經理Manager對象,這時的內存狀態爲:

注意 e 雖然被聲明爲 Employee 這個父類,但 CLR 知道它的實際類型是 子類 Manager,該對象的 類型對象指針 指向的也是實際的 Manager類型對象。

(7)執行非虛實例方法 GetYearsEmployed()。調用非虛實例方法時,JIT 會找到 對象(e) 的被使用的類型(即聲明的類型,而不是實際類型)對應的 類型對象,再從其方法表中找到該 非虛實例方法。如果從聲明類型的方法表中找不到該方法,JIT 會 回溯 類型的層次結構,往上查找,一直找到 Object。之所以能這樣回溯,是因爲每個類型對象都有一個字段引用了他的父類型對象,這一點在圖中沒有顯示。

本例中,調用非虛實例方法的對象 e 的使用類型(聲明類型)是 父類 Employee,JIT 會調用 Employee 的相應方法。

(8)執行虛實例方法:下一行代碼調用 Employee 的虛實例方法 GetProgressReport()。調用虛實例方法時,JIT 會在方法中生成一些額外的代碼,這些代碼會找到 對象e 的實例,然後檢查該對象的 類型對象指針,通過該指針找到對象的實際類型,再從實際類型的方法表中查找該方法。

這裏,雖然 e 是 Employee 類型,但最終執行的是 Manager 類型的 GetProgressReport 方法。

執行非虛實例方法 和 虛實例方法 查找的類型對象不同,原因在於:虛方法是子類可以重寫的,由於可能存在隱式轉換,因此應該找到對象的實際類型的方法,才符合虛方法重寫的設計理念。

(9)執行完畢,該方法的棧幀將被銷燬(棧幀展開),返回值被寫入到 調用者方法的棧幀中。

類型對象指針

每個實例對象都有一個類型對象指針,它指向該對象實際的 類型對象(Type object)。上文提到,類型對象 也是個對象,而且本質上是個 System.Type 實例對象。

CLR 在一個進程中開始運行時,會爲 System.Type 創建一個特殊的 類型對象,之後所有 類型對象 的 類型對象指針 都會指向它。其實,這個特殊的類型對象自己的 類型對象指針 也指向自己。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章