第41章 概念和约束(C++20)

在第40章中我们探讨了SFINAE规则,即替换失败不是错误。对于SFINAE规则,一个典型的应用就是标准库中的std::enable_if模板元函数,SFINAE规则使该模板元函数能辅助模板的开发者限定实例化模板的模板实参类型,举例来说:

template <class T, class U = std::enable_if_t<std::is_integral_v<T>>>
struct X {};

X<int> x1; // 编译成功
X<std::string> x2; // 编译失败

在上面的代码中,类模板X的模板形参class U = std::enable_if_t <std::is_integral_v<T>>只是作为一个约束条件存在,当T的类型为整型时,std::is_integral_v <T>返回true,于是std::enable_if_t<std::is_ integral_v<T>>返回类型void,所以X<int>实际上是X<int, void>的一个合法类型。反之,对于X<std::string>来说,T的类型不为整型,std:: enable_if不存在嵌套类型type,于是std::enable_if_t<std::is_ integral_v<T>>无法符合语法规范,导致编译失败。

以下是enable_if的一种实现方法:

template<bool B, class T = void>
struct enable_if {};

template<class T>
struct enable_if<true, T> { using type = T; };

可以看到enable_if的实现十分简单,而让它发挥如此大作用的幕后功臣就是SFINAE规则。不过使用std::enable_if作为模板实参约束也有一些硬伤,比如使用范围窄,需要加入额外的模板形参等。于是为了更好地对模板进行约束,C++20标准引入了概念(concept)。

概念是对C++核心语言特性中模板功能的扩展。它在编译时进行评估,对类模板、函数模板以及类模板的成员函数进行约束:它限制了能被接受为模板形参的实参集。

实际上概念并不是新鲜的特性,早在2008年“概念”已经被C++0x接受,只不过在2009年7月的法兰克福C++标准委员会会议上,通过投票表决删除了C++0x中的“概念”,原因是委员会需要限制新语法规则带来的风险并保证标准的实现进度。虽然在当时对于大多数程序员的影响不大,但是对于研究和意识到该特性的潜力的人来说确实是非常令人失望的。

“概念”最早的实现要追溯到2016年的GCC6.1,在GCC6.1中我们可以使用-fconcepts开关来开启“概念”实验性特性,当时我们称其为“Concept TS”(Concepts Technical Specification)。但即使已经实现了“概念”特性,也没让它进入C++17标准,原因简单来说就是“还不够好”。就这样一直到2017的多伦多C++标准委员会会议,新的概念功能特性才被正式列入C++20标准中。

所以在C++20中,上一节的例子可以改写为:

template <class C>
concept IntegerType = std::is_integral_v<C>;

template <IntegerType T>
struct X {};

上面的代码使用concept关键字定义了模板形参T的约束条件IntegerType,模板实参替换T之后必须满足std::is_integral_v<C>计算结果为true的条件,否则编译器会给出IntegerType约束失败的错误提示。这份代码还可以简化为:

template <class T>
requires std::is_integral_v<T>
struct X {};

requires关键字可以直接约束模板形参T,从而达到相同的效果。conceptrequires的详细用法将在后面的章节中讨论。现在我想让大家看一看用概念约束模板的另外一个优势,请对比下面的编译错误日志:

std::enable_if:
In substitution of 'template<bool _Cond, class _Tp> using enable_if_t =    typename std::enable_if::type [with bool _Cond = false; _Tp = void]':
required from here
error: no type named 'type' in 'struct std::enable_if<false, void>'
 2554 |     using enable_if_t = typename enable_if<_Cond, _Tp>::type;
-------------------------------------------------------------------------------------
concept:
error: template constraint failure for 'template<class T>  requires  IntegerType<T> struct X'

显然,使用concept代码的错误日志更加简洁清晰,在错误日志中明确地提示用户struct X模板约束失败。

我们可以使用concept关键字来定义概念,例如:

template <class C>
concept IntegerType = std::is_integral_v<C>;

其中IntegerType是概念名,这里的std::is_integral_v<C>称为约束表达式。

约束表达式应该是一个bool类型的纯右值常量表达式,当实参替换形参后,如果表达式计算结果为true,那么该实参满足约束条件,概念的计算结果为true。反之,在实参替换形参后,如果表达式计算结果为false或者替换结果不合法,则该实参无法满足约束条件,概念的计算结果为false

请注意,这里所谓的计算都是编译期执行的,概念的最终结果是一个bool类型的纯右值:

template <class T> concept TestConcept = true;
static_assert(TestConcept<int>);

通过上面的代码可以看出,TestConcept<int>是一个bool类型的常量表达式,因为它能够作为static_assert的实参。

约束表达式还支持一般的逻辑操作,包括合取和析取:

// 合取
template <class C>
concept SignedIntegerType = std::is_integral_v<C> && std::is_signed_v<C>;

// 析取
template <class C>
concept IntegerFloatingType = std::is_integral_v<C> || std::is_floating_point_v<C>;

观察上面的代码可知,约束的合取是通过逻辑与&&完成的,运算规则也与逻辑与相同,要求两个约束都为true,整个约束表达式才会为true,当左侧约束为false时,整个约束表达式遵循短路原则为false。同样,约束的析取是通过逻辑或||完成的,运算规则与逻辑或相同,只要任意约束为true,整个约束表达式就会为true,当左侧约束为true时,整个约束表达式遵循短路原则为true。让我们尝试用上面的两个概念约束模板实参:

template <SignedIntegerType T>
struct X {};

template <IntegerFloatingType T>
struct Y {};

X<int> x1;                // 编译成功
X<unsigned int> x2;       // 编译失败

Y<int> y1;                // 编译成功
Y<double> y2;             // 编译成功

在上面的代码中,只有x2会编译失败,因为X的模板形参的约束条件是一个有符号整型。

除了逻辑操作的合取和析取之外,约束表达式还有一种特殊情况叫作原子约束,很明显原子约束中的表达式不能存在约束的合取或者析取。由于原子约束概念解释起来比较晦涩,而且需要配合requires子句示例做解释,因此将在后面详细讨论。

除了使用concept关键字来定义概念,我们还可以使用requires子句直接约束模板实参,例如:

template <class T>
requires std::is_integral_v<T> && std::is_signed_v<C>
struct X {};

上面的代码同样能够限制类模板X的模板实参必须为有符号整型类型,其中requires紧跟的std::is_integral_v<T>&& std::is_signed_v<C>必须是一个类型为bool的常量表达式。requires子句对于该常量表达式还有一些额外的要求。

1.是一个初等表达式或带括号的任意表达式。例如:

constexpr bool bar() { return true; }

template <class T>
requires bar()
struct X {};

由于这里的bar()不是初等表达式,不符合语法规则,因此编译失败,需要修改为:

constexpr bool bar() { return true; }

template <class T>
requires (bar())
struct X {};

2.使用&&或者||运算符链接上述表达式:

constexpr bool bar() { return true; }

template <class T>
requires (bar()) && true || false
struct X {};

requires子句除了能出现在模板形参列表尾部,还可以出现在函数模板声明尾部,所以下面的用法都是正确的:

template <class T> requires std::is_integral_v<T>
void foo();

template <class T>
void foo() requires std::is_integral_v<T>;

约束模板实参的方法很多,那么现在就有一个问题摆在我们面前——当一个模板同时具备多种约束时,如何确定优先级,例如:

template <class C>
concept ConstType = std::is_const_v<C>;

template <class C>
concept IntegralType = std::is_integral_v<C>;

template <ConstType T>
requires std::is_pointer_v<T>
void foo(IntegralType auto) requires std::is_same_v<T, char * const> {}

上面的代码分别使用概念ConstType、模板形参列表尾部requires std:: is_pointer_v <T>和函数模板声明尾部requires std::is_ integral_v<T>来约束模板实参,还使用概念IntegralType约束了auto占位符类型的函数形参。对于函数模板调用:

foo<int>(1.5);

编译器究竟应该用什么顺序检查约束条件呢?事实上,标准文档给出了明确的答案,编译器应该按照以下顺序检查各个约束条件。

1.模板形参列表中的形参的约束表达式,其中检查顺序就是形参出现的顺序。也就是说使用concept定义的概念约束的形参会被优先检查,放到刚刚的例子中foo<int>();最先不符合的是ConstType的约束表达式std::is_const_v<C>

2.模板形参列表之后的requires子句中的约束表达式。这意味着,如果foo的模板实参通过了前一个约束检查后将会面临std::is_pointer_v<T>的检查。

3.简写函数模板声明中每个拥有受约束auto占位符类型的形参所引入的约束表达式。还是放到例子中看,如果前两个约束条件已经满足,编译器则会检查函数实参是否满足IntegralType的约束。

4.函数模板声明尾部requires子句中的约束表达式。所以例子中最后检查的是std::is_same_v<T, char * const>

为了更好地理解约束的检查顺序,让我们来分别编译以下5句代码,看一看编译器输出日志(以GCC为例):

foo<int>(1.5);
foo<const int>(1.5);
foo<int * const>(1.5);
foo<int * const>(1);
foo<char * const>(1);

  对于foo<int>(1.5);,不满足所有约束条件,编译器报错提示不满足ConstType<T>的约束。

  对于foo<const int>(1.5);,满足ConstType<T>,但是不满足其他条件,编译器报错提示不满足std::is_pointer_v<T>的约束。

  对于foo<int * const>(1.5);,满足前两个条件,但是不满足其他条件,编译器报错提示不满足IntegralType<auto>的约束。

  对于foo<int * const>(1);,满足前3个条件,但是不满足其他条件,编译器报错提示不满足std::is_same_v<T, char * const>的约束。

  foo<char * const>(1);满足所有条件,编译成功。

现在让我们回头看一看什么是原子约束。原子约束是表达式和表达式中模板形参到模板实参映射的组合(简称为形参映射)。比较两个原子约束是否相同的方法很特殊,除了比较代码上是否有相同的表现,还需要比较形参映射是否相同,也就是说功能上相同的原子约束可能是不同的原子约束,例如:

template <int N> constexpr bool Atomic = true;
template <int N> concept C = Atomic<N>;
template <int N> concept Add1 = C<N + 1>;
template <int N> concept AddOne = C<N + 1>;
template <int M> void f()
requires Add1<2 * M> {};
template <int M> void f()
requires AddOne<2 * M> && true {};

f<0>(); // 编译成功

在上面的代码中,虽然概念Add1AddOne使用了不同的名称,但是实际上是相同的,因为在这两个函数中概念C的原子约束都是Atomic<N>,其形参映射都为N~2 * M + 1。在两个函数都符合约束的情况下,编译器会选择约束更为复杂的requires AddOne<2 * M> && true作为目标函数,因为AddOne<2 * M> && true包含了AddOne<2 * M>。接下来让我们把形参映射改变一下:

template <int N> void f2()
requires Add1<2 * N> {};
template <int N> void f2()
requires Add1<N * 2> && true {};

f2<0>(); // 编译失败

上面的代码无法通过编译,虽然都是用了概念Add1,但是它们的形参映射不同,分别为2 * N + 1N * 2 + 1,所以Add1<N * 2> && true并不能包含Add1 <2 * N>,而对于f2<0>();而言,两个f2函数模板都满足约束,这里的二义性让编译器不知所措,导致编译失败。当然,如果将requires Add1<N * 2> && true中的true改为false,就不会产生二义性,可以顺利地通过编译。

当约束表达式中存在原子约束时,如果约束表达式结果相同,则约束表达式应该是相同的,否则会导致编译失败,例如:

template <class T> concept sad = false;
template <class T> int f1(T) requires (!sad<T>) { return 1; };
template <class T> int f1(T) requires (!sad<T>) && true {return 2; };

f1(0); // 编译失败

需要注意的是,逻辑否定表达式是一个原子约束。所以以上代码会产生二义性,原子约束表达式!sad<T>并不来自相同的约束表达式。为了让代码能成功编译,需要修改代码为:

template <class T> concept not_sad = !sad<T>;
template <class T> int f2(T) requires (not_sad<T>) { return 3; };
template <class T> int f2(T) requires (not_sad<T>) && true  { return 4; };

f2(0);

这样一来,原子约束表达式!sad<T>都来自概念not_sad。另外,因为(not_sad<T>) && true包含了not_sad<T>,所以编译器选取约束表达式为requires (not_sad<T>) && true的函数模板进行编译,最终函数返回4。再进一步:

template <class T> int f3(T) requires (not_sad<T> == true) { return 5; };
template <class T> int f3(T) requires (not_sad<T> == true) && true  { return 6; };

f3(0);

template <class T> concept not_sad_is_true = !sad<T> == true;
template <class T> int f4(T) requires (not_sad_is_true<T>) { return 7; };
template <class T> int f4(T) requires (not_sad_is_true<T>) && true  { return 8; };

f4(0);

同样的理由,f3(0);会因为二义性无法通过编译,而f4(0)可以编译成功并最后返回8。

requires关键字除了可以引入requires子句,还可以用来定义一个requires表达式,该表达式同样是一个纯右值表达式,表达式为true时表示满足约束条件,反之false表示不满足约束条件。需要特别说明的是requires表达式的判定标准,因为这个标准比较特殊,具体来说是对requires表达式进行模板实参的替换,如果替换之后requires表达式中出现无效类型或者表达式违反约束条件,则requires表达式求值为false,反之则requires表达式求值为true。例如:

template <class T>
concept Check = requires {
  T().clear();
};

template <Check T>
struct G {};

G<std::vector<char>> x;      // 编译成功
G<std::string> y;            // 编译成功
G<std::array<char, 10>> z;   // 编译失败

上面的代码使用requires表达式定义了概念CheckCheck要求T().clear();是一个合法的表达式。因此G<std::vector<char>> x;G<std::string> y;可以顺利通过编译,因为std::vector<char>std::string都有成员函数clear()。而std::array<char, 10>中不存在成员函数clear(),导致编译失败。

值得注意的是,requires表达式还支持形参列表,使用形参列表可以使requires表达式更加灵活清晰,比如在上面的例子中,我希望除了要求实参具备成员函数clear()以外还需要支持+运算符,那么我们可以将代码修改为:

template <class T>
concept Check = requires {
  T().clear();
  T() + T();
};

以上代码可以完成检查+运算符的工作,但通常我们并不这样做,因为存在更加清晰的方式:

template <class T>
concept Check = requires(T a, T b) {
  a.clear();
  a + b;
};

在上面的代码中,我们使用了requires表达式的形参列表,形参列表和普通函数的形参列表类似,不同的是这些形参并不存在生命周期和存储方式,只在编译期起作用,而且只有在requires表达式作用域内才是有效的。自然的,对于需要运行时计算实参数量的不定参数列表来说,requires表达式的形参列表也是不支持的:

template<typename T>
concept C = requires(T t, …) { // 编译失败,requires表达式的形参列表不能使用…
  t;
};

回过头来看经过修改的概念,Check会将G<std::vector<char>> x;拒之门外,因为std::vector<char>的实例是无法使用+运算符的。另外,由于std::string支持用+运算符完成字符串的连接,因此G<std::string> y;能够编译成功。

在上面的requires表达式中,a.clear()a + b可以说是对模板实参的两个要求,这些要求在C++标准中称为要求序列(requirement-seq)。要求序列分为4种,包括简单要求、类型要求、复合要求以及嵌套要求,接下来就让我们详细讨论这4种要求。

简单要求是不以requires关键字开始的要求,它只断言表达式的有效性,并不做表达式的求值操作。如果表达式替换模板实参失败,则该要求的计算结果为false:

template<typename T> concept C =
requires (T a, T b) {
  a + b;
};

在上面的代码中a + b是一个简单要求,编译器会断言a + b的合法性,但不会计算其最终结果。不以requires关键字开始是简单表达式的重要特征,后面将提到的嵌套要求则正好相反,它要求以requires关键字开头。

类型要求是以typename关键字开始的要求,紧跟typename的是一个类型名,通常可以用来检查嵌套类型、类模板以及别名模板特化的有效性。如果模板实参替换失败,则要求表达式的计算结果为false

template<typename T, typename T::type = 0> struct S;
template<typename T> using Ref = T&;
template<typename T> concept C = requires {
  typename T::inner;           // 要求嵌套类型
  typename S<T>;               // 要求类模板特化
  typename Ref<T>;             // 要求别名模板特化
};

template <C c>
struct M {};

struct H {
  using type = int;
  using inner = double;
};

M<H> m;

在上面的代码中,概念C中有3个类型要求,分别为T::innerS<T>Ref<T>,它们各自对应的是对嵌套类型、类模板特化和别名模板特化的检查。请注意代码中的类模板声明S,它不是一个完整类型,缺少了类模板定义。但是编译器仍然可以编译成功,因为标准明确指出类型要求中的命名类模板特化不需要该类型是完整的。

相对于简洁的简单要求和类型要求,复合要求则稍微复杂一些,比如下面的代码:

template <class T>
concept Check = requires(T a, T b) {
  {a.clear()} noexcept;
  {a + b} noexcept -> std::same_as<int>;
};

在上面的代码中,{a.clear()} noexcept;{a + b} noexcept -> std::same_as<int>;是需要断言的复合要求。复合要求可以由3个部分组成:{}中的表达式、noexcept以及->后的返回类型约束,其中noexcept->后的返回类型约束是可选的。根据标准,断言一个复合要求需要按照以下顺序。

1.替换模板实参到{E}中的表达式E,检测表达式的有效性。

2.如果使用了noexcept,则需要检查并确保{E}中的表达式E不会有抛出异常的可能。

3.如果使用了->后的返回类型约束,则需要将模板实参替换到返回类型约束中,并且确保表达式E的结果类型,即decltype((E)),满足返回类型约束。

如果出现任何不符合以上检查规则的情况,则requires表达式判定为false。例如,在之前的代码中只有G<std::string> y;可以编译成功,因为std::string不仅存在成员函数clear(),也能够进行+操作。但是现在,a + b又多了两个约束,首先noexcept要求a + b不能有抛出异常的可能性,其次其结果类型必须满足概念std::same_as<int>;的约束。其中概念std::same_as的实现类似于:

template< class T, class U >
concept same_as =  std::is_same_v<T, U> && std::is_same_v<U, T>;

a + b的结果类型会作为第一个模板实参,实际编译代码类似于:

std::same_as<decltype((a + b)), int>

显然,两个std::string相加的运算结果不可能是int类型,所以G<std::string> y;是不能通过编译的。最后如果我们给std::vector<char>添加以下声明:

int operator+ (const std::vector<char>&, const std::vector<char>&) noexcept;

那么G<std::vector<char>> x;就可以编译成功了。值得注意的是,这里的noexcept是必不可少的,operator+不需要是完整的。

正如简单要求中提到的,嵌套要求是以requires开始的要求,它通常根据局部形参来指定其他额外的要求。例如:

template <class T>
concept Check = requires(T a, T b) {
  requires std::same_as<decltype((a + b)), int>;
};

在上面的代码中,requires std::same_as<decltype((a + b)), int>;是一个嵌套要求,它要求表达式a + b的结果类型与int相同,可以等同于:

template <class T>
concept Check = requires(T a, T b) {
  {a + b} -> std::same_as<int>;
};

最后请注意,这里的局部形参不是可以参与运算的操作数,例如:

template<typename T> concept C = requires (T a) {
  requires sizeof(a) == 4;  // 编译成功
  requires a == 0;          // 编译失败
};

这里a == 0a的值是无法计算的。

使用概念约束可变参数模板实际上就是将各个实参替换到概念的约束表达式后合取各个结果。例如下面的代码:

template<class T> concept C1 = true;
template<C1… T> struct s1 {};

s1包展开后的约束为(C1<T> &&),具体来说对于s1<int, double, std::string>,其约束实际上为(C1<int> && C1<double> && C1<std:: string>)。以上代码比较容易理解,但是有时候代码会更加复杂一些,比如:

template<class… Ts> concept C2 = true;
template<C2… T> struct s2 {};

现在问题来了,s2包展开之后的结果应该是(C2<T> &&)C2<T>还是(C2<T> &&)呢?是不是有点难以抉择,请记住,在这种情况下包展开的结果依然是(C2<T> &&)。不得不说,C2<T>曾经是正确的,但现在不是了。再次强调一下,包展开的结果是(C2<T> &&)

接下来让我们更进一步,对于:

template<class T, class U> concept C3 = true;
template<C3<int> T> struct s3 {};

经过模板实参替换后实际的约束为C<T, int>,对比这个结果,下面的代码:

template<C3<int>… T> struct s3 {};

包展开后的约束应该是(C3<T, int> &&)

约束可以影响类模板特化的结果,在模板实例化的时候编译器会自动选择更满足约束条件的特化版本进行实例化,比如:

template<typename T> concept C = true;
template<typename T> struct X {
  X() { std::cout << "1.template<typename T> struct X" << std::endl; }
};
template<typename T> struct X<T*> {
  X() { std::cout << "2.template<typename T> struct X<T*>" << std::endl; }
};
template<C T> struct X<T> {
  X() { std::cout << "3.template<C T> struct X<T>" << std::endl; }
}; 

X<int*> s1;
X<int> s2;

以上代码的输出结果如下:

2.template<typename T> struct X<T*>
3.template<C T> struct X<T>

显然,对于X<int*>而言,匹配更精确的是template<typename T> struct X<T*>。而对于X<int>,由于template<C T> struct X<T>有概念约束,相对于template<typename T> struct X更加特殊,因此编译器选择前者进行实例化。

上面的例子只是说明了约束对类模板特化的影响,实际上约束在类模板特化上可以发挥很大的作用,请看以下代码:

template<typename T> concept C = requires (T t) { t.f(); };
template<typename T> struct S {
  S() {
      std::cout << "1.template<typename T> struct S" << std::endl;
  }
};
template<C T> struct S<T> {
  S() {
      std::cout << "2.template<C T> struct S<T>" << std::endl;
  }
};
struct Arg { void f(); };

S<int> s1;
S<Arg> s2;

以上代码的输出结果如下:

1.template<typename T> struct S
2.template<C T> struct S<T>

可以看出,由于S<int>中的int无法满足概念C的约束条件,因此编译器使用template<typename T> struct Ss1进行实例化。而对于S<Arg>Arg满足概念C的约束,所以编译器选择更加特殊的template<C T> struct S<T>来实例化s2。值得注意的是,如果只是约束构造函数,区分不同类型的构造方法,那么有更简单的方式:

template<typename T> struct S {
  S() {
      std::cout << "1.call S()" << std::endl;
  }

  S() requires requires(T t) { t.f(); }  {
      std::cout << "2.call S() requires requires(T t)" << std::endl;
  }
};
struct Arg { void f(); };

S<int> s1;
S<Arg> s2;

上文曾介绍过使用概念约束简写函数模板中的auto占位符,事实上对autodecltype(auto)的约束可以扩展到普遍情况,例如:

template <class C>
concept IntegerType = std::is_integral_v<C>;

IntegerType auto i1 = 5.2;    // 编译失败
IntegerType auto i2 = 11;     // 编译成功

IntegerType decltype(auto) i3 = 4.8;       // 编译失败
IntegerType decltype(auto) i4 = 7;         // 编译成功

IntegerType auto foo1() { return 1.1; }    // 编译失败
IntegerType auto foo2() { return 0; }      // 编译成功

auto bar1 = []()->IntegerType auto  { return 1.0; };    // 编译失败
auto bar2 = []()->IntegerType auto  { return 10; };     // 编译成功

在上面的代码中,概念IntegerType约束auto的推导结果必须是一个整型,于是在声明并初始化i1i3的时候会导致编译失败。同理,函数foo1返回值为浮点类型也会导致编译失败。对于lambda表达式也是一样,只不过需要显式声明返回类型和约束概念。

最后需要注意的是,要约束的autodecltype(auto)总是紧随约束之后。因此,cv限定符和概念标识符不能随意混合:

const IntegerType auto i5 = 23;    // 编译成功
IntegerType auto const i6 = 8;     // 编译成功
IntegerType const auto i7 = 6;     // 编译失败

在上面的代码中,i5i6可以顺利通过编译,因为auto紧跟在IntegerType之后。反观i7的声明,IntegerTypeauto之间存在const,导致编译失败。

C++20标准中的概念和约束不同于以往的实验版本,它不仅仅是一个扩展,而是一套完备的语言特性。与模板语法的相似使它很容易被程序员理解和接受。很明显,概念和约束的推行能够很好地补充C++的类型检查机制,这对于通用代码库的作者来说无疑是一个很好的消息,而对于代码库的使用者来说编码的错误会更容易排查,并且在运行代码的时候不会有任何多余的开销。借用本贾尼·斯特劳斯特卢普的一句话:“尝试使用概念!它们将极大地改善读者的通用编程,并让当前使用的变通方法(例如traits类)和低级技术(例如基于enable_if的重载)感觉就像是容易出错和烦琐的汇编编程”。