C++之多态篇
(一) 多态的概念和定义
1.1 概念
通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。如:买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
1.2 定义
对于具有继承关系的不同类对象,当调用同一函数时,会因对象的实际类型不同,产生不同的行为,这一特性称为多态。
多态构成的条件(缺一不可):
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数
虚函数是构成多态的核心条件,也是面向对象编程中的关键知识点,具体定义与注意事项如下:
概念:即被virtual修饰的类成员函数称为虚函数。
注意:
- 虚函数若在类内声明、类外定义,virtual关键字仅能在类内声明时添加,类外实现时不可加(例:class A { virtual void func(); }; void A::func() {},此处类外实现无需加virtual);
- 友元函数不属于类的成员函数,因此不能被定义为虚函数;
- 静态成员函数属于整个类(与具体对象无关),且不属于成员函数,因此不能被定义为虚函数。
虚函数的重写(覆盖)
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
例子:
class Person
{
public:virtual void BuyTicket() { cout << "买票—全价" << endl; }
};class Student :public Person
{
public:virtual void BuyTicket() { cout << "买票—半价" << endl; }//void BuyTicket() { cout << "买票—半价" << endl; }
};
小思考:
void BuyTicket()不加virtual可以吗?
可以,但不推荐。
原因:派生类会继承基类的虚函数,且该函数在派生类中仍保持 “虚函数属性”—— 即使派生类重写时不加virtual
,也能满足重写的条件。但这种写法会降低代码可读性(无法直观判断该函数是重写的虚函数),不符合编码规范,因此不建议采用。
虚函数重写的两个例外:
1. 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,允许返回值类型与基类虚函数不同,但需满足:
- 基类虚函数返回基类对象的指针或引用;
- 派生类虚函数返回派生类对象的指针或引用(派生类需与基类有继承关系)。
这种特殊的重写场景,称为 “协变”。
例子:
class A{};class B:public A{};class Person { public:virtual A* f() { return new A; } };class student :public Person { public:virtual B* f() { return new B; } };
2.析构函数的重写(基类与派生类析构函数的名字不同)
若基类的析构函数为虚函数,那么派生类只要定义了析构函数(无论是否加virtual关键字),都视为对基类析构函数的重写。
尽管基类与派生类析构函数的显式名称不同(如基类为~Person(),派生类为~Student()),看似违背重写的 “函数名一致” 规则,但实际上编译器会对析构函数名称做特殊处理:编译后所有析构函数的内部名称会统一为destructor,以此满足重写的语法要求。
例子:
class Person { public:virtual ~Person() { cout << "~Person()" << endl; } };class Student :public Person { public:virtual ~Student() { cout << "~Student()" << endl; } };
小思考:
为什么要“编译后析构函数的名称统一处理成destructor”这样处理?为何要构成重写?
要“编译后析构函数的名称统一处理成destructor”这样处理:
重写的语法前提是 “函数名一致”,但代码中析构函数的显式名称是 “~类名”(如~Person()、~Student())—— 若编译器不统一名称,基类与派生类的析构函数无法满足 “函数名一致”,自然无法构成重写。
因此,编译器将所有析构函数的内部名称统一为destructor,本质是为了让析构函数适配重写规则,为后续的多态调用铺路。
为何要构成重写:
delete p;//等同于p->destructor(); operator delete(p);若不构成重写可能是普通对象,调用时会看当前类型,可能回出现内存泄漏等错误,故我们更期待将其整成一个多态调用,摄影这样处理了,
重载、覆盖(重写)、隐藏(重定义)的对比
这个是一个很重要的对比,大家一定要熟悉
对比维度 重载(Overload) 覆盖(重写,Override) 隐藏(重定义,Redefine) 作用域 同一作用域(如同一类内) 不同作用域(基类 vs 派生类) 不同作用域(基类 vs 派生类) 函数名 必须相同 必须相同(协变除外,本质逻辑同) 必须相同 参数列表 必须不同(个数 / 类型 / 顺序) 必须完全相同 可相同、可不同 返回值类型 无强制要求(可同可不同) 必须相同(协变:基类指针 / 引用→派生类指针 / 引用,为特例) 无强制要求(可同可不同) 虚函数要求 无需(与虚函数无关) 必须(基类、派生类函数均为虚函数) 无需(与虚函数无关) 核心判定逻辑 同一域内 “同名不同参” 跨域 “同名 + 同参 + 同返回 + 双虚函数” 跨域 “同名” 且不满足覆盖条件 记忆口诀:
- 重载:同一类、名相同、参不同,不管返回和虚函;
- 覆盖:基派类、名参返全同,双虚函数是前提;
- 隐藏:基派类、名相同,不满足覆盖就归它。
(二) C++11 final 和 override
在 C++ 虚函数重写中,需严格满足 “返回值类型、函数名、参数列表完全一致” 的条件,但实际开发中,若因疏忽出现函数名拼写错误、参数类型不匹配等问题,会导致 “未成功重写却编译不报错” 的情况 —— 这类错误仅在程序运行时才会暴露(如行为不符合预期),排查成本极高。
为此,C++11 引入 final 和 override 两个关键字,通过编译期强制检测,提前规避此类问题,提升代码的健壮性。
final(限制重写与继承)
核心作用是 “禁止后续的继承或重写”,具体分为两种使用场景:修饰虚函数、修饰类。
1. 修饰虚函数 —— 禁止该虚函数被派生类重写
当 final 修饰基类的虚函数时,意味着该函数是 “最终版本”,任何派生类都不能重写它;若派生类强行重写,编译器会直接报错。
例子:
class Person
{
public:virtual void BuyTicket() final { cout << "买票—全价" << endl; }
};class Student :public Person
{
public:virtual void BuyTicket() { cout << "买票—半价" << endl; }//报错
};
2. 修饰类 —— 禁止该类被继承
当 final 修饰整个类时,该类成为 “最终类”,不允许任何其他类继承它;若强行继承,编译器会报错。
例子:
class Person final
{
public:virtual void BuyTicket() { cout << "买票—全价" << endl; }
};class Student :public Person //报错
{
public:virtual void BuyTicket() { cout << "买票—半价" << endl; }
};
小思考:
如何设计一个不能被继承的类?
1. 基类构造函数私有化
- 原理:派生类在实例化时,必须调用基类的构造函数(无论显式 / 隐式);若基类的构造函数被声明为 private(私有),派生类无法访问基类构造函数,导致派生类无法实例化,间接实现 “不能被继承”。
2. 用 final 直接修饰类
- 原理:final 是 C++11 专门为 “禁止类继承” 设计的关键字,直接修饰类即可强制禁止继承,语法简洁且语义明确,无需依赖构造函数访问权限。
override(校验虚函数重写)
核心作用是 “强制校验派生类虚函数的重写有效性”:仅能修饰派生类的虚函数,编译器会自动检查该函数是否确实重写了基类中对应的虚函数(即函数名、参数列表、返回值类型是否完全匹配,协变场景除外);若未匹配(如拼写错误、参数不匹配),则直接编译报错。
例子:
class Person
{
public:virtual void BuyTicket() { cout << "买票—全价" << endl; }
};class Student :public Person
{
public:virtual void BuyTicket() override { cout << "买票—半价" << endl; }
};
注意:
- override 仅能修饰派生类的虚函数,不能修饰基类虚函数或非虚函数;
- override 不改变函数的 “虚函数属性”,仅做编译期校验,无运行时开销;
- 若派生类函数未重写基类虚函数,却加了 override,编译器会明确提示错误原因(如 “函数名不匹配”“参数数量不匹配”),便于快速定位问题。
(三) 抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
语法形式:
virtual 返回值类型 函数名(参数列表) = 0;
例子:
// 抽象类:含纯虚函数 BuyTicket(),无具体实现
class Person {
public:virtual void BuyTicket() = 0; // 纯虚函数:仅定义接口,无函数体
};class Student : public Person {
public:virtual void BuyTicket() override { // 重写纯虚函数cout << "买票—半价" << endl;}
};class Soldier : public Person {
public:virtual void BuyTicket() override { // 重写纯虚函数cout << "买票—优先买票" << endl;}
};int main() {// 1. 抽象类不能实例化:Person p; // 编译报错// 2. 通过抽象类指针实现多态调用Person* pst = new Student;pst->BuyTicket();Person* pso = new Soldier;pso->BuyTicket();delete pst;delete pso;return 0;
}
核心作用
- 定义统一接口:让不同派生类(如 Student,Soldier)遵循相同的行为规范(如都有 “买票” 的方法)。
- 支持多态调用:通过抽象类的指针 / 引用,可调用不同派生类的重写方法,实现 “同一接口,不同行为”。
(四) 多态的原理
以下代码环境在X86中,涉及到的指针是4个字节
4.1 虚函数表
我们先从一道经典笔试题切入,理解虚函数表的存在意义:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}
运行结果:
按常规理解,Base类仅含一个int成员(4 字节),但实际大小是 8 字节,为什么了?
看看监视
通过监视窗口发现,除了_b成员,还多一个__vfptr放在对象的前面(注意有些 平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代 表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数 的地址要被放到虚函数表中,虚函数表也简称虚表。
为了方便观察派生类中这个表放了些什么呢,我们在在里面多放几个虚函数和一个不是虚函数
class Base
{
public:virtual void Func1() { cout << "Base::Func1()" << endl; }virtual void Func2() { cout << "Base::Func2()" << endl; }void Func3() { cout << "Base::Func3()" << endl; } // 普通函数(非虚)
private:int _b = 1;
};class Derive : public Base
{
public:virtual void Func1() {cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
看监视:
可知:
1. 派生类对象的内存构成:
Derive对象d包含两部分:
- 从基类继承的成员(虚表指针__vfptr + _b);
- 自身新增成员_d,总大小为 4(虚表指针)+ 4(_b)+ 4(_d)= 12 字节(X86)。
2. 虚表的 “覆盖” 特性(重写的原理):
基类Base和派生类Derive的虚表是不同的:
- Base的虚表:存储Base::Func1()、Base::Func2()的地址;
- Derive的虚表:先拷贝基类虚表的所有内容,再将Func1()的地址替换为Derive::Func1()(即 “覆盖”),Func2()的地址仍保留Base::Func2()。
这就是 “虚函数重写” 的底层原理 —— 语法上叫 “重写”,原理上叫 “虚表中虚函数地址的覆盖”。
3. 虚表的本质:
虚表是存储虚函数指针的数组,普通函数(如Func3())不会放入虚表;为标记数组结尾,虚表的最后通常会存放一个nullptr.
证明:打开内存输入虚表的地址可发现
小思考:
1. 虚函数存在哪的?虚表存在哪的?
- 虚函数的函数体存储在常量区(代码段)(函数本质是指令,属于代码段);
- 虚表(存储虚函数地址的数组)也存储在常量区(类的固定资源,与代码段数据关联)。
解释:
我们可以回忆一下可以存在哪里?答案是:栈,堆,数据段(静态区) ,代码段(常量区),若这段记忆不清晰,可以去看博主的这篇——C/C++内存管理
我们首先可以排除堆,堆需通过new/malloc动态开辟、delete/free释放,而虚表是类级别的固定资源(类定义时就确定虚函数地址,虚表结构不变),无需动态管理;若虚表在堆,类销毁时无法统一释放(多对象共享虚表),会导致内存泄漏。
栈也可以排除,栈的特点是 “随函数栈帧创建而分配,函数结束后销毁”,且每个栈帧内存独立。但同一类的所有对象共享同一个虚表(如多个Base对象的虚表指针都指向同一个Base虚表)—— 若虚表在栈,函数结束后虚表会被销毁,后续对象的虚表指针会指向无效内存,矛盾。
那究竟是存在静态区还是常量区了?
若实在不肯定我们可以写一个小代码来区别
class Base { public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;}private:int _b = 1; };class Derive :public Base { public:virtual void Func1() {cout << "Derive::Func1()" << endl;} private:int _d = 2; };int main() {Base b_obj; Derive d;int a = 0;printf("栈:%p\n", &a);static int b_static = 0; printf("静态区:%p\n", &b_static);int* p = new int;printf("堆:%p\n", p);delete p; const char* str = "hello world";printf("常量区:%p\n", str);printf("虚表1:%p\n", *((int*)&b_obj));printf("虚表2:%p\n", *((int*)&d));return 0; }
小解释:
printf("虚表1:%p\n", *((int*)&b_obj));和printf("虚表2:%p\n", *((int*)&d));
主要解释*((int*)&b_obj)后面那个可以按博主接下来的之解释总结解释一下:
主要逻辑:“取地址 → 强转 → 解引用”
取地址:
- &b_obj 会拿到 b_obj 在内存中最开始的那个字节的地址(也就是 “虚表指针(__vfptr)” 所在内存的起始地址)。
强转:(这个比较难理解一点)
把地址强转成 int* 类型
为什么要转 int*?
在 X86 环境下:虚表指针(__vfptr)是 “指针类型”,占 4 字节;int* 也是 “指针类型”,同样占 4 字节—— 二者大小完全一致。如果不转,直接用 Base* 类型的 &b_obj,编译器会按 Base 类的结构去解读地址(比如关联 Base 的成员函数 / 变量),无法直接读取 “虚表指针” 的内容。(int*)&b_obj 表示 “把 b_obj 的起始地址,当作一个指向 int 的指针”—— 此时这个 int* 指针,刚好 “对准” 了 b_obj 内存中 “虚表指针(__vfptr)” 所在的 4 字节区域。
解引用:
- 我们已经通过上一步拿到虚表的地址了,解引用就是获取虚表的地址(因为虚表指针的核心作用就是 “存储虚表的地址”)。
运行结果:
发现虚表地址(00D5AB34、00D5AB64)与常量区地址(00D5ABAC)仅相差几十字节,远小于与栈、堆、静态区的地址差距。故他们在常量区!
2.必须通过基类的指针或者引用调用虚函数为什么不能为”派生类的指针“?为什么不能为“基类对象”?
派生类指针(如Derive* p = new Derive();)的特性是 “只能指向派生类对象(或其派生类对象)”,无法指向基类对象。此时调用虚函数(p->Func1()),永远只能执行派生类的重写版本,不存在 “指向基类对象执行基类函数、指向派生类对象执行派生类函数” 的 “多种形态”,不符合多态的定义。
当派生类对象赋值给基类对象时(如Base b = d;),会发生切片:
- 切片仅拷贝 “派生类中从基类继承的成员变量”(如_b),不会拷贝派生类的虚表指针—— 基类对象b的虚表指针,始终指向Base类的虚表(而非Derive的虚表);
- 若强行让切片拷贝虚表指针,会导致逻辑混乱:基类对象b的虚表中,到底是存基类虚函数地址还是派生类地址?若存派生类地址,b作为基类对象却执行派生类函数,会破坏类的封装逻辑(比如派生类函数可能访问b中没有的_d成员)。
故只有 “基类指针 / 引用” 能实现多态,基类指针 / 引用的核心优势是 “可灵活指向基类或派生类对象”,且访问对象时会使用对象自身的虚表指针:
总结一下派生类的虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中
- 如果派生 类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
- 派生类自己 新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
验证一下3
在Derive类中增加一个虚函数,如下
virtual void Func4()
{
cout << "Derive::Func4()" << endl;
}
看看监视和内存:
我们可以发现:为什么监视里面没有显示Func4,而内存里面确实多了一个指针,这个指针会是Func4的指针,我们继续验证一下吧:
我们借助这个代码可以完成要的结果:
//上面的类省略了
typedef void (*FUNC_PTR)(); void PrintVftable(FUNC_PTR* f)
{for (size_t i = 0; f[i] != nullptr; ++i) {printf("第%d个虚函数地址 :%p->", i + 1, f[i]);FUNC_PTR F = f[i];F(); }cout << endl;
}int main()
{Derive d;Base b;FUNC_PTR* p1 = (FUNC_PTR*)(*(int*)&b);FUNC_PTR* p2 = (FUNC_PTR*)(*(int*)&d);PrintVftable(p1); PrintVftable(p2); return 0;
}
小解释:
typedef void (*FUNC_PTR)();
为 “指向‘无参数、返回值为 void 的函数’的指针类型”,定义一个别名叫FUNC_PTR 。
定义 “函数指针类型别名”*的语法。核心作用是给 “指向特定类型函数的指针” 起一个简短的名字
语法结构:
typedef 返回值类型 (*别名)(参数列表);
FUNC_PTR* p1 = (FUNC_PTR*)(*(int*)&b):
从b
的内存中提取虚表地址,并转换为可用的虚表指针类型。
(*(int*)&b)这个在之前介绍了拿到 “虚表的地址”。
(FUNC_PTR*)(*(int*)&b) —— 转换为 “指向虚表的指针”
- FUNC_PTR是 “指向无参数、返回 void 的函数的指针”(即虚函数指针类型),而虚表本质是 “存储虚函数指针的数组”,因此 “指向虚表的指针” 就是 “指向函数指针数组的指针”,即FUNC_PTR*类型。
FUNC_PTR* p1 = (FUNC_PTR*)(*(int*)&b):p1
就成为了一个指向Base
类虚表的指针(FUNC_PTR*
类型),通过p1
可以访问Base
虚表中的所有虚函数指针(如Func1
、Func2
的地址)。
需要注意的是可能会出现代码报错,这时候可以在生成中重新生成解决方案
运行结果:
由此可以发现那个内存多出来的指针确实是Func4的指针。
(五) 多继承的虚表
多继承是 C++ 虚表机制的核心复杂场景 —— 派生类会继承多个基类的虚表,且重写的虚函数可能在不同基类虚表中显示不同地址。以下结合代码、运行结果与反汇编,拆解这一现象的本质。
来看看这个代码:
class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
class Base2 {
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int b2;
};
class Derive : public Base1, public Base2 {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int d1;
};typedef void (*FUNC_PTR)(); void PrintVftable(FUNC_PTR* f)
{for (size_t i = 0; f[i] != nullptr; ++i) {printf("第%d个虚函数地址 :%p->", i + 1, f[i]);FUNC_PTR F = f[i];F(); }cout << endl;
}int main()
{Derive d;Base1* ptr1 = &d;// ptr1->func1();PrintVftable((FUNC_PTR*)(*(int*)ptr1));Base2* ptr2 = &d;//ptr2->func1();PrintVftable((FUNC_PTR*)(*(int*)ptr2));Derive* ptr3 = &d;//ptr3->func1();PrintVftable((FUNC_PTR*)(*(int*)ptr3));return 0;
}
运行结果:
发现Base1和Base2两个虚函数地址的func1调用是一样的函数但是地址为什么不同了?我们仅重写了一次Derive::func1(),且ptr1->func1()、ptr2->func1()、ptr3->func1()最终都执行该函数 ——为何同一函数在 Base1 和 Base2 的虚表中显示不同地址(00F01244 vs 00F01366)?我们尝试用反汇编来看一下
小拓展:
反汇编中地址差异的本质是 “this 指针适配”
虚函数调用的核心是 “通过虚表找函数地址”+“传递正确的 this 指针”(this 指针存储在ecx寄存器,供虚函数访问成员)。在多继承中,不同基类指针指向的是 Derive 对象的 “子对象地址”,需通过 “地址偏移调整” 确保 this 指针正确 —— 这正是地址差异的根本原因。
我们先大概看看他的储存样子Base1在Derive中的上面,Base2在Base1下面
ptr1->func1();的反汇编:(这些反汇编博主就放出主要造成上诉疑问的部分)
ptr1指向 Derive 中的 Base1 子对象(即 Derive 对象的起始地址,因 Base1 是第一个基类)。
ptr1调用func1的反汇编 call然后F11,走到了eax里面的jmp指令,再走一步,就到了func1()函数
ptr2->func1();的反汇编:
ptr2指向的是 Derive 中的 Base2 子对象,其地址比 Derive 对象起始地址偏移了 8 字节。
ptr2调用func1的反汇编 call然后F11,走到了eax里面的jmp指令,走一步,然后到了sub ecx,8,再走两步,就到了func1()函数。
小思考:
sub ecx,8
- sub ecx, 8的作用是修正 this 指针:将 Base2 子对象的地址(ptr2 指向的地址)调整为 Derive 对象的起始地址,确保虚函数能正确访问所有成员(包括 Base1、Base2 的继承成员和 Derive 的自身成员)。
ptr3->func1();的反汇编:
ptr3调用func1的反汇编 call然后F11,走到了eax里面的jmp指令,再走一步,就到了func1()函数.与ptr1相同,看第一张大概位置的图片可知,指向一个位置。
多继承已经很麻烦了大家一定不要闲着无聊再弄一个菱形继承了!!!!
(六) 动态绑定与静态绑定
-
静态绑定(又称前期绑定或早绑定)在程序编译阶段即可确定程序行为,因此也被称为静态多态,主要通过函数重载机制实现。
-
动态绑定(又称后期绑定或晚绑定)则是在程序运行时根据实际类型确定具体行为,调用对应的函数实现,这种机制被称为动态多态。
以上就是C++之多态的学习就到这里告一段落,后续的完善工作我们将留待日后进行。希望这些知识能为你带来帮助!如果觉得内容实用,欢迎点赞支持~ 若发现任何问题或有改进建议,也请随时与我交流。感谢你的阅读!