C++ 抽象类与多态原理:从纯虚函数到虚表机制
C++ 抽象类通过纯虚函数定义接口规范,派生类必须重写才能实例化。多态底层依赖虚表指针和虚函数表实现动态绑定。虚表存储虚函数地址,位于代码段。对象包含虚表指针指向虚表。基类指针调用虚函数时,运行时根据实际对象类型查找虚表确定函数地址。静态绑定在编译时确定,动态绑定在运行时确定。析构函数建议设为虚函数以正确释放资源。

C++ 抽象类通过纯虚函数定义接口规范,派生类必须重写才能实例化。多态底层依赖虚表指针和虚函数表实现动态绑定。虚表存储虚函数地址,位于代码段。对象包含虚表指针指向虚表。基类指针调用虚函数时,运行时根据实际对象类型查找虚表确定函数地址。静态绑定在编译时确定,动态绑定在运行时确定。析构函数建议设为虚函数以正确释放资源。

在 C++ 多态体系中,纯虚函数与抽象类是实现'接口规范'的核心工具,而虚函数表(vtable)与虚表指针(vfptr)则是多态底层实现的关键。本文将基于实战代码,从纯虚函数与抽象类的定义、使用场景入手,逐步拆解多态的底层原理——包括虚表指针的存在性、虚函数表的结构与存储位置,最终帮你打通'多态怎么用'到'多态如何实现'的认知链路。
在实际开发中,我们经常需要定义一个'只规定行为,不提供具体实现'的类。C++ 通过纯虚函数和抽象类实现这种'接口契约'。
在虚函数的声明后加=0,该函数即为纯虚函数。纯虚函数无需在基类中实现(语法上允许实现,但无实际意义,因为会被派生类重写),其核心作用是'强制派生类必须重写该函数'。
包含纯虚函数的类称为抽象类,它有两个关键特性:
有了上面的知识储备,我们来看下代码示例吧:
#include <iostream>
using namespace std;
// 抽象类:包含纯虚函数 Drive()
class Car {
public:
// 纯虚函数:只声明接口,不提供实现
virtual void Drive() = 0;
};
// 派生类 Benz:重写纯虚函数,成为'具体类'
class Benz : public Car {
public:
// 必须重写 Drive(),否则 Benz 也是抽象类
virtual void Drive() {
cout << "Benz-舒适" << endl;
}
};
// 派生类 BMW:重写纯虚函数,成为'具体类'
class BMW : public Car {
public:
virtual void Drive() {
cout << "BMW-操控" << endl;
}
};
int main() {
// 抽象类无法实例化对象
// Car car;
// 用抽象类指针指向派生类对象(多态核心用法)
Car* pBenz = new Benz;
pBenz->Drive(); // 多态调用:输出'Benz-舒适'
Car* pBMW = new BMW;
pBMW->Drive(); // 多态调用:输出'BMW-操控'
delete pBenz;
delete pBMW;
return 0;
}
当我们用基类指针调用派生类的虚函数时,编译器如何'知道'该调用哪个类的函数?答案藏在虚表指针(vfptr) 和虚函数表(vtable) 中——这是 C++ 实现动态绑定(运行时多态)的核心机制。
首先通过下面这个题目来验证一下虚表指针的存在。下面编译为 32 位程序的运行结果是什么(D)。 A. 编译报错 B. 运行报错 C. 8 D. 12
class Base {
public:
// 虚函数:触发编译器生成虚表指针
virtual void Func1() { cout << "Func1()" << endl; }
virtual void Func2() { cout << "Func2()" << endl; }
// 普通函数
void Func3() { cout << "Func3()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base b;
// 除了我们能看到的_b 和_ch,其实有虚函数的类就会有一个虚函数表指针 (32 位下 4 字节,64 位下 8 字节)
// 因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
cout << sizeof(b) << endl; // 32 位:4+4+1->12
// 输出结果:32 位环境下为 12 字节,64 位环境下为 16 字节
return 0;
}
关键结论:
从底层的角度 Func 函数中 ptr->BuyTicket(),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicket,ptr 指向 Student 对象调用 Student::BuyTicket 的呢?通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
第一张图,ptr 指向的 Person 对象,调用的是 Person 的虚函数;第二张图,ptr 指向的 Student 对象,调用的是 Student 的虚函数。
class Person {
public:
virtual void BuyTicket() {
cout << "买票 - 全价" << endl;
}
private:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() {
cout << "买票 - 打折" << endl;
}
private:
string _id;
};
void Func(Person& ptr) {
// 这里可以看到虽然都是 Person 指针 Ptr 在调用 BuyTicket
// 但是跟 ptr 没关系,而是由 ptr 指向的对象决定的。
ptr.BuyTicket();
}
int main() {
// 其次多态不仅仅发生在派生类对象之间,多个派生类继承基类,重写虚函数后
// 多态也会发生在多个派生类之间。
Person ps;
Student st;
Func(ps);
Func(st);
// 这三个的虚函数表是一样的,同类型的对象共用一虚表
// Person p1;
// Person p2;
// Person p3;
return 0;
}
虚函数表(简称'虚表')是编译器为每个含虚函数的类生成的一张'虚函数指针数组',数组中存储的是该类所有虚函数的地址。其结构与生成规则如下:
Base 类的虚表存储 Func1 和 Func2 的地址);0x00000000 作为结束标记(g++ 无此标记,C++ 标准未强制规定)。class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
// 普通函数:不存入虚表
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base {
public:
// 重写基类的 func1:会覆盖虚表中 func1 的地址
virtual void func1() { cout << "Derive::func1" << endl; }
// 派生类新增虚函数:会追加到虚表末尾
virtual void func3() { cout << "Derive::func1" << endl; }
// 普通函数:不存入虚表
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
int main() {
Base b;
Derive d;
return ;
}
当用基类指针调用虚函数时,编译器会按以下步骤完成'动态绑定'(运行时确定调用的函数):
以之前的'买票'场景为例,流程如下:
// 基类指针指向派生类对象
Person* ptr = new Student;
// 动态绑定流程:
// 1. 从 ptr 指向的 Student 对象中,取出 vfptr;
// 2. 通过 vfptr 找到 Student 类的虚表;
// 3. 在虚表中找到 BuyTicket 对应的地址(Student::BuyTicket 的地址);
// 4. 调用该地址对应的函数,输出'买票 - 打折'。
int main() {
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
printf("Base 虚函数表地址:%p\n", *((int*)&b));
printf("Derive 虚函数表地址:%p\n", *((int*)&d));
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
运行结果: 栈:010FF954 静态区:0071D000 堆:0126D740 常量区:0071ABA4 Person 虚表地址:0071AB44 Student 虚表地址:0071AB84 虚函数地址:00711488 普通函数地址:007114BF
1. 面向对象的三大特性 (这里重点讲讲什么是多态)
2. 什么是重载,重写 (覆盖),重定义 (隐藏)?
| 特性 | 定义 | 示例 |
|---|---|---|
| 重载 | 同一类中,方法名相同,参数列表(参数类型、个数、顺序)不同,与返回值类型无关 | 类中 add(int a, int b) 和 add(double a, double b) |
| 重写(覆盖) | 子类继承父类后,对父类的虚函数进行重新实现,方法名、参数列表、返回值类型(协变情况除外)完全相同 | 父类 Animal 的虚函数 makeSound(),子类 Dog 重写为 void makeSound() { cout << "汪汪" << endl; } |
| 重定义(隐藏) | 子类中定义了与父类同名的非虚函数,隐藏父类的该函数 | 父类有 func(),子类也定义 func(),子类对象调用 func() 时执行子类的,父类对象调用执行父类的 |
3. 多态的实现原理? 答: 多态通过 虚函数表(vtable)和虚表指针(vptr) 实现。每个包含虚函数的类都有一个虚函数表,表中存储着该类所有虚函数的地址。每个对象都有一个虚表指针,指向所属类的虚函数表 (相同类型的对象指向同一张虚函数表)。当通过父类指针或者引用调用虚函数时,程序会根据指向的实际对象类型,通过其虚表指针找到虚函数表,再找到对应的虚函数地址 (可以通过虚函数指针) 并调用,从而实现运行时的多态。
4. inline 函数可以是虚函数吗?inline 属性和虚函数属性能同时存在吗? 答:可以是虚函数,从语法上看,inline 函数可以声明为虚函数,但实际上编译器会忽略 inline 属性 (inline 一般展开是不需要地址的),将其当作普通虚函数处理。因为虚函数要放在虚表中去,两者机制冲突,也就是说 inline 属性和虚函数属性是不同时存在的。
5. 静态成员可以是虚函数吗? 答:不能,静态成员函数属于类,不属于某个对象,没有 this 指针,而虚函数的调用需要通过对象的虚函数指针来实现,所以静态成员函数不能是虚函数。
6. 构造函数可以是虚函数吗? 答:不可以。因为对象中的虚函数指针是在构造函数初始化列表阶段才初始化的,所以构造函数不能是虚函数。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
Base,子类 Derived,若用 Base* p = new Derived(); delete p;,如果 Base 的析构函数不是虚函数,只会调用 Base 的析构函数,导致子类资源未释放;若为虚函数,则会先调用 Derived 的析构函数,再调用 Base 的析构函数。8. 对象访问普通函数快还是虚函数更快? 答:首先如果是普通对象调用的话两者是一样快的,但如果是基类的指针或者引用去调用,且构成了多态调用,则调用的普通函数更快,运行时调用虚函数需要到虚函数表中去查找,有一定开销。
| 调用场景 | 普通函数调用机制 | 虚函数调用机制 | 性能差异根源 |
|---|---|---|---|
| 普通对象调用 | 编译时直接绑定函数地址,直接跳转执行 | 编译时直接绑定函数地址,直接跳转执行 | 无差异 |
| 基类指针/引用多态调用 | 编译时直接绑定函数地址,直接跳转执行 | 运行时通过 vptr 找 vtable,再找函数地址执行 | 虚函数多了查表的运行时开销 |
9. 虚函数表是在什么阶段生成的,存在哪里的? 答:虚函数表是在编译阶段生成的,一般情况下是存在**代码段 (常量区) 的。
10. C++ 菱形继承的问题?虚继承的原理? 答:菱形继承会导致数据冗余和二义性的问题,虚继承则是通过虚基类指针和虚基表 (不要把虚函数表,虚函数指针和虚基表,虚基类指针搞混了) 实现的中间基类在继承时顶层基类时声明为虚继承,这样可以保证顶层基类的成员只会有一份,解决了数据冗余和二义性的问题。
11. 什么是抽象类?抽象类的作用?
答:包含纯虚函数(形如 virtual void func() = 0;)的类,无法实例化对象。抽象类的作用是 作为接口规范,强制子类必须重写实现纯虚函数
通过抽象类,我们能定义清晰的接口契约;通过虚表机制,C++ 实现了'运行时动态绑定'的多态能力。理解这两者,不仅能正确使用多态,更能在复杂场景中(如框架设计、模块扩展)写出更灵活、可维护的 C++ 代码。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online