1 问题提出
笔者偶然发现对于含有虚函数的类,析构函数也会更新虚表指针。小有所得,特此记录。
这里使用vs2022 32位debug作为实验环境。
对于一个有虚函数的类,编译器在生成构造函数时,不只生成我们自己写的虚构函数里面的语句,还会把虚表地址赋值到对象中。
比如如下类,构造函数里面根本没有一句自己写的语句,但是编译器却自动生成了将虚表地址填充到类对象里面,这是大家都知道的。
class A {
public:
virtual void func() {}
A() {}
~A() {}
};
但是,对于析构函数,由于也是空的,生成代码和构造函数一模一样。这里再次把虚表地址填充到类对象中,对此,起初笔者觉得没有必要。想法也很简单,对象都快没有了,还把虚表地址放到对象里面,简直就是多此一举!不过,显然,编译器是对的。
2 探索
可以猜测,这里更新虚表指针必然要和调用虚函数有些关系,虚表就是为了调用虚函数服务的嘛。
如下例子,使用两种方式调用虚函数,第一种编译之后直接给出相对偏移,也就是编译器已经确定对象a是就是类A的实例,调用func必然是调用类A的func,于是编译时就确定了调用函数的地址。但是第二种用指针调用func,由于父类指针可以指向子类对象,那么编译器就无法知道p指向的是父类对象还是子类对象,于是就从虚表中得到了func的地址。
class A {
public:
virtual void func() {}
A() {}
~A() {}
};
int main() {
A a;
A* p = new A;
a.func();
p->func();
}
既然这样的话,合理推测,如果析构函数中调用了虚函数的话,需要从虚表中得到函数地址。而对于虚基类对象和派生类对象,虚表是不同的。对于派生类来说,如果基类析构函数没有更新虚表的话,并且基类调用了某个被重写的虚函数,如果不更新虚表指针,那调用的将会是子类函数,多么糟糕!
于是笔者写下以下代码测试,在析构A时,调用虚函数func,这样如果~A
不更新虚表,就会调用子类的func。不过很不幸,编译器很聪明,知道析构函数调用自己类的虚函数,将调用func优化为使用地址调用,而不是从虚表中找到func地址然后调用。
class A {
public:
virtual void func()
{
}
virtual ~A()
{
func();
}
};
class B:public A{
public:
virtual void func() { }
};
int main(){
A* p = new B;
delete p;
}
既然如此,那就不让编译器知道,于是改写代码如下,这样,依然通过指针调用,编译器就无法优化了。
class A {
friend void foobar(A *const p);
public:
virtual void func()
{
}
virtual ~A()
{
foobar(this);
}
};
void foobar(A* const p)
{
p->func();
}
class B:public A
{
public:
virtual void func() override { }
};
int main()
{
A* p = new B;
delete p;
}
结果很好,如果对象虚表指针没有更新,那么调用的func对象将是子类的。
既然如此,虽然结论已经出来了,这里也验证一下,口说无凭嘛。
class A {
friend void foobar(A *const p);
public:
virtual void func()
{
cout << "hello" << endl;
}
virtual ~A()
{
foobar(this);
}
};
void foobar(A* const p)
{
p->func();
}
class B:public A
{
public:
virtual void func() override {
cout << "world" << endl;
}
};
int main()
{
A* p = new B;
delete p;
}
对照之前代码,我将~A
更新虚表的指令换成nop,这样就对象的虚表就不会更新了,将会调用子类的func。打印出world而不是hello。perfect