C++多态

张开发
2026/4/9 22:42:25 15 分钟阅读
C++多态
1. 多态的定义及实现1.1 多态的构成条件多态是指在继承关系下不同的类对象调用同一函数时产生了不同的行为。示例Student类继承了Person类。Person对象买票全价Student对象买票优惠1.2 实现多态的两个必要条件要实现多态效果必须同时满足以下两个关键条件调用方式限制必须是基类的指针或者引用来调用虚函数。原因只有基类的指针或引用才能既指向基类对象又指向派生类对象从而实现统一接口下的不同表现。函数定义限制被调用的函数必须是虚函数并且派生类必须完成对该虚函数的重写覆盖。原因只有完成了重写/覆盖基类和派生类之间才能拥有不同的函数实现逻辑从而达到多态的“不同形态”效果。2 虚函数在类成员函数前面加上virtual修饰符该成员函数即被称为虚函数。注意非成员函数如全局函数、静态成员函数不能加virtual修饰。代码示例classPerson{public:// 使用 virtual 关键字声明虚函数virtualvoidBuyTicket(){cout买票-全价endl;}};2.1 虚函数的重写/覆盖定义当派生类中定义了一个与基类完全相同的虚函数时称为派生类重写或覆盖了基类的虚函数。重写的严格条件派生类的虚函数必须与基类虚函数在以下三个方面完全一致返回值类型相同函数名字相同参数列表相同结论只有满足上述所有条件才能构成有效的重写从而实现多态行为。虚函数重写的一些其他问题1. 协变 (了解)定义派生类重写基类虚函数时返回值类型可以不同。具体规则是基类虚函数返回基类对象的指针或引用。派生类虚函数返回派生类对象的指针或引用。这种情况称为协变。虽然实际开发中意义不大但作为知识点需要了解。代码示例classA{};classB:publicA{};classPerson{public:// 基类返回基类指针 A*virtualA*BuyTicket(){cout买票-全价endl;returnnullptr;}};classStudent:publicPerson{public:// 派生类返回派生类指针 B* (构成协变)virtualB*BuyTicket(){cout买票-打折endl;returnnullptr;}};voidFunc(Person*ptr){ptr-BuyTicket();// 多态调用}intmain(){Person ps;Student st;Func(ps);Func(st);return0;}2. 析构函数的重写规则如果基类的析构函数是虚函数那么派生类的析构函数无论是否显式添加virtual关键字只要定义了该析构函数它都会自动与基类的析构函数构成重写。原理虽然基类和派生类的析构函数在源码中名字不同例如~A()和~B()表面上看不符合“函数名相同”的重写规则但实际上编译器对析构函数的名称做了特殊处理。在编译后所有析构函数的名称被统一处理为destructor。因此只要基类析构函数加了virtual修饰派生类的析构函数在底层逻辑上就构成了重写。重要性核心问题如果基类的析构函数不是虚函数当通过基类指针删除派生类对象时只会调用基类的析构函数。不会调用派生类的析构函数。后果这会导致派生类中申请的资源如动态分配的内存、打开的文件句柄等无法被释放从而造成严重的内存泄漏。最佳实践在设计类继承体系时基类的析构函数必须设计为虚函数。这是保证多态删除对象时资源正确释放的关键。override和final关键字从上⾯可以看出C对虚函数重写的要求⽐较严格但是有些情况下由于疏忽⽐如函数名写错、参数写错等导致⽆法构成重写⽽这种错误在编译期间是不会报出的。只有在程序运⾏时没有得到预期结果才来debug会得不偿失。因此C11提供了override可以帮助⽤⼾检测是否重写。如果我们不想让派⽣类重写这个虚函数那么可以⽤final去修饰。1. override 关键字作用显式地指示编译器该虚函数旨在重写基类中的同名虚函数。优势如果派生类中的函数签名函数名、参数列表、const属性等与基类中的虚函数不匹配编译器会直接报错。这有助于在编译期捕获重写错误避免运行时出现逻辑错误。2. final 关键字作用禁止派生类重写该虚函数。应用场景当你希望某个虚函数在继承体系中保持特定实现不允许派生类修改其行为时可以使用final。3. 示例代码#includeiostreamclassBase{public:virtualvoidfunc1(){std::coutBase::func1std::endl;}virtualvoidfunc2()final{// 禁止派生类重写func2std::coutBase::func2std::endl;}};classDerived:publicBase{public:voidfunc1()override{// 正确重写Base::func1std::coutDerived::func1std::endl;}// void func2() override { // 错误尝试重写final函数编译器会报错// std::cout Derived::func2 std::endl;// }voidfunc3()override{// 错误Base中没有func3编译器会报错std::coutDerived::func3std::endl;}};intmain(){Derived d;d.func1();// 输出: Derived::func1d.func2();// 输出: Base::func2return0;}重载、重写与隐藏的对比概念作用域函数名参数列表返回值关键字/特殊要求重载同一作用域(同一个类中)相同不同(类型或个数不同)无关(可同可不同)无特殊要求重写/覆盖不同作用域(父类与子类)相同必须相同必须相同(协变例外)基类函数必须有virtual关键字隐藏不同作用域(父类与子类)相同不同(若相同且无 virtual 也属隐藏)无关只要不构成重写同名即隐藏补充说明隐藏还有一种情况子类成员变量与父类成员变量同名也称为隐藏。协变指重写时返回值可以是父类虚函数返回值的派生类指针或引用。3. 纯虚函数和抽象类在虚函数的后面写上0则这个函数为纯虚函数。纯虚函数不需要定义实现虽然语法上允许实现但通常没有意义因为会被派生类重写只要声明即可。抽象类包含纯虚函数的类叫做抽象类也叫接口类。实例化限制抽象类不能实例化出对象。派生类规则如果派生类继承后不重写纯虚函数那么该派生类也是抽象类同样无法实例化对象。纯虚函数强制了派生类必须重写该虚函数否则无法生成实例。示例代码classAbstractClass{public:virtualvoidfunc()0;// 纯虚函数};classDerivedClass:publicAbstractClass{public:voidfunc()override{// 必须重写纯虚函数std::coutDerivedClass::funcstd::endl;}};intmain(){// AbstractClass a; // 错误抽象类不能实例化DerivedClass d;// 正确重写了纯虚函数可以实例化d.func();return0;}4.多态的原理什么是虚函数表指针 (vptr)简单来说vptr是一个隐藏在类对象内部的指针。归属它属于对象实例。作用它指向该对象所属类的虚函数表vtable。目的为了让程序在运行时能够根据对象的实际类型找到并调用正确的虚函数版本即实现动态绑定。每个拥有虚函数的类都会有一个属于自己的、独立的虚函数表。底层工作原理当一个类中声明了虚函数virtual函数时编译器会自动做两件事生成虚函数表vtable这是一个静态的数组属于类所有该类的对象共享一张表。表中存放了该类所有虚函数的地址。插入虚函数表指针vptr编译器会在类的每个对象的内存布局中隐式插入一个指针这就是 vptr。在对象构造时vptr 会被初始化指向该类的 vtable。内存布局示意图通常情况下取决于编译器实现如 GCC/MSVCvptr位于对象内存布局的最前端。内存偏移内容说明0x00vptr指向虚函数表的指针 (通常占 4 或 8 字节)0x08成员变量 A类中定义的其他数据………多态调用的过程当你通过基类指针或引用调用虚函数时底层发生了以下步骤访问 vptr程序通过对象地址读取位于首部的vptr。查找 vtable顺着vptr找到对应的虚函数表。定位函数在表中根据函数声明的顺序偏移量找到对应的函数地址。调用跳转到该地址执行代码。核心逻辑因为派生类对象的vptr指向的是派生类的虚表所以即使你用基类指针指向它程序也能通过 vptr 找到派生类的函数实现。初始化时机与构造顺序这是一个非常关键且容易出错的细节。vptr的初始化发生在构造函数执行之前由编译器插入代码。继承体系下的变化在构造派生类对象时vptr会经历“变色”过程基类构造阶段先执行基类构造函数。此时对象的vptr被设置为指向基类的 vtable。注意如果在基类构造函数中调用虚函数调用的是基类版本不会发生多态。派生类构造阶段基类构造完成后执行派生类构造函数。此时vptr被覆盖/修改指向派生类的 vtable。对对象大小的影响由于vptr的存在含有虚函数的类对象会比普通类对象大。普通类大小 所有非静态成员变量之和。含虚函数的类大小 所有非静态成员变量之和 1个指针的大小。在32位系统上通常增加4字节。在64位系统上通常增加8字节。总结特性说明名称虚函数表指针 (_vptr,vfptr)存储位置对象的内存空间内通常在头部指向目标类的虚函数表 (vtable)数量每个含虚函数的对象包含1个vptr (单继承下)主要代价增加对象内存占用 (1个指针大小)以及运行时查表的微小时间开销4.2 多态的原理4.2.1 多态是如何实现的从底层的角度来看在Func函数中执行ptr-BuyTicket()时系统是如何做到“指向Person对象就调用Person::BuyTicket指向Student对象就调用Student::BuyTicket的呢通过底层分析我们可以发现满足多态条件后函数的调用机制发生了根本变化不再是编译时确定底层不再是编译时通过调用对象的类型来确定函数的地址静态绑定。而是运行时动态查找是在运行时到指向的对象的**虚函数表vtable**中确定对应虚函数的地址动态绑定。这样就实现了“指针或引用指向基类就调用基类的虚函数指向派生类就调用派生类对应的虚函数”。图解分析指向基类对象Person场景ptr指向Person对象。结果通过ptr中的vptr找到Person的虚表调用的是Person的虚函数。指向派生类对象Student场景ptr指向Student对象。结果通过ptr中的vptr找到Student的虚表调用的是Student的虚函数。4.2.2 动态绑定与静态绑定1. 静态绑定Static Binding定义也称为编译时绑定。是指在程序编译期间就已经确定了函数调用的具体地址。触发条件针对不满足多态条件的函数调用。例如普通函数调用、函数重载、指针/引用调用非虚函数等。机制编译器在编译阶段直接确定调用函数的地址生成代码时直接跳转到该地址。2. 动态绑定Dynamic Binding定义也称为运行时绑定。是指在程序运行期间根据对象的实际类型来确定调用哪个函数。触发条件针对满足多态条件的函数调用。必须同时满足通过指针或者引用调用。调用的是虚函数。机制在运行时通过对象内部的vptr找到其对应的虚函数表并在表中查找并定位到具体调用函数的地址。虚函数表vtable详解1. 虚函数表的基本归属基类基类对象的虚函数表中存放基类所有虚函数的地址。独立性同类型的对象共用同一张虚表不同类型的对象各自有独立的虚表。因此基类和派生类拥有各自独立的虚表。2. 派生类与虚表指针vptr构成派生类由“继承下来的基类部分”和“自己的成员部分”构成。vptr 的继承一般情况下派生类会继承基类中的虚函数表指针自己不会再生成新的虚表指针。独立性注意虽然继承了 vptr但派生类对象中继承下来的这个 vptr与独立基类对象的 vptr 是相互独立的就像派生类中的基类成员变量与独立的基类对象也是独立的一样。3. 虚函数的覆盖与组成覆盖机制如果派生类重写了基类的虚函数派生类虚函数表中对应的条目就会被覆盖更新为派生类重写后的虚函数地址。虚表内容组成派生类的虚函数表中通常包含三部分基类的虚函数地址未被重写的。派生类重写的虚函数地址完成覆盖。派生类自己新增的虚函数地址。4. 虚函数表的底层结构本质虚函数表本质上是一个存放虚函数指针的指针数组。结束标记VS 编译器通常会在数组最后放一个0x00000000作为结束标记。GCC 编译器通常不会放这个标记。注C 标准并未规定必须有结束标记这是编译器厂商的自行定义。5. 内存存储位置虚函数代码虚函数和普通函数一样编译后是一段指令存放在代码段。虚表中存的只是它的地址。虚函数表数据C 标准并没有严格规定虚表存放在哪里。VS 编译器下通常存放在代码段常量区。注不同编译器实现可能不同需具体验证。

更多文章