1. 多态的定义及实现
1.1 多态的构成条件
多态是指在继承关系下,不同的类对象调用同一函数时,产生了不同的行为。
示例 :
Student类继承了Person类。
Person对象买票:全价Student对象买票:优惠
1.2 实现多态的两个必要条件
要实现多态效果,必须同时满足以下两个关键条件:
-
调用方式限制
必须是基类的指针 或者引用来调用虚函数。
- 原因:只有基类的指针或引用才能既指向基类对象,又指向派生类对象,从而实现统一接口下的不同表现。
-
函数定义限制
被调用的函数必须是虚函数 ,并且派生类必须完成对该虚函数的重写(覆盖)。
- 原因:只有完成了重写/覆盖,基类和派生类之间才能拥有不同的函数实现逻辑,从而达到多态的"不同形态"效果。

2 虚函数
在类成员函数前面加上 virtual 修饰符,该成员函数即被称为虚函数。
注意 :非成员函数(如全局函数、静态成员函数)不能加
virtual修饰。
代码示例:
cpp
class Person
{
public:
// 使用 virtual 关键字声明虚函数
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
2.1 虚函数的重写/覆盖
定义 :
当派生类中定义了一个与基类完全相同 的虚函数时,称为派生类重写(或覆盖)了基类的虚函数。
重写的严格条件 :
派生类的虚函数必须与基类虚函数在以下三个方面完全一致:
- 返回值类型相同
- 函数名字相同
- 参数列表相同
结论:只有满足上述所有条件,才能构成有效的重写,从而实现多态行为。
虚函数重写的一些其他问题
1. 协变 (了解)
定义 :
派生类重写基类虚函数时,返回值类型可以不同。具体规则是:
- 基类虚函数返回 基类对象的指针或引用。
- 派生类虚函数返回 派生类对象的指针或引用。
这种情况称为协变。虽然实际开发中意义不大,但作为知识点需要了解。
代码示例:
cpp
class A {};
class B : public A {};
class Person {
public:
// 基类返回基类指针 A*
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
// 派生类返回派生类指针 B* (构成协变)
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket(); // 多态调用
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
return 0;
}
2. 析构函数的重写
规则
如果基类的析构函数是虚函数,那么派生类的析构函数:
- 无论是否显式添加
virtual关键字; - 只要定义了该析构函数;
它都会自动 与基类的析构函数构成重写。
原理
虽然基类和派生类的析构函数在源码中名字不同(例如 ~A() 和 ~B()),表面上看不符合"函数名相同"的重写规则,但实际上:
- 编译器对析构函数的名称做了特殊处理。
- 在编译后,所有析构函数的名称被统一处理为
destructor。 - 因此,只要基类析构函数加了
virtual修饰,派生类的析构函数在底层逻辑上就构成了重写。
重要性
核心问题 :
如果基类的析构函数不是 虚函数,当通过基类指针 删除派生类对象时:
- 只会调用基类的析构函数。
- 不会调用派生类的析构函数。
后果 :
这会导致派生类中申请的资源(如动态分配的内存、打开的文件句柄等)无法被释放,从而造成严重的内存泄漏。
最佳实践 :
在设计类继承体系时,基类的析构函数必须设计为虚函数。这是保证多态删除对象时资源正确释放的关键。
override和final关键字
从上⾯可以看出,C++对虚函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错、参数写错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的。只有在程序运⾏时没有得到预期结果才来debug会得不偿失。
因此,C++11提供了 override ,可以帮助⽤⼾检测是否重写。如果我们不想让派⽣类重写这个虚函数,那么可以⽤ final 去修饰。
1. override 关键字
- 作用:显式地指示编译器,该虚函数旨在重写基类中的同名虚函数。
- 优势:如果派生类中的函数签名(函数名、参数列表、const属性等)与基类中的虚函数不匹配,编译器会直接报错。这有助于在编译期捕获重写错误,避免运行时出现逻辑错误。
2. final 关键字
- 作用:禁止派生类重写该虚函数。
- 应用场景 :当你希望某个虚函数在继承体系中保持特定实现,不允许派生类修改其行为时,可以使用
final。
3. 示例代码
cpp
#include <iostream>
class Base {
public:
virtual void func1() {
std::cout << "Base::func1" << std::endl;
}
virtual void func2() final { // 禁止派生类重写func2
std::cout << "Base::func2" << std::endl;
}
};
class Derived : public Base {
public:
void func1() override { // 正确:重写Base::func1
std::cout << "Derived::func1" << std::endl;
}
// void func2() override { // 错误:尝试重写final函数,编译器会报错
// std::cout << "Derived::func2" << std::endl;
// }
void func3() override { // 错误:Base中没有func3,编译器会报错
std::cout << "Derived::func3" << std::endl;
}
};
int main() {
Derived d;
d.func1(); // 输出: Derived::func1
d.func2(); // 输出: Base::func2
return 0;
}
重载、重写与隐藏的对比
| 概念 | 作用域 | 函数名 | 参数列表 | 返回值 | 关键字/特殊要求 |
|---|---|---|---|---|---|
| 重载 | 同一作用域 (同一个类中) | 相同 | 不同 (类型或个数不同) | 无关 (可同可不同) | 无特殊要求 |
| 重写/覆盖 | 不同作用域 (父类与子类) | 相同 | 必须相同 | 必须相同 (协变例外) | 基类函数必须有 virtual 关键字 |
| 隐藏 | 不同作用域 (父类与子类) | 相同 | 不同 (若相同且无 virtual 也属隐藏) | 无关 | 只要不构成重写,同名即隐藏 |
补充说明:
- 隐藏还有一种情况:子类成员变量与父类成员变量同名,也称为隐藏。
- 协变:指重写时返回值可以是父类虚函数返回值的派生类指针或引用。
3. 纯虚函数和抽象类
在虚函数的后面写上 =0,则这个函数为纯虚函数。纯虚函数不需要定义实现(虽然语法上允许实现,但通常没有意义,因为会被派生类重写),只要声明即可。
- 抽象类:包含纯虚函数的类叫做抽象类(也叫接口类)。
- 实例化限制 :抽象类不能实例化出对象。
- 派生类规则 :
- 如果派生类继承后不重写纯虚函数,那么该派生类也是抽象类,同样无法实例化对象。
- 纯虚函数强制了派生类必须重写该虚函数,否则无法生成实例。
示例代码
cpp
class AbstractClass {
public:
virtual void func() = 0; // 纯虚函数
};
class DerivedClass : public AbstractClass {
public:
void func() override { // 必须重写纯虚函数
std::cout << "DerivedClass::func" << std::endl;
}
};
int main() {
// AbstractClass a; // 错误:抽象类不能实例化
DerivedClass d; // 正确:重写了纯虚函数,可以实例化
d.func();
return 0;
}
4.多态的原理
什么是虚函数表指针 (vptr)?
简单来说,vptr 是一个隐藏在类对象内部的指针。
- 归属 :它属于对象(实例)。
- 作用 :它指向该对象所属类的 虚函数表(vtable)。
- 目的 :为了让程序在运行时,能够根据对象的实际类型,找到并调用正确的虚函数版本(即实现动态绑定 )。
每个拥有虚函数的类,都会有一个属于自己的、独立的虚函数表。
底层工作原理
当一个类中声明了虚函数(virtual 函数)时,编译器会自动做两件事:
- 生成虚函数表(vtable) :
- 这是一个静态 的数组,属于类,所有该类的对象共享一张表。
- 表中存放了该类所有虚函数的地址。
- 插入虚函数表指针(vptr) :
- 编译器会在类的每个对象的内存布局中,隐式插入一个指针,这就是 vptr。
- 在对象构造时,vptr 会被初始化,指向该类的 vtable。
内存布局示意图
通常情况下(取决于编译器实现,如 GCC/MSVC),vptr 位于对象内存布局的最前端。
| 内存偏移 | 内容 | 说明 |
|---|---|---|
| 0x00 | vptr | 指向虚函数表的指针 (通常占 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++ 标准并未规定必须有结束标记,这是编译器厂商的自行定义。
- VS 编译器 :通常会在数组最后放一个
5. 内存存储位置
- 虚函数(代码) :虚函数和普通函数一样,编译后是一段指令,存放在代码段 。虚表中存的只是它的地址。
- 虚函数表(数据) :
- C++ 标准并没有严格规定虚表存放在哪里。
- VS 编译器下 :通常存放在代码段(常量区)。
- 注:不同编译器实现可能不同,需具体验证。