继承、封装、多态是C++作为OO语言的三大特性。
在学习C++的过程中,我们都对虚函数机制实现多态有或多或少的了解。
尽管在日常的编程中,我们可能掌握了虚函数的特性并熟练地将其运用在项目中又或者根本搞不来C++而对虚函数望而生畏。
别慌!本文将从底层揭秘虚函数究竟是怎么 *** 作的,在运行过程中究竟执行的是什么样的代码。
话不多说,搞快点!
环境- *** 作系统:macOS Mojave 10.14.5
- 编译器:Apple LLVM version 10.0.1 (clang-1001.0.46.4)
- 工具:Hopper Disassembler v4
class A
{
public:
int a;
int b;
virtual void f() {}
};
class B : public A
{
public:
int x;
int y;
void f() override {}
};
int main()
{
B b;
A *x = &b;
x->f();
return 0;
}
先让我们来看看简单的一段代码,main
函数中实例化了一个派生类B
的对象,然后使用基类指针去指向该对象,当该指针调用f
成员的时候,调用的并不是基类A
的成员函数而是派生类B
的成员函数。
原来如此!基类指针似乎能够根据其真正指向的对象类型来调用实际重载过的函数,这就是虚函数机制嘛!!?
那么问题来了,这到底是如何实现的?可以通过编译器静态编译实现嘛?答案是不行的,比如我们随手写一手辣鸡代码。
... // 重用上述 class A B
int main()
{
int x;
A* p;
A a;
B b;
while(cin >> x)
{
p = x > 0 ? &a : &b;
p->f();
}
return 0;
}
我们无法在运行前知晓p所指向的真正类型,因此必须有合适的方法来解决这一问题。
我们在学习过程中也听说过虚函数表(vtable)、动态绑定(dynamic binding)等名词,据说是用来实现虚函数的,那么其中的魔法究竟是怎么样的呢?让我们来深入了解一下!
虚函数表是啥?之前我们就一直提到要深入了解深入了解,那么究竟是多深入呢?那自然是要通过反汇编来瞧一瞧啦!这里我们使用最开始的一段代码来分析。
main函数
首先我们注意到地址为0x0000000100000edf的指令lea rdi, qword [rbp+var_20]
,这条指令等价于rdi = rbp+var_20
(rbp+var_20
是一个内存地址,而[rbp+var_20]
代表该地址上的内容,qword
指的是这个地址开始的包含8字节的内存空间),而这个地址正是B b
的地址。
下面我们进入派生类B的构造函数中观察构造函数(call __ZN1BC1Ev
)如何构造这一对象。
(mov
指令可以理解为把右边的值赋给左边)
这里先关注一下此时堆栈的情况:
_____________________________
| | 高地址
rbp -----> | | |
|_____________________________| |
| | | 堆栈向下增长
| | |
|_____________________________| \ | /
| | \ /
| | 低地址
|_____________________________|
| |
address of B b -> | | 左侧一个空间8字节,假设最底下地址为
|_____________________________| 0x1000, 那么上一个格子的地址为0x1008
| |
| |
|_____________________________
class B的构造函数
第一个函数似乎没有什么实质性的作用,因此我们进入下一个函数(call __ZN1BC2Ev
)
注意此时rdi
寄存器中存储的是B b
的地址。
我们注意到地址为0x0000000100000f38~0x0000000100000f47的指令执行后的效果是rax = rdi = address of B b
。
由于类B派生自类A,因此会调用A的构造函数,因此我们进入A的构造函数来看一看。
class A的构造函数
注意此时rdi
寄存器中存储仍然的是B b
的地址。
首先我们观察到地址为0x0000000100000f87的指令mov qword [rdi], rax
,是将寄存器rax
中的值赋给了B b
的首8个字节,那么寄存器rax
中存的是啥?我们看到前面有两条指令mov rax, qword [0x100001000]
以及add rax, 0x10
,于是我们追踪地址0x10000100,看看相应的内存块中存的是什么。
我们发现其存放的依然是一个地址(可以理解为C语言中的指针),于是我们继续挖掘下去。
class A的vtable
wow!我们看到反汇编软件上表明这是基类A的vtable(虚函数表),而rax += 0x10
所指向的地址为0x0000000100001068,我们发现前8个字节似乎有点玄机,取出来瞧瞧,由于我们的机器上遵循的是小端表示(little endian),因此应该是0x0000000100000fa0,似乎又是一个地址,我们继续追踪。
A::f()
于是我们来到了基类A的成员函数f
,原来虚函数表中存储的是该函数的地址!不过别慌,我们还没有结束。
构造完A后,我们继续分析B的构造函数。
此前的堆栈情况发生变化:
_____________________________
| | 高地址
rbp -----> | | |
|_____________________________| |
| | | 堆栈向下增长
| | |
|_____________________________| \ | /
| | \ /
| | 低地址
|_____________________________| 左侧一个空间8字节,假设最底下地址为
| | 0x1000, 那么上一个格子的地址为0x1008
address of B b -> | address of A's vtable |_________
|_____________________________| |
| | |
| | |
|_____________________________| | ______________________
| | |
| | |
| |_____________________|
--------->| |
| address of A::f |
|_____________________|
class B的构造函数
我们在B的构造函数中同样发现像了类似的赋值情况,因此我们断定这是在 *** 作B的vtable(虚函数表),于是继续追踪验证我们的猜想。
追踪地址0x0000000100001010
class B的vtable
果不其然,我们发现了B的vtable(虚函数表),同样地址0x0000000100000f90指向的是B的成员函数f
B::f()
class B的构造函数
继续回到B的构造函数中,由于B的构造函数中,在执行A的构造函数之前,我们执行了mov qword [rbp + var_10], rdi
的存储指令,因此在执行mov qword [rbp + var_10], rdi
后,rdi
寄存器指对象B b
的地址。
此前的堆栈情况继续变化(这里我们标注出成员变量的布局):
_____________________________
| | 高地址
rbp -----> | | |
|_____________________________| |
| | | 堆栈向下增长
| x | y | |
|_____________________________| \ | /
| | \ /
| a | b | 低地址
|_____________________________| 左侧一个空间8字节,假设最底下地址为
| | 0x1000, 那么上一个格子的地址为0x1008
address of B b -> | address of B's vtable |_________
|_____________________________| |
| | | 在我的机器上int占4字节
| | |
|_____________________________| | ______________________
| | |
| | |
| |_____________________|
--------->| |
| address of B::f |
|_____________________|
虚函数如何执行?
分析完虚函数表,让我们来看一看汇编代码中虚函数究竟被如何表达?
main函数
依然回到main
函数中,我们继续向下分析:首先还是需要获得B b
的地址,指令lea rdi, qword[rbp+var_20]
mov qword[rbp+var_28], rdi
即对应代码A* x = &b;
,获得地址并存放在局部变量中(栈上空间),然后关键的一步来了,指令mov rax, qword [rdi]
,获得B b
的首8个字节,我们回忆起之前得到的布局图,首8个字节不就是虚函数表的地址!然后看到指令call qword [rax]
,一下子恍然大悟,虚函数表中的第一项不就是B的成员函数f
的地址!也就是说,虚函数的机制实际上是通过在对象中存储的虚函数表的地址,追踪到编译生成出来附带在生成的可执行文件中某处的虚函数表,然后根据其中记录的具体的函数地址来实现调用的啊!
- 使用指针
在上述的探索中,我们正是使用了这个方法,此外像下面这样调用也会触发虚函数机制。
... // 重用上述class A B
int main()
{
B b;
A* x = &b;
(*x).f();
return 0;
}
- 使用引用
... // 重用上述class A B
int main()
{
B b;
A& x = b;
x.f();
return 0;
}
我们照例来看一下这段代码的汇编代码。
我们发现使用引用的情况下,我们仍然触发了虚函数的机制。
那么直接调用会是什么情况?整上代码!
... // 重用上述class A B
int main()
{
B b;
b.f();
return 0;
}
我们发现汇编代码直接调用成员函数。
这也回答了为什么虚函数机制会带来更多的开销,当触发虚函数时,首先要取得存储在对象中的虚函数表的地址,这里需要一次内存寻址,然后通过该地址取得真正要调用的函数的地址,这里又是一次内存寻址,之后才开始进行真正的函数调用。
因此我们不能过分依赖于虚函数机制,在类型明确的情况下更适合于直接调用。
虚函数的实现依赖的是一张虚函数表(vtable),由编译器编译生成并存放在某处,程序运行时在构造对象时会将该地址存放在对象中。
在真正调用的时候会先通过存储在对象中的虚函数表的地址,寻找得到真正需要调用的成员函数的地址。
转载:探索C++虚函数的实现 - 知乎 (zhihu.com)
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)