约束与概念
类模板,函数模板,以及非模板函数(通常是类模板的成员),可以与一项约束(constraint)相关联,它指定了对模板实参的一些要求,这些要求可以被用于选择最恰当的函数重载和模板特化。
这种要求的具名集合被称为概念(concept)。每个概念都是一个谓词,它在编译时求值,并在将之用作约束时成为模板接口的一部分。
前言
在 C++20 引入了约束与概念,这一核心语言特性是所有使用模板的 C++ 开发者都期待的。
有了它,我们的模板可以有更多的静态检查,语法更加美观,写法更加容易,而不再需要利用古老的 SFINAE。
请务必学习完了上一章节内容;本节会一边为你教学约束与概念的语法,一边用 SFINAE 对比,让你意识到:这是多么先进、简单的核心语言特性。
定义概念(concept)与使用
template < 模板形参列表 >
concept 概念名 属性 (可选) = 约束表达式;
定义概念所需要的 约束表达式,只需要是可以在编译期产生 bool
值的表达式即可。
- 你可以先不看基本概念,关注我们的示例和下面的讲解。
我需要写一个函数模板
add
,想要要求传入的对象必须是支持operator+
的,应该怎么写?
此需求就是 SFINAE
中提到的,我们使用概念(concept)来完成。
template<typename T>
concept Add = requires(T a) {
a + a; // "需要表达式 a+a 是可以通过编译的有效表达式"
};
template<Add T>
auto add(const T& t1, const T& t2){
std::puts("concept +");
return t1 + t2;
}
我们使用关键字 concept
定义了一个概念(concept),命名为 Add
,它的约束是 requires(T a) { a + a; }
即要求 f(T a)
、a + a
是合法表达式。
template<Add T> // T 被 Add 约束
语法上就是把原本的 typename
、class
换成了我们定义的 Add
概念(concept),语义和作用也非常的明确:
- 就是让这个概念约束模板类型形参
T
,即要求T
必须满足约束表达式的要求序列T a
a + a
。如果不满足,则不会选择这个模板。
"满足":要求带入后必须是合法表达式;
最开始的概念已经说了:
概念(concept)可以与一项约束(constraint)相关联,它指定了对模板实参的一些要求,这些要求可以被用于选择最恰当的函数重载和模板特化。
另外最开始的概念中还说过:
每个概念都是一个谓词,它在编译时求值,并在将之用作约束时成为模板接口的一部分。
也就是说我们其实可以这样:
std::cout << std::boolalpha << Add<int> << '\n'; // true
std::cout << std::boolalpha << Add<char[10]> << '\n'; // false
constexpr bool r = Add<int>; // true
我相信这非常的好理解,这些语法形式,合理且简单。
记得我们在第一章节函数模板中提到的:“C++20 简写函数模板”吗?
decltype(auto) max(const auto& a, const auto& b) { // const T&
return a > b ? a : b;
}
这段代码来自函数模板那一章节。
我想要约束:传入的对象 a b 必须都是整数类型,应该怎么写?。
#include <concepts> // C++20 概念库标头
decltype(auto) max(const std::integral auto& a, const std::integral auto& b) {
return a > b ? a : b;
}
max(1, 2); // OK
max('1', '2'); // OK
max(1u, 2u); // OK
max(1l, 2l); // OK
max(1.0, 2); // Error! 未满足关联约束
如你所见,我们没有自己定义 概念(concept),而是使用了标准库的 std::integral
,它的实现非常简单:
template< class T >
concept integral = std::is_integral_v<T>;
这也告诉各位我们一件事情:定义概念(concept) 时声明的约束表达式,只需要是编译期可得到 bool
类型的表达式即可。
我相信你这里一定有疑问:“那么我们之前写的 requires 表达式呢?它会返回
bool
值吗?” 对,简单的说,把模板参数带入到requires
表达式中,是否符合语法,符合就返回true
,不符合就返回false
。在requires
表达式 一节中会详细讲解。
它的实现是直接使用了标准库的 std::is_integral_v<T>
,非常简单。
再谈概念(concept)在简写函数模板中的写法 const std::integral auto& a
,概念(concept)只需要写在 auto
之前即可,表示此概念约束 auto
推导的类型必须为整数类型,语义十分明确,像是 cv 限定、引用等,不需要在乎,或许我们可以先写的简单一点先去掉那些:
decltype(auto) max(std::integral auto a, std::integral auto b) {
return a > b ? a : b;
}
这是否直观了很多?并且概念不单单是可以用作简写函数模板中的 auto
,还有几乎一切语境,比如:
int f() { return 0; }
std::integral auto result = f();
还是那句话,语义很明确:
- 概念(concept)约束了
auto
,它必须被推导为整数类型;如果函数f()
返回类型是double
auto
无法推导为整数类型,那么编译器会报错:“未满足关联约束”。
类模板同理,如:
template<typename T>
concept add = requires(T t){ // 定义概念,通常推荐首字母小写
t + t;
};
template<add T>
struct X{
T t;
};
变量模板也同理
template<typename T>
concept add = requires(T t){
t + t;
};
template<add T>
T t;
t<int>; // OK
t<char[1]>; // “t”未满足关联约束
将模板中的 typename
、class
换成 概念(concept)即可,表示约束此模板类型形参 T
。
requires
子句
关键词 requires 用来引入 requires 子句,它指定对各模板实参,或对函数声明的约束。
也就是说我们多了一种使用概念(concept)或者说约束的写法。
template<typename T>
concept add = requires(T t) {
t + t;
};
template<typename T>
requires std::is_same_v<T, int>
void f(T){}
template<typename T> requires add<T>
void f2(T) {}
template<typename T>
void f3(T)requires requires(T t) { t + t; }
{}
requires
子句期待一个能够编译期产生bool
值的表达式。
以上示例展示了 requires
子句的用法,我们一个个解释
f
的requires
子句写在template
之后,并空四格,这是我个人推荐的写法;它的约束是:std::is_same_v<T, int>
,意思很明确,约束T
必须是 int 类型,就这么简单。f2
的requires
子句写法和f
其实是一样的,只是没换行和空格;它使用了我们自定义的概念(concept)add
,约束T
必须满足add
。f3
的requires
子句在函数声明符的末尾元素出现;这里我们连用了两个requires
,为什么?其实很简单,我们要区分,第一个requires
是requires
子句,第二个requires
是约束表达式,它会产生一个编译期的bool
值,没有问题。(如果T
类型带入约束表达式是良构,那么就返回true
、否则就返回false
)。
类模板、变量模板等也都同理
requires 子句中,关键词 requires 必须后随某个常量表达式。
template<typename T>
requires true
void f(T){}
完全可行,各位其实可以直接带入,说白了 requires
子句引入的约束表,必须是可以编译期返回 bool
类型的值的表达式,我们前面的三个例子:std::is_same_v
、add
、requires 表达式
都如此。
约束
前面我们讲的都是非常基础的概念(concept)使用,它们的约束也都十分简单,本节我们详细讲一下。
约束是逻辑操作和操作数的序列,它指定了对模板实参的要求。它们可以在 requires 表达式(见下文)中出现,也可以直接作为概念的主体。
有三种类型的约束:
- 合取(conjunction)
- 析取(disjunction)
合取
两个约束的合取是通过在约束表达式中使用 && 运算符来构成的:
template<class T>
concept Integral = std::is_integral_v<T>;
template<class T>
concept SignedIntegral = Integral<T> && std::is_signed_v<T>;
template<class T>
concept UnsignedIntegral = Integral<T> && !SignedIntegral<T>;
很合理,约束表达式可以使用 &&
运算符连接两个约束,只有在两个约束都被满足时才会得到满足
我们先定义了一个 概念(concept)Integral,此概念要求整形;又定义了概念(concept)SignedIntegral,它的约束表达式用到了先前定义的概念(concept)Integral,然后又加上了一个 &&
还需要满足 std::is_signed_v。
概念(concept)SignedIntegral
是要求有符号整数类型,它的约束表达式是:Integral<T> && std::is_signed_v<T>
,就是这个表达式要返回 true
才成立,就这么简单。
void s_f(const SignedIntegral auto&){}
void u_f(const UnsignedIntegral auto&){}
s_f(1); // OK
s_f(1u); // 未找到匹配的重载函数
u_f(1); // 未找到匹配的重载函数
u_f(1u); // OK
两个约束的合取只有在两个约束都被满足时才会得到满足。合取从左到右短路求值(如果不满足左侧的约束,那么就不会尝试对右侧的约束进行模板实参替换:这样就会防止出现立即语境外的替换所导致的失败)。
struct X{
int c{}; // 无意义,为了扩大 X
static constexpr bool value = true;
};
template<typename T>
constexpr bool get_value() { return T::value; }
template<typename T>
requires (sizeof(T) > 1 && get_value<T>())
void f(T){}
X x;
f(x); // OK
析取
两个约束的析取,是通过在约束表达式中使用 || 运算符来构成的:
template<typename T>
concept number = std::integral<T> || std::floating_point<T>;
和 ||
运算符本来的意思一样, std::integral<T>
、std::floating_point
满足任意一个,那么整个约束表达式就都得到满足。
void f(const number auto&){}
f(1); // OK
f(1u); // OK
f(1.2); // OK
f(1.2f); // OK
f("1"); // 未找到匹配的重载函数
如果其中一个约束得到满足,那么两个约束的析取的到满足。析取从左到右短路求值(如果满足左侧约束,那么就不会尝试对右侧约束进行模板实参替换)。
struct X{
int c{}; // 无意义,为了扩大 X
//static constexpr bool value = true;
};
template<typename T>
constexpr bool get_value() { return T::value; }
template<typename T>
requires (sizeof(T) > 1 || get_value<T>())
void f(T){}
X x;
f(x); // OK 即使 X 根本没有静态 value 成员。
如你所见,即使我们的 X 根本不满足右侧约束 get_value<T>()
的要求,没有静态 value
成员,不过一样可以通过编译。
requires
表达式
产生描述约束的 bool 类型的纯右值表达式。
虽然前面聊概念(concept)的时候,用到了 requires
表达式(定义 concept 的时候),但是没有详细说明,本节我们详细展开说明。
注意,
requires
表达式 和requires
子句,没关系。
requires { 要求序列 }
requires ( 形参列表 (可选) ) { 要求序列 }
要求序列,是以下形式之一:
- 简单要求
- 类型要求
- 复合要求
- 嵌套要求
解释
要求可以援引处于作用域内的模板形参,形参列表中引入的局部形参,以及在上下文中可见的任何其他声明。
- 将模板参数代换到模板化实体的声明中所使用的 requires 表达式中,可能会导致在其要求中形成无效的类型或表达式,或者违反这些要求的语义。在这种情况下,requires 表达式的值为
false
而不会导致程序非良构。 - 按照词法顺序进行代换和语义约束检查,当遇到决定 requires 表达式结果的条件时就停止。如果代换(若存在)和语义约束检查成功,则 requires 表达式的结果为
true
。
简单的说,把模板参数带入到 requires 表达式中,是否符合语法,符合就返回
true
,不符合就返回false
。
#include <iostream>
template<typename T>
void f(T) {
constexpr bool v = requires{ T::type; }; // 此处可不使用 typename
std::cout << std::boolalpha << v << '\n';
}
struct X { using type = void; };
struct Y { static constexpr int type = 0; };
int main() {
f(1); // false 因为 int::type 不是合法表达式
f(X{}); // false 因为 X::type 在待决名中不被认为是类型,需要添加 typename
f(Y{}); // true 因为 Y::type 是合法表达式
}
三端测试。
简单要求
简单要求是任何不以关键词 requires 开始的表达式语句。它断言该表达式是有效的。表达式是不求值的操作数;只检查语言的正确性。
template<typename T>
concept Addable = requires (T a, T b) {
a + b; // "需要表达式 a+b 是可以通过编译的有效表达式"
};
template<class T, class U = T>
concept Swappable = requires(T && t, U && u) {
swap(std::forward<T>(t), std::forward<U>(u));
swap(std::forward<U>(u), std::forward<T>(t));
};
template<typename T>
requires (Addable<T> && Swappable<T, T>)
struct Test{};
namespace loser{
struct X{
X operator+(const X&)const{
return *this;
}
};
void swap(const X&,const X&){}
}
int main() {
using loser::X;
Test<X> t2; // OK
std::cout << std::boolalpha << Addable<X> << '\n'; // true
std::cout << std::boolalpha << Swappable<X,X> << '\n'; // true
}
以上代码利用了实参依赖查找(
ADL
),即swap(X{})
是合法表达式,而不需要增加命名空间限定。
以关键词 requires 开始的要求总是被解释为嵌套要求。因此简单要求不能以没有括号的 requires 表达式开始。
类型要求
类型要求是关键词 typename
后面接一个可以被限定的类型名称。该要求是,所指名的类型是有效的。
可以用来验证:
- 某个指名的嵌套类型是否存在
- 某个类模板特化是否指名了某个类型
- 某个别名模板特化是否指名了某个类型。
struct Test{
struct X{};
using type = int;
};
template<typename T>
struct S{};
template<typename T>
using Ref = T&;
template<typename T>
concept C = requires{
typename T::X; // 需要嵌套类型
typename T::type; // 需要嵌套类型
typename S<T>; // 需要类模板特化
typename Ref<T>; // 需要别名模板代换
};
std::cout << std::boolalpha << C<Test> << '\n'; // true
稍微解释一下,类 Test
有一个嵌套类 X
,一个别名 type
,所以 typename T::X
、typename T::type
类型是有效的。
typename S<T>
因为有类模板 S
,且它接受类型模板参数,所以 typename S<T>
类型是有效的。假设模板类 S
的模板是接受非类型模板参数的,比如 template<std::size_t> S
,那么 typename S<T>
类型自然不是有效的。
typename Ref<T>
因为有别名模板 Ref
,自然没问题,类型自然是有效的。
其实说来说去也很简单,你就直接带入,把概念(concept)的模板实参(比如 Test)直接带入进去
requires
表达式,想想它是不是合法的表达式就可以了。
复合要求
复合要求具有如下形式
{ 表达式 } noexcept(可选) 返回类型要求 (可选) ;
返回类型要求:-> 类型约束(概念 concept)
并断言所指名表达式的属性。替换和语义约束检查按以下顺序进行:
- 模板实参 (若存在) 被替换到 表达式 中;
- 如果使用了
noexcept
,表达式 一定不能潜在抛出; - 如果返回类型要求 存在,则:
- 模板实参被替换到返回类型要求 中;
decltype((表达式))
必须满足类型约束 蕴含的约束。否则,被包含的 requires 表达式是false
。
template<typename T>
concept C2 = requires(T x){
// 表达式 *x 必须合法
// 并且 类型 T::inner 必须存在
// 并且 *x 的结果必须可以转换为 T::inner
{*x} -> std::convertible_to<typename T::inner>;
// 表达式 x + 1 必须合法
// 并且 std::same_as<decltype((x + 1)), int> 必须满足
// 即, (x + 1) 必须为 int 类型的纯右值
{x + 1} -> std::same_as<int>;
// 表达式 x * 1 必须合法
// 并且 它的结果必须可以转换为 T
{x * 1} -> std::convertible_to<T>;
// 复合:"x.~T()" 是不会抛出异常的合法表达式
{ x.~T() } noexcept;
};
我们可以写一个满足概念(concept)C2
的类型:
struct X{
int operator*()const { return 0; }
int operator+(int)const { return 0; }
X operator*(int)const { return *this; }
using inner = int;
};
std::cout << std::boolalpha << C2<X> << '\n'; // true
测试。
析构函数比较特殊,不需要我们显式声明它为 noexcept
的,它默认就是 noexcept
的。
不管编译器为我们生成的 X
析构函数,还是我们用户显式定义的 X
析构函数,默认都是有 noexcept
的[1]。只有我们用户定义析构函数的时候把它声明为了 noexcept(false)
这个析构函数才不是 noexcept
的,才会不满足 概念(concept)C2
的要求。
嵌套要求
嵌套要求具有如下形式
requires 约束表达式 ;
它可用于根据本地形参指定其他约束。约束表达式 必须由被替换的模板实参(若存在)满足。将模板实参替换到嵌套要求中会导致替换到 约束表达式 中,但仅限于确定是否满足 约束表达式 所需的程度。
template<typename T>
concept C3 = requires(T a, std::size_t n) {
requires std::is_same_v<T*, decltype(&a)>; // 要求 is_same_v 求值为 true
requires std::same_as<T*, decltype(new T[n])>; // 要求 same_as 求值为 true
requires requires{ a + a; }; // 要求 requires{ a + a; } 求值为 true
requires sizeof(a) > 4; // 要求 sizeof(a) > 4 求值为 true
};
std::cout << std::boolalpha << C3<int> << '\n'; // false
std::cout << std::boolalpha << C3<double> << '\n'; // true
嵌套要求的 约束表达式,只要能编译期产生
bool
值的表达式即可,概念(concept)、类型特征的库、requires
表达式,等都一样。
这里用 std::is_same_v
和 std::same_as
其实毫无区别,因为它们都是编译时求值,返回 bool
值的表达式。
在上面示例中 requires requires{ a + a; }
其实是更加麻烦的写法,目的只是为了展示 requires
表达式是编译期产生 bool
值的表达式,所以有可能会有两个 requires
连用的情况;我们完全可以直接改成 a + a
,效果完全一样。
部分(偏)特化中使用概念
我们在讲 SFINAE 的时候提到了,它可以用作模板偏特化,帮助我们选择特化版本;本节的约束与概念当然也可以做到,并且写法更加简单直观优美:
#include <iostream>
template<typename T>
concept have_type = requires{
typename T::type;
};
template<typename T>
struct X {
static void f() { std::puts("主模板"); }
};
template<have_type T>
struct X<T> {
using type = typename T::type;
static void f() { std::puts("偏特化 T::type"); }
};
struct Test { using type = int; };
struct Test2 { };
int main() {
X<Test>::f(); // 偏特化 T::type
X<Test2>::f(); // 主模板
}
这个示例完全是从 SFINAE 的写法改进而来,我们不需要再写第二个模板类型参数,我们直接写作 template<have_type T>
就完成了,概念约束了模板类型参数 T
。
- 只有概念被满足的时候,才会选择到这个偏特化。
一些实际的用途,比如我以前的 C++20 STL Cookbook
中对 std::formatter
进行偏特化,也是使用的概念,std::ranges::range
。
总结
我们先讲述了 概念(concept)的定义和使用,其中使用到了 requires
表达式,但是我们留到了后面详细讲述。
其实本章内容可以划分为两个部分
约束与概念
requires
表达式
如果你耐心看完,我相信也能意识到它们是互相掺杂,一起使用的。语法上虽然感觉有些多,但是也都很合理,我们只需要 带入,按照基本的常识判断这是不是符合语法,基本上就可以了。
requires
关键字的用法很多,但是划分的话其实就两类
requires
子句requires
表达式
requires
子句和 requires
表达式可以连用,组成 requires requires
的形式。我们在 requires
子句讲过。
还有在 requires
表达式中的嵌套要求,也会有 requires requires
的形式。
如果看懂了,这些看似奇怪的 requires
关键字复用,其实也都很合理,只需要记住最重要的一句话:
可以连用
requires requires
的情况,都是因为第一个requires
期待一个可以编译期产生bool
值的表达式;而requires
表达式就是产生描述约束的 bool 类型的纯右值表达式。