本章将介绍C++中一个非常重要的机制,继承。
一、什么是继承?继承的意义何在?
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保****持原有类特性的基础上进行扩展,增加功能,这样产生新的类。
原有类称作父类(又称基类),新的类称作子类(又称派生类)。
继承呈现了面向对象****程序设计的层次结构 ,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继****承是类设计层次的复用。
1、继承的格式
继承的格式
2、继承方式与访问限定符之间的关系
基类不同类型的成员在采用不同继承方式时,在派生类中的表现形式与可访问性。
(不可见与私有的区别:不可见是类内类外都不能使用,私有是限制使用、类内可用、类外不可用)小总结:
基类private成员在派生类中,无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员虽然还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
基类private成员在派生类中也不能被访问;如果基类成员不想在类外直接被访问,但想在派生类中可以被访问,就需要定义为protected。因此可以看出,protected 保护成员限定符是其实因继承才出现的。
对上面的表格进行一下总结会发现,基类的私有成员在子类都是不可见,而
基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式)(public > protected > private)
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public。不过
最好显式地写出继承方式,增强可读性。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡
使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里
面使用,实际中扩展维护性不强。
3、继承小实例
cpp#include <iostream> using namespace std; // 实例演示三种继承关系下基类成员的各类型成员访问关系的变化 class Person { public: void Print() { cout << _name << endl; } protected: string _name = "dfwm"; // 姓名 private: int _age; // 年龄 }; //class Student : protected Person //class Student : private Person // 继承后,父类Person成员(成员函数 + 成员变量)都会变成子类Student的一部分 class Student : public Person { protected: int _stunum; // 学号 }; int main() { Student s; s.Print(); }
可以用子类调用父类的成员函数
二、赋值兼容(切片问题)
父子类之间的赋值机制,是什么样的呢?
1、子类对象可以直接赋值给父类对象 / 父类指针 / 父类引用。因为父类成员子类全都有,可以形象的比喻为切片,即把子类的一部分切分给父类。(PS:公有继承方式下,子类对象赋值给父类对象 / 父类指针 / 父类引用,因为父类成员子类都有,所以这种赋值被视为是天然的,不会产生中间临时对象进行转换。这被称作父子类赋值兼容原则*)***
2、父类对象不能直接赋值给子类对象(子类对象的所有成员父类不一定全都有)**
3、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。否则会越界。
cpp#include <iostream> using namespace std; class Person { protected: string _name; // 姓名 string _sex; // 性别 int _age; // 年龄 }; class Student : public Person { public: int _No; // 学号 }; void Test() { Student sobj; // 1.子类对象可以赋值给父类对象/指针/引用 Person pobj = sobj; Person* pp = &sobj; Person& rp = sobj; //2.基类对象不能赋值给派生类对象 sobj = pobj; // 3.基类的指针可以通过强制类型转换赋值给派生类的指针 pp = &sobj Student * ps1 = (Student*)pp; // 这种情况转换时可以的。 ps1->_No = 10; pp = &pobj; Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问 题 ps2->_No = 10; }
三、继承中的"隐藏"机制
1、在继承体系中基类和派生类都有独立的作用域。
2、子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,优先访问自己的。这种情况叫隐藏 ,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
3、成员变量的隐藏,同名会隐藏。需要注意的是如果是成员函数的隐藏,也是只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员,防止混淆。
cpp#include <iostream> using namespace std; // 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; // 学号 }; // B中的fun和A中的fun不是构成重载,因为不是在同一作用域 // B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。 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; } };
四、派生类中的默认成员函数
六大默认成员函数(默认的意思是用户自己不写,编译器也会自动生成)
在派生类中,默认成员函数是如何生成的?
**派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。****(即调用父类自己的构造函数来构造父类部分)**如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
派生类的拷贝构造函数也必须调用基类的拷贝构造完成基类的拷贝初始化。
派生类的operator=必须要调用基类的operator=完成基类的复制。
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 先构造的后析构;后构造的先析构。即派生类对象初始化先调用基类构造再调派生类构造, 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同***(后面讲解)***。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
五、多继承与虚拟继承(重点)
1、单继承、多继承、菱形继承
单继承: 一个子类只有一个直接父类时,这个继承关系叫做单继承。
单继承多继承:一个子类有两个 / 两个以上的直接父类时,这个继承关系叫做多继承 。
多继承菱形继承:多继承的一种特殊情况。 但是会存在一定问题。
菱形继承的问题:数据冗余 和二义性问题。
菱形继承2、如何解决菱形继承造成的数据冗余问题? Virtual 虚拟继承
菱形继承的数据冗余和二义性示范代码:
cpp#include <iostream> using namespace std; 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"; }
菱形继承错误
虚拟继承解决问题:
cpp#include <iostream> using namespace std; class Person { public: string _name; // 姓名 }; class Student : virtual public Person // 虚继承 { protected: int _num; //学号 }; class Teacher : virtual public Person // 虚继承 { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher // 此时Assiantant类中将只有一份Person { protected: string _majorCourse; // 主修课程 }; void Test() { Assistant a; a._name = "peter"; }
上面的继承关系,在Student 和 Teacher 的继承中使用 Virtual 虚拟继承,即可解决。
(注意:虚拟继承只能在菱形继承中使用,不要在其它地方使用)
2.1 虚拟继承的格式
在派生类的继承列表中,使用
virtual
关键字。格式:
class DerivedClass : virtual AccessSpecifier BaseClass { ... };
2.2 虚拟继承的实现原理
为了研究原理方便,我们给出一个简化的菱形继承和虚拟继承体系,然后通过内存窗口观察:
cpp#include <iostream> using namespace std; class A { public: int _a; }; class B : public A { public: int _b; }; class C : public A { public: int _c; }; // 先继承谁,谁在前面 class D : public B, public C { public: int _d; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
菱形继承的内存对象成员模型,可以发现有在B和C的内存中,各有一个继承自A的_a变量,造成数据冗余
cpp#include <iostream> using namespace std; class A { public: int _a; }; // class B : public A class B : virtual public A { public: int _b; }; // class C : public A class C : virtual public A { public: int _c; }; class D : public B, public C { public: int _d; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
虚拟继承内存表
存在三张表:
内存1:
可以发现在虚继承的内存对象表中,B和C中冗余的A类被独立了出来,放在最底部***(大部分编译器都把冗余类放在最底部,但是这不是硬性规定,由编译器自行定义)。***
这个独立出来的A类既不属于B类,也不属于C类,被B、C所共享,d 对象操作B、C虚继承A类中的_a,修改的将会是同一个_a。(少存了一份A,也节省了一定空间,A越大,节省空间也就越多)
我们会发现,B和C中虽然不存A,但是仍然会有两个额外的地址00BC5F50,00BC5F5C,
这两个地址是干什么的?
这两个地址是虚基表指针,指向两张虚基表的地址。
内存2,3:
内存2和内存3就是两张虚基表,记载着距离A类的偏移量。B和C类通过虚基表来找到A类,操作虚继承的A类的变量。(14和0c就是)
3、虚拟继承的原理总结:
虚继承是为了解决菱形继承可能造成的数据冗余和二义性而出现的;它把菱形继承可能会出现的冗余类独立了出来***(一般放在内存表最底部) ,*** 而采用虚继承的类会存储一个虚基表指针指向虚基表,虚基表中储存着这个类距离冗余类的偏移量,通过偏移量找到冗余类进行操作。
虚继承原理解释图4、有关多继承的总结和反思
1、多继承是C++语法复杂性的一个体现。有了多继承,就存在菱形继承;有了菱形继承,就需要虚拟继承;底层实现很复杂。
2、一般不建议设计出多继承,一定不要设计出菱形继承,否则在复杂度和性能上都存在问题。
六、继承 VS 组合(重点)
1、继承和组合的区别
cpp#include <iostream> using namespace std; // 继承 class Animal { public: void eat() { std::cout << "Eating..." << std::endl; } }; class Dog : public Animal { public: void bark() { std::cout << "Barking..." << std::endl; } }; // 组合 class Engine { public: void start() { std::cout << "Engine started..." << std::endl; } }; class Car { private: Engine engine; public: void startCar() { engine.start(); std::cout << "Car started..." << std::endl; } };
继承:
继承全称类继承,是一种"is-a"关系,表示子类是父类派生而来的,每一个子类对象都是一个父类对象。子类继承了基类的成员,并可以添加新的属性和函数 / 覆盖父类成员。
在继承中,父类内部的实现细节对子类可见,被称作白箱复用。因此,继承在一定程度上破坏了父类的封装。
同时父类的改变对子类影响很大,父子类之间的依赖关系很强,耦合度很高。
**组合:
组合全称对象组合,是另外一种复用选择。**是一种"has-a"关系,假设B组合了A,则每个B中都有一个A。即B里面有A类的成员对象。在组合里面,被组合的成员对象内部细节外部不可见,称作黑箱复用。因此,组合的封装性较好。
组合之间的依赖关系也不强,耦合度较低。
2、什么时候用继承?什么时候用组合?
在继承和组合都可以使用的时候,尽量用对象组合而不是类继承,组合耦合度低,代码维护性也好。
有些关系适合继承,那就用继承。实现多态也必须使用继承。