一篇文章帶你掌握C++虛函數(shù)的來龍去脈
一切從繼承講起
我們有一個基類 Animal。
有一個 Dog 類繼承了 Animal。
有一個 Fish 類也繼承了 Animal。
一切從上面的小例子開始講起。
假設(shè) Animal 有一個成員函數(shù) print,可以打印自己是什么物種,在 Animal類中,可以這么寫:
class Animal { public: void print() { std::cout << "我是 Animal" << std::endl; } }; class Dog: public Animal{}; class Fish: public Animal{};
上面代碼里Dog和Fish沒有任何新的改動,僅僅繼承了Animal而已。所以當(dāng)實例化Dog或者Fish的時候,將生成的對象調(diào)用print函數(shù),只能顯示出"我是 Animal"。
Dog d; d.print(); // 打印 我是 Animal Fish f; f.print(); // 打印 我是 Animal
這樣不好,我們想要更精確的打印物種信息,所以我們在子類中重定義print函數(shù):
class Dog: public Animal { public: void print() { std::cout << "我是 Dog" << std::endl; } } class Fish: public Animal { public: void print() { std::cout << "我是 Fish" << std::endl; } }
這樣的話,Dog類和Fish類的變量調(diào)用print函數(shù)的時候,就會打印相應(yīng)的信息了:
Dog d; d.print(); // 打印 我是 Dog Fish f; f.print(); // 打印 我是 Fish
到目前為止,一切都是順理成章。
繼承的語義是什么
請思考一下這個問題:Dog 和 Animal 之間是什么關(guān)系?
在C++里,Dog繼承自Animal,我們就說,Dog就是Animal。
就是說,子類就是父類。
不是誰包含誰的關(guān)系。
這很重要,但是還是需要進(jìn)一步分析,【子類就是父類】這種關(guān)系到底在哪里能體現(xiàn)出來。
舉一個例子,我們有一個函數(shù),參數(shù)是 Animal*
, 如下:
void foo(Animal* a) { }
由于C++是一個強(qiáng)類型系統(tǒng),大部分語法都是來限制類型的。所以我們經(jīng)??梢詮暮瘮?shù)傳參來試圖理解一些比較難理解的概念,比如說【子類就是父類】這個概念。
Animal a; Dog d; foo(&a); // 這個天經(jīng)地義,完美匹配類型系統(tǒng) foo(&d); // ????? 這個行不行呢
上面代碼最后一句到底行不行?
根據(jù)【子類就是父類】 -> 【Dog 就是 Animal】。答案很明顯,行!
我們再來看一個例子:
Animal a; Animal* pa {&a}; // 依然天經(jīng)地義 Dog d; Animal* pd {&d}; // 依然?????
上面的代碼不是函數(shù)傳參,卻與函數(shù)傳參無二,花括號里需要填一個東西,來匹配前面的類型聲明。
很明顯,&d
的類型是Dog*
類型,完全可以當(dāng)做Animal*
來使用。
小總結(jié),【子類就是父類】這個東西,在實踐里,就是說,當(dāng)我們需要一個【父類指針】的變量的時候,我們完全可以把一個【子類指針變量】丟進(jìn)去。
上面的總結(jié)不僅僅對于指針來說,對于引用也是同樣的。畢竟C++里,引用本身的概念與指針類似。
這里給個例子:
Dog d; Animal& rvvxyksv9kd; // 完全可以
這是為什么呢,為什么可以這么做呢?
這是因為,子類對象的內(nèi)存里,確實包含了完整的基類對象。
注意,對象之間的關(guān)系可以說包含與被包含了。
std::vector
我們在使用std::vector
的時候,只能存儲同種類型的變量,比如說,我們要存的是Animal*
類型的變量,根據(jù)上面的說法,我們不僅僅能存Animal對象
的指針,也可以存Dog對象 或者 Fish對象
的指針。
這就給我們的代碼帶來了便利,一個std::vector
可以來存儲所有Animal
子類的指針了。
否則,我們需要給每一個子類聲明一個std::vector
變量。
接著往下說,我們考慮下面的例子:
std::vector<Animal*> list; Animal a; Dog d; Fish f; list.push_back(&a); list.push_back(&d); list.push_back(&f); for (auto e : list) { e->print(); }
我們知道,這三個類,都有自己定義的print
函數(shù),那么這個for循環(huán)執(zhí)行的時候,到底怎么打印呢?
我是 Animal
我是 Animal
我是 Animal
這種結(jié)果是出乎意料,還是不出所料呢,不同的人有不同的見解。
這里應(yīng)該是不出所料的,因為,c++是一個靜態(tài)類型的語言,大部分特性都是靜態(tài)的,所謂靜態(tài),就是編譯的時候就能確定一些事情,比如說,調(diào)用哪個函數(shù)。
由于e
的類型是Animal*
, 所以在編譯的時候,就已經(jīng)確定好了,for循環(huán)
里的print
是Animal::print
。這就是所謂靜態(tài)。
我們發(fā)現(xiàn),這個std::vector
確實能存儲Animal對象指針
、 Dog對象指針
、 Fish對象指針
, 但好像一旦存儲進(jìn)去了,就無法區(qū)分,誰是誰了。
這怎么行,有一些行為,確實在子類里覆蓋了,比如說print
的行為。
如何讓靜態(tài)的c++編譯器生成一些看起來動態(tài)的機(jī)器碼呢,比如說,上面的循環(huán)里,能夠調(diào)用各自類里面重新定義的print函數(shù),而不簡單粗暴的直接使用Animal::print
呢?
虛函數(shù)登場
虛函數(shù)定義
虛函數(shù)是一種特殊的類成員函數(shù), 這種函數(shù)在編譯器,無法確定真正的函數(shù)地址在哪里,所以稱之為虛函數(shù)。
程序運(yùn)行的時候,根據(jù)具體的對象是什么,就調(diào)用什么相應(yīng)的版本。
用嚴(yán)格一點的話來說:調(diào)用該虛函數(shù)出現(xiàn)的那個類和當(dāng)前對象的類,這兩個類之間,最靠下的那個版本的函數(shù)。
如何讓一個普通成員函數(shù)成為一個虛函數(shù)呢,在聲明的時候,前面加上virtual
就行了。
話太繞了,我們來看例子:
class L1 { }; class L2: public L1 { public: virtual void print() { std::cout << "L2" << std::endl; } }; class L3: public L2 { } class L4: public L3 { public: virtual void print() { std::cout << "L4" << std::endl; } } /// void test() { L4 l4; L1* pL1 {&l4}; pL1->print(); // 1. 打印什么 L2* pL2 {&l4}; pL2->print(); // 2. 打印什么 L3 l3; pL2 = &l3; pL2->print(); // 3. 打印什么 }
我們來看上面的三個問題.
問題1:
pL1->print();
。這句話其實很簡單,壓根就不能編譯,因為pL1的類型是L1*
, 而L1類里面根本就沒有print
函數(shù)。問題2:
pL2->print();
。L2*
的身子裝了L4指針,這就很明顯了,L2和L4之間,最靠下的print,出現(xiàn)在L4
中,所以這里應(yīng)該打印L4
。問題3:
pL2->print();
。L2*
的身子裝了L3指針,根據(jù)我們的說法,也是很明顯的,L2
和L3
之間,最靠下的,還是L2,所以這里應(yīng)該打印L2
。
通過這三個小問題,應(yīng)該稍微了解虛函數(shù)到底調(diào)用哪一個的問題了。
子類中如何改變一個虛函數(shù)的行為
如果想要在子類中改變一個虛函數(shù)的行為,那么就必須嚴(yán)格按照基類中該虛函數(shù)的函數(shù)簽名,重新實現(xiàn)這個虛函數(shù):
class A { public: virtual void print(){} } class B: public A { public: virtual void print(int a){} }
來看看上面的子類B中,我們給print加了一個參數(shù),此時B中的print還是A中的那個print嗎?
答案是否定的,
- 首先這個代碼是能編譯過的
- 只不過,
B::print
和A::print
壓根就沒啥聯(lián)系,在具體的搜索虛函數(shù)進(jìn)行調(diào)用的時候,他們被看做完全不同的兩個函數(shù)。
再來看看虛函數(shù)的返回值類型所帶來的問題:
class A { public: virtual void print(){} } class B: public A { public: virtual int print(){return 0;} }
問,此時B::print
還是A::print
嗎?
答案,是的。。。。只不過,這個直接編譯不過。
編譯不過是好的,為什么,因為在編譯的時候,就告訴你錯在哪了。
上面那個由于疏忽或者別的原因,給原本的虛函數(shù)多加了一個參數(shù),這種才可怕呢,因為編譯通過了。
那怎么防范生成了一個新的函數(shù)?
override 限定符
如果在子類里面,我們確定要重新實現(xiàn)一個虛函數(shù),那么我們就在函數(shù)簽名的后面加上這個override
限定符。
class A { public: virtual void print(){}; }; class B: public A { public: void print(int a) override {}; }
看上面代碼,B這個子類中print函數(shù)前面前面,我們?nèi)サ袅?code>virtual, 而在花括號前面加了override
。
此時,編譯器就報錯了,邏輯是這樣的:
- 編譯器看到
override
,它就認(rèn)為print是從基類繼承而來的一個虛函數(shù),所以它去看看A::print
, 發(fā)現(xiàn)這個函數(shù)沒有參數(shù)。 - 回過頭來,發(fā)現(xiàn)
B::print(int)
帶了一個參數(shù),編譯器直接報錯。
這就讓錯誤盡早出現(xiàn)在編譯時期,棒!
final 限定符
可能會有這么一種情況,有一個類A
,里面有一個虛函數(shù)print
,你寫了一個類B
,繼承了類A
,然后override
了這個print函數(shù)
。然后別人寫了一個類C
繼承了類B
,你不想類C
擁有override
這個print函數(shù)
的權(quán)限。
此時,在類B
中,override print 函數(shù)的地方,可以加一個final
:
class A { public: virtual void print(){}; } class B: public A { public: void print() override final {}; // 注意看,加了final } class C: public B { public: void print() override {}; // 編譯報錯 }
上面的代碼演示了,class C
中無法繼續(xù)override print
的寫法。
還有一種極端的情況,你寫了一個類A
,你壓根就不想別人去繼承這個類A
:
class A final { }; class B: public A // oh, 直接報錯 { };
加了final之后,就可以阻止別的類來繼承了。
covariant 返回類型
上面講過,一個虛函數(shù),想要在子類里override,那么函數(shù)簽名必須一模一樣,包括返回值類型。但是有一種特殊的情況,需要考慮。看下面的例子
class A { public: void print() { std::cout << "This is A" << std::endl; } }; class B: public A { public: void print() { std::cout << "This is B" << std::endl; } }; class L1 { public: virtual A* get() { return new A{}; } }; class L2: public L1 { public: B* get() override { return new B{}; } };
我們先注意到,B和A就是兩個普通的有繼承關(guān)系的類,里面并沒有出現(xiàn)virtual函數(shù)。
真正要研究的是L2和L1,get 函數(shù)
是一個virtual函數(shù),但是L2里get
返回值類型是B*
。
這似乎違反了virtual函數(shù)的規(guī)定,那就是函數(shù)簽名必須一致。
但是又能說的通:【子類就是父類】。
所以上面的代碼能編譯過嗎?
答案是能。這種特殊的情況被稱之為covariant 返回類型
,有的地方翻譯成協(xié)變返回類型。
接著看如下的代碼:
void test() { L2 l2; l2.get()->print(); // 問題1,這里打印什么? L1& rl1{l2}; rl1.get()->print(); // 問題2,這里打印什么? }
- 問題1:這個地方不難,就是打印
This is B
。 - 問題2:我們來慢慢分析,rl1 聲明的類型是 L1& ,但是引用了一個子類對象l2。此時
rl2.get()
是遵循虛函數(shù)的調(diào)用邏輯,也就是肯定調(diào)用的是L2::get
。L2::get
的返回類型是什么,是B*
,所以直接得出結(jié)果應(yīng)該是B::print
, 打印This is B
。
不好意思,問題2的結(jié)論是錯的。
虛函數(shù)不會改變原本的函數(shù)返回類型,在L1這個基類中,返回類型就是A*
,即使調(diào)用了L2::get
,仍然返回了A*
這個類型,如果你有IDE,你可以將鼠標(biāo)懸停在
rl1.get()
->print();
get
這個地方,會顯示出,返回類型是A*
, 于是乎,最后的print其實是A::print
, 所以打印了
This is A
。
virtual destructor 虛析構(gòu)函數(shù)
在大部分時候,我們都無需為自定的class
提供一個析構(gòu)函數(shù), 因為大部分時候自定義的class
里面不包含需要釋放的資源,比如說內(nèi)存,文件等等。此時c++會提供一個默認(rèn)的析構(gòu)函數(shù)。
但是,如果我們的class
里有這種動態(tài)的資源,那么就不得不提供一個自定義的析構(gòu)函數(shù),來針對這些動態(tài)資源進(jìn)行釋放。
更進(jìn)一步的是,如果一個擁有動態(tài)資源的class
同時繼承了別的class
,此時最好小心一點:
這是啥意思, 來看例子:
class L1 { public: ~L1() { std::cout << "L1 正在析構(gòu)" << std::endl; } }; class L2: public L1 { int* resource; public: L2():resource{new int} { } ~L2() { delete resource; } }; void test() { L2* l2{new L2}; L1* pl1{l2}; delete pl1; }
分析以上代碼,pl1 指向了一個子類L2的對象,在delete pl1的時候,編譯器發(fā)現(xiàn),L1 的析構(gòu)函數(shù)是正常函數(shù),所以編譯器在這里指定決定調(diào)用L1::~L1
這個函數(shù),然后就結(jié)束了。
我們會發(fā)現(xiàn),L2 的析構(gòu)函數(shù)并沒有被調(diào)用到,也就是說, resource 所指向的資源沒有被回收?。?!
怎么辦呢,將 L1 中的析構(gòu)函數(shù)標(biāo)記成virtual
:
class L1 { public: virtual ~L1(){}; }
這樣才能保證,任何繼承自L1的類中的動態(tài)資源被回收。
結(jié)論:如果寫了一個類,這個類有可能被別的類繼承的話,那么最好將這個類的析構(gòu)函數(shù)標(biāo)記成virtual
的:
class A { public: virtual ~A() = default; }
關(guān)于這一點,有很多大師級人物都討論過,不同的人有不同的看法,不過,上面的結(jié)論還是穩(wěn)妥的,雖然有一點性能消耗。
虛函數(shù)如何實現(xiàn)的
為什么要有這個疑問,難道這種實現(xiàn)不正常嗎?
不正常,非常不正常,C++是一個靜態(tài)語言,必須先編譯再運(yùn)行,執(zhí)行什么函數(shù),一定是編譯時就決定好的。
而虛函數(shù)打破了這種既有的規(guī)則,而這種規(guī)則的打破依賴于函數(shù)指針。
下面來講講虛函數(shù)這一套邏輯到底是怎么跑起來的。
函數(shù)指針
這是一種指針,這個指針指向的是一塊代碼,用這個指針可以進(jìn)行函數(shù)調(diào)用:
void print_v1() { std::cout << "print_v1" <<std::endl; } void print_v2() { std::cout << "print_v2" <<std::endl; } void test() { auto f {print_v1}; f(); // 打印 print_v1 f = print_v2; f(); // 打印 print_v2 }
觀察上面的代碼,發(fā)現(xiàn),兩個f()調(diào)用了不同的函數(shù),這是一種動態(tài)行為。也就是說,程序運(yùn)行的時候,根據(jù)f本身的指向,才能決定真正調(diào)用哪一塊代碼。
虛函數(shù)表
有了函數(shù)指針,使得動態(tài)行為有了可能,剩下的就是奇思妙想,讓虛函數(shù)邏輯跑起來。
大部分編譯器采用了所謂虛函數(shù)表的東西來實現(xiàn)虛函數(shù)邏輯。
這種東西文字描述不清,直接看例子:
class L1 { public: virtual void func1() { } virtual void func2() { } }; class Sub1: public L1 { public: void func1() override { } }; class Sub2: public L1 { public: void func2() override {} };
先描述一下,上面有三個class,L1是一個基類,里面有兩個virtual 函數(shù):
- func1
- func2
然后
- Sub1繼承了L1, 然后override了 func1
- Sub2繼承了L1, 然后override了 func2
此時,先來考慮一個小問題,sizeof 三個 class,應(yīng)該是多大呢,假如是64bit
機(jī)器。
答案是都是占8字節(jié),也就是64bit。
那么這8字節(jié)存了啥東西?
答案就是,這8字節(jié)其實是一個指針,指向哪,先不說,一會再來說明。
虛函數(shù)表的概念
對于上面的例子來說,編譯器生成了三個虛函數(shù)表,也就是L1、Sub1、Sub2每個class,各一個。
注意這個虛函數(shù)表是每個class一個,而不是每個對象一個,一定要搞明白。
這很類似于 class 里的靜態(tài)成員,這么說就好理解了。
那虛函數(shù)表長啥樣?
其實虛函數(shù)表就是一個數(shù)組,數(shù)組里的每一項就是一個簡單的函數(shù)指針。
我們來畫一畫上面例子的虛函數(shù)表:
在右邊的代碼段里,我們可以看見,一共有四個不同的函數(shù),這與我們的代碼是一致的。
再來看左邊的虛函數(shù)表,可以清晰的看出來,每個類里的兩個虛函數(shù)都真實地指向了正確的版本。
光有這個虛函數(shù)表,是沒用的,在調(diào)用虛函數(shù)的地方,必須與這個虛函數(shù)表聯(lián)系起來。
還記得剛才說的那個8字節(jié)的指針嗎。
那個指針就是起到這種關(guān)聯(lián)的。
我們看下面的例子:
void test() { L2 l2; L1* p{&l2}; p->func1(); }
我們畫出上面的整個關(guān)系圖:
此時用 p->func1() 的時候,為什么會調(diào)用到Sub1::func1
就一目了然了,一直跟著指針往下走就明白了!
vtable指針
我們將上面的那個8字節(jié)指針稱做vtable指針,它的作用就是來指向相應(yīng)的class的虛函數(shù)表的。
一般而言,這個變量是在基類里聲明的,子類是繼承了這個變量。
在對象初始化的時候,這個指針會指向真正的本class
的虛函數(shù)表。
比如說
- Sub1對象里的vtable就會指向Sub1的虛函數(shù)表
- Sub2對象里的vtable就會指向Sub2的虛函數(shù)表
虛函數(shù)的消耗
我們從上面的實現(xiàn)可以看出,在使用虛函數(shù)的class里,強(qiáng)行塞入了一個vtable指針,占了8字節(jié),這無疑會增加內(nèi)存的消耗。
其次,調(diào)用虛函數(shù)的時候,需要三步走。
- 從vtable找到虛函數(shù)表
- 從虛函數(shù)表找到真正的函數(shù)指針
- 然后由函數(shù)指針找到函數(shù),進(jìn)行調(diào)用
而一般的函數(shù)只有最后一步,這無疑也是增加了一些步驟的,不過這種消耗不怎么明顯,所以該用虛函數(shù),還是盡量用吧,不要有什么心理負(fù)擔(dān),然后搞什么靜多態(tài)。
對了,我們把整個虛函數(shù)所進(jìn)行的行為稱之為多態(tài),這是一種動態(tài)多態(tài),因為這是運(yùn)行時的行為。
至于什么叫靜多態(tài),那就不屬于本文所討論的了。
總結(jié)
到此這篇關(guān)于C++虛函數(shù)的文章就介紹到這了,更多相關(guān)掌握C++虛函數(shù)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
c++ 入門——淺析構(gòu)造函數(shù)和析構(gòu)函數(shù)
這篇文章主要介紹了c++ 淺析構(gòu)造函數(shù)和析構(gòu)函數(shù)的相關(guān)資料,幫助大家入門c++ 編程,感興趣的朋友可以了解下2020-08-08