淺談C++虛擬函式機制
0.前言
在後端面試中語言特性的掌握直接決定面試成敗,C++語言一直在增加很多新特性來提高使用者的便利性,但是每種特性都有複雜的背後實現,充分理解實現原理和設計原因,才能更好地掌握這種新特性。
只要出發總會達到,只有出發才會到達,焦慮沒用,學就完了,今天一起來學習C++的虛擬函式考點吧。
通過本文你將瞭解的以下內容:
- C++多型機制
- 虛擬函式的基本使用
- 虛擬函式的底層實現
- 純虛擬函式和抽象類
- 虛解構函式
- 虛擬函式的優缺點
1.C++多型機制
- 多型機制簡介
C++面向物件的三大特徵:
- 多型(Polymorphism)
- 封裝(Encapsulation)
- 繼承(Inheritance)
從字面上理解多型就是多種形態,具體如何多種形態,多型和繼承的關係非常密切,試想下面的場景:
- 派生類繼承使用基類提供的方法,不需更改
- 同一個方法在基類和派生類的行為是不同的,具體行為取決於呼叫物件。
後者就是C++的多型需求場景,即同一方法的行為隨呼叫者上下文而異,舉個現實生活中類似的栗子,來加深理解:
基類Woker包括三個方法:打卡、午休、幹活。
派生類包括產品經理PMer、研發工程師RDer、測試工程師Tester,派生類從基類Worker中繼承了打卡、午休、幹活三個方法。
打卡和午休對三個派生類來說是一樣的,因此可以直接呼叫基類的方法即可。
但是每個派生類中幹活這個方法具體的實現並不一樣:產品經理提需求、研發寫程式碼、測試找Bug。
計算機程式的出現就是為了解決現實中的問題,從上面的例子可以看到,這種同一方法的行為隨呼叫者而異的需求很普遍,然而多型的設計原因只有C++之父Bjarne Stroustrup大佬最清楚了。
- 靜態繫結和動態繫結
要充分理解多型,就要先說什麼是繫結?
繫結體現了函式呼叫和函式本身程式碼的關聯,也就是產生呼叫時如何找到提供呼叫的方法入口,這裡又引申出兩個概念:
- 靜態繫結:程式編譯過程中把函式呼叫與執行呼叫所需的程式碼相關聯,這種繫結發生在編譯期,程式未執行就已確定,也稱為前期繫結。
- 動態繫結:執行期間判斷所引用物件的實際型別來確定呼叫其相應的方法,這種發生於執行期,程式執行時才確定響應呼叫的方法,也稱為後期繫結。
- 靜態多型和動態多型
在C++泛型程式設計中可以基於模板template和過載override兩種形式來實現靜態多型。
動態多型主要依賴於虛擬函式機制來實現,不同的編譯器對虛擬函式機制的實現也有一些差異,本文主要介紹Linux環境下gcc/g++編譯器的實現方法。
多型本質上是一種泛型技術,說白了就是試圖使用不變的程式碼來實現可變的演算法,要麼試圖在編譯時決定,要麼試圖在執行時決定。csdn部落格專家-左耳朵耗子-陳皓
- 虛擬函式與三大特徵
虛擬函式為多型提供了基礎,並且藉助於繼承來發揮多型的優勢,從而完善了語言設計的封裝,可見虛擬函式與C++三大特徵之間有緊密的聯絡,是非常重要的特性。
2.虛擬函式的基本使用
- 虛擬函式使用例項
使用virtual關鍵字即可將成員函式標記為虛擬函式,派生類繼承基類的虛擬函式之後,可以重寫該成員函式,派生類中是否增加virtual關鍵字均可,程式碼舉例:
#include<iostream> using namespace std; class Worker{ public: virtual ~Worker(){} virtual void DoMyWork(){ cout<<"BaseWorker:I am base worker"<<endl; } }; class PMer:public Worker{ public: //virtual void DoMyWork(){ void DoMyWork(){ cout<<"ChildPMer:Tell rd demands"<<endl; } }; class RDer:public Worker{ public: //virtual void DoMyWork(){ void DoMyWork(){ cout<<"ChildRDer:Write code and solve bugs"<<endl; } }; class Tester:public Worker{ public: //virtual void DoMyWork(){ void DoMyWork(){ cout<<"ChildTester:Find bugs and inform rd"<<endl; } }; int main(){ //使用基類指標訪問派生類 Worker *ptr_pm = new PMer(); Worker *ptr_rd = new RDer(); Worker *ptr_ts = new Tester(); cout<<"#### use ptr #####"<<endl; ptr_pm->DoMyWork(); ptr_rd->DoMyWork(); ptr_ts->DoMyWork(); ptr_pm->Worker::DoMyWork(); cout<<"-----------------------------"<<endl; //使用基類引用訪問派生類 PMer pmins; RDer rdins; Tester tsins; Worker &ref_pm = pmins; Worker &ref_rd = rdins; Worker &ref_ts = tsins; cout<<"#### use ref #####"<<endl; ref_pm.DoMyWork(); ref_rd.DoMyWork(); ref_ts.DoMyWork(); ref_pm.Worker::DoMyWork(); }
編譯後,執行結果:
// 上述程式碼儲存在檔案virtual.cpp // g++編譯器執行編譯 g++ virtual.cpp -o virtual // 執行exe檔案 ./virtual //詳細輸出 #### use ptr ##### ChildPMer:Tell rd demands ChildRDer:Write code and solve bugs ChildTester:Find bugs and inform rd BaseWorker:I am base worker ----------------------------- #### use ref ##### ChildPMer:Tell rd demands ChildRDer:Write code and solve bugs ChildTester:Find bugs and inform rd BaseWorker:I am base worker
- 基類對派生類的訪問
通過基類的指標或引用指向派生類的例項,在面向物件程式設計中使用非常普遍,這樣就可以實現一種基類指標來訪問所有派生類,更加統一。這種做法的理論基礎是:一個派生類物件也是一個基類物件,可以將派生類物件看成基類物件,但是期間會發生隱式轉換。
- A *pA = new B;
- B b; A &rb=b;
class Base { ... }; class Derived: public Base { ... }; Derived d; Base *pb = &d; // implicitly convert Derived* => Base*
3.虛擬函式的底層實現
- 虛擬函式表和虛表指標
不同的編譯器對虛擬函式的實現方法不一樣,並且C++規範也並沒有規定如何實現虛擬函式,大部分的編譯器廠商使用虛表指標vptr和虛擬函式表vtbl來實現。
現代的C++編譯器對於每一個多型型別,其所有的虛擬函式的地址都以一個表V-Table的方式存放在一起,虛擬函式表的首地址儲存在每一個物件之中,稱為虛表指標vptr,這個虛指標一般位於物件的起始地址。通過虛指標和偏移量計算出虛擬函式的真實地址實現呼叫。
- 單繼承模式
單繼承就是派生類只有1個基類,派生類的虛擬函式表中包含了基類和派生類的全部虛擬函式,如果發生覆蓋則以派生類為準。
舉個栗子:
//dev:Linux 64bit g++ 4.8.5 #include <iostream> using namespace std; //定義函式指標型別 typedef void(*Func)(void); //包含虛擬函式的基類 class Base { public: virtual void f() {cout<<"base::f"<<endl;} virtual void g() {cout<<"base::g"<<endl;} virtual void h() {cout<<"base::h"<<endl;} }; //派生類 class Derive : public Base{ public: void g() {cout<<"derive::g"<<endl;} virtual void k() {cout<<"derive::k"<<endl;} }; int main () { //base類佔據空間大小 cout<<"size of Base: "<<sizeof(Base)<<endl; //基類指標指向派生類 Base b; Base *d = new Derive(); //派生類的首地址--虛表指標 long* pvptr = (long*)d; long* vptr = (long*)*pvptr; //從虛擬函式表依次獲取虛擬函式地址 Func f = (Func)vptr[0]; Func g = (Func)vptr[1]; Func h = (Func)vptr[2]; Func k = (Func)vptr[3]; f(); g(); h(); k(); return 0; }
特別注意,網上很多程式碼都是32位機器使用int*進行強轉,但是指標型別在32bit和64bit機器的大小不一樣,因此如果在64位機器執行32位的程式碼會出現第二個虛擬函式地址錯誤,產生coredump。
上述程式碼在Linux 64位機器 g++4.8.5版本下編譯結果為:
size of Base: 8 base::f derive::g base::h derive::k
單繼承派生類虛擬函式表的結構:
- 多繼承模式
當派生類有多個基類,在派生類中將出現多個虛表指標,指向各個基類的虛擬函式表,在派生類中會出現非覆蓋和覆蓋的情況,以覆蓋為例:
//dev:Linux 64bit g++ 4.8.5 #include<iostream> using namespace std; class Base1 { public: virtual void f() { cout << "Base1::f" << endl; } virtual void g() { cout << "Base1::g" << endl; } virtual void h() { cout << "Base1::h" << endl; } }; class Base2 { public: virtual void f() { cout << "Base2::f" << endl; } virtual void g() { cout << "Base2::g" << endl; } virtual void h() { cout << "Base2::h" << endl; } }; class Base3 { public: virtual void f() { cout << "Base3::f" << endl; } virtual void g() { cout << "Base3::g" << endl; } virtual void h() { cout << "Base3::h" << endl; } }; class Derive :public Base1, public Base2, public Base3 { public: //覆蓋各個基類的f virtual void f() { cout << "Derive::f" << endl; } virtual void g1() { cout << "Derive::g1" << endl; } virtual void h1() { cout << "Derive::h1" << endl; } }; int main() { Derive d; Base1 *b1 = &d; Base2 *b2 = &d; Base3 *b3 = &d; b1->f(); b2->f(); b3->f(); b1->g(); b2->g(); b3->g(); }
上述程式碼在Linux 64位機器 g++4.8.5版本下編譯結果為:
Derive::f Derive::f Derive::f Base1::g Base2::g Base3::g
多繼承派生類各個虛指標和虛擬函式表的佈局如圖:
- 虛繼承
虛繼承是面向物件程式設計中的一種技術,是指一個指定的基類在繼承體系結構中,將其成員資料例項共享給也從這個基型別直接或間接派生的其它類。
舉例來說:
假如類A和類B各自從類X派生,且類C同時多繼承自類A和B,那麼C的物件就會擁有兩套X的例項資料。
但是如果類A與B各自虛繼承了類X,那麼C的物件就只包含一套類X的例項資料。
這一特性在多重繼承應用中非常有用,可以使得虛基類對於由它直接或間接派生的類來說,擁有一個共同的基類物件例項,避免由菱形繼承問題。
菱形繼承(鑽石問題):
虛繼承的作用:
菱形問題(又稱鑽石問題)帶來了二義性和多份拷貝的問題,虛繼承可以很好解決菱形問題。虛繼承將共同基類設定為虛基類,從不同途徑繼承來的同名數據成員在記憶體中就只有一個拷貝,同一個函式名也只有一個對映。從而解決了二義性問題、節省了記憶體,避免了資料不一致的問題。
維基百科虛繼承的栗子:
#include <iostream> using namespace std; class Animal { public: void eat(){cout<<"delicious!"<<endl;} }; // Two classes virtually inheriting Animal: class Mammal : virtual public Animal { public: void breathe(){} }; class WingedAnimal : virtual public Animal { public: void flap(){} }; // A bat is still a winged mammal class Bat : public Mammal, public WingedAnimal { }; int main(){ Bat b; b.eat(); return 0; }
在後續學習繼承和C++物件記憶體佈局時,將對虛繼承的底層實現原理進行展開,本文暫時不深入討論。
4.純虛擬函式和抽象類
虛擬函式的宣告以=0結束,便可將它宣告為純虛擬函式,包含純虛擬函式的類不允許例項化,稱為抽象類,但是純虛擬函式也可以有函式體,純虛擬函式提供了面向物件中介面的功能,類似於Java中的介面。
語法格式為:virtual 返回值型別 函式名(函式引數) = 0;
需要抽象類的場景:
- 功能不應由基類去完成
- 無法缺點如何寫基類的函式
- 基類本身不應被例項化
就像雖然有Animal類,但是並不能生成一個動物例項,並且Animal的類的成員函式無法定義,需要其派生類Tiger類、Fish類來具體實現,這種思想在面向物件的介面設計很普遍。
class CPerson{ public: virtual void hello() = 0; }; CPerson p; //例項化抽象類 編譯錯誤
如果一個類從抽象類派生而來,它必須實現了基類中的所有純虛擬函式,才能成為非抽象類,否則仍然為抽象類。
#include<iostream> using namespace std; class A{ public: virtual void f() = 0; void g(){ this->f(); } A(){} }; class B:public A{ public: void f(){ cout<<"B:f()"<<endl;} }; int main(){ B b; b.g(); return 0; }
5.虛解構函式
- 虛析構的作用
實現多型的基類解構函式一般被宣告成虛擬函式,如果不設定成虛擬函式,在析構的過程中只會呼叫基類的解構函式而不會呼叫派生類的解構函式,從而可能造成記憶體洩漏。
虛析構舉例:
#include<iostream> using namespace std; class Base{ public: Base() { cout<<"Base Constructor"<<endl; } ~Base() { cout<<"Base Destructor"<<endl; } //virtual ~Base() { cout<<"Base Destructor"<<endl; } }; class Derived: public Base{ public: Derived() { cout<<"Derived Constructor"<<endl; } ~Derived() { cout<<"Derived Destructor"<<endl; } }; int main(){ Base *p = new Derived(); delete p; return 0; }
非虛析構的輸出:
Base Constructor Derived Constructor Base Destructor
虛析構的輸出:
Base Constructor Derived Constructor Derived Destructor Base Destructor
可以看到在Base使用虛析構時會執行派生類的解構函式,否則不執行。
- 虛析構的使用時機
如果某個類不包含虛擬函式,一般表示它將不作為一個基類來使用,因此不使虛解構函式,否則增加一個虛擬函式表和虛指標,使得物件的體積增大。如果某個類將作為基類那麼建議使用虛析構,包含虛擬函式則這條要求成為必然。無故使用虛解構函式和永遠不使用一樣是錯誤的。
- 為什麼建構函式不能是虛擬函式
其他語言中可能會成立,但是在C++中存在問題,原因主要有:
- 構造物件時需要知道物件的實際型別,而虛擬函式行為是在執行期間才能確定實際型別的,由於物件還未構造成功,編譯器無法知道物件的實際型別,儼然是個雞和蛋的問題。
- 如果建構函式是虛擬函式,那麼建構函式的執行將依賴虛擬函式表,而虛擬函式表又是在建構函式中初始化的,而在構造物件期間,虛擬函式表又還沒有被初始化,又是個死迴圈問題。
總結:
這塊有點繞,從編譯器的角度去看,建構函式就是為了在編譯階段確定物件型別、分配空間等工作,虛擬函式為了實現動態多型需要在執行期間才能確定具體的行為,顯然建構函式不可能同時具備靜態特性和動態特性。
6.虛擬函式的優缺點
虛擬函式的優點主要實現了C++的多型,提高程式碼的複用和介面的規範化,更加符合面向物件的設計理念,但是其缺點也比較明顯,主要包括:
- 編譯器藉助於虛表指標和虛表實現時,導致類物件佔用的記憶體空間更大,這種情況在子類無覆蓋基類的多繼承場景下更加明顯。
- 虛擬函式表可能破壞類的安全性,可以根據地址偏移來訪問Private成員
- 執行效率有損耗,因為涉及通過虛擬函式表定址真正執行函式
7.參考資料
- https://www.cnblogs.com/chio/archive/2007/09/10/888260.html
- https://juejin.im/post/5d1ee816f265da1bb13f5196
- https://harttle.land/2015/06/28/cpp-polymorphism.html
- https://www.jianshu.com/p/5d6edcb3b83e
- https://www.jianshu.com/p/4dcd834be1ab
- https://blog.csdn.net/zhang2531/article/details/51218149
- https://www.zfl9.com/cpp-polymorphism.html
- https://zhuanlan.zhihu.com/p/41309205
- https://cloud.tencent.com/developer/article/1155155
- https://blog.csdn.net/qq_16209077/article/details/52788864
- https://jocent.me/2017/08/07/virtual-table.htm