约束与概念

类模板,函数模板,以及非模板函数(通常是类模板的成员),可以与一项约束(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 约束

语法上就是把原本的 typenameclass 换成了我们定义的 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”未满足关联约束

将模板中的 typenameclass 换成 概念(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 子句的用法,我们一个个解释

  1. frequires 子句写在 template 之后,并空四格,这是我个人推荐的写法;它的约束是:std::is_same_v<T, int>,意思很明确,约束 T 必须是 int 类型,就这么简单。

  2. f2requires 子句写法和 f 其实是一样的,只是没换行和空格;它使用了我们自定义的概念(concept)add,约束 T 必须满足 add

  3. f3requires 子句在函数声明符的末尾元素出现;这里我们连用了两个 requires,为什么?其实很简单,我们要区分,第一个 requiresrequires 子句,第二个 requires约束表达式,它会产生一个编译期的 bool 值,没有问题。(如果 T 类型带入约束表达式是良构,那么就返回 true、否则就返回 false)。

类模板、变量模板等也都同理

requires 子句中,关键词 requires 必须后随某个常量表达式

template<typename T>
    requires true
void f(T){}

完全可行,各位其实可以直接带入,说白了 requires 子句引入的约束表,必须是可以编译期返回 bool 类型的值的表达式,我们前面的三个例子:std::is_same_vaddrequires 表达式 都如此。

约束

前面我们讲的都是非常基础的概念(concept)使用,它们的约束也都十分简单,本节我们详细讲一下。

约束是逻辑操作和操作数的序列,它指定了对模板实参的要求。它们可以在 requires 表达式(见下文)中出现,也可以直接作为概念的主体。

有三种类型的约束:

  1. 合取(conjunction)

  2. 析取(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 后面接一个可以被限定的类型名称。该要求是,所指名的类型是有效的。

可以用来验证:

  1. 某个指名的嵌套类型是否存在

  2. 某个类模板特化是否指名了某个类型

  3. 某个别名模板特化是否指名了某个类型。

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::Xtypename 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)

并断言所指名表达式的属性。替换和语义约束检查按以下顺序进行:

  1. 模板实参 (若存在) 被替换到 表达式 中;

  2. 如果使用了noexcept,表达式 一定不能潜在抛出;

  3. 如果返回类型要求 存在,则:

    • 模板实参被替换到返回类型要求 中;

    • 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 的。只有我们用户定义析构函数的时候把它声明为了 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_vstd::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 类型的纯右值表达式

Last updated