虛函數定義與作用:
virtual關鍵字聲明虛函數,虛函數可被派生類override(保證返回類型與參數列表,名字均相同),從而通過基類指針調用時,實現多態的功能
virtual關鍵字:
將函數聲明為虛函數
override關鍵字:
告訴編譯器該函數為重寫的虛函數,若重寫失敗,報錯,防止出現疏忽導致虛函數未重寫的情況
final關鍵字:
聲明給虛函數時,表明該虛函數不可再被派生類重寫
聲明給類時,表明該類不可再被繼承
class Base{
public:virtual void print() const{cout << "Base" << endl;}
};class Derived final : public Base{//注意此處final的位置
public:void print() const override final{ //此處const override final的順序不可調換cout << "Derived" << endl;}
};
指針的動態類型與靜態類型:
指針/引用的定義類型為其靜態類型
指針/引用指向的對象類型為其動態類型
當調用非虛函數時,函數的匹配取決于指針/引用的靜態類型
當調用虛函數時,函數的匹配取決于指針/引用的動態類型
例子:
class Base{
public:virtual void print(){cout << "Base" << endl;}
};class Derived:public Base{
public:void print()override{cout << "Derived" << endl;}
};int main(){Base *p_base{new Derived};p_base->print(); //調用Derived::print()Base obj_base = *p_base;obj_base.print();//調用Base::print()return 0;
}
輸出:
Derived
Base
特殊的虛函數重寫:協變返回類型
當虛函數的返回類型為一系列的基類/派生類的指針/引用時,重寫的虛函數返回類型可以不一樣
例子:
class A{
public:void print(){cout << "A" << endl;}
};
class B:public A{
public:void print(){cout << "B" << endl;}
};class Base{
public:virtual A& get(){cout << "return A" << endl;return *(new A{});}
};class Derived:public Base{
public:B& get() override{cout << "return B" << endl;return *(new B{});}
};int main(){Base *p_base{new Derived};p_base->get();Base obj_base = *p_base;obj_base.get();return 0;
}
輸出:
return B
return A
綜合的虛函數調用例子:
#include <iostream>
using namespace std;class A{
public:void print(){cout << "A" << endl;}virtual void vprint(){cout << "A" << endl;}
};
class B:public A{
public:void print(){cout << "B" << endl;}void vprint()override{cout << "B" << endl;}
};class C{
private:A m_a{};
public:virtual A& get(){return m_a;}
};class D:public C{
private:B m_b{};
public:B& get() override{return m_b;}
};int main(){C *p_C{new D{}};p_C->get().print(); //靜態調用print(),所以調用的是p_C的靜態類型對應的get(),返回A&p_C->get().vprint(); //動態調用vprint(), 所以調用的是p_C的動態類型對應的get(),返回B&return 0;
}
輸出:
A
B
虛析構函數:
在類的析構函數前加上virtual關鍵字,可將其變為虛析構函數,此后的派生類寫自己的析構函數時,相當于重寫基類的虛函數,派生類的析構函數默認成為虛函數
如果一個類會被繼承的話,那么應當將其析構函數寫成虛析構函數,以避免內存泄漏
不使用虛析構函數的繼承:
class Base{
public:~Base(){cout << "~Base()" << endl;}
};class Derived:public Base{
public:~Derived(){cout << "~Derived()" << endl;}
};int main(){Base* p{new Derived{}};delete p;return 0;
}
輸出:
~Base()
可以看到delete只調用了Base的析構函數,從而導致Derived部分分配的內存未被清空,發生內存泄漏
使用虛析構函數的繼承:
class Base{
public:virtual ~Base(){cout << "~Base()" << endl;}
};class Derived:public Base{
public:~Derived() override{cout << "~Derived()" << endl;}
};int main(){Base* p{new Derived{}};delete p;return 0;
}
輸出:
~Derived()
~Base()
指針正確調用了Derived的虛析構函數,而該析構函數又調用了~Base(),從而正確的清空了分配的內存
純虛函數:
在虛函數聲明后面加上=0,使其成為純虛函數
class A{
public:virtual void func() = 0;void func2(){}
};
純虛函數也可以有定義,如寫在類外面的定義:
void A::func(){cout << "I'm a pure virtual function" << endl;
}
抽象類:
只要包含純虛函數的類就稱為抽象類(如上述的A類),抽象類不可被實例化
繼承自抽象類的派生類需要重寫其所有純虛函數,否則該派生類也是抽象類
接口類:
不包含任何屬性和成員函數,只包含純虛函數的類稱為接口類
利用虛函數修改派生類的operator<<
為了使用ostream,我們通常將operator<<寫成友元函數,但友元函數不能是虛函數,因此無法被派生類重寫
但我們又不想每定義一個派生類就新添加一個友元operator<<
因此,我們可以定義一個輔助print()虛函數,然后用operator<<來調用這個虛函數,從而達到多態的目的
class Base{
public:virtual ostream& print(ostream& out){out << "Base" << endl;}friend ostream& operator<<(ostream& out,Base& obj){return obj.print(out);}
};class Derived:public Base{
public:ostream& print(ostream& out) override{out << "Derived" << endl;}
};int main(){Derived d{};cout << d << endl;return 0;
}
輸出:
Derived
在cout<<d的時候,由于沒有與Derived匹配的<<運算符,因此編譯器將d隱式轉換為Base,然后傳入operator<<(ostream& out,Base& obj)里,從而通過Base&調用Derived對應的虛函數print(),實現多態的目的
虛函數的實現原理:
結構
當一個類內包含虛函數,那么編譯器就會為這個類分配一個數組,數組里存了若干個指針(虛函數指針,vfptr),每個指針指向對應的虛函數的地址,我們把這個數組稱作虛函數表(__vtable)。
當該類實例化為對象時,編譯器會在該對象頭部插入一個指針,該指針指向虛函數表,我們把這個指針稱作虛函數表指針(__vptr),虛函數表指針的初始化在構造函數之前。
結構如圖所示:
運行:
當我們通過類指針調用虛函數的時候,編譯器并不會在編譯期就根據函數簽名來確定調用的函數的地址(靜態綁定),而是在運行期讓類指針通過__vptr找到vtable,并通過調用的函數簽名,確定在vtable的偏移量,從而找到對應的vfptr,通過vfptr找到需要調用的函數的地址,進而調用該函數,此謂動態綁定
繼承規則:
當我們發生繼承、虛函數重寫、添加虛函數時,
那么vtable以及__vptr的分配規則如下:
每個含有虛函數的基類的子對象的首地址都會有對應的__vptr
重寫虛函數:修改對應__vptr所指向的vtable的對應位置(下標)內的vfptr,
新添虛函數: 在首個繼承的基類的vtable后添加vfptr
例子:
當我們有如下代碼的繼承關系時:
class Base1{
public:virtual void func1(){}virtual void print1(){}void test1(){}
};class Base2{
public:virtual void func2(){}virtual void print2(){}void test2(){}
};class Derived:public Base1,public Base2{
public:void func1() override{}void print2() override{}virtual void f_derived(){}
};
Base1,Base2各有兩個虛函數和一個非虛函數,Derived各重寫了基類的一個虛函數,以及新添加了一個自己的虛函數
結構如圖所示:
可以看到
在Derived obj內,
虛函數表內vfptr_func1_b和vfptr_print2_b對應的位置被替換成了vfptr_func1_d和vfptr_print2_d
新添加的f_derived對應的vfptr_f_d被添加到了__vptr1指向的vtable的末尾
值得注意的是
Derived的vtable和Base1,Base2的vtable是獨立開來的,因此這里總共有三個虛函數表
如果Derived沒有重寫任何虛函數,其仍會生成一個獨立的vtable
多態實現原理:
當我們用基類指針指向不同的派生類,調用其相同的虛函數時,若其虛函數被重寫過,則會產生不同的效果,此謂多態
那么多態是如何實現的呢:
用上述的Base1,Base2,Derived舉例
調用重寫的虛函數:
比如我們現在有
Base2* p{new Derived{}};
p->print2();
由于我們使用了Base2指針指向Derived對象,那么該指針會指向Derived中的Base2子對象的首地址,也就是__vptr2所在處,當我們調用print2()時,指針會通過__vptr2找到vtable,然后由print2()的函數簽名,編譯器會讓指針偏移一個指針偏移量,從而找到vfptr_print2_d,進而調用Derived::print2(),這也就是為什么要修改對應位置的vfptr的原因,因為vtable就是根據位置來找對應的虛函數的
調用新添的虛函數:
當我們使用Base1指針指向Derived對象時,也是同樣的道理:
Base1* p2{new Derived{}};
p2->f_derived();
當我們調用f_derived()時,編譯器是用__vptr1去找該函數的,這也就是為什么vfptr_f_d會加到__vptr1的末尾的原因,也就是說Derived與其Base1的子對象是共用一個vtable的,也因此,p2無法調用在Base2定義的虛函數
指向不同對象時的調用:
當我們用Base1指針指向Base1對象時,其__vptr指向的是Base1的vtable(圖右上角),因此調用時只會調用Base1定義的虛函數
總結:
多態的根本原理是不同類的__vtpr指向的vtable不同,從而在運行時索引的時候找到不同的函數并運行
this指針調整:
編譯器在運行時動態調整this指針的功能,具體實現原理這里不再贅述
例子1:
在上述例子中,我們再加入一個派生類:
class Derived2:public Derived{
public:
};int main(){Derived* p{new Derived2{}};p->print2();return 0;
}
上述代碼肯定是可運行的,但按上述運行原理,Derived*所用的__vptr是不可能找到print2()的,也就是說其使用了__vptr2,那么編譯器是怎么實現的呢?
在這里,編譯器用到了一種叫做this指針調整的功能,在調用虛函數表指針之前,編譯器先讓p向下偏移到了Base2子對象首地址的位置,進而使用__vptr2正確調用print2()
例子2:
若Derived有虛析構函數,那么該虛析構函數只會放在__vptr1所指向的vtable內,此時,若用Base2指針指向Derived對象,在調用虛析構函數時,編譯器會先將this指針偏移到Base1子對象的首地址處,進而使用__vptr1正確地調用虛析構函數