最近在结合虚函数表分析构造和析构函数能否为virtual时,发现了一些问题,基类的构造与析构函数 和 派生类中构造与析构函数 的名字都不一样,即使使用virtual,怎么能实现基类指针访问到派生类虚函数实现上的呢?于是我决定挖一下,以下是分析过程和结果,如有错误,望指正。
1、引子:虚函数表和虚函数表指针
早些时候我看网上有些说法是一个类维护一个虚函数表,类对象中保存一个虚表指针,但停留在一知半解的状态下。于是我以为类对象的虚表指针指向类的虚表,然后如果派生类有重写,则覆盖掉之前的虚函数。这样就有了上面提出的问题:基类和派生类的类名都不一样,构造函数和析构函数要怎么重写去定位呢?
先回顾一下,虚函数表和虚函数指针是什么?
对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其内容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
这里我们着重看一下这张虚函数表。C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
————————————————
版权声明:本段为优快云博主「haoel」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.youkuaiyun.com/haoel/article/details/1948051
先看一个例子:假设我们有一个基类Base,有成员函数f(), g(), h() 。其中f()和g()是虚函数(当然这里可以定义为纯虚函数,但这样就不能生成类对象了),h()为非虚成员函数。
class Base
{
public:
virtual void f(){}//虚函数
virtual void g(){}//虚函数
void h(){}
};
简单画一下Base实例与其虚函数表之间的关系:
单纯的看虚函数是没有意义的,现在我们创建一个类AA继承自Base类。重写f()函数(此处可以省略AA中的virtual关键字),同时新加一个普通成员k(),如下:
class Base
{
public:
virtual void f(){}//虚函数
virtual void g(){}//虚函数
void h(){}
};
class AA: public Base
{
public:
void f() override{}//虚函数重写
void k(){}
};
因为f()是重写的虚函数,这里会替代原先的虚函数,再看一下各自实例与其虚函数表之间的关系:
试一下用基类指针绑定派生类对象,其调用怎么样:
void main()
{
Base *b = new AA();
b->f(); //正儿八经的多态调用,调用的是AA的f()
b->g(); //Base::g()
b->h(); //Base::h()
b->k(); //抱歉,k()是AA的成员,Base 无权访问
}
分析一下:
(1)b->f(),正常多态调用,调用AA::f()
(2)b->g(),调用自身成员
(3)b->h(),调用自身成员函数
(4)b->k(),调用失败,因为b是Base *类型,无权访问派生类AA的成员
这样的话我们就知道,用基类指针绑定派生类对象后,得到的是一个Base的实例。其对应虚表的关系图如下:
这就是为啥访问不了k()的原因。
小总结:使用基类指针绑定派生类对象,其基类指针的访问权限有:重写的virtual派生类成员,自身基类中非该派生类重写的其它成员
2、基类的构造函数声明不能为virtual,而基类析构函数一般设置为virtual
于是我们想一下,大家说的基类的构造函数不能为virtual,而析构函数要为virtual(甚至是必须为virtual),这是什么道理?
先从理论上分析构造函数为啥不能为virtual?我们知道,类的创建过程是先有基类,再创建派生类对象。假设基类的构造函数是virtual,那么我们使用类似Base *b = new AA();语句的时候,调用AA的构造函数的时候,发现Base还没有创建,于是调用Base的构造函数,但这时由于Base构造函数是virtual的,故会调用到派生类AA的构造函数上。而AA的构造函数需要基类Base的构造,Base构造需要AA的构造......显然这时一种无法实现的设计。
看看析构为啥要为virtual?显然是为了正确调用到派生类的析构函数。我们知道析构函数的作用是释放对象,如果析构不为virtual,那么delete b的时候只会调用自身的析构(可参考文章:析构函数什么时候被调用),对于AA对象中存在的堆空间成员(比如new,malloc分配的空间)则会造成内存泄露。
tips:可能会有一些追求完美的小伙伴有疑问,如果我们派生类没有堆空间成员,基类析构是否可以不为virtual?理论上是可以,但是一般不这么做。好的设计要遵从统一的规则,而且谁也不能保证以后都不用堆空间。
我们写一下代码,画一下关系图:
class Base
{
public:
Base(){}
virtual ~Base(){}
};
class AA: public Base
{
public:
AA(){}
~AA(){}
};
void main()
{
Base *b = new AA;
delete b;
}
如果析构函数不为virtual,则关系图如下:
析构函数为virtual,图如下:
类的普通成员是是编译时绑定,而虚函数是运行时才知道具体调用哪一个,构造函数是对象创建时调用,不可以晚绑定,故构造函数是不能声明为virtual
即:构造函数是不能重写的,否则会报错:“inline”是构造函数唯一合法存储类
我们结合图看一下能否行得通:delete b的时候,准备调用Base的析构,但是AA是其派生类,故先调用~AA(),结束后再调用自身的析构,结束。
3、总结
(1)基类的构造函数声明不能为virtual,而基类析构函数不一定为virtual,但是一般都设置为virtual,是为了防止派生类堆空间成员内存泄露
(2)使用基类指针绑定派生类对象,其基类指针的访问权限有:重写的virtual派生类成员,基类中自身非该派生类重写的其它成员
(3)多重继承中,派生类为每一个含有虚函数的基类维护一个虚函数表
(4)构造函数是不能重写的,否则会报错:“inline”是构造函数唯一合法存储类