对 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;
}
为了规范模板对其参数的要求,对其提供良好支持,有过数次尝试。本节会进行描述:
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)。这些都是后面所有概念变体的一部分。
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] 中的例子进行总结。
概念被定义为一组操作和相关类型:
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
// ...
}
细化有点像,但又不那么像类派生。这个想法是为了让程序员明确地建立概念的层次结构。不幸的是,这给系统引入了严重的不灵活性。概念(按常规的英语含义)通常不是严格的层次结构。
一个概念既可以用作 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
。
概念和类型之间的关系是由 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
时,即使用户没有提供该类型的特化,编译器也会自动使用指向 EqualityComparable
的 concept_map
。
编译器根据模板参数的概念检查模板定义中的代码:
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)。
初始提案得到了相对迅速的批准,之后的若干年,我们忙于为初始的设计堵漏,还要应付在通用性、可实现性、规范质量和可用性方面的意见。
作为主要实现者,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;
}
这一“解决方案”充其量只能算是个补丁,而且有一个特殊的问题,即调用到的无约束模板中不会知道 Semigroup
的 concept_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] 和其他库中定义的概念来解决这些问题并获得经验。
2009年,我不情愿地得出结论,概念工作陷入了困境。我期望能被我们解决掉的问题仍在加剧,而新的问题又层出不穷:
- 我们仍然没有达成一致意见,在大多数情况下,到底应使用隐式还是显式建模(隐式或显式使用
concept_map
),哪种才是正确的方法。 - 我们仍然没有达成一致意见,是要依赖概念之间隐式还是显式的关系陈述(我们是否应该以某种非常类似面向对象的继承的方式,显式地构建“精化”关系的层次结构?)。
- 我们仍不断看到一些实例,由受概念约束的代码生成出来的代码不及无约束模板生成出来的代码。来自模板的后期组合机会仍然显示出惊人的优势。
- 编写概念来捕获我们在泛型和非泛型 C++ 中惯于使用的每种转换和重载情况仍然很困难。
- 我们看到了越来越多的例子,这些例子中,足够复杂的
concept_map
和late_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 概念的失败中得出了不同的结论,我得出三点主要的:
- 我们过分重视早期实现。我们原本应该花更多的精力来确定需求、约束、期望的使用模式,以及相对简单的实现模型。此后,我们可以依靠使用反馈来让我们的实现逐步增强。
- 有些分歧是根本的(哲学上的),无法通过折中解决,我们必须尽早发现并阐明此类问题。
- 没有一套功能集合能做到既满足一个大型专家委员会的所有不同愿望,又不会变得过分庞大,这种膨胀会成为实现者的难题和用户的障碍。我们必须确定核心需求,并用简单的写法来满足;对于更复杂的用法和罕见的用例,则可以用对使用者的专业知识要求更高的功能和写法。
这些结论与概念没有什么特别的关系。它们是对大团体内的设计目标和决策过程的一般观察。
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_iterator
与 Input_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))的程序要快。
在 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],包括定义检查。如果我们找到一种可接受的定义检查形式,我们就可以实现它。
简单的示例看起来很像 C++0x 及更早的版本中的样子:
template<Sequence Seq, Number Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v += x;
return v;
}
这里 Sequence
和 Number
是概念。使用概念而不是 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
的值类型和我们用作累加器的类型的组合使用。我们不再需要说明 Num
为 Number
,Arithmetic
会检查 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
程序员唯一能利用的控制机制是在定义概念时为其增加操作。不过对于现实世界的例子来说,这似乎已经足够了。当然,你可以定义一些只在语义上有差异的概念,这样就没有办法根据我们的纯语法概念来区分它们。然而,要避免这样做并不困难。
通过 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)。
从使用中我们学到的一件事情是,基础概念的使用有很多重复。我们在 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);
仅仅通过尝试,你就能学到很多东西,这真是令人惊叹!同样令人惊叹的是,对于那些尚未经历过这些问题的人,新颖的写法和解决方案在他们那里也会遭遇巨大的阻力。
许多人仍然将概念视为(无论过去和现在)类型的类型这个想法的变体。是的,只有一个类型参数的概念可以看作是一个类型的类型,但只有最简单的用法才适合该模式。
大多数泛型函数(算法)都需要不止一个模板参数,要让这样的函数有意义,这些参数类型必须以某种方式关联起来。因此,我们必须使用多参数概念。例如:
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_type
和Comparable
) - 这三种技术的组合。
结果是非常复杂的模板参数类型约束。我们认为这种复杂性是不必要的,也无法进行管理。譬如 x+y
和 y+x
,其中 x
和 y
具有不同的模板参数类型,X
和 Y
。在处理各自的模板参数时,我们必须将 X
和 Y
以及 Y
和 X
进行参数化。在纯面向对象语言中,这看起来很自然。毕竟,有两种方法可以进行 +
运算,一种在 X
的层次结构中,一种在 Y
的层次结构中。然而,我早在 1982 年就拒绝了 C++ 的这个解决方案。要完成这一图景,我们必须添加隐式类型转换(例如,处理 x+2
和 2+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
。
在 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>;
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 中最不幸的设计错误,我们应该尝试纠正这一错误,而不是让它影响概念的定义。毫无疑问,误解的可能性是真实存在的,并且一旦该机制被很多人使用时,肯定会发生。但是,我在现实中没有看到过这种问题,而且我怀疑经验相对丰富的程序员如果遇到这种差异真正会产生影响时,真的会遇到麻烦。换句话说,我认为这是“尾巴摇狗”的一个示例;也就是说,一个不起眼的例子阻止了一个可以使大量用户受益的特性。
我也很确定,我的目标是使泛型编程尽可能地像“普通”编程,但这不是普遍共识。仍然有人认为,泛型编程超出了绝大部分程序员的能力。但我没有看到任何证据。
我曾希望并期望在 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)。
在 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
的临时设计数量激增。一代程序员在低级的、无类型的元编程中成长起来。
在发布 C++20 之前有关概念的最后讨论中,有一个是关于概念的命名约定。命名始终是一个棘手的话题。在我早期涉及概念的工作中,我通常以非标准的命名类型的方式来命名概念:像命名专有名词一样,将第一个字母大写,并用下划线来分隔单词,以保证可读性(例如 Sortable
和 Forward_iterator
)。其他人(尤其是印第安纳团队)则使用了驼峰式命名(例如 Sortable
和 ForwardIterator
)。不幸的是,这种命名约定悄悄进入了标准文本 [Carter 2018],并由于与标准库中的所有其他名称不同而引起一些混乱。在那里,使用了下划线,不使用大写字母(除了一些宏和三个晦涩难懂的示例)。然后有人认为,不同的命名约定旨在将“新颖且困难”的概念与“常规构件”(例如函数和类型)区分开来。
当我注意到这种辩解时,我非常不喜欢。在 C++ 中,我们通常不会把类型编码到实体名称中,但我认为更改命名风格为时已晚。在 2019 年,Herb Sutter 对我的抱怨做出了回应,提议重命名所有标准库中的概念,以遵循常见的标准库命名约定 [Sutter et al. 2019]。大部分概念设计者和范围库(§9.3.5)的设计者作为共同作者都签了字。进行此更改的另一个原因是,我们开始看到标准库里概念的驼峰式名称与其他库中的驼峰式名称之间存在冲突。使用驼峰式命名(或使用我的大写类型约定)的原因之一就是为了避免与标准库冲突。因此,我们现在有了 sortable
、forward_iterator
等。
C++20 标准库包含大约 70 个概念,包括 constructible_from
、convertible_to
、derived_from
、equal_comparable
、invocable
、mergeable
、range
、regular
、same_as
、signed_integral
、semiregular
、sortable
、swappable
和 totally_ordered
,涵盖了运算符调用、基本类型的使用、范围和标准算法的需求。它们将指导许多 C++ 库的设计。请注意,这 70 个概念中很多并不是基本概念,而只是为了方便表示或用作基本构建单元。