C++中的out-of-line虛函數


引子

在現實編碼過程中,曾經遇到過這樣的問題“warning:’Base’ has no out-of-line method definition; its vtable will be emitted in every translation unit”。由於對這個warning感興趣,於是搜集了相關資料來解釋這個warning相關的含義。

    C++虛表內部架構
    Vague Linkage
    out-of-line virtual method

C++虛表內部架構

在C++實現機制RTTI中,我們大概談到過C++虛表的組織結構。 但是我們對C++虛表的詳細實現細節並沒有具體談及,例如在繼承體系下虛表的組織以及在多重繼承下虛表的組織方式。

(1)沒有繼承情況下的類虛表結構

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void Add() { cout << "Base Virtual Add()!"<< "\n";  }
    virtual void Sub() { cout << "Base Virtual Sub()!" << "\n"; }
    virtual void Div() { cout << "Base Virtual Div()!" << "\n"; }
};

int main()
{
    Base* b = new Base();
    b->Add();
    b->Sub();
    b->Div();
    return 0;
}

 



由於虛函數調用時的行為由指針或者引用所關聯的對象所決定,當然我們已經知道虛表指針存放在對象頭4個字節,對象b的值“0x00e8ac38”,調出內存監視器,查看該內存的情況,如下圖所示:

 

 


對象b只存放了虛表的指針“0x00a3cc74”,后面的“0xfdfdfdfd”為Visual Studio在Debug模式下,堆內存上的守護字節。我們跳轉到“0x00a3cc74”查看該內存到底存放了什么,如下圖所示:

 

 



這三個字存放的數據就是Base類三個虛函數所存放的虛函數地址,我們驗證下,我查看調用”b->Add()”時,跳轉地址為“0x00a31483”,如下圖所示:


和虛表所存放的第一個slot的數據進行比對,是相同的。畫出虛表示意圖如下所示:

 
注意,這里虛表結構和C++實現機制RTTI中中的略有差異,那里type_info信息存放在虛表頭,這里存放在虛表尾,由於虛表實現是編譯器相關,只要理解用於RTTI的type_info和虛表相關即可。

(2)存在繼承時的虛表結構

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void Add() { cout << "Base Virtual Add()!"<< "\n";  }
    virtual void Sub() { cout << "Base Virtual Sub()!" << "\n"; }
    virtual void Div() { cout << "Base Virtual Div()!" << "\n"; }
};

class Derive : public Base
{
public:
    // 定義Drived類的Sub函數,與父類Base的Sub不同
    virtual void Sub() { cout << "Derive Virtual Sub()!" << "\n"; }
};

int main()
{
    Base* b = new Base();
    b->Add();
    Base* d = new Derive();
    d->Add();
    return 0;
}


Base虛表信息如下圖所示:


Derived虛表信息如下圖所示:

 

 
從這兩幅圖中可以看到,兩張圖中虛表唯一的不同是,虛表第二項不一樣。Derive自定義了Sub()函數,所以理應相應的虛表應該指向Derive新定義的函數位置,而Derive繼承了(沒有覆蓋Add()和Div()函數)Add()和Div()函數,所以第一項和第三項和Base類虛表的第一項和第三項相同。

 

 



也就是說子類重寫了相應的虛函數,那么虛表中相應位置的地址會指向新的函數,沒有重寫那么相應位置的地址和父類相同。

(3)在多重繼承下
代碼如下,Derive繼承自Base1和Base2,也就是說Derive從兩個父類繼承了兩個虛表,現在的問題是,兩個虛表會不會合並到一起呢?如下圖所示:

 

 



這種形式的話,只有從第一個父類繼承的虛表的下標和父類虛表的下標是相同的,后面的虛表都要移動一定的偏移量,這樣做顯然不太漂亮。所以現在Visual Studio不是通過這樣的方式,而是將從父類繼承的多個虛表分開,以每個父類為一個單位,如下圖所示。

 

 



如果子類覆蓋了相應父類的虛函數,則會在相應父類的內存區域頭部虛表指針所對應的虛表上覆蓋掉對應的虛函數地址。

#include <iostream>
using namespace std;

class Base1
{
public:
    int m_base1;
    Base1(int para):m_base1(para){}
    virtual void Add() { cout << "Base1 Virtual Add()!"<< "\n";  }
    virtual void Sub() { cout << "Base1 Virtual Sub()!" << "\n"; }
    virtual void Div() { cout << "Base1 Virtual Div()!" << "\n"; }
};

class Base2
{
public:
    int m_base2;
    Base2(int para) : m_base2(para){}
    virtual void Mul() { cout << "Base2 Virtual Mul()!" << "\n"; }
    virtual void INC() { cout << "Base2 Virtual INC()!" << "\n"; }
    virtual void DEC() { cout << "Base2 Virtual DEC()!" << "\n"; }
};

class Derive : public Base1, public Base2
{
public:
    int m_derive;
    Derive(int b1, int b2, int d) : Base1(b1), Base2(b2), m_derive(d){}
    virtual void Sub() { cout << "Derive Virtual Sub()!" << "\n"; }
    virtual void INC() { cout << "Derive Virtual INC()!" << "\n"; }
};

int main()
{
    Derive* d = new Derive(1, 11, 22);
    // 此時指針指向的位置不是Derive的開頭位置,而是Derive對象中子區域Base2的頭部
    Base2* b2 = d;
    // 此時b2只能調用Base2的虛函數
    b2->INC();
    Base1* b1 = d;
    return 0;
}

 



如下圖所示:

 

 



類繼承時的內存區域布局時非常重要的,特別是在多繼承時很重要的,虛析構函數在虛表中的存放還不是很明確。后面會繼續分析。
Vague Linkage

在C++中,有些創建過程需要占用.o文件的空間,例如函數的定義需要占用.o文件的空間。但是函數能夠比較明確地創建到指定的.o文件中,有些創建過程卻並沒有明確的指定創建到那個編譯單元中。我們稱這些創建過程需要”Vague Linkage”,及模糊鏈接。通常它們會在任何需要的地方創建,所以這樣創建的信息有可能會有冗余。

    inline函數(Inline Functions)
    虛表(VTables)
    類型信息(type_info objects)
    模板實例化(Template Instantiations)

(1)inline函數
inline函數通常會定義在頭文件中,以便能夠被不同的編譯單元包含進來。但是inline只是一個建議,編譯器不一定會真的執行inline操作,並且有時候真的會需要一份inline函數的拷貝,比如說獲取inline函數的地址或者inline操作失敗。在這種情況下,通常我們會將inline函數的定義散播到所有需要用到該函數的編譯單元中。

另外,我們通常會將附帶虛表的inline虛函數(虛函數大部分情況下不會為inline函數)散播到目標文件中,因為虛函數通常需要真正地定義出來。

(2)虛表
對於C++虛函數機制,大部分編譯器都是使用查找表(lookup table)實現的,也就是虛表。虛表保存着指向虛函數的指針,另外每個含有虛函數的類對象都有一個指向虛表的指針(虛表在多重繼承下,有可能有多個)。如果class聲明了一個非inline,非純虛的虛函數,那么這些虛函數中的第一個out-of-line方法就被選為關鍵方法(key method),那么虛表只會散播到(即定義到)這個關鍵方法所定義的編譯單元中。

其實關於關鍵方法,還有一個有趣的例子,有時候大家會遇到“未定義的外部符號”這樣的鏈接錯誤,這樣的錯誤是由於你使用了聲明但是沒有定義的外部符號導致的。虛表其實在一定程度上也可以稱為全局變量,只是這個全局變量是隱式地被C++語言機制實現的。虛表只會生成在第一個out-of-line虛函數所在編譯單元中,如果沒有定義out-of-line虛函數,那么所有include該頭文件的編譯單元中生成虛表。

// Base.h
class Base{
public:
    // 第一函數print為關鍵方法,虛表只會散播到(定義在)print所定義在的編譯單元中
    // 如果print也定義在Base.h,那么所有包含Base.h的所有.cpp都會有一份vtable的拷貝
    // 通過鏈接器來消除冗余數據
    virtual int print();
    virtual int add(int lhs, int rhs) { return lhs + rhs; }
};

// A.cpp
#include "Base.h"
// vtable會定義在A.cpp編譯單元中
int Base::print() { cout << "print" << endl;}

// main.cpp
#include "Base.h"
int main() {return 0;}

  
(3)type_info對象
為了實現”dynamic_cast”,”type_id”, 異常處理,C++要求類型信息能夠完整地寫出來(即存儲,以便運行時能夠獲取)。對於多態類(含有虛函數)來說,”type_info”結構體隨着虛表一起出現,虛表中會有一個slot來存放type_info結構體的指針,這樣才能在運行時,在執行dynamic_cast<>的時候獲得對象具體的類型信息。

對於其他類型,我們只會在需要的時候實現其type_info結構體。比如,當你使用”typeid”來獲取表達式的類型信息時,或者拋出對象時和捕獲對象信息時。

(4)模板實例化
最常見的就是我們又可能在多個編譯單元中,同時實例化同一個類型的模板。當然連鏈接器會做冗余處理,或者使用C++11的外部模板。
ouf-of-line virtual method

前面我們已經知道虛函數滿足vague linkage的條件,有可能需要鏈接器去消除冗余。

如果一個類中所有的虛函數都是inline的,那么編譯器就無法知道該挑選哪一個編譯單元來存放虛表的唯一的一份拷貝,相對應地,每一個需要虛表(例如調用虛函數)的目標文件中都會有一份虛表拷貝。在很多平台上,鏈接器能夠統一這些重復的拷貝,要么丟棄重復的定義或者將所有虛表引用指向同一份拷貝,所以只會產生一個warning。

相對應的std::type_info也會使用這種形式,即vague linkage,從字面意思上看就是說type_info並不是緊緊地綁定在每個編譯單元中,而是以一個弱鏈接的形式出現。所以接下來的任務就交給鏈接器了,確保在最后的可執行文件中只有一份type_info的結構體對象。

    these std::type_info objects have what is called vague linkage because they are not tightly bound to any one particular translation unit (object file).

    The compiler has to emit them in any translation unit that requires their presence, and then rely on the linking and loading process to make sure that only one of them is active in the final executable.

    With static linking all of these symbols are resolved at link time, but with dynamic linking, further resolution occurs at load time. – [GCC Frequently Asked Questions]

上面的鏈接是GCC關於這方面信息的解釋,下面是LLVM在其編碼規范中給出的關於Out-of-line虛函數的解釋。

    If a class is defined in a header file and has a vtable (either it has virtual methods or it derives from classes with virtual methods), it must always have at least one out-of-line virtual method in the class. Without this, the compiler will copy the vtable and RTTI into every .o file that #includes the header, bloating .o file sizes and increasing link times. – [LLVM Coding Standards]

“out-of-line”虛函數是指類中第一個虛函數的實現的能夠讓編譯器選擇一個特定的編譯單元,來實現這些虛函數或者實現類的具體細節(例如類型信息),並在這個編譯單元中存放一份共享的虛表。但是如果有多個”out-of-line”虛函數分別定義在不同的.cpp文件中,那么編譯器就會將虛表以及類型信息,生成在類中聲明最靠前的”out-of-line”虛函數所在的TranslationUnit中。

我以前看LLVM源碼的時候,看到過一條有趣的注釋信息:

 

 



如下代碼所示:

//===--------------------test.h---------------------===//
class Base
{
    public:
    // virtual函數全部是默認inline
    virtual int print() { return 0;}
    virtual int Add() { return 1;}
};
//===-----------------------------------------------===//

//===-------------------test.cpp--------------------===//
#include "test.h"
// test.cpp需要用到虛表,所以虛表應該在test.cpp中生成一份兒
int main()
{   
    Base* b = new Base();
    b->Add();
    delete b;
    return 0;
}
//===-----------------------------------------------===//

//===--------------------foo.cpp--------------------===//
#include "test.h"
// foo.cpp 也用到了虛表所以在編譯的時候,在foo.cpp中也應該產生一份兒
void func()
{
    Base* b = new Base();
    b->print();
    delete b;
}
//===-----------------------------------------------===//

 

我們編譯一下,看一下編譯結果是否如此:

$g++ -c test.cpp foo.cpp
$objdump -d foo.o

// 得到下面結果,說明在foo.o中生成了虛函數定義
Disassembly of section .text$_ZN4Base5printEv:

00000000 <__ZN4Base5printEv>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 04                sub    $0x4,%esp
   6:   89 4d fc                mov    %ecx,-0x4(%ebp)
   9:   b8 00 00 00 00          mov    $0x0,%eax
   e:   c9                      leave  
   f:   c3                      ret    

Disassembly of section .text$_ZN4Base3AddEv:

00000000 <__ZN4Base3AddEv>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 04                sub    $0x4,%esp
   6:   89 4d fc                mov    %ecx,-0x4(%ebp)
   9:   b8 01 00 00 00          mov    $0x1,%eax
   e:   c9                      leave  
   f:   c3                      ret  

$objdump -d test.o
// 得到下面的結果
Disassembly of section .text$_ZN4Base5printEv:

00000000 <__ZN4Base5printEv>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 04                sub    $0x4,%esp
   6:   89 4d fc                mov    %ecx,-0x4(%ebp)
   9:   b8 00 00 00 00          mov    $0x0,%eax
   e:   c9                      leave  
   f:   c3                      ret    

Disassembly of section .text$_ZN4Base3AddEv:

00000000 <__ZN4Base3AddEv>:
   0:   55                      push   %ebp
   1:   89 e5                   mov    %esp,%ebp
   3:   83 ec 04                sub    $0x4,%esp
   6:   89 4d fc                mov    %ecx,-0x4(%ebp)
   9:   b8 01 00 00 00          mov    $0x1,%eax
   e:   c9                      leave  
   f:   c3                      ret


我們從上面的結果中看到,確實在test.o和foo.o中都產生了虛函數print()和add()的定義,如果我們使用”readelf -s test.o”查看更詳細的信息的話,會發現虛表和type_info在test.o和foo.o也都存在一份拷貝。

Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS test.cpp
     2: 00000000     0 SECTION LOCAL  DEFAULT    7
     3: 00000000     0 SECTION LOCAL  DEFAULT    9
     4: 00000000     0 SECTION LOCAL  DEFAULT   10
     5: 00000000     0 SECTION LOCAL  DEFAULT   11
     6: 00000000     0 SECTION LOCAL  DEFAULT   12
     7: 00000000     0 SECTION LOCAL  DEFAULT   13
     8: 00000000     0 SECTION LOCAL  DEFAULT   15
     9: 00000000     0 SECTION LOCAL  DEFAULT   17
    10: 00000000     0 SECTION LOCAL  DEFAULT   18
    11: 00000000     0 SECTION LOCAL  DEFAULT   21
    12: 00000000     0 SECTION LOCAL  DEFAULT   22
    13: 00000000     0 NOTYPE  LOCAL  DEFAULT    3 _ZN4BaseC5Ev
    14: 00000000     0 SECTION LOCAL  DEFAULT   20
    15: 00000000     0 SECTION LOCAL  DEFAULT    1
    16: 00000000     0 SECTION LOCAL  DEFAULT    2
    17: 00000000     0 SECTION LOCAL  DEFAULT    3
    18: 00000000     0 SECTION LOCAL  DEFAULT    4
    19: 00000000     0 SECTION LOCAL  DEFAULT    5
    20: 00000000     0 SECTION LOCAL  DEFAULT    6
    21: 00000000    10 FUNC    WEAK   DEFAULT   11 _ZN4Base5printEv
    22: 00000000    10 FUNC    WEAK   DEFAULT   12 _ZN4Base3AddEv
    23: 00000000    14 FUNC    WEAK   DEFAULT   13 _ZN4BaseC2Ev
    24: 00000000    16 OBJECT  WEAK   DEFAULT   15 _ZTV4Base
    25: 00000000    14 FUNC    WEAK   DEFAULT   13 _ZN4BaseC1Ev
    26: 00000000    84 FUNC    GLOBAL DEFAULT    7 main
    27: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _Znwj
    28: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _ZdlPv
    29: 00000000     8 OBJECT  WEAK   DEFAULT   18 _ZTI4Base
    30: 00000000     6 OBJECT  WEAK   DEFAULT   17 _ZTS4Base
    31: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _ZTVN10__cxxabiv117__clas

 

我們可以看到在test.o中生成了類Base的虛表和type_info結構體,_ZTV表示虛表,_ZTI表示type_info結構, _ZTS表示type name,注意在gcc的設計中,type_info存放在虛表的第一個slot(Visual Studio是存放在虛表的最后一個slot中)。我們看一下foo.o的相關信息,如下:

Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS foo.cpp
     2: 00000000     0 SECTION LOCAL  DEFAULT    7
     3: 00000000     0 SECTION LOCAL  DEFAULT    9
     4: 00000000     0 SECTION LOCAL  DEFAULT   10
     5: 00000000     0 SECTION LOCAL  DEFAULT   11
     6: 00000000     0 SECTION LOCAL  DEFAULT   12
     7: 00000000     0 SECTION LOCAL  DEFAULT   13
     8: 00000000     0 SECTION LOCAL  DEFAULT   15
     9: 00000000     0 SECTION LOCAL  DEFAULT   17
    10: 00000000     0 SECTION LOCAL  DEFAULT   18
    11: 00000000     0 SECTION LOCAL  DEFAULT   21
    12: 00000000     0 SECTION LOCAL  DEFAULT   22
    13: 00000000     0 NOTYPE  LOCAL  DEFAULT    3 _ZN4BaseC5Ev
    14: 00000000     0 SECTION LOCAL  DEFAULT   20
    15: 00000000     0 SECTION LOCAL  DEFAULT    1
    16: 00000000     0 SECTION LOCAL  DEFAULT    2
    17: 00000000     0 SECTION LOCAL  DEFAULT    3
    18: 00000000     0 SECTION LOCAL  DEFAULT    4
    19: 00000000     0 SECTION LOCAL  DEFAULT    5
    20: 00000000     0 SECTION LOCAL  DEFAULT    6
    21: 00000000    10 FUNC    WEAK   DEFAULT   11 _ZN4Base5printEv
    22: 00000000    10 FUNC    WEAK   DEFAULT   12 _ZN4Base3AddEv
    23: 00000000    14 FUNC    WEAK   DEFAULT   13 _ZN4BaseC2Ev
    24: 00000000    16 OBJECT  WEAK   DEFAULT   15 _ZTV4Base
    25: 00000000    14 FUNC    WEAK   DEFAULT   13 _ZN4BaseC1Ev
    26: 00000000    70 FUNC    GLOBAL DEFAULT    7 _Z4funcv
    27: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _Znwj
    28: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _ZdlPv
    29: 00000000     8 OBJECT  WEAK   DEFAULT   18 _ZTI4Base
    30: 00000000     6 OBJECT  WEAK   DEFAULT   17 _ZTS4Base
    31: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _ZTVN10__cxxabiv117__clas

 

可以發現在foo.o中也生成了虛表和type_info信息,也就是說如果inline虛函數都沒有設置成out-of-line的話,那么編譯器會向每個需要用到虛表結構的目標文件中散播虛表,虛函數和type_info定義。直到鏈接的時候,鏈接器進行冗余消除操作。由於鏈接器需要消除冗余的type_info和vtable,所以就要求虛表和type_info的符號必須是弱符號(weak symbols),GCC好像永遠會將RTTI信息設置為弱符號,即使虛函數中有關鍵方法(key method)。

    對於目標文件中的符號名,可以使用c++filt命令來得到符號名所表示的真正的name,例如:
    $ c++filt ZNK3MapI10StringName3RefI8GDScriptE10ComparatorIS0_E16DefaultAllocatorE3hasERKS0

但是如果派生類沒有覆蓋掉任何父類的虛函數的話,完全可以完成虛函數調用時的靜態決議,則不需要對象的頭4個字節的虛表指針,其實也就不需要虛表了。

相關信息請見:
LLVM:multiple typeinfo name
GCC Frequently Asked Questions
LLVM:CodingStandards
 
 
原文鏈接:https://blog.csdn.net/dashuniuniu/article/details/50162903


免責聲明!

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



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