基础
在讲解带虚函数的多基派生问题时,我们要先弄清楚不带虚函数的多基派生存在什么样的问题,这样才好弄明白带虚函数的多基派生问题。
多基派生的二义性问题
一般来说,在派生类中对基类成员的访问应当具有唯一性,但在多基继承时,如果多个基类中存在同名
成员的情况,造成编译器无从判断具体要访问的哪个基类中的成员,则称为对基类成员访问的二义性问
题。如下面的例子,我们先定义3个不同的类A、B、C,这3个类中都有一个同名成员函数print,然后让
类D继承自A、B、C,则当创建D的对象d,用d调用成员函数print时,发生编译错误。
示例代码:
class A{
public:
void print(){
cout << "A::print()" << endl;
}
};
class B{
public:
void print(){
cout << "B::print()" << endl;
}
};
class C{
public:
void print(){
cout << "C::print()" << endl;
}
};
class D
: public A
, public B
, public C
{
};
void test(){
D d;
d.print();//error,编译器无法判断其调用的是哪个基类中的print函数
//解决办法:加作用域限定符
d.A::print();//ok
d.B::print();//ok
d.C::print();//ok
}
有了这个前提之后再来看带上虚函数的多基派生问题的分析。
带虚函数的多基派生问题分析
先来看一段代码:
#include <iostream>
using namespace std;
//A类拥有三个虚函数:a、b、c
class A{
public:
virtual
void a(){
cout << "A::a()" << endl;
}
virtual
void b(){
cout << "A::b()" << endl;
}
virtual
void c(){
cout << "A::c()" << endl;
}
};
//B类用有两个虚函数a和b,两个非虚函数c和d
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;
}
};
//类C继承A和B,其有一个虚函数a,两个非虚函数c和d
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;
}
};
void test(){
C c; //栈对象
A *pa = &c;
//问题出现了:下面这三句代码,执行的是类A中的函数还是类C中的函数呢?
pa->a();//这个毫无疑问,是多态机制,因为C重写了虚函数a,所以是类C中的函数a
pa->b();//派生类没有重写虚函数b的话,那么调用的虚函数b就是属于基类型类A中的函数b
/*对于函数c而言,比较难判断,因为A中的c函数是虚函数,而B中的c函数是非虚函数
* 且类C同时继承了类A和类B,意思就是类C同时有一份虚函数c和一份非虚函数c
* 这里其实调用的是类C中的c函数,因为它既没有重写A中的虚函数,又隐藏了B中的非虚函数
* 那不就只能调用到C中的c函数了*/
pa->c();
cout << endl;
B* pb = &c;
pb->a();//调用的是类C中的函数a,因为重写了虚函数a
pb->b();//调用的是类B中的函数b,因为派生类C没有重写该虚函数
pb->c();//因为函数c并非虚函数,所以调用的还是基类B中的函数b
pb->d();//同上
cout << endl;
C* pc = &c;
pc->a();//调用的是类C中的函数a
//pc->b(); error
/*这里的b函数会产生二义性,因为一份是类A中的b函数一份是类B中的b函数
* 产生了二义性,说明带虚函数的多基继承依然存在二义性问题,所以报错
* 正确写法依然还是像之前说的,使用作用域限定符即可,如下
* */
pc->A::b();
pc->B::b();
/*这里的函数c也很难判断,因为类A中的是虚函数,类B中的是却是非虚函数
* 同时类C当中也有一个非虚函数c,这里直接无脑是类C中的非虚函数c就行了
* 这里的情况和上面pa->c()是一样的,类C中的c函数既没有重写A中的虚函数
* 又隐藏了类B中的非虚函数,那就只能调用到类C中的c函数本身了*/
pc->c();
pc->d();//类B中的d函数直接被隐藏了,所以这里是类C中的d函数
}
int main(){
test();
return 0;
}
分析的情况都在代码的注释中了,请好好研读。
运行结果:
从图中可以看出,结果验证了我们的猜想。
从内存布局的层面进行分析
虚函数的底层实现
简单来说,就是通过一张虚函数表(Virtual Fucntion Table)实现的。具体地讲,当类中定义了一个虚函数后,会在该类创建的对象的存储布局的开始位置多一个虚函数指针(vfptr),该虚函数指针指向了一张虚函数表,而该虚函数表就像一个数组,表中存放的就是各虚函数的入口地址。如下图
当一个基类中设有虚函数,而一个派生类继承了该基类,并对虚函数进行了重定义,我们称之为覆盖(override). 这里的覆盖指的是派生类的虚函数表中相应虚函数的入口地址被覆盖。
那么虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?从上面的例子,可以得出结论:
- 基类定义虚函数
- 派生类重定义(覆盖、重写)虚函数
- 创建派生类对象
- 基类的指针指向派生类对象
- 基类指针调用虚函数
多基派生的底层实现
从代码可以知道,我们先讨论三个类各自的情况,先不谈多基继承的问题:
类A有三个虚函数a、b和c,对应上图,因为类A有虚函数,所以其会产生一张虚函数表,里面存放的是类A的三个虚函数的入口地址,而类A的内存地址空间的第一个位置存放虚函数表指针,其指向该表。
类B有三个两个虚函数a和b,因此类B也会具有一个虚函数表,其内存放的是类B的两个虚函数的入口地址,虚函数指针vfptr指向其虚表。
而类C也有一个虚函数a,因此其也会有一张虚表,存放其虚函数的入口地址,情况如下:
没问题吧?我们继续往后讨论,现在我们加入代码中继承的情况,即类C继承了类A和类B的情况:
在类C的内存地址空间中,因为继承关系,类C会继承得到类A中的三个虚函数和类B中的两个虚函数以及两个非虚函数(即继承得到类A和类B的两张虚表),因此地址开头的两块空间被用来存放了类A和类B的虚函数指针。所以从图中可以看到,因为类C重写了虚函数a,所以其覆盖了基类A中的虚函数a的地址,而C中并重未写函数b,所以在类A的虚表中函数b还是属于类A的(即调用的是类A中的函数b),函数c则因为没有被类C重写因此是属于类C的(调用都是类C的函数c)。
同理在类B的虚函数表中,类C重写了函数a,因此覆盖了类B中虚表里虚函数a的入口地址,所以调用的是类C的函数c,而b函数没被重写,因此依然属于类B。
这样的分析要比之前的代码中的注释应该要好理解一些吧。
这就是多基派生的底层实现原理。
番外:虚拟继承
两个概念:
虚拟继承是指在继承定义中包含了virtual关键字的继承关系。虚基类是指在虚继承体系中的通过virtual
继承而来的基类。
语法格式如下:
class Baseclass;
class Subclass
: public/private/protected virtual Baseclass{
public:
//...
private:
//...
protected:
//...
};
//其中Baseclass称之为Subclass的虚基类, 而不是说Baseclass就是虚基类
来举个例子加以说明,先来看一段代码,代码逻辑是类C继承了类B,类B继承了类A,三个类各自有一个成员变量,在main函数中初始化类C对象然后传入了三个值:
#include <ctime>
#include <iostream>
using namespace std;
class A{
public:
A(){
cout << "A()" << endl;
}
A(int ia):_ia(ia){
cout << "A(int)" << endl;
}
protected:
int _ia;
};
class B: public A{
public:
B(){
cout << "B()" << endl;
}
B(int ia,int ib):A(ia),_ib(ib){
cout << "B(int,int)" << endl;
}
protected:
int _ib;
};
class C:public B{
public:
C(){
cout << "C()" << endl;
}
C(int ia,int ib,int ic):B(ia,ib),_ic(ic){
cout << "C(int,int,int)" << endl;
}
void show() const{
cout << " ia = " << _ia << endl
<< "ib = " << _ib << endl
<< "ic = " << _ic << endl;
}
protected:
int _ic;
};
int main(){
C c(10,20,30);
c.show();
return 0;
}
其运行结果:
当我们将类B继承类A改成虚拟继承时:
class B: public virtual A{
public:
B(){
cout << "B()" << endl;
}
B(int ia,int ib):A(ia),_ib(ib){
cout << "B(int,int)" << endl;
}
protected:
int _ib;
};
此时运行结果截然不同:
可以看到ia变成了一个随机值,为什么?
细心对比的话,我们可以发现第一次调用时构造函数调用的是有参构造函数A(int),而第二次调用的则是无参构造函数A();
我们明明显式调用了A(int),却在使用了虚拟继承之后就成调用无参构造函数了(相当于没调用到A(int)),从这一点可以看出,派生类B并不负责虚基类A的数据成员的初始化。
那么谁来初始化虚基类A的数据成员呢?我们来将类C进行改写:
class C:public B{
public:
C(){
cout << "C()" << endl;
}
//改写位置
C(int ia,int ib,int ic):A(ia),B(ia,ib),_ic(ic){
cout << "C(int,int,int)" << endl;
}
void show() const{
cout << " ia = " << _ia << endl
<< "ib = " << _ib << endl
<< "ic = " << _ic << endl;
}
protected:
int _ic;
};
运行结果如下:
可以发现,虚基类A成员变量的初始化是由继承体系中的最后一个类来负责初始化的。
为什么?
在 C++ 中,如果继承链上存在虚继承的基类,则最底层的子类要负责完成该虚基类部分成员的构造。即我们需要显式调用虚基类的构造函数来完成初始化,如果不显式调用,则编译器会调用虚基类的缺省构造函数,不管初始化列表中次序如何,对虚基类构造函数的调用总是先于普通基类的构造函数。如果虚基类中没有定义的缺省构造函数,则会编译错误。因为如果不这样做,虚基类部分会在存在的多个继承链上被多次初始化。很多时候,对于继承链上的中间类,我们也会在其构造函数中显式调用虚基类的构造函数,因为一旦有人要创建这些中间类的对象,我们要保证它们能够得到正确的初始化。
菱形继承问题
在C++中,菱形继承是指一个类同时继承自两个不同的类,而这两个类又都继承自同一个基类。这种继承结构形成一个菱形的图形,导致了一些潜在的问题,其中最主要的问题是"菱形继承"问题(Diamond Inheritance Problem)。
问题的本质在于,如果不使用虚继承(virtual inheritance),最终派生类会包含两份相同的基类(共享的基类会被重复继承),导致数据冗余和访问冲突。
使用virtual关键字可以解决这个问题,即在派生类对共同的基类使用虚继承。虚继承的作用是确保只有一份共同的基类子对象,而不会出现重复。这样,菱形继承结构中的最终派生类只包含一份共同的基类,从而解决了数据冗余和访问冲突的问题。
代码示例:
class Base {
public:
// ...
};
class Derived1 : public virtual Base {
public:
// ...
};
class Derived2 : public virtual Base {
public:
// ...
};
class FinalDerived : public Derived1, public Derived2 {
public:
// ...
};
在这个示例中,Derived1和Derived2都使用了virtual继承自Base类。这确保了FinalDerived最终只包含一份Base类的实例,从而解决了菱形继承问题。
菱形继承深入
虚继承主要解决的是共享基类时的数据冗余问题,而不是成员函数的冲突问题。
让我们更深入地理解为什么使用virtual关键字可以解决数据冗余的问题。
在C++中,当一个类使用虚继承时,基类的子对象在派生类中只会有一份实例,而不是像普通继承那样每次都有一份。这是通过在派生类对象中引入虚指针(vpointer)和虚表(vtable)的机制来实现的(参考前文的讲述内存布局的部分)。
让我们看一下使用虚继承的例子:
class Base {
public:
int data;
};
class Derived1 : public virtual Base {
// ...
};
class Derived2 : public virtual Base {
// ...
};
class FinalDerived : public Derived1, public Derived2 {
// ...
};
在这个例子中,Derived1 和 Derived2 都使用了虚继承,因此它们共享一个虚表和虚指针,指向共同的 Base 子对象。当 FinalDerived 继承这两个虚基类时,由于它们共享相同的虚表和虚指针,最终 FinalDerived 中只包含一份 Base 类的子对象,而不是两份。
如果没有使用虚继承,FinalDerived 将分别继承 Derived1 和 Derived2 中的 Base 类子对象,导致 FinalDerived 中包含两份 Base 类的数据成员,造成了数据冗余。
虚继承的实现涉及到额外的内存结构,包括虚指针和虚表。这些机制确保了在派生类中只有一份共享的基类子对象,从而解决了数据冗余的问题。
关于菱形继承的一点疑惑
上面的深入部分我是问的GPT回答的,但我感觉不太对劲,因为在没有如果基类并不存在虚函数的话,那么虚函数表应该不会存在啊(学艺不精,等俺后面对这些概念更加清晰了再回来补这个坑)…
在有虚函数的基类中用上面的虚函数表理论比较好懂,但是在没有虚函数的基类中我觉得用虚基表的存在来解释这个更好理解:
在C++中,虚基表(Virtual Table,简称vtable)是用于支持多态性(polymorphism)和虚函数(virtual function)的一种机制。虚基表是针对包含虚函数的类层次结构而言的。
在C++中,当一个类包含至少一个虚函数时,编译器会为该类创建一个虚函数表(vtable)。虚函数表是一个数组,其中存储了指向每个虚函数的指针。当一个类派生自另一个类,而这两个类都包含虚函数时,派生类会继承基类的虚函数表,并在其自己的虚函数表中添加新的虚函数或覆盖基类的虚函数。
虚基表(virtual base table)是为了解决C++中的菱形继承问题而引入的。菱形继承指的是一个类同时继承自两个不同路径上的同一个基类,导致基类的实例在派生类中存在多份拷贝。为了解决这个问题,C++引入了虚基类(virtual base class)和虚基表。
虚基表的作用是为了跟踪虚基类的偏移量,确保在派生类中正确访问虚基类的成员。当一个类包含虚基类时,它的虚函数表中会包含一个指向虚基表的指针,虚基表中记录了虚基类的偏移量信息。这样,通过虚基表,派生类可以正确访问基类的成员,避免了菱形继承问题带来的二义性和数据冗余。
总的来说,虚基表是为了支持多继承和解决菱形继承问题而引入的,通过虚基表,C++能够正确地处理包含虚函数和虚基类的类层次结构。