簡介
虛函數(shù)是C++中用于實現(xiàn)多態(tài)(polymorphism)的機制。核心理念就是通過基類訪問派生類的函數(shù)。
例如下面的兩個類:
class Base {
public:
virtual void func() { std::cout << "Base::func() is called" << std::endl;};
};
class Derive : public Base {
public:
virtual void func() { std::cout << "Derive::func() is called" << std::endl;};
};
使用時:
Base* base = new Derive();
base->func(); //base雖然是指向Base的指針,但是被調(diào)用的函數(shù)func()是A的函數(shù)
此處輸出的內(nèi)容為:Derive::func() is called。這只是一個簡單的虛函數(shù)的例子,從這里能大概看出虛函數(shù)的一些樣子。此處也需要知道一點,創(chuàng)建一個基類的實例可以從派生類中new創(chuàng)建出來,但是派生類的實例不能來源于new一個基類,這也是面向?qū)ο蟮囊恍┰瓌t。另外一個方面,虛函數(shù)只能借助于指針或者引用來達(dá)到多態(tài)的效果,例如下面這樣的代碼,雖然都是虛函數(shù),但它卻不是多態(tài):
void f(void) {
Base b;
b.func(); // 此處輸出:"Base::func() is called"
}
多態(tài)
1.多態(tài)有什么用
C++中的虛函數(shù)的作用主要是實現(xiàn)了多態(tài)的機制。關(guān)于多態(tài),簡而言之就是用父類型的指針指向其子類的實例,然后通過父類的指針調(diào)用實際子類的成員函數(shù)。這種技術(shù)可以讓父類的指針有“多種形態(tài)”,這是一種泛型技術(shù)。所謂泛型技術(shù),說白了就是試圖使用不變的代碼來實現(xiàn)可變的算法。比如:模板技術(shù),RTTI技術(shù),虛函數(shù)技術(shù),要么是試圖做到在編譯時決議,要么試圖做到運行時決議。例如:
void f(Base* b)
{
b->func(); // 被調(diào)用的是Derive::func() 還是Base::func()?
}
因為func()是一個虛函數(shù),只通過這一段代碼是無法判斷調(diào)用的是哪一個類的函數(shù)。從面向?qū)ο蟮牡弥?,函?shù)傳入的實例可能是Base基類,也可能是派生類Derive,所以需要在編譯時是無法確定此函數(shù)的調(diào)用,這種同一代碼可以產(chǎn)生不同效果的特點,被稱為“多態(tài)”。
動態(tài)聯(lián)編
虛函數(shù)實際上是如何被編譯器處理的呢?Lippman在深度探索C++對象模型中的不同章節(jié)講到了幾種方式,這里把“標(biāo)準(zhǔn)的”方式簡單介紹一下。
我所說的“標(biāo)準(zhǔn)”方式,也就是所謂的VTABLE(虛函數(shù)表)機制。編譯器發(fā)現(xiàn)一個類中有被聲明為virtual的函數(shù),就會為其創(chuàng)建一個虛函數(shù)表,也就是 VTABLE。VTABLE實際上是一個函數(shù)指針的數(shù)組,每個虛函數(shù)的指針占用這個數(shù)組的一個slot。一個類只有一個VTABLE,不管它有多少個實例。派生 類有自己的VTABLE,但是派生類的VTABLE與基類的VTABLE有相同的函數(shù)排列順序,同名的虛函數(shù)被放在兩個數(shù)組的相同位置上。后面將會詳細(xì)講虛函數(shù)表。
overload和override
- overload(重載)
overload是指一個與已有函數(shù)同名但是參數(shù)表不同的函數(shù)。例如一個函數(shù)即可以接受整型數(shù)作為參數(shù),也可以接受浮點數(shù)作為參數(shù),還可以接受string等,這樣讓編譯器來判斷使用哪一個函數(shù)來達(dá)到效率最優(yōu)。 - override(覆蓋)
虛函數(shù)總是在派生類中被改寫,這種改寫被稱為“override”,翻譯成覆蓋貌似比較多。override是指派生類重寫基類的虛函數(shù),就象我們前面Derive類中重寫了Base類中的func()函數(shù)。重寫的函數(shù)必須有一致的參數(shù)表和返回值,C++標(biāo)準(zhǔn)允許 返回值不同的情況,但是很少編譯器支持這個feature。目前我也沒有使用過這種特性,這會讓我的代碼變的混亂,建議大家最好也不要用到這個特性。
虛函數(shù)語法
virtual關(guān)鍵字
class Base
{
public:
virtual void func();
};
class Derive: public Base
{
public:
void func(); // 沒有virtual關(guān)鍵字
};
class Derive: public Derive // 從Derive繼承,不是從Base繼承
{
public:
void func(); // 也沒有virtual關(guān)鍵字
};
例如上面的代碼,Derive::func()是虛函數(shù),Derive::func()也同樣是虛函數(shù)?;惵暶鞯奶摵瘮?shù),在派生類中也是虛函數(shù),不管是否使用virtual關(guān)鍵字。但是通常為了代碼簡潔易懂,最好在派生類中也添加上virtual關(guān)鍵字,那么一眼就知道是虛函數(shù)。
純虛函數(shù)
class Base
{
public:
virtual void func()=0; // =0標(biāo)志一個虛函數(shù)為純虛函數(shù)
};
一個函數(shù)聲明為純虛后,純虛函數(shù)的意思是:我是一個抽象類,不可以把我實例化。純虛函數(shù)用來規(guī)范派生類的行為,實際上就是所謂的“接口”,在java中就是interface。它告訴使用者,我的派生類都會有這個函數(shù),并且需要實現(xiàn)它,否則就是一個指向為空的指針,沒有可以執(zhí)行的函數(shù)。事實是怎樣的呢,如下:
class Derive : public Base {
public:
};
int main() {
Derive* derive = new Derive();
derive->func();
}
此代碼的在gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)下編譯,事實結(jié)果如下:

編譯都沒有通過,所以虛函數(shù)是必須要實現(xiàn)。
虛析構(gòu)函數(shù)
首先我們看如下的代碼:
class Base {
public:
Base(){};
~Base(){std::cout << "Base::~Base() is called!" << std::endl;}
};
class Derive : public Base {
public:
Derive(){};
~Derive(){std::cout << "Derive::~Derive() is called!" << std::endl;}
};
int main(void) {
Base* base = new Derive;
delete base;
return 0;
}
結(jié)果可能跟你想的不一樣,只有Base類的析鉤函數(shù)被調(diào)用了。

所以想要達(dá)到你想要的效果,需要把基類的析構(gòu)函數(shù)加上關(guān)鍵字virtual。結(jié)果下圖:

所以我們在定義析構(gòu)函數(shù)時,需要給他加上virtual關(guān)鍵字,一般情況下不需要定義成純虛函數(shù)。只有在希望將一個類變成抽象類(不能實例化的類),而這個類又沒有合適的函數(shù)可以被純虛化的時候,可以使用純虛的析構(gòu)函數(shù)。另外一點如果上面的代碼,Base類中虛函數(shù)甚至都沒有定義,main函數(shù)中delete base時,什么都不會發(fā)生,也不會調(diào)用派生類A的析構(gòu)函數(shù)。
虛函數(shù)表
虛函數(shù)表
使用C++的人都應(yīng)該知道虛函數(shù)Virtual Function,它是通過一張?zhí)摵瘮?shù)表Virtual Table來實現(xiàn),簡稱為V-Table,前文所說的標(biāo)準(zhǔn)方式,其他方式不討論。在這個表中,主是要一個類的虛函數(shù)的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應(yīng)實際類中的函數(shù)。這樣,在有虛函數(shù)的類的實例中這個表被分配在了這個實例的內(nèi)存中。所以,當(dāng)我們用父類的指針來操作一個子類的時候,這張?zhí)摵瘮?shù)表就顯得非常重要了,它就像一個地圖一樣,指明了實際所應(yīng)該調(diào)用的函數(shù)。
這里我們著重看一下這張?zhí)摵瘮?shù)表。C++的編譯器應(yīng)該是保證虛函數(shù)表的指針存在于對象實例中最前面的位置(這是為了保證取到虛函數(shù)表的有最高的性能,如果有多層繼承或是多重繼承的情況下)。 這意味著我們通過對象實例的地址得到這張?zhí)摵瘮?shù)表,然后就可以遍歷其中函數(shù)指針,并調(diào)用相應(yīng)的函數(shù)。如下代碼通過實例地址來獲取,編譯環(huán)境(gcc version 6.5.0 (MacPorts gcc6 6.5.0_1)):
class Base {
public:
virtual void func1() { std::cout << "Base::func1" << std::endl; }
virtual void func2() { std::cout << "Base::func2" << std::endl; }
virtual void func3() { std::cout << "Base::func3" << std::endl; }
};
typedef void (*Func)(void);
int main(int argc, char** argv){
Base b;
Func func = nullptr; //c++11的空指針
std::cout << "vtable pointer address:"<< (long*)(&b) << std::endl;
std::cout << "vtable address:"<< *(long*)(&b) << std::endl;
std::cout << "vtable - first function pointer address:"<< (long*)*(long*)(&b) << std::endl;
std::cout << "vtable - first function address:"<< *(long*)*(long*)(&b) << std::endl;
func = (Func)*((long*)*(long*)(&b)); // Invoke the first virtual function
func();
return 0;
}
int main(int argc, char** argv) {
Base b;
Func func1 = nullptr;
Func func2 = nullptr;
Func func3 = nullptr;
func1 = (Func)*((long*)*(long*)(&b) + 0);
func2 = (Func)*((long*)*(long*)(&b) + 1);
func3 = (Func)*((long*)*(long*)(&b) + 2);
func1();
func2();
func3();
}
上面兩段代碼的運行結(jié)果如下圖,圖1輸出依次是虛函數(shù)表的指針地址,對指針地址取值后是虛函數(shù)表的地址,然后是第一個虛函數(shù)的指針地址,取值后為虛函數(shù)的地址。類型轉(zhuǎn)化后,運行就知,指向的是第一個虛函數(shù)。通過后面對地址的增加可以取到第二個,第三個虛函數(shù)。虛函數(shù)是按照申明時的順序排列,對地址按順序加,就可以按順序得到虛函數(shù),如圖2。


內(nèi)存中的結(jié)構(gòu)
如果看了上面還是有些不懂,下面畫出大概的內(nèi)存中的示例圖來表示:
-
一般繼承 - 無虛函數(shù)覆蓋
覆蓋父類的虛函數(shù)是很顯然的事情,不然,虛函數(shù)就變得毫無意義。如下的示意圖:
image.png
在這個繼承關(guān)系中,子類沒有覆蓋或者繼承任何的父類函數(shù),那么對于一個實例Derive d;,虛函數(shù)表示意如下:
image.png
我們可以看到下面兩點:- 虛函數(shù)按照其聲明順序放于表中。
- 父類的虛函數(shù)在子類的虛函數(shù)前面。
-
一般繼承 - 有虛函數(shù)覆蓋
覆蓋父類的虛函數(shù)是很顯然的事情,不然,虛函數(shù)就變得毫無意義。子類中有虛函數(shù)重載了父類的虛函數(shù):
image.png

我們從表中可以看到下面兩點:
- 覆蓋的
f()函數(shù)被放到了虛表中原來父類虛函數(shù)的位置 - 沒有被覆蓋的函數(shù)依舊
-
多重繼承 - 無虛函數(shù)覆蓋
子類并沒有覆蓋父類的函數(shù):
image.png

我們可以看到下面兩點:
- 每個父類都有自己的虛表
- 子類的成員函數(shù)被放到了第一個父類的表中(第一個父類是按照聲明順序來判斷)
這樣做就是為了解決不同的父類類型的指針指向同一個子類實例,而能夠調(diào)用到實際的函數(shù)。
-
多重繼承 - 有虛函數(shù)覆蓋
虛函數(shù)覆蓋的情況:
image.png

我們可以看見,兩個父類虛函數(shù)表中的
f()的位置被替換成了子類的函數(shù)指針。這樣,我們就可以任一靜態(tài)類型的父類來指向子類,并調(diào)用子類的f()。如下的示例:
int main(int argc, char ** argv) {
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
}
安全性
我們知道,子類沒有重載父類的虛函數(shù)是一件毫無意義的事情。因為多態(tài)也是要基于函數(shù)重載。任何妄圖使用父類指針想調(diào)用子類中的未覆蓋父類的成員函數(shù)的行為都會被編譯器視為非法,所以,這樣的程序根本無法編譯通過。但在運行時,我們可以通過指針的方式訪問虛函數(shù)表來達(dá)到違反C++語義的行為。如下代碼:
class Base {
public:
virtual void func1() { std::cout << "Base::func1() is called!" << std::endl; }
virtual void func2() { std::cout << "Base::func2() is called!" << std::endl; }
};
class Derive : public Base {
public:
virtual void func1() { std::cout << "Derive::func1() is called!" << std::endl; }
virtual void func3() { std::cout << "Derive::func3() is called!" << std::endl; }
};
Base* b = new Derive();
b->func3(); //編譯失敗
Derive* d = new Derive();
d->func2(); //輸出 Base::func2() is called!
Base* b = new Derive();
b->func1(); //輸出 Derive::func1() is called!
第二種情況是訪問non-public的虛函數(shù)。如果父類的虛函數(shù)是private或protected,但這些非public的虛函數(shù)同樣會存在于虛函數(shù)表中,所以,我們同樣可以使用訪問虛函數(shù)表的方式來訪問這些non-public的虛函數(shù),這很容易做到。如下:
class Base {
private:
virtual void func1() { std::cout << "Base::func1() is called!" << std::endl; }
};
class Derive : public Base {
};
typedef void (*Func)(void);
int main(int argc, char** argv) {
Derive d;
Func func = (Func)*((long*)*(long*)(&d)+0);
func();
return 0;
}

這種不安全性不僅僅是在虛函數(shù)表會有,其他地方也會有。只要指針能訪問內(nèi)存,那么就可以做到許多意想不到的事,編寫代碼時需要自己注意這些,不是特別的需要還是不要通過的這樣的方式訪問。因為直接通過訪問內(nèi)存的方式不好理解,同時也容易出錯。但是拋開來說訪問內(nèi)存也是
C/C++語言的魅力所在!




