8.8 C++17 中未包含的提议
除了概念(§6.3.8)以外,一些我认为很重要的提案没有加入 C++17。如果不提及它们,C++ 的历史就不完整:
- §6.3.8:概念(C++20)
- §8.8.1:网络
- §8.8.2:点运算符
- §8.8.3:统一调用语法
- §8.8.4:默认比较
- §9.3.2:协程(C++20)
静态反射是在一个研究小组(§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
的朴素实现,也就是比较持有 s1
和 s2
的字的比特位的方式,可能会给出 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)。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论