大多数语言里,抽象是有代价的:层次越高,跑得越慢。C++ 是少数例外——你用模板、类、lambda 写出来的代码,编译器能把它们压成和手写 C 甚至汇编相当的机器码。这不是编译器的魔法,而是一条被写进语言设计优先级里的原则:零成本抽象(zero-overhead abstraction)。

本文从”抽象是什么”讲起,拆解这条原则的三条含义、四种实现手段,以及它没解决的那些问题。

抽象的代价,与 C++ 的例外

C++ 的主战场是操作系统、数据库、游戏引擎、量化交易、高性能计算——这些领域的共同点是软件规模巨大且对性能敏感。

规模大,就必须用抽象来管理复杂度。没有人用汇编或机器码去写一个数据库。函数、结构体、类、模块、泛型、模板、AOP,这些概念都是语言设计者为了让人能构建大型程序而发明出来的,它们在 CPU 指令集里并不存在。

性能敏感,就意味着抽象不能变成运行时的额外开销。这两件事本身是冲突的,C++ 的选择是:让抽象在编译后消失。

抽象到底是什么

先把词说清楚。这里的”抽象”指的是中高级语言为了帮人类组织程序而创造的高层次概念——它们不是硬件概念:

抽象 解决的问题
函数 复用逻辑,隐藏实现
结构体 / 类 组织数据,封装不变式
多态(虚函数) 运行时根据实际类型分派
模板 / 泛型 一份代码适配多种类型
模块 划分编译单元与可见性

这些概念在机器语言里一个都不存在。CPU 只认识加载、存储、跳转、运算。所以每一个抽象,都必须由编译器翻译成机器指令——翻译得干不干净,就是”零成本”与否的分界线。

零成本抽象的三条含义

照着 Bjarne Stroustrup 的表述,这条原则包含三层意思:

  1. 用抽象写,不比不用抽象慢。 你没被强迫为了表达力付性能税。
  2. 不用抽象,就不产生任何代价。 特性是”按需付费”的,不用就零成本。
  3. 自己手写,也不会更快。 拿 C 语言用函数指针模拟虚函数,性能不会超过 C++ 的原生虚函数——C++ 的多态已经是”你自己实现也追不上”的水平。

第三条最容易被忽略,但它才是这条原则真正硬的地方。前两条只是”不比手写差”,第三条是”手写也追不上”——这意味着抽象不是妥协,而是最优解。

举一个具体的例子。一个只有虚函数的类,64 位下一个对象只多一个虚表指针(8 字节);一个没有虚函数的类,对象布局和 C 的 struct 完全一致:

struct Point { double x, y; };        // sizeof == 16,与 C struct 等价
struct Shape { virtual ~Shape(); };   // sizeof == 8,只多一个 vptr

不定义虚函数,就没有虚表指针,没有虚表,没有间接跳转。C++ 不会”顺手”给你加上去。

手段一:在编译期把抽象消灭掉

最彻底的做法是让抽象根本活不到运行时。

模板实例化。 模板函数在编译期展开、内联,编译产物里没有”模板”这个东西,只有被具体类型实例化出来的普通函数。

静态多态。 一般多态在运行时才决定调用哪个函数(查虚表、间接跳转,还可能挡住内联);CRTP 这类静态多态在编译期就确定目标函数,因此可以内联、可以常量传播,没有分派开销:

template <typename Derived>
struct Comparable {
    bool operator<(const Derived& rhs) const {
        return static_cast<const Derived*>(this)->value() < rhs.value();
    }
};

struct Money : Comparable<Money> {
    long long cents;
    long long value() const { return cents; }
};

a < b 在内联后就是一次整数比较,没有函数指针,没有虚表。

编译期求值。 constexpr、consteval 和折叠表达式把计算搬到编译器里做(C++17 起支持折叠表达式):

template <typename... Ts>
constexpr double sum_of_squares(Ts... xs) {
    return (... + (xs * xs));        // 折叠表达式,C++17
}

constexpr double s = sum_of_squares(3.0, 4.0);   // 编译期算出 25

顺带纠正一个常见误会:std::sqrt 在 C++20 和 C++23 里都不是 constexpr,直到 C++26 才成为常量表达式函数。所以在 C++20 想写编译期的 l2_norm,得自己实现一个 constexpr 的牛顿迭代开方,不能直接调 std::sqrt。这也是”零成本”的边界所在——标准库不一定跟得上语言特性。

手段二:按需付费

C++ 的特性大都是可裁剪的,不用就不生成代码、不占内存:

特性 不用时的代价
异常 不抛不捕获,就没有异常表与清理路径的开销(-fno-exceptions 可彻底关掉)
RTTI 不用 dynamic_cast / typeid,编译器就不生成类型信息表、类型名字符串、额外的对象元数据
虚函数 不定义虚函数,对象里就没有虚表指针
模板 不实例化,就不生成任何代码

RTTI 是最好的例子。只要你的代码里没有 dynamic_cast、没有 typeid、也没开需要 RTTI 的编译选项,那 RTTI 在你的程序里就”完全不存在”——没有类型信息表,没有类型名,没有额外检查。它是语言的一部分,但不是你二进制的一部分。

手段三:抽象不遮挡硬件

C++ 的对象布局、内存访问、指针行为都直接对应机器模型。语言层不插入虚拟机、不做垃圾回收、不接管对象布局,所以你能控制内存、控制布局、控制性能,也能预测编译器会生成什么。

对比 Java 或 Python,那里抽象层厚得多:对象必须堆分配、内存由 GC 托管、类型布局由 VM 决定、运行时模型和你写的代码并不一致。这些设计换来了安全与开发效率,代价是——你无法准确控制性能,无法预测真实的对象布局,无法决定资源何时释放,也无法消除隐藏成本。

C++ 刻意不这么做。它的立场是:

抽象能让你写得更轻松,但不能妨碍你直接控制硬件。

手段四:没有全局虚拟机

C++ 没有统一的运行时,没有全局调度器,也没有强制的中间层。编译器因此可以做相当激进的优化:

  • 内联:消除函数调用开销——这是所有抽象”归零”的前提,没有内联,包装类型和 lambda 都要付出代价
  • noexcept:告诉编译器这段代码不会抛,异常路径的清理代码可以直接省掉,容器扩容时也会优先走移动而非拷贝
  • [[likely]] / [[unlikely]]:把分支概率写给编译器,改善代码布局与分支预测
  • 类型信息:静态类型让编译器知道确切布局,从而做更激进的数据布局优化和去虚化

这些机制叠在一起,才有了”抽象几乎不带运行时成本,不用的特性不产生任何代价”这个结果。

代价在哪里

零成本抽象说的是运行时零成本,它没承诺别的地方也免费。诚实地讲,代价被转移到了这几处:

  • 编译时间:模板实例化、内联、常量求值都要在编译期做完,重模板代码的编译时间是实打实的支出
  • 二进制体积:每个类型都实例化一份,代码膨胀是常态
  • 错误信息:模板报错能长达几百行,诊断可读性一直是弱项(Concept 就是在补这个短板)
  • 调试体验:激进内联之后,调试器里的调用栈和你写的代码对不上
  • ABI 脆弱:布局暴露给使用者,改一个成员就可能破坏二进制兼容

所以这条原则准确的表述是:把成本从运行时挪到编译期,并且严格按使用付费。它换的是程序跑得快,不是开发者写得轻松。

一句话总结

零成本抽象不是”没有抽象”,而是”抽象在编译后消失”。模板替你展开,静态多态替你定分派,constexpr 替你算完,noexcept 和 [[likely]] 替你告诉编译器哪条路是热的——剩下的事情交给优化器。

这就是 C++ 能撑住操作系统、数据库、游戏引擎这些场景的原因:写得高级,跑得低级。


视频版:B站「谦信CPP」· 现代 C++ 之道 第二集