引子
在現實編碼過程當中,曾經遇到過這樣的問題「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)沒有繼承狀況下的類虛表結構ios
#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」,調出內存監視器,查看該內存的狀況,以下圖所示:
c++
對象b只存放了虛表的指針「0x00a3cc74」,後面的「0xfdfdfdfd」爲Visual Studio在Debug模式下,堆內存上的守護字節。咱們跳轉到「0x00a3cc74」查看該內存到底存放了什麼,以下圖所示:
sass
這三個字存放的數據就是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虛表信息以下圖所示:
ui
Derived虛表信息以下圖所示:
this
從這兩幅圖中能夠看到,兩張圖中虛表惟一的不一樣是,虛表第二項不同。Derive自定義了Sub()函數,因此理應相應的虛表應該指向Derive新定義的函數位置,而Derive繼承了(沒有覆蓋Add()和Div()函數)Add()和Div()函數,因此第一項和第三項和Base類虛表的第一項和第三項相同。
編碼
也就是說子類重寫了相應的虛函數,那麼虛表中相應位置的地址會指向新的函數,沒有重寫那麼相應位置的地址和父類相同。
(3)在多重繼承下
代碼以下,Derive繼承自Base1和Base2,也就是說Derive從兩個父類繼承了兩個虛表,如今的問題是,兩個虛表會不會合併到一塊兒呢?以下圖所示:
spa
這種形式的話,只有從第一個父類繼承的虛表的下標和父類虛表的下標是相同的,後面的虛表都要移動必定的偏移量,這樣作顯然不太漂亮。因此如今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 nameGCC Frequently Asked QuestionsLLVM:CodingStandards 原文連接:https://blog.csdn.net/dashuniuniu/article/details/50162903