我們再看看虛成員函數的調用。類C041中含有虛成員函數,它的定義如下:
struct C041
{
C041() : c_(0x01) {}
virtual void foo() { c_ = 0x02; }
char c_;
};
執行如下代碼:
C041 obj;
PRINT_DETAIL(C041, obj)
PRINT_VTABLE_ITEM(obj, 0, 0)
obj.foo();
C041 * pt = &obj;
pt->foo();
結果如下:
The detail of C041 is 14 b3 45 00 01
obj : objadr:0012F824 vpadr:0012F824 vtadr:0045B314 vtival(0):0041DF1E
我們打印出了C041的對象內存布局及它的虛表信息。
先看看obj.foo();的匯編代碼:
004230DF lea ecx,[ebp+FFFFF948h]
004230E5 call 0041DF1E
和前一篇文章中看過的普通的成員函數調用產生的匯編代碼一樣。這說明了通過對象進行函數調用,即使被調用的函數是虛函數也是靜態綁定,即在編譯時決議出函數的地址。不會有多態的行為發生。
我們跟蹤進去看看函數的匯編代碼。
01 004263F0 push ebp
02 004263F1 mov ebp,esp
03 004263F3 sub esp,0CCh
04 004263F9 push ebx
05 004263FA push esi
06 004263FB push edi
07 004263FC push ecx
08 004263FD lea edi,[ebp+FFFFFF34h]
09 00426403 mov ecx,33h
10 00426408 mov eax,0CCCCCCCCh
11 0042640D rep stos dword ptr [edi]
12 0042640F pop ecx
13 00426410 mov dword ptr [ebp-8],ecx
14 00426413 mov eax,dword ptr [ebp-8]
15 00426416 mov byte ptr [eax+4],2
16 0042641A pop edi
17 0042641B pop esi
18 0042641C pop ebx
19 0042641D mov esp,ebp
20 0042641F pop ebp
21 00426420 ret
值得注意的是第14、15行。第14行把this指針的值移到eax寄存器中,第15行給類的第一個成員變量賦值,這時我們可以看到在取變量的地址時用的是[eax+4],即跳過了對象布局最前面的4字節的虛表指針。
接下來我們看看通過指針進行的虛函數調用pt->foo();,產生的匯編代碼如下:
01 004230F6 mov eax,dword ptr [ebp+FFFFF900h]
02 004230FC mov edx,dword ptr [eax]
03 004230FE mov esi,esp
04 00423100 mov ecx,dword ptr [ebp+FFFFF900h]
05 00423106 call dword ptr [edx]
第1行把pt指向的地址移入eax寄存器,這樣eax中就保存了對象的內存地址,同時也是類的虛表指針的地址。第2行取eax中指針指向的值(注意不是 eax的值)到edx寄存器中,實際上也就是虛表的地址。執行完這兩條指令後,我們看看eax和edx中的值,果然和我們前面打印的obj的虛表信息中的 vpadr和vtadr的值是一樣的,分別為0x0012F824和0x0045B314。第4行同樣用ecx寄存器來保存並傳遞對象地址,即 this指針的值。第5行的call指令,我們可以看到目的地址不象通過對象來調用那樣,是一個直接的函數地址。而是將edx中的值做為指針來進行間接調用。前面我們已經知道edx中存放的實際是虛表的地址,我們也知道虛表實際是一個指針數組。這樣第5行的調用實際就是取到虛表中的第一個條目的值,即 C041::foo()函數的地址。如果被調用的虛函數對應的虛表條目的索引不是0,將會看到edx後加上一個索引號乘4後的偏移值。繼承跟蹤可以發現, ptr[edx]的值為0x0041DF1E,也和我們打印的vtival(0)的值相同。前面已經提到過,這個地址實際也不是真正的函數地址,是一個跳轉指令,繼續執行就到了真正的函數代碼部分(即前面列出的代碼)。
我們在上面看到的這個過程,就是動態綁定的過程。因為我們是通過指針來調用虛成員函數,所以會產生動態綁定,即使指針的類型和對象的類型是一樣的。為了保證多態的語義,編譯器在產生call指令時,不象靜態綁定時那樣,是在編譯時決議出一個確定的地址值。相反它是通過用發出調用的指針指向的對象中的虛指針,來迂回的找到對象所對應類型的虛表,及虛表中相應條目中存放的函數地址。這樣具體調用哪個函數就與指針的類型是無關的,只與具體的對象相關,因為虛指針是存放在具體的對象中,而虛表只和對象的類型相關。這也就是多態會發生的原因。
請回憶一下前面討論過的C071類,當子類重寫從父類繼承的虛函數時,子類的虛表內容的變化,及和父類虛表內容的區別(請參照第二篇中打印的子類和父類的虛表信息)。具體的通過指向子類對象的父類指針來調用被子類重寫過的虛函數時的調用過程,請有興趣的朋友自己調試一下,這裡不再列出。
另外前面在《C++對象布局及多態實現之動態和強制轉換》中我們討論了指針的類型動態轉換。我們在這裡再利用C041、C042及C051類,來看看指針的類型動態轉換。這幾個類的定義請參見前文。類C051從C041和C042多重繼承而來,且後兩個類都有虛函數。執行如下代碼:
C051 obj;
C041 * pt1 = dynamic_cast<C041*>(&obj);
C042 * pt2 = dynamic_cast<C042*>(&obj);
pt1->foo();
pt2->foo2();
第一個動態轉型對應的匯編代碼為:
00404B59 lea eax,[ebp+FFFFF8ECh]
00404B5F mov dword ptr [ebp+FFFFF8E0h],eax
因為不需要調整指針位置,所以很直接,取出對象的地址後直接賦給了指針。
第二個動態轉型牽涉到了指針位置的調整,我們來看看它的匯編代碼:
01 00404B65 lea eax,[ebp+FFFFF8ECh]
02 00404B6B test eax,eax
03 00404B6D je 00404B7D
04 00404B6F lea ecx,[ebp+FFFFF8F1h]
05 00404B75 mov dword ptr [ebp+FFFFF04Ch],ecx
06 00404B7B jmp 00404B87
07 00404B7D mov dword ptr [ebp+FFFFF04Ch],0
08 00404B87 mov edx,dword ptr [ebp+FFFFF04Ch]
09 00404B8D mov dword ptr [ebp+FFFFF8D4h],edx
代碼要復雜的多。&obj運算後得到的是一個指針,前三行指令就是判斷這個指針是否為NULL。奇怪的是第4行並沒有根據eax中的地址(即對象的起始地址)來進行指針的位置調整,而是直接把[ebp+FFFFF8F1h]的地址取到ecx寄存器中。第1行指令中的[ebp+ FFFFF8ECh]實際是得到對象的地址,ebp所加的那個數實際是個負數(補碼)也就是對象的偏移地址。對比兩個數發現相差5字節,這樣實際上第4行是直接得到了指針調整後的地址,即將指針指向了對象中的屬於C042的部分。後面的代碼又通過一個臨時變量及edx寄存器把調整後的指針值最終存到了 pt2指針中。
這些代碼實際可以優化成二行:
lea eax, [ebp+FFFFF8F1h]
mov dword ptr [ebp+FFFFF8d4h], eax
我們曾提到C051類有兩個虛表,相應對象中有也兩個虛表指針,之所以不合並為一個,就是為了處理指針的類型動態轉換。結合前面對於多態的討論,我們就可以理解得更清楚了。pt2->foo2();調用時,對象的類型還是C051,但經過指針動態轉換pt2指向了對象中屬於C042的部分的起始,也就是第二個虛表指針。這樣在進行函數調用時就不需要再做額外的處理了。我們看看pt1->foo();及pt2->foo2 ();產生的匯編碼即知。
01 00404B93 mov eax,dword ptr [ebp+FFFFF8E0h]
02 00404B99 mov edx,dword ptr [eax]
03 00404B9B mov esi,esp
04 00404B9D mov ecx,dword ptr [ebp+FFFFF8E0h]
05 00404BA3 call dword ptr [edx]
06 00404BA5 cmp esi,esp
07 00404BA7 call 0041DDDE
08 00404BAC mov eax,dword ptr [ebp+FFFFF8D4h]
09 00404BB2 mov edx,dword ptr [eax]
10 00404BB4 mov esi,esp
11 00404BB6 mov ecx,dword ptr [ebp+FFFFF8D4h]
12 00404BBC call dword ptr [edx]
13 00404BBE cmp esi,esp
14 00404BC0 call 0041DDDE
前7行為pt1->foo();,後7行為pt2->foo2();。唯一不同的是指針指向的地址不同,調用機制是一樣的。