1. 程式人生 > >深入瞭解.NET中繼承和多型(下)

深入瞭解.NET中繼承和多型(下)

很久都沒寫BLGO了,關於多型的第3篇文章一晃就1年了才寫。有時比較迷茫,感覺太多東西都要學,什麼都想學,卻找不清方向了。呵呵,看著好多牛人的BLOG覺得自己水平實在是太差了。呵呵。有時甚至覺得自己寫的東西太低階了。呵呵,或許是自己抱怨太多了,還是靜下心來慢慢學習吧。以後一定多寫一些東西,自己經常看看還是挺有幫助的。

如果大家對多型的機制還不瞭解,可以先檢視上面這2篇文章。本篇本打算使用一些例子說話,但是實際大家明白了方法表的佈局結構。其實是根本不需要任何例項去講解了。所以這一篇主要算是查缺補漏吧。

一 多型的例子

這個例子,主要是為了解釋上一篇中遺留的new關鍵字時候的問題。我們主要從兩個方面來講解這個列子:

1:變數型別同對象型別不同的場合

C2和C3_1這兩個物件的變數型別都是CPU型別,分配在棧上:

在編譯前:他們都只能使用變數型別所具有的方法,也就是CPU::FUN() 方法。

在編譯時:編譯器發現CPU::FUN()是一個虛方法,然後便獲得了FUN的方法槽偏移量為【28H】 ,C2和C3_1物件呼叫Fun()方法的地址是【物件方法表地址】+【28H】

在執行時:因為虛方法是通過callvirt 指令呼叫 ,需要知道具體的物件型別,這個時候C2物件是IntelCpu型別,而C3_1物件是NewCpu型別。於是訪問C2.FUN()時地址是【IntelCpu型別地址】+【28H】;而C3_1.FUN()的地址是【NewCpu】+【28H】

上面就是編譯後的記憶體方法表的佈局情況。IntelCpu型別使用了override關鍵字,所以方法槽偏移量為【28H】不再指向CPU物件的方法地址,而NewCpu型別物件使用了new關鍵字,所以繼承的方法槽偏移量為【28H】的地址仍舊指向CPU物件的方法地址,只是在下一個方法槽建立了一個新的fun方法。更具上面的圖就很清楚的看出了執行時的結果。

2 變數型別同對象型別相同的場合

C1和C3_2這2個物件的變數型別與型別物件是相同的。

在編譯前:C1變數是Cpu型別,所以能見的是CPU::FUN()方法;而C3_2變數是NewCpu型別,能見的是NewCpu::Fun()方法(因為用New關鍵字覆蓋了)

在編譯時:發現C1的fun方法是虛方法,所以才C1.fun訪問地址是【物件方法表地址】+【28H】 ;而C3_2的fun方法不是虛方法,所以編譯器可以直接確定此方法的地址【0x0001】。

在執行時:同樣是使用callvirt 指令呼叫,因為他們變數型別與型別物件是相同的,所以不會表現出多型。

上面就是這種情況的方法表佈局。可以看到NewCpu型別物件有兩個fun方法,一個是繼承於Cpu型別物件的虛方法,一個是自己新建的方法。C3_1物件同C3_2物件區別就在於他們棧上的變數型別不同。C3_1在編譯時是Cpu物件,可見的fun是虛方法,所以獲得了繼承的Fun方法的方法槽偏移量,而C3_2在編譯時NewCpu物件,可見的fun方法是非虛方法,所以直接得到了自己的fun方法的地址。

另外要補充的就是對於new override的情況,這個是和new一樣的,不同的只是自己新建的這個方法是一個虛方法。而如果直接使用override方法,被重寫的方法仍舊是虛方法,可以被自己的子類繼續重寫,一層一層。

二 更進一步的例子

對於override和new的情況,應該說是應該比較清楚了,那麼接著看下面的列子吧。

上面的列子中在父類中有2個虛方法,一個非虛方法。而子類中,只是覆蓋了一個虛方法。而我們要關注的也就是這個子類的呼叫情況。因為父類沒有任何方法被重寫,所以準確的說,這裡並不能算是一個多型的例子。但是有了虛方法,有了new,總是容易和多型混淆。還是那句話,弄清楚了方法表佈局,一切都不在是問題。

再次提醒一次,NewCpu方法表只會繼承基類的虛方法到自己的方法槽表中,並且保持相同的佈局; 所以此時的記憶體方法表應該如上圖所示。fun1和fun3兩個虛方法繼承於父類,並且保持了相同的佈局。而fun2是非虛方法,所以沒有被繼承。

呼叫fun1方法,和前一個列子是相同的,因為被覆蓋,並且是非虛方法,所以編譯時確定了地址,

呼叫fun2方法,因為fun2是父類的一個非虛方法,所以也是編譯時確定了地址

呼叫fun3方法,因為fun3方法是父類的一個虛方法,所以編譯時只能確定方法槽的偏移量,而要在執行時確定執行地址。

上面是編譯後的彙編程式碼,大家也可以到call指令後的地址形式。只有fun3是間接定址,而其他是直接定址。這裡唯一的問題就是對fun2方法的呼叫。fun2沒有被繼承下來,那麼NewCpu物件是如何去Cpu物件中得到他的地址的呢?

上面是NewCpu生成的IL程式碼,我們可以發現Extends專案中只是了它的父類是Cpu類,這樣在編譯時,雖然在自身類中找不到fun2方法,但是系統會去他的父類中找到此方法並確定方法的地址,而在我們編譯前,智慧感知中能找到fun2,也是依靠元資料來實現的。而在子類例項化之前,呼叫父類建構函式,應該也是同一個道理。

三 終極武器

光憑空YY,是解決不了問題的,這個時候我就要要用sos.dll來除錯程式碼; 看看記憶體佈局到底是個啥樣子。

為了看的清楚,在上面程式碼中加入了Cpu的物件c,來分別呼叫3個方法:

1:檢視實際的記憶體方法表佈局

從上面我們清楚的看到Cpu型別物件的方法表中有我們自己定義的3個方法,Entry就是方法槽偏移量,也是遞增的。接下來看看NewCpu物件的方法表:

如何,看明白了吧,繼承下來的fun1和fun3的地址是一樣的,而方法表中確實沒有fun2的身影。平時我們說的繼承,子類會繼承父類的所有方法(包括私有方法,只是不能訪問,但不包括構造方法),這個實際是邏輯上的繼承,而真正物理上的整合只對虛方法有效。而且對於非虛方法也不需要去繼承到子類中,因為這就是程式碼重用嗎。哈哈!

2:MethodDesc

在看看上面的MethodDesc這個欄位,方法描述(MethodDesc)是CLR知道的方法實現的一個封裝。方法描述在類載入過程中產生,初始化為指向IL。每個方法描述帶有一個預編譯代理(PreJitStub),負責觸發JIT編譯。下圖顯示了一個典型的佈局,方法表的槽實際上指向代理,而不是實際的方法描述資料結構。對於實際的方法描述,這是-5位元組的偏移,是每個方法的8個附加位元組的一部分。這5個位元組包含了呼叫預編譯代理程式的指令。5位元組的偏移可以從SOS的DumpMT輸出從看到,因為方法描述總是方法槽表指向的位置後面的5個位元組。在第一次呼叫時,會呼叫JIT編譯程式。在編譯完成後,包含呼叫指令的5個位元組會被跳轉到JIT編譯後的x86程式碼的無條件跳轉指令覆蓋。(轉)

3:EEClass

EEClass在方法表建立前開始生存,它和方法表結合起來,是型別宣告的CLR版本。實際上,EEClass和方法表邏輯上是一個數據結構(它們一起表示一個型別),只不過因為使用頻度的不同而被分開。經常使用的域放在方法表,而不經常使用的域在EEClass中。這樣,需要被JIT編譯函式使用的資訊(如名字,域和偏移)在EEClass中,但是執行時需要的資訊(如虛表槽和GC資訊)在方法表中。

對每一個型別會載入一個EEClass到應用程式域中,包括介面,類,抽象類,陣列和結構。每個EEClass是一個被執行引擎跟蹤的樹的節點。CLR使用這個網路在EEClass結構中瀏覽,其目的包括類載入,方法表佈局,型別驗證和型別轉換。EEClass的子-父關係基於繼承層次建立,而父-子關係基於介面層次和類載入順序的結合。在執行託管程式碼的過程中,新的EEClass節點被加入,節點的關係被補充,新的關係被建立。在網路中,相鄰的EEClass還有一個水平的關係。EEClass有三個域用於管理被載入型別的節點關係:父類(Parent Class),相鄰鏈(sibling chain)和子鏈(children chain)。

我們通過使用!DumpClass可以檢視EEClass

上面的程式碼中我們從NewCpu物件開始檢視,可以看到她Parent Class的地址,這個就是當前父物件的型別EEClass地址。我們繼續看Cpu物件的父物件發現是Object,而她的父物件地址是 00000000。而這個結構中,還包括了類中定義的靜態欄位和例項欄位數;Vtable Slots是虛方法數量和實現的介面方法,而父類的藉口方法也會被繼承下來,因為介面方法也是虛方法,雖然被繼承但不能被重寫,因為IL程式碼中有final關鍵字 ,而Total Method  Slots是類中總的的方法。由此可見,在NewCpu方法中,虛方法有6個,4個繼承與Object,2個繼承與Cpu;而總的方法有7個,6個虛方法,和一個繼承與Cup的非虛方法。

四 抽象方法、虛方法和介面方法

上面的列子包含了抽象方法,虛方法和介面方法,以及他們的繼承和重寫。實際上抽象方法和介面方法都是虛方法,只不過他們不需要也不能顯示的使用virtual關鍵字。我們通過ILDASM來檢視他們的IL有什麼區別。

可以看到3種方法的IL程式碼都有virtual關鍵字,說明他們全是虛方法。不同的是介面和抽象方法都有abstract方法,表示他們都是抽象的,所以非抽象類或非介面繼承他們之後都需要被實現。

我們接著看繼承他們的類的IL程式碼

上面的Cpu類分別重寫了3種方法。抽象方法和虛方法是相同的,而介面卻多了一個final關鍵字,這樣的話,此介面方法不能被子類重寫。雖然他是虛方法。如果需要介面方法能被重寫,需要顯示的加上Virtual關鍵字。而如果希望一個虛方法不能被不能被子類重寫,那麼可以使用sealed關鍵字,而不能使用private來限制虛方法。 效果如下IL程式碼:

有意思的是,如果你吧虛方法定義為private,在編碼時,只能感知會更具元資料來顯示出這個方法為可重寫的方法,但是編譯時會報錯,所以不知道這算不算一個小BUG。但是在C++中,私有虛擬函式是有意義的,http://topic.csdn.net/t/20040805/16/3245820.html

五 總結

.NET中的繼承和多型的第3篇文章終於寫完了。其實自己也是從對多型懵懵懂懂的認識開始的,在網上看了好多介紹繼承和多型,但很多都是給你一些自己總結的規則,看的人云裡霧裡,有一些也介紹到了方法表,記憶體結構,但是介紹的都很淺,所以自己打算稍微深入研究一下。結果一直沒寫下來。感覺對於繼承和多型的把握關鍵還是在記憶體模型。記憶體結構瞭解了,萬變不離其中。在複雜的情況也能分析的清楚。但是鑑於本人能力有限,對於記憶體模型那塊,也是知之甚少,難免有錯誤的地方。