导语:本篇介绍的内容源自于 C++ 之父 Bjarne Stroustrup 的论文——HOPL4 。HOPL 是 History of Programming Languages(编程语言历史)的缩写,是 ACM(Association of Computing Machines,国际计算机协会)旗下的一个会议,Bjarne 的这篇论文是他为 2021 年 HOPL IV 会议准备的。这篇 HOPL4 论文尤其重要,因为它涵盖了 C++98 之后的所有 C++ 版本,从 C++11 直到 C++20。论文中有许多涉及提案的细节,也包含了一些 "故事",感兴趣的可以阅读原文。
对 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++ 标准规定:
(1) 第一个模板参数必须是前向迭代器。
(2) 第二个模板参数类型必须能够使用 == 与该迭代器的值类型进行比较。
(3) 前两个函数参数必须标示出一个序列。
这些要求是隐含在代码中的:编译器所要做的就是在函数体中使用模板参数。结果是:极大的灵活性,对正确调用生成出色的代码,以及对不正确的调用有糟糕得一塌糊涂的错误信息。解决方案显而易见,将前两项条件作为模板接口的一部分来指定:
- template
typename Value> - requires equality_comparable
- forward_iterator find(Iter first, Iter last, const Value& val);
注意 equity_comparable 概念,它捕获了两个模板参数之间必需有的关系。这样的多参数概念非常常见。表达第三个要求([first:last) 是一个序列)需要一个库扩展。C++20 在 Ranges 标准库组件中提供了该特性:
- template
typename Value> - requires equality_comparable
- forward_iterator find(R r, const Value& val) {
- auto first = begin(r);
- auto last = end(r);
- while (first != last && *first != val)
- ++first;
- return first;
- }
concept 其实是一个语法糖,它的本质可以认为是一个模板类型的 bool 变量。定义一个concept 本质上是在定义一个 bool 类型的编译期的变量。使用一个 concept 实际上是利用 SFINAE 机制来约束模板类型。约束模板类型,用传统的 C++11 编写代码如下所示:
- template<class T,
- class = typename std::enable_if
::value>::type> - T add_test(T& a, T& b) {
- return a + b;
- }
标准库已经定义了一些常用的concepts了,位于头文件
- template<typename T>
- concept Integral = std::is_integral
::value;
C++ 20 引入了 requires 关键字,在 template 声明以后紧接着用requires关键字说明模板参数需要满足的 concept。
- template <typename T>
- requires Integral
- T add(T& a, T& b) {
- return a + b;
- }
-
- template <typename T>
- T add(T& a, T& b) requires Integral
{ - return a + b;
- }
-
- template
- T add(T& a, T& b) {
- return a + b;
- }
-
- int main() {
- add(1, -2);
- add(1.0f, 2.0f); // 匹配失败
- }
在 C++ 程序中改进模块化是一个迫切的需求。从 C 语言中,C++ 继承了 #include 机制,依赖从头文件使用文本形式包含 C++ 源代码,这些头文件中包含了接口的文本定义。一个流行的头文件可以在大型程序的各个单独编译的部分中被 #include 数百次。基本问题是:
(1) 不够卫生:一个头文件中的代码可能会影响同一翻译单元中包含的另一个 #include 中的代码的含义,因此 #include 并非顺序无关。宏是这里的一个主要问题,尽管不是唯一的问题。
(2) 分离编译的不一致性:两个翻译单元中同一实体的声明可能不一致,但并非所有此类错误都被编译器或链接器捕获。
(3) 编译次数过多:从源代码文本编译接口比较慢。从源代码文本反复地编译同一份接口非常慢。
- #include
- int main() {
- std::cout << "Hello, World!" << std::endl;
- }
这段标准代码有 70 个字符,但是在 #include 之后,它会产生 419909 个字符需要编译器来消化。尽管现代 C++ 编译器已有傲人的处理速度,但模块化问题已经迫在眉睫。
模块化是什么意思?顺序独立性:import X; import Y; 应该与 import Y; import X; 相同。换句话说,任何东西都不能隐式地从一个模块“泄漏”到另一个模块。这是 #include 文件的一个关键问题。#include 中的任何内容都会影响所有后续的 #include。
顺序独立性是“代码卫生”和性能的关键。通过坚持这种做法,Gabriel Dos Reis 的模块实现也比使用头文件在编译时间上得到了 10 倍量级的性能提升——即使在旧式编译中使用了预编译头文件也是如此。考虑如下代码所示的 C++20 模块的简单示例:
- export module map_printer; // 定义一个模版
- import iostream; // 使用 iostream
- import containers; // 使用自己的 containers
- using namespace std;
-
- export // 让 print_map() 对 map_printer 的用户可见
- template
- requires Printable
> && Printable> - void print_map(const S& m) {
- for (const auto& [key, val] : m) {
- cout << key << " -> " << val << endl;
- }
- }
这段代码定义了一个模块 map_printer,该模块提供函数 print_map 作为其用户接口,并使用了从模块 iostream 和 containers 导入的功能来实现该函数。为了强调与旧的 C++ 风格的区别,我使用了概念和结构化绑定。关键思想:
(1) export 指令使实体可以被 import 到另一个模块中。
(2) import 指令使从另一个模块 export 出来的的实体能够被使用。
(3) import 的实体不会被隐式地再 export 出去。
(4) import 不会将实体添加到上下文中;它只会使实体能被使用(因此,未使用的 import 基本上是无开销的)。
最后两点不同于 #include,并且它们对于模块化和编译期性能至关重要。头文件和 #include 并不会在一夜之间被淘汰,可能再过几十年都不会。好几个人和组织指出,我们需要一些过渡机制,使得头文件和模块可以在程序中共存,并让库为不同代码成熟度的用户同时提供头文件和模块的接口。请记住,在任何给定的时刻,都有用户依赖 10 年前的编译器。
考虑在无法修改 iostream 和 container 头文件的约束下实现 map_printer:
- export module map_printer;
-
- import
; - import "containers";
- using namespace std;
-
- export
- template
- requires Printable
> && Printable> - void print_map(const S& m) {
- for (const auto& [key, val] : m) {
- cout << key << " -> " << val << endl;
- }
- }
指名某个头文件的 import 指令工作起来几乎与 #include 完全一样——宏、实现细节以及递归地 #include 到的头文件。但是,编译器确保 import 导入的“旧头文件”不具有相互依赖关系。也就是说,头文件的 import 是顺序无关的,因此提供了部分、但并非全部的模块化的好处。例如,像 import
协程提供了一种协作式多任务模型,比使用线程或进程要高效得多。协程曾是早期 C++ 的重要组成部分。如果没有提供协程的任务库,C++ 将胎死腹中,但是由于多种原因,协程并没有进入 C++98 标准。
C++20 协程的历史始于 Niklas Gustafsson(微软)关于“可恢复函数”的提案。其主要目的是支持异步 I/O;“能够处理成千上万或以百万计客户的服务器应用程序”。它相当于当时引入到 C#(2015 年的 6.0 版)的 async/await 功能。类似的功能已经存在于 Python、JavaScript 和其他语言里。Niklas 的提案引发了来自 Oliver Kowalke 和 Nat Goodspeed 的基于 Boost.Coroutine 的竞争提案,并引起了人们的浓厚兴趣。await 设计无栈、不对称且需要语言支持,而源自 Boost 的设计则使用栈、具有对称控制原语且基于库。无栈协程只能在其自身函数体中挂起,而不能从其调用的函数中挂起。这样,挂起仅涉及保存单个栈帧(“协程状态”),而不是保存整个栈。对于性能而言,这是一个巨大的优势。
协程的设计空间很大,因此很难达成共识。委员会中的许多人(包括我在内)都希望能够综合考虑这两种方式的优点,因此一群感兴趣的成员对可选方案进行了分析。结论是,有可能同时利用这两种方式的优点,但这需要认真研究。这项研究花了数年时间,但没有得出明确的结果。与此同时,出现了更多的提案。
在提案中,三种想法反复出现:
(1) 将协程的状态及其操作表示为 lambda 表达式,从而使协程优雅地适配 C++ 类型系统,而不需要 await 式协程所使用的某些“编译器魔法”。
(2) 为无栈和有栈协程提供通用接口——也可能为其他类型的并发机制,例如线程和纤程,提供通用接口。
(3) 为了在最简单和最关键的用途(生成器和管道)上获得最佳性能(运行时间和空间),无栈协程需要编译器支持,并且一定不能为了支持更高级的用例而在接口上作妥协。
作者非常喜欢通用接口的想法,因为这样可以最大限度地减少学习需要的努力,并使得实验大为便捷。类似地,使用完全普通的对象来表示协程将开放整个语言来支持协程。然而,最终性能论胜出。
TS 协程的一个重要且可能致命的问题是,它依赖于自由存储区(动态内存、堆)上的分配。在某些应用程序中,这是很大的开销。更糟糕的是,对于许多关键的实时和嵌入式应用程序,自由存储区的使用是不允许的,因为它可能导致不可预测的响应时间和内存碎片。核心协程没有这个问题。然而,Gor Nishanov 和 Richard Smith 论证了,TS 协程可以通过多种方式之一保证几乎所有用途都没有(并检测和防止其他用途)自由存储区的使用。特别是,对于几乎所有的关键用例,都可以将自由存储区使用优化为栈分配(所谓的 "Halo 优化")。
考虑一个 C++20 协程的简单例子:
- generator<int> fibonacci() {
- 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 << endl;
- }
使用 co_yield 使 fibonacci() 成为一个协程。generator
这部分取得的进展和最后的成功很大程度上归功于 Gor Nishanov。要不是有他的坚韧不拔和扎实实现(他完成了微软和 Clang 两种编译器里的实现),那么在 C++20 也不会有协程。
多年以来,在 C++ 中编译期求值的重要性一直在稳步提高。STL 严重依赖于编译期分发,而模板元编程主要旨在将计算从运行期转移到编译期。甚至在早期的 C++ 中,对重载的依赖以及虚函数表的使用都可以看作是通过将计算从运行期转移到编译期来获得性能。因此,编译期计算一直是 C++ 的关键部分。
C++ 从 C 继承了只限于整型且不能调用函数的常量表达式。曾有一段时间,宏对于任何稍微复杂点的事情都必不可少。但这些都不好规模化。一旦引入模板并发现了模板元编程,模板元编程就被广泛用于在编译期计算值和类型上。在 2010 年,Gabriel Dos Reis 和 Bjarne Stroustrup 发表了一篇论文,指出编译期的值计算可以(也应该)像其他计算一样表达,一样地依赖于表达式和函数的常规规则,包括使用用户定义的类型。这成为 C++11里的 constexpr 函数,它是现代编译期编程的基础。C++14 推广了 constexpr 函数,而 C++20 增加了好几个相关的特性:
(1) consteval——保证在编译期进行求值的 constexpr 函数
(2) constinit——保证在编译期初始化的声明修饰符
(3) 允许在 constexpr 函数中使用成对的 new 和 delete
(4) constexpr string 和 constexpr vector
(5) 使用 virtual 函数
(6) 使用 unions、异常、dynamic_cast 和 typeid
(7) 使用用户定义类型作为值模板参数——最终允许在任何可以用内置类型的地方使用用户定义类型
(8) is_constant_evaluated() 谓词——使库实现者能够在优化代码时大大减少平台相关的内部函数的使用
随着这一努力,标准库正在变得对编译期求值更加友好,最终也是为了让 C++23 或更高版本支持静态反射。
紧接在“飞船运算符”(<=>)投票进入 C++20 之后,很明显,在语言规则及其与标准库的集成方面都需要进一步的认真工作。出于对解决跟比较有关的棘手问题的过度热情和渴望,委员会成了意外后果定律的受害者。一些委员(包括我在内)担心引入 <=> 过于仓促。然而,在我们的担忧坐实的时候,早已经有很多工作在假设 <=> 可用的前提下完成了。此外,三向比较可能带来的性能优势让许多委员会成员和其他更广泛的 C++ 社区成员感到兴奋。因此,当发现 <=> 在重要用例中导致了显著的低效时,那就是一个相当令人不快的意外了。类型有了 <=> 之后,== 是从 <=> 生成的。对于字符串,== 通常通过首先比较大小来优化:如果字符数不同,则字符串不相等。从 <=> 生成的 == 则必须读取足够的字符串以确定它们的词典顺序,那开销就会大得多了。经过长时间的讨论,我们决定不从 <=> 生成 ==。这一点和其他一些修正解决了手头的问题,但损害了 <=> 的根本承诺:所有的比较运算符都可以从一行简单的代码中生成。此外,由于 <=> 的引入,== 和 < 现在有了许多不同于其他运算符的规则(例如,== 被假定为对称的)。无论好坏,大多数与运算符重载相关的规则都将 <=> 作为特例来对待。
范围库始于 Eric Niebler 对 STL 序列观念的推广和现代化的工作。它提供了更易于使用、更通用及性能更好的标准库算法。例如,C++20 标准库为整个容器的操作提供了期待已久的更简单的表示方法:
- void test(vector
& vs) { - sort(vs); // 而不是 sort(vs.begin(), vs.end());
- }
C++98 所采用的原始 STL 将序列定义为一对迭代器。这遗漏了指定序列的两种重要方式。范围库提供了三种主要的替代方法(现在称为 ranges):
(1) (首项,尾项过一) 用于当我们知道序列的开始和结束位置时(例如“对 vector 的开始到结束位置进行排序”)。
(2) (首项,元素个数) 用于当我们实际上不需要计算序列的结尾时(例如“查看列表的前 10 个元素”)。
(3) (首项,结束判据) 用于当我们使用谓词(例如,一个哨位)来定义序列的结尾时(例如“读取到输入结束”)。
range 本身是一种 concept。所有 C++20 标准库算法现在都使用概念进行了精确规定。这本身就是一个重大的改进,并使得我们在算法里可以推广到使用范围,而不仅仅是迭代器。这种推广允许我们把算法如管道般连接起来:
- vector<int> vec = {1,2,3,4,5,6,7,8,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 << endl; // 打印前 5 个偶整数的平方
像在 Unix 中一样,管道运算符 | 将其左操作数的输出作为输入传递到其右操作数(例如 A|B 表示 B(A))。一旦人们开始使用协程来编写管道过滤器,这就会变得有趣得多。
日期库是日期库是多年工作和实际使用的结果,它基于 chrono 标准库的时间支持。在 2018 年,它通过投票进入了 C++20,并和旧的时间工具一起放在
- constexpr auto tp = 2016y/May/29d + 7h + 30min + 6s + 153ms;
- cout << tp << endl; // 2016-05-29 07:30:06.153
该表示法很传统(使用用户定义的字面量),日期表示为 年,月,日 结构。但是,当需要时,日期会在编译期映射到标准时间线(system_time)上的某个点(使用 constexpr 函数),因此它极其快速,也可以在常量表达式中使用。例如:
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
日期库还可以处理星期几(例如,Monday 和 Friday)、多个日历(例如,格里历和儒略历),以及更深奥(但必要)的概念,比如闰秒。
除了有用和快速之外,日期库还有趣在它提供了非常细粒度的静态类型检查。常见错误会在编译期捕获。例如:
- 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 年?
日期库是标准库组件中的一个少见的例子,它直接服务于某应用领域,而非“仅仅”提供支持性的“计算机科学”抽象。我希望在将来的标准中能看到更多这样的例子。
iostream 库提供了类型安全的 I/O 的扩展,但是它的格式化工具比较弱。另外,还有的人不喜欢使用 << 分隔输出值的方式。格式化库提供了一种类 printf 的方式去组装字符串和格式化输出值,同时这种方法类型安全、快捷,并能和 iostream 协同工作。类型中带有 << 运算符的可以在一个格式化的字符串中输出:
- string s = "foo";
- cout << format("The string '{}' has {} characters",s,s.size());
输出结果是 The string 'foo' has 3 characters。
这是“类型安全的 printf”变参模板思想的一个变体。大括号 {} 简单地表示了插入参数值的默认表示形式。参数值可以按照任意顺序被使用:
- // s 在 s.size() 前
- cout << format("The string '{0}' has {1} characters",s,s.size());
- // s 在 s.size() 后
- cout << format("The string '{1}' has {0} characters",s.size(),s);
像 printf() 一样,format() 为展现格式化细节提供了一门小而完整的编程语言,比如字段宽度、浮点数精度、整数基和字段内对齐。不同于 printf(),format() 是可扩展的,可以处理用户定义类型。下面是
- 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 支持。
越界访问,有时也称为缓冲区溢出,从 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 标准委员会提议:“‘胖指针’,它的表示中包括了内存空间以存放运行期可调整的边界”。由于各种原因,C 标准委员会没有通过这个提案。在当时,我听到一条极可笑的评论:“Dennis 不是 C 的专家;他从不来参加会议。”我没记住这到底是谁说的,也许这是件好事。
2015 年,Neil MacIntosh 在 C++ 核心指南里恢复了这一想法,那里我们需要一种机制来鼓励和选择性地强制使用高效编程风格。span
- void f(span<int> a) { // span 包含一个指针和一条大小信息
- for (int& x : a) {
- x = 7; // 可以
- }
- }
范围 for 从跨度中提取范围,并准确地遍历正确数量的元素(无需代价高昂的范围检查)。这个例子说明了一个适当的抽象可以同时简化写法并提升性能。对于算法来说,相较于挨个检查每一个访问的元素,明确地使用一个范围(比如 span)要容易得多,开销也更低。
如果有必要的话,你可以显式地指定一个大小(比如操作一个子范围)。但这样的话,你需要承担风险,并且这种写法比较扎眼,也易于让人警觉:
- int x = 100;
- int a[100];
- f(a, x); // 模版参数推导: f(span
{a, 100}) - f(a, x / 2); // 可以:a 的前半部分
- f(a, x + 1); // 灾难
自然、简单的元素访问也办得到,比如 a[7]=9, 同时运行期也能进行检查。span 的范围检查是 C++ 核心指南支持库(GSL)的默认行为。
事实证明,将 span 纳入 C++20 的最具争议的部分在于下标和大小的类型。C++ 核心指南中 span::size() 被定义返回一个有符号整数,而不是标准库容器所使用的无符号整数。下标的情况也类似。像在数组中,下标一向是有符号的整数,而在标准库容器中下标却是无符号整数。这导致了一个古老争议的重演:
(1) 一组人认为显然下标作为非负数应该使用无符号整数。
(2) 一组人认为与标准库容器保持一致性更重要,这点使得使用无符号整数是不是一个过去的失误变得无关紧要。
(3) 一组人认为使用无符号整数去表示一个非负数是一种误导(给人一种虚假的安全感),并且是错误的主要来源之一。
不顾 span 最初的设计者(包括我在内)和实现者的强烈反对,第二组赢得了投票,并受到第一组热情地支持。就这样,std::span 拥有无符号的范围大小和下标。我个人认为那是一个令人悲伤的失败,即未能利用一个难得的机会来弥补一个令人讨厌的老错误。C++ 委员会选择了与问题兼容而不是消除一个重大的错误来源,这在某种程度上是可以预见的,也算不无道理吧。
但是用无符号整数作为下标会出什么问题呢?这似乎是一个相当情绪化的话题。我曾收到很多封与之相关的仇恨邮件。存在两个基本问题:
(1) 无符号数并不以自然数为模型:无符号数使用模算数,包括减法。比如,如果 ch 是个 unsigned char,ch+100 将永远不会溢出。
(2) 整数和无符号数彼此相互转换,稍不留意负数值就会变成巨大的无符号数值,反之亦然。比如,-2<2u 为假;2u 是 unsigned,因此 -2 在比较前会被转换为一个巨大的正整数。
这是一个在真实环境下偶尔可见的无限循环的例子:
for (size_t i = n-1; i >= 0; --i) { /* ... */ } // “反向循环”
不幸的是,标准库中的类型 size_t 是无符号类型,然后很明显结果永远 >=0。总的来说,作为 C++ 继承自 C 的特性,有符号和无符号类型之间的转换规则几十年来都是那种难以发现的错误的一个主要来源。但说服委员会去解决那些老问题总是很难的。
尽管做出了英勇的努力,并正在形成广泛的共识,但是人们所期望的通用并发模型(“执行器”)在 C++20 中还没有准备好。这并非是因为缺乏努力,我们的努力中包括了 2018 年 9 月在华盛顿州贝尔维尔举行的为期两天的特别会议,约有 25 人出席,其中有来自英伟达、Facebook 和美国国家实验室的代表。不过,有几个不那么剧烈的有用改进还是及时完成了,其中包括:
(1) jthread 和停止令牌
(2) atomic
> (3) 经典的信号量
(4) 屏障和锁存器
(5) 小的内存模型的修复和改进
jthread(“joining thread”的缩写)是一个遵守 RAII 的线程;也就是说,如果 jthread 超出作用域了,它的析构函数将合并线程而不是终止程序:
- void some_fct() {
- thread t1;
- jthread t2;
- // ...
- }
在作用域的最后,t1 的析构函数会终止程序,除非 t1 的任务已经完成,已经 join 或 detach,而 t2 的析构函数将会等待其任务完成。
一开始的时候(C++11 之前),很多人(包括我在内)都希望 thread 可以拥有如今 jthread 的行为,但是根植于传统操作系统线程的人坚持认为终止一个程序要远比造成死锁好得多。2012 年和 2013 年,Herb Sutter 曾经提出过合并线程。这引发了一系列讨论,但最终却没有作出任何决定。2016 年,Ville Voutilainen 总结了这些问题,并为将合并线程纳入 C++17 发起了投票。投票支持者众多以至于我(只是半开玩笑地)建议我们甚至可以把合并线程作为一个错误修复提交给 C++14。但是不知何故,进展又再次停滞。到了 2017 年,Nico Josuttis 又一次提出了这个问题。最终,在八次修订和加入了停止令牌之后,这个提案才成功进入了 C++20。
“停止令牌”解决了一个老问题,即如何在我们对线程的结果不再感兴趣后停止它。基本思想是使用协作式的线程取消方式。假如我想要一个 jthread 停止,我就设置它的停止令牌。线程有义务不时地去检查停止令牌是否被设置了,并在设置时进行清理和退出。这个技巧由来已久,对于几乎每一个有主循环的线程都能完好高效地工作,在这个主循环里就可以对停止令牌进行检查。
像往常一样,命名成了问题:safe_thread、ithread(i 代表可中断)、raii_thread、joining_thread,最终成了 jthread。C++ 核心指南支持库 (GSL) 中称其为 gsl::thread。说真的,最合适的名字就是 thread,但是很不幸,那个名字已经被一类不太有用的线程占用了。
C++20 提供了许多次要的新特性,包括:
(1) C99 风格的指派初始化器
(2) 对 lambda 捕获的改进
(3) 泛型 lambda 表达式的模板参数列表
(4) 范围 for 中初始化一个额外的变量
(5) 不求值语境中的 lambda 表达式
(6) lambda 捕获中的包展开
(7) 在一些情况下移除对 typename 的需要
(8) 更多属性:[[likely]] 和 [[unlikely]]
(9) 在不使用宏的情况下,source_location 给出一段代码中的源码位置
(10) 功能测试宏
(11) 条件 explicit
(12) 有符号整数保证是 2 的补码
(13) 数学上的常数,比如 pi 和 sqrt2
(14) 位的操作,比如轮转和统计 1 的个数
其中有些属于改进,但是我担心的是晦涩难懂的新特性的数量之大会造成危害。对于非专家来说,它们使得语言变得更加难以学习,代码更加难以理解。我反对一些利弊参半的特性(比如,使用指派初始化器的地方原本可以使用构造函数,那会产生更易于维护的代码)。很多特性具有特殊用途,有些是“专家专用”。不过,有的人总是领会不到,一个对某些人有某种好处的特性,对于 C++ 整体可能是个净负债。当然,那些增加写法和语义上的通用性和一致性的小特性,则总是受欢迎的。从标准化的角度来看,即使最小的特性也需要花时间去处理、记录和实现。这些时间是省不掉的。
网络库
在 2003 年,Christopher M. Kohlhoff 开始开发一个名叫 asio 的库,以提供网络支持:
“Asio 是用于网络和底层 I/O 编程的一个跨平台 C++ 库,它采用现代化 C++ 的方式,为开发者提供了一致的异步模型”
在 2005 年,它成为了 Boost 的一部分,并在 2006 年被提案进入标准。在 2018 年,它成为了 TS。尽管经过了 13 年的重度生产环境使用,它还是未能进入 C++17 标准。更糟糕的是,让网络库进入 C++20 标准的工作也停滞不前。这意味着,在 asio 得以在生产环境中使用 15 年之后,我们还是不得不至少等到 2023 年,才能看到它成为标准的一部分。延误原因在于,我们仍在进行严肃的讨论,如何最好地将 asio 中和其他场合中处理并发的方式一般化。为此提出的“执行器(executors)”提案得到了广泛的支持,并且有人还期望它能成功进入 C++20。
契约
契约的特殊之处在于,不但很多人希望它可以进入 C++20,而且契约是被投票写入 C++20 的工作文件中的,只是在最后一刻又被移除了。
为避免引入新的关键字,我们使用属性语法。例如,[[assert: x+y>0]]。一个契约对一个有效的程序不起任何作用,因此这种方式满足属性的原来概念。
有三种契约:
assert——可执行代码中的断言
expects——函数声明中的前置条件
ensure——函数声明中的后置条件
有三种不同等级的契约检查:
audit——“代价高昂的”谓词,仅在某些“调试模式”检查
default——“代价低廉的”谓词,即使在生产代码中检查也是可行的
axiom——主要给静态分析器看的谓词,在运行期从不检查
在违反契约时,将执行(可能是用户安装的)契约违反处理程序。默认行为是程序立即终止。
基于 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 的检查。这种被称为“时间旅行优化”的做法当然是与契约的初衷大相径庭,还好若干补救方案被及时提出。
2018 年 8 月,在 C++20 新提案的最后期限过后,由 John Lakos 领导的 Bloomberg 的一个小组,提出了一系列重新设计的提案。特性冻结的日期(审议新提案的最后一天)是由委员会全体投票表决确定的。这些提案则是基于在契约自身中规定契约行为的方案。例如:
[[assert check_maybe_continue: x>0]] 和 [[assert assume: p!=nullptr]]。
与其使用构建模式去控制所有契约(比如,激活所有默认契约或关闭所有基于契约的运行期检查)的行为,你不如直接修改单个契约的代码。在这方面,这些新方案与工作文件中决议通过的设计大相径庭。考虑下面的例子:
[[assert assume: p!=nullptr]]
这将使得 2014 年被否决的基于宏的方案卷土重来,因为管理代码变化的显然方式是用宏,例如:
[[assert MODE1: p!=nullptr]]
这里的 MODE1 可以被 #define 成所支持的若干选项之一,如 assume 和 default。或者,大致等效地,通过命令行上的参数(类似于命令行宏)来定义诸如 assume 之类的限定符的含义。
本质上,契约违约后继续执行的可能性与程序员对契约含义的控制的两者的结合,将把契约机制从断言系统转变为一种新的控制流机制。
一些提案甚至建议放弃对静态分析的支持。类似这样的提案有几十个变种,全都来得太晚,没一个能增进共识。
大量涌入的新奇提案和成百上千讨论这些提案的电子邮件阻碍了真正必需的讨论,即对工作文件中的设计现状进行问题修复。这些企图重新设计契约的提案的结果是,在 Nico Josuttis 的提议下,契约被从 C++20 中移除。
静态反射
2013 年,一个研究“反射”的研究组成立了,并发出了征集意见的呼吁。有一个广泛的共识,那就是 C++ 需要静态反射机制。更确切地说,需要一种方法来写出能检查它自己是属于哪个程序的一部分的代码,并基于此往该程序中注入代码。那样,就可以使用简洁的代码替换冗长而棘手的样板代码、宏和语言外的生成器。比如,可以为下面的场景自动生成函数,如:I/O 流、日志记录、比较、用于存储和网络的封送处理(marshaling)、构造和使用对象映射、枚举的“字符串化”、测试支持,及其他的更多可能。反射研究组的目标是为 C++20 或 C++23 做好准备。
大家普遍认同,依赖在运行期遍历一个始终存在的数据结构的反射/内省方式不适合 C++,因为这种数据的大小、语言构造的完整表示的复杂性和运行期遍历的成本都会是问题。
很快出现了一些提案,并且,在接下来的数年里,由 Chandler Carruth 主持的研究组召开了多次会议试图决定其范围和方向。选定的方式基于类型,这些类型以经典的面向对象的类层次结构来组织,需要泛型的地方由概念支持。
在静态反射(预期的)长时间的酝酿期内,基于 constexpr 函数的编译期计算稳步发展,最终出现了基于函数而不是类层次结构的静态反射的提案。主要的拥护者是 Andrew Sutton、Daveed Vandevoorde、Herb Sutter 和 Faisal Vali。设计焦点转移的主要论据,一部分是由于分析和生成代码这些事天生就是函数式的,而且基于 constexpr 函数的编译期计算已经发展到元编程和反射相结合的地步。这种方法的另一个优点是从编译器的内部的数据结构看,为函数服务的天生就比为类型层次结构服务的更小、生命周期更短暂,因此它们使用的内存明显更少,编译速度也明显更快。
2019 年 2 月在科隆召开的标准会议上,David Sankel 和 Michael Park 展示了一个结合了这两个方法优点的设计。在最根本的层面上仅有一个单一的类型存在。这达到了最大的灵活性,并且编译器开销也最小。
最重要的是,静态类型的接口可以通过一种类型安全的转换来实现(从底层的单一类型 meta::info 到更具体的类型,如 meta::type_ 和 meta::class_)。通过概念重载,它实现了从 meta::info 到更具体类型的转换。考虑下面的例子:
- namespace meta {
- consteval std::span
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,根据一个异质结构中的元素的类型扩展每个元素,从而遍历该结构的各元素。此外,我们也有机制可以将代码注入正在编译的程序中。类似这样的东西很可能会在 C++23 或 C++26 中成为标准。
作为一个副作用,在反射方案上的雄心勃勃的工作也刺激了编译期求值功能的改进:
标准中的类型特征集
源代码位置的宏(如 __FILE__ 和 __LINE__)被内在机制所替代
编译期计算的功能(例如,用于确保编译期求值的 consteval)
展开语句(template for——到 C++23 就可以用来遍历元组中的元素。
参考:
Thriving in a Crowded and Changing World: C++ 2006–2020
https://zhuanlan.zhihu.com/p/266086040