C++ 是如何实现多态的?
在 C++ 中,多态(Polymorphism)是指“同一接口,多种实现”。C++ 实现多态主要分为两大类:静态多态(编译时多态)和动态多态(运行时多态)。
面试和底层原理中讨论得最多的通常是动态多态(通过虚函数表实现)。下面为你全面解析 C++ 是如何实现这两种多态的。
一、 静态多态(编译时多态)
静态多态是在编译阶段就已经决定了要调用哪个函数,也称为早绑定(Early Binding)或静态绑定。它的实现机制主要有两种:
- 函数重载(Function Overloading)
- 原理:在同一个作用域内,允许存在多个同名函数,只要它们的参数列表(参数个数、类型、顺序)不同即可。
- 底层实现:编译器在编译时,会根据参数列表对函数名进行名字修饰(Name Mangling)。例如
void print(int)和void print(double)在编译后会被重命名为类似_Z5printi和_Z5printd的完全不同的函数。
- 模板(Templates)
- 原理:允许编写泛型代码。
- 底层实现:编译器在编译时,会根据传入的具体类型进行模板实例化,为每一种类型生成一份专属的代码。
二、 动态多态(运行时多态)—— 核心重点
动态多态是在程序运行阶段才决定调用哪个函数,称为迟绑定(Late Binding)或动态绑定。
1. 动态多态成立的三个核心条件
- 继承关系:必须存在基类和派生类。
- 虚函数重写:基类中必须有
virtual声明的虚函数,派生类必须重写(override)该虚函数。 - 基类指针/引用指向派生类对象:通过基类的指针或引用来调用虚函数。
2. 底层实现机制:虚函数表(V-Table)与虚表指针(vptr)
C++ 的动态多态是依赖虚函数表(Virtual Table,简称 vtable)和虚表指针(Virtual Pointer,简称 vptr)来实现的。
- 虚函数表(vtable):
- 归属:属于类级别,每个含有虚函数的类在编译时都会生成一个唯一的虚函数表。
- 内容:本质上是一个函数指针数组,里面记录了该类所有的虚函数地址。如果派生类重写了基类的虚函数,派生类虚函数表中的对应位置就会被替换为派生类的虚函数地址。
- 存储位置:通常存储在可执行文件的只读数据段(
.rodata)。
- 虚表指针(vptr):
- 归属:属于对象级别。当类实例化为对象时,如果该类有虚函数,编译器会在对象的内存布局的起始位置(或末尾,取决于编译器,一般是起始)偷偷插入一个指针,这就是
vptr。 - 内容:指向该对象所属类的虚函数表(vtable)。
- 初始化:在对象的构造函数执行时,编译器会自动插入代码来初始化
vptr,使其指向对应的vtable。
- 归属:属于对象级别。当类实例化为对象时,如果该类有虚函数,编译器会在对象的内存布局的起始位置(或末尾,取决于编译器,一般是起始)偷偷插入一个指针,这就是
3. 动态调用的全过程(图解与原理)
假设有如下代码:
cpp
class Animal {
public:
virtual void speak() { cout << "Animal speaks" << endl; }
};
class Dog : public Animal {
public:
void speak() override { cout << "Dog barks" << endl; } // 重写虚函数
};
int main() {
Animal* p = new Dog(); // 基类指针指向派生类对象
p->speak(); // 触发动态多态
return 0;
}
运行 p->speak() 时,底层发生了什么?
- 编译器发现
speak()是一个虚函数,且p是一个指针。因此它不会直接生成调用Animal::speak()的汇编代码,而是生成一段间接寻址的代码。 - 程序运行时,通过指针
p找到它所指向的Dog对象。 - 取出
Dog对象内存中的隐藏成员:虚表指针(vptr)。 - 通过
vptr找到Dog类的虚函数表(vtable)。 - 在
vtable中查找speak()函数对应的偏移量(在此例中可能是表中的第一个元素)。 - 发现表中存放的是
Dog::speak()的地址,于是跳转到该地址执行。
转化为类似 C 语言的伪代码,多态调用的本质如下:
c
// p->speak(); 在底层的实际逻辑类似于:
(*p->vptr[speak_offset])(p);
// p是作为隐藏的 this 指针传入的
三、 多态相关的面试高频考点
1. 虚析构函数(Virtual Destructor)
- 问题:为什么基类的析构函数通常要声明为
virtual? - 答案:如果基类析构函数不是虚函数,当我们用
delete p;删除一个指向派生类对象的基类指针时,由于是静态绑定,编译器只会调用基类的析构函数,而不会调用派生类的析构函数,这就导致派生类中独有的资源(如动态分配的内存)无法释放,造成内存泄漏。声明为虚函数后,析构行为也会走虚表,从而先调用派生类析构,再调用基类析构。
2. 构造函数可以是虚函数吗?
- 答案:不能。
- 原因:虚函数的调用依赖对象的
vptr。而vptr是在对象的构造函数内部才被初始化的。如果构造函数是虚函数,调用它时vptr还没建好,自然无法去查虚函数表。此外,构造对象时必须确切知道对象的实际类型,不需要多态机制。
3. 纯虚函数与抽象类
- 如果在基类中定义虚函数时加上
= 0(如virtual void speak() = 0;),它就变成了纯虚函数。 - 包含至少一个纯虚函数的类叫做抽象类,抽象类不能被实例化(不能创建对象)。只能被继承,且派生类必须实现所有的纯虚函数才能被实例化。
4. 多态的代价(性能开销)
虽然多态极大地提高了代码的可扩展性,但也有成本:
- 空间开销:每个带有虚函数的类都需要一个虚函数表(vtable);每个对象都会多出一个指针(vptr)的内存空间(32位系统占4字节,64位系统占8字节)。
- 时间开销:调用虚函数需要额外的指针寻址(先找 vptr,再查 vtable,再跳转执行),比普通的函数直接跳转要慢一点。同时,虚函数通常无法被编译器内联(inline)优化。
总结
C++ 的多态机制是一套极其精巧的设计。静态多态靠编译器在编译时进行函数重载决议和模板推导;动态多态则是通过继承配合虚函数表 (vtable) 和虚指针 (vptr),在程序运行时进行查表和动态跳转来实现的。