欢乐C++ —— 15. 继承
参考:
简述
还记得我们之前写的编译期多态与运行期多态吗?继承是运行期多态的基石。下一节则是模板,编译期多态的基础。
运行期多态
- 优点
- OO设计中重要的特性,对客观世界直觉认识。
- 能够处理同一个继承体系下的异质类集合。
- 缺点
- 运行期间进行虚函数绑定,提高了程序运行开销。
- 庞大的类继承层次,对接口的修改易影响类继承层次。
- 由于虚函数在运行期在确定,所以编译器无法对虚函数进行优化。
- 虚表指针增大了对象体积,类也多了一张虚函数表,当然,这是理所应当值得付出的资源消耗,列为缺点有点勉强。
OOP核心思想在C++中表现是三部分:数据抽象,继承和动态绑定。通过使用数据抽象,可以将类的接口和实现分离(也就是类);使用继承,可以定义相似的对象类型,并对其关系建模;使用动态绑定,在一定程度上忽略了相似类型间的区别,统一了相似类的接口。
首先通过小例子来看看为什么继承。
-
通过前面的学习我们得知了类的概念,现在考虑这样一种应用场景,要编写一些管理学生的类,分别管理中学生,大学生,研究生。
按照面对对象的思想,我们应该给这几个不同的学生都要对应一个类。现在问题来了,学生这一个群体,它有一些共同属性,例如年龄,姓名,性别等等,这些属性及其所对应的方法没必要在每个类中单独写一遍,这样既不利于编写代码(多个类万一有的属性没打对),也不利于后期维护(如果发现哪个方法错误,要修改的话所有类都需要修改一遍)。
到了使用继承的时候了,我们可以将这些共同的属性统一组织到一个学生类(基类),然后其它的中学生,大学生等(派生类)都可以通过继承这个类,获得它所有的属性。这样修改时只需要修改这个基类,减少代码量和方便维护。
后面我们会进一步介绍,这种表示一个基类是派生类共同属性,派生类是基类的扩展 这种继承关系是public 继承 ,学生类是 抽象基类
-
还有一些类它们间的关系和上面的例子又不太相似。考虑这样一种关系:发动机和汽车。直接按上面的思想来继承的话就会有根本思想的错误,发动机不具有汽车的通有属性,发动机更像是汽车的一个组成部分,实际上对于这种关系我们也采用继承。
这种继承关系是private 继承,表达基类是派生类一部分的概念。
继承的概念不是很难,但在实际应用中正确使用比较困难,本文只介绍基础应用,后期会再做补充扩展。
继承基础
静态类型和动态类型
表达式的静态类型在编译期间是已知的,它是变量声明时的类型。动态类型则是内存中对象的类型,动态类型只有运行时才能确定。
只有基类指针或引用的动态类型和静态类型 才可能 不一致,从根本说,这才是C++支持多态的基础。
静态绑定,动态绑定 所谓动态绑定是相对静态绑定而言,静态绑定就是我们要调用哪个函数是在编译期就已经绑定决定好,而动态绑定是在运行时决定的。
可以将派生类的指针和引用传递给基类的指针和引用,当通过基类的指针或引用调用虚成员函数时,那么会根据这个指针或引用所指向的对象是 基类对象还是 派生类对象,从而调动基类或派生类的虚成员函数。
引用的底层就是指针,所以在这它和指针的作用等价。
声明
声明一个派生类,不需要加上派生列表 class Deriver;
并且如果想继承某个类,则基类必须在之前已经定义过。没有定义则为不完整类型,无法继承。
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只有该成员的唯一定义。
拷贝和赋值
当派生类对象赋值给基类对象,或作为参数拷贝构造基类对象,调用的都是基类的函数。因为基类对象无法转化为派生类对象。而派生类对象可以只使用基类部分。
虚函数
基类必须区分两类函数,一类是希望派生类直接继承而不要改变的函数,一类是希望派生类对其进行覆盖的函数。对于后者,基类应将其声明为虚函数(virtual)。当我们使用指针或引用调用虚函数时,调用将会被动态绑定。这样带来的好处是我们可以以同一种形式调用不同版本的函数。根据指针或引用的动态类型不同而调用不同的函数,简化编程。
虚函数适用于公有继承,因为跟公有继承的思想符合。
任何构造函数之外的非静态函数都可以是虚函数。virtual 声明只能出现在类内。
如果派生类想覆盖基类的虚函数,就必须函数名,形参,返回类型 都相同(而函数重载不要求返回类型)。
必须为每一个虚函数提供定义,因为编译器在编译时不知道将来会运行哪个版本的虚函数。
override 和final 可以控制成员函数在继承中关系,都是添加在形参后
-
final 指定一个函数(包括虚函数与非虚函数)不可在派生类中被覆盖
假如有学生类(基类,有年龄属性),那么不管是哪个阶段的学生(不同的派生类),其查询年龄的函数都是一样的,为了避免派生类不小心覆盖这个函数,就可以在学生类中将此函数声明为final
-
override 指定这个函数是覆盖基类的虚函数
主要用于排除一些人为的错误,如果指定一个函数为override 说明想用这个函数覆盖基类的虚函数,假如此时不小心形参没写对,或者返回类型错误等等,则编译器会直接指出该函数未能正确覆盖。
虚函数的默认实参:通常使用 引用或指针的静态类型中虚函数默认实参,所以尽量不要有同一个虚函数基类子类默认实参不同的情况。
处于派生体系中间的类即是基类也是派生类,如果指针或引用的静态类型是该类,则虚函数默认实参是该类指定的默认实参,
class Base { public: virtual void show( int x=10) { cout <<x<< "666" << endl; } }; class D1 :public Base{ public: void show(int x=20 )override { cout << x<<"D1" << endl; } }; class D2 :public D1 { public: void show(int x = 30)override { cout << x<<"D3" << endl; } }; int main( ) { D1 * x = new D1( ); D1 * y = new D2( ); x->show( ); y->show( ); return 0; } /*结果 20D1 30D3 */
如果想避免虚函数动态绑定机制,指定域名访问父类中的虚函数
BaseClass *p = new DerivedClass(); //基类有fun 虚函数,并且派生类已覆盖
p->fun(); //调用派生类fun
p->BaseClass::fun(); //调用基类fun
虚析构函数
只要有虚函数,则析构函数应该设置为虚函数,这样当delete 动态类型和静态类型不一致的指针对象时,可以正常先调用派生类的析构函数,再调用基类。
底层实现
其底层实现是通过虚函数指针和虚函数表来实现,具体感兴趣可进一步搜索研究
纯虚函数
纯虚函数就是没有定义的虚函数,具有纯虚函数的类是抽象基类,通过 =0
声明一个虚函数使其变为纯虚函数。
= 0 实际上将该函数的指针指向NULL
抽象基类
类是描述对象的模板,而有些东西,它是一个抽象的概念,例如汽车,游戏,电脑,这种表示一类物品的抽象概念。例如游戏,游戏都具有发布时间,运行平台等属性(这些属性就应该放在基类)。当我们说游戏,世界上所有的游戏产品都是游戏这个概念下的实体,例如王者荣耀,它是游戏这个类的派生类的一个具体对象。当我说创建一个游戏时,无法直接创建游戏这两个字所代表的特定对象。
所以,这些抽象类,它不对应现实世界中的实体,我们无法直接获得它的对象,只能通过子类进一步描述后再获取对象。
为了实现这样的功能,c++提供了抽象基类,抽象基类不可以定义对象。从实现的角度来说具有纯虚函数的类称为抽象基类。子类中没有实现纯虚函数的类依然是抽象基类。
一个抽象基类中所有函数都是纯虚函数称为接口类。
访问权限与继承关系
访问权限
在前面 类与对象的基础 中我们已经看到了类内两种访问权限 public private ,这块,我们看一个新的访问限制符 受保护的成员protected,这个访问限制只有在继承中表现与private不同。
如果基类中成员定义为protected 则基类,派生类中都可以访问它,而限制通过基类对象和派生类对象访问。
继承限制
类内的访问限制符是限制用户通过对象访问类 ,而在继承中访问限制符是 控制派生类用户对基类的访问权限
如果想更改继承的权限可以在派生类中使用 using
继承关系
- 公有继承表示派生类是基类,派生类是对基类更详细的描述的概念。(如动物类为基类,人类为派生类)
- 私有继承表示基类是派生类一部分的概念。(如发动机类是基类,汽车类是派生类)
- 保护继承不太常用,它的特点不是很鲜明。
派生类向基类的转换的可访问性
当操作者是用户时,只有派生类公有继承基类才可以转换。
当操作者是派生类时,派生类向基类的转换都是可以的。
其它相关知识点
类作用域
重载 指的是函数名相同,形参不同。在继承中,派生类只要和基类中 **函数名相同 **就会发生 同名隐藏 现象。
而通过using 可以解除同名隐藏现象。此时可进行 基类与派生类 函数重载。
class Base {
public:
void show( int x) {
cout << "666" << endl;
}
};
class D1 :public Base{
public:
void show(string str) {
cout << str << endl;
}
};
class D2 :public D1 {
public:
//using Base::show;
void show( ) {
cout << "7777" << endl;
}
};
int main( ) {
D2 d;
d.show(2); //x D2 没有接受int 的成员函数,Base 和 D1 的show 方法都被隐藏了。如果添加第15行,则正常
D1 dd;
dd.show(); //x D1 没有无参的show 函数
return 0;
}
除了覆盖基类的虚函数之外,不要定义和基类同名的成员。
构造函数与拷贝控制
派生类构造函数必须调用基类构造函数
派生类赋值运算符也需要调用基类赋值运算,控制基类的赋值。
派生类析构函数不用调用基类析构函数,其会自动调用。
继承构造函数
可以通过using 声明来继承 基类的构造函数
继承与智能指针与容器
当在容器中存放派生类或基类对象时,通过存放指针,可以混用。这种集合称为异质对象集合。
多重继承与虚继承
多重继承
在派生类的派生类列表中继承多个基类。例如以下继承体系,Panda 既继承Bear 又继承 Endangered ,就称为多重继承。
class ZooAnimal {
public:
int z = 1;
};
class Bear :public ZooAnimal {
int b = 2;
};
class Endangered {
int e = 4;
};
class Panda :public Bear, public Endangered {
int p = 5;
};
int main( ) {
Panda temp;
return 0;
}
构造顺序
在多重继承的情况下,派生类会在构造函数初始化列表中,按照派生列表中基类出现的顺序,初始化所有的基类。(当有虚继承时,存在例外)
虚继承
多重继承体系中,有些继承体系得到的结果却不是我们所期望的。假如如下继承体系:
先看看多重继承:
class ZooAnimal {
public:
int z = 1;
};
class Bear :public ZooAnimal {
int b = 2;
};
class Raccoon :public ZooAnimal {
int r = 3;
};
class Endangered {
int e = 4;
};
class Panda :public Bear, public Raccoon, public Endangered {
int p = 5;
};
int main( ) {
Panda temp;
temp.z = 6; //error z 不明确
temp.Raccoon::z = 6;
return 0;
}
这样的思路是没有问题的,但是实际上实现的过程中有点问题。但为什么 z 不明确呢?实际上只要明白这种情况下的内存分布就很好理解。
可以看到,对于 Panda 对象来说,它实际上有两个 ZooAnimal 成员,一个是由 Bear 继承而来,另一个是由Raccoon 继承而来。两个ZooAnimal 对象的数据没有关系。
这显然不是我们想要表示的继承关系,在我们想表示的继承关系中,熊猫 就是属于 动物园动物,而不是说它属于两个动物园动物。这种情况下,就要考虑使用虚继承。
虚继承的就是令某个类愿意共享它继承的基类(被共享的基类称为虚基类),所以虚继承一般出现在在继承体系的中间部分(即除过最底层的基类和最顶层的派生类)。无论一个派生类的派生体系中出现过多少次虚基类,它都只有一份虚基类对象。
//... 其它部分都不改变,只是添加两个virtual 关键字
class Bear :virtual public ZooAnimal { //表示愿意与其它类共享ZooAnimal
int b = 2;
};
class Raccoon :virtual public ZooAnimal {
int r = 3;
};
//...
底层实现
其底层是通过虚继承表 和虚继承指针 实现,具体感兴趣可进一步搜索研究
构造顺序
由最底层的派生类,按照继承列表的顺序,先构造所有虚基类,无论是直接还是间接。之后再按照顺序构造普通基类,最后再构造自身。而析构顺序与构造顺序相反。虚基类总会优先于普通基类构造。