第八章:多态
一、多态的概念
1.什么是多态?
1.多态的概念
什么叫多态?
多态( polymorphism )是面向对象设计语言的基本特征之一。仅仅是将数据和函数捆绑在一起,进行类的封装,使用一些简单的继承,还不能算是真正应用了面向对象的设计思想。多态是面向对象的精髓。多态可以简单地概括为“一个接口,多种方法”。比如说:警车鸣笛,普通人反应一般,但逃犯听见会大惊失色,拔腿就跑。
通常是指对于同一个消息、同一种调用,在不同的场合,不同的情况下,执行不同的行为。
2.多态的好处
我们知道,封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类)。它们的目的都是为了代码重用。而多态除了代码的复用性外,还可以解决项目中紧耦合的问题,提高程序的可扩展性。
如果项目耦合度很高的情况下,维护代码时修改一个地方会牵连到很多地方,会无休止的增加开发成本。而降低耦合度,可以保证程序的扩展性。而多态对代码具有很好的可扩充性。增加新的子类不影响已存在类的多态性、继承性,以及其他特性的运行和操作。实际上新加子类更容易获得多态功能。例如,在实现了圆锥、半圆锥以及半球体的多态基础上,很容易增添球体类的多态性。
3.编译时多态、运行时多态
C++支持两种多态性:编译时多态和运行时多态。 (面试高频)
(1)编译时多态 (静态多态):也称为静态联编,如(4种):函数重载、运算符重载、模板、std::bind + std::function。
C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数。
(2)运行时多态 (动态多态):也称为动态联编,通过继承和虚函数实现。
在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供这么一套称为“动态联编”(dynamic binding)的机制。不做说明,多态默认指动态多态。
二、虚函数
1.虚函数的概念
1.概念
虚函数是在基类中使用virtual关键字声明的成员函数。
它允许派生类 重写/覆盖(override) 基类的同形虚函数(函数名、参数、返回值都相同的虚函数)。
2.作用
虚函数提出的目的,就是为了实现动态多态,提高代码的可拓展性。但是由直接访问函数变为间接访问(通过虚表),降低了系统的效率。
使用基类指针或引用调用虚函数时,会根据实际指向的对象类型调用相应的函数实现。这种机制被称为“动态绑定”或“晚绑定”。
2.虚函数的实现原理
虚函数的实现依赖于 虚函数表 vtable 和 虚函数指针 vfptr 。
总结:对象中的虚函数指针,指向虚函数表。虚函数表中存放的是函数的入口地址,指向程序代码区中虚函数的存储位置。
(1)虚函数指针:
在每个对象中,编译器会插入一个指向虚函数表的指针(vptr)
vptr在对象创建时初始化,指向该对象所属类的虚函数表。
(2)虚函数表(虚表):存放虚函数的入口地址
每个包含虚函数的类都会有一个虚函数表。虚函数表是一个指针数组,数组的每个元素都是一个指向虚函数的指针。
每个类有自己的虚函数表,包含该类的所有虚函数的地址。
(1)虚函数指针
虚函数指针,指向虚表的首地址。虚表是一个函数指针数组,存放的是虚函数的入口地址。
当Base的display函数加上了virtual关键字,变成了一个虚函数,Base对象的存储布局就改变了。在存储的开始位置会多加一个虚函数指针,该虚函数指针指向一张虚函数表(简称虚表),其中存放的是虚函数的入口地址
Derived继承了Base类,那么创建一个Derived对象,依然会创建出一个Base类的基类子对象
在Derived类中又定义了display函数,发生了 覆盖(override) 的机制,覆盖的是虚函数表中虚函数的入口地址
Base* p 去指向Derived对象,依然只能访问到基类的部分。用指针p去调用display函数,发现是一个虚函数,那么会通过vfptr找到虚表,此时虚表中存放的是Derived::display的入口地址,所以调用到Derived的display函数。
(2)虚函数的覆盖
如果一个基类的成员函数定义为虚函数,那么它在所有派生类中也保持为虚函数,即使在派生类中省略了virtual关键字,也仍然是虚函数。虚函数一般用于灵活拓展,所以需要派生类中对此虚函数进行覆盖。
1.覆盖的要求:
与基类的虚函数有相同的函数名、相同的参数个数和参数类型、相同的返回类型。
而隐藏(oversee),只需要派生类函数和基类函数同名。
2.覆盖 总结:
(1)覆盖是在虚函数之间的概念,需要派生类中定义的虚函数与基类中定义的虚函数的形式完全相同,即函数名相同、参数的个数和类型相同、返回值相同。
(2)当基类中定义了虚函数时,派生类去进行覆盖,即使在派生类的同名的成员函数前不加virtual,依然是虚函数。
(3)发生在基类派生类之间,基类与派生类中同时定义形式相同的虚函数。覆盖的是虚函数表中的入口地址,并不是覆盖函数本身。
①关键字override的作用
我们在派生类中对虚函数进行覆盖时,很有可能写错函数的形式(函数名、返回类型、参数个数),等到要使用时才发现没有完成覆盖。这种错误很难发现,所以C++提供了关键字override来解决这一问题。
在虚函数的函数参数列表之后,函数体的大括号之前,加上override关键字,告诉编译器此处定义的函数是要对基类的虚函数进行覆盖。
(3)动态多态被触发的5个条件 (重点*)
虚函数机制被激活的5个条件:
(1)基类定义了虚函数或声明了纯虚函数
(2)派生类中覆盖虚函数(覆盖的是派生类的虚函数表中的虚函数的入口地址)
(3)创建派生类对象
(4)基类的指针指向派生类对象,或基类引用绑定派生类对象
(5)通过基类指针或引用调用虚函数
效果:基类指针可以调用到派生类的覆盖的虚函数。(基类指针是调不到派生类的普通函数的!只能调用到实现了动态多态覆盖的虚函数)
(如果没有虚函数机制,基类指针只能调用到基类的成员函数)
(4)虚函数表 *
在虚函数机制中virtual关键字的含义
(1)虚函数是存在的;(存在)
(2)通过间接的方式去访问;(间接)
(3)通过基类的指针访问到派生类的函数,基类的指针共享了派生类的方法 (共享)
(1)如果没有虚函数,当通过pbase指针去调用一个普通的成员函数,那么就不会通过虚函数指针和虚表,直接到程序代码区中找到该函数;
(2)有了虚函数,去找这个虚函数的方式就成了间接的方式。
面试题:
对虚函数和虚函数表有了基本认知后,我们可以思考这样几个问题 (面试常考题)
1、虚表存放在哪里?
只读段。具体是程序代码区还是文字常量区,不同IDE编译器(不同厂商)的实现不同。
gcc的虚表存在文字常量区。可见下文用夹逼地址证明。
编译完成时,虚表应该已经存在;在使用的过程中,虚函数表不应该被修改掉(如果能修改,将会找不到对应的虚函数
2、一个类中虚函数表有几张?
虚函数表(虚表)可以理解为是一个数组,存放的是一个个虚函数的地址
一个类可以没有虚函数表(没有虚函数就没有虚函数表);
可以有一张虚函数表(即使这个类有多个虚函数,将这些虚函数的地址都存在虚函数表中);
也可以有多张虚函数表(继承多个有虚函数的基类)
3、Q:虚函数机制的底层实现是怎样的?
A:虚函数机制的底层是通过虚函数表实现的。当类中定义了虚函数之后,就会在对象的存储开始位置,多一个虚函数指针,该虚函数指针指向一张虚函数表,虚函数表中存储的是虚函数入口地址。
4.三个概念的区分
(1)重载 (overload) : 发生在同一作用域中, 当函数名称相同时 ,函数参数类型、顺序 、个数任一不同;
(2)隐藏 (oversee) : 发生在基类派生类之间 ,函数名称相同时,就构成隐藏(参数不同也能构成隐藏);
(3)覆盖(override): 发生在基类派生类之间,基类与派生类中同时定义返回类型、参数信息、名字都相同的虚函数,覆盖的是虚函数表中的入口地址,并不是覆盖函数本身
①虚表被覆盖
继承时,派生类会复制一份虚函数表。若满足覆盖,则覆盖复制的那份虚表。
基类指针指向派生类对象,并调用重写的虚函数,本来还是只能调用基类子对象的&Base::display(),但因为虚表(中函数入口地址)被覆盖,调用到的是&Derived::display(),实现了动态多态。
(1)基类指针指向基类对象,调用虚函数,是查基类的虚表。
(2)基类指针指向派生类对象,调用虚函数,是查派生类的虚表。
如果没覆盖,那派生类虚表的内容和基类虚表的内容相同。如果覆盖了,对应位置就是派生类重写的虚函数的地址。
(3)应当是两张虚表。
反证法:因为如果只有一张,那么覆盖以后,基类指针指向基类对象,调用基类的虚函数时,也只能调用被覆盖的派生类虚函数了,不合理。
(5)基类指针指向派生类对象
1.基类指针指向栈上的派生类对象
Derived d;
Base * pbase = &d;
pbase->show();
//作用域结束,栈上的派生类对象自动销毁,此时pbase是悬空指针,不能再被安全使用
2.基类指针指向堆上的派生类对象
Base * pbase = new Derived();
pbase->display();
delete pbase; //动手new,必须手动delete回收
3.虚函数的限制:有四种函数不能设为虚函数
虚函数机制给C++提供了灵活的用法,但仍然受到了一些约束,以下几种函数不能设为虚函数:
1.构造函数不能设为虚函数
构造函数不能设为虚函数。
若构造函数设为虚函数,则确实可以创建基类对象。但当创建派生类对象的时候,在派生类的构造函数的初始化列表中先调用基类的构造函数。若基类的构造函数是虚函数,则此处又会被派生类的构造函数给覆盖,造成死循环。无法成功创建派生类对象。就无法实现动态多态。
因此,在语法上,构造函数之前也不能加virtual关键字,会报错。
2.静态成员函数不能设为虚函数
(1)虚函数指针vfptr 是 属于对象的数据成员,需要用this指针来指示是哪一个对象。
而静态成员函数没有this指针,因此静态成员函数不能被设为虚函数。
(2)虚函数的实际调用: this -> vfptr -> vtable -> virtual function
,但是静态成员函数没有this指针,所以无法访问到vfptr
3.inline函数不能设为虚函数
动态多态是运行时多态,inline是编译期完成替换。
而在编译期间无法展现动态多态机制,所以起作用的时机是冲突的。
虚函数和inline同时存在,则编译器不采用inline的建议,inline失效
4.普通函数不能设为虚函数
虚函数要解决的是对象多态的问题,与普通函数无关
4.虚函数的各种访问情况
虚函数机制的触发条件中规定了要使用基类指针(或引用)来调用虚函数,那么其他的调用方式会是什么情况呢?
1.通过派生类对象直接调用虚函数
并没有满足动态多态触发机制的条件,此时只是Derived中定义display函数对Base中的display函数形成了隐藏。
2.在构造函数和析构函数中访问虚函数
3.在普通成员函数中调用虚函数
class Base{
public:
Base(long x)
: _base(x)
{}
virtual void display() const{
cout << "Base::display()" << endl;
}
void func1(){
display();
cout << _base << endl;
}
void func2(){
Base::display();
}
private:
long _base = 10;
};
class Derived
: public Base
{
public:
Derived(long base,long derived)
: Base(base)
, _derived(derived)
{}
void display() const override{
cout << "Derived::display()" << endl;
}
private:
long _derived;
};
void test0(){
Base base(10);
Derived derived(1,2);
base.func1();
base.func2();
derived.func1(); //调用了Derived::display();
derived.func2();
}
第1/2/4次调用,显然调用Base的display函数。
第3次调用的情况比较特殊:
derived对象调用func1函数,因为Derived类中没有重新定义自己的func1函数,所以会去调用基类子对象的func1函数(通过基类子对象调用func1函数)。
可以理解为this指针此时发生了向上转型,成为了Base*类型。此时this指针还是指向的derived对象,就符合基类指针指向派生类对象的条件,在func1中调用虚函数display,触发动态多态机制。
5.抽象类
1.抽象类的定义
声明了纯虚函数的类称为抽象类,抽象类不能实例化对象。
2.抽象类的特点:
抽象类虽然无法创建对象,但是可以定义抽象类的指针,指向派生类对象,去调用派生类重写的(实现好的)纯虚函数。
3.抽象类有两种形式:
(1)声明了纯虚函数的类,称为抽象类
(2)只定义了protected型构造函数的类,也称为抽象类
(1)纯虚函数
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。纯虚函数的格式如下:
class 类名 {
public:
virtual 返回类型 函数名(参数 ...) = 0;
};
在基类中声明纯虚函数就是在告诉子类的设计者 —— 你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它。
多个派生类可以对纯虚函数进行多种不同的实现,但是都需要遵循基类给出的接口(纯虚函数的声明)。
class A
{
public:
virtual void print() = 0;
virtual void display() = 0;
};
class B
: public A
{
public:
virtual void print() override{
cout << "B::print()" << endl;
}
};
class C
: public B
{
public:
virtual void display() override{
cout << "C::display()" << endl;
}
};
void test0(){
//A类定义了纯虚函数,A类是抽象类。抽象类无法创建对象
//A a;//error
//B类只实现了一部分,B类也成了抽象类,也不能创建对象
//B b;//error
//C类只需要实现剩下的虚函数
C c;
A * pa = &c; //抽象类可以通过指针调用派生类,实现动态多态
pa->print(); //B类实现的。当然如果C类中也定义了,会覆盖
pa->display(); //C类实现的
}
(2)只定义了protected构造函数的类
1.构造函数是protected类型,也是抽象类,不能创建对象(不能实例化对象),也不能作为成员子对象存在。但是可以被继承,可以作为基类子对象存在。
2.构造函数是private类型:无法继承,无法向下派生。例如单例类的构造函数就是私有的
举例:
Base类只定义了protected属性的构造函数,不能创建Base类的对象,但是可以定义Base类的指针—— Base类是抽象类
如果Derived类也只定义了protected属性的构造函数,Derived类也是抽象类,无法创建对象,但是可以定义指针指向派生类对象
那么还需要再往下派生,一直到某一层提供了public的构造函数,才能创建对象。
class Base {
protected:
Base(int base): _base(base) { cout << "Base()" << endl; }
int _base;
};
class Derived
: public Base {
public:
Derived(int base, int derived)
: Base(base)
, _derived(derived)
{ cout << "Derived(int,int)" << endl; }
void print() const
{
cout << "_base:" << _base
<< ", _derived:" << _derived << endl;
}
private:
int _derived;
};
void test()
{
Base base(1);//error
Derived derived(1, 2);
}
6.虚析构函数
1.析构函数设为虚函数的原因:
当通过基类指针删除派生类对象(delete基类指针)时,首先调用派生类Derived的析构函数(后构造的先析构),然后再调用基类Base的析构函数。
如果基类的析构函数不是虚函数,删除基类指针时只会调用基类的析构函数,无法正确释放派生类对象的资源。尤其是当派生类中的指针申请了堆空间的时候。
所以很有必要将基类的析构函数设置为虚函数,即虚析构函数。
虽然构造函数不能被定义成虚函数,但析构函数可以定义为虚函数,一般来说,如果类中显式定义了虚函数,析构函数也应被定义为虚析构函数,尤其是类内有申请的动态内存,需要清理和释放的时候。
先调用Derived的析构,再执行Base的析构,最后operator delete。
但是Base的指针,无法调用到派生类的析构函数。因此考虑覆盖,将基类的析构函数设为虚函数,让
派生类的析构函数进行覆盖。虽然派生类的析构函数的函数名和基类不同,但是也可以形成覆盖!
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++的语言规则。
总结:
在实际的使用中,如果有通过基类指针回收派生类对象的需求,都要将基类的析构函数设为虚函数。
建议:一个类定义了虚函数,而且需要显示定义析构函数,就将它的析构函数设为虚函数。
7.验证虚表的存在 (重点)
代码验证,带虚函数的单继承结构下,虚函数的存放规则:
(1)虚表中,虚函数的存放规则
0.虚函数地址存放在虚表中。
虚表是函数指针的数组。
虚函数指针,是函数指针的指针。
通过指针调用虚函数:需要通过虚函数指针找到虚函数表,找到虚函数的入口地址。【通过指针调用虚函数,一定通过虚表。通过对象调用虚函数,不走虚表,直接去程序代码区找】
对象调用虚函数,直接去程序代码区中调用,不需要通过虚函数和虚表,不会有动态多态。
1.虚表的顺序,按虚函数在基类中的声明顺序排布。
2.派生类中若发生覆盖,则覆盖虚表中虚函数的入口地址。
3.若派生类自己也有虚函数,则往后排放。
4.在带虚函数的多继承的结构下,虚表有多张:
(1)第一张虚表进行覆盖,后面几张虚表应该发生覆盖的对应位置存放go to指令,跳转到第一张虚表 (vs的存放规则)。
(2)派生类中内存布局,基类子对象按照继承的先后顺序排放。若有定义虚函数的类,会优先排放其虚函数指针和基类子对象。
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; //base类的数据成员 10
cout << pDerived[2] << endl; //derived类的数据成员 100
cout << endl;
long * pVtable = reinterpret_cast<long*>(pDerived[0]);//long再转为long*
//虚表是函数指针数组
cout << pVtable[0] << endl; //虚表中的&Derived::print()
cout << pVtable[1] << endl; //虚表中的&Derived::display()
cout << pVtable[2] << endl; //虚表中的&Derived::show()
cout << endl;
//定义函数指针: 返回值类型为void,参数为无参
typedef void (*Function)();
Function f = (Function)(pVtable[0]); //强转:这里函数指针f就指向的是Derived::print()
f();
f = (Function)(pVtable[1]); //f指向Derived::display()
f(); //执行Derived::display()
f = (Function)(pVtable[2]); //f指向Derived::show()
f();
}
夹逼,证明了gcc的虚表在文字常量区
8.带虚函数的多继承
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
{
//... 对应Base1,补全 f g h 虚构
private:
double _iBase2;
};
class Base3
{
public:
//...对应Base1,补全 f g h 虚构
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上验证内存布局、虚函数表中存放的内容:f、g、h、虚构函数
vs:有多张虚表,第一张虚表存放的都是函数入口地址,后面几张虚表,覆盖的地方存go to指令
(1)布局规则
1.派生类中内存布局,基类子对象按照继承的先后顺序排放。 (都是虚函数,或都不是虚函数,按照函数声明的顺序存放)
2.若有定义虚函数的类,会优先排放其虚函数指针和基类子对象。(有的是虚函数,有的不是虚函数,则虚函数优先存放。)
通过VS平台展示类对象内存布局的功能,我们可以总结出以下规则:
(1)每个基类都有自己的虚函数表(前提是基类定义了虚函数)
(2)派生类如果有自己的虚函数,会被加入到第一个虚函数表之中 —— 希望尽快访问到虚函数
(3)内存布局中,其基类的布局按照基类被声明时的顺序进行排列(有虚函数的基类会往上放——希望尽快访问到虚函数)
(4)派生类会覆盖基类的虚函数,只有第一个虚函数表中存放的是真实的被覆盖的函数的地址;其它的虚函数表中对应位置存放的并不是真实的对应的虚函数的地址,而是一条跳转指令 —— 指示到哪里去寻找被覆盖的虚函数的地址
(2)带虚函数的多重继承的二义性
这种结构,c函数对于C还是虚函数。可用D公有继承进行验证。
C类指针指向D类对象,调用到的却是D的c函数。说明c函数对于C类还是虚函数。
三、虚拟继承
1.虚函数 vs 虚拟继承
虚基类指针vbptr中存储的是虚基类a的偏移信息。
指向虚基类表,简称虚基表。
虚基表中记录从虚基指针到基类子对象的成员需要偏转的信息。
(1)虚拟继承的内存结构
存储:虚基类指针、自己的数据成员、虚基类子对象
虚基类指针和本类单独的部分在最前,是比虚基类多出来的部分。40-16=24.
(2)如果虚基类中包含了虚函数
存储:
先是虚基指针 vbptr,指向虚基类表 vbtable。
再是虚函数指针 vfptr,指向虚函数表 vftable。
(3)如果派生类中又定义了新的虚函数,会在内存中多出一个属于派生类的虚函数指针,指向一张新的虚表(VS的实现)
内存布局:在最前面又新增虚函数指针,在虚基类指针之前。
(4)带虚函数的菱形继承——虚拟继承方式(拔高,不要求一定掌握)
需要显式定义构造函数、析构函数,vs会添加一些内容。可以用这条指令关闭vs添加内容。
#pragma vtordisp(off)
2.虚拟继承时派生类对象的构造和析构
1.普通继承,D类对象中的B类基类子对象和C类基类子对象中都有A类的基类子对象。A类会构造两次。初始化列表只需要显式调用直接基类的构造函数。
2.虚拟继承,D类要显式调用顶层基类A类的构造函数,只存一份A类的基类子对象。并会抑制B类和C类调用A的构造函数。
3.效率分析
不继承 > 单继承 > 多继承(两层) > 菱形继承(三层) > 虚拟继承
结构越简单,效率越高。