天天看點

C++ | 虛函數初探

虛函數

虛函數 是在基類中使用關鍵字 virtual 聲明的函數。在派生類中重新定義基類中定義的虛函數時,會告訴編譯器不要靜态連結到該函數。

我們想要的是在程式中任意點可以根據所調用的對象類型來選擇調用的函數,這種操作被稱為動态連結,或後期綁定。

1、普通的繼承關系
#include <iostream>

class Base		//定義基類
{
public:
	Base(int a) :ma(a) {}
	void Show()
	{
		std::cout << "Base: ma = " << ma << std::endl;
	}
protected:
	int ma;
};

class Deriver : public Base		//派生類
{
public:
	Deriver(int b) :mb(b), Base(b) {}
	void Show()
	{
		std::cout << "Deriver: mb = " << mb << std::endl;
	}
protected:
	int mb;
};

int main()
{
	Base* pb = new Deriver(10);							
	std::cout << sizeof(Base) << std::endl;				// 4
	std::cout << sizeof(Deriver) << std::endl;			// 8
	std::cout << typeid(Base).name() << std::endl;		// class Base
	std::cout << typeid(Deriver).name() << std::endl;	// class Deriver
	pb->Show();			// Base : ma = 10
	return 0;
}

           

運作結果與我們預想的一樣。其中 pb變量為指針類型,指針類型被認為是内置類型,且隻與定義點有關,是以 Base * 類型的指針解引用之後是 Base 類型。

C++ | 虛函數初探
檢視記憶體布局

打開指令行的開發者指令提示視窗

C++ | 虛函數初探

指令如下:(記得切換到該項目檔案夾下)

cl file.cpp /d1reportSingleClassLayoutXXX
其中 file.cpp 是cpp檔案名,XXX是檔案中要檢視的類名
           

分别輸入指令檢視 基類Base、派生類Derive 的記憶體布局

/*  Base記憶體布局  */
class Base      size(4):
        +---
 0      | ma
        +---

/*  Derive記憶體布局  */
class Derive    size(8):
        +---
 0      | +--- (base class Base)
 0      | | ma
        | +---
 4      | mb
        +---
           

成員變量依據聲明的順序進行排列(類内偏移為0開始),成員函數不占記憶體空間。

我們可以看到,在 Derive 的記憶體布局中,有繼承自 Base 類的資料成員。

C++ | 虛函數初探

可以看到派生類繼承了基類的成員變量,在記憶體排布上,先是排布了基類的成員變量,接着排布派生類的成員變量,同樣,成員函數不占位元組

2、使用 virtual 關鍵字

給基類的 Show() 函數加上 virtual 關鍵字

class Base		//定義基類
{
public:
	Base(int a) :ma(a) {}
	virtual void Show()
	{
		std::cout << "Base: ma = " << ma << std::endl;
	}
protected:
	int ma;
};
           

重新編譯并運作,可以看到運作結果發生了改變。

C++ | 虛函數初探
檢視記憶體布局

在基類中加入 virtual 關鍵字,基類記憶體布局中增加了一個{vfptr}指針(指向Base的虛表),Base 所占位元組數也從 4 位元組變成了 8 位元組。同時,Base 還增加一個虛表(vftable),在該虛表中寫入了Base中所有虛函數的位址。

在派生類中也有一個虛表指針和一個虛表,需要說明的是,派生類中的虛表繼承自基類,派生類通過将自己的虛函數寫入繼承的虛表, 覆寫 掉原來的虛表(基類與派生類各自擁有一個虛表)。是以,在 Derive 的記憶體布局中隻有一個虛表指針。

/*  Base 記憶體布局  */
class Base      size(8):
        +---
 0      | {vfptr}
 4      | ma
        +---

Base::[email protected]:
        | &Base_meta
        |  0
 0      | &Base::Show


/*  Derive 記憶體布局  */
class Derive    size(12):
        +---
 0      | +--- (base class Base)
 0      | | {vfptr}
 4      | | ma
        | +---
 8      | mb
        +---

Derive::[email protected]:
        | &Derive_meta
        |  0
 0      | &Derive::Show
           

記憶體布局如圖所示

C++ | 虛函數初探

3、虛函數表機制

基類指針指向派生類對象實質上是指向派生類對象中基類的起始部分,在虛函數表結構中有三部分組成,分别是:

  • RTTI(Run-Time Type Identification)資訊:通過儲存在其中的類型資訊在運作時能夠使用基類的指針或引用來檢查這些指針或引用所指的對象的實際派生類型。
  • 偏移: 成員變量與類對象的偏移位址(在 vs 中,vfptr 相對于成員變量的優先級更大,位于記憶體分布的首位,是以偏移量為0)。
  • 虛函數入口位址:在調用函數時通過 call 指令跳轉到函數,在虛表中儲存虛函數的入口位址可以在運作階段通過查虛表的方式實作動多态。
    C++ | 虛函數初探
    vfptr是在構造函數的棧幀進行初始化的時候:在構造函數初始化清單之後并調用構造函數第一行代碼之前,函數棧幀開辟後進行指派虛表位址指派給vfptr的,This指針的指派也是在構造函數的棧幀進行。

4、基類指針指向派生類對象

在執行個體2 中有這樣一段代碼

虛函數調用:基類指針 pb 指向 派生類對象,而在派生類對象的記憶體布局中有一個虛表指針,其中虛表指針指向的 Derive 的虛表結構。是以,在

pb->Show()

調用時,實際上是

pb -> vfptr -> Derive::Show()

,最終在螢幕上輸出了 “Derive: mb = 10” 。

C++ | 虛函數初探

可以這麼了解,Base pb = new Derived();生成的是子類的對象,在構造時,子類對象的虛指針指向的是子類的虛表,接着由Derived到Base*的轉換并沒有改變虛表指針,pb 所指向的對象它在構造的時候就已經指向了子類的Derive::Show(),是以調用的是子類的虛函數,這就是多态了。

另外,在基類指針 pb 解引用時,優先檢視 RTTI 資訊中儲存的類型資訊。是以,

*pb

的類型被解析成 Derive 類型。

5、虛函數與析構

在 main 函數中,派生類是在 new 形成的,也就是說在堆記憶體上開辟的,但在結束時我們并沒有手動的釋放就會造成記憶體洩漏。修改上述代碼如下:

#include <iostream>

class Base		//定義基類
{
public:
	Base(int a) :ma(a) 
	{ 
		std::cout << "Base()" << std::endl; 
	}
	virtual void Show()
	{
		std::cout << "Base: ma = " << ma << std::endl;
	}
	///
	/*  以下内容為新添加  */
	~Base()
	{
		std::cout << "~Base()" << std::endl;
	}
	///
protected:
	int ma;
};

class Derive : public Base		//派生類
{
public:
	Derive(int b) :mb(b), Base(b) 
	{ 
		std::cout << "Derive()" << std::endl; 
	}
	void Show()
	{
		std::cout << "Derive: mb = " << mb << std::endl;
	}
	///
	/*  以下内容為新添加  */
	~Derive()
	{
		std::cout << "~Derive()" << std::endl;
	}
	///
protected:
	int mb;
};

int main()
{
	Base* pb = new Derive(10);
	delete pb;
	return 0;
}

           

在原有的 Base類 和 Derive類 中,添加了析構函數,可以看到運作結果中類構造了兩次,最後卻被析構了一次。Derive 類是繼承自 Base 類的,是以,在構造 Derive 對象時會先構造他的父類 Base 類,析構的時候理應也析構兩次才對,這裡卻隻析構了一次。

C++ | 虛函數初探

分析:

  • 析構函數跟普通成員沒有什麼不同,隻是編譯器在會在特定的時候自動調用析構函數(離開作用域或者執行delete操作);甚至我們可以通過對象手動調用析構。
  • 由于 Base 類中析構函數不是虛函數,就不滿足動多态發生的條件,在 delete pb 時就會被當做普通函數調用,而 pb 本質上是 Base* 類型指針,是以在釋放基類指針時調用了基類析構(~Base() ),而派生類對象還沒有被析構造成了記憶體洩露。

綜上,在 Derive類 與 Base類 中,在 delete pb 時,可以看做是發生了 pb -> ~Base() 的函數調用(對象指針調用析構函數,這裡的pb為 Base* 類型),而我們希望執行的是 pb -> Derive() 的函數調用(這裡的 pb 是Derive 類型),是以我們可以設法讓 pb 在調用時實作動多态,而調用派生類的函數。

基類的析構函數聲明為虛函數

将基類 Base 的析構函數申明為虛函數,這樣析構函數就會被寫入虛函數表,可以實作析構時的動多态。

/*  Base  */
	virtual ~Base()
	{
		std::cout << "~Base()" << std::endl;
	}
           

記憶體布局

/*  Base  */
Base::[email protected]:
        | &Base_meta
        |  0
 0      | &Base::Show
 1      | &Base::{dtor}

/*  Derive  */
Derive::[email protected]:
        | &Derive_meta
        |  0
 0      | &Derive::Show
 1      | &Derive::{dtor}
           

運作測試,和我們預想的結果一緻。

C++ | 虛函數初探

其中,destroy()函數就是我們平時說的析構函數。添加了 virtual 關鍵字後基類中析構函數是虛函數,派生類的析構函數自動成為虛函數。基類就有一張虛函數表,派生類繼承基類的時候會把自己的析構函數覆寫到虛函數表中,delete基類指針的時候,調用的就是該派生類析構函數而該派生類析構函數會先釋放派生類對象再釋放基類對象。這樣的話就不會造成派生類的資源沒有釋放的問題。

是以,在繼承中做父類(基類),并且可能會發生動多态時(比如 Base * pb = new Derive() )父類的析構函數要聲明為虛函數,如果不用虛函數,子類的析構函數不能得到調用,會造成記憶體洩漏。但并不是要把所有類的析構函數都寫成虛函數。隻有當一個類被用來作為基類的時候,才把析構函數寫成虛函數。

那麼是不是所有類的析構函數都可以設定成虛析構呢?

可以,但沒必要,也不建議這麼做。設定成虛析構并不影響析構函數的調用,但設定虛析構會産生額外的開銷,因為系統會産生虛表和虛表指針占用類的存儲空間。

繼續閱讀