C++ | 虛函數表內存布局


虛表指針

虛函數有個特點。存在虛函數的類會在類的數據成員中生成一個虛函數指針 vfptr,而vfptr 指向了一張表(簡稱,虛表)。正是由於虛函數的這個特性,C++的多態才有了發生的可能。

其中虛函數表由三部分組成,分別是 RTTI(運行時類型信息)、偏移及虛函數的入口地址。而虛表與類及類生成的對象有存在着以下兩種關系:

  • 類與虛表的關系:一個類只有一個虛表
  • 對象與類的關系:所有對象共享一個虛表

如下圖所示:對象通過一個 vfptr (虛表指針)共享虛表.在這里插入圖片描述

虛表指針在類中的布局

1、虛表指針 vfptr 在上,類成員變量 ma 在下(圖左)
2、類成員變量 ma 在上,虛表指針 vfptr 在下(圖右)
在這里插入圖片描述
在類中,vfptr 的優先級最高,所以虛函數在類中的布局應該是上圖左邊的結構,其中vftpr指針指向虛表,在虛表的起始位置存放這虛表所屬類的類型信息RTTI(運行時類型信息 Run-Time Type Identification)。可以通過 typeid(pb).name() 查看。

虛函數表在類中的布局

現有基類 Base、派生類 Deriver 為測試代碼:

#include<iostream>

class Base		//定義基類
{
public:
	Base(int a) :ma(a) {}
	virtual 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;
};

1. 查看Base類的內存布局

在VS 2019開發者命令提示中輸入:
cl 虛函數.cpp /d1reportSingleClassLayoutBase
其中,虛函數.cpp 為源文件的文件名, 最后的Base為要查看的類

/* Base類 內存布局 */
class Base      size(8):
        +---
 0      | {vfptr}
 4      | ma
        +---

Base::$vftable@:
        | &Base_meta		//運行時類型信息 Run-Time Type Identification
        |  0				//虛函數指針相對於整體作用域的偏移
 0      | &Base::Show		//虛函數入口地址,虛函數入口地址有一個或多個
2. 查看Deriver內存布局

輸入:cl 虛函數.cpp /d1reportSingleClassLayoutDeriver
我們查尋到 Deriver的內存布局中類對象占據12個字節的空間。

/* Deriver類 內存布局 */
class Deriver   size(12):
        +---
 0      | +--- (base class Base)
 0      | | {vfptr}
 4      | | ma
        | +---
 8      | mb
        +---

Deriver::$vftable@:
        | &Deriver_meta
        |  0
 0      | &Deriver::Show

發現:我們在源代碼中並沒有把 Deriver::Show() 聲明為虛函數,但在Deriver的類內存布局中也存在 {vfptr} 指針。

這里不得不說虛函數的另一個特點了,“基類中同名同參的函數是虛函數,派生類中同名同參的函數也會變成虛函數”。意思是,在派生類中同名同參的函數即使沒有 virtual 關鍵字聲明也默認是虛函數,也會產生一張虛表。

那么派生類中的虛表結構又是什么樣的呢?

根據上面提到的 vfptr 的優先級最大,並且 Deriver 是繼承自 Base 類。因此,我門推測 Deriver 的內存布局應該是如下格式16字節布局才對,但顯然不是這樣。那么在派生類 Deriver 的內存布局中究竟進行了怎樣的操作,才形成了12字節的內存布局呢?

注:以下結構為錯誤示范

/* 我們推測的Deriver類的內存布局 */
class Deriver   size(16):
        +---
 0      | {vfptr}		// Deriver::
 4      | +--- (base class Base)
 4      | | {vfptr}		// Base::
 8      | | ma
        | +---
 12     | mb
        +---
解釋這個原因之前我們先得了解派生類的虛表是怎樣生成的?

在編譯基類時,基類生成了一張虛表,在編譯派生類時,又生成一張虛表。
我們在基類中添加一個Print() 函數,派生類中沒有該函數。在上述假設成立的前提下,對應內存布局如下:
在這里插入圖片描述
如果是這樣,那么試想,在調用Print的時候,就需要查詢兩張虛表,從而找到 Base::Show() 對應的入口地址。這樣做的確可行,但是整個調用的效率會變得非常差。

那么怎么來解決這個效率問題呢?

虛表合並
其實,在派生類的虛表生成好之后還有一個步驟,就是虛表的合並,具體演示如下:
在這里插入圖片描述

將派生類中同名的虛函數覆蓋到基類的虛表中,虛表合成之后,其中一個虛表指針已經沒用了,不如也一並合並了。虛表指針合並的方式為向內層合並。因此,通過這一步虛表合並最終得到了Deriver 了的12字節內存布局。


那么有人就問了:為什么虛表指針合並的方式是向內合並,就不能向外合並嗎?

要知道在繼承中基類的指針是可以指向派生類對象,更加具體的說法是,基類的指針指向派生類對象中基類的起始部分。如果虛表指針向外層合並,那么對應的結構如下圖所示,其中 Base* pb = new Deriver(10);
注:以下結構為錯誤示范
在這里插入圖片描述
而正如我們問到的那樣,如果虛表指針向外層合並的話,我們會發現無法通過虛表指針找到我們的虛表,因為在 Base:: 作用域中已經不存在虛表指針了。並且,當我們想要釋放 new 出的堆區資源時,也不再是用 delete pb 而是 delete (Base*)((char*)pb - 4),因為在申請空間時內存分配的程序往往在被分配出的內存塊“頭部”放上一些校驗信息。釋放時必須從此空間的頭部開始釋放,否則會報 “Expression: is_block_type_valid(header->block_use)”錯誤。而我們申請的內存空間頭部是在 0x100 的位置,而不是 0x104 的位置。這樣在我們實際操作中就會很麻煩。因此,選擇向內層合並就不就有這種問題產生。

因此,虛表指針選擇向內層合並。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM