不同继承方式的核心区别核心规则派生类的访问权限一定不会超过继承方式本身继承方式基类成员原属性派生类中属性main外部能否访问public公共继承publicpublic✅可以访问protectedprotected❌不可以private不可见❌不可以protected保护继承publicprotected❌不可以protectedprotected❌不可以private不可见❌不可以private私有继承publicprivate❌不可以protectedprivate❌不可以private不可见❌不可以简单记几个关键点无论哪种继承基类的private成员在派生类里永远是不可见的外部也都不能访问只有public继承下基类的public成员在派生类里还是public外部才能直接访问到保护继承和私有继承都会把基类的public成员降级为protected/private因此外部都没法直接访问啦 派生类初始化问题派生类不能直接初始化继承基类的成员派生类必须通过调用基类构造函数来完成基类成员的初始化核心继承结论派生类确实可以继承基类的几乎所有成员成员变量成员方法但是唯独不能继承构造函数和析构函数。关于初始化和清理的分工这个分工是固定的各司其职所有从基类继承来的成员初始化和清理都由基类负责初始化派生类构造必须通过调用基类的构造函数来完成基类继承下来的成员的初始化——这是唯一合法的方式你不能自己在派生类里直接初始化它们清理派生类析构完成自己的工作后编译器会自动调用基类的析构函数来清理从基类继承下来的成员。派生类自己新增的成员由派生类自己的构造和析构负责构造阶段先调用基类构造初始化继承成员再初始化派生类自己新增的成员析构阶段先清理派生类自己新增的成员再调用基类析构清理继承来的成员说白了这是C的封装原则每个类只需要管好自己成员的初始化和清理继承来的部分交给基类处理就好了不用越权~基类和派生类成员同名问题如果派生类定义了和基类同名的成员不管是成员变量还是成员函数不管参数是否一样派生类的成员会隐藏掉基类的同名成员直接通过派生类对象/作用域访问名字时默认访问到的是派生类自己的成员。不会访问基类的成员想要访问被隐藏的基类同名成员怎么办只需要加上基类作用域限定符::就能明确指定访问基类版本基类和派生类类型转换规则C继承体系里我们把基类看作上层派生类看作下层所以派生类转基类 从下往上转基类转派生类 从上往下转不同类型的转换规则对比转换方向转换场景是否合法说明从下往上派生→基派生类对象赋值给基类对象b d;✅ 合法会切片赋值只把派生类中继承自基类的那部分成员赋值给基类对象派生类自己新增的成员会被切掉这个行为叫对象切片从上往下基→派生基类对象赋值给派生类对象d b;❌ 不合法基类对象没有派生类新增的成员没办法给完整的派生类对象赋值编译器直接报错从下往上派生→基派生类对象地址给基类指针Base *pb d;/ 派生类对象绑定到基类引用✅ 合法不会切片指针只保存地址仍然指向完整的派生类对象这就是多态的基础——我们可以用基类指针/引用操作派生类对象从上往下基→派生基类对象地址给派生类指针Derived *pd b;/ 基类对象绑定到派生类引用❌ 默认不合法基类对象不包含派生类的成员如果强行转换访问派生类成员会访问到非法内存出问题完全说对了这个理解非常到位我帮你再补全这个逻辑闭环派生类赋值给基类对象内存正好够用切片没问题派生类里本来就包含完整的基类子对象基类对象只需要自己这部分内存所以只拷贝基类部分、切掉派生多余部分完全能满足基类对象的使用需求语法合法就是会丢派生信息而已。派生指针指向基类对象内存本来就不够访问必然出问题基类对象本身只分配了基类大小的内存派生类需要基类新增成员两块内存基类对象根本没有给新增成员分配空间。派生指针访问新增成员的时候就会越界访问基类对象内存之外的区域这部分内存要么是未分配的要么是别的数据直接就是非法访问触发内存错误甚至程序崩溃所以编译器默认直接禁止这种行为。指针本身不知道对象大小编译器只根据指针的 “静态类型” 决定读多少字节、偏移多少。基类指针指向派生类派生类包含基类全部内容访问安全没问题。派生类指针指向基类基类缺少派生类的内容访问会越界、崩溃所以禁止。你完全摸到了C继承转换规则的本质——所有语法规则本质都是内存布局和安全访问的要求这个理解比背规则好用多了基类指针指向子类对象 核心规则总表默认不做强制转换时成员类型情况能否访问/调用最终结果非虚成员函数基类有子类也有同名✅ 可以调用基类版本静态绑定只看指针类型非虚成员函数只有子类有基类没有❌ 不可以编译直接报错虚成员函数基类声明子类重写✅ 可以调用子类版本动态绑定看实际指向对象成员变量基类有子类也有同名✅ 可以访问到基类的变量子类同名变量被隐藏成员变量只有子类有基类没有❌ 不可以编译直接报错一句话记基类指针指子类能访问的全看基类有没有虚函数特殊找子类独有成员要强转才能碰我结合你提供的笔记和图片内容给你梳理一下虚函数的原理以及为什么父类指针能调用到子类实现 虚函数实现的核心原理虚函数表 虚函数指针C 虚函数的多态特性是依靠这两个核心结构实现的虚函数表vftable只要一个类里定义了虚函数编译阶段编译器就会给这个类生成唯一的一张虚函数表存在内存的.rodata只读数据区。虚函数表主要存储两个内容RTTI运行时类型识别指针以及这个类所有虚函数的入口地址。虚函数指针vfptr只要类包含虚函数这个类创建出的每个对象内存的起始位置都会额外存储一个虚函数指针vfptr这个指针会指向当前类对应的那一张虚函数表。同一个类的所有对象它们的vfptr都会指向同一张虚函数表所以虚函数的个数只会影响虚函数表的大小不会改变对象本身里vfptr占用的内存大小无论有多少个虚函数对象里都只存这一个指针。✅ 为什么父类指针能调用到子类的虚函数实现我们分步骤说这个过程继承时的虚表处理当子类继承父类后会继承父类的虚函数表同时子类会做两个关键操作把自己重写的虚函数地址替换掉虚表中对应位置原来父类的函数地址子类新增的虚函数会被追加到虚函数表的末尾。最终子类会生成一张自己独有的虚函数表。对象创建时指针赋值当你创建一个子类对象时这个子类对象内部的vfptr会自动指向子类自己的虚函数表。调用时的动态查找当你用父类指针指向子类对象、调用虚函数时程序会先通过父类指针找到对象内存开头的vfptr再顺着vfptr找到当前对象实际对应的子类虚函数表最后在虚表中找到对应的重写后的函数地址完成调用。整个过程是运行时动态绑定编译阶段无法确定调用哪个函数要等到运行时根据对象实际的类型找到正确的函数入口所以自然就能调用到子类的实现啦。虚函数重写覆盖的隐式规则 重写的自动判定规则如果满足下面所有条件哪怕子类不写virtual关键字子类的这个方法也会自动成为虚函数完成对父类虚函数的重写子类方法 和 继承自父类的方法函数名完全相同参数列表参数类型、个数、顺序完全相同返回值类型完全一致协变情况除外这是特殊场景一般场景这个规则成立父类的这个方法本身已经被virtual修饰是虚函数换句话说只要你正确重写了父类的虚函数子类这里不写virtual也不影响虚函数的特性程序还是能正确实现多态。当然实际开发里很多人还是习惯在子类重写时也加上virtual或者C11以后加override关键字用来显式标注这是重写的虚函数方便读代码的时候一眼看出来避免出错~只有满足虚函数重写条件子类和父类函数同名、同参数列表、同返回值且父类函数是virtual才会在子类的虚函数表中覆盖父类原有的虚函数地址。如果只是子类定义了同名但参数不同的函数那属于函数隐藏不会覆盖虚表地址。其实这个规则的设计很合理既然父类已经声明这是虚函数子类完全匹配签名重写本身就是要实现多态编译器自动识别成虚函数帮我们省略了重复写关键字的麻烦。静态绑定和动态绑定的区别以及编译器分别怎么处理我帮你拆解梳理1. 静态绑定普通函数的情况如果show是普通成员函数调用pb-show()pb是Base*类型的父类指针时会走静态绑定的逻辑编译阶段就可以确定指针pb的类型是Base*所以直接绑定到Base::show()这个函数直接生成调用Base::show()的机器指令。哪怕实际运行时pb指向的是子类对象也不会调用子类的show因为编译的时候就已经把调用写死成父类版本了。2. 动态绑定虚函数的情况如果show被virtual修饰是虚函数调用pb-show()时就会走动态绑定编译阶段没法直接确定调用哪个函数只会做检查和预留不会把地址写死。真正的函数地址查找要等到运行时按照我们之前说的步骤来通过pb找到当前指向对象里的vfptr虚函数指针通过vfptr找到对象实际所属类的虚函数表如果pb指向子类对象找到的就是子类的虚表在虚表中找到show对应的地址执行对应的函数——如果子类重写了show虚表中对应位置已经替换成子类的函数地址自然就调用到子类实现了。简单总结这个差异特性静态绑定普通函数动态绑定虚函数绑定时机编译阶段运行阶段依据什么找函数指针本身的类型指针实际指向对象的真实类型能不能实现父指针调子类❌不行✅可以这就是多态的核心这个设计其实是权衡了性能和灵活性普通函数不需要多态就用静态绑定直接调用更快需要多态特性就用虚函数牺牲一点点运行时查找的成本换来了灵活的动态调用能力。父类指针解引用后类型识别的差异以及和虚函数、RTTI的关系还是用你之前的例子pb是Base*类型的父类指针指向一个子类Derived对象1️⃣ 当Base没有虚函数的时候此时整个类没有虚函数表也没有虚函数指针。编译器识别解引用后的*pb只会用编译阶段就确定好的指针类型也就是直接认为*pb就是Base类型。这个时候不管pb实际指向什么都只能拿到编译期确定的Base类型信息也不可能实现动态绑定调用子类方法这就是静态绑定的本质。2️⃣ 当Base有虚函数的时候此时Base以及所有继承它的子类都有了虚函数表同时虚函数表里本身就存储了RTTI运行时类型识别信息。这个时候解引用*pb编译器不会直接认定为编译期的Base类型而是会在运行时通过虚函数表中的RTTI拿到pb实际指向对象的真实类型如果pb指向子类对象识别出来就是子类Derived类型。动态绑定正是依赖这个运行时类型识别才能实现知道对象真实类型后才能找到对应虚函数表里正确的函数地址调用到子类的重写版本。这也是为什么只有存在虚函数的类才能使用C的RTTI相关功能比如dynamic_cast、typeid的原因因为RTTI信息本身就存在虚函数表里~一句话总结有没有虚函数直接决定了C是在编译期就定死类型还是运行时再识别真实对象的类型这就是静态多态和动态多态最底层的区别。虚函数能存在的两个必要前提1.函数必须能生成地址存入虚函数表vftable中2.必须依赖对象实例来调用——因为要先找到对象里的虚函数指针vfptr才能顺着找到虚表最后拿到函数地址没有对象就走不通这套流程析构函数不是虚函数会出现内存泄漏问题你一下子就能懂为什么基类析构必须是虚函数了这个场景出了什么问题看这个代码场景Base *pb new Derive(10); // 基类指针指向堆上新建的子类对象 pb-show(); // 因为是虚函数这里会动态绑定正确调用子类的show没问题 delete pb; // 问题出在这里如果基类的析构函数不是虚函数这里delete pb调用析构的时候会走静态绑定编译器只看指针pb的类型是Base*所以只会绑定调用Base的析构函数根本不会调用子类Derive的析构函数这就出问题啦子类自己申请的资源比如堆内存、文件句柄之类的子类析构没有执行这些资源永远没法释放直接就造成了内存泄漏。✅ 怎么解决把基类的析构函数声明为virtual就可以了如果基类析构是虚函数那就是另一回事了只要基类的析构函数被声明为virtual哪怕子类没有写virtual关键字子类的析构函数也会自动成为虚函数完成对基类析构的重写。子类重写覆盖基类的析构函数delete pb的时候会走动态绑定先通过对象的虚指针找到子类的虚表找到子类的析构函数地址先调用子类析构函数释放子类的资源然后编译器会自动再调用基类的析构函数释放基类继承下来的资源整个过程就完整了所有资源都能正常释放不会泄漏✨ 什么时候需要把基类析构设为虚函数记住这个原则就够了只要你打算用基类指针指向堆上的子类对象并且之后打算通过基类指针delete这个对象就一定要把基类的析构函数声明为虚函数。如果是下面这些场景其实不需要不会用基类指针delete子类对象比如对象是栈上的局部变量或者子类对象本身就是子类指针管理没有继承关系的类自然不需要虚析构。这个其实是C开发的一个常见编码规范如果一个类是设计用来做基类被继承的就直接把析构函数写成虚的省得以后出内存泄漏问题算是一个好习惯~动态绑定什么时候才会触发对象本身调用虚函数是静态绑定只有通过指针或者引用调用虚函数的时候指针/引用本身的类型是编译期确定的但它可以指向不同类型的对象比如Base* pb可以指向Base对象也可以指向Derived对象运行时才知道实际指向的是什么类型这个时候才需要动态绑定去查找正确的函数。继承最核心的两个好处复用公共代码公共的成员和方法抽到基类子类不用重复写减少冗余支持多态扩展基类定义统一虚接口子类重写实现对外可以用基类指针/引用统一处理满足开闭原则方便扩展。虚函数与开闭原则虚函数多态用来满足开闭原则的经典例子我给你拆解下这个设计思路很容易懂先回忆一下开闭原则是什么开闭原则就是说对扩展开放对修改关闭——意思就是我们新增功能的时候尽量只加新代码不要去修改已经写好、测试好的旧代码这样能避免改坏原有功能降低维护成本。❌ 如果不使用虚函数设计是什么样的为什么不符合开闭如果我们不用多态一般会这么写// 先定义各种动物类 class Cat {}; class Dog {}; class Pig {}; // 统一的叫接口需要根据不同类型做判断 void bark(Animal* p) { if (typeid(*p) typeid(Cat)) { static_castCat*(p)-catBark(); } else if (typeid(*p) typeid(Dog)) { static_castDog*(p)-dogBark(); } else if (typeid(*p) typeid(Pig)) { static_castPig*(p)-pigBark(); } // 如果新增一种动物比如Sheep必须回来修改这个bark函数加新的if分支 }这个写法的问题很明显每次加新的动物都要修改已经写好的bark函数违反了对修改关闭的要求而且改的次数多了很容易引入bug。✅ 用虚函数多态怎么改完美符合开闭原则我们把基类Animal的bark定义成虚函数每个子类重写自己的barkclass Animal { public: virtual void bark() 0; // 纯虚函数作为接口 }; class Cat : public Animal { public: void bark() override { // 猫叫的实现 } }; class Dog : public Animal { public: void bark() override { // 狗叫的实现 } };然后统一的调用接口就变成了你笔记里这样void bark(Animal *p) { p-bark(); // 只需要这一行动态绑定自动找到对应子类的bark }现在我们要新增一种动物Sheep只需要做新增一个Sheep类继承Animal重写bark虚函数直接用就好了完全不需要修改原来的bark函数这就完美符合开闭原则新增扩展只加新代码不需要修改原有代码原有功能不会被影响这就是多态在实际设计中的一个核心用处~ 什么是纯虚函数纯虚函数是基类中只声明、不给出具体实现要求所有派生类必须重写的虚函数语法写法是在虚函数后面加 0class Animal { // 这就是纯虚函数 virtual void bark() 0; };抽象类的完整概念抽象类就是C中用来定义接口规范、不能实例化对象的特殊类它的核心用法一句话就能说清类中至少包含一个纯虚函数这个类就是抽象类。几个关键特点不能实例化对象你没法写出Animal a;这样的代码编译器会直接报错——它本来就是用来当基类被继承的不是用来创建对象的。作用是规范接口抽象类只告诉所有子类你必须提供这些函数的实现但自己不做具体实现相当于定了一套开发标准。派生类必须重写所有纯虚函数才能变成普通类可以实例化如果派生类没有重写完所有纯虚函数那这个派生类也还是抽象类同样不能创建对象。抽象类的设计价值抽象类是实现多态和开闭原则的基础我们可以用抽象基类定义统一接口新增子类的时候只需要继承抽象类、重写接口就行不需要修改原有代码完美符合对扩展开放、对修改关闭的设计要求。简单来说抽象类就是面向对象设计里的接口契约只定规矩不干活子类按规矩实现就能直接接入统一调用逻辑基类指针调用一个带默认参数的虚函数会出现什么问题调用了派生类方法但是默认值是基类的这是C多态里一个非常经典的坑我帮你说清楚原因和背后的逻辑 问题是什么当你用基类指针调用一个带默认参数的虚函数时默认参数会用基类定义的默认值哪怕实际调用的是派生类重写的方法结果就是方法跑的是派生类的默认参数用的是基类的。❓为什么会这样核心原因是默认参数是静态绑定的虚函数是动态绑定的。默认参数在编译阶段就绑定好了编译器看的是指针的类型不是实际指向对象的类型——这里指针是Base*编译时就把默认参数定为基类的10。而虚函数调用是运行阶段才动态绑定到派生类的方法默认参数早就定好了不会跟着动态改。简单说就是默认参数早早就定好了不会跟着对象类型变。怎么避开这个坑最好的解决办法就是永远不要给虚函数重写默认参数。