文章目录
- 继承
- 继承的基本概念
- 继承的基本定义
- 继承方式
- 继承的一些注意事项
- 继承类模板
- 基类和派生类之间的转换
- 继承中的作用域
- 派生类的默认成员函数
- 默认构造函数
- 拷贝构造
- 赋值重载
- 析构函数
- 默认成员函数总结
- 不能被继承的类
- 继承和友元
- 继承与静态成员
- 多继承及其菱形继承问题
- 继承模型
- 多继承
- 菱形继承
- 菱形继承解决方案之——虚继承
- 菱形继承的一个实例
- 多继承中的指针偏移
- 继承和组合
继承
本篇文章将进入c++学习地进阶部分。相比以往学的基础语法和基本概念会有所提升,且需要以往的概念掌握较为扎实。第一个部分就从继承开始讲起。
继承的基本概念
c++面向对象有三大特性:封装、继承、多态。我们今天要讲的正是三大特性之一——继承。
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派⽣类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
我个人看来,可以这么理解:一个类继承一个类,就是将被继承的类的东西放在继承的类上,但是又可以在此基础上衍生新的成员变量和函数。这不就很像继承先辈遗产嘛?将先辈遗产继承过来,但是我们可能还有自己的财产。那加在一起才是我的总财产。
我们下面来看一段代码就能理解了:
比如我们想要设计两个类,叫老师和学生。那这两个类都会有基本的成员变量比如:年龄、姓名、电话等。可能针对于老师会有一些特殊的函数如教书,学生会有学习的函数:
class Student
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证void identity(){// ...}// 学习void study(){// ...}
protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄int _stuid; // 学号
};class Teacher
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证void identity(){// ...}// 授课void teaching(){//...}
protected:string _name = "张三"; // 姓名int _age = 18; // 年龄string _address; // 地址string _tel; // 电话string _title; // 职称
};
如果我们分别实现两个类,其实是很麻烦的。最主要的就是代码逻辑会有冗余,因为有些成员是重复的。在以前函数或者某些类在功能上重复,数据类型不同时,衍生了一个叫模板的概念,是一种效率比较高的代码复用手段。但是现在是内部的代码有些相同,有些不同,应当如何复用呢?答案是使用继承。
我们先来简单看看是怎么实现的,有一些概念我们后续会提及:
class Preson {
public:void identity() {cout << "identity: " << _id << endl;}protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 电话string _id;//身份int _age = 18; // 年龄
};class Student : public Preson {
public:void study(){}
protected:int _stuid;//学号
};class Teacher : public Preson {
public:void teach() {}
protected:string _title;//职称
};
我们先不管继承的方式和为什么使用protected限定符,我们现在只需看看逻辑是怎么走的即可。
我们发现,对于重复的信息(即相同的成员变量和成员函数),我们都放在了一个叫Person的类内。那对于Student和Teacher的定义,不正好是在Person类的基础上衍生而来的吗?那可以进行继承,就是把Person类中的内容继承下来,再配合自己需要使用的成员,这不构造好了吗?这里代码量并不大,但是已经能体现出代码复用的优势了。
我们来看看子类内部是个什么结构:
符合我们之前说的,内部就是包含了父类的内容。而且在监视窗口下,会把父类当成一个整体。我们也最好是这么理解的。具体原因后续来说。
然后现在就该来讲,继承是如何使用的。
继承的基本定义
对于很多刚刚出现的新的内容,我们现在来一一讲解。
继承是需要有父类(被继承类) 的,就和我们模板特化一样,需要有主模板。在有了父类的情况下,继承类的用法为:class 继承类名 : 继承方式 父类(被继承类)
被继承的类就是父类,那类比人类父子关系,继承类就是叫子类。当然有些教材或者书籍上可能会称父类为基类,子类为派生类。这个其实也很好理解。基类对应的就是基本的概念,就代表被继承。派生类就是在基础的方式上进行衍生。
所以我们最后得到继承类的定义:class 子类名 : 继承方式 父类(被继承类)
代码示例:
class Student : public Preson {
protected:int _stuid;//学号
};class Teacher : public Preson {
protected:string _title;//职称
};
继承方式
我们重点讲讲这里的继承方式:
我们发现,继承方式对应的那个空填的的是public,这是不是意味着这个空还能填
private或者protected这两个类作用域限定符呢?
很聪明,是可以,只不过一般情况下用的都是public而已。
那它们分别代表着什么意思呢?我们来看看下面这个表格:
父类中成员变量属性 / 继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
父类的public成员 | 子类的public成员 | 子类的protected成员 | 子类的private成员 |
父类的protected成员 | 子类的protected成员 | 子类的protected成员 | 子类的private成员 |
父类的private成员 | 不可见 | 不可见 | 不可见 |
这个表格看着很多情况,其实很好记忆,我们将其分为两大类:
- 父类的private成员无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员
还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
我们来看看是怎么个事:
至于在类外面使用就肯定是不可能的,因为有限定符private的限制。这是我们早已知道的。但是我们发现,继承过后,父类A中的私有成员在子类a中也是无法使用的。至于其他继承方式就不再演示了,也都是一样的效果。
我们现在来看看是怎么样继承的:
其实这个看不见并不是说不继承过来,而是语法规定,基类的私有成员继承到派生类也是无法在派生类中进行使用。那类外面就更不可能了。
- 对于基类中public和protected成员来讲,继承到派生类的限定方式是Min(基类中成员的访问方式, 继承方式),其中:public > protected > private
这里我们就来讲讲限定符protected的作用是什么:
在之前刚进入类的学习的时候,我们并没有对这个限定符进行过多介绍,只是说知道那时候它和private的用法差不多就ok了。确实如此,因为protected正常情况下确实也是限制了类外不能使用其限定的成员。
但是我们倒回继承方式表格来看:
基类本身也是类。很可能有时候也会直接使用基类。如果基类中想控制在类中使用而不能在外界使用,用private来修饰确实可以。但是无论你以何种方式继承到派生类,都是 “看不见” 的,也就是不能在派生类中使用。
我们此时就来想,有没有这么一种场景,我需要控制只能在类中使用,但是又希望继承到派生类的时候在派生类里面也能使用呢?这个时候protected的起到这个作用了。所以可以看出保护成员限定符是因继承才出现的,我们也就很好的理解三个限定符的大小关系了。
所以对于这个表格记忆其实很简单,就这两个大点进行理解。
继承的一些注意事项
当然,我们还需要了解一些关于继承这个概念的注意事项。
其实对于继承方式的那个选项是可以不写的,甚至继承的基类可以是struct。
只不过使用对于继承的是struct还是class是有区别的。
区别就是:继承class默认是private继承,而继承struct默认是public继承,我们验证一下class的即可:
很明显,使用私有继承,对于A类中的public成员 _b在报错界面显示的是无法访问在a中的private成员。如果默认是private继承,那么基类中什么成员继承下来都是private成员。所以很好理解这个报错。而至于变量_a则是因为本身就是在基类中的私有变量,所以继承下来是看不见的。
对于继承struct的情况我就不再演示了,感兴趣的读者可以自行前往尝试。
但是还需要注意的是:
在实际运用中⼀般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
所以如果实在记忆不下那么多知识点就专门记一下public继承的要点即可。当然充分理解上面说的每一点进行记忆还是不难的。
继承类模板
现在我们再来看一个情况,即继承的类是个类模板。
之所以要提及继承类是个类模板是因为编译器的按需实例化处理。
在讲模板进阶的时候就已经说到过,编译器为了提高效率以及节省资源,对于类模板的处理都是按需实例化的。只会检查一下基本的语法是否有问题(如缺失分号、括号是否匹配,或者检查不依赖于模板参数的函数是否存在)。老一点的编译器可能在没有调用某个接口的情况下对于内部语法检查都不会做(如vs 2013)。这是模板的知识,我们先简单回顾一下做铺垫。
我们现在来看下面这样一个例子:
这里实现栈是使用继承来实现的。和适配器方式的区别后面再说。当前只需要知道,栈也可以使用继承方式来实现。
我们来看这个场景,发现报错了。正常来讲,这些接口在基类中是public 成员,按道理public继承下来在派生类中应该是可以使用的。为什么找不到标识符呢?
这时候就需要深刻理解按需实例化了。我们说过,编译器对于模板的处理是按需实例化。即调用了才会进行类型推演。此时我们定义一个stack< int > st,很多人会觉得,我不是把int传过去了吗,怎么还不能识别呢?
这是因为,定义模板类对象的时候传入的参数类型是给默认构造函数/构造函数使用的。我们并没有调用它的内部接口啊,所以那些接口都是没有实例化的。
我们可以尝试验证一下:
我们把默认构造写成private成员,编译器发现我们写了就不会自己写。但是我们在外界定义类对象的时候很明显报错了。无法访问默认构造函数。这就很好证明了,模板类定义的时候是只先实例化构造函数的。
基于此我们应该改进一下我们的代码,并且将功能完善一下:
#include<vector>
template<class T>
class stack : public std::vector<T>{
public:void push(const T& val) {std::vector<T>::push_back(val);}void pop() {std::vector<T>::pop_back();}size_t size() const{return std::vector<T>::size();}bool empty() const {return std::vector<T>::empty();}T& top() {return std::vector<T>::back();}const T& top() const{return std::vector<T>::back();}protected:
};
我们发现是可以正常使用的。
所以基类为类模板的时候需要我们特别的注意。
基类和派生类之间的转换
我们直接来看看要点:
• public继承的派生类对象可以赋值给(基类的指针 / 基类的引⽤)。有个形象的说法叫切⽚或者切
割。寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分。
• 基类对象不能赋值给派生类对象。
• 基类的指针或者引用可以通过强制类型转换赋值给派⽣类的指针或者引⽤。但是必须是基类的指针
是指向派⽣类对象时才是安全的。这⾥基类如果是多态类型,可以使⽤RTTI(Run-Time Type
Information)的dynamic_cast 来进⾏识别后进⾏安全转换。(ps:这个目前仅作了解)。
我们来举个例子看看就明白了:
class Person
{
protected:string _name; // 姓名string _sex; // 性别int _age; // 年龄
};
class Student : public Person
{
public:int _No; // 学号
};
int main()
{Student sobj;// 1.派⽣类对象可以赋值给基类的指针/引⽤Person* pp = &sobj;Person& rp = sobj;// 派⽣类对象可以赋值给基类的对象是通过调⽤后⾯会讲解的基类的拷⻉构造完成的Person pobj = sobj;return 0;
}
我们进入监视窗口查看一下:
很明显能发现,确实是只将派生类切片分割出基类有的成员,并且将这一个部分转化成了基类对应的对象/指针/引用。
其实是很形象的。注意,继承后的派生类中,基类的成员是放在前面存储的。编译器当识别到是派生类对应的内容向基类转化时,会做特殊处理。也就是会进行自动切割出从基类继承出来的内容,然后再转化。
但是需要注意的是,基类对象不能转化为派生类对象:
一是编译器没有对这种情况重载,二是根本就不符合语法规定。
其实很好理解,要执行切片分割,基类和派生类都有的当然好办,但是派生类中可能会有基类中不存在的成员呢?这该怎么处理呢?所以对此c++直接明确规定了基类对象不能转化为派生类的对象。
但是c++又规定经过强制转换,基类的指针/引用可以转化为派生类的指针/引用:
这里发现编译是不会报错的。至于引用的我就不再展示了。效果类似。
至于最后一个对于dynamic_cast来判断指针转换类型的方法,由于其涉及到多态等未学到的概念,这就后续再说了。当前了解即可。
继承中的作用域
首先我们得知道,基类和派生类本质都是类。c++中有四大作用域,即局部域、全局域、命名空间域、类域。类域也是独立的域。
不同的类对象构成的都是自己的域,所以不同的类对象内的变量是需要通过类域作用限定符来访问的。基类和派生类本质都是类,所有都是有自己的独立的作用域的。
既然是不同的域,就可以在不同的域中写相同的函数名或者变量名。这一定是不会构成命名歧义的。但是对于继承来讲,是一个新的现象。如果基类和派生类有同名成员是怎么办呢?
我们先说结论,无论是在类内中使用或是类外调用,都是将基类的同名成员隐藏起来,默认访问的是派生类中的那个。如若要访问基类中的那个需要使用域作用限定符来指定访问。
class A {
public:void test() {cout << "class A: test()" << endl;}
protected:int _a = 0;int _b = 1;
};class AA : public A {
public:void test() {cout << "class AA: test()" << endl;}
protected:int _a = 2;int _b = 3;
};
我们以这一段代码为例:
我们发现基类A和派生类AA中的内容均是同名的。我们来看看在派生类使用是如何使用呢?
很明显,默认使用的就是派生类中的那个。因为从基类继承下来的那一部分被隐藏了。但并不是说派生类中就没有从基类继承下来的同名成员了。我们通过调试还是会发现被继承下来的。只不过默认调用的是派生类的,要用基类中的需要通过域作用限定符进行访问:
这是我们需要注意的一点。当然对于成员变量也是一样的。我在这里就不进行演示了。原理都是一样的。
那如果在类外面直接调用这个test函数呢?
默认使用的依然是派生类中的那一份。想要使用到基类中的也需要指定访问:
当然还是有细节要注意的,我们通过下面这个例子来看看:
1.A和B类中的两个func构成什么关系()
A. 重载 B. 隐藏 C.没关系2.下⾯程序的编译运⾏结果是什么()
A. 编译报错 B. 运⾏报错 C. 正常运⾏
class A{
public:void fun(){cout << "func()" << endl;}
};class B : public A{
public:void fun(int i){cout << "func(int i)" << i << endl;}
};
int main(){B b;b.fun(10);b.fun();return 0;
};
先来看第一题,很多人直接就不假思索地说是函数重载了。看到函数名相同,参数不同。这是错的。这是没有对函数重载的定义深刻理解。
函数重载是对一个函数在有不同的参数(个数,顺序,类型不同)可以在同一个作用域内进行编写同名函数。**我们需要注意,函数重载是在一个作用域内的!**不同作用域本来就可以写同名函数,甚至一模一样也是可以。因为在两个独立的作用域内。
现在这两个func函数是隶属于两个独立的类中的,都具有自己的独立的作用域。所以一定不是函数重载。实际上是构成隐藏关系。
所以引出了第三点:继承中只要函数名相同就构成隐藏关系,无论参数是否相同。
所以第二题来说,会直接编译报错。因为就没办法使用到那个不带参数的func,因为它在基类中,是需要通过类作用域限定符来访问:
而且报错的是因为不接受0个参数,说明默认调用的都是派生类中的那个,也就验证了在继承中函数名只要相同就构成隐藏关系。
但是还需要注意的是,在实际中在继承体系里面最好不要定义同名的成员。因为这样子使用起来会非常麻烦,对于同名的还需要去指定访问。所以一般建议是不要定义同名成员。
派生类的默认成员函数
现在我们再来一起回忆一下默认成员函数有哪些:
就是这六种,其中对于取地址重载的两个默认成员函数一般是不需要我们自己写的,编译器默认生成的就够用了,但是其他的四个我们是需要看情况来写的。
那对与派生类来讲,由于其继承了基类的成员,那对于派生类的几个默认成员函数是否需要写呢?是否需要对基类的部分进行构造呢?下面我们一起来探讨一下。
我们主要来看看前四个默认成员函数。
默认构造函数
#include<string>
class Person {
public://Person的默认构造Person():_name(""),_gender(""),_age(0){}
protected:string _name;string _gender;size_t _age;
};class Teacher : public Person {
protected:int _id;//工号string _title;//身份
};int main() {Teacher t1;return 0;
}
我们先来看在基类中有写构造函数,但派生类中不写:
很明显发现,这个派生类对象还是能够正常构造出来的,为什么?
因为对于一个类来讲,无论是否写了初始化列表,内部的成员变量都要走初始化列表。因为初始化列表可以看作是成员变量定义的地方(即开空间)。
我们需要分三个部分来看:
1.内置类型
2.自定义类型
3.基类(把基类当作一个整体对象存储在派生类中)
对于内置类型,有传参用传参,没传参用缺省,没有缺省值就是随机值。
对于自定义类型,编译器会自动调用其默认构造函数,如果没有就会报错:
只要我们任意写了一个构造函数,编译器将不在生成默认构造。那此时报错的是无法使用Teacher(void),其实就是Teacher的默认构造。
前面两个我们以前就讲过,应当需要清楚。我们现在最需要知道的是对于基类的部分是如何操作的。对于上面的情况,基类中有默认构造(不传参就可以构造),所以我们在派生类中不需要写也是可以的。因为编译器自动调用了基类的默认构造。对于派生类内部的成员,就按照以往认识的规律来走。内置类型最次也是随机值,而自定义类型会自动调用其默认构造。很显然对Teacher类来讲,string这个自定义类型肯定是有默认构造的。
其实绝大部分情况下确实不用写,但是如果当派生类中的成员变量有指向资源呢?那就需要写了。这种情况我就不再多说了,详细的参考一下string的实现即可。
但是如果基类中没有默认构造呢?比如下面这种情况:
发现又报错了。这个时候我们就必须自行为基类的成员变量进行构造,也就是我们需要写对Teacher类的默认构造。但是这个默认构造不是乱写的。
我们需要记住的是,当基类不提供默认构造函数的时候,那派生类中对基类的构造就必须通过派生类的初始化列表进行显示调用,具体操作如下:
对于派生类中自己的成员变量我并没有写到初始化列表去,因为就算不写编译器也会让它们走初始化列表那一套,且规律就是以往认知的那个。所以我就不写了。
对基类的构造函数显示调用是很有趣的,就是在初始化列表内显示调用,就好像再构造一个基类的匿名对象一样。注意:这个显示调用只能在初始化列表走。
不走初始化列表就会报错。这点需要格外注意。
拷贝构造
直接看结论:
派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。
#include<string>
class Person {
public://Person的默认构造Person():_name(""),_gender(""),_age(0){}Person(string name, string gender, size_t age):_name(name),_gender(gender),_age(0){}Person(const Person& per) {_name = per._name;_gender = per._gender;_age = per._age;}protected:string _name;string _gender;size_t _age;
};class Student : public Person {
public:Student():Person("zhangsan", "male", 18),_pos("班长"),_stuid(2301){}
protected:string _pos;//班级职务int _stuid;//学号
};int main() {Student stuA; Student stuB = stuA; return 0;
}
我们来看看这个情形是否能够与正确使用:
我们并没有再派生类中写拷贝构造,所以用的就是默认生成的那个拷贝构造函数。
还是分为三类。基类部分的会去调用基类的拷贝构造。对于内置类型会使用值拷贝。对于自定义类型,如果没有写拷贝构造,那就是浅拷贝。如果写了,就自动调用其拷贝构造(如string)。
但是如果基类中不写拷贝构造呢?对于我上述举的例子也是可以的。为什么?
因为基类中的对象string是有拷贝构造函数的。那编译器生成Person类的拷贝构造的时候,势必要调用到string类的拷贝构造。
本质上来讲,还是依赖于基类中的拷贝构造。
但是再反过来想想,如果基类中有一个自定义类A,指向了资源,但是并没有A的拷贝构造。如果在基类中也不写拷贝构造,那么不就出问题了吗?因为A没有合适的拷贝构造,用A默认生成的那个是值拷贝,在有指向资源的时候肯定是不符合要求的:
我们在基类Person中加多这么一个类A进行验证,确实如我们所说。所以尽量还是给基类写一下拷贝构造。这样子在派生类没有额外变量指向资源的情况下我们就可以不用给派生类写拷贝构造函数了。对于默认构造也是一样的,也是推荐基类写好。
当然,我们也是可以在派生类中的拷贝构造函数内显示调用基类的拷贝构造:
还是需要在初始化列表内显示调用,但是我们发现传的竟然是将stu作为参数传入。这就用到了前面部分讲的派生类向基类的转换,也就是切片分割。编译器会自动地将属于基类的切割出来赋值。
赋值重载
至于赋值重载,其实它的行为和拷贝构造很类似。只不过一个是针对对象定义的时候,一个是针对于两个变量都已经存在的赋值情况。
再讲完前面两个默认成员函数后,我们其实已经很熟悉了,对于赋值重载只简单带过一下。
其实派生类的赋值重载也是依赖于基类的赋值重载的。这点的理由已经在拷贝构造部分讲了,这里是类似的,就不再多说。
我们来看看下面一段代码:
class Person{public:Person(const char* name = "peter"): _name(name){}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}
protected:string _name; // 姓名
};
class Student : public Person
{
public:Student(const char* name, int num): Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s): Person(s), _num(s._num){cout << "Student(const Student& s)" << endl;}Student& operator= (const Student& s){cout << "Student& operator= (const Student& s)" << endl;if (this != &s){// 构成隐藏,所以需要显⽰调⽤Person::operator =(s);_num = s._num;}return *this;}
protected:int _num; //学号
};
我们会发现对于operator=这个函数在基类和派生类中是构成隐藏关系的,所以是需要指定访问的基类的赋值重载函数的。
析构函数
这里我们先讲结论,对于析构函数~类名,在底层都是会经过封装称函数destructor的,所以基类和派生类中的析构函数也是构成隐藏关系。
还有就是编译器会自动调用派生类的析构函数再自动调用基类的析构函数。这就符合我们之前讲的类的构造和析构顺序了。因为基类都是比派生类先构造的。
class Person{public:Person(const char* name = "peter"): _name(name){}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};
class Student : public Person
{
public:Student(const char* name, int num): Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s): Person(s), _num(s._num){cout << "Student(const Student& s)" << endl;}Student& operator= (const Student& s){cout << "Student& operator= (const Student& s)" << endl;if (this != &s){// 构成隐藏,所以需要显⽰调⽤Person::operator =(s);_num = s._num;}return *this;}~Student(){cout << "~Student()" << endl;}
protected:int _num; //学号
};
我们之前也讲过,对于自定义类型,就算我们的析构函数里面什么也没写,编译器也会自行调用其析构函数。所以里面是可以不用写的。内置类型是否析构其实是无所谓的。
我们发现确实是先析构了派生类再析构基类。注意我们不需要自行显示调用基类的析构函数。因为编译器会自己调用,就是为了保证后构造的先析构。如果我们显式调用了就没办法做到这样的保证了,这点需要格外注意。
默认成员函数总结
经过一大段的分析,相比我们对类的默认成员函数有了更深的理解了。
我们最后来总结一下:
1. 派⽣类的构造函数必须调⽤基类的构造函数初始化基类的那⼀部分成员。如果基类没有默认的构造
函数,则必须在派⽣类构造函数的初始化列表阶段显⽰调⽤。
2. 派⽣类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷⻉初始化。
3. 派⽣类的operator=必须调用基类的operator=完成基类的复制。需注意的是派⽣类的operator=
隐藏了基类的operator=,所以显⽰调⽤基类的operator=,需要指定基类作⽤域
4. 派⽣类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派
⽣类对象先清理派生类成员再清理基类成员的顺序。
5. 派⽣类对象初始化先调用基类构造再调派⽣类构造。
6. 派⽣类对象析构清理先调用派⽣类析构再调基类的析构。
7. 因为多态中⼀些场景析构函数需要构成重写,重写的条件之⼀是函数名相同(这个我们多态章节会讲
解)。那么编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以基类析构函数不加
virtual的情况下,派⽣类析构函数和基类析构函数构成隐藏关系。
不能被继承的类
在c++11以后,引入了一个新的关键字final,加在某个类的定义后就代表这个类是不能被继承的。
在了解这个知识前,我们来看看c++98是怎么做的:
c++98的方法就是将类的构造函数放在私有域,就能使得继承下来后的派生类无法调用基类的构造函数,这样子就没办法继承了。但是这个方法是不太好的,构造函数都不能直接使用了。
所以c++11引入了关键字final,使用这个关键字后,直接代表该类不能被继承:
这里就直接报错了,不能将final类作为基类。
继承和友元
友元关系不能继承,也就是说基类友元不能访问派⽣类私有和保护成员
这个其实很好的符合了以往讲的,友元是具有单向性的:
至于为什么前置要先写一个声名class Student这早已讲过。因为在person类中声名友元函数Display中用到了Student类,但是此时有还没定义。所以先进行声名(编译器只会向上查找),意思差不多是告诉编译器后面会对这个类进行定义。
那如果想要让函数Display也能够访问派生类中的私有变量呢?很简单,在派生类中派生类中再声名一次友元即可:
这样就可以了,说明友元是严格的单向性的。
继承与静态成员
我们之前也讲过,如果在类中放一个静态成员变量,需要在类外进行初始化。而且它存储在静态区,并不隶属于某个类对象。
也就是说,加入定义一个静态变量static int i,如果不是静态变量,是普通的变量,那么每个实例化后的对象都有一个独属于自己的变量i,虽然名字一样,但是属于不同的类对象的。如果是静态变量,那就是大家公用一份,这个值虽然也能通过对象访问,但是大家访问的是同一份在静态区上的。如果可以修改的话那所有的对象再访问就会被修改了。静态变量的使用就参考string中的static size_t npos。
那对于继承是如何呢?
仍是一样的,继承下来给派生类,派生类去访问那个静态成员是和在基类中访问的那个是一样的,也就是地址是一样的,我们举个例子验证一下即可:
class Person
{
public:string _name;static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum;
};
int main()
{Person p;Student s;// 这⾥的运⾏结果可以看到⾮静态成员_name的地址是不⼀样的// 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份cout << &p._name << endl;cout << &s._name << endl;// 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的// 说明派⽣类和基类共⽤同⼀份静态成员cout << &p._count << endl;cout << &s._count << endl;// 公有的情况下,⽗派⽣类指定类域都可以访问静态成员cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}
注意,如果成员变量定义为私有成员,就没办法在类外面指定访问了。
多继承及其菱形继承问题
这个部分我们将重点来将一下多继承
继承模型
单继承:⼀个派⽣类只有⼀个直接基类时称这个继承关系为单继承
多继承:⼀个派⽣类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型
是,先继承的基类在前⾯,后⾯继承的基类在后⾯,派⽣类成员在放到最后⾯。
菱形继承:菱形继承是多继承的⼀种特殊情况。菱形继承的问题,从下⾯的对象成员模型构造,可以
看出菱形继承有数据冗余和⼆义性的问题,在Assistant的对象中Person成员会有两份。⽀持多继承就
⼀定会有菱形继承,像Java就直接不⽀持多继承,规避掉了这⾥的问题,所以实践中我们也是不建议
设计出菱形继承这样的模型的。
单继承:意思就是一个类只有一个直接的基类:
如图所示,PostGraduate的直接基类是Student,Student的直接基类是Person,从图来看就是单向继承。
多继承:意思就是一个类其实是从多个类继承下来的,也就是说不止有一个基类(父类):
如图所示,Assistant的基类有两个,分别为Student和Teacher,这就是多继承。当然多继承可以是继承多个下来。
菱形继承:这个是特别需要注意的,只要有多继承的概念就一定会有菱形继承的出现:
也就是说,Assistant从Student和Teacher继承来,但是Student和Teacher又分别继承了Person。这就好像构成了一个菱形的关系。
菱形继承只是一个普适模型,并不是一定要规规整整的菱形才是菱形继承。如上面这个图也是,菱形继承的本质就是某个类的前继基类中有两个是从同一个类继承下来的,我们简单可以认为是封闭图形时即为菱形继承。
对于单继承就不需要讲太多了,前面都是以单继承作为例子讲解的。
多继承
对于多继承来说,还是很常用的。因为总有一些类会同时满足另外两个类的特性,举一个生活中的例子: 如水果黄瓜/小番茄,它们既是蔬菜,又是水果。所以我们可以认为它是由水果和蔬菜继承下来的。
class Student {
protected:int _stuid;string _name;
};class Teacher {
protected:int _id;string _name;
};class Assistant : public Student, public Teacher {
protected:int _num;
};
但我们需要注意的是,如果使用多继承的话,就得控制好一下逻辑,就比如此时Assist这个类继承Student和Teacher,对于_name这个变量很明显继承下来就重复了。所以最好的方式就是将_name这个变量放在派生类中。
如图所示:
这点我们需要特别注意一下。
菱形继承
当然在此我们先说一个结论,尽量不要玩菱形继承,会非常麻烦!
首先对于菱形继承来讲,最顶上的那个基类会把成员分别继承到它的直接派生类,如果有一个类又使用多继承来继承这两个类,那么就会导致最顶上那个类的信息复制了两份:
class Person {
public:string _name; // 姓名
};
class Student : public Person{
protected:int _num; //学号
};
class Teacher : public Person{
protected:int _id; // 职⼯编号
};
class Assistant : public Student, public Teacher{
protected:string _majorCourse; // 主修课程
};
int main()
{// 编译报错:error C2385: 对“_name”的访问不明确Assistant a;a._name = "peter";// 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";return 0;
}
我们来看看这一段代码就知道了:
会报错,发现调用_name变量不明确,这是为什么?
我们分析一下就可以知道,这几个类之间的关系如下图所示:
先继承了Student,再继承了Teacher,所以对于Assistant这个派生类来讲,其内部结构大致是这样的。先继承的放前面。自己的变量放最后。然后对于Student和Teacher两个类来说,又是继承了Person,它们两个就有共同的继承下来的变量_name,这会导致歧义。调用的时候到底是哪个呢?这是无法说清楚的。
就算没有这个问题,更令人头大的问题是占用可空间的问题。因为Person的东西会有两份在菱形继承后的派生类中,所以这个派生类占用的空间将会变得特别大。
所以我们尽量还是不要玩菱形继承这一套。
菱形继承解决方案之——虚继承
但是菱形继承还是有解决办法的,就是使用虚继承。需要用到关键字virtual。
使用方法直接记住即可,即在菱形继承中找到谁会在菱形继承后的派生类中有二义性,然后再这个类的直接派生类中加上关键字virtual即可。注意,不能加多。
这个看着很奇怪,我们直接举例子就知道了:
对于刚刚那个例子,直接在Student和Teacher类加关键字virtual即可。因为Person会有两份在Assistant中,所以在Person的直接继承(Student和Teacher)上直接加入关键字virtual,这样子编译器就会在最后继承的时候,自动识别其基类的内容合并为一份给最后的派生类,这样子就不会产生歧义了。
其背后的原理很复杂,在这里就不进行过多赘述了。
再举这个例子,我们的virtual应该加在哪里呢?答案是B和C。因为A会在E中产生二义性,所以要在A的直接继承处加关键字virtual。
菱形继承的一个实例
一般是不建议玩菱形继承,但是在我们的程序中我们确实天天接触菱形继承,在这里就稍微做一下了解即可。
即我们常用的输入流和输出流,本质也是类:
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};
我们会发现中间的那个basic_iostream的直接基类是basic_ostream和basic_istream,这两个都是虚继承了ios_base。就是为了防止二义性的。
多继承中的指针偏移
然后我们现在来看一个指针偏移的问题:
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
现在要问的是:p1、p2、p3的关系是:
A:p1 == p2 == p3
B:p1 < p2 < p3
C:p1 == p3 != p2
D:p1 != p2 != p3
我们画出Derive的结构就知道了:
注意,先继承的在前面。
将子类的指针转化给父类是可以的,会做切片分割。
p3指针没得说,当然指向整个空间的开头。p1也是,因为做了切割,base1的内容又是存在Derive的空间的最前面,所以p1和p3指向一样。
p2指向的是图中base2开始的位置,因为编译器将内容切割出来。
最后我们看这个指向:
所以最后的答案是C。
继承和组合
最后来讲讲继承和组合的方式。
在前面讲到,栈的实现方式可以用适配器模式,也可以像这篇文章写的继承模式。
这两种方式各有优势,前者通常称为has_a,后者是is_a。这很好理解。
因为:
public继承是⼀种is_a的关系。也就是说每个派生类对象都是⼀个基类对象。
组合是⼀种has_a的关系。假设B组合了A,每个B对象中都有⼀个A对象。
继承允许你根据基类的实现来定义派⽣类的实现。这种通过⽣成派⽣类的复⽤通常被称为⽩箱复⽤
(white-box reuse)。术语“⽩箱”是相对可视性⽽⾔:在继承⽅式中,基类的内部细节对派⽣类可
⻅ 。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依
赖关系很强,耦合度⾼。对象组合是类继承之外的另⼀种复⽤选择。新的更复杂的功能可以通过组装或组合对象来获得。对
象组合要求被组合的对象具有良好定义的接⼝。这种复⽤⻛格被称为⿊箱复⽤(black-box reuse),
因为对象的内部细节是不可⻅的。对象只以“⿊箱”的形式出现。 组合类之间没有很强的依赖关
系,耦合度低。优先使⽤对象组合有助于你保持每个类被封装。优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。不过也不太
那么绝对,类之间的关系就适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的
关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合。
黑箱复用是更简单的,因为不需要太关注其底层原理,且代码耦合度不会太高。一旦代码耦合度太高,就会导致修改涉及范围变大,这在工程上是很避讳的。
所以优先使用组合,因为生活中大多食物还是满足组合关系的,如车里面有轮胎。不可能说轮胎继车,那就是轮胎是车。这是不对的。
当然也不是说继承一无是处,既然有而且那么大篇幅来讲,肯定是有其重要意义的。特别是在后续讲到多态中。且有些场景确实只能用继承。
所以我们应当适当选择方法,只不过说在能用组合的情况下尽量用组合而已。