1. 程式人生 > 實用技巧 >.NET基礎知識-型別、方法與繼承

.NET基礎知識-型別、方法與繼承

型別Type簡述

  .NET中主要的型別就是值型別和引用型別,所有型別的基類就是System.Object,也就是說我們使用FCL提供的各種型別的、自定義的所有型別都最終派生自System.Object,因此他們也都繼承了System.Object提供的基本方法。

  

  System.Object可以說是.NET中的萬物之源,如果非要較真的話,好像只有介面不繼承她了。介面是一個特殊的型別,可以理解為介面是普通型別的約束、規範,她不可以例項化。(實際編碼中,介面可以用object表示,只是一種語法支援,此看法不知是否準確,歡迎交流)

  在.NET程式碼中,我們可以很方便的建立各種型別,一個簡單的資料模型、複雜的聚合物件型別、或是對客觀世界實體的抽象。類(class)

是最基礎的 C# 型別(注意:本文主要探討的就是引用型別,文中所述型別如沒註明都為引用型別),支援繼承與多型。一個c# 類Class主要包含兩種基本成員:

  • 狀態(欄位、常量、屬性等)
  • 操作(方法、事件、索引器、建構函式等)

  利用建立的型別(或者系統提供的),可以很容易的建立物件的例項。使用 new 運算子建立,該運算子為新的例項分配記憶體,呼叫建構函式初始化該例項,並返回對該例項的引用,如下面的語法形式:

  <類名> <例項名> = new <類名>([建構函式的引數])

  建立後的例項物件,是一個儲存在記憶體上(線上程棧或託管堆上)的一個物件,那可以創造例項的型別在記憶體中又是一個什麼樣的存在呢?她就是型別物件(Type Object)

型別物件(Type Object)

  看看下面的程式碼:

int a = 123;                                                           // 建立int型別例項a
int b = 20;                                                            // 建立int型別例項b
var atype = a.GetType();                                               // 獲取物件例項a的型別Type
var btype = b.GetType();                                               //
獲取物件例項b的型別Type Console.WriteLine(System.Object.Equals(atype,btype)); //輸出:True Console.WriteLine(System.Object.ReferenceEquals(atype, btype)); //輸出:True

  任何物件都有一個GetType()方法(基類System.Object提供的),該方法返回一個物件的型別,型別上面包含了物件內部的詳細資訊,如欄位、屬性、方法、基類、事件等等(通過反射可以獲取)。在上面的程式碼中兩個不同的int變數的型別(int.GetType())是同一個Type,說明int在記憶體中有唯一一個(類似靜態的)Systen.Int32型別。

  上面獲取到的Type物件(Systen.Int32)就是一個型別物件,她同其他引用型別一樣,也是一個引用物件,這個物件中儲存了int32型別的所有資訊(型別的所有元資料資訊)

  關於型別型別物件(Object Type):

>每一個型別(如System.Int32)在記憶體中都會有一個唯一的型別物件,通過(int)a.GetType()可以獲取該物件;

>型別物件(Object Type)儲存在記憶體中一個獨立的區域,叫載入堆(Load Heap),載入堆是在程序建立的時候建立的,不受GC垃圾回收管制,因此型別物件一經建立就不會被釋放的,他的生命週期從AppDomain建立到結束;

>前問說過,每個引用物件都包含兩個附加成員:TypeHandle和同步索引塊,其中TypeHandle就指向該物件對應的型別物件;

>型別物件的載入由class loader負責,在第一次使用前載入;

>型別中的靜態欄位就是儲存在這裡的(載入堆上的型別物件),所以說靜態欄位是全域性的,而且不會釋放;

  可以參考下面的圖,第一幅圖描述了物件在記憶體中的一個關係, 第二幅圖更復雜,更準確、全面的描述了記憶體的結構分佈。

  

  

方法表

  型別物件內部的主要的結構是怎麼樣的呢?其中最重要的就是方法表,包含了是型別內部的所有方法入口,關於具體的細節和原理這裡不多贅述(太多了,可以參考文末給的參考資料),本文只是初步介紹一下,通過下面程式碼講解來進行理解。

public class A
{
    public virtual void Print() { Console.WriteLine("A"); }
}
public class B1 : A
{
    public override void Print() { Console.WriteLine("B1"); }
}
public class B2 : A
{
    public new void Print() { Console.WriteLine("B2"); }
}

  上面的程式碼中,定義兩個簡單的類,一個基類A,,B1和B2繼承自A,然後使用不同的方式改變了父類方法的行為。當定義了b1、b2兩個變數後,記憶體結構示意圖如下:

B1 b1 = new B1();
B2 b2 = new B2();

  

  方法表的載入:

  • 方法表的載入時父類在前子類在後的,首先載入的是固定的4個來自System.Object的虛方法:ToString, Equals, GetHashCode, and Finalize;
  • 然後載入父類A的虛方法;
  • 載入自己的方法;
  • 最後是構造方法:靜態建構函式.cctor(),物件建構函式.ctor();

  方法表中的方法入口(方法表槽 )還有很多其他的資訊,比如會關聯方法的IL程式碼以及對應的本地機器碼等。其實型別物件本身也是一個引用型別物件,其內部同樣也包含兩個附件成員:同步索引塊和型別物件指標TypeHandel,具體細節、原理有興趣的可以自己深入瞭解。

方法的呼叫:當執行程式碼b1.Print()時(此處只關注方法呼叫,忽略方法的繼承等因素),通過b1的TypeHandel找到對應型別物件,然後找到方法表槽,然後是對應的IL程式碼,第一次執行的時候,JIT編譯器需要把IL程式碼編譯為本地機器碼,第一次執行完成後機器碼會保留,下一次執行就不需要JIT編譯了。這也是為什麼說.NET程式啟動需要預熱的原因。

NET中的繼承本質

  方法表的建立過程是從父類到子類自上而下的,這是.NET中繼承的很好體現,當發現有覆寫父類虛方法會覆蓋同名的父方法,所有型別的載入都會遞迴到System.Object類。

  • 繼承是可傳遞的,子類是對父類的擴充套件,必須繼承父類方法,同時可以新增新方法。
  • 子類可以呼叫父類方法和欄位,而父類不能呼叫子類方法和欄位。
  • 子類不光繼承父類的公有成員,也繼承了私有成員,只是不可直接訪問。
  • new關鍵字在虛方法繼承中的阻斷作用,中斷某一虛方法的繼承傳遞。

  因此型別B1、B2的型別物件進一步的結構示意圖如下:

  • 在載入B1型別物件時,當載入override B1.Print(“B1”)時,發現有覆寫override的方法,會覆蓋父類的同名虛方法Print(“A”),就是下面的示意圖,簡單來說就是在B1中Print只有一個實現版本;
  • 載入B2型別物件時,new關鍵字表示要隱藏基類的虛方法,此時B2中的Print(“B2”)就不是虛方法了,她是B2中的新方法了,簡單來說就是在B2型別物件中Print有2個實現版本;

  

B1 b1 = new B1();
B2 b2 = new B2();
b1.Print(); b2.Print();      //按預期應該輸出 B1、B2

A ab1 = new B1(); 
A ab2 = new B2();
ab1.Print(); ab2.Print();   //這裡應該輸出什麼呢?

  上面程式碼中紅色高亮的兩行程式碼,用基類(A)和用本身B1宣告到底有什麼區別呢?類似這種程式碼在實際編碼中是很常見的,簡單的概括一下:

  • 無論用什麼做引用宣告,哪怕是object,等號右邊的[ = new 型別()]都是沒有區別的,也就說說物件的建立不受影響的,b1和ab1物件在記憶體結構上是一致的;
  • 他們的的差別就在引用指標的型別不同,這種不同在編碼中智慧提示就直觀的反應出來了,在實際方法呼叫上也與引用指標型別有直接關係;
  • 綜合來說,不同引用指標型別對於物件的建立(new操作)不影響;但對於物件的使用(如方法呼叫)有影響,這一點在上面程式碼的執行結果中體現出來了!

  上面呼叫的IL程式碼:

  

  對於虛方法的呼叫,在IL中都是使用指令callvirt,該指令主要意思就是具體的方法在執行時動態確定的:

callvirt使用虛擬排程,也就是根據引用型別的動態型別來排程方法,callvirt指令根據引用變數指向的物件型別來呼叫方法,在執行時動態繫結,主要用於呼叫虛方法。

  不同的型別指標在虛擬方法表中有不同的附加資訊作為標誌來區別其訪問的地址區域,稱為offset。不同型別的指標只能在其特定地址區域內進行執行。編譯器在方法呼叫時還有一個原則:

執行就近原則:對於同名欄位或者方法,編譯器是按照其順序查詢來引用的,也就是首先訪問離它建立最近的欄位或者方法。

  因此執行以下程式碼時,引用指標型別的offset指向子類,如下圖,按照就近查詢執行原則,正常輸出B1、B2  

B1 b1 = new B1();

B2 b2 = new B2();
b1.Print(); b2.Print();      //按預期應該輸出 B1、B2

  

  而當執行以下程式碼時,引用指標型別都為父類A,引用指標型別的offset指向父類,如下圖,按照就近查詢執行原則,輸出B1、A。  

A ab1 = new B1(); 

A ab2 = new B2();
ab1.Print(); ab2.Print();   //這裡應該輸出什麼呢?