C++ 2006–2020

 主页   资讯   文章   代码   电子书 

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)。