0%

C++20 约束和概念(Constraint and Concept)详解

C++20前模板需要给类型添加限制时,比如需要给一个函数模板的参数类型限制为整形,可以使用c++11中的std::enable_ifstd::is_integral

1
2
template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void foo(T t) { /*...*/ }

即通过额外的类型匹配来完成类型约束,你可以在stl源码中找到大量的enable_if约束。
但这种做法有几个问题:

  1. 复用麻烦,当多个模板需要相同的类型约束时,只能将相同代码复制多份
  2. 约束失败时错误不明了,如上模板传入浮点类型时,编译器只会抱怨enable_if<false, void>内没有type类型,如果约束条件更复杂,得到的编译错误将更难以阅读

综上原因,20标准往前走了一步,将类型约束(Constraint)进行了规范,并提出了新的“概念”(Concept)。

约束 Constraint

标准里将约束定义为对模板参数的逻辑操作和操作数序列,20标准里约束可以出现在两种场景,模板requires子句中及concept的主体中。

requires关键字

20添加了requires关键字用以编写约束,requires关键字可以出现在两种场景:模板requires子句和requires表达式

requires子句

以上面的整型约束为例,改写为requires子句为:

1
2
3
template <typename T>
requires std::is_integral_v<T>
void foo(T t) { /*...*/ }

requires后的表达式求值结果必须为编译期常量bool值,可以使用逻辑与&&(标准上称为conjunctions 合取)、逻辑或||(标准上称为disjunctions 析取)联立表达式,也就是可以如下编写:

1
2
3
4
5
6
7
8
template <typename T>
requires std::is_integral_v<T> && std::is_arithmetic_v<T> ||
std::is_integral_v<typename T::value>
void foo(T t) { /*...*/ }

int main() {
foo(1);
}

逻辑操作短路求值,以上代码中前两项已满足,编译器不再校验int::value类型的存在,godbolt编译

requires 表达式

标准委员会可能认为requires子句写出复杂的约束还是不够简洁,于是就有了requires表达式,以上requires子句的例子可以改写为:

1
2
3
4
5
6
template <typename T>
requires requires {
requires std::is_integral_v<T>;
requires std::is_arithmetic_v<T>;
}
void foo(T t) { /*...*/ }

我特意使用了多个requires,是想说明下这里各个requires的作用,否则一个requires子句就可以了。
上例中的

1
2
3
4
requires {
requires std::is_integral_v<T>;
requires std::is_arithmetic_v<T>;
}

就是一个requires表达式,标准规定其求值结果为bool类型的纯右值,但我感觉形容为编译期bool常量更合适,所以才可以放到requires子句的右边。
语法为:

1
requires ( 形参列表 (可选) ) { 要求序列 }

形参列表即函数的形参列表,一些要求会涉及变量操作故引入,但这些参数仅作标记存在,仅在编译期校验要求使用;形参列表不能出现默认参数,不能出现可变参数。
要求序列内以分号隔开多个要求,多个要求必须同时满足整个表达式才为true,要求按类型分为:

简单要求 Simple requirements

1
2
3
template <typename T>
requires requires(T a, T b) { a + b; }
void foo(T t) { /*...*/ }

以上a + b即简单要求,编译期会校验这个表达式的有效性,类型T是否存在可用的operator+,其a + b结果并不会被实际求值。

类型要求 Type requirements

语法:
typename 标识符
用于校验这个类型是否有效:

1
2
3
template <typename T>
requires requires { typename T::value; }
class Foo {};

复合要求 Compound requirements

语法:
{ 表达式 } noexcept(可选) -> 类型约束(可选);
如:

1
2
3
4
5
6
7
#include <concepts>

template <typename T>
requires requires(T t) {
{ t + 1 } noexcept -> std::same_as<T>;
}
void foo(T t) { /*...*/ }

以上约束的含义为t + 1表达式必须有效,且相关操作必须声明为noexcept,且整个表达式返回值类型必须为Tstd::same_as为20标准新增<concept>头中的“概念”(有关见本文“概念”章节),表示类型必须一致(包含cv ref性质)。
虽然标准中未要求Compound requirements的类型约束必须为concept,但我实际测试下来,目前三大主流编译期此处只能使用concept进行约束。

嵌套要求 Nested requirements

语法:
requires 约束表达式
也就是requires-clause的语法也可以出现在这里:

1
2
3
4
5
template <typename T>
requires requires {
requires std::is_integral_v<T> && std::is_arithmetic_v<T>;
}
void foo(T t) { /*...*/ }

当然上面只是为了演示,需要注意的是嵌套要求内不能再出现requires表达式嵌套。

requires 示例

1
2
3
4
5
6
7
8
9
// requires子句内包含requires表达式
template <typename T>
requires std::is_class_v<T> && requires(T t, size_t index) {
{ t.at(index) }; // 简单要求
typename T::iterator; // 类型要求
{ t[index] } noexcept -> std::same_as<typename T::value_type&>; // 复合要求
requires std::is_convertible_v<typename T::size_type, int>; // 嵌套要求
}
void foo(T t) { /*...*/ }

编译结果

约束规范化 Constraint normalization

约束表达式转化为替换类型之后的多个原子表达式的合取于析取序列的过程,称为约束规范化。
规范化过程类型参数替换后,如果产生了无效的表达式,则该约束非良构,不要求诊断,不参与编译候选。

约束偏序 Partial ordering by constraints

当有多个模板约束同时满足时,就会产生最佳匹配问题,这也就是约束偏序要解决的问题。
标准规定如果约束P蕴含约束Q(或称约束P归入Q,“a constraint P subsumes a constraint Q”),则约束P相比约束Q更严格,更受偏序约束,重载决议将选择更严格的P版本:

偏序例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 约束偏序例1
template <typename T>
requires std::integral<T>
void foo(T t) {
std::cout << "integral" << std::endl;
}

template <typename T>
// integral、signed_integral为20 <concepts> 新增概念
requires std::integral<T> && std::signed_integral<T>
void foo(T t) {
std::cout << "signed integral" << std::endl;
}

int main() {
foo(1);
}

以上第二个约束P(整型且为有符号整型)成立时第一个约束Q(整型)必定成立,则P蕴含Q。约束偏序例1 godbolt

偏序例2

但蕴含关系不检查实际意义上的蕴含,仅作具名“概念”concept的检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 约束偏序例2
#include <type_traits>

template <typename T>
requires std::is_integral_v<T>
void foo(T t) { /*..*/ }

template <typename T>
// 逻辑上,下约束蕴含上约束
requires std::is_integral_v<T> && std::is_signed_v<T>
void foo(T t) { /*..*/ }

int main() {
foo(1); // 编译出错,编译期无法选择重载模板
}

标准中虽然没有明确说明约束偏序只针对具名的概念有效,但从目前各编译起器支持情况看,编译器不会检查具体约束条件,即使上例中"A && B"已经明显蕴含"A",编译器最终仍会报错无法进行重载决议:约束偏序例2 godbolt。顺带提一下,上例中换成requires表达式仍然是同样情况。

偏序例3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 约束偏序例3
#include <type_traits>

template<typename T>
concept Integral = std::is_integral_v<T>;

template<typename T>
concept SignedIntegral = std::is_integral_v<T> && std::is_signed_v<T>;

template <typename T>
requires Integral<T>
void foo(T t) { /*...*/ }

template <typename T>
// 逻辑上,SignedIntegral蕴含Integral
requires SignedIntegral<T>
void foo(T t) { /*...*/ }

int main() {
foo(1); // 虽然使用了concept,但编译器仍不检查concept内具体约束的关系
}

同样的情况发生在上例这种概念的定义内,编译器无法认定SignedIntegral蕴含Integral约束偏序例3 godbolt

偏序例4

1
2
3
4
5
6
// 约束偏序例4
template<typename T>
concept Integral = std::is_integral_v<T>;

template<typename T>
concept SignedIntegral = Integral<T> && std::is_signed_v<T>;

但只要把上例中的SignedIntegral中的条件改为使用Integral概念,即可通过编译:约束偏序例4 godbolt
实际上std::integralstd::signed_integral正是这样定义的,也就是说编译器检查约束偏序必须包含concept,然后再这基础上校验了逻辑表达式的蕴含关系(具体规则在标准上有,这里不赘述,有兴趣的可以查看参考链接中"Partial ordering by constraints"章节)。

概念 Concept

在介绍完约束之后,再来看concept就比较简单了,在上文中也出现了concept的使用。
概念concept即对其模板参数进行了约束的模板,用以解决之前标准中类型约束无法复用的问题,concept在20标准中作为新关键字被添加。

Concept 语法

1
2
template < 模板形参列表 >
concept 概念名 属性 (可选) = 约束表达式;

属性即如[[deprecated]]的列表,上文介绍的requires子句及requires表达式即是常见的约束表达式,但实际上任意编译期bool常量都可作为约束表达式。所以,只要你想,你可以写出:

1
2
3
4
5
template<typename T>
concept Truth = 1 + 1 == 2;

template<typename T>
concept Lie = 1 + 1 == 3;

编译结果见godbolt编译

Concept 相关规范

  1. concept定义时不能出现类型约束,即:
1
2
3
template<typename T>
requires true
concept Truth = (1 + 1 == 2);

这里的requires true是不被允许的,原因也很显然

  1. concept定义时不能递归定义:
1
2
template<typename T>
concept Integral = std::is_integral_v<T> && Integral<T::Value>;

虽然标准中没有明确的说明,但各编译器实现下来是无法如上定义的。

  1. concept不能被显式实例化、显式特化、部分特化。虽然同样是顶着template的前缀,但诸如Truth<int>template<> Truth<int>的写法显然是没有意义的。

  2. 概念出现在约束表达式中被求值时,满足即为true,否则为false

  3. 概念约束可以出现在模板形参定义时,以这种方式定义时,概念接受的类型实参会比形参少一个,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
concept Integral = std::is_integral_v<T>;

template<Integral T> // T的约束为requires Integral<T>
void foo(T t) {}

template<typename T, typename U>
concept Derived = std::is_base_of_v<U, T>;

class Parent { };

template<Derived<Parent> T> // T的约束为requires Derived<T, Parent>
void fooo(T t) {}

小结

综上,20标准对类型约束的规范和概念的加入,解决了本文开头提出的复用和编译错误难以定位问题,使c++的类型约束语法更加清晰。

参考链接

cpp reference 协程
c++20 标准最终草案