C#繼承

2020-07-16 10:04:47
最高層的實體往往具有最一般、最普遍的特徵,而越下層的事物越具體,並且下層包含了上層的特徵。

它們之間的關係是基礎類別與派生類之間的關係。繼承 (inheritance) 是物件導向語言刻畫實際生產生活物件的一個有力工具。

通過繼承一個現有的類,新建立的類可以不需要寫任何程式碼,就可以按照繼承來的父類別中的合適的存取許可權直接擁有所繼承的類的功能,同時還可以建立自己的專門功能,這使得程式碼可以重用。

顯然,方法表是幕後的最大功臣。在建立子類物件時,子類的方法表包括了父類別的虛方法和指向父類別方法表的指標,使得子類可以呼叫父類別的方法。

另外,如果存在方法的重寫,則會用子類的方法替代掉父類別的方法。

當我們要修改類中共同的成員時,只需要修改父類別中的共同類成員,就可修改繼承這個父類別的子類們,而如果我們想單獨地修改子類中的類成員,是不會影響到父類別的,更不會影響到從父類別中繼承的其他子類們,這說明了繼承關係只會向下傳遞,子類本身的調整並不會影響基礎類別。

有些語言支援多重繼承,一個子類可以同時有多個父類別,比如 C++,而在有些程式語言中,一個子類只能繼承自一個父類別,比如 Java/C# 程式語言,這時可以利用介面來實現與多重繼承相似的效果。

C# 中型別只能繼承自一個型別,但可以再繼承多個介面。如果是這樣的情況,那麼方法表中會增加兩項:
  • 指向方法表中“介面虛表”的指標。
  • “介面虛表”包括了一組指標,每個指標都指向該型別實現的一個介面的方法表。 介面虛表指標的順序按照程式碼中書寫的繼承順序排列。

在學習參照型別的初始化時,我們知道子型別的範例物件中含有父類別型的範例欄位成員,因此,子型別可以輕易的存取父類別型的欄位。

對於靜態成員,子型別不能存取父類別型的靜態成員,這是因為靜態成員是屬於型別的。

那麼,現在在欄位層面上,繼承問題解決了。 我們來看看方法層面上情況是怎麼樣的。

子型別的方法表含有父類別型的虛方法,所以虛方法的呼叫沒問題,但是普通方法呢?子型別不含有父類別型的非虛方法,那麼,子型別該去哪裡尋找父類別型的普通方法?實際上,子型別的方法表根本不需要再加入父類別型的普通方法,它們已經存在於父類別型的方法表之中,沒必要再重複。

所以,為了讓子型別可以呼叫到父類別型的方法,子型別的方法表包括一個指向父類別型方法表的指標。而父類別型的虛方法不在方法槽表中,是因為它們有被重寫的可能。

型別載入器負責初始化整個方法表,它會遍歷型別以及它的所有父類別,包括介面的後設資料。在方法表的排列過程中,如果遇到方法的重寫,就替換掉父類別的虛方法。

方法表與繼承

在討論參照型別的初始化時,我們知道子型別的範例物件中含有父類別型的範例欄位成員,因此,子型別可以輕易的存取父類別型的欄位。

對於靜態成員,子型別不能存取父類別型的靜態成員,這是因為靜態成員是屬於型別的。那麼,現在在欄位層面上,繼承問題解決了。 我們來看看方法層面上情況是怎麼樣的。

子型別的方法表含有父類別型的虛方法,所以虛方法的呼叫沒問題,子型別的方法表不需要再加入父類別型的普通方法,它們已經存在於父類別型的方法表之中, 沒必要再重複。

所以,為了讓子型別可以呼叫到父類別型的方法,子型別的方法表包括一個指向父類別型方法表的指標。而父類別型的虛方法不在方法槽表中,是因為它們有被重寫的可能。

型別載入器負責初始化整個方法表,它會遍歷型別以及它的所有父類別,包括介面的後設資料。

在方法表的排列過程中,如果遇到方法的重寫,就替換掉父類別的虛方法。

Call 與 Callvirt

Call 是一個十分樸實的關鍵字,它會直接奔向方法的原生代碼,不管呼叫方法的範例是否合法。

而 Callvirt,顧名思義,在誕生時是用於呼叫虛擬函式的,不過,隨著時間的推移,它的用處已經不僅僅只是呼叫虛擬函式那麼簡單。

首先,我們要明確 Call 與 Callvirt 的最大區別,前者不會檢查範例是否為 null,而後者會檢查範例是否為null,如果是,則爆發執行時異常;否則,就會去型別物件找到方法表。

由於隱式型別轉換的存在,棧上的參照(編譯時型別)和範例物件(執行時型別)未必是同一個型別。

因此,Call 的使用場景為:
  • 範例不可能為 null:值型別的方法呼叫(不論什麼方法)。
  • 不需要檢查範例是否為 null:任何型別的靜態方法呼叫,以及方法內部呼叫同型別其他方法。

其他場景都需要 Callvirt 檢查範例是否為 null,例如參照型別的實體方法呼叫。

最後,有一個特殊情況:顯式使用 base 關鍵字呼叫父類別(一定是參照型別)的虛方法,也會使用 Call 而不是 Callvirt 進行呼叫。

例如,如果 IL 使用 Callvirt 呼叫 base.ToString,那麼實際上這等同於 this.ToString,形成了無限迴圈(Callvirt 去了自己的方法表,然後,父類別的虛方法又被子類重寫,因此,只能呼叫到子類的方法)。

可以用下面的 C# 和 IL 程式碼證明:
class C
{
    public override string ToString()
    {
        return base.ToString();
    }
}
對應的 IL 程式碼為:
.method public hidebysig virtual
               instance string ToString () cil managed
    {
        // Method begins at RVA 0x209c
        // Code size 12 ( 0xc)
        .maxstack 1
        .locals init (
            [0] string
        )
        IL_0000: nop
        IL_0001: ldarg.0
        IL_0002: call instance string [mscorlib]System.Object::ToString()
        IL_0007: stloc.0
        IL_0008: br.s IL_000a

        IL_000a: ldloc.0
        IL_000b: ret
    } //end of method C: : ToString
我們可以看到 IL 是使用 call 呼叫父類別的虛方法的。