继承概述
作为面向对象的三大特性之一,继承(inheritance)是面向对象编程中代码复用的一种重要手段。继承是类设计层面的一种复用,它允许在保证原有类性质不变的基础上对其进行扩展新的属性和功能,产生新的类。例如,在类 person 中定义关于 ‘人’ 的基本属性和行为,以 person 为基础扩展出特定人群的特殊属性和行为,即产生了一个新的类,这即是继承的基本思想。被继承的类称为父类(基类),继承产生的新的类称为子类(派生类)。
继承与组合
继承常常被与组合的概念进行对比。继承是一种白盒复用(white-box reuse),父类的内部细节对子类可见,耦合度高,是一种 is-a 的关系;组合(composition)是一种黑箱复用(black-box reuse),只能使用被组合对象的公开接口,没有强依赖关系,是一种 has-a 的关系。在保证逻辑合理的情况下,应优先考虑使用组合。
//人和工程师是一种is-a关系
class Person
{
/*…………*/
}
class Engineer : public Person
{
/*…………*/
}
//汽车和轮胎是一种has-a关系
class Tyre
{
/*…………*/
};
class Car
{
protected:
Tyre _tyre;
/*…………*/
};
继承方式
在C++中有三种继承方式,分别是public继承、protected继承和private继承。继承方式和父类的访问限定符共同决定了子类对父类成员的访问权限,其中继承方式的影响力大于父类成员的访问限定符。无论以何种方式继承,父类的private成员对于子类都是不可见的,“不可见”指的是父类的private成员被子类继承,但是语法上限制了子类不能在类内外访问父类的private成员。
在实际使用中,一般进行的都是public继承,后两种继承方式很少见。
在C++11中,可以使用final关键字修饰类,不允许该类被继承。
public继承中的对象赋值问题
C++语法规定,不允许父类对象赋值给子类对象,即向下转换,而允许子类对象赋值给父类对象,即向上转换。当子类对象赋值给父类对象时,不发生隐式类型转换和产生临时变量,而是进行赋值兼容,将共有部分进行切片,赋值给父类对象。
当用父类指针接收子类对象的地址时,父类指针维护的是父子类的共有部分;同样的,当用父类类型引用子类对象时,引用是共有部分的别名。这种切片行为也是多态实现的一种支持因素。
继承中的类作用域
在继承体系中,父类和子类处于不同的作用域,当访问一个成员时,优先在子类中寻找。当子类与父类中的成员同名时,这两个成员即构成“隐藏”关系,子类隐藏了父类的同名成员,访问被隐藏的父类成员需要用父类的域作用限定符进行指定,否则默认是子类的成员。对于成员函数,只要未被修饰的函数名相同即构成隐藏。
派生类的默认成员函数
派生类的默认成员函数遵循同一个设计理念:父类成员部分由父类的默认成员函数完成工作,子类独有的部分由子类的默认成员函数完成工作。
构造函数
子类的构造函数必须调用父类的构造函数对父类的那部分成员进行初始化。如果父类没有默认构造函数,则必须在子类构造函数的初始化列表进行显式调用。
析构函数
在子类析构时,编译器会先析构子类的独有部分,再自动调用父类的析构函数对父类部分进行析构。编译器保证“先子后父”的析构顺序,一方面符合栈的使用习惯,另一方便考虑到子类中的某些行为可能依赖父类中的某些成员。
拷贝构造函数
与构造函数类似,需要在子类的拷贝构造函数初始化列表中显式调用父类的拷贝构造函数对父类部分进行拷贝构造,直接将子类对象作为参数传递给父类的拷贝构造函数,此时会发生赋值兼容。最后对子类的独有部分进行拷贝构造。
赋值运算符重载
子类的赋值重载需要首先调用父类的赋值重载对父类部分进行赋值,由于子类和父类的赋值重载会发生同名隐藏,所以调用父类的赋值重载时需要用类访问限定符进行指定。最后对子类的独有部分进行赋值。
继承与友元、继承与静态成员
在C++中,友元关系不能继承。子类可以使用父类的静态成员,静态成员同时属于父类和子类。静态成员不会额外拷贝,子类继承的是静态成员的使用权。
C++多继承
一个子类只有一个直接父类,这种继承称为单继承,除单继承外,还存在一种事物同时具有多种事物的属性的情况,此时一个类继承自多个父类,这种继承称为多继承。
有多继承便有菱形继承。
菱形继承
菱形继承是多继承的一种特殊情况,其中一个子类的两个父类同时继承自同一个类。
从下面的代码和对象模型中可以看到,菱形继承会造成两个问题:
- 数据冗余。类A中的成员在D中出现了两份,会造成空间浪费。
- 二义性。由于D中有两份A的成员,所以调用的是B中的A成员或是C中的A成员便会有歧义,这便是二义性问题。实际访问时,需要用类访问限定符进行指定。
class A
{
public:
int _a = 0;
int _aa = 0;
};
class B : public A
{
public:
int _b = 0;
int _bb = 0;
};
class C : public A
{
public:
int _c = 0;
int _cc = 0;
};
class D : public B, public C
{
public:
int _d = 0;
int _dd = 0;
};
为了有效解决上述菱形继承造成的问题,虚继承应运而生。
菱形虚拟继承
在继承关系的腰部(例如上文的B类继承和C类继承)用virtual
修饰,即进行虚拟继承。
class A
{
public:
int _a = 0;
int _aa = 0;
};
class B : virtual public A
{
public:
int _b = 0;
int _bb = 0;
};
class C : virtual public A
{
public:
int _c = 0;
int _cc = 0;
};
class D : public B, public C
{
public:
int _d = 0;
int _dd = 0;
};
进行虚拟继承后,通过观察对象的内存分布和对象模型,可以发现对象中只有一份A类的成员,解决了数据冗余和二义性问题。
与普通菱形继承不同的是,菱形虚拟继承中的B对象和C对象中的第一个成员变成了一个地址,通过寻址,可以发现这个地址指向了一个表,其中存储了一些整型,这个表即是虚基表(virtual base table)。
虚基表存储了当前对象相对于共同父类对象(上文中的A)成员的偏移量,当用户通过B类或C类访问或修改A类成员时,只需要通过B对象或C对象的虚基表指针找到虚基表,并进一步拿到对象相对于A成员存储位置的偏移量即可得到A成员的实际存储位置,并对其进行操作,以达到同步的目的。
虚拟继承固然解决了菱形继承中的问题,但是避免造成这些问题最有效的方式便是不使用菱形继承。相比于菱形继承在实际中的使用频率,其造成的麻烦驱使我们尽量避免使用这种技术。