6.2 C++0x 概念
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] 中的例子进行总结。
6.2.1 概念定义
概念被定义为一组操作和相关类型:
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
// ...
}
细化有点像,但又不那么像类派生。这个想法是为了让程序员明确地建立概念的层次结构。不幸的是,这给系统引入了严重的不灵活性。概念(按常规的英语含义)通常不是严格的层次结构。
6.2.2 概念使用
一个概念既可以用作 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
。
6.2.3 概念映射
概念和类型之间的关系是由 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
。
6.2.4 定义检查
编译器根据模板参数的概念检查模板定义中的代码:
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)。
6.2.5 教训
初始提案得到了相对迅速的批准,之后的若干年,我们忙于为初始的设计堵漏,还要应付在通用性、可实现性、规范质量和可用性方面的意见。
作为主要实现者,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] 和其他库中定义的概念来解决这些问题并获得经验。
6.2.6 哪里出错了?
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 概念的失败中得出了不同的结论,我得出三点主要的:
- 我们过分重视早期实现。我们原本应该花更多的精力来确定需求、约束、期望的使用模式,以及相对简单的实现模型。此后,我们可以依靠使用反馈来让我们的实现逐步增强。
- 有些分歧是根本的(哲学上的),无法通过折中解决,我们必须尽早发现并阐明此类问题。
- 没有一套功能集合能做到既满足一个大型专家委员会的所有不同愿望,又不会变得过分庞大,这种膨胀会成为实现者的难题和用户的障碍。我们必须确定核心需求,并用简单的写法来满足;对于更复杂的用法和罕见的用例,则可以用对使用者的专业知识要求更高的功能和写法。
这些结论与概念没有什么特别的关系。它们是对大团体内的设计目标和决策过程的一般观察。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论