C++ 多态是如何实现的?


对于 OOP 编程,相信大家对于多态( polymorphism )肯定都耳熟能详。 但是对于多态是如何实现的,我不知道大家是否清楚,但我发现自己并不是很了解, 只知道在运行时通过一个 vtable 来确定应该调用哪个函数, 实际上 vtable 真的存在吗?它是如何起作用的?

首先我们来看下普通方法的调用情况,比如 non-virtual-class.cpp 有一个类及其方法调用:

class Base
{
public:
    int foo() { return 1; }
};

class Derived
{
public:
    int bar() { return 2; }
};

int main(void)
{
    Derived foo;

    return foo.bar();
}

g++ -g -O0 non-virtual-class.cpp 编译之后,用 gdb a.out 查看它的汇编代码:

(gdb) disas main
Dump of assembler code for function main():
   0x00000000004004b6 <+0>:	push   %rbp
   0x00000000004004b7 <+1>:	mov    %rsp,%rbp
   0x00000000004004ba <+4>:	sub    $0x10,%rsp
   0x00000000004004be <+8>:	lea    -0x1(%rbp),%rax               ; &foo 取自身对象地址
   0x00000000004004c2 <+12>:	mov    %rax,%rdi
   0x00000000004004c5 <+15>:	callq  0x4004ce <Derived::bar()> ; 确定的函数地址
   0x00000000004004ca <+20>:	nop
   0x00000000004004cb <+21>:	leaveq 
   0x00000000004004cc <+22>:	retq   
End of assembler dump.
(gdb) disas Derived::bar
Dump of assembler code for function Derived::bar():
   0x00000000004004ce <+0>:	push   %rbp
   0x00000000004004cf <+1>:	mov    %rsp,%rbp
   0x00000000004004d2 <+4>:	mov    %rdi,-0x8(%rbp)
   0x00000000004004d6 <+8>:	mov    $0x2,%eax                     ; return 2
   0x00000000004004db <+13>:	pop    %rbp
   0x00000000004004dc <+14>:	retq   
End of assembler dump.

可以发现与普通的 C 函数调用类似,编译之后就已经确定好了调用 Derived::bar 方法, 除了把对象自身地址作为隐藏的第一个参数传入之外并没有什么不同。 也就是说对于普通不含虚函数的类对象,在编译过程中就确定了其方法的调用,没有运行时开销。

那么,在多态场景下,类方法的调用又是怎样的呢?这里还是从汇编的角度观察。 以同样参数编译如下 virtual-class-polymorphism.cpp 文件,然后用 gdb 观察运行时信息:

class Base
{
public:
    virtual void foo() {};
};

class Derived : public Base
{
public:
    void foo() {};
};

int main(void)
{
    Base *p = new Derived;
    p->foo();

    return 0;
}

这次我们通过在 GDB 运行时观察:

;; 设置解析符号及断点,启动程序
(gdb) set print asm-demangle on
(gdb) set print demangle on
(gdb) b main
Breakpoint 1 at 0x40061f: file virtual-class-polymorphism.cpp, line 15.
(gdb) r
Starting program: /home/hgw/demo-code/cpp/vtable/a.out 
Missing separate debuginfos, use: dnf debuginfo-install glibc-2.27-30.fc28.x86_64

Breakpoint 1, main () at virtual-class-polymorphism.cpp:15
15	    Base *p = new Derived;
Missing separate debuginfos, use: dnf debuginfo-install libgcc-8.1.1-5.fc28.x86_64 libstdc++-8.1.1-5.fc28.x86_64
(gdb) p p
$1 = (Base *) 0x400530 <_start>
(gdb) n
16	    p->foo();

(gdb) p p
$2 = (Base *) 0x613e70
(gdb) x/16xb p
0x613e70:	0x40	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x613e78:	0x00	0x00	0x00	0x00	0x00	0x00	0x00	0x00
;; 可以看到对象中包含了一个 vptr ,指向 vtable 的一个偏移位置
(gdb) p *p
$3 = {_vptr.Base = 0x400740 <vtable for Derived+16>}

;; 再看 vtable 中的内容,注意这里已经去掉了偏移量16
;; 可以看到先是两个 vtable ,再是 typeinfo , typeinfo name ,后两者是 RTTI 相关,本文暂不了解。
(gdb) x/200xb 0x400730
0x400730 <vtable for Derived>:	0x00	0x00	0x00	0x00	0x00	0x00	0x00	0x00
0x400738 <vtable for Derived+8>:	0x60	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x400740 <vtable for Derived+16>:	0x64	0x06	0x40	0x00	0x00	0x00	0x00	0x00
0x400748 <vtable for Base>:	0x00	0x00	0x00	0x00	0x00	0x00	0x00	0x00
0x400750 <vtable for Base+8>:	0x88	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x400758 <vtable for Base+16>:	0x58	0x06	0x40	0x00	0x00	0x00	0x00	0x00
0x400760 <typeinfo for Derived>:	0xa8	0x0d	0x60	0x00	0x00	0x00	0x00	0x00
0x400768 <typeinfo for Derived+8>:	0x78	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x400770 <typeinfo for Derived+16>:	0x88	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x400778 <typeinfo name for Derived>:	0x37	0x44	0x65	0x72	0x69	0x76	0x65	0x64
0x400780 <typeinfo name for Derived+8>:	0x00	0x00	0x00	0x00	0x00	0x00	0x00	0x00
0x400788 <typeinfo for Base>:	0x50	0x0d	0x60	0x00	0x00	0x00	0x00	0x00
0x400790 <typeinfo for Base+8>:	0x98	0x07	0x40	0x00	0x00	0x00	0x00	0x00
0x400798 <typeinfo name for Base>:	0x34	0x42	0x61	0x73	0x65	0x00	0x00	0x00
0x4007a0:	0x01	0x1b	0x03	0x3b	0x5c	0x00	0x00	0x00
;; 这里省略一些无意义的输出

;; 查看 <vtable for Derived+16> 地址中存储的内容,可以看到就是函数指针
(gdb) info symbol 0x400664
Derived::foo() in section .text of /home/hgw/demo-code/cpp/vtable/a.out
;; 查看 <vtable for Derived+8> 地址中存储的内容,指向 typeinfo 内存位置
(gdb) info symbol 0x400760
typeinfo for Derived in section .rodata of /home/hgw/demo-code/cpp/vtable/a.out

现在再回过头来看静态的汇编代码,就容易理解了:

(gdb) disas main
Dump of assembler code for function main():
   0x0000000000400616 <+0>:	push   %rbp
   0x0000000000400617 <+1>:	mov    %rsp,%rbp
   0x000000000040061a <+4>:	push   %rbx
   0x000000000040061b <+5>:	sub    $0x18,%rsp
   0x000000000040061f <+9>:	mov    $0x8,%edi                         ; sizeof Derived 变为了 8 字节
   0x0000000000400624 <+14>:	callq  0x400520 <_Znwm@plt>          ; new 对象
   0x0000000000400629 <+19>:	mov    %rax,%rbx                     ; 地址 p
   0x000000000040062c <+22>:	mov    %rbx,%rdi
   0x000000000040062f <+25>:	callq  0x400688 <Derived::Derived()> ; constructor
   0x0000000000400634 <+30>:	mov    %rbx,-0x18(%rbp)
   0x0000000000400638 <+34>:	mov    -0x18(%rbp),%rax              ; 地址 p ,指向对象存储空间
   0x000000000040063c <+38>:	mov    (%rax),%rax                   ; 取对象内容,其实就是 vptr 值
   0x000000000040063f <+41>:	mov    (%rax),%rax                   ; 取 vptr 指向地址的内容,就是上边看到的 <vtable for Derived+16>
   0x0000000000400642 <+44>:	mov    -0x18(%rbp),%rdx
   0x0000000000400646 <+48>:	mov    %rdx,%rdi                     ; 把地址 p 作为第一个参数( this )传入
   0x0000000000400649 <+51>:	callq  *%rax                         ; 调用 vtable 中指定的函数
   0x000000000040064b <+53>:	mov    $0x0,%eax
   0x0000000000400650 <+58>:	add    $0x18,%rsp
   0x0000000000400654 <+62>:	pop    %rbx
   0x0000000000400655 <+63>:	pop    %rbp
   0x0000000000400656 <+64>:	retq   
End of assembler dump.

综上,可以把相关的内存布局绘制成如下图: /img/2018-12-10-vtable.png

从以上分析可以看出, vtable 确实存在,程序运行时根据 vtable 查找对应的函数(此过程叫做 dynamic dispatch )。 若在不必要的场景下定义了虚函数,一方面会使得对象占用的内存变大, 另一方面在调用虚函数时,需要查找 vtable ,有一定的性能损耗,因此编程时应当避免此情况。

延伸阅读、参考资料:

C++  GDB 

也可以看看

comments powered by Disqus