C++系列總結——多態
前言
封裝隱藏了類內部細節,通過繼承加虛函數的方式,我們還可以做到隱藏類之間的差異,這就是多態(運行時多態)。多態意味一個接口有多種行為,今天就來說說C++的多態是怎麽實現的。
編譯時多態感覺沒什麽好說的,編譯時直接綁定了函數地址。
多態
有下面這麽一段代碼:A有兩個虛函數(virtual
關鍵字修飾的函數),B繼承了A,還有一個參數為A*
的函數foo()
。
#include <iostream> class A { public: A(); virtual void foo(); virtual void bar(); private: int a; }; A::A() : a( 1 ) { } void A::foo() { std::cout << "A::foo()\n"; return; } void A::bar() { std::cout << "A::bar()\n"; return; } class B : public A { public: B(); virtual void foo(); virtual void bar(); private: int b ; }; B::B() : b( 2 ) { } void B::foo() { std::cout << "B::foo()\n"; return; } void B::bar() { std::cout << "B::bar()\n"; return; } void foo( A* x ) { x->foo(); x->bar(); return; }
我們要先知道,對於虛函數的重寫,規則要求編譯器必須根據實際類型調用對應的函數,而不是像重寫普通成員函數那樣,直接調用當前類型的函數。
假設
bar()
是一個非虛函數,B重寫了bar()
,那麽即使x
指向B的對象,在foo()
調用x->bar()
時也還是輸出"A::bar()"
這段代碼編譯成動態庫的話,編譯器就無法確定foo()
的入參x
指向的對象是什麽類型了(父類指針可以指向自身類型的對象或任意子類的對象),因此編譯器就無法直接得出foo()
和bar()
實際的函數地址,無法完成函數調用。這中間肯定發生了什麽!
題外話:一旦函數重寫,
A::foo()
和B::foo()
就是兩個函數,兩個地址。如果只是單純繼承的話,之前介紹繼承的時候說過,子類是不存在B:;foo()
這個函數,而只是讓編譯器允許通過B類型的對象調用A::foo()
。
如何確定實際函數地址
一旦無法自然地想通一個流程,覺得中間缺了什麽東西時,那肯定是編譯器幹了什麽。因此還是要祭出gdb
這件大殺器。
// 省略前面那段代碼
int main()
{
B* x = new B;
foo( x );
return 0;
}
當我們打印x
的內容時,會發現其多了一個位於對象的首地址的_vptr.A
,它其實指向了虛函數表。
(gdb) p *x $2 = {<A> = {_vptr.A = 0x400a70 <vtable for B+16>, a = 1}, b = 2}
foo()
中的x->foo()
和x->bar()
對應著如下匯編
# x->foo()
0x0000000000400815 <+8>: mov %rdi,-0x8(%rbp) # 將rdi中的對象地址保存到-0x8(%rbp) 中
=> 0x0000000000400819 <+12>: mov -0x8(%rbp),%rax
0x000000000040081d <+16>: mov (%rax),%rax # 取對象首地址的8個字節也就是_vptr.A 0x400a70保存到rax中
0x0000000000400820 <+19>: mov (%rax),%rax # 再取出0x400a70這個地址存放的4個字節數據保存到rax中,其實就是B::foo()函數地址
0x0000000000400823 <+22>: mov -0x8(%rbp),%rdx # 將對象地址保存到rdx中
0x0000000000400827 <+26>: mov %rdx,%rdi # 將對象地址保存到rdi中,作為虛函數foo()的參數
0x000000000040082a <+29>: callq *%rax # 調用B::foo()
# x->bar()
0x000000000040082c <+31>: mov -0x8(%rbp),%rax
0x0000000000400830 <+35>: mov (%rax),%rax # 取對象首地址的8個字節也就是_vptr.A 0x400a70保存到rax中
0x0000000000400833 <+38>: add $0x8,%rax # 跳過8字節,即0x400a70+8
0x0000000000400837 <+42>: mov (%rax),%rax # 取出B::bar()的地址
0x000000000040083a <+45>: mov -0x8(%rbp),%rdx
0x000000000040083e <+49>: mov %rdx,%rdi
0x0000000000400841 <+52>: callq *%rax # 調用B::bar()
看一下0x400a70
這個地址的內容,更容易理解上面的匯編。
(gdb) x /4x 0x400a70
0x400a70 <_ZTV1B+16>: 0x0040095e 0x00000000 0x0040097c 0x00000000
(gdb) x 0x0040095e
0x40095e <B::foo()>: 0xe5894855 # 0x0040095e就是B::foo()的首地址
(gdb) x 0x0040097c
0x40097c <B::bar()>: 0xe5894855 # 0x0040097c就是B::bar()的首地址
從上面可以看出,虛函數表類似於一個數組,其中每個元素是該類實現的虛函數地址,利用虛函數表,就執行正確的函數了。
何時設置虛函數表
既然虛函數表是類數據結構裏的一部分,那它的初始化肯定就是在類的構造函數裏了,讓我們去找找。
下面是B::B()
的一部分匯編,A::A()
也類似只不過是將A的虛函數表地址賦值給_vptr.A
。
0x0000000000400941 <+19>: callq 0x4008d2 <A::A()> # 先構造父類
0x0000000000400946 <+24>: mov -0x8(%rbp),%rax
0x000000000040094a <+28>: movq $0x400a70,(%rax) # 將B的虛函數表地址0x400a70保存到對象的首地址中,即給_vptr.A賦值
0x0000000000400951 <+35>: mov -0x8(%rbp),%rax
0x0000000000400955 <+39>: movl $0x2,0xc(%rax) # 初始化列表
題外話:在更新虛函數表和初始化列表之後,才執行我們顯式寫在
B::B()
中的代碼。
每個類都有一個自己的虛函數表,這在編譯時就確定了。如果子類沒有實現虛函數,虛函數表裏對應位置的函數地址就還是父類的函數地址。
隱晦的錯誤
從上面我們知道
- 虛函數表中的元素順序就是函數聲明的順序,這在編譯時就固定了。
- 執行虛函數時,只是取了虛函數表中對應偏移的元素(即函數地址)去執行,並沒有做符號綁定。這個偏移是由虛函數聲明順序決定的。
基於這兩點,如果我們在真正構造B的地方修改了虛函數的聲明順序,就會導致函數調用出錯。
簡單驗證一下,將最開始的那段代碼編譯為動態庫(liba.so),並在main.cpp中調換其函數聲明順序
class A
{
public:
A();
virtual void bar();
virtual void foo();
private:
int a;
};
class B : public A
{
public:
B();
virtual void bar();
virtual void foo();
int b;
};
void bar( A* x )
{
x->foo();
x->bar();
return;
}
int main()
{
B* b = new B;
bar( b );
return 0;
}
上面代碼的輸出是
B::bar()
B::foo()
與預期結果剛好相反
B::foo()
B::bar()
出現這樣錯誤的原因就是在編譯main.cpp時,編譯器認為B::foo()
是虛函數表的第二個元素,但實際在liba.so中B::foo()
是虛函數表中的第一個元素。
強烈建議虛函數的聲明順序必須保持一致,而且增加虛函數時,只在尾部增加
結語
了解C++的多態實現後,對於理解其他語言的多態實現也是有益處的,本質都應當是在通過一個中間結構確定實際函數的地址。
除了以上內容外,還有
- 不論是否能通過上下文判斷出實際類型,只要是以指針方式調用虛函數,都會以虛函數表跳轉的方式來調用函數。
- 在構造函數中調用虛函數,並不會使用多態,而是直接調用函數地址。
這兩點通過上面的調試方法很容易就能確認。
gcc version 4.8.5
C++系列總結——多態