【C++】多态到底难在哪?虚函数表 + 动态绑定,一篇吃透底层逻辑!
【C++】多态到底难在哪?虚函数表 + 动态绑定,一篇吃透底层逻辑!
摘要
本文深入解析 C++ 多态的底层逻辑,聚焦运行时多态,从概念、实现条件、虚函数机制、虚表原理到抽象类、菱形继承等场景,结合代码与内存图解,全面梳理多态的核心知识与面试考点。
目录
一、多态的概念
多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运⾏时多态(动态多态)。编译时多态(静态多态)主要就是我们前⾯讲的函数重载和函数模板,他们传不同类型的参数就可以调⽤不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的。运⾏时多态,具体点就是去完成某个⾏为(函数),可以传不同的对象就会完成不同的⾏为,就达到多种形态。⽐如买票这个⾏为,当普通⼈买票时,是全价买票;学⽣买票时,是优惠买票(5折或75折);军⼈买票时是优先买票。再⽐如,同样是动物叫的⼀个⾏为(函数),传猫对象过去,就是”(>ω<)喵“,传狗对象过去,就是"汪汪"。
多态通俗来讲就是面对同一个任务,不同的对象去完成会有不同的状态。就排队这个任务来说,军人有军人优先通道,残疾人士有残疾人专属通道,普通人有普通人通道等等…

这篇文章重点讲解运行时多态。
二、多态的定义和实现
1. 多态的构成必要条件
多态是在不同继承关系的类对象,去调用同一函数产生了不同行为。例如以买票为例,Student对象继承了Person,Student对象买票半价,Person对象买票全价。必须通过基类的指针或引用进行调用虚函数.被调用的函数必须是基函数,且派生类必须对基类的虚函数进行了重写。

//多态#include<iostream>usingnamespace std;classPerson//基类{public:virtualvoidBuyTicket(){ cout <<"买票全价"<< endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){ cout <<"买票半价"<< endl;}};voidFunc(Person& ps){ ps.BuyTicket();}voidtest(){ Person dh;Func(dh); Student Daitou;Func(Daitou);}intmain(){test();return0;}2. 虚函数(virtual)
类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意:非成员函数不能加virtual修饰。虚函数这里的virtual和虚继承中的virtual是同一个关键字,但是它们之间没有任何关系。虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。2.1 虚函数的重写 / 覆盖
C++ 的运行时多态,在原理层面上被称为“覆盖”(Overriding)。它的核心机制可以理解为:派生类继承了父类定义的虚函数的接口或声明,但重写了函数的具体的实现。
- 如果用“标准电源插座”来比喻:基类中的虚函数就像是插座的“标准孔洞形状”,它定义了一个统一的接入规范(继承接口)。而不同的派生类,比如表示“学生电费”的子类,虽然保留了相同的孔洞形状,但它们重写了插座后面实际连接的“电源系统”(覆盖实现)。在实际调用时,程序就像拿着一个标准插头去插电一样,它只管调用这个统一的接口,至于最终获得的是“全价电”还是“半价电”,则是由程序在运行时根据当前插座(对象)的真实类型来决定的。这就是多态的精髓:“同一个动作,不同的表现”。
派⽣类中有⼀个跟基类完全相同的虚函数(即派⽣类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派⽣类的虚函数重写了基类的虚函数。注意: 在重写基类虚函数时,派⽣类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使⽤,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
2.2 重写 / 覆盖 的例外(协变)
协变是对派生类重写基类的虚函数中必要条件之一 返回值类型相同的唯一松绑;它允许派生类的虚函数返回一个更具体(更窄)的类型。
协变的规则:返回类型必须是类或结构体的指针 (*) 或引用 (&)。(不能是基本类型如 int, double 或值类型)派生类虚函数的返回类型必须是基类虚函数返回类型的 派生类型。
假如我们是一家大型产品制造集团的总裁,我们的集团总部(基类 FactoryBase)统一规定所有工厂的 createProduct() 方法必须承诺返回一个通用的 【基础产品】(ProductBase*),集团下有一个专业的子工厂(派生类 SpecificFactory),它在实现时,可以行使协变特权,返回一个更精确 的 【特定产品】 (SpecificProduct*)。
这种做法是安全的,因为 【特定产品】本质上也是【基础产品】的一种,调用者(程序)通过基类指针接收时可以安全地向上转型进行处理,同时又为后续需要特定功能的代码提供了便利,从而在保持多态统一接口的前提下实现了精准的出货(类型)。
// --- 1. 产品层次结构 ---classProductBase{public:virtualvoidinfo(){ cout <<" -> 通用产品"<< endl;}};classSpecificProduct:publicProductBase{public:// 可选:重写以展示差异voidinfo(){ cout <<" -> 特定产品"<< endl;}};// --- 2. 工厂层次结构(协变发生在这里)---classFactoryBase{public:// 【基类承诺】 返回 ProductBase*virtual ProductBase*createProduct()const{ cout <<"工厂基类生产:";returnnewProductBase();}// 必须有虚析构函数,保证安全,但为了简化,可以省略(实际项目不能省!)virtual~FactoryBase(){}};classSpecificFactory:publicFactoryBase{public:// 【协变】// 派生类重写,返回 ProductBase 的派生类型 SpecificProduct* SpecificProduct*createProduct()const{ cout <<"特定工厂生产:";returnnewSpecificProduct();}};voidtestCovariance(){// 使用基类指针指向派生类工厂 FactoryBase* factory =newSpecificFactory();// 多态调用:// 尽管 factory 是基类指针,但它调用了 SpecificFactory 的 createProduct() ProductBase* product = factory->createProduct();// 验证实际类型 product->info();// 释放内存delete product;delete factory;}intmain(){testCovariance();return0;}
不使用协变的代码在功能上是完全正确的,它实现了多态。协变只是一个“语法糖”,让派生类能返回更精确的类型,以提高代码的清晰度和类型安全性。
2.3 重写析构函数的重要性
当我们在多态场景下,使用基类指针(如Person*)指向一个派生类对象(如new Student)时,我们期望在调用delete时,系统能根据指针实际指向的类型来调用正确的析构函数。如果基类的析构函数不是虚函数,delete过程就会执行静态绑定,只根据指针的静态类型(Person*)调用~Person(),从而导致子类的析构函数(~Student())被跳过,造成子类资源的内存泄漏。因此,我们必须将基类的析构函数声明为virtual,强制启用动态绑定,确保在运行时能正确地从派生类(~Student())开始,完整地执行整个析构链,从而安全地清理所有资源。


当我们在多态场景下使用虚析构函数时,~Person() 析构函数会被调用两次,这是因为程序销毁了两个不同的对象。第一次调用发生在执行 delete st; 时:由于 st 指向的是 Student 对象,多态机制首先调用 ~Student() 清理派生类成员,然后编译器会自动、隐式地调用一次基类析构函数 ~Person(),完成对 Student 对象中继承自 Person 部分的清理。第二次调用则完全独立,它发生在 delete ps; 时,是为了销毁 ps 指向的那个纯粹的 Person 对象本身,从而确保了所有动态分配的内存都得到了正确的释放。
2.4 析构函数重写成虚函数的原理
C++ 析构函数(Destructor)的重写机制是一个特殊的规则,它看似违反了虚函数重写中“函数名称必须相同”的必要条件,但实际上,这是语言标准为了保证多态的完整性而设立的例外。
其原理在于:语言的特殊约定: C++ 语言明确规定,如果基类(Person)的析构函数被声明为virtual,那么所有派生类(Student)的析构函数将自动被视为虚函数,并自动重写基类的虚析构函数,即便它们的符号名必须遵循各自的类命名规则(即~Person和~Student)。底层机制的统一: 在编译器的底层实现中,为了支持运行时多态,每个包含虚函数的类都会维护一个虚函数表(VTable)。编译器在 VTable 中为析构函数预留了一个固定的、特殊的槽位。无论类名是什么,派生类都会使用自己的析构函数地址来填充基类 VTable 中对应的这个统一槽位。动态绑定实现: 当通过基类指针调用delete时,程序依赖于对象的虚指针(vptr)查找到 VTable 中这个固定的析构函数槽位,从而获得实际对象类型的析构函数地址。这确保了在运行时能够正确地调用派生类的析构函数,保证了完整的、从派生类到基类的链式清理,是解决多态场景下内存泄漏问题的核心机制。
因此,析构函数名称的不同是 C++ 语法要求,而其重写是 C++ 运行时多态机制强制实现的语言特性。
2.5 C++11 的 override 和 final
override 用于检查派生类虚函数是否重写了基类的某个虚函数,如果没有则报错注意:override要放在被检查的派生类的虚函数的声明的最后
// error C3668: “Benz::Drive”: 包含重写说明符“override”的⽅法没有重写任何基类⽅法//函数名的不同classCar{public:virtualvoidDirve()//D i r v e{}};classBenz:publicCar{public:virtualvoidDrive() override { cout <<"Benz-舒适"<< endl;}//D r i v e};intmain(){return0;}final用于修饰虚函数,表明这个虚函数不能被重写注意:final同时还可以修饰类,说明这个类不能被继承
//error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写classCar{public:virtualvoidDrive() final {}};classBenz:publicCar{public:virtualvoidDrive(){ cout <<"Benz-舒适"<< endl;}};intmain(){return0;}3. 重载 / 重写 / 隐藏的对比

// 1. 重载版本 A (无参数)voideat()const{ cout <<"Animal: 默认进食方式。"<< endl;}// 2. 重载版本 B (带参数:食物)voideat(const string& food)const{ cout <<"Animal: 正在吃 "<< food << endl;}// 3. 重载版本 C (带参数:数量)voideat(int amount)const{ cout <<"Animal: 吃了 "<< amount <<" 份食物。"<< endl;}};intmain(){ Animal a; a.eat();// 调用版本 A a.eat("肉");// 调用版本 B a.eat(3);// 调用版本 Creturn0;}
classAnimal{public:// 1. 基类虚函数 (必须是虚函数)virtualvoidmove()const{ cout <<"Animal: 四肢移动。"<< endl;}};classBird:publicAnimal{public:// 2. 派生类重写 (签名必须一致)voidmove()const override {// 使用 override 确保是重写 cout <<"Bird: 飞行移动。"<< endl;}};intmain(){ Animal* pAnimal =newBird();// 基类指针指向派生类对象// 多态:通过基类指针调用虚函数 pAnimal->move();// 运行时调用 Bird::move()delete pAnimal;return0;}
classAnimal{public:// 1. 被隐藏的基类函数 A (无参数)voidsleep()const{ cout <<"Animal: 睡 8 小时。"<< endl;}// 2. 被隐藏的基类函数 B (带参数)voidsleep(int hours)const{ cout <<"Animal: 睡 "<< hours <<" 小时。"<< endl;}};classDog:publicAnimal{public:// 3. 隐藏者:函数名相同,但参数列表不同!voidsleep(const string& position)const{ cout <<"Dog: 趴着睡。"<< endl;}};intmain(){ Dog d;// d.sleep(); // 编译错误!基类的无参版本被隐藏了。// d.sleep(5); // 编译错误!基类的带参版本也被隐藏了。// 只能调用自己的版本: d.sleep("窝里");// 输出: Dog: 趴着睡。// 必须使用作用域解析符来调用被隐藏的基类函数: d.Animal::sleep();// 输出: Animal: 睡 8 小时。}
三、抽象类
1. 抽象类
在虚函数的后⾯写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派⽣类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派⽣类继承后不重写纯虚函数,那么派⽣类也是抽象类。纯虚函数某种程度上强制了派⽣类重写虚函数,因为不重写实例化不出对象。
// --- 抽象类(只定义接口)---classBaseInterface{public:// 纯虚函数:强制派生类必须实现此功能virtualvoidmustImplement()const=0;// 必须要有虚析构函数virtual~BaseInterface(){}};// --- 派生类(提供实现)---classConcreteDerived:publicBaseInterface{public:// 实现了纯虚函数,因此可以被实例化voidmustImplement()const override { cout <<"实现完成!"<< endl;}};intmain(){// BaseInterface bi; // 编译错误:抽象类不能实例化// 必须通过指针或引用使用,实现多态 BaseInterface* obj =newConcreteDerived(); obj->mustImplement();delete obj;return0;}
1.1 接口继承和实现继承
普通函数继承本质上是一种实现继承,它关注的是代码的复用:派生类直接继承并使用基类提供的具体函数实现。与此相对,虚函数继承本质上是一种接口继承,它关注的是规范:派生类继承了基类函数的接口签名,目的是重写这个实现,从而在运行时能够根据对象的实际类型调用正确的版本,实现多态性。因此,遵循最小开销原则,如果一个函数不打算在继承体系中被多态调用,就不应该将其定义为虚函数,以避免引入不必要的运行时开销和内存负担。
四、多态的原理(X86环境下进行讲解)
1.虚表
普通的函数调用他不符合多态,编译器在编译的时候就以及确定了要确定函数的地址(静态绑定);而多态依赖于虚函数和动态绑定,函数地址的确定被推迟到程序运行的阶段。在x86环境下指针的大小为四字节,并且在虚函数表的最后放一个nullptr的指针,作为vs2022打印虚表的依据

我们发现a中有一个_vfptr和一个_a,_vfptr是一个一级指针,它指向了虚函数表的地址(虚函数表的本质是一个数组,存放着虚函数的指针)。

当我们新增了两个函数我们发现对象a中还是一个指针_vfptr和一个变量_a,但是指针中存放了两个虚函数的指针(因为func3()并不是虚函数所以没用被放入_vfptr中)_vfptr是虚函数表指针,在x86环境下是4个字节,在x64环境下是8个字节。
1.1 单继承中的虚表
classA{public:virtualvoidfunc(){ cout <<"func()"<< endl;}virtualvoidfunc1(){ cout <<"func1()"<< endl;}voidfunc2(){ cout <<"func2()"<< endl;}private:int _a;};classB:publicA{public:virtualvoidfunc(){ cout <<"Bfunc()"<< endl;}private:int _b;};intmain(){ A a; B b;return0;}

派生类虚表的生成规则:将基类的虚函数拷贝一份到派生类的虚表里面。如果派生类对基类的虚函数进行了重写,则派生类重写的虚函数覆盖到派生类虚表上的对应基类的虚函数上。派生类新增的虚函数,按照派生类声明的次序一次增加到派生类继承的第一个虚表后。
1.2 虚函数 和 虚表 的储存位置

classDaitou{public:virtualvoidfunc(){ cout <<"wobushidaitou!";}};intmain(){int a =0; cout <<"a是普通变量,存储在栈上"<< endl; cout <<"栈的地址为:"<<&a << endl;int* ptr =newint; cout <<"ptr是动态开辟的指针,存储在堆上"<< endl; cout <<"堆的地址为:"<< ptr << endl;staticint b =0; cout <<"b是静态变量,存储在数据段(静态区)"<< endl; cout <<"数据段的地址为:"<<&b << endl;constchar* str ="daitou"; cout <<"str是常量字符串,常量区"<< endl; cout <<"代码段的地址为:"<<(void*)str << endl; Daitou d; cout <<"a是普通变量,存储在栈上"<< endl; cout <<"虚表的地址为:"<<(void*)*((int*)&d)<< endl;return0;}str字符串指向的首个字符的地址,但是使用cout的形式打印会被识别为字符串打印,使用void*强制转换指针类型就可以打印出常量字符串的地址。对象d的首4字节存储虚表指针(x86环境下,指针占4字节,与int大小一致)&d取对象首地址,强转为int*,可精准访问前4字节(虚表指针所在区域)解引用int*得到虚表指针的值(此时为int类型)强转为void*,以地址格式(十六进制)打印,即得到虚表的首地址

虚表很有可能储存在代码段。
2. 原理讲解
2.1 反汇编观察
classPerson{public:virtualvoidBuyTicket(){ cout <<"买票全价"<< endl;}};classStudent:publicPerson{public:virtualvoidBuyTicket(){ cout <<"买票半价"<< endl;}};voidfunc(Person& ps){ ps.BuyTicket();}intmain(){ Person ps; Student st;func(ps);func(st);return0;}
在反汇编中,call和eax是 x86 汇编中非常基础且关键的指令/寄存器,结合虚函数表场景,具体含义如下:call的作用是跳转到指定地址执行函数,同时会自动将当前指令的下一条地址(“返回地址”)压入栈中,方便函数执行完后能回到原位置继续运行。eax是 x86 架构中的一个 32位通用寄存器,用途广泛:存储函数返回值:C++ 中函数的返回值(如int、指针等)通常会放在eax中,调用者通过读取eax获取结果。临时数据传递:在内存/指针操作中,常用来暂存地址或数据。
这里:
eax临时存储对象地址(虚函数指针),用于后续取虚表指针;call负责跳转到虚表中存储的函数地址,完成动态绑定调用。
2.2 使用基类的指针或引用调用的原因
赋值兼容规则:派生类对象的指针或引用可以直接赋值给基类的指针或引用。这相当于让一个“基类视角”的指针,去指向一个完整的派生类对象。指针的两种指向:因此,一个基类指针或引用可能指向两种对象:一个基类对象一个派生类对象虚函数表 (vtable) 的作用:如果基类有虚函数,那么每个对象内部都有一个指向虚函数表的指针。基类对象的虚表中,存放的是基类虚函数的地址。派生类对象的虚表中,如果派生类重写了虚函数,那么对应位置存放的就是重写后的派生类虚函数的地址。多态的实现:当通过基类指针调用虚函数时,程序会根据指针实际指向的对象类型,去找到该对象的虚表。如果指向基类对象,就查基类的虚表,调用基类的虚函数。如果指向派生类对象,就查派生类的虚表,调用派生类重写的虚函数。同时派生类指针只能指向派生类的对象,不能指向基类的对象,所以无法通过派生类指针或者引用来同时处理基类和派生类对象,缺少了“一个接口,多种形态”的前提条件。所以不能使用派生类的指针或者引用进行调用。当派生类对象复制给基类对象的时候,会发生对象切片——只复制基类部分,虚表指针不会被覆盖,基类对象的虚表是始终指向基类的虚函数表,即使派生类重写了虚函数,基类对象的虚表中仍然是基类函数的地址,所以不能使用基类函数的对象进行调用。
#include<iostream>usingnamespace std;classBase{public:virtualvoidshow(){ cout <<"Base::show()"<< endl;}int base_data =10;};classDerived:publicBase{public:virtualvoidshow() override { cout <<"Derived::show()"<< endl;}int derived_data =20;};intmain(){ Derived derived_obj;// 情况1:基类指针指向派生类对象 - 多态正常工作 Base* base_ptr =&derived_obj; cout <<"基类指针调用: "; base_ptr->show();// 输出: Derived::show()// 情况2:基类引用指向派生类对象 - 多态正常工作 Base& base_ref = derived_obj; cout <<"基类引用调用: "; base_ref.show();// 输出: Derived::show()// 情况3:对象切片 - 多态失效! Base base_obj = derived_obj;// 对象切片发生 cout <<"基类对象调用: "; base_obj.show();// 输出: Base::show() ← 不是我们期望的派生类版本!// 验证切片效果 cout <<"base_data: "<< base_obj.base_data << endl;// 正常复制// cout << base_obj.derived_data << endl; // 错误!派生类数据被切掉了return0;}在类中同类型的对象共用一张虚表的时候,他们的虚表指针,虚表内容完全相同。
3. 单继承 和 多继承 关系的虚表函数
3.1 单继承中的虚表函数
//基类classBase{public:virtualvoidfunc1(){ cout <<"Base::func1()"<< endl;}virtualvoidfunc2(){ cout <<"Base::func2()"<< endl;}private:int _a;};//派生类classDerive:publicBase{public:virtualvoidfunc1(){ cout <<"Derive::func1()"<< endl;}virtualvoidfunc3(){ cout <<"Derive::func3()"<< endl;}virtualvoidfunc4(){ cout <<"Derive::func4()"<< endl;}private:int _b;};


我们可以通过查看一下派生类虚表指针对应的虚表的内存窗口的情况

想要手动通过代码查看的话
typedefvoid(*VFPTR)();//虚函数指针类型重命名//打印虚表地址及其内容voidPrintVFT(VFPTR* ptr){printf("虚表地址:%p\n", ptr);for(int i =0; ptr[i]!=nullptr; i++){printf("ptr[%d]:%p-->", i, ptr[i]);//打印虚表当中的虚函数地址 ptr[i]();//使用虚函数地址调用虚函数}printf("\n");}intmain(){ Base b;PrintVFT((VFPTR*)(*(int*)&b));//打印基类对象b的虚表地址及其内容 Derive d;PrintVFT((VFPTR*)(*(int*)&d));//打印派生类对象d的虚表地址及其内容return0;}3.2 多继承中的虚表函数
//基类1classBase1{public:virtualvoidfunc1(){ cout <<"Base1::func1()"<< endl;}virtualvoidfunc2(){ cout <<"Base1::func2()"<< endl;}private:int _b1;};//基类2classBase2{public:virtualvoidfunc1(){ cout <<"Base2::func1()"<< endl;}virtualvoidfunc2(){ cout <<"Base2::func2()"<< endl;}private:int _b2;};//多继承派生类classDerive:publicBase1,publicBase2{public:virtualvoidfunc1(){ cout <<"Derive::func1()"<< endl;}virtualvoidfunc3(){ cout <<"Derive::func3()"<< endl;}private:int _d1;};

使用代码查看
typedefvoid(*VFPTR)();//虚函数指针类型重命名//打印虚表地址及其内容voidPrintVFT(VFPTR* ptr){printf("虚表地址:%p\n", ptr);for(int i =0; ptr[i]!=nullptr; i++){printf("ptr[%d]:%p-->", i, ptr[i]);//打印虚表当中的虚函数地址 ptr[i]();//使用虚函数地址调用虚函数}printf("\n");}intmain(){ Base1 b1; Base2 b2;PrintVFT((VFPTR*)(*(int*)&b1));//打印基类对象b1的虚表地址及其内容PrintVFT((VFPTR*)(*(int*)&b2));//打印基类对象b2的虚表地址及其内容 Derive d;PrintVFT((VFPTR*)(*(int*)&d));//打印派生类对象d的第一个虚表地址及其内容PrintVFT((VFPTR*)(*(int*)((char*)&d +sizeof(Base1))));//打印派生类对象d的第二个虚表地址及其内容return0;}- 分别继承各个基类的虚表内容到派生类的各个虚表当中。
- 对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖),比如func1。
在派生类第一个继承基类部分的虚表当中新增派生类当中新的虚函数地址,比如func3。

这里我们可以发现在多继承的虚函数重写后,它们在对应的两张虚表中的地址不同,这是为什么?
3.3 多继承中虚函数的重写在对应虚函数表地址不同的原因
Derive对象d的内存布局是:[Base1子对象(vptr1 + _b1)][Base2子对象(vptr2 + _b2)][_d1]。当用Base1& a = d(a是Base1类型引用)时:a指向d的开头(即Base1子对象的开头),vptr1(Base1的虚表指针)中func1的地址是Derive::func1的真实地址。调用a.func1()时,this指针直接指向d的开头(和Base1子对象地址一致),无需调整,直接调用Derive::func1,正确访问d的成员。当用Base2& b = d(b是Base2类型引用)时:b指向d中Base2子对象的开头(比d的整体地址偏移sizeof(Base1)(8字节)),vptr2(Base2的虚表指针)中func1的地址是编译器特殊处理后的“跳板地址”。调用b.func1()时,this原本指向Base2子对象的开头(非d的开头),但通过“跳板地址”的逻辑,this会被减去Base1的大小(8字节),调整到d的开头,最终正确调用Derive::func1,保证访问d成员时地址正确。
这就是多继承中,Derive::func1在Base1和Base2的虚表中地址不同的原因——Base2的虚表需要通过“跳板”调整this指针,而Base1的虚表无需调整,直接使用真实地址。
五、关于多态考察题目
1. 选择题
- 下面哪种面向对象的方法可以让你变得富有()
A.继承 B.封装 C.多态 D.抽象 - ()是面向对象程序设计语言中的一种机制,这种机制实现了方法的定义与具体的对象无关,而方法的调用则可以关联于具体的对象。
A.继承 B.模板 C.对象的自身引用 D.动态绑定 - 关于面向对象设计中的继承和组合,下面说法错误的是()
A.继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为白盒复用。
B.组合的对象不需要关系各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也称为黑盒复用。
C.优先使用继承,而不是组合,是面向对象设计的第二原则。
D.继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现。 - 以下关于纯虚函数的说法,正确的是()
A.声明纯虚函数的类不能实例化对象
B.声明纯虚函数的类是虚基类
C.子类必须实现基类的纯虚函数
D.纯虚函数必须是空函数 - 关于虚函数的描述正确的是()
A.派生类的虚函数与基类的虚函数具有不同的参数个数和类型
B.内联函数不能是虚函数
C.派生类必须重新定义基类的虚函数
D.虚函数可以是一个static型的函数 - 关于虚表的说法正确的是()
A.一个类只能有一张虚表
B.基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
C.虚表是在运行期间动态生成的
D.一个类的不同对象共享该类的虚表 - 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则()
A.A类对象的前4个字节存储虚表地址,B类对象的前4个字节不是虚表地址
B.A类对象和B类对象前4个字节存储的都是虚基表的地址
C.A类对象和B类对象前4个字节存储的虚表地址相同
D.A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表 - 下面程序输出结果是什么?
#include<iostream>usingnamespace std;classA{public:A(char* s){ cout << s << endl;}~A(){};};classB:virtualpublicA{public:B(char* s1,char* s2):A(s1){ cout << s2 << endl;}};classC:virtualpublicA{public:C(char* s1,char* s2):A(s1){ cout << s2 << endl;}};classD:publicB,publicC{public:D(char* s1,char* s2,char* s3,char* s4):B(s1, s2),C(s1, s3),A(s1){ cout << s4 << endl;}};intmain(){ D* p =newD("class A","class B","class C","class D");delete p;return0;}A.class A class B class C class D
B.class D class B class C class A
C.class D class C class B class A
D.class A class C class C class D
- 下面说法正确的是?(多继承中指针的偏移问题)
classBase1{public:int _b1;};classBase2{public:int _b2;};classDerive:publicBase1,publicBase2{public:int _d;};intmain(){ Derive d; Base1* p1 =&d; Base2* p2 =&d; Derive* p3 =&d;return0;}A.p1 == p2 == p3
B.p1 < p2 < p3
C.p1 == p3 != p2
D.p1 != p2 != p3
- 以下程序输出结果是什么?
#include<iostream>usingnamespace std;classA{public:virtualvoidfunc(int val =1){ cout <<"A->"<< val << endl;}virtualvoidtest(){func();}};classB:publicA{public:voidfunc(int val =0){ cout <<"B->"<< val << endl;}};intmain(){ B* p =new B; p->test();return0;}A.A->0 B.B->1 C.A->1 D.B->0
E.编译错误 F.以上都不正确
参考答案
| 题号 | 答案 | 题号 | 答案 |
|---|---|---|---|
| 1 | A | 6 | D |
| 2 | D | 7 | D |
| 3 | C | 8 | A |
| 4 | A | 9 | C |
| 5 | B | 10 | B |
2. 问答题
- 什么是多态?
多态指“同一行为在不同对象上有不同表现”,分为两类:
- 静态多态:编译时确定调用地址(如函数重载)。
- 动态多态:运行时根据实际对象类型调用函数(需满足:虚函数重写 + 基类指针/引用调用)。
- 重载、重写(覆盖)、重定义(隐藏)的区别
| 特性 | 重载(Overload) | 重写(覆盖,Override) | 重定义(隐藏,Hide) |
|---|---|---|---|
| 作用域 | 同一作用域(如同一类) | 不同作用域(基类+派生类) | 不同作用域(基类+派生类) |
| 核心条件 | 函数名相同,参数列表不同(个数/类型/顺序) | 虚函数 + 函数名、参数、返回值(协变除外)完全相同 | 函数名相同(无其他限制) |
- 多态实现的原理
- 静态多态(函数重载):依赖函数名修饰规则,编译器根据参数匹配不同函数。
- 动态多态(虚函数):依赖虚函数表(vtable)。基类虚函数地址存于虚表,派生类重写时会覆盖虚表中对应地址;运行时通过基类指针/引用指向的对象虚表,找到实际函数地址调用。
- 特殊函数与虚函数的关系
- inline函数可以是虚函数吗?
可以,但编译器会忽略inline属性(虚函数需入虚表,而inline函数无地址)。 - 静态成员可以是虚函数吗?
不可以。静态成员无this指针,而虚函数调用需通过this访问虚表。 - 构造函数可以是虚函数吗?
不可以。虚表指针在构造时初始化,构造函数调用时对象尚未完全创建,无虚表指针。 - 析构函数可以是虚函数吗?
可以。当通过基类指针delete派生类对象时,需虚析构保证派生类析构被调用(如A* a = new B; delete a;场景)。
- 对象访问函数的效率对比
- 若为普通对象(非基类指针/引用):普通函数与虚函数效率一致(编译器直接调用地址)。
- 若为基类指针/引用:普通函数更快(虚函数需运行时查虚表)。
- 虚函数表的生成与存储
- 生成阶段:编译期(编译时已确定虚函数地址,直接生成虚表)。
- 存储位置:代码段(常量区)。
- 菱形继承与虚继承
- 菱形继承问题:数据冗余(同一基类成员在派生类中多次存储)、二义性。
- 虚继承原理:将公共基类成员存为一份“公共成员”,基类原位置存虚基表指针,指向虚基表(存储公共成员的地址偏移量),从而解决冗余和二义性。
- 抽象类
- 定义:包含纯虚函数(
virtual void func() = 0;)的类。 - 作用:强制派生类重写虚函数,体现接口继承关系(抽象类无法实例化)。
总结
C++ 多态是面向对象的核心特性,静态多态依赖编译期函数重载,动态多态由虚函数表与动态绑定实现。虚函数重写、基类指针 / 引用调用是动态多态的关键条件,虚表在编译期生成并存储于代码段,抽象类强制接口继承。理解这些机制,能帮助我们在继承体系中高效实现 “同一接口,不同行为” 的设计,同时规避内存泄漏、二义性等问题,是掌握 C++ 面向对象编程的关键。
✨ 坚持用清晰易懂的图解+代码语言, 让每个知识点都简单直观!
🚀 个人主页 :不呆头 · ZEEKLOG
🌱 代码仓库 :不呆头 · Gitee
📌 专栏系列 :📖 《C语言》🧩 《数据结构》💡 《C++》🐧 《Linux》💬 座右铭 :“不患无位,患所以立。”