
一、继承的概念继承inheritance机制是面向对象程序设计使代码可以复用的最重要的手段它允许我们在保持原有类特性的基础上进行扩展增加方法成员函数和属性成员变量这样产生新的类称为派生类。下面我们看到两个类Student和TeacherStudent和Teacher都有姓名/地址/电话/年龄等成员变量都有identity身份认证的成员函数设计到两个类里面就是冗余的。当然他们也有一些不同的成员变量和函数比如老师独有成员变量是职称学生的独有成员变量是学号学生的独有成员函数是学习老师的独有成员函数是授课。classStudent:publicPerson{public:voididentity(){//...;}voidstudy(){//...;}protected:int_stuid;};classTeacher:publicPerson{public:voididentity(){//...;}voidteaching(){//...;}protected:string title;};下面是我们公共的成员都放到Person类中通过继承就可以复用这些成员不需要重复定义了省去了很多麻烦。classPerson{public:voididentity(){coutvoid identity()_nameendl;cout_ageendl;}protected:string _name张三;// 默认姓名string _address;// 地址string _tel;// 电话private:int_age18;// 年龄};二、继承的定义和使用2.1 定义下面我们看到Person是基类也称作父类Student是派生类也称作子类。因为翻译的原因所以既叫基类/派生类也叫父类/子类。继承的格式如下通过某种继承方式将基类右边的成员继承给派生类左边这样派生类就拥有了基类的成员。中间用冒号隔开。2.2 三大继承方式与访问限定符C 提供三种继承方式public公有继承、protected保护继承、private私有继承类成员的访问限定符同样有这三种二者组合后基类成员在派生类中的访问权限遵循严格的规则。基类成员 / 继承方式public 继承protected 继承private 继承基类 public 成员派生类 public派生类 protected派生类 private基类 protected 成员派生类 protected派生类 protected派生类 private基类 private 成员不可见不可见不可见基类 private 成员始终不可见基类的私有成员会被继承到派生类对象中但语法上限制派生类无论在类内还是类外都无法访问这是封装性的体现。protected 的专属价值若基类成员不想被类外访问但需要让派生类访问就定义为protected—— 保护成员限定符是因继承而诞生的。权限取最小值基类非私有成员在派生类中的访问权限 Min成员在基类的访问限定符, 继承方式权限优先级publicprotectedprivate。默认继承方式使用class定义类时默认继承方式为private使用struct时默认继承方式为public建议显式写出继承方式提升代码可读性。实战首选公有继承实际开发中几乎只使用public继承protected/private继承会让派生类的成员仅能在类内使用扩展和维护性极差不推荐使用。三、基类与派生类的对象赋值转换3.1 赋值兼容转换的定义public继承的子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切来赋值过去。父类对象不能赋值给子类对象。如图所示派生类对象赋值给基类对象时是直接将派生类中属于基类那一部分切割给基类引用和指针也是一样基类的引用是派生类中属于基类那一部分成员的别名基类的指针指向派生类中属于基类的那一部分。3.2 什么是类型转换在 C 常引用以及类和对象下部分其实我们已经学习了类型转换了这里我们再来回顾一下。类型转换分为显式类型转换和隐式类型转换显式类型转换即当两个不同类型的对象进行操作比较/赋值等时我们手动将其中一个对象的类型转换为另一个对象的类型使得二者能够顺利进行比较而隐式类型转换则是指当两个不同类型的变量之间进行运算时编译器会自动将其中一个变量的类型转换为另一个变量的类型。在一般情况下隐式类型转换没有什么要特别注意的地方但是当涉及到引用或者指针时我们就需要特别注意了。voidtest1(){inta0;doubleba;doublerba;intc1;}对于上面这段代码如果我们不将rb和c定义为const引用而定义为普通引用那么编译器会报错原因如下如果我们将int的a赋值给double的b因为二者类型不同所以a不会被直接赋值给b而是编译器会先根据a创建一个double类型的临时变量tmp然后将tmp赋值给b对于rb来说也是一样的但是rb是引用类型因为对于引用和指针来说权限只能缩小和平移而不能放大所以用引用类型的变量rb去引用a生成的临时变量tmp需要使用const修饰(临时变量具有常性)对于最后一条语句来说也是一样 – 数字 1 只存在于指令中在内存中并不占用空间所以当我们对其进行引用时1 会先赋给一个临时变量然后我们再对这个临时变量进行引用同时由于临时变量具有常性所以我们需要使用const修饰voidtest1(){inta0;doubleba;// double tmpa(临时变量具有常性), btmpconstdoublerba;//不加constrb和c都是可读可写的权限放大了.现在是缩小constintc1;}3.3 派生类对象赋值给基类对象不存在类型转换现在我们就能真正理解 “派生类对象可以直接赋值给基类的对象/基类的指针/基类的引用而不产生类型转换” 是什么意思了 – 派生类对象赋给基类对象时中间不会产生临时变量所以基类对象可以直接引用/指向派生类对象而不需要使用const修饰。classPerson{public:voidPrint(){cout_nameendl;}protected:string _name;};classStudent:publicPerson{protected:int_stunum;};intmain(){//test1();Person p;Student ljm;pljm;Personrsljm;Person*psljm;return0;}对象切片把派生类对象里属于基类的成员拷贝赋值给新的基类对象派生类特有成员被丢弃指针 / 引用切片基类指针引用直接指向绑定到派生类对象中属于基类的那部分内存并非新建对象只是只能访问基类成员。3.4 注意事项1、基类对象不能赋值给派生类对象即只能向上转不能向下转2、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的同时这里基类如果是多态类型可以使用 RTTIRun-Time Type Information的dynamic_cast来进行识别后进行安全转换。多态我们将在下一节讲解这里大家先做了解即可voidtest3(){Student sobj;// 1.子类对象可以赋值给父类对象/指针/引用Person pobjsobj;Person*ppsobj;Personrpsobj;//2.基类对象不能赋值给派生类对象sobjpobj;// 3.基类的指针可以通过强制类型转换赋值给派生类的指针ppsobj;Student*ps1(Student*)pp;//这种情况转换时可以的。pppobj;Student*ps2(Student*)pp;// 这种情况转换时虽然可以但是会存在越界访问}四、继承中的作用域继承体系中基类和派生类拥有相互独立的作用域这是理解成员隐藏的关键。当子类和父类出现同名成员时会触发隐藏重定义规则这是继承中最容易踩坑的点之一。4.1 成员变量的隐藏子类和父类的同名成员变量子类成员会屏蔽父类对同名成员的直接访问若想在子类中访问父类的同名成员需通过基类::基类成员显式指定。classPerson{protected:string _name小李子;int_num111;// 身份证号};classStudent:publicPerson{public:voidPrint(){cout姓名:_nameendl;cout身份证号:Person::_numendl;// 显式访问父类同名成员cout学号:_numendl;//如果不显式访问父类同名成员就构成隐藏会访问子类自身成员}protected:int_num999;// 学号与父类_num同名触发隐藏};voidtest4(){Student s1;s1.Print();// 输出小李子 111 999}4.2 成员函数的隐藏成员函数的隐藏只需函数名相同即可触发与函数的参数列表、返回值无关这一点与函数重载同一作用域、函数名相同 参数列表不同有本质区别。classA{public:voidfun(){coutfunc()endl;}};classB:publicA{public:// 函数名相同触发隐藏与参数无关voidfun(inti){A::fun();// 显式调用父类同名函数coutfunc(int i)-iendl;}};voidtest5(){B b;b.fun(10);// 调用子类的fun(int)b.fun();//编译不了b的fun构成隐藏必须要传参}4.3 总结1、虽然子类中的func函数和父类中的func函数参数不同但是它们仍然构成了隐藏因为它们的函数名相同2、由于子类中的成员造成了父类同名成员的隐藏所以默认调用同名成员时默认调用子类成员如需调用父类成员需要指定父类的五、派生类的默认成员函数作用域5.1 普通类的默认成员函数在学习派生类的默认成员函数之前我们先来回顾一下普通类的默认成员函数C 中成员变量一共可以分为两类 – 内置类型和自定义类型各个默认成员函数对它们的处理可以用下面两句话概括5.2 派生类的默认成员函数派生类的默认成员函数的规则如下1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数则必须在派生类构造函数的初始化列表阶段显式调用基类的构造函数。2、派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。3、派生类的operator必须要调用基类的operator完成基类的复制。4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。5、派生类对象初始化先调用基类构造再调派生类构造派生类对象析构清理先调用派生类析构再调基类的析构。6、子类析构和父类析构构成隐藏关系。由于多态关系需求所有的析构函数的函数名都会被编译器处理为destructor上面这些文字看起来太复杂了其实总结起来就两条1、派生类的成员变量分为三类 – 内置类型、自定义类型以及父类成员变量其中派生类成员函数对内置类型和自定义类型的处理和普通类的成员函数一样但是父类成员变量必须由父类成员函数来处理2、派生类的析构函数非常特殊它不需要我们显式调用父类的析构函数而是会在子类析构函数调用完毕后自动调用父类的析构函数这样做是为了保证子类成员先被析构父类成员后被析构如果我们显式调用父类析构那么父类成员变量一定先于子类成员变量析构。同时子类析构和父类析构构成隐藏。他们的名字一样和我们后面的多态有关1父类没有提供默认构造所以我们需要在子类构造函数的初始化列表处显式调用父类构造来完成父类成员的初始化2子类的拷贝构造必须调用父类的拷贝构造完成对父类成员的拷贝同时这里还存在子类对象赋值给父类对象切片的问题s是Student的对象而父类拷贝构造需要的参数是Person对象但是这里我们能够直接将s作为父类拷贝构造参数且中间并不存在类型转换3子类的赋值重载必须调用父类的赋值重载完成父类成员的赋值这里需要特别注意由于子类父类赋值重载函数的函数名相同构成隐藏所以我们调用父类赋值重载时必须指定父类作用域否则会无限递归调用子类赋值重载同时这里也存在子类对象赋值给父类对象的问题4子类析构会在被调用完成后自动调用父类的析构函数清理父类成员同时子类析构和父类析构函数名都会被处理成destructor看不懂没关系后面多态章节会详细讲现在只需要知道我们的析构函数是会在编译的时候统一成destructor()的构成隐藏5.3 实现一个不能被继承的类基类的构造函数私有派生类的构造必须调用基类的构造函数但是基类的构造函数私有化以后派生类看不见就不能调用了那么派生类就无法实例化出对象。classBase{protected:inta1;private:// C98的方法Base(){}};classDerive:publicBase{public:Derive(){coutDeriveendl;}};继承体系中派生类析构可能使用基类动态资源若先析构基类会释放资源产生野指针导致崩溃而基类析构不会使用派生类资源因此必须先析构派生类、再析构基类确保资源安全释放。按照这个我们来解释一下为什么祖师爷会把基类的析构函数设计成自动调用而构造函数不会构造函数在初始化列表显式调用基类的构造函数这个时候编译器会自动根据声明顺序调用基类还是子类的构造。但是析构函数就不一样了如果人为操作析构函数可能会先让基类先析构子类后析构。六、继承与友元 继承与静态6.1 继承与友元友元是为了让我们可以在类外可以访问类里面的成员因为有的时候在类里面第一个位置始终是this指针友元关系不能继承函数是基类的友元但不是子类的友元也就是说基类友元不能访问派生类私有和保护成员。错误示范classStudent;classPerson{public:friendvoidDisplay(constPersonp,constStudents);protected:string _name;//};classStudent:publicPerson{protected:int_stuNum;};voidDisplay(constPersonp,constStudents){coutp._nameendl;couts._stuNumendl;}解决办法是在子类里面也加一个友元声明。6.2 继承与静态父类定义了static静态成员则整个继承体系里面只有一个这样的成员。无论派生出多少个子类都只有一个static成员实例。普通成员被继承下来后子类里面有一个父类继承过来的父类里面有一个原生的这是两个成员。静态成员被继承下来后子类里面的和父类里面的是同一个。classPerson{public:string _name;staticint_count;};intPerson::_count0;//静态成员类外定义classStudent:publicPerson{protected:int_stuNum;};voidtest10(){Person p;Student s;// 这里的运行结果可以看到非静态成员_name的地址是不一样的// 说明派生类继承下来了父派生类对象各有一份coutp._nameendl;couts._nameendl;// 这里的运行结果可以看到静态成员_count的地址是一样的// 说明派生类和基类共用同一份静态成员coutp._countendl;couts._countendl;// 公有的情况下父派生类指定类域都可以访问静态成员coutPerson::_countendl;coutStudent::_countendl;}七、C11 中的 final在 C 中如果我们希望一个类不被继承该如何做到呢我们上面的做法是将类的构造私有因为子类对象在进行构造时必须调用父类的构造函数完成父类成员的初始化工作同时父类的私有成员在子类中是不可访问的所以子类无法调用父类构造自然也就无法创建子类对象。上面这种是 C98 给出的做法它虽然阻止了子类创建对象但是构造私有化也使得它本身也不能创建对象因为创建对象需要调用构造函数。所以 C11 提供了另外一种方式 – 使用final关键字来修饰被final修饰的类不能被继承八、多继承原理与菱形继承困境8.1 继承模型单继承一个子类只有一个直接父类时称这个继承关系为单继承多继承一个子类有两个或以上直接父类时称这个继承关系为多继承多继承使得子类可以同时继承多个直接父类的成员进一步实现了代码复用。但是也会引发菱形继承。菱形继承菱形继承是多继承的一种特殊情况它的表现形式如下8.2 菱形继承的问题菱形继承存在数据冗余和二义性的问题如下由于Student和Teacher都继承了Person的成员而Assistant又同时继承了Student和Teacher的成员所以Person的成员在Assistant中就会存在两份造成数据冗余以及调用时二义性的问题8.3 二义性的解决办法classA{public:int_a;};classB:publicA{public:int_b;};classC:publicA{public:int_c;};classD:publicB,publicC{public:int_d;};voidtest10(){A a;B b;C c;D d;a._a;b._a;c._a;d._a;}那么如何解决呢我们先来看内存的图我们可以通过指定类域在B或者C里面来解决二义性的问题虽然我们可以通过指定类域来解决二义性的问题但数据冗余仍然存在。D 里面仍然有 2 个_a。8.4 菱形虚拟继承为了解决菱形继承数据冗余和二义性的问题C 引入了虚拟继承 – 虚拟继承可以解决菱形继承的二义性和数据冗余的问题如上面的继承关系在Student和Teacher继承Person时使用虚拟继承即可解决问题。需要注意的是虚拟继承不要在其他地方去使用。为了研究虚拟继承的原理我们给出了一个简化的菱形继承体系然后通过内存窗口观察对象成员的模型classA{public:int_a;};classB:publicA{public:int_b;};classC:publicA{public:int_c;};classD:publicB,publicC{public:int_d;};我们首先来观察不进行虚拟继承时菱形继承的内存情况可以看到在没有进行虚拟继承时D 对象中存在着三部分成员 – 从 B 继承来的成员、从 C 继承来的成员以及 D 自身的成员同时由于 B 和 C 同时继承自 A所以 D 对象中存在两份 A 的成员从而造成数据冗余和二义性。进行虚拟继承时菱形继承的内存情况classA{public:int_a;};classB:virtualpublicA{public:int_b;};classC:virtualpublicA{public:int_c;};classD:publicB,publicC{public:int_d;};voidtest11(){D d;d._b1;d._c2;d._d3;d.B::_a4;d.C::_a5;d._a6;}通过观察我们发现进行虚拟继承后内存窗口变得非常奇怪1、整个 D 对象中只有一份 A 的成员了并且位于最下面B 和 C 对象中不再有 A 的成员2、但是 B 和 C 对象中多了一个像指针一样的东西并且当我们查看该指针指向的内存窗口时我们发现该窗口第二个整形的值恰好为 B/C 对象的起始地址与 A 对象的起始地址的偏移量。这些现象其实是合理的1、整个 D 对象中只有一份 A 对象成员并且位于最下面解决了数据冗余和二义性2、B 和 C 对象中多出来的那个指针指向的内容被称为虚基表虚基表中前四个字节的内容与多态有关我们下一节介绍后四个字节的内容存放的是对象的起始地址与虚基类起始地址的偏移量。我们上面的代码使用虚继承解决数据冗余和二义性问题的本质是在于虚继承后D 对象继承的两个类共用一个 A 类。特别注意虚拟继承是使用在产生菱形继承的地方即菱形的腰部而不是使用在最后出现问题的地方即菱形的尾部。虽然菱形虚拟继承正确但是非必要不要去设计菱形虚拟继承。在代码理解和时间复杂度上都不友好。虚拟继承的底层通过虚基表指针和虚基表实现8.5 菱形虚拟继承的一些问题问题 1菱形虚拟继承为啥用虚基表存储偏移量当有多个虚基类时会产生多个偏移量。如果直接将这些偏移量存在对象中创建大量对象时会重复存储相同数据造成内存浪费。而将偏移量统一存到类级别的虚基表中每个对象只需用一个指针指向该表即可共享所有偏移量大幅节省内存同时保证在不同继承场景下都能正确定位虚基类。在只有单个虚基类的场景下两种方式的内存占用几乎无差别此时虚基表的核心价值在于通用性同一个中间层类B在不同的继承环境如独立的 B 对象或派生类 D 中到虚基类 A 的偏移量不同通过虚基表指针可动态查表保证在任何继承场景下都能正确定位虚基类。问题 2什么时候需要用偏移量访问共享数据直接访问无需偏移量当你用最终派生类对象如D d直接访问虚基类成员时D d1;d1._a1;此时虚基类 A 的位置在编译期就已确定编译器可以直接计算出_a的地址不需要在运行时通过虚基表动态查找偏移量来计算。切片访问必须用偏移量当你用基类指针如B* pb指向派生类对象如D d并通过该指针访问虚基类成员时D d;B*pbd;pb-_a1;独立 B 对象编译期就能确定 B→A 的固定偏移量直接用这个偏移量访问 AD 中切片的 B 子对象编译期不知道该用哪个偏移量因为不知道pb指向的是独立 B 还是 D 中的 B所以只能在运行时通过虚基表查 “当前场景下的正确偏移量”再用这个偏移量访问 A。类型偏移量特点访问方式编译期确定偏移量偏移量在编译时就已固定直接使用该偏移量无需运行时动态查询运行时动态查偏移量偏移量在编译期无法确定必须在运行时通过虚基表等机制查询后才能访问问题 3菱形虚拟继承是否解决了数据冗余问题虽然虚拟继承会引入虚基表指针图中每个指针占 8 字节看起来多了一点内存开销但当虚基类 A 的成员越多、体积越大时这份指针的开销就越微不足道整体来看内存效率反而更高。九、继承和组合9.1 什么是组合// 组合classstack{private:list _lt;}// 继承classstack:publiclist{}第一个代码我们将一个基类的对象直接声明在子类里面。这就是组合。第二个代码我们将基类直接继承给子类这就是继承。9.2 对 C 多继承的客观认知多继承是 C 语法复杂的重要体现菱形继承和菱形虚拟继承的底层实现繁琐易引发问题实际开发中应尽量避免设计多继承坚决避免菱形继承。多继承被认为是 C 的缺陷之一后续的面向对象语言如 Java、C#都取消了多继承仅保留单继承 接口的方式规避了菱形继承的问题。9.3 继承与组合的区别类之间的关系主要分为两种对应两种复用方式继承is-a和组合has-a二者的设计思想和适用场景有本质区别。何时该用继承何时该用更优的组合这是体现 C 设计思维的关键。什么是 is-a 什么是 has-a 关系is-a我是一名大学生。has-a我有一颗心脏。黑箱与白箱 —— 继承与组合的本质区别继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用white-box reuse。在继承方式中基类的内部细节对派生类可见。继承一定程度破坏了基类的封装基类的改变对派生类有很大的影响。派生类和基类间的依赖关系很强耦合度高。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用black-box reuse。因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系耦合度低。优先使用对象组合有助于你保持每个类被封装。9.4 总结优先使用组合而不是继承。实际尽量多去用组合组合的耦合度低代码维护性好。不过也不太那么绝对类之间的关系就适合继承is-a那就用继承另外要实现多态也必须要继承。类之间的关系既适合用继承is-a也适合组合has-a就用组合。十、经典面试题10.1 什么是菱形继承菱形继承是 C 多继承中的一种特殊场景比如有一个基类 A类 B 和 C 都继承自 A然后类 D 又同时继承 B 和 C整个继承结构画出来就像一个菱形。10.2 菱形继承的问题是什么它主要会带来两个核心问题数据冗余基类 A 的成员会在 D 里存两份一份来自 B一份来自 C这会浪费内存。二义性当你在 D 中访问 A 的成员时编译器不知道该用 B 分支的还是 C 分支的直接编译报错。而且这种结构会让代码耦合度变高后续维护起来也很麻烦。10.3 什么是菱形虚拟继承它是如何解决数据冗余和二义性的菱形虚拟继承就是在 B 和 C 继承 A 时加上virtual关键字让 A 成为整个继承体系的虚基类。这样在最底层的 D 中A 就只会存在一份实例彻底解决了数据冗余。同时因为只有一份 A访问时也就不存在二义性了。需要注意的是虚基类 A 是由最底层的 D 来直接初始化的而不是由 B 或 C 初始化。10.4 继承和组合的区别是什么最核心的区别在于它们的语义和复用方式继承is-a 关系代表 “是一种” 的关系比如Student是一种Person。它是白盒复用子类可以直接访问父类的public和protected成员好处是复用代码很直接但坏处是耦合度高父类的改动可能会影响到子类。组合has-a 关系代表 “包含一个” 的关系比如Car包含一个Engine。它是黑盒复用外部类只能通过成员对象的public接口来使用看不到内部实现好处是耦合度低代码更稳定、灵活。10.5 什么时候用继承什么时候用组合用继承的场景当类之间有明确的 is-a 关系并且满足里氏替换原则子类可以完全替代父类时。比如Square继承Shape因为正方形确实是一种形状而且可以在任何需要Shape的地方用Square代替。用组合的场景当类之间是 has-a 的包含关系或者你想降低耦合、保持代码灵活性时。比如Computer组合CPU电脑包含 CPU但它们不是 “是一种” 的关系。组合也是实现依赖倒置原则的常用手段能让代码更容易扩展。当然也有这样的经验法则在一个场景中如果既可以用组合也可以用继承那就优先选择组合因为组合的耦合度更低扩展性更好如果场景中只能用继承比如必须复用基类接口并满足 is-a 关系那就只能选择继承。