尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【C++】继承和多态

【C++】继承和多态 目录引言一、继承1、继承的概念2、继承的定义父类是类模板时的特殊情况3、父类和子类对象的赋值兼容转换4、隐藏问题5、子类的默认成员函数6、多继承和菱形继承问题重要7、虚继承8、继承和组合二、多态1、多态的概念2、多态的定义3、虚函数的重写/覆盖4、协变5、虚析构函数6、纯虚函数和抽象类7、多态的原理总结引言面向对象三大特性中继承和多态是C里最核心也最容易被初学者混淆的部分。很多人只记住语法却不懂继承如何实现代码复用多态又凭什么实现运行时动态绑定。本文从基础语法出发梳理继承方式、虚函数、重写等关节知识点同时剖析切片问题、虚析构等常见易错点。读懂继承与多态才能真正理解C面向对象的设计思想为后续泛型和大型项目开发打好基础。一、继承1、继承的概念继承机制是面向对象程序设计使代码可以服用的最重要手段它允许我们在保持原有类特性的基础上即从扩展增加方法成员函数和属性成员变量这样产生新的类称子类。继承呈现了面向对象程序设计的层次结构体现了由简单到复杂的认知过程。继承是类设计层次的复用。2、继承的定义在语法上继承的写法是class 子类名称 继承方式 父类名称。class A { ... } class B : public A { ... }这样我们就实现了一个简单的继承结构我们接下来需要了解的是不同的继承方式它们之间有什么区别。所谓继承方式就是子类在继承父类的成员时决定父类成员在子类中的访问权限。继承方式与访问限定符一样有三种也是public、protected、private之前我们在类和对象的讲解中并没有讲解protected这个访问限定符的用法今天我们就来一起讲解一下。在类里面private注释的成员就是绝对的私有成员不管是外部还是子类都无法访问那如果有些成员我不希望它在类外面被直接访问但是又想被子类访问这时候该怎么办呢这时候就可以定义为protected被protected注释的成员既可以被子类直接访问又不会在类外被直接访问可以看出这个限定符就是因为继承这个功能才被设计出来的。那作为继承方式时它们三个又有什么区别呢类成员/继承方式publicprotectedprivate父类的public成员子类的public成员子类的protected成员子类的private成员父类的protected成员子类的protected成员子类的protected成员子类的private成员父类的private成员在子类中不可见在子类中不可见在子类中不可见通过表格我们可以看出子类中的父类成员的访问权限取决于在父类中的访问权限类型和继承方式中取两者中权限最小的。在实际运用中一般不推荐使用protected/private方式继承一般都是public继承因为protected/private继承下来的成员都只能在子类的类内部使用实际中扩展维护性不强。在不显示地写出继承方式时使用class定义的子类默认继承方式是private使用struct时默认是public这点和定义类内不写访问限定符时成员的默认访问权限类型是一致的不过最好还是显示地写出继承方式。父类是类模板时的特殊情况父类是类模板时子类在使用父类里的成员函数之前需要指定一下类域因为我们之前说过模板是按需进行实例化的最开始实例化子类时虽然也实例化了父类但其实只实例化了父类的构造函数其余的函数会因为没有使用编译器为了提高效率会暂时不进行实例化编译器的实例化规则一般是用哪个我实例化哪个所以父类在想调用父类的函数时就会因为找不到实例化的函数而报错所以需要指定一下类域然后编译器才会进入特定的类域找到对应的函数将其实例化。具体写法大致如下:templateclass T class A { public: void func() { ... } }; templateclass T class B : public AT { public: void func2() { AT::func(); ... } };3、父类和子类对象的赋值兼容转换public继承的子类对象可以赋值给父类的对象/父类的指针/父类的引用。这里有个形象的说法叫切片或切割它相当于将子类中属于父类的那一部分切出来给父类对象需要注意的是这个过程中并没有产生临时变量。借助图像来理解就大概是这样。此外父类对象不能赋值给子类对象但是父类的指针或者引用是可以通过强制类型转换赋值给子类的因为父类的引用或指针可能原本就是指向子类的所以通过强转可以赋值回去也很合理。4、隐藏问题当子类与父类成员中有同名成员时子类成员会将父类的同名成员给屏蔽掉这与函数重载不同的是函数重载是根据所传参数类型或个数的不同来选择性的调用函数而隐藏问题是无论如何都只访问子类里自己定义的那个函数除了用域访问限定符来显示的调用父类的同名函数。还有一个与函数重载不同的是成员函数的隐藏只要函数名相同就构成隐藏其他所有条件都不影响。5、子类的默认成员函数子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。如果父类没有默认的构造函数就必须在子类构造函数的初始化列表显示的调用父类的构造函数。初始化的顺序是先调用父类构造函数再调用子类构造函数。大致写法如下图所示class A { public: A(int name) { _name name; } protected: int _name; } class B : public A { B(int name) :A(name) { } }子类的拷贝构造函数必须调用父类的拷贝构造函数完成父类的拷贝初始化子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员因为我们之前学过析构函数在析构时是按照定义的顺序从后往前析构的所以这样才能保证子类对象先清理子类成员再清理父类成员的顺序。在一般情况下子类不需要再自己显示的写出构造函数外的其他成员函数使用默认生成的成员函数就够用了除非子类内部还进行了资源的申请这时候才需要自己显示的写出。此外还有最重要的一点在子类里显示地写出析构函数时需要谨慎因为虽然子类和父类的析构函数名称看上去是不一样的但实际上编译器在对析构函数进行编译时会将所有的析构函数名都转化为一个相同的名字destructor。也就是说其实子类和父类的析构函数之间会构成函数隐藏如果不稍加注意会导致严重的后果。所以如果需要显示的写出子类的析构函数时一定要在函数的内部用域访问限定符显示地调用父类的析构函数然后再进行子类成员的释放。至于编译器为什么会这么处理析构函数我们在后面学习多态时再进行讲解。6、多继承和菱形继承问题重要在C的继承体系中既可以进行单继承也可以进行多继承。单继承一个子类只有一个直接父类时称这个关系为单继承多继承一个子类有两个或者以上直接父类时称这个关系为多继承多继承对象在内存中的模型时先继承的父类在前面后继承的父类在后面子类在最后面。多继承的写法为class A { ... }; class B { ... }; class C : public B, public A { ... };接下来需要讲解的是C继承体系中的一个大BOSS菱形继承菱形继承菱形继承是多继承的一种特殊情况它的结构是B类和C类分别继承自A类而D类又同时继承了B类和C类这时候整个继承体系呈一个菱形于是称这种继承类型为菱形继承。菱形继承带来最大的问题就是它具有数据冗余和二义性的问题。该怎么理解这两个问题呢我们只要设想一下菱形继承的继承路线B类和C类都继承自A类说明它们两个内类都各有一份来自A类的成员而此时D类同时继承了它们两个是不是就意味着D类同时继承到了两份A类的成员是不是数据就冗余了我们在调用这部分成员的时候是调用B内的用法呢还是调用C内的用法呢是不是数据就有二义性了。那么我们该怎么解决这个问题呢下附单继承、多继承和菱形继承的示意图7、虚继承接下来我们就来讲解一下如何解决菱形继承这个大boss。我们需要用到的方法是虚继承。虚继承顾名思义就是它并没有将原先父类的所有内容都继承了下来而是使用了一种指针叫虚基类指针。具体内部实现我们这篇文章先不进行深入的讲解有点复杂。反正大致意思就是在继承时我并不全部继承而是只继承那个虚指针在后续使用时根据这个虚指针去找到父类的成员。用在菱形继承上就有很明显的效果最下层的子类就不再保存两份父类成员而是只指向父类中的一份内容就解决了数据冗余和二义性问题。虚继承的具体写法就是在继承方式的前面加一个virtual关键字class A { ... }; //虚继承 class B : virtual public A { ... };在拥有多层继承关系的结构中virtual也不是随便加的我们需要判断的是从哪一层开始出现了数据冗余和二义性将那一层的继承改为虚继承就行了。说人话就是发现有多个类继承自同一个类就在这几个类前面加上virtual。多继承是C语法复杂的一个体现有了多继承就会存在菱形继承有了菱形继承就有了菱形虚继承底层实现很复杂性能也会有一些损失所以最好不要设计出菱形继承多继承可以认为是C的缺陷之一后来的一些编程语言都没有多继承如Java。8、继承和组合继承这种复用方式在编程中常被称为是 “白箱复用”在继承中父类的内部细节对子类可见在一定程度上破坏了父类的封装。父类的改变对子类有很大的影响子类与父类之间的依赖关系很强耦合度很高。而组合则是一种“黑箱复用”在使用组合是被组合对象的内部细节是不可见的对象只以黑箱的形式出现组合之间没有很强的一来关系耦合度低。继承和组合这两种复用方式形象来说就是对继承来说我的身体里有一部分是你我相当于是挂在你身上的对组合来说组合就像是我直接将你组合进了我的身体内部我也不管你是怎么实现的细节是什么我只需要你能在我的内部实现就行然后我在你实现的基础上加上我自己的东西组成我的身体。在选择复用方式时优先使用组合而不是继承。实际尽量多使用组合民族和耦合度低代码维护性好。不过也不绝对如果两个类之间的关系适合用继承也可以用继承只不过在既适合继承又适合组合时优先使用组合是最好的。举个继承和组合的例子//继承 class stack : public vector { ... }; //组合 class stack { ... private: vector v; ... };二、多态1、多态的概念多态分为编译时多态静态多态和运行时多态动态多态静态多态就是我们之前讲的函数重载和函数模板它们传不同类型的参数就可以调用不同的函数通过参数不同达到多种形态之所以叫编译时多态是因为它们实参传给形参的参数匹配是在编译时完成的我们把编译时一遍归为静态运行时归为动态。我们今天要学习的多态指的是一个继承关系下的类对象去调用同一函数产生了不同行为。2、多态的定义要想实现一个多态必须要满足两个要求1、必须是父类指针或者引用调用虚函数2、被调用的函数必须是虚函数。在上面对继承的学习中我们已经知道了在继承前加virtual可以构成虚继承虚函数也是一样的在函数名前面加上virtual就构成了虚函数。虚函数在一般情况下父类和子类中的虚函数前都要加virtual虽然子类里的虚函数不加virtual也可以构成多态但是一般情况下都写上会更好父类虚函数前的virtual无法省略。注意必须是父类指针或者引用调用虚函数这一点是最重要的。为什么这么写我在后面会讲解多态的原理class A { public: virtual void func() { ... } }; class B : public A { public: virtual void func() { ... } }; void func2(A a) { a.func(); } int main() { A a; B b; func2(b); func2(a); }3、虚函数的重写/覆盖虚函数的重写/覆盖指的就是子类中有一个和父类完全相同的虚函数就是返回类型、名字和参数列表都完全相同但是函数体不同这样就实现了子类虚函数对父类虚函数的重写/覆盖。子类必须对父类的虚函数重写/覆盖了子类才能有不同的虚函数多态的不同形态效果才能达到。因为你要想它在不同的情况下显示不同的功能就必须得自己重写函数体来实现别的功能才行重写的本质就是重写虚函数的实现。4、协变上面我们学习了要构成多态需要函数的返回类型相同这点其实也有例外当子类重写父类的虚函数时父类的返回类型为父类对象的指针或引用子类的返回类型为子类对象的指针或引用这种写法也是可以构成多态的。这种写法被称为协变class A { public: virtual A* func() { ... } }; class B : public A { public: virtual B* func() { ... } };5、虚析构函数上面在继承部分我们有讲编译器对析构函数的特殊处理现在我们就在这里结合虚函数的知识一起讲解原因。当父类的析构函数为虚函数时无论子类的析构函数是否加virtual它们之间都构成虚函数原因我们之前知道了虽然看上去它们的名字不同但实际上编译器会将它们的名字都特殊处理为destructor目的就是为了实现析构函数的多态可以实现在不同情况下调用不同的析构函数。所以说其实编译器会这么特殊处理的原因就是为了实现析构函数的多态相当于是当时在设计C时就为了多态这个功能做的特殊设计。6、纯虚函数和抽象类当我们在父类里写虚函数时可以省略虚函数的函数体不写而直接在函数后写上 ‘ 0 ’这种写法的虚函数就被称为是纯虚函数纯虚函数不需要定义实现也没必要定义实现只要声明即可。包含纯虚函数的类叫做抽象类。抽象类不能实例化出对象因为它实际上没有任何功能如果子类后续不对纯虚函数进行重写那么子类也是抽象类。纯虚函数在某种成都上强制力子类重写虚函数因为不重写就无法实例化出对象。//抽象类 class A { public: //纯虚函数 virtual void func() 0 }; class B : public A { public: virtual void func() { ... } };7、多态的原理我们现在来讲解一下多态的原理相信很多人都会好奇多态这个过程到底是怎么实现的。首先在所有含有虚函数的类内都会自带一个看不见的指针叫做虚函数表指针那么虚函数表又是什么呢在一个含有虚函数的类里编译器都会生成一个指针数组专门用来存储虚函数的指针也就是说虚函数表就是一个用来存储虚函数指针的函数指针数组。一个类里的所有虚函数的地址都会被按顺序存到这个虚函数表里当我们在调用时编译器就会去虚函数表里去找对应的虚函数的地址然后再跳转到对应的地址调用函数。子类由两部分组成继承下来的父类和自己的自己的成员一般情况下继承下来的父类成员中就已经含有虚函数表指针这个成员了所以子类就不会再自己定义一个成员而是继续沿用父类的成员创建自己的虚函数表指针。然后对虚函数表来说子类会先将父类的整个虚函数表都复制过来然后编译器再在子类里面找符合多态条件的虚函数将函数名与虚函数表里的名字进行匹配如果函数名匹配上了就将子类的虚函数地址覆盖在那个表里对应的位置上。所以上面我们所说的对虚函数的重写/覆盖中的覆盖其实就是指的这个过程。子类的虚函数表中包含父类的虚函数地址、子类覆盖的地址和子类自己的虚函数地址三个部分。至于之前说的为什么一定要用父类的指针或引用来进行调用这点可以和我们之前继承里讲的切片那个知识点联系起来。我们之前讲了在讲子类赋值给父类的指针或引用时父类指针或引用会将属于自己的那一部分切出来而子类又继承了来自父类的虚函数表指针这个成员所以会被一起切过去这时候再进行调用时编译器就可以通过这个指针去到对应的虚函数表里去找对应的虚函数了。而不管是哪一个子类它们在内部进行了怎样的成员定义都不会影响到父类成员所以用父类来切片也最准确最标准。这就是为什么要用父类指针或引用调用的原因。总结本文讲解了C继承的三种继承方式、访问权限变化剖析了菱形继承带来的数据冗余与成员二义性问题并解释了虚继承依靠虚基类指针共享唯一基类实例的底层原理。继承实现代码复用而虚继承作为特殊手段解决多重继承缺陷合理使用才能写出规范健壮的面向对象代码。
返回列表