C++多態分析:虛函數調用是如何實現的?

什么是虛函數?

  1. 簡單來說,虛函數是動態調用。相比于一般的函數調用在編譯期確定了函數地址,而調用虛函數是在運行時決定調用的函數地址。
  2. 虛函數怎么使用相信大家都比較清楚,這里簡單帶過一下。C++中父類的指針可以指向子類實例,通過父類指針調用虛函數時會因為指向的不同的實例類型來調用不同的函數。
  3. C++多態性主要體現在虛函數上,某種程度上來說也只體現在虛函數上。(泛型是否屬于多態這種PL問題我并不能準確回答。)

虛函數是如何實現的?

  1. 虛表指針(vfptr)
  2. 虛函數表(vtable)
  3. 動態調用。

虛表指針

在單繼承情況下,如果父類存在虛函數,子類實例首地址開始4字節(在32位編譯器下)會用來存放虛表指針。比如下面這兩個類:

struct base {
    int x;
    virtual void func1() {
        printf("base func1\n");
    }
};
struct sub:base {
    int y;
};

在這個例子中,父類和子類分別擁有一個4字節成員。如果沒有虛函數的情況下,sub類型所占的空間應該是8字節,但是因為父類中存在虛函數,就需要額外4字節用來存放虛表指針,所以用sizeof(sub)返回的結果會是12字節。

虛函數表

在sub實例開始的4字節所指向的就是虛函數表,這個表可以認為是一個由函數指針組成的數組。現在我們來看一下剛才示例中兩個類的虛表,下面是base實例對象的內存,前4個字節就是虛表指針,后四個字節是成員x,因為debug沒有初始化所以是CC。

base實例對象(其中00420F94指向的就是base的虛表, 虛表只有一個元素就是base::func1的指針
0040101E):

x86內存中因為是小端存儲,所以“看起來”是反的,需要肉眼parse...

0019FF38  94 0F 42 00  // 虛表指針 
0019FF3C  CC CC CC CC

base的虛表:

00420F94  1E 10 40 00 // base::func1的函數指針

base::func1函數:

0040101E E9 4D 00 00 00       jmp         base::func1 (00401070)

sub的虛表內容相信大家也已經想到了,雖然在內存中這是兩張表,但是因為sub并沒有重寫任何虛函數,所以虛表的內容和base是完全一樣的,都是只有一個指向base::func1的函數指針。
sub的虛表:

0042003C  1E 10 40 00 // 和base虛表內容一樣指向base::func1
示例2(重寫了父類虛函數):

寫到這里我發現剛剛的例子可能不太好,因為子類中沒有重寫虛函數,而且只有一個虛函數,沒有直觀的體現出作用。現在讓我們來豐富一下剛才的兩個類。

struct base {
    int x;
    virtual void func1() {
        printf("base func1\n");
    }
    virtual void func2() {
        printf("base func2\n");
    }
};
struct sub:base {
    int y;
    void func1() {
        printf("sub func1\n");
    }
    void func4() {
        printf("sub func4\n");
    }
};
void vPrint(base* ptr) {
    ptr->func1(); // 通過虛表指針動態調用函數
}

在這個例子中,sub重寫了父類的func1函數。并且我們也可以通過傳給vPrint不同類型的指針,來調用不同的函數:

int  main() {
    base b;
    vPrint(&b);
    sub s;
    vPrint(&s);
    return 0;
} 

輸出如下:

base func1
sub func1

這個效果和我們想要的一樣,現在我們再看一下sub的虛函數表。
sub的虛表:

00420058  6E 10 40 00  // sub::func1的指針
0042005C  5A 10 40 00 // base::func2的指針
0040105A E9 11 03 00 00       jmp         base::func2 (00401370)
0040106E E9 AD 03 00 00       jmp         sub::func1 (00401420)

可以看到因為重寫了func1,所以虛表中第一個位置換成了sub::func1。func2沒有被重寫,所以還是和父類一樣指向base::func2. 而func4因為沒有被聲明為虛函數,所以不會在虛表中存在。(func1雖然在子類中沒有聲明確聲明為虛函數,但是C++中規定與父類虛函數同名的函數都會自動被聲明為虛函數,當然這個同名指的是包括整個函數名、返回值、參數列表。)

base的虛表就不貼出來了,因為base沒有繼承其他類,所以base的虛表中只能是func1和func2兩個函數的指針。

動態調用

說了這么久的虛表指針和虛表,現在終于可以看看是如何使用它們來實現動態調用的。
讓我們來看一下vPrint函數的反匯編:

mov         eax,dword ptr [ebp+8]  // ebp+8是vPrint函數的第一個參數base* ptr
mov         edx,dword ptr [eax]    // 將實例對象的前4個字節,也就是虛表指針放在edx中
mov         ecx,dword ptr [ebp+8]  // 傳參this指針,不用管
call        dword ptr [edx]        // call虛表中的第一個元素,根據傳進來的對象的虛表不同,調用不同的函數

可以清晰的看到call的函數地址并不是一個立即數,而是edx指向的數據。這就是動態調用實現的關鍵了,根據對象中攜帶的虛表指針,來調用不同對象關聯的不同函數。

總結

虛函數調用是通過call虛表數據來實現運行時調用。傳遞的參數類型不同,虛表指針就不同、虛表指針不同,指向的虛函數表就不同、虛函數表不同,指向的函數就不同。

附言

這篇文章中只講了單繼承的情況,而在多繼承的情況下子類對象實例中就會有多個虛表指針,為了不使文章太過冗長,就不一一列出來了。

大家感興趣的可以自己動手試一下,看看多繼承的情況下內存分布如何,在傳參時的偏移如何。

趙克,寫于2017年01月13日。


如需轉載請與我聯系,并注明出處。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 原文地址:C語言函數調用棧(一)C語言函數調用棧(二) 0 引言 程序的執行過程可看作連續的函數調用。當一個函數執...
    小豬啊嗚閱讀 4,686評論 1 19
  • 一個博客,這個博客記錄了他讀這本書的筆記,總結得不錯。《深度探索C++對象模型》筆記匯總 1. C++對象模型與內...
    Mr希靈閱讀 5,642評論 0 13
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,766評論 18 399
  • 1. C++基礎知識點 1.1 有符號類型和無符號類型 當我們賦給無符號類型一個超出它表示范圍的值時,結果是初始值...
    Mr希靈閱讀 18,040評論 3 82
  • C++虛函數 C++虛函數是多態性實現的重要方式,當某個虛函數通過指針或者引用調用時,編譯器產生的代碼直到運行時才...
    小白將閱讀 1,755評論 4 19