C++虛函數淺探

C++中和虛函數(Virtual Function)密切相關的概念是“動態綁定”(Dynamic Binding),與之相對的概念是“靜態綁定”(Static Binding)。所謂“靜態綁定”,是指在編譯時就能確定函數調用語句和實際執行的函數;而“動態綁定”則是——對於同一個函數調用,編譯時並不能確定具體調用的函數,直到執行時才能決定。

靜態綁定

繼承而沒有體現多態的例子:
#include <iostream>

using namespace std;

class Base
{
public:
	void show() { cout << "I am a Base object!\n"; }
};

class Derived : public Base
{
public:
	void show() { cout << "I am a Derived object!\n"; }
};

int main(int argc, char *argv[])
{
	Base *pBase = new Base();
	pBase->show();
	
	pBase = new Derived();
	pBase->show();
	
	return 0;
}
這個例子將會輸出:
I am a Base object!
I am a Base object!
這個例子說明,通過基類指針(或引用)調用一般的成員函數(Member Function)時(編譯器)採取的都是靜態綁定。

靜態綁定的實現

靜態綁定的實現,即一般的成員函數的實現。例如,有這樣的一個類的定義:
class Simple
{
	int data;
public:
	void setData(int d) { data = d; }
	int getData() { return data; }
};
編譯器會將上面的兩個成員函數處理爲類似下面的C代碼(暫不考慮名稱修飾)[1]:
// 僞代碼, 說明編譯器對一個成員函數定義的展開形式
void setData(Simple* this, int d)
{
	this->data = d;
}

int getData(Simple* this)
{
	return this->data;
}
(C代碼?現在的編譯器根本不會這麼做(間接編譯)!哦,只有當年的cfront纔會先把C++轉成C代碼再編譯☺)
靜態綁定的實現大體如以上代碼所示,即在成員函數的參數列表最前面插入一個指針(this指針),成員函數內部所有對成員變量的訪問都將由此this指針尋址;這樣即可實現語言層面上不同對象調用相同成員函數,訪問各自的數據拷貝。

動態綁定

只需將上面靜態綁定的例子上的show函數加上virtual,此時雖然Derived::show沒有聲明爲virtual,但它也是virtual(由繼承而來的virtual屬性,標準就是這樣規定的)。
#include <iostream>

using namespace std;

class Base
{
public:
	virtual void show() { cout << "I am a Base object!\n"; }
};

class Derived : public Base
{
public:
	void show() { cout << "I am a Derived object!\n"; }
};

int main(int argc, char *argv[])
{
	Base *pBase = new Base();
	pBase->show();
	
	pBase = new Derived();
	pBase->show();
	
	return 0;
}
這樣,程序將輸出:
I am a Base object!
I am a Derived object!

這裏的例子體現了多態性,這裏pBase->show()前後兩次分別執行了Base::show()和Derived::show(),而且這種選擇是在執行時期決定的,而非前面例子的編譯時期。
一個更激進的例子是——爲了證明是在執行時期,可以讓pBase所指向的對象由用戶輸入決定:
void testRTTI()
{
	int n = 0;
	while(cin >> n) { // 遇到EOF字符結束,Windows控制檯上Ctrl+Z可輸入EOF,Linux Ctrl+D
		if( n % 2 ) 
			pBase = new Base();
		else
			pBase = new Derived();
		pBase->show();
		delete pBase;
	}	
}
例如,一組輸入輸出(黑體是輸入):
1
I am a Base object!
2
I am a Derived object!
3
I am a Base object!

動態綁定的實現

動態綁定的實現,即virtual function的實現,《深度探索C++對象模型》(以下簡稱<模型>)第四章對此有詳細探討[3]。這裏僅簡要描述,編譯器在編譯時將一個類的所有Virtual Function的地址存入一個表中(Virtual Table, vtbl),並在類的數據成員中安插一個指向該表的指針(vptr)。在一個繼承體系中(上例中的Base和Derived),子類繼承自父類的virtual function將會被安插在與父類vtbl相同的位置。編譯器在該類的構造函數(若用戶沒有定義,編譯器將生成)中插入初始化vptr的代碼(將vptr指向vtbl);析構函數中也會有類似的行爲。
根據<模型>的論述,上例Base和Derived的內存佈局和其virtual table如下:

這些信息使得編譯器可以將pBase->show();轉化爲:
pBase->_vptr[1](pBase);
在執行時就能夠實現不同的函數調用了。

VC2008跟蹤

以下將上述動態綁定的代碼在VC2008下以Win32 Debug版本編譯,並用反彙編(調試->窗口->反彙編)調試。

1.構造函數初始化vptr

先看看Base創建的代碼:

	Base *pBase = new Base();
00B6152E  push        4    
00B61530  call        operator new (0B6120Dh) ; 申請內存
00B61535  add         esp,4 ; 清除壓入的4
00B61538  mov         dword ptr [ebp-0E0h],eax ;  保存到棧上臨時變量(暫計爲ret)
00B6153E  cmp         dword ptr [ebp-0E0h],0      ; ret和0比較
00B61545  je          main+4Ah (0B6155Ah)         ; 如果ret==0 ,不執行構造函數
00B61547  mov         ecx,dword ptr [ebp-0E0h] ; ret存入 ecx(this指針)
00B6154D  call        Base::Base (0B61136h)    ; 調用Base::Base

看到了call Base::Base,繼續:

00B61136  Base::Base (0B61630h)

Base::Base:
00B61630  push        ebp  
00B61631  mov         ebp,esp 
00B61633  sub         esp,0CCh       ; 棧上開闢空間(棧向下生長)
00B61639  push        ebx  
00B6163A  push        esi  
00B6163B  push        edi  
00B6163C  push        ecx  ; 最後一個push
00B6163D  lea         edi,[ebp-0CCh]       ; \
00B61643  mov         ecx,33h              ; 初始化剛開闢的空間
00B61648  mov         eax,0CCCCCCCCh       ; (Debug版 特有代碼)
00B6164D  rep stos    dword ptr es:[edi]   ; /
00B6164F  pop         ecx  ; 最近一次 push的是 ecx,而這期間esp沒有被修改;而上次push之前ecx也沒有被修改,所以ecx還是原來的值(main寫入的this指針)
00B61650  mov         dword ptr [ebp-8],ecx 
00B61653  mov         eax,dword ptr [this]    ; 取出this指針
00B61656  mov         dword ptr [eax],offset Base::`vftable' (0B67804h) ;  初始化__vptr,讓它指向vftable
00B6165C  mov         eax,dword ptr [this] ; 取出this指針,寫入eax
00B6165F  pop         edi  
00B61660  pop         esi  
00B61661  pop         ebx  
00B61662  mov         esp,ebp 
00B61664  pop         ebp  
00B61665  ret

可以看到 dword ptr [eax],offset Base::`vftable' (0B67804h) 就是用來設置__vptr的,因爲代碼中的Base,Derived沒有定義其他數據成員,所以this指針所指的dword(4B)就是__vptr。

2.virtual table裏保存什麼?

通過VC2008的內存窗口(調試->窗口->內存)可以查看vftable的內容:

可以看到vftable的第一個成員是:0x00B61118,第二個是0(表示結束,沒有後續的),它很可能是一個函數的地址,在反彙編窗口輸入改地址能看到:

由此可以看到,VC2008的vftable和<模型>一書描述的並不相同,vftable第一個slot並沒有存放type_info,而是直接存了Base::show;因此這裏vftable只有一個slot。

3.調用virtual function的實際代碼

再來看看從調用Base::Base()到pBase->show();的代碼:
00B6154D  call        Base::Base (0B61136h) 
00B61552  mov         dword ptr [ebp-0E8h],eax ; eax 裏存的是this指針,這相當於保存函數返回值到臨時變量
00B61558  jmp         main+54h (0B61564h) 
00B6155A  mov         dword ptr [ebp-0E8h],0   ; 這行代碼被忽略
00B61564  mov         eax,dword ptr [ebp-0E8h] ;
00B6156A  mov         dword ptr [pBase],eax    ; 將this保存到pBase裏; 相當於 pBase = eax
	pBase->show();
00B6156D  mov         eax,dword ptr [pBase]    ; 再取出; 相當於 eax = pBase
00B61570  mov         edx,dword ptr [eax]      ; 這裏很關鍵,Base::show()在vftable的slot 0中,所以直接取eax所指向的dword(4字節)
00B61572  mov         esi,esp 
00B61574  mov         ecx,dword ptr [pBase]    ; ecx 傳入 this 指針
00B61577  mov         eax,dword ptr [edx]      ; 取出virtual function實際地址
00B61579  call        eax                      ; 調用
至此,一個完整的virtual function的執行已經梳理清楚了。

第二次,pBase = new Derived(); 後 pBase->show(); 的流程與此完全類似,這裏不再羅列;唯一不同的是,Derived::Derived()裏初始化__vptr的值會是offset Derived::`vftable'。

參考

[1] 潘愛民, 張麗 譯, Stanley B.Lippman, Josee Lajoie 著. C++ Primer 3e 中文版[M]. 北京:中國電力出版社, 2002. 521-523.
[2]王挺,週會平,賈麗麗,徐錫山 著. C++ 程序設計[M]. 北京:清華大學出版社,2005.374-375.
[3]侯捷 譯, Stanley B.Lippman 著. 深度探索C++對象模型[M]. 北京:電子工業出版社, 2012. 152-169.


發佈了31 篇原創文章 · 獲贊 40 · 訪問量 14萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章