曲径通幽处
禅房花木深
前言
在比较多的面经中有看到这么一个问题:虚函数可以在构造函数和析构函数中调用吗?
从能不能通过编译并执行的角度来说:肯定是可以的。
但是不推荐这么做。
虚函数在构造/析构函数中调用
前面结论是肯定是能编译通过的。。。为了尽快验证这个事情,这里给一个简单的小例子。
#include <bits/stdc++.h>
using namespace std;
class A {
public:
A() {
f();
}
virtual ~A() {
df();
}
virtual void f() {
printf("A\n");
}
virtual void df() {
printf("~A\n");
}
};
class B : public A {
public:
B() : A() {
f();
}
~B() {
df();
}
void df() override {
printf("~B\n");
}
void f() override {
printf("B\n");
}
};
int main() {
A *a = new B();
delete a;
return 0;
}
执行结果:
可以看到在A的构造/析构函数里,则调用的是A的同名虚函数;在B的构造/析构函数里,则调用的是B的同名函数。
此时就失去了之前多态的特性,即A类型的指针指向派生类B的对象,那调用同名虚函数时,应该调用B的函数。这里构造函数中却是依次调用了A、B。。。
这种并不是按照“多态”实现的情况下,可能造成二义性。。。所以是不建议在构造函数中调用虚函数的。
为了对比,这里在一个其他类型(非构造/析构)的函数里调用虚函数 f()
。
#include <bits/stdc++.h>
using namespace std;
class A {
public:
A() {
f();
}
virtual ~A() {
df();
}
void interest() {
f();
}
virtual void f() {
printf("A\n");
}
virtual void df() {
printf("~A\n");
}
};
class B : public A {
public:
B() : A() {
f();
}
~B() {
df();
}
void df() override {
printf("~B\n");
}
void f() override {
printf("B\n");
}
};
int main() {
A *a = new B();
a->interest();
delete a;
return 0;
}
打印结果:
结果很显然,只调用了B的同名函数,说明多态生效。
这里用GDB分别在构造函数A和构造函数B中打上断点,然后查看this指针的类型。
首先在构造函数 A()
中,this指针类型为A *
。
而在构造函数 B()
中,this指针类型为B *
。
从这个现象来看,我们似乎可以发现一点端倪了。
虚函数的实现
部分参考 c++虚函数的作用是什么?
现在换一个问题,如果让我们自己来设计成员函数,应该如何设计?
其实很容易想到一种,就是实现不同同名函数,函数的第一个参数就是类A和类B的指针。
void f(A *this);
void f(B *this);
这样成员函数的调用就变成了这样子:
a->f() -----> f(a)
b->f() -----> f(b)
这样就解决了不同类中的同名函数调用时,如何能够调用到对应类型的函数的问题。
这其实就是静态绑定。。。
那如何实现动态绑定,也就是动多态(根据指针所指对象类型调用对应的函数)。
答案就是虚函数表和虚函数指针。
当类中的一个函数被声明为虚函数时,该类就会在.rodata
区生成一个虚函数表,虚函数表里就存储着这个类中所有虚函数的地址。
经过C++的语法转换,实际上的成员虚函数调用就成了:
a->f() -----> a->_vptr[1]()
这个时候就会根据指针指向的内存块里的虚函数指针来调用对应的函数,这样只要控制虚函数表中对应的函数地址,用子类同名重写函数去覆盖父类的函数,就能达到调用子类函数的目的,也就是实现多态了。
给出简单的例子:
#include <cstdio>
#include <iostream>
class A {
virtual void f() {
std::cout << "A::f" << std::endl;
}
};
class B : public A {
void f() {
std::cout << "B::f" << std::endl;
}
};
int main () {
A *a1 = new A();
A *a2 = new B();
delete a1, a2;
return 0;
}
我理解应该是这样的:
a1->_vptr[1] ----- A::f
a2->_vptr[1] ----- B::f
接下来进行验证。
将上面的程序运行起来,断点打在19行,然后看下虚函数表地址。
也可以通过地址偏移找到虚函数表中第一个函数的地址:
从上面两站图可以看出来,其实虚函数指针存储的不是虚函数表的首地址,而是表头+16
,前面还有两个槽位。
接下来通过符号表+代码段首地址,计算出每个虚函数的地址。
首先给出代码段首地址,命令cat /proc/<pid>/maps | grep <可执行文件名字>
:
其中第一行标注r-xp
表示可执行的就是代码段的虚拟地址空间映射啦。
起始地址是0x55a3998ce000
。
然后查一下符号表readelf -Ws little_test | grep f
:
其中_ZN1A1fEv
和_ZN1B1fEv
就是函数A::f
和B::f
啦,不过因为C++的函数符号在编译器里是不一样的。。。所以看上去很奇怪,如果是C的函数符号就是正常的。。
然后计算函数的绝对地址就能得到两个函数对应的函数地址:
A::f = 0x55a3998ce000 + 0xb8a = 0x55a3998ceb8a
B::f = 0x55a3998ce000 + 0xbc2 = 0x55a3998cebc2
之前我们已经得到了类A和类B的虚函数表的首地址,按理说这个虚函数表里的第一个8位空间就应该分别存储A::f
和B::f
函数的地址。
于是再套个娃:
p (void *)*(uintptr_t *)(void *)*(uintptr_t *)(void *)(uintptr_t)a1
p (void *)*(uintptr_t *)(void *)*(uintptr_t *)(void *)(uintptr_t)a2
简单解释一下:首先根据a1指针获得对象的地址,然后根据对象的前8个字节存储了虚函数表地址(实际是表头+16
),将这里存储的值表示为vta
。
vta = (void *)*(uintptr_t *)(void *)(uintptr_t)a1
然后根据 vta
的前8个字节中存储了第一虚函数的首地址,
p (void *)*(uintptr_t *)vta
算是很套娃了。
打印执行的gdb的结果:
然后看一下之前计算出的函数地址:
A::f = 0x55a3998ce000 + 0xb8a = 0x55a3998ceb8a
B::f = 0x55a3998ce000 + 0xbc2 = 0x55a3998cebc2
是一样的!感动的痛哭流涕。
验证成功!