0.前言
在後端面試中語言特性的掌握直接決定面試成敗,因此後續會持續輸出程式語言的必知必會知識點系列。
C++語言一直在增加很多新特性來提高使用者的便利性,但是每種特性都有複雜的背後實現,充分理解實現原理和設計原因,才能更好地掌握這種新特性。
說在前面:小夥伴們在學習的過程中難免會遇到很多的困難,有的是初學不知道如何入手,亦或是想要繼續提升自己,小編為了幫助大家解決學習問題,大家可以點擊上方我的頭像私信我發送:「學習」兩個字,我將會針對性的幫助解答你學習上的問題和發送你學習資料哦,大家一起進步!
C++語言
只要出發總會達到,只有出發才會到達,焦慮沒用,學就完了,今天一起來學習C++的虛函數考點吧。
通過本文你將了解的以下內容:
- C++多態機制
- 虛函數的基本使用
- 虛函數的底層實現
- 純虛函數和抽象類
- 虛析構函數
- 虛函數的優缺點
1.C++多態機制
- 多態機制簡介
C++面向對象的三大特徵:
- 多態(Polymorphism)
- 封裝(Encapsulation)
- 繼承(Inheritance)
從字面上理解多態就是多種形態,具體如何多種形態,多態和繼承的關係非常密切,試想下面的場景:
- 派生類繼承使用基類提供的方法,不需更改
- 同一個方法在基類和派生類的行為是不同的,具體行為取決於調用對象。
後者就是C++的多態需求場景,即同一方法的行為隨調用者上下文而異,舉個現實生活中類似的栗子,來加深理解:
基類Woker包括三個方法:打卡、午休、幹活。
派生類包括產品經理PMer、研發工程師RDer、測試工程師Tester,派生類從基類Worker中繼承了打卡、午休、幹活三個方法。
打卡和午休對三個派生類來說是一樣的,因此可以直接調用基類的方法即可。
但是每個派生類中幹活這個方法具體的實現並不一樣:產品經理提需求、研發寫代碼、測試找Bug。
SomeWhere
C++語言
電腦程式的出現就是為了解決現實中的問題,從上面的例子可以看到,這種同一方法的行為隨調用者而異的需求很普遍,然而多態的設計原因只有C++之父Bjarne Stroustrup大佬最清楚了。
- 靜態綁定和動態綁定要充分理解多態,就要先說什麼是綁定?
綁定體現了函數調用和函數本身代碼的關聯,也就是產生調用時如何找到提供調用的方法入口,這裡又引申出兩個概念:
- 靜態綁定:程序編譯過程中把函數調用與執行調用所需的代碼相關聯,這種綁定發生在編譯期,程序未運行就已確定,也稱為前期綁定。
- 動態綁定:執行期間判斷所引用對象的實際類型來確定調用其相應的方法,這種發生於運行期,程序運行時才確定響應調用的方法,也稱為後期綁定。
- 靜態多態和動態多態
在C++泛型編程中可以基於模板template和重載override兩種形式來實現靜態多態。
動態多態主要依賴於虛函數機制來實現,不同的編譯器對虛函數機制的實現也有一些差異,本文主要介紹Linux環境下gcc/g++編譯器的實現方法。
多態本質上是一種泛型技術,說白了就是試圖使用不變的代碼來實現可變的算法,要麼試圖在編譯時決定,要麼試圖在運行時決定。
- 虛函數與三大特徵
虛函數為多態提供了基礎,並且藉助於繼承來發揮多態的優勢,從而完善了語言設計的封裝,可見虛函數與C++三大特徵之間有緊密的聯繫,是非常重要的特性。
C++語言
2.虛函數的基本使用
- 虛函數使用實例
使用virtual關鍵字即可將成員函數標記為虛函數,派生類繼承基類的虛函數之後,可以重寫該成員函數,派生類中是否增加virtual關鍵字均可,代碼舉例:
#include
using namespace std;
class Worker{
public:
virtual ~Worker(){}
virtual void DoMyWork(){
cout<<"BaseWorker:I am base worker"<
}
};
class PMer:public Worker{
public:
//virtual void DoMyWork(){
void DoMyWork(){
cout<<"ChildPMer:Tell rd demands"<
}
};
class RDer:public Worker{
public:
//virtual void DoMyWork(){
void DoMyWork(){
cout<<"ChildRDer:Write code and solve bugs"<
}
};
class Tester:public Worker{
public:
//virtual void DoMyWork(){
void DoMyWork(){
cout<<"ChildTester:Find bugs and inform rd"<
}
};
int main(){
//使用基類指針訪問派生類
Worker *ptr_pm = new PMer();
Worker *ptr_rd = new RDer();
Worker *ptr_ts = new Tester();
cout<<"#### use ptr #####"<
ptr_pm->DoMyWork();
ptr_rd->DoMyWork();
ptr_ts->DoMyWork();
ptr_pm->Worker::DoMyWork();
cout<<"-----------------------------"<
//使用基類引用訪問派生類
PMer pmins;
RDer rdins;
Tester tsins;
Worker &ref_pm = pmins;
Worker &ref_rd = rdins;
Worker &ref_ts = tsins;
cout<<"#### use ref #####"<
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
using namespace std;
//定義函數指針類型
typedef void(*Func)(void);
//包含虛函數的基類
class Base {
public:
virtual void f() {cout<<"base::f"<
virtual void g() {cout<<"base::g"<
virtual void h() {cout<<"base::h"<
};
//派生類
class Derive : public Base{
public:
void g() {cout<<"derive::g"<
virtual void k() {cout<<"derive::k"<
};
int main ()
{
//base類占據空間大小
cout<<"size of Base: "<
//基類指針指向派生類
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
單繼承派生類虛函數表的結構:
C++語言
- 多繼承模式
當派生類有多個基類,在派生類中將出現多個虛表指針,指向各個基類的虛函數表,在派生類中會出現非覆蓋和覆蓋的情況,以覆蓋為例:
//dev:Linux 64bit g++ 4.8.5
#include
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
多繼承派生類各個虛指針和虛函數表的布局如圖:
C++語言
- 虛繼承
虛繼承是面向對象編程中的一種技術,是指一個指定的基類在繼承體系結構中,將其成員數據實例共享給也從這個基類型直接或間接派生的其它類。
舉例來說:
假如類A和類B各自從類X派生,且類C同時多繼承自類A和B,那麼C的對象就會擁有兩套X的實例數據。
但是如果類A與B各自虛繼承了類X,那麼C的對象就只包含一套類X的實例數據。
這一特性在多重繼承應用中非常有用,可以使得虛基類對於由它直接或間接派生的類來說,擁有一個共同的基類對象實例,避免由菱形繼承問題。
維基百科-虛繼承
菱形繼承(鑽石問題):
C++語言
虛繼承的作用:
菱形問題(又稱鑽石問題)帶來了二義性和多份拷貝的問題,虛繼承可以很好解決菱形問題。
虛繼承將共同基類設置為虛基類,從不同途徑繼承來的同名數據成員在內存中就只有一個拷貝,同一個函數名也只有一個映射。從而解決了二義性問題、節省了內存,避免了數據不一致的問題。
維基百科虛繼承的栗子:
#include
using namespace std;
class Animal {
public:
void eat(){cout<<"delicious!"<
};
// 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
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()"<
};
int main(){
B b;
b.g();
return 0;
}
5.虛析構函數
- 虛析構的作用
實現多態的基類析構函數一般被聲明成虛函數,如果不設置成虛函數,在析構的過程中只會調用基類的析構函數而不會調用派生類的析構函數,從而可能造成內存泄漏。
虛析構舉例:
#include
using namespace std;
class Base{
public:
Base() { cout<<"Base Constructor"<
~Base() { cout<<"Base Destructor"<
//virtual ~Base() { cout<<"Base Destructor"<
};
class Derived: public Base{
public:
Derived() { cout<<"Derived Constructor"<
~Derived() { cout<<"Derived Destructor"<
};
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成員
- 執行效率有損耗,因為涉及通過虛函數表尋址真正執行函數