现象
笔者最近遇到了一个诡异的BUG,析构函数执行期间crash(VS2022调试器下表现为abort),调用堆栈最后一级是调用虚函数,所有指针变量正常。
更深层的原因和特征隐藏在虚函数表中。abort发生时,虚函数表中有_purecall指针,这是一个非空指针,指向一个由编译器提供的错误处理函数(而非子类的成员函数)。该函数默认直接abort,按照微软的说法可以设置自定义的错误处理函数(笔者没有尝试),具体参考:
_purecall | Microsoft Learn
开发环境
win11, VS2022
调用纯虚函数的条件
_purecall指针出现的条件是:在父类的析构函数中调用虚函数,要求此虚函数在父类中是纯虚函数,在子类中实现。
下面给出满足此条件的测试代码:
#include <iostream>
using namespace std;
class C1
{
public:
int m_iData = 0;
C1() { m_iData = 1; };
virtual ~C1()
{
cout << "C1-d"<< endl;
Test();
Bug1();
//Bug2();//此行会引发链接错误
}
virtual int Test() { return Print(); };
virtual int Print() {
cout << "C1" << endl;
return 1;
};
virtual int Bug1() { return Bug2(); };
virtual int Bug2()=0;
};
class C2 : public C1
{
public:
virtual ~C2()
{
cout << "C2-d" << endl;
Test();
Bug1();
}
virtual int Print() {
cout << "C2" << endl;
return 2;
};
virtual int Bug2() {
cout << "Bug2" << endl;
return 100; };
};
int main()
{
C2 *p2 = new C2();
C1 *p1 = p2;
p1->Test();
p2->Test();
delete p1;
cout << "end" << endl;
return 0;
}
运行结果:
问题原因
分析此类问题的技巧:在abort发生后点调试器的暂停按钮,然后查看调用堆栈和局部变量,虚函数表包含在局部变量里。
可以看到crash位置是C1析构函数调用Bug2(20行),此时的虚函数表里已经没有Bug2,而有一个_purecall指针,如果在C2的析构函数里打断点,这个位置是Bug2。
再分析程序输出:main函数2次调用Test,都是C2,此时虚函数表里的Print指针是C2::Print,~C2调用Test结果也相同。但~C1调用Test打印C1,注意我们一共只创建了一个对象,所以是这个对象的虚函数表发生了变化,在~C2返回后,将Print改为了C1::Print,同时也改变了Bug2,又因为C1::Bug2是纯虚函数,所以编译器将其设为_purecall。
总结
1、析构函数的执行过程中,虚函数表会发生变化,使得虚函数的表现与一般情况不同。
2、子类的析构函数返回后,所有子类实现的虚函数在虚函数表中的指针都会替换为父类的对应方法(相当于子类重写父类方法的过程反过来),然后才会执行父类的析构函数。
3、如果在析构函数中直接调用纯虚函数,则会引发链接错误,这算是编译器帮我们处理了最简单的情况。但间接调用纯虚函数的复杂情况编译器无法识别。
4、父类的构造函数也有类似的现象,在执行期间,虚函数表中的指针都是父类的,纯虚函数则是_purecall。这一点读者可以在C1的构造函数中打断点观察。
---完---