现代C++白皮书(C++ 2006-2020)
Table of Contents
1. 现在C++的文艺复兴
C++ 作为一门博大精深的语言,其发展演化历程也堪称波澜壮阔。由于教育的原因,很多人对 C++ 还停留在 C++98 之前的版本。殊不知 C++ 在经历从 2006 年之后至今的 15 年“激流勇进式的发展”,在很多人眼里已经近乎变为一个全新的语言。这 15 年间,国际 C++ 标准委员会发布的 4 个版本:C++11/14/17/20 也被统称为“现代 C++”。因为这一时期对 C++ 发展的里程碑作用,我将其称为“现代 C++ 的文艺复兴”。
Bjarne 对这个问题在书中有着很清晰的回答:C++ 在其近 40 年的发展中取得成功的根本原因是,它填补了编程领域一个重要的“生态位”:需要有效使用硬件和管理高复杂性的应用程序。C++ 的核心精神“直接映射硬件”和“零开销抽象”正是对这一“生态位”恰如其分的支撑。换言之,如果不那么在乎性能开销,那么 C++ 并不是最好的语言选择(Java、Go、Python 等正是填补了这些领域);或者软件规模不大、无需很多抽象手段来管理软件的复杂度,那么 C 语言就足够。但如果性能是软件的关键指标,同时又有越来越大的复杂度,那么 C++ 几乎是独一无二的选择。我们看到 C++ 这些年来的发展,都是紧扣 40 年前 Bjarne 为 C++ 设定的“生态位”与“核心精神”而开展的。只有深刻理解这一点,才能从根本上抓住 C++ 的发展脉络。
本书另外一个难能可贵的地方是 Bjarne 对于 C++ 语言发展过程中一些缺失之处也有非常深刻的反思。比如对于标准委员会过于关注语言和库的设计,而忽略“动态链接、构建系统和静态分析等工具设施”,Bjarne 也直言是一大错误。再比如,对于很多专家的各种奇思妙想,Bjarne 甚至在 2018 年写了一篇文章《记住瓦萨号!》来提醒标准委员会,追求大而全的新奇功能,而忽略稳定性对 C++ 是非常危险的,后来为此领导标准委员会“方向组”提出《C++ 程序员的“权利法案”》。 Bjarne 还谈到 2006 年是 C++ 发展的最低谷,那时候本来打算推出的 C++0x 标准由于委员会的决策机制和实现问题而变得遥遥无期。另一方面单核处理器的性能停止提高(Herb Sutter 有著名的文章:“The Free Lunch Is Over”),这种环境对 C++ 语言的期待其实很高,但 C++ 那时候的发展缓慢,将很多本来是 C++ 的机会拱手让位给了很多其他商业语言。
2. 前言和背景
C++ 在 1980 年代仅仅是一个基于 C 和 Simula 语言功能的组合,在当时的计算机上作为系统编程的相对简单的解决方案,经过多年的发展,已经成长为一个远比当年更复杂和有效的工具,应用极其广泛。它保持了如下两方面的关注:
- 语言结构到硬件设备的直接映射
- 零开销抽象
这种组合是 C++ 区别于大多数语言的决定性特征。“零开销”是这样解释的 [Stroustrup 1994]:
- 你不用的东西,你就不需要付出代价(“没有散落在各处的赘肉”)。
- 你使用的东西,你手工写代码也不会更好。
- 抽象在代码中体现为函数、类、模板、概念和别名。
有一种流传广泛的谬见,就是程序员希望他们的语言是简单的。当你不得不学习一门新的语言、不得不设计一门编程课程、或是在学术论文中描述一门语言时,追求简单显然是实情。对于这样的用途,让语言干净地体现一些明确的原则是一个明显的优势,也是理想情况。当开发人员的焦点从学习转移到交付和维护重要的应用程序时,他们的需求从简单转移到全面的支持、稳定性(兼容性)和熟悉度。人们总是混淆熟悉度和简单,如果可以选择的话,他们更倾向于熟悉度而不是简单。
看待 C++ 的一种方式是,把它看成几十年来三种相互矛盾的要求的结果:
- 让语言更简单!
- 立即添加这两个必要特性!
- 不要搞砸我的(任何)代码!!!
请注意,早年 C++ 的库是很匮乏的。事实上,当时还是存在大量各种各样的库 (包括图形用户界面库),但很少被广泛使用,并且很多库是专有软件。这是在开源开发普及之前的事。这造成了 C++ 社区没有一个重要的共享基础库。在我的 HOPL2 论文 [Stroustrup 1993] 的回顾中,我认为那是早期 C++ 最糟糕的错误。
为了能够理性思考 C++ 的成长,我想出了一套设计规则。这些在 [Stroustrup 1993, 1994] 中有介绍,所以这里我只提一小部分:
- 不要陷入对完美的徒劳追求。
- 始终提供过渡路径。
- 说出你的意图(即,能够直接表达高层次的思路)。
- 不要隐式地在静态类型系统方面违规。
- 为用户定义类型提供和内置类型同样好的支持。
- 应取消预处理器的使用。
- 不要给 C++ 以下的低级语言留有余地(汇编语言除外)。
2006 年,很少有人注意到硬件趋势对 C++ 固有优势的滋养。而社区和标准委员会正在关注新的语言特性和库,以增加 C++ 的实用性并提高对它的热情。包括我在内的一些委员感到迫切需要重大改进。其他人更关注于稳定语言和改进它的实现。一个标准委员会需要这两个群体,但创新和整顿之间不断的拉锯战是紧张的来源。就像在任何大型组织中一样,维护现状和服务当前用户的人有组织上优势。在《C++ 程序设计语言(第三版)》[Stroustrup 1997] 中,我引用了尼科洛·马基雅维利(Niccolo Machiavelli)的话:
没有什么比开创一种新秩序更难于推行、更让人怀疑能否成功、处理起来更加危险。因为改革者会与所有从旧秩序中获利的人为敌,而所有从新秩序中获利的人却只是冷淡的捍卫者。
3. C++标准委员会
委员会成员都是志愿者,也没有带薪的秘书处,虽然许多委员确实以其工作组织的代表身份出现。在每次会议上,都会有人自豪地声称就代表“自己”。也就是说,他们没有得到赞助,只代表自己。有些人换工作后,就会代表新组织,这种情况并不少见。许多人以“参加 C++ 标准委员会”作为接受新工作的条件。有人加入了委员会来学习 C++,有人则把“C++ 委员会成员”当作资格来引用(并非一定是真的)。
对于 C++17 和 C++ 20 的工作,每次面对面的 WG21 会议有多达 250 人出席,而总成员人数约为出席人数的两倍。此外,加拿大、芬兰、法国、德国、俄罗斯、西班牙、英国、美国等十几个国家都有国家标准委员会以及 C++ 标准技术联盟的付费支持成员。成员代表了一百多个组织。为了让大家有所了解,在此列举部分成员所属组织:苹果、Bloomberg、欧洲核子研究中心、Codeplay、EDG(Edison Design Group)、Facebook、谷歌、IBM、英特尔、微软、摩根士丹利、英伟达、 Qt、高通、红帽、Ripple、美国 Sandia 国家实验室、拉珀斯维尔应用科技大学 (HSR)和马德里卡洛斯三世大学。编译器供应者、硬件供应者、金融、游戏、库供应者、平台供应者、国家实验室(物理)等都有坚实的代表。早期 C++ 中突出的电信业者的身影已经减少,而过去极少的大学的身影似乎在增加。
ISO 只需要也只认可三名正式官员:
- 召集人——担任工作组主席,制定工作组会议时间表(召开会议),任命研究组,并向更高级别的 ISO(SC22、JTC1 和 ITTF)负责——Herb Sutter (微软),自 2002 年以来一直担任该职位的工作,除 2008–2009 年期间是由 P.J. Plauger(Dinkumware)担任。
- 项目编辑——最终负责将委员会批准的更改应用于标准的工作草案—— Richard Smith( 谷歌 );Pete Becker(Dinkumware) 负责 C++11; Stefanus Du Toit(Intel)负责 C++14。
- 书记——负责记录和分发 WG21 会议的会议纪要——Nina Ranns(Edison Design Group,之前代表 Symantec)。
标准会议令人筋疲力尽。通常,委员从早餐到午夜一直在讨论工作问题。大多数时候,正式会议在 8:30–12:30 和 14:00–17:30 举行,加上大多数时候都会进行的晚间会议(19:00–22:00)。正在准备提案的委员的工作时间比这些还要长。WG 和 SG 主席一般在大多数用餐时间都在开会。周一至周五是全天,而如果没有任何意外发生,大多数委员会成员到了星期六的 15:00 左右会收工。不过,当会议在诸如夏威夷科纳(Kona)之类的好地方举行时,委员会以外的人似乎都不愿意相信开会并不是什么度假。
委员会的管理结构非常薄弱,甚至缺乏最基本的管理工具:
- 成员资格、发言或投票没有任何资质要求(例如,学历或实际经验)。支付 ISO 会员费(2018 年美国会员为 1280 美元)并参加两次会议,就能拥有正式投票权。在研究组和工作组中,任何人都可以发言与投票,即使这是他们的第一次参加会议。
- 除了让提案得到采纳,以及看到改进后的标准而感到满足,并没有任何其他回报。不过,满足感确实是一个主要动力。
- 没有真正的办法来阻止破坏性行为。非官方委员会管理人员所能做的只是有礼貌地提醒人们不要做别人认为具有破坏性的事情。然而委员们对于什么是有破坏性的,意见也不一致。
当考虑在一个大型委员会里演化一门语言的各种问题之前,请记住委员会里大部分时间和工作都是为了解决“小问题”;就是那些不会上升到语言设计哲学、学术出版物、或会议演示层面的问题。它们对于防止语言及其标准库被分割成方言,并保证在编译器和平台之间的可移植性至关重要。这些问题包括:命名、名称查找、重载决策、语法细节、构造的确切含义、临时变量的生存周期、链接,还有其他很多很多。许多问题需要技巧才能得以解决,而拙劣的解决方案可能带来让人吃惊而具有破坏性的后果。解决方案往往经过精心设计,以最大程度减少对现有代码的破坏。委员会每年解决数百个问题。我估计委员至少要为此花费他们时间和精力的三分之一,乃至于三分之二。这项工作往往被忽视和低估。如果你用过计算机或计算机化的设备(例如电话或汽车),你得感谢 CWG 和 LWG 的工作。
当关注由一个庞大的委员会引起的问题时,也请记住,这些问题本质是一种有钱人的烦恼:C++ 的标准化流程由数百位各种不同背景的热心人士所驱动,他们的经验各不相同,但都满怀理想主义。
倾向专家的偏见:想象别人的问题总是困难的。委员会成员几乎都是某方面的专家。在日常工作中,他们通常是处理最细微、最复杂问题的人。这样的问题在“外面”的数十亿行常规 C++ 代码中一般不常见,而且也不是大多数 C++ 程序员所苦恼的问题。但是,对委员会来说,专家级的问题通常就是紧急问题,也是最容易通过流程的问题。例子:支持 enable_if 和类型特征(§4.5.1)在标准库中的使用简直水到渠成,但接受概念(§6)却大费周章。
聪明的问题:委员会成员一般是聪明人,他们中许多人无法抵御机灵的解决方案。此外,他们也很难断定,并非每个问题都值得解决,而拥有解决方案也并不意味着我们必须将其纳入标准。这会带来过于精巧的特性,带来大多数程序员用不着的特性。公平起见,也需要指出,许多程序员也很聪明,有时也会以使用过分机灵的语言和标准库特性为乐。
完美主义:一个标准预期会被几百万人用到,并且可以稳定数十年。人们自然希望它是完美的。这会导致特性膨胀(特性过多),尤其是导致单个特性的膨胀。程序员善于想象出问题,特性在委员会走流程的时候,委员们会坚持要它解决掉所有想象中的问题。这会导致严重的使命偏离,并导致只有专家才会喜爱的特性。这也可能导致特性一直无法加入标准。
内聚的团体:许多工作组和研究组都拥有稳定的核心人员群体,这些年来他们形成了内聚的技术观、共享的词汇表和特定的运作方式。这会使“外部人员”难以交流和贡献。这也可能使设计跨越 WG 边界的特性(例如同时具有库和语言部分的特性)变得困难。每个小组都往往会设计出适合其自身组织结构领域的内容,再次印证了老格言,即系统的结构总是长得像创造它的组织的结构。
有其他方案吗?在理想的世界里,我会建议限定由一小部分(大约 5 人)的全职受信任专家委员做决定,而由大团队完成(例如超过 350 人的委员会)完成讨论、提案、以及大部分流程。但我不认为 C++ 会发展成这样,因为:
- 没有人喜欢放弃权力(在这种情况下是投票权)。
- 要为固定的全职专家团队保持稳定的资金投入需要非同小可的技能(而这种技能在 C++ 社区还没有出现)。
- 成功的时刻不会发生激进的变化;只有 C++ 使用量的显著下降才能促进委员会进行剧烈的组织创新(那时多半已经为时已晚)。
我不认为公司控制是可行的替代方案,因为:
- 公司期望投资回报。
- 公司的支持往往几年后就会消失。
- 公司往往选择差异化的优势,而不是惠及所有人的进步。
我也不认为完全开放的审议流程(成千上万的投票者)是可行的:
- 超过千人的投票就会失去品味。
- 大群体的成员和意见没法在几十年里保持稳定。
我从温斯顿·丘吉尔的格言中得到些许安慰,“民主是最糟糕的政府形式,除了所有那些人类一再尝试过的其他形式”。
特别要指出,我不认为经常被建议的“仁慈的终身独裁者”模式可以规模化,而且,不管怎么说,该模型从来就没对 C++ 适用过。
在我心目中,启动语言设计项目的理想模式是单个人或一小群密切配合的朋友。但我看不到这种方式可以规模化。一门成熟的语言需要数十甚至数百个人来解决他们必须面对的各种问题。即使只是与相关的标准、行业组织进行协调,也会让一个小规模、紧密配合的团体彻底应接不暇。
4. C++11
内存模型很大程度上是由 Linux 和 Windows 内核的需求驱动的。目前它不只是用于内核,而且得到了更加广泛的使用。内存模型被广泛低估了,因为大多数程序员都看不到它。从一阶近似来看,它只是让代码按照任何人都会期望的方式正常工作而已。
最开始,我想大多数委员都小瞧了这个问题。我们知道 Java 有一个很好的内存模型 [Pugh 2004],并曾希望采用它。令我感到好笑的是,来自英特尔和 IBM 的代表坚定地否决了这一想法,他们指出,如果在 C++ 中采用 Java 的内存模型,那么我们将使所有 Java 虚拟机的速度减慢至少两倍。因此,为了保持 Java 的性能,我们不得不为 C++ 采用一个复杂得多的模型。可以想见而且讽刺的是,C++ 此后因为有一个比 Java 更复杂的内存模型而受到批评。
基本上,C++11 模型基于之前发生(happens-before)关系 [Lamport 1978],并且既支持宽松的内存模型,也支持顺序一致 [Lamport 1979] 的模型。在这些之上,C++11 还提供了对原子类型和无锁编程的支持,并且与之集成。这些细节远远超出了本文的范围(例如,参见 [Williams 2018])。
不出所料,并发组的内存模型讨论有时变得有点激烈。这关系到硬件制造商和编译器供应商的重大利益。最困难的决定之一是同时接受英特尔的 x86 原语(某种全存储顺序,Total Store Order(TSO)模型 [TSO Wikipedia 2020] 加上一些原子操作)和 IBM 的 PowerPC 原语(弱一致性加上内存屏障)用于最底层的同步。从逻辑上讲,只需要一套原语,但 Paul McKenney 让我相信,对于 IBM,有太多深藏在复杂算法中的代码使用了屏障,他们不可能采用类似英特尔的模型。有一天,我真的在一个大房间的两个角落之间做了穿梭外交。最后,我提出必须支持这两种方式,这就是 C++11 采用的方式。当后来人们发现内存屏障和原子操作可以一起使用,创造出比单单使用其中之一更好的解决方案时,我和其他人都感到 非常高兴。
在 C++11 开始得到认真使用后,我就开始在旅行时做一些不那么科学的小调查。我会问各地的 C++ 使用者:你最喜欢哪些 C++11 的特性?排在前三位的一直都是:
- §4.2.1:auto
- §4.2.2:范围 for
- §4.3.1:lambda 表达式
这三个特性属于 C++11 中新增的最简单特性,它们并不能提供任何新的基础功能。它们做的事情,在 C++98 中也能做到,只是不那么优雅。
我认为这意味着不同水平的程序员都非常喜欢让惯常用法变简洁的写法。他们会高兴地放弃一个通用的写法,而选择一个在适用场合中更简单明确的写法。有一个常见的口号是,“一件事只应有一种说法!*”这样的“设计原则”根本不能反映现实世界中的用户偏好。我则倾向于依赖洋葱原则 [Stroustrup 1994]。你的设计应该是这样的:如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。
请注意,这里简单并不意味着底层。void*、宏、C 风格字符串和类型转换等底层功能表面上学起来简单,但使用它们来产出高质量、易维护的软件就难了。
允许类的设计者定义移动操作后,我们就有了完整的对对象生命周期和资源管理的控制,这套控制始于 1979 年对构造函数和析构函数的引入。移动语义是 C++ 资源管理模型的重要基石 [Stroustrup et al. 2015],正是这套机制使得对象能够在不同作用域之间可以简单而高效地进行移动。
constexpr 函数很快变得非常流行。它们遍布于 C++14、C++17 和 C++20 标准库,并且不断有相关建议,以求在 constexpr 函数中允许更多的语言构造、将 constexpr 应用于标准库中的更多函数,以及为编译期求值提供更多支持 (§9.3.3)。
但是,constexpr 函数进入标准并不容易。它们一再被认为是无用和无法实现的。实现 constexpr 函数显然需要改进较老的编译器,但是很快,所有主要编译器的作者都证明了“无法实现”的说法是错误的。关于 constexpr 的讨论是有史以来最激烈、最不愉快的。让初始版本通过标准化流程 [Dos Reis and Stroustrup 2007] 花费了四年的时间,而完整地完成又花了十二年的时间。
5. 概念和错误处理
大约在 1987 年,我尝试设计具有合适接口的模板 [Stroustrup 1994],但失败了。我需要三个基本属性来支持泛型编程:
- 全面的通用性/表现力——我明确不希望这些功能只能表达我想到的东西。
- 与手工编码相比,零额外开销——例如,我想构建一个能够与 C 语言的数组在时间和空间性能方面相当的 vector。
- 规范化的接口——我希望类型检查和重载的功能与已有的非泛型的代码相类似。
那时候没人知道如何做到全部三个方面,因此 C++ 所做到的是:
- 图灵完备性 [Veldhuizen 2003]
- 优于手动编码的性能
- 糟糕的接口(基本上是编译期鸭子类型),但仍然做到了静态类型安全
前两个属性使模板大获成功。由于缺乏规范化的接口,我们在这些年里看到了极其糟糕的错误信息,到了 C++17 还仍然是这样。缺乏规范化的接口这一问题,让我和很多其他人困扰很多年。它让我非常困扰的原因是,模板无法满足 C++ 的根本的设计标准 [Stroustrup1994]。我们(显然)需要一种简单的、没有运行期开销的方法来指定模板对其模板参数的要求。
换句话说,我们是应该将概念设计成为供少数语言专家进行细微控制的精密设备,还是供大多数程序员使用的健壮工具?在语言特性和标准库组件的设计中,这个问题反复出现。关于类,我多年以来都听到这样的声音;某些人认为,显然不应该鼓励大多数程序员定义类。在某些人眼里,普通的程序员(有时被戏称为“码农小明”)显然不够聪明或没有足够的知识来使用复杂的特性和技巧。我一向强烈认为大多数程序员可以学会并用好类和概念等特性。一旦他们做到了,他们的编程工作就变得更容易,并且他们的代码也会变得更好。整个 C++ 社区可能需要花费数年的时间来吸取教训;但是如果做不到的话,我们——作为语言和库的设计者——就失败了。
我认为问题要严重得多:委员会想要概念,但委员们对他们想要什么样的概念没有达成一致。委员会没有一套共同的设计目标。这仍然是一个问题,也不仅仅出现在概念上。委员之间存在着深刻的“哲学上”的分歧,特别是:
- 显式还是隐式:为了安全和避免意外,程序员是否应该显式地说明如何从潜在可选方案中做决策?该讨论最终涉及有关重载决策、作用域决策、类型与概念的匹配、概念之间的关系,等等。
- 专家与普通人:关键语言和标准库工具是否应该设计为供专家使用?如果是这样,是否应该鼓励“普通程序员”只使用有限的语言子集,是否应该为“普通程序员”设计单独的库?这个讨论出现在类、类层次结构、异常、模板等的设计和使用的场景中。
这两种情况下,回答“是”都会使功能的设计偏向于复杂的特性,这样就需要大量的专业知识和频繁使用特殊写法才能保证正确。从系统的角度,我倾向于站在这类论点的另一端,更多地信任普通程序员,并依靠常规语言规则,通过编译器和其他工具进行检查以避免令人讨厌的意外。对于棘手的问题,采用显式决策的方式比起依靠(隐式)的语言规则,程序员犯错的机会只多不少。
不同的人从 C++0x 概念的失败中得出了不同的结论,我得出三点主要的:
- 我们过分重视早期实现。我们原本应该花更多的精力来确定需求、约束、期望的使用模式,以及相对简单的实现模型。此后,我们可以依靠使用反馈来让我们的实现逐步增强。
- 有些分歧是根本的(哲学上的),无法通过折中解决,我们必须尽早发现并阐明此类问题。
- 没有一套功能集合能做到既满足一个大型专家委员会的所有不同愿望,又不会变得过分庞大,这种膨胀会成为实现者的难题和用户的障碍。我们必须确定核心需求,并用简单的写法来满足;对于更复杂的用法和罕见的用例,则可以用对使用者的专业知识要求更高的功能和写法。
这些结论与概念没有什么特别的关系。它们是对大团体内的设计目标和决策过程的一般观察。
错误处理作为一种备受争议的话题,我认为将长期存在下去。许多人在这个问题上有强烈的固有认知,其中一些是基于各种应用领域中扎实的经验——过去 50 多年已经有了很多相关的技术积累。在错误处理领域,性能、通用性、可靠性的需求往往发生冲突。
与 C++ 一样,问题不是我们没有解决方案,而是有太多解决方案。从根本上讲,很难通过单一的机制来满足 C++ 社区的多样化需求,但是人们往往只看到问题的一部分,就以为他们掌握了解决问题的终极方案 [Stroustrup 2019a]。
当然总有一些应用不适合使用异常,例如:
- 内存严重受限系统,异常处理所需的运行期支持内存会占用应用程序功能所需要的内存。
- 工具链不能保证异常抛出后能够迅速做出响应的硬实时系统(例如 [Lockheed Martin Corporation 2005])。
- 系统依赖于多台不可靠的计算机,因此立即崩溃并重新启动是对付那些无法在本地处理的错误的合理(且几乎是必要的)方式。
为了使异常被接受,我们不得不添加了异常规约 [Stroustrup 2007]。但异常规约从来没有提供支持者们所声称的更好的可维护性,而确实提供了反对者(包括我)所诟病的冗长和开销。一旦异常规约出现在语言中,许多人就觉得使用它们是受到鼓励的,并将由此产生的问题归咎于异常机制本身。具有讽刺意味的是,那些坚定支持异常规约的人转而去帮助设计 Java 了。异常规约在 2010 年被宣布废弃,并最终在 2017 年被移除(§4.5.3)。作为部分替代方案,C++11 引入了 noexcept 作为一种更简单、更有效的控制异常的机制(§4.5.3)。
从根本上讲,我认为 C++ 需要两种错误处理机制:
- 异常——罕见的错误或直接调用者无法处理的错误。
- 错误码——错误码表示可以由直接调用者处理的错误(通常隐藏在易于使用的检测操作中或作为 (值,错误码) 对从函数返回)。
很少有关于异常的性能和 C++ 中返回码可靠性的认真研究([Renwick et al. 2019] 是一个例外)。但是,有许多不科学的小研究和许多大声表达的意见——常常声称异常天生就比各种形式的错误码检查慢。这与我的经验不符。就我所知,还没有任何严谨的研究发现在现实的例子中错误码能胜出“很多”,或者异常能胜出“很多”。在这一讨论场景下,“很多”表示整数倍的差异,而不是几个百分点。
运行一个简单的性能测试:进行一个 N 层深度的调用序列,然后报告错误。如果错误很少见,例如 1:1000 或 1:10000 的错误率,并且调用嵌套很深,例如 100 或 1000,则异常处理要比明确的错误码判断方式快得多。如果调用深度为 1,并且错误发生的概率为 50%,则显式判断错误码测试将大获全胜。调用深度和错误概率决定了这些测试之间的差异。我要问一个简单而潜在有用的问题:“一个错误要多罕见才被看作是异常情况”?不幸的是,答案是“这要看情况”。这取决于代码、硬件、优化器、异常处理的实现,等等等等。C++ 异常的设计假设答案至少在 1:100 的范围。换句话说,错误指示的传播要远比显式的处理更为常见。
空间占用问题可能比运行期问题更难解决。对于那些遇到不能在本地处理的错误就可以立即终止的系统,我可以想象这样一个实现,在遇到 throw 时立即终止程序。但是如果要传播和处理错误,那么就不可避免,需要面对选择各种困难的折中。
对于错误处理这团乱码,任何解决方案都很可能遇到 N+1 问题(§4.2.5) [Stroustrup 2018a]。
奇怪的是,当初 C++ 引入异常时,人们担心的问题之一就是异常不够通用。许多人认为恢复(resumption)语义必不可少 [Stroustrup 1993]。当时我的猜测是,允许恢复将使异常处理的速度至少再降低两倍。
6. C++20
C++ 想发展成为什么样?或者说,WG21 对于它在努力做什么有一个清晰的观点么?我认为答案是否定的。每位具体成员对于这个问题都有其个人想法,但没有一个想法是被广泛接受的并且足够具体到可以指导个人的讨论和决策。
ISO C++ 标准委员会既没有一组得到广泛认可的设计标准,也没有一组得到广泛认可的采纳某个特性的标准。有这个问题的存在,并不是因为没有做过这方面的尝试。我曾经反复不断地明确强调以下设计标准:
- 在《C++ 语言的设计和演化》[Stroustrup 1994] (§2.1)中提出的“经验法则”包括有 RAII(§2.2.1)、面向对象编程、泛型编程、静态类型安全。
- 简单的事情简单做!(§4.2)则引出洋葱原则(§4.2)。
- 直接映射到硬件和零开销抽象(§1)(§11.2)。
- 基于意见反馈来发展 C++,以解决现实世界的实际问题(§11.2)。
- 保持稳定性和兼容性 [Koenig and Stroustrup 1991b; Stroustrup 1994]。
- 直接和硬件打交道的能力,强有力的可组合的抽象机制,以及最小化的运行时系统(参见我在 HOPL3 的论文 [Stroustrup 2007] 中的回顾)。
问题在于,人们发现要在解释上达成一致太难,而要忽视他们所不喜欢的又太容易。这种倾向,使得“什么才是重要的”这个问题上的根本分歧得以发酵。大家基于他们所受的教育和他们的日常工作中所获得的理解,来做出设计决策。问题之一是这种背景上的多样性,再加上标准委员(§3.3)内部对于 C++ 广泛应用领域的不均衡覆盖。许多人只是对于自己的观点 [Stroustrup 2019b] 过于确定无疑。而要分辨清楚到底什么只是一时的流行,什么才长远来看对 C++ 社区有帮助,确实很困难。通常来说,第一个提出的解决方案往往不是最好的那个。
人们很容易在细节中迷失而忽略了大局。人们很容易关注当前的问题而忘记长期目标(以十年计)。相反,委员会成员是如此专注于通用的原则和遥远的未来,以至于对迫在眉睫的实际问题视而不见。
7. 回顾
C++ 是由一个大型委员会控制的,成员多种多样,并且会不断变化(§3.2)。因此,除了技术问题外,我们必须考虑在语言的演化过程中什么是有效的:
- 问题驱动:C++ 开发应该被那些真实世界中的具体问题的需求所驱动。
- 简单:C++ 应该从简单、高效、易用的解决方案中进行推广而成长。
- 高效:C++ 语言和标准库应该遵循零开销原则。
- 稳定性:不要搞砸我的代码!
相比之下,一个功能如果设计时没有明确专注在解决大部分开发者实际面临的问题上,那它通常会失败:
- 只为专家:某个功能从开始的时候就要满足所有专家的需要。
- 模仿:我们需要这个功能,因为它在另外某个语言里很流行。
- 理论性:语言理论里说语言里一定要有这个特性。
- 革命性:此功能非常重要,以至于我们必须打破兼容性,或者摒弃那些不好的老方法。
我的结论是,尽早确定方向和期望至关重要。稍晚一些,就会有太多的人有太多的不同意见,因而无法达成一套连贯而一致的想法。
给定一个方向和一套原则,一种语言可以基于反馈、用户经验、实验和作为工具的理论成长。这是好的工程方法;反之,则是无原则的实用主义或教条的理想主义。
C++ 标准委员会的章程几乎只关注语言和库的设计。这是有局限性的。一直以来,像动态链接、构建系统和静态分析之类的重要主题大多被忽略了。这是个错误。工具是软件开发人员世界的一个重要组成部分,要是能不把它们置于语言设计的外围就好了。
热衷于各种不同的想法具有危险性。在 2018 年的一篇论文 [Stroustrup 2018d] 中,我列出了 51 条最近的提案:
我列出了我认为有可能显著改变我们编写代码方式的论文,每一篇对教学、维护和编码指导都有重要的影响,其中许多对实现也有影响。 单独来说,许多(大多数)提案都是有道理的,但是放在一起却是疯狂的,甚至足以危及 C++ 的未来。
那篇论文的题目是《记住瓦萨号!》(Remember the Vasa!)。瓦萨号是 17 世纪瑞典的一艘宏伟战舰,由于设计上不断后期添加以及测试不充分,在首航时就沉没在斯德哥尔摩港。在 1990 年代,委员会经常提醒自己记得瓦萨号,但在 2010 年代,这一教训似乎已经被遗忘。
为了对委员会的流程进行组织约束,方向组提出 C++ 程序员的“权利法案” [Dawes et al. 2018]:
- 编译期稳定性:新版本标准中的每一个重要行为变化都可以被支持以前版本的编译器检测到。
- 链接期稳定性:除极少数情况外,应避免 ABI 兼容性破坏,而且这些情况应被很好地记录下来并有书面理由支持。
- 编译期性能稳定性:更改不会导致现有代码的编译时间开销有明显增加。
- 运行期性能稳定性:更改不会导致现有代码的运行时间开销有明显增加。
- 进步:标准的每一次修订都会为某些重要的编程活动提供更好的支持,或为某些重要编程群体提供更好的支持。
- 简单性:每一次对标准的修订都会简化某些重要的编程活动。
- 准时性:每一次标准的修订都会按照公布的时间表按时交付。
由于种种原因,我们需要简化大多数的 C++ 使用的场景。C++ 的演进已经使之成为可能,而我预计这一趋势将继续下去(§4.2)。改进的优化器——有能力利用代码中使用的类型系统和抽象——让优化这件事变得不同了。在过去的几年里,这极大地改变了我优化代码的方式。我从放弃精巧而复杂的东西开始,那是错误的藏身之处;并且,如果我难以理解发生了什么,编译器和优化器也会如此。我发现,这种方法通常会给我带来从适度到惊人的性能提高,同时也简化了未来的维护。只有当这种方法不能给我带来我想要的性能时,我才会求助于高级(又称复杂)的数据结构和算法。这是 C++ 抽象机制设计上的一大胜利。