普通的虛繼承 下面我們來看虛繼承。首先看看這C020類,它從C010虛繼承: struct C010 { C010() : c_(0x01) {} void foo() { c_ = 0x02; } char c_; }; struct C020 : public virtual C010 { C020() : c_(0x02) {} char c_; }; 運(yùn)行如下代碼,查看對(duì)象的內(nèi)存布局: PRINT_SIZE_DETAIL(C020) 結(jié)果為: The size of C020 is 6 The detail of C020 is c0 c2 45 00 02 01 很明顯對(duì)象的起始處是一個(gè)指針,然后是子類的成員變量,接下來是父類的成員變量。和以前的討論不同的是由于使用了虛繼承,父類的成員變量被放到了最后面。 運(yùn)行如下的代碼: C020 c020; c020.C010::c_ = 0x04; 由于子類中的變量和父類中的變量重名,所以我們必須用這種方式來訪問屬于父類的成員變量,普通情況下不需要這種寫法。我們看看后面這行代碼對(duì)應(yīng)的匯編代碼: 0042387E mov eax,dword ptr [ebp+FFFFF82Ch] 00423884 mov ecx,dword ptr [eax+4] 00423887 mov byte ptr [ebp+ecx+FFFFF82Ch],4 前面說過對(duì)象的起始是一個(gè)指針,第1行指令取到這個(gè)指針的值,第2行把這個(gè)指針指向的地址后移4字節(jié)后的值(做為一個(gè)4字節(jié)的值)取出來。執(zhí)行完這句我們看看ecx寄存器,可知取出來的值為5。最后一行是真正的賦值指令,它通過在對(duì)象的起始處(即[ebp+FFFFF32Ch])加上ecx中的值做偏移值(即5)來得到賦值的目的地址。接合前面的對(duì)象布局輸出,我們可以發(fā)現(xiàn)從對(duì)象起始地址開始加5字節(jié)的偏移值,剛好得到父類的成員變量的地址。這樣我們可以大致分析出直接虛繼承的子類的對(duì)象布局。 |子類5 |父類1 ?。?/span> |偏移值指針4,5|子類成員變量1|父類成員變量1| (注:第一個(gè)數(shù)字為所在區(qū)域的長(zhǎng)度(字節(jié)數(shù)),偏移值指針后的第二個(gè)數(shù)字為該指針指向的偏移值。后同。) 通過查看內(nèi)存可以發(fā)現(xiàn)偏移值指針指向的內(nèi)存前4字節(jié)為0,我不知道它的具體的用途是什么。接下來的4字節(jié)是一個(gè)32位的整數(shù),也就是真正的偏移值。即從子類的起始位置到被虛繼承的父類的起始位置的偏移值,在我們前面的例子中這個(gè)值為5(一個(gè)指針加一個(gè)char成員變量)。 通過這個(gè)分析我們可以看到在虛承繼的情況下,通過子類的對(duì)象訪問父類的普通成員變量的效率是相當(dāng)?shù)偷摹H绻仨氂玫教摾^承,也應(yīng)該盡量不要在父類中放置普通成員變量(靜態(tài)成員變量不受影響)。 另外為什么微軟不把偏移值直接放到子類中,而是采用偏移值指針。我想是因?yàn)椴捎弥羔樀姆绞礁鼮殪`活,即使以后需要擴(kuò)展也不影響類對(duì)象的布局。 按下來我們?cè)倏纯催@幾行代碼: PRINT_OBJ_ADR(c020); C010 * pt = &c020; PRINT_PT(pt); pt->c_ = 0x03; 第2行聲明了一個(gè)父類指針,并讓它指向一個(gè)子類的對(duì)象。第3行打印出這個(gè)指針的值。運(yùn)行結(jié)果為: c020's address is : 0012F708 pt's value is : 0012F70D 我們可以看到賦值后的指針的值并不等于賦給它的對(duì)象地址值。也就是說在這個(gè)賦值過程中編譯器進(jìn)行了額外的工作,即調(diào)整了指針的值。我們看看第2行對(duì)應(yīng)的匯編代碼,看看編譯器究竟做了些什么? 01 004238EA lea eax,[ebp+FFFFF82Ch] 02 004238F0 test eax,eax 03 004238F2 jne 00423900 04 004238F4 mov dword ptr [ebp+FFFFF014h],0 05 004238FE jmp 00423916 06 00423900 mov ecx,dword ptr [ebp+FFFFF82Ch] 07 00423906 mov edx,dword ptr [ecx+4] 08 00423909 lea eax,[ebp+edx+FFFFF82Ch] 09 00423910 mov dword ptr [ebp+FFFFF014h],eax 10 00423916 mov ecx,dword ptr [ebp+FFFFF014h] 11 0042391C mov dword ptr [ebp+FFFFF820h],ecx 喔!比想象的要復(fù)雜的多。一行簡(jiǎn)單的指針賦值語句卻產(chǎn)生了這么多的匯編代碼。這行代碼本身的語義是取對(duì)象的地址賦給一個(gè)指針,對(duì)于編譯器來說它把這做為指針到指針的賦值來處理。由于牽涉到了向上的類型轉(zhuǎn)換,同時(shí)又有虛繼承存在。根據(jù)前面的布局分析,在虛繼承的情況下,父類位于對(duì)象布局的后部。因此在這里要做一個(gè)指針位置的調(diào)整。由于調(diào)整要根據(jù)源指針來進(jìn)行計(jì)算,所以先要對(duì)源指針的合法性進(jìn)行檢查,以避免運(yùn)行時(shí)的指針異常錯(cuò)誤。前3行的匯編指令就是在做這件事,檢查源指針是否為NULL。如果為NULL則執(zhí)行4、5、10、11行,最終給pt賦0。如果不為NULL跳至第6行執(zhí)行到最后。重要的是第6、7、8行代碼,它們通過偏移值指針找到偏移值,并以此來調(diào)整指針的位置,讓目的指針最終指向?qū)ο笾械母割惒糠值臄?shù)據(jù)成員。 對(duì)比一下普通的指針賦值,我們可以對(duì)上面賦值的復(fù)雜性和低效有更深的認(rèn)識(shí)。 C010 * pt1 = NULL; C010 * pt2 = pt1; 這兩行相應(yīng)的匯編代碼為: 0042397D mov dword ptr [ebp+FFFFF814h],0 00423987 mov eax,dword ptr [ebp+FFFFF814h] 0042398D mov dword ptr [ebp+FFFFF808h],eax 第1行是普通的賦值,編譯器并不做任何的檢查,即使源指針為NULL。因?yàn)樗恍枰鶕?jù)源指針(本處為NULL)做任何計(jì)算。第2個(gè)賦值也很直接,只是通過eax做了一個(gè)中轉(zhuǎn)。這里我們就可以看到前面的虛繼承下的子類指針到父類指針的賦值是我么的低效。在程序中應(yīng)盡量的避免這種代碼。 (未完待繼) 菱形結(jié)構(gòu)的虛繼承
菱形結(jié)構(gòu)的虛繼承(3) 最后我們看看,如果在上篇例子的基礎(chǔ)上,子類及左、右父類都各自定義了自己的虛函數(shù),這時(shí)的情況又會(huì)怎樣。 struct C140 : public virtual C041 { C140() : c_(0x02) {} virtual void foo() { c_ = 0x11; } char c_; }; struct C160 : public virtual C041 { C160() : c_(0x02) {} virtual void foo() { c_ = 0x12; } virtual void f160() { c_ = 0x12; } char c_; }; struct C161 : public virtual C041 { C161() : c_(0x03) {} virtual void foo() { c_ = 0x13; } virtual void f161() { c_ = 0x13; } char c_; }; struct C170 : public C160, public C161 { C170() : c_(0x04) {} virtual void foo() { c_ = 0x14; } virtual void f170() { c_ = 0x14; } char c_; }; 首先運(yùn)行如下的代碼,看看內(nèi)存的布局。 PRINT_SIZE_DETAIL(C041) PRINT_SIZE_DETAIL(C160) PRINT_SIZE_DETAIL(C161) PRINT_SIZE_DETAIL(C170) 結(jié)果為: The size of C041 is 5 The detail of C041 is f0 b2 45 00 01 The size of C160 is 18 The detail of C160 is 84 b3 45 00 88 b3 45 00 02 00 00 00 00 80 b3 45 00 01 The size of C161 is 18 The detail of C161 is 98 b3 45 00 9c b3 45 00 03 00 00 00 00 94 b3 45 00 01 The size of C170 is 28 The detail of C170 is b0 b3 45 00 c8 b3 45 00 02 ac b3 45 00 bc b3 45 00 03 04 00 00 00 00 a8 b3 45 00 01 C170對(duì)象的布局為: |C160,9 |C161,9 |C170,1 |zero,4 |C041,5 | |vp,4 |op,4,19 |m,1 |vp,4 |op,4,10 |m,1 |m,1 | |vp,4 |m1 | (注:為了不折行,我用了縮寫。op代表偏移值指針、m代表成員變量、vp代表虛表指針。第一個(gè)數(shù)字是該區(qū)域的大小,即字節(jié)數(shù)。只有偏移值指針有第二個(gè)數(shù)字,第二個(gè)數(shù)字就是偏移值指針指向的偏移值的大小。) 左右父類由于各自定義了自己的新的虛函數(shù),因此都擁有了自己的虛表指針。奇怪的是子類雖然也定義了自己的新的虛函數(shù),我們?cè)谏厦娴牟季种袇s看到它并沒有自己的虛表指針。那么它應(yīng)該是和頂層類或是某一父類共用了虛表。我們可以在后面通過對(duì)調(diào)用的跟蹤來找到答案。 另一個(gè)奇怪的地方是在左右父類中的偏移值指針指向的偏移值不再是到祖父類的偏移量,而是變成了到祖父類之前的4字節(jié)0值的偏移量。同時(shí)在前面第八篇中我們說過偏移值指針指向的地址的前4個(gè)字節(jié)為零,接下來的4個(gè)字節(jié)才是真正的偏移量。在這個(gè)例子中,前4個(gè)字節(jié)不再為0,而是0xFFFFFFFC,即整數(shù)-4。 照例我們先通過對(duì)象來調(diào)用一下。 C170 obj; PRINT_OBJ_ADR(obj); obj.foo(); 結(jié)果為: obj's address is : 0012F54C 最后一行調(diào)用對(duì)應(yīng)的匯編指令為: 004245B8 lea ecx,[ebp+FFFFF687h] 004245BE call 0041D122 ecx中的值(即this指針的值)為0x0012F563,和前面一樣是指向祖父類的起始部分。同樣函數(shù)中的指令也是通過將this-5字節(jié)來定位到正確的成員變量的地址,這里不再列出函數(shù)的匯編指令。 再看看調(diào)用它自己新定義的虛函數(shù)。 obj.f170(); 對(duì)應(yīng)的匯編指令為: 004245C3 lea ecx,[ebp+FFFFF670h] 004245C9 call 0041D127 讓我非常驚奇的是這次this指針的值居然是0x0012F54C。和前面的對(duì)象地址輸出是一樣的,也就是指向了整個(gè)對(duì)象的起始位置。這就讓人非常的奇怪了,在同一個(gè)對(duì)象上調(diào)用的兩個(gè)虛函數(shù),編譯器為它們傳遞的this指針卻是不同的。 讓我們跟到函數(shù)中,看它怎樣取得正確的成員變量的地址。 01 00426F80 push ebp 02 00426F81 mov ebp,esp 03 00426F83 sub esp,0CCh 04 00426F89 push ebx 05 00426F8A push esi 06 00426F8B push edi 07 00426F8C push ecx 08 00426F8D lea edi,[ebp+FFFFFF34h] 09 00426F93 mov ecx,33h 10 00426F98 mov eax,0CCCCCCCCh 11 00426F9D rep stos dword ptr [edi] 12 00426F9F pop ecx 13 00426FA0 mov dword ptr [ebp-8],ecx 14 00426FA3 mov eax,dword ptr [ebp-8] 15 00426FA6 mov byte ptr [eax+12h],14h 16 00426FAA pop edi 17 00426FAB pop esi 18 00426FAC pop ebx 19 00426FAD mov esp,ebp 20 00426FAF pop ebp 21 00426FB0 ret 看看第15行可以知道,是直接在this指針上加了18字節(jié)(即16進(jìn)制的12h)來定位到子類的成員變量。 由于函數(shù)中的指令是以這種方式來定位子類成員變量,所以即使我們是通過指針來調(diào)用,不同的只是怎樣定位函數(shù)地址,而this指針的值是肯定不會(huì)變的。我們來驗(yàn)證一下。 C170 * pt = &obj; pt->f170(); 第二行代碼對(duì)應(yīng)的匯編指令如下: 01 004245DA mov eax,dword ptr [ebp+FFFFF664h] 02 004245E0 mov edx,dword ptr [eax] 03 004245E2 mov esi,esp 04 004245E4 mov ecx,dword ptr [ebp+FFFFF664h] 05 004245EA call dword ptr [edx+4] 06 004245ED cmp esi,esp 07 004245EF call 0041DDF2 第一行把整個(gè)對(duì)象的起始地址放到eax中,第2行把eax當(dāng)指針,并把所指地址放到edx中。對(duì)象的起始地址正好也是左父類中的虛表指針,第5行進(jìn)行調(diào)用的時(shí)候果然是把edx指向的地址后移了4字節(jié)后取值,做為函數(shù)地址。這也就回答了前面的一個(gè)問題,子類沒有虛表,它的虛表實(shí)際合并到了左父類的虛表中,左父類定義了一個(gè)自己的虛函數(shù),占用了虛函數(shù)表的第一個(gè)條目,子類的虛函數(shù)則占用了第二個(gè)條目。因此在尋址時(shí)要加上4個(gè)字節(jié)。ecx中的this指針值和我們前面估計(jì)一樣,是整個(gè)對(duì)象的起始地址。 最后我們看看怎樣得到祖父類地址。 pt->C041::c_ = 0x33; 對(duì)應(yīng)的匯編指令為: 01 004245F4 mov eax,dword ptr [ebp+FFFFF664h] 02 004245FA mov ecx,dword ptr [eax+4] 03 004245FD mov edx,dword ptr [ecx+4] 04 00424600 mov eax,dword ptr [ebp+FFFFF664h] 05 00424606 mov byte ptr [eax+edx+8],33h 首先把對(duì)象的起始地址賦給eax。第2行把eax+4字節(jié)后得到的指針指向的地址賦給ecx,這個(gè)值就是偏移值指針指向的地址。果然第3行把它+4字節(jié)后取值,再賦給edx。這時(shí)edx的值為13h,照理這應(yīng)該是到祖父類區(qū)域的偏移值,但實(shí)際是只到我們?cè)趯?duì)象布局中列出的4字節(jié)0值,也就是真正的祖父類起始地址的前4個(gè)字節(jié)。我們?cè)谇懊嬗懻揅170的對(duì)象布局時(shí)已經(jīng)提到這個(gè)問題。所以我們看到第5行定位到成員變量時(shí)再加了8字節(jié),以跳過4字節(jié)的0值為4字節(jié)的祖父類的虛表指針,而不是只加4字節(jié)跳過虛表指針。在C150對(duì)象中我們可以看到偏移值是直接跳過4字節(jié)0值,定位到祖父類起始地址的。 我們始終沒有清楚的解釋過祖父類之前的4字節(jié)0值及偏移值指針指向地址的前4字節(jié)的語義。有可能是出于兼容的原因,也有可能是為編譯器提供一些薄記信息。而且,引入虛繼承后的對(duì)象繼承的拓樸結(jié)構(gòu)可以比我們討論過的菱形結(jié)構(gòu)要復(fù)雜得多。這兩個(gè)值也可能是用來處理更復(fù)雜的繼承結(jié)構(gòu)。要想通過表象去揣測(cè)出使用它們的動(dòng)機(jī)太困難了。 (未完待續(xù)) 后記
結(jié)合前面的討論,我們可以看到,只要牽涉到了虛繼承,在訪問父類的成員變量時(shí)生成的代碼相當(dāng)?shù)牡托?,需要通過很多間接的計(jì)算來定位成員變量的地址。在指針類型轉(zhuǎn)換,動(dòng)態(tài)轉(zhuǎn)型,及虛函數(shù)調(diào)用時(shí),也需要生成很多額外的代碼來調(diào)整this指針。象前一篇中對(duì)C170對(duì)象的obj.foo()和obj.f170()兩次調(diào)用,傳遞到兩個(gè)函數(shù)中的this指針居然是不一樣的。 前面我們碰到過的怪異行為還有很多,比如偏移值指針指向地址的前4字節(jié),及C150、C170對(duì)象中的4字節(jié)0值的語義,為什么對(duì)C150和C170調(diào)用foo函數(shù)時(shí),this指針指向的不是子類部分的起始位置而是祖父類的起始位置,等等。去徹底的探究這些問題的意義并不是很大。虛繼承的實(shí)現(xiàn)屬于編譯器廠商的行為,廠商出于不同的考慮,實(shí)現(xiàn)的方法也會(huì)大相徑庭。 對(duì)于傳統(tǒng)的C程序員,他們可能會(huì)認(rèn)為C++的效率低。其實(shí)效率低是低在多態(tài)部分,因?yàn)檫@要在運(yùn)行時(shí)通過虛表來決議出函數(shù)的地址。但對(duì)于設(shè)計(jì)而言,多態(tài)是一個(gè)非常強(qiáng)大的武器。多態(tài)也是面向?qū)ο笤O(shè)計(jì)的核心技術(shù)之一。雖然在執(zhí)行的效率上有所損失,但對(duì)于大規(guī)模的程序設(shè)計(jì),對(duì)于問題域到模型的映射,使用以多態(tài)為核心的面向?qū)ο笤O(shè)計(jì)技術(shù)可以提高設(shè)計(jì)、實(shí)現(xiàn)及維護(hù)的效率,對(duì)于大部分的應(yīng)用,總體來說得大于失。 但是對(duì)于虛繼承,個(gè)人感覺只是為了解決菱形繼承及更復(fù)雜繼承問題不得已而引入的一項(xiàng)機(jī)制,而且沒有完美的解決方案,不但大幅的損失效率,而且?guī)砹司薮蟮膹?fù)雜性,使得繼承結(jié)構(gòu)晦澀難懂。如非萬不得已,且在自己清楚一切后果的情況下,建議不要使用。尤其是不要在被虛繼承的基類中聲明非靜態(tài)的成員變量。 C++支持多種編程范型,面向過程式的、數(shù)據(jù)抽象及封裝、面向?qū)ο?、現(xiàn)在又多了一種基于模板技術(shù)的泛型編程。我們以一個(gè)優(yōu)秀的開源C++編程環(huán)境ACE(之所以叫編程環(huán)境,因?yàn)樗峁┝藦念悗斓娇蚣艿亩鄬哟蔚闹С?為例,看看在設(shè)計(jì)時(shí)的衡量及各種編程范型的運(yùn)用契機(jī)。ACE分幾個(gè)層次,依次為:OS適配層、wrapper facade層、框架層、服務(wù)組件層。OS適配層、wrapper facade層主要運(yùn)用了數(shù)據(jù)抽象及封裝,沒有用到多態(tài)及虛機(jī)制。因?yàn)檫@兩層的關(guān)鍵是效率。而且在這兩層中的類的成員方法盡量的內(nèi)聯(lián)。其實(shí)不使用多態(tài)及虛機(jī)制的話,C++的效率和C應(yīng)該是差不多的,但對(duì)象封裝導(dǎo)致了大量的瑣碎方法(如對(duì)成員變量的訪問封裝,即set,get方法),而方法調(diào)用的成本也是相當(dāng)高昂的(需要保存及恢復(fù)當(dāng)前的執(zhí)行環(huán)境上下文,參數(shù)的傳遞及返回值可能產(chǎn)生很多的臨時(shí)變量及對(duì)象等)。所以這兩層通過內(nèi)聯(lián)來減少函數(shù)調(diào)用的開銷,提高執(zhí)行效率。在框架層則使用了大量的設(shè)計(jì)模式,大量使用了多態(tài)機(jī)制及泛型技術(shù)。在這一層的主要關(guān)注點(diǎn)是結(jié)構(gòu)的清晰,及實(shí)現(xiàn)設(shè)計(jì)上的語義。在這時(shí)多態(tài)機(jī)制在執(zhí)行效率上的損失是可以忽略不計(jì)的。 最后,我用Lippman在他的經(jīng)典書籍《Inside the C++ Object Model》中關(guān)于描述虛成員函數(shù)章節(jié)中的一段話來做為這系列文章的結(jié)束。“Although I have a folder full of examples worked out and more than one algorithm for determining the proper offsets and adjustments, the material is simply too esoteric to warrant discussion in this text. My recommendation is not to declare nonstatic data members within a virtual base class. Doing that goes a long way in taming the complexity.”大意為在虛繼承時(shí)用以確定偏移地址及進(jìn)行this指針調(diào)整的可行算法很多,而且大都非常的詭異(這個(gè)我們已經(jīng)見識(shí)了:))。同時(shí)他建議不要在被虛繼承的基類中聲明非靜態(tài)的成員變量,這樣做純屬自取煩惱。 另,感謝張水松和張建業(yè)這兩個(gè)土人,在寫這些文章時(shí)和他們進(jìn)行了一些非常有益的探討。最后也是他們提醒我,不要再深入下去,以免走火入魔。 (全文完) |
|