【转载翻译C++之父文章】在纷繁多变的世界里茁壮成长:C++ 2006–2020

在纷繁多变的世界里茁壮成长:C++ 2006–2020

这是 C++ 之父 Bjarne Stroustrup 的 HOPL4 论文的中文版。转载自Github https://github.com/Cpp-Club/Cxx_HOPL4_zh

文章目录


C++

HOPL 是 History of Programming Languages(编程语言历史)的缩写,是 ACM(Association of Computing Machines,国际计算机协会)旗下的一个会议,约每十五年举办一次。Bjarne 的这篇论文是他为 2021 年 HOPL IV 会议准备的论文,也是他的第三篇 HOPL 论文。在这三篇前后间隔近三十年的论文里,Bjarne 记录了 C++ 的完整历史,从 1979 年到 2020 年。这篇 HOPL4 论文尤其重要,因为它涵盖了 C++98 之后的所有 C++ 版本,从 C++11 直到 C++20。如果你对更早期的历史也感兴趣的话,则可以参考他的其他 HOPL 论文,及他在 1994 年出版的《C++ 语言的设计和演化》(The Design and Evolution of C++)。

鉴于这篇论文对于 C++ 从业者的重要性,全球 C++ 及系统软件技术大会的主办方 Boolan 组织了一群译者,把这篇重要论文翻译成了中文,让 C++ 开发人员对 C++ 的设计原则和历史有一个系统的了解。下面是论文的完整摘要:

到 2006 年时,C++ 已经在业界广泛使用了 20 年。它既包含了自 1970 年代初引入 C 语言以来一直没有改变的部分,又包含了在二十一世纪初仍很新颖的特性。从 2006 年到 2020 年,C++ 开发者人数从约 300 万增长到了约 450 万。在这段时期里,有新的编程模型涌现出来,有硬件架构的演变,有新的应用领域变得至关重要,也有好些语言在争夺主导地位,背后有雄厚的资金支持和专业的营销。C++——一种没有真正商业支持的、老得多的语言——是如何在这些挑战面前继续茁壮成长的?

本文重点关注 ISO C++ 标准在 2011 年、2014 年、2017 年和 2020 年的修订版中的重大变化。标准库在篇幅上约占 C++20 标准的四分之三,但本文的主要重点仍是语言特性和它们所支持的编程技术。

本文包含了长长的特性清单,其中记录了 C++ 的成长。我会对重要的技术要点进行讨论,并用简短的代码片段加以说明。此外,本文还展示了一些失败的提案,以及导致其失败的讨论。它提供了一个视角,如何看待这些年来令人眼花缭乱的事实和特性。我的重点是塑造语言的想法、人和流程。

讨论主题包括各种方向上的努力,包括:通过演进式变化保留 C++ 的本质,简化 C++ 的使用,改善对泛型编程的支持,更好地支持编译期编程,扩展对并发和并行编程的支持,以及保持对几十年前的代码的稳定支持。

ISO C++ 标准是通过一个共识流程演化而来的。无可避免,在方向、设计理念和原则方面,不同的提案间存在竞争和(通常是礼貌性的)冲突。委员会现在比以往任何时候都更大、更活跃,每年有多达 250 人参加三次为期一周的会议,还有更多的人以电子方式参加。我们试图(并不总是成功)减轻各种不良影响,包括“委员会设计”、官僚主义,以及对各种语言时尚的过度热衷。

具体的语言技术话题包括内存模型、并发并行、编译期计算、移动语义、异常、lambda 表达式和模块。要设计一种机制来指定模板对其参数的要求,既足够灵活和精确,又不会增加运行期开销,实践证明这很困难。设计“概念”来做到这一点的反复尝试可以追溯到 1980 年代,并触及到 C++ 和泛型编程的许多关键设计问题。

文中的描述基于个人对关键事件和设计决策的参与,并以 ISO C++ 标准委员会档案中的数千篇论文和数百份会议记录作为支持。


参加论文翻译工作的译者有(按拼音序):

  • 陈常筠
  • 高辉
  • 何荣华
  • 何一娜
  • 侯晨
  • 侯金亭
  • 彭亚
  • 王奎
  • 王绍新
  • 吴咏炜
  • 徐宁
  • 杨文波
  • 于波
  • 余水清
  • 翟华明
  • 章爱国
  • 张云潮

论文翻译的校对和体例统一工作由吴咏炜、杨文波、张云潮完成。最后的发布由吴咏炜完成。

我们翻译的是论文的正文部分,英文原文超过 140 页。最后的参考文献部分,由于翻译的意义不大,没有译出。不过,这也带来了一个小小的负面后果:虽然我们对论文内部的交叉引用可以用脚本生成链接,但对指向参考文献的链接就完全无能为力了。所以,如果想要阅读参考文献的话,只能请你到英文原文结尾的 References 部分自行查找了。

翻译过程中我们发现了一些原文中的小问题,并在译文中进行了修正或标注(绝大部分已经经过 Bjarne 老爷子确认)。当然,在翻译过程中引入翻译错误或其他技术问题,恐怕也在所难免——不过,跟 ACM 上发表论文不同,这个网页仍然是可以修正的。所以,如果你,亲爱的读者,发现问题的话,请不吝提交 pull request,我们会尽快检查并进行修正。

1. 前言

最初,我设计 C++ 是为了回答这样的一个问题:如何直接操作硬件,同时又支持高效、高级的抽象?C++ 在 1980 年代仅仅是一个基于 C 和 Simula 语言功能的组合,在当时的计算机上作为系统编程的相对简单的解决方案,经过多年的发展,已经成长为一个远比当年更复杂和有效的工具,应用极其广泛。它保持了如下两方面的关注:

  • 语言构件到硬件设施的直接映射
  • 零开销抽象

这种组合是 C++ 区别于大多数语言的决定性特征。“零开销”是这样解释的 [Stroustrup 1994]:

  • 你不用的东西,你就不需要付出代价(“没有四处散落的赘肉”)。
  • 你使用的东西,你手工写代码也不会更好。

抽象在代码中体现为函数、类、模板、概念和别名。

C++ 是一种活的语言,因此它会不断变化以应对新出现的挑战和演变中的使用风格。2006 年至 2020 年期间的这些挑战和变化是本文的重点。当然,一门语言本身不会改变;是人们改变了它。所以这也是参与 C++ 演化的人们的故事,他们识别出面临的挑战,诠释解决方案的局限,组织他们的工作成果,并解决他们之间必然出现的分歧。当我呈现一种语言或标准库特性时,其背景是 C++ 的一般发展和当时参与者的关切。对于在早期被接受的许多特性,我们现在从大量的工业使用中获得了后见之明。

C++ 主要是一种工业语言,一种构建系统的工具。对于用户来说,C++ 不仅仅是一种由规范定义的语言;它是由许多部分组成的工具集的一部分:

  • 语言
  • 标准库
  • 许多的其他库
  • 庞大的——常常是旧的——代码库
  • 工具(包括其他语言)
  • 教学和培训
  • 社区支持

只要有可能,只要合适,我就会考虑这些组成部分之间的相互作用。

有一种流传广泛的谬见,就是程序员希望他们的语言是简单的。当你不得不学习一门新的语言、不得不设计一门编程课程、或是在学术论文中描述一门语言时,追求简单显然是实情。对于这样的用途,让语言干净地体现一些明确的原则是一个明显的优势,也是理想情况。当开发人员的焦点从学习转移到交付和维护重要的应用程序时,他们的需求从简单转移到全面的支持、稳定性(兼容性)和熟悉度。人们总是混淆熟悉度和简单,如果可以选择的话,他们更倾向于熟悉度而不是简单。

看待 C++ 的一种方式是,把它看成几十年来三种相互矛盾的要求的结果:

  • 让语言更简单!
  • 立即添加这两个必要特性!!
  • 不要搞砸我的(任何)代码!!!

我添加了感叹号,因为这些观点的表达常常带着不小的情绪。

我想让简单的事情简单做,并确保复杂的事情并非不可能,也不会没有必要地难。前者对于不是语言律师的开发者来说是必不可少的;后者对于基础性代码的实现者是必要的。稳定是所有意图持续运行几十年的系统的基本属性,然而一种活的语言必须适应不断变化的世界。

C++ 有一些总体构想。我阐述了一些(如《C++ 语言的设计和演化》(The Design and Evolution of C++)[Stroustrup 1994](§2)、设计原则(§9.1),以及 C++ 模型(§11.1))并试图让语言在演化时遵循它们。然而,C++ 的开发由 ISO 标准委员会控制,它主要关注的是长长的新特性列表,以及对实际细节的关心。这是社区里最能表达和最有影响力的人所坚持的东西,仅仅基于哲学或理论观点就否认他们的关切和意见的话,恐怕就失之鲁莽了。

1.1 年表

为了给出一个快速的概述,这里有一个粗略的年表。如果你不熟悉 C++,很多术语、构件、库都会晦涩难懂;大多数在以前的 HOPL 论文 [Stroustrup 1993, 2007] 或本文中有详细解释。

  • 1979 年:工作始于“带类的 C”,它变成了 C++;拥有了第一个非研究性的用户;
    • 语言:class、构造函数/析构函数、public/private、简单继承、函数参数类型检查
    • 库:task(协程和仿真支持)、用宏参数化的 vector
  • 1985 年:C++ 的首次商业发行;TC++PL1 [Stroustrup 1985b]
    • 语言:virtual 函数、运算符重载、引用、常量
    • 库:complex 算法,流输入输出
  • 1989–91 年:ANSI 和 ISO 标准化开始;TC++PL2 [Stroustrup 1991]
    • 语言:抽象类、多重继承、异常、模板
    • 库:输入输出流(但没有 task
  • 1998 年:C++98、第一个 ISO C++ 标准 [Koenig1998]、TC++PL3 [Stroustrup 1997]
    • 语言:namespace、具名类型转换1booldynamic_cast
    • 库:STL(容器和算法)、stringbitset
  • 2011 年:C++11 [Becker 2011],TC++PL4 [Stroustrup 2013]
    • 语言:内存模型、auto、范围 forconstexpr、lambda 表达式、用户定义字面量……
    • 库:thread 和锁、futureunique_ptrshared_ptrarray、时间和时钟、随机数、无序容器(哈希表)……
  • 2014 年:C++14 [du Toit 2014]
    • 语言:泛型 lambda 表达式、constexpr 函数中的局部变量、数字分隔符……
    • 库:用户定义字面量……
  • 2017 年:C++17 [Smith 2017]
    • 语言:结构化绑定、变量模板、模板参数的构造函数推导……
    • 库:文件系统、scoped_lockshared_mutex(读写锁)、anyvariantoptionalstring_view、并行算法……
  • 2020 年:C++20 [Smith 2020]
    • 语言:conceptmodule、协程、三路比较、改进对编译期计算的支持……
    • 库:概念、范围、日期和时区、span、格式、改进的并发和并行支持……

请注意,早年 C++ 的库是很匮乏的。事实上,当时还是存在大量各种各样的库(包括图形用户界面库),但很少被广泛使用,并且很多库是专有软件。这是在开源开发普及之前的事。这造成了 C++ 社区没有一个重要的共享基础库。在我的 HOPL2 论文 [Stroustrup 1993] 的回顾中,我认为那是早期 C++ 最糟糕的错误。

任务库 [Stroustrup 1985a,c] 是一个基于协程的库,支持事件驱动的仿真(例如随机数生成),与替代方案相比是非常高效的,甚至可以运行在很小的计算机上。例如,我在 256KB 的内存中运行了 700 个任务的仿真。任务库在 C++ 早期非常重要,是贝尔实验室和其他地方许多重要应用的基础。然而,它有点丑陋,并且不容易移植到 Sun 的 SPARC 体系结构,因此大多数 1989 年以后的实现都不支持它。2020 年,协程才刚刚回归(§9.3.2)。

总的来说,C++ 的特性不断增多。ISO 委员会也废除了一些特性,对语言进行了稍许清理,但是考虑到 C++ 的大量使用(数十亿行代码),重要的特性是永远不会被移除的。稳定性也是 C++ 的关键特性。要解决跟语言不断增长的规模和复杂性相关的问题,办法之一是通过编码指南(§10.6)。

1.2 概述

这篇论文是按照 ISO 标准发布的大致时间顺序组织的。

  • §1:前言
  • §2:背景:C++ 的 1979–2006
  • §3:C++ 标准委员会
  • §4:C++11:感觉像是门新语言
  • §5:C++14:完成 C++11
  • §6:概念
  • §7:错误处理
  • §8:C++17:大海迷航
  • §9:C++20:方向之争
  • §10:2020 年的 C++
  • §11:回顾

如果一个主题跨越了一段较长的时间,比如“概念”和标准化流程,我会把它放在一个地方,让内容优先于时间顺序。

这篇论文特别长,真是一篇专题论文了。但是从 2006 年到 2020 年,C++ 经历了两次主要修订:C++11 和 C++20;而论文的早期读者们也都要求获得更多的信息。结果就是论文的页数几乎翻倍。即使以目前的篇幅,读者也会发现某些重要的主题没有得到充分的展现,如并发和标准库。

2. 背景:C++ 的 1979–2006

C++ 从 1979 年到 2006 年的历史记录在我的 HOPL 论文中 [Stroustrup 1993, 2007]。在那段时间里,C++ 从一个单人的研究项目成长为大约有 300 万程序员的社区。

2.1 第一个十年

后来成为了 C++ 的东西始于 1979 年 4 月,名为带类的 C(C with Classes)。我的目标是设计一个工具,它既拥有直接而高效的处理硬件的能力(例如编写内存管理器、进程调度器和设备驱动程序),又同时可以有类似 Simula 的功能来组织代码(例如“强”静态可扩展类型检查、类、多级类和协程)。我想用这个工具编写一版 Unix 内核,可以在通过局域网或共享内存互联的多个处理器上运行。

我选择 C 作为我工作的基础,因为它足够好,并且在办公室里就能得到很好的支持:我的办公室就在 Dennis Ritchie 和 Brian Kernighan 走廊对面。然而,C 语言并不是我考虑的唯一语言。Algol68 当时深深吸引了我,我还是 BCPL 和其他一些机器层面的语言的专家。C 后来的巨大成功在当时还完全不确定,但是 Brian Kernighan 和 Dennis Ritchie 杰出的介绍和手册 [Kernighan and Ritchie1978] 已经出现,Unix 也正开始它的胜利路程。

最初我实现的是一个预处理器,它将“带类的 C” 差不多逐行翻译成 C。1982 年,在“带类的 C”的用户数量增长到了几十人的时候,这种方法已经显得无法把控了。所以我写了一个传统的编译器,叫作 Cfront,1983 年 10 月第一次给别人使用。Cfront 是一个传统的编译器,它有一个词法分析器、一个构建抽象语法树的语法分析器、一个用类型装饰语法树的类型检查器,以及一个重新排列 AST 以提高生成代码的运行期效率的高层次优化器。关于 Cfront 的本质有很多困惑,因为当时它最终输出的是 C(优化的,不是特别可读的 C)。我生成了 C,这样我就不必直接处理当年正在使用的众多的(非标准化)链接器和优化器。不过,Cfront 一点也不像传统的预处理器。你可以在计算机历史博物馆的源代码收藏 [McJones 2007–2020] 中找到一份带有文档的 Cfront 源代码。Cfront 从“带类的 C”自举为 C++,所以第一个 C++ 编译器是用(简单的)C++ 写的,适合非常小的计算机(内存小于 1MB,处理器速度小于 1MHz)。

“带类的 C” 添加到 C 上的第一个特性是类。我从早期在 Simula 中的使用中了解到它们的力量,在 Simula 中,类是严格静态、但又可扩展的类型系统的关键。我立即添加了构造函数和析构函数。它们当时非常新颖,但从我的计算机架构和操作系统背景来看,我认为它们也不算很新奇,因为我需要一个机制来建立一个工作环境(构造函数)和一个逆操作来释放运行期获得的资源(析构函数)。以下摘自我 1979 年的实验记录本:

  • “new 函数”为成员函数创建运行的环境
  • “delete 函数”则执行相反的操作

“new 函数”和“delete 函数”这两个术语是“构造函数”和“析构函数”的原始术语。直到今天,我仍然认为构造函数和析构函数是 C++ 的真正核心。另见(§2.2.1)和(§10.6)。

当时,除了 C 语言,基本上所有语言都有适当的函数参数类型检查。我认为没有它我无法完成任何重要的事情。因此,在我的部门主管 Alexander Fraser 的鼓励下,我立即添加了(可选的)函数参数声明和参数检查。这就是 C 语言中现在所说的函数原型。1982 年,在看到让函数参数检查保持可选的效果后,我将其设为强制的。这导致了十几二十年里关于与 C 不兼容的大声抱怨。人们想要保留他们的类型错误,或者至少许多人大声说他们不想检查,并以此作为不使用 C++ 的借口。这个小事实也许能让人们认识到演化一门被大量使用的语言会涉及到的各种问题。

鉴于过于狭隘的 C 和 C++ 爱好者之间偶尔会恶语相向,或许值得指出,我一直是 Dennis Ritchie 和 Brian Kernighan 的朋友,在 16 年里几乎天天同他们一起吃午饭。我从他们那里学到了很多,现在还经常同 Brian 见面。我将一些对 C++ 语言的贡献 [Stroustrup 1993] 归功于他们两位,而我自己也是 C 的主要贡献者(例如函数定义语法、函数原型、const// 注释)。

为了能够理性思考 C++ 的成长,我想出了一套设计规则。这些在 [Stroustrup 1993, 1994] 中有介绍,所以这里我只提一小部分:

  • 不要陷入对完美的徒劳追求。
  • 始终提供过渡路径。
  • 说出你的意图(即,能够直接表达高层次的思路)。
  • 不要隐式地在静态类型系统方面违规。
  • 为用户定义类型提供和内置类型同样好的支持。
  • 应取消预处理器的使用。
  • 不要给 C++ 以下的低级语言留有余地(汇编语言除外)。

这些目标的野心并不小。其中某些目标,现在 2020 年了我依然在为之努力工作。在 1980 年代早期到中期,我给 C++ 添加了更多的语言功能:

  • 1981 年const——支持接口和符号常量的不变性。
  • 1982 年:虚函数——提供运行期多态。
  • 1984 年:引用——支持运算符重载和简化参数传递。
  • 1984 年:运算符和函数重载——除了算术和逻辑运算符外,还包括:允许用户定义 =(赋值)、()(调用;支持函数对象(§4.3.1))、[](下标访问)和 ->(智能指针)。
  • 1987 年:类型安全链接——消除许多来自不同翻译单元中不一致声明的错误。
  • 1987 年:抽象类——提供纯接口。

在 1980 年代后期,随着计算机能力的急剧增强,我对大型软件更感兴趣,并做了如下补充:

  • 模板——在经历了多年使用宏进行泛型编程的痛苦之后,更好地支持泛型编程。
  • 异常——试图给混乱的错误处理带来某种秩序;RAII(§2.2.1)便是为此目标而设计的。

后面这些功能并没有受到普遍欢迎(例如,见(§7))。部分原因是社区已经变得庞大和难以管理。ANSI 标准化已经开始,所以我不再能够私下实现和实验。人们坚持大规模的精心设计,坚持在认真实施之前进行广泛的辩论。我不能再在明知道不可能让每个人都满意的情况下,从一个最小的提议开始,把它发展成一个更完整的功能。例如,人们坚持到处使用笨重的带有 template<class T> 前缀的模板语法。

在 1980 年代末,“面向对象”的宣传变得震耳欲聋,淹没了我对 C++ 传达的讯息。我对 C++ 是什么和应当成为什么的看法被广泛忽视了——很多人甚至从未听说过。对于“面向对象”的某些定义来说,所有新语言都应是“纯面向对象的”。“不真正面向对象”被视为是糟糕的,不容争辩。

我从未使用过“C++ 是一种面向对象的编程语言”这种说法,这件事很多人并不知道,或者因为感到有些尴尬而有意忽略了。那时候,我的标准描述是

C++ 是一门偏向系统编程的通用编程语言,它是

  • 更好的 C
  • 支持数据抽象
  • 支持面向对象编程
  • 支持泛型编程

这个说法过去和现在都是准确的,但不如“万物皆对象!”这样的口号那么令人兴奋。

2.2 第二个十年

ANSI C++ 委员会是 1989 年 12 月在华盛顿特区的一次会议上成立的,距离第一次使用“带类的 C”这个名称仅仅 10 年多的时间。大约有 25 名 C++ 程序员出席了会议。我出席了会议,还有另外一些近些年来依然活跃的 ISO C++ 标准委员会成员当时也在。

经过了惯例性的、大约十年的工作,该委员会终于发布了第一个标准:C++98。我和许多其他人自然更愿意更快地输出一个标准,但是委员会规则、过度的雄心和各种各样的延迟使我们在时间表方面与 Fortran、C 和其他正式标准化的语言站在了同一起跑线上。

形成 C++98 的工作是 HOPL3 论文的核心 [Stroustrup 2007],所以这里我只简单总结一下。

2.2.1 语言特性

C++98 的主要语言特性是

  • 模板——无约束的、图灵完备的、对泛型编程的编译期支持,在我早期工作(§2.1)的基础上进行了许多细化和改进;这项工作仍在继续(§6)。
  • 异常——一套在单独(不可见的)路径上返回错误值的机制,由调用方栈顶上的“在别处”的代码处理;见(§7)。
  • dynamic_casttypeid——一种非常简单的运行期反射形式(“运行期类型识别”,又名 RTTI)。
  • namespace——允许程序员在编写由几个独立部分组成的较大程序时避免名称冲突。
  • 条件语句内的声明——让写法更紧凑和限制变量作用域。
  • 具名类型转换——(static_castreinterpret_castconst_cast):消除了 C 风格的类型转换中的二义性,并使显式类型转换更加显眼。
  • bool:一种被证明非常有用和流行的布尔类型;C 和 C++ 曾经使用整数作为布尔变量和常量。

让我们看一个简单的 C++98 例子。dynamic_cast 是面向对象语言中常被称为类似“是某种”的概念的 C++ 版本:

void do_something(Shape* p)
{
    if (Circle* pc = dynamic_cast<Circle*>(p)) { // p 是某种 Circle?
        // ... 使用 pc 指向的 Circle ...
    }
    else {
        // ... 不是 Circle,做其他事情 ...
    }
}

dynamic_cast 是一个运行期操作,依赖于存储在 Shape 的虚拟函数表中的数据。它通用、易用,并且与其他语言类似的功能一样高效。然而,dynamic_cast 变得非常不受欢迎,因为它的实现往往是复杂的,特殊情况下手动编码可能更高效(可以说这导致 dynamic_cast 违反了零开销原则)。在条件语句里使用声明很新颖,不过当时我认为我只是沿用了 Algol68 里的这个主意而已。

一种更简单的变种是使用引用而不是指针:

void do_something2(Shape& r)
{
    Circle& rc = dynamic_cast<Circle&>(r);  // r 是某种 Circle!
    // ... 使用 rc 引用的 Circle ...
}

这简单地断言 r 指代一个 Circle,如果不是则抛出一个异常。思路就是,错误能够在本地被合理地处理时,使用指针和测试,如果不能则依赖引用和异常。

C++98 中最重要的技术之一是 RAII(Resource Acquisition Is Initialization, 资源获取即初始化)。那是我给它取的一个笨拙的名字,想法就是每个资源都应该有一个所有者,它由作用域对象表示:构造函数获取资源、析构函数隐式地释放它。这个想法出现在早期的“带类的 C”中,但直到十多年后才被命名。这里有一个我经常使用的例子,用来说明并非所有资源都是内存:

void my_fct(const char* name)  // C 风格的资源管理
{
    FILE* p = fopen(name, "r");  // 打开文件 name 来读取
    // ... 使用 p ...
    fclose(p);
}

问题是,如果(在 fopen()fclose() 的调用之间)我们从函数 return 了,或者 throw 了一个异常,或者使用了 C 的 longjmp,那么 p 指向的文件句柄就泄漏了。文件句柄泄漏会比内存泄漏更快地耗尽操作系统的资源。这个文件句柄是非内存资源的一个例子。

解决方案是将文件句柄表示为带有构造函数和析构函数的类:

class File_handle {
    FILE* p;
public:
    File_handle(const char* name,const char* permissions);  // 打开文件
    ~File_handle();  // 关闭文件
    // ...
};

我们现在可以简化我们的用法:

void my_fct2(const char* name)  // RAII 风格的资源管理
{
    File_handle p(name,"r");    // 打开文件 name 来读取
    // ... 使用 p ...
} // p 被隐式地关闭

随着异常的引入,这样的资源句柄变得无处不在。特别的,标准库文件流就是这样一个资源句柄,所以使用 C++98 标准库,这个例子变成:

void my_fct3(const string& name)
{
    ifstream p(name);    // 打开文件 name 来读取
    // ... 使用 p ...
} // p 被隐式的关闭

请注意,RAII 代码不同于传统的函数使用,它允许在库中一劳永逸地定义“清理内存”,而不是程序员每次使用资源时都必须记住并显式编写。至关重要的是,正确和健壮的代码更简单、更短,并且至少与传统风格一样高效。在接下来的 20 年里,RAII 已遍布 C++ 库。

拥有非内存资源意味着垃圾收集本身不足以进行资源管理。此外,RAII 加上智能指针(§4.2.4)消除了对垃圾收集的需求。另见(§10.6)。

2.2.2 标准库组件

C++98 标准库提供了:

  • STL——创造性的、通用的、优雅的、高效的容器、迭代器和算法框架,由 Alexander Stepanov 设计。
  • 特征(trait)——对使用模板编程有用的编译期属性集(§4.5.1)。
  • string——一种用于保存和操作字符序列的类型。字符类型是一个模板参数,其默认值是 char
  • iostream——由 Jerry Schwartz 和标准委员会精心制作,基于我 1984 年的简单的数据流,处理各种各样的字符类型、区域设置和缓冲策略。
  • bitset——一种用于保存和操作比特位集合的类型。
  • locale——用来处理不同文化传统的精致框架,主要与输入输出有关。
  • valarray——一个数值数组,带有可优化的向量运算,但遗憾的是,未见大量使用。
  • auto_ptr——早期的代表独占所有权的指针;在 C++11 中,它被 shared_ptr(共享所有权)和 unique_ptr(独占所有权)(§4.2.4)替代。

毫无疑问,STL 框架是最为重要的标准库组件。我认为可以说,STL 和它开创的泛型编程技术挽救了 C++,使它成长为一种有活力的现代语言。像所有的 C++98 功能一样,STL 在其他地方已经有了广泛的描述(例如 [Stroustrup 1997, 2007]),所以在这里我只会给出一个简单的例子:

void test(vector<string>& v, list<int>& lst)
{
    vector<string>::iterator p
        = find_if(v.begin(), v.end(), Less_than<string>("falcon"));
    if (p != v.end())  {  // p 指向 'falcon'
        // ... 使用 *p ...
    }
    else {                // 没找到 'falcon'
        // ...
    }

    list<int>::iterator q
        = find_if(lst.begin(), lst.end(), Greater_than<int>(42));
    // ...
}

标准库算法 find_if 遍历序列(由 begin/end 定界)寻找谓词为真的元素。该算法在三个维度上都是通用的:

  • 序列元素的存储方式(这里是 vectorlist
  • 元素的类型(这里是 stringint
  • 用于确定何时找到元素的谓词(此处为 Less_thanGreater_than

注意这里没有用到任何面向对象的方法。这是依赖模板的泛型编程,有时也被称为编译期多态。

模板的写法仍然很原始,但是从 2017 年左右开始,我可以使用 auto§4.2.1)、范围(§9.3.5)和 lambda 表达式(§4.3.1)来简化代码:

void test2(vector<string>& v, list<int>& lst)
{
    auto p = find_if(v,[](const string& s) { return s<"falcon"; })
    if (p!=v.end()) {
        // ...
    }
    // ...
    auto q = find_if(lst,[](int x) { return x>42; })
    if (q!=lst.end()) {
        // ...
    }
    // ...
}

2.3 C++ 的 2006

2006 年,我和 ISO C++ 委员会的大多数其他成员都对功能丰富的 C++0x 标准寄予厚望。计划在 2007 进行特性冻结,所以我们有一个合理的预期,C++0x 将是 C++08 或 C++09。事实上 C++0x 变成了 C++11,引出了关于十六进制 C++0xB 的笑话。

在我 2006 年的 HOPL 论文 [Stroustrup 2007] 中,我列出了 39 个提案,并预测前 21 个会进入 C++0x。有趣的是,我列表上的前 25 个建议中,有 24 个进入了 C++11。我把提案 22–25 列为“正在制定中,目标是在 2007 年 7 月进行投票”。令我惊喜的是,它们全都成功了。而提案 26–39 则连 C++17 都没有进入。这中间就留下了第 10 号提案“概念”,它有一个自己的长长的悲伤故事,不过最终还是以进入 C++20 而快乐收尾。

我和其他许多人对 C++0x 的延迟感到沮丧,并担心在面对来自更现代、资金更充足的替代品的竞争时,一个未经改进的 C++ 可能无法作为一种活的语言生存下去。在 2006 年,Java 的使用仍在增加,微软的 C# 也有大量的支持和营销。我在 2006 年的估计是 C++ 的使用在过去 4 年中首次略有下降。获取真实的数字很难,我的最佳估计(下降 7%)完全在误差范围内,但确实有理由去担心。类似 Java 和 C# 这样的语言会作出这样一种假设——并常常大声宣扬——C++ 没有生态位:

  • “低级编程”可以由少量的 C 或汇编代码处理。
  • “高级编程”则可以使用一种带有巨大的运行时支持系统的更安全、更小并使用垃圾收集的语言来做,这样可以更好、更便宜、更高效地完成。
  • 像 Java 和 C# 这样的托管语言使用垃圾收集和一致的运行期范围检查,使得不太专业的程序员能更有生产力,这样可以减少对高技能的开发人员的需求。
  • 编程语言与平台的深度集成,并使用集成工具来进行支持,这对生产力和大型系统的构建至关重要。

显然,我和许多其他人并不同意。但这些在过去和现在都是严肃的争辩,它们如果正确的话应该导致 C++ 被放弃使用。C++ 基于传统的编程语言模型,与底层操作系统分离,并由众多独立的工具供应者提供支持。托管语言往往是专有的;只有一个庞大而富有的组织才能开发所需的庞大基础设施和库。我和 C++ 社区中的其他许多人更喜欢不受公司控制的语言;这是我参加 ISO 标准工作的一个原因。

回想起来,2006 年可能是 C++ 的最低谷,但重要的技术事件也恰好在此时发生了:大约在 2005 年,历史上第一次单个处理器(单核)的性能停止提高,能效(“每瓦特的性能”)成为一个关键指标(尤其是对于服务器集群和手持设备)。计算经济学转而青睐更好的软件。硬件的进步再也不能完全掩盖语言或编程技术的低效。这样,执掌“利器”的高手跟差点的程序员或受工具链开销束缚的程序员相比,能赢得高一个数量级的经济优势,而这种优势十多年之后还依然存在。即使在今天,这些事实还没有被所有的教育和管理体制充分领会,但是现在有许多重要的任务,为它们花时间精心打造高性能的代码会获得巨大的回报。

另一个转折点来自供应商,他们试图通过定义标准接口(比如图形用户界面)将自己喜欢的语言强加给所有用户,而这只能通过使用他们喜欢的、通常是专有的语言来实现。比如谷歌对安卓系统使用 Java,苹果对 iOS 使用 Objective-C,微软对 Windows 使用 C#。应用程序供应商可以尝试通过使用一些编程方言来避开锁定,例如 Objective C++ [Objective C++ Wikipedia 2020] 或 C++/CLI [ECMA International 2005],但是这样写出的代码仍然不可移植。许多组织,比如 Adobe、谷歌和微软,他们的响应方式是使用 C++ 编写他们要求苛刻的应用程序的主要部分,然后为各种平台(如 Android、iOS 和 Windows)使用薄接口层。2006 年时这一趋势几乎不引人注目。

在便携式设备(尤其是智能手机)上,对能效和平台独立性的需求是彼此融合的。一个影响是,据我在 2018 年的最佳估计,自 2006 年以来 C++ 程序员的数量增长了约 50%,达到约 450 万名开发人员 [Kazakova 2015]。也就是说开发者每年增长 15 万人,十年来每年大约增长 4%。

2006 年,很少有人注意到硬件趋势对 C++ 固有优势的滋养。而社区和标准委员会正在关注新的语言特性和库,以增加 C++ 的实用性并提高对它的热情。包括我在内的一些委员感到迫切需要重大改进。其他人更关注于稳定语言和改进它的实现。一个标准委员会需要这两个群体,但创新和整顿之间不断的拉锯战是紧张的来源。就像在任何大型组织中一样,维护现状和服务当前用户的人有组织上优势。在《C++ 程序设计语言(第三版)》[Stroustrup 1997] 中,我引用了尼科洛·马基雅维利(Niccolò Machiavelli)的话:

没有什么比开创一种新秩序更难于推行、更让人怀疑能否成功、处理起来更加危险。因为改革者会与所有从旧秩序中获利的人为敌,而所有从新秩序中获利的人却只是冷淡的捍卫者。

我的观点是 C++ 需要显著的改进来更好地服务于它的用户群体。C++ 应用程序被大规模部署,但是新项目通常选择更流行的语言,一些成功的 C++ 项目被改写成这样的语言。举例来说,谷歌的许多大规模应用,如搜索,一直是基于他们的 map-reduce 框架 [Dean and Ghemawat 2004, 2008]。它就是 C++ 程序。然而,由于它因为商业原因是专有的,人们复制了它,而开源的 map-reduce 框架(Hadoop)出于各种原因是用 Java 实现的。这对于 C++ 社区来说是一件憾事。

开发转向其他语言的另一个重要原因是,模板提供的接口的灵活性使得使用所有 C++ 特性并提供稳定的 ABI 变得极其困难:可以灵活,也可以提供稳定的二进制接口,但大多数组织都做不到两者兼顾。我认为人们之所以需要 C++ 编写的程序提供 C、Java、C# 之类的接口,这是个促成因素。C++ 的 ABI 稳定性是一个真正的技术难题,尤其是因为 C++ 标准必须独立于平台。

更让 C++ 社区的问题雪上加霜的是,到了 2006 年,随着纸质出版的衰退,以及记者们关注更流行的技术和广告收入,大多数报道 C++ 的专业软件杂志已经消亡。Dr. Dobbs 期刊还支撑了几年(2009 年 2 月停刊)。C++ 会议被吸收到“面向对象”或一般软件开发会议中,剥夺了 C++ 社区展示新发展的场所。书籍仍在编写中,但程序员阅读的书籍越来越少(或至少购买的书越来越少,因为盗版变得越来越容易,因此统计数据变得越来越不可靠),在线资源变得越来越受欢迎。

一个更严重的问题是 C++ 在教育中的作用正在急剧下降。C++ 不再是“新的、有趣的”,而 Java 正作为一种更简单、更强大的语言被直接推向大学。美国高中计算机科学考试突然从 C++ 变成了 Java。在大学里,Java 作为入门语言的使用急剧增加。C++ 的教学质量也在下降,大多数课程优先选择 C 语言,或者认为严重依赖类层次结构的面向对象编程是唯一正确的方法。这两种方法都弱化了 C++ 的优势,并且需要大量使用宏。标准库(依靠泛型编程;(§2.2))和 RAII(依赖构造函数/析构函数对(§2.2.1))经常被完全排除在基础课程之外,或者被放在一个所谓的“高级特性”部分,大多数学生要么从未接触过,要么认为它很可怕。教科书经常陷入晦涩难懂的细节。当然也有例外,但平均来说,呈现给学生的 C++ 远不是最佳的工程实践。在 2005 年,我接受了挑战,给大学一年级的学生教编程。我调查了大约二十本最流行的 C++ 编程教材,最后大声抱怨:

如果那就是 C++,我也会不喜欢它!

在用一本著名的教科书教了一年书后,我开始只用自己的教案,并且在 2008 年出版了《C++ 程序设计:原理与实践》(Programming: Principles and Practice Using C++)[Stroustrup 2008a],但直到今天,许多 C++ 教学仍带有 1980 年代的特色。

尽管如此,C++ 的使用又开始增加了。我认为这是因为根本的技术趋势再次青睐 C++,并且在二十一世纪的第一个十年结束的时候,C++11 的出现也有所帮助。

Boost 库和 Boost 组织非常重要 [Boost 1998–2020]。1998 年,经验丰富的开发者及 WG21 的有影响力的成员 Beman Dawes 建立了一个“C++ 代码库网站”[Dawes 1998],其明确目标是开发 C++ 库以确立现有实践,使得未来的标准化可以据此进行。在此之前,C++ 甚至从来没有一个公共的代码库。Boost 慢慢成长为一个活跃的组织,有新库的同行评审和一年一度的会议。Boost 库被广泛使用,最流行的被吸收到标准中(例如,regex§4.6)、thread§4.1.2)、shared_ptr§4.6)、variant§8.3)和文件系统(§8.6))。对于 C++ 社区来说重要的是,Boost 库比它们的 ISO 标准版本早十多年,但仍被当作某种“预备标准”来信任。有许多委员会成员都参与了 Boost,特别是 Dave Abrahams、Doug Gregor、Jaakko Järvi、Andrew Sutton,当然还有 Beman Dawes。

到 2006 年,C++ 在业界已经不再是新鲜刺激的东西,但它遍布很多行业。在 C++ 诞生的电信行业,它一直被大量使用。从电信领域出发,它已经扩展到游戏(如 Unreal、PlayStation、Xbox 和 Douglas Adams 的《宇宙飞船泰坦》)、金融(如摩根士丹利和 Renaissance Technologies)、微电子(如英特尔和 Mentor Graphics)、电影(如皮克斯和 Maya)、航空航天(如洛克希德·马丁和美国国家航空航天局)和许多其他行业。

就我个人而言,我特别喜欢 C++ 在科学和工程中的广泛使用,比如高能物理(例如 CERN 欧洲核子研究中心、SLAC 国家加速器实验室、费米实验室)、生物学(例如人类基因组项目)、空间探索(例如火星漫游车和深空通信网络)、医学和生物学(例如断层扫描、常规成像、人类基因组项目和监控设备)等等。

2.4 其他语言

人们常常会寻找其他编程语言对 C++ 的直接技术影响。其实非常之少。典型情况是,影响涌现自共同的前代语言和共同思想(而非特定的现有语言)。扩展 C++ 的决定性理由往往与 C++ 社区中已经发现的问题有关。直接从流行语言中借鉴的情况并不常见,而且比人们想象的要困难得多。大多数标准委员会成员都掌握多种语言,并密切留意(其他语言中)有用的功能、库和技巧。

下面是其他语言在二十一世纪对 C++ 的某些真实或假想的影响:

  • auto——从初始化器推断类型的能力。它在现代语言中很流行,但也已由来已久。我不知它的最早起源,但我在 1983 年实现这个功能的时候,也并不认为它很新颖(§4.2.1)。
  • tuple——许多语言,特别是源自函数式编程传统的语言,都有元组,它通常是一个内置类型。C++ 标准库 tuple 及其许多用法都从中受到启发。std::tuple 派生自 boost::tuple [Boost 1998–2020](§4.3.4)。
  • regex——加入 C++11 的标准库 regex 是(经由 Boost;已致谢)从 Unix 和 JavaScript 的功能中拷贝来的(§4.6)。
  • 函数式编程——函数式编程特性和 C++ 构件之间有许多明显的相似之处。大多数不是简单的语言特性,而是编程技巧。STL 受到函数式编程的启发,并首先在 Scheme [Stepanov 1986] 和 Ada [Musser and Stepanov 1987] 中进行了尝试(未成功)。
  • futurepromise——源自 Multilisp,经由其他 Lisp 方言(§4.1.3)。
  • 范围 for——许多语言中都有对应物,但直接启发来自 STL 序列(§4.2.2)。
  • variantanyoptional——显然受到多种语言的启发(§8.3)。
  • lambda 表达式——显然,部分灵感来自于函数式语言中 lambda 表达式的应用。但是,在 C++ 中,lambda 表达式的根源还可以上溯到 BCPL 语言中用作表达式的代码块、局部函数(多次被 C 和 C++ 拒绝,因其容易出错且增加了复杂性)和(最重要的)函数对象(§4.3.1)。
  • finaloverride——用于更明确地管理类层次结构,并且在许多面向对象的语言中都可以使用。在早期的 C++ 中已经考虑过它们了,但当时被认为是不必要的。
  • 三路比较运算符 <=>,受 C 的 strcmp 及 PERL、PHP、Python 和 Ruby 语言的运算符的启发(§9.3.4)。
  • await——C++ 里最早的协程(§1.1)受 Simula 启发,但是作为库提供,而不是作为语言特性,这是为了给其他替代的并发技术留出空间。C++20 中的无栈协程的思想主要来自 F#(§9.3.2)。

即使以非常直接的方式从另一种语言借用了某个特性,该特性也会发生变化。通常,为了适合 C++ 语法会发生很大变化。当从支持垃圾收集的语言借鉴时,生命周期问题必须得到解决。而 C++ 区分对象和对象的引用,这通常使得 C++ 需要以和原语言不同的方式来解决。在“翻译”成 C++ 的过程中,经常会发现全新的用法。在把 lambda 引入 C++ 的过程中,出现了大量此类现象的例子(§4.3.1)。

在很多人的想象中,我(和其他参与 C++ 的人)整日无所事事,满脑子想的是在流行语言中如何占据主导地位,为这个复杂的语言战争制定战略。实际上,我并没有在那上面花时间。大多数日子里,我不会去思考其他的语言,除非我碰巧出于一般的技术兴趣去学习一门其他语言或要使用它来完成一些工作。我要做的是与软件开发人员交谈,考虑人们在使用 C++ 时遇到的问题,还要考虑潮水般涌入标准委员会的改进建议。当然,我也编写代码来体验问题并测试改进思路。问题在于要能抽出时间冷静地考虑,什么是根本的,什么只是一时流行,以及什么会造成危害。

同样,C++ 对其他语言的贡献也难以估量。通常,类似的特性是平行演化的,或有着共同的根源。例如:

  • Java 和 C# 中的泛型——他们采用了其他语言的泛型模式,但采用了 C++ 语法,并且是在 C++ 大规模展示了泛型编程的用途之后,才添加泛型。
  • Java、Python 等的资源弃置惯用法(dispose idiom)——这大致是在垃圾收集语言中最能接近析构函数的做法了。
  • D 编程语言进行编译期求值——我向 Walter Bright 解释了早期的 constexpr 设计。
  • C++ 基于构造函数和析构函数的对象生存期模型是 Rust 灵感的一部分。好笑的是,最近 C++ 经常被指责从 Rust 那里借用了这种想法。
  • C 采用了 C++11 的内存模型、函数声明和定义语法、以声明为语句、const// 注释、inline 以及 for 循环中的初始化表达式。

C++ 与其他语言之间的许多差异源于 C++ 对析构函数的使用。这使得垃圾收集的语言很难直接从 C++ 借用。

3. C++ 标准委员会

国际 C++ 标准委员会正式名称为 ISO/IEC JTC1/SC22/WG21,它是 C++ 发展的核心。自 1991 年成立以来,就一直如此。而从 1989 年开始到它成立之前,C++ 开发的中心则是美国国家标准学会(ANSI)的 C++ 标准委员会 [Stroustrup 1993]。C++ 没有腰缠万贯的所有者,也没有其他重要的资金提供来源,因此社区依赖于企业开发和开源项目。对于很多处于相互竞争的组织中的人来说,WG21 和各个国家的标准委员会是他们能够会面并联合解决问题的唯一场合。

委员会成员都是志愿者,也没有带薪的秘书处,虽然许多委员确实以其工作组织的代表身份出现。在每次会议上,都会有人自豪地声称就代表“自己”。也就是说,他们没有得到赞助,只代表自己。有些人换工作后,就会代表新组织,这种情况并不少见。许多人以“参加 C++ 标准委员会”作为接受新工作的条件。有人加入了委员会来学习 C++,有人则把“C++ 委员会成员”当作资格来引用(并非一定是真的)。

有些人仅参加过几次会议,不那么经常。另一方面,也有人一直参加了大多数会议,数十年没有间断。一开始时,还有现在,我们一年开三次会。在 1998 年标准之后的几年里,我们一年只开两次会。目前,除了面对面的会议,还有很多的电话会议进行补充,以及天天都有的大量电子邮件。

在这里,我会描述

  • 标准的作用(§3.1
  • 委员会的组织(§3.2
  • 委员会的结构对 C++ 设计的影响(§3.3

3.1 标准

标准委员会的目的是编写标准。制定标准的一个官方理由是“促进贸易,特别是减少国际贸易的技术壁垒和人为障碍”,及“提供实现经济、效率和互操作性的框架”。标准是规范,不是实现。它的目的是保持多个实现一致,并确定“一致性”在一个必须能够有效利用各种不同底层硬件的世界里到底意味着什么。许多程序员在理解这一点上存在问题。他们要么认为当前的编译器就是语言的定义,要么难以理解为什么很难在许多不同的、通常是竞争性的组织之间达成 100% 的协议。在 1990 年代,委员会考虑过制定形式规约(formal specification),但咨询过世界一流的专家后得出结论,规约技术的进展和成员的水平都还达不到制定 C++ 形式规约的程度。当然,也考虑过参考实现,但语言的复杂度,特别是与硬件使用和优化相关的问题,已经挫败了这种想法。如果有参考实现,它会太复杂,也会代价过大。要么就得把它简化到一种对最困难的问题没有帮助的程度,但这样的困难问题正是最需要参考实现的场合。再有,当 N 个彼此竞争的实现团队记录他们的决策、运行广泛的合规性测试并讨论它们的不同之处时,会有些意外收获;如果已经有了个复杂的参考实现,就可能掩盖掉这样的意外收获。对于 C++,从前端实现来说(Clang、EDG、GCC 和微软)N 至少为 4,至于后端,N 少说有十几个。

因此,标准委员会正在努力解决拥有多种实现带来的问题。另一条路是冒险搞单一文化。如果 C++ 技术只来源于一个组织,那么无论好坏,每个人都会得到相同的东西。一个控制“唯一真正实现”的组织将在社区中拥有主导话语权,他们出现的问题,就会影响到所有人。特别是,一旦有资金问题、商业顾虑、政治见解或者技术上的一意孤行,就会严重破坏语言及其用户群体。

无论好坏,C++ 社区选择了“半组织”的混乱,里面有一个很大的委员会加上多个编译器、工具及库的供应者。我们没有用统一所有权或独裁者模式。

3.2 组织

对于 C++17 和 C++ 20 的工作,每次面对面的 WG21 会议有多达 250 人出席,而总成员人数约为出席人数的两倍。此外,加拿大、芬兰、法国、德国、俄罗斯、西班牙、英国、美国等十几个国家都有国家标准委员会以及 C++ 标准技术联盟的付费支持成员。成员代表了一百多个组织。为了让大家有所了解,在此列举部分成员所属组织:苹果、Bloomberg、欧洲核子研究中心、Codeplay、EDG(Edison Design Group)、Facebook、谷歌、IBM、英特尔、微软、摩根士丹利、英伟达、Qt、高通、红帽、Ripple、美国 Sandia 国家实验室、拉珀斯维尔应用科技大学(HSR)和马德里卡洛斯三世大学。编译器供应者、硬件供应者、金融、游戏、库供应者、平台供应者、国家实验室(物理)等都有坚实的代表。早期 C++ 中突出的电信业者的身影已经减少,而过去极少的大学的身影似乎在增加。

显然,如此庞大的组织和个人组成的群体代表着千差万别的兴趣和技术背景,需要一个组织结构来运作。会议是围绕工作组(WG)和研究组(SG)进行组织的。2019 年的夏天,我们已经有了这样一些分组:

  • 核心工作组(Core WG 或 CWG)——编写语言的最终标准文本——主席 Michael Miller(EDG)。
  • 库工作组(Library WG 或 LWG)——为标准库编写最终标准文本——主席 Marshall Clow(C++ 联盟,之前代表高通)。
  • 演化工作组(Evolution WG 或 EWG)——处理语言建议——主席 Ville Voutilainen(Qt,之前代表 Symbio)。
  • 库演化工作组(Library Evolution WG 或 LEWG)——处理标准库提案——主席 Titus Winters(谷歌)。

研究组探索新领域并设计可能的标准化:

  • SG1 并发——并发和并行性主题——主席 Olivier Giroux(英伟达)。
  • SG5 事务内存——探索事务内存的构件——主席 Michael Wong(Codeplay,之前代表 IBM)。
  • SG6 数值——包括但不限于定点数、浮点数和分数——主席 Lawrence Crowl(“自己”,之前代表谷歌和 Sun)。
  • SG7 编译期编程——最初专注于编译期反射,然后扩展到一般的编译期编程——主席 Chandler Carruth(谷歌)。
  • SG12 未定义的行为和漏洞——系统地审查漏洞和未定义/未指定的行为——主席 Gabriel Dos Reis(微软,之前代表得州农工大学)。
  • SG13 人机界面和 I/O——精选的底层输出(例如图形、音频)和输入(例如键盘、指点设备)的 I/O 原语——主席 Roger Orr(英国标准(BSI))。
  • SG14 游戏开发和低延迟——游戏开发者和其他有低延迟要求的人感兴趣的主题——主席 Michael Wong(Codeplay,之前代表 IBM)。
  • SG15 工具——与针对标准 C++ 的开发者工具创建有关的主题,其中包括但不仅限于模块和包管理——主席 Titus Winters(谷歌)。
  • SG16 Unicode——与 C++ 中的 Unicode 文本处理相关的主题——主席 Tom Honermann(Synopsys)。
  • SG19 机器学习——主席 Michael Wong(CodePlay,之前代表 IBM)
  • SG20 教育——探索可以支持学习者和教师掌握今天的 C++ 的方法——主席 Jan Christiaan van Winkel(谷歌)
  • SG21 契约——在 C++20 失败后尝试设计出契约系统(§9.6.1)——主席 John Spicer(EDG)

2017 年成立了一个小组来解决与语言和标准库设计缺乏方向有关的问题 [Dawes et al. 2018]。该方向组(DG)的成员由召集人与工作组主席协商后任命,其成员是委员会、语言和标准库的长期贡献者。最初的成员是 Beman Dawes、Howard Hinnant、Bjarne Stroustrup、David Vandevoorde 和 Michael Wong。之后,Beman 退休,Roger Orr 加入。DG 的主席是轮流担任的,从我开始。DG 是咨询机构,其政策是只有在其成员一致同意的情况下才能提出意见。它维护有一份描述其建议的文档 [Dawes et al. 2018; Hinnant et al. 2019]。

工作组可持续十年以上,成员变化也很少。研究组则聚散自由,可能因兴趣使然,或者因为工作已经完成并提交给工作组进行最后的处理。例如,四个最重要的研究组已宣告胜利完成并解散:

  • SG2 模块——主席 Gabriel Dos Reis(微软,之前代表得州农工大学)。
  • SG3 文件系统——主席 Beman Dawes(“自己”)。
  • SG8 概念——主席 Andrew Sutton(俄亥俄州阿克伦大学,之前代表得州农工大学)。
  • SG9 范围——更新 STL,以使用概念,简化写法,及提供无限序列和管道——主席,Eric Niebler(Facebook)。

SG4 网络,目前处于休眠状态,因为其结果正在等待被合并到标准(§8.8.1)中。另一个研究组 SG11 数据库,因缺乏共识和缺乏足够数量的志愿者完成工作而解散。

某些研究组会产出技术规范(TS),这些技术规范可能是具有重要意义的文件,也以标准本身的风格写就。它们具有一定的官方(ISO)地位,但不能提供国际标准(IS)所具有的长期稳定性。并发研究组(SG1)自 2006 年以来一直活跃,大部分时间由 Hans-J. Boehm(谷歌,之前代表过惠普实验室和 SGI)领导,它的地位已经接近 WG 了。

除了这些分组外,还有一个半官方的 C/C++ 联络组,由同时加入 C++ 委员会和 C 委员会(ISO/SC22/WG14)的成员组成。这个小组力图减少 C 和 C++ 之间的不兼容性,而 C++ 标准也会把每种不兼容之处记录下来。如果没有联络小组的不断努力,C 和 C++ 的兼容性远没有现在好。不过,即便如此,大多数从 C++ 导入 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)。

各个国家的标准委员会有各自自己的官员和章程。

显然,这些年来这些职位由不同的人担任过,但尽管工作量通常很大,很少有人在职少于 5 年。我曾担任 EWG 的主席 24 年,到 2014 年才把这一职位移交给 Ville Voutilainen。

通常,较小的提案直接提交给 EWG 和/或 LEWG,较大的提案则从研究组开始。提案需要以书面形式提出,并有人进行演示。一般来说,处理一项重要的提案需要数次会议(通常为数年),并且需要数篇论文、修订论文和反复演示。最后,已经获得大力支持的提案将提交给整个委员会进行最终表决。召集人查看表决结果并裁定是否达成共识。共识不只是要多数。委员会更倾向于能在经过工作组处理和投票后获得一致同意,如果达不到,通常至少也需要 9 比 1 或 8 比 2 的优势。召集人很可能会认为 8 比 2 的多数票“未达成共识”。如果国家标准机构的负责人或几个主要委员表示强烈反对,就会发生这种情况。这样议题就会处于悬而未决的状态或者导致提案只被部分采纳。

标准会议令人筋疲力尽。通常,委员们从早餐到午夜一直在讨论工作问题。大多数时候,正式会议在 8:30–12:30 和 14:00–17:30 举行,加上大多数时候都会进行的晚间会议(19:00–22:00)。正在准备提案的委员的工作时间比这些还要长。WG 和 SG 主席一般在大多数用餐时间都在开会。周一至周五是全天,而如果没有任何意外发生,大多数委员会成员到了星期六的 15:00 左右会收工。不过,当会议在诸如夏威夷科纳(Kona)之类的好地方举行时,委员会以外的人似乎都不愿意相信开会并不是什么度假。

在 WG 和 SG 里,每个出席者都可以投一票。委员会全体会议的正式投票则是每个到会的组织一票(这样,大型组织就不会有多票),再加上国家标准机构的票数。“技术性投票”和国家机构投票必须一致才算达成共识。

委员会 2006 年以前的历史记录在 [Stroustrup 1993, 1994, 2007] 中。C++ 基金会(§10.2)在其网站(isocpp.org/std)上维护了一份会及时更新的描述,涵盖组织、关键人物和委员会流程。

从 1989 年起,委员会的所有论文几乎都可以从一份文集中获取到 [WG21 1989–2020]。目前,该文集每年增加 500 多篇论文。另外,很多委员会的讨论是在已归档的邮件列表中进行的。每天可能有超过一百条邮件消息。要跟上委员会中发生的所有事情非常难,特别是由于很多事情需要专门的技术知识才能跟进。我将自己的 WG21 论文集保存在主页 [Stroustrup 1990–2020] 上。

传统上,ISO 标准每十年左右修订一次。例如,我们有 C89、C99 和 C11。如此长的修订周期是有问题的,如果新特性错过了特性冻结,我们就会要再等上 12 年左右才能将它加入标准。人们自然就会主张将即将通过的标准拖延一两年:“这个特性太重要了,不能等,因此得延迟一下标准的发布!”这就是为什么原本的 C++0x 结果成了 C++11,在 C++98 后过了 13 年。

在 C++11 之后,一些委员会成员希望缩短周期,召集人 Herb Sutter 建议我们采用列车模型。也就是说,列车在预定时间出发,任何没上车的人将不得不等待下一班。大家喜欢这个建议,也花了挺长时间讨论标准修订之间的合适间隔。我主张短点,3 年,因为再长(例如 5 年)就容易被“这个特性非常重要,等不了”这样的说法拖累,导致发布延迟。我们商定了三年的发布周期,Herb Sutter 补充建议采用交替发行大版本和小版本的英特尔“滴答”模型。这也得到了同意,因此在 C++11(§4)三年后,我们发布了 C++14(§5),它纳入了之前被延迟的特性并纠正了早期使用中发现的小问题。C++17 也按时交付,但可惜并不是一次大升级(§8)。C++20 在 2019 年 2 月通过投票,确定了完整的发布特性。最终技术性投票于 2020 年 2 月在布拉格完成。

3.3 对设计的影响

这样的工作组织方式、复杂的决策流程以及大量的参与者会如何影响 C++ 的发展?看看委员会的规模、组成及其流程,我认为,任何建设性成果居然能从中产生,都足以令人惊喜。这已经不只是“委员会的设计”了,而是“多委员会的联合设计”。

此外,委员会的管理结构非常薄弱,甚至缺乏最基本的管理工具:

  • 成员资格、发言或投票没有任何资质要求(例如,学历或实际经验)。支付 ISO 会员费(2018 年美国会员为 1280 美元)并参加两次会议,就能拥有正式投票权。在研究组和工作组中,任何人都可以发言与投票,即使这是他们的第一次参加会议。
  • 除了让提案得到采纳,以及看到改进后的标准而感到满足,并没有任何其他回报。不过,满足感确实是一个主要动力。
  • 没有真正的办法来阻止破坏性行为。非官方委员会管理人员所能做的只是有礼貌地提醒人们不要做别人认为具有破坏性的事情。然而委员们对于什么是有破坏性的,意见也不一致。

当考虑在一个大型委员会里演化一门语言的各种问题之前,请记住委员会里大部分时间和工作都是为了解决“小问题”;就是那些不会上升到语言设计哲学、学术出版物、或会议演示层面的问题。它们对于防止语言及其标准库被分割成方言,并保证在编译器和平台之间的可移植性至关重要。这些问题包括:命名、名称查找、重载决策、语法细节、构件的确切含义、临时变量的生存周期、链接,还有其他很多很多。许多问题需要技巧才能解决,而拙劣的解决方案可能带来让人吃惊而具有破坏性的后果。解决方案往往经过精心设计,以最大程度减少对现有代码的破坏。委员会每年解决数百个问题。我估计委员至少要为此花费他们时间和精力的三分之一,乃至于三分之二。这项工作往往被忽视和低估。如果你用过计算机或计算机化的设备(例如电话或汽车),你得感谢 CWG 和 LWG 的工作。

当关注由一个庞大的委员会引起的问题时,也请记住,这些问题本质是一种有钱人的烦恼:C++ 的标准化流程由数百位各种不同背景的热心人士所驱动,他们的经验各不相同,但都满怀理想主义。

委员会应起到过滤作用,也就是说,把坏提案挡在标准之外,同时,还要提升最后通过的提案的品质。委员会的存在,是要鼓励大家提出建议,并主动提供帮助。然而,并没有正式的提案征求流程。

不存在全职的 C++ 设计者,尽管有许多全职人员从事 C++ 编译器、库和工具方面的工作。直到最近,委员会中还很少有人从事应用程序开发,这是一个问题,因为它使委员会偏向于语言“律师”、高阶特性和实现问题,而不是直接解决大量 C++ 开发者的需求——很多成员只是间接地了解这些需求。最近新成员急剧增加,也许会部分缓解这个问题。

委员会中的教育工作者相对较少。这可能是个问题,因为委员会(理所当然地)高度重视“易于学习”,但是委员们对其含义有着非常不同的理念(经常会意见强烈)。这往往使关于“简单性”和“易用性”的讨论变得混乱。

当思考组织问题对 C++ 发展的影响时,请记住,ISO 流程本不是为 200 人的会议而设计的——典型的 ISO 编程语言委员会只有一二十人。平均而言,我们在某种程度上是通过识别和解决问题来进行管理。考虑下面这些观察到的问题:

  • 延迟:多阶段的流程为延迟、阻止提案和提案变化提供了很多机会。常常出现几十名委员坚持要满足他们的要求的情况,往往是通过阐释、扩展和寻找特例的方式。一个人眼中的过度延迟在另一个人看来却是尽职尽力。

    例如:概念(当前方案为 6 年(§6))、契约(从开始到失败花了 6 年(§9.6.1))、网络(15 年,仍在进行中(§8.8.1))和 constexpr(5 年(§4.2.7))。甚至 nullptr 被接受也花费了三年时间(§4.2.6)。

  • 孤立特性:大多数委员会成员喜欢特性的添加。另一方面,他们(非常合理地)深刻地担心破坏现有代码的可能性。这给了孤立特性系统性的优势,孤立特性是不影响语言和标准库其余部分的小提案。这样的小提案很少会对语言的使用产生重大影响,但却会增加学习和实现的复杂性。而且,到头来,它们往往还是会和其他特性发生令人惊讶的交互。

    例如:大多数在本语言演化总结中不值得提及的特性。结构化绑定(§8.2)和运算符 <=>§8.8.4)都需要多次会议去完善。

  • 后来者居上:有时经过多年的工作之后,提案已接近投票表决,一些一向未曾关注提案的委员此时进入讨论并提供了替代提案。这样的提案可能与原始提案有戏剧性的差异,或者只是一系列小的请求。这往往导致延迟、混乱、甚至有时是争执。这种时候,已经议定的问题又重被激活,而未经尝试(通常也未实现)的新想法和多年工作的成果获得了接近相等的权重。对老提案而言,瑕疵已经被发现过了,相应的技术折中也已经完成。人们很容易想象新事物的好处而忘记意外后果定律:意外后果总是会出现的。新的和相对未经审查的总是看起来比老的更好。这使得较早提案的拥护者变得具有防御性,从而分散了进一步完善“老提案”的精力。在这里“老”可能只是几年,或者就像概念(§6)那样十几年。有时,接受未经尝试的后期变更(所谓改进)是为了安抚反对派;这经常导致意外的后果。后期加入讨论的人们,通常不会认为有“冲刺的必要”,而是自然地希望他们自己的想法得到认真考虑(而通常并没有认真考虑老提案的细节和理由)。这就可能会与已经在老提案上投入多年工作的人们产生摩擦。

    例子:结构化绑定(语法更改,对位域的新增支持,笨拙的 get()§8.2))、概念(§6)。数字分隔符(§5.1)、点运算符(§8.8.2)、模块(§9.3.1)、协程(§9.3.2)、契约(§9.6.1)。

  • 热情总青睐新事物:唤起对新事物的热情比反对它们容易。每个提案都是为某人解决某事,支持者愿意花大量时间展现其价值。而要反对它们,有人就不得不说像这样的话:

    • “不,这个问题不是那么重要。”
    • “不,这种解决方案有缺陷。”
    • “不,你还没有充分记录解决方案。”
    • “不,你还没有仔细检查替代方案。”

    不管措辞怎么客气,这都让反对者看起来更像“坏人”,是他们阻碍了进步并否认支持者需求的合理性。更糟糕的是,拥护者总是比反对者花费更多的时间来准备论文和演讲。大多数人喜欢对自己相信的事物进行建设性的工作,而不是小心地拆除他人的工作。因此,支持者通常都很热情并且准备充分,而反对者总是显得意见含糊而不懂细节。然而,每项新特性都有其成本:如设计、规范、实现、修订、部署和教学(§9.5)。我害怕在演化工作组度过周四下午。那时,EWG 成员经过几天的大提案工作而感到疲倦,许多老成员(例如我)已经被拖入其他小组,参会者又急于看到有成果。这种时候,小提案就会只经受相对较少的审查而滑入标准。

    例如:条件中的显式测试(§8.7)、inline 变量(§8)、结构化绑定的后期更改(§8.2)。

  • 过度自信:相对于整个语言及标准库的复杂度,尤其是不同应用领域的 C++ 用户所面临的问题的复杂度,个人在日常工作中能获得的经验总是不足的。并非所有委员会成员都能意识到这一局限,或是能通过质疑自身经验的推广价值加以弥补。这就导致某些一般性有限的提案被过度推广。更糟糕的是,一些委员强烈反对某些提案,是因为他们不认为有必要解决该提案所针对的问题。语言设计需要一定的智力上的谦逊 [Stroustrup 2019b]。先想出来的解决方案很少是最好的,而未经进一步认真思考就提出轻率的反对意见和建议很少会带来改进。

    例子:出于对犯错者的保护,就不举例了。

  • 实现时机不当:在标准流程中,实现提案晚了有风险:特性出现严重缺陷、有潜在无法实现的部分、以及缺乏使用反馈;实现早了也有风险:特性以不完整的、次优的且难以使用的形式冻结。委员会中的许多人不会投票赞成尚未实现或以他们不信任的方式实现的提案。另一方面,许多实现者不愿意为委员会未批准的提案投入实现资源。这是一个困难而现实的两难困境。委员会经常听到“它已经实现了吗?”的问题。通常,“它是经过设计的吗?” 和“要如何使用?” 是更重要的问题。人们很容易在细节中迷失。我提出的走出这一困境的方法是,就建议的方向、提案的总体范围达成一致,然后从一个相对较小子集的详细设计和实现出发,以关键用例为指导前进。这样,我们可以相对较早地获得用户体验,并了解该特性如何与其他特性交互。这需要对这种语言应该是什么有一个长远的看法 [Stroustrup 1993, 1994, 2007](§1),(§11.2),否则语言就会沦为机会主义的零敲碎打。如果这个方法起作用,语言将从反馈和有机增长中受益。

    例子:模块(§9.3.1)、C++ 0x 概念(§6)和 <=>§8.8.4)。

  • 特性交互:最难处理的问题之一是特性的组合使用。一定程度上这是规范和实现的技术问题。因此,这会占用大量委员会时间。从设计的角度来看,更难的问题是要预计新特性在整个语言的语境中如何使用,这些语境包括其他正在考虑中的语言和库的新特性。每个特性都应设计成便于同其他特性结合使用。我担心这一点没有得到重视。很少有提案书提供详细的讨论,而委员会里关于特性交互的讨论往往简短或混乱。其结果之一是,个别特性趋于膨胀而只好把它孤立于语言的其余部分才能用起来。

    例子:tuple§4.3.4)和 <=>§9.3.4)。为 lambda 表达式(§4.3.1)中的动作指定专用语法的(失败)提案。

  • 篇幅和分心:千头万绪往往同时发生,没有人能全跟得上。那些尝试全部关注的人,就容易失去对真正重要课题的关注,而把注意力分散在一些事实证明并不那么重要的课题上。如今每年有超过 500 篇委员会论文,有些长达数十甚至数百页。与 2010 年代初相比,文献总篇幅翻了一番。我注意到,2018 年秋天的会前邮件(新论文汇总)的字数是莎士比亚全集的三倍。

    电子邮件的泛滥最让人分心,因为许多委员喜欢通过一波一波地爆发短邮件来进行技术讨论。在这样的讨论中掉队意味着失去对问题的跟踪,其结果可能是,共识只是从几个一直能跟得上讨论的人中间浮现。

    这种讨论不利于冷静而系统地权衡各种选择。有时候,它会导致不幸的特性滑入标准。有时候,它会导致不一致的设计理念体现于语言和标准库的不同部分,进而损害了互操作性。

    例子:anyoptionalvariant 的不同接口(§8.3)。概念(§6)。

  • 精确规范:标准是规范,而不是实现。但是,标准是用英语编写的,因此我们做不到数学般的精度。委员会的许多成员擅长数学,但不擅长数学的人更多,因此在规范中没办法使用数学式的写法。试图使英文文本精确而详尽,则会让文本变得生硬又难以理解。我常常很难理解标准中对我自己提案的描述。

    大多数委员是程序员,而不是设计师,因此规范有时看起来会像程序——用没有类型系统或编译器的低级语言写成的程序。有详尽的如果、那么、否则的说明,却很少写出不变量。更糟糕的是,很多词汇继承自 C,而且是基于程序源代码文本中的标记,因此,更高级别的概念仅被间接提出。

    奇怪的是,标准库规范在结构上明显比语言规范更为正式。

  • 经院主义:当然有必要大力强调标准文本的正确性和准确性。但是,人们有时会忘记标准本身可能就是错误的,而仅根据标准文本的论证来讨论正确性。这样一来,根据标准文本所应反映的模型和使用上的论证,反倒可能被忽略。

  • 方向:哪些问题是真实的?重要吗?对于谁?哪些紧急?十年后,哪些解决方案仍然有意义?有些事情也许算个问题,但这并不意味着它必须在语言里有直接的解决方案。尤其是,委员会很难记住这一点:一种语言不可能对所有人来说都是万能的。更难以接受的是,它居然不能解决每个委员最紧急的问题 [Stroustrup 2018d]。

    例子:C++17(§8)和 C++ 20(§9)。

  • 专一关注:一些委员仅关注一个或两个课题,例如语言技术、易用性、“可教学性”、效率、使用单一编程风格、在单个行业中使用、在单个公司中使用、单个语言特性等。对于专一关注的委员而言,这可能是一种非常有效的技巧,但这样做会让广泛的、平衡的进展变得困难。过分相信理论或个人经验则是这个问题的另一类例子。一个好的建议在许多领域都会推动进步,但通常不能在所有这些方面都达到完美。

  • 原则的不适当应用:将一般原则应用于具体事例通常很困难。有时,我们会不与其他原则进行必要折中,就去严格应用某项原则。折中的必要性是《设计和演化》一书 [Stroustrup 1994] 将设计原则称为“经验法则”的原因之一。有时,似乎没有经验基础就凭空冒出来一个原则。有时,一个提案严格遵循了某一个原则,而另一个提案则忽略它。有原则的设计很困难;它需要品味、经验以及原则。实用的语言设计不只是从第一原理出发进行演绎的练习。通常,多种原则之间必须进行权衡。

  • 倾向专家的偏见:想象别人的问题总是困难的。委员会成员几乎都是某方面的专家。在日常工作中,他们通常是处理最细微、最复杂问题的人。这样的问题在“外面”的数十亿行常规 C++ 代码中一般不常见,而且也不是大多数 C++ 程序员所苦恼的问题。但是,对委员会来说,专家级的问题通常就是紧急问题,也是最容易通过流程的问题。

    例子:支持 enable_if 和类型特征(§4.5.1)在标准库中的使用简直水到渠成,但接受概念(§6)却大费周章。

  • 聪明的问题:委员会成员一般是聪明人,他们中许多人无法抵御机灵的解决方案。此外,他们也很难断定,并非每个问题都值得解决,而拥有解决方案也并不意味着我们必须将其纳入标准。这会带来过于精巧的特性,带来大多数程序员用不着的特性。公平起见,也需要指出,许多程序员也很聪明,有时也会以使用过分机灵的语言和标准库特性为乐。

    例子:在有些提案中,即使简单用法也需要用上严肃的模板元编程。

  • 不愿妥协:大多数委员会成员都有强烈的意见,但要在一个大型团体中达成共识需要妥协。分辨哪些妥协无关紧要,而哪些妥协事关基本原则,有时会很困难。后一类妥协可能对语言造成破坏,应该避免。不幸的是,当委员们坚信自己所担忧的才至关重要时,他们比起心态开放的委员就有了有关键的战术优势。有些人能做到从整体上关注语言而不纠结于个别话题,但他们往往得向不能如此的人们屈服。而反过来,那些从不认真质疑自己的原则或需求的人,倒往往可以向别人视为必要的技术妥协发动猛攻。取得进展需要关注整个社区,有自知之明,并懂得适当的谦逊 [Stroustrup 2019b]。

  • 缺乏优先级:从技术的角度来看,所有问题都是平等的:不精确的规范就是不精确的规范,这一点与它未能正确规定的内容是什么不相干。任何可能从类型系统的漏洞中混进代码的错误原则上都可能造成死亡和毁灭。但是,现实世界中不同错误的影响可能大不相同。实际上,大多数晦涩的细节基本上没有破环性。有些人在研究设计细节时很难记住这一点。

    例子:在数字分隔符(§5.1)上花费的时间比在范围 for§4.2.2)上花费的时间更多。

  • 完美主义:一个标准预期会被几百万人用到,并且可以稳定数十年。人们自然希望它是完美的。这会导致特性膨胀(特性过多),尤其是导致单个特性的膨胀。程序员善于想象出问题,特性在委员会走流程的时候,委员们会坚持要它解决掉所有想象中的问题。这会导致严重的使命偏离,并导致只有专家才会喜爱的特性。这也可能导致特性一直无法加入标准。

    例子:. 运算符(§8.8.2)、网络库(§8.8.1)和异常规约(§4.5.3)。

  • 少数人的阻挠:共识流程可以防止某些类型的错误,尤其是防止多数人的暴政。但是,它很容易受到个人和小团体的阻挠。这可以是好事(避免错误),但是当它在提案流程的各个阶段一再发生,或正好在最后一刻发生时,就会具有破坏性了。

    例子:constexpr§4.2.7)、. 运算符(§8.8.2)、模块(§9.3.1)和协程(§9.3.2)。

  • 内聚的团体:许多工作组和研究组都拥有稳定的核心人员群体,这些年来他们形成了内聚的技术观、共享的词汇表和特定的运作方式。这会使“外部人员”难以交流和贡献。这也可能使设计跨越 WG 边界的特性(例如同时具有库和语言部分的特性)变得困难。每个小组都往往会设计出适合其自身组织结构领域的内容,再次印证了老格言,即系统的结构总是长得像创造它的组织的结构。

    例子:范围 for§4.2.2)和可能需要更改语言的并发机制(§4.1.3)。anyoptionalvariant§8.3)的接口差异。

从积极的一面来看,基于个人敌意或针锋相对的行为非常罕见。从这个意义上讲,委员会是非常专业的。

幸运的是,并非每个提案都受所有这些现象的影响,并且大多数其他大型项目也会遇到这类问题。但是,以其 ISO 标准所代表的 C++ 语言,整体上反映出了这些现象。它们不是新问题,但是自 C++11 起出现得越来越多。我怀疑它们是由以下因素共同造成的

  • 委员会人数增加
  • 新人的涌入
  • 成员的专业化(分散化)
  • 成员对 C++ 历史的了解有所减少

尽管存在这些严重的问题,但标准制定流程仍屡屡成功,原因之一是很多人不断努力将负面影响降到最低。方向组(Direction Group)的建立就是这方面的努力的一部分(§3.2)[Dawes et al. 2018; Stroustrup 2018d]。另见(§11.4)。工作组主席、笔记记录员、会议组织者和编辑组的不懈努力是无形的,但却至关重要。例如,Jens Maurer 数十年来一直在 CWG 中做笔记,帮助提案者编写标准文本,安排网络访问,为无法出席的成员安排电话接入,安排会议室,告知成员当地旅行的可能性,等等。

有其他方案吗?在理想的世界里,我会建议限定由一小部分(大约 5 人)的全职受信任专家委员做决定,而由大团队完成(例如超过 350 人的委员会)完成讨论、提案、以及大部分流程。但我不认为 C++ 会发展成这样,因为:

  • 没有人喜欢放弃权力(在这种情况下是投票权)。
  • 要为固定的全职专家团队保持稳定的资金投入需要非同小可的技能(而这种技能在 C++ 社区还没有出现)。
  • 激进的变化不会发生在成功的时候;只有 C++ 使用量的显著下降才能促使委员会进行剧烈的组织创新(到那时多半为时已晚)。

我不认为公司控制是可行的替代方案,因为:

  • 公司期望投资回报。
  • 公司的支持往往几年后就会消失。
  • 公司往往选择差异化的优势,而不是惠及所有人的进步。

我也不认为完全开放的审议流程(由成千上万人投票)是可行的:

  • 超过千人的投票就会失去品味。
  • 大群体的成员和意见没法在几十年里保持稳定。

对许多大型开源项目起作用的分级审批程序可能至少提供了部分方案,但是在 C 和 C++ 的标准化开始时,这方面的经验很少。当这样一个系统运行良好时,你在审批层级中的地位越高,审批者的知识基础就越广阔,他们关注的领域也就越广泛。在组织结构顶部,我们会找到一人或多人,他们对所有知识都有所了解、对所有用户都有所关心。而与此相对的是,提案越接近最终批准,ISO 流程就越会稀释专业知识和关注领域:全体会议上,许多委员投票的提案是他们不感兴趣、领域经验有限且没有密切关注的。人们努力想负起责任,但是这真的很难。还要从大局角度来看待每个提案,把它们当作其中的一部分,那就几乎不可能了。

这样看来,WG21 的工作还不算糟糕。我确实担心这样的工作模式能否使 C++ 长久保持连贯并与时俱进。从另一个角度来看,出席 C++ 标准会议的有 200 多人,比其他标准的团体要大一个数量级,而 ISO 的流程本来就是为那种较小的团体设计的。另外,委员的多样性远远超过了过去的老三样:头发斑白的专家、公司代表、以及国家机构代表。混乱有可能爆发。

我从温斯顿·丘吉尔的格言中得到些许安慰,“民主是最糟糕的政府形式,除了所有那些人类一再尝试过的其他形式”。

特别要指出,我不认为经常被建议的“仁慈的终身独裁者”模式可以规模化,而且,不管怎么说,该模型从来就没对 C++ 适用过。

在我心目中,启动语言设计项目的理想模式是单个人或一小群密切配合的朋友。但我看不到这种方式可以规模化。一门成熟的语言需要数十甚至数百个人来解决他们必须面对的各种问题。即使只是与相关的标准、行业组织进行协调,也会让一个小规模、紧密配合的团体彻底应接不暇。

3.4 提案检查清单

C++98 有个“如何编写提案”的指南 [Stroustrup et al. 1992],但奇怪的是,演化组并没有为提给 C++14、C++17 或 C++20 的提案准备一份检查清单。有一份针对标准库提案的检查清单 [Meredith 2012]。对于 C++20,国家标准机构负责人的一份说明 [van Winkel et al. 2017] 和 Direction Group 的一份文件 [Hinnant et al. 2019] 给出了一些指导。以下是一个简短而不完整的问题清单,这些问题几乎总会被提给一项提案:

  • 要解决的问题是什么?将为什么样的用户提供服务?新手?专家?
  • 解决方案是什么?阐明它所基于的原则。给出简单的使用案例和专家级的使用案例。
  • 有哪些替代解决方案?库解决方案是否足够?为什么现有功能不够好?
  • 为什么解决方案需要在标准中?
  • 采用该技术存在哪些障碍?从现有的技术过渡可能需要多久?
  • 已经实现了吗?在实现过程中遇到了或预期会遇到哪些问题?有用户体验吗?
  • 会不会有很大的编译期开销?
  • 该特性是否能融入到现有工具和编译器的框架中?
  • 与变通方案相比,会有运行期开销吗?在时间上?在空间上?
  • 会有兼容性问题吗?会破坏现有的代码吗?ABI 会被破坏吗?
  • 新功能将如何与现有功能和其他新功能交互?
  • 解决方案是否容易教授?教给谁?谁来教?
  • 标准库会受到怎样的影响?
  • 该提案是否会导致对未来标准进一步扩展的要求?
  • 该特性在标准里如何措辞表达?
  • 用户在使用新功能时可能会犯哪些错误?
  • 就整个 C++ 社区的利益而言,该提案是否属于前 20 名?前 10?
  • 该提案是否属于特定子社区的前三名?哪个子社区?
  • 该提案是解决某一类问题的通用机制还是某个特定问题的特定解决方法?如果是针对一类问题,是哪一类问题?
  • 该提案在语义、语法和命名方面是否与语言的其余部分一致?

理想的情况是,一项提案能够回答所有这些问题,甚至更多,但这种情况很少发生。特别是,在最初的提案中,理由往往非常薄弱,因为提案者认为所处理的问题的重要性和他们建议的解决方案非常明显。然而,后续的论文、修改、电子邮件讨论和演化组的面对面讨论通常都会涉及这些问题,但很少对各个提案进行系统的或一致的检查。成员们倾向于关注技术细节(例如,语法、歧义、优化机会和命名),而不是重新探讨根本问题。有时,我所认为的糟糕的提案会混进去。原因通常是提案者的极大热情加上反对者的分心、礼貌和疲惫 [Stroustrup 2019b]。

4. C++11:感觉像是门新语言

C++11 [Becker 2011] 发布后,其实现相对来说很快就出现了。这导致了极大的热情,增加了使用,有大量新人涌入 C++ 世界,并进行了大量的实验。C++11 的三个完整或几乎完整的实现在 2013 年面世。我当时的评论被广泛认为是准确的——C++11 感觉像是一门新的语言 [Stroustrup 2014d]。为什么 C++11 在帮助程序员方面做得如此出色?又是如何做到的?

C++11 引入了大量令人眼花缭乱的语言特性,包括:

  • 内存模型——一个高效的为现代硬件设计的底层抽象,作为描述并发的基础(§4.1.1
  • autodecltype——避免类型名称的不必要重复(§4.2.1
  • 范围 for——对范围的简单顺序遍历(§4.2.2
  • 移动语义和右值引用——减少数据拷贝(§4.2.3
  • 统一初始化—— 对所有类型都(几乎)完全一致的初始化语法和语义(§4.2.5
  • nullptr——给空指针一个名字(§4.2.6
  • constexpr 函数——在编译期进行求值的函数(§4.2.7
  • 用户定义字面量——为用户自定义类型提供字面量支持(§4.2.8
  • 原始字符串字面量——不需要转义字符的字面量,主要用在正则表达式中(§4.2.9
  • 属性——将任意信息同一个名字关联(§4.2.10
  • lambda 表达式——匿名函数对象(§4.3.1
  • 变参模板——可以处理任意个任意类型的参数的模板(§4.3.2
  • 模板别名——能够重命名模板并为新名称绑定一些模板参数(§4.3.3
  • noexcept——确保函数不会抛出异常的方法(§4.5.3
  • overridefinal——用于管理大型类层次结构的明确语法
  • static_assert——编译期断言
  • long long——更长的整数类型
  • 默认成员初始化器——给数据成员一个默认值,这个默认值可以被构造函数中的初始化所取代
  • enum class——枚举值带有作用域的强类型枚举

以下是主要的标准库组件列表(§4.6):

  • unique_ptrshared_ptr——依赖 RAII(§2.2.1)的资源管理指针(§4.2.4
  • 内存模型和 atomic 变量(§4.1.1
  • threadmutexcondition_variable 等——为基本的系统层级的并发提供了类型安全、可移植的支持(§4.1.2
  • futurepromisepackaged_task,等——稍稍更高级的并发(§4.1.3
  • tuple——匿名的简单复合类型(§4.3.4
  • 类型特征(type trait)——类型的可测试属性,用于元编程(§4.5.1
  • 正则表达式匹配(§4.6
  • 随机数——带有许多生成器(引擎)和多种分布(§4.6
  • 时间——time_pointduration§4.6
  • unordered_map 等——哈希表
  • forward_list——单向链表
  • array——具有固定常量大小的数组,并且会记住自己的大小
  • emplace 运算——在容器内直接构建对象,避免拷贝
  • exception_ptr——允许在线程之间传递异常

还有很多,但这些是最重要的变化。所有这些都在 [Stroustrup 2013] 中进行了描述,许多信息可以在网上获得(例如 [Cppreference 2011–2020])。

这些表面上互不相干的扩展怎么能组成一个连贯的整体?这怎么可能真正地改变我们写代码的方式,使之变得更好呢?C++11 确实做到了这一点。在相对较短的时间里(算 5 年吧),大量的 C++ 代码被升级到 C++11(并进一步升级到 C++14 和 C++17),而且 C++ 在会议和博客上的呈现也完全改变了。

这种在语言的“感觉”和使用风格上的巨大变化,并不是由某位大师级工匠指导的传统的精心设计过程的结果,而是海量建议经由一大批不断变化的个人层层决策过滤后的结果。

在我的 HOPL3 论文 [Stroustrup 2007] 中,我正确地描述了 C++11 语言的许多特性。值得注意的例外是“概念”,我会在(§6)中进行讨论。我将不再赘述细节,而是根据它们所解决的程序员需求来描述功能的“主题”分类。我认为这种看待提案的方式是 C++11 成功的根源:

  • §4.1:支持并发
  • §4.2:简化使用
  • §4.3:改进对泛型编程的支持
  • §4.4:提高静态类型安全
  • §4.5:支持对库的开发
  • §4.6:标准库组件

这些“主题”并不是不相干的。事实上,我猜想 C++11 之所以成功,是因为它相互关联的功能彼此加成,形成了一张精细的网络,可以处理真正的需求。每一个主题里都有我喜欢的特性。我怀疑,我在写作(例如 [Stroustrup 1993, 1994, 2007])和演讲中明确表述了 C++ 的目标,也帮助设计保持了合理的重点。对我来说,衡量每个新特性的一个关键指标是它是否使 C++ 更接近它的理想,例如,是否通过引入该特性能让对内建类型和用户定义类型的支持更加相似(§2.1)。

纵观 C++11,我们可以看到有些改进建议在 2002 年左右就被提出,有不少库也出现得很早,经常是作为 Boost 的一部分 [Boost 1998–2020]。然而,直到 2013 年才有完整的 C++11 实现。在 2020 年,一些组织仍在为升级到 C++11 而苦恼,因为代码库巨大,程序员不思进取,教学方式陈旧,以及编译器严重过时(尤其是在嵌入式系统领域)。不过 C++17 的采用速度明显快于 C++98 和 C++11;并且,早在 2018 年,C++20 的一些主要特性就已经投入生产使用。

直到 2018 年,我仍能看到 C++98 前的编译器被用于教学。我认为这是对学生的虐待,剥夺了他们接触学习我们 20 多年的进展的机会。

对标准委员会、主要编译器厂商以及大多数 C++ 的积极支持者来说已是遥远的过去的东西,对许多人来说仍然是现在,甚至是未来。其结果是,人们对 C++ 到底是什么仍然感到困惑。只要 C++ 继续演化,这种困惑就会持续下去。

4.1 C++11:并发支持

C++11 必须支持并发。这既是显而易见的,也是所有主要用户和平台供应商的共同需求。C++ 一直在大多数软件工业的基础中被重度使用,而在二十一世纪的头十年,并发性变得很普遍。利用好硬件并发至关重要。和 C 一样,C++ 当然一直支持各种形式的并发,但这种支持那时没有标准化,并且一般都很底层。机器架构正在使用越来越精巧的内存架构,编译器编写者也在应用越来越激进的优化技术,这让底层软件编写者的工作极为困难。机器架构师和优化器编写者之间亟需一个协定。只有有了明确的内存模型,基础库的编写者才能有一个稳定的基础和一定程度的可移植性。

并发方面的工作从 EWG 中分离出来,成为由 Hans-J. Boehm(惠普,后加入谷歌)领导的专家成员组成的并发组。它有三项职责:

此外,并行算法(§8.5)、网络(§8.8.1)和协程(§9.3.2)是单独分组处理的,并且(正如预期)还没法用于 C++11。

4.1.1 内存模型

最紧迫的问题之一,是在一个有着多核、缓存、推测执行、指令乱序等的世界里精确地规定访问内存的规则。来自 IBM 的 Paul McKenney 在内存保证方面的课题上非常活跃。来自剑桥大学的 Mark Batty 的研究 [Batty et al. 2013, 2012, 2010, 2011] 帮助我们将这一课题形式化,见 P. McKenney、M. Batty、C. Nelson、H. Boehm、A. Williams、S. Owens、S. Sarkar、P. Sewell、T. Weber、M. Wong、L. Crowl 和 B. Kosnik 合作的论文 [McKenney et al. 2010]。它是 C++11 的一个庞大而至关重要的部分。

在 C11 中,C 采用了 C++ 的内存模型。然而,就在 C 标准付诸表决前的最后一刻,C 委员会引入了不兼容的写法,而此时 C++11 标准修改的最后一次机会已经过去。这成了 C 和 C++ 实现者和用户的痛苦。

内存模型很大程度上是由 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 采用的方式。当后来人们发现内存屏障和原子操作可以一起使用,创造出比单单使用其中之一更好的解决方案时,我和其他人都感到非常高兴。

稍后,我们增加了对基于数据依赖关系的一致性支持,通过属性(§4.2.10)在源代码中表示,比如 [[carries_dependency]]

C++11 引入了 atomic 类型,上面的简单操作都是原子的:

atomic<int> x;
void increment()
{
    x++;  // 不是 x = x + 1
}

显然,这些都是广泛有用的。例如,使用原子类型使出名棘手的双重检查锁定优化变得极为简单:

mutex mutex_x;
atomic<bool> init_x;  // 初始为 false
int x;

if (!init_x) {
    lock_guard<mutex> lck(mutex_x);
    if (!init_x) x = 42;
    init_x = true ;
}  // 在此隐式释放 mutex_x(RAII)

// ... 使用 x ...

双重检查锁定的要点是使用相对开销低的 atomic 保护开销大得多的 mutex 的使用。

lock_guard 是一种 RAII 类型(§2.2.1),它确保会解锁它所控制的 mutex

Hans-J. Boehm 将原子类型描述为“令人惊讶地流行”,但我不能说我感到惊讶。我没 Hans 那么专业,对简化更为欣赏。C++11 还引入了用于无锁编程的关键运算,例如比较和交换:

template<typename T>
class stack {
    std::atomic<node<T>*> head;
public:
    void push(const T& data)
    {
        node<T>* new_node = new node<T>(data);
        new_node->next = head.load(std::memory_order_relaxed);
        while(!head.compare_exchange_weak(new_node->next, new_node,
              std::memory_order_release, std::memory_order_relaxed)) ;
    }
    // ...
};

即使有了 C++11 的支持,我仍然认为无锁编程是专家级的工作。

4.1.2 线程和锁

在内存模型之上,我们还提供了“线程和锁”(threads and locks)的并发模型。我认为线程和锁级别的并发是应用程序使用并发最差的模型,但是对于 C++ 这样的语言来说,它仍然必不可少。不管它还是别的什么,C++ 一直是一种能够与操作系统直接交互的系统编程语言,可用于内核代码和设备驱动程序。因此,它必须支持系统最底层支持的东西。在此基础上,我们可以建立各种更适合特定应用的并发模型。就我个人而言,我特别喜欢基于消息的系统,因为它们可以消除数据竞争,而数据竞争可能产生极为隐晦的并发错误。

C++ 对线程和锁级别编程的支持是 POSIX 和 Windows 所提供的线程和锁的类型安全变体。在 [Stroustrup 2013] 有所描述,在 Anthony Williams 的书 [Williams 2012, 2018] 中有更为深入的探讨:

  • thread——系统的执行线程,支持 join()detach()
  • mutex——系统的互斥锁,支持 lock()unlock() 和保证 unlock() 的 RAII 方式
  • condition_variable——系统中线程间进行事件通信的条件变量
  • thread_local——线程本地存储

与 C 版本相比,类型安全使代码更简洁,例如,不再有 void** 和宏。考虑一个简单的例子,让一个函数在不同的线程上执行并返回结果:

class F {  // 传统函数对象
public:
    F(const vector<double>& vv, double* p) : v{vv}, res{p} { }
    void operator()();        // 将结果放入 *res
private:
    const vector<double>& v;  // 输入源
    double* res;              // 输出目标
};

double f(const vector<double>& v);  // 传统函数

void g(const vector<double>& v, double* res); // 将结果放入 *res

int comp(vector<double>& vec1, vector<double>& vec2, vector<double>& vec3)
{
    double res1;
    double res2;
    double res3;
    // ...
    thread t1 {F{vec1,&res1}};           // 函数对象
    thread t2 {[&](){res2=f(vec2);}};    // lambda 表达式
    thread t3 {g,ref(vec3),&res3};       // 普通函数

    t1.join();
    t2.join();
    t3.join();

    cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}

类型安全库支持的设计非常依赖变参模板(§4.3.2)。例如,std::thread 的构造函数就是变参模板。它可以区分不同的可执行的第一个参数,并检查它们后面是否跟有正确数量正确类型的参数。

类似地,lambda 表达式(§4.3.1)使 <thread> 库的许多使用变得更加简单。例如,t2 的参数是访问周围局部作用域的一段代码(lambda 表达式)。

在发布标准的同时,让新特性在标准库中被接受和使用是很困难的。有人提出这样做过于激进,可能会导致长期问题。引入新的语言特性并同时使用它们无疑是有风险的,但它通过以下方式大大增加了标准的质量:

  • 给用户一个更好的标准库
  • 给用户一个很好的使用语言特性的例子
  • 省去了用户实现底层功能的麻烦
  • 迫使语言特性的设计者应对现实世界的困难应用

线程和锁模型需要使用某种形式的同步来避免竞争条件。C++11 为此提供了标准的 mutex(互斥锁):

mutex m;  // 控制用的互斥锁
int sh;   // 共享的数据

void access ()
{
    unique_lock<mutex> lck {m};   // 得到互斥锁
    sh += 7;                      // 操作共享数据
} // 隐式释放互斥锁

unique_lock 是一个 RAII 对象,确保用户不会忘记在这个 mutex 上调用 unlock()

这些锁对象还提供了一种防止最常见形式的死锁的方法:

void f()
{
    // ...
    unique_lock<mutex> lck1 {m1,defer_lock};  // 还未得到 m1
    unique_lock<mutex> lck2 {m2,defer_lock};
    unique_lock<mutex> lck3 {m3,defer_lock};
    // ...
    lock(lck1,lck2,lck3);  // 获取所有三个互斥锁
    // ... 操作共享数据 ...
}   // 隐式释放所有互斥锁

这里,lock() 函数“同时”获取所有 mutex 并隐式释放所有互斥锁(RAII(§2.2.1))。C++17 有一个更优雅的解决方案(§8.4)。

线程库是由 Pete Becker(Dinkumware)在 2004 年首次为 C++0x 提出的 [Becker 2004],它基于 Dinkumware 对 Boost.Thread [Boost 1998–2020] 所提供的接口的实现。在同一次会议上(华盛顿州 Redmond 市,2004 年 9 月)提出了第一个关于内存模型的提案 [Alexandrescu et al. 2004],这可能不是巧合。

最大的争议是关于取消操作,即阻止线程运行完成的能力。基本上,委员会中的每个 C++ 程序员都希望以某种形式实现这一点。然而,C 委员会在给 WG21 的正式通知 [WG14 2007] 中反对线程取消,这是唯一由 WG14(ISO C 标准委员会)发给 WG21 的正式通知。我指出,“但是 C 语言没有用于系统资源管理和清理的析构函数和 RAII”。管理 POSIX 的 Austin Group 派出了代表,他们 100% 反对任何形式的这种想法,坚称取消既没有必要,也不可能安全进行。事实上 Windows 和其他操作系统提供了这种想法的变体,并且 C++ 不是 C,然而 POSIX 人员对这两点都无动于衷。在我看来,恐怕他们是在捍卫自己的业务和 C 语言的世界观,而不是试图为 C++ 提出最好的解决方案。缺乏标准的线程取消一直是一个问题。例如,在并行搜索(§8.5)中,第一个找到答案的线程最好可以触发其他此类线程的取消(不管是叫取消或别的名字)。C++20 提供了停止令牌机制来支持这个用例(§9.4)。

4.1.3 期值(future)

一个类型安全的、标准的、类似 POSIX/Windows 的线程库是对正在使用的不兼容的 C 风格库的重大改进,但这仍然是 1980 年代风格的底层编程。一些成员,特别是我,认为 C++ 迫切需要更现代、更高层次的东西。举例来说,Matt Austern(谷歌,之前代表 SGI)和我主张消息队列(“通道”)和线程池。这些意见没有什么进展,因为有反对意见说没有时间来做这些事情。我恳求并指出,如果委员会中的专家不提供这样的功能,他们最终将不得不使用“由我的学生匆匆炮制的”功能。委员会当然可以做得比这好得多。“如果你不愿意这样做,请给我一种方法,就一种方法,在没有显式同步的情况下在线程之间传递信息!”

委员会成员分为两派,一派基本上想要在类型系统上有改进的 POSIX(尤其是 P.J. Plauger),另一派指出 POSIX 基本上是 1970 年代的设计,“每个人”都已经在使用更高层次的功能。在 2007 年的 Kona 会议上,我们达成了一个妥协:C++0x(当时仍期望会是 C++09)将提供 promisefuture,以及异步任务的启动器 async(),允许但不需要线程池。和大多数折中方案一样,“Kona 妥协”没有让任何人满意,还导致了一些技术问题。然而,许多用户认为它是成功的——大多数人不知道这当时是一种妥协——并且这些年来,已经出现了一些改进。

最后,C++11 提供了:

  • future——一个句柄,通过它你可以从一个共享的单对象缓冲区中 get() 一个值,可能需要等待某个 promise 将该值放入缓冲区。
  • promise——一个句柄,通过它你可以将一个值 put() 到一个共享的单对象缓冲区,可能会唤醒某个等待 futurethread
  • packaged_task——一个类,它使得设置一个函数在线程上异步执行变得容易,由 future 来接受 promise 返回的结果。
  • async()——一个函数,可以启动一个任务并在另一个 thread 上执行。

使用这一切的最简单方法是使用 async()。给定一个普通函数作为参数,async() 在一个 thread 上运行它,处理线程启动和通信的所有细节:

double comp4(vector<double>& v)
// 如果 v 足够大则会产生多个任务
{
    if (v.size()<10000)    // 值得用并发机制吗?
        return accum(v.begin(),v.end(),0.0);
    auto v0 = &v[0];
    auto sz = v.size();

    auto f0 = async(accum,v0,v0+sz/4,0.0);         // 第一部分
    auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0);    // 第二部分
    auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0);  // 第三部分
    auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0);    // 第四部分

    return f0.get()+f1.get()+f2.get()+f3.get();    // 收集结果
}

async 将代码包装在 packaged_task 中,并管理 future 及其传输结果的 promise 的设置。

值或异常都可以通过这样一对 future/promise 从一个 thread 传递到另一个 thread。例如:

X f(Y); // 普通函数

void ff(Y y, promise<X>& p)     // 异步执行 f(y)
{
    try {
        X res = f(y);           // ... 给 res 计算结果 ...
        p.set_value(res);
    }
    catch (...) {               // 哎呀:没能计算出 res
        p.set_exception(current_exception());
    }
}

为简单起见,我没有使用参数的完美转发(§4.2.3)。

对应 futureget() 现在要么得到一个值,要么抛出一个异常——与 f() 的某个等效同步调用完全一样。

void user(Y arg)
{
    auto pro = promise<X>{};
    auto fut = pro.get_future();
    thread t {ff,arg,ref(pro)}; // 在不同线程上运行 ff
    // ... 做一会别的事情 ...
    X x = fut.get();
    cout << x.x << '\n';
    t.join();
}

int main()
{
    user(Y{99});
}

标准库的 packaged_task 自动化了这个过程,可以将普通函数包装成一个函数对象,负责 promise/future 的自动配置并处理返回和异常。

我曾希望这会产生一个由线程池支持的工作窃取(work-stealing)的实现,但我还是失望了。

另见(§8.4)。

4.2 C++11:简化使用

C++ 是“专家友好”的。我想我是第一个将这句话用作委婉的批评,并且在 C++ 中推行“简单的事情简单做!”的口号的人。当然,主要面向工业应用的语言就应该对专家友好,但是一门语言不能只对专家友好。大多数使用编程语言的人并不是专家——他们也不想精通该语言的方方面面,而只是想把工作做到足够好,不会因为语言而分心。编程语言的存在,是为了能够表达应用程序的创意,而不是把程序员变成语言律师。语言的设计应该尽力让简单的事情能够简单地做。语言要给专家使用的话,则必须额外确保,没有什么基本事项是做不了的,并且代价也不会过于高昂。

当讨论潜在的 C++ 语言扩展和标准库组件时,另外一个准则是“教起来容易吗?”这个问题现在已经很普遍了,它最早是由 Francis Glassborow 和我倡导的。“教起来容易”的思想起源于 C++ 的早期,可以在《C++ 语言的设计和演化》[Stroustrup 1994] 中找到。

当然,新事物的拥护者不可避免地认为他们的设计简单、易用、足够安全、高效、易于传授,及对大多数程序员有用。反对者则倾向于怀疑他们的部分甚至全部说法。但是,确保对 C++ 提议的每个特性都经历这样的讨论是很重要的:可以通过面对面会议,可以通过论文 [WG21 1989–2020],也可以通过电子邮件。在这些讨论中,我经常指出,我大部分时间也是个新手。也就是说,当我学习新的特性、技巧或应用领域时,我是一个新手,我会用到从语言和标准库中可以获得的所有帮助。一个结果是,C++11 提供了一些特别的功能,旨在简化初学者和非语言专家对 C++ 的使用。

每一项新特性都会让一些人做某些事时更加简单。“简化使用”的主题聚焦于这样一些语言特性,它们的主要设计动机是让已知的惯用法使用起来更加简单。下面列举其中的一些:

  • §4.2.1auto——避免类型名称的不必要重复
  • §4.2.2:范围 for——简化范围的顺序遍历
  • §4.2.3:移动语义和右值引用——减少数据拷贝
  • §4.2.4:资源管理指针——管理所指向对象生命周期的“智能”指针(unique_ptrshared_ptr
  • §4.2.5:统一初始化——对所有类型都(几乎)完全一致的初始化语法和语义
  • §4.2.6nullptr——给空指针一个名字
  • §4.2.7constexpr 函数——编译期被估值的函数
  • §4.2.8:用户定义字面量——为用户自定义类型提供字面量支持
  • §4.2.9:原始字符串字面量——转义字符(\)不被解释为转义符的字面量,主要用在正则表达式中
  • §4.2.10:属性——将任意信息同一个名字关联
  • §4.2.11:与可选的垃圾收集器之间的接口
  • §4.3.1:lambda 表达式——匿名函数对象

在 C++11 开始得到认真使用后,我就开始在旅行时做一些不那么科学的小调查。我会问各地的 C++ 使用者:你最喜欢哪些 C++11 的特性?排在前三位的一直都是:

这三个特性属于 C++11 中新增的最简单特性,它们并不能提供任何新的基础功能。它们做的事情,在 C++98 中也能做到,只是不那么优雅。

我认为这意味着不同水平的程序员都非常喜欢让惯常用法变简洁的写法。他们会高兴地放弃一个通用的写法,而选择一个在适用场合中更简单明确的写法。有一个常见的口号是,“一件事只应有一种说法!1”这样的“设计原则”根本不能反映现实世界中的用户偏好。我则倾向于依赖洋葱原则 [Stroustrup 1994]。你的设计应该是这样的:如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。

请注意,这里简单并不意味着底层void*、宏、C 风格字符串和类型转换等底层功能表面上学起来简单,但使用它们来产出高质量、易维护的软件就难了。

4.2.1 autodecltype

C++11 中最古老的新特性,是能够在初始化的时候就给对象指定一个确定的类型。例如:

auto i = 7;          // i 是个整数
auto d = 7.2;        // d 是个双精度浮点数
auto p = v.begin();  // p 是 v 的迭代器类型
                     // (begin() 返回一个迭代器)

auto 是一个方便的静态特性,它允许从初始化表达式中推导出对象的静态类型。如果要用动态类型的变量,应该使用 variant 或者 any§8.3)。

我早在 1982/83 年冬天就实现了 auto,但是后来为了保持 C 兼容性而不得不移除了这一特性。

在 C++11 中,大家提出用 typeof 运算符代替已经流行的 typeof 宏和编译器扩展。不幸的是,不同 typeof 宏在处理引用时并不兼容,因而采用任何一种都会严重破坏现有代码。引入一个新的关键字总是困难的,因为如果它简短而且意义明确,那它一定已经被使用了成千上万次。如果建议的关键字又丑又长,那大家就会讨厌它。

Jaakko Järvi,Boost 库最多产的贡献者之一,那时是我在得州农工大学的同事。他当时领导了 typeof 的讨论。我们意识到语义的问题可以概括为:“一个引用的 typeof 到底是引用自身,还是所引用的类型?”同时,我们还感觉到,typeof 有点冗长而且容易出错,比如:

typeof(x+y) z = y+x;

在这里,我以为我重复计算了 x+y,但其实并没有(潜在的不良影响),但不管怎么样,我为什么要把任何东西重复写两遍呢?这时候我意识到,我其实在 1982 年就解决过这个问题,我们可以“劫持”关键字 auto 来消除这种重复:

auto z = y+x;  // z 获得 y+x 的类型

在 C 和早期的 C++ 中,auto 曾表示“在自动存储(比如栈上)上分配”,但是从来没有被用过。我们查看了数百万行的 C 和 C++ 代码,确认了 auto 只在一些测试集和错误中用到过,于是我们就可以回收这个关键字,用作我 1982 年的意思,表示“获取初始化表达式的类型”。

剩下的问题是,我们要在某些场景中把引用的类型也推导为一个引用。这在基于模板的基础库中并不少见。我们提出了用 decltype 运算符来处理这种保留引用的语义:

template<typename T> void f(T& r)
{
    auto v = r;            // v 是 T
    decltype(r) r2 = r;    // r2 是 T&
    // ...
}

为什么是 decltype?可惜,我已经不记得是谁建议了这个名字了,但是我还记得原因:

  • typeof 已经不能用了,因为那样会破坏很多老代码
  • 我们找不到其他优雅、简短、且没有被用过的名字了
  • decltype 足够好记(“declared type”的简写);但也足够古怪,因而没有在现有代码中用过
  • decltype 还算比较短

提议 decltype 的论文写于 2003 年 [Järvi et al. 2003b],而通过投票接受到标准中的论文写于 2006 年 [Järvi et al. 2007]。Jaakko Järvi 做了让 decltype 通过委员会评审的大部分细节的工作,Doug Gregor、Gabriel Dos Reis、Jeremy Siek 和我也帮过忙,并且在一些论文中作为合著作者出现。事实证明,澄清 decltype 的确切语义比我在这里说的要难得多。花费数年在一个看上去很简单的特性细节上的情况并不少见——部分原因是特性的固有复杂性,部分原因则是,需要最后批准的人可真不少,他们需要同意每个细节的设计和具体说明都已经让人满意了。

我认为 auto 是个纯粹的简化特性,而 decltype 的主要目的,则是让基础库可以使用复杂的元编程。然而,从语言使用的技术角度来看,它们是密切相关的。

我探索过推广 auto 到另外两个显而易见的场景 [Stroustrup and Dos Reis 2003b]:作为返回类型和参数类型。这显而易见,因为在 C++ 中,参数传递和值返回被定义为一种初始化。但在 2003 年,当我第一次向委员会提出这些想法时,演化工作组的成员们毫不掩饰地表现出恐惧的神情。考虑下面的例子:

auto f(auto arg)
{
    return arg;
}

auto x = f(1);                // x 是 int
auto s = f(string("Hello"));  // s 是 string

当我向委员会提出这个想法时,我收到了超过我的任何其他提案的负面反馈。我形容当时的情景“就像贵妇见到了老鼠一样”,他们叫嚷着:“咦咿……!”。然而,故事还没结束。C++17 后来对 lambda 表达式(§4.3.1)的参数和返回值都支持了 auto,而对普通的函数,C++17 只支持返回值的 auto。作为概念的一部分(§6.4),C++20 为函数参数添加了 auto 支持,至此才完全实现了我在 2003 年提出的建议。

C++11 中添加了一种弱化的 auto 用法,把返回类型的说明放到参数后面。例如,在 C++98 中,我们会这样写:

template<typename T>
vector<T>::iterator vector<T>::begin() { /* ... */ }

重复出现的 vector<T>:: 令人厌烦,当时也没法表达返回类型依赖于参数类型(这在一些泛型编程中很有用)。C++11 弥补了这个问题,并提高了代码的可读性:

template<typename T>
auto vector<T>::begin() -> iterator { /* ... */ }

这样,在多年努力后,我们终于有了 auto。它立即就变得非常流行,因为它让程序员不用再拼写冗长的类型名称,也不需要在泛型代码中考虑类型的细节。例如:

for (auto p = v.begin(); p != v.end(); ++p) ...  // 传统的 STL 循环

它允许人们对齐名字:

class X {
public:
    auto f() -> int;
    auto gpr(int) -> void;
    // ...
};
void use(int x, char* p)
{
    auto x2 = x*2;   // x2 是 int
    auto ch = p[x];  // ch 是 char
    auto p2 = p+2;   // p2 是 char*
    // ...
}

还曾经有论文主张尽量多地使用 auto [Sutter 2013b]。有句话很经典:每个有用的新特性,一开始都会被滥用和误用。一段时间后,部分开发者找到了平衡点。把这种平衡的用法阐述为最佳实践,是我(和很多其他人)致力于编程指南(§10.6)的原因之一。对于 auto,我收到了很多评论,说当人们将它和没有明显类型的初始化表达式放一起使用时可读性不好。因此,C++ 核心指南 [Stroustrup and Sutter 2014–2020](§10.6)有了这条规则:

ES.11:使用 auto 来避免类型名称的多余重复

我的书 [Stroustrup 2013, 2014d] 中也有类似的建议。考虑下面的例子:

auto n = 1;  // 很好:n 是 int
auto x = make_unique<Gadget>(arg);  // 很好:x 是 std::unique_ptr<Gadget>
auto y = flopscomps(x,3);           // 不好:flopscomps() 返回的是啥东西?

这仍然无法百分百地确定如何在每种情况下应用该规则,但有规则总比没有规则要好得多,并且代码会比使用绝对规则“不许使用 auto!”和“永远使用 auto!”更加可读。真实世界的编程往往需要更多的技巧,不会像展示语言特性的例子这样简单。

如果 flopscomps() 不是泛型计算的一部分,那么最好显式地声明想要的类型。我们需要等到 C++ 20 才能用概念来约束返回类型(§6.3.5):

Channel auto y = flopscomps(x,3);   // y 可以当做 Channel 使用

那么,针对 auto 的工作值得吗?它是一个很小的功能,对于简单的情况,一天就可以实现,但却花了 4 年的时间才在委员会通过。它甚至都不算新颖:很多语言 40 年前就有这样的功能了,甚至带类的 C 在 35 年前就有这样的功能!

对 C++ 标准委员会通过哪怕是最小的功能所需的时间,以及常伴其间的痛苦讨论,经常让我感到绝望。但是另一方面,把事情做好之后,成千上万的程序员会从中受益。当某件事做得很好时,最常见的评论是:“这很明显啊!怎么你们要花那么久?”

4.2.2 范围 for

范围 for 是用来顺序遍历一个序列中所有元素的语句。例如:

void use(vector<int>& v, list<string>& lst)
{
    for (int x : v) cout << x << '\n';
    int sum = 0;
    for (auto i : {1,2,3,5,8}) sum+=i; // 初始化列表是一个序列
    for (string& s : lst) s += ".cpp"; // 使用引用允许遍历时修改
}

它最初是由 Thorsten Ottosen(丹麦奥尔堡大学)提出的,理由是“基本上任何现代编程语言都内置了某种形式的 for each” [Ottosen 2005]。我通常不认为“别人都有了”是个好的论据,但在这一情况下,真正的要点是,简单的范围循环可以简化一种最常见的操作,并提供了优化的机会。所以,范围 for 完美符合我对 C++ 的总体设计目标。它直接表达应该做什么,而不是详细描述如何做。它的语法简洁,语义明晰。

由于更简单和更明确,范围 for 的写法消除了一些“微不足道”然而常见的错误:

void use(vector<int>& v, list<string>& lst)
{
    for (int i=0; i<imax; ++i)
        for (int j=0; i<imax; ++j) ...  // 错误的嵌套循环

    for (int i=0; i<=max; ++i) ...      // 多循环了一次的错误
}

尽管范围 for 够简单了,它在这些年还是有些变化。Doug Gregor 曾建议使用 C++0x 中的概念来修改范围 for,方案优雅并且得到了批准 [Ottosen et al. 2007]。我还记得他在我在得州的办公室里写这个提案的场景,但很遗憾,后来因为删除了 C++0x 的概念(§6),我们不得不回退了那些修改。在 2016 年,它还做过一点小修改,以配合 Ranges TS(§9.3.5)所支持的无限序列。

4.2.3 移动语义

在 C 和 C++ 中,要从函数获得大量的数据,传统做法是在自由存储区(堆、动态内存)上分配空间,然后传递指向该空间的指针作为函数参数。比如,对于工厂函数和返回容器(例如 vectormap)的函数就需要如此。这对开发者来说看起来很自然,而且相当高效。不幸的是,它是显式使用指针的主要来源之一,导致了写法上的不便、显式的内存管理,以及难以查找的错误。

多年来,很多专家使用“取巧”的办法来解决这个问题:把句柄类作为简单数值(常称为值类型)来传递,例如:

Matrix operator+(const Matrix&, const Matrix&);

void use(const Matrix& m1, const Matrix& m2)
{
    Matrix m3 = m1+m2;
    // ...
}

这里 operator+ 让我们可以使用常规的数学写法,同时也是一个工厂函数返回大对象的示例。

通过 const 引用把 Matrix 传递给函数,一直是传统而高效的做法。而问题在于,如何以传值来返回 Matrix 而不用拷贝所有的元素。早在 1982 年,我曾通过一种优化方案来部分解决这一问题,即干脆将返回值分配在调用函数的栈帧上。它工作得很好,但它只是优化技术,不能处理更复杂的返回语句。而用户在按值返回“大对象”时,需要确保绝不会进行大量的数据复制。

要做到这一点,需要观察到“大对象”通常是在自由存储区上的数据的一个句柄。为了避免复制大量的数据,我们只需要确保在实现返回时,构造函数复制的只是句柄,而不是所有元素。C++11 对这个问题的解决方案如下所示:

class Matrix {
    double* elements;    // 指向所有元素的指针
    // ...
public:
    Matrix (Matrix&& a)  // 移动构造
    {
        elements = a.elements;  // 复制句柄
        a.elements = nullptr;   // 现在 a 的析构函数不用做任何事情了
    }
    // ...
};

当用于初始化或赋值的源对象马上就会被销毁时,移动就比拷贝要更好:移动操作只是简单地把对象的内部表示“窃取”过来。&& 表示构造函数是一个移动构造函数Matrix&& 被称为右值引用。当用于模板参数时,右值引用的写法 && 被叫做转发引用,这是由 John Spicer 在 2002 年的一次会议上,同 Dave Abrahams 和 Howard Hinnant 一起提出的。

这个 Matrix 的例子有个有意思的地方:如果 Matrix 的加法返回指针的话,那传统的数学写法(a+b)就不能用了。

移动语义蕴含着性能上的重大好处:它消除了代价高昂的临时变量。例如:

Matrix mx = m1+m2+m3;  // 不需要临时变量
string sx = s1+s2+s3;  // 不需要临时变量

这里我添加了 string 的例子,因为移动语义立刻就被添加到了所有的标准库容器上,这可以让一些 C++98 的程序拿来不做任何代码修改就获得性能提升。

允许类的设计者定义移动操作后,我们就有了完整的对对象生命周期和资源管理的控制,这套控制始于 1979 年对构造函数和析构函数的引入。移动语义是 C++ 资源管理模型的重要基石 [Stroustrup et al. 2015],正是这套机制使得对象能够在不同作用域之间简单而高效地进行移动。

早期对参数传递、完美转发和智能指针强调颇多,可能掩盖了这个重要的一般性观点。Howard Hinnant、Dave Abrahams 和 Peter Dimov 在 2002 年提出了移动语义的一般化版本 [Hinnant et al. 2004, 2002]:

右值引用可以用于给现有类方便地添加移动语义。意思是说,拷贝构造函数和赋值运算符可以根据实参是左值还是右值来进行重载。当实参是右值时,类的作者就知道他拥有对该实参的唯一引用。

一个突出的例子是生成“智能指针”的工厂函数:

template <class T, class A1>
std::shared_ptr<T> factory(A1&& a1)
{
    return std::shared_ptr<T>(new T(std::forward<A1>(a1)));
}

现已进入标准库的函数 forward 告诉编译器将实参视为右值引用,因此 T 的移动构造函数(而不是拷贝构造函数)会被调用,来窃取该参数。它本质上就是个右值引用的类型转换。

在 C++98 中,没有右值引用,这样的“智能指针”很难实现。在 C++11 中,解决方案就简单了 [Hinnant et al. 2006]:2

template <class T>
class clone_ptr
{
private:
    T* ptr;
public:
    // ...
    clone_ptr(clone_ptr&& p)            // 移动构造函数
        : ptr(p.ptr)    // 拷贝数据的表示
    {
        p.ptr = 0;      // 把源数据的表示置空
    }
    clone_ptr& operator=(clone_ptr&& p) // 移动赋值
    {
        std::swap(ptr, p.ptr);
        return *this;   // 销毁目标的旧值
    }
};

很快,移动语义技术就被应用到了标准库的所有容器类上,像 vectorstringmapshared_ptrunique_ptr 的确智能,但它们仍然是指针。我更喜欢强调移动构造和移动赋值,它们使得(以句柄表示的)大型对象在作用域间能够高效移动。

右值引用的提案在委员会中涉险过关。有人认为右值引用和移动语义多半来不及进入 C++11,因为这些概念很新,而我们那时连合适的术语都没有。部分由于术语上的问题 [Miller 2010],右值引用这一术语在核心语言和标准库中的使用就有了分歧,从而使得标准草案中出现了不一致。在 2010 年 3 月的匹兹堡会议上,我参与了核心工作组(CWG)的讨论,在午饭休息的时间,在我看来“我们陷入了僵局,或者混乱之中,也许兼而有之”。我没有去吃午饭,而是对问题进行了分析,并得出结论,这里只涉及到两个基本概念:有标识符(identity),及可被移动。从这两个原语出发,我推导出了传统的左值和右值类别 [Barron et al. 1963],以及解决我们的定义问题所需要的三个新类别。在核心工作组回来之后,我提出了我的解决方案。它很快就得到了接受,这样我们就在 C++11 中保留了移动语义 [Stroustrup 2010a]。

4.2.4 资源管理指针

C++11 提供了“智能指针”(§4.2.4):

  • shared_ptr——代表共享所有权
  • unique_ptr——代表独占所有权(取代 C++98 中的 auto_ptr

添加这些表示所有权的资源管理“智能指针”对编程风格有很大的影响。对很多人来说,这意味着不再有资源泄漏,悬空指针的问题也显著减少。在自动化资源管理和减少裸指针使用的努力中,它们是最明显的部分了(§4.2.3)。

shared_ptr 是传统的计数指针:指向同一对象的所有指针共享一个计数器。当最后一个指向对象的共享指针被销毁时,被指向的对象也会被销毁。这是一种简单、通用且有效的垃圾收集形式。它能正确地处理非内存资源(§2.2.1)。为了正确处理环形数据结构,还需要有 weak_ptr;不过,这往往不是最好的做法。人们常常简单地使用 shared_ptr 来安全地从工厂函数返回数据:

shared_ptr<Blob> make_Blob(Args a)
{
    auto p = shared_ptr<Blob>(new Blob(a));
    // ... 把很多好东西填到 *p ...
    return p;
}

当把对象移出函数时,引用计数会从 1 变到 2 再变回 1。在多线程程序中,这通常是涉及到同步的缓慢操作。另外,粗率地使用和/或实现引用计数,会增加分配和回收的开销。

正如预期的那样,shared_ptr 很快就流行起来,并在有些地方被严重滥用。因此,后来我们提供了不引入额外开销的 unique_ptrunique_ptr 对它所指的对象拥有独占的所有权,并会在自身被销毁的时候把指向的对象也简单地 delete 掉。

unique_ptr<Blob> make_Blob(Args a)
{
    auto p = unique_ptr<Blob>(new Blob(a));
    // ... 把很多好东西填到 *p ...
    return p;
}

shared_ptrweak_ptr 是 Peter Dimov 的工作成果 [Dimov et al. 2003]。Howard Hinnant 贡献的 unique_ptr 是对 C++98 的 auto_ptr 的改进 [Hinnant et al. 2002]。考虑到 unique_ptrauto_ptr 的即插即用式的替代品,这提供了从标准中(最终)删除有缺陷的功能的难得机会。资源管理指针跟移动语义、完美转发及右值引用的工作密切相关(§4.2.3)。

资源管理指针被广泛地用于持有对象,以便异常(及类似的情况)不会导致资源泄漏(§2.2)。例如:

void old_use(Args a)
{
    auto q = new Blob(a);
    // ...
    if (foo) throw Bad();  // 会泄漏
    if (bar) return;       // 会泄漏
    // ...
    delete q;    // 容易忘
}

显式使用 newdelete 的旧方式容易出错,在现代 C++ 中已经不推荐使用(例如,C++ 核心指南(§10.6))。现在我们可以这样写:

void newer_use(Args a)
{
    auto p = unique_ptr<Blob>(new Blob(a));
    // ...
    if (foo) throw Bad();  // 不会泄漏
    if (bar) return;       // 不会泄漏
    // ...
}

这种写法更简短、更安全,迅速就流行开去。不过,“智能指针”仍然被过度使用:“它们的确智能,但它们仍然是指针。”除非我们确实需要指针,否则,简单地使用局部变量会更好:

void simplest_use(Args a)
{
    Blob b(a);
    // ...
    if (foo) throw Bad(); // 不会泄漏
    if (bar) return;      // 不会泄漏
    // ...
}

智能指针用于表示资源所有权的主要用途是面向对象编程,其中指针(或引用)用于访问对象,而对象的确切类型在编译时并不知道。

4.2.5 统一初始化

出于历史原因,C++ 有多种初始化的写法,而它们的语义有惊人的不同。

从 C 语言中,C++ 继承了三种初始化形式,并添加了第四种形式:

int x;              // 默认初始化(仅适用于静态变量)
int x = 7;          // 值初始化
int a[] = {7,8};    // 聚合初始化
string s;           // 由默认构造函数初始化
vector<int> v(10);  // 由构造函数初始化

用于初始化的概念既取决于要初始化的对象的类型,也取决于初始化的上下文。这是一团乱麻,而且人们也认识到这一点。比如,为什么可以用列表初始化内建数组,但却不能初始化 vector

int a[] = {7,8};        // 可以
vector<int> v = {7,8};  // 应该可以工作(显然,但是没有)

上一个例子令我非常不舒服,因为它违反了 C++ 的根本设计目标,即为内建类型和用户定义的类型提供同等的支持。特别是,因为对数组初始化有比 vector 更好的支持,这会鼓励人们使用容易出错的内建数组。

当 C++0x 的工作从 2002 年开始的时候,Daniel Gutson、Francis Glassborow、Alisdair Meredith、Bjarne Stroustrup 和 Gabriel Dos Reis 曾进行了许多讨论和提议,来解决其中一些问题。在 2005 年,Gabriel Dos Reis 和我提出了统一初始化的写法,该写法可用于每种类型,并且在程序中的任何地方都具有相同的含义 [Stroustrup and Dos Reis 2005b]。这种写法有望大大简化用户代码并消除许多不易察觉的错误。这一写法基于使用花括号的列表写法。举例来说:

int a = {5};            // 内建类型
int a[] {7,8};          // 数组
vector<int> v = {7,8};  // 具有构造函数的用户定义的类型

花括号({})对于单个值是可选的,并且花括号初始化器列表之前的 = 也是可选的。为了统一起见,在许多 C++98 不允许使用花括号或者 = 初始化的地方都接受花括号样式的初始化:

int f(vector<int>);
int i = f({1,2,3});  // 函数参数

struct X {
    vector<int> v;
    int a[];
    X() : v{1,2}, a{3,4} {}  // 成员初始化器
    X(int);
    // ...
}

vector<int>* p = new vector<int>{1,2,3,4};  // new 表达式
X x {};  // 默认初始化

template<typename T> int foo(T);
int z = foo(X{1});  // 显式构造

其中许多的情形,例如为使用 new 创建的对象提供初始化器列表,使用以前的写法根本就做不到。

可惜,对于这一理想,我们仅仅达到不完全的近似,我们有的方案只能算大致统一。有些人发现,使用 {…} 很别扭,除非 是同质对象的列表,而其他人则坚持 C 语言中对聚合和非聚合的区分,并且许多人担心没有显式类型标记的列表会导致歧义和错误。例如,以下写法被认为是危险的,不过最终还是被接受了:

struct S { string s; int i; };

S foo(S s)
{
    // ...
    return {string{"foo"},13};
}

S x = foo({string{"alpha"},12.3});

在一种情况下,对统一写法的追求被一种惯用法击败。考虑:

vector<int> v1(10);          // 10 个元素
vector<int> v2 {10};         // 10 个元素还是 1 个值为 10 的元素?
vector<int> v3 {1,2,3,4,5};  // 拥有 5 个元素的 vector

使用像 vector<int> v1(10) 的指定大小的初始化器的代码有数百万行,而从基本原则上来说,vector<int> v2 {10} 确实是模棱两可的。假如是在一门新的语言中,我不会使用普通的整数来表示大小,我会为此指定一种特定的类型(比如 SizeExtent);举例来说:

vector<int> v1 {Extent{10}};  // 10 个元素,默认值为 0
vector<int> v2 {10};          // 1 个元素,值为 10

但是,C++ 并不是一门新语言,因此我们决定,在构造函数中进行选择时优先选择初始化器列表解释。这使 vector<int> v2 {10} 成为具有一个元素的 vector,并且使 {…} 初始化器的解释保持一致。但是,当我们想要避免使用初始化器列表构造函数时,这就迫使我们使用 (…) 写法。

初始化的问题之一正在于,它无处不在,因此基本上所有程序和语言规则的问题都会在初始化上下文中体现出来。考虑:

int x = 7.2;  // 传统的初始化
int y {7.2};  // 花括号初始化

从大约 1974 年将浮点数引入 C 语言以来,x 的值就是 7;也就是说,7.2 被隐式截断,从而导致信息丢失。这是错误的来源。花括号初始化不允许窄化转换(此处为截断)。很好,但是升级旧代码变得更加困难:

double d = 7.2;
int x = d;   // 可以:截断
int y {d};   // 错误

这是一个常见问题的例子。人们想要一条简单的升级路径,但是除非需要做出一些努力和更改,否则一次非常简单的升级的结果是,旧的问题和错误得以保留。改善一门广泛使用的语言比我们一般想像的要难。

经过许多激烈的辩论和许多修改(并非其中每一项我都认为是改进),统一初始化在 2008 年被批准进入 C++0x [Stroustrup 2008b]。

与以往一样,写法是一个有争议的问题,但是最终我们同意有一个标准库类型的 initializer_list 用作初始化器列表构造函数的参数类型。举例来说:

template<typename T> class vector {
public:
    vector(initializer_list<T>);  // 初始化器列表构造函数
    // ...
};

vector<int> v3 {1,2,3,4,5};  // 具有 5 个元素的 vector

令人遗憾的是,统一初始化({} 初始化)的使用并不像我期望的那样广泛。人们似乎更喜欢熟悉的写法和熟悉的缺陷。我似乎陷入了 N+1 问题:你有 N 个不兼容和不完整的解决方案,因此添加了一个新的更好的解决方案。不幸的是,原始的 N 个解决方案并没有消失,所以你现在有了 N+1 个解决方案。公平地说,有一些细微的问题超出了本文的范围,这些问题只是在 C++14、C++17 和 C++20 中被逐步补救。我的印象是,泛型编程和对更简洁写法的普遍推动正在慢慢增加统一初始化的吸引力。所有标准库容器(如 vector)都有初始化器列表构造函数。

4.2.6 nullptr

在 C 和 C++ 中,如果将字面量 0 赋值给指针或与指针比较时它表示空指针。更令人困惑的是,如果将任何求值为零的整数常量表达式赋值给指针或与指针比较时它也表示空指针。例如:

int* p = 99-55-44; // 空指针
int* q = 2;        // 错误:2 是一个 int,而不是一个指针

这使很多人感到烦恼和困惑,因此有一个标准库宏 NULL(从 C 中采用),它在标准 C++ 中定义为 0。某些编译器会对 int* p = 0 提出警告;但是我们仍然没法为函数针对指针和整数重载而避免 0 的歧义。

这很容易通过给空指针命名来解决,但是不知何故没有人能提出一份人们能达成一致的提议。在 2003 年的某个时候,我正通过电话参加一个会议,讨论如何给空指针命名。如 NULLnullnilnullptr0p 等建议名都是备选方案。照旧,那些简短而“漂亮”的名字已经被使用了成千上万次,因此不能在不破坏数百万行代码的情况下使用。我听了数十次这样的讨论,有点厌烦了,只是在似听非听。人们说到 null pointer、null ptr、nullputter 的变体。我醒过来说:“你们都在说 nullptr。我想我没有在代码中看到过它。”

Herb Sutter 和我写下了该提案 [Sutter and Stroustrup 2003],该提案在 2007 年相对容易地通过了(仅仅进行了四次小修订后),所以现在我们可以说:

int* p0 = nullptr;
int* p1 = 99-55-44;  // 可以,为了兼容性
int* p2 = NULL;      // 可以,为了兼容性

int f(char*);
int f(int);

int x1 = f(nullptr); // f(char*)
int x2 = f(0);       // f(int)

我对 nullptr 的发音是“null pointer”。

我仍然认为如能将宏 NULL 定义为 nullptr 可以消除一类重要的问题,但委员会认为这一改变过于激进。

4.2.7 constexpr 函数

在 2003 年,Gabriel Dos Reis 和我提出了用于在 C++ 中进行常量表达式求值的一种根本不同且明显更好的机制 [Dos Reis 2003]。人们当时使用(无类型的)宏和贫乏的 C 语言定义的常量表达式。另一些人则开始使用模板元编程来计算值(§10.5.2)。“这既乏味又容易出错” [Dos Reis and Stroustrup 2010]。我们的目标是

  • 让编译期计算达到类型安全
  • 一般来说,通过将计算移至编译期来提高效率
  • 支持嵌入式系统编程(尤其是 ROM)
  • 直接支持元编程(而非模板元编程(§10.5.2))
  • 让编译期编程与“普通编程”非常相似

这个想法是简单的:允许在常量表达式中使用以 constexpr 为前缀的函数,还允许在常量表达式中使用简单用户定义类型,叫字面量类型。字面量类型基本上就是一种所有运算都是 constexpr 的类型。

考虑这样一个应用,为了提高效率、支持 ROM 或可靠性,我们想使用一套单位制 [Dos Reis and Stroustrup 2010]:

struct LengthInKM {
    constexpr explicit LengthInKM(double d) : val(d) { }
    constexpr double getValue() { return val; }
private:
    double val;
};

struct LengthInMile {
    constexpr explicit LengthInMile(double d) : val(d) { }
    constexpr double getValue() { return val; }
    constexpr operator LengthInKM() { return LengthInKM(1.609344 * val); }
private:
    double val;
};

有了这些,我们可以制作一个常量表,而不必担心单位错误或转换错误:

LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };

传统的解决方案要么需要更多的运行时间,要么需要程序员在草稿纸上算好值。我对单位制的兴趣是由 1999 年的火星气候探测者号的失事激发的,事故原因是单位不匹配没有被发现 [Stephenson et al. 1999]。

constexpr 函数可以在编译期进行求值,因此它无法访问非本地对象(它们在编译时还不存在),因此 C++ 获得了一种纯函数。

为什么我们要求程序员应该使用 constexpr 来标记可以在编译期执行的函数?原则上,编译器可以弄清楚在编译期可以计算出什么,但是如果没有标注,用户将受制于各种编译器的聪明程度,并且编译器需要将所有函数体“永远”保留下来,以备常量表达式在求值时要用到它们。我们选择 constexpr 一词是因为它足够好记,但又“足够奇怪”而不会破坏现有代码。

在某些地方,C++ 需要常量表达式(例如,数组边界和 case 标签)。另外,我们可以通过将变量声明为 constexpr 来要求它在编译期被初始化:

constexpr LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };

void f(int x)
{
    int y1 = x;
    constexpr int y2 = x;   // 错误:x 不是一个常量
    constexpr int y3 = 77;  // 正确
}

早期的讨论集中在性能和嵌入式系统的简单示例上。直到后来(大约从 2015 年开始),constexpr 函数才成为元编程的主要支柱(§10.5.2)。C++14 允许在 constexpr 函数中使用局部变量,从而支持了循环;在此之前,它们必须是纯函数式的。C++20(最终,在首次提出后约 10 年)允许将字面类型用作值模板参数类型 [Maurer 2012]。因此,C++20 将非常接近最初的目标(1979 年),即在可以使用内建类型的地方也都可以使用用户定义的类型(§2.1)。

constexpr 函数很快变得非常流行。它们遍布于 C++14、C++17 和 C++20 标准库,并且不断有相关建议,以求在 constexpr 函数中允许更多的语言构件、将 constexpr 应用于标准库中的更多函数,以及为编译期求值提供更多支持(§9.3.3)。

但是,constexpr 函数进入标准并不容易。它们一再被认为是无用和无法实现的。实现 constexpr 函数显然需要改进较老的编译器,但是很快,所有主要编译器的作者都证明了“无法实现”的说法是错误的。关于 constexpr 的讨论几乎是有史以来最激烈、最不愉快的。让初始版本通过标准化流程 [Dos Reis and Stroustrup 2007] 花费了四年的时间,而完整地完成又花了十二年的时间。

4.2.8 用户定义字面量

“用户定义字面量”是一个非常小的功能。但是,它合乎我们的总体目标,即让用户定义类型得到和内建类型同等的支持。内建类型有字面量,例如,10 是整数,10.9 是浮点数。我试图说服人们,对于用户定义类型,显式地使用构造函数是等价的方式;举例来说,complex<double>(1.2,3.4) 就是 complex 的字面量等价形式。然而,许多人认为这还不够好:写法并不传统,而且不能保证构造函数在编译期被求值(尽管这还是早年间的事)。对于 complex,人们想要 1.2+3.4i

与其他问题相比,这似乎并不重要,所以几十年来什么都没有发生。2006 年的一天,David Vandevoorde(EDG)、Mike Wong(IBM)和我在柏林的一家中餐馆吃了一顿丰盛的晚餐。我们在餐桌边聊起了天,于是一个设计浮现在一张餐巾纸上。这个讨论的起因是 IBM 的一项十进制浮点提案中对后缀的需求,该提案最终成了一个独立的国际标准 [Klarer 2007]。在大改后,该设计在 2008 年成为用户定义字面量(通常称为 UDL)[McIntosh et al. 2008]。当时让 UDL 变得有趣的重要发展是 constexpr 提案的进展(§4.2.7)。有了它,我们可以保证编译期求值。

照例,找到一种可接受的写法是一个问题。我们决定使用晦涩的 operator"" 作为字面量运算符(literal operator)的写法是可以接受的,毕竟 "" 是一个字面量。然后,""x 是用来表示字面量后面跟后缀 x 的写法。这样一来,要定义一个用于 complex 数的 Imaginary 类型,我们可以定义:

constexpr Imaginary operator""i(long double x) { return Imaginary(x); }

现在,3.4i 是一个 Imaginary,而 1.2+3.4icomplex<double>(1.2,3.4)。任务完成!

这一功能的语言技术细节相当古怪,但我认为对于一个相对很少使用的特性来说,这是合理的。即使在大量使用 UDL 时,字面量运算符的定义也很少。最重要的是后缀的优雅和易用性。对于许多类型,重要的是可以在编译时完成从内建类型到用户定义类型的转换。

很自然,人们使用 UDL 来定义许多有用的类型的字面量,有些来自标准库(例如,s 代表 秒,s 代表 std::string)。关于支持二进制字面量的讨论,Peter Sommerlad(HSR)提出了我认为的“最佳滥用规则”奖的候选方案:适当地定义 operator""_01(long int),于是 101010_01 就成了个二进制字面量!当惊讶和笑声平息下来后,委员会决定在语言本身里定义二进制字面量并使用 0b 作为前缀,表示“binary”(例如 0b101010),类似于使用 0x 表示“hexadecimal”(例如 0xDEADBEEF)。

4.2.9 原始字符串字面量

这是一个罕见的简单特性,它的唯一目的是为容易出错的写法提供一种替代方法。和 C 一样,C++ 使用反斜杠作为转义字符。这意味着要在字符串字面量中表示反斜杠,你需要使用双反斜杠(\\),当你想在字符串中使用双引号时,你需要使用 \"。然而,通常的正则表达式模式广泛使用反斜杠和双引号,所以模式很快变得混乱和容易出错。考虑一个简单的例子(美国邮政编码):

regex pattern1 {"\\w{2}\\s*\\d{5}(-\\d{4})?"}; // 普通字符串字面量

regex pattern2 {R"(\w{2}\s*\d{5}(-\d{4})?)"};  // 原始字符串字面量

这两种模式是相同的。原始字符串字面量 R"(…)" 的括号可以精调以容纳更复杂的模式,但是当你使用正则表达式(§4.6)时,最简单的版本就足够了,而且非常方便。当然,提供原始字符串字面量是一个小细节,但是(类似于数字分隔符(§5.1))深受需要大量使用字面量的人们的喜爱。

原始字符串字面量是 Beman Dawes 在 2006 年 [Dawes 2006] 基于使用 Boost.Regex [Maddock 2002] 的经验而提出来的。

4.2.10 属性

在程序中,属性提供了一种将本质上任意的信息与程序中的实体相关联的方法。例如:

[[noreturn]] void forever()
{
    for (;;) {
        do_work();
        wait(10s);
    }
}

属性 [[noreturn]] 通知编译器或其他工具 forever() 永远不会返回,这样它就可以抑制关于缺少返回的警告。属性用 [[…]] 括起来。

属性最早是在 2007 年由库工作组的负责人 Alisdair Meredith [Meredith 2007] 提出来的,目的是消除专有属性写法(例如 __declspec__attribute__)之间的不兼容性,这种不兼容性会使库实现更加复杂。对此,Jens Maurer 和 Michael Wong 对问题进行了分析,并提出了 [[…]] 语法,方案是基于 Michael 为 IBM 的 XL 编译器所做的实现 [Maurer and Wong 2007]。除了对大量不可移植的实践进行标准化之外,这还将允许用更少的关键字来完成语言扩展,而新的关键字总是有争议的。

该提案提到了可能的使用:覆盖虚函数的明确语法,动态库,用户控制的垃圾收集,线程本地存储,控制对齐,标识“简旧数据”(POD)类,default 和 delete 的函数,强类型枚举,强类型 typedef,无副作用的纯函数,final 覆盖,密封类,对并发性的细粒度控制,运行期反射支持,及轻量级契约编程主持。在早期的讨论中还提到了更多。

“属性”当然是一个使某些事情变得更简单的特性,但我不确定它是否鼓励了良好的设计,或者它简化的“事情”总是能产生最大的好处。我可以想象属性打开了闸门,放进来一大堆不相关的、不太为人们了解的、次要的特性。任何人都可以为编译器添加一个属性,并游说各处采用它,而不是向 WG21 提出一个特性。许多程序员就是喜欢这些小特性。它不需要引入关键字和修改语法,这可以降低门槛,但也更容易不可避免地导致对特性交互关注度不够,造成重叠而不兼容的类似特性出现在不同的编译器中。这种情况在私有扩展中已经发生过了,但我认为私有扩展是不可避免的、局部的,而且往往是暂时的。

为了限制潜在的损害,我们决定属性应该意味着不改变程序的语义。也就是说,忽略属性,编译器不会有任何危害。多年来,这条“规则”几乎奏效。大多数标准属性——尽管不是全部——没有语义效果,即使它们有助于优化和错误检测。

最后,大多数最初那些建议的对属性的使用都通过普通的语法和语言规则来解决。

C++11 增加了标准属性 [[noreturn]][[carries_dependency]]

C++17 增加了 [[fallthrough]][[nodiscard]][[maybe_unused]]

C++20 增加了 [[likely]][[unlikely]][[deprecated(message)]][[no_unique_address]][[using: …]]

我仍然看到属性扩散是一个潜在的风险,但到目前为止,水闸还没有打开。C++ 标准库大量使用了属性;[[nodiscard]] 属性尤其受欢迎,特别用来防止由于没有使用本身是资源句柄的返回值而造成的潜在资源泄漏。

属性语法被用于(失败的)C++20 契约设计(§9.6.1)。

4.2.11 垃圾收集

从 C++ 的早期开始,人们就考虑可选的垃圾收集(对于“可选”有各种定义)[Stroustrup 1993, 2007]。经过一番争论,C++11 为 Mike Spertus 和 Hans-J. Boehm 设计的保守垃圾收集器提供了一个接口 [Boehm and Spertus 2005; Boehm et al. 2008]。然而,很少有人留意到这一点,更少有人使用了垃圾收集(尽管有好的收集器可用)。设计的方法是 [Boehm et al. 2008]:

同时支持垃圾收集实现和基于可达性的泄漏检测器。这是通过把“隐藏指针”的程序定为未定义行为来实现的;举例来说,将指针与另一个值进行异或运算,然后将它转换回普通指针并对其进行解引用就是一种隐藏行为。

这项工作造福了 C++ 语义的精确规范,并且 C++ 中也存在一些对垃圾收集的使用(例如,在 Macaulay2 中 [Eisenbud et al. 2001; Macaulay2 2005–2020])。然而,垃圾收集器不处理非内存资源,而 C++ 社区通常选择使用资源管理指针(§4.2.4)和 RAII(§2.2.1)二者的组合。

4.3 C++11:改进对泛型编程的支持

泛型编程(及其产物模板元编程(§10.5.2))在 C++ 98 中迅速轻松地获得了成功。它的使用对语言造成了严重的压力,而不充分的语言支持导致了巴洛克式矫揉造作的编程技巧和可怕的错误消息。这证明了泛型编程和元编程的实用性,许多明智的程序员为了获得其好处而甘愿承受其痛苦。这些好处是

  • 超越以 C 风格或面向对象风格所可能获得的灵活性
  • 更清晰的代码
  • 更细的静态类型检查粒度
  • 效率(主要来自内联、让编译器同时查看多处的源代码,以及更好的类型检查)

C++11 中支持泛型编程的主要新特性有:

在 C++11 中,概念本应是改进支持泛型编程的核心,但这并没有发生(§6.2.6)。我们不得不等到 C++20(§6.4)。

4.3.1 lambda 表达式

BCPL 允许将代码块作为表达式,但是为了节省编译器中的空间,Dennis Ritchie 没有在 C 中采用这个特性。我在这点上遵循了 C 的做法,但是添加了 inline 函数,从而(重新)得到在没有函数调用的开销下执行代码的能力。不过,这仍然不能提供以下能力

  • 把代码写在需要它的那个准确位置上(通常作为函数参数)。
  • 从代码内部访问代码的上下文。

在 C++98 的开发过程中,曾有人提议使用局部函数来解决第二点,但被投票否决了,因为这可能成为缺陷的来源。

C++ 不允许在函数内部定义函数,而是依赖于在类内部定义的函数。这使得函数的上下文可以表示为类成员,因而函数对象变得非常流行。函数对象只是一个带有调用运算符(operator()())的类。这曾是一种非常高效和有效的技术,我(和其他人)认为有名字的对象比未命名的操作更清晰。然而,只有当我们可以在某样东西使用的上下文之外给它一个合理的名称,特别是如果它会被使用多次时,这种清晰度上的优势才会表现出来。

2002 年,Jaakko Järvi 和 Gary Powell 编写了 Boost.Lambda 库 [Järvi and Powell 2002] 这让我们可以写出这样的东西

find_if(v.begin(), v.end(), _1<i);  // 查找值小于 i 的元素

这里,_1 是代码片段 _1<i 的某个第一个实参的名称,而 i 是表达式所在作用域(enclosing scope)中的一个变量。_1<i 展开为一个函数对象,其中 i 被绑定到一个引用,_1 成为 operator()() 的实参:

struct Less_than {
    int& i;
    Less_than(int& ii) :i(ii) {}  // 绑定到 i
    bool operator()(int x) { return x<i; }  // 跟参数比较
}

lambda 表达式库是早期模板元编程的典范(§10.5.2),非常方便和流行。不幸的是,它的效率并不特别高。多年来,我追踪了它相对于手工编码的同等实现的性能,发现它的开销是后者的 2.5 倍且这种差距相当一致。我不能推荐一种方便但却很慢的东西。这样做会损害 C++ 作为产生高效代码的语言的声誉。显然,这种慢在一定程度上是由于优化不当造成的,但出于这个和其他原因,我们有一群人在 Jaakko Järvi 领导下决定将 lambda 表达式作为一种语言特性 [Willcock et al. 2006] 来提出。举例来说:

template<typename Oper>
void g(Oper op)
{
    int xx = op(7);
    // ...
}

void f()
{
    int y = 3;
    g(<>(int x) -> int {return x + y;});  // 以 lambda 表达式作为参数调用 g()
}

这里,xx 会变成 3+7

<> 是 lambda 表达式引导器。我们不敢提出一个新的关键词。

这一提议引起了相当多的兴奋和许多热烈的讨论:

  • 语法应该是富有表现力的还是简洁的?
  • lambda 表达式可以从哪个作用域引用什么名字?[Crowl 2009]。
  • 从 lambda 表达式生成的函数对象应该是可变的吗?默认情况下不是。
  • lambda 表达式能是多态的吗?到 C++14 才可以(§5.4)。
  • lambda 表达式的类型是什么?独有的类型,除非它基本上是一个局部函数。
  • lambda 表达式可以有名字吗?不可以。如果你需要一个名字,就把它赋给一个变量。
  • 名称是由值绑定还是由引用绑定?你来选择。
  • 变量可以移动到 lambda 表达式中(相对于复制)吗?到 C++14 才可以(§5)。
  • 语法是否会与各种非标准扩展发生冲突?(不严重)。

到 2009 年 lambda 表达式被批准时,语法已经发生了变化,变得更加合乎惯例 [Vandevoorde 2009]:

void abssort(float* x, unsigned N)
{
    std::sort(x, x+N,
        [](float a, float b) { return std::abs(a) < std::abs(b); }
             );
}

<> 切换到 [] 是由 Herb Sutter 建议并由 Jonathan Caves 实现的。这种变化在一定程度上是由于需要一种简单的方法来指定 lambda 表达式可以使用周围作用域中的哪些名称。Herb Sutter 回忆道:

我的并行算法项目需要 lambda 表达式,这是我的动机……看到 EWG 所采用的 lambda 表达式那实在丑到爆的用法,以及从语法一致性/干净性的角度来看极为糟糕的设计(例如,捕获出现在两个分开的位置,语法元素使用不一致,顺序错误——因为“构造函数”元素应该先出现然后才是调用“运算符”元素,以及其他一些小问题)。

默认情况下,lambda 表达式不能引用在本地环境的名字,所以它们只是普通的函数。然而,我们可以指定 lambda 表达式应该从它的环境中“捕获”一些或所有的变量。回调是 lambda 表达式的一个常见用例,因为操作通常只需要写一次,并且操作会需要安装该回调的代码上下文中的一些信息。考虑:

void test()
{
    string s;
    // ... 为 s 计算一个合适的值 ...
    w.foo_callback([&s](int i){ do_foo(i,s); });
    w.bar_callback([=s](double d){ return do_bar(d,s); });
}

[&s] 表示 do_foo(i,s) 可以使用 ss 通过引用来传递(“捕获”)。[=s] 表示 do_bar(d,s) 可以使用 ss 是通过值传递的。如果回调函数在与 test 相同的线程上被调用,[&s] 捕获可能效率更高,因为 s 没有被复制。如果回调函数在不同的线程上被调用,[&s] 捕获可能是一个灾难,因为 s 在被使用之前可能会超出作用域;这种情况下,我们想要一份副本。一个 [=] 捕获列表意味着“将所有局部变量复制到 lambda 表达式中”。而一个 [&] 捕获列表意味着“lambda 表达式可以通过引用指代所有局部变量”,并意味着 lambda 表达式可以简单地实现为一个局部函数。事实证明,捕获机制的灵活性非常有价值。捕获机制允许控制可以从 lambda 表达式引用哪些名称,以及如何引用。这是对 1990 年代人们担心局部函数容易出错的一种回答。

lambda 表达式的实现基本上是编译器构建一个合适的函数对象并传递它。捕获的局部变量成为由构造函数初始化的成员,lambda 表达式的代码成为函数对象的调用运算符。例如,bar_callback 变成:

struct __XYZ {
    string s;
    __XYZ(const string& ss) : s{ss} {}
    int operator()(double d) { return do_bar(d,s); }
};

lambda 表达式的返回类型可以从它的返回语句推导出来。如果没有 return 语句,lambda 表达式就不会返回任何东西。

我把 lambda 表达式归类为对泛型编程的支持,因为最常见的用途之一——也是主要的动机——是用作 STL 算法的参数:

// 按降序排序:
sort(v.begin(),v.end(),[](int x, int y) { return x>y; });

因此,lambda 表达式显著地增加了泛型编程的吸引力。

在 C++11 之后,C++14 添加了泛型 lambda 表达式(§5.4)和移动捕获(§5)。

4.3.2 变参模板

2004 年,Douglas Gregor、Jaakko Järvi 和 Gary Powell(当时都在印第安纳大学)提出了变参模板 [Gregor et al. 2004] 的特性,用来:

直接解决两个问题:

  • 不能实例化包含任意长度参数列表的类模板和函数模板。
  • 不能以类型安全的方式传递任意个参数给某个函数

这些都是重要目标,但我起初发现其解决方案过于复杂,写法太过晦涩,按我的品味其编程风格又太递归。不过在 Douglas Gregor 于 2004 年做的精彩演示之后,我改变了主意并全力支持这项提案,帮助它在委员会顺利通过。我被说服的部分原因是变参模板和当时的变通方案在编译时间上的对比测量。编译时间过长的问题随模板元编程的大量使用(§10.5.2)变得越来越严重,对此变参模板是一项重大(有时是 20 倍)改进。可惜,变参模板越变越流行,也成了 C++ 标准库中必需的部分,以至编译时间的问题又出现了。不过,成功的惩罚(在当时)还是在遥远的将来。

变参模板的基本思路是,递归构造一个参数包,然后在另一个递归过程来使用它。递归技巧是必须的,因为参数包中的每个元素都有它自己的类型(和大小)。

考虑 printf 的一种实现,能够处理可由标准库 iostream 的输出运算符 << 输出的每种类型 [Gregor 2006]:

为了创建类型安全的 printf(),我们采用以下策略:写出字符串直至碰到第一个格式说明符,按格式打印相应的值,然后递归调用 printf() 来打印字符串剩下部分和其余各值。

template<typename T, typename... Args>
void printf(const char* s, const T& value, const Args&... args)
{
    while (*s) {
        if (*s == '%' && *++s != '%') { // 忽略 % 后的字符:
                                        // 我们已经知道要打印的类型了!
            std::cout << value;
            return printf(++s, args...);
        }
        std::cout << *s++;
    }
    throw std::runtime_error("extra arguments provided to printf");
}

这里 <typename T, typename... Args> 指定了一个传统的列表,有头(T)和尾(Args)。每次调用会处理头,然后以尾为参数来调用自身。普通字符会被简单打印,而格式符 % 则表示某个参数要被打印了。Doug(当时他住在印第安纳州)提供了一个测试例子:

const char* msg = "The value of %s is about %g (unless you live in %s).\n";
printf(msg, std::string("pi"), 3.14159, "Indiana");

结果会打印


The value of pi is about 3.14159 (unless you live in Indiana).

这个实现的好处之一是,和标准的 printf 不同,用户定义的类型也和内建类型一样会得到正确处理。通过使用 << 也避免了类型指示符和参数类型之间的不匹配,比如 printf("%g %c","Hello",7.2)

这个 printf 所展示的技巧是 C++20 format§9.3.7)的基础之一。

变参模板的缺点是容易导致代码膨胀,因为 N 个参数意味着模板的 N 次实例化。

4.3.3 别名

C 定义类型别名的机制是靠 typedef。例如:

typedef double (*pf)(int);   // pf 是一个函数指针,该函数接受一个 int
                             // 返回一个 double

这是有点诘屈聱牙,但是类型别名在 C 和 C++ 代码中非常有用,使用非常普遍。从最初有 C++ 模板的时候,人们就一直考虑是否可以有 typedef 模板;如果可以,它们应该是什么样子。2002 年时,Herb Sutter 提出一个方案 [Sutter 2002]:

template<typename A, typename B> class X { /* ... */ };
template<typename T> typedef X<T,int> Xi;  // 定义别名
Xi<double> Ddi;                            // 相当于 X<double, int>

在此基础之上,又经历了冗长的邮件列表讨论,Gabriel Dos Reis(当时在法国国立计算机及自动化研究院)和 Matt Marcus(Adobe)解决了特化相关的若干棘手问题,并引入 David Vandevoorde 称之为别名模板的简化语法 [Dos Reis and Marcus 2003]。例如:

template<typename T, typename A> class MyVector { /* ... */};
template<typename T> using Vec = MyVector<T, MyAlloc<T> >;

其中的 using 语法,即要引入的名字总是出现在前面,则是我的建议。

我和 Gabriel Dos Reis 一道把这个特性推广成一个(几乎)完整的别名机制,并最终得到接受 [Stroustrup and Dos Reis 2003c]。即便不涉及模板,它也给了人们一种写法上的选择:

typedef double (*analysis_fp)(const vector<Student_info>&);

using analysis_fp = double (*)(const vector<Student_info>&);

类型和模板别名是某些最有效的零开销抽象及模块化技巧的关键。别名让用户能够使用一套标准的名字而同时让各种实现使用各自(不同)的实现技巧和名字。这样就可以在拥有零开销抽象的同时保持方便的用户接口。考虑某通讯库(利用了 Concepts TS [Sutton 2017] 和 C++20 的写法简化)中的一个实例:

template<InputTransport Transport, MessageDecoder MessageAdapter>
class InputChannel {
public:
    using InputMessage = MessageAdapter::InputMessage<Transport::InputBuffer>;
    using MessageCallback = function<void(InputMessage&&)>;
    using ErrorCallback = function<void(const error_code&)>;
    // ...
};

概念和别名对于规模化地管理这样的组合极有价值。

InputChannel 的用户接口主要由三个别名组成,InputMessageMessageCallbackErrorCallback,它们由模板的参数初始化而来。

InputChannel 需要初始化它的传输层,该传输层由一个 Transport 对象表示。然而,InputChannel 不应该知道传输层的实现细节,所以它不应直接初始化它的 Transport 成员。变参模板(§4.3.2)就派上了用场:

template<InputTransport Transport, MesssageDecoder MessageAdapter>
class InputChannel {
public:
    template<typename... TransportArgs>
        InputChannel(TransportArgs&&... transportArgs)
            : _transport {forward<TransportArgs>(transportArgs)... }
        {}
    // ...
    Transport _transport;
}

如果没有变参模板,就得定义出一个通用接口来初始化传输层,或者得把传输层暴露给用户。

这个漂亮的例子展示了如何把 C++11 的特性(加上概念)组合起来以优雅的零开销方案解决一个困难问题。

4.3.4 tuple

C++98 有个 pair<T,U> 模板;它主要用来返回成对的值,比如两个迭代器或者一个指针加上一个成功标志。2002 年时,Jaakko Järvi 在参考 Haskell、ML、Python 和 Eiffel 后,提议把这个思路进一步推广,变成 tuple(元组)[Järvi 2002]:

元组是大小固定而成员类型可以不同的容器。作为一种通用的辅助工具,它们增加了语言的表现力。举几个元组类型一般用法的例子:

  • 作为返回类型,用于需要超过一个返回类型的函数
  • 编组相关的类型或对象(如参数列表中的各条目)成为单个条目
  • 同时赋多个值

对于特定的设计意图,定义一个类,并在里面对成员进行合理命名、清晰表述成员间的语义关系,通常会是最好的做法。Alisdair Meredith 在委员会内力陈以上观点,劝阻在接口中过度使用未命名的类型。然而,当撰写泛型代码时,把多个值打包到一个元组中作为一个实体进行处理往往能简化实现。元组对于不值得命名、不值得设计类的一些中间情况特别有用。

比如,考虑一个只需返回三个值的矩阵分解:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return make_tuple(U,S,V);
};

void use()
{
    Matrix A, U, V;
    Vector S;
    // ...
    tie(U,S,V) = SVD(A); // 使用元组形式
}

在这里,make_tuple() 是标准库函数,可以从参数中推导元素类型来构造 tupletie() 是标准库函数,可以把 tuple 的成员赋给有名字的变量。

使用 C++17 的结构化绑定(§8.2),上面例子可简化为:

auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

void use()
{
    Matrix A;
    // ...
    auto [U,S,V] = SVD(A); // 使用元组形式和结构化绑定
}

进一步的写法简化被提议加入 C++20 [Spertus 2018],但没来得及成功通过:

tuple SVD(const Matrix& A) // 从返回语句中推导出元组模板参数
{
    Matrix U, V;
    Vector S;
    // ...
    return {U,S,V};
};

为什么 tuple 不是语言特性?我不记得当时有人这么问过,尽管一定有人想到过这一点。长期以来(自 1979 年),我们的策略就是,如果能合理地将新特性以库的形式加入 C++,就不要以语言特性加入;如果不能,就要改进抽象机制使其成为可能。这一策略有显而易见的优势:

  • 通常对一个库做试验比对一个语言特性做试验更容易,这样我们就更快地得到更好的反馈。
  • 库可以早在所有编译器升级到支持新特性之前就得到严肃使用。
  • 抽象机制(类,模板等)上的改进,能在眼前问题之外提供帮助。

tuple 以 Boost.Tuple 为基础构建,其实现之巧妙也足以让众人引以为傲。在这一特性上,并没有出现运行期效率方面的理由,使我们去偏向一个语言实现而不是库实现。这让人颇为敬佩。

参数包就是一个拥有编译器支持接口的元组的例子(§4.3.2)。

元组大量用于 C++ 和其他语言(例如 Python)交互的程序库里。

4.4 C++11:提高静态类型安全

依赖静态类型安全有两大好处:

  • 明确意图
    • 帮助程序员直接表达想法
    • 帮助编译器捕获更多错误
  • 帮助编译器生成更好的代码。

第二点是第一点的结果。受 Simula 的启发,我对 C++ 的目标是要提供一个灵活可扩展的静态类型系统。目的不仅是类型安全,还要能够直接表达细粒度的区别,例如物理单位检查(§4.2.7)。一段只用了内建类型如整型和浮点型写成的程序,也算是类型安全但却没有由此带来特别的安全优势。那样的代码没有直接表达应用中的概念。特别需要指出,int 或者 string 几乎可以表达任何东西,所以传递这样的值就完全没有给出被传递值的任何语义信息。

C++11 中与类型安全直接相关的改进有:

  • 对于线程和锁的类型安全接口——避免 POSIX 和 Windows 在并发代码中对 void** 及宏的依赖(§4.1.2
  • 范围 for——避免错误地指定范围(§4.2.2
  • 移动语义——解决指针的过度使用问题(§4.2.3
  • 资源管理指针(unique_ptrshared_ptr§4.2.4))
  • 统一初始化——让初始化更通用,更一致,更安全(§4.2.5
  • constexpr——消除多处(无类型和无作用域的)宏的使用(§4.2.7
  • 用户定义的字面量——让用户定义类型更像内建类型(§4.2.8
  • enum class——消除一些涉及整型常量的弱类型做法
  • std::array——避免内建数组不安全地“退化”成指针

委员会一直收到建议,应当通过禁止不安全特性(例如,废弃像内建数组和类型转换这样的 C 风格特性)来改善类型安全。然而,移除特性(“取缔”它们)的尝试一再失败,因为用户无视移除的警告并坚持要求实现的提供者继续支持这些特性。一个更可行的方式似乎是给用户提供使用指南和实施指南的手段,同时保持标准本身继续和先前的版本兼容(§10.6)。

4.5 C++11:支持对库的开发

设计 C++ 基础库,往往要在性能和易用性方面同 C++ 及其他语言的内置功能进行竞争。这时,查找规则、重载决策、访问控制、模板实例化规则等特性之中的微妙之处会组合起来,产生强大的表达能力,但同时也暴露出可怕的复杂性。

4.5.1 实现技巧

有些实现技巧实属“黑魔法”,不应当暴露给非专家。大部分程序员可以愉快地编写多年好的 C++ 代码,而不用了解这些复杂手段和神秘技巧。遗憾的是,初学者们一拥而上去研究这些最可怕的特殊代码,并从给别人(经常是错误地)解释它们的过程中得到巨大的自豪感。博主和演讲者们通过显摆令人提心吊胆的例子抬高他们的名望。这是 C++ 语言复杂性名声的一个主要来源。在其他语言中,要么不提供这样的优化机会,要么手段被藏在了优化器内部。

我不能在此深入细节,就只提一个技巧,它在 C++11 的发展中作为关键技巧出现,并在基于模板的库(包括 C++ 标准库)中广为使用。它以奇怪的缩写为人所知:SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)。

你如何表达一个当且仅当某个谓词为真时才有的操作?概念为 C++20 提供了这样的支持(GCC 自 2015 年开始支持),但在 21 世纪早期,人们不得不依赖于晦涩的语言规则。例如:

template<typename T, typename U>
struct pair {
    T first;
    U second;
    // ...
    enable_if<is_copy_assignable<T>::value
              && is_copy_assignable<U>::value,pair&>::type
        operator=(const pair&);
    //...
};

这样,当且仅当 pair 的两个成员都有拷贝赋值操作时 pair 才有拷贝赋值操作。这超乎寻常的丑陋,但它对于定义和实现基础库也超乎寻常的有用——在概念还没有出现时。

要点在于,如果成员都有拷贝赋值,enable_if<…,pair&>::type 会成为一个普通的 pair&,否则它的实例化就会失败(因为 enable_if 没有为赋值提供一个返回类型)。这里 SFINAE 就起作用了:替换失败不是错误;失败的结果就如同整条声明不曾出现一样。

这里的 is_copy_assignable 是一个 type trait(类型特征),C++11 提供了数十个这样的特征以便程序员在编译期询问类型的属性。

enable_if 元函数由 Boost 开创并成为 C++11 的一部分。一个大致合理的实现:

template<bool B, typename T = void>
struct enable_if {}; // false 的情况:里面没有 type

template<typename T>
struct enable_if<true, T> { typedef T type; }; // type 是 T

SFINAE 的精确规则非常微妙而难以驾驭,但是在用户的不断压力下,它们在 C++11 的发展过程中变得越来越简单和通用。SFINAE 的一个附带收获是,它从内部显著改善了编译器,因为编译器必须能够从失败的模板实例化中进行无副作用的回退。这就大大阻止了编译器对非本地状态的使用。

4.5.2 元编程支持

二十一世纪的头十年对于 C++ 元编程来说有点像是无法无天的美国西部拓荒时代,新的技巧和应用在仅有基本模板机制支持的情况下被不断尝试。那些基本机制被反复使用到令人痛苦。错误信息可谓糟糕透顶,编译时间经常奇慢无比,编译器资源(如内存、递归深度和标识符长度)会轻易耗尽。同时,人们纷纷重新发现同样的问题,并重新发明一些基本技巧。显然,我们需要更好的支持。改进尝试采用了两条(至少理论上)互补的路径:

  • 语言:概念(§6),编译期函数(§4.2.7),lambda 表达式(§4.3.1),模板别名(§4.3.3),以及更精确的模板实例化规范(§4.5.1)。
  • 标准库tuple§4.3.4),类型特征(§4.5.1),以及 enable_if§4.5.1)。

遗憾的是,概念在 C++11(§6.2)中失败了,这给(通常复杂得可怕而且容易出错的)权宜之计留下了生存空间,典型情况会涉及类型特征和 enable_if§4.5.1)。

4.5.3 noexcept 规约

起初的异常设计没有办法表明某个异常可能会从某函数中抛出。我仍然认为那才是正确的设计。为了让异常为 C++98 接纳,我们不得不加入异常规约,来列举一个函数会抛出那些异常 [Stroustrup 1993]。使用异常规约可选,并会在运行期进行检查。正如我担心的那样,这带来了维护的问题,在展开路径上对异常反复检查增加的运行期开销,还有源代码膨胀。在 C++11 中,异常规约被废弃 [Gregor 2010],而到了 C++17,我们终于(一致同意)移除了异常规约这个特性。

一直有人希望能够在编译时检查函数会抛出什么异常。从类型理论的角度,在小规模程序中,在有高速编译器和对代码完全控制的情况下,那当然行得通。委员会一再拒绝这种想法,原因是它不能扩展到由数十(或更多)组织维护的百万行代码规模的程序上 [Stroustrup 1994]。参见(§7.4)。

没有异常规约,库实现者们就要面对一个性能问题:在许多重要场合,一个库实现者需要知道一个拷贝操作是否会抛异常。如果会,就必须拿到一份拷贝以避免留下一个无效对象(这样会违犯异常保证 [Stroustrup 1993])。如果不会,我们可以直接写入到目标中。在这种场合,性能的差别可以非常显著,而最简单的异常规约 throw(),什么也不抛出,在此可以帮助判断。于是,在异常规约被弃之不用并最终从标准中移除的时候,我们基于 David Abrahams 和 Doug Gregor 的提案 [Abrahams et al. 2010; Gregor 2010; Gregor and Abrahams 2009] 引入了 noexcept 概念。

一个 noexcept 函数仍会被动态检查。例如:

void do_something(int n) noexcept
{
    vector<int> v(n);
    // ...
}

如果 do_something() 抛异常,程序会被终止。这样操作恰好非常接近零开销,因为它简单地短路了通常的异常传播机制。参见(§7.3)。

还有一个条件版本的 noexcept,用它可以写出这样的模板,其实现依赖于某参数是否会抛异常。这是最初促成 noexcept 的用例。例如,下面代码中,当且仅当 pair 的两个元素都有不抛异常的移动构造函数时,pair 的移动构造函数才会声明不抛异常:

template<typename First, typename Second>
class pair {
    // ...
    template <typename First2, typename Second2>
    pair(pair<First2, Second2>&& rhs)
        noexcept(is_nothrow_constructible<First, First2&&>::value
              && is_nothrow_constructible<Second, Second2&&>::value)
    : first(move(rhs.first)),
      second(move(rhs.second))
    {}
    // ...
};

其中的 is_nothrow_constructible<> 是 C++11 标准库的类型特征(type traits)之一(§4.5.1)。

在这相对底层和非常通用的层级写出最优代码可不简单。在基础层面上,懂得到底该按位拷贝,该移动,还是该按成员拷贝,会带来非常大的区别。

4.6 C++11:标准库组件

C++ 跟其他现代语言比一直有个小巧的标准库。此外,大多标准库组件都很基础,而不是试图处理应用层面的任务。不过,C++11 增加了几个关键的库组件来支持特定任务:

  • thread——基于线程和锁的并发
  • regex——正则表达式
  • chrono——时间
  • random——随机数产生器和分布

和大量的商业支持程序库相比,这显然小得可怜,但这些组件质量很高,并且跟之前的标准 C++ 相比数量也多多了。

设计这些组件,是要服务于一些特定任务。在这些任务中,它们为程序员提供了重大帮助。遗憾的是,这些库来自不同背景,体现在接口风格上,就出现了差异;除了要灵活和高性能之外它们没有一致的整体设计哲学。C++11 在合入一个组件方面没有明晰的标准(C++98 有一些 [Stroustrup 1994])。更准确地说,我们只是从现有的、已被社区证明成功的组件中接收组件进来。很多组件来自 Boost(§2.3)。

如果你需要使用正则表达式,标准库中新加入的 regex 就是个巨大改进了。类似,加入无序容器(哈希表),如 unordered_map,为很多程序员省去了大量繁琐的工作,使之可以产出更好的程序。然而,这些库组件并没有对人们组织代码的方式产生重大影响,所以我在此不对这些库组件的细节展开讨论。

regex 库主要是 John Maddock 的工作 [Maddock 2002]。

哈希表不巧错过了 C++98 的截止时间,因而出现在了 C++0x 的第一批提案之中 [Austern 2002]。它们被称做无序的(例如 unordered_map),是为了区别于老的、有序的标准容器(例如 map),也是因为较明显的名字(例如 hash_map)已经在 C++11 之前被其他库大量使用了。另外,unordered_map 也可以说是个更好的名字,因为它指出了类型提供什么,而不是它是如何实现的。

random 库提供了分布函数和随机数产生器,其复杂性被誉为“每个随机数库都想长成的样子”。但它对初学者或者一般用户(常需要随机数)并不易用。它在 2002 年由 Jens Maurer [Maurer 2002] 提出,并在 2006 年经由费米国家实验室的一群人修订 [Brown et al. 2006],随即被接受。

相比之下,Howard Hinnant 的 chrono 库 [Hinnant et al. 2008] 处理时间点和时间间隔,在提供复杂功能的同时仍保持了易用性。例如:

using namespace std::chrono;  // 在子命名空间 std::chrono
auto t0 = system_clock::now();
do_work();
auto t1 = system_clock::now();
cout << duration_cast<milliseconds>(t1-t0).count() << "msec\n";

其中的 duration_cast 把依赖于时钟的“嘀嗒”节拍数转换为程序员选用的时间单位。

使用如此简单的代码,你可以让大一学生都能感受到不同算法和数据结构的代价差异。chronothread 库提供了时间支持(§4.1.2)。

到了 C++20,chrono 得到进一步增强,加入了处理日期和时区的功能(§9.3.6)。C++20 也允许把上面的例子简化为:

cout << t1-t0 << '\n';

这就会把 t0t1 之间的时间差自动以合适的单位进行输出。

5. C++14:完成 C++11

依据大版本和小版本交替发布的计划,C++14 [du Toit 2014] 的目标是“完成 C++11”(§3.2);也就是说,接受 2009 年特性冻结后的好的想法,纠正最初大规模使用 C++11 标准时发现的问题。对这个有限目标而言,C++14 是成功的。

重要的是,它表明 WG21 可以按时交付标准。反过来,这也使得实现者能够按时交付。在 2014 年年底之前,三个主要的 C++ 实现者(Clang、GCC 和微软)提供了大多数人认为完整的特性。尽管并没有完美地符合标准,但人们基本上可以对所有的特性和特性组合进行实验。要能编译“用到所有高级特性”的库,还需要延后一些时间(对微软而言要到 2018 年),但对于大多数用户而言,对标准的符合程度足以满足实际使用。标准工作和实现工作已经紧密联系在一起。这给社区带来了很大的不同。

C++14 特性集可以概括为:

  • 二进制字面量,例如 0b1001000011110011
  • §5.1:数字分隔符——为了可读性,例如 0b1001'0000'1111'0011
  • §5.2:变量模板——参数化的常量和变量
  • §5.3:函数返回类型推导
  • §5.4:泛型 lambda 表达式
  • §5.5constexpr 函数中的局部变量
  • 移动捕获——例如 [p = move(ptr)] {/* ... */}; 将值移入 lambda 表达式
  • 按类型访问元组,例如 x = get<int>(t);
  • 标准库中的用户定义字面量,例如:10i"Hello"s10s3ms55us17ns

这些特性中的大多数都面临着两个问题:“很好,什么使你花了这么长的时间?”以及“谁需要这个?”我的印象是,每个新特性都有着重要的需求作为动机——即使该需求不是通用的。在 constexpr 函数中添加局部变量和泛型 lambda 表达式大大改善了人们的代码。

重要的是,从 C++11 升级到 C++14 是相对无痛的,没有 ABI 破坏。经历过从 C++98 到 C++11 这一大而困难的升级的人感到了惊喜:他们升级可以比预想还快,花费的精力也更少。

5.1 数字分隔符

奇怪的是,数字分隔符引起了最激烈的争论。Lawrence Crowl 反复提出了各种选项的分析 [Crowl 2013]。包括我在内的许多人都主张使用下划线作为分隔符(和好几种其他语言一样)。例如:

auto a = 1_234_567;    // 1234567

不幸的是,人们正在使用下划线作为用户定义字面量后缀的一部分:

auto a = 1_234_567_s;  // 1234567 秒

这可能会引起歧义。例如,最后一个下划线是多余的分隔符还是后缀的开始?令我惊讶的是,这种潜在的歧义使下划线对很多人来说变得难以接受。其中一个原因是,为了免得程序员遇到意想不到的结果,库小组为标准库保留了不以下划线开头的后缀。经过长时间的讨论,包括全体委员会(约 100 人)的辩论,我们一致同意使用单引号:

auto a = 1'234'567;    // 1234567(整数)
auto b = 1'234'567s;   // 1234567 秒

尽管有严厉的警告指出使用单引号会破坏无数的工具,但实际效果似乎不错。单引号由 David Vandevoorde 提出 [Crowl et al. 2013]。他指出,在一些国家,特别是在瑞士的金融写法中,单引号被当作分隔符来使用。

我的另一个建议,使用空白字符,则一直没有得到认同:

int a = 1 234 567;     // 1234567
int b = 1 234 567 s;   // 1234567 秒

许多人认为这个建议是一个与在愚人节发表的老文章 [Stroustrup 1998] 有关的笑话。而实际上,它反映了一个旧规则,即相邻字符串会被连接在一起,因而 "abc" "def" 表示 "abcdef"

5.2 变量模板

2012 年,Gabriel Dos Reis 提议扩展模板机制,在模板类、函数和别名 [Dos Reis 2012] 之外加入模板变量。例如:

template<typename T>
constexpr T pi = T(3.1415926535897932385);

template<typename T>
T circular_area(T r)
{
    return pi<T> * r * r;
}

起初,我觉得这是一种平淡无奇的语言技术上的泛化,没有特别重要的意义。然而,为指定各种精度的常数而采取的变通办法由来已久,而且充斥着令人不安的变通和妥协。经过这种简单的语言泛化,代码可以大幅简化。特别是,变量模板作为定义概念的主要方式应运而生(§6.3.6)。例如:

// 表达式:
template<typename T>
concept SignedIntegral = Signed<T> && Integral<T>;

C++20 标准库提供了一组定义为变量模板的数学常数,最常见的情况是定义为 constexpr [Minkovsky and McFarlane 2019]。例如:

template<typename T> constexpr T pi_v = unspecified;
constexpr double pi = pi_v<double>;

5.3 函数返回类型推导

C++11 引入了从 lambda 表达式的 return 语句来推导其返回类型的特性。C++14 将该特性扩展到了函数:

template<typename T>
auto size(const T& a) { return a.size(); }

这种写法上的便利对于泛型代码中的小函数来说非常重要。但用户必须很小心,此类函数不能提供稳定的接口,因为它的类型现在取决于它的实现,而且在编译到使用这个函数的代码时,函数实现必须是可见的。

5.4 泛型 lambda 表达式

lambda 表达式是函数对象(§4.3.1),因此它们显然可以是模板。有关泛型(多态)lambda 表达式的问题在 C++11 的工作中已经进行了广泛讨论,但当时被认为还没有完全准备好(§4.3.1)。

2012 年,Faisal Vali、Herb Sutter 和 Dave Abrahams 提议了泛型 lambda 表达式 [Vali et al. 2012]。提议的写法只是从语法中省略了类型:

auto get_size = [](& m){ return m.size(); };

委员会中的许多人(包括我)都强烈反对,指出该语法太过特别,且不能推广到受约束的泛型 lambda 表达式中。因此,写法更改为使用 auto 作为标记,指明有类型需要推导:

auto get_size = [](auto& m){ return m.size(); };

这使泛型 lambda 表达式与早在 2002 年就提出的概念提案和泛型函数建议 [Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 保持一致。

这种将 lambda 表达式语法与语言其他部分所用的语法相结合的方向与一些人的努力背道而驰,这些人希望为泛型 lambda 表达式提供一种独特(超简洁)的语法,类似于其他语言 [Vali et al. 2012]:


C# 3.0 (2007):      x => x * x;
Java 1.8 (~2013):   x -> x * x;
D 2.0 (~2009):      (x) { return x * x; };

我认为,使用 auto 而且没有为 lambda 表达式引入特殊的不与函数共享的写法是正确的。此外,我认为在 C++14 中引入泛型 lambda 表达式,而没有引入概念,则是个错误;这样一来,对受约束和不受约束的 lambda 表达式参数和函数参数的规则和写法就没有一起考虑。由此产生的语言技术上的不规则(最终)在 C++20 中得到了补救(§6.4)。但是,我们现在有一代程序员习惯于使用不受约束的泛型 lambda 表达式并为此感到自豪,而克服这一点将花费大量时间。

从这里简短的讨论来看,似乎委员会流程对写法/语法给予了特大号的重视。可能是这样,但是语法并非无足轻重。语法是程序员的用户界面,与语法有关的争论通常反映了语义上的分歧,或者反映了对某一特性的预期用途。写法应反映基础的语义,而语法通常偏向于对某种用法(而非其他用法)有利。例如,一个完全通用和啰嗦的写法有利于希望表达细微差别的专家,而一个为表达简单情况而优化的写法,则有利于新手和普通用户。我通常站在后者这边,并且常常赞成两者同时都提供(§4.2)。

5.5 constexpr 函数中的局部变量

到 2012 年,人们不再害怕 constexpr 函数,并开始要求放松对其实现的限制。实际上有些人希望能够在 constexpr 函数中执行任何操作。但是,无论是使用者还是编译器实现者都还没有为此做好准备。

经过讨论,Richard Smith(谷歌)提出了一套相对适度的放松措施 [Smith 2013]。特别是,允许使用局部变量和 for 循环。例如:

constexpr int min(std::initializer_list<int> xs)
{
  int low = std::numeric_limits<int>::max();
  for (int x : xs)
    if (x < low)
      low = x;
  return low;
}

constexpr int m = min({1,3,2,4});

给定一个常量表达式作为参数,这个 min() 函数可以在编译时进行求值。本地的变量(此处为 lowx)仅在编译器中存在。计算不能对调用者的环境产生副作用。Gabriel Dos Reis 和 Bjarne Stroustrup 在原始的(学术)constexpr 论文中指出了这种可能性 [Dos Reis and Stroustrup 2010]。

这种放松简化了许多 constexpr 函数并使许多 C++ 程序员感到高兴。以前在编译时只能对算法的纯函数表达式进行求值,他们对此感到不满。特别是,他们希望使用循环来避免递归。就更长期来看,这释放出了要在 C++17 和 C++20(§9.3.3)中进一步放松限制的需求。为了说明潜在的编译期求值的能力,我已经指出 constexpr thread 也是可能的,尽管我并不急于对此进行提案。

6. 概念

对 C++ 来说,泛型编程和使用模板的元编程已经取得了巨大的成功。但是,对泛型组件的接口却迟迟未能以一种令人满意的方式进行合适的规范。例如,在 C++98 中,标准库算法大致是如下规定的:

template<typename Forward_iterator, typename Value>
ForwardIterator find(Forward_iterator first, Forward_iterator last,
                              const Value & val)
{
    while (first != last && *first != val)
        ++first;
    return first;
}

C++ 标准规定:

  • 第一个模板参数必须是前向迭代器。
  • 第二个模板参数类型必须能够使用 == 与该迭代器的值类型进行比较。
  • 前两个函数参数必须标示出一个序列。

这些要求是隐含在代码中的:编译器所要做的就是在函数体中使用模板参数。结果是:极大的灵活性,对正确调用生成出色的代码,以及对不正确的调用有糟糕得一塌糊涂的错误信息。解决方案显而易见,将前两项条件作为模板接口的一部分来指定:

template<forward_iterator Iter, typename Value>
    requires equality_comparable<Value, Iter::value_type>
forward_iterator find(Iter first, Iter last, const Value& val);

这大致就是 C++20 所提供的了。注意 equity_comparable 概念,它捕获了两个模板参数之间必需有的关系。这样的多参数概念非常常见。

表达第三个要求([first:last) 是一个序列)需要一个库扩展。C++20 在 Ranges 标准库组件(§9.3.5)中提供了该特性:

template<range R, typename Value>
    requires equality_comparable<Value, Range::value_type>
forward_iterator find(R r, const Value& val)
{
    auto first = begin(r);
    auto last = end(r);
    while (first!=last && *first!=val)
        ++first;
    return first;
}

为了规范模板对其参数的要求,对其提供良好支持,有过数次尝试。本节会进行描述:

  • §6.1:概念的早期历史
  • §6.2:C++0x 中的概念
  • §6.3:Concepts TS
  • §6.4:C++20 中的概念

6.1 概念的早期历史

1980 年,我猜想泛型编程可以通过 C 风格的宏来有效支持 [Stroustrup 1982]。然而我完全错了。一些有用的简单泛型抽象能通过这种方法表达,1980 年代的标准化之前的 C++ 通过 <generic.h> 中的一组宏为泛型编程提供支持,但宏在大型项目或广泛使用的情况下无法有效管理。尽管泛型编程在当时流行的“面向对象的思想”中并没有一席之地,我确实发现了一个问题,需要解决它才能达到我对“带类的 C”的目标。

大约在 1987 年,我尝试设计具有合适接口的模板 [Stroustrup 1994],但失败了。我需要三个基本属性来支持泛型编程:

  • 全面的通用性/表现力——我明确不希望这些功能只能表达我想到的东西。
  • 与手工编码相比,零额外开销——例如,我想构建一个能够与 C 语言的数组在时间和空间性能方面相当的 vector。
  • 规范化的接口——我希望类型检查和重载的功能与已有的非泛型的代码相类似。

那时候没人知道如何做到全部三个方面,因此 C++ 所做到的是:

  • 图灵完备性 [Veldhuizen 2003]
  • 优于手动编码的性能
  • 糟糕的接口(基本上是编译期鸭子类型),但仍然做到了静态类型安全

前两个属性使模板大获成功。

由于缺乏规范化的接口,我们在这些年里看到了极其糟糕的错误信息,到了 C++17 还仍然是这样。缺乏规范化的接口这一问题,让我和很多其他人困扰很多年。它让我非常困扰的原因是,模板无法满足 C++ 的根本的设计标准 [Stroustrup 1994]。我们(显然)需要一种简单的、没有运行期开销的方法来指定模板对其模板参数的要求。

多年以来,一些人(包括我)相信模板参数的要求可以在 C++ 本身中充分指定。1994 年,我在 [Stroustrup 1994] 中记录了基本的想法,并在我的网站上发布了示例 [Stroustrup 2004–2020]。自 2006 年以来,基于 Jeremy Siek 的作品,Boost 提供了该想法的一个变体,Boost 概念检查库 [Siek and Lumsdaine 2000–2007]。不知何故,它并未像我所希望的那样广泛流行。我怀疑原因是它不够通用、不够优雅(Boost 感到有义务使用宏隐藏细节),并且在标准中不受支持。许多人将其视为一种奇技淫巧。

为 C++ 定义的概念可以追溯到 Alex Stepanov 在泛型编程上的工作,这是 1970 年代末开始的,一开始用的名称是“代数结构” [Kapur et al. 1981]。注意,那差不多比 Haskell 的类型类设计 [Wadler and Blott 1989] 要早十年,比我尝试解决 C++ 的类似问题要早 5 年。对于这种需求,Alex Stepanov 早在 1990 年代末期的讲座中就使用了“概念”这一名称,并记录在 [Dehnert and Stepanov 2000]。我之所以提到这些,是因为许多人猜测概念是从 Haskell 类型类派生而来但被错误命名了。Alex 使用“概念”这一名称是因为概念此处用来代表应用领域(如代数)中的基本概念。

目前把概念当作依靠使用模式来描述操作的类型谓词,这起源于二十一世纪初期 Bjarne Stroustrup 和 Gabriel Dos Reis 的工作,并记录在 [Dos Reis and Stroustrup 2005b, 2006; Stroustrup and Dos Reis 2003b, 2005a] 之中。这种方法在 1994 年的《设计和演化》[Stroustrup 1994] 一书也被提及,但是我不记得我第一次进行尝试的时间了。将概念建立于使用模式的主要原因是为了以一种简单而通用的方式处理隐式转换和重载。我们了解 Haskell 类型类,但它们对当前的 C++ 设计影响不大,因为我们认为它们太不灵活了。

精确指定并检查一个模板对于参数的要求曾经是 C++0x 的最出彩之处,会对泛型编程提供关键支持。可是,它最终甚至没能进入 C++17。

Bjarne Stroustrup 和 Gabriel Dos Reis 在 2003 年发表的论文 [Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 明确指出,概念是简化泛型编程的宏伟计划的一部分。例如,一个 concept 可以被定义为一组使用模式的约束,就是说,作为对某种类型有效的语言构件 [Stroustrup and Dos Reis 2003b]:

concept Value_type {
    constraints(Value_type a)
    {
        Value_type b = a;      // 拷贝初始化
        a = b;                 // 拷贝赋值
        Value_type v[] = {a};  // 不是引用
    }
};

template<Value_type V>
    void swap(V& a, V& b);  // swap() 的参数必须是值类型

但是,当时的语法和语义还很不成熟。我们主要是试图建立设计标准 [Stroustrup and Dos Reis 2003a]。从现代(2018 年)的角度来看,[Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 有很多缺陷。但是,它们为概念提供了设计约束,并在以下方面提出了建议:

  • 概念——用于指定对模板参数要求的编译期谓词。
  • 根据使用模式来指定原始约束——以处理重载和隐式类型转换。
  • 多参数概念——例如 Mergeable<In1,In2,Out>
  • 类型和值概念——也就是说,概念既可以将值也可以将类型当作参数,例如 Buffer<unsigned char,128>
  • 模板的“类型的类型”简略写法—例如 template<Iterator Iter> …
  • “模板定义的简化写法”——例如 void f(Comparable&); 使泛型编程更接近于“普通编程”。
  • auto 作为函数参数和返回值中约束最少的类型。
  • 统一函数调用(§8.8.3)——减少泛型编程与面向对象编程之间的风格差异问题(例如 x.f(y)f(x,y)x+y)。

奇怪的是,我们没有建议通用的 requires 子句(§6.2.2)。这些都是后面所有概念变体的一部分。

6.2 C++0x 概念

2006 年,基本上每个人都期望 [Gregor et al. 2006; Stroustrup 2007] 中所描述的概念版本会成为 C++09 的一部分,毕竟它已经投票进入了 C++ 标准草案(工作文件)。但是,C++0x 变成了 C++11,并且在 2009 年,概念因复杂性和可用性问题陷入困境 [Stroustrup 2009a,b],委员会以绝对多数票一致同意放弃概念设计 [Becker 2009]。失败的原因多种多样,而且可能使我们获得在 C++ 标准化努力之外的教训。

在 2004 年,有两项独立的工作试图将概念引入 C++。因为主要支持者分别来自印第安纳大学和得克萨斯农工大学,这两派通常就被称为“印第安纳”和“得克萨斯”:

  • 印第安纳:一种与 Haskell 类型类相关的方法,主要依赖于操作表来定义概念。这派认为,程序员应当显式声明一个类型“模拟”了一个概念;也就是说,该类型提供了一组由概念指定的操作 [Gregor et al. 2006]。关键人物是 Andrew Lumsdaine(教授)和 Douglas Gregor(博士后和编译器作者)。
  • 得克萨斯:一种基于编译期类型谓词和谓词逻辑的方法。这派认为,可用性很重要,因而程序员不必显式指定哪些类型与哪些概念相匹配(这些匹配可以由编译器计算)。对于 C++,优雅而有效地处理隐式转换、重载以及混合类型的表达式被认为是必需的 [Dos Reis and Stroustrup 2006; Stroustrup and Dos Reis 2003b]。关键人物是 Bjarne Stroustrup(教授)和 Gabriel Dos Reis(博士后,后来成为教授)。

根据这些描述,这些方法似乎是不可调和的,但是对于当时的参与人员而言,这并不明显。实际上,我认为这些方法在理论上是等效的 [Stroustrup and Dos Reis 2003b]。该论点的确可能是正确的,但对于 C++ 上下文中的详细语言设计和使用的实际影响并不等同。另外,按照委员会成员的解释,WG21 的共识流程强烈鼓励合作和联合提案,而不是在竞争性的提案上工作数年,最后在它们之间进行大决战(§3.2)。我认为后一种方法是创造方言的秘诀,因为失败的一方不太可能放弃他们的实现和用户,并就此消失。请注意,上面提到的所有的人在一起与 Jeremy Siek(印第安纳的研究生和 AT&T 实验室的暑期实习生)和 Jaakko Järvi(印第安那的博士后,得州农工大学教授)是 OOPSLA 论文的合著者,论文展示了折中设计的第一个版本。印第安纳和得克萨斯的团体从未完全脱节,我们为达成真正的共识而努力。另外,从事这项工作之前,我已经认识 Andrew Lumsdaine 很多年。我们确实希望折中方案能够正常工作。

在实现方面,印第安纳的设计的进度远远领先于得克萨斯的设计的进度,并且具有更多人员参与,所以我们主要基于此进行。印第安纳的设计也更加符合常规,基于函数签名,并且与 Haskell 类型类有明显相似之处。考虑到涉及的学术界人士的数量,重要的是印第安纳的设计被视为更符合常规并且学术上更为得体。看来我们“只是”需要

  • 使编译器足够快
  • 生成有效的代码
  • 处理重载和隐式转换。

这个决定使我们付出了三年的辛勤工作和许多争论。

C++0x 概念设计在 [Gregor et al. 2006; Stroustrup 2007] 中得到阐述。前一篇论文包含一个标准的学术“相关工作”部分,将这个设计与 Java、C#、Scala、Cecil、ML、Haskell 和 G 中的工具进行比较。在这里,我使用 [Gregor et al. 2006] 中的例子进行总结。

6.2.1 概念定义

概念被定义为一组操作和相关类型:

concept EqualityComparable<typename T> {
    bool operator==(const T& x, const T& y);
    bool operator!=(const T& x, const T& y) { return !(x==y); }
}

concept InputIterator<typename Iter> {
    // Iter 必须有 value_type 成员:
    typename value_type = Iter::value_type;
    // ...
}

某些人(印第安纳)认为概念和类之间的相似性是一种优势。

但是,概念中指定的函数并不完全类似于类中定义的函数。例如,在一个 class 中定义的运算符具有隐式参数(“this”),而 concept 中声明的运算符则没有。

将概念定义为一组操作的方法中存在一个严重的问题。考虑在 C++ 中传递参数的方式:

void f(X);
void f(X&);
void f(const X&);
void f(X&&);

暂时不考虑 volatile,因为它在泛型代码参数中很少见到,但是我们仍然有四种选择。在一个 concept 中,我们是否

  • f 表示为一个函数,用户是否为调用选择了正确的参数?
  • 是否重载了 f 的所有可能?
  • f 表示为一个函数,并要求用户定义一个 concept_map§6.2.3)映射到 f 的所需的参数类型?
  • 语言是否将用户的参数类型隐式映射到模板的参数类型?

对于两个参数,我们将有 16 种选择。尽管很少有三个参数泛型函数,但是这种情况我们会有 4*4*4 种选择。变参模板会如何呢?我们会有 4N 种选择,如(§4.3.2)。

传递参数的不同方式的语义并不相同,因此我们自然而然地转向接受指定的参数类型,将匹配的负担推到了类型设计者和 concept_maps 的作者(§6.2.3)。

类似地,我们到底是在为 x.f(y)(面向对象样式)指定 concept 还是为 f(x,y)(函数样式),还是两者兼而有之。这个问题在我们尝试描述二元运算符时,例如 +,会立刻出现。

回想起来,我们对于在以特定类型的操作或特定的伪签名定义的概念框架内解决这些问题太过乐观了。“伪签名”某种程度上代表了对此处概述的问题的解决方案。

概念之间的关系通过显式细化定义:

concept BidirectionalIterator<typename Iter>    // BidirectionalIterator 是
    : ForwardIterator<Iter> {                   // 一种 ForwardIterator
        // ...
}

细化有点像,但又不那么像类派生。这个想法是为了让程序员明确地建立概念的层次结构。不幸的是,这给系统引入了严重的不灵活性。概念(按常规的英语含义)通常不是严格的层次结构。

6.2.2 概念使用

一个概念既可以用作 where 子句中的推断,也可以用在简略写法里:

template<typename T>
    where LessThanComparable<T>    // 显式谓词
const T& min(const T& x, const T& y)
{
    return x<y ? x : y;
}

template<GreaterThanComparable T>  // 简略写法
    const T& max(const T& x, const T& y)
{
    return x>y ? x : y;
}

对于简单的“类型的类型”的概念,简略写法(最早在 [Stroustrup 2003] 中提出)很快变得非常流行。但是,我们很快发现,现有代码中的标识符中 where 太过于流行,于是将其重命名为 requires

6.2.3 概念映射

概念和类型之间的关系是由 concept_map 的特化来定义的:

concept_map EqualityComparable<int> {};  // int 满足 EqualityComparable

// student_record 满足 EqualityComparable:
concept_map EqualityComparable<student_record> {
    bool operator==(const student_record& a, const student_record& b)
    {
        return a.id_equal(b);
    }
};

对于 int,我们可以简单地说 int 类型具有 EqualityComparable 所要求的属性(也就是说,它具有 ==!=),然而,student_record 没有 ==,但是我们可以在 concept_map 中添加一个。因此,concept_map 是一种非常强大的机制,可以在特定的环境中非侵入性地往类型中添加属性。

既然编译器已经知道 int 是可比较的,为什么我们还要再告诉编译器?

这一直是一个争论的焦点。“印第安纳小组”一般认为明确表达意图(永远)是好的,而“得克萨斯小组”倾向于认为除非一条概念映射能增加新的功能,写它就不只是没用,更可能有害。显式的声明是否能使用户避免因为语义上无意义的“意外”语法匹配而导致的严重错误?还是说这种错误会很少见,显式的建模语句多半只是增加了编写麻烦和犯错误的机会?折中的解决方案是允许在 concept 的定义处通过加上 auto 来声明使用某条 concept_map 是可选的:

auto concept EqualityComparable<typename T> {
    bool operator==(const T& x, const T& y);
    bool operator!=(const T& x, const T& y) { return !(x==y); }
}

这样,当一个类型被要求是 EqualityComparable 时,即使用户没有提供该类型的特化,编译器也会自动使用指向 EqualityComparableconcept_map

6.2.4 定义检查

编译器根据模板参数的概念检查模板定义中的代码:

template<InputIterator Iter, typename Val>
    requires EqualityComparable<Iter::value_type,Val>
Iter find(Iter first, Iter last, Val v)
{
    while (first<last && !(*first==v))  // 错误:EqualityComparable 中没有 <
        ++first;
    return first;
}

这里我们用到了 < 比较迭代器,但 EqualityComparable 只保证了 ==,因此这个定义不能通过编译。捕获这种无保障操作的使用那时被视为一个重要的好处,但是事实证明这会带来严重的负面影响:(§6.2.5)和(§6.3.1)。

6.2.5 教训

初始提案得到了相对迅速的批准,之后的若干年,我们忙于为初始的设计堵漏,还要应付在通用性、可实现性、规范质量和可用性方面的意见。

作为主要实现者,Doug Gregor 为生成高质量的代码做出了英勇的表现,但最终,支持概念的编译器在速度上仍然比只实现了无约束模板的编译器慢了 10 倍以上。我怀疑实现问题的根源是在编译器中采用类的结构来表示概念。这样可以快速获得早期结果,但却让概念用上了本来为类精心打造的表示方式,但概念并不是类。将概念表示为一组函数(类似于虚成员函数),导致在处理隐式转换和混合类型操作时出问题。将来自不同上下文的代码灵活的加以组合,原本是支撑泛型编程和元编程的强大代码生成技术的“秘诀”,但这种组合却无法使用 C++0x 的概念来指定。要赶上(无约束的)模板性能,用于指定概念的函数就不能作为可被调用的函数出现在生成的代码中(更糟糕的是,间接的函数调用也不行)。

我不愉快地联想到了许多早期 C++ 编译器作者由于采用了 C 编译器的结构和代码库而遇到的问题,当时用来处理 C++ 作用域和重载的代码没法合适地放到 C 语言的编译器框架中。本着设计概念应该直接以代码表示的观点,Cfront(§2.1)使用了特定的作用域类来避免这种问题,然而,大多数 C 语言背景的编译器作者认为他们可以使用熟悉的 C 技巧走捷径,最终还是不得不从头开始重写 C++ 前端代码。语言设计和实现技巧可以非常强烈地彼此影响。

很快,事情就变得很明显:为了完成从无约束的模板到使用概念的模板的转换,我们需要语言支持。在 C++0x 的设计中,这两类模板非常不同:

  • 受约束模板不能调用无约束模板,因为不知道无约束模板使用什么操作,因此无法对受约束模板进行定义检查。
  • 无约束模板可以调用受约束模板,但是检查必须推迟到实例化的时候,因为在那之前我们不知道无约束模板在调用中使用什么类型。

第一个问题的解决方案是允许程序员使用 late_check 块,告诉编译器“别检查这些来自受约束模板的调用” [Gregor et al. 2008]:

template<Semigroup T>
T add(T x, T y) {
    T r = x + y;    // 用 Semigroup<T>::operator+
    late_check {
        r = x + y;  // 使用在实例化的时候找到的 operator+
                    // (不考虑 Semigroup<T>::operator+)
    }
    return r;
}

这一“解决方案”充其量只能算是个补丁,而且有一个特殊的问题,即调用到的无约束模板中不会知道 Semigroupconcept_map。这样就导致一个“有趣效果”,即一个对象可以在一段程序的两个地方以一模一样的方式被使用,但却表达不同的语义。这样一来,类型系统就以一种实在难以追踪的方式被破坏了。

随着概念的使用越来越多,语义在概念(实际上是类型和库)设计中的作用变得越来越清晰,委员会中的许多人开始推动一种表达语义规则的机制。这并不奇怪,Alex Stepanov 喜欢说“概念全都是语义问题”。然而,大部分人那时都像对待其他语言功能一样对待概念,他们更关心语法和命名查找规则。

2009年,Gabriel Dos Reis(在我大力支持下)提出了一种称为 axiom(公理)的写法并获得批准 [Dos Reis et al. 2009]:

concept TotalOrdering<typename Op, typename T> {
    bool operator()(Op, T, T);
    axiom Antisymmetry(Op op, T x, T y) {
        if (op(x, y) && op(y, x))
            x <=> y;
    }
    axiom Transitivity(Op op, T x, T y, T z) {
    if (op(x, y) && op(y, z))
        op(x, z);
    }
    axiom Totality(Op op, T x, T y) {
        op(x, y) || op(y, x);
    }
}

奇怪的是,要让公理的概念被接受很困难。主要的反对意见似乎是,提议者们明确拒绝了让编译器针对它们所使用的类型来对公理进行测试“以捕获错误”的想法。显然,axiom 就是数学意义上的公理(也就是说,是因为你通常无法检查而允许作的一些假设),这一观念对于某些委员是陌生的。另外一些人则不相信指定公理还可以帮助编译器以外的工具。不过,axiom 还是被纳入了 concept 规范中。

我们在概念的定义和实现上都存在明显的问题,但我们有了一套相当完整的工具,努力地试图通过使用标准库 [Gregor and Lumsdaine 2008] 和其他库中定义的概念来解决这些问题并获得经验。

6.2.6 哪里出错了?

2009年,我不情愿地得出结论,概念工作陷入了困境。我期望能被我们解决掉的问题仍在加剧,而新的问题又层出不穷:

  • 我们仍然没有达成一致意见,在大多数情况下,到底应使用隐式还是显式建模(隐式或显式使用 concept_map),哪种才是正确的方法。
  • 我们仍然没有达成一致意见,是要依赖概念之间隐式还是显式的关系陈述(我们是否应该以某种非常类似面向对象的继承的方式,显式地构建“精化”关系的层次结构?)。
  • 我们仍不断看到一些实例,由受概念约束的代码生成出来的代码不及无约束模板生成出来的代码。来自模板的后期组合机会仍然显示出惊人的优势。
  • 编写概念来捕获我们在泛型和非泛型 C++ 中惯于使用的每种转换和重载情况仍然很困难。
  • 我们看到了越来越多的例子,这些例子中,足够复杂的 concept_maplate_check 的组合导致了对类型的不一致的看法(也就是对类型系统的惊人和几乎无法追踪的破坏)。
  • 标准草案中规范的复杂性吹气球般迅速膨胀,超出了所有人的预期(有 91 页,这还不包括库中对概念的使用),我们中的一些人认为它基本上不可读。
  • 用于描述标准库的概念集越来越大(大约有 125 个概念,仅 STL 就有 103 个)。
  • 编译器在代码生成方面越来越好(因为 Doug Gregor 的英勇努力),但速度仍未提高。一些主要的编译器供应商私下里向我透露,如果一个支持概念的编译器比旧的编译器慢 20% 以上,他们就不得不反对这些概念,不管它们有多好。当时,支持概念的编译器要慢 10 倍以上。

在 2009 年春季,在标准的邮件群组上进行过一场广泛的讨论。起头的是 Howard Hinnant,他提出一个关于概念使用的非常实际的问题:他正在设计的工具可以通过两种方式来完成:一种将需要大量用户——不一定是专家用户——编写概念映射。另一种——远不够优雅的——设计将避免使用概念映射(和概念),以免要求用户了解有关概念的任何重要知识。“普通用户”需要理解概念吗?理解到足以使用它们就行?还是要能理解到足以定义它们?

这个讨论主题后来被称作“码农小明是否需要概念?”。谁是“码农小明”?Peter Gottschling 问道。这是个好问题,我回答道:

我认为大多数 C++ 程序员都是“码农小明”(我再次表示反对该术语),我大部分时间和使用大多数库的时候都是“码农小明”,我预料我一直都会是,因为我会一直保持学习新技术和库。但是,我想使用概念(并且在必要时使用概念映射),我希望“使用原则”比现在这样仅供专家使用的精细功能要简单得多。

换句话说,我们是应该将概念设计成为供少数语言专家进行细微控制的精密设备,还是供大多数程序员使用的健壮工具?在语言特性和标准库组件的设计中,这个问题反复出现。关于类,我多年以来都听到这样的声音;某些人认为,显然不应该鼓励大多数程序员定义类。在某些人眼里,普通的程序员(有时被戏称为“码农小明”)显然不够聪明或没有足够的知识来使用复杂的特性和技巧。我一向强烈认为大多数程序员可以学会并用好类和概念等特性。一旦他们做到了,他们的编程工作就变得更容易,并且他们的代码也会变得更好。整个 C++ 社区可能需要花费数年的时间来吸取教训;但是如果做不到的话,我们——作为语言和库的设计者——就失败了。

为了回应这场讨论,并反映我对 C++0x 概念的工作方向的日益关注,我写了一篇论文 Simplifying the use of concepts [Stroustrup 2009c] 概述了在我看来要让概念在 C++0x 中变得可接受所必须做的最小改进:

  • 尽量少使用 concept_map
  • 使所有 concept_map 隐式/自动化。
  • 概念如需要 begin(x),那它也得接受 x.begin(),反之亦然(统一函数调用);(§6.1),(§8.8.3
  • 使所有标准库概念隐式/自动化。

这篇论文非常详细地包含了多年来出现的许多例子和建议。

我坚持让所有概念都成为隐式/自动的原因之一是观察到,如果给一个选择,最不灵活和最不轻信的程序员可能会强迫每个人都接受他们所选择的显式概念。库作者们表现出一种强烈的倾向,即通过使用显式的(非自动的)概念把决策推到用户那去做,即便是对于那些最明显的选择也一样。

我当时注意到,C++ 泛型编程之父 Alex Stepanov 不久之前所写的《编程原本》(Elements of Programming)[Stepanov and McJones 2009] 并没有使用哪怕是一条 concept_map 来描述 STL 工具的超集和当时常见的泛型编程技术的超集。

委员展开了一次讨论回应我的论文,焦点是,为了及时加入标准,我们是否来得及达成共识。结论也很显然,没多大希望。我们没法同意“修补”概念让它对大多数程序员可用,同时还能(多少)及时地推出标准。这样,“概念”,这个许多有能力的人多年工作的成果,被移出了标准草案。我对“删除概念”决定的总结 [Stroustrup 2009a,b] 比技术论文和讨论更具可读性。

当委员会以压倒多数投票赞成删除概念时(我也投票赞成删除),每个发言的人都再次确认他们想要概念。投票只是反映出概念设计还没有准备好进行标准化。我认为问题要严重得多:委员会想要概念,但委员们对他们想要什么样的概念没有达成一致。委员会没有一套共同的设计目标。这仍然是一个问题,也不仅仅出现在概念上。委员之间存在着深刻的“哲学上”的分歧,特别是:

  • 显式还是隐式:为了安全和避免意外,程序员是否应该显式地说明如何从潜在可选方案中做决策?该讨论最终涉及有关重载决策、作用域决策、类型与概念的匹配、概念之间的关系,等等。
  • 专家与普通人:关键语言和标准库工具是否应该设计为供专家使用?如果是这样,是否应该鼓励“普通程序员”只使用有限的语言子集,是否应该为“普通程序员”设计单独的库?这个讨论出现在类、类层次结构、异常、模板等的设计和使用的场景中。

这两种情况下,回答“是”都会使功能的设计偏向于复杂的特性,这样就需要大量的专业知识和频繁使用特殊写法才能保证正确。从系统的角度,我倾向于站在这类论点的另一端,更多地信任普通程序员,并依靠常规语言规则,通过编译器和其他工具进行检查以避免令人讨厌的意外。对于棘手的问题,采用显式决策的方式比起依靠(隐式)的语言规则,程序员犯错的机会只多不少。

不同的人从 C++0x 概念的失败中得出了不同的结论,我得出三点主要的:

  • 我们过分重视早期实现。我们原本应该花更多的精力来确定需求、约束、期望的使用模式,以及相对简单的实现模型。此后,我们可以依靠使用反馈来让我们的实现逐步增强。
  • 有些分歧是根本的(哲学上的),无法通过折中解决,我们必须尽早发现并阐明此类问题。
  • 没有一套功能集合能做到既满足一个大型专家委员会的所有不同愿望,又不会变得过分庞大,这种膨胀会成为实现者的难题和用户的障碍。我们必须确定核心需求,并用简单的写法来满足;对于更复杂的用法和罕见的用例,则可以用对使用者的专业知识要求更高的功能和写法。

这些结论与概念没有什么特别的关系。它们是对大团体内的设计目标和决策过程的一般观察。

6.3 Concepts TS

2009 年,几乎是在概念刚从 C++0x 移除之后,Gabriel Dos Reis、Andrew Sutton 和我开始重新设计概念。这次设计是根据我们最初的想法、从 C++0x 语言设计中得到的经验、使用 C++0x 概念的经验,以及标准委员会的反馈。我们的结论是

  • 概念必须有语义上的意义
  • 概念数量应该相对较少
  • 概念应该基本,而非最小

我们认为 C++ 标准库中包含的大部分单独使用的概念是没有意义的 [Sutton and Stroustrup 2011]。“对于任何合理的‘概念’定义,STL 都用不了 103 个‘概念’!”我在和 Andrew Sutton 的讨论中大声嚷道,“基础代数都没有超过十几个概念!”语言设计的讨论可以变得相当热烈。

2011年,在 Andrew Lumsdaine 的敦促下,Alex Stepanov 在 Palo Alto 召集了为期一周的会议。一个相当大的团队,包含了大多数与 C++0x 概念工作密切相关的人,加上 Sean Parent 和 Alex Stepanov,一起讨论从用户的角度来解决这个问题:理想情况下,一个被适度约束的 STL 算法集应当是什么样子?然后,我们回家记录我们以用户为导向的设计,并发明语言机制以接近这个理想设计 [Stroustrup and Sutton 2012]。这一努力重新启动了标准工作,而且使用的是一种全新的、与 C++0x 工作完全不同且更好的方法。2016 年 ISO 出版的概念的 TS(技术规范)[Sutton 2017] 和 C++20 概念(§6.4)就是该会议的直接结果。Andrew Sutton 的实现从 2012 年开始就被用于实验,并作为 GCC 6.0 或更高版本的一部分发布。

在 Concepts TS 中 [Sutton 2017]

  • 概念基于编译期谓词(包括多参数谓词和值参数)。
  • 以使用模式来描述原始要求 [Dos Reis 和 Stroustrup 2006](requires 表达式)。
  • 概念可以用在一般的 requires 子句中,当作模板形参定义中 typename 的替代,也可以当作函数形参定义中类型名的替代。
  • 从类型到概念的匹配是隐式的(没有 concept_map)。
  • 重载中概念间是隐式的关系(通过计算得出,而不需要为概念进行显式细化)。
  • 没有定义检查(至少目前还没有,所以也没有 late_check)。
  • 没有 axiom,但这只是因为我们不想因为一个潜在有争议的特性而让设计更加复杂、产生拖延。C++0x 的 axiom 也可以是一个好起点。

与 C++0x 的概念相比,这里非常强调简化概念的使用,其中的一个主要部分是不要求程序员做显式表达,而让编译器根据明确规定的、简单的算法来解决问题。

支持由用户显式决策的人认为以上的方案重语义而轻语法,并警告会有“意外匹配”和“惊吓”。最常见的例子是 Forward_iteratorInput_iterator 的区别仅在于语义:Forward_iterator 允许在其序列中做多遍扫描。没有人否认这种例子的存在,但围绕这些例子的重要性以及如何解决它们的争论却没断过(仍然很起劲)。我认为让几个罕见的复杂例子主导设计是大错特错。

Concepts TS 设计是基于这样的看法(有大量经验支持),即上面这样的例子非常罕见(特别是在精心设计的概念中 [Stroustrup 2017]),通常被概念编写者很好地理解,而且常常可以通过在最受约束的概念上添加操作以反映语义上的差异来解决。例如,Forward_iterator/Input_iterator 问题的一个简单解决方案是要求 Forward_iterator 提供一个 can_multipass() 操作。此操作甚至不需要做任何事情;它存在只是为了让概念决策机制能够检查它的存在。因此,不需要专门添加新的语言特性来解决可能出现的意外歧义。

因为这一点经常被忽视,我必须强调,概念是谓词,它们不是类或类层次结构。根本上,我们只是问某个类型一些简单的问题,如“你是迭代器吗?”并问类型的集合关于它们的互操作的问题,如“你们之间能用 == 来相互比较吗?”(§6.3.2)。使用概念时,我们只问那些可以在编译期回答的问题,不涉及运行期求值。潜在的歧义是通过比较类型(或类型集合)所涉及的谓词来检测的,而不是让程序员写决策规则(§6.3.2)。

出于对 C++0x 概念(§6.2.6)中所发生问题的敏感,我们小心翼翼地设计概念,以求使用它们不会隐含显著的编译期开销。即使是 Andrew Sutton 的编译器的早期版本,编译使用了概念的模板的速度也比编译使用变通方案(例如 enable_if§4.5.1))的程序要

6.3.1 定义检查

在 Palo Alto 会议后几个月之内的某个时间点,Andrew Sutton、Gabriel Dos Reis 和我做出决定,分阶段着手设计和实现概念的语言特性。这样,我们可以从实现的经验中学习,并在“设计冻结”之前获得早期的反馈。特别是,我们决定推迟实现定义检查§6.2.4);也就是说,检查并确保模板没有使用并未为其参数指定的功能。考虑 std::advance() 的一个简化版本,它将迭代器在序列中向前移动 n 个位置:

template<Forward_iterator Iter>
void advance(Iter p, int n)
{
    p+=n;  // p 前进 n 个位置
}

Forward_iterator 不提供 +=,只提供 ++,所以定义检查会把它当作错误抓出来。如果不单独(在使用前)检查 advance() 的函数体,我们将只会从 += 的(错误)使用中得到糟糕的实例化时的错误信息,请注意,模板实例化生成的代码总会经过类型检查,所以不做定义检查不会导致运行期错误。

我们认为,概念带来的约 90% 的好处会从使用点检查中收获,而对于那些相对专家级的受约束模板作者来说,没有定义检查也能将就一段时间。这里 90% 显然是基于有限信息的临时估计,但得益于十年间在概念上的工作,我认为这是一个不错的猜测。作为语言特性和库的设计者,对我们来说,更重要的是从使用中获得经验,这一经验获得的过程始于 Palo Alto 技术备忘录 [Stroustrup and Sutton 2012] 中的 STL 算法示例。我们重视反馈胜于重视理论完整性。这种看法曾是激进的。回顾一下关于概念的文档(在 C++ 和其他语言中),之所以将概念作为语言特性提供,定义检查总是被强调成一个主要原因 [Gregor et al. 2006; Stroustrup and Dos Reis 2003b]。

这种新设计一度被称为轻量概念(Concepts Lite),许多人认为它不完整,甚至没用。但是,我们很快发现,进行定义检查会带来真正的好处 [Sutton and Stroustrup 2011]。

  • 有了定义检查,我们在开发过程中就没办法使用部分概念检查。在构建一个大程序的初始阶段中,不知道全部的需求是非常常见的。部分检查可以让很多错误在早期被发现,并有助于根据早期使用的反馈逐步改进设计。
  • 定义检查使得设计难以拥有稳定的接口。特别是,要往类或者函数中增加调试语句、统计收集、追踪或者“遥测”之类的支持,就不能不改变类或函数的接口来包含相应功能。这些功能对于类或函数来说很少是根本的,而且往往会随着时间的推移而改变。
  • 当我们不使用定义检查时,现有的模板可以逐渐转换为使用概念。但是,如果我们有定义检查,一个受约束的模板就不能使用一个无约束的模板,因为我们一般没法知道无约束的模板使用了哪些功能。另外,不管做不做定义检查,一个无约束的模板使用一个有约束的模板都意味着后期(实例化时)检查。

从 2014 年起担任 EWG 主席的 Ville Voutilainen 更为坚定地表示:

我不能支持任何包含定义检查的概念提案。

我们最终可能会得到一种定义检查的形式,但前提是我们能够设计一种机制来避开它,以满足过渡和数据收集的需要。这需要仔细考虑,需要进行实验。C++0x 的 late_check 是不够的。

定义检查的问题是使用的问题,而不是实现的问题。Gabriel Dos Reis 设计并实现了一种名为 Liz 的实验语言,用来测试 Concepts TS 设计中的功能 [Dos Reis 2012],包括定义检查。如果我们找到一种可接受的定义检查形式,我们就可以实现它。

6.3.2 概念使用

简单的示例看起来很像 C++0x 及更早的版本中的样子:

template<Sequence Seq, Number Num>
Num sum(Seq s, Num v)
{
    for (const auto& x : s)
        v += x;
    return v;
}

这里 SequenceNumber 是概念。使用概念而不是 typename 来引入类型的名称,意味着使用的类型必须满足概念的要求。需要注意的是,由于 Concepts TS 不提供定义检查,所以使用 += 不会被概念所检查,而只会在后期、在实例化时检查。以上是最初的开发阶段中可能的做法,稍后我们很可能会更为明确:

template<typename T>
using Value_type = typename T::value_type;  // 简化的别名

template<Sequence Seq, typename Num>
    requires Arithmetic<Value_type<Seq>,Num>
Num sum(Seq s, Num v)
{
    for (const auto& x : s)
        v += x;
    return v;
}

也就是说,我们必须有算数运算符,包括 +=,以供 Sequence 的值类型和我们用作累加器的类型的组合使用。我们不再需要说明 NumNumberArithmetic 会检查 Num 具有所需的一切属性。在这里,Arithmetic 被显式地用作(C++0x 风格的)requires 子句中的谓词。

重载是通过挑选具有最严格要求的函数来处理。考虑标准库中的经典函数 advance 的一个简单版本:

template<Forward_iterator Iter>
void advance(Iter p, int n)  // 将 p 向前移动 n 个元素
{
    while (n--)
        ++p;  // 前向迭代器有 ++,但没有 + 或者 +=
}

template<Random_access_iterator Iter>
void advance(Iter p, int n)  // 将 p 向前移动 n 个元素
{
    p += n;  // 随机迭代器有 +=
}

也就是说,我们应该对提供随机访问的序列使用第二个版本,对只提供前向迭代的序列使用第一个版本。

void user(vector<int>::iterator vip, list<string>::iterator lsp)
{
    advance(vip, 10);  // 使用较快的 advance()
    advance(lsp, 10);  // 使用较慢的 advance()
}

编译器将这两个函数的概念分解为原始(“原子”)要求,由于前向迭代的要求是随机访问迭代要求的严格子集,所以这个例子可以被解决。

当一个参数类型同时匹配到互相之间不是严格子集的重叠要求时,会产生歧义(编译期错误)。例如:

template<typename T>
    requires Copyable<T> && Integral<T>
T fct(T x);

template<typename T>
    requires Copyable<T> && Swappable<T>
T fct(T x );

int x = fct(2);  // 有歧义:int 满足 Copyable、Integral 和 Swappable
auto y = fct(complex<double>{1,2});  // OK:complex 不满足 integral

程序员唯一能利用的控制机制是在定义概念时为其增加操作。不过对于现实世界的例子来说,这似乎已经足够了。当然,你可以定义一些只在语义上有差异的概念,这样就没有办法根据我们的纯语法概念来区分它们。然而,要避免这样做并不困难。

6.3.3 概念的定义

通过 requires 表达式的使用模式可指定概念的原始要求:

template<typename T, typename U =T>
concept Equality_comparable =
    requires (T a, U b) {
        { a == b } -> bool ; // 使用 == 比较 T 和 U 得到一个 bool 值
        { a != b } -> bool ; // 使用 != 比较 T 和 U 得到一个 bool 值
    };

requires 表达式是 Andrew Sutton 发明的,作为他实现 Concepts TS 的一部分。事实证明它们非常有用,以至于用户坚持认为它们应该成为标准的一部分。

=T 为第二个类型参数提供默认值,因此概念 Equality_comparable 可以用于单个类型。

使用模式的写法是 Bjarne Stroustrup 基于 2003 年的想法 [Stroustrup and Dos Reis 2003b] 在 Palo Alto 的现场会议上发明的。这种写法及其思想并不涉及函数签名或函数表的实现。

不存在特定的机制来表达类型与概念相匹配,但如果有人要这么做,可以使用 C++11 中普通的 static_assert

static_assert(Equality_comparable<int>);       // 成功
static_assert(Equality_comparable<int,long>);  // 成功
struct S { int a; };
static_assert(Equality_comparable<S>);    // 失败了,因为结构体不会
                                          // 自动生成 == 和 != 操作

来自 C++0x(及更早的 [Stroustrup 2003])中的关联类型(associated type)概念也得到了支持:

template<typename S>
concept Sequence = requires(S a) {
  typename Value_type<S>;             // S 必须具有值类型。
  typename Iterator_type<S>;          // S 必须具有迭代器类型。

  { begin(a) } -> Iterator_type<S>;   // begin(a) 必须返回一个迭代器。
  { end(a) } -> Iterator_type<S>;     // end(a) 必须返回一个迭代器。
  { a.begin() } -> Iterator_type<S>;  // a.begin() 必须返回一个迭代器。
  { a.end() } -> Iterator_type<S>;    // a.end() 必须返回一个迭代器。

  requires Same_type<Value_type<S>,Value_type<Iterator_type<S>>>;
  requires Input_iterator<Iterator_type<S>>;
};

注意上面的代码有重复,这是为了可以同时接受 a.begin()begin(a)。缺少统一函数调用让人头疼(§6.1)、(§8.8.3)。

6.3.4 概念名称引导器

从使用中我们学到的一件事情是,基础概念的使用有很多重复。我们在 requires 语句中直接使用了太多的 requires 表达式,并且使用了太多“小”概念。我们的概念要求看起来像新手程序员编写的代码:很少的函数,很少的抽象,很少的符号名。

考虑标准的 merge 家族函数。这些函数都接受三个序列的输入并需要指明这些序列之间的关系。因此就有了对序列类型的三个要求和描述序列元素之间关系的三个要求。第一次尝试:

template<Input_iterator In1, Input_iterator In2, Output_iterator Out>
    requires Comparable<Value_type<In1>,Value_type<In2>>
    && Assignable<Value_type<In1>, Value_type<Out>>
    && Assignable<Value_type<In2>, Value_type<Out>>
Out merge(In1, In1, In2, In2, Out);

这种形式太乏味了;而且,这种引入类型名称的模式非常常见。例如,STL 中至少有四个 merge 函数。乏味且重复的代码非常容易出错,也难以维护。我们很快学会了更多使用多参数概念来定义类型间要求的共同模式:

template<Input_iterator In1, Input_iterator In2, Output_iterator Out>
    requires Mergeable<In1,In2,Out>
Out merge(In1, In1, In2, In2, Out);

对于 Andrew Sutton 来说,这还是太混乱了。他在 2012 年使用概念编写的代码量可能超过任何其他人。他提出了一种机制来表达“为满足一个概念的多个类型引入一个类型名集合”。这样将 merge 的示例减少到了逻辑上的最少限度:

Mergeable{In1,In2,Out} // 概念名称引导器
Out merge(In1, In1, In2, In2, Out);

仅仅通过尝试,你就能学到很多东西,这真是令人惊叹!同样令人惊叹的是,对于那些尚未经历过这些问题的人,新颖的写法和解决方案在他们那里也会遭遇巨大的阻力。

6.3.5 概念和类型

许多人仍然将概念视为(无论过去和现在)类型的类型这个想法的变体。是的,只有一个类型参数的概念可以看作是一个类型的类型,但只有最简单的用法才适合该模式。

大多数泛型函数(算法)都需要不止一个模板参数,要让这样的函数有意义,这些参数类型必须以某种方式关联起来。因此,我们必须使用多参数概念。例如:

template<Forward_iterator Iter, typename Val>
    requires Equality_comparable<Value_type<Iter>,Val>
Forward_iterator find(Iter first, Iter last, Val v)
{
    while (first!=last && *first!=v)
        ++first;
    return first;
}

至关重要的是,多参数概念直接解决了处理隐式转换和混合类型操作的需求。早在 2003 年,我就和 Gabriel Dos Reis 一起考虑过将每个参数的所有约束条件与其他参数隔离开来说明的可能性 [Stroustrup 2003; Stroustrup and Dos Reis 2003b]。这将涉及

  • 参数化(例如,用值类型来参数化的 Iterator
  • 某种形式的继承(例如,Random_access_iterator 是一个 Forward_iterator
  • 能对一个模板参数应用多个概念的能力(例如,一个 Container 的元素必须满足 Value_typeComparable
  • 这三种技术的组合。

结果是非常复杂的模板参数类型约束。我们认为这种复杂性是不必要的,也无法进行管理。譬如 x+yy+x,其中 xy 具有不同的模板参数类型,XY。在处理各自的模板参数时,我们必须将 XY 以及 YX 进行参数化。在纯面向对象语言中,这看起来很自然。毕竟,有两种方法可以进行 + 运算,一种在 X 的层次结构中,一种在 Y 的层次结构中。然而,我早在 1982 年就拒绝了 C++ 的这个解决方案。要完成这一图景,我们必须添加隐式类型转换(例如,处理 x+22+x)。而多参数概念与 C++ 解决此类场景的方式完全吻合,并避免了大部分的复杂性。

这个决定经过多年的反复审查并得到确认。在设计 C++0x 概念的努力中,人们尝试应用了标准的学术系统,正如在 Haskell 类型类(typeclass)和 Java 约束中可见的。但是,这些做法最终不能提供在大规模使用中所需要的实现和使用上的简单性。

当一个泛型用法符合类型的类型这一模式时,概念能非常优雅地支持它。

  • 类型指定了一组可以(隐式和显式)应用于对象的操作,依赖于函数声明和语言规则,并会指定对象在内存中如何布局。
  • 概念指定了一组可以(隐式和显式)应用于对象的操作,依赖于可以反映函数声明和语言规则的使用模式,并且不涉及对象的布局。因此,概念是一种接口。

我的理想是,能用类型的地方就能用概念,并且使用方式相同。除了定义布局外,它们非常相似。概念甚至可以用来约束那些由其初始化器来确定其类型的变量的类型(受约束的 auto 变量(§4.2.1))。例如:

template<typename T>
concept Integer = Same<T,short> || Same<T,int> || Same<T,long>;

Integer x1 = 7;
int x2 = 9;

Integer y1 = x1+x2;
int y2 = x2+x1;

void f(int&);      // 一个函数
void f(Integer&);  // 一个函数模板

void ff()
{
    f(x1);
    f(x2);
}

C++20 离实现这一理想接近了。为了使该例子能在 C++20 中工作,我们必须在每个 Integer§6.4)概念后添加一个逻辑上冗余的 auto。另一方面,在 C++20 中,我们可以使用标准库里的 integral 概念来替换明显不完整的 Integer

6.3.6 改进

在 Concepts TS 工作的初期,一个 concept 是一个返回 bool 值的 constexpr 函数(§4.2.7)。这很合理,因为我们把概念看作是编译期的谓词。然后 Gabriel Dos Reis 将变量模板引入到 C++14(§5.2)中。现在,我们有了选择:

// 函数风格:
template<typename T>
concept bool Sequence() { return Has_begin<T>() && Has_end<T>(); }

// 表达式风格:
template<typename T>
concept bool Sequence = Has_begin<T> && Has_end<T>;

我们可以愉快地使用任何一种风格,但是如果两种风格都允许的话,使用概念的用户就必须知道概念定义中使用了哪种风格,否则无法正确使用括号。很快这就成了一个大麻烦。

函数式风格允许概念重载,但是我们只有很少的概念重载例子;于是我们决定没有概念重载也可以。因此,我们进行了简化,只使用变量模板来表达概念。Andrew Sutton 率先全面使用了概念的表达式形式。

我们(Andrew Sutton、Gabriel Dos Reis 和我)始终知道,显式写出 concept 返回 bool 是多余的。毕竟,概念从定义上来看就是一个谓词。然而,我们决定不去搞乱语法而专注于语义上的重要话题。后来,人们总是将冗余的 bool 作为一个反对概念设计的论点,因此我们对其进行了修正,不再提到 bool

删除 bool 是 Richard Smith 提出的一系列改进建议的一部分,其中还包括更精确地描述什么是原子谓词,以及对匹配规则的简化 [Smith and Sutton 2017]。现在,我们使用表达式风格:

// 表达式风格:
template<typename T>
concept Sequence = Has_begin<T> && Has_end<T>;

6.3.7 等效语法

Concepts TS 支持在函数声明中使用概念的三种写法:

  • 为通用起见,显式使用 requires 语句
  • 简略写法,用于表示类型的类型
  • 自然写法(也称为简短写法、常规写法等)

基本思想是,让程序员使用与特定声明的需求紧密匹配的写法,而不会因使用更复杂声明所需的写法而淹没该定义。为了使程序员可以自由选择写法,尤其是允许在项目开发初期或维护阶段随着功能的变化而调整,这些风格的写法被定义为是等效的:

void sort(Sortable &); // 自然写法

等同于

template<Sortable S> void sort(S&); // 简略写法

等同于

template<typename S> requires Sortable<S> void sort(S&);

用户对此感到非常满意,并且倾向于在大多数声明中使用自然和简略写法。但是,有些委员会成员对自然写法感到恐惧(“我看不出它是一个模板!”),而喜欢使用最显式的 requires 写法,因为它甚至可以表达最复杂的示例(“为什么你还要比那更复杂的东西?”)。我的解释是,我们对什么是简单有两种看法:

  • 我可以用最简单、最快捷的方式编写代码
  • 我只需要学习一种写法

我赞成前一种观点,认为这是洋葱原则(§4.2)的一个很好的例子。

自然写法成为对概念强烈反对的焦点。我——还有其他人——坚持这种优雅的表达

void sort(Sortable&); // 自然写法

我们看到(过去和现在)这是有用而优雅的一步,可以使泛型编程逐渐变成一种普通的编程方式,而不是一种具有不同语法、不同源代码组织偏好(“仅头文件”)和不同编码风格(例如模板元编程(§10.5.2))的暗黑艺术。模块解决了源代码组织问题(§9.3.1)。另外,更“自然”的语法解决了人们总是抱怨的关于模板语法过于冗长和笨拙的问题,我同意这些抱怨。在设计模板时,template<…> 前缀语法不是我的首选。由于人们总是担心能力不强的程序员滥用模板而引起混淆和错误,我被迫接受了这种写法。繁重的异常处理语法(try { … } catch ( … ) { … })也是类似的故事 [Stroustrup 2007]。似乎对于每个新特性,许多人都要求有醒目的语法来防止实际和想象中的潜在问题。然后过一段时间后,他们又抱怨太啰嗦了。

无论如何,有为数不少的委员会成员坚持认为自然语法会导致混乱和误用,因为人们(尤其是经验不足的程序员)不会意识到以这种方式定义的函数是模板,和其他函数并不相同。我在使用和教授概念的多年里并没有观察到这些问题,因此我并不特别担心这样的假设性问题,但反对意见仍然非常强烈。人们就是知道这样的代码很危险。主要的例子是

void f(C&&); // 危险:C 是一个概念还是类型?

C&& 的含义因 f 是函数模板还是“普通的”函数而有所不同。在我看来,C&& 语义上的这种差异是 C++11 中最不幸的设计错误,我们应该尝试纠正这一错误,而不是让它影响概念的定义。毫无疑问,误解的可能性是真实存在的,并且一旦该机制被很多人使用时,肯定会发生。但是,我在现实中没有看到过这种问题,而且我怀疑经验相对丰富的程序员如果遇到这种差异真正会产生影响时,真的会遇到麻烦。换句话说,我认为这是“尾巴摇狗”的一个示例;也就是说,一个不起眼的例子阻止了一个可以使大量用户受益的特性。

我也很确定,我的目标是使泛型编程尽可能地像“普通”编程,但这不是普遍共识。仍然有人认为,泛型编程超出了绝大部分程序员的能力。但我没有看到任何证据。

6.3.8 为什么在 C++17 中没有概念?

我曾希望并期望在 C++17 看到概念。在我认为在 2017 年时间窗口可行的扩展(§9.2)中,我把概念看作是对 C++ 程序员的基本词汇的最重大改进。它可以消除很多对丑陋且易出错的模板元编程(§10.5.2)的需求,可以简化库的精确规范定义,并显著改善库的设计。恐怕这就是问题的一部分:概念会直接影响所有投票成员。有些人对旧的方式比较满意,有些人没有概念方面的经验,而有些人则认为它们是未被尝试过的(“学院派”/“理论派”)想法。

C++0x 概念(§6.2)的惨败加剧了这种担忧,这导致我们首先有了技术规范(TS)[Sutton 2017]。我们没有语言特性方面的技术规范经验,但是这似乎值得尝试:Andrew Sutton 在 GCC 中的概念实现仍然比较新,需要谨慎评估。在(2013 年的)Bristol 标准会议上,Herb Sutter 强烈主张采用 TS 路线,而我和 J-Daniel Garcia 警告说可能会有延期。我还指出了将概念与通用 lambda 表达式(§4.3.1)分开考虑的危险性,但是“谨慎”和“我们需要更多经验”在标准委员会里是很有力的理由。最终,我投票赞成了 Concepts TS。现在我把这看作是一个错误。

2013 年,我们有了一个概念的实现和一个相当不错的规范(主要感谢 Andrew Sutton),但是完成 Concepts TS 还是花了三年的时间。我无法识别出完善 TS 和纳入 ISO 标准在严格程度有什么区别。但是,在 2016 年 Jacksonville 会议上,当对 TS 中描述的概念进行投票以将其纳入标准时,先前的所有反对意见又出现了。反对者似乎只是把概念给忽略了三年。我甚至听到了只对 C++0x 中的概念设计有效、而与 TS 概念设计无关的反对意见。人们再次主张“谨慎”和“我们需要更多的经验”。据我所知,由于委员会人数增长的部分原因,在 Jacksonville 会议上还没有尝试过概念的人比在 Bristol 时更多。除了我在过去十年中听到的所有反对意见之外,有人提出了全新的反对意见,有人在全体委员会上提出了未经尝试的设计建议,还被认真考虑了。

在 2016 年 2 月的 Jacksonville 会议上,Ville Voutilainen(EWG 主席)提议按照 Concepts TS [Voutilainen 2016c] 把概念放到标准中:

……程序员们非常渴望能使用新的语言特性,现在正是将其交付给他们的时候了。概念化标准库需要花费时间,相信在这个过程中不会发现概念设计有什么大的问题。我们不应该让程序员一直等待语言特性,只是因为一些假想中的设计问题,这些问题没有证据,甚至有一些反证,很可能根本不存在。为了使世界各地的 C++ 用户受益,让我们在 C++17 里交付概念这一语言特性吧。

他得到了许多人的大力支持,尤其是 Gabriel Dos Reis、Alisdair Meredith(之前是 LWG 主席)和我,但是(尽管 EWG 在本周早些时候投了赞成票)投票结果依然对我们不利:25 票赞成,31 票反对,8 票弃权。我的解释是,用户投了赞成票,语言技术人员投了反对票,但这可能会被认为是酸葡萄吧。

在这次会议上,统一调用语法(§8.8.3)被否决,协程(§9.3.2)被转为 TS,基本上确保了 C++17 只是标准的一个小版本(§8)。

6.4 C++20 概念

在 2017 年,作为 C++20 的最早特性之一,WG21 将 Concepts TS [Sutton 2017] 中基础部分和无争议的部分通过投票进入了工作文件(§6.3.2):

  • 为通用起见,显式使用 requires 语句;例如 requires Sortable<S>
  • 简略写法,用于表示类型的类型;例如 template<Sortable S>

自然写法(例如 void sort(Sortable&);§6.3.7))因有争议而被排除在外。被排除在外的原因有以下几点:

  • void sort(Sortable&); 是一个模板,但这不很明显。
  • void f(C&&); 的含义取决于 C 是概念还是类型。
  • Iterator foo(Iterator,Iterator); 中,三个 Iterator 必须是相同类型,还是可以分开约束的类型?
  • 自然语法令人困惑且难以教授。
  • 我们如何约束 template<auto N> void f(); 中的参数?

这些异议并不新鲜,但这次它们伴随着许多使用全新语法的提案 [Honermann 2017; Keane et al. 2017; Köppe 2017a; Riedle 2017; Sutter 2018a]。这些提案各不相同,和 Concepts TS 也不兼容。人们带着热情在会议上介绍这些提案,而其中没有一个有实际经验的支持。相比之下,我的立场是基于约四年的教学经验、很多的实验使用、一些业界应用,以及在几个标准库提案组件中的使用(如,迭代器封装 [Dawes et al. 2016]、元组实现 [Voutilainen 2016b]、范围 [Niebler et al. 2014])。

在 Jacksonville 会议(2018)上,Tom Honerman 建议删除自然语法,并提出了另一种选择 [Honermann 2017]。我捍卫了自己的立场和 Concepts TS 的设计 [Stroustrup 2017a,b]。我的辩护主要是

  • 五年多来,自然语法在实际教学和使用中未引起任何问题。
  • 用户喜欢它。
  • 没有技术上的歧义。
  • 它简化了常见用法。
  • 这是使泛型编程更像普通编程的动力之一。

但这未能说服任何反对者,因此自然语法没有移到 C++20 的工作文件中。

最后一个反对意见来自 C++17 的一个新的小特性,auto 值参数 [Touton and Spertus 2015],并成为反对的焦点:

template<auto N> void f();

人们想在语法上区分值模板参数和类型模板参数。通常,这意味着自 2002 年以来一直在提案里被使用的简写语法将不再有效。

template<Concept T> void f(T&); // 建议被废止

在 2018 年中,我提出了一个最小折中方案 [Stroustrup 2018b]:

  • 保留 template<Concept T> void f(T&); 的含义;
  • 使用前缀 template 来识别使用自然写法的模板(例如 template void f(Concept&)

提议成功了,但是 Herb Sutter [Sutter 2018a] 提出的一个截然不同的建议也成功了 [Sutter 2018a]。我们当时处于一种非常特殊的境地,同时有两个截然不同且互不兼容的提案,每个都得到了 EWG 的大多数人的支持。这种僵局为 Ville Voutilainen(EWG 主席)提出一种变通方案打开了大门,这一方案在 2018 年 11 月得到了广泛的支持并被接受 [Voutilainen et al. 2018]:

  • 保留 template<Concept T> void f(T&); 的含义
  • 使用 auto 来识别使用自然写法的模板参数,例如 void f(Concept auto&);

举例来说:

// 几乎自然的写法:
void sort(Sortable auto& x);  // x 必须 Sortable
Integral auto ch = f(val);    // f(val) 的结果必须为 Integral
Integral auto add(Integral auto x, Integral auto x); // 可以用一个更宽的
                                                     // 类型来防止溢出

“自然写法”已重命名为“缩写语法”,虽然它不仅仅是一个缩写。

尽管我认为在这种 auto 的使用有些多余,分散和损害了我想使泛型编程变成“普通编程”的目标,但我还是支持这种折中方案。也许在将来的某个时候,人们会(正如当时 Herb Sutter 所暗示的那样)达成一致,让在概念名后的 auto 不再必要。不过,我并没有抱太大的希望;很多人认为为技术实现而定义的语法标记很重要。或许 IDE 的自动完成功能可以使用户免于手写这多余的 auto

遗憾的是,对于重新引入概念名称引导器并没有达成共识(§6.3.4)。缺乏足够传统的语法是一个主要的绊脚石。同样,仍然有很多人似乎不相信其有用。

延迟很多年才引入概念造成了长期的伤害。基于特征(traits)和 enable_if 的临时设计数量激增。一代程序员在低级的、无类型的元编程中成长起来。

6.5 概念的命名

在发布 C++20 之前有关概念的最后讨论中,有一个是关于概念的命名约定。命名始终是一个棘手的话题。在我早期涉及概念的工作中,我通常以非标准的命名类型的方式来命名概念:像命名专有名词一样,将第一个字母大写,并用下划线来分隔单词,以保证可读性(例如 SortableForward_iterator)。其他人(尤其是印第安纳团队)则使用了驼峰式命名(例如 SortableForwardIterator)。不幸的是,这种命名约定悄悄进入了标准文本 [Carter 2018],并由于与标准库中的所有其他名称不同而引起一些混乱。在那里,使用了下划线,不使用大写字母(除了一些宏和三个晦涩难懂的示例)。然后有人认为,不同的命名约定旨在将“新颖且困难”的概念与“常规构件”(例如函数和类型)区分开来。

当我注意到这种辩解时,我非常不喜欢。在 C++ 中,我们通常不会把类型编码到实体名称中,但我认为更改命名风格为时已晚。在 2019 年,Herb Sutter 对我的抱怨做出了回应,提议重命名所有标准库中的概念,以遵循常见的标准库命名约定 [Sutter et al. 2019]。大部分概念设计者和范围库(§9.3.5)的设计者作为共同作者都签了字。进行此更改的另一个原因是,我们开始看到标准库里概念的驼峰式名称与其他库中的驼峰式名称之间存在冲突。使用驼峰式命名(或使用我的大写类型约定)的原因之一就是为了避免与标准库冲突。因此,我们现在有了 sortableforward_iterator 等。

C++20 标准库包含大约 70 个概念,包括 constructible_fromconvertible_toderived_fromequal_comparableinvocablemergeablerangeregularsame_assigned_integralsemiregularsortableswappabletotally_ordered,涵盖了运算符调用、基本类型的使用、范围和标准算法的需求。它们将指导许多 C++ 库的设计。请注意,这 70 个概念中很多并不是基本概念,而只是为了方便表示或用作基本构建单元。

7. 错误处理

错误处理作为一种备受争议的话题,我认为将长期存在下去。许多人在这个问题上有强烈的固有认知,其中一些是基于各种应用领域中扎实的经验——过去 50 多年已经有了很多相关的技术积累。在错误处理领域,性能、通用性、可靠性的需求往往发生冲突。

与 C++ 一样,问题不是我们没有解决方案,而是有太多解决方案。从根本上讲,很难通过单一的机制来满足 C++ 社区的多样化需求,但是人们往往只看到问题的一部分,就以为他们掌握了解决问题的终极方案 [Stroustrup 2019a]。

7.1 背景

C++ 从 C 语言中继承了各种基于错误返回码的机制,错误可以用特殊值、全局状态、局部状态和回调等多种方式表达。例如:

double sqrt(double d);  // 当 d 为负数时,设置 errno 为 33
int getchar();          // 遇到文件结尾返回 -1
char* malloc(int);      // 如果分配出错,返回 0

C++ 的早期用户(1980 年代)发现这些技术令人困惑,也不足以解决所有问题。返回 (值,错误码) 对变得流行,但这更增加了混乱和变化。例如:

Result r = make_window(arguments); // Result 是 (值,错误码) 对
if (r.error) {
    // ... 错误处理 ...
}
Shape* p = r.value;

繁琐的重复错误检查使代码变得混乱。使用错误码时,很难将程序的主要逻辑与错误处理区分开。程序的主线(业务逻辑)与大量奇怪和模糊的错误处理代码紧密耦合在一起。对于那些错误处理本身就是主要的复杂逻辑而言,这种基于错误返回码的处理方式可能会带来严重的问题。

使用包含 (值,错误码) 对的类会带来巨大的成本。除了检测错误码的成本外,许多 ABI(应用程序二进制接口)甚至不使用寄存器来传递小的结构体,所以 (值,错误码) 对不仅传递了更多的信息(是通常数量的两倍),而且也使传递的性能有数量级的降低。可悲的是,在许多 ABI 中,尤其那些针对嵌入式系统的 ABI(专为 C 代码设计),这个问题直到今天(2020 年)依然存在。

此外,并不存在真正的好方法可以使用错误码来处理发生在构造函数中的错误(构造函数没有返回值),还有那些过去流行的具有复杂类层次结构的系统,子对象创建中各种潜在错误也很难通过错误码的方式处理。

还有,对于所有传统的错误处理技术,最令人头疼的是人们会忘记检查错误。这一直是错误的主要根源,并且在 2020 年的今天依旧如此。C++ 异常机制的主要目标是使不完整或复杂的错误处理中的错误最小化。

C++ 异常是在 1988–89 年设计的,旨在解决当时普遍存在的复杂且容易出错的错误处理技术。它们记录在 ARM(The Annotated C++ Reference Manual)[Ellis and Stroustrup 1990] 中,并作为标准基础文档 [Stroustrup 1993] 的一部分被 ANSI C++ 所采用。

与其他语言的异常设计相比,用于 C++ 的异常设计由于 C++ 代码需要和其他语言(尤其是 C)的代码结合使用而变得复杂。考虑一个 C++ 函数 f() 调用一个 C 函数 g(),该函数又调用一个 C++ 函数 h()。现在 h() 抛出异常由 f() 捕获。通常,C++ 函数不知道被调用函数的实现语言。这样的场景使我们不能通过修改函数签名以添加“异常传播参数”,或隐式地向返回类型添加返回码的方法做错误处理。

与使用其他技术相比,异常与 RAII(§2.2)一起解决了许多棘手的错误处理问题(例如,如何处理构造函数中的错误以及那些远离错误处理代码的错误),而且所需的时间成本要小得多(与 1990 年代中期所用的技术相比通常不到 3%,甚至更便宜)。虽然异常从来都不是没有争议的,但我还是低估了它们引起争议的可能性。

7.2 现实中的问题

当然总有一些应用不适合使用异常,例如:

  • 内存严重受限系统,异常处理所需的运行期支持内存会占用应用程序功能所需要的内存。
  • 工具链不能保证异常抛出后能够迅速做出响应的硬实时系统(例如 [Lockheed Martin Corporation 2005])。
  • 系统依赖于多台不可靠的计算机,因此立即崩溃并重新启动是对付那些无法在本地处理的错误的合理(且几乎是必要的)方式。

因此,大多数 C++ 实现仍然保留了非异常机制的错误处理方式。另一方面,也存在一些通过错误码无法提供良好解决方案的场景:

  • 构造函数失败——由于构造函数没有返回值(不算被构造对象本身),单纯依赖 RAII 的方式必须替换为通过对对象状态的显式检查来处理错误。
  • 运算符——没有办法从 ++*-> 中返回错误码。你将不得不使用非本地的错误指示,或使用一种糟糕的写法,如 multiply(add(a,b),c) 而不是 (a+b)*c1
  • 回调——使用回调的函数应该能够调用具有多种可能错误的函数(通常,回调是 lambda 表达式(§4.3.1))。
  • 非 C++ 代码——我们无法通过那些没有专门做错误码处理的非 C++ 函数传递错误。
  • 调用链深处新的错误类型——必须准备调用链上的每个函数来处理或传播一种新的错误(例如,程序中的网络错误,而它并不是专门为通过网络访问数据而预先设计的)。
  • 忘记处理返回码——有一些精巧的方案来试图确保统一检查错误码,但是它们要么不完整,要么依赖于在遗漏检查时使用异常或程序终止(例如 [Botet and Bastien 2018])。

此外,还有一些与使用异常有关的现实问题:

  • 有些人不愿意引入异常机制,是因为他们的代码由于无原则使用指针而形成了一团乱麻。通常,这些人将他们的批评指向异常,而不是他们的陈旧代码。
  • 有些人(很多)根本不理解甚至不知道 RAII(§2.2),而只是把异常当作返回错误码的一种替代机制来用。通常,把 try-catch 当作 if-then 的一种形式来用的话,代码比正确使用错误码或 RAII 要更丑陋、更繁琐、更缓慢。
  • 异常的许多实现速度很慢,是因为实现者把 C++ 的异常与其他类型的异常(例如微软的结构异常)统一处理,优先考虑调试(例如 GCC 在 throw 后两次遍历堆栈来保存回溯),使用单一机制为各种语言服务(每一种都很糟糕),或者只是没有在异常处理优化上花费很多开发精力。
  • 这些年来,异常处理的性能相对较慢,是因为我们在优化非异常方面花费了大量精力。我怀疑还有很大的优化机会。例如,Gor Nishanov 报告说,通过一些与 Windows 和 Linux 上的协程实现相关的简单优化,速度提高了多达 1000 倍 [Nishanov 2019a]。不过,大幅改善空间占用可能会更难实现。一些最近的实验看起来还比较不错 [Renwick et al. 2019]。
  • 为了使异常被接受,我们不得不添加了异常规约 [Stroustrup 2007]。但异常规约从来没有提供支持者们所声称的更好的可维护性,而确实提供了反对者(包括我)所诟病的冗长和开销。一旦异常规约出现在语言中,许多人就觉得使用它们是受到鼓励的,并将由此产生的问题归咎于异常机制本身。具有讽刺意味的是,那些坚定支持异常规约的人转而去帮助设计 Java 了。异常规约在 2010 年被宣布废弃,并最终在 2017 年被移除(§4.5.3)。作为部分替代方案,C++11 引入了 noexcept 作为一种更简单、更有效的控制异常的机制(§4.5.3)。
  • 通过指定要捕获的异常类型来捕获异常往往使 throwcatch 的实现与运行期类型识别(RTTI [Stroustrup 2007])纠缠在一起,这导致了效率低下和复杂性。特别是,它会导致内存被消耗(被 RTTI 所需的数据消耗),即使应用程序从不依赖 RTTI 来区分异常,对于简单的场景也很难做优化。而且,依赖 RTTI 使得使用动态链接的类型匹配很难优化。基本上,异常处理实现是针对罕见的最复杂的情况进行优化的。当一个具有嵌套异常的类被添加到标准库中,人们甚至被鼓励在最简单的情况下使用它时,情况就更糟了。对于可以静态分析的类层次结构(在许多嵌入式系统中),以常量时间进行快速类型匹配是可能的 [Gibbs and Stroustrup 2006]。由于异常是平台 ABI 的一部分,这就使得要改变早期的过度设计非常之困难。
  • 有人坚持只使用一种错误处理方法,并且通常得出这样的结论:由于异常不适用于每种情况,因此该方法必须是错误码。那些由错误码所带来的问题也就仅仅是“不方便而已”。
  • 一些人相信那些关于异常机制的基于最坏情况和/或不切实际的比较的低效传闻,例如在添加异常后保留错误码处理方式,将不完整的错误处理与基于异常的处理进行比较,或者使用异常来做简单的错误处理,而不是把异常用于无法在本地处理的错误。很少有关于异常及其替代方案成本的认真调查。我怀疑关于异常的虚假传说比任何事实都具有更大的影响力。

最终结果是 C++ 社区分裂为异常和非异常阵营。事实上,“不要异常”是一种方言,而方言是标准要避免的事情之一(§3.1)。对于个人组织或社区而言,方言可能有一些优势,但它使代码和技能的共享变得复杂,因此损害了整个 C++ 社区。

有人声称,异常机制的问题在于它违反了零开销原则(例如 [Sutter 2018b])。对比通过终止应用来响应错误的处理方案,任何错误处理机制显然都是开销,也都违反了零开销原则(除非考虑到处理终止的成本,例如在另一个处理器中)。在我们设计异常时,我们考虑了这些,并认为开销是可接受的。理由是:异常情况很少见;除非抛出异常,否则没有运行期开销;并且用于实现异常的表可以保存在虚拟内存中 [Koenig and Stroustrup 1989]。在虚拟内存不可用或内存不足的情况下,使用表来实现异常可能成为一个严重问题。我们当时设计异常时主要关注的是,需要某种形式的错误传播和错误处理的系统。在这种情况下,零开销可以解释为“异常与以在同样严格程度的错误处理下的错误码使用相比没有额外开销”。

如今,错误处理的混乱比以往任何时候都严重,处理错误的替代技术比以往任何时候都多,从而造成很大的混乱和危害。假设有 N 种错误处理方式,又有人提出了一个新的解决方案,只要旧的解决方案不被抛弃,现在我们就必须应对 N+1 种方式(“N+1 问题”)。如果一个组织有 M 个程序,使用了 N 个库,我们甚至可能有 N*M 个需要处理的问题。异常的引入可以看作是将处理错误的常用方法从 7 种增加到了 8 种。2015 年,Lawrence Crowl 撰写了一份问题分析报告 [Crowl2015a] 对这个问题进行了分析。

基础库的作者对多种错误处理方案的问题感受最为深刻。他们不知道他们的用户喜欢什么,他们的用户可能有很多不同的偏好。C++17 文件系统库(§8.6)的作者们选择了把接口重复一遍:对于每个操作,他们提供两个函数,一个在错误的情况下抛出异常,另一个函数则通过设置标准库的 error_code 参数将错误码通过参数传递出来:

bool create_directory(const filesystem::path& p); // 出现错误时抛异常
bool create_directory(const filesystem::path& p, error_code& ec) noexcept;

当然,这有点冗长,只会取悦那些仅喜欢异常或 error_code 的人。也要注意作者提供了 bool 返回值,这样人们就不必一直使用 try 或直接测试 error_code 了。事实上,文件系统(在我看来相当正确)使用异常来处理罕见的错误并不能让那些认为异常有根本缺陷的人满意,特别是,它仍要求存在异常支持。

7.3 noexcept 规约

使用 noexcept§4.5.3),人们可以抑制所有从函数抛出的异常,并允许调用者忽略抛出异常的可能性。

使用 noexcept 可以使担心性能问题(或真或假)的人们放心。它也可以通过减少控制路径的数量来改善优化效果,但前提是程序员不要通过测试返回码将这些路径添加回去。许多低级函数,例如大多数 C 函数,都不存在异常。

使用 noexcept 可以简化错误处理(如果一个函数不抛出异常,我们就不需要捕获任何异常),也可以使其复杂化(如果一个函数不能抛出异常,但又可能会失败,我们必须使用其他错误处理机制)。特别是,在异常抛出与其处理程序之间的路径上的 noexcept,会把一个异常变成程序终止运行。因此,对于一个处于维护期的程序,在函数中使用使用 noexcept,可能会导致先前正确的程序失败。

请注意,异常被添加到 C++ 中的一个重要原因是为了支持那些在发生错误时也决不可以无条件中止的应用。异常仅表示发生了故障,并且从 main() 到抛出点的路径上的任何代码都可以对其进行处理。特别是,这样可以支持一个重要场景:在终止之前进行一些本地清理(例如,刷新输出缓冲区,或向日志文件添加错误报告)。

7.4 类型系统的支持

解决 C++ 中的逻辑和性能问题的传统方法是将计算从运行期挪到编译期。显然,将异常与静态类型系统集成的可能性在 1980 年代被认真考虑过,后来又反复被重新考虑。如果异常是函数类型的一部分,那么程序就会有更好的类型检查,函数就更能自我描述,异常处理也更容易优化。

不将异常作为类型系统的一部分的一个主要原因是,如果异常是函数类型的一部分,那么对该函数可能抛出的异常集的更改将要求所有调用该函数的函数重新编译。在一个大多数主要程序都由许多单独开发的库组成的世界里,这将导致灾难性的脆弱,及无法管理的相互依赖 [Stroustrup 1994]。

函数指针方面也有相关的明显问题。在大多数主要的 C++ 程序中都有很多 C 风格的代码,现在仍然如此。C 风格的泛型代码(例如,qsort 的比较函数参数)和回调(例如,在 GUI 中)的主要参数化机制均会用到函数指针。

如果我需要一个指向函数的指针,并且异常是类型系统的一部分,那么,我要么决定始终从所指向的函数中获取异常,要么不接受异常,要么以某种方式处理这两种选择。除非将对类型查询的支持或基于异常的重载添加到语言中,否则都很难两者兼顾。确定了要接受哪种类型函数指针参数后,我现在必须调整调用函数中的错误检查方式以匹配所接受的函数指针类型。即使这些可以在 C++ 语言中处理,也将影响与 C 的交互:这时如何将指向 C++ 函数的指针传递给 C?例如,如何处理从 C 中回调依赖异常的 C++ 的函数?显然,C++ 函数中的异常不会消失,因此我们将有四种选择:错误码、编译期检查的异常(例如 [Sutter 2018b])、当前异常和 noexcept。只有当前的异常和非本地错误码不会影响类型系统或调用约定(ABI 接口)。幸运的是,很少有函数需要两个函数指针,否则我们将面临选择 16 种方案的风险。因此,如果接受异常类型系统(就当前的异常而言),混乱将是全方面的。

在现代 C++ 中,此类问题将以其他回调机制的不同形式继续存在,例如具有要被调用的成员函数的对象、函数对象和 lambda 表达式。

我的结论(得到 WG21 的认可)过去和现在都是,在 C++ 的静态类型系统中添加异常会导致系统脆弱、代码复杂性显著增加、严重的不兼容性以及与 C 代码交互的问题。这一点在 1989 年就得到了重视。

7.5 回归基础

从根本上讲,我认为 C++ 需要两种错误处理机制:

  • 异常——罕见的错误或直接调用者无法处理的错误。
  • 错误码——错误码表示可以由直接调用者处理的错误(通常隐藏在易于使用的检测操作中或作为 (值,错误码) 对从函数返回)。

考虑代码:

void user()
{
    vector<string> v{"hello!"};
    for (string s; cin>>s; )
        v.push_back(s);
    auto ps = make_unique<Shape>(read_shape(cin));
    Smiley_face face{Point{0,0},20};
    // ...
}

这个例子是人造的,但其编程风格并非不典型。我们可以从中看出,user() 函数里有很多发生不太可能的错误的可能性:内存耗尽、读取错误、构造失败(例如,在 Smile_face 的多层次结构中出现错误等)。另外,使用 unique_ptr<Shape> 可以防止内存泄漏。如果我们使用显式错误码而不是异常,那么这个函数中至少需要进行五次错误检查,源代码数量将翻倍,并需要在各个构造函数中进行更多检查。没有 RAII(及其与异常的集成),代码将进一步膨胀。一般来说,更多的代码意味着更多的错误。当添加的代码使控制流程复杂时,尤其如此。这一点经常被那些通过小例子论证的人所忽视。对于小例子来说,“就一项测试”关系不大,相对也很难漏掉。

另一方面,有些错误是预料得到的,我们更愿意使用某种形式的错误码来对其进行检查:

ifstream f {"Myfile"};
if (!f) {
    // ... 处理错误 ...
}
// ... 使用 f ...

在这里,为方便起见,错误码隐藏在输入流的状态里。

因此,在理想情况下,应该只有两种错误处理的方法,但是我真的不知道如何达到这样一种理想状态。仅仅 (值,错误码) 对就有十几种变体被广泛使用(例如 std::map::insert()),并且还有一些新的变体也在 2011 年的 WG21 中被讨论(如 [Botet and Bastien 2018; Sutter 2018b])。即使委员会能就其中一个方案达成一致,也仍然会有至少十几个广泛使用的错误处理方案,每个方案都有一大群忠实的追随者支持,许多方案都有数百万行难以更动的代码。

很少有关于异常的性能和 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]。当时我的猜测是,允许恢复将使异常处理的速度至少再降低两倍。

8. C++17:大海迷航

在经过 C++14 这个小版本标准之后,C++17 [Smith 2017] 原本被看作是一个大版本。C++17 有很多新的特性,但没有一个我认为称得上重大。尽管我们已经有给 C++11 和 C++14 带来成功的工作流程,标准社区也更丰富、更强大、更热情,但对于 C++17 的关键问题是:为什么所有的辛劳却没有带来更显著的改进?

C++17 有大约 21 个新的语言特性(取决于你的计数方式),包括:

  • 构造函数模板参数推导——简化对象定义(§8.1
  • 推导指引——解决构造函数模板参数推导歧义的明确写法(§8.1
  • 结构化绑定——简化写法并消除一种未初始化变量的来源(§8.2
  • inline 变量——简化了那些仅有头文件的库实现中的静态分配变量的使用 [Finkel and Smith 2016]
  • 折叠表达式——简化变参模板的一些用法 [Sutton and Smith 2014]
  • 条件中的显式测试——有点像 for 语句中的条件(§8.7
  • 保证的复制消除——去除了很多不必要的拷贝操作 [Smith 2015]
  • 更严格的表达式求值顺序——防止了一些细微的求值顺序错误 [Dos Reis et al. 2016b]
  • auto 当作模板参数类型——值模板参数的类型推导 [Touton and Spertus 2016]
  • 捕捉常见错误的标准属性——[[maybe_unused]][[nodiscard]][[fallthrough]] [Tomazos 2015]
  • 十六进制浮点字面量 [Köppe 2016a]
  • 常量表达式 if——简化编译期求值的代码 [Voutilainen and Vandevoorde 2016]

不幸的是,这并不是完整的功能扩展列表。相当一部分是如此之小,我们很难简单地描述它们。

C++17 标准库中增加了大约 13 个新特性,并加上了许多小的修改:

  • optionalanyvariant——用于表达“可选”的标准库类型(§8.3
  • shared_mutexshared_lock(读写锁)和 scoped_lock§8.4
  • 并行 STL——标准库算法的多线程及矢量化版本(§8.5
  • 文件系统——可移植地操作文件系统路径和目录的能力(§8.6
  • string_view——对不可变字符序列的非所有权引用 [Yasskin 2014]
  • 数学特殊函数——包括拉盖尔和勒让德多项式、贝塔函数、黎曼泽塔函数 [Reverdy 2012]

尽管我也喜欢 C++17 中的某些功能,但令人困扰的是这些功能没有统一的主题,没有整体的规划,似乎只是由于可以达到投票多数而被扔进语言和标准库中的一组“聪明的想法”。这种状况可能给未来语言的发展带来更大的弊端,因此必须采取一些措施做出改变 [Stroustrup 2018d]。方向小组的成立是 WG21 针对这个问题的回应(§3.2)(§9.1)的一部分。

不可否认,C++17 提供了一些可以在小方面帮助大多数程序员的东西,但没有什么可以让我认为是重大的。在这里,我将“重大”定义为“对我们思考编程和组织代码的方式产生影响”。在此,我描述了我猜想会产生最大积极影响的功能。

我也检查了一些尽管经过严肃考虑、仍没有进入 C++17 标准的例子:

  • §6.3.8:概念(C++20)
  • §8.8.1:网络库
  • §8.8.2:点运算符(operator.()
  • §8.8.3:统一函数调用
  • §8.8.4:简单类型的默认比较运算符 ==!=<<=>>=
  • §9.3.2:协程(C++20)

我怀疑如果它们被采纳的话,其中的任何一项都会成为 C++17 最重要的特性之一。它们符合 C++ 应该成为什么的一致观点(§9.2);即使只有少数几项,也会极大地改变 C++17 的使用方式。

在 C++11 中我看到了相互支持的特性网,它们带来了更好的代码编写方式。对于 C++17,我没有看到。但是,C++20 完善了这样一张网,使 C++ 又向前迈进了一大步(§9)。可以说 C++17 只是通向 C++20 路上的垫脚石,但是委员会的讨论对此毫无暗示,重点始终放在单独的特性上。我甚至听到有人说“列车模型”(§3.2)不适合长期规划;事实并非如此。

8.1 构造函数模板参数推导

几十年来,人们好奇为什么模板参数可以从其他函数参数中推导出来,却不能从构造函数参数中推导。例如,在 C++98、C++11 和 C++14 中:

pair<string,int> p0 (string("Hi!"),129);  // 不需要推导
auto p1 = make_pair("Hi!"s,129);          // p1 是 pair<string,int>
pair p2 ("Hi!"s,129);    // 错误:pair 缺少模板参数

很自然,在我第一次设计模板的时候,我也考虑过从构造函数参数中推导出模板参数的可能性,但因为担心出现歧义而止步。解决方案也有技术障碍,但 Michael Spertus 和 Richard Smith 克服了这些障碍。所以在 C++17 中,我们可以写上面最后一个例子中那样的代码(p2)而不会报错,这样一来就不需要 make_pair() 了。

这简化了类型的使用,例如 pairtuple,还有当编写并行的代码时用到的锁和互斥锁(§8.4)。

shared_lock lck {m};    // 不需要显式写出锁类型

这是一个在 C++17 中少见的例子,相互支持的特性促成了明显的代码简化。不幸的是,这些简化被接受与否都是个案,而非总体的简化努力的结果。所以,在类型推导规则中“填坑”的努力仍在继续 [Spertus et al. 2018]。

除了这里的描述之外,这套机制提供了解决歧义的一种写法(§8.3)。

8.2 结构化绑定

结构化绑定始于 Herb Sutter、Bjarne Stroustrup 和 Gabriel Dos Reis 的一个简单的提案 [Sutter et al. 2015],旨在简化写法和消除剩余的几个变量未初始化的来源。例如:

template<typename T, typename U>
void print(vector<pair<T,U>>& v)
{
    for (auto [x,y] : v)
        cout << '{' << x << ' ' << y << "}\n";
}

名称 xy 被分别绑定于 pair 的第一个和第二个元素。这可算作是写法上的重大便利。

C++14 给我们提供了返回多个值的方便方式。例如:

tuple<T1,T2,T3> f(/*...*/)  // 优美的声明语法
{
    // ...
    return {a,b,c};  // 优美的返回语法
}

我认为在当前的 C++ 中,tuple 有点被过度使用了,当多个值并不互相独立的时候,我倾向于使用明确定义的类型,但从写法上讲,这没有什么区别。然而,C++14 并没有提供像创建多返回值那样方便的方式去解包它们。这导致了繁琐的变通解决方案、变量未初始化或运行期开销。例如:

tuple<T1,T2,T3> res = f();
T1& alpha = get<0>(res);    // 通过 alpha 来间接访问
T2& val = get<1>(res);
T3 err_code = get<2>(res);  // 拷贝

很多专家更喜欢用标准库函数 tie() 去解包 tuple

T1 x;
T2 y;
T3 z;
// ...
tie(x,y,z) = f(); // 使用现有变量的优美调用方式

tie() 函数赋值的时候,会向 tie() 函数的参数赋值。然而,使用 tie,你必须分别定义变量,并且写出它们的类型以匹配 f() 返回的对象的成员(在这个例子中就是 T1T2、和 T3)。不幸的是,这会导致局部变量“设置前使用”的错误,及“初始化后赋值”的开销。并且,大多数程序员并不知道 tie() 的存在,或者认为在真实代码中使用它太奇怪了。

Herb Sutter 建议了一种跟正常返回语法类似的方案:

auto {x,y,z} = f(); // 优美的调用语法,会引入别名

这对任何有三个成员的 struct 都有效,而不仅仅只对 tuple。消除核心指南(§10.6)中未初始化变量的倒数第二个来源是我的主要动机。是的,我喜欢这种写法,但更重要的是它使得 C++ 更接近于其理想。

不是每个人都喜欢这个想法,而且我们几乎没能在 C++17 中及时讨论它。提出结构化绑定的论文 [Sutter et al. 2015] 比较晚,而正当 2015 年 11 月底在科纳 Ville Voutilainen 刚要结束 EWG 会议时,我注意到我们离午饭还有 45 分钟,我觉得小组应该会想要看到这个提案。2015 年科纳的会议是我们冻结 C++17 的功能集的时间点,所以这 45 分钟很关键。我们甚至没时间去另一个小组找到 Herb,我就直接讲了这个提案。EWG 喜欢这个提案,会议纪要说鼓掌以资鼓励;EWG 想要这样的东西

现在,真正的工作开始了。

在这个及以后的会议中,几个人——尤其是 Chandler Carruth——指出要达到 C++ 的理想,我们需要扩展将一个对象分解为多个值的能力,以应对不是 tuple 或普通 struct 的类型。例如:

complex<double> z = 2+3i;
auto {re,im} = sqrt(z);      // sqrt() 返回复数值

标准库类型 complex 并没有暴露其内部表示。

在 C++17 中我们通过允许用户定义一系列 get 函数解决了这个问题,例如,get<0>get<1> 实际上是假装将结果看作是 tuple。这能工作,但需要用户提供一些不优雅的重复样板式代码。关于潜在改进的讨论仍在继续,但没有明显的简化被纳入 C++20。

有人要求让这种方式也能适用于返回数组的函数和返回带位域的 struct 的函数。我们加入了对那些情况的支持,所以最终设计至少比原始提案复杂了一倍。

有一个冗长的争论(跨多次会议),是关于是否可能(或必须)显式地指定被引入的局部变量类型。例如:

auto {int x, const double* y, string& z} = f();    // 非 C++

关于这种做法的理由——其中最雄辩的当属 Ville Voutilainen——如果没有显式类型,写法的可读性将会降低,从而损害可维护性,还可能导致错误。这跟常见的反对 auto 的理由很相似,而显式类型也会有它们自己的问题。如果类型跟返回值不匹配怎么办?有人说这应该属于错误。有些人说,转换到指定的类型将是非常有用的(例如,char[20] 返回到 string 中)。我指出结构化绑定应该引入零开销别名,而任何意味着表示变化的类型转换将导致显著的开销。并且,结构化绑定的一个目的是优化写法,而要求显式类型会导致代码比现有的方式更加冗长。

最初的提案使用花括号({})来组合引入的名字:

auto {x,y,z} = f(); // 优美的调用语法,引入别名

然而一些成员,如 Chandler Carruth 和 David Vandevoorde,怕语法上会有歧义,而坚持认为这样会令人困惑,“因为 {} 意味着作用域”。所以我们有了 [] 语法:

auto [x,y,z] = f(); // 调用语法,引入别名

这是个小改动,但我认为是个错误。这个最后一刻的改动,导致了属性写法的小小复杂化(比如 [[fallthrough]])(§4.2.10)。我对关于美学或作用域的论据并不买账,并且在 2014 年我就展示了关于为 C++ 添加函数式编程风格的模式匹配的想法,以 { … } 表示用模式将值分解出来(§8.3)。结构化绑定的设计就是为了适应这一总体方案。

这些并不是唯一的后期修改提案。每个提案都增加了或将增加复杂性。

对语言每次升级仅孤立地增加一项功能是危险的。除非符合更大的规划,最后一刻的改变也是危险的,容易导致在要求“完整性”的过程中“膨胀”。在这个结构化绑定的例子中,我不相信允许结构化绑定指定位域能提供充分的效用,值得为之提高复杂性。

8.3 variantoptionalany

可以使用 union 来无运行期开销地表示多个可选的类型。例如:

union U {
    int i;
    char* p;
};

U u;
// ...
int x = u.i;    // 正确:当且仅当 u 持有整数
char* p = u.p;  // 正确:当且仅当 u 持有指针

从 C 语言最早期开始,这就被当作一个不同的类型之间“分时共享”内存的基本方法来使用和误用。没有编译期和运行期的检查来确保这个地址仅被用作其真实指代的类型。确保 union 成员在使用上一致,是程序员的职责,然而令人头痛的是程序员常在这个地方出错。

有经验的程序员通过将联合体封装在类中来避免问题,用类来确保正确使用。Boost 特别提供了三种这样的类型:

  • optional<T>——持有 T 或什么都不持有
  • variant<T,U>——持有 TU
  • any——持有任意类型

这些类型的巨大效用已经在 C++ 和许多其他语言中得到了证明。

委员会决定对这三种类型进行标准化。不幸的是,这三种类型的设计被分开讨论,好像它们的使用情况毫不相干一样。相对于标准库而言,直接语言支持的可能性似乎从未被认真考虑。结果是三种标准库类型(就像它们的 Boost 祖先一样)彼此之间有很大的不同。因此,尽管这些类型的效用毋庸置疑,但它们是委员会设计的一个典型案例。试考虑:

optional<int> var1 = 7;
variant<int,string> var2 = 7;
any var3 = 7;

auto x1 = *var1 ;               // 对 optional 解引用
auto x2 = get<int>(var2);       // 像访问 tuple 一样访问 variant
auto x3 = any_cast<int>(var3);  // 转换 any

为了提取存储的值,需要使用三种不兼容的写法之一。这对程序员来讲是一种负担。没错,有经验的程序员会习惯的,但这种非要人们去习惯的不规则性本就不该存在。

为了简化 variant 的使用,有一种访问者机制。首先我们需要一个辅助模板去定义一个重载集合:

// 简单访问的样板:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

模板 overloaded 真应该成为标准。只有那些熟悉变参模板(§4.3.2)和模板参数推导(§8.1)的人才会觉得它比较简单。不过,有了 overloaded,我就能根据变体的类型来构造出分支:

using var_t = std::variant<int, long, double, std::string>; // variant 类型

// 简单访问的样板:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

void use()
{
    std::vector<var_t> vec = {10, 20L, 30.40, "hello"};

    for (auto& var : vec) {
        std::visit (overloaded {
            [](auto arg) { cout << arg << '\n'; },    // 处理整数类型
            [](double arg) { cout << "double : " << arg << '\n'; },
            [](const std::string& arg) { cout << "\"" << arg << "\"\n"; },
        }, var);
    }
}

毋庸置疑,variant 和它的伙伴们解决了一个重要问题,但其方式并不优雅。或许将来的工作能减轻接口不一致上的困惑,从而让人能专注于真正需要区分的地方。同时,应该让更多的 C++ 同仁去使用这些新的类型,从而消除 union 经年累月带来的老问题。

我认为这三种可辨识 union 的变体只是权宜之计。要解决 union 的问题,函数式编程风格的模式匹配要优雅、通用得多,也潜在地更为高效。在 2014 年 11 月在伊利诺伊大学厄巴纳——香槟分校举行的会议上,我发表了关于模式匹配相关设计问题的演讲 [Solodkyy et al. 2014],部分内容基于我同得州农工大学的 Yuriy Solodkyy 和 Gabriel Dos Reis 合作的研究 [Solodkyy et al. 2013]。我们有一个库的实现,它的性能和函数式编程语言相若,尽管它没有和编译器进行集成。这个库既能应对包含多个可选类型的封闭集合(代数类型),也能应对开放集合(类层次结构)。我们的目的之一是消除对访问者模式的使用 [Gamma et al. 1994]。然而,我们没有一种能让人普遍接受的语法。我演讲的目的是提高人们的兴趣,并设定长期的目标。人们对此很感兴趣。在 C++17 完成后,工作就开始了 [Murzin et al. 2019, 2020]。或许模式匹配能加入到 C++23 中(§11.5)。

8.4 并发

在 C++17 中,以下类型的加入极大地简化了锁的使用:

  • scoped_lock——获取任意数量的锁,而不会造成死锁
  • shared_mutexshared_lock——实现读写锁

例如,我们能获取多个锁,而不用担心会产生死锁:

void f()
{
    scoped_lock lck {mutex1, mutex2, mutex3}; // 获得所有三把锁
    // ... 操作共享数据 ...
} // 隐式地释放所有锁

C++11 和 C++14 没能带给我们读写锁。这显然是个严重的疏忽,原因是各种提议的压力,以及处理提议所需的时间。C++17 通过加入 shared_mutex 解决了这一问题:

shared_mutex mx;    // 一个可以被共享的锁
void reader()
{
    shared_lock lck {mx};  // 跟其他 reader 共享访问
    // ... 读 ...
}
void writer()
{
    unique_lock lck {mx};  // writer 需要独占访问
    // ... 写 ...
}

多个读线程可以“共享”该锁(即同时进入临界区),而写线程则需要独占访问。

我认为这些例子很好体现了“简单的事情简单做”的哲学。有时,我同很多 C++ 程序员一样在想,“是什么让他们花了这么长时间?”

请注意使用从构造函数参数推导出来的模板参数是如何简化了写法的(§8.1)。

8.5 并行 STL

从长远来看,并行算法的使用将是非常重要的,因为从用户角度看,没有什么比只说“请执行这个算法”更简单的了。从实现者的角度来看,算法中有一套特定接口而没有对算法的串行约束将是一个机会。C++17 只迈出了一小步,但这远比没有开始好得多,因为它指明了方向。不出意外,委员会中有一些反对的声音,大多数来自于希望为专家级用户提供复杂接口的人。有些人对这样简单的一个方案是否可行表示严重怀疑,并主张推迟这一方案。

基本的想法是,为每个标准库算法提供一个额外参数,允许用户请求向量化和/或多线程。例如:

sort(par_unseq, begin(v), end(v));  // 考虑并行和向量化

但这还只适用于 STL 算法,所以重要的 find_anyfind_all 算法被忽略了。将来我们会看到专门为并行使用而设计的算法。这正在 C++20 中变为现实。

另一个弱点是,仍然没有取消一个线程的标准方法。例如,在搜索中找到一个对象后,一个线程不能停止其他正在并行执行的搜索。这是 POSIX 干预的结果,它反对所有形式的取消操作(§4.1.2)。C++ 20 提供了协作式取消(§9.4)。

C++17 的并行算法也支持向量化。这很重要,因为对 SIMD 的优化支持是硬件在单线程性能方面仍然(2017 年后)有巨大增长的少数领域之一。

在 C++20 中,我们(总算)能用范围库(§6.3)来避免显式使用容器的元素序列,只要这么写:

sort(v);

不幸的是,并行版本的范围在 C++20 中没有及时完成,因此我们只能等到 C++23 才能这么写:

sort(par_unseq, v);  // 使用并行和向量化来对 v 进行排序

不想等 23 的话,我们可以自己实现适配器:

template<typename T>
concept execution_policy = std::is_execution_policy<T>::value;

void sort(execution_policy auto&& ex, std::random_access_range auto& r)
{
    sort(ex, begin(r), end(r));  // 使用执行策略 ex 来排序
}

毕竟标准库是可扩展的。

8.6 文件系统

2002 年,Beman Dawes 编写了 Boost 文件系统库,成为最受欢迎的 Boost 库之一 [Boost 1998–2020]。2014 年,Boost 文件系统库 [Dawes 2002–2014](经修改后)被加入了 TS [Dawes 2014, 2015],又经过进一步修改被加入了 C++17 标准。跟文件名和文件系统打交道是很棘手的,因为它涉及到并行、多种自然语言和操作系统间的差异。最终能通过标准方式操作目录(文件夹)是件好事(正如 Boost 从 15 年前开始做的那样)。提供的关键类型是 path,对字符集和文件系统的不同写法进行了抽象。例如:

void do_something(const string& name)
{
    path p {name};  // name 可能是俄语或阿拉伯语
                    // name 可能使用 Windows 或 Linux 文件写法
    try {
        if (exists(p)) {
            if (is_regular_file(p))
                cout << p << " regular file, size: " << file_size(p) << '\n';
            else if (is_directory(p)) {
                cout << p << " directory, containing:\n";
                for (auto& x : directory_iterator(p))
                    cout << "    " << x.path() << '\n';
            }
            else
                cout << p << " exists\n";
        }
        else
            cout << p << " does not exist\n";
    }
    catch (const filesystem_error& ex) {
        cerr << ex.what() << '\n';
        throw;
    }
    // ... 使用 p ...
}

捕捉异常可以防止罕见的错误,比如有人在 exists(p) 检查后、执行详细检索前删除了文件。文件系统接口同时为罕见(异常)和常见(预期)错误提供了支持(§7.2)。

8.7 条件的显式测试

我认为“很多小的提案”是危险的,即使每个都能帮助一些人。考虑为条件增加显式测试的能力 [Köppe 2016b]:

if (auto p = f(y); p->m>0) {
    // ...
}

p->m>0 是一个显式测试,它的意思是:

{
    auto p = f(y);
    if (p->m>0) {
        // ...
    }
}

这是对 C++98 里在条件里同时声明和测试的推广(§2.2.1):

if (auto pd = dynamic_cast<Derived*>(pb)) { // 如果 pd 指向 Derived 类型则为真
    // ...
}

问题是这种推广是否足够明显和有用,值得作为提案引入。我的回答是否定的。然而,这是我被否决的一个例子(不是很罕见)。

我的观点是,显式测试最好体现在 if 语句中。那里更不容易被忽视,而且遵循常规有其好处,特别是对那些不仅仅使用 C++ 语言编程的人。另一方面,显式测试似乎在有的人那里很受欢迎,他们的代码设计成需要对每个函数的结果都做错误检查。我个人非常反感那种设计风格(§7.5)。

有些人为了用上新特性而积极地重写代码。我听说过好几个例子,有人一看到下面这样的代码:

if (auto p = f(y)) {
    if (p->m>2) {
        // ...
    }
    // ...
}

就立刻重写为这样:

if (auto p = f(y); p->m>2) {
    // ...
}

并声称这样更优雅和简洁。自然,当 p==nullptr 时它会崩溃,而最初的代码不会。无论我们从中能得到什么好处,这样的重写可能带来新的错误和混乱。

为了通用,显式测试也可以用在 switchwhile 条件中。在 C++20 中,这一机制被进一步扩展到可以在范围 for 语句中包含初始化 [Köppe 2017c]。

8.8 C++17 中未包含的提议

除了概念(§6.3.8)以外,一些我认为很重要的提案没有加入 C++17。如果不提及它们,C++ 的历史就不完整:

静态反射是在一个研究小组(§3)中处理的,并不在 C++17 的既定规划之中。但作为一项重要工作,它是在这一时期启动的。

8.8.1 网络库

在 2003 年,Christopher M. Kohlhoff 开始开发一个名叫 asio 的库,以提供网络支持 [Kohlhoff 2018]:

“Asio 是用于网络和底层 I/O 编程的一个跨平台 C++ 库,它采用现代化 C++ 的方式,为开发者提供了一致的异步模型”

在 2005 年,它成为了 Boost [Kohlhoff 2005] 的一部分,并在 2006 年被提案进入标准 [Kohlhoff 2006]。在 2018 年,它成为了 TS [Wakely 2018]。尽管经过了 13 年的重度生产环境使用,它还是未能进入 C++17 标准。更糟糕的是,让网络库进入 C++20 标准的工作也停滞不前。这意味着,在 asio 得以在生产环境中使用 15 年之后,我们还是不得不至少等到 2023 年,才能看到它成为标准的一部分。延误原因在于,我们仍在进行严肃的讨论,如何最好地将 asio 中和其他场合中处理并发的方式一般化。为此提出的“执行器(executors)”提案得到了广泛的支持,并且有人还期望它能成功进入 C++20 [Hoberock et al. 2019, 2018]。我认为 C++20 中执行器和网络库的缺失,正是“最好是好的敌人”的一个例子。

8.8.2 点运算符

在标准化进程启动之初,首个对 C++ 扩展的提案,就是由 Jim Adcock 在 1990 年提出的允许重载点(.)运算符的提案 [Adcock 1990]。从 1984 年开始,我们就可以重载箭头运算符(->),并且该机制被重度使用,以实现“智能指针”(比如 shared_ptr)。人们当时希望(并且现在仍然希望)能重载点运算符以实现智能引用(代理)。基本上,人们想要有一种方式,使得 x.f() 意味着 x.operator.().f(),从而 operator.() 可以控制对成员的访问。然而,关于该议题的讨论总是陷入僵局,因为大家对于重载版的点运算符是否应该应用到其隐式使用上无法达成一致。举个例子:++x 对于用户定义类型,被解释为 x.operator++()。现在,如果用户定义类型定义了 operator.()++x 是否应该表示 x.operator.().operator++()?Andrew Koenig 和 Bjarne Stroustrup 在 1991 年 [Koenig and Stroustrup 1991a] 尝试过解决这个问题,但被最初的提案者 Jim Adcock 所强烈反对。Gary Powell、Doug Gregor 和 Jaakko Järvi 在 2004 年再度进行了尝试,试图提案到 C++0x [Powell et al. 2004],但在委员会那里又一次陷入僵局。最后,在 2014 年,Bjarne Stroustrup 和 Gabriel Dos Reis 又进行了一次尝试,试图提案到 C++17,我认为该提案 [Stroustrup and Dos Reis 2014] 是更为全面的,也是更为合理的。举例如下:

template<class X>
class Ref {  // 智能引用(带有所有权)
public:
    explicit Ref(int a) : p{new X{a}} {}
    X& operator.() { /* 这里可以有代码 */ return *p; }
    ~Ref() { delete p; }
    void rebind(X* pp) { delete p; p=pp; }
    // ...
private:
    X* p;
};

Ref<X> x {99};
x.f();                // 意思是 (x.operator.()).f() 即  (*x.p).f()
x = X{9};             // 意思是 x.operator.() = X{9} 即 (*x.p)=X{9}
x.rebind(new X{77});  // 意思是 x 持有并拥有那个新的 X

其基本想法是,在“句柄”(这里是 Ref 类)中定义的运算(比如构造、析构、operator.()rebind())会作用于句柄之上,而没有在“句柄”中定义的运算则作用于该句柄所对应的 “值”,也就是 operator.() 的结果之上。

在付出很多努力之后 [Stroustrup and Dos Reis 2016],这个提案也失败了。2014 年的这份提案失败的原因颇为有趣。当然,设计中还存在一些常见的措辞问题和模糊的“阴暗角落”,但我认为,这份提案本来是可以获得成功的,如果不是因为委员会对智能引用的想法太过激动以至于逐渐偏离了目标,再加上 Mathias Gaunard 和 Dietmar Kühl [Gaunard and Kühl 2015] 以及 Hubert Tong 和 Faisal Vali [Tong and Vali 2016] 也分别提交了替代方案的话。这两份提案中,前者需要所有试图定义 operator.() 的使用者去重度使用模板元编程,而后者基本上是面向对象的,引入了一种新的继承形态和隐式转换。

operator.() 的动作应该取决于将被访问的成员呢?还是说 operator.() 应该是个一元运算符,仅仅依赖于它应用的对象呢(就像 operator->() 一样)?前者是 Gaunard 和 Kühl 的提案的核心。Bjarne Stroustrup 和 Gabriel Dos Reis 也考虑过让 operator.() 成为二元运算符,但结论是这种方案过于复杂,而且在这件事上跟箭头运算符(->)保持匹配是重要的。

最后,虽然初始的提案并没有被真正拒绝(它被 EWG 所批准,但从未进入全体委员会投票的阶段),但由于缺乏新的输入从而无法在相互竞争的提案中间赢得共识,进一步的进展也就停滞不前了。另外,最初的提议者(Bjarne Stroustrup 和 Gabriel Dos Reis)也被更为重要的提案以及他们的“日常工作”分散了精力,比如概念(§6)和模块(§9.3.1)。我认为点运算符的历程是一个典型案例,体现了委员会成员对于 C++ 是什么和它应该发展成什么样(§9.1)缺乏共同的看法。三十年的时间,六个提案,很多次的讨论,大量的设计和实现工作,然后我们仍然一无所获。

8.8.3 统一调用语法

对概念的首次讨论是在 2003 年,在这个过程中提及了函数调用需要一个统一的语法 [Stroustrup and Dos Reis 2003b]。也就是说,理想情况下 x.f(y)f(x,y) 应该含义相同。重点是,当编写泛型库时,你必须决定调用参数做运算时是采用面向对象的写法还是函数式的写法(x.f(y)f(x,y))。而作为用户,你不得不适应库的设计者所做出的选择。不同的库和不同的组织会有不同的选择。对于运算符,如 +*,统一的重载决策是一直以来的规则;也就是说,一个使用(比如 x+y)既会找到成员函数,也会找到独立函数。在标准库中,我们使用泛滥成灾的成对的函数来应对这种困境(例如,让 begin(x)x.begin() 都能使用)。

我应该在 1985 年左右,在委员会纠结于细节和潜在问题之前,就把这个问题解决掉。但我当时没能把运算符的情形推广。

在 2014 年,Herb Sutter 和我各自提案了“统一函数调用语法” [Stroustrup 2014a; Sutter 2014]。当然,这两份提案并不兼容,但我们立刻解决了兼容问题,并将它们合并成了一份联合提案 [Stroustrup and Sutter 2015]。

Herb 的部分动力来自于希望在 IDE 里面支持自动完成,并且倾向于“面向对象”的写法(例如 x.f(y)),而我则主要出于泛型编程的考虑,并且倾向于传统的数学式写法(例如 f(x,y))。

一如既往地,第一个严重的反对意见是兼容性问题;也就是,我们可能会破坏现有的代码。最初的提案确实可能会破坏一些代码,因为它倾向于更好的匹配或使得调用变得含糊,而我们的辩论主张是它是值得的,并且往往是有益的。但我们在这场辩论中失败了,之后我们重新准备了一份修改过的版本,其工作方式基于一个原则,x.f(y) 会首先查找 x 的类,仅当无法找到 f 成员函数时,才考虑 f(x,y)。类似的,f(x,y) 只会在没有相应的独立函数的情况下才会查找 x 对应的类。这个方案并不会让 f(x,y)x.f(y) 完全等价,但显然它不会破坏现有代码。

这看起来很有希望,但却遭到了一片愤怒的嚎叫:它将意味着稳定接口的终结!这个观点主要由来自谷歌的人提出,他们认为依赖于重载决策的接口无法再保持稳定了,因为添加一个函数就有可能改变现有代码的含义。这当然是真的。考虑:

void print(int);
void print(double);

print('a');       // 打印 'a' 的整数值

void print(char); // 添加一个 print () 以改变重载集合

print('a');       // 打印字符 'a'

我对于这个观点的回应就是,几乎任何程序都可被相当多的各种新增声明改变其含义。而且,重载的一个常见用法,就是通过添加函数,来提供语义上更佳的方案(往往是为了修复缺陷)。我们总是强烈建议,不要在程序的半途添加会导致重载集合的调用语义发生变化的重载(比如上例中的 print(char))。换句话说,这个“稳定”的定义是不切实际的。我(和其他人)指出,这个问题对于类成员也早就存在了。反方的基本回应是说,类成员的集合是封闭的,所以这个问题在类成员上是可控的。我观察到,通过使用命名空间,和某个类相关的独立函数集合几乎可以像成员一样来识别 [Stroustrup 2015b]。

在这个时候,大量的争议和混乱爆发了,新的提案也开始出现,并和正处于讨论中的提案竞争。英国的代表建议采用 C# 风格的拓展方法 [Coe and Orr 2015],而其他一些人,尤其是 John Spicer 坚持认为,如果我们需要一种统一的函数调用写法,那它应该是一种全新的写法,以和现有的两种相区分。我还是不能看出添加第三种写法(例如所建议的 .f(x,y))能统一什么。这只会变成 N+1 问题(§4.2.5)的又一个案例。

在提案被否决后,我被要求在有了模块后(§9.3.1)重新审视该问题。到那时,对独立函数名字的查找范围就可以被限定在它第一个参数的类所在的模块。这可能可以使统一函数调用的提案起死回生,但我仍然无法看出这可以怎样解决(在我看来过于夸大的)关于接口稳定性的顾虑。

又一次,对 C++ 的角色和未来缺乏共同的看法阻碍了事情的进展(§9.1)。

回过头来看,我认为面向对象的写法(如 x.f(y))压根就不该被引入。传统的数学式写法 f(x,y) 就足够了。而且作为一个附带的好处,数学式写法可以很自然的给我们带来多方法(multi-methods),从而将我们从访问者模式这个变通方案 [Solodkyy et al. 2012] 中拯救出来。

8.8.4 缺省比较

和 C 一样,C++ 并没有给数据结构提供缺省的比较。比如:

struct S {
    char a;
    int b;
};

S s1 = {'a',1};
S s2 = {'a',1};

void text ()
{
    S s3 = s1 ;       // 可以,初始化
    s2 = s1 ;         // 可以,赋值
    if (s1 == s2) { /* ... */ }  // 错误:== 对 S 未定义
}

其原因在于,考虑到 S 的通常内存布局,在持有 S 的内存中的部分会有“未使用的比特位”,因此 s1==s2 的朴素实现,也就是比较持有 s1s2 的字的比特位的方式,可能会给出 false 值。如果不是由于这些“未使用的比特位”,C 语言至少会有缺省的等值比较。我在 1980 年代早期曾经和 Dennis Ritchie 进行过讨论,但我们当时都太忙了,因而没时间为解决这个问题做些什么。这个问题对于复制(如 s1=s2)不是个问题,朴素而传统的方案就是简单的复制所有比特位。

由于简单实现的效率,允许赋值而不允许比较在 1970 年代是合适的,而到了 2010 年代就不合适了。现在我们的优化器可以很容易地处理这个问题,而且我——跟其他很多人一样——已经厌倦了解释为什么没有提供这样的缺省比较。尤其是很多 STL 算法需要 ==<,如果用户没有显式地为这些数据结构定义 operator==() 和/或 operator<(),它们就无法支持简单的数据结构。

在 2014 年,Oleg Smolsky [Smolsky 2014] 提议了一种定义比较运算符的简单方法:

struct Thing {
    int a, b, c;
    std::string d;

    bool operator==(const Thing &) const = default;
    bool operator<(const Thing &) const = default;

    bool operator!=(const Thing &) const = default;

    bool operator>=(const Thing &) const = default;
    bool operator>(const Thing &) const = default;
    bool operator<=(const Thing &) const = default;
};

这处理了正确的问题,但它是繁琐的(长长的六行代码就为了说明“我想要缺省的运算符”),并且,和缺省就有比较运算符相比,这绝对是退而求其次了。它还有些其他的技术问题(例如“但这个方案是侵入式的:如果我不能修改一个类,我就没法给它添加比较能力”),但现在竞赛已经是在如何更好地在 C++17 支持运算符上了。

我写了一篇论文讨论这个问题 [Stroustrup 2014c],并且提议为简单类提供缺省比较 [Stroustrup 2014b]。事实证明,在这个上下文中,很难定义“一个类是简单的”意味着什么,而且 Jens Maurer 发现了一些令人不愉快的作用域问题,关于在有了缺省运算符的同时又自定义比较运算符的组合情况(例如,“在使用了缺省的 == 之后,如果我们在不同的作用域又定义了 operator==(),这意味着什么?”)。

Oleg、我还有其他人写了更多的其他论文,但提案都停滞了。人们开始在提案上堆积更多的要求。比如,要求缺省比较的性能在简单使用情况下要和三路比较相等。Lawrence Crowl 写了对通用的比较的分析 [Crowl 2015b],论及如全序、弱序和偏序这样的问题。EWG 的普遍观点是 Lawrence 的分析非常棒,但他需要时间机器才能把这些机制加入到 C++ 中。

最后,在 2017 年,Herb Sutter 给出了一份提案(部分基于 Lawrence Crowl 的工作),该提案基于三路比较运算符 <=>(如在各种语言中可见到的),基于该运算符可以生成其他常用的运算符 [Sutter 2017a]。它没有为我们提供缺省的运算符,但至少它让我们可以用一行公式去定义它们:

struct S {
    char a;
    int b;
    friend std::strong_order operator<=>(S,S) = default;
};

S s1 = {'a',1};
S s2 = {'a',1};

bool b0 = s1==s2;    // true
int b1 = s1<=>s2;    // 0
bool b2 = s1<s2;     // false

上述方案是 Herb Sutter 所推荐的,因为它带来的问题最少(例如跟重载和作用域相关的),但它是侵入式的。我无法在不能修改的类中使用这个方案。在这种情况下,可以定义一个非成员函数的 <=>

struct S {
    char a;
    int b;
};

std::strong_order operator<=>(S,S) = default;

关于 <=> 的提案包含了一个可选项,为简单类隐式定义 <=>,但不出所料,认为一切都是显式的才更安全的人们投票否决了这个选项。

于是,我们得到的并不是一个让简单的例子在新手手中按预期工作的功能,而是一个允许专家仔细打造精妙比较运算的复杂功能。

尽管这个 <=> 的提案并没有可用的实现,并且对标准库有强烈潜在影响。它还是比其他任何我能想到的近期的提案都更容易地通过了委员会。不出所料,这个提案带来了很多惊讶(§9.3.4),包括导致之前 == 提案未能成功的查找问题。我猜测,关于比较运算符的讨论让很多人相信了我们总得做些什么,而 <=> 提案解决了很多各种问题,并与其他语言中熟悉的内容相吻合。

将来的某个时间,我很可能会再次提议为简单类缺省定义 ==<=>。C++ 的新人和普通用户理当享有这种简单性。

<=> 被提议于 2017 年,错过了 C++17,但经过后来很多进一步的工作,它进入了 C++20(§9.3.4)。

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] 中的回顾)。

问题在于,人们发现要在解释上达成一致太难,而要忽视他们所不喜欢的又太容易。这种倾向,使得在“什么才是重要的”这个问题上的根本分歧得以发酵。大家基于他们所受的教育和他们的日常工作中所获得的理解,来做出设计决策。这种背景上的多样性,再加上标准委员会内部对于 C++ 广泛应用领域的不均衡覆盖(§3.3),就构成了一个问题。许多人只是对于自己的观点过于确定无疑 [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 里的 constexpr 函数(§4.2.7),它是现代编译期编程的基础。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 曾经提出过合并线程(joining thread)[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 流、日志记录、比较、用于存储和网络的封送处理(marshalling)、构建和使用对象映射、枚举的“字符串化”、测试支持,及其他的更多可能 [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])。

10. 2020 年的 C++

本节我们来查看一下在二十一世纪的第二个十年里,C++ 如何被使用,以及用来做什么:

C++ 的使用领域绝大部分与 2006 年相同(§2.3)。虽然有一些新的领域,但在大多数情况下,我们看到的 C++ 还是在相同或类似的领域中被更加广泛和深入地使用。C++ 没有突然成为一种面向 Web 应用开发的语言,虽然即使在那种场景下仍有人用 C++ [Obiltschnig et al. 2005]。对于大多数程序员来说,C++ 依然是某种隐没在后台的东西,稳定、可靠、可移植、高性能。最终用户是看不见 C++ 的。

编程风格则有更加巨大的变化。比起 C++98,C++11 是门好得多的语言。它更易于使用,表达能力更强,性能还更高。2020 年发布的 C++20 则在 C++11 的基础上做出了类似程度的改进。

10.1 C++ 用来做什么?

大致而言,C++ 可谓无处不在、无所不用。但是,大象无形,大多数 C++ 的使用并不可见,被深深隐藏在重要系统的基础设施内部。

C++ 被用在哪里,是如何被使用的,没人能够完整了解。2015 年,捷克公司 JetBrains 委托进行了一项研究 [Kazakova 2015],结果显示在北美、欧洲、中东以及亚太地区 C++ 被大量使用,在南美也有一些使用。“在南美的一些使用”就有 40 万开发者,而 C++ 开发者的总人数则达到了 440 万。使用 C++ 的行业有(按顺序)金融、银行、游戏、前台、电信、电子、投资银行、营销、制造和零售。所有迹象表明,自 2015 年以来,C++ 的用户数量和使用领域一直在稳步增长。

在这里,我将对 2006 到 2020 年期间内 C++ 的应用领域给出一个可能有些个人化的、印象派的、非常不完整的概览:

  • 工业界:电信(例如 AT&T、爱立信、华为和西门子)、移动设备(基本上是所有,信号处理、屏幕渲染、对性能或可移植性有重大要求的应用)、微电子(例如 AMD、英特尔、Mentor Graphics 和英伟达)、金融(例如摩根士丹利和文艺复兴)、游戏(几乎所有)、图形和动画(例如 Maya、迪士尼和 SideFx)、区块链实现(例如 Ripple)、数据库(例如 SAP、Mongo、MySQL 和 Oracle)、云(例如谷歌、微软、IBM 和 Amazon)、人工智能和机器学习(例如 TensorFlow 库)、运营支持(例如 Maersk 和 AT&T)。
  • 科学:航空航天(例如 Space X、火星漫游者、猎户座载人飞行器、詹姆斯·韦伯太空望远镜)、高能物理(例如 CERN 欧洲核子研究中心、SLAC 国家加速器实验室、费米实验室)、生物学(遗传学、基因组测序)、超大规模计算。
  • 教学:全球大多数工程院校。
  • 软件开发:TensorFlow、工具、库、编译器、Emscripten(从 C++ 生成 asm.js 和 WebAssembly)、运行期代码生成、LLVM(许多新语言的后台支柱,也大量用于工具构建中)、XML 和 JSON 解析器、异构计算(例如 SYCL [Khronos Group 2014–2020] 和 HPX [Stellar Group 2014–2020])。
  • Web 基础设施:浏览器(Chrome、Edge、FireFox 和 Safari)、JavaScript 引擎(V8 和 SpiderMonkey)、Java 虚拟机(HotSpot 和 J9)、谷歌和类似组织(搜索、map-reduce 和文件系统)。
  • 主要 Web 应用:阿里巴巴、Amadeus(机票)、Amazon、苹果、Facebook、PayPal、腾讯(微信)、Yandex。
  • 工程应用:达索(CAD/CAM)、洛克希德·马丁(飞机)。
  • 汽车:辅助驾驶 [ADAS Wikipedia 2020; Mobileye 2020; NVIDIA 2020]、软件架构 [Autosar 2020; Autosar Wikipedia 2020]、机器视觉 [OpenCV 2020; OpenCV Wikipedia 2020]、宝马、通用、梅赛德斯、特斯拉、丰田、沃尔沃、大众、Waymo(谷歌自动驾驶汽车)。
  • 嵌入式系统:智能手表和健康监控器(例如佳明)、相机和视频设备(例如奥林巴斯和佳能)、导航辅助设备(例如 TomTom)、咖啡机(例如 Nespresso)、农场动物监控器(例如 Big Dutchman)、生产线温度控制(例如嘉士伯)。
  • 安全:卡巴斯基、美国国家安全局、赛门铁克。
  • 医疗和生物学:医学监测和成像(例如西门子、通用电气、东芝和飞利浦)、断层扫描(例如 CT)、基因组分析、生物信息学、放射肿瘤学(例如 Elekta 和 Varian)。

虽然这只是冰山一角,但它展示了 C++ 使用的广度和深度。大多数 C++ 的使用对其(间接)用户不可见。某些对 C++ 的使用早于 2006 年,但也有很多是之后才开始的。没有一个主要现代系统只用单一语言写就,但是 C++ 在所有这里提到的应用场合中发挥了重要作用。

我们常常忘记那些平凡的却在我们的生活中起着重要作用的应用。没错,C++ 可以帮助运行美国国家航空航天局的深空网络,但也可以在人们日常熟悉的小设备中运行,例如咖啡机、立体声扬声器和洗碗机。让我惊讶的是,C++ 竟然也被应用于运转现代养猪场的先进系统中。

10.2 C++ 社区

与 2006 年相比,2020 年的 C++ 社区更加壮大,不断蓬勃发展、积极向上、富有成效,并且急切地想看到未来的进一步改进。

与大多数编程语言社区相比,C++ 社区一向是出奇地无组织和分散。这个问题早已有之,因为我就没有建立组织的才能。当时我的雇主 AT&T 贝尔实验室并不想建立一个 C++ 社区,但是似乎其他所有人都非常感兴趣,并且愿意花钱来建立他们的用户群。最终的结果是,许多公司,例如苹果、Borland、GNU、IBM、微软和 Zortech 都建立了以其客户为中心的 C++ 社区,但是却没有总体的 C++ 社区,社区没有中心。有杂志,读的人不多(相对于 C++ 社区的规模)。虽然有会议,但它们倾向于被一般的“面向对象”的会议或“软件开发”的会议所吸收或者就演变成了那些一般性会议。没有总体的 C++ 用户组。

如今,世界上有数十个本地、国家和国际 C++ 用户组,这些用户组之间也经常进行一些合作。除此之外,还有数十个 C++ 会议,每个会议都有数百人参加:

  • C++ 基金会——成立于 2014 年,是一家非盈利性组织,旨在推广 ISO C++(而不是任何特定供应商的 C++),它主办 CppCon 年度会议。
  • Boost——成立于 1999 年,它是一组经过同行评审的库、以及建造使用它们的社区。Boost 举行年度会议。
  • Meeting C++——成立于 2012 年,是一个非常活跃的用户团体网络,定期举行会议(最初在德国活跃)。在不同地方有数十个 Meeting C++ 的会议和聚会。
  • ACCU——成立于 1984 年,最初作为 C 用户组而建立,是所有现存 C++ 组织中的爷爷辈了;它出版两本杂志,并举行年度会议(主要在英国活跃)。
  • isocpp.org——C++ 基金会的网站,其中包含与 C++ 有关的新闻,标准化进程相关的信息,以及有用的链接。
  • cppreference.com——出色的在线参考资料;它甚至有一个历史部分!
  • 会议——CppCon、ACCU、Meeting++、C++ Now(以前称为 BoostCon)、Qt、NDC、std::cpp 的会议,以及在波兰、俄罗斯、中国、以色列和其他地方的一些会议。此外,很多通用软件会议上也在越来越多的安排 C++ 专题。
  • 博客——有许多,播客也是。
  • 视频——视频已成为有关 C++ 的最新进展的主要信息来源。主要的 C++ 会议通常会录制演讲视频并将其发布以供免费访问(例如 CppCon、C++Now 和 Meeting++)。视频采访已变得很流行。最多最受欢迎的托管网站是 YouTube,但不幸的是,YouTube 在有些拥有大型 C++ 开发者群体的国家(例如中国)被封了。
  • GitHub——使共享代码和组织联合项目开发变得更加容易。

跟某些语言和供应商的集中组织相比,这还差得很远。但是,这些 C++ 社区和组织富有活力,彼此保持联系,并且比在 2006 年的时候活跃得多。此外,一些企业的用户组和会议也仍然活跃。

10.3 教育和研究

从 2006 年不太理想的状态(§2.3)到现在,C++ 的教育是否得到了改善?也许吧,但是对于 C++ 来说,教育仍然不是强项,大多数教育还都集中在为业内人士提供信息和培训上。在大多数国家/地区,很多大学毕业生对 C++ 语言及使用它的关键技术只能算一知半解。对于 C++ 社区来说,这是一个严重的问题。因为,对于一门语言来说,如果没有热情洋溢的程序员们源源不断、前赴后继地精通其关键设计和实现技术,那它是无法在工业规模上取得成功的。假如更多使用 C++ 的开发者知道如何更好地使用它,那他们就能做太多太多的事来改进软件!如果毕业生带着更准确的 C++ 视角进入工作岗位,那么太多太多的事情会变得容易得多!

C++ 教学所面临的一个问题是教育机构经常将编程视为低级技能,而不是基础课目。好的软件对我们的文明至关重要。为了把控软件,我们需要像对待数学和物理学一样,严肃认真地对待关键系统的软件开发。那种削足适履的方式对于教育和软件开发是行不通的。一个学期的教学也远远不够。我们永远都不会期望在教了短短几个月英语之后,学生就会懂得欣赏莎士比亚。同样,了解语言的基本机制与精通内行所使用的惯用法和技巧之间是有差距的。就像任何主要的现代编程语言一样,教授 C++ 也需要根据学生的背景和需求相应地调整教学方法。即使教育机构意识到了这些问题并愿意做出一些弥补,奈何学生已经课满为患,教师也很难保持不跟工业实践脱节。SG20(教育)正试图总结教授和使用现代 C++ 的方法来提供一些帮助。SG15(工具)则可能提供更多支持教学的工具,从而越来越多地发挥重要作用。

从 C++11 开始,我们对此有了越来越多的认识。例如,Kate Gregory 制作了一些很棒的视频,介绍了如何教授 C++ [Gregory 2015, 2017, 2018]。最近的一些书籍认识到在支持教育方面,不同的受众存在不同的需求,并试图迎头解决这些问题:

  • 《C++ 程序设计原理与实践》(Programming: Principles and Practice Using C++)[Stroustrup 2008a]——这是一本针对刚入门的大学生和自学人士的教科书。
  • 《C++ 语言导学》(A Tour of C++)[Stroustrup 2014d,2018f]——针对经验丰富的程序员的简短概述(200 页)。
  • 《发现现代 C++》(Discovering Modern C++)[Gottschling 2015]——这是一本专门为数学背景较强的学生准备的书。

我也写了一些半学术性质的论文(Software Development for Infrastructure [Stroustrup 2012] 和 What should we teach software developers? Why? [Stroustrup 2010b]),并在 CppCon 2017 开幕式上作了关于 C++ 教育的主题演讲(Learning and Teaching Modern C++ [Stroustrup 2017c])。

自 2014 年左右以来,视频和在线课程的使用急剧增加。这对 C++ 的教学来说很有帮助,因为这样就不需要一个中心组织或大量资金的支持。

以下列出了从 2006 到 2020 年间,与 C++ 语言相关的学术研究成果:

  • 概念:泛型编程 [Dehnert and Stepanov 2000]、C++0x 概念 [Gregor et al. 2006]、使用模式 [Dos Reis and Stroustrup 2006]、库设计 [Sutton and Stroustrup 2011]。
  • 理论与形式化体系:继承模型 [Wasserrab et al. 2006]、模板和重载 [Dos Reis and Stroustrup 2005a]、模板语义 [Siek and Taha 2006]、对象布局 [Ramananandro et al. 2011]、构造和析构 [Ramananandro et al. 2012]、用于代码处理的表示形式 [Dos Reis and Stroustrup 2009,2011]、资源模型 [Stroustrup et al. 2015]。
  • 动态查找:快速动态类型转换 [Gibbs and Stroustrup 2006]、模式匹配 [Solodkyyet et al. 2012]、多重方法 [Pirkelbauer et al. 2010]。
  • 静态分析:可靠的表示法 [Yang et al. 2012]、实践经验 [Bessey 2010]。
  • 性能:代码膨胀 [Bourdev and Järvi 2006,2011]、异常实现 [Renwicket et al. 2019]。
  • 语言比较:泛型编程 [Garcia et al. 2007]。
  • 并发和并行编程:内存模型 [Batty et al. 2013,2012,2011]、HPX(一个适用于任何规模的并行和分布式应用程序的通用 C++ 运行时系统 [Kaiser et al. 2009Sept])、STAPL(自适应泛型并行 C++ 库 [Zandifar et al. 2014])、TBB(英特尔的任务并行库 [Reinders 2007])。
  • 协程:数据库优化 [Jonathan et al. 2018; Psaropoulos et al. 2017]。
  • 软件工程:代码的组织和优化 [Garcia and Stroustrup 2015]、常量表达式求值 [Dos Reis and Stroustrup 2010]

看起来还有更多的关于 C++ 的学术研究机会,关于语言的特性和技巧(例如,异常处理、编译期编程和资源管理),以及其使用的有效性(例如,静态分析或基于真实世界代码和经验的研究)。

C++ 社区中最活跃的成员中很少有人会考虑撰写学术论文,写书似乎更受欢迎(例如,[Čukić 2018; Gottschling 2015; Meyers 2014; Stepanov and McJones 2009; Vandevoorde et al. 2018; Williams 2018])。

10.4 工具

与其他语言相比,在 1990 年代初期到中期,C++ 在用于工业用途的工具和编程环境方面做得相当不错。例如,图形用户界面和集成软件开发环境都率先应用于 C++。后来,开发和投资的重点转移到专属语言,例如 Java(Sun)、C#(微软)和 Objective-C(苹果)以及更简单的语言,例如 C(GNU)。

在我看来,有两个主要原因:

  • 资金:组织倾向于使用他们可以控制的语言和工具,从而提供比竞争对手更大的差异化优势。从这个角度来看,C++ 由正式的标准委员会控制、强调所有人的利益,这反倒成了一个缺点——某种公地悲剧的变体。
  • 宏和文本定义:C++ 没有一个简单,可广泛使用的内部表示形式来简化基于源代码的工具构建,并且大量使用宏必然导致程序员看到的跟编译器所分析的有所不同。和 C 一样,C++ 是根据字符序列来定义的,而非根据直接表示抽象且更易于操作的构件来定义。我与 Gabriel Dos Reis 一起定义了这样一个表示形式 [Dos Reis and Stroustrup 2009, 2011],但事实证明 C++ 社区中面向字符的传统难以克服。当初建造时没有意识到的规范化结构,想通过翻新加上去就难了。

因此,在 2006–2020 年期间,与其他语言相比,C++ 被支持工具方面的问题严重困扰。但是,随着以下这些工具的涌现,这种情况得到了稍许改善:

  • 工业级的集成软件开发环境:例如微软的 Visual Studio [Microsoft 2020; VStudio Wikipedia 2020] 和 JetBrains 的 CLion [CLion Wikipedia 2020; JetBrains 2020]。这些环境不仅支持编辑和调试,还支持各种形式的分析和简单的代码转换。
  • 在线编译器:例如 Compiler Explorer [Godbolt 2016] 和 Wandbox [Wandbox 2016–2020]。这些系统允许从任何浏览器中编译 C++ 程序,有时甚至可以执行。它们可用于实验,检查代码质量,还有比较不同的编译器及编译器和库的不同版本。
  • GUI 库和工具:例如 Qt [Qt 1991–2020]、GTKmm [GTKmm 2005–2020] 和 wxWidgets [wxWidgets 1992–2020]。不幸的是,Qt 依赖于元对象协议(meta-object protocol,缩写为 MOP),因此 Qt 程序还不是标准的 ISO C++ 应用。静态反射(§9.6.2)使我们最终能够解决这个问题。C++ 社区的问题不是没有好的 GUI 库,而是太多了,因此会有选择困难。
  • 分析器:例如 Coverity [Coverity 2002–2020],Visual Studio 的 C++ Core Guidelines 分析器(§10.6)和 Clang Tidy [Clang Tidy 2007–2020]。
  • 编译器工具支持:例如 LLVM 编译器后端基础设施,可简化代码生成和代码分析 [LLVM 2003–2020]。除了 C++ 本身,这为许多新语言提供了福利。
  • 构建系统:例如 build2 [Build2 2014–2020] 和 CMake [CMake 2000–2020],以及 GNUmake[GNUmake 2006–2020]。同样,在没有标准的情况下,选择会有困难。
  • 包管理器:例如 Conan [Conan 2016–2020] 和 vcpkg [vcpkg 2016–2020]。
  • 运行时环境:例如 WebAssembly:将 ISO C++ 编译为字节码以在浏览器中部署的系统 [WebAssembly 2017–2020]。
  • 运行时编译、JIT 和链接:例如 Cling [Cling 2014–2020; Naumann 2012; Naumann et al. 2010] 和 RC++ [RC++ 2010–2020]。

上面列出的只是一些示例。像往常一样,C++ 用户面临的问题是可选方案的数量众多,例如:[RC++ 2010–2020] 列出了 26 个用于在编译时生成代码的系统,并且有数十个程序包管理器。因此,我们需要的是某种形式的标准化。

截至 2020 年,工具仍不是 C++ 的强项,但我们正在大范围内取得进展。

10.5 编程风格

针对大多数现实问题的最佳解决方案需要组合使用多种技术,这也是 C++ 演进的主要动力。自然地,这让那些声称拥有单个简单最佳解决方案(“编程范式”)的人感到不爽,但是支持多种风格一直是 C++ 的根本优势。考虑一下“绘制所有形状”的例子,这个例子自 Simula 发展早期(绘图设备为湿墨绘图仪)以来就一直用于说明面向对象编程。用 C++20,我们可以这样写:

void draw_all(range auto& seq)
{
    for (Shape& s : seq)
        s.draw();
}

该段代码是什么编程范式?

  • 显然,它是面向对象编程:使用了虚函数和类层次结构。
  • 显然是泛型编程:使用了模板(通过使用 range 概念进行参数化,我们得到一个模板)。
  • 显然,这是普通的命令式编程:使用了 for 循环,并按照常规 f(x) 语法定义了一个将要被调用的函数。

对这个例子我可以进一步展开:Shape 通常具有可变状态;我可以使用 lambda 表达式,也可以调用 C 函数;我可以用 Drawable 的概念对参数进行更多约束。对于各种“更好”的定义,适当的技术组合比我能想到的任何一种单一范式所能提供的解决方案更好。

C++ 支持多种编程风格(如您坚持,也可以称为“范式”),其背后的想法并不是要让我们选择一种最喜欢的样式进行编程,而是可以将多种风格组合使用,以表达比单一风格更好的解决方案。

10.5.1 泛型编程

在 2006 年,许多 C++ 代码仍然是面向对象的风格和 C 风格编程的混合体。自然而然的,到 2020 年仍然有很多类似这样的代码。但是,随着 C++98 的到来,STL 风格的泛型编程(通常称为 GP)变得广为人知,并且用户代码也逐渐开始使用 GP,而不只是简单地使用标准库。C++11 中对 GP 的更好支持为在生产代码中更广泛的使用 GP 提供了极大的便利。但是,C++17 中缺少概念(§6),这仍然阻碍了 C++ 中泛型编程的使用。

基本上,所有专家都阅读过 Alex Stepanov 的《编程原本》(Elements of Programming,通常称为 EoP)[Stepanov and McJones 2009],并受到其影响。

基于模板的泛型编程是 C++ 标准库的支柱:容器、范围(§9.3.5)、算法、iostream、文件系统(§8.6)、随机数(§4.6)、线程(§4.1.2)(§9.4)、锁(§4.1.2)(§8.4)、时间(§4.6)(§9.3.6)、字符串、正则表达式(§4.6)和格式化(§9.3.7)。

10.5.2 元编程

C++ 中的元编程出自泛型编程,因为两者都依赖于模板。它的起源可以追溯到 C++ 模板的早期,当时人们发现模板是图灵完备的 [Vandevoorde and Josuttis 2002; Veldhuizen 2003],并以某种有用的形式提供编译期纯函数式编程。

模板元编程(通常称为 TMP)往往非常丑。有时,这种丑陋通过使用宏来掩盖,从而造成了其他问题。TMP 几乎无处不在,这也证明了它确实有用。例如,如果没有元编程,就无法实现 C++14 标准库。许多技巧和实验在 2006 年前就有了,但是 C++11 具有更好的编译器、变参模板(§4.3.2)和 lambda 表达式(§4.3.1),这些推动了 TMP 成为主流用法。C++ 标准库还增加了更多元编程的支持,比如:编译期选择模板 conditional,允许代码依赖于类型属性的类型特征(type trait)如“能否安全地按位复制类型 X?”(§4.5.1),还有 enable_if§4.5.1)。举例来说:

conditional<(sizeof(int)<4),double,int>::type x; // 如果 int 小,就用 double

计算类型以精确地反映需求,这可以说是 TMP 的本质。我们还可以计算值:

template <unsigned n>
struct fac {
    enum { val = n * fac<n-1>::val };
};

template <>
struct fac<0> {   // 0 的特化:fac<0> 为 1
    enum { val = 1 };
};

constexpr int fac7 = fac<7>::val;  // 5040

注意,模板特化在其中起着关键作用,这一点在大多数 TMP 中是必不可少的。它已用于计算复杂得多的数值,也可以表示控制流(例如,在编译期计算决策表,进行循环展开,等等)。在 C++98 [Stroustrup 2007] 中,模板特化是一个很大程度上没有得到足够重视的特性。

在设计精巧的库中以及在现实世界的代码中,诸如 enable_if 之类的原语已成为数百甚至数千行的程序的基础。TMP 的早期示例包含一个完整的编译期 Lisp 解释器 [Czarnecki and Eisenecker 2000]。此类代码极难调试,而维护它们更是可怕的差事。我见识过这样的情形,几百行基于 TMP 的代码(不得不承认非常聪明),在一台 30G 内存的计算机上编译需要好几分钟的时间,由于内存不足而导致最终编译失败。即使是简单的错误,编译器的错误信息也可以达到几千行。然而,TMP 仍被广泛使用。理智的程序员发现,尽管 TMP 有着各种问题,仍比起其他方案要好。我见过 TMP 生成的代码比我认为一个合格的人类程序员会手写的汇编代码要更好。

因此,问题变成了如何更好地满足这种需求。当人们开始把像 fac<> 这样的代码视为正常时,我为此而感到担心。这不是表达普通数值算法的好方法。概念(§6)和编译期求值函数(constexpr§4.2.7))可以大大简化元编程。举例来说:

constexpr int fac(int n)
{
    int r = 1;
    while (n>1) r*=n--;
    return r;
};

constexpr int fac7 = fac(7);  // 5040

这个例子说明,当我们需要一个值时,函数是最佳的计算方式,即使——尤其——在编译期。传统模板元编程最好只保留用于计算新的类型和控制结构。

Jaakko Järvi 的 Boost.Lambda [Järvi and Powell 2002; Järvi et al. 2003a] 是 TMP 的早期使用案例,它帮助说服了人们 lambda 表达式是有用的,并且他们需要直接的语言支持。

Boost 元编程库 Boost.MPL [Gurtovoy and Abrahams 2002–2020] 展示了传统 TMP 的最好和最坏的方面。更现代的库 Boost.Hana [Boost Hana 2015–2020] 使用 constexpr 函数。WG21 的 SG7(§3.2)试图开发一种更好的标准元编程系统,其中还包括编译期反射(§9.6.2)。

10.6 编码指南

我对 C++ 语言的最终目标是:

  • 使用和学习上都要比 C 或当前的 C++ 容易得多
  • 完全类型安全——没有隐式类型违规,没有悬空指针
  • 完全资源安全——没有泄漏,不需要垃圾收集器
  • 为其构建工具要相对简单——不要有宏
  • 跟当前 C++ 一样快或更快——零开销原则
  • 性能可预测——适用于嵌入式系统
  • 表达力不亚于当前的 C++——很好地处理硬件

这和《C++ 语言的设计和演化》[Stroustrup 1994] 及更早版本中阐述的设计目标并没有太多不同。显然,这是一项艰巨的任务,并且与较旧的 C 和 C++ 的多数用法不兼容。

最早,在 C++ 还是“带类的 C”的时候,人们就建议创建语言的安全子集,并使用编译器开关来强制执行这种安全性。但是,由于许多原因中的某一个原因,这些建议失败了:

  • 没有足够的人在“安全”的定义上达成一致。
  • 不安全特性(对每种“不安全”的定义来说)是构建基本安全抽象的基础。
  • 安全子集的表达能力不足。
  • 安全子集效率低下。

第二个原因意味着,你不能仅仅通过禁止不安全的功能来定义一个安全的 C++。“通过限制以达到完美”这个方法,对于编程语言的设计来说,在极其有限的场合下才能发挥作用。你需要考虑那些一般来说不安全但有安全用途的特性的使用场景和特征。此外,该标准不能放弃向后兼容(§1.1),所以我们需要一种不同的方法。

从一开始,C++ 就采用了不同的哲学 [Stroustrup 1994]:

让良好的编程成为可能比防止错误更重要。

这意味着我们需要“良好使用”的指南,而不是语言规则。但是,为了在工业规模上有用,指南必须可以通过工具强制执行。例如,从 C 和 C++ 的早期开始,我们就知道悬空指针存在的问题。例如:

int* p = new int[]{7,9,11,13};
// ...
delete[] p;    // 删除 p 指向的数组
               // 现在 p 没有指向有效对象,处于“悬空”状态
// ...
*p = 7;        // 多半会发生灾难

虽然许多程序员已经开发出防止指针悬空的技术。但是,在大多数大型代码库中,悬空指针仍然是一个主要问题,安全性问题比过去更加关键。一些悬空的指针可以作为安全漏洞被利用。

10.6.1 一般方法

在 2004 年,我帮助制定了一套用于飞行控制软件 [Lockheed Martin Corporation 2005] 的编码指南,这套指南接近于我对安全性、灵活性和性能的构想。2014 年,我开始编写一套编码指南,以解决这一问题,并在更广泛的范围内应用。这一方面是为了回应对用好 C++11 的实用指南的强烈需求,另外一方面是有人认为的好的 C++11 让我看着害怕。与人们交谈后,我很快发现了一个明显的事实:我并不是唯一沿着这样的路线思考和工作的人。因此,一些经验丰富的 C++ 程序员、工具制作者和库构建者齐心协力,与来自 C++ 社区的众多参与者一起启动了 C++ 核心指南项目 [Stroustrup and Sutter 2014–2020]。该项目是开源的(MIT 许可证),贡献者列表可以在 GitHub 上找到。早期,来自摩根士丹利(主要是我)、微软(主要是 Herb Sutter、Gabriel Dos Reis 和 Neil Macintosh)、Red Hat(主要是 Jonathan Wakely)、CERN、Facebook 和谷歌的贡献者都做出了突出贡献。

核心指南绝不是唯一的 C++ 编码指南项目,但却是最突出、最雄心勃勃的。它们的目标明确而清晰,那就是显著提升 C++ 代码的质量。例如,早在 Bjarne Stroustrup、Herb Sutter 和 Gabriel Dos Reis 的论文中 [Stroustrup et al. 2015] 就阐明了关于完全类型和资源安全的理想和基础模型。

为了实现这些雄心勃勃的目标,我们采用了一种“鸡尾酒式”的混合方法:

  • 规则:一套庞大的规则集,意图在 C++ 里实现使用上的类型安全和资源安全,推荐那些已知的有效实践,并禁止已知的错误和低效的来源。
  • 基础库:一组库组件,使程序员可以有效的编写低层次程序,而无需使用已知的容易出错的功能,并且从总体上为编程提供更高层次的基础。大多数组件来自标准库,其中一些来自以 ISO 标准 C++ 编写的小型指南支持库(Guidelines Support Library,GSL)。
  • 静态分析:检测违规行为、并强制执行指南关键部分的工具。

这些方法中的每一种都有很长的历史,但是每一项都无法单独在工业规模上解决这些问题。例如,我是静态分析的忠实拥护者,但是如果程序员使用动态链接的方式在一个单独编译的程序中编写任意复杂的代码,那么我最感兴趣的分析算法(例如,消除悬空指针)是不能求解成功的。这里的“不能”是指“一般说来,理论上是不可能的”,以及“对于工业规模的程序而言在计算上过于昂贵”。

基本方式不是简单的限制,而是我称之为“超集的子集”或 SELL 的方法 [Stroustrup 2005]:

  • 首先,通过库功能对语言进行扩展,从而为正确的使用语言奠定坚实的基础。
  • 然后,通过删除不安全、易出错及开销过高的功能来设置子集。

对于库,我们主要依赖标准库的各个部分,例如 variant§8.3)和 vector。小型指南支持库(GSL)提供了类型安全的访问支持,例如 span 可以提供在给定类型的连续元素序列上的带范围检查的访问(§9.3.8)。我们的想法是通过将 GSL 吸收到 ISO 标准库中,从而最终也就不需要它了。例如,span 已被添加到 C++20 标准库中。当时机成熟时,GSL 中对于契约的微弱支持也应当被合适的契约实现所替代(§9.6.1)。

10.6.2 静态分析

为了能规模化,静态分析完全是局部的(一次仅一个函数或一个类)。最难的问题与对象的生命周期有关。RAII 是必不可少的:我们已经不止一次的看到,手动资源管理的方法在很多语言中都很容易出错。此外,也有很多现存的程序,以一种有原则的方式使用指针和迭代器。我们必须接受此类使用方式。要使一个程序安全很容易,我们只需禁止一切不安全的功能。然而,保持 C++ 的表现力和性能是核心指南的目标之一,所以我们不能仅仅通过限制来获得安全。我们的目的是一个更好的 C++,而不是一个缓慢或被阉割的子集。

通过阐明原则、让那些优秀的做法更加显而易见、以及对已知问题进行机械化检查,这些指南可以帮助我们把教学的重点放在那些让 C++ 更有效的方面。这些指南还有助于减轻对语言本身的压力,以适应最新的发展趋势。

对于对象的生命周期,主要有两个要求:

  • 切勿指向超出范围的对象。
  • 切勿访问无效的对象。

考虑以下“基础模型”论文中的一个例子 [Stroustrup et al. 2015]):

int glob = 666;
int* f(int* p)
{
    int x = 4;          // 局部变量
    // ...
    return &x;          // 不行,会指向一个被销毁的栈帧
    // ...
    return &glob ;      // 可以,指向某个“永远存在”的对象
    // ...
    return new int{7};  // 可以(算是可以吧:不悬空,
                        //       但是把所有者作为 int* 返回了)
    // ...
    return p;           // 可以,来自调用者
}

指针指向已知会超过函数生命周期的对象(例如,作为参数被传递到函数中),我们可以返回它,但对于指向局部资源的指针就不行。在遵循该指南的程序中,我们可以确保作为参数的指针指向某资源或为 nullptr

为避免泄漏,上面示例中的“裸 new”操作应当通过使用资源句柄(RAII)或所有权标注来消除。

如果指针所指向的对象已重新分配,则该指针会变为无效。例如:

vector<int> v = { 1,2,3 };
int* p = &v[2];
v.push_back(4); // v 的元素可能会被重新分配
*p = 5;         // 错误:p 可能已失效
int* q = &v[2];
v.clear();      // v 所有的元素都被删除
*q = 7;         // 错误:q 无效

无效检查甚至比检查简单的悬空指针还要困难,因为很难确定哪个函数会移动对象以及是否将其视为失效(指针 p 仍然指向某个东西,但从概念上讲已经指向了完全不同的元素)。尚不清楚在没有标注或非本地状态的情况下,静态分析器是否可以完全处理无效检查。在最初的实现中,每个将对象作为非 const 操作的函数都被假定为会使指针无效,但这太保守了,导致了太多的误报。最初,关于对象生命周期检查的详细规范是由 Herb Sutter [Sutter 2019] 编写的,并由他在微软的同事实现。

范围检查和 nullptr 检查是通过库支持(GSL)完成的。然后使用静态分析来确保库的使用是一致的。

静态分析设想最早是由 Neil Macintosh 实现的,目前已作为微软 Visual Studio 的一部分进行发布。有一些检查规则已经成为了 Clang 和 HSR 的 Cevelop(Eclipse 插件)[Cevelop 2014–2020] 的一部分。一些课程和书籍中都加入了关于这些规则的介绍(例如 [Stroustrup 2018f])。

核心指南是为逐步和有选择地采用而设计的。因此,我们看到其中一部分在工业和教育领域被广泛采用,但很少被完全采用。要想完全采用,良好的工具支持必不可少。

11. 回顾

编程语言设计的最终目的,是在程序员交付有用的程序的同时,改进他们的思考方式和工作方式。尽管有些程序语言被视为“只是实验性的”,但是一旦程序语言被用于和语言本身无关的实际工作,这门语言的设计者们就应对他们的用户承担相应的责任。正确、合适、稳定性和足够的性能就成为重要的课题。对 C++ 来说,这些事情在 1979 年仅用了 6 个月就发生了。C++ 已经茁壮成长了 40 年之久。为什么能成功?又是如何成功的?

我之前的 HOPL 论文 [Stroustrup 1993, 2007] 以 1991 到 2006 年的观点回答了这些问题。从那时起发生的变化,除了语言的特性和组件库之外,主要是标准委员会的作用和影响(§3)。

这里,我主要考虑:

11.1 C++ 模型

C++ 为高要求的应用而生,并成长为一种重要的编程语言——在某些领域,它甚至是主导语言。这是在没有认真的商业支持和没有营销的情况下达到的。许多现代语言拷贝了它的特性和理念。关键的语言技术贡献有:

  • 静态类型系统,对内置类型和用户定义类型具有同等支持(§2.1
  • 既有值语义,又有引用语义(§4.2.3
  • 系统和通用资源管理(RAII)(§2.2
  • 支持高效的面向对象编程(§2.1
  • 支持灵活的和高效的泛型编程(§10.5.1
  • 支持编译期编程(§4.2.7
  • 直接使用机器和操作系统资源(§1
  • 通过库提供并发支持(往往使用内建函数实现)(§4.1)(§9.4

相较于目前占主导地位的依靠垃圾收集器和广泛运行期支持的“托管”模式——典型的如 Java、C#、Python 和 JavaScript(§2.3)等语言——C++ 提供了一种不同的、对许多应用领域来说更好的软件模式。我所说的 “更好”是指更容易编写、更有可能正确、更可维护、使用更少的内存、耗能更低和更快。

这些贡献的领域是互帮互助的,举例来说:

  • 引用语义(例如,指针和智能指针)支持使用值语义(例如,jthreadvector)高效地实现高级类型。
  • 对内置类型和用户定义类型的统一规则,简化了泛型编程(内置类型不是特殊情况)。
  • 编译期编程使得一系列的抽象技术因为能够有效使用硬件而变得负担得起。
  • RAII 允许使用用户定义的类型,而无需采取特定的操作来支持其实现对资源(包括非内存资源)的使用。

11.2 技术上的成功

C++ 成功的根本原因很简单——它填补了编程领域的一个重要的“生态位”:

需要有效使用硬件和管理高复杂性的应用程序

如果你能负担得起“浪费”25% 甚至 99% 的硬件机能,那可供选择的编程语言和环境就多了。如果你的底层模块需要仅仅千行的底层代码,C 语言或者汇编语言可以效劳。40 年以来,C++ 的独特“生态位”足以使其社区不断成长。

这里有一个现代(2014 年)的 C++ 总结:

  • 直接映射到硬件
    • 指令和基本数据类型
    • 最初来自于 C 语言
  • 零开销抽象
    • 带构造和析构函数的类、继承、泛型编程、函数对象
    • 最初来自于 Simula 语言(当时还不是零开销的)

Simula 开创了许多抽象机制和一个灵活的类型系统,但在运行时间和空间成本上,它们带来了沉重的代价。与 1995 年的 C++(§2.1)描述相比,关注点从编程技术转向了问题领域。这更多的是解释风格和人们兴趣的不同,而不是语言设计的不同。这两个总结现在和当时都是准确的。

在过去几十年的基础上,21 世纪的关键技术进步包括:

它们都与零开销原则相关,但最后两个有点令人惊讶,因为在 2006 至 2020 年期间内,C++ 对它们的支持并不完全。

假如 C++ 分裂成互不兼容的方言,或者成为你无法长期依赖的东西,以上这些就都失去意义了:

  • 稳定性和兼容性至关重要(§1.1)(§11.4

新特性(C++11 以来)带来了标准库的改进(例如:unique_ptrchronoformatscoped_lock),也带来了很多其他库的改进。

C++ 的目的是成为构建应用程序的工具,许多用 C++ 开发的伟大应用程序,例如在(§2.3)和(§10.1)章节提到的那些,是 C++ 真正的成功。

11.3 需要工作的领域

没有一种语言对所有人和所有事都是完美的。对于这点,没有人比既懂多种语言、又严肃使用其中一种并努力支持它的人了解更多了。阻碍进步的很少是单纯的无知。相反,重大改进的主要障碍是缺乏方向、缺乏开发资源以及害怕破坏现有代码。

C++ 苦于诞生过早,在现代化的集成开发环境(IDE)、构建系统、图形界面(GUI)系统和 Unicode 问世之前就已经诞生了。我期待 C++ 能慢慢赶上来。举例来说:

  • 工具使用:从 C 语言开始,用字符和词法标记来说明语义,以及用 #include 和宏来组织源代码,这一直是有效工具建设的主要障碍。模块应该会有所帮助(§9.3.1),而且是有可能为 C++ 设计出一个合理的内部表示的 [Dos Reis and Stroustrup 2009, 2011]。
  • 教育:今天的 C++ 教学大多仍然过时和落后(§2.3)。核心指南(§10.6)是对实践进行现代化的一种方法。WG21 的教育研究小组(§3.2)和许多面向教育的会议报告表明,这些问题得到了重视和并正在解决中。
  • 打包和发布:C++ 诞生时,由独立开发、维护的模块组成的软件并不常见。今天,已经有了用于 C++ 的构建系统和打包管理程序。然而,还没有一个是标准的,有些难以用于简单的任务,有些则不够通用,不能应对使用 C++ 构建的大规模系统。我在 2017 年的 CppCon 主题演讲中提出了这个问题,并向社区发起挑战 [Stroustrup 2017c] 来解决它。我认为我们正在看到进展。此外,C++ 社区还缺少一个标准的地方来寻找有用的库。Boost [Boost 1998–2020] 是解决这个问题的一个努力,GitHub 正逐渐成为一个通用的资源库。但要达到让相对的新手能找到、下载、安装和运行几个主流的库这样的方便程度,我们的路还很长。
  • 字符集和图形:C++ 语言和标准库依赖于 ASCII,但大多数应用程序使用某种形式的 Unicode。WG21 工作组现在有一个研究小组试图找到一个方式去标准化 Unicode 支持(§3.2)。缺乏标准的图形和图形界面则是更难的问题。
  • 清理陈年烂账:这非常困难,而且令人不快。例如,我们知道内置类型之间的隐式窄化转换会导致无穷无尽的问题(§9.3.8),但是有数以万亿计的 C++ 代码行,这些代码以难以预测的方式依赖于那些转换。试图通过添加“更现代”的特性来替换旧特性来进行改进很容易成为 N+1 问题(§4.2.5)的牺牲品。改进的工具(例如静态程序分析和程序转换)提供了希望。

大型语言社区所面临的挑战是多种多样的,不可能有单一而简单的解决方案。这不仅仅是一个语法、类型理论或基本语言设计的问题。有些问题是商业性的。在工业规模上取得成功所需的各种技能范围令人望而生畏。时间会证明,C++ 社区是否能处理好所有这些问题,以及更多的其他问题。这点上我适度乐观,因为现在所有领域都已经有一些积极的举措(§3.2)。

11.4 教训

C++ 是由一个大型委员会控制的,成员多种多样,并且会不断变化(§3.2)。因此,除了技术问题外,我们必须考虑在语言的演化过程中什么是有效的:

  • 问题驱动:C++ 开发应该被那些真实世界中的具体问题的需求所驱动。
  • 简单:C++ 应该从简单、高效、易用的解决方案中进行推广而成长。
  • 高效:C++ 语言和标准库应该遵循零开销原则。
  • 稳定性:不要搞砸我的代码!

大部分(全部?)C++ 最成功部分的开发都遵从了那些“经验法则”。它们自然会限制语言的发展范围,但这是好事。C++ 并不意味着对所有的人都是无所不能的。此外,这些原则迫使 C++ 在现实世界的挑战中相对缓慢地成长,并从反馈中受益。也请参见《C++ 语言的设计和演化》中的其他“经验法则” [Stroustrup 1994] 和我的 HOPL2 论文 [Stroustrup 1993]。这里面一直有连续性。

相比之下,一个功能如果设计时没有明确专注在解决大部分开发者实际面临的问题上,那它通常会失败:

  • 只为专家:某个功能从开始的时候就要满足所有专家的需要。
  • 模仿:我们需要这个功能,因为它在另外某个语言里很流行。
  • 理论性:语言理论里说语言里一定要有这个特性。
  • 革命性:此功能非常重要,以至于我们必须打破兼容性,或者摒弃那些不好的老方法。

我的结论是,尽早确定方向和期望至关重要。稍晚一些,就会有太多的人有太多的不同意见,因而无法达成一套连贯而一致的想法。

给定一个方向和一组原则,一种语言可以基于不同的工具来发展,如反馈、用户体验、实验和理论。这是好的工程方法;反之,则是无原则的实用主义或教条的理想主义。

C++ 标准委员会的章程几乎只关注语言和库的设计。这是有局限性的。一直以来,像动态链接、构建系统和静态分析之类的重要主题大多被忽略了。这是个错误。工具是软件开发人员世界的一个重要组成部分,要是能不把它们置于语言设计的外围就好了。

热衷于各种不同的想法具有危险性。在 2018 年的一篇论文 [Stroustrup 2018d] 中,我列出了 51 条最近的提案:

我列出了我认为有可能显著改变我们编写代码方式的论文,每一篇对教学、维护和编码指导都有重要的影响,其中许多对实现也有影响。

单独来说,许多(大多数)提案都是有道理的,但是放在一起却是疯狂的,甚至足以危及 C++ 的未来。

那篇论文的题目是《记住瓦萨号!》(Remember the Vasa!)。瓦萨号是 17 世纪瑞典的一艘宏伟战舰,由于设计上不断后期添加以及测试不充分,在首航时就沉没在斯德哥尔摩港。在 1990 年代,委员会经常提醒自己记得瓦萨号,但在 2010 年代,这一教训似乎已经被遗忘。

为了对委员会的流程进行组织约束,方向组提出 C++ 程序员的“权利法案” [Dawes et al. 2018]:

  1. 编译期稳定性:新版本标准中的每一个重要行为变化都可以被支持以前版本的编译器检测到。
  2. 链接期稳定性:除极少数情况外,应避免 ABI 兼容性破坏,而且这些情况应被很好地记录下来并有书面理由支持。
  3. 编译期性能稳定性:更改不会导致现有代码的编译时间开销有明显增加。
  4. 运行期性能稳定性:更改不会导致现有代码的运行时间开销有明显增加。
  5. 进步:标准的每一次修订都会为某些重要的编程活动提供更好的支持,或为某些重要编程群体提供更好的支持。
  6. 简单性:每一次对标准的修订都会简化某些重要的编程活动。
  7. 准时性:每一次标准的修订都会按照公布的时间表按时交付。

接下来的几十年,我们将会看到结果到底怎么样。

11.5 未来

从近期来说,C++20 会像 C++11 那样,让 C++ 社区受益良多。在 2020 年 2 月的布拉格会议上,委员会对 C++20 进行了定稿,也投票同意了 Ville Voutilainen 的“C++23 大胆计划” [Voutilainen 2019b]:

“在 C++23 努力做到以下几点:”

注意关注点是在库上。“同时也在以下方面取得进展:”

  • 静态反射功能(§9.6.2
  • 函数式编程风格的模式匹配(§8.2
  • 契约(§9.6.1

鉴于这些议题的工作已经相当深入,委员会有可能会完成大部分工作。这一大群充满热情的人还能拿出什么东西并达成共识,就不那么容易预测了。对于未来几年,方向小组(我是其中的一员)提到了一些有希望进一步开展工作的领域 [Hinnant et al. 2020]:

  • 改进 Unicode 的支持
  • 支持简单图形和简单用户交互
  • 支持新类型的硬件
  • 探索错误处理的更好表达方式和实现方法

在委员会之外,我期望在构建系统、包管理和静态分析方面取得重大进展(§10.4)。

再往后的五年、十年或更远的未来,我在预测水晶球里就有点看不清了。在这个时间范围内,我们需要着眼于根本,而不是具体的语言特性。我希望标准委员会能注意到学到的教训(§11.4),并把重点放在根本上(§11.1):

  • 把完全资源安全和类型安全的 C++ 作为追求目标
  • 很好地支持各种各样的硬件
  • 保持 C++ 的稳定性记录(兼容性)

保持稳定性需要在关注兼容性的同时,抵制试图通过添加大量“完美”特性来取代不完美或不时髦的旧方式来大幅改善 C++ 的冲动。新的特性总是会带来意外(有些令人愉快,有些则不那么令人愉快),旧的特性不会简单地消失。记住瓦萨号![Stroustrup 2018d](§11.4)。很多情况下,库、指南和工具是比修改语言更好的方法。

对于单线程计算来说,硬件已无法变得更快,所以对效率的重视将持续存在,而有效支持各种形式的并发和并行的压力将不断增加(§2.3)。专用硬件将大量涌现(例如,各种内存架构和特殊用途的处理器);这将使 C++ 这样的、可以利用这些硬件的语言受益。唯一比硬件性能增长更快的是人们的期望。

随着系统越来越复杂,开销可负担的抽象机制的重要性也在增加。对于依赖实时交互的系统,可预测的性能是至关重要的(例如,许多实时系统禁止使用自由存储(动态内存))。

随着我们对计算机化系统的依赖程度的增加、高手黑客数量的增多,安全问题只会越来越重要。为了防御,我看好硬件保护,看好更结构化、能支持更好的静态分析的系统,而非无休止的临时运行期检查和低级代码。

语言和系统之间的互操作性仍会至关重要;很少有大系统会只用一种语言来编写。

随着系统变得越来越复杂,对可靠性的要求也越来越高,对设计和编码质量的需求也急剧增加。我认为 C++ 已经为此做好了充分的准备,C++23 的计划是要进一步加强它。然而,仅靠语言特性是不足以满足未来需求的。我们需要有工具支持的使用指南,以确保语言的有效使用(§10.6)。特别是,我们需要确保完全的类型安全和资源安全,这必须反映在教育中。为了蓬勃发展,C++ 需要为新手提供更好的教育材料,也需要帮助有经验的程序员掌握现代 C++。仅仅介绍奇技淫巧和高级用法是不够的,而且反而会因为增强了 C++ 的复杂性名声而对语言造成伤害。

由于种种原因,我们需要简化大多数的 C++ 使用的场景。C++ 的演进已经使之成为可能,而我预计这一趋势将继续下去(§4.2)。改进的优化器——有能力利用代码中使用的类型系统和抽象——让优化这件事变得不同了。在过去的几年里,这极大地改变了我优化代码的方式。我从放弃精巧而复杂的东西开始,那是错误的藏身之处;并且,如果我难以理解发生了什么,编译器和优化器也会如此。我发现,这种方法通常会给我带来从适度到惊人的性能提高,同时也简化了未来的维护。只有当这种方法不能给我带来我想要的性能时,我才会求助于高级(又称复杂)的数据结构和算法。这是 C++ 抽象机制设计上的一大胜利。

我期待着看到用 C++ 构建更多令人兴奋的应用程序,并看到新的编程惯用法和设计技巧的发展。

我也希望其他语言能从 C++ 的成功中学习。假如从 C++ 的演化中吸取的经验教训仅局限于 C++ 社区,那将是可悲的。我希望并期待在其他语言和系统中看到 C++ 模型的关键方面,这将是一个真正的成功衡量标准。在一定程度上,这已经发生了(§2.4)。

致谢

我痛苦地意识到

  • 这篇论文太长了。
  • 对大多数技术主题的描述都省略了很多可以看作是根本的内容。很多情况下,许多人经年累月的工作会被简化为一页甚至一句话。特别是,我忽略了并发性这个极其重要的话题;它应该有一篇专门的长篇论文来进行详述。

感谢让 C++ 成功的数以百万计的程序员,他们创建的应用是我们这个世界的关键部件。

感谢本文草稿的审稿人,包括 Al Aho、A. Bratterud、Shigeru Chiba、J. Daniel Garcia、Brent Hailpern、Howard Hinnant、Roger Orr、Aaron Satlow、Yannis Smaragdakis、David Vandevoorde、J.C. Van Winkel 和 Michael Wong。本文的完整性和准确性在很大程度上依靠这些审稿人。当然,错误归我自己。

感谢 Guy Steele 帮我顺利解决了 LaTex 和 BibTex 中的谜团,把文章引用做到满足 ACM 要求的形式。

感谢所有在标准上努力工作的人。还有很多我没有提到的名字,可以在 WG21 论文的作者和这些论文的致谢部分中找到。我参考和引用的许多“P”和“N”编号的论文保存在 open-std.org/jtc1/sc22/wg21/docs/papers/。没有这些论文,本文的一些内容就会过度依赖我的记忆了。


  1. 译注:Heap Allocation eLision Optimization ↩︎ ↩︎ ↩︎ ↩︎

  2. 译注:下面的代码引自 2006 年的论文,但 operator= 的实现不符合现代惯用法:一般要么把参数设为 clone_ptr p,这就成了一个可以同时适配拷贝或移动的通用赋值函数;要么在函数体内进行一次移动构造,先 clone_ptr temp(std::move(p));std::swap(ptr, temp.ptr);。否则,当传递的实参是 std::move 的结果(xvalue)而不是真正的临时对象(prvalue)时,代码的行为会不符合预期。当然,就如下面 Bjarne 讨论到的,在 2006 年应该还没有 xvalue 和 prvalue 的概念。 ↩︎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值