C++多继承陷阱全解:虚析构函数与虚表布局的工程实践
目录
析构函数设为虚函数(重点)
验证虚表的存在(重点)
带虚函数的多继承
布局规则
带虚函数的多重继承的二义性
析构函数设为虚函数(重点)
虽然构造函数不能被定义成虚函数,但析构函数可以定义为虚函数,一般来说,如果类中定义了虚函数,析构函数也应被定义为虚析构函数,尤其是类内有申请的动态内存,需要清理和释放的时候。
class Base
{
public:Base(): _base(new int(10)){ cout << "Base()" << endl; }virtual void display() const{cout << "*_base:" << *_base << endl;}~Base(){if(_base){delete _base;_base = nullptr;}cout << "~Base()" << endl;}private:int * _base;
};class Derived
: public Base
{
public:Derived(): Base(), _derived(new int(20)){cout << "Derived()" << endl;}virtual void display() const override{cout << "*_derived:" << *_derived << endl;}~Derived(){if(_derived){delete _derived;_derived = nullptr;}cout << "~Derived()" << endl;}private:int * _derived;
};void test0(){Base * pbase = new Derived();pbase->display();delete pbase;//编译器会进行类型检查,pbase指向的空间是一个Derived对象//所以会调用Derived的析构函数 —— 需要让析构函数设为虚函数,Derived析构函数会在虚表中覆盖Base析构函数的地址//这样通过pbase才能调用到Derived析构函数//Derived析构函数执行完,会自动调用Base的析构函数(没有走虚表这个途径) —— 析构函数本身的机制
}
下面为将析构设置为虚函数和没有设置为虚函数的两种情况,可以发现未设置为虚函数的 基类的指针不会去调用派生类的析构函数,就会造成内存泄漏。
在执行delete pbase时的步骤:
首先会去调用Derived的析构函数,但是此时是通过一个Base类指针去调用,无法访问到,只能跳过,再去调用Base的析构函数,回收掉存放10这个数据的这片空间,最后调用operator delete回收掉堆对象本身所占的整片空间(编译器知道需要回收的是堆上的Derived对象,会自动计算应该回收多大的空间,与delete语句中指针的类别没有关系 —— delete pbase)
为了让基类指针能够调用派生类的析构函数,需要将Base的析构函数也设为虚函数。
Derived类中发生虚函数的覆盖,将Derived的虚函数表中记录的虚函数地址改变了。析构函数尽管不重名,也认为发生了覆盖。
在派生类析构函数执行完毕后,还是会自动调用基类析构函数。这是由编译器在析构函数调用序列中隐式安排的,这个过程不依赖于虚函数表,属于C++的语言规则。
总结:
在实际的使用中,如果有通过基类指针回收派生类对象的需求,都要将基类的析构函数设为虚函数。
建议:一个类定义了虚函数,而且需要显示定义析构函数,就将它的析构函数设为虚函数。
验证虚表的存在(重点)
从前面的知识讲解,我们已经知道虚表的存在,但之前都是理论的说法,我们是否可以通过程序来验证呢?——当然可以
class Base{ public:virtual void print() {cout << "Base::print()" << endl;}virtual void display() {cout << "Base::display()" << endl;}virtual void show() {cout << "Base::show()" << endl;} private:long _base = 10; };class Derived : public Base { public:virtual void print() {cout << "Derived::print()" << endl;}virtual void display() {cout << "Derived::display()" << endl;}virtual void show() {cout << "Derived::show()" << endl;} private:long _derived = 100; };void test0(){Derived d;long * pDerived = reinterpret_cast<long*>(&d);cout << pDerived[0] << endl;cout << pDerived[1] << endl;cout << pDerived[2] << endl;cout << endl;long * pVtable = reinterpret_cast<long*>(pDerived[0]);cout << pVtable[0] << endl;cout << pVtable[1] << endl;cout << pVtable[2] << endl;cout << endl;typedef void (*Function)();Function f = (Function)(pVtable[0]);f();f = (Function)(pVtable[1]);f();f = (Function)(pVtable[2]);f(); }
创建一个Derived类对象d,这个对象的内存结构是由三个内容构成的,开始位置是虚函数指针,第二个位置是long型数据_base,
第三个位置是long型数据_derived.
第一次强转将这个Derived类对象视为了存放三个long型元素的数组,打印这个数组中的三个元素,后两个本身就是long型数据,输出其值,第一个本身是指针(地址),打印出来的结果是编译器以long型数据来看待这个地址的值。
这个虚函数指针指向虚表,虚表中存放三个虚函数的入口地址(3 * 8字节),那么再将虚表视为存放三个long型元素的数组,第二次强转,直接输出数组的三个元素,得到的结果是编译器以long型数据来看待这三个函数地址的值。
虚表中的三个元素本身是函数指针,那么再将这个三个元素强转成相应类型的函数指针,就可以通过函数指针进行调用了。
——验证了虚表中存放虚函数的顺序,是按照基类中虚函数的声明顺序去存放的。
![]()
带虚函数的多继承
描述:先是Base1、Base2、Base3都拥有虚函数f、g、h,Derived公有继承以上三个类,在Derived中覆盖了虚函数f,还有一个普通的成员函数g1,四个类各有一个double成员。
class Base1
{
public:Base1() : _iBase1(10) { cout << "Base1()" << endl; }virtual void f(){cout << "Base1::f()" << endl;}virtual void g(){cout << "Base1::g()" << endl;}virtual void h(){cout << "Base1::h()" << endl;}virtual ~Base1() {}
private:double _iBase1;
};class Base2
{//...
private:double _iBase2;
};class Base3
{
public://...
private:double _iBase3;
};class Derived : public Base1, public Base2, public Base3
{
public:Derived(): _iDerived(10000) { cout << "Derived()" << endl; }void f(){cout << "Derived::f()" << endl;}void g1(){cout << "Derived::g1()" << endl;}
private:double _iDerived;
};int main(void)
{cout << sizeof(Derived) << endl;Derived d;Base1* pBase1 = &d;Base2* pBase2 = &d;Base3* pBase3 = &d;cout << "&Derived = " << &d << endl; cout << "pBase1 = " << pBase1 << endl; cout << "pBase2 = " << pBase2 << endl; cout << "pBase3 = " << pBase3 << endl; return 0;
}
三种不同的基类类型指针指向派生类对象时,实际指向的位置是相应类型的基类子对象的位置
VS上验证布局和虚函数表存放的内容,可见vs不会直接将原来的虚表进行覆盖,而是一些跳转指令。
布局规则
通过VS平台展示类对象内存布局的功能,我们可以总结出以下规则:
1 . 每个基类都有自己的虚函数表(前提是基类定义了虚函数)
2 . 派生类如果有自己的虚函数,会被加入到第一个虚函数表之中 —— 希望尽快访问到虚函数
![]()
3 . 内存布局中,其基类的布局按照基类被声明时的顺序进行排列(有虚函数的基类会往上放——希望尽快访问到虚函数)
如果继承顺序为Base1/Base2/Base3,在Derived对象的内存布局中就会先是Base1类的基类子对象,然后是Base2、Base3基类子对象
此时,如果Base1中没有定义虚函数,那么内存排布上会将Base1基类子对象排在Base2、Base3基类子对象之后。
4 . 派生类会覆盖基类的虚函数,只有第一个虚函数表中存放的是真实的被覆盖的函数的地址;其它的虚函数表中对应位置存放的并不是真实的对应的虚函数的地址,而是一条跳转指令 —— 指示到哪里去寻找被覆盖的虚函数的地址
带虚函数的多重继承的二义性
例子:
class A{
public:virtual void a(){ cout << "A::a()" << endl; } virtual void b(){ cout << "A::b()" << endl; } virtual void c(){ cout << "A::c()" << endl; }
};class B{
public:virtual void a(){ cout << "B::a()" << endl; } virtual void b(){ cout << "B::b()" << endl; } void c(){ cout << "B::c()" << endl; } void d(){ cout << "B::d()" << endl; }
};class C
: public A
, public B
{
public:virtual void a(){ cout << "C::a()" << endl; } void c(){ cout << "C::c()" << endl; } void d(){ cout << "C::d()" << endl; }
};//先不看D类
class D
: public C
{
public:void c(){ cout << "D::c()" << endl; }
};
内存结构的示意图:
请分析以下各种调用情况的结果
void test0(){C c;c.a(); c.b(); c.c(); c.d(); cout << endl;A* pa = &c;pa->a(); pa->b(); pa->c(); pa->d(); cout << endl;B* pb = &c;pb->a(); pb->b(); pb->c(); pb->d(); cout << endl;C * pc = &c;pc->a(); pc->b(); pc->c(); pc->d();
}
——思考:pc->c() 这里的c函数是不是虚函数
从内存的角度分析,C::c()已经在第一张虚函数表中了,所以应该当成是虚函数处理。能否验证一下呢?
D类继承C类,重新定义c()函数,用C类指针指向D类对象,并调用c()函数
如果将A类中c函数的virtual关键字去掉,毫无疑问C中c函数是一个普通函数(发生的是隐藏)
总结:
如果通过对象来调用虚函数,那么不会通过虚表来找虚函数,因为编译器从一开始就确定调用函数的对象是什么类型,直接到程序代码区中找到对应函数的实现;
如果基类指针指向派生类对象,通过基类指针调用虚函数,若派生类中对这个虚函数进行了覆盖(重写-override),那么符合动态多态的触发机制,最终的效果是基类指针调用到了派生类定义的虚函数;如果派生类对这个虚函数没有进行覆盖,也会通过虚表访问,访问到的是基类自己定义的虚函数的入口地址;
如果是派生类指针指向本类对象,调用虚函数时,也会通过虚表去访问虚函数。若本类中对基类的虚函数进行覆盖,那么调用到的就是本类的虚函数实现,如果没有覆盖,那么会调用到基类实现的虚函数。