写了这么多年 C++,如果让我挑一个最能区分“会写语法”和“会做设计”的知识点,多态绝对排得上前三。很多人背得出“一个接口,多种实现”,可真到了要把一段越写越长的 if-else 拆成可扩展的结构,或者在 delete 一个基类指针时莫名其妙漏了内存,才发现自己对多态的理解还停在课本那一页。这篇笔记我想把 C++ 多态从概念、底层机制、落地细节到踩坑排查完整捋一遍,重点讲清楚虚函数表到底怎么工作、vptr 在构造析构期间为什么会“变脸”、哪些地方编译器不会替你兜底,以及虚调用到底贵在哪里。内容适合已经能写类、会继承,但对“为什么这么写”还没完全打通的朋友;也适合写了两三年、想回头补一补底层认知的同学。代码都以通用场景为主,可以直接抄去改。
1. 多态到底是什么:先把概念和边界划清楚
C++ 里的多态这个词被用得很泛,同一个词在不同语境下指的东西完全不一样。有人说的多态是函数重载,有人说的多态是模板,有人说的多态是虚函数。这三种东西在编译器眼里是三条完全不同的路径,混着谈很容易把自己绕进去。所以第一步不急着写虚函数,先把边界划清楚:编译期能定下来的叫静态多态,运行时才能定下来的叫动态多态。这篇笔记的重点是后者,也就是靠 virtual 关键字驱动的运行期多态,因为它是绝大多数设计模式、插件架构、接口抽象的底座,也是最容易出隐蔽 bug 的地方。
1.1 静态多态与动态多态的差别在哪
静态多态的核心特征是“决策发生在编译期”。函数重载靠参数类型在编译期完成名字查找和匹配,模板靠实例化在编译期生成具体代码,CRTP 也是靠模板在编译期把派生类类型传回基类。这类多态的代价是零运行时开销,编译器甚至能把整个调用内联掉;代价是灵活性受限,你没法在运行时根据配置或用户输入来决定调用哪个版本,因为类型必须在编译时确定。
动态多态的核心特征是“决策推迟到运行期”。调用点只写一个基类指针或引用,具体跳到哪个函数体,要等程序跑起来、看这个指针实际指向哪个对象才知道。这种延迟换来了极强的扩展性:新增一个派生类,不用改动任何调用方代码。但它也带来了间接跳转、无法内联、对象体积变大这些实打实的成本。
| 维度 | 静态多态 | 动态多态 |
|---|---|---|
| 决策时机 | 编译期 | 运行期 |
| 典型手段 | 重载、模板、CRTP | 虚函数、虚继承 |
| 运行时开销 | 基本为零,可内联 | 一次间接跳转,难以内联 |
| 对象体积 | 不变 | 通常增加一个指针(64 位下 8 字节) |
| 扩展方式 | 改代码重编译 | 新增派生类,调用方不动 |
| 适用场景 | 数值计算、容器、性能敏感路径 | 插件、接口抽象、状态机、UI 组件 |
这张表不是为了分高下,而是提醒你:选哪种多态,取决于你的扩展点在哪里。如果类型集合在编译期就固定了,模板更划算;如果类型集合会随需求不断增长,甚至要以动态库的形式加载,虚函数几乎是唯一选择。
1.2 一个被 if-else 撑爆的模块长什么样
抽象的对比不如看一个具体场景。假设某实验室有个小工具,负责把不同格式的图像读进来做统一处理。最初只支持两种格式,代码里一个 if-else 就搞定了。后来需求一个个加进来:加了第三种、第四种,每加一种就要在三个地方同步改代码——读取、处理、保存。半年后那个函数的 switch 分支有十几条,每条分支里还各自带着一小段初始化逻辑,改一处忘了改另一处,就会出现“能读不能存”的诡异现象。
我见过最典型的一版是这样的:一个 enum 列出所有格式,三个大函数各自 switch 一遍,谁都不敢动,因为一动就要连带验证所有分支。这种结构的根本问题不是代码丑,而是“新增一种类型”这件事需要改动已有代码,而且改动点分散在多个函数里。开闭原则听起来很虚,落到这个场景就很实:你要的是新增代码,而不是修改代码。
多态在这里干的事情,就是把“按类型分支”换成“按类型分派”。基类定义一组纯虚接口——打开、读取一行、关闭,每种格式一个派生类各自实现。调用方只持有一个基类指针,从头到尾只写一遍流程代码,新增格式时只需要新增一个文件,其他代码一行不动。这个转变的价值不在省了几行代码,而在于把“变化的部分”收敛到了一个点上。
1.3 多态生效的三个前提,缺一个就白写
这里有个特别容易踩的点:virtual 写了不等于多态就生效了。要让一次调用真正走到派生类的实现,必须同时满足三个条件。
第一,调用必须通过基类的指针或引用发生。如果你用一个基类对象去接收派生类对象,比如Base b = derived;,发生的是对象切片,派生类那部分成员被直接切掉了,vptr 也被复制成基类的,后面再怎么调都是基类版本。这个坑在函数传参时最容易中,因为按值传参本质上就是一次拷贝构造。
第二,被调用的函数在基类中必须声明为 virtual。如果基类里忘了写 virtual,派生类里写了同名同签名的函数,那不叫覆盖,叫名字隐藏,编译器不会报错,运行时的行为和你预期完全相反。
第三,派生类的函数签名必须和基类完全一致。参数类型、const 限定、引用限定符、异常规格,任何一处不一致,派生类那个函数就变成了一个新函数,不构成覆盖。这个错误编译器默认也不报,加上 override 才能抓出来。
注意:这三个条件里,第二条和第三条是最隐蔽的。它们不会产生编译错误,只会产生“看起来跑通了但结果不对”的运行时行为,排查成本极高。养成给每个覆盖函数加 override 的习惯,能把大部分这类问题挡在编译期。
2. 虚函数的底层机制:vtable 和 vptr 到底怎么配合
理解了“为什么需要多态”,下一步要搞清楚“多态是怎么做到的”。这一步很多人停在“有个虚函数表”就结束了,但真正写代码时出问题的地方,恰恰在表的细节上:vptr 存在哪里、什么时候被赋值、多重继承下有几个 vptr、构造期间它指向谁。这些细节标准里没有强制规定,属于实现层面的约定,但主流编译器在常见平台上高度一致,值得花时间弄明白。
2.1 一个多态对象的内存布局
先看最简单的情形:基类有一个虚函数,派生类覆盖了它。对象的内存布局大致是——最前面是 vptr(64 位平台 8 字节),后面依次是基类的非静态数据成员,再后面是派生类新增的数据成员。派生类对象里的这个 vptr 指向的是派生类自己的虚函数表,而不是基类的。
虚函数表是一张函数指针数组,每个多态类(准确地说是每个“有虚函数的类在某条继承链上的具体形态”)对应一张。表里按固定顺序排列该类的所有虚函数地址:基类声明的虚函数占据前面的槽位,派生类新增的虚函数排在后面。覆盖发生时,派生类表里对应槽位填的是派生类的函数地址;槽位位置不变,只是内容被替换了。这一点非常关键——正是因为槽位下标在编译期就固定了,调用方才只需要写“取第 0 个槽位”,而不需要知道具体是哪个类。
多继承会打破这个简洁的画面。如果一个类同时继承两个带虚函数的基类,对象里就会出现两个 vptr,分别指向两张表,各自负责对应基类子对象的虚函数分派。这时候用不同的基类指针指向同一个对象,得到的地址可能不一样(因为要偏移到对应的子对象起始位置),这也是为什么跨基类指针比较和dynamic_cast要格外小心。虚继承更复杂,通常会额外引入虚基类表指针来定位虚基类子对象,对象体积进一步变大。
class Base { public: virtual void foo() { } virtual ~Base() { } int a = 0; }; class Derived : public Base { public: void foo() override { } virtual void bar() { } int b = 0; }; // 64 位下 sizeof(Base) 通常是 16:vptr 8 + int a 4 + 对齐填充 4 // sizeof(Derived) 通常是 24:vptr 8 + int a 4 + int b 4 + 填充 8这段代码说明一个常被忽略的事实:加一个虚函数,对象的体积就可能从 4 字节翻到 16 字节。如果你的类会被大量实例化(比如放进容器存上百万个),这笔开销不能不算。反过来,如果一个类本来就只有一个 int 成员,为了几个虚函数把体积翻四倍,可能不如换用其他方案。
2.2 一次虚函数调用的完整链路
把调用链拆开看,一次ptr->foo()大致经过这几步:先从ptr指向的内存里读出 vptr;再用编译期确定的槽位下标从 vptr 指向的表里取出函数地址;最后做一次间接跳转,把 this 指针(也就是 ptr 本身)作为第一个隐式参数传进去。
这条链路上有两个后果。第一个后果是编译器无法内联。普通函数调用编译器知道目标是谁,可以把函数体展开、做常量传播、消除无用分支;虚调用在编译期只知道“要去表里取”,目标不确定,内联基本做不了。除非编译器能通过类型分析确认动态类型(比如对象是在同一个函数里刚构造的,或者类被标记为 final),才能做所谓的去虚化。
第二个后果是分支预测的不友好。间接跳转的目标地址在运行时才确定,CPU 的分支预测器面对一系列不同目标的跳转,命中率会下降。如果这个调用发生在热循环里,比如每帧要遍历几万个对象调用一次 update,这部分开销会变得明显。
不过也别被这些吓住。实测下来,单次虚调用的额外开销通常在几个纳秒量级,比一次 cache miss 便宜得多。真正的问题往往不是“虚函数慢”,而是“在不该用虚函数的地方用了它”,比如一个每天调用几次的初始化函数,纠结这点开销毫无意义;而在一个每帧跑十万次的内层循环里,能静态确定类型的地方就别用虚调用。
2.3 构造与析构期间,vptr 会经历一次“变脸”
这是我认为最值得单独讲的一节,因为它是很多“玄学 bug”的源头。构造一个派生类对象时,执行顺序是:先分配内存,然后基类构造,再派生类构造。而 vptr 的赋值是跟着构造函数走的——进入基类构造函数体之前,vptr 被设置成基类的虚函数表;进入派生类构造函数体之前,vptr 被改写成派生类的表。
这意味着在基类构造函数体内调用虚函数,调用的是基类版本,而不是派生类版本。原因是此时派生类部分还没构造完,如果允许派发到派生类,那个函数可能会访问尚未初始化的成员,行为完全不可预期。所以标准明确规定:构造和析构期间,虚调用只派发到当前正在构造/析构的那一层。
析构是同样的道理,只是顺序反过来:派生类析构函数执行时 vptr 指向派生类的表,进入基类析构函数体之前,vptr 被改回基类的表。所以在基类析构函数里调用虚函数,同样只会走到基类版本。
更危险的一种情况是在构造或析构期间调用纯虚函数。因为纯虚函数在基类的表里通常填的是一个“纯虚调用桩”,一旦被真正执行到,典型表现是运行时直接终止(不同平台提示不同,有的是纯虚函数调用错误)。这类崩溃的调用栈往往指向构造函数,看起来毫无道理,实际原因就是上面这条规则。
class Base { public: Base() { init(); } // 危险:这里只会调到 Base::init virtual void init() { } virtual ~Base() { } }; class Derived : public Base { public: void init() override { /* 期望在这里初始化,但永远不会在构造期被调用 */ } };正确的做法是把“构造完成后需要做的初始化”抽成一个显式的两段式接口,比如initialize(),由创建对象的一方在构造完成之后主动调用;或者干脆不在构造函数里做任何需要多态的事情,把配置通过构造参数传进去。
3. 落地时必须抠的细节:编译器不会替你兜底的地方
概念和机制讲完,接下来是最容易翻车的部分。C++ 在虚函数这块给了你极大的自由度,代价是一堆规则需要你自己记住,编译器只在部分场景下给提示,很多时候是静默通过。这一节我按“出问题概率从高到低”的顺序,把几个必须抠清楚的细节过一遍。
3.1 虚析构函数:一个 delete 就能捅出来的泄漏
这是老生常谈但每年都有人踩的坑。当你用一个基类指针delete一个派生类对象时,如果基类的析构函数不是 virtual,那么只会执行基类的析构函数,派生类的析构函数不会被调用。派生类里持有的资源——堆内存、文件句柄、锁——全部泄漏。而且这个过程没有任何报错,程序可能跑得好好的,只是在长时间运行后内存缓慢上涨。
class Base { public: ~Base() { } // 非虚析构 }; class Derived : public Base { public: ~Derived() { delete[] buffer; } char* buffer = new char[1024]; }; Base* p = new Derived(); delete p; // 只调 Base::~Base,Derived::~Derived 不会执行,buffer 泄漏规则很简单:只要一个类可能被当作基类通过指针删除,它的析构函数就必须是 virtual。反过来,如果一个类不打算被继承(可以用 final 明确表达这个意图),或者确定不会被多态删除,那么非虚析构可以省下 vptr 的开销。标准库里的很多类就是这种取舍。
还有一个容易被忽略的细节:析构函数声明为 virtual 之后,编译器会为这个类生成 vtable,并且析构函数会占据表里的槽位(通常是两个槽位,分别对应 deleting destructor 和 complete destructor,具体实现因编译器而异)。所以加上 virtual 之后,类的布局和调用开销都会发生变化,这一点在对比对象大小时要心里有数。
3.2 纯虚函数与抽象类:接口和实现的边界
纯虚函数用= 0声明,含有纯虚函数的类叫抽象类,不能实例化。这个语法大家都会写,但有几个点常被误解。
第一,纯虚函数可以有定义。语法上允许你给virtual void foo() = 0;在类外提供一个实现,派生类可以通过Base::foo()显式调用它。这个特性在需要给派生类提供一份“默认但必须显式选择”的实现时很有用,比如所有派生类的析构逻辑有共通部分。
第二,纯虚析构函数必须提供定义。因为派生类析构时会沿着继承链一路调用基类析构,如果基类那个纯虚析构只有声明没有定义,链接阶段就会报未定义符号。这是个经典陷阱:写virtual ~Base() = 0;让类变成抽象类,却忘了在类外补上Base::~Base() { }。
第三,抽象类不等于接口。C++ 没有 interface 关键字,实践中常把“全部是纯虚函数、没有数据成员、有虚析构”的类当作接口使用。这个约定很好用,但要记住它只是约定——编译器不会阻止你在“接口”里塞数据成员。
提示:如果一个类只作为接口使用,建议把构造函数设为 protected 或删除拷贝操作,避免有人误实例化或误切片。这类防御性写法在多人协作的项目里能省掉不少沟通成本。
3.3 override 与 final:给编译器一个抓错的机会
C++11 引入的 override 和 final 是两个“零运行时成本、纯编译期收益”的关键字,不用白不用。
override 的作用是显式声明“我这个函数是要覆盖基类的虚函数”。如果签名不匹配,编译器直接报错,而不是默默把它当成一个新函数。签名不匹配的情形比想象中多:基类写了virtual void handle(int),派生类写成void handle(long),参数类型不同,不构成覆盖;基类写了virtual void run() const,派生类漏了 const,也不构成覆盖;C++11 之后还有引用限定符,virtual void get() &和void get() &&是两个不同的函数。
final 用在虚函数上表示“到此为止,后面的派生类不能再覆盖它”;用在类上表示“这个类不能被继承”。它除了表达设计意图,还有一个实际好处:编译器知道某个虚函数不会再被覆盖,就有可能做去虚化,把间接调用变成直接调用。在性能敏感的地方,给叶子类的覆盖函数加 final 是个低成本的优化手段。
这里顺带说一个访问权限的误区:很多人以为基类的虚函数必须是 public 才能被覆盖。实际上覆盖不检查访问权限,派生类可以用 private 覆盖一个 public 的虚函数。调用是否合法取决于调用点看到的静态类型和访问权限,而最终执行哪个函数体取决于动态类型。这个特性在“模板方法”模式里用得很多——基类把流程函数设为 public,把各个步骤设为 private virtual,派生类实现这些私有虚函数,外部无法直接调用它们。
3.4 默认参数静态绑定、协变返回、对象切片
这三个问题各自独立,但都属于“看起来应该这样,实际不是这样”的类型,放在一张表里对照看更清楚。
| 现象 | 你的直觉 | 实际行为 | 规避方式 |
|---|---|---|---|
| 虚函数带默认参数 | 用派生类的默认值 | 默认参数静态绑定,用基类声明的值 | 默认参数只写在基类,或在函数体内判断 |
| 返回值协变 | 可以返回任意兼容类型 | 只允许返回基类函数返回类型的指针或引用,且派生关系一致 | 返回智能指针时注意不构成协变 |
| 基类对象接收派生类对象 | 保留派生类行为 | 对象切片,派生部分被丢弃,vptr 也是基类的 | 一律用指针或引用传递多态对象 |
默认参数那条特别值得展开。因为默认参数是在编译期根据静态类型填进去的,而函数体是在运行期根据动态类型选的,两者走的不是同一条路。结果就是调用一个派生类对象,执行的却是派生类的函数体,用的是基类的默认参数值。这种错位很难在代码审查中发现,因为它需要同时理解两条绑定规则。
协变返回类型的限制也常被忽略。基类virtual Base* clone(),派生类可以写成Derived* clone() override,这是合法的协变;但如果改成返回std::unique_ptr<Base>,就不构成协变了,因为智能指针不是指针类型本身,只是行为像指针的类模板。想要克隆语义又想用智能指针,通常得在基类定义virtual std::unique_ptr<Base> clone() const = 0;,让派生类返回std::unique_ptr<Base>并内部构造Derived。
4. 从零写一个能跑的多态骨架:以图像处理小工具为例
前面讲了不少原理,这一节把它们串成一个完整可运行的例子。场景还是那个图像处理小工具,假设某实验室需要支持多种格式的读写,未来还会持续增加。这个例子的重点不在图像处理本身,而在于展示接口怎么设计、派生类怎么写、调用方怎么保持稳定。
4.1 接口设计:先想清楚谁是基类
设计接口的第一步不是写代码,而是问自己:哪些行为是“所有类型共有但实现不同”的?在这个场景里,共有的行为是打开、逐行读取、关闭,以及一个查询尺寸的辅助接口。这些放进基类作为纯虚函数。哪些行为是“调用方需要的但不属于具体类型”的?比如统一的处理流程,这类放在基类作为非虚函数,内部调用虚函数——这就是模板方法模式。
class ImageSource { public: virtual ~ImageSource() = default; virtual bool open(const std::string& path) = 0; virtual bool readRow(std::vector<std::uint8_t>& row) = 0; virtual void close() = 0; virtual int width() const = 0; virtual int height() const = 0; // 非虚的统一流程,内部调用虚函数 bool processAll() { if (!open(path_)) return false; std::vector<std::uint8_t> row; while (readRow(row)) { handleRow(row); } close(); return true; } void setPath(std::string p) { path_ = std::move(p); } protected: std::string path_; virtual void handleRow(const std::vector<std::uint8_t>& row) { } };这个设计里有两个考虑。第一,析构函数声明为 virtual 且用= default,保证通过基类指针删除时派生类析构能正常执行,同时不引入额外的实现负担。第二,processAll是非虚的,调用方看到的是固定流程,派生类无法改变这个流程,只能改变每一步的具体实现——这让“流程”和“步骤”的变更是解耦的。
4.2 具体实现:派生类只管自己那一份
以两种虚构的格式为例,一个假设是简单的行式二进制格式,一个假设是带文件头的小端格式。两个派生类各自实现虚函数,互不干扰。
class RawImageSource : public ImageSource { public: ~RawImageSource() override { close(); } bool open(const std::string& path) override { file_.open(path, std::ios::binary); if (!file_) return false; file_.read(reinterpret_cast<char*>(&w_), sizeof(w_)); file_.read(reinterpret_cast<char*>(&h_), sizeof(h_)); remaining_ = h_; return file_.good(); } bool readRow(std::vector<std::uint8_t>& row) override { if (remaining_ <= 0) return false; row.resize(static_cast<std::size_t>(w_)); file_.read(reinterpret_cast<char*>(row.data()), w_); --remaining_; return file_.good(); } void close() override { if (file_.is_open()) file_.close(); remaining_ = 0; } int width() const override { return w_; } int height() const override { return h_; } private: std::ifstream file_; int w_ = 0, h_ = 0, remaining_ = 0; };写这个派生类时有几个细节值得停下来看。第一,析构函数里显式调用了close(),虽然std::ifstream的析构本身会关闭文件,但显式写出来能让“资源在该释放时释放”这件事一目了然,也方便以后换成 C 风格的文件句柄。第二,readRow用remaining_记录剩余行数,而不是依赖eof(),因为eof()只有在尝试读取失败之后才置位,用它做循环条件会导致最后一行被处理两次。这个坑我在真实项目里见过不止一次。
第三,所有覆盖函数都加了 override,签名写错编译器立刻报错。这一点在派生类多的时候尤其重要,因为人眼核对签名的可靠性很低。
4.3 编译、运行与验证:看看表里到底填了什么
代码写完之后,光跑通还不够,最好能亲眼看到 vtable 的内容。GCC 提供了一个很有用的编译选项,能把类的内存布局和虚函数表打印出来。
g++ -std=c++17 -fdump-lang-class -c image_source.cpp -o /dev/null生成的.class文件里会有类似这样的输出:Vtable 的条目数、每个槽位对应的函数名、vptr 的偏移。对比基类和派生类的输出,你能直接看到派生类表里哪些槽位被替换成了自己的实现,哪些新增函数被追加到了表尾。这比看文章印象深得多。
运行层面的验证则要覆盖几件事:用基类指针调用是否走到了派生类实现;通过基类指针 delete 是否触发了派生类析构(可以在析构里打日志,或者用计数器验证);对象切片场景下行为是否符合预期(通常是不符合预期,正好用来加深印象)。
一个实用的调试技巧:如果怀疑某次调用没有派发到正确的类,可以在每个覆盖函数的开头打印typeid(*this).name(),或者用dynamic_cast做一次检查。如果dynamic_cast<Derived*>(basePtr)返回的是 nullptr,说明动态类型根本不是你以为的那个,问题多半出在对象切片或者构造期的虚调用上。
4.4 开销实测:虚调用到底贵多少
与其凭感觉说“虚函数有开销”,不如实测一次。构造一个循环,分别用直接调用、通过基类指针的虚调用、通过std::function的调用跑同样的工作量,比较耗时。下面是一个思路性的对比表,实际数字会随编译器和 CPU 变化,但量级关系比较稳定。
| 调用方式 | 相对开销 | 能否内联 | 典型场景 |
|---|---|---|---|
| 直接调用(静态绑定) | 1x | 能 | 类型已知的内层循环 |
| 通过基类指针虚调用 | 2x 到 4x | 通常不能 | 插件、接口分派 |
| 通过 std::function | 5x 以上 | 不能 | 回调、事件系统 |
| 通过函数指针 | 2x 到 3x | 不能 | C 风格回调 |
这个对比不是让你远离虚函数,而是让你知道成本落在哪里。如果一次虚调用发生在每秒几十次的路径上,完全可以忽略;如果发生在每帧十万次的内层循环里,那就值得考虑用模板或者类型擦除的替代方案。同时注意,现代编译器的去虚化能力比想象中强,如果调用点附近能看到对象的构造过程,或者类被标记为 final,间接调用有可能被优化成直接调用。
5. 常见问题排查与替代方案权衡
实际项目里关于多态的问题,绝大多数集中在几个固定的模式上。这一节把它们整理成速查表,配上排查思路,最后再聊什么时候该避开虚函数。
5.1 问题速查表
| 现象 | 最可能的原因 | 排查方法 |
|---|---|---|
| 调用总是走到基类实现 | 基类函数漏了 virtual,或签名不一致导致隐藏 | 给覆盖函数加 override,重新编译看是否报错 |
| delete 基类指针后内存持续增长 | 基类析构不是 virtual | 在派生类析构里打日志,确认是否被执行 |
| 构造函数里调用虚函数行为异常 | 构造期间只派发到当前层 | 把初始化逻辑移到构造完成后显式调用 |
| 程序在构造/析构中直接崩溃 | 构造或析构期间调用了纯虚函数 | 检查虚函数调用链,尤其是析构中的清理逻辑 |
| dynamic_cast 返回空指针 | 对象已切片,或动态类型不符预期 | 检查是否有按值传参、按值存储 |
| 虚函数参数明明一样却不生效 | const、引用限定符、默认参数导致签名不同 | 对照基类声明逐字比对,用 override 验证 |
| 多重继承下指针地址对不上 | 不同基类子对象有不同偏移 | 用 void* 或统一基类比较,避免跨基类直接比较 |
5.2 三个踩坑记录
第一个坑来自一次代码重构。某开发者把一个原本用枚举区分类型的类改造成了继承结构,接口都写好了,测试也过了,但上线后发现有部分分支的处理逻辑没生效。排查了半天,发现是覆盖函数的参数类型从size_t写成了int,编译器没有报错,因为它被当成了一个新函数,而基类的纯虚函数在派生类里依然是“未实现”状态——按说纯虚函数没实现应该链接失败,但那个类恰好还有另一个派生类实现了它,链接器只检查了那一份。加上 override 之后,这个错误会在编译期直接暴露。
第二个坑和析构有关。某个缓存管理模块用基类指针持有多种缓存实现,析构函数忘记加 virtual。表面上看程序运行正常,因为大部分派生类析构里没有需要释放的东西。直到后来加了一个持有文件句柄的派生类,文件描述符开始缓慢泄漏,跑一天就耗尽。这类问题的麻烦之处在于它不会立刻崩溃,而是延迟暴露,排查时容易怀疑到别的地方去。
第三个坑是构造期虚调用。某同学在基类构造函数里调用了一个虚函数,期望派生类用它来完成初始化。结果每次都是基类版本执行,派生类的成员全是默认值,后面用的时候拿到了一堆零。他在派生类构造函数里又调了一次同样的虚函数作为“补救”,结果这次倒是走了派生类版本,但和基类那次调用加起来等于执行了两遍,有一遍是在成员还没初始化的时候跑的。最后的修法是把初始化拆成一个显式的init()方法,由工厂函数在构造完成后调用。
注意:这三个坑的共同点是“编译通过、运行不报错、行为错误”。C++ 在这方面的容错性比你想象的低,能用编译期检查手段(override、final、强类型枚举、
[[nodiscard]])的地方尽量用上,把问题往前挪。
5.3 什么时候别用虚函数
虚函数不是万能的,有些场景用别的工具更合适。这里列几种常见替代方案和它们的适用边界。
如果类型集合在编译期固定,且性能敏感,考虑 CRTP 或者普通模板。CRTP 的做法是把派生类类型作为模板参数传给基类,基类通过static_cast<Derived*>(this)调用派生类的方法,整个调用在编译期解析,可以被内联。代价是代码体积可能膨胀,且无法在运行时切换类型。
如果类型集合有限且变化不大,考虑std::variant加std::visit。它的好处是值语义、无堆分配、无需继承,缺点是新增类型时要改的地方可能比继承更多(取决于实现方式),而且编译时间会随类型数量增长。
如果只是需要“可替换的行为”而不是“可扩展的类型”,考虑std::function或者函数指针。回调、策略注入这类需求,用函数对象比建一套继承体系简单得多。缺点是每次调用有一次类型擦除的开销,比虚调用只贵不便宜,但代码结构更轻。
| 方案 | 扩展方式 | 运行时开销 | 适合场景 |
|---|---|---|---|
| 虚函数继承 | 新增派生类 | 间接调用,对象变大 | 类型会长期增长、需要动态加载 |
| CRTP | 新增派生类并重新编译 | 接近零 | 类型集合固定、性能敏感 |
| std::variant | 修改 variant 类型列表 | 状态访问,无堆分配 | 类型少、值语义、无继承关系 |
| std::function | 传入新的可调用对象 | 类型擦除,可能堆分配 | 回调、策略注入 |
我在实际项目里的取舍大致是:如果扩展点会跨越模块边界,或者需要以动态库形式加载,虚函数几乎是最省心的选择;如果是模块内部的算法切换,模板和函数对象往往更轻;如果一个类型集合已经稳定了两三年没变过,那用虚函数带来的灵活性其实是浪费,不如换成更直接的写法。
最后分享一个我自己一直在用的小习惯:每写一个基类,先在文件顶部写一行注释,说明这个类的设计意图——是接口(全纯虚)、是带默认实现的可扩展基类、还是仅用于多态删除的标记。这一行注释能省掉后来者(包括几个月后的自己)大量猜测。C++ 里同一个语法可以表达完全不同的设计意图,把意图写下来,比任何技巧都值钱。