其中有一道選擇題讓我印象深刻,是這樣的:
實例化一個X類型對象時所執行的順序:
A.調用X類型構造函數,調用X類型基類的構造函數,調用X類型內部字段的構造函數
B.調用X類型內部字段的構造函數,調用X類型基類的構造函數,調用X類型構造函數
C.調用X類型基類的構造函數,調用X類型構造函數,調用X類型內部字段的構造函數
D.調用X類型基類的構造函數,調用X類型內部字段的構造函數,調用X類型構造函數
我覺的這道題出得很沒水平。在C++的世界裡,我會毫不猶豫的選D。但是,由於C#引入了字段初始化器,所以選什麼答案完全依賴於類具體是如何設計的。好吧,我們今天就來談談C#在類型實例化時都有哪些步驟。
首先我們都知道,對於類對象,在執行構造函數之前,我們需要使用關鍵字new來為新實例分配內存。new可以根據對象的類型來為其在堆上分配足夠的空間,並且將這個對象的所有字段都設為默認值。也就是說,CLR會把該對象的所有引用類型字段設為null,而把值類型字段的所有底層二進制表示位設為0(本質上來說,不論是將值類型或引用類型字段初始化為“默認值”,其實都是把他們底層的數據位設為0)。這是任何類對象實例化的第一步。
我們暫且先不考慮對象有指定基類的情況,先看看下面的代碼吧:
class MyClass{ static MyClass() { Console.WriteLine("靜態構造函數被調用。"); } private static Component staticField = new Component("靜態字段被實例化。"); private Component instanceField = new Component("實例成員字段被實例化。"); public MyClass() { Console.WriteLine("對象構造函數被調用。"); }}//此類型用於作MyClass類的成員//此類型在實例化的時候可以再控制台輸出自定義信息,以給出相關提示class Component{ public Component(String info) { Console.WriteLine(info); }}class Program{ static void Main(string[] args) { MyClass instance = new MyClass(); }}
很顯然,靜態構造函數和靜態字段的構造函數會首先被調用。因為CLR在使用任何類型實例之前一定會先裝載該類型,也就需要調用靜態構造函數並且初始化靜態成員。但是,到底是先初始化靜態成員呢,還是調用靜態構造函數呢?答案是初始化靜態成員,因為CLR必須保證在執行構造函數的方法體時,相關的成員變量應該都可以被安全地使用。同樣的道理也適用於實例構造函數和字段,也就是說對象成員的實例化會先於成員構造函數被執行。順便說一句,類定義直接初始化類對象字段的功能是由類\對象字段初始化器完成的。以下是實例化MyClass對象時控制台的輸出:
靜態字段被實例化。靜態構造函數被調用。實例成員字段被實例化。對象構造函數被調用。
接下來,我們看看如果對象有指定的基類的情況:
class Base{ static Base() { Console.WriteLine("基類靜態構造函數被調用。"); } private static Component baseStaticField = new Component("基類靜態字段被實例化。"); private Component baseInstanceField = new Component("基類實例成員字段被實例化。"); public Base() { Console.WriteLine("基類構造函數被調用。"); }}//此類型用作派生類,同基類一樣,它也包含靜態構造函數,以及靜態字段、實例成員字段各一個。class Derived : Base{ static Derived() { Console.WriteLine("派生類靜態構造函數被調用。"); } private static Component derivedStaticField = new Component("派生類靜態字段被實例化。"); private Component derivedInstanceField = new Component("派生類實例成員字段被實例化。"); public Derived() { Console.WriteLine("派生類構造函數被調用。"); }}//此類型用於作為Base類和Derived類的成員//此類型在實例化的時候可以在控制台輸出自定義信息,以給出相關提示class Component{ public Component(String info) { Console.WriteLine(info); }}//在主程序裡實例化了一個子類對象class Program{ static void Main(string[] args) { Derived derivedObject = new Derived(); }}
類似於上個例子裡的MyClass,這裡的子類Derived和基類Base都有靜態構造函數,也包含靜態和實例成員各一個。當實例化一個子類Derived對象的實例時,輸出的結果可能並不容易想到:
派生類靜態字段被實例化。派生類靜態構造函數被調用。派生類實例成員字段被實例化。基類靜態字段被實例化。基類靜態構造函數被調用。基類實例成員字段被實例化。基類構造函數被調用。派生類構造函數被調用。
從結果我們可以看出,派生類的靜態字段初始化,靜態構造函數調用,實例成員字段初始化都會先於基類的任何初始化動作被執行。對於派生類靜態部分先被構造這一點比較容易理解,因為畢竟在CLR裝載派生類Derived之前,基類Base還未被使用過,也就不會先被裝載。
但是,為什麼派生類的實例成員字段會在基類被構造之前被初始化呢?答案和虛函數有關。試想有這麼一個基類,它在構造函數中調用了一個虛方法。然後又有這麼一個派生類,它重寫了基類的那個虛方法,並且在這個虛方法中訪問了它自己的一個實例成員字段。這一切都是完全合法的(至少在C#的世界裡是這樣的),對吧?在實例化一個派生類對象的過程中,其基類的構造函數會被調用,接著那個虛方法也會被調用,再接著派生類的實例成員字段會被訪問。所以此時此刻,這個類的實例成員字段必須是已被准備好了的!因此,派生類的實例成員字段必須先於基類部分被構造。
好了,再回到我們的例子。剩下的部分很容易理解:基類按照我們預想的方式被生成,然後派生類的構造函數被調用。至此,一個派生類的對象就被實例化了。
順便說一句,關於類字段初始化器,或對象字段初始化器,他們初始化成員字段的順序是成員在類定義中出現的先後順序。再順便說一句,如果程序的邏輯依賴於成員在類定義中出現的順序則是不好的設計,這可能會大大降低您代碼的易讀性。
現在當我們再回過頭看文章開頭的題目時,一切都明朗了——根本就沒有一個正確答案!因為如果X類型有對象字段初始化器,且其構造函數內沒有初始化任何實例字段的話,答案應該選B。如果X類型沒有對象字段初始化器,且其構造函數內初始化了實例字段的話,答案選C。如果X類型沒有對象字段初始化器,且其構造函數內沒有初始化任何實例字段的話,答案選D。再其他的情況,則沒有答案可選了。