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