【C++】详解继承机制 目录前言一、继承的概念二、继承的定义2.1、定义格式2.2、继承基类成员访问方式的变化2.3、类模板的继承2.4、基类与派生类的转换三、继承的作用域四、派生类的默认成员函数4.1、四个常见的派生类默认成员函数1️⃣构造函数2️⃣拷贝构造3️⃣赋值重载4️⃣析构函数4.2、面试题——如何实现一个不能被继承的类五、继承与友元六、继承与静态成员七、多继承与菱形继承问题7.1、继承模式7.2、虚继承7.3、面试题——多继承中指针的偏移问题八、继承与组合前言一、继承的概念继承机制是面向对象程序设计使代码可以复用的最重要的手段它允许我们在保持原有类特性的基础上进行扩展增加成员函数和成员变量这样产生新的类称派生类子类。继承呈现了面向对象程序设计的层次结构体现了由简单到复杂的认知过程。继承是类设计层次的复用。二、继承的定义2.1、定义格式class 派生类:继承方式基类{};2.2、继承基类成员访问方式的变化关于派生类的继承方式主要有三种public、private、protected。这与类成员的访问方式是一样的。根据继承方式的不同基类中不同成员的访问方式会具有相应的变化进而继承到派生类。我们可以通过以下的表格清晰地了解根据表格我们可以总结出基类的private成员在派生类中无论以何种方式继承都是不可见的这些私有成员还是被继承到了派生类对象中只是语法上被限制了而基类的其他成员在派生类的访问方式遵循着这样一个规律:min{ 成员在基类的访问限定符, 继承方式},(public protected private)。看到这里其实应该会疑惑这个protected访问限定符究竟起着个啥作用好像与private并没有什么差别??这里要补充一点如果基类成员不想在类外被直接访问但是需要在派生类中能够访问我们就可以定义为protected。这就是它与private的显著差别可以看出protected访问限定符就是特意为继承而设计的。我们知道在定义类的时候有两种关键字可以使用在使用class时默认的继承方式是private而在使用struct时默认的继承方式为public。在实际应用中我们一般都是使用public继承几乎很少使用protected和private继承。由上表可知protected和private继承下来的成员都只能在派生类的类里面使用实际中的拓展维护性不强。接下来看一段简单的代码加深以上内容的理解#includeiostreamusingnamespacestd;classPerson{public:voididentity(){coutidentity_name:_nameendl;}protected:string _name张三;// 姓名string _address;// 地址string _tel;// 电话int_age18;// 年龄};classStudent:publicPerson{public:voidstudy(){coutstudyendl;}protected:int_stuid;// 学号};classTeacher:publicPerson{public:voidteach(){coutteachendl;}protected:string _title;// 职称};intmain(){Student s;Teacher t;s.identity();s.study();t.identity();t.teach();}2.3、类模板的继承当基类是类模板如果我们要在派生类中使用基类的相关成员此时我们需要指定一下类域。否则会进行编译报错——“error C3861找不到标识符”。如下在实例化stackint的时候也实例化了vectorint。由于模板是按需实例化因此vectorint内的成员函数并没有实例化因此找不到对应的标识符。只有当我们指定类域并调用它编译器才会自动实现对应的成员函数供我们调用。2.4、基类与派生类的转换一般情况下我们将一个类型的对象赋值给另一个类型的对象的指针或引用时存在类型转换期间会产生一个具有常性的临时对象类型转化、传值返回都会产生临时对象所以需要添加const进行修饰。但是在public继承中前提就是一个特殊处理的意外派生类的对象可以直接赋值给基类对象的指针或者引用而不需要使用const修饰此处没有产生临时对象并且这里指针或引用绑定的是派生类对象中的基类部分。如下图派生类对象赋值给基类对象是通过拷贝构造或者赋值运算符重载完成的这个过程就像把派生类自己定义的部分成员切掉一样所以也被叫做切割或者切片。这里要与上述的内容稍作区分注意不能将基类的对象赋值给派生类。三、继承的作用域在继承机制下基类和派生类都有独立的作用域。如果派生类和基类中有同名成员派生类将会屏蔽基类成员这种行为叫做隐藏。注意继承关系才有隐藏的概念。被隐藏的同名基类成员并非无法访问但是我们可以使用域访问限定符::指定作用域访问。对于成员函数只要函数名相同就构成隐藏与参数、返回值类型、函数体无关。在实际的使用中我们要尽可能得避免在继承体系中定义同名的成员。为了充分理解这个知识点我们可以通过下面两道笔试题加深影响1️⃣A和B类中的两个func构成什么关系。A. 重载 B. 隐藏 C. 没关系2️⃣下面程序的编译运行结果是什么。A. 编译报错即出现了语法错误B. 运行报错出现了野指针、对空指针解引用等操作C. 正常运行classA{public:voidfun(){coutfunc()endl;}};classB:publicA{public:voidfun(inti){coutfunc(int i)iendl;}};intmain(){B b;b.fun(10);b.fun();return0;}答案分别为B与A。四、派生类的默认成员函数类的默认成员函数总共有六个分别为构造函数、析构函数、拷贝构造、赋值重载、取地址重载分为普通对象与const对象两种这里我们主要讲解在派生类中编译器如何自动生成前四种比较常用的默认成员函数。4.1、四个常见的派生类默认成员函数派生类的默认成员函数均遵循着这样一个处理原则将派生类的成员分成两部分基类成员和派生类成员两部分分开处理把基类成员视为一个整体调用基类的默认成员函数剩余的派生类成员就与普通类的规则一样。根据接下来讲解更好地理解这句话1️⃣构造函数派生类的构造函数必须调用基类的构造函数初始化基类的成员。注意如果基类没有默认的构造函数则必须在派生类构造函数的初始化列表阶段显式调用!!!补充引用成员变量、const成员变量、没有默认构造的类类型变量都必须在初始化列表阶段显式初始化。因为这三种变量都必须在定义的时候初始化。2️⃣拷贝构造派生类的拷贝构造必须调用基类的拷贝构造完成基类成员的拷贝初始化。注意一般情况下编译器自动生成就足够用。若涉及深拷贝就需要自己实现。3️⃣赋值重载赋值重载与拷贝构造非常类似派生类的赋值重载函数必须调用基类的赋值重载函数完成基类成员的复制。注意由于派生类与基类的赋值重载重名构成重写因此在实现派生类的赋值重载时需要显式调用基类的赋值重载。4️⃣析构函数析构函数相较于前三个会有较大的不同首先派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。派生类对象初始化先调用基类构造再调派生类构造派生类对象析构清理先调用派生类析构再调用基类析构。类似于压栈、弹栈的过程为什么需要这样处理呢有时候派生类的成员在析构时可能会访问到基类成员在这样的场景下就必须这样处理。此外在多态的场景下析构函数构成重写而重写的条件之一就是函数名相同相关知识会在《多态》章节中解释。那么编译器就会对析构函数名进行特殊处理处理成destructor()所以基类析构函数不加virtual的情况下派生类析构函数与基类析构函数构成隐藏关系。4.2、面试题——如何实现一个不能被继承的类这是一道面试题在学习派生类默认成员函数的相关知识后我们能够解决直接输出结论构造函数私有化派生类的构造必须调用基类的构造这是毋庸置疑的但是基类的构造函数私有化后就意味着派生类看不见基类构造就不能调用了那么派生类就无法实例化出对象了。这本质是利用两种语法之间的冲突来实现的。一般在C98中使用较多不过该方法不够直观因此C11开始提供了一个新的关键字来支持。classBase{public:voidfunc5(){coutBase::func5endl;}protected:inta1;private:Base(){}};classDerive:publicBase{public:voidfunc4(){coutDerive::func4endl;}protected:intb2;};intmain(){Base b;Derive d;return0;}final关键字这个就是C11推出来的关键字用于修饰类被修饰的类称为“最终类”往后这个类就无法被继承。// C11的⽅法classBasefinal{public:voidfunc5(){coutBase::func5endl;}protected:inta1;};classDerive:publicBase{public:voidfunc4(){coutDerive::func4endl;}protected:intb2;};intmain(){Base b;Derive d;return0;}五、继承与友元直接输出结论友元关系不能继承也就是说基类友元不能访问派生类私有和保护成员 。这很好理解你可以继承你爸的房子、车子等但是你无法继承他的朋友忘年交除外。classStudent;// 前置声明classPerson{public:friendvoidDisplay(constPersonp,constStudents);protected:string _name;// 姓名};classStudent:publicPerson{protected:int_stuNum;// 学号};voidDisplay(constPersonp,constStudents){coutp._nameendl;couts._stuNumendl;}intmain(){Person p;Student s;// 编译报错error C2248: “Student::_stuNum”: 无法访问protected成员Display(p,s);return0;}执行上述代码必然会编译报错只要将Display也变成Student的友元即可解决。六、继承与静态成员基类定义了static静态成员则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类都只有一个static成员实例。简单来说就是该继承体系中公用一个static成员。classPerson{public:string _name;staticint_count;};intPerson::_count0;classStudent:publicPerson{protected:int_stuNum;};intmain(){Person p;Student s;// 这里的运行结果可以看出两个非静态成员_name的地址是不一样的// 说明派生类继承下来了基类和派生类各有一份_namecoutp._nameendl;couts._nameendl;// 这里的运行结果可以看到静态成员_count的地址是一样的// 说明派⽣类和基类共⽤同一份静态成员coutp._countendl;couts._countendl;// 公有的情况下⽗派⽣类指定类域都可以访问静态成员coutPerson::_countendl;coutStudent::_countendl;return0;}运行结果如下七、多继承与菱形继承问题7.1、继承模式继承模式主要分为两种单继承与多继承。单继承一个派生类只有一个直接基类时称这个继承关系为单继承。多继承一个派生类有两个或以上直接基类时称这个继承关系为多继承。多继承对象在内存中的模型是先继承的基类在前面后面继承的基类在后面派生类成员在放到最后面。如下图多继承可能会导致菱形继承。菱形继承是多继承的一种特殊情况。菱形继承的问题从下面的对象成员模型构造就可以看出菱形继承有数据冗余和二义性的问题在Assistant的对象中Person成员会有两份。支持多继承就一定会存在菱形继承问题像Java就直接不支持多继承从而规避掉菱形继承的问题所以实践中我们也是不建议设计出菱形继承这样的模型的。如果在项目中出现了菱形继承我们应该如何解决数据冗余与二义性问题呢看下面这段代码classPerson{public:string _name;// 姓名};classStudent:publicPerson{protected:int_num;//学号};classTeacher:publicPerson{protected:int_id;// 职工编号};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;// 主修课程};intmain(){// 编译报错error C2385: 对“_name”的访问不明确Assistant a;a._namepeter;// 需要显示指定访问哪个基类的成员可以解决二义性问题但是数据冗余问题无法解决a.Student::_namexxx;a.Teacher::_nameyyy;return0;}如果不指定类域单纯地a._name编译器无法知道该变量是基类Student的还是基类Teacher的因此需要显示指定访问哪个基类的成员可以解决二义性问题但是数据冗余问题无法解决那么我们究竟应该如何解决该问题呢虚继承virtual7.2、虚继承多继承就是C语法复杂的一个体现。有了多继承就必然存在菱形继承为了解决菱形继承带来的各种问题于是便诞生了虚继承关键字virtual格式class 派生类 virtual public 基类。虚继承的底层实现就很复杂性能也会有一些损失所以最好不要设计出菱形继承。多继承可以认为是C的缺陷之一后来的一些编程用语言都没有多继承如Java。classPerson{public:string _name;// 姓名};classStudent:virtualpublicPerson{protected:int_num;//学号};classTeacher:virtualpublicPerson{protected:int_id;// 职工编号};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;// 主修课程};intmain(){// 使用虚继承可以解决数据冗余和二义性Assistant a;a._namepeter;return0;}此外在使用虚继承时我们要考虑virtual关键字摆放的位置。通常我们会将可能造成数据冗余与二义性的基类派生出来的派生类设计为虚继承。在项目实践中我们可以设计出多继承但是不建议设计出菱形继承因为菱形虚拟继承以后无论是程序员使用还是代码底层都会变得复杂很多。当然有多继承语法支持就一定存在会设计出菱形继承像Java是不支持多继承的就避开了菱形继承。来看看下面这段代码思考一下a对象中_name是“张三” “李四” “王五”中的哪一个classPerson{public:Person(constchar*name):_name(name){}string _name;// 姓名};classStudent:virtualpublicPerson{public:Student(constchar*name,intnum):Person(name),_num(num){}protected:int_num;//学号};classTeacher:virtualpublicPerson{public:Teacher(constchar*name,intid):Person(name),_id(id){}protected:int_id;// 职工编号};classAssistant:publicStudent,publicTeacher{public:Assistant(constchar*name1,constchar*name2,constchar*name3):Person(name3),Student(name1,1),Teacher(name2,2){}protected:string _majorCourse;// 主修课程};intmain(){Assistanta(张三,李四,王五);return0;}运行代码得到结果如下由于菱形继承会为我们带来诸多的麻烦因此我们一直强调不要设计出菱形继承但是在现实中是否存在菱形继承呢答案是肯定的C中的IO库就是一个典型的菱形继承设计。7.3、面试题——多继承中指针的偏移问题classBase1{public:int_b1;};classBase2{public:int_b2;};classDerive:publicBase1,publicBase2{public:int_d;};intmain(){Derive d;Base1*p1d;Base2*p2d;Derive*p3d;return0;}根据上述代码下面哪一个选项是正确的Ap1 p2 p3 Bp1 p2 p3 Cp1 p3 ! p2 Dp1 ! p2 ! p3有了前文的知识铺垫答案很显然为C见下图八、继承与组合public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a的关系。假设B组合了A每个B对象中都有一个A对象。前文类模板继承中举的stack继承vector模板就是一种典型的is-a关系而我们一般正常实现stack是通过在类中定义一个vector对象以此来实现的这就是一种典型的has-a关系。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用white-box reuse。术语“白箱”是相对可视性而言在继承方式中基类的内部细节对派生类可见。继承在一定程度上破坏了基类的封装基类的改变对派生类有很大的影响。派生类和基类间的依赖关系很强耦合度高。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用black-box reuse因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系耦合度低。优先使用对象组合有助于你保持每个类被封装。优先使用组合而不是继承。实际尽量多去用组合组合的耦合度低代码维护性好。不过也不要太绝对如果类之间的关系适合使用继承那就是用继承此外如果要实现多态也必须使用继承。类之间的关系既适合用继承也适合用组合就用组合。**完