C++ 虛函數表

C++類在記憶體中的存儲方式

C++ 記憶體分為 5 個區域:

  • 堆 heap :由 new 分配的記憶體塊,其釋放編譯器不去管,由程式設計師自己控制。如果程式設計師沒有釋放掉,在程式結束時系統會自動回收。涉及的問題:「緩衝區溢出」、「記憶體泄露」。
  • 棧 stack :是那些編譯器在需要時分配,在不需要時自動清除的存儲區。存放局部變數、函數參數。存放在棧中的數據只在當前函數及下一層函數中有效,一旦函數返回了,這些數據也就自動釋放了。
  • 全局/靜態存儲區 (.bss段和.data段) :全局和靜態變數被分配到同一塊記憶體中。在 C 語言中,未初始化的放在.bss段中,初始化的放在.data段中;在 C++ 里則不區分了。
  • 常量存儲區 (.rodata段) :存放常量,不允許修改(通過非正當手段也可以修改)。
  • 程式碼區 (.text段) :存放程式碼(如函數),不允許修改(類似常量存儲區),但可以執行(不同於常量存儲區)。

注意:靜態局部變數也存儲在全局/靜態存儲區,作用域為定義它的函數或語句塊,生命周期與程式一致。

其中對象數據中存儲非靜態成員變數、虛函數表指針以及虛基類表指針(如果繼承多個)。這裡就有一個問題,既然對象里不存儲類的成員函數的指針,那類的對象是怎麼調用公用函數程式碼的呢?對象對公用函數程式碼的調用是在編譯階段就已經決定了的,例如有類對象a,成員函數為show(),如果有程式碼a.show(),那麼在編譯階段會解釋為 類名::show(&a)。會給show()傳一個對象的指針,即this指針。

從上面的this指針可以說明一個問題:靜態成員函數和非靜態成員函數都是在類的定義時放在記憶體的程式碼區的,但是類為什麼只能直接調用靜態成員函數,而非靜態成員函數(即使函數沒有參數)只有類對象能夠調用的問題?原因是類的非靜態成員函數其實都內含了一個指向類對象的指針型參數(即this指針),因而只有類對象才能調用(此時this指針有實值)。

虛函數表

C++中虛函數是通過一張虛函數表(Virtual Table)來實現的,在這個表中,主要是一個類的虛函數表的地址表;這張表解決了繼承、覆蓋的問題。在有虛函數的類的實例中這個表被分配在了這個實例的記憶體中,所以當我們用父類的指針來操作一個子類的時候,這張虛函數表就像一張地圖一樣指明了實際所應該調用的函數。

C++編譯器是保證虛函數表的指針存在於對象實例中最前面的位置(是為了保證取到虛函數表的最高的性能),這樣我們就能通過已經實例化的對象的地址得到這張虛函數表,再遍歷其中的函數指針,並調用相應的函數。

C++對象的記憶體布局(x86環境)

只有數據成員的對象

#include<iostream>

class Base1 {
public:
    int base1_1;
    int base1_2;
};
int main() {
    std::cout << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
	return 0;
}

運行結果
在這裡插入圖片描述

可以看到,成員變數是按照定義的順序來保存的,最先聲明的在最上邊,然後依次保存!類對象的大小就是所有成員變數大小之和(嚴格說是成員變數記憶體對齊之後的大小之和)。

擁有僅一個虛函數的類對象

#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    Base1 b1;
	return 0;
}

運行結果:
在這裡插入圖片描述
多了4個位元組?且 base1_1 和 base1_2 的偏移都各自向後多了4個位元組!說明類對象的最前面被多加了4個位元組的東西。

現在, 我們通過VS2022來瞧瞧類Base1的變數b1的記憶體布局情況:虛函數指針_vfptr位於所有的成員變數之前定義。
在這裡插入圖片描述

  • 由於我沒有寫構造函數,所以變數的數據沒有根據,但虛函數是編譯器為我們構造的,數據正確!
  • Debug模式下,未初始化的變數值為0xCCCCCCCC,即:-858983460。

base1_1 前面多了一個變數 _vfptr (常說的虛函數表 vtable 指針),其類型為void**,這說明它是一個void*指針(注意不是數組)。

再看看[0]元素, 其類型為void*,其值為 ConsoleApplication2.exe!Base1::base1_fun1(void),這是什麼意思呢?如果對 WinDbg 比較熟悉,那麼應該知道這是一種慣用表示手法,她就是指 Base1::base1_fun1() 函數的地址。

可得,__vfptr的定義偽程式碼大概如下:

void*        __fun[1] = { &Base1::base1_fun1 };
const void** __vfptr = &__fun[0];

大家有沒有留意這個__vfptr?為什麼它被定義成一個 指向指針數組的指針,而不是直接定義成一個 指針數組呢?我為什麼要提這樣一個問題?因為如果僅是一個指針的情況,您就無法輕易地修改那個數組裡面的內容,因為她並不屬於類對象的一部分。屬於類對象的, 僅是一個指向虛函數表的一個指針_vfptr而已,注意到_vfptr前面的const修飾,她修飾的是那個虛函數表, 而不是__vfptr。

我們來用程式碼調用一下:

/**
* x86
*/
#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {
        std::cout << "Base1::base1_fun1" << std::endl;
    }
    virtual void base1_fun2() {
        std::cout << "Base1::base1_fun2" << std::endl;
    }
};
/*
對(int*)*(int*)(&b)可以這樣理解,(int*)(&b)就是對象b的地址,只不過被強制轉換成了int*了,如果直接調用*(int*)(&b)則是指向對象b地址所指向的數據,但是此處是個虛函數表呀,所以指不過去,必須通過(int*)將其轉換成函數指針來進行指向就不一樣了,它的指向就變成了對象b中第一個函數的地址,所以(int*)*(int*)(&b)就是獨享b中第一個函數的地址;
又因為pFun是由Fun這個函數聲明的函數指針,所以相當於是Fun的實體,必須再將這個地址轉換成pFun認識的,即加上(Fun)*進行強制轉換:簡要概括就是從b地址開始
讀取四個位元組的內容,然後將這個內容解釋成一個記憶體地址,然後訪問這個地址,然後將這個地址中存放的值再解釋成一個函數的地址.
*/
typedef void(*Fn)(void);

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    Base1 b1;
    Fn fn = nullptr;
    std::cout << "虛函數表的地址為:" << (int*)(&b1) << std::endl;
    std::cout << "虛函數表的第一個函數地址為:" << (int*)*(int*)(&b1) << std::endl;
    fn = (Fn) * ((int*)*(int*)(&b1) + 0);
    fn();
    fn = (Fn) * ((int*)*(int*)(&b1) + 1);
    fn();
	return 0;
}

在這裡插入圖片描述

擁有多個虛函數的類對象

在上個程式碼調用虛函數的日子中你有沒有注意到,多了一個虛函數, 類對象大小卻依然是12個位元組!
再看看VS形象的表現,_vfptr所指向的函數指針數組中出現了第2個元素,其值為Base1類的第2個虛函數base1_fun2()的函數地址。

現在, 虛函數指針以及虛函數表的偽定義大概如下:

void*        __fun[] = { &Base1::base1_fun1, &Base1::base1_fun2 };
const void** __vfptr = __fun[0];

通過上面圖表, 我們可以得到如下結論:

  • 更加肯定前面我們所描述的: __vfptr只是一個指針, 她指向一個函數指針數組(即: 虛函數表)
  • 增加一個虛函數, 只是簡單地向該類對應的虛函數表中增加一項而已, 並不會影響到類對象的大小以及布局情況

前面已經提到過: __vfptr只是一個指針,她指向一個數組,並且:這個數組沒有包含到類定義內部,那麼她們之間是怎樣一個關係呢?

不妨,我們再定義一個類的變數b2,現在再來看看__vfptr的指向:
在這裡插入圖片描述

通過窗口我們看到:

  • b1和b2是類的兩個變數,理所當然,她們的地址是不同的(見 &b1 和 &b2)
    雖然b1和b2是類的兩個變數, 但是她們的__vfptr的指向卻是同一個虛函數表

由此我們可以總結出:同一個類的不同實例共用同一份虛函數表, 她們都通過一個所謂的虛函數表指針__vfptr(定義為void**類型)指向該虛函數表。
在這裡插入圖片描述
那麼問題就來了! 這個虛函數表保存在哪裡呢?

  1. 虛函數表是全局共享的元素,即全局僅有一個.
  2. 虛函數表類似一個數組,類對象中存儲vptr指針,指向虛函數表。即虛函數表不是函數,不是程式程式碼,不肯能存儲在程式碼段。
  3. 虛函數表存儲虛函數的地址,即虛函數表的元素是指向類成員函數的指針,而類中虛函數的個數在編譯時期可以確定,即虛函數表的大小可以確定,即大小是在編譯時期確定的,不必動態分配記憶體空間存儲虛函數表,所以不再堆中。

根據以上特徵,虛函數表類似於類中靜態成員變數。靜態成員變數也是全局共享,大小確定。

所以我推測虛函數表和靜態成員變數一樣,存放在全局數據區。其實,我們無需過分追究她位於哪裡,重點是:

  • 她是編譯器在編譯時期為我們創建好的, 只存在一份;
  • 定義類對象時, 編譯器自動將類對象的__vfptr指向這個虛函數表;

單繼承且本身不存在虛函數的繼承類的記憶體布局

#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
    virtual void base1_fun2() {}
};

class Derive1 : public Base1
{
public:
    int derive1_1;
    int derive1_2;
};

int main() {
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
	return 0;
}

在這裡插入圖片描述
現在類的布局情況應該是下面這樣:
在這裡插入圖片描述

本身不存在虛函數(不嚴謹)但存在基類虛函數覆蓋的單繼承類的記憶體布局

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
    virtual void base1_fun2() {}
};

class Derive1 : public Base1
{
public:
    int derive1_1;
    int derive1_2;

    // 覆蓋基類函數
    virtual void base1_fun1() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
	return 0;
}

在這裡插入圖片描述
特別注意那一行:原本是Base1::base1_fun1(),但由於繼承類重寫了基類Base1的此方法,所以現在變成了Derive1::base1_fun1()!

那麼, 無論是通過Derive1的指針還是Base1的指針來調用此方法,調用的都將是被繼承類重寫後的那個方法(函數),多態發生了!!!

那麼新的布局圖:
在這裡插入圖片描述

定義了基類沒有的虛函數的單繼承的類對象布局

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
    virtual void base1_fun2() {}
};

class Derive1 : public Base1
{
public:
    int derive1_1;
    int derive1_2;
	//和上上個類不同的是多了一個自身定義的虛函數. 和上個類不同的是沒有基類虛函數的覆蓋.
    virtual void derive1_fun1() {}
};

在這裡插入圖片描述
為嘛呢?現在繼承類明明定義了自身的虛函數,但不見了?類對象的大小,以及成員偏移情況居然沒有變化!
既然表面上沒辦法了, 我們就只能從彙編入手了, 來看看調用derive1_fun1()時的程式碼:

Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun1();

要注意:我為什麼使用指針的方式調用?說明一下:因為如果不使用指針調用,虛函數調用是不會發生動態綁定的哦!你若直接 d1.derive1_fun1();是不可能會發生動態綁定的,但如果使用指針:pd1->derive1_fun1(); 那麼 pd1就無從知道她所指向的對象到底是Derive1 還是繼承於Derive1的對象,雖然這裡我們並沒有對象繼承於Derive1,但是她不得不這樣做,畢竟繼承類不管你如何繼承,都不會影響到基類,對吧?

    pd1->derive1_fun1();
004A2233  mov         eax,dword ptr [pd1]  
004A2236  mov         edx,dword ptr [eax]  
004A2238  mov         esi,esp  
004A223A  mov         ecx,dword ptr [pd1]  
004A223D  mov         eax,dword ptr [edx+8]  
004A2240  call        eax  

彙編程式碼解釋:

  • 第2行:由於pd1是指向d1的指針,所以執行此句後 eax 就是d1的地址。
  • 第3行:又因為Base1::__vfptr是Base1的第1個成員,同時也是Derive1的第1個成員,那麼: &__vfptr == &d1, clear?所以當執行完 mov edx, dword ptr[eax] 後,edx就得到了__vfptr的值,也就是虛函數表的地址。
  • 第5行:由於是__thiscall調用,所以把this保存到ecx中。
    第6行:一定要注意到那個 edx+8,由於edx是虛函數表的地址,那麼 edx+8將是虛函數表的第3個元素,也就是__vftable[2]!
  • 第7行:調用虛函數。

結果:

  • 現在我們應該知道內幕了!繼承類Derive1的虛函數表被加在基類的後面!事實的確就是這樣!
  • 由於Base1隻知道自己的兩個虛函數索引 [0][1], 所以就算在後面加上了[2],Base1根本不知情,不會對她造成任何影響。
  • 如果基類沒有虛函數呢?

記憶體布局
在這裡插入圖片描述

多繼承且存在虛函數覆蓋同時又存在自身定義的虛函數的類對象布局

#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
    virtual void base1_fun2() {}
};

class Base2
{
public:
    int base2_1;
    int base2_2;

    virtual void base2_fun1() {}
    virtual void base2_fun2() {}
};

// 多繼承
class Derive1 : public Base1, public Base2
{
public:
    int derive1_1;
    int derive1_2;

    // 基類虛函數覆蓋
    virtual void base1_fun1() {}
    virtual void base2_fun2() {}

    // 自身定義的虛函數
    virtual void derive1_fun1() {}
    virtual void derive1_fun2() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
	return 0;
}

在這裡插入圖片描述
結論:

  • 按照基類的聲明順序,基類的成員依次分布在繼承中。
  • 已經發生了虛函數覆蓋!
  • 我們自己定義的虛函數呢?怎麼還是看不見?

繼承反彙編,這次的調用程式碼如下:

Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();

反彙編程式碼如下:

pd1->derive1_fun2();
0008631E  mov         eax,dword ptr [pd1]  
00086321  mov         edx,dword ptr [eax]  
00086323  mov         esi,esp  
00086325  mov         ecx,dword ptr [pd1]  
00086328  mov         eax,dword ptr [edx+0Ch]  
0008632B  call        eax  

解釋:

  • 第2行: 取d1的地址
  • 第3行: 取Base1::__vfptr的值
  • 第6行: 0x0C, 也就是第4個元素(下標為[3])

結論:Derive1的虛函數表依然是保存到第1個擁有虛函數表的那個基類的後面的.

類對象布局圖:
在這裡插入圖片描述

如果第1個直接基類沒有虛函數(表)

#include<iostream>
class Base1
{
public:
    int base1_1;
    int base1_2;
};

class Base2
{
public:
    int base2_1;
    int base2_2;

    virtual void base2_fun1() {}
    virtual void base2_fun2() {}
};

// 多繼承
class Derive1 : public Base1, public Base2
{
public:
    int derive1_1;
    int derive1_2;

    // 自身定義的虛函數
    virtual void derive1_fun1() {}
    virtual void derive1_fun2() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
    Derive1* pd1 = &d1;
    pd1->derive1_fun2();
	return 0;
}

在這裡插入圖片描述
Base1已經沒有虛函數表了嗎?重點是看虛函數的位置,進入函數調用(和前一次是一樣的):

Derive1 d1;
Derive1* pd1 = &d1;
pd1->derive1_fun2();

反彙編調用程式碼:

pd1->derive1_fun2();
008F667E  mov         eax,dword ptr [pd1]  
008F6681  mov         edx,dword ptr [eax]  
008F6683  mov         esi,esp  
008F6685  mov         ecx,dword ptr [pd1]  
008F6688  mov         eax,dword ptr [edx+0Ch]  
008F668B  call        eax  

這段彙編程式碼和前面一個完全一樣,那麼問題就來了,Base1 已經沒有虛函數表了,為什麼還是把Base1的第1個元素當作__vfptr呢?

不難猜測: 當前的布局已經發生了變化, 有虛函數表的基類放在對象記憶體前面?不過事實是否屬實?需要仔細斟酌。

我們可以通過對基類成員變數求偏移來觀察:
在這裡插入圖片描述
所以不難驗證: 我們前面的推斷是正確的, 誰有虛函數表, 誰就放在前面!
現在類的布局情況:
在這裡插入圖片描述

兩個基類都沒有虛函數表

#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;
};

class Base2
{
public:
    int base2_1;
    int base2_2;
};

// 多繼承
class Derive1 : public Base1, public Base2
{
public:
    int derive1_1;
    int derive1_2;

    // 自身定義的虛函數
    virtual void derive1_fun1() {}
    virtual void derive1_fun2() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
    Derive1* pd1 = &d1;
    pd1->derive1_fun2();
	return 0;
}

在這裡插入圖片描述
可以看到, 現在__vfptr已經獨立出來了, 不再屬於Base1和Base2!
再看看偏移:
在這裡插入圖片描述
&d1==&d1.__vfptr 說明虛函數始終在最前面!

記憶體布局:
在這裡插入圖片描述

如果有三個基類: 虛函數表分別是有,沒有,有!

#include<iostream>

class Base1
{
public:
    int base1_1;
    int base1_2;

    virtual void base1_fun1() {}
    virtual void base1_fun2() {}
};

class Base2
{
public:
    int base2_1;
    int base2_2;
};

class Base3
{
public:
    int base3_1;
    int base3_2;

    virtual void base3_fun1() {}
    virtual void base3_fun2() {}
};

// 多繼承
class Derive1 : public Base1, public Base2, public Base3
{
public:
    int derive1_1;
    int derive1_2;

    // 自身定義的虛函數
    virtual void derive1_fun1() {}
    virtual void derive1_fun2() {}
};

int main() {
    std::cout << "地址偏移:" << sizeof(Base1) << " " << offsetof(Base1, base1_1) << " " << offsetof(Base1, base1_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Base2) << " " << offsetof(Base2, base2_1) << " " << offsetof(Base2, base2_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Base3) << " " << offsetof(Base3, base3_1) << " " << offsetof(Base3, base3_2) << std::endl;
    std::cout << "地址偏移:" << sizeof(Derive1) << " " << offsetof(Derive1, derive1_1) << " " << offsetof(Derive1, derive1_2) << std::endl;
    Derive1 d1;
    Derive1* pd1 = &d1;
    pd1->derive1_fun2();
	return 0;
}

在這裡插入圖片描述
記憶體布局:
在這裡插入圖片描述
只需知道: 誰有虛函數表, 誰就往前靠!