C++ 2006–2020

 主页   资讯   文章   代码   电子书 

9. C++20:方向之争

由超过 350 名成员组成的委员会进行设计,不太可能产生一个连贯一致的结果。大家都有截然不同的背景(包括不同的教育背景),也在各自的“日常工作”中承受不同的压力,自然会在方向上、优先级上和委员会程序上有不同的见解。粗略估算一下,对于每个提案,大概都有十多位成员会强烈反对其中部分内容。考虑到 WG21 希望同意人数达到 80% 或 90% 才宣告达成共识,C++ 到目前为止的成功令人惊讶。

9.1 设计原则

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++ 社区有帮助,确实很困难。通常来说,第一个提出的解决方案往往不是最好的那个。

人们很容易在细节中迷失而忽略了大局。人们很容易关注当前的问题而忘记长期目标(以十年计)。相反,委员会成员是如此专注于通用的原则和遥远的未来,以至于对迫在眉睫的实际问题视而不见。

在 2017 年,一群国家标准机构代表团 [van Winkel et al. 2017] 的领导人要求对 C++ 的方向性问题予以正式严肃的考量,在他们的敦促之下,WG21 建立了方向组(Direction Group,通常称之为 DG)以试图解决设计目标和方向的问题(§3.2)。DG 在 2018 年 发布了它的第一个广泛而详尽的声明 [Dawes et al. 2018],强调了要遵守明确清晰的原则、一致性,并鼓励用流程来确保这些。比如说:

我们从根本上需要:

  • 稳定性:有用的代码“存活”达数十年。
  • 不断演进:世界在不断变化,而 C++ 也需要不断改变以面对新的挑战。

这里有一种内在的张力。

DG 强调一致性有必要贯穿整个标准:

现如今,某些最为强大的设计技术融合了传统的面向对象编程方面、泛型编程方面、函数式编程方面以及一些传统的命令式编程技术。这种组合,而不是理论上的纯粹,才是理想的。

  • 提供在风格(语法和语义)和使用风格上一致的特性。

该要求适用于库、语言特性,以及这两者的组合

当然了,还有静态类型:

C++ 极其依赖于静态类型安全,以达成其表达能力、性能和安全性。理想的情况下应有

  • 完全的类型安全和资源安全(没有内存损坏和内存泄漏)

该要求可以在不增加额外开销的情况下达成,尤其是,不需要添加垃圾收集器,也不需要限制表达能力。

国家机构领导的要求 [van Winkel et al. 2017] 和 DG 的文档 [Dawes et al. 2018] 都强调了委员会成员需要了解 C++ 的历史,以确保一定程度的连续性。一个缺乏历史的组织无法对他们的设计内容保持一致性的观点。因此,HOPL 论文 [Stroustrup 1993, 2007] 和《C++ 语言的设计和演化》[Stroustrup 1994] 扮演了基石角色。

传统上,为符合 WG21 在 ISO 的章程,C++ 演化方面的工作主要都聚焦于语言和库的课题。然而,开发者不仅仅需要考虑语言:程序是工具链(§1)的产物。令人震惊的是,C++ 并没有关于动态链接库的标准,也没有标准化的构建系统。工具研究小组 SG15 在 2018 年成立,以尝试应对工具方面的形形色色的问题(§3.2)。

9.2 我的 C++17 清单

我一直努力鼓励委员会关注重要的改进——而不只去做那些容易完成和容易达成一致的事情——作为这个努力的一部分,我制定了一个清单,包含了我认为重要且适合引入 C++17 的内容及其理由:

  • 概念——它让我们可以精确描述泛型程序,并解决对于错误信息质量的广泛抱怨。
  • 模块——只要它可以显著地提高与宏的隔离并大大优化编译时间。
  • 范围库和其他关键 STL 组件对概念的使用——为主流用户改进错误信息质量和提高库规范(“STL2”)的精确性。
  • 统一调用语法——简化模板库的规范和使用。
  • 协程——应该非常快速而简单。
  • 网络库支持——基于 asio 库,如相应 TS 所描述。
  • 契约——不一定需要在 C++17 的库规范中使用。
  • SIMD 向量和并行算法。
  • 标准库词汇类型,比如 optionalvariantstring_viewarray_view
  • 一种在栈上提供数组(stack_array)的“魔法类型”,合理支持安全、便捷的使用。

在 2015 年 4 月份,在 Kansas 州 Lenexa 的 WG21 会议中,我在晚间会议上向一些有共鸣的观众展示了这个清单。然而,几乎没有人感受到足够的动力去根据这个清单调整工作焦点。这个清单后来“泄露”了出去,并且在网上引起了混乱的讨论,因此我不得不把它正式写出来 [Stroustrup 2015a]。

如果是在一个团结的委员会中,该清单上的每一项都应该已经准备好进入 C++17 了。实际上我认为,如果我们专注于这个列表,完成其中的大约一半提案还是可行的。然而我还是过于乐观 了。我们唯一达成共识的也就只有关于标准库词汇类型的那一项。其中 array_view 被重命名为 span,成了 C++20(§9.3.8)的一部分。

幸运的是,列表上的大部分条目进入了 C++20。除了

  • 网络库(§8.8.1)——现在是个 TS [Wakely 2018]
  • 契约(§9.6.1)——差一点进入 C++20
  • 统一函数调用(§8.8.3
  • SIMD 向量——目前在一个 TS 中 [Hoberock 2019]
  • stack_array

这份列表带来了日程安排上的争论。鉴于概念的提案(§6.3.8)在 2016 年的失败看起来是不可避免了,我被询问——由整个委员会——是否我打算提议推迟标准的发布一到两年,来把概念加入到标准中,让标准变成 C++18 或者 C++19。我拒绝了,因为我认为可预见的发布周期对于整个社区而言更为重要,其重要性要超过某个单项的改进。而且,当时也无法确保一定会就该提案形成共识,再说一次日程延误很可能会造成多次延误。如果一份提案被认为值得推迟标准发布,那么就会有人主张也有其他的提案同样值得标准发布的推迟。这样的逻辑使得 C++0x 变成了 C++11,哪怕当时曾有人希望是 C++06。

9.3 C++20 特性

WG21 将针对 C++20 的新提案的截止日期定为 2018 年 11 月,并在 2019 年 2 月会议之后宣布“特性冻结”。2020 年 2 月,在捷克共和国布拉格举行的一次会议上,技术投票结果为 79 比 0,一票弃权 [Smith 2020]。所有 15 个国家成员体的代表团团长均投了赞成票。官方标准将由 ISO 在 2020 年末发布。C++20 特性包括:

  • §6.4概念——对泛型代码的要求进行明确规定
  • §9.3.1模块——支持代码的模块化,使代码更卫生并改善编译时间
  • §9.3.2协程——无栈协程
  • §9.3.3编译期计算支持
  • §9.3.4<=>——三向比较运算符
  • §9.3.5范围——提供灵活的范围抽象的库
  • §9.3.6日期——提供日期类型、日历和时区的库
  • §9.3.8跨度——提供对数组进行高效和安全访问的库
  • §9.3.7格式化——提供类型安全的类似于 printf 的输出的库
  • §9.4并发改进——例如作用域线程和停止令牌
  • §9.5很多次要特性——例如 C99 风格的指派初始化器和使用字符串字面量作为模板参数

以下内容在 C++20 时尚未准备就绪,但可能会成为 C++23 的主要特性:

  • §8.8.1网络——网络库(sockets 等)
  • §9.6.2静态反射——根据周围程序生成代码的功能
  • 模式匹配——根据类型和对象值选择要执行的代码 [Murzin et al. 2019]

C++20 提供了一组反映 C++ 长期目标的特性,并解决了一些根本问题。例如,从 1994 年《C++ 语言的设计和演化》[Stroustrup 1994] 书中就提到了模块和概念,而协程在整个 1980 年代都是“带类的 C”和 C++ 的一部分。C++20 对 C++ 的影响将与 C++11 一样大。

不幸的是,C++20 没有对模块和协程提供标准库支持。这可能会成为一个严重的问题,但当时实在没有时间来准备并赶上 C++20 的时间要求。C++23 应该会提供所需的支持(§4.1.3)。

9.3.1 模块

在 C++ 程序中改进模块化是一个显然的需求。从 C 语言中,C++ 继承了 #include 机制,依赖从头文件使用文本形式包含 C++ 源代码,这些头文件中包含了接口的文本定义。一个流行的头文件可以在大型程序的各个单独编译的部分中被 #include 数百次。基本问题是:

  • 不够卫生:一个头文件中的代码可能会影响同一翻译单元中包含的另一个 #include 中的代码的含义,因此 #include 并非顺序无关。宏是这里的一个主要问题,尽管不是唯一的问题。
  • 分离编译的不一致性:两个翻译单元中同一实体的声明可能不一致,但并非所有此类错误都被编译器或链接器捕获。
  • 编译次数过多:从源代码文本编译接口比较慢。从源代码文本反复地编译同一份接口非常慢。

自“开辟鸿蒙”而始,这已经众所周知(例如,参见《C++ 语言的设计和演化》[Stroustrup 1994] 第 18 章),但随着越来越多的信息被放入头文件(inline 函数、constexpr 函数,还有尤其是模板),这些问题在这些年里变得越来越严重。在 C++ 的早期,通常 10% 的文本来自头文件,但现在它更可能是 90% 甚至 99%。考虑下面的代码:

#include<iostream>

int main()
{
    std::cout << "Hello, World\n";
}

这段标准代码有 70 个字符,但是在 #include 之后,它会产生 419909 个字符需要编译器来消化。尽管现代 C++ 编译器已有骄人的处理速度,但模块化问题已经迫在眉睫。

在委员会的鼓励下(并得到了我的支持),David Vandevoorde 在二十一世纪产出了一系列模块设计 [Vandevoorde 2007,2012],但进展非常缓慢。委员会的首要任务是完成 C++0x,而不是在模块上取得进展。David 主要靠自己奋斗,此外基本就只得到一些精神支持了。在 2012 年,Doug Gregor 从苹果提交了一个完全不同的模块系统设计 [Gregor 2012]。在 Clang 编译器基础设施中,这一设计已经针对 C 和 Objective C 实现 [Clang 2014]。它依赖于语言之外的文件映射指令,而不是 C++ 语言里的构造。该设计还强调了不需要对头文件进行修改。

在 2014 年,由 Gabriel Dos Reis 领导的微软团队成员根据他们的工作提出了一项提案 [Dos Reis et al. 2014]。从精神层面上讲,它更接近于 David Vandevoorde 的设计,而不是 Clang/苹果的提议,并且很大程度上是基于 Gabriel Dos Reis 和 Bjarne Stroustrup 在得州农工大学所做的关于 C++ 源代码的最优图表示的研究(于 2007 年发布并开源 [Dos Reis 2009; Dos Reis and Stroustrup 2009, 2011])。

这为在模块方面取得重大进展奠定了基础,但同时也为苹果/谷歌/Clang 方式(和实现)及微软方式(和实现)之间的一系列冲突埋下了伏笔。

为此一个模块研究小组被创建。3 年后,该小组主要基于 Gabriel Dos Reis 的设计 [Dos Reis 2018] 制订了 TS。

在 2017 年,然后在 2018 年又发生了一次,将 Modules TS 纳入 C++20 标准的建议受阻,就因为谷歌提出了不同的设计 [Smith 2018a,b]。争论的主要焦点是在 Gabriel Dos Reis 的设计中宏无法导出。谷歌的人认为这是一个致命缺陷,而 Gabriel Dos Reis(和我)认为这对于模块化至关重要 [Stroustrup 2018c]:

模块化是什么意思?顺序独立性:import X; import Y; 应该与 import Y; import X; 相同。换句话说,任何东西都不能隐式地从一个模块“泄漏”到另一个模块。这是 #include 文件的一个关键问题。#include 中的任何内容都会影响所有后续的 #include

我认为顺序独立性是“代码卫生”和性能的关键。通过坚持这种做法,Gabriel Dos Reis 的模块实现也比使用头文件在编译时间上得到了 10 倍量级的性能提升——即使在旧式编译中使用了预编译头文件也是如此。迎合传统头文件和宏的常规使用的方式很难做到这一点,因为需要将模块单元保持为允许宏替换(“标记汤”)的形式,而不是 C++ 逻辑实体的图。

经过精心设计的一系列折中,我们最终达成了一个被广泛接受的解决方案。这一多年努力的关键人物有 Richard Smith(谷歌)和 Gabriel Dos Reis(微软),以及 GCC 的模块实现者 Nathan Sidwell(Facebook),还有其他贡献者 [Dos Reis and Smith 2018a,b; Smith and Dos Reis 2018]。从 2018 年年中开始,大多数讨论都集中在需要精确规范的技术细节上,以确保实现之间的可移植性 [Sidwell 2018; Sidwell and Herring 2019]。

考虑如下代码所示的 C++20 模块的简单示例:

export module map_printer;  // 定义一个模块

import iostream;       // 使用 iostream
import containers;     // 使用我自己的 containers
using namespace std;

export                 // 让 print_map() 对 map_printer 的用户可用
template<Sequence S>
    requires Printable<Key_type<S>> && Printable<Value_type<S>>
void print_map(const S& m) {
    for (const auto& [key,val] : m)  // 分离“键”和“值”
        cout << key << " -> " << val << '\n';
}

这段代码定义了一个模块 map_printer,该模块提供函数 print_map 作为其用户接口,并使用了从模块 iostreamcontainers 导入的功能来实现该函数。为了强调与旧的 C++ 风格的区别,我使用了概念(§6)和结构化绑定(§8.2)。

关键思想:

  • export 指令使实体可以被 import 到另一个模块中。
  • import 指令使从另一个模块 export 出来的的实体能够被使用。
  • import 的实体不会被隐式地再 export 出去。
  • import 不会将实体添加到上下文中;它只会使实体能被使用(因此,未使用的 import 基本上是无开销的)。

最后两点不同于 #include,并且它们对于模块化和编译期性能至关重要。

这个简单的例子纯粹是基于模块的;这是理想情况。但是,已经部署的 C++ 代码也许有五千亿行,而头文件和 #include 并不会在一夜之间被淘汰,可能再过几十年都不会。好几个人和组织指出,我们需要一些过渡机制,使得头文件和模块可以在程序中共存,并让库为不同代码成熟度的用户同时提供头文件和模块的接口。请记住,在任何给定的时刻,都有用户依赖 10 年前的编译器。

考虑在无法修改 iostreamcontainer 头文件的约束下实现 map_printer

export module map_printer;  // 定义一个模块

import <iostream>      // 使用 iostream 头文件
import "containers"    // 使用我自己的 containers 头文件
using namespace std;

export                 // 让 print_map() 对 map_printer 的用户可用
template<Sequence S>
    requires Printable<Key_type<S>> && Printable<Value_type<S>>
void print_map(const S& m) {
    for (const auto& [key,val] : m)  // 分离“键”和“值”
        cout << key << " -> " << val << '\n';
}

指名某个头文件的 import 指令工作起来几乎与 #include 完全一样——宏、实现细节以及递归地 #include 到的头文件。但是,编译器确保 import 导入的“旧头文件”不具有相互依赖关系。也就是说,头文件的 import 是顺序无关的,因此提供了部分、但并非全部的模块化的好处。例如,像 import <iostream> 这样导入单个头文件,程序员就需要去决定该导入哪些头文件,也因为与文件系统进行不必要的多次交互而降低编译速度,还限制了来自不同头文件的标准库组件的预编译。我个人希望看到颗粒度更粗的模块,例如,标准的 import std 表示让整个标准库都可用。然而,更有雄心的标准库重构 [Clow et al. 2018] 必须要推迟到 C++23(§11.5)了。

import 头文件这样的功能是谷歌/Clang 提案的重要组成部分。这样做的一个原因是有些库的主要接口就是一堆宏。

在设计/实现/标准化工作的后期,反对意见集中在模块对构建系统的可能影响上。当前 C 和 C++ 的构建系统对处理头文件已经做了大量优化。数十年的工作已经花费在优化这一点上,一些与传统构建系统相关的人表示怀疑,是否可以不经(负担不起的)重大重新设计就顺利引入模块,而使用模块的构建会不允许并行编译(因为当前要导入的模块依赖于某个先前已导入模块的编译结果)[Bindels et al. 2018; Lopes et al. 2019; Rivera 2019a]。幸运的是,早期印象过于悲观了 [Rivera 2019b],build2 系统已经为处理模块进行了修改,微软和谷歌报告说他们的构建系统在处理模块方面显示出良好的效果,最后 Nathan Sidwell 报告说他在仅两周的业余时间里修改了 GNU 的构建系统来处理模块 [Sidwell 2019]。这些经验的最终演示及关键模块实现者(Gabriel Dos Reis、Nathan Sidwell、Richard Smith 和 David Vandevoorde)的联署论文打动了几乎所有反对者 [Dos Reis et al. 2019]。

在 2019 年 2 月,模块得到了 46 比 6 的多数票,进入了 C++20;投票者中包含了所有的实现者 [Smith 2019]。在那时,主要的 C++ 实现已经接近 C++20 标准。模块有望成为 C++20 提供的最重要的单项改进。

9.3.2 协程

协程提供了一种协作式多任务模型,比使用线程或进程要高效得多。协程曾是早期 C++ 的重要组成部分。如果没有提供协程的任务库,C++ 将胎死腹中,但是由于多种原因,协程并没有进入 C++98 标准(§1.1)。

C++20 协程的历史始于 Niklas Gustafsson(微软)关于“可恢复函数”的提案 [Gustafsson 2012]。其主要目的是支持异步 I/O;“能够处理成千上万或以百万计客户的服务器应用程序”[Kohlhoff 2013]。它相当于当时引入到 C#(2015 年的 6.0 版)的 async/await 功能。类似的功能已经存在于 Python、JavaScript 和其他语言里。Niklas 的提案引发了来自 Oliver Kowalke 和 Nat Goodspeed [Kowalke and Goodspeed 2013] 的基于 Boost.Coroutine 的竞争提案,并引起了人们的浓厚兴趣。await 设计无栈、不对称且需要语言支持,而源自 Boost 的设计则使用栈、具有对称控制原语且基于库。无栈协程只能在其自身函数体中挂起,而不能从其调用的函数中挂起。这样,挂起仅涉及保存单个栈帧(“协程状态”),而不是保存整个栈。对于性能而言,这是一个巨大的优势。

协程的设计空间很大,因此很难达成共识。委员会中的许多人(包括我在内)都希望能够综合考虑这两种方式的优点,因此一群感兴趣的成员对可选方案进行了分析 [Goodspeed 2014]。结论是,有可能同时利用这两种方式的优点,但这需要认真研究。这项研究花了数年时间,但没有得出明确的结果。与此同时,出现了更多的提案。

至于密切相关的并发主题(§8.4),对所编写、演示和讨论的提案的完整解释超出了本文的范围。在这里,我只描述一个概况。因为复杂的细节简直太多,在此也只能简而言之;仅论文就有数百页,许多讨论都取决于高级用例的(有时是假设的)高度优化实现的性能。讨论发生在 SG1(并发)、EWG(演化)、LEWG(库演化)、CWG(核心语言)、LWG(库),甚至在晚间会议和全体会议上。

在这些讨论和提案中,三种想法反复出现:

  • 将协程的状态及其操作表示为 lambda 表达式,从而使协程优雅地适配 C++ 类型系统,而不需要 await 式协程 [Kohlhoff 2013] 所使用的某些“编译器魔法”。
  • 为无栈和有栈协程提供通用接口——也可能为其他类型的并发机制,例如线程和纤程,提供通用接口。[Kowalke 2015; Riegel 2015]。
  • 为了在最简单和最关键的用途(生成器和管道)上获得最佳性能(运行时间和空间),无栈协程需要编译器支持,并且一定不能为了支持更高级的用例而在接口上作妥协 [Nishanov 2018,2019b]。

你不可能同时满足这三者。我非常喜欢通用接口的想法,因为这样可以最大限度地减少学习需要的努力,并使得实验大为便捷。类似地,使用完全普通的对象来表示协程将开放整个语言来支持协程。然而,最终性能论胜出。

在 2017 年,Gor Nishanov 基于 await 无栈方式的提案被接受为 TS [Nishanov 2017]。这一提案(不可避免地被戏称为“Gor-routines”)获得批准的原因是,它的实现在其关键用例(管道和生成器)中表现出了卓越的性能 [Jonathan et al. 2018; Psaropoulos et al. 2017]。之所以把它写成 TS,而不是放到标准中,是因为许多人喜欢更通用(但速度较慢)的有栈协程,有些人仍然希望这两种方式的零开销统一。我当时(今天仍没有变)的观点是,在合理的时间段里,统一并不可能。我已经等了近 30 年的时间让协程重新回到 C++ 中,我可不想等待一个可能永远不会到来的突破:“最好是好的敌人。”

和往常一样,命名是一个有争议的问题。特别是,TS 草案使用了关键字 yield,这很快被判定为一个流行的标识符(例如,在金融和农业领域)。而且,协程产生的结果需要被包到一个调用者可以等待的结构中(例如,future§4.1.3)),因此,协程 return 语句的语义与普通 return 语句的语义不是完全一样。所以,有些人就反对 return 的“复用”。作为回应,演化工作组引入了关键字 co_returnco_yieldco_await,用于协程中的三个关键操作。使用下划线是为了防止母语为英语的人将 coreturncoyieldcoawait 误读为 core-turncoy-ieldcoa-wait。人们也探索了使 yieldawait 成为上下文敏感的关键词的可能性,但没有达成共识。这些新的关键词并不漂亮,它们很快就成为了那些出于任何原因不喜欢 TS 协程的人们的靶子。

在 2018 年,TS 协程被提议纳入 C++20 标准,但在最后那一刻,来自谷歌的 Geoff Romer、James Dennett 和 Chandler Carruth 提出了一个对新手颇不友好的提案 [Romer et al. 2018]。谷歌的提案名为“核心协程”(Core Coroutines),它和 Gor 的提案一样,需要库支持来使基本机制对非专家用户变得友好。所需要的库当时还没有设计好。核心协程被宣称比 TS 协程更高效,并且解决了谷歌的一个用例,用于不基于异常的错误传播。其思想基于将协程的状态表示为 lambda 表达式。为了避免人们普遍鄙视的关键词 co_returnco_yieldco_await,核心协程提供了据称更友好的运算符 [->][<-]。令人惊讶的是,作为运算符,[->] 有四个字符长,并且有四个操作数,“[”和“]”是标记的一部分。不幸的是,核心协程没有实现,因此可用性和效率的主张无法得到验证。这推迟了关于协程的进一步决定。

TS 协程的一个重要且可能致命的问题是,它依赖于自由存储区(动态内存、堆)上的分配。在某些应用程序中,这是很大的开销。更糟糕的是,对于许多关键的实时和嵌入式应用程序,自由存储区的使用是不允许的,因为它可能导致不可预测的响应时间和内存碎片的可能性。核心协程没有这个问题。然而,Gor Nishanov 和 Richard Smith 论证了,TS 协程可以通过多种方式之一保证几乎所有用途都没有(并检测和防止其他用途)自由存储区的使用 [Smith and Nishanov 2018]。特别是,对于几乎所有的关键用例,都可以将自由存储区使用优化为栈分配(所谓的“Halo 优化”[^1])。

随着时间的推移,核心协程不断发展和完善 [Romer et al. 2019a],但完整的实现一直没有出现。在 2018 年,保加利亚国家标准机构反对 TS 协程设计 [Mihaylov and Vassilev 2018],并提出了另一种设计 [Mihaylov and Vassilev 2019]。又一次,提案宣称具有优雅、通用性和高性能,但同样地,没有任何实现存在。

这时候,演化小组的负责人 Ville Voutilainen 要求这三个仍然活跃的提案的作者撰写两份评估和比较论文:

  • Coroutines: Use-cases and Trade-offs(《协程:用例与取舍》)[Romer et al. 2019b]
  • Coroutines: Language and Implementation Impact(《协程:语言与实现影响》)[Smith et al. 2019]

这三个提案(Gor、谷歌和保加利亚)都是无栈的,需要栈的用例被留给未来的提案。所有这些提案都有数量惊人的定制点 [Nishanov 2018],它们的实现者和专家用户都认为这些是必不可少的。结果表明,在不同的提案中,关键用例的表达并没有显著不同。因此,这些差异可以认为很大程度上只是表面文章,不用多理会。例如,co_await[<-] 更丑吗?

这就只留下性能问题有待讨论。Gor 的提案,因为有着四年的生产环境使用,并在微软和 Clang 编译器中都有实现,而具有明显的优势。在 C++20 的关键投票之前的最后几次会议上,委员会听取了来自 Sandia [Hollman 2019]、微软 [Jonathan et al. 2018] 和 Facebook [Howes et al. 2018] 的人的体验报告,并考虑了一些关于基于使用体验的改进和简化的建议 [Baker 2019]。然而,(据我判断)打动委员会、使其以 48 比 4 的绝对优势投票支持 Gor-routine 的要点是,在使用“普通的 lambda 表达式”来代表协程状态的策略中发现了一个根本性的缺陷。为了使表示协程状态的 lambda 表达式与其他 lambda 表达式一样,必须在编译的第一阶段就知道其大小。只有这样,我们才能在栈上分配协程状态、复制它们、移动它们,并以语言允许的各种方式使用它们。但是,在优化器运行之前,栈帧(根本上,这就是无栈协程的状态)的大小是未知的。没有从优化器返回到编译器早期阶段的信息路径。优化器可能会通过消除变量来减小帧的大小,也可能会通过添加有用的临时变量来增加帧的大小。因此,用来代表某个协程状态的 lambda 表达式不能是“普通的”。

最后,考虑一个 C++20 协程的简单例子:

generator<int> fibonacci()  // 生成 0,1,1,2,3,5,8,13 ...
{
    int a = 0;    // 初值
    int b = 1;

    while (true) {
        int next = a+b;
        co_yield a;    // 返回下一个斐波那契数
        a = b;         // 更新值
        b = next;
    }
}

int main()
{
    for (auto v : fibonacci())
        cout << v << '\n';
}

使用 co_yield 使 fibonacci() 成为一个协程。generator<int> 返回值将保存生成的下一个 intfibonacci() 等待下一个调用所需的最小状态。对于异步使用,我们将用 future<int> 而不是 generator<int>。对协程返回类型的标准库支持仍然不完整,不过库就应该在生产环境的使用中成熟。

委员会本来可以更好地处理协程提案吗?也许可以吧;C++20 协程与 Niklas Gustafsson 2012 年的提案非常相似。我们探索了替代方案固然很好,但我们真的需要 7 年时间吗?许多有能力的人所做的大量努力是否可以更多协作、更少竞争?我觉得更好的学术知识在早期阶段会有所帮助。毕竟,协程有约 60 年的历史,例如 [Conway 1963]。人们是知道 C++ 和相关语言中的现代方法的,但我们的理解既未共享,也不系统。如果我们当初花上几个月或一年的时间对基本设计选择、实现技术、关键用例和文献进行彻底审核,我怀疑我们早在 2014 年就可以得出 2019 年 2 月得出的结论。之后的几年本可以花在对我们所选择的基本方法进行增量改进和功能添加上。

我们取得的进展和最后的成功很大程度上归功于 Gor Nishanov。要不是有他的坚韧不拔和扎实实现(他完成了微软和 Clang 两种编译器里的实现),我们在 C++20 也不会有协程。锲而不舍是在委员会成功的关键要素。

9.3.3 编译期计算支持

多年以来,在 C++ 中编译期求值的重要性一直在稳步提高。STL 严重依赖于编译期分发 [Stroustrup 2007],而模板元编程主要旨在将计算从运行期转移到编译期(§10.5.2)。甚至在早期的 C++ 中,对重载的依赖以及虚函数表的使用都可以看作是通过将计算从运行期转移到编译期来获得性能。因此,编译期计算一直是 C++ 的关键部分。

C++ 从 C 继承了只限于整型且不能调用函数的常量表达式。曾有一段时间,宏对于任何稍微复杂点的事情都必不可少。但这些都不好规模化。一旦引入模板并发现了模板元编程,模板元编程就被广泛用于在编译期计算值和类型上(§10.5.2)。在 2010 年,Gabriel Dos Reis 和 Bjarne Stroustrup 发表了一篇论文,指出编译期的值计算可以(也应该)像其他计算一样表达,一样地依赖于表达式和函数的常规规则,包括使用用户定义的类型 [Dos Reis and Stroustrup 2010]。这成为 C++11(§4.2.7)里的 constexpr 函数,它是现代编译期编程的基础。C++14 推广了 constexpr 函数(§5.5),而 C++20 增加了好几个相关的特性:

  • consteval——保证在编译期进行求值的 constexpr 函数 [Smith et al. 2018a]
  • constinit——保证在编译期初始化的声明修饰符 [Fiselier 2019]
  • 允许在 constexpr 函数中使用成对的 newdelete [Dimov et al. 2019]
  • constexpr stringconstexpr vector [Dionne 2018]
  • 使用 virtual 函数 [Dimov and Vassilev 2018]
  • 使用 unions、异常、dynamic_casttypeid [Dionne and Vandevoorde 2018]
  • 使用用户定义类型作为值模板参数——最终允许在任何可以用内置类型的地方使用用户定义类型 [Maurer 2012]
  • is_constant_evaluated() 谓词——使库实现者能够在优化代码时大大减少平台相关的内部函数的使用 [Smith et al. 2018b]

随着这一努力,标准库正在变得对编译期求值更加友好。

这一努力的最终目的是为了让 C++23 或更高版本支持静态反射(§9.6.2)。在我最初设计模板时,曾期望使用用户自定义类型作为模板参数类型,使用字符串作为模板参数,但以我当时的能力无法恰当地设计和实现出这一功能。

有些人希望每一个 C++ 构造在编译期都能可用。特别是,他们希望能够在 constexpr 函数中使用完整的标准库。那可能就好过头了。比如,你真的需要在编译期使用线程吗?是的,这可行。没有使所有函数在编译期都可用,这就给我们留下了一个问题:哪些应该可用,哪些不应该可用。到目前为止,答案有点临场发挥而并不连贯。这需要进一步完善。

要让一个语言的构造或库组件成为 constexpr,我们必须非常精确地进行描述,并消除未定义行为的可能性。因此,推动编译期求值已经成为更精确的规范说明、平台依赖性分析和未定义行为根源分析的主要驱动力。

显然,这种对编译期计算的推动为编译器带来了更多的工作。接口里需要增加更多的信息,来允许编译器完成所有的工作,这个问题正在通过模块来解决(§9.3.1)。编译器还通过缓存结果进行补偿,依赖并行构建的系统也很常见。然而,C++ 程序员必须学会限制编译期计算和元编程的使用,只有在值得为了代码紧凑性和运行期性能而引入它们的地方才使用。

9.3.4 <=>

参见(§8.8.4)。紧接在“飞船运算符”(<=>)投票进入 C++20 之后,很明显,在语言规则及其与标准库的集成方面都需要进一步的认真工作。出于对解决跟比较有关的棘手问题的过度热情和渴望,委员会成了意外后果定律的受害者。一些委员(包括我在内)担心引入 <=> 过于仓促。然而,在我们的担忧坐实的时候,早已经有很多工作在假设 <=> 可用的前提下完成了。此外,三向比较可能带来的性能优势让许多委员会成员和其他更广泛的 C++ 社区成员感到兴奋。因此,当发现 <=> 在重要用例中导致了显著的低效时,那就是一个相当令人不快的意外了。类型有了 <=> 之后,== 是从 <=> 生成的。对于字符串,== 通常通过首先比较大小来优化:如果字符数不同,则字符串不相等。从 <=> 生成的 == 则必须读取足够的字符串以确定它们的词典顺序,那开销就会大得多了。经过长时间的讨论,我们决定不从 <=> 生成 ==。这一点和其他一些修正 [Crowl 2018; Revzin 2018, 2019; Smith 2018c] 解决了手头的问题,但损害了 <=> 的根本承诺:所有的比较运算符都可以从一行简单的代码中生成。此外,由于 <=> 的引入,==< 现在有了许多不同于其他运算符的规则(例如,== 被假定为对称的)。无论好坏,大多数与运算符重载相关的规则都将 <=> 作为特例来对待。

9.3.5 范围

范围库始于 Eric Niebler 对 STL 序列观念的推广和现代化的工作 [Niebler et al. 2014]。它提供了更易于使用、更通用及性能更好的标准库算法。例如,C++20 标准库为整个容器的操作提供了期待已久的更简单的表示方法:

void test(vector<string>& vs)
{
    sort(vs);   // 而不是 sort(vs.begin(),vs.end())
}

C++98 [Stroustrup 1993] 所采用的原始 STL 将序列定义为一对迭代器。这遗漏了指定序列的两种重要方式。范围库提供了三种主要的替代方法(现在称为 ranges):

  • (首项,尾项过一) 用于当我们知道序列的开始和结束位置时(例如“对 vector 的开始到结束位置进行排序”)。
  • (首项,元素个数) 用于当我们实际上不需要计算序列的结尾时(例如“查看列表的前 10 个元素”)。
  • (首项,结束判据) 用于当我们使用谓词(例如,一个哨位)来定义序列的结尾时(例如“读取到输入结束”)。

range 本身是一种 concept§6)。所有 C++20 标准库算法现在都使用概念进行了精确规定。这本身就是一个重大的改进,并使得我们在算法里可以推广到使用范围,而不仅仅是迭代器。这种推广允许我们把算法如管道般连接起来:

vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

auto even = [](int i){ return i%2 == 0; }

for (int i : vec | view::filter(even)
                 | view::transform( [](int i) { return i*i; } )
                 | view::take(5))
    cout << i << '\n';    // 打印前 5 个偶整数的平方

像在 Unix 中一样,管道运算符 | 将其左操作数的输出作为输入传递到其右操作数(例如 A|B 表示 B(A))。一旦人们开始使用协程(§9.3.2)来编写管道过滤器,这就会变得有趣得多。

在 2017 年,范围库成为了 TS [Niebler and Carter 2017];在 2019 年 2 月,它被投进了 C++20 [Niebler et al. 2018]。

9.3.6 日期和时区

日期库是 Howard Hinnant(现在任职于 Ripple,之前任职于苹果)的作品,为 C++ 提供标准的日历和时区支持 [Hinnant and Kamiński 2018]。它基于 chrono 标准库的时间支持。Howard 也是 chrono 标准库(§4.6)背后的主要人物。日期库是多年工作和实际使用的结果。在 2018 年,它通过投票进入了 C++20,并和旧的时间工具一起放在 <chrono> 中。

考虑如何表达时间点(time_point):

constexpr auto tp = 2016y/May/29d + 7h + 30min + 6s + 153ms;
cout << tp << '\n';    // 2016-05-29 07:30:06.153

该表示法很传统(使用用户定义的字面量§4.2.8)),日期表示为 年,月,日 结构。但是,当需要时,日期会在编译期映射到标准时间线(system_time)上的某个点(使用 constexpr 函数(§4.2.7)),因此它极其快速,也可以在常量表达式中使用。例如:

static_assert(2016y/May/29==Thursday);  // 编译期检查

默认情况下,时区是 UTC(又称 Unix 时间),但转换为不同的时区很容易:

zoned_time zt = {"Asia/Tokyo", tp};
cout << zt << '\n';          // 2016-05-29 16:30:06.153 JST

日期库还可以处理星期几(例如,MondayFriday)、多个日历(例如,格里历和儒略历),以及更深奥(但必要)的概念,比如闰秒。

除了有用和快速之外,日期库还有趣在它提供了非常细粒度的静态类型检查。常见错误会在编译期捕获。例如:

auto d1 = 2019y/5/4;    // 错误:是 5 月 4 日还是 4 月 5 日?
auto d2 = 2019y/May/4;  // 正确
auto d2 = May/4/2019;   // 正确(日跟在月后面)
auto d3 = d2+10;        // 错误:是加 10 天、10 个月还是 10 年?

日期库是标准库组件中的一个少见的例子,它直接服务于某应用领域,而非“仅仅”提供支持性的“计算机科学”抽象。我希望在将来的标准中能看到更多这样的例子。

9.3.7 格式化

iostream 库提供了类型安全的 I/O 的扩展,但是它的格式化工具比较弱。另外,还有的人不喜欢使用 << 分隔输出值的方式。格式化库提供了一种类 printf 的方式去组装字符串和格式化输出值,同时这种方法类型安全、快捷,并能和 iostream 协同工作。这项工作主要是由 Victor Zverovich [Zverovich 2019] 完成的。

类型中带有 << 运算符的可以在一个格式化的字符串中输出:

string s = "foo";
cout << format("The string '{}' has {} characters",s,s.size());

输出结果是 The string 'foo' has 3 characters

这是“类型安全的 printf”变参模板思想的一个变体(§4.3.2)。大括号 {} 简单地表示了插入参数值的默认表示形式。

参数值可以按照任意顺序被使用:

// s 在 s.size() 前:
cout << format("The string '{0}' has {1} characters",s,s.size());
// s.size() 在 s 前:
cout << format("The string '{1}' has {0} characters",s.size(),s);

printf() 一样,format() 为展现格式化细节提供了一门小而完整的编程语言,比如字段宽度、浮点数精度、整数基和字段内对齐。不同于 printf()format() 是可扩展的,可以处理用户定义类型。下面是 <chrono> 库中(§9.3.6)一个打印日期的例子 [Zverovich et al. 2019]:

string s1 = format("{}", birthday);
string s2 = format("{0:>15%Y-%m-%d}", birthday);

“年-月-日”是默认格式。>15 意味着使用 15 个字符和右对齐文本。日期库中还包含了另一门小的格式化语言可以同 format() 一起用。它甚至可以用来处理时区和区域:

std::format(std::locale{"fi_FI"}, "{}", zt);

这段代码将会给出芬兰的当地时间。默认情况下,格式化不依赖于区域,但是你可以选择是否根据区域来格式化。相比于传统的 iostream,默认区域无关的格式化大大提升了性能,尤其是当你不需要区域信息的时候。

输入(istream)没有等价的 format 支持。

9.3.8 跨度

越界访问,有时也称为缓冲区溢出,从 C 的时代以来就一直是一个严重的问题。考虑下面的例子:

void f(int* p, int n)  // n 是什么?
{
    for (int i=0; i<n; ++i)
        p[i] = 7;  // 可以吗?
}

试问一个工具,比如编译器要如何知道 n 代表着所指向的数组中元素的个数?一个程序开发人员如何要能够在一个大型程序中对此始终保持正确?

int x = 100;
int a[100];
f(a,x);    // 可以
f(a,x/2);  // 可以:a 的前半部分
f(a,x+1);  // 灾难!

几十年来,像“灾难”这样的评论一向是准确的,范围错误也一直是大多数重大安全问题的根因。编译器不能够捕获范围错误,而运行期检查所有的下标则普遍被认为对于生产代码来说代价过于高昂。

显而易见的解决方案就是提供一种抽象机制,带有一个指针再加上一个大小。举例来说,1990 年,Dennis Ritchie 向 C 标准委员会提议:“‘胖指针’,它的表示中包括了内存空间以存放运行期可调整的边界。”[Ritchie 1990]。由于各种原因,C 标准委员会没有通过这个提案。在当时,我听到一条极可笑的评论:“Dennis 不是 C 的专家;他从不来参加会议。”我没记住这到底是谁说的,也许这是件好事。

2015 年,Neil MacIntosh(那个时候他还在微软)在 C++ 核心指南(§10.6)里恢复了这一想法,那里我们需要一种机制来鼓励和选择性地强制使用高效编程风格。span<T> 类模板就这样被放到 C++ 核心指南的支持库中,并立即被移植到微软、Clang 和 GCC 的 C++ 编译器里。2018 年,它投票进入了 C++20。

使用 span 的一个例子如下:

void f(span<int> a)  // span 包含一根指针和一条大小信息
{
    for (int& x : a)
        x = 7;  // 可以
}

范围 for 从跨度中提取范围,并准确地遍历正确数量的元素(无需代价高昂的范围检查)。这个例子说明了一个适当的抽象可以同时简化写法并提升性能。对于算法来说,相较于挨个检查每一个访问的元素,明确地使用一个范围(比如 span)要容易得多,开销也更低。

如果有必要的话,你可以显式地指定一个大小(比如操作一个子范围)。但这样的话,你需要承担风险,并且这种写法比较扎眼,也易于让人警觉:

int x = 100;
int a[100];
f(a);        // 模板参数推导:f(span<int>{a, 100})
f({a,x/2});  // 可以:a 的前半部分
f({a,x+1});  // 灾难

自然、简单的元素访问也办得到,比如 a[7]=9, 同时运行期也能进行检查。span 的范围检查是 C++ 核心指南支持库(GSL)的默认行为。

事实证明,将 span 纳入 C++20 的最具争议的部分在于下标和大小的类型。C++ 核心指南中 span::size() 被定义返回一个有符号整数,而不是标准库容器所使用的无符号整数。下标的情况也类似。像在数组中,下标一向是有符号的整数,而在标准库容器中下标却是无符号整数。这导致了一个古老争议的重演:

  • 一组人认为显然下标作为非负数应该使用无符号整数。
  • 一组人认为与标准库容器保持一致性更重要,这点使得使用无符号整数是不是一个过去的失误变得无关紧要。
  • 一组人认为使用无符号整数去表示一个非负数是一种误导(给人一种虚假的安全感),并且是错误的主要来源之一。

不顾 span 最初的设计者(包括我在内)和实现者的强烈反对,第二组赢得了投票,并受到第一组热情地支持。就这样,std::span 拥有无符号的范围大小和下标。我个人认为那是一个令人悲伤的失败,即未能利用一个难得的机会来弥补一个令人讨厌的老错误 [Stroustrup 2018e]。C++ 委员会选择了与问题兼容而不是消除一个重大的错误来源,这在某种程度上是可以预见的,也算不无道理吧。

但是用无符号整数作为下标会出什么问题呢?这似乎是一个相当情绪化的话题。我曾收到很多封与之相关的仇恨邮件。存在两个基本问题:

  • 无符号数并不以自然数为模型:无符号数使用模算数,包括减法。比如,如果 ch 是个 unsigned charch+100 将永远不会溢出。
  • 整数和无符号数彼此相互转换,稍不留意负数值就会变成巨大的无符号数值,反之亦然。比如,-2<2u 为假;2uunsigned,因此 -2 在比较前会被转换为一个巨大的正整数。

这是一个在真实环境下偶尔可见的无限循环的例子:

for (size_t i = n-1; i >= 0; --i) { /* ... */ }  // “反向循环”

不幸的是,标准库中的类型 size_t 是无符号类型,然后很明显结果永远 >=0

总的来说,作为 C++ 继承自 C 的特性,有符号和无符号类型之间的转换规则几十年来都是那种难以发现的错误的一个主要来源。但说服委员会去解决那些老问题总是很难的。

9.4 并发

尽管做出了英勇的努力,并正在形成广泛的共识,但是人们所期望的通用并发模型(“执行器”)在 C++20 中还没有准备好(§8.8.1)。这并非是因为缺乏努力,我们的努力中包括了 2018 年 9 月在华盛顿州贝尔维尔举行的为期两天的特别会议,约有 25 人出席,其中有来自英伟达、Facebook 和美国国家实验室的代表。不过,有几个不那么剧烈的有用改进还是及时完成了,其中包括:

  • jthread 和停止令牌 [Josuttis et al. 2019a]
  • atomic<shared_ptr<T>> [Sutter 2017b]
  • 经典的信号量 [Lelbach et al. 2019]
  • 屏障和锁存器 [Lelbach et al. 2019]
  • 小的内存模型的修复和改进 [Meredith and Sutter 2017]

jthread(“joining thread”的缩写)是一个遵守 RAII 的线程;也就是说,如果 jthread 超出作用域了,它的析构函数将合并线程而不是终止程序:

void some_fct()
{
    thread t1;
    jthread t2;
    // ...
}

在作用域的最后,t1 的析构函数会终止程序,除非 t1 的任务已经完成,已经 joindetach,而 t2 的析构函数将会等待其任务完成。

一开始的时候(C++11 之前),很多人(包括我在内)都希望 thread 可以拥有如今 jthread 的行为,但是根植于传统操作系统线程的人坚持认为终止一个程序要远比造成死锁好得多。2012 年和 2013 年,Herb Sutter 曾经提出过合并线程 [Sutter 2012, 2013a]。这引发了一系列讨论,但最终却没有作出任何决定。2016 年,Ville Voutilainen 总结了这些问题,并为将合并线程纳入 C++17 发起了投票 [Voutilainen 2016a]。投票支持者众多以至于我(只是半开玩笑地)建议我们甚至可以把合并线程作为一个错误修复提交给 C++14。但是不知何故,进展又再次停滞。到了 2017 年,Nico Josuttis 又一次提出了这个问题。最终,在八次修订和加入了停止令牌之后,这个提案才成功进入了 C++20 [Josuttis et al. 2019a]。

“停止令牌”解决了一个老问题,即如何在我们对线程的结果不再感兴趣后停止它。基本思想是使用协作式的线程取消方式(§4.1.2)。假如我想要一个 jthread 停止,我就设置它的停止令牌。线程有义务不时地去检查停止令牌是否被设置了,并在设置时进行清理和退出。这个技巧由来已久,对于几乎每一个有主循环的线程都能完好高效地工作,在这个主循环里就可以对停止令牌进行检查。

像往常一样,命名成了问题:safe_threadithreadi 代表可中断)、raii_threadjoining_thread,最终成了 jthread。C++ 核心指南支持库 (GSL) 中称其为 gsl::thread。说真的,最合适的名字就是 thread,但是很不幸,那个名字已经被一类不太有用的线程占用了。

9.5 次要特性

C++20 提供了许多次要的新特性,包括:

  • C99 风格的指派初始化器 [Shen et al. 2016]
  • 对 lambda 捕获的改进 [Köppe 2017b]
  • 泛型 lambda 表达式的模板参数列表 [Dionne 2017]
  • 范围 for 中初始化一个额外的变量(§8.7
  • 不求值语境中的 lambda 表达式 [Dionne 2016]
  • lambda 捕获中的包展开 [Revzin 2017]
  • 在一些情况下移除对 typename 的需要 [Vandevoorde 2017]
  • 更多属性:[[likely]][[unlikely]] [Trychta 2016]
  • 在不使用宏的情况下,source_location 给出一段代码中的源码位置 [Douglas and Jabot 2019]
  • 功能测试宏 [Voutilainen and Wakely 2018]
  • 条件 explicit [Revzin and Lavavej 2018]
  • 有符号整数保证是 2 的补码 [Bastien 2018]
  • 数学上的常数,比如 pisqrt2 [Minkovsky and McFarlane 2019]
  • 位的操作,比如轮转和统计 1 的个数 [Maurer 2019]

其中有些属于改进,但是我担心的是晦涩难懂的新特性的数量之大会造成危害 [Stroustrup 2018d]。对于非专家来说,它们使得语言变得更加难以学习,代码更加难以理解。我反对一些利弊参半的特性(比如,使用指派初始化器的地方原本可以使用构造函数,那会产生更易于维护的代码)。很多特性具有特殊用途,有些是“专家专用”。不过,有的人总是领会不到,一个对某些人有某种好处的特性,对于 C++ 整体可能是个净负债。当然,那些增加写法和语义上的通用性和一致性的小特性,则总是受欢迎的。

从标准化的角度来看,即使最小的特性也需要花时间去处理、记录和实现。这些时间是省不掉的。

9.6 进行中的工作

当然,很多目标放在 C++20 之后版本的工作还在进行中,而另一些原本目标在 C++20 发布的工作则没能及时完成,尤其是:

  • §8.8.1:网络和执行器——再度延迟。
  • §9.6.1:契约——断言、前置条件和后置条件;原本目标是 C++20,但延迟了。
  • §9.6.2:反射——基于当前编译的代码将代码注入程序;目标是 C++23。

另外,工作组和研究组也仍有工作正在进行中(§3.2)[Stroustrup 2018d]。

9.6.1 契约

契约的特殊之处在于,不但很多人希望它可以进入 C++20,而且契约是被投票写入 C++20 的工作文件中的,只是在最后一刻被从中移除。一个由 John Spicer 主持的新的研究组 SG21 已经成立,试图为 C++23 或者 C++26 提供某种形式的契约。契约于 C++20 的遭遇是令人惋惜的,但可能也能给人以启发。

各种形式的契约在 C++ 和其他语言中都有着悠久的历史。我记得在 1970 年代初,当我第一次遇到 Peter Naur 的不变量 [Naur 1966] 的时候,我一度被它深深吸引。在 1990 年代早期,一个被称为 A++ 的断言系统被考虑用于 C++,但却被认为涉及面太广而不现实。在 1980 年代晚期,Bertrand Meyer 曾推广过 Eiffel 里“契约”的概念 [Meyer 1994]。作为 C++0x 努力的一部分,一些提案 [Crowl and Ottosen 2006] 在 C++ 委员会受到了高度重视,但最终却失败了,主要原因在于被认为过于复杂,写法也不优雅。

多年来,Bloomberg(那家纽约市的金融信息公司)一直使用一个名为“契约”的实时断言系统去捕获代码中的问题。2013 年,来自 Bloomberg 的 John Lakos 提议标准化该系统 [Lakos and Zakharov 2013]。这个提案受到了好评,但它遇到两个问题:

  • 它基于宏
  • 它严格来说是代码实现中的断言,而不是可以增强接口的东西

修订接踵而至,但是共识却没有出现。为了打破僵局,一群来自微软、Facebook、谷歌和马德里的卡洛斯三世大学的人提出一个“简单契约”的系统,该系统不使用宏,并且对前置条件和后置条件提供支持(正如 C++0x 所尝试的)[Garcia et al. 2015]。和 Bloomberg 的提案一样,这一提案得到了多年大规模工业应用的背书,但它的重点是在静态分析中使用契约。J. Daniel Garcia(卡洛斯三世大学)努力工作以求做出满足各方面需求的设计,但该提案也遭到了反对。

经过了无数次的会议、多篇论文和(偶尔激烈的)讨论之后,妥协显然是难以达成了。两个小组请求我来进行协调。我之前宣称,讨论太专注在细枝末节上了,而我们需要一个最小提案,包含两个小组的核心诉求,而不是有争议的细节。他们要我来证明我的推断,拿一个这样的最小提案出来。在我和两个小组的代表轮番讨论、工作了相当一段时间之后,我们最终联合各方共同起草了联合提案 [Dos Reis et al. 2016a]。我认为这个设计技术上是相当充分的,并非一个政治上的妥协。它旨在满足三方面的需求(按重要性排序):

  • 系统和可控的运行期测试
  • 为静态分析器提供信息
  • 为优化器提供信息

在 J. Daniel Garcia 领导的进一步工作之后,该提案最终在 2018 年 6 月正式被 C++20 采纳 [Dos Reis et al. 2018]。

为避免引入新的关键字,我们使用属性语法。例如,[[assert: x+y>0]]。一个契约对一个有效的程序不起任何作用,因此这种方式满足属性的原来概念(§4.2.10)。

有三种契约:

  • assert——可执行代码中的断言
  • expects——函数声明中的前置条件
  • ensure——函数声明中的后置条件

有三种不同等级的契约检查:

  • audit——“代价高昂的”谓词,仅在某些“调试模式”检查
  • default——“代价低廉的”谓词,即使在生产代码中检查也是可行的
  • axiom——主要给静态分析器看的谓词,在运行期从不检查

在违反契约时,将执行(可能是用户安装的)契约违反处理程序。默认行为是程序立即终止。

我发现一个有意思的事:有一种构建模式允许程序在契约失败后继续执行。我的第一反应是“疯了吧!契约旨在防止违反契约的程序运行”。那算是最最常见的反应了。不论如何,John Lakos 坚信,基于 Bloomberg 代码的相关经验,当你把契约加入一个大型的古老代码仓库,契约总是会被违反:

  • 某些代码会违反契约,而实际上并没有做任何该契约所要防止的事情。
  • 某些新契约本身就包含错误。
  • 某些新契约具有意料之外的效果。

有了继续的选项,你可以使用契约违反处理程序去记录日志并继续运行。这样的话,你既可以在单次运行中检测到多次违规,也可以让契约在假定正确的老代码中启用。人们相信这是逐步采用契约的关键。

我们并没有找到充足的理由去添加类不变量,或允许在覆盖函数中削弱前置条件,或允许在覆盖函数中增强后置条件。要点是简单。理想情况是先为 C++20 提供一个最小的初始设计,然后如有需要再在之上添砖加瓦。

这个设计由 J. Daniel Garcia 实现,并于 2018 年 6 月投票通过进入 C++ 委员会的 C++20 的工作文件中。像往常一样,虽然规范还有一些问题,但我们相信能够赶在最终标准发布前的两年内修复所有的问题。例如,人们发现工作文件文本中允许编译器基于所有契约(无论检查与否)进行优化。那并非有意而为之。从所有的契约在正确的程序中都有效的角度看,这是合理的,但是这么做,对于那些带有特别为捕获“不可能的错误”而写的契约的程序来说却是灾难性的。考虑下面的例子:

[[assert: p!=nullptr]]
p->m = 7;

假如 p==nullptr,那么 p->m 将是未定义行为。编译器被允许假设未定义行为不会发生;由此编译器优化掉那些导致未定义行为的代码。这样做的结果可能让人大吃一惊。在这样的情况下,如果违反契约之后程序能够继续执行,编译器将被允许假定 p->m 是有效的,因此 p!=nullptr;然后编译器会消除契约关于 p==nullptr 的检查。这种被称为“时间旅行优化”的做法当然是与契约的初衷大相径庭,还好若干补救方案被及时提出 [Garcia 2018; Stroustrup 2019c; Voutilainen 2019a]。

2018 年 8 月,在 C++20 新提案的最后期限过后,由 John Lakos 领导的 Bloomberg 的一个小组,包括 Hyman Rosen 和 Joshua Berne 在内,提出了一系列重新设计的提案 [Berne et al. 2018; Berne and Lakos 2018a,b; Lakos 2018]。特性冻结的日期(审议新提案的最后一天)是由委员会全体投票表决确定的。这些提案则是基于在契约自身中规定契约行为的方案。例如,[[assert check_maybe_continue: x>0]][[assert assume: p!=nullptr]]

与其使用构建模式去控制所有契约(比如,激活所有默认契约或关闭所有基于契约的运行期检查)的行为,你不如直接修改单个契约的代码。在这方面,这些新方案与工作文件中决议通过的设计大相径庭。考虑下面的例子:

[[assert assume: p!=nullptr]]

这将使得 2014 年被否决的基于宏的方案卷土重来,因为管理代码变化的显然方式是用宏,例如:

[[assert MODE1: p!=nullptr]]

这里的 MODE1 可以被 #define 成所支持的若干选项之一,如 assumedefault。或者,大致等效地,通过命令行上的参数(类似于命令行宏)来定义诸如 assume 之类的限定符的含义。

本质上,契约违约后继续执行的可能性与程序员对契约含义的控制的两者的结合,将把契约机制从断言系统转变为一种新的控制流机制。

一些提案甚至建议放弃对静态分析的支持。类似这样的提案有几十个变种,全都来得太晚,没一个能增进共识。

大量涌入的新奇提案(来自 Bloomberg 团队和其他团队,比如,[Berne 2019; Berne and Lakos 2019; Khlebnikov and Lakos 2019; Lakos 2019; Rosen et al. 2019])和成百上千讨论这些提案的电子邮件阻碍了真正必需的讨论,即对工作文件中的设计现状进行问题修复。正如我曾不断警告的那样(比如 [Stroustrup 2019c]),这些企图重新设计契约的提案的结果是,在 Nico Josuttis 的提议下,契约被从 C++20 中移除 [Josuttis et al. 2019b]。我认为去年关于契约的讨论是一个典型的例子,谁都得不到任何东西,因为有人只想要按他们的方式来。时间会给出答案,是否新的研究组 SG21 能够为 C++23 或 C++26 交付某种能够被更广泛接受的东西。

9.6.2 静态反射

2013 年,一个研究“反射”的研究组(SG7)成立了,并发出了征集意见的呼吁 [Snyder and Carruth 2013]。有一个广泛的共识,那就是 C++ 需要静态反射机制。更确切地说,我们需要一种方法来写出能检查它自己是属于哪个程序的一部分的代码,并基于此往该程序中注入代码。那样,我们就可以使用简洁的代码替换冗长而棘手的样板代码、宏和语言外的生成器。比如,我们可以为下面的场景自动生成函数,如:I/O 流、日志记录、比较、用于存储和网络的封送处理(marshaling)、构造和使用对象映射、枚举的“字符串化”、测试支持,及其他的更多可能 [Chochlík et al. 2017; Stroustrup 2018g]。反射研究组的目标是为 C++20 或 C++23 做好准备;我们认为 C++17 并不是一个现实的目标。

大家普遍认同,依赖在运行期遍历一个始终存在的数据结构的反射/内省方式不适合 C++,因为这种数据的大小、语言构造的完整表示的复杂性和运行期遍历的成本都会是问题。

很快出现了一些提案 [Chochlík 2014; Silva and Auresco 2014; Tomazos and Spertus 2014],并且,在接下来的数年里,由 Chandler Carruth 主持的研究组召开了多次会议试图决定其范围和方向。选定的方式基于类型,这些类型以经典的面向对象的类层次结构来组织,需要泛型的地方由概念(§6)支持 [Chochlík 2015; Chochlík and Naumann 2016; Chochlík et al. 2017]。该方式主要由 Matóš Chochlík、Axel Naumann 和 David Sankel 发展和实现。结果作为一项技术规范在 2019 得以批准 [Sankel 2018]。

在静态反射(预期的)长时间的酝酿期内,基于 constexpr 函数(§9.3.3)的编译期计算稳步发展,最终出现了基于函数而不是类层次结构的静态反射的提案。主要的拥护者是 Andrew Sutton、Daveed Vandevoorde、Herb Sutter 和 Faisal Vali [Sutton and Sutter 2018; Sutton et al. 2018]。设计焦点转移的主要论据,一部分是由于分析和生成代码这些事天生就是函数式的,而且基于 constexpr 函数的编译期计算已经发展到元编程和反射相结合的地步。这种方法的另一个优点(最先由 Daveed Vandevoorde 提出)是从编译器的内部的数据结构看,为函数服务的天生就比为类型层次结构服务的更小、生命周期更短暂,因此它们使用的内存明显更少,编译速度也明显更快。

2019 年 2 月在科隆召开的标准会议上,David Sankel 和 Michael Park 展示了一个结合了这两个方法优点的设计 [Sankel and Vandevoorde 2019]。在最根本的层面上仅有一个单一的类型存在。这达到了最大的灵活性,并且编译器开销也最小。

最重要的是,静态类型的接口可以通过一种类型安全的转换来实现(从底层的单一类型 meta::info 到更具体的类型,如 meta::type_meta::class_)。这里有一个基于 [Sankel and Vandevoorde 2019] 的例子。通过概念重载(§6.3.2),它实现了从 meta::info 到更具体类型的转换。考虑下面的例子:

namespace meta {
    consteval std::span<type_> get_member_types(class_ c) const;
}

struct baz {
    enum E { /*...*/ };
    class Buz{ /*...*/ };
    using Biz = int;
};

void print(meta::enum_);    // 打印一个枚举类型
void print(meta::class_);   // 打印一个类类型
void print(meta::type_);    // 打印任何类型

void f()
{
    constexpr meta::class_ metaBaz = reflexpr(baz);
    template for (constexpr meta::type_ member : get_member_types(metaBaz))
        print(meta::most_derived(member));
}

这里关键的新语言特性是 reflexpr 运算符,它返回一个(元)对象,该对象描述了它的参数,还有 template for [Sutton et al. 2019],根据一个异质结构中的元素的类型扩展每个元素,从而遍历该结构的各元素。

此外,我们也有机制可以将代码注入正在编译的程序中。

类似这样的东西很可能会在 C++23 或 C++26 中成为标准。

作为一个副作用,在反射方案上的雄心勃勃的工作也刺激了编译期求值功能的改进:

  • 标准中的类型特征集(§4.5.1
  • 源代码位置的宏(如 __FILE____LINE__)被内在机制所替代 [Douglas and Jabot 2019]
  • 编译期计算的功能(例如,用于确保编译期求值的 consteval
  • 展开语句(template for——到 C++23 就可以用来遍历元组中的元素 [Sutton et al. 2019])。

[^1]: 译注:Heap Allocation eLision Optimization