单继承和多继承

继承分为单继承和多继承

单继承:派生类只有一个直接基类(如:A->B->C)
多继承:派生类有多个直接基类(如:X,Y->Z)

定义单继承派生类的基本格式:

class 派生类名:<继承方式>基类名{继承基类的成员,新添加的成员};

定义多继承派生类的基本格式:

class 派生类名:<继承方式1>基类名1,<继承方式2>基类名2…{继承基类的成员,新添加的成员};

定义的缺省继承方式是私有继承方式

继承关系中构造与析构原则

派生类继承了基类成员,对象中既包含基类成员又包含派生类成员,因此初始化时需要通过构造函数同时初始化全部成员
派生类在构造对象时,先生成自己的基类部分,然后生成自己的成员对象,最后生成自身特有的部分

构造函数、析构函数调用原则

在创建派生类对象时(其实创建组合类的情况类似)

  • 如果派生类没有定义构造函数,则调用基类的无参数的构造函数
  • 如果派生类定义了构造函数,不论是无参数还是带参数,在创建派生类的对象的时候,首先执行基类无参数的构造方法,然后执行自己的构造函数
  • 如果派生类的构造函数没有显示调用基类的构造函数,则会调用基类的默认无参构造函数
  • 如果派生类的构造函数没有显示调用基类的构造函数且基类自己提供了无参构造函数,则会调用基类自己的无参构造函数
  • 如果派生类的构造函数没有显示调用基类的构造函数且基类只定义了自己的有参构造函数,则会出错
    • 如果基类只有有参数的构造函数,则派生类必须显示调用此带参构造方法
  • 如果派生类调用基类带参数的构造函数,需要用初始化基类成员对象的方式

析构函数因为不涉及传入参数初始化的问题,因此直接调用基类的析构函数即可

单继承情况

派生类初始化与组合类的初始化方式类似,(构造函数)的基本格式为:

派生类名(初始化表):基类名(初始化表),对象成员1(初始化表)…对象成员n(初始化表){新增成员…}

如果基类使用缺省的或不带参数的构造函数,则初始化列表中基类构造函数(参数表)可以省略
如果没有初始对象成员,则初始化列表中对象成员i(参数表)也可以省略

例:定义基类椭圆类和派生类圆类

class Point{
    float x,y;
public:
    Point(int a,int b):x(a),y(b){}
};
class Elliptic{
    float a,b;
    Point centre;
public:
    Elliptic(float a1,float b1,float x,float y):a(a1),b(b1),centre(x,y){}
};
class Circle:public Elliptic{
public:
    Circle(float r,float x,float y):Elliptic(r,r,x,y){}
};                                         //创建单位圆对象,先调用Elliptic(),调用Elliptic()时先调用Point(),最后调用Circle()
int main(){
    Circle circle(1,0,0);
}          //如果Circle类中的新增成员存在类对象的话也是先构造部分再构造整体

构造函数的调用顺序为:(相当于是嵌套调用,自底向上地遍历完整棵继承树)

基类构造函数 对象成员所属类的构造函数 派生类构造函数

析构函数调用顺序相反:

派生类析构函数 对象成员所属类的析构函数 基类析构函数

多继承情况

多继承派生类在创建时也需要调用构造函数,基本格式为:

派生类名(初始化表):
基类名1(初始化表)…基类名n(初始化表),对象成员1(初始化表),…,对象成员n(初始化表){新增成员…}

多继承构造函数调用的基本顺序与单继承是一样的,也是先基类变量、再对象成员、最后普通变量,多个基类和成员依次按照规则完成各自的构造和初始化,其中基类对象和成员对象的构造函数调用顺序和声明继承关系的顺序有关

至于析构函数调用顺序和构造函数相反就可以了

二义性

当两个或多个基类中有同名的成员时,如果直接访问该成员会产生命名冲突,编译器不知道使用哪个基类的成员,即出现二义性
出现二义性存在两种可能的情况:

  • 访问不同基类具有相同名字的成员
  • 访问共同基类的成员(多层继承)

不同基类同名成员

在多继承情况下,访问不同基类的同名成员时存在二义性的问题,例:

class A{
public:
    int value;
    void f(){}
};
class B{
    public:int value;
    void f(){}
};
class C:public A,public B{
public:
    void g(){}
    void h(){}
};
int main(){
    C c;
    c.value=10;
    c.f();
}             // 此时即出现二义性,无法通过编译

解决方法是用类名和作用域符对同名成员加以限定,例:

int main(){
    C c;
    c.A::value=10;
    c.B::value=10;
    c.A::f();
    c.B::f();
}

多层继承

当存在多层继承(重复继承)的时候,会存在一个基类的多份拷贝,此时也会会存在二义性
基类的成员在派生类中被继承后,再次向下继承,此时单单指明最上层的基类是不够的,例:

class A0{
public:
    int a;
};
class A11:public A0{};
class A12:public A0{};
class A2:public A11,public A12{};
int main(){
    A2 a2;
    a2.A0::a=10
}                 //出现二义性,无法通过编译,因为A2继承的两个基类A1和A2都有A0的a成员,仅指明公共基类A0是不明确的

一种解决方法是明确指明成员是继承自哪个类,例:

int main(){
    A2 a2;
    a2.A11::a=10,a2.A12::a=10;
}  

虚继承

对于多层继承,如果对一个基类每层都只有一个拷贝,就可以达到消除二义性的目的,例:

class A0{};
class A11:public A0{};
class A12:public A0{};
class A2:public A11,public A12{};

在上面的例子中,类A0是派生类A2两条继承路径上的公共基类,因此这个公共基类会在派生类对象中产生两个基类子对象,虽然可以通过类限定符避免二义性,但问题关键在于我们不需要在派生类对象中存在多个基类对象的拷贝,要达到这种目的则需要将基类设置为虚基类,例:

class A11:virtual public A0{};
class A12:virtual public A0{};
int main(){
    A2 a2;
    a2.A0::a=10;
    cout<<a2.A0::a;
}  //输出结果:10

引进虚基类的目的就是为了解决二义性,使公共基类在其对象中只生成一个基类子对象(虚类子对象被合并成一个子对象)
由于多继承生成对象时是按声明顺序依次调用基类的构造函数,依次如果声明为虚基类时就会检查之前的拷贝,如果已经存在虚继承自相同基类的情况则将之前的那份对象的引用作为自身的拷贝,从而达到只有一份基类拷贝的目的
引申来说,虚继承只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。(因为虚派生出来的类中还是有基类的拷贝,只有当这个派生的类再次派生出类时虚继承的作用才能体现出来,即多重继承来的成员只保存一份拷贝,换句话说虚继承解决的是隔一代的问题)

在都定义虚基类的情况下,基类只有对具有第一份拷贝的(先声明的)那个虚继承派生类是真基类,对其余的虚继承派生类而言是假基类(虚基类),因此虚基类是一种相对的概念

虚基类对象的初始化

虚基类构造函数调用次序的规则为:

  • 虚基类构造函数在非虚基类之前调用
  • 若在同一层次中包含多个虚基类,那么虚基类构造函数按照声明的顺序调用
  • 若虚基类由上层基类派生,则还是遵守先调用基类构造函数,再调用派生类构造函数的规则

例:虚基类对象的初始化示例

class A01{public:A01(){cout<<"构造A01"<<' ';}};
class A02{public:A02(){cout<<"构造A02"<<' ';}};
class A03{public:A03(){cout<<"构造A03"<<' ';}};
class A11:public A01,virtual public A02{public:A11(){cout<<"构造A11"<<' ';}};
class A12:virtual public A02,virtual public A03{public:A12(){cout<<"构造A12"<<' ';}};
class A2:public A11,virtual public A12{public:A2(){cout<<"构造A2"<<' ';}};
int main(){
    A2 a2;
}    //输出结果:构造A02 构造A03 构造A12 构造A01 构造A11 构造A2  

上例的分析:

  • 定义A2对象,首先调用A2构造函数
    • A2构造函数先调用虚基类A12构造函数
      • A12构造函数先调用第一个声明的虚基类A02构造函数,打印"构造A02"
      • A12构造函数再调用第二个声明的虚基类A03构造函数,打印"构造A03"
      • A12打印"构造A12",完成A12的构造
      • 返回A2构造函数
    • A2构造函数再调用非虚基类A11构造函数
      • A11构造函数先调用虚基类A02构造函数,打印"构造A02"
      • A11构造函数再调用非虚基类A01构造函数,打印"构造A01"
      • A11打印构造"构造A11",完成A11的构造
      • 返回A2构造函数
  • A2打印"构造A2",完成A2的构造

虚基类表

从内存分布角度看,对于虚继承,大部分编译器会把基类成员变量放在派生类成员变量的后面,这与正常的继承方式相反(正常是继承的成员在前,新定义的在后)。
虚基类的子对象,也就是共享部分的偏移量会随着继承层次的增加而改变。关于如何计算共享部分的偏移细节,c标准并没有定义,不过c提供了虚基类表用于派生类中虚基类对象的检索与访问。

如果某个派生类有一个或多个虚基类,编译器就会在派生类对象中安插一个指针vbptr,指向虚基类表vbtable
虚基类表其实就是一个数组,保存的是所有虚基类(包括直接继承自基类和间接继承自祖先基类得到的)成员相对于当前对象的首地址的偏移,这样通过派生类指针访问虚基类的成员变量时,不管继承层次都多深,只需要一次间接转换就可以。(注意虚基类指针本身也会被继承下去)
假设A是B的虚基类,同时B又是C的虚基类,C是D的非虚基类,那么各对象的内存模型如下图所示:(仅用于举例,真正的内存分布要看不同编译器的具体实现)

另外,这种方案还可以避免有多个虚基类时让派生类对象额外背负过多的指针,每派生一次只会新建一个vbptr和虚基类表

注意vbtable和vftable的区别

  • 最大的区别在于前者属于一个对象实例,而后者属于一个类
    • 因为类的成员函数是在代码区中共享的,因此只需要在定义类时在类中加入一个虚函数的“接口”就可以让之后实例化的每个对象都通过这个接口访问该类独有的虚函数
  • 前者解决多重继承中数据重复的问题,后者是为了实现多态性
  • 前者的作用要隔代才能体现出来,后者的作用主要体现在直接继承的关系中(因为派生类是以直接基类的vftable为模板进行改写的)