1. 程式人生 > >C++中的out-of-line虛擬函式

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個位元組的虛表指標,其實也就不需要虛表了。