Bjarne Stroustrup 与 C++:为什么零成本抽象很重要
了解 Bjarne Stroustrup 如何围绕“零成本抽象”塑造 C++,以及为何性能关键的软件仍依赖它对控制、工具链和生态的支持。

这篇文章讲什么(以及为什么重要)
C++ 的诞生带着一个明确的承诺:你应该能够编写富有表达力的高层代码——类、容器、泛型算法——而不会因此自动付出额外的运行时代价。如果你不使用某个特性,就不应为它付费;如果你使用它,代价应接近你用低级风格手写的代码。
本文讲述了 Bjarne Stroustrup 如何把这个目标塑造成一门语言,以及为什么这个理念至今仍然重要。它也是一份实用指南,面向关心性能、想理解 C++ 优化目标(超越口号)的人。
这里“高性能软件”的含义
“高性能”并不只是让基准数字更好看。更通俗地说,通常至少包含以下某项约束:
- 低延迟: 工作必须在很紧的时间预算内完成(毫秒或微秒级)。
- 高吞吐量: 系统每秒必须处理大量事务(请求、帧、交易、数据包)。
- 资源受限: CPU、内存、电池或功耗受限,浪费的工作会很快显现出来。
当这些约束真实存在时,隐藏的开销——额外分配、不必要的拷贝或不该有的虚调用——可能就是“能运行”与“达不到目标”之间的差别。
C++ 今天出现在哪里
C++ 常用于系统编程和性能关键组件:游戏引擎、浏览器、数据库、图形流水线、交易系统、机器人、通信和操作系统的部分模块。它不是唯一选择,许多现代产品混合使用多种语言。但当团队需要直接控制代码如何映射到机器时,C++ 仍然是常见的“内循环”工具。
接下来我们将以通俗的方式拆解“零成本”思想,随后把它与具体 C++ 技术(如 RAII 与模板)联系起来,并讨论团队面临的实际权衡。
Bjarne Stroustrup 的目标:无惩罚的抽象
Bjarne Stroustrup 的出发点并不是“为了发明一门新语言”。在 1970 年代末到 1980 年代早期,他在做系统工作时发现 C 很快且贴近机器,但大型程序难以组织、难以修改且易出错。
他的目标简单但难以实现:在不放弃 C 的性能和硬件访问能力的前提下,引入更好的大型程序结构化方法——类型、模块、封装。
从“带类的 C”到 C++
最早的名称字面就是 “带类的 C(C with Classes)”。这个名字暗示了方向:不是全新设计,而是演进。保留 C 已有的优点(可预测的性能、直接内存访问、简单的调用约定),再添加构建大型系统所缺少的工具。
随着语言发展为 C++,新增的不只是“更多特性”,而是旨在使高层代码在良好使用时能编译为与手写 C 代码相近的机器码。
设计的张力:便利性 vs 控制
Stroustrup 的核心张力在于:
- 便利性: 更安全的默认、更可重用的组件、富表达的抽象。
- 控制: 能选择布局、管理生命周期并推断开销。
许多语言通过隐藏细节来选择一方(但这可能隐藏开销)。C++ 试图让你构建抽象的同时仍能询问“这要花什么代价?”,并在需要时降到低层操作。
这个动机——无惩罚的抽象——把 C++ 最初的类支持与后来像 RAII、模板和 STL 的思想连在一起。
零成本抽象:用通俗的话说
“零成本抽象”听起来像个口号,但它实际上描述了一种权衡。日常表述是:
如果你不使用它,就不要为它付费;如果你使用它,你应该付出的代价与手动写低级代码差不多。
“成本”到底指什么
在性能术语中,“成本”是使程序在运行时多做工作的任何东西,包括:
- 额外的 CPU 指令
- 隐蔽的内存分配
- 更多的指针间接(访问数据多“跳”)
- 在本可直接调用处的虚调用与动态分派
- 你没要求的看不见的记账(引用计数、日志钩子、安全检查)
零成本抽象的目标是让你编写清晰的高层代码(类型、类、函数、泛型算法),同时生成的机器码像手写循环和手动资源管理那样直接。
重要的反面说明
C++ 并不会自动让一切都快。它只是使得可能写出高层代码并编译为高效指令——但你仍然可以选择昂贵的模式。
如果你在热点循环里分配、频繁拷贝大对象、错过缓存友好的数据布局,或构建阻碍优化的多层间接,程序仍会变慢。C++ 不会阻止你犯这些错。“零成本”是避免被迫的开销,而不是保证你总是做出好选择。
后续内容
下文将把理念具体化。我们会看编译器如何消除抽象开销、为什么 RAII 既安全又可能更快、模板如何生成像手工调优一样运行的代码,以及 STL 如何在不引入隐蔽运行时工作的前提下提供可复用的构建块——前提是使用得当。
C++ 如何让抽象便宜:编译器做了什么
C++ 依赖一条简单交易:在构建时多付出一些代价以便在运行时少付出。编译时,编译器不仅仅是翻译代码——它努力移除那些会在运行时显现的开销。
在构建时付费
编译过程中,编译器可以“预付”许多开销:
- 内联:用函数体替换函数调用。
- 常量折叠:提前计算常量表达式。
- 优化通道:简化控制流、移除死码、优化循环。
目标是让你清晰可读的结构在最终机器码中接近手写的版本。
直观示例
像这样的辅助小函数:
int add_tax(int price) { return price * 108 / 100; }
在编译后常常会变成没有函数调用。编译器可能直接把算术表达式粘贴到使用处,而不是“跳转到函数、设置参数、返回”。抽象(一个好名的函数)实际上消失了。
循环也会被优化:对连续范围的简单循环在可证明安全的情况下可以移除边界检查、将重复计算提升到循环外、并重组织循环体以更高效地利用 CPU。
“消失的抽象”
这就是零成本抽象的实际意义:你得到更清晰的代码,而不必为你用来表达的结构长期付出运行时代价。
权衡
没有免费的午餐。更重的优化和更多“消失的抽象”可能意味着更长的编译时间和有时更大的二进制文件(例如多个调用点被内联时)。C++ 给你选择权和责任,让你在构建成本与运行速度之间权衡。
RAII:通过自动清理实现安全与速度
RAII(资源获取即初始化)是个简单规则但后果深远:资源的生命周期与作用域绑定。对象创建时获取资源,对象离开作用域时由析构函数释放它——自动完成。
“资源”可以是你必须可靠清理的几乎任何东西:内存、文件、互斥锁、数据库句柄、套接字、GPU 缓冲区等。你不再需要在每个路径上记得调用 close()、unlock() 或 free(),而是把清理放在一个地方(析构函数),由语言保证其执行。
为什么 RAII 往往既更快又更安全
手动清理会产生“影子代码”:额外的 if 检查、重复的返回路径处理以及在每个可能失败点后的清理调用。随着函数演进,很容易遗漏某个分支。
RAII 通常生成直线代码:获取、工作、让作用域退出处理清理。这样既减少了 bug(内存泄漏、双重释放、忘记解锁),也减少了热路径上的防御性记账开销。从性能角度看,热点路径中更少的错误处理分支会改善指令缓存行为并减少错误分支预测带来的损失。
可预测的性能——更少的意外
泄漏和未释放的锁不仅是“正确性问题”,也是性能炸弹。RAII 使资源释放可预测,有助于系统在负载下保持稳定。
关于异常的说明
RAII 在处理异常时非常有用,因为栈展开会调用析构函数,资源在控制流意外跳转时也会被释放。异常本身是一个工具:它们的代价取决于用法以及编译器/平台的设置。关键点是 RAII 让清理在任何离开作用域的方式下都确定发生。
模板与泛型代码:像手工代码一样运行
模板常被描述为“编译时的代码生成”,这是个有帮助的思维模型。你写一次算法(比如“对这些元素排序”或“存储元素到容器”),编译器为你使用的具体类型生成专门化版本。
编译时专门化(没有运行时账单)
因为编译器知道具体类型,它可以内联函数、选择合适的操作并进行激进优化。在许多情况下,这意味着可以避免虚调用、运行时类型检查和为使“泛型”代码工作而需要的动态分派。
例如,为整型生成的模板 max(a, b) 可能最终只是一两条机器指令。相同模板用于小结构体时也可以编译为直接比较与赋值——没有接口指针或运行时“这是什么类型?”的检查。
你已经用过的泛型编程
标准库大量依赖模板,因为它们能在不引入隐蔽工作量的情况下让常见构建块可复用:
- 像
std::vector<T>与std::array<T, N>这样的容器直接存储你的T。 std::sort等算法能作用于只要可比较的多种数据类型。- 迭代器让相同算法能在向量、数组和自定义集合上工作。
结果是代码常常像手写的类型专用版本一样高效——因为它实际上变成了类型专用代码。
权衡
模板对开发者并非完全免费:它们会增加编译时间(更多代码要生成和优化),并且出错时错误信息可能冗长难读。团队通常通过编码准则、良好工具链和把模板复杂度控制在收益明显处来应对。
STL:在无隐蔽开销的前提下提供可复用构建块
标准模板库(STL)是 C++ 的内置工具箱,用于编写可复用且仍能编译为紧凑机器指令的代码。它不是你要“额外添加”的独立框架——它是标准库的一部分,并以零成本思想为设计目标:使用高层构建块而不为未要求的工作买单。
三大支柱:容器、算法、迭代器
- 容器 用于存储数据:
vector、string、array、map、unordered_map、list等。 - 算法 在元素范围上工作:
sort、find、count、transform、accumulate等。 - 迭代器 是“胶水”,让算法通过统一接口作用于多种容器类型。
这种分离很重要。容器不必各自重写“sort”或“find”,STL 给你一组经过良好测试的算法,编译器可以对它们做激进优化。
正确使用时的效率
STL 代码可以很快,因为许多决策发生在编译时。如果你对 std::vector<int> 进行排序,编译器知道元素类型与迭代器类型,它可以内联比较并像手写代码那样优化循环。关键是选择与访问模式匹配的数据结构。
实用容器建议(没有绝对法则)
-
vectorvs.list:vector通常是默认选择,因为元素在内存中连续,对遍历和随机访问友好。只有在确实需要稳定迭代器并频繁在中间拼接/插入且不想移动元素时才考虑list,但它对每个节点有额外开销并且遍历速度可能更慢。 -
unordered_mapvs.map:unordered_map通常是快速的平均查找选择。map保持键有序,适合区间查询(例如“所有在 A 与 B 之间的键”)和可预测的迭代顺序,但查找通常比好的哈希表慢。
更多深度指南见 /blog/choosing-cpp-containers
支持零成本目标的现代 C++ 特性
现代 C++ 并没有放弃 Stroustrup 的“无惩罚抽象”初衷。许多新特性专注于让你写更清晰的代码,同时仍给编译器机会生成紧凑的机器码。
移动语义:在转移所有权时避免拷贝
不必要的拷贝(重复复制大字符串、缓冲区或数据结构)是常见慢点。移动语义的简单思想是“如果你只是把东西交出,就不要复制”。当对象是临时的(或你完成了对它的使用)时,C++ 可以把其内部资源转移给新所有者,而不是复制它们。对日常代码来说,这通常意味着更少的分配、更少的内存流量和更快的执行——而无需你逐字节地管理内存。
constexpr:提前计算以减少运行时工作
有些值与决策永远不会改变(表大小、配置常量、查找表)。使用 constexpr,你可以让编译器在编译期计算某些结果,这样运行时就做更少的事。好处是速度与简洁:代码能像普通计算一样书写,而结果可能“烘焙”成常量。
ranges 与更清晰的迭代(无隐蔽开销)
Ranges(以及类似的视图)让你以可读的方式表达“取这些项目、过滤它们、转换它们”。在良好使用下,它们可以编译为直接的循环——而不会强制引入运行时层。
现实的说明:零成本是目标,不是保证
这些特性朝零成本方向发展,但性能仍取决于如何使用它们以及编译器能否优化最终程序。清晰的高层代码往往能被很好地优化——但在真正关心速度的地方仍然值得进行测量。
真实 C++ 代码中性能得失的关键所在
C++ 可以把“高层”代码编译成非常快的机器指令——但它并不自动保证结果就是快。性能通常不是因为你用了模板或清晰抽象而丢失,而是因为小的成本在热点路径上被成百万次地放大。
常见的无意开销来源
反复出现的一些模式有:
- 不必要的分配(在堆上创建大量短生命周期对象)及其隐蔽开销。
- 拷贝而不是移动或引用,尤其是容器或大结构体。
- 因内存布局分散导致的缓存未命中(到处都是指针,数据不在一起)。
- 在紧循环中的虚调用,使编译器难以内联调用。
- 争用(线程争抢锁、原子或共享队列),使“快速代码”花时间在等待上。
这些问题不是“C++ 的问题”,而是设计和使用的问题——任何语言中都可能存在。区别在于 C++ 给你足够的控制来修复它们,也给你足够的自由去造成它们。
实用经验法则
养成有助于保持成本模型简单的习惯:
- 先测量再猜测。 尤其是在缓存与并发方面,你的直觉往往错误。
- 在热点代码中减少分配。 复用缓冲区、预留容量、避免在内部循环中构造临时容器。
- 在性能敏感处优先简单、连续的数据布局。 更少指针、更多“元素数组”通常优于“对象图”。
- 让热点路径平淡无奇。 可内联的函数、可预测分支和最小同步是朋友。
轻松的性能剖析
使用能回答基本问题的剖析器:时间花在哪儿?发生了多少分配?哪些函数被最多调用?把这些与针对性基准结合起来。持续这样做时,“零成本抽象”会变得可行:你保留可读代码,然后去除测量中出现的具体开销。
为什么性能敏感行业仍然选择 C++
C++ 在那些毫秒或微秒不是“可选项”而是产品要求的领域持续出现。你常会在低延迟交易系统、游戏引擎、浏览器组件、数据库与存储引擎、嵌入式固件和高性能计算(HPC)工作负载中找到它。这些并不是全部用例,但它们说明了语言持续存在的原因。
可预测的延迟与显式控制
许多性能敏感领域更关心的是可预测性:导致帧丢失、音频卡顿、错失交易机会或错过实时截止的尾延迟。C++ 让团队决定何时分配内存、何时释放以及数据如何在内存中布局——这些选择强烈影响缓存行为与延迟尖峰。
因为抽象可以编译为直接的机器码,C++ 代码可以为了可维护性而组织结构,同时不会自动为那种结构付出运行时开销。当你确实付出代价(动态分配、虚调用、同步)时,通常是可见且可测量的。
适配既有生态(尤其是 C)
实际原因之一是互操作性。许多组织有几十年的 C 库、操作系统接口、设备 SDK 与经受考验的代码,无法简单地全部重写。C++ 能直接调用 C API,在需要时暴露与 C 兼容的接口,并逐步现代化代码库,而无需一次性迁移。
工具链、硬件访问与部署现实
在系统编程与嵌入式工作中,“贴近金属”仍然重要:直接访问指令、SIMD、内存映射 I/O 和平台特定优化。结合成熟的编译器与剖析工具,当团队需要在控制二进制、依赖项与运行时行为的同时尽可能榨取性能,C++ 常被选用。
困难之处:复杂性、安全性以及团队如何应对
C++ 因能极快且灵活而赢得忠诚——但这种能力有代价。人们的批评并非凭空而来:语言庞大、旧代码中存在风险习惯,失误可能导致崩溃、数据损坏或安全问题。
为什么 C++ 会显得难
C++ 经历了数十年演进,痕迹明显。你会看到多种实现同一目的的方式,以及会惩罚小错误的“尖锐边缘”。两个常见的痛点:
- 复杂性: 模板、重载与构建系统会让调试和入门比小语言更难。
- 未定义行为: 一些错误(如读取无效内存或违反类型规则)不会稳定失败;它们可能看似“可行”,直到编译器更新或新优化改变结果。
旧有模式也增加风险:原始的 new/delete、手动内存所有权与未经检查的指针运算在遗留代码中仍常见。
团队如何降低风险(没有魔法保证)
现代 C++ 实践主要是通过采纳准则与更安全的子集来获得好处并避免“脚枪”。团队一般通过以下方式减少失败模式:
- 优先使用 RAII 类型和标准容器(
std::vector、std::string),而非手动分配。 - 使用 智能指针(
std::unique_ptr、std::shared_ptr)让所有权显式。 - 开启警告并严肃对待,把它们当作错误;使用
clang-tidy等规则执行风格。 - 在测试中运行 sanitizers(AddressSanitizer、UndefinedBehaviorSanitizer)以早期捕获问题。
- 在不受信任输入处加入静态分析与模糊测试(fuzzing)。
发展方向
标准在朝着更安全、更清晰的代码方向演进:更好的库、更具表达力的类型,以及关于契约、安全指导和工具支持的持续工作。权衡依旧存在:C++ 给予你杠杆,但团队必须通过纪律、审查、测试和现代约定来换取可靠性。
实用决策指南:何时(以及如何)押注 C++
当你需要对性能与资源进行精细控制并且愿意在纪律上投入时,C++ 是不错的选择。这不是“C++ 更快”的简单结论,而是“C++ 让你决定工作何时发生、以何种代价发生”的事实基础。
何时适合选 C++
当下面大多数条件成立时请选择 C++:
- 你有严格的延迟、吞吐或内存限制(实时系统、交易、游戏、渲染、嵌入式)。
- 你需要与硬件、操作系统 API 或现有 C/C++ 库紧密集成。
- 启动时间与可预测性能比快速迭代更重要。
- 你能配备愿把安全与测试当作一等任务的工程师。
考虑其他语言的场景:
- 如果开发速度、安全默认和简化部署更重要(许多 Web 后端、内部工具),Rust、Go、Java/Kotlin、C# 或 Python 可能会降低风险。
- 如果团队缺乏 C++ 经验且无法为培训、工具与审查预算时间,也应考虑替代方案。
- 如果你实际上不需要对分配、数据布局或尾延迟的控制,则无需使用 C++。
团队的实用清单
如果选择 C++,请及早设定护栏:
- 编码准则: 采纳现代基线(C++17/20),优先 RAII,避免原始
new/delete,有意使用std::unique_ptr/std::shared_ptr,在应用代码中禁止不受检的指针运算。 - 代码审查重点: 生命周期/所有权、异常安全、隐蔽分配、拷贝与移动、线程安全与 API 清晰度(谁拥有什么?)。
- 工具链: 警告当作错误、sanitizers(ASan/UBSan/TSan)、静态分析与格式化工具。
- 基准文化: 定义代表性工作负载、在改动前后测量、追踪延迟百分位(而不只是平均值),并在 CI 中保留性能测试。
简单学习路径
- 现代基础: 值类型、引用、RAII、标准库与清晰接口设计。
- 核心性能基础: 数据结构、缓存友好性、分配策略与剖析方法。
- 高级工具: 模板/泛型、并发原语,以及在必要时阅读编译器输出。
如果你在评估选项或计划迁移,也建议把内部决策记录下来并分享到团队空间(如 /blog),便于将来招聘与利益相关者查阅。
Koder.ai 在这幅图中的角色
即便你的性能关键核心保留在 C++ 中,许多团队仍需快速交付外围产品代码:仪表盘、管理工具、内部 API 或用于验证需求的原型,以免一开始就投入底层实现。
这正是 Koder.ai 可补充的地方。它是一个基于对话的低代码/试验平台,能从聊天界面生成 Web(React)、后端(Go + PostgreSQL)、移动(Flutter)等应用,支持规划模式、源码导出、部署/托管、自定义域名以及带回滚的快照功能。换言之:你可以在“热点路径”之外快速迭代产品其余部分,同时把 C++ 组件集中在那些零成本抽象与精细控制最重要的地方。
常见问题
C++ 中的“零成本抽象”是什么意思?
“零成本抽象”是一个设计目标:如果你不使用某个特性,它不应增加运行时开销;如果你使用它,生成的机器代码应接近你用低级风格手写的代码。
在实践中,这意味着你可以写更清晰的代码(类型、函数、泛型算法),而不会自动付出额外的分配、间接访问或调度开销。
本文所说的“成本”都指哪些?
在这里,“成本”指会在运行时带来额外工作的任何东西,例如:
- 额外的 CPU 指令
- 隐蔽的堆分配
- 更多的指针间接与缓存未命中
- 阻止内联的虚函数调用
- 你没要求的记账工作(检查、引用计数、钩子)
目标是让这些成本可见,并避免强制把它们施加到每个程序上。
什么时候 C++ 的抽象实际上会“几乎免费”?
当编译器能够在编译时看穿抽象时效果最好——常见情况包括被内联的小函数、编译期常量(constexpr)以及用具体类型实例化的模板。
当运行时间接占主导(例如热循环中大量虚调用)或频繁分配与指针跳跃的数据结构出现时,这种做法效果就差一些。
编译器如何“抹去”抽象开销?
C++ 把许多开销转移到构建期,从而让运行时更精简。典型例子:
- 内联:消除调用开销并启用更多优化。
- 常量折叠:预先计算表达式。
- 死码消除:移除未使用的分支。
要受益,请用带优化的编译选项(例如 -O2/-O3)编译,并以便于编译器推理的方式组织代码。
在日常 C++ 代码中如何应用 RAII?
RAII(资源获取即初始化)将资源的生命周期绑定到作用域:构造时获取,析构时释放。用于内存、文件、锁、套接字等。
实用习惯:
- 优先使用标准 RAII 类型(
std::vector、std::string)。 - 将操作系统资源封装在小的守护(guard)对象中。
- 避免在每个返回路径手动清理;让析构函数可靠地完成清理。
异常与高性能是否不兼容?
RAII 在异常处理中尤其有用,因为在栈展开时会调用析构函数,资源会被释放。
性能上,异常通常在“抛出”时代价高,而不是在“可能抛出”时。如果你的热路径频繁抛出异常,应改用错误码或类似 expected 的结果;如果抛出确实是例外情况,RAII + 异常通常能保持快速路径的简单性。
为什么模板常常像手写代码一样高效?有什么代价?
模板允许你写一次泛型代码,编译器在具体类型处生成专门化代码,从而常常启用内联并避免运行时类型检查。
需要权衡的地方:
- 更长的编译时间
- 在某些情况下更大的二进制体积
- 更冗长难懂的错误信息
把模板复杂度放在收益明显的核心算法或可复用组件上,避免过度模板化应用层胶水代码。
如何在 vector 与 list,unordered_map 与 map 之间选择?
通常默认选择 std::vector,因为它的元素在内存中连续,遍历和随机访问时缓存友好。只有在确实需要稳定迭代器并且频繁在中间拼接/插入而不移动元素时才考虑 std::list。
键值映射选择:
std::unordered_map:平均情况下查找快(哈希表)。std::map:保持键有序,适合区间查询和可预测的迭代顺序,但查找通常比哈希表慢。
更多容器选择指南见 /blog/choosing-cpp-containers。
真实 C++ 代码中最常见的性能错误有哪些?
常见会放大成本的错误模式有:
- 在内部循环中不必要的分配(应复用缓冲区、使用
reserve())。 - 复制而不是移动或引用,尤其是容器或大结构体。
- 因为内存分散导致的缓存未命中(指针随处可见、数据未聚集)。
- 紧循环中的虚调用阻止内联。
- 在热点路径上锁竞争(锁/原子操作)导致等待。
用性能剖析而不是直觉来验证问题并优先修复真正的热点。
哪些实践能帮助团队在不丧失性能的情况下更安全地使用 C++?
早期设立护栏可以让性能与安全不依赖个别高手的技巧:
- 采用现代基线(C++17/20),优先 RAII 与标准容器,避免原始
new/delete。 - 明确所有权(
std::unique_ptr/std::shared_ptr的有意使用)。 - 将编译器警告当作错误、使用
clang-tidy等工具并严格执行风格规则。 - 在 CI 中运行 sanitizers(ASan/UBSan/TSan)。
- 为代表性工作负载保留基准测试与性能回归检测。
这些实践帮助保留 C++ 的控制力,同时减少未定义行为和意外开销。