约束与概念
类模板,函数模板,以及非模板函数(通常是类模板的成员),可以与一项约束(constraint)相关联,它指定了对模板实参的一些要求,这些要求可以被用于选择最恰当的函数重载和模板特化。
这种要求的具名集合被称为概念(concept)。每个概念都是一个谓词,它在编译时求值,并在将之用作约束时成为模板接口的一部分。
前言
在 C++20 引入了约束与概念,这一核心语言特性是所有使用模板的 C++ 开发者都期待的。
有了它,我们的模板可以有更多的静态检查,语法更加美观,写法更加容易,而不再需要利用古老的 SFINAE。
请务必学习完了上一章节内容;本节会一边为你教学约束与概念的语法,一边用 SFINAE 对比,让你意识到:这是多么先进、简单的核心语言特性。
定义概念(concept)与使用
定义概念所需要的 约束表达式,只需要是可以在编译期产生 bool
值的表达式即可。
你可以先不看基本概念,关注我们的示例和下面的讲解。
我需要写一个函数模板
add
,想要要求传入的对象必须是支持operator+
的,应该怎么写?
此需求就是 SFINAE
中提到的,我们使用概念(concept)来完成。
我们使用关键字 concept
定义了一个概念(concept),命名为 Add
,它的约束是 requires(T a) { a + a; }
即要求 f(T a)
、a + a
是合法表达式。
语法上就是把原本的 typename
、class
换成了我们定义的 Add
概念(concept),语义和作用也非常的明确:
就是让这个概念约束模板类型形参
T
,即要求T
必须满足约束表达式的要求序列T a
a + a
。如果不满足,则不会选择这个模板。
"满足":要求带入后必须是合法表达式;
最开始的概念已经说了:
概念(concept)可以与一项约束(constraint)相关联,它指定了对模板实参的一些要求,这些要求可以被用于选择最恰当的函数重载和模板特化。
另外最开始的概念中还说过:
每个概念都是一个谓词,它在编译时求值,并在将之用作约束时成为模板接口的一部分。
也就是说我们其实可以这样:
我相信这非常的好理解,这些语法形式,合理且简单。
记得我们在第一章节函数模板中提到的:“C++20 简写函数模板”吗?
这段代码来自函数模板那一章节。
我想要约束:传入的对象 a b 必须都是整数类型,应该怎么写?。
如你所见,我们没有自己定义 概念(concept),而是使用了标准库的 std::integral
,它的实现非常简单:
这也告诉各位我们一件事情:定义概念(concept) 时声明的约束表达式,只需要是编译期可得到 bool
类型的表达式即可。
我相信你这里一定有疑问:“那么我们之前写的 requires 表达式呢?它会返回
bool
值吗?” 对,简单的说,把模板参数带入到requires
表达式中,是否符合语法,符合就返回true
,不符合就返回false
。在requires
表达式 一节中会详细讲解。
它的实现是直接使用了标准库的 std::is_integral_v<T>
,非常简单。
再谈概念(concept)在简写函数模板中的写法 const std::integral auto& a
,概念(concept)只需要写在 auto
之前即可,表示此概念约束 auto
推导的类型必须为整数类型,语义十分明确,像是 cv 限定、引用等,不需要在乎,或许我们可以先写的简单一点先去掉那些:
这是否直观了很多?并且概念不单单是可以用作简写函数模板中的 auto
,还有几乎一切语境,比如:
还是那句话,语义很明确:
概念(concept)约束了
auto
,它必须被推导为整数类型;如果函数f()
返回类型是double
auto
无法推导为整数类型,那么编译器会报错:“未满足关联约束”。
类模板同理,如:
变量模板也同理
将模板中的 typename
、class
换成 概念(concept)即可,表示约束此模板类型形参 T
。
requires
子句
requires
子句关键词 requires 用来引入 requires 子句,它指定对各模板实参,或对函数声明的约束。
也就是说我们多了一种使用概念(concept)或者说约束的写法。
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 必须后随某个常量表达式。
完全可行,各位其实可以直接带入,说白了 requires
子句引入的约束表,必须是可以编译期返回 bool
类型的值的表达式,我们前面的三个例子:std::is_same_v
、add
、requires 表达式
都如此。
约束
前面我们讲的都是非常基础的概念(concept)使用,它们的约束也都十分简单,本节我们详细讲一下。
约束是逻辑操作和操作数的序列,它指定了对模板实参的要求。它们可以在 requires 表达式(见下文)中出现,也可以直接作为概念的主体。
有三种类型的约束:
合取(conjunction)
析取(disjunction)
合取
两个约束的合取是通过在约束表达式中使用 && 运算符来构成的:
很合理,约束表达式可以使用 &&
运算符连接两个约束,只有在两个约束都被满足时才会得到满足
我们先定义了一个 概念(concept)Integral,此概念要求整形;又定义了概念(concept)SignedIntegral,它的约束表达式用到了先前定义的概念(concept)Integral,然后又加上了一个 &&
还需要满足 std::is_signed_v。
概念(concept)SignedIntegral
是要求有符号整数类型,它的约束表达式是:Integral<T> && std::is_signed_v<T>
,就是这个表达式要返回 true
才成立,就这么简单。
两个约束的合取只有在两个约束都被满足时才会得到满足。合取从左到右短路求值(如果不满足左侧的约束,那么就不会尝试对右侧的约束进行模板实参替换:这样就会防止出现立即语境外的替换所导致的失败)。
析取
两个约束的析取,是通过在约束表达式中使用 || 运算符来构成的:
和 ||
运算符本来的意思一样, std::integral<T>
、std::floating_point
满足任意一个,那么整个约束表达式就都得到满足。
如果其中一个约束得到满足,那么两个约束的析取的到满足。析取从左到右短路求值(如果满足左侧约束,那么就不会尝试对右侧约束进行模板实参替换)。
如你所见,即使我们的 X 根本不满足右侧约束 get_value<T>()
的要求,没有静态 value
成员,不过一样可以通过编译。
requires
表达式
requires
表达式产生描述约束的 bool 类型的纯右值表达式。
虽然前面聊概念(concept)的时候,用到了 requires
表达式(定义 concept 的时候),但是没有详细说明,本节我们详细展开说明。
注意,
requires
表达式 和requires
子句,没关系。
要求序列,是以下形式之一:
简单要求
类型要求
复合要求
嵌套要求
解释
要求可以援引处于作用域内的模板形参,形参列表中引入的局部形参,以及在上下文中可见的任何其他声明。
将模板参数代换到模板化实体的声明中所使用的 requires 表达式中,可能会导致在其要求中形成无效的类型或表达式,或者违反这些要求的语义。在这种情况下,requires 表达式的值为
false
而不会导致程序非良构。按照词法顺序进行代换和语义约束检查,当遇到决定 requires 表达式结果的条件时就停止。如果代换(若存在)和语义约束检查成功,则 requires 表达式的结果为
true
。
简单的说,把模板参数带入到 requires 表达式中,是否符合语法,符合就返回
true
,不符合就返回false
。
三端测试。
简单要求
简单要求是任何不以关键词 requires 开始的表达式语句。它断言该表达式是有效的。表达式是不求值的操作数;只检查语言的正确性。
以上代码利用了实参依赖查找(
ADL
),即swap(X{})
是合法表达式,而不需要增加命名空间限定。
以关键词 requires 开始的要求总是被解释为嵌套要求。因此简单要求不能以没有括号的 requires 表达式开始。
类型要求
类型要求是关键词 typename
后面接一个可以被限定的类型名称。该要求是,所指名的类型是有效的。
可以用来验证:
某个指名的嵌套类型是否存在
某个类模板特化是否指名了某个类型
某个别名模板特化是否指名了某个类型。
稍微解释一下,类 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
表达式,想想它是不是合法的表达式就可以了。
复合要求
复合要求具有如下形式
返回类型要求:-> 类型约束(概念 concept)
并断言所指名表达式的属性。替换和语义约束检查按以下顺序进行:
模板实参 (若存在) 被替换到 表达式 中;
如果使用了
noexcept
,表达式 一定不能潜在抛出;如果返回类型要求 存在,则:
模板实参被替换到返回类型要求 中;
decltype((表达式))
必须满足类型约束 蕴含的约束。否则,被包含的 requires 表达式是false
。
我们可以写一个满足概念(concept)C2
的类型:
测试。
析构函数比较特殊,不需要我们显式声明它为 noexcept
的,它默认就是 noexcept
的。
不管编译器为我们生成的 X
析构函数,还是我们用户显式定义的 X
析构函数,默认都是有 noexcept
的。只有我们用户定义析构函数的时候把它声明为了 noexcept(false)
这个析构函数才不是 noexcept
的,才会不满足 概念(concept)C2
的要求。
嵌套要求
嵌套要求具有如下形式
它可用于根据本地形参指定其他约束。约束表达式 必须由被替换的模板实参(若存在)满足。将模板实参替换到嵌套要求中会导致替换到 约束表达式 中,但仅限于确定是否满足 约束表达式 所需的程度。
嵌套要求的 约束表达式,只要能编译期产生
bool
值的表达式即可,概念(concept)、类型特征的库、requires
表达式,等都一样。
这里用 std::is_same_v
和 std::same_as
其实毫无区别,因为它们都是编译时求值,返回 bool
值的表达式。
在上面示例中 requires requires{ a + a; }
其实是更加麻烦的写法,目的只是为了展示 requires
表达式是编译期产生 bool
值的表达式,所以有可能会有两个 requires
连用的情况;我们完全可以直接改成 a + a
,效果完全一样。
部分(偏)特化中使用概念
我们在讲 SFINAE 的时候提到了,它可以用作模板偏特化,帮助我们选择特化版本;本节的约束与概念当然也可以做到,并且写法更加简单直观优美:
这个示例完全是从 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 类型的纯右值表达式。
Last updated