返回介绍

9.3 C++20 特性

发布于 2024-08-19 12:44:36 字数 44711 浏览 0 评论 0 收藏 0

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:2])。

随着时间的推移,核心协程不断发展和完善 [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 的特性,有符号和无符号类型之间的转换规则几十年来都是那种难以发现的错误的一个主要来源。但说服委员会去解决那些老问题总是很难的。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文