虛函數(shù)表在對(duì)象內(nèi)存中的布局

  • 步驟一,先表述虛函數(shù)表的3個(gè)特性來(lái)做引子:

1, 單繼承時(shí),虛函數(shù)表指針通常存儲(chǔ)在類對(duì)象“內(nèi)存布局”的最前面。

2,虛函數(shù)表實(shí)質(zhì)上是一個(gè)“函數(shù)指針”的數(shù)組,該數(shù)組最后一個(gè)元素必然為0。(很多博客上都說(shuō)虛函數(shù)表的最后一個(gè)元素是0,但我自己用vs2010做的實(shí)驗(yàn)有時(shí)候不是0)。

3,一個(gè)有虛函數(shù)(無(wú)論是繼承得到的虛函數(shù)還是自身有的)的類,該類的所有對(duì)象,都共用一份虛函數(shù)表。

? ?? ?每個(gè)對(duì)象有一套(這里用套而不用個(gè),是因?yàn)槎嘀乩^承時(shí),可能有多個(gè)指針組成的一套)“虛函數(shù)表指針”,指向該虛函數(shù)表。

  • 步驟二,下面來(lái)證明上面幾個(gè)特性,并推導(dǎo)出類對(duì)象的內(nèi)存布局。
//VC++ 32位編譯器下
#include "stdafx.h"
#include <stdio.h>
#include <iostream>
using namespace std;

typedef void(*FUNC)();
class A{
public:
    virtual void func(){
        cout << "  A::func" << endl;
    }
    virtual void funcA(){
        cout << "  A::funcA" << endl;
    }
public:
    int a;
};

class B:public A{
public:
    virtual void func(){
        cout << "  B::func" << endl;
    }
    virtual void funcB(){
        cout << "  B::funcB" << endl;
    }
public:
    int b;
};

int main()
{
    B b1;
    B b2;

    printf("\n b1對(duì)象的首地址里面存放的虛函數(shù)表的指針是:0x%x\n", (*(int*)&b1));
    ((FUNC)(  *(int*)*((int*)&b1)  ))();
    ((FUNC)*((int*)*((int*)&b1) + 1))();


    printf(" b2對(duì)象的首地址里面存放的虛函數(shù)表的指針是:0x%x\n", (*(int*)&b2));

    system("pause");
    return 0;
}

輸出結(jié)果:
image.png

由輸出結(jié)果可以看出,程序正確調(diào)用了兩個(gè)虛函數(shù)(先不管調(diào)用的是什么虛函數(shù)),所以找到的虛函數(shù)表的指針是正確的。步驟一中的1得到證實(shí)。又因?yàn)?b1 和 b2 虛函數(shù)表的指針值是相同的,所以步驟一中的3也得到了證實(shí)。

  • 步驟三,再來(lái)看一個(gè)單繼承的例子
//VC++  32位編譯器下:
#include "stdafx.h"
#include <iostream>
using namespace std;

//單繼承下虛函數(shù)表:是如何組織的
class A{
public:
    virtual void func(){
        cout << "A::funccommon" << endl;
    }
    virtual void funcA(){
        cout << "A::funcA" << endl;
    }
};
 
class B:public A{
public:
    virtual void func(){
        cout << "B::funccommon" << endl;
    }
    virtual void funcB(){
        cout << "B::funcB" << endl;
    }
};
 
 
class C:public A{
public:
    virtual void func(){
        cout << "C::funccommon" << endl;
    }
    virtual void funcC(){
        cout << "C::funcC" << endl;
    }
};
 
typedef void (*FUNC)();
int main()
{
    A a;
    B b;
    C c;

    cout << "A::虛表:" << endl;
    ((FUNC)(*(int*)(*(int*)(&a))))();
    ((FUNC)(*((int*)(*(int*)(&a)) + 1)))();
    cout << "-------------------------------------" << endl;

    cout << "B::虛表:" << endl;
    ((FUNC)(*(int *)(*(int*)(&b))))();
    ((FUNC)(*((int*)(*(int*)(&b)) + 1)))();
    ((FUNC)(*((int*)(*(int*)(&b)) + 2)))();
    cout << "-------------------------------------" << endl;
 
    cout << "C::虛表:" << endl;
    ((FUNC)(*(int *)(*(int*)(&c))))();
    ((FUNC)(*((int*)(*(int*)(&c)) + 1)))();
    ((FUNC)(*((int*)(*(int*)(&c)) + 2)))();
    system("pause");
    return 0;
}

輸出結(jié)果:
image.png

在分析輸出結(jié)果之前,先看一下這句代碼是什么意思?

              (    (FUNC)   (  *(int*)  (*(int*)(&a))  )    )();

????(*(int*)(&a))的意思是,從對(duì)象 a 的起始地址所指向的那個(gè)字節(jié)的位置算起,取4個(gè)字節(jié)的一個(gè)整形值。我們知道,在VC++ 32位編譯器下,指針和 int 型一樣,也是占4個(gè)字節(jié)。

????所以實(shí)際上,取出來(lái)的這個(gè)整形值,也可以看作是一個(gè)地址(也稱指針,實(shí)際上該指針就是對(duì)象a指向虛函數(shù)表的指針)。

????( *(int*) ?(*(int*)(&a)) )的意思是,將上面取出的指針,強(qiáng)制轉(zhuǎn)換為 int* 的指針,然后取出該指針?biāo)傅恼沃担ㄍ瑯?,也可以看作是一個(gè)地址),該整形值事實(shí)上是一個(gè)函數(shù)指針。

????由以上分析可以對(duì)代碼進(jìn)行解釋:


QQ截圖20181029164850.png

????我們?cè)賮?lái)看剛才的輸出結(jié)果,我們對(duì)虛函數(shù)表,按照由數(shù)組首地址,計(jì)算偏移獲取到數(shù)組元素的做法,獲取了A, B, C三個(gè)類里的虛函數(shù)的指針,并且都調(diào)用成功了。
????由此步驟一中的2也得到了證實(shí)。

  • 步驟四,如果派生類的虛函數(shù)和基類的虛函數(shù)相同,即派生類的虛函數(shù)“覆蓋”了基類的虛函數(shù),則在派生類的虛函數(shù)表中,只有派生類的那個(gè)虛函數(shù)。如果是派生類新增的虛函數(shù),則將該虛函數(shù)追加到派生類虛函數(shù)表的末尾。如下圖,是類B的虛函數(shù)表的產(chǎn)生過(guò)程:
untitled.png
  • 步驟五,虛函數(shù)表的生成的時(shí)間

這里說(shuō)明下一個(gè)問(wèn)題,我用 VS2010 的 Debug 模式來(lái)調(diào)試 步驟三 里面的代碼,然后在監(jiān)視窗口里查看變量b, c的虛函數(shù)表,發(fā)現(xiàn)只能查看到 從基類繼承下來(lái)的派生類覆蓋的 虛函數(shù),不能看到派生類自己追加的虛函數(shù)。

而且對(duì)于對(duì) 虛函數(shù)表 沒有深刻理解的人來(lái)說(shuō),VS2010 的顯示方式讓人容易誤解,認(rèn)為存儲(chǔ)的是基類的虛函數(shù)表的指針。如下圖:

image.png

VS運(yùn)行起來(lái),可以打印上圖 b 和 c 的_vfptr[2],但是不明白為什么在調(diào)試器里不能查看到。

??????現(xiàn)在在 linux 下用 gdb 來(lái)查看:


image.png

注:上圖中,因?yàn)槲业膅++編譯器是64位的,所以把 int 改成了 long long 類型。因?yàn)樵趃++ 64位編譯器下,指針是占 8個(gè)字節(jié)的,long long 類型也是占8個(gè)字節(jié)的。當(dāng)然直接用 long 也行。

程序構(gòu)建(Build)的四個(gè)過(guò)程(預(yù)編譯、編譯、匯編和鏈接)
虛函數(shù)表應(yīng)該是在編譯期確定的,原因如下:

1)預(yù)編譯器主要處理那些源代碼文件中的以“#”開始的預(yù)編譯指令,如“#include”、“#define”。很明顯這個(gè)過(guò)程可以排除。

2)匯編器是將編譯器生成的匯編代碼轉(zhuǎn)變成機(jī)器可以執(zhí)行的指令,每一個(gè)匯編語(yǔ)句幾乎都對(duì)應(yīng)一條機(jī)器指令。匯編過(guò)程相對(duì)于編譯期來(lái)說(shuō)比較簡(jiǎn)單,沒有復(fù)雜的語(yǔ)法,也沒有語(yǔ)義,也不需要做指令優(yōu)化,只是根據(jù)匯編指令和機(jī)器指令的對(duì)照表一一翻譯就行了。所以,匯編期也是可以排除的。

3)鏈接器(現(xiàn)只考慮靜態(tài)鏈接)是將匯編器生成的目標(biāo)文件(和庫(kù))鏈接成一個(gè)可執(zhí)行文件,本質(zhì)上做的是重定位(Relocation)的工作,詳細(xì)可參考《程序員的自我修養(yǎng)》2.3、2.4節(jié)。很明顯鏈接期也是可以排除的。

4)編譯器要做的事情就比較多了,包括詞法分析、語(yǔ)法分析、語(yǔ)義分析及優(yōu)化代碼等,是整個(gè)程序構(gòu)建的核心。所以,排除了預(yù)編譯期、匯編期、鏈接期及考慮到編譯期所做的事情,虛函數(shù)表應(yīng)該是在編譯期建立的。


為什么不是在運(yùn)行時(shí)確定的呢?
C++是編譯型語(yǔ)言,當(dāng)然是在編譯階段把能夠做的工作都做完,執(zhí)行起來(lái)效率更高。像多態(tài)那種因?yàn)橛脩粜袨闀?huì)影響執(zhí)行路徑的,才不得不在執(zhí)行階段確定。

  • 步驟五,虛函數(shù)表存放在進(jìn)程(在磁盤上稱 ”可執(zhí)行文件”,在內(nèi)存中就稱 “進(jìn)程”)的哪個(gè)區(qū)?

???用readelf命令查看,這個(gè)以后再回來(lái)學(xué)習(xí)并補(bǔ)充。開始因?yàn)椴恢肋€有readelf這種命令,也不知道有elf文件這種格式,我愚蠢地去學(xué)習(xí)了一下匯編,想用反匯編的方法看進(jìn)程在內(nèi)存中分布,搞得花了一天的時(shí)間來(lái)折騰,還沒有好的結(jié)果。再寫的時(shí)候參考一下 https://blog.csdn.net/chenlycly/article/details/53377942 這篇博文。

???借用別人博客的一句話先告訴結(jié)果:vtable在Linux/Unix中存放在可執(zhí)行文件的只讀數(shù)據(jù)段中(rodata),而微軟的編譯器將虛函數(shù)表存放在常量段

  • 步驟六,多重繼承時(shí) ,派生類對(duì)象的內(nèi)存布局

多重繼承的概念:網(wǎng)上很多人的博客對(duì)多繼承多重繼承兩個(gè)概念有不同的解釋,搞得很混亂。尤其是多重繼承,有些博客錯(cuò)得離譜,說(shuō)類C繼承自類B,類B繼承自類A,這樣叫多重繼承。對(duì)多繼承歧義的倒比較少。這里我統(tǒng)一一下概念,根據(jù)C++ Primer中文版(第4版) 的說(shuō)法:

??????多重繼承是從多于一個(gè)直接基類派生類的能力,多重繼承的派生類繼承其所有父類的屬性。

??????多繼承 與 多重繼承實(shí)際上是一個(gè)概念。

//多繼承條件下的虛函數(shù)表

#include "stdafx.h"
#include <iostream>  
using namespace std;  
  
#include<iostream>
using namespace std;

class A
{
public:
    virtual void fun1()
    {
        printf("A::virtual void fun(int n)\n");
    }
    int _a;
};

class B
{
public:
    virtual void fun2()
    {
        printf("B::virtual void fun(int n)\n");
    }
    int _b;
};

class C:public A,public B
{
public:
    int _c;
};

int main()
{
    A a;
    B b;
    C c;
    a._a = 1;
    b._b = 2;
    c._a = 3;
    c._b = 4;
    c._c = 5;
    printf("%p\n", &a);
    printf("%p\n", &b);
    printf("%p\n", &c);
    return 0;
}

內(nèi)存窗口分析:


index.png
33.png
  • 步驟七,虛繼承的作用

??????虛繼承是解決C++多重繼承問(wèn)題的一種手段,從不同途徑繼承來(lái)的同一基類,會(huì)在子類中存在多份拷貝。

這將存在兩個(gè)問(wèn)題:

其一,浪費(fèi)存儲(chǔ)空間;

第二,存在二義性問(wèn)題,通??梢詫⑴缮悓?duì)象的地址賦值給基類對(duì)象,實(shí)現(xiàn)的具體方式是,將基類指針指向繼承類(繼承類有基類的拷貝)中的基類對(duì)象的地址,但是多重繼承可能存在一個(gè)基類的多份拷貝,這就出現(xiàn)了二義性。

??????虛繼承是為了解決上述兩個(gè)問(wèn)題而產(chǎn)生的。

??????這個(gè)比較復(fù)雜,用一篇博客里的說(shuō)法就是,其復(fù)雜度遠(yuǎn)遠(yuǎn)大于它的使用價(jià)值,不想花太多時(shí)間研究,僅僅知道其用法就行了。

??????如果很想知道,可以參考一下https://blog.csdn.net/zhourong0511/article/details/79950847這篇博客的最后一張圖,那里講的菱形繼承里有虛繼承的內(nèi)容。

? ? ? 參考了以下兩篇博客:
1,https://blog.csdn.net/zongyinhu/article/details/51276806?tdsourcetag=s_pcqq_aiomsg。發(fā)現(xiàn)作者講得很透徹,為了我能完全弄懂并記住虛函數(shù)表的有關(guān)問(wèn)題,現(xiàn)在用自己的話整理出來(lái),并發(fā)布。

2,https://blog.csdn.net/zhourong0511/article/details/79950847使用的內(nèi)存表示方法非常好,讓我完全看懂了,不過(guò)博客中有少量錯(cuò)誤和歧義的內(nèi)容。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容