C++基礎篇--虛擬函式原理
虛擬函式算是C++最關鍵和核心的內容之一,是元件的基礎。下面先列出一些相關名詞,再圍繞它們舉例說明虛擬函式的本質實現原理。
基礎概念(英文部分來自C++程式設計思想)
1)繫結:Connectinga function call to a function body is called binding.(把函式呼叫和函式實現關聯的過程)
2)早繫結:Whenbinding
is performed before the program is run (by the compiler and linker),it' s calledearly binding(程式執行前,即編譯和連結階段,完成的繫結即為早繫結)。
3)遲繫結:latebinding,
which means the binding occurs at runtime, based on the type of theobject.When a language implements late binding, there must be some mechanism todetermine the type of the object at runtime and call the appropriate memberfunction.(遲繫結發生在執行時,不同型別的物件繫結不同函式。實現遲繫結,必須有某種機制確定物件的具體型別然後呼叫合適的成員函式)。
4)虛擬函式表(VTable):一個儲存於常量區的函式指標表,類似函式指標陣列。每個含有虛擬函式的類(基類及派生類)各自包含一張虛擬函式表(一個類一張表),表中依次存放虛擬函式地址。派生類vtable繼承它各個基類的vtable,這裡繼承是指:基類vtable中包含某item,派生類vtable中也將包含同樣item,但值可能不同。如派生類(override)重新實現了某虛擬函式,則它的vtable中該項item指向新寫的虛擬函式,若未重新實現則沿用基類vtable中對應項的值。
5) 指向虛擬函式表的指標(vtptr):所有包含虛擬函式的類所例項化的物件裡,都包含該指標,執行時物件藉助於它定址到虛擬函式表,從而完成後繫結。因此每個包含虛擬函式的物件,相比普通物件會額外多佔用一個指標型的儲存空間。
6) override,覆蓋:派生類中使用同名同參的函式,重新定義基類某virtual函式的過程。其背後,編譯器用指向派生類新函式的指標覆蓋VTable中原來預設指向基類同名虛擬函式的指標。
魔術與揭祕
class Base //基類,包含virtual函式
{
public:
virtual void output(){ cout << "Base::output()" << endl;}
};
//派生兩個類Drv0和Drv1
class Drv0 : public Base
{
public:
void output () { cout << "Drv0:: output ()" << endl;}
};
class Drv1 : public Base
{
public:
void output () { cout << "Drv1:: output ()" << endl;}
};
void main()
{
Base b;
Base* pb = &b;
pb-> output (); //輸出Base::output()
Drv0 d1;
pb =reinterpret_cast<Base*>(&d1);
pb-> output (); //輸出Drv0:: output()
Drv0 d2;
pb =reinterpret_cast<Base*>(&d2);
pb-> output (); //輸出Drv0:: output()
Drv1 d3;
pb = reinterpret_cast<Base*>(&d3);
pb-> output (); //輸出Drv1:: output() }
經過一些中間封裝變換,最終同樣的”pb-> output ()”執行時選擇了不同函式,得到不同結果。奇妙的魔術?別急,下面用C結構體實現類似功能(引入C中不存在的::,故下面為偽碼):
typedef void (*pvfun)();
const pvfun pf_Base[2]= {Base::default,Base::output};
const pvfun pf_Drv0[2]= {Base::default, Drv0::output}; // ④
const pvfun pf_Drv1[2]= {Base::default, Drv1::output}; // ⑤
typedef struct BASE
{
void *vtptr;
int mBase;
}Base;
void Base::default() { printf("Base::default()"); }
void Base::output() { printf("Base::output()"); }
typedef struct DRV0
{
void *vtptr;
int mBase;
int mDrv0
}Drv0;
void Drv0::output() { printf("Drv0::output()"); }
typedef struct DRV1
{
void *vtptr;
int mBase;
int mDrv1
}Drv1;
void Drv1::output() { printf("Drv1::output()"); }
void main()
{
Base b;
b.vtptr =pf_Base ①
Base* pb =&b; //
*((pvfun)(pb->vtptr+0))(); //呼叫Base_default()
*((pvfun)(pb->vtptr+sizeof(pvfun)))(); //呼叫Base_output() ⑥
Drv0 d1;
d1.vtptr =pf_Drv0 ②
pb = (Base*)(&d1);
*((pvfun)(pb->vtptr+0))(); //沿用Base_default()
*((pvfun)(pb->vtptr+sizeof(pvfun)))(); //呼叫Drv0_output() ⑦
Drv0 d2;
d2.vtptr =pf_Drv0
pb = (Base*)(&d2);
*((pvfun)(pb->vtptr+0))(); //沿用Base_default()
*((pvfun)(pb->vtptr+sizeof(pvfun))() //呼叫Drv0_output () ⑧
Drv1 d3;
d3.vtptr =pf_Drv1 ③
pb = (Base*)(&d3);
*((pvfun)(pb->vtptr+0))() //沿用Base_default ()
*((pvfun)(pb->vtptr+sizeof(pvfun)))() //呼叫Drv1_output ()
}
上例同樣實現了用相同形式呼叫不同函式⑥⑦⑧,但這次能清楚看出貓膩所在:首先①②③處分別為結構體成員vtptr賦了不同值;其次pf_Drv0,pf_Drv1中第2個元素Base::output分別被新函式Drv0::output和Drv1::output覆蓋 ④⑤。魔術揭穿了,還記得麼:”所有軟體問題都可以通過增加一箇中間層解決”。表面的神奇是依靠VTable和vtptr組成的中間層在背後耍把戲。
例中pf_Base/pf_Drv0/pf_Drv1就是虛擬函式表VTable;各結構體的成員vtptr就是指向VTable的指標;①②③處把vtptr與各自struct對應的VTable關聯;Drv0和Drv1新定義同名函式(output)會覆蓋pf_Drv0和pf_Drv1中對應元素,如未新定義則沿用Base中元素Base::default 見④⑤。只不過這些在C++中都隱藏不可見,由編譯器自動生成和處理:
1)虛擬函式表與類關聯:編譯器在編譯時自動為每個包含虛擬函式的類及其派生類各自單獨生成一張虛擬函式表,用於存放虛擬函式指標。注意:基類與派生類各有各的虛表,獨立存放於不同地址,唯的一關聯是:派生類如果沒重新實現某基類虛擬函式,編譯器在其VTable對應條目中預設存放基類虛擬函式地址作為後備;如果派生類重新實現某虛擬函式,則編譯器在VTable中用新函式的地址代替預設的基類虛擬函式地址,這個過程即為上文的名詞--override覆蓋。
2)物件與虛擬函式表關聯:對包含虛擬函式的類,C++編譯器為其每個物件插入一個指標成員vtptr,指向該類的虛擬函式表,即同類物件的vtptr值相同。vtptr值在建構函式中初始化(編譯器自動加入),即使該類沒定義建構函式,預設建構函式也會初始化vtptr。
3)上面兩步說明物件例項化一完畢,就已經和具體虛擬函式實現掛鉤,呼叫時看似智慧的選擇不過是順藤摸瓜:
Drv0 d1; //這一步背後d1->vtptr= VTable(Drv0),其中VTable[0]=(*Drv0::output)()
pb =reinterpret_cast<Base*>(&d1); //編譯器支援指標強制向上型別轉換,把派生類物件的地址賦給基類指標,pb值仍是&d1
pb-> output(); //d1->vtptr[0](),即呼叫Drv0:: output ()
總結虛擬函式實現原理:
編譯期建立vtable表,設定表中元素;
執行期間在物件建立時的建構函式中關聯vtptr和vtable表;
藉助於指標支援的以小引大,通過強制轉換將派生類物件的地址賦給基類指標;
通過基類指標呼叫虛擬函式,先取得物件中的vtptr(obj->vtptr),再找到其所指的對應於特定父類或子類的虛擬函式表(VTable=*(vtptr)),然後表頭加偏移量定址到相應函式指標(vfunptr = VTable[offset]),最後執行*vfunptr()。
這就是C++通過虛擬函式實現多型的背後原理,多型使我們可統一用指向基類物件的指標呼叫所有基類/派生類的虛擬函式實現,到底會調哪個,關鍵看物件的vtptr指標指向了哪個類的VTable,而這點在物件例項化時會通過建構函式隱含設定好。
以一個問題結尾,可否在類的建構函式中呼叫虛擬函式,為什麼?