C++ 概念简介
C++ 模板不仅具备强大的泛化能力,自身也是一种“图灵完备”的语言,掀起了 C++ 之父 Bjarne Stroustrup 自己都没料到的“模板元编程”这一子领域。
但是,使用模板做泛型编程,最大的问题就是缺少良好的接口,一旦使用过程中出现偏差,报错信息我们难以理解,甚至无从下手。更糟的是,使用模板的代码几乎无法做到程序 ABI 层面兼容。这些问题的根本原因是 C++ 语言本身缺乏模板参数约束能力,因此,既能拥有良好接口、高性能表达泛化,又能融入语言本身是非常困难的。
好在 C++20 标准及其后续演进中,为我们带来了 Concepts 核心语言特性变更来解决这一难题。那么它能为我们的编程体验带来多大的革新?能解决多少模板元编程的历史遗留问题?今天我们一起探究 Concepts。
第 1 章 概念和约束的历史
早在 1987 年,C++ 之父 Bjarne Stroustrup 就着手为模板参数设置合适的接口。长期以来,模板参数没有任何约束,仅仅在实例化的时候才能发现类型上的错误。他希望模板拥有如下三大特点:
- 强大的泛化、表达能力。
- 相对于手写代码做到零成本开销。
- 良好的接口。
目前看来 C++ 做到了前两点,强大的泛化与表达能力具备“图灵完备”的能力,能够在编译时完成大量计算任务,同时生成的代码拥有比手写更高的性能,在提供前所未有的灵活性的前提下并没有性能损失,这使得模板特性非常成功。
20 世纪 90 年代,泛型编程因 C++ 中的标准模板库而成为主流,开发人员也开始在库开发中广泛使用泛型编程手段。使用模板做泛型编程过程中遇到的问题是缺少良好的接口,导致编译错误信息非常难读,这困扰了开发人员许多年。除了错误信息不够友好之外,在阅读使用模板元编程的库时面对大量模板参数,在不深入实现的前提下也常常不知为何物。语言上的缺陷导致后来产生 enable_if
等变通方法。
包括 C++ 之父在内的许多人都在寻求解决方案,尤其是标准委员会的成员希望该方案能够在 C++0x 版本落地,但直到后来的 C++17 版本也都没能实现。没有人能够提出一种既能满足这三种目标,又能合适地融于语言,并且编译速度足够快的方案。
好在 C++20 起对 concept 特性进行了标准化,目前主流的编译器也提供了支持。concept 的名字由 STL 之父 Alex Stepanov 命名,将一类数据类型和对它的一组操作所满足的公理集称为 concept:不仅需要从语法上满足要求,还需要从语义层面上满足。
几十年来,计算机科学一直在追求软件重用的目标。有多种方法,但没有一种方法能像其他工程学科中的类似尝试那样成功。泛型编程提供了机会。它基于这样一个原则,即软件可以分解为组件,这些组件只对其他组件做出最小的假设(concept),从而得到允许组合的最大灵活性。
泛型编程的关键在于高度可复用的组件必须以 concept 为基础进行编程,而 concept 尽可能匹配更多的类型,并且要求在不牺牲性能的前提下完成这一任务。标准模板库就是基于少量广泛有用的概念,使得用户能够通过各种方式与它们灵活组合。因此,concept 是泛型编程的基石。
本章将详细介绍 C++ 的 concept 特性发展过程,从中我们能看到语言设计者们需要面临与考虑的问题。
1.1 1994 年(早期想法)
1994 年,在 Bjarne Stroustrup 的著作 The Design and Evolution of C++ 中提到了两种对模板参数的约束方案,分别是继承形式和依据表达式的形式。
1.1.1 继承方案
该方案是通过继承方式来表达约束的,使用和声明类同样的语法,然后在模板定义的时候将模板参数派生自约束类。
1 |
|
上述代码中我们声明了约束类 Comparable,它对模板参数的要求为能够进行复制、判等、比较操作,然后在定义模板类 vector 的时候,使用继承语法 T: Comparable 表明模板参数被约束。
这种方式有个几个问题,首先是滥用继承,由于约束被设计成类,因此想要表达约束,使用继承这个特性也合理,但对于表达“模板参数必须支持某些操作”与“模板参数派生自某些约束”的情况而言,后者是一种不灵活的表达方式,而且会导致继承的滥用。除此之外基础类型无法使用继承特性,那么模板参数也就对基础类型封闭,它仅限于用户自定义类型。继承通常表达 subtype 关系,而不是所有的约束都需要硬塞进继承体系中。
其次是它混淆了编程语言中的不同层次的概念:concept 与抽象类,前者是静态的函数而后者是动态的概念,上述实现方式使得它们无法被区分。最后的问题是这种方式不够灵活,由于约束类中声明了一系列函数原型,而这些原型是严格匹配的,这就无法适用于隐式类型转换与函数重载等场景;严格匹配也限制了该方法的灵活性,且存在过约束的问题。
1.1.2 基于表达式使用
下述代码方案是依据表达式使用的形式,它比继承的方式灵活得多,能够解决隐式类型转换和函数重载的问题。
1 |
|
这种方式无须添加任何语言上的支持,但代码的编写取决于编译器实现。早期的 C++ 编译器 Cfront 会检查所有函数的语法,若模板参数不符合要求,constraints 函数将产生语法错误,用户通过错误信息找到函数 constraints 的实现便能得知对类型上的约束。而现代主流的编译器仅对被调用的函数进行代码生成,那么就要求用户使用的时候对 constraints 函数进行调用,这加重了用户的负担。
从这个例子我们能够看出缺少语言上的支持,那么会产生很多变通方案,包括第 10 章介绍的一些技巧。对于这个问题 Bjarne Stroustrup 想到了提供关键字 constraints 来编写约束,并且在函数调用前进行自动调用检查。
1 |
|
这种方案基本不会对模板参数过约束,同时也能满足一定程度的泛化、简洁与可理解性,并且容易实现。
1.2 2003 年(初步设计)
对模板参数进行约束的想法持续到了 2003 年。Bjarne Stroustrup 在他的论文 Conceptchecking 中进一步细化并提出了 4 种解决方案。
1.2.1 虚基类方式
该方案与最初的继承方案不同,它完全采用虚函数机制。使用继承的好处是容易理解且不需要增加额外的语法符号,并且可视作面向对象方式的语法糖,降低了编译器实现的难度。另一个的好处是可以将模板的声明与实现分离,无须将它们统一定义到头文件中,从而隐藏了实现。
1 |
|
这种方式带来的问题在于,具体实现 Element 实例的接口时,需要进行基类到具体类的转换,这需要运行时类型检查以确保类型安全。将泛型函数转换成面向对象的形式会带来性能损失:每一个模板函数的调用将触发虚函数调用。
虽然可以通过编译器对程序进行分析,或者通过编译器根据标记特殊处理某些模板函数的方式来提高性能,但这都不是最优雅的方式,同时也增添了编译器的实现负担。
另一个问题是,将对模板参数的要求提炼成基类会导致类的泛滥,而且这也不符合泛型编程的习惯。假如有两个人开发科学计算库,其中一个人表达加法使用 Addable 约束类,而另一个人表达加法使用 Add 约束类,当用户提供的 Number 类想要使用这两个人提供的泛型函数时,不得不同时派生自 Addable 约束类与 Add 约束类并实现两套接口,这样做会引入额外的复杂度。考虑如下常见的函数:
1 |
|
如何确保两个约束类 Addable 拥有同一个具体类型?返回类型又该如何确定?答案是基础类型需要额外的包装才能使用,而返回抽象类型的值在 C++ 语言中是非法的。
1.2.2 函数匹配方案
更理想的方案是避免使用继承来表达约束,可以使用匹配(match)一词取而代之,表达如下:
我们要求模板参数类匹配由 match 声明的函数所指定的约束。
1 |
|
当 vector<Number>
想要使用泛型函数 sum 时,要求 Number 的实现中能够提供成员函数 operator+,否则将导致编译错误。这种方式避免了基类方案中的很多缺点。
通过关键字 match 使得开发者可以表达自由函数与成员函数,而不仅仅局限于成员函数。此外基础数据类型也能很好地支持,只要基础类型匹配被要求的操作即可。这种灵活性一定程度带来了编译器实现上的复杂度:基类方案可以复用已有的语法规则,并且能够复用抽象类的实现模型;而函数匹配方案没有已有的实现模型,需要更复杂的代码生成策略以实现传统模板的性能。
该方案和基类方案都有一个共同的缺点,它们都需要严格匹配函数的签名。就操作符重载而言,可以通过成员函数与非成员函数实现,那么在声明 match 的时候就需要考虑支持这两种方式中的一种;同样需要考虑,函数的参数既可以声明成 const 也可以声明非 const 等。严格匹配无法很好地表达那些函数涉及重载与参数隐式类型转换的场景。
1.2.3 基于表达式使用
这个方案后来也被称为得克萨斯提案,它在论文中占据近一半的篇幅。相比前两种方案一直要求模板参数能够满足什么操作,这个方案则进一步表达该如何使用这些操作,使用两个新的关键字:concept 定义概念,constraints 描述表达式。
1 |
|
这种方案是基于 1994 年的 constraints 函数的想法,和普通函数类似,也需要使用合法的 C++ 表达式、语句。编译时可以通过检查 constraints 的语法来判断模板参数是否满足要求,且毫无运行时开销。与函数匹配方案相比,它无须显式指明要求的函数签名,而是以一种很自然的使用方式来表达。
更进一步,它还可以对多个 concept 进行组合,并使用逻辑操作符来表达:同时满足约束、满足其中一个、要求不满足。
1 |
|
因为 concept 是一组类型的模型,是从现有类型产生新类型的常用方法,所以参数化(模板)、派生等方式也自然适用于 concept。
考虑通过参数化从已有的 concept 产生的新的 concept,例如标准库中迭代器的概念代码如下:
1 |
|
和模板类型类似,concept 模板也能通过接受模板参数形成新的 concept,在这个例子中当作 find 与 int 数组使用,可以形成约束 ForwardIterator<int>
。concept 也可以在定义时接受多个模板参数。
考虑通过派生的方式从已有的 concept 形成新的 concept,同样以标准库中迭代器的概念作为例子,定义随机访问迭代器最合适的方式是通过派生已有迭代器概念。
1 |
|
当对模板函数进行重载时,可以提供一个毫无约束的普通版本,并提供一个带约束的版本,在重载决议时,被替换的具体类型如果满足约束将使用约束版本。这也被称为基于 concept 的重载,它可以替换传统上使用的 enable_if 或标签分发技术。
模板参数支持非类型参数,因此可以对非类型参数进行约束,例如要求传递的非类型参数为奇数。
1 |
|
1.2.4 基于伪签名方案
使用函数签名来表达约束存在的缺点是必须严格匹配签名,这样会导致过约束问题。我们是不是可以考虑同样使用签名方式来表达,但是又不会导致过约束呢?考虑使用伪签名方式,代码如下。
1 |
|
上述代码引入了新的语法和新的语义,这要求符合 Element 约束的类型能够支持 operator< 与 swap 操作,而不关心它们的形参是否为 const 或引用形式等。它与前一个方案的表达力相同。这两种方案基于同一思想,只是语法形式不同,论文中没有对这一方案进行过多的分析。
1.2.5 设计目标
Stroustrup 在对现有模板机制进行分析,以及对比当时支持泛型的编程语言,思考如何在 C++ 中更好地支持泛型编程技术,尝试从不同角度来处理 concept 的问题,并提出了 concept 的一些设计目标。下面的设计目标是按照优先级列出的,并不是所有的目标都需要满足。
- 灵活性。在传统的面向对象编程范式中,通过接口来保证调用者与实现者之间的约定,而 concept 约束不应该显式指明类型,并且应该是非层次体系结构的。对于基础数据类型应该天然支持而不是采用变通方案。
- 模板检查。模板的定义不应该依赖于实际被替换的类型,而是检查 concept 中声明的要求,简而言之,模板应该依赖 concept 而不是实际类型。最好是在模板使用处就能进行检查,而无须看到定义。
- 友好、精确的错误信息。模板的编译错误信息应该比之前更加友好,尤其是受约束的模板。错误信息分为三类:检查模板的定义是否使用了 concept 中未声明的操作(无须使用模板)、被替换的实际类型是否符合要求(使用模板但无须看到模板定义)、实例化时的模板是否有无效表达式(使用模板且需要看到模板的定义)。
- 基于 concept 的模板特化、函数重载。能够定义一系列受约束的模板并根据实际的模板参数选择使用哪个模板。
- 无运行时开销。借助抽象类的手段很容易实现对模板参数的检查,但这是以失去一定的灵活性与运行时性能为代价实现的。concept 约束必须延续并增强编译时计算和内联能力,这是传统模板性能的根基。
- 对编译器实现友好。模板特性本身对于编译器而言非常难实现,concept 不应该比它们更难,另一方面 concept 应该会减轻编译器检查模板代码的难度。
- 向后兼容。即便引入新的语法,也不能对已有的模板代码产生冲击。
- 分离编译。这个想法可能需要像虚函数表那样来实现模板参数与模板实现的接口,从而做到独立编译。
- 简洁的语法,强大的表达力。约束应该简单明了地表达对模板参数的要求,并且能够利用逻辑关系将已有的 concept 组合成新的 concept。一个 concept 应该能够支持多个模板参数的输入,以便表达它们之间关系的要求。除了能够从语法的角度表达,还应该能够表达它们的语义。
以上便是设计 concept 特性的所有目标,当然它们也存在矛盾的地方,比如基于 concept 的重载与分离编译这两点,一个是编译时目标,而另一个是运行时目标。
1.3 2004 年(印第安纳提案与得克萨斯提案)
2004 年,concept 特性出现了两大提案,分别被称为“印第安纳提案”与“得克萨斯提案”,它们分别对伪签名方案与基于表达式方案做出了更加深入的分析。
1.3.1 印第安纳提案
该提案基于伪签名方案并提供如下的语法形式,它看上去和函数匹配方案类似,但是匹配要求没那么严格。
1 |
|
在这个例子中,只要被替换后的实际类型支持 operator< 和 operator<= 操作即可:不管是内建方式、还是自由函数方式或者成员函数,只要这两个操作符能够接受两个相同的类型并且返回类型为 bool 或者能够通过隐式类型转换成 bool 即可。
另一个值得注意的点是模板参数被声明为 typeid,笔者建议复用该关键字来区分受约束与未受约束的模板参数。引入新的关键字 where 来提高表达力。
通过使用派生语法并基于已有的 concept 创建新的作法,被称为概念改良(concept refinement)。
1 |
|
定义 concept 时,能够对函数提供默认实现,从而减少被约束的类型所需要满足的函数数量。当被约束类型仅提供 operator== 时,下述代码的概念会自动满足 operator!= 的要求。
1 |
|
在泛型类中有很多关联类型可供使用,例如 vector 会提供成员类型 value_type 来存储容器中每个元素的类型。同样地,定义 concept 时也可以要求一个类提供一些关联类型,并且能为某些关联类型提供默认值。
1 |
|
当定义一个概念时,会要求模板参数的关联类型也满足概念的要求,这时候可以使用 require 子句。
1 |
|
在设计层面上,该提案提出了一个显著的问题:结构一致性与名字一致性的问题,并通过对实际类型进行显式概念建模声明(explicit model declarations)的方式来解决,这也是该提案的特点。通常来说有两种方案可以确定类型是否符合接口(概念)的要求:结构一致性与名字一致性。
结构一致性仅依赖接口的内容,而不关心接口的名字。例如有两个不同名字的 concept,但是它们的要求(结构)是一样的,那么它们实际上是同一个接口。这种方式无须对实际类型进行声明是否实现接口,即可满足多个的要求,只要在模板参数被替换成实际类型时能够通过约束检查。前文介绍的几个方案都是这种形式。
名字一致性依赖于接口的名字,因此两个不同名的接口即便内容一样,它们也是不同的。这就要求对一个类型进行显式声明是否实现了接口,C++ 中的 subtype 使用了名字一致性的方式,显式声明一个类继承了另一个接口类。在泛型编程的术语中,名字一致性意味着显式建模,表明实际类型对概念进行建模。
之所以会出现这个问题,原因在于一个概念不仅需要从语法层面满足要求,还需要从语义层面满足要求。一个比较明显的例子是在标准模板库中输入迭代器与前向迭代器的概念,它们的定义(结构)是一样的,但语义不一样:前者只能迭代一轮,后者可以保证多轮迭代,仅从语法角度上无法区分两者。
那么结构一致性存在的可能是,实际类型既匹配输入迭代器也匹配前向迭代器,从语义角度而言输入迭代器不是前向迭代器,如果使用基于 concept 重载的函数,将可能决策出错误的重载实现。
如果使用名字一致性并对实际类型进行显式概念建模,就能够通过名字来实现对语义上的区分。比如声明 MyFileIterator 是一个输入迭代器,那么重载决议时仅使用输入迭代器的版本,不会出现实际类型符合语法但不符合语义的情况。
model InputIterator<MyFileIterator> {}; // 对实际类型进行概念建模声明
即使该类型不包含概念所需的函数定义,也可以对实际类型进行概念建模声明,因为能够通过 model 子句补充被要求的函数定义,从而满足概念。
1 |
|
名字一致性的另一个好处是提供了一种简单的机制来支持关联类型,只要用户对实际类型声明了概念建模,就无须再使用 type traits 方式访问关联类型。该方式也有利于编译器的实现。
结构一致性的好处在于无须开发者为每一个类型进行概念建模声明,这有助于将当前的泛型库过渡到基于概念的泛型库。可以借助编译器的帮助生成一些默认的声明来解决显式概念建模的问题。
1.3.2 得克萨斯提案
继印第安纳提案之后得克萨斯提案也诞生了,该提案对基于表达式使用方式做了进一步细化。通过列出一系列函数、操作符、关联类型的使用来定义一个 concept,如下是前向迭代器概念的定义。
1 |
|
前向迭代器的概念定义直接从 C++ 标准中与前向迭代器相关的语法要求表而得。如果一个模板类型满足概念的所有要求,我们可以说该类型“匹配”对应的概念,印第安纳提案中使用的类似术语叫作“建模”。通常不使用术语“是一个”(is-a)来表达,因为这样会和类体系中的术语相混淆。
概念是一个编译期谓词,得克萨斯提案中通过使用静态断言可以判断具体类型是否匹配概念,若不匹配则编译报错。值得一提的是编译器可以缓存概念匹配的结果,供后续使用。
static_assert ForwardIterator<int*>; // 静态断言类型 int* 是否匹配前向迭代器概念
可惜的是上述断言将失败,因为指针类型没有成员类型 value_type,这不符合我们的预期。为了让基础数据类型也能够匹配概念,需要通过静态断言来对基础类型做扩展。
1 |
|
在印第安纳提案中提到,如果不通过显式建模声明的方式,会出现因为语法相同、语义不同而导致决策错误的问题,因此得克萨斯提案考虑使用否定断言(Negative assertions)的方式来解决,例如断言 MyIterator 虽然从语法上匹配前向迭代器,但从语义上不匹配。
static_assert ! ForwardIterator<MyIterator>;
因此,得克萨斯提案的静态断言有三个语义:第一、通过及早断言给定的类型是否匹配概念来及时发现错误;第二、通过对诸如基础数据类型进行扩展;第三、通过否定断言来声明指定类型不匹配概念。这样做可以避免为每个类型都显式建模声明。
1.3.3 ConceptGCC
2005 年下半年,印第安纳提案的修订版本中移除了 typeid 关键字,它被替换成标准的关键字 typename。
与此同时 GCC 编译器基于印第安纳提案衍生出一个分支:ConceptGCC,这个原型项目至关重要,因为它是首个证明该提案可行的实现。然而这个过程中遇到了很多问题:实现赶不上标准制定的进展,存在非常多的 bug 并且编译速度慢,这些都使其很难用于大型泛型库中。
1.4 2006 年(妥协)
Alexander Stepanov 于 2006 年邀请得克萨斯提案和印第安纳提案的团队参与 Adobe 公司举行的会议,旨在解决双方提案之间存在显著差异的问题,从而进一步在设计上达成一致。一些权衡的点主要包括采用伪签名模式还是依据表达式使用模式、使用哪种手段对 concept 进行组合、关于显式建模还是隐式匹配等。
两个团队经过数个月的合作并公布了折中方案,后由 Stroustrup 等人汇总并正式向 C++ 标准委员会提出提案,该提案的一些要点如下:
基于伪签名模式与依据表达式使用模式拥有等价的语义,两种方式应该能够相互转换,只是表现形式不一样,因此需要考虑其他方面的问题。尽管依据表达式模式很贴近文档中的约束描述,但是应该采用伪签名方式,原因是它的表现形式与类和类所需的成员函数具有相似性以及与显式建模声明子句的一致性。伪签名的另一个优势是容易构造原型类(archetypes),它是提供所需函数、成员以满足概念的最小类,在提案中便于对受约束的模板参数定义进行检查。
对模板类或模板函数使用约束时,拥有两种表现形式,分别是应对简单的场是与应对复杂的场景,在复杂的情况下可以使用逻辑关系来组合多个概念。
1 |
|
在提案中提出了基于概念重载的规则来决策哪个可行函数更优的方式:受概念约束的重载比未受约束的更具体,同样受约束的多个概念逻辑组合关系的比较,例如 A<T>
与 A<T> && B<T>
相比,根据规则后者将更具体。
关于显式建模还是隐式匹配的抉择也是个很大的问题。显式建模能够避免给定类型仅因为语义差别而导致误匹配概念的情况,但是它增加了简单场景的复杂度:需要大量的 model 声明语句,使得一个类型变得相当模糊。该提案给出的解决方案是将 concept 分成两种:一种是默认 concept 需要显式建模,而另一种是需要在 concept 定义前使用 auto 修饰来表明它可以隐式匹配。
由于显式建模的关键字 model 太过平凡,可能会与现存代码造成冲突,因此提案中将该关键字修改为 concept_map,它的子句中可以对不满足概念要求的指定类型进行补充扩展定义,从而满足概念要求。顾名思义,concept_map 可视作模板参数到指定类型的概念映射。
公理(axiom)表达了概念的语义要求,虽然编译器仅能检查语法要求,但是它可以提示编译器基于这些假设对类型做出优化。例如,某个概念要求类型的二元操作 op 符合结合律,那么编译器可能会将表达式 op(x, op(y, z))
等价替换成 op(op(x, y), z)
。
1 |
|
1.5 2009 年(标准化投票)
Stroustrup 在 2009 年写了一篇论文,总结了标准委员会对 ConceptGCC 提案的担忧,他们担心这个特性对普通的 C++ 程序员来说太复杂了,所以决定简化设计。
其中一点是建议将默认的显式建模改成默认隐式匹配,并提出了相关手段,这样能够减少 concept_map 声明的数量,使得对普通程序员更加友好,但这一手段需要相当大的改动。
同年7月的法兰克福会议上,C++ 标准委员会对该特性进行投票,有如下选项。
- 将当前的 concept 特性提案直接写入 C++0x 标准化。
- 根据 Stroustrup 的建议进行修改,并写入 C++0x 标准化。
- 从 C++0x 标准中移除该特性。
标准委员会注意到当前设计的缺点并将投票分成第二、第三个选项。然而大多数人选择了更安全的选项:从当前标准中移除该特性。因为时间相当紧张,离第一个标准 C++98 已经过去了近二十年,如果对concept特性进行修改将进一步推迟 C++ 的标准化进程。此外,更多人担心的是 ConceptGCC 的运行效率太低了,最后委员会决定延期到下一个标准中。参与到 concept 开发的成员们虽然都很失望,但他们更愿意提供一个高质量的解决方案。
1.6 2013 年轻量级概念(conceptslite)
在 concept 特性未能进入 C++11(C++0x) 标准后,相关人员不仅简化了设计,而且改变了开发的方式。考虑到一次性将如此复杂的特性融入语言的困难程度,Stroustrup 和他的同事们专注于 concept 设计的第一部分:模板参数约束,这也在后来被称为轻量级概念,使用谓词来约束模板参数。
轻量级概念仅检查被约束的模板是否使用正确,而不检查模板的定义是否正确。换句话说,模板的定义可以使用概念要求之外的操作。它的目的是让程序员简单、轻松地接受并使用。它仅满足如下目标:
- 允许程序员直接将声明一组模板参数的要求作为模板接口的一部分。
- 支持基于 concept 的函数重载与模板类特化。
- 明确模板使用时检查模板参数的诊断信息。
- 无任何运行时开销,且能提高编译速度。
值得一提的是,GCC 编译器在设计报告编写时已经完成了大部分目标与实现,并且包含了配套使用 concept 的标准库。
轻量级概念定义如下:
1 |
|
我们可以发现 concept 的定义发生了变化,它相当于 constexpr 谓词函数,能够在编译时求值,原型要求返回类型为 bool 的无参函数。
同时引入了 requires 表达式,它提供了可以简明表示表达式是否合法和关联的类型是否满足要求的能力。requires 表达式能够声明一些参数,然后罗列这些参数的表达式来判断其是否符合要求。这个例子中通过声明模板类型T的两个实例 a 和 b,并通过表达式 a==b 来判断它们是否能够判等,并且最终判等的结果是否为 bool。
当实例化时若这些表达式无效,则 requires 表达式最终结果为 false,表明模板参数不满足要求。在这个设计报告中使用了基于表达式使用的方式而不是伪签名方式,它的一个优势在于能够根据标准库的文档代码样例简单地转换成概念的定义。此外,基于表达式使用的方式比伪签名更加抽象,它们表达更多的是如何(How)使用而不是提供什么(What)签名,这使得程序员能够写出更加通用的代码。
接着看看概念的使用,同样提供了两种方式分别应对简单与复杂的场景,使用 requires 子句来表达多个概念的逻辑组合。
1 |
|
如上两种方式是等价的,前者使用概念 FloatingPoint 来约束模板参数,而后者使用 requires 对三个 Same 概念进行组合约束。
对于模板类型可以基于概念的特化实现,考虑如下例子。
1 |
|
上述代码声明了一个模板类型 complex,将模板参数约束为数值类型,既可以是浮点类型也可以是整数类型,接着分别对浮点类型和整数类型进行特化,当用户使用 complex<int>
时将使用 Integral 概念约束的特化版本。
设计报告没有使用显式建模 concept_map 方案,而是隐式匹配方案。同样地,这种方案也面临着语法相同、语义不同而导致的 concept 无法区分的情况。目前的变通方案是将语义要求的差异转换成语法上的差异,以此进行区分。
1.7 2015 年(ConceptsTS)
C++14 的目标是完成 C++11 的特性并修复一些已知问题,concept 没有足够的时间进入 C++14 标准,标准委员会决定为该特性单独编写一份技术规范文档(TechnicalSpecification,TS)。
2012 年,标准委员会的工作方式发生了变化,其主要工作独立于标准制定,并行地以技术规范形式交付,随后可以纳入标准。这种工作方式允许标准委员会能够快节奏、可预测地交付。Concepts TS 形成了最终的技术规范,在 GCC 编译器中能够使用选项 -fconcepts 来使用该特性。
1.8 2016 年(C++17)
轻量级概念本应该进入 C++17 标准,但最终未能实现。由于社区存在两种声音,有支持的也有反对的,对立双方的论点如下。
支持的声音是,模板参数约束(即便只检查模板的使用而不是定义)正是程序员想要的:它拥有友好的报错信息、文档、表现形式与重载。在各种各样的项目中都已经验证了轻量级概念,仅有少量问题。而且它在学术演讲中也得到了积极的响应,此外,程序员等待该特性实在太久了,由于缺乏 concept 的支持,导致各种各样的类 concept 库被开发,并产生了一系列变通方法。
反对的声音是,目前缺乏基于 concept 支持的标准库,仅仅拥有语言特性仍不足够。在不借助基础概念支持的情况下很难去编写一个高质量的库。甚至负责新的标准模板库开发的专家们也遇到了如何建立可靠概念体系的问题。另外,轻量级概念只是整个 concept 特性的第一部分,后续部分需要对第一部分的设计进行修改,加上最终的技术标准刚落地,目前只有 GCC 这一个编译器实现了概念特性,而语法上还存在一些问题。
所以标准委员会在 2016 年决定再次延后 concept 的标准化。
1.9 2020 年(C++20)
随后,概念的语法经过了一些精简。首先,concept 为编译时概念谓词,那么指明返回类型为 bool 则有些多余。其次,concept 的定义不再是一个类 constexpr 模板函数,而是变成了模板变量的形式。标准库的一些 concept 命名风格也发生了变化,例如 View 被命名为 view。千呼万唤始出来,轻量级概念终于进入了 C++20 标准。
1.10 小结
概念的目标非常简单:提供接口约束模板参数。然而随着对概念特性的开发在这一过程中也产生了许多问题,即使通过技术规范的工作方式集中于轻量级概念,依然存在问题。这表明了语言设计师必须时刻意识到项目中可能存在的困难与风险。
另外,是为设计负责。在不考虑后果的情况下轻易创造、修改设计是不明智的,还有对于新特性的开发很难预见所有决策的后果。这种风险和项目类型有关,如果是小项目,那么能够接受试错成本;而在复杂的大型项目中,错误的决策将导致不可逆转的结果。语言设计师应尽最大的努力来避免这种灾难性的决定。
方案的多样性也是有价值的,对于印第安纳提案与得克萨斯提案而言,它们提供了不同的思路来解决同一问题,并且一起改善了提案;缺少编译器实现(最初仅 GCC 编译器实现)上的多样性导致了概念被延期进入标准。因此,面对与讨论不同的方案以及进行广泛的测试验证是有价值的。
第 2 章 C++20 标准的概念特性
2.1 定义概念
这里正式给 concept 下定义,它是一个对类型约束的编译期谓词,给定一个类型判断其能否满足语法和语义要求,这对泛型编程而言极为重要。举个例子,给定模板参数 T
,对它的要求如下。
- 一种迭代器类型
Iterator<T>
。 - 一种数字类型
Number<T>
。
符号 C<T>
中的 C`` 就是概念,
T是一个类型,它表达“如果
T满足
C` 的所有要求,那么为真,否则为假。”
类似地,我们能够指定一组模板参数来满足概念的要求,例如 Same<T, U>
概念可定义为类型 T
与 U
相等。这种多类型概念对于 STL 来说是必不可少的,同时也能应用于其他领域中。
concept 拥有强大的表达力并且对编译时间友好,程序员能够通过非常简单地定义一个概念,也可以借助概念库对已有的概念进行组合。概念支持重载,能够消除对变通方案(诸如 enable_if 等技巧)的依赖,因此不仅大大降低了元编程的难度,同时也简化了泛型编程。在 C++ 中定义一个 concept 的语法为:
1 |
|
概念被定义为约束表达式(constraint-expression),也可以简单理解成布尔常量表达式。在实现一些简单的概念时可以复用在标准库 <type_traits>
中的组件,它们是编译时查询类型特征的接口,在配套的概念标准库 <concepts>
中可以看到一些和数值相关的概念被定义为:
1 |
|
这种简单的概念定义能否不依托于 type traits 呢?答案是可能不行,根据 C++20 标准,概念不允许做特化且约束表达式在定义时处于不求值环境中,因此除了 type traits 之外没有更好的方式了。
概念和模板 using 的别名很类似,前者是对布尔表达式的别名,而后者是对模板类型的别名,它们都不允许自身进行实例化或特化。记住这个有助于对后文介绍的约束偏序规则的理解。
在判断类型是否满足概念时,编译器将会对概念定义的约束表达式进行求值,因此可以通过静态断言来检测类型是否满足。
1 |
|
如果在定义概念时约束表达式类型不为 bool 类型,将引发一个编译错误,而不是返回不满足(假)。
1 |
|
约束表达式通过逻辑操作符的方式进行组合以定义更复杂的概念,这种操作符有两种:合取(conjunction)与析取(disjunction),C++ 标准中使用符号 ∧ 来代表合取操作,符号 ∨ 代表析取操作。
由于在 C++ 语法中并没有定义这两个符号,而是复用逻辑与(&&)和逻辑或(||)来分别表达合取与析取,那么它们在约束表达式中的语义相对布尔运算也就有了细微区别。
约束的合取表达式由两个约束组成,判断一个合取是否满足要求,首先要对第一个约束进行检查,如果它不满足,整个合取表达式也不满足;否则,当且仅当第二个约束也满足时,整个表达式满足要求。
约束的析取表达式同样由两个约束组成,判断一个析取是否满足要求,首先对第一个约束进行检查,如果它满足,整个析取表达式满足要求;否则,当且仅当第二个约束也满足时,整个表达式满足要求。
合取与析取操作与逻辑表达式中的与或运算类似,也是一个短路操作符。在依次对每个约束进行检查时,首先检查表达式是否合法,若不合法则该约束不满足,否则进一步对约束进行求值判断是否满足。
1 |
|
对 C<double>
进行求值的过程中,模板类型参数T被替换为 double,整个约束表达式为 is_integral_v<double::type> ∨ sizeof(double) > 1
,显然第一个约束的表达式非法,结果为不满足要求,然而第二个表达式满足要求,因此整个结果为真。
对于可变参数模板形成的约束表达式,既不是约束合取也不是约束析取。
1 |
|
上述代码不是析取表达式,因此没有短路操作,它首先检查整个表达式是否合法,只要有一个模板参数没有类型成员 type
,整个表达式将为假。若要表达“至少一个模板参数存在类型成员 type
且类型成员为整数”,则可以添加一层间接层解决:
1 |
|
由于约束表达式使用的合取与析取操作符分别与逻辑表达式的逻辑与和逻辑或相同,若要表达“逻辑表达式”的合法性,而不是被当成析取或合取表达式处理则需要额外的工作。
1 |
|
概念 C1
中的约束表达式为析取表达式,它具有短路性质,表达“要求存在一个模板参数拥有类型成员 type
且类型成员为整数”,而 C2
表达了一条完整的逻辑表达式:“要求两个模板参数存在类型成员 type
且其中一个为整数”。
另一个比较特殊的是逻辑否定(negation),在对概念进行求值的过程中,若约束中的模板参数替换发生错误(表达式非法),则该约束的结果为不满足。考虑如下情况。
1 |
|
其中 C1
表达式“要求类型 T
存在关联类型 type
,且关联类型为整数类型”,C1
的否定“要求类型 T
不存在关联类型 type
,或关联类型不为整数”。
根据约束否定的特殊性质,C2
并不是 C1
的否定,它表达的是“要求类型 T
存在关联类型 type
,且关联类型不为整数类型”,在断言 C2<Foo>
和 C2<int>
时我们可以确认这一点。
如果需要表达 C1
的否定“要求类型 T
不存在关联类型 type
,或关联类型不为整数”,应该定义为如下形式。
1 |
|
2.2 requires 表达式
除了使用 type traits 来定义概念之外,requires 表达式也提供了一种简明的方式来表达对模板参数及其对象的特征要求:成员函数、自由函数、关联类型等。在 C++ 中定义 requires 表达式的语法为:
1 |
|
requires 表达式的结果为 bool 类型,即编译时谓词。表达式体应至少提出一条要求,同样地在表达式体中处于不求值环境。当对 requires 表达式进行求值时,按照表达式体中声明的先后顺序依次检查表达式的合法性,当遇到一条非法的表达式时,返回结果为不满足(假),与短路类似的后续表达式也无须进一步检查;当所有表达式都合法时,返回的结果为满足(真)。
可选的形参列表声明了一系列局部变量,这些局部变量不允许提供默认参数,它们对整个表达式体可见。这些变量没有链接性、存储性与生命周期,仅仅用作提出要求时的符号。如果在表达式体中引用了未声明的符号,则视作语法错误。
requires 表达式提供了四种形式的要求:简单要求、类型要求、复合要求与嵌套要求,它们分别应对不同场景。
2.2.1 简单要求
对于简单的要求,仅仅通过表达式就能表达。考虑定义一个机器的概念,能够上电与下电。
1 |
|
这里涉及两个特性,首先通过 concept 定义机器概念,其次约束表达式为 requires 表达式,它声明了模板参数 M
的局部对象 m
,然后在表达式体中提出了两个要求,分别是能够使用对象的上下电接口。
对约束表达式求值的过程中并不会去创建对象,因此我们可以使用简单的值语义,而无须添加额外的引用或者指针形式,这样代码更简洁。此外也不会进行接口调用,仅仅是依据表达式是否合法来确认是否满足要求。
有时候我们要求模板参数的对象含有相关的自由函数,以及含有静态成员函数,或者成员变量,这些要求都可以通过下面这种形式来表达。
1 |
|
又或者需要进行复杂的操作符运算时,可以声明几个对象,并在提出要求的同时表达对象之间的操作。目的只是检查表达式的合法性,不会去进行真正的计算。
1 |
|
2.2.2 类型要求
简单要求虽然可以表达对象的成员函数、成员变量,但无法表达对象的类成员。类型要求可以表达一个类型是否含有成员类型,该类型是否能够和其他模板类型组合等。考虑如下情况。
1 |
|
这段代码中的 requires 表达式无须引入局部变量,直接对类型提出要求即可。表达式体中使用关键字 typename 来表达它是一个类型要求。
2.2.3 复合要求
有时候我们会希望一个表达式的类型也能够符合要求,例如要求函数的返回类型为 int,希望表达式不会抛异常等,这时候可以使用复合要求来表达。复合要求的语法如下。
{ 表达式 } 可选的 noexcept,可选的返回类型概念要求
复合要求需要用大括号将表达式括起来,最简单的复合要求和简单要求几乎没什么区别。
1 |
|
如果要求表达式不能抛异常,这时候 noexcept
关键字便派上了用场。考虑定义一个概念 Movable
,要求对象之间的移动禁止抛异常。当用户自定义移动赋值操作符而忘记声明 noexcept
时,将无法通过约束的检查。
1 |
|
当我们对一个表达式的类型提出要求时,有两个问题需要考虑。首先,是明确要求为某个确定的类型;其次,是由于在 C++ 中允许类型转换,对表达式的类型要求可以稍微放宽,只要能隐式转换到要求的类型即可。
C++ 标准库 <concepts>
提供了两个概念 same_as
和 convertible_to
来分别表达这两种情况,它们的声明如下。
1 |
|
借助这两个概念的帮助,我们可以定义如下的概念。
1 |
|
使用箭头“->”来表达对表达式类型的要求,后面紧接着的是需要满足的概念。值得注意的是,这两个概念本应该接受两个模板类型参数,为何这里只需要提供一个?其实这是 concept 的性质,它会将表达式的类型补充到概念的第一个参数,如果读者将 same_as
替换成元函数 is_same_v
那么编译时将提示需要提供两个类型参数。上述代码等价于如下形式。
1 |
|
细心的读者会发现 requires 表达式体中又出现了 requires 关键字,这正是下一小节将介绍的嵌套要求。
2.2.4 嵌套要求
除了前面几种要求,最后一种是嵌套要求,它在表达式体中通过 requires 连接一个编译时常量谓词来表达额外的约束。根据定义,嵌套要求的额外约束有如下几种形式。
- type traits。
- concept。
- requires 表达式。
- constexpr 值或函数。
requires 表达式体通常只检查表达式的合法性,而嵌套要求的谓词约束则通过对表达式求值来确认是否满足要求。在 2.2.3 节中我们不仅要求 f(x)
表达式有效,还通过 requires same_as<decltype(f(x)), T>
嵌套要求 same_as
的概念为真。
通过嵌套要求定义一个概念,它要求给定的类型大小大于指针大小,并且是平凡的。
1 |
|
2.2.5 注意事项
本小节介绍 requires 表达式的一些特殊性质,以及使用的时候需要注意的地方。requires 表达式为编译时谓词,它不一定需要在 concept 定义的时候出现,只要是能够接受布尔表达式的地方都允许它的存在。
最容易想到的是在定义变量模板的时候,判断给定类型是否存在成员函数 swap。
1 |
|
requires 表达式难道只能对模板参数或者其对象提出要求么?如果对具体类型提出要求又会怎么样?
1 |
|
从设计角度来看。requires 表达式是服务于模板参数约束的,结果是编译错误而不是返回不满足(假)。除了支持类模板参数外,它还支持非类型模板参数,考虑定义一个偶数概念,要求输入的模板参数为偶数。
1 |
|
在模板函数 if constexpr
中,也有可能出现 requires 表达式。
1 |
|
除了以上场景外,还有很多地方能够接受布尔表达式,例如非类型模板参数中,定义 constexpr 谓词函数时,static_assert
中,实现 type traits 时,还有后面将介绍的 requires 子句等。一个容易混淆的地方是简单要求与嵌套要求中对布尔表达式的约束,考虑如下两种形式的差异。
1 |
|
如果用户写了如下代码,那么很可能违背约束条件。
1 |
|
用户可能要求模板类型 T 的大小不应该超过 int 的大小,然而从编译器的角度来看,这仅仅是检查表达式的合法性,对 sizeof 的结果进行比较是永远满足的。想达成用户的意图应该是用嵌套要求,让编译器进一步对这个布尔表达式进行求值判断以查看其是否满足。
1 |
|
另一个容易出错的地方在于嵌套要求可以接受一个 requires 表达式,考虑如下代码。
1 |
|
经过分析会发现,以上代码表达式体中的 requires 并不是表达嵌套要求,而是简单要求形式,仅仅检查了表达式体中的 requires 表达式是否合法。好在 C++ 标准不接受这种代码,只要是以 requires 开头的代码都会被当作嵌套要求处理,其后还紧接着一个编译时谓词;现有的编译器实现也会对该代码报错。这时需要通过添加 requires 前缀进一步表达嵌套要求,具体代码如下。
1 |
|
最后一个需要注意的地方是,requires 表达式的可选形参列表中可能涉及非法表达式的问题,考虑如下代码。
1 |
|
形参 v 是否有效取,决于类型 T 是否含有类型成员 value_type,在形参无效的情况下,requires 表达式是否应该返回不满足(假)?根据 C++ 标准提到,编译器仅检查 requires 表达式体中的表达式要求是否合法,如果形参列表中的表达式非法,那么程序非良构,所以上述代码将产生一个编译错误。
如果使用 concept 定义,那么在可选的形参无效的情况下,requires 表达式将返回假,不过这是 concept 的特殊性质,和 requires 表达式无关。
1 |
|
2.3 requires子句
我们通过 concept、requires 表达式、constexpr 谓词常量或函数及 type traits 能够定义对类型的谓词,本节将介绍如何应用这些编译期谓词对模板参数添加约束,所有可以用来实例化这个模板的参数都必须满足这些约束。
使用 requires 子句可以为一个模板类或者模板函数添加约束,考虑如下代码。
1 |
|
模板头中额外的 requires 子句表达了模板参数应该在什么条件下工作,同样地,它还可以接受一个约束表达式。当我们错误地使用受约束的 gcd 函数,编译器将产生一个友好的错误信息。设计 requires 子句的意图是判断它所约束的声明在某些上下文中是否可行。对于函数模板而言,上下文是在执行重载决议中进行的;对于模板类而言,是在决策合适的特化版本中;对于模板类中的成员函数而言,是决策当显式实例化时是否生成该函数。
我们讨论第一个场景,在重载决议中,考虑如下代码。
1 |
|
这里提供了两个模板函数f,前者要求类型是平凡的,后者则没有任何约束。当对函数进行调用时,传递一个非平凡对象 vector<int>
,由于候选集中的第一个可行函数的类型不满足要求,将其从候选集中删除,只剩下一个不受约束的版本,因此重载决议没有产生歧义,最终输出的结果为 2。
这里的关键在于违反约束本身并不是一个错误,除非候选集中没有可行函数了,但那是另一回事。上述情况也可以被看作 SFINAE,但我们不需要继续使用诸如 enable_if 等变通方法。
1 |
|
enable_if 提供的可行函数之间的条件必须两两互斥,以避免重载决策上的歧义。而 concept 本身存在优先级机制,这一机制能避免上述问题,这是重大的改进。
在概念标准化之前,除了 enable_if 之外,人们常常使用 decltype 操作符与表达式进行组合来决策重载函数,考虑如下代码。
1 |
|
如果用户提供的类型拥有成员函数 OnInit
,那么候选集中的这两个函数都可行,根据重载决议的规则,不定参数函数的优先级较低,编译器将选择正确的第一个版本;若用户提供的类型没有该成员函数,那么第一个版本将触发 SFINAE 机制,候选集中仅剩下第二个版本的函数,最终将什么也不做。
如果使用 requires 子句结合 requires 表达式来实现将更加合理。
1 |
|
如果用户提供的类型拥有成员函数 OnInit
,那么候选集中这两个函数都可行,根据标准,受约束的函数比未受约束的更优,编译器将选择正确的第一个版本;若用户提供的类型没有该成员函数,第一个版本不符合要求,候选集中仅剩下第二个版本的函数,同理最终将什么也不做。
从这两个例子中我们能够看到 concept 特性所带来的优势,它不需要那么多元编程技巧,让新人也能够容易接受、上手,而无须理解变通技巧中涉及的一些隐晦问题。
requires 子句拥有和 concept 类似的性质,考虑如下代码。
1 |
|
当用户对该函数进行调用时,首先检查表达式是否合法,如果模板参数类型没有类型成员 type
,将不满足要求;否则进一步判断类型成员是否为整数类型,如果是则满足要求,函数能够被正常调用,否则不满足要求,产生编译错误。
当对 requires 子句中的约束使用否定时需要额外注意,它可能并不是在表达否定的意思,回忆在 2.1 节提到的一个例子。
1 |
|
程序员可能把这个否定理解为“要求模板参数类型没有类型成员 type
或类型成员不为整数”,而它真正的语义为“要求模板参数类型拥有类型成员 type
且类型成员不为整数”,如果需要表达前者语义,可以参考 2.1 节提到的方式,这里不再赘述。
可能有读者注意到了 requires 子句中对约束的否定使用了圆括号,这是因为编译器对代码进行解析的过程中存在困难,考虑如下代码。
1 |
|
编译器在解析这段代码时,遇到约束 P(0)
会认为这是一个类型转换表达式,将数值类型转换成其他类型 P
,然而实际上表达的是一个谓词函数调用,这时候需要通过括号将 P(0)
括起来。好在编译器又足够智能,能通过错误信息提醒用户更正这个错误。
requires 子句中的约束表达式也支持对约束进行合取与析取操作。除了通过 requires 子句引入约束之外,在简单情况下还可以通过更简洁的语法来引入约束。
1 |
|
我们可以看到关键字 typename 被替换成了概念 integral
,对多个模板参数添加概念约束,将产生一个约束合取表达式,正如注释中提到的一样。此外,不需要填充概念中的模板参数,根据 concept 的性质它会自动将模板参数补充到概念中第一个参数位置,这是 type traits 做不到的。
另一方面也说明了,不需要通过 requires 子句也能施加约束。约束的合取比较容易得到,而约束的析取需要通过 requires 子句才能得到。
如不关心模板参数类型,则 C++20 模板函数的参数可以使用 auto 来简化,并同时支持添加约束。如下函数原型和上面一样。
void f(integral auto a, integral auto b);
此外,泛型 lambda 也能够通过使用概念进行约束。
auto f = [](integral auto lhs, integral auto rhs) { return lhs + rhs; };
前面提到模板类与它的特化版本能够通过 requires 子句施加约束,根据约束比较规则可以决策出约束最强的版本。
1 |
|
如果使用 Optional<int>
,因为 int 类型为平凡类型,符合特化版本中的约束要求,那么将决策特化版本而不是更一般的版本,这样能够有针对性地进行优化。在传统的元编程技巧中,常常使用 enable_if_t
与 void_t
进行特化版本的决策,通过使用约束方式降低了程序员学习的难度。
当对模板类型进行显式实例化时,若受约束的成员函数不符合要求,编译器将不为这个函数生成代码,这是 enable_if_t
做不到的地方。
1 |
|
这里的 requires 子句写在了函数声明后,当对该模板类进行实例化时,由于成员函数 operator()()
不满足要求,编译器将不为它生成代码。
2.4 约束的偏序规则
在前一节我们看到了通过给模板施加约束,受约束的版本比未受约束的版本更优,如果两个版本同样含有约束且都满足,哪个最优呢?
之所以会有这个问题,要回到 C++ 最初的标准模板库中的设计,迭代器是算法与容器之间的桥梁,并且分为几类。同一个算法针对不同类的迭代器中拥有不同的高效实现:如 rotate 旋转算法在随机访问迭代器、双向迭代器、单向迭代器中拥有不同的实现,其中随机访问迭代器的效率最高。
如果一个随机访问迭代器使用了单向迭代器的算法,那么效率不是最优。在 C++11 之前。使用标签分发技术来决策最优算法,迭代器种类标签之间存在继承关系,重载决议时通过比较规则决策出正确的版本;在 C++17 中,可以使用 if constexpr 来决策最优算法;进入 C++20 后,则使用概念约束进行决策。
在 C++ 的概念特性发展历史中,它曾经支持以继承形式扩展,这被称为概念改良。概念继承形式能够比较自然地表达合取关系,但在表达析取关系就不那么自然了。因此在 C++20 标准中废除了这一形式,而是采用更加自然的合取与析取关系。
在模板函数重载决议与类模板特化决策中,约束的合取与析取关系以及 concept 扮演至关重要的角色,对于两个约束都满足的模板,可以通过约束的偏序规则决策出谁最优。
2.4.1 约束表达式归一化
对于受约束的模板函数、模板类而言,施加的约束表达式被称为关联约束(associated constraint),为了进一步判断是否满足约束以及谁更优,需要将关联约束分解成原子约束的合取与析取形式,这个过程被称为归一化(normalization)。
前面提到 concept 只是约束表达式的别名,在归一化过程中会对 concept 进行展开,展开后的约束表达式若包含 concept,则会进一步递归展开。直到所有的约束都无法进一步展开,这些约束即为原子约束,那么最终的形式就是原子约束的合取与析取表达式。每个原子约束既不是合取也不是析取形式。
1 |
|
函数 f1 的关联约束为 C2<T>
,为了判断关联约束是否满足要求,将对它进行归一化,展开过程如下。
1 |
|
最终形式是原子约束 sizeof(T)==1
与原子约束 1==2
的合取形式,归一化过程在模板参数替换时没有产生非法表达式,这时进行最终的求值,可以发现约束不满足。函数 f2
的关联约束为 C3<T>
,归一化过程类似。需要注意 requires 表达式、约束的否定是原子约束,最终结果为:
1 |
|
2.4.2 简单约束的包含关系
对于同样满足要求的两个约束表达式的关系,C++ 标准中拥有更正式的规则来描述,本小节首先考虑简单的合取与析取表达式。
约束表达式 P
与 Q
的偏序关系也被称为包含关系(subsumption),如果它们拥有包含关系,若 P
包含 Q
而 Q
不包含 P
,则 P
比 Q
更优;反之,Q
比 P
更优。P
和 Q
可能没有包含关系,那么将产生决议歧义的编译错误。
约束表达式 P
包含 Q
,当且仅当 P
满足要求时 Q
也满足;Q
不包含 P
,则当 Q
满足时 P
不一定满足。考虑如下两个约束表达式。
1 |
|
当 TotallyOrdered<T>
所指的约束合取表达式满足要求时,意味着它的两个约束都为真,可以得出 EqualityComparable<T>
满足要求,因此 TotallyOrdered<T>
包含 EqualityComparable<T>
。
当 EqualityComparable<T>
所指的约束表达式满足要求时,不能得出 TotallyOrdered<T>
也满足要求,因此 EqualityComparable<T>
不包含 TotallyOrdered<T>
(见图 3.1)。
图3.1约束的合取包含关系
在对两个都满足约束的函数 f 决议中,将决出更优的第二个版本。
再来看看约束析取表达式,同样给出两个约束表达式。
1 |
|
当 Arithmetic<T>
所指的约束表达式满足要求时,意味着它的两个约束中至少有一个为真,不能得出 FloatingPoint<T>
也满足要求,因此 Arithmetic<T>
不包含 FloatingPoint<T>
。
当 FloatingPoint<T>
所指的约束合取表达式满足要求时,得出 Arithmetic<T>
满足要求,因此 FloatingPoint<T>
包含 Arithmetic<T>
(见图 3.2)。
图 3.2 约束的析取包含关系
在对两个都满足约束的函数 f
的决议中,将决策出第一个版本更优。
通过这两个例子我们可以发现,约束的合取形式 R∧S
要比 R
更优,而析取形式 R
要比 R∨S
更优。
2.4.3一般约束的包含关系
上一小节介绍了简单约束表达式的包含关系,这一节将介绍更为通用的规则,当编译器面临复杂的约束表达式时,是如何决策出最优的。
首先,考虑如下两个约束表达式,谁更优?
1 |
|
当模板参数 T
为 int
时,这两个函数都满足要求,那么它们究竟谁更优呢?答案是由于编译错误,它们没有任何关系,无法决策出最优版本。2.4.2 节提到“约束的合取形式 R∧S
要比 R
更优”,为什么结论在这里不成立了?
其实不然,之前为了简化讨论,忽略了对约束表达式进行归一化的过程:约束表达式中的 concept(如果存在)会递归展开成最终原子约束的合取与析取形式。判断两个约束表达式之间 是否存在关系,需要进一步判断它们归一化后的原子约束之间是否存在相同(identical)关系。
原子约束 Ai
和 Aj
的相同关系被定义为:它们是否词法上相等且来自于同一个 concept。这个例子中的两个约束表达式都没有 concept,归一化后的原子约束表达式分别为:
原子约束表达式 P
和 Q
存在词法上相等的原子约束 is_integral_v<T>
,但它们不是来自于同一个 concept ,因此这两个原子约束其实不相同,最终两个表达式没有包含关系,它们相当于“ R∧S
与 T
没有关系”,因此无法决策出谁最优。
使用 concept 改写这个例子,代码如下。
同样地,当模板参数 T
为 int
时,两个版本都满足要求,但是这次编译器选择了第二个版本作为更优的版本。对这两个原子约束表达式进行归一化,过程如下。
归一化后的结果和前面一样,唯一不同的是这期间 Integral
概念进行了展开:两个原子约束 is_integral_v<T>
来自于同一个概念 Integral
。上一节的结论再次成立。
虽然我们能够一眼看出来谁更优,但是编译器却不那样认为。当使用 concept 时,编译器才会在需要的时候尝试计算它们之间的关系,这也是 concept 具有的独特性质。
更一般地,C++ 标准通过如下的规则来计算约束表达式 P
与 Q
之间的偏序关系。P
包含 Q
,当且仅当 P
的析取范式中的每个析取子句Pi包含Q的合取范式的每个合取子句 Qj
,那么 P
包含 Q
。原子约束归一化后可以标准化为析取范式与合取范式,其中析取范式的析取子句为约束合取表达式,合取范式的合取子句为约束析取表达式。
考虑原子约束 A、B、C,归一化后的约束表达式 A∧(B∨C)
,将它写成析取范式时需要进一步转换成 (A∧B)∨(A∧C)
,它的两个析取子句分别为合取表达式 A∧B
和 A∧C
;将它写成合取范式时为它本身,两个合取子句分别为析取表达式 A
和 B∨C
。
析取子句 Pi
包含合取子句 Qj
当且仅当 Pi
存在一个原子约束 Pia
且 Pia
与 Qj
中的一个原子约束 Qjb
相同。
这些规则相当抽象,我们可以结合具体的例子来分析一下。
考虑归一化后约束表达式 P=R∧S
与 Q=R
,首先判断 P
是否包含 Q
,将 P
写成析取范式,它只有一个析取子句 P0=R∧S
,将 Q
写成合取范式,同样只有一个合取子句 Q0=R
,P0
和 Q0
存在相同的原子约束 R
,因此 P
包含 Q
;接下来判断 Q
是否包含 P
,将 Q
写成析取范式,它只有一个析取子句 Q0=R
,将 P
写成合取范式,它有两个合取子句 P0=R
与 P1=S
,显然 Q0
包含 P0
(因为存在相同的原子约束 R
),而 Q0
不包含 P1
(因为不存在相同的原子约束),最后得到 Q
不包含 P
。综上所述,R∧S
要比 R
更优。
考虑归一化后约束表达式 P=R∨S
与 Q=R
,首先判断 P
是否包含 Q
,将 P
写成析取范式,它有两个析取子句 P0=R
与 P1=S
,将 Q
写成合取范式,它只有一个合取子句 Q0=R
,P0
和 Q0
存在相同的原子约束 R
,因此 P0
包含 Q0
,而 P1
不包含 Q0
(由于不存在相同的原子约束),因此 P
不包含 Q
;接下来判断 Q
是否包含 P
,将 Q
写成析取范式,它只有一个析取子句 Q0=R
,将 P
写成合取范式,同样只有一个合取子句 P0=R∨S
,显然 Q0
包含 P0
(因为存在相同的原子约束 R
),最后得到 Q
包含 P
。综上所述,R
比 R∨S
要更优。
接下来考虑更为复杂的情况,考虑为一个假想的数学库提供概念设计,例如标量概念中要求为整数或者浮点类型。
该数学库考虑为用户提供的类型进行扩展,提供一个叫作 MathematicalTraits
的元函数,用户需要通过特化实现该元函数,以便让数学库识别。
同时,数学库提供了一个概念 CustomMath
用于识别给定类型是否为用户扩展的类型。最后,需要用一个概念 Mathematical
来表达要么为内置的标量类型,要么为用户扩展的自定义类型,即通过两个概念的析取来表达。
数学库提供了一个计算函数 calculate
,它接受两个模板参数类型,对类型的约束为 Mathematical
,关联约束为合取表达式。
这个计算函数要求的两个类型不一定一样,其中一个有可能属于标量类型,另一个属于自定义类型。该数学库可能会提供一个性能更优的计算函数的重载版本,只要给定的两个类型属于同一个概念:要么都属于标量概念 Scalar
,要么都属于自定义概念 CustomMath
。
当用户使用两个标量类型对该函数进行调用时,可发现两个候选函数都满足要求,那么究竟哪个更优呢?
首先,我们判断第二个重载版本的关联约束 P
是否包含第一个版本中的关联约束 Q
。将 P
和 Q
分别写成析取范式与合取范式。
于是我们需要进一步判断P的每个析取子句 Pi
是否包含 Q
的每个合取子句 Qj
,也就是证明如下命题都为真。
P0
包含Q0
。P0
包含Q1
。P1
包含Q0
。P1
包含Q1
。
为了进一步证明 Pi
是否包含 Qj
,需要在 Pi
中找到一个原子约束 Pia
使得,它与 Qj
中的原子约束 Qjb
相同。显然,我们可以找出它们共同的原子约束:
- 对于
P0
与Q0
而言,存在相同的原子约束Scalar<T>
。 - 对于
P0
与Q1
而言,存在相同的原子约束Scalar<U>
。 - 对于
P1
与Q0
而言,存在相同的原子约束CustomMath<T>
。 - 对于
P1
与Q1
而言,存在相同的原子约束CustomMath<U>
。
因此可以得出 P
包含 Q
的结论,为了证明 P
比 Q
更优而不是重载歧义,我们需要证明 Q
不包含 P
。类似地,将 Q
和 P
分别写成析取范式与合取范式。
析取范式与合取范式互相转换,每个子句间的原子约束将两两分配,最终子句数量最多为原范式子句数量的指数级别。
在这个例子中 Q
的合取范式只有两个子句,每个子句由两个原子约束组成,转换成析取范式后各子句中的原子约束两两分配产生 $2^2=4$ 个子句。
P
的析取范式转换成合取范式也是类似的过程。
需要进一步判断 Q
的每个析取子句 Qi
是否包含 P
的每个合取子句 Pj
,这需要证明 16
个命题,只要我们能够找到一个 Qj
不包含 Pi
即可证明 Q
不包含 P
。仔细观察可以发现,Q1
与 P2
之间、Q2
与 P1
之间都不存在相同的原子约束,这就证明了 Q
不包含 P
。
最后的结果符合我们的预期,第二个重载 calculate
函数为最优的候选函数,最终输出的结果为 P
。
从这个过程中我们也能够发现,当涉及复杂的约束表达式时,编译器的计算量将大幅增加。约束合取表达式是难以避免的,因为可以通过多种方式引入,而约束析取表达式则没那么多。如果可能的话,应尽可能避免使用析取表达式,这将有助于减少编译器的计算量。
2.4.4 using 类型别名与 concept 表达式别名
前面提到 concept 作为表达式别名,其机制和 using 作为类型别名类似。C++ 中判断两个类型别名是否相同也是通过展开后判断词法与位置是否相等。考虑如下两个类型。
这里的类型别名 A
和 B
其实是一个类型,它们都为 Point
。而如下两个类型却是不相同的类型,尽管它们词法上相等。
两个原子约束是否存在包含关系仅取决于它们是否相同,这就要求原子约束在词法上相等,并且来源于同一个 concept。
2.5 概念标准库 <concepts>
C++20 标准库 <concepts>
提供了一些基本的概念,用于在编译期对模板参数进行校验和基于概念的函数重载。标准库中的许多概念都有语法和语义上的要求,如果一个模板参数符合语法上的约束,那么它通常被称为“满足(satisfy)要求”。更进一步,如果模板参数符合语义上的约束,则被称为“对该概念进行建模(model)”。通常编译器只能检查语法上的要求,对于语义上的要求需要程序员自行检查。
本节将介绍一些常用的 concept,基于这些 concept 能够组合出更为强大的概念。
2.5.1 same_as(与某类相同)
same_as
概念要求输入两个类型参数,借此判断这两个类型是否满足相同的约束。一个可能的实现如下。
1 |
|
上述实现是有问题的,所以考虑要求两个模板参数类型一致的函数,并提供一个特别的重载版本。
1 |
|
需要注意的是,特别版本中的 requires 子句中的约束 same_as
的类型参数正好与一般的版本相反,前者为 same_as<U, T>
,后者为 same_as<T, U>
,根据 same_as
的对称性可知,两者应该
是一样的,当使用 f(1, 1)
时,预期应该决策使用特别的版本。
然而在编译器决策的时候发生了重载歧义,无法决策出最优的实现。分别将两个版本的约束表达式进行正规化后,得到如下结果。
1 |
|
虽然这两个表达式最终的原子表达式 is_same_v
都来自于同一个概念 same_as
,但是它们在词法上不相等,因此这两个原子约束不相同,也就没法进一步判断它们之间的偏序关系了。
为了解决这个问题,标准中通过添加一层间接层来解决,即引入额外的 concept。最后,same_as
的正确实现如下。
1 |
|
这表达了一种对称关系:same_as<T,U>
包含 same_as<U,T>
,反之亦然。
2.5.2 derived_from(派生自某类)
derived_from
用于表达两个类之间是否存在 is-a 的关系,也就是判断两个类之间是否存在公有继承关系。在元编程场景中,通常定义一个空标签类来代表某一族类,然后同一族类派生自该特征标签,后续只需要判断某个类是否派生自该特征类即可判断是否为所需。
derived_from
的实现比较简单,需要注意的是,同样的类在忽略 cv 修饰符的情况下也满足派生关系,这通过给类型都加上 cv 属性来保证。
1 |
|
2.5.3 convertible_to(可转换为某类)
除了要求表达式的类型严格相同之外,另一个常见的场景是,只要一个表达式的类型能够通过隐式或显式转换成目标类型即可。语义上要求这两种转换方式的结果应该是相等的,这种情况可以使用convertible_to
来表达。
convertible_to
的实现如下,通过约束合取来表达。
1 |
|
第一个约束要求类型 From
能够通过隐式类型转换成 To
,第二个约束根据 requires 表达式要求进行显式类型转换。
requires 表达式的形参列表中声明了一个无参函数类型,其返回类型为 From&&
,通过符号 f
来指代这个函数。在表达式体中使用 static_cast
将函数调用的结果显式类型转换成 To
,由于 requires 表达式为不求值环境,所以不会发生真正的函数调用。
为何需要进一步要求类型能够通过显式转换?什么类型能够通过隐式转换成目标类型但又无法通过显式转换?虽然在实际场景中几乎不可能出现这种类型,但是在 C++ 中,允许用户定义这种“奇怪”的类型。
1 |
|
我们构造的这种“奇怪”类型 To
删除了显式类型转换构造函数,而另一个类型 From
拥有类型转换操作符,由于没有使用 explicit
修饰,所以能够隐式地转换成类型 To
。
1 |
|
C++ 标准中正是考虑了这种能够通过隐式类型转换而无法通过显式类型转换的奇怪场景,才使用 convertible_to
的概念,这样我们在编写泛型代码时就无须考虑这种奇怪场景,它们将无法通过概念检查。
2.5.4 算术概念
在我们初学编程时常常会涉及一些基本的数据类型,这些数据类型被分为整数类和浮点类,整数类包含了 char
、short
、int
等,进一步可划分成有符号类和无符号类;浮点类包含了 float
、double
等。它们统称为算术类型,根据这些概念不难定义出与之对应的 concept。
1 |
|
2.5.5 值概念
在面向值语义编程与泛型编程时,常常会涉及一些相当重要的概念:regular
(正则)与 semiregular
(半正则)。
regular
(正则)的概念指的是一些类型看上去可以像基础数据(如 int
)一样,能够进行默认构造、移动构造与赋值、拷贝构造与赋值,并且能够进行判等操作。标准库中的容器设计就使用了这个概念,这样对容器进行的一些操作与对基础数据类型进行的操作没什么区别,都能够以一致的形式编写泛型代码。
semiregular
与 regular
类似,但放松了限制,无须支持判等操作。
2.5.6 invocable(可调用的)
除了值和对象之外,还有一些编程元素如函数和函数对象,它们都属于 invocable
(可调用)概念。
1 |
|
若可调用类返回类型为 bool
,那么也满足 predicate
(谓词)的概念。
1 |
|
若一个谓词入参仅接受两个参数,那么也满足 relation
(关系)的概念。
1 |
|
通过关系(relation)可以进一步定义等价关系(equivalence_relation)和弱序关系(strict_weak_order),这在 2.3.1 节中介绍过,这里不再赘述。虽然它们从语法定义上一样,但语义不同,正如程序员使用接口时需要关注它们的语义一样,使用概念同样也要关注语义。
1 |
|
2.6 综合运用之扩展 transform 变换算法
C++ 标准库中的 transform
算法接受 1 到 2 个输入迭代器、一个输出迭代器与单元或二元函数对象,它在对这 1 到 2 输入迭代器迭代的过程中,将解引用后的值作为单元或二元函数对象的入参,并将二元函数的结果写到输出迭代器上。以下代码实现的功能是将字符串小写转换成大写:
1 |
|
本节的任务是扩展该算法,使其接受任意多个输入迭代器、一个输出迭代器并接受同等输入个数的函数对象,允许输入迭代器的长度不一致,这将以最短的迭代器作为结束。这个算法的名字也应该被命名为 zip_transform
,它的原型如下:
1 |
|
这里简单地使用 pair 类将输入迭代器的起始与终止部分打包,考虑可变参数必须作为函数最后的参数,它们也被放到了最后。但该模板函数没有任何约束,用户仅靠模板参数名来人为地遵守语义上的要求。通过使用 <concepts>
标准库中预定义的概念,添加约束如下:
1 |
|
这要求用户输入的参数必须满足输入迭代器的要求,并且函数对象的参数类型、个数、输入迭代器的解引用类型及个数都能够对应得上,同时还对输出迭代器进行了约束,要求其能够接受函数对象的返回类型。实现部分使用折叠表达式进行代码生成:
1 |
|
一个可能的用例如下:
1 |
|
2.7 注意事项
如今已经有了很多惯用的手段来表达模板参数的接口,例如 Boost 专门有个 concept check 的库,语言提供了 static_assert
、constexpr
函数与值、if constexpr
,还有标准库提供的 typetraits
,那么 concept 特性存在的必要性是什么?
这些技巧有些涉及模板的实例化阶段,而不是仅仅去检查模板参数的声明,这或多或少不够理想。此外,这些手段相当低级,有点类似于元编程世界中的汇编代码。但读者不要就此认为这些低级手段就足够用了,这就好比我们自认为只要拥有了 if
和 goto
语句后就不需要 for
、while
语句。类似地,只要有了函数指针,虚函数与 lambda
就变得不再那么重要。C++ 不仅仅支持这些低级手段,它还是一门足够抽象的语言,因此:
- concept 并不是专门针对泛型编程的专家才能使用的高级特性。
- concept 不仅仅是
type traits
、enable_if
和标签分发等变通方法的语法糖。 - concept 是最基础的语言特性,最好在最初模板特性出现的时候就使用它。
如果 concept 在 20 世纪 90 年代就已经出现,那么今日的模板与模板库将会简单很多。好在最初的模板特性关键字为 typename
:它仅要求模板参数为类型,因此一些老的模板库可以很容易与 concept 特性集成。
concept 仅对所约束的模板参数声明部分进行检查,而不会去检查函数体中该模板参数是否使用了未被约束的操作,考虑如下代码。
1 |
|
模板函数 mod2
仅检查模板参数 T
是否为整数概念 integral
,而这个概念的定义并没有要求模板参数能够使用求余 %
操作,但在函数定义中使用未被约束的操作是允许的。
这也体现了一个设计层面的问题,是否应该将实现细节暴露给 concept?需要记住的是,实现并不是接口规范,如果有一天你发现一个更高效的实现,理想情况下是通过重构实现,而不是去影响它的接口,这样可以避免破坏用户的代码。若概念设计的要求太宽泛则起不到约束的作用,若设计得太细则难以应对各种变化,因此应该在保证语义一致性的前提下定义与使用 concept。
如果使用了概念中未被约束的操作,即使通过了约束检查,当该参数不支持这些操作时也会进一步导致模板实例化错误。不根据模板概念去检查模板的定义是一个深思熟虑的决定,而不是一个技术上不可行的问题。
concept 特性的贡献者们已经分析并尝试过,最终慎重地决定在 concept 的标准化中不包含这个功能,主要原因有以下几点。
- 减轻最初设计的复杂度,不想进一步延期标准化,因为延期意味着一些反馈与库的建设将会进一步延后。
- concept 的好处在于模板参数的接口规范化与模板使用之处检查。
- 能够在较早阶段发现错误而不是延迟到实例化阶段。
- 如果检查模板定义的话,将很难把未受约束的模板与模板库重构成基于 concept 的模板。
- 如果检查模板定义的话,很难在不修改 concept 接口的情况下对模板定义部分添加调试辅助、日志、性能打点等代码。
- 模板之间的调用会相当困难,一个受约束的模板只能调用另一个受约束的模板,这意味着新的基于 concept 的模板库将无法使用老的库,这是个非常严重的问题,原因在于不同的库是由不同组织开发的,而使用 concept 是一个渐进的过程。
从上可见 C++ 是一门工程性非常强的语言,所有的特性引入都需要考虑是否破坏了已有代码,老代码能否容易迁移到新特性上,在以上问题未被恰当解决之前是不会去考虑那样做的。concept 曾经考虑通过定义检查将模板的声明与实现分离,而这会导致很多函数的间接调用,并严重影响最终代码的性能。一个可能的解决方案是将模板作为模块(module)特性的一部分,通过半编译形式实现。
concept 的设计提供了几种语法形式,从简洁到复杂的 requires 子句,因为简单的形式不可避免地限制了它的表达力,而通过复杂的形式表达简单的场景又增加了冗余的噪声。
在极少会出现两个概念语法一样而语义不同的情况,这就需要程序员手动将语义上的差别转换成语法上的差别:例如通过定义额外的方法或类型成员作为特征以便区分。此外,可以使用 static_assert
显式声明给定类型以满足概念上的要求。
concept 非常容易定义与使用,这极大改善了模板与泛型编程的代码质量。它就和基础的语言特性(诸如函数、类)一样,需要理解才能高效使用。与未受约束的模板相比,它们没有引入额外的运行时开销。这也符合 C++ 的设计原则:不要强迫程序员去做那些机器能做得更好的事,并且简单的事简单做,以及零成本抽象的哲学。
concept 解决了模板与泛型编程的很多痛点,它达到了最初所设想的 C++ 模板系统应有的样子,而不是语言特性的扩展。
本文摘自《C++20 高级编程》罗能/著