对于 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.
综上,可以把相关的内存布局绘制成如下图:
从以上分析可以看出, vtable 确实存在,程序运行时根据 vtable 查找对应的函数(此过程叫做 dynamic dispatch )。 若在不必要的场景下定义了虚函数,一方面会使得对象占用的内存变大, 另一方面在调用虚函数时,需要查找 vtable ,有一定的性能损耗,因此编程时应当避免此情况。
延伸阅读、参考资料:
- C++ vtables - Part 1 - Basics | Shahar Mike's Web Spot - shaharmike.com 此系列文章深入讲解了 C++ 的 vtable 和 RTTI 实现原理,本文主要参考了其中的第一篇。
- C++ Object Model.pdf - lifegoo.pluskid.org
- Stanley B·Lippman Inside the C++ Object Model