【C++ 第十章】继承
1.继承的概念及定义
1.1继承的概念
继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
继承的本质是复用,比如你需要定义好几个类,但是这几个类有相当一部分的成员都是 重复的,此时就应该想到怎么 复用这一重复的部分,即写一个父类
例如:一个外卖平台有 骑手、商家、客户 三种类
这三种类都有 相同的几种成员,显得有点重复
此时就可以利用 继承,定义一个 父类,骑手、商家、客户 三种类 直接继承 那个基础的类(即父类),实现复用(就和函数复用差不多,当几个函数都有相同的程序步骤,可以直接封装成一个新函数,那几个函数直接复用这个新函数就行,增加可读性,减少代码量)
⭐多个类继承同一个父类 只是拷贝新的一份父类的成员变量到子类,父类的成员函数 共用同一个
因为在一个类对象的存储上来讲,成员函数本来就不属于一个类,而是放到公共代码区,供所有该类的对象复用
1.2 继承定义
1.2.1定义格式
下面我们看到 Person 是父类,也称作基类。Student是子类,也称作派生类。
Student 具有人的基础信息,则定义成父类,直接继承父类就好
// 父类:一个人的基础信息 class Person { public:Person(){}string _name = "peter"; // 姓名protected:int _age = 18; // 年龄 private://父类定义本质,不想被子类继承int _tel = 110; };// 子类:继承的父类的成员 class Student : public Person { protected:int _stuid; // 学号:学生类的特殊成员 };
1.2.2 继承关系和访问限定符
继承方式:顾名思义,以什么方式继承一个父类
访问限定符:就是之前讲过的,一个类中设置的 访问限定
1.2.3 继承基类成员访问方式的变化
这个表格的核心点:基类的 private 成员是在 子类中无论以什么继承方式继承, 都是不能被访问的的
注意:只是不能访问这个 private 成员,而不是没有被继承到子类里面
通俗解释:你爸爸继承了家族产业给你,但是你爸还是有自己的私房钱,你可以使用家族产业,但你不能使用 你爸的私房钱(即你是知道有这部分私房钱的,但是你不能使用,就像 子类确实有继承到父类的私有成员,但是不能访问)
那有无方式访问到 父类的 私有成员?
通过调用父类的成员函数,间接的访问 父类的 私有成员
(1)精华总结 相关性质:
1、 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私 有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面 都不能去访问它。
2、基类 private 成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
小结:一个基类不想要 外界 和 子类 访问的成员,就写成 private;如果不想要 外界访问,但是允许子类访问,就写成 protected
3、父类的其他成员在子类的访问方式 等价于 Min(成员在基类的访问限定符,继承方式),public > protected > private。
(即 成员在基类的访问限定符 和 继承方式,取级别小的那个, 因为 private 最小,因此无论 继承方式是什么,最终 子类的对父类成员的访问方式都是 private的)
4、使用关键字 class 时默认的继承方式是private,使用 struct 时默认的继承方式是 public,不过 最好显式的写出继承方式。
即可以不显式写 继承方式,class 默认 private,struct 默认 public
class Student : Person // 默认 private 继承 struct Student : Person // 默认 public 继承
5、不同的继承方式有什么意义:若子类 私有 private 的 继承一个 父类,则这个父类的继承下来的所有成员都变成这个子类的 私有成员(包括成员变量);
其他的同理:若子类 公有的 继承一个 父类,则以优先级取小的原则,父类的 公有成员 变成 子类的公有,父类的 保护成员 变成 子类的 保护
6、在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡 使用protetced/private继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
(2)结合上面的 6 条性质,分析一下下面的代码:
父类 Person 继承给 子类 Student
子类 Student 的继承方式是 public
因此,按照 继承方式和成员在基类的访问限定符 优先级取 min原则,
父类中的 public 成员 _name 继承到子类变成子类的 public 成员,
父类中的 protected 成员 _age 继承到子类变成子类的 protected 成员,
父类中的 private 成员 _tel 形式上是被继承到了子类,但是在 子类中不能访问 父类的 private 成员
// 父类:一个人的基础信息 class Person { public:Person(){}string _name = "peter"; // 姓名protected:int _age = 18; // 年龄 private://父类定义本质,不想被子类继承int _tel = 110; };// 子类:继承的父类的成员 class Student : public Person { protected:int _stuid; // 学号:学生类的特殊成员 };
2.基类和派生类对象赋值转换
2.1 关于类型转换
// 类型转换:会产生临时变量int i = 1234;// 截断:将 int 4字节的数据截断成 1 字节的 char 类型 char ch = i;// 提升:将 1 字节的 char 类型 的数据提升成 int 4字节 i = ch;const char& refch = i; // 临时变脸具有常性,这里就要加 const
2.2 基类和派生类对象赋值转换 的 概念
(1)“基类和派生类对象赋值转换” 是一种 特殊语法规则:
不是上面讲的类型转换,我们这个中间没有产生临时变量
(2)只限于公有继承:
即 父类的成员都有的情况,相当于子类中有一整个父类
(子类分两部分:自己的成员 和 父类继承过来的成员)
(3)赋值过程:
相当于将子类的 父类部分 切割出来 进行 “ 赋值给 父类的对象 / 父类的指针 / 父类的引用 ”的操作。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
而 引用和指针也是 直接指向 那个 子类的父类部分:即可以通过指针或引用对 子类的父类部分进行操作
(4) 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面再讲解,这里先了解一下)
(5)基类对象不能赋值给派生类对象。(因为 父类不一定有子类的全部,子类肯定有父类的全部才能切的)
创建的指针 ptr 和 引用 ref 就是指向 子类的 父类部分
// 父类:一个人的基础信息 class Person { public:Person(){}string _name = "peter"; // 姓名protected:int _age = 18; // 年龄 private://父类定义本质,不想被子类继承int _tel = 110; };// 子类:继承的父类的成员 class Student : public Person { protected:int _stuid; // 学号:学生类的特殊成员 };
int main() {Student s;Person p;// 跟下面机制不一样p = s; // 子类直接赋值给 父类:本质是切割子类 s 中 父类的部分给 pPerson* ptr = &s; // 父类指针 指向 子类 s 中 父类的部分Person& ref = s; // 父类引用 也“指向” 子类 s 中 父类的部分ptr->_name += 'x';ref._name += 'y';s._name += 'z';return 0; }
将 子类的 父类部分切割过去 赋值给父类
3.继承中的作用域
3.1 概念与性质
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 对基类的成员显式访问)
- 即 子类和父类存在的同名成员 的情况,就构成隐藏,子类或外界访问这个同名成员,会优先访问自己的,然后才到父类的
- 若想要直接访问 父类的那个成员,可以指定类域访问,否则默认先访问子类的
- 同名成员间不会发生冲突,因为两个同名成员各自属于不同的类域
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。(定义同名成员简直在给自己找坑)
Student 的 _num 和 Person 的 _num 构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person { protected:string _name = "小李子"; // 姓名 int _num = 111; // 身份证号 }; class Student : public Person { public:void Print() {cout << " 姓名:" << _name << endl;cout << " 身份证号:" << Person::_num << endl;cout << " 学号:" << _num << endl;} protected:int _num = 999; // 学号 };void Test() {Student s1;s1.Print(); };
3.2 面试题:
题一:A 类 中的 fun 和 B 类中的 fun 构成什么关系
A. 重载 B. 隐藏 C. 编译错误 D. 运行错误
class A { public: void fun(){ cout << "func()" << endl; } }; class B : public A { public: void fun(int i) { A::fun(); cout << "func(int i)->" << i << endl; } };
B 中的 fun 和 A 中的 fun 不是构成重载,因为不是在同一作用域(重载必须在同一作用域)
B 中的 fun 和 A 中的 fun 构成隐藏,成员函数满足函数名相同就构成隐藏。
题二:下列代码运行结果是什么?(不定项选择)
-
fun构成重载
-
fun构成隐藏
-
fun构成重写
-
编译报错
-
运行报错
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 bb;bb.fun();return 0; }
首先,肯定构成 隐藏
其次,fun() 会优先到 类 B 中找,但是类B中的 fun需要一个参数,编译器认为参数不匹配,则导致 编译错误
因此选 2 和 4
4.派生类的默认成员函数
4.1 各种默认成员函数 功能介绍
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
(1)⭐构造函数:派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。(不能直接在初始化列表对 父类的成员进行初始化, 而是要调函数)。
子类生成的 默认构造需要处理三个部分 :
-
父类的成员:视作一个整体,调用父类的默认构造(注意:只能调默认构造)
-
自己的内置类型:一般不处理
-
自己的自定义类型:调用自己的默认构造
(2)⭐拷贝构造:派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
(3)⭐operator=:派生类的operator=必须要调用基类的operator=完成基类的复制。
(4)⭐析构函数:派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
(5)⭐构造顺序 先父后子:派生类对象初始化先调用基类构造再调派生类构造。
(6)⭐析构顺序 先子后父:派生类对象析构清理先调用派生类析构再调基类的析构。
(7)⭐析构可能构成重写:因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
4.2 关于 派生类 和 基类 的 构造和析构顺序:先父后子 or 先子后父
先说结论:
派生类要构造:先调用 父类的构造,再完成 子类的构造:先父后子
派生类要析构:先完成 子类的析构,再调用 父类的析构:先子后父
4.2.1 派生类要构造:先父后子
因为子类构造初始化可能会需要使用父类成员:
比如子类的一个成员初始化:name(person::name) 要使用到父类的成员的值来初始化 子类的 成员 name
如果父类没有先初始化(即 先 子 后 父),则父类成员就是随机值,会导致子类的这里初始化也是随机值
比如下面 子类Student 的构造函数,在初始化列表处 显式调用 父类的构造函数
// 父类构造显示调用,可以保证先父后子 Student(const char* name = "", int x = 0, const char* address = ""):_x(x), _address(address), _name(Person::_name + 'x'), Person(name) // 显式调用父类的构造 {}
注意:初始化列表的初始化顺序是由 成员声明顺序决定的,不是初始化列表的排列顺序
编译器底层默认 子类中,父类是最先声明的,则在初始化列表中,也就最先调用父类的构造函数进行初始化,因此 编译器自己本就默认 先父后子
4.2.2 派生类要析构:先子后父
为什么需要先子后父:因为在 子类的析构函数中,可能还会使用到 父类的成员,此时如果 父类早早的先析构了,即先父后子的顺序,则我们这里访问的父类成员就是一块随机空间
正确的做法:自己不要显式的调用 父类的析构函数,可以在 子类析构后,编译器会自动调用 父类的析构函数
~Student() {// 析构函数会构成隐藏,所以这里要指定类域//Person::~Person(); // 不要显式调用cout << "~Student()" << endl;cout << _str << endl; // 打印父类中的成员 _str(若前面显式调用父类的析构,则这里会打印出随机值) }
4.2.3 析构函数会构成隐藏
由于多态,在底层,父类和子类的 析构函数的名字会被统一处理成 destructor()
因此导致同名,同名函数则构成隐藏
调用父类的 析构函数会构成隐藏,所以这里要指定类域
Person::~Person();
4.3 拷贝构造 和 赋值重载
子类默认生成的拷贝构造 (赋值重载跟拷贝构造类似)
- 父类成员(整体) -- 调用父类的拷贝构造
- 子类自己的内置成员 -- 值拷贝
- 子类自己的自定义成员 -- 调用他的拷贝构造
一般就不需要自己写了,若子类成员涉及深拷贝,就必须自己实现
要注意以下 赋值重载处,可能会出现死递归的情况
// 子类的拷贝构造 Student(const Student& st):Person(st) // 显式调用 父类的 拷贝构造:直接传一个子类过去,这就是 前面讲过的 父子赋值转换 的运用, _x(st._x), _address(st._address) {}// 子类的赋值重载 Student& operator=(const Student& st) {if (this != &st){Person::operator=(st); // operator=(st);: 注意这里必须加上 父类的类域指定,否则会视作不断调用自己的死递归_x = st._x;_address = st._address;}return *this; }
4.4 ⭐子类和父类 的总代码
class Person { public:Person(const char* name = ""): _name(name){cout << "Person()" << endl;}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;delete[] _str;}protected:string _name; // 姓名char* _str = new char[10] { 'x', 'y', 'z'}; };class Student : public Person { public:// 父类构造显示调用,可以保证先父后子Student(const char* name = "", int x = 0, const char* address = ""):_x(x), _address(address), _name(Person::_name + 'x'), Person(name) // 显式调用父类的构造{}Student(const Student& st):Person(st) // 显式调用 父类的 拷贝构造:直接传一个子类过去,这就是 前面讲过的 父子赋值转换 的运用, _x(st._x), _address(st._address){}Student& operator=(const Student& st){if (this != &st){Person::operator=(st); // operator=(st);: 注意这里必须加上 父类的类域指定,否则会视作不断调用自己的死递归_x = st._x;_address = st._address;}return *this;}// 由于多态,析构函数的名字会被统一处理成destructor()// 父类析构不能显示调用,因为显示调用不能保证先子后父~Student(){// 析构函数会构成隐藏,所以这里要指定类域Person::~Person();cout << "~Student()" << endl;// delete [] _ptr;cout << _str << endl; // 打印父类中的成员 _str}protected:int _x = 1;string _address = "广州番禺区";string _name;//int* _ptr = new int[10]; };// 子类默认生成的构造 // 父类成员(整体) -- 默认构造 // 子类自己的内置成员 -- 一般不处理 // 子类自己的自定义成员 -- 默认构造// 子类默认生成的拷贝构造 赋值重载跟拷贝构造类似 // 父类成员(整体) -- 调用父类的拷贝构造 // 子类自己的内置成员 -- 值拷贝 // 子类自己的自定义成员 -- 调用他的拷贝构造 // 一般就不需要自己写了,子类成员涉及深拷贝,就必须自己实现// 子类默认生成的析构 // 父类成员(整体) -- 调用父类的析构 // 子类自己的内置成员 -- 不处理 // 子类自己的自定义成员 -- 调用析构 //int main() {Student s1;Student s2("张三", 1, "广州市天河区");//Student s3 = s2;//s1 = s3;return 0; }
5.继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员, 反过来也不行
你爸的朋友不是你的朋友,友元不能继承
(但是子类需要使用父类的友元函数可以自己 先将该函数标识为自己的友元)
6. 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。 或者说 静态成员继承下来,静态成员不仅仅属于父类也属于子类
即从始至终,那个静态成员都是同一个
因为静态成员存储在静态区,不属于整个类
7.复杂的菱形继承及菱形虚拟继承
7.1 单继承:一个子类只有一个直接父类时称这个继承关系为单继承
7.2 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
即 一个类可以有多重身份,如一个助理员类,可以同时继承学生类和老师类(因为助理员具有老师和学生双重身份)
多继承存在一个缺点:即 菱形继承
7.3 菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。即在Assistant的对象中Person成员会有两份。
在访问父类成员时,就会出现二义性,有两个 name 属于不同类,但是在 语义上就是属于同一个名字
结合下面代码:了解 菱形继承产生的 数据冗余和二义性 及其解决方法
class Person { public:string _name; // 姓名int _id;int _tel;int _adress; };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() {// 这样会有二义性无法明确知道访问的是哪一个 Assistant a ; a._name = "张三";// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "张同学"; a.Teacher::_name = "张老师";cout << a._name << endl;return 0; }
7.4 使用建议
实践中,可以使用 多继承
但是不建议使用 菱形继承
在现实中确实有人使用 菱形继承:IO流的设计就是一个典型的菱形继承
istream 需要有 ios 的特征,ostream 也需要有 ios 的特征,iostream 需要同时有 istream 和 ostream ,则 iostream 就重复包含和 ios,因此这里使用 virtual 虚拟继承
8. 虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
如上面的继承关系,在 Student 和 Teacher 的继承 Person 时使用虚拟继承,即在 继承处加上 virtual ,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
class Student : virtual public Person { protected:int _num; //学号 };class Teacher : virtual public Person { protected:int _id; // 职工编号 };
8.1 在哪加上 virtual 关键字才对?
判断哪里继承会出现 数据冗余:即 Student 和 Teacher 继承了同一份 Person类,因此会出现数据冗余,则在这两个类中加上 virtual
下面这张图中的 菱形继承 virtual 应该加在 B 和 C 中,因为 B 和 C 继承了同一份 A类,因此会出现数据冗余
(下面的菱形继承关系只是作为出题需求,一般实践中不会搞这么复杂)
8.2 虚拟继承解决数据冗余和二义性的原理
在内存中,本来 Person 在 类Student 和 类Teacher 里面都有一份,现在 虚继承将 Person类 直接放到 类Assistant 的一块空间中,再给 类Student 和 类Teacher 设置一个 Person类的 位置偏移量值,方便这两个类访问 Person类
这样既不影响 类Student 和 类Teacher 对 Person类的访问,也解决了存在多份 Person类 的数据冗余和二义性的问题
9.继承的总结和反思
1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的语言都没有多继承,如Java。
9.1 继承和组合
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象(每个子类都是一个特殊的父类)。
- 组合 是一种 has-a 的关系。假设B组合了A,每个B对象中都有一个A对象(即一个类中包含了 其他类的对象,如 一个 Tire轮胎类(成员:轮胎尺寸),一个 Car类(包含着好几个 Tire类对象,表示一个车有几种尺寸的轮胎))。
- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装;基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承(适合的场景用适合的工具就行),
- 另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
- (适合使用继承的就用继承;适合使用组合的就用组合;可以使用继承和组合的,就使用组合)
9.2 继承 和 组合的 例子解释
// Car 和 Xiaomi_su7、Car 和 AITO 构成 is-a 的关系 :即继承 class Car { protected:string _colour = "白色"; // 颜色 string _num = "粤AHE100"; // 车牌号 };class Xiaomi_su7 : public Car { public:void Drive() {cout << "米时捷,豪帅!" << endl;} };class AITO : public Car { public:void Drive() {cout << "问界,遥遥领先!doge" << endl;} };// Tire 和 Car 构成 has-a 的关系 :即 组合 class Tire { protected:string _brand = "Michelin"; // 品牌 size_t _size = 17; // 尺寸 }; class Car { protected:string _colour = "白色"; // 颜色 string _num = "粤AHE100"; // 车牌号 Tire _t; // 轮胎 :只需要实例化一个对象,即为 has-a 的组合关系 };
10. 笔试面试题
1. 什么是菱形继承?菱形继承的问题是什么?
2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的
3. 继承和组合的区别?什么时候用继承?什么时候用组合?
这些问题在本章节中都有讲解,这里就不直接给出答案了