谈谈 C++ 的不足与缺陷
C++ 是一个古老的编程语言,不可否认,它在程序界有着无可撼动的地位和话语权。
然而,如果考虑现代编程语言的要求和发展环境,C++ 实际上已经暴露出一些积重难返的问题。我使用 C++ 工作很多年了,过去的一年也熟悉了 Rust 这个新晋 C++ 挑战者,借此就谈谈 C++ 存在的问题。
开始前先叠个甲,我依然喜欢使用 C++,毕竟它是我最熟悉的工具,只是很多时候怒其不争。有关于它的优势,比如极致性能和兼容性,不是本文讨论的重点。
我梳理了下笔记,打算从语言规范、编译工具、标准库、安全性、软件生态和演进策略这几个角度来讨论。
语言规范:从丰富到失控
语法多样且混乱
C++ 的语法继承自 C 的风格,曾经,它总是被认为是“带类的C”,真正让它成为一个独立的现代编程语言,是直到 C++11 开始。
然而,这种继承下来的设计体系并不会那么容易改变。作为 C++ 程序员,自己总得承担一部分人肉语法解析器的工作。
比如说,不直观的类型声明语法、初始化列表和构造函数的二义性、函数声明和对象构造语法混杂。这种问题不像是一个现代编程语言应该有的东西,它让编写和阅读代码充满陷阱。
而由于C++兼容性的设计,将来也不太可能修复。
值类型体系复杂
只有 C++ 初学者需要上来就学习什么是左值、右值、将亡值、纯右值等概念,这些是编译器层面的概念,大多数语言都会隐藏起来,但在 C++ 里,若分不清这些概念,写出来的代码会有 bug。
甚至很多人自认为懂了,实际上却懂错了。我曾经面试过好几位有着多年 C++ 开发经验的同学,但什么是 std::forward 和 std::move,依然说不清楚。
隐藏在后边的行为
C++ 中有大量隐藏在代码背后的编译器行为,从程序表面上非常不直观。
比如对象的一次移动构造,在调用行为改变时,会悄悄地变成复制构造;改变一个值类型时,在代码另外一个位置,一个隐式类型转换就可能出错;看似一次简单的函数重构,却导致编译器放弃对这块代码做内联优化。在性能分析场景,这些悄然的行为变化,会引入非常麻烦的调试工作。
我们只能小心翼翼的编写代码,对每一笔看似人畜无害的改动,都拉出整个数据流图去分析,才能尽可能避免这些问题。所以有经验的 C++ 工程师才如此可贵。
模板带来的灾难性体验
C++ 的模板是个很独特的设计,它是图灵完备的,在设计复杂系统时非常灵活。但它同时也带来了编译慢、错误信息繁杂、代码膨胀等问题。
即便开发者决定在自己的项目中不使用模板,但一旦项目复杂度上去了,就不得不使用标准库或第三方的组件,那里边都是模板化编程的代码。若自己作为 C++ 工程师,不熟悉 SFINAE 这种约定是什么,那说明还谈不上 “熟悉C++”。
再叠加上函数重载机制,想直观地把握住某个函数调用实际上使用的实现体,就变得更不可靠了。
C++20 中,提出的 Concepts 概念之后才有所改善,但依然做不到像很多语言的泛型那样简洁清晰,Concepts 也并不是用来取代模版的。
头文件导入的问题
#include 头文件的本质是文本的复制,它直接导致了编译单元的代码膨胀、宏污染、重复编译和 ODR 违规风险。
同样的,C++20 中提出的 Modules 是专门来解决这个问题的。但编译器目前的支持还不完善,对于构建系统的实现更具挑战性。所以大多数项目依然没有切换为 Modules。
以软件更新的节奏来看,可能要等到 10 年后,才能看到纯粹的基于 Modules 开发的项目。
另外不太相关的一点,C++ 项目非常依赖预处理阶段对头文件、宏、条件编译等代码的展开,但这类无作用域,无类型信息的代码,却也是极容易隐藏 bug 的地方。
实用的语言级特性发布迟缓
在我看来,C++ 从 C++11 到 C++20 失去了宝贵的十年时间,很多实用且重要的语言级特性,迟迟难以发布。
比如反射机制,直到 C++26 才进入标准草案阶段。而像 coroutines 和 ranges 这种在其他编程语言中已经被广泛使用的功能,也在 C++20 才进入标准中。虽然异步运行时在 C++20 中被引入,但其过于底层以至于无法直接使用。而模式匹配和原生网络库这些在很多语言中很基础的组件,在 C++ 中至今没有通过。
我尊重 C++ 作为一门极其重要的系统级编程语言,它的变更带来的影响将极其深远。故而标准委员会谨慎些无可厚非。但这种“慢”,也实打实地伤害了它的评价和影响力。
深层次的原因我想到以下几点:
- 兼容性,还是兼容性:C++ 委员会推出的每一个新版本,都要求可以直接拿来编译旧代码。只有少数几个特性(比如旧版的
auto、std::auto_ptr),是经过仔细调查和讨论后,认为着实没必要兼容才被抛弃了。 - 零开销原则:支持新特性的大前提,是零开销,所以限制更多,难度更大。一些特性比如模式匹配始终难产的原因就是很难做到零开销。
- 委员会政治:似乎只有 C++ 语言有这个问题。每次开会讨论某个议题,不止是一群编程语言和编译器的大佬们在技术交锋,还有各个大企业和开源组织的代表在维护自己的利益。想要达成一致难上加难。
编译工具:严重落后于时代
编译速度落后
即便编译器已经很努力,在预编译技术、并行编译、增量构建这些操作之下,大型 C++ 项目的完整重构建时间仍然很慢,比如构建 Linux 或 llvm project 源码的时间,足够起身冲杯咖啡了。
根本原因还是前边提到的包含模型和模板,这是语言层面上的上限,很难根治。
各自为政的编译方言
关于 C++ 的可移植性的争论始终未停止。一些人认为,C++ 很灵活,设计良好的兼容性代码,会让 C++ 更易移植;另一些人认为,C++ 很开放,每种编译器和操作系统,都会加入自己定义的方言,导致 C++ 更不易移植。
两种角度两种观点,我不评论,但诸如 __attribute__ 和 __declspec 确实在很多代码中存在,它们使得业务代码和标准 C++ 之间存在不小的鸿沟。 事实上,移植 C++ 项目,是个很有挑战性的工作。
缺乏标准化的构建系统和包管理系统
缺少项目构建的描述约定,所以每个 C++ 程序员都需要熟练掌握 make, cmake, MSBuild 这些构建系统衍生工具,但这些并不是语言标准的一部分。它们都由各自的开发社区和团队在维护。
而包管理器更是始终空缺,vcpkg、conan、build2 都各有各的问题,至今没出现一个像 cargo 和 npm 这种标准且统一的方案。它也缺乏一个标准化的测试框架,各项目只能自行选择如 Google Test、Catch2 等第三方工具。
在拉取第三方组件库或本地源码构建一个 C++ 开源项目时,会不断与各种依赖和版本问题搏斗,这是很多人都经历过的事情。
编译器错误难以阅读
和其他问题相比,这一点其实算小问题。调皮一点说,我们 C++ 软件工程师是能自适应的(滑稽)。
C++ 编译器报错时,很多都会进入到标准库中,而标准库都使用模板来实现,而对模板的实例化回溯,就会吐出大量与错误位置无关的信息。各大编译器现在已经努力改进了,但其根本原因是 C++ 模板极高的抽象嵌套。
标准库:功能不足且设计不佳
核心功能长期缺失
C++ 的标准库质量相比于其他几种主流编程语言来说,其实一直都比较糟糕,以至于一个中大型 C++ 项目,总是不得不自己设计一些基础组件模块或者引入第三方依赖。
比如说,直到 C++17 才有了标准的文件系统,在这之前只能依赖于文件流或者特定系统提供的系统库接口来操作;标准库至今还没有任何 GUI 功能,开发者只能借助 Qt、GTK 这种第三方库来实现;另外,诸如网络、加密、数据库操作、科学运算、日志、多媒体处理等库,一直都是空白。
这些缺失的组件库只能借助于官方 “试验田” Boost 库,或者更多充满活力的第三方生态了。
历史遗留的设计问题
相比于功能缺失,有些库和接口的实现,被明确认为是设计缺陷。
比如说 std::vector<bool> 的特化行为,和其他标准容器接口不能无缝兼容;初始化列表和构造函数两种定义方式的二义性干扰,std::vector 是个典型的例子;而std::iostream 和 std::regex 的性能问题严重而备受批评。
类似的例子还有一些,不一一列举。
错误处理机制的问题
C++ 存在多种定义和传播错误的机制:异常、错误码、std::optional、std::expected 等。这些机制同时存在,导致不同的 C++ 项目,其错误处理的实现风格可能大相径庭。
其中,异常在很多对性能和安全敏感的场合直接禁用,比如工具链基础设施、游戏、嵌入式和高性能计算场景下。而由于标准库采用了异常机制,所以在这些项目中,选择哪些标准库组件可以用,是个不小的负担。
从语言机制和软件工程的角度来说,异常是成功的,但它所普及的时代却是水土不服的。在很多现存项目中,还是习惯于 -fno-exceptions禁用异常,然后自己设计传播错误的机制。
C++ 标准库的设计哲学是在性能、通用性和兼容性之间取得平衡。很多“缺陷”,其实也是在特定历史条件下的最优解,或是“零开销原则”下的必然选择。但不足就是不足,该批评还是要批评。
安全性:质疑声此起彼伏
未定义行为遍地都是
一个优秀的 C++ 工程师,其双眼中应该始终能看到代码中存在的未定义行为;一群优秀的 C++ 工程师,讨论一段代码的未定义行为就是家常便饭。越界访问、空指针解引用、有符号数溢出、数据竞争……
C++ 的设计原则中,把大量的安全责任直接推给了程序员,这多数是处于零开销原则的妥协。然而,现代编程语言应该倾向于默认安全,而不是默认不安全。
这就造成了 C++ 开发的项目质量参差不齐,极大的依赖于程序员的技术积淀和个人素质。但这个世界上永远都不缺 C++ 新手,他们总得需要项目来成熟。为此,我们不得不必须设计很多像 sanitizer 这样的工具来 double check 软件的安全性。
内存和并发安全问题同样突出
让 C++ 现在面临极大战略被动的一点,是现在被广泛质疑的内存安全问题。虽然智能指针解决了资源自动分配释放的问题,但悬挂指针、空指针解引用、迭代器失效等问题依然散落在项目和基础组件库代码中的很多角落。
而数据竞争的问题,C++ 从语言层面还无法解决,只能依赖于程序员的技术能力。
也正因为这一点,以内存安全著称的 Rust 才异军突起,对 C++ 发起直接挑战。
C++ 也并没有提出一个官方的、标准化的安全子集或编译器 “严格模式”,来允许在编译时就拒绝所有可能的未定义行为和资源安全问题。各个项目只能自行约定编码规范,然后通过松散的 code review 来保证。
软件生态:长期的迷茫探索
系统兼容性和跨平台
C++ 并没有标准的 ABI 约定,这是由于它为了给开发者极致的底层掌控力和零开销承诺。但因此却导致了程序源码在不同编译器、不同版本、甚至不同的编译选项下都可能生成不同的 ABI 标准产物。它们编译的库可能不能链接,跨平台程序无法直接运行。
这导致使用 C++ 开发的项目多依赖于源码来分发,或者为各种不同的软硬件配置生成特定的二进制产物。
工具链的野蛮生长
论编程语言工具链,没有哪个语言比 C++ 更丰富多彩了。不同型号和版本的编译器、调试器、链接器、linter、formatter、sanitizer、profiler 以及各个系统和厂商提供的定制化工具,层出不穷。
程序员需要理清整个编译链路,才能真的把握住一个项目的工程走向,因版本不同导致的问题也时有发生,而准确建立符号索引也变得更为困难。
身处苦难中的人们,只能不断地设计新的工具来查漏补缺。
缺乏统一的风格导向
你很难在两个不同的 C++ 软件项目中看到完全相同的编码风格和规范。几乎每个大型项目中都包含了一份核心指导原则,告诉每一个参与开发的程序员,什么东西不能用、建议怎么写代码、应该用什么风格。
它们有时是矛盾的,有时是繁琐的,而保证认真执行的一切,都建立在人与人之间的监督和审查中。而人是会有疏漏的,是会犯错的,是会疲惫的,甚至是有恶意的。
C++ 用几十年的时间,都没有专门去约定一份官方的指导标准,除了积重难返之外,我想不到其他原因。而这给开发者和整个社区带来的问题,就是碎片化的生态环境和永无休止的争执与内耗。
标准化的演进:追逐时代、蹒跚而行
严苛的向后兼容性枷锁
C++ 委员会对支持旧版本代码有着绝对的承诺,导致很多过去错误或不合理的设计无法修复。新特性必须在旧的语法框架下演进,这导致语言只能越更新越臃肿。即便每个新特性的推出,都经过了深思熟虑和广泛讨论,但那些被遗漏的失败设计,只能永远散落在众多历史项目中。
委员会当然意识到了这一点,但处于很多实际考量,最终实践上很难做到打破这一点。最终,只能建议让各项目自己去约束一个符合项目自身的合规子集。
所以,只有工作经验丰富的 C++ 软件工程师,而没有精通 C++ 的软件工程师。
科研驱动的影响力和缓慢的发布节奏
影响 C++ 标准发布的一批有极大话语权的人,他们热衷于研究计算机语言的科学原理,所以新特性的发布必须经过严肃且专业的评审。即便 Herb Sutter 想要支持的特性,有时也不得不多次推迟。
虽然 C++ 现在每 3 年发布一个版本,发布周期已经很短,但从特性被提出,到进入草案,到版本发布,再到主流编译器支持,最终能达到在生产环境中放心使用,往往需要至少大于 5 年的时间。这让 C++ 在面临现代软件开发节奏的方面依然步履迟缓。
比如说,协程最早在 2013 年第一次被正式提案,经过几年的讨论和迭代,在 2017 年发布正式的技术规范,最终在 2019 年进入 C++20 工作草案,并最终在 C++20 版本中包含。
形象问题
可以认为这一条是硬凑上去的,但它实打实也是一个问题。聊到 C++,无论是专职工程师还是吃瓜群众,都会出现这些负面印象:复杂、难上手、陷阱多、不安全、编译慢。
虽然理性的那些人会同样提出其正面印象:扩展性强、暴露底层实现、高性能、兼容性强,但当和那些有着愉快体验的编程语言和工具相比,C++ 真的显得老旧和不友好。
这不只是一个认知问题,它实实在在反映在真实的项目中。
C++ 是一个非常古老的语言,它能在过去的几十年依然保持活力,已经非常不容易,更不论它实际上和 C 语言一起,支撑起了整个软件世界大厦的地基。
它的核心调性早在它诞生之初就定下来了:极致的底层掌控力和绝对的性能优先,而冲突的一切都可以被牺牲。当现代软件工程开始注重安全性、开发效率和可维护性时,这种取舍便让它开始和时代格格不入。
可惜的是,兼容性的枷锁又让它无法轻装上阵。这些已知的问题,再过 30 年都不可能从语言演进中摆脱。
熟悉一门编程语言,熟悉你手里的工具,就必须同时了解它的优势和劣势,并在恰当的时间开始警惕和做出取舍。
本文同步发布在知乎账号下:谈谈 C++ 的不足与缺陷










