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

posted @ 2019-04-05 12:07 一罪 閱讀(...) 評論(...) 編輯 收藏