C++ 表情包趣味教程 《C++要笑着学》
写在前面
本系列 C++ 教学博客的基础知识已经告一段落了,下面的章节我会先把面向对象三大特性讲完,然后穿插一些数据结构的教学以方便我们继续讲解 STL 的 map 和 set。对于面向对象三大特性 —— 封装、继承、多态,我们已经在之前讲解过封装了,本章将开始讲解继承,详细探讨多继承引发的钻石继承问题,并用虚继承解决钻石继承问题。阅读本章需要掌握访问限定符以及默认成员函数的知识,如果阅读过程中感到有些许生疏建议先去复习一下。
Ⅰ. 继承(inheritance) 0x00 知识回顾
回顾一下面向对象三大特性:封装、继承、多态。
面向对象还有其它特性:反射、抽象。
① C++ Stack 类设计和 C 设计 Stack 对比,封装更好、访问限定符 + 类 狭义。
② 迭代器设计,如果没有迭代器,容器访问只能暴露底层结构。 -> 使用复杂、使用成本很高,对使用者要求极高。
封装了容器底层结构,不暴露底层结构的情况,提供统一的访问容器的方式,降低使用成本,简化使用。
③ stack/queue/priority_queue 的设计 —— 适配器模式。
今天我们的主角是继承。
0x01 继承的概念继承(inheritance)机制是面向对象程序设计,使代码可以复用的最重要的手段。
它允许程序员在保持原有类特性的基础上进行扩展,以增加功能。这样产生新的类,称为派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
以前我们接触的复用都是函数复用,而继承是类设计层次的复用。
举例:比如我们要设计一个图书管理系统,每个角色的权限是不同的。
角色类:学生、老师、保安、保洁、后勤…… 为了区分这些角色,我们就要设计一些类出来:
class Student { string _name; string _tel; string _address; int _age; // ... string _stuID; // 学号 }; class Teacher { string _name; string _tel; string _address; int _age; // ... string _wordID; // 工号 }; ...
不难发现其存在大量冗余部分,有些信息是公共的,有些信息是每个角色独有的。
对于有些数据和方法是每个角色都具有的,我们每次都写一边,这就导致设计重复了。
我们说了代码是要讲究复用的,我们要想办法去做一个 "提取" ,把共有的成员变量提取出来。
解决方案:设计一个 Person 类
// 把大家共有的东西写进来 class Person { string _name; string _tel; string _address; string _age; };
然后使用 "继承" 去把这些大家公有的东西运送给各个角色,先看操作:
class Person { string _name; string _tel; string _address; string _age; }; class Student : public Person { string _stuID ; // 学号 }; class Teacher : public Person { string _wordID; // 工号 };
这就是继承。在需要称为子类的类的类名后加上冒号,并跟上继承方式和父类类名即可。
比如说我们这里希望让 Student 以 public 的继承方式继承自 Person。
为了能够演示继承的效果,我们给 Person 类加上个 Print 打印函数:
class Person { public: void Print() { cout << "name: " << _name << endl; cout << "age: " << _age << endl; cout << endl; } string _name = "user"; string _tel; string _address; string _age = "null"; }; class Student : public Person { string _stuID ; // 学号 }; class Teacher : public Person { string _wordID; // 工号 }; int main(void) { Person p; p.Print(); Student s; s.Print(); Teacher t; t.Print(); return 0; }
0x02 继承的定义格式 运行结果:
我们还是拿刚才的 Person 和 Student 举例:
派生类 继承方式 基类 class Student : public Person { public: string _stuID; // 学号 };
Student 是 子类,我们也称之为派生类。Person 是父类,我们也称之为 基类。
个人觉得,把 Person 和 Student 看作是父子关系是比较容易理解的。
子承父业,孩子 Student 从父亲 Person 那里继承一些 "资产" ,
这里的继承方式是 public,即公有继承,还有其他的一些继承方式。
(这里我们先做一个铺垫,复习和补充一下访问限定符的知识)
0x03 访问限定符:public / protected / private 链接:【C++要笑着学】访问限定符
知识回顾:对之前没讲的 protected 进行补充
三种访问限定符,分别是 public(公有)、protected(保护)、private(私有)。
这一听名字就能知道,公有就是随便玩,保护和私有就是藏起来一点点不让你随便玩得到。
① public 修饰的成员,可以在类外面随便访问(直接访问)。
② protected 和 private 修饰的成员,不能在类外随便访问。
③ 定义成 protected 可以让父类成员不能在类外直接访问,但可以在子类中访问。
public、protected、private 不仅仅是访问限定符,它们也可以表示继承的三种继承方式:
0x04 继承基类成员访问方式的变化三种访问限定符和三种继承方式相碰撞,就产生了 种情况:
① 父类的 private 成员在子类种无论以何种方式继承都是不可见的。 这里的不可见指的是父类的私有成员还是被继承到了子类对象中,但是语法上限制了子类对象不管在类里面还是类外卖呢都不能去访问父类的 private 成员。
② 父类 private 成员在子类种不能被访问,如果父类成员不想在类外被直接访问,但是想让它们在子类中能被访问,可定义为 protected。 不难看出,保护成员限定符是因继承才出现的。
③ 实际上,上面的表格我们通过观察不难发现,父类的私有成员在子类都是不可见的,父类的其他成员在子类的访问方式 == Min(成员在父类的访问限定符,继承方式):
protected>private" src="https://latex.codecogs.com/gif.latex?public%3Eprotected%3Eprivate" />
④ 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,但是最好还是显式的写出继承方式,提高代码可读性。
⑤ 一共 9 种组合,实际上是大佬们早期设计的时候想复杂了,实际中父类成员基本都是保护和公有,继承方式基本都是用公有继承,几乎很少使用 protected / private 继承。 而且也不提倡使用 protected / private 继承,因为 protected / private 继承下来的成员都只能在子类里使用,实际扩展维护性不强。
class Person { public: void Print() { cout << "name: " << _name << endl; } protected: string _name; private: string _age; }; // class Student : protected Person { // class Student : private Person { class Student : public Person { protected: string _stuID; // 学号 };0x05 父类和子类对象赋值转换
子类对象可以赋值给父类的对象、父类的指针、父类的引用:
class Person { protected: string _name; string _age; }; class Student : public Person { public: string _stuID; // 学号 }; int main(void) { Student s; // 子类对象可以赋值给父类对象/指针/引用 Person p = s; Person* pp = &s; Person& rp = s; return 0; }
这种操作我们称之为 "切割"(或切片),寓意是把子类中父类的那部分切过来赋值过去。
注意事项:
① 父类对象不能赋值给子类对象(儿子不能抢父亲的钱)
Student s; // 子类 Person p; // 父类 s = p; ❌
② 父类的指针可以通过强转赋值给子类的指针,但是必须是父类的指针是指向子类对象时才是安全的。这里父类如果是多态类型,可以使用 RTTI(Run-Time Type Information,即运行时类型识别)的 dynamic_cast 来进行识别后进行安全转换。
Student s; Person* pp = &s; // 父类的指针可以通过强制类型转换赋值给子类的指针 pp = &s; Student* ps1 = (Student*)pp; ps1->_stuID = 10001; pp = &p; Student* ps2 = (Student*)pp; // 这种情况虽然可以,但是会存在越界访问问题 ps2->_stuID = 20002;0x06 继承中的作用域
继承体系中的父类和子类都有独立的作用域,如果子类和父类有同名成员,
此时子类成员会屏蔽父类对同名成员的直接访问,这种情况叫做 "隐藏" (也叫重定义)。
在子类成员函数中,可以使用如下方式进行显式访问:
基类::基类成员
注意事项:
① 如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
② 实际运用中在继承体系里最好不要定义同名的成员。父类成员名称不要和子类成员名称冲突。
代码演示:父类和子类的成员函数同名的场景(注意父类和子类的 _num)
class Person { protected: string _name = "小明"; // 姓名 string _num = "320103xxxxxxxxxx14"; // 身份证号 }; class Student : public Person { public: void Print() { cout << "姓名:" << _name << endl; cout << "身份证号: " << Person::_num << endl; // 指定是Person的_num cout << "学号:" << _num << endl; // 默认在自己作用域内找_num } protected: string _num = "10001"; // 学号 }; int main(void) { Student s1; s1.Print(); return 0; }
运行结果:
❓ 思考:观察下列代码,A::func 和 B::func 的关系是重载还是隐藏?
class A { public: void func() { cout << "func()" << endl; } }; class B : public A { public: void func(int i) { A::func(); cout << "func(int i) -> " << i << endl; } }; int main(void) { B b; b.func(10); }
运行结果:
解读:函数重载要求在同一作用域,我们说了,子类和父类都有独立的作用域,因为不是在同一作用域,B 中的 func 和 A 中的 func 不可能构成重载,正确答案是构成隐藏。B 中的 func 和 A 中的 func 构成隐藏,成员函数满足函数名相同就构成隐藏。
(从语言的设计角度来说,如果出现同名直接报错,就没这么多事了)
0x07 继承与友元友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员!
class Student; class Person { public: friend void Display(const Person& p, const Student& s); protected: string _name; // 姓名 }; class Student : public Person { protected: int _stuNum; // 学号 }; void Display(const Person& p, const Student& s) { cout << p._name << endl; cout << s._stuNum << endl; ❌ } void main() { Person p; Student s; Display(p, s); }
0x08 继承与静态成员 运行结果:
“Student::_stuNum”: 无法访问 protected 成员(在“Student”类中声明)
父类定义了 static 静态成员,则整个继承体系里面中有一个这样的成员。
可以理解为共享,父类的静态成员可以在子类共享,父类和子类都能去访问它。
无论派生出多少个子类,都只有一个 static 成员实例:
class Person { public: Person() { ++_count; } protected: string _name; // 姓名 public: static int _count; // 统计人的个数 }; int Person::_count = 0; class Student : public Person { protected: int _stuNum; // 学号 }; class Graduate : public Student { protected: string _seminarCourse; // 研究科目 }; void TestPerson() { Student s1; Student s2; Student s3; Graduate s4; Person s; cout << "大家都可以访问" << endl; cout << "人数 : " << Person::_count << endl; cout << "人数 : " << Student::_count << endl; cout << "人数 : " << s4._count << endl; cout << "大家也都可以变动" << endl; s3._count = 0; cout << "人数 : " << Person::_count << endl; cout << "并且他们的地址也都是一样的,因为所有继承体系中只有一个" << endl; cout << "人数 : " << &Person::_count << endl; cout << "人数 : " << &Student::_count << endl; cout << "人数 : " << &s4._count << endl; }
Ⅱ. 子类默认成员函数 0x00 引入:默认成员函数 运行结果:
复习:【C++要笑着学】类的默认成员函数详解
(不含C++11)
我们知道,对于默认成员函数,如果我们不主动实现,编译器会自己生成一份。
那么这些默认成员函数在子类中,它们又是如何生成的?
0x01 子类构造函数① 父类成员需调用自己的构造完成初始化。 即子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。
② 如果 父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显式调用。
③ 子类对象初始化先调用父类构造再调子类构造。
代码演示:
class Person { public: Person(const char* name = "foxny") : _name(name) { cout << "Person()" << endl; } protected: string _name; }; class Student : public Person { public: Student(const char* name, int num) : Person(name) // 父类成员,调用自己的构造完成初始化 , _num(num) { cout << "Student()" << endl; } protected: int _num; // 学号 }; void test() { Student s1("小明", 18); }
运行结果:
调用父类构造函数初始化继承自父类的成员,自己再初始化自己的成员(规则参考普通类)。
析构、靠别构造、赋值重载也是类似的。
❓ 思考:如何设计一个不能被继承的类?
将父类的构造函数私有化:
class A { private: // 将A的构造函数私有化 A() {} }; class B : public A { }; int main(void) { B b; ❌ return 0; }
父类 A 的构造函数私有化后 B 就无法构造对象,因为 B 的构造函数必须要调用 A 的。
A a; ❌ 但是好像A也没办法构造了
这波属于是自损八百了,A 也没法构造了,但是我们可以这么玩(后期讲单例模式会细说):
class A { public: static A CreateObject() { // 提供一个获取对象的方式 return A(); } private: A() {} }; class B : public A {}; int main(void) { A a = A::CreateObject(); return 0; }
此时我们提供一个获取对象的成员函数即可,这里加上 static 解决先有鸡还是先有蛋的问题。
0x02 子类拷贝构造函数子类的拷贝构造函数必须调用父类的拷贝构造完成拷贝初始化。
代码演示:
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; } 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; } protected: int _num; // 学号 }; void test() { Student s1("小明", 18); Student s2(s1); }
0x03 子类的赋值重载 运行结果:
子类的 operator= 必须要调用父类的 operator= 完成父类的复制。
代码演示:
class Person { public: Person(const char* name = "小明") : _name(name) { cout << "Person()" << 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& operator=(const Student& s) { cout << "Student& operator=(const Student& s)" << endl; if (this != &s) { // 子类的 operator= 必须要调用父类的 operator= 完成父类的复制 Person::operator=(s); _num = s._num; } return *this; } protected: int _num; // 学号 }; void test() { Student s1("小明", 18); Student s3("小红", 17); s1 = s3; }
0x04 子类析构函数 运行结果:
为了保证子类对象先清理子类成员再清理父类成员的顺序,先子后父。
子类析构先子后父,子类对象的析构清理是先调用子类析构再调父类析构。
子类析构函数完成后会自动调用父亲的析构函数,所以不需要我们显式调用。
class Person { public: Person(const char* name = "小明") : _name(name) { cout << "Person()" << endl; } ~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() { cout << "~Student()" << endl; } // -> 自动调用父类析构函数 protected: int _num; // 学号 }; void test() { Student s1("小明", 18); }
Ⅲ. 多继承与钻石继承问题 0x00 多继承的概念 运行结果:
我们先说说单继承,刚才我们讲的其实就是单继承。
单继承:一个子类只有一个直接父类,我们称这种继承关系为单继承。
多继承:一个子类有两个或以上直接父类,我们称这种继承关系为多继承。
大佬早期设计的时候认为多继承挺好的,这也没有出现什么大的毛病。
因为一个子类继承多个父类的情况也挺合理的,比如有的角色,既是学生也是老师;
房车,既是房子也是车;微软 Surface 二合一设备,既是平板也是电脑……
但实际慢慢用起来后问题就慢慢显现出来了,有多继承就会产生 "钻石继承",我们继续往下看。
0x01 钻石继承的概念 概念:钻石继承,又称菱形继承(diamond-inheritance),是多继承的一种特殊情况。
举个例子:研究生助教继承了学生和老师,学生和老师又都继承了人
这时候就产生了经典的钻石继承,此时会带来一些问题,我们下面会详细探讨。
❓ 为什么叫做钻石继承呢?看图你就知道为什么了:
钻石继承的问题:钻石继承存在数据冗余和二义性的问题
代码:演示一下二义性带来的问题
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; // 主修课程 }; void Test() { // 这样会有二义性无法明确知道访问的是哪一个 Assistant a; a._name = "peter"; // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; }
但是二义性通过指定作用域是可以解决的,告诉编译器是从学生那继承的还是从老师那继承的。
但是数据冗余没有解决,数据冗余带来的最大问题是空间的浪费:
class Person { public: string _name; int _hugeArr[10000]; // 如果数据很大,浪费的可不是一点点了 };
如果数据很大,这可不是闹着玩,这会造成大量的空间浪费。
0x02 通过虚拟继承解决钻石继承问题对于空间浪费,有什么解决的方法吗?有的,使用虚拟继承去解决钻石继承的数据冗余问题。
代码:在类腰部位置加一个 virtual 关键字
class Person { public: string _name; int _hugeArr[10000]; }; // 虚继承 class Student : virtual public Person { protected: int _num; }; // 虚继承 class Teacher : virtual public Person { protected: int _id; }; class Assistant : public Student, public Teacher { protected: string _majorCourse; };
加上 virtual 表示虚继承,此时就能完美解决了钻石继承带来的数据冗余问题。
再配合刚刚我们讲的指定作用域,二义性也可以得到很好的解决:
void Test() { // 显示指定访问哪个父类的成员解决二义性 a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; }
加上虚继承后我们统称为 —— 钻石虚拟继承。
0x03 有关多继承的思考钻石继承存在的根源是因为多继承的存在,有了多继承就会导致钻石继承。
而钻石继承就要使用虚继承去解决,这是比较复杂的,Java 为了躲掉了这个坑,
直接索性取消掉了多继承,这样自然也就不存在钻石继承这些东西了。
可能是早期设计的时候没有经验,正所谓:
"前人栽树候人乘凉,前人踩坑后人避坑。"
❓ 思考:既然如此,为什么C++不取消多继承机制?
木已成舟,东西都设计出来了,不可能说再取消多继承机制,那人家之前写的代码跑不过去了。
[ 笔者 ] 王亦优 [ 更新 ] 2022.8.7 ❌ [ 勘误 ] [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免, 本人也很想知道这些错误,恳望读者批评指正!
参考资料 C++reference[EB/OL]. []. http://www.cplusplus.com/reference/. Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. . 百度百科[EB/OL]. []. https://baike.baidu.com/. 比特科技. C++[EB/OL]. 2021[2021.8.31]. |