首页 C++20 Concept
文章
取消

C++20 Concept

模板

在为静态类型语言开发代码时,我们很经常遇到这样的情况:我们需要为多个数据类型实现相同的功能。放在 C 语言中,我们不得不为他们各自定义一个函数或结构体,例如:

1
2
3
4
5
6
7
8
int add(int x, int y);
unsigned addu(unsigned x, unsigned y);
float addf(float x, float y);
// ...

struct Vector { int x; int y };
struct VectorF { float x; float y};
// ...

等等诸如此类。在现代化的高级编程语言中,我们通常使用一种称为泛型(Generic)的语法,将定义中的类型提取出来依赖调用者的输入。而在 C++ 中,我们称之为模板(Template),上述代码使用模板可以写作:

1
2
3
4
5
template<typename T>
T add(T x, T y);

template<typename T>
struct Vector { T x; T y; };

其中的模板类型 T 通过使用的输入来决定:

1
2
3
int a = add(114, 514);                  // 自动推导
double b = add<double>(19.19, 810);     // 显式指定
Vector<unsigned> v { 2, 3};

在 C++20 之前,我们所能做到的就只有这样了。但是,这种做法显然是存在很大的弊端的:对于模板提供者而言,我们无法限制使用者传入什么类型,这很可能导致使用者使用了预料之外的类型从而导致了未知的结果;而对于模板函数或模板类的调用者而言,没有一个简单的方法可以知道自己的类需要实现哪些接口才能满足模板类型的需求。在上述的例子中,我们可以简单地猜测 add 函数只需要我们实现 operator+(T) 的重载即可,但是在实际开发中,尤其是模板类,其内部复杂且通常都会嵌套许多其他的模板类,你很难通过看代码来整理出自己需要实现的接口列表。

具名要求

由于上述提到的问题,C++ 为其标准库制定了一套具名要求(Named Requirement),标准库中某些模板参数会要求你传入的类型满足某一种具体的具名要求,例如,标准库中有一个函数用来随机打乱容器中的元素,其声明为:

1
2
template< class RandomIt >
void random_shuffle( RandomIt first, RandomIt last );

在该函数的解释中,模板参数 RandomIt 被要求 ValueSwappable 以及 LegacyRandomAccessIterator,通过进一步查阅二者的定义,我们就能知道我们需要为自己的类实现什么接口才能将其作为该模板函数的模板参数使用了。

具名要求某种程度上已经提供了一种新语法的雏形,我们不禁会想:如果能把这种限制直接写进代码中,而不是通过文档的形式体现该有多好!

概念

是的,在 C++20,我们所想的已经成为了现实。

让我们回到 add 函数之中,在 C++20,我们可以这样定义它:

1
2
3
4
5
template<typename T>
concept Addable = requires(T x, T y) { x + y; };

template<typename T> requires Addable<T>
T add(T a, T b);

相比于之前只使用模板的写法,这种写法显然复杂了许多。

概念的定义

首先,在定义函数之前,我们先定义了一个概念(Concept):事实上,“概念”这一概念几乎可以和上文所说的“具名要求”画上等号,它就可以视为以代码形式写出来的具名要求。

概念的定义以 concept 关键字开头,之后紧跟该概念的标识符,在此处我们使用 Addable,接着跟随 = 为其赋值。

为一个概念赋值的方法有很多,其中一种办法就是使用 requires 关键字引入一系列要求的集合,这些要求可以是:

  1. 简单要求

    表达式是一个不求值的操作数;只检查语言的正确性,例如上文中的:

    1
    2
    
     template<typename T>
     concept Addable = requires(T x, T y) { x + y; };
    

    只要 T 能够令 x + y 通过编译,即 T 类型有重载过 operator+(T)T 就满足概念 Addable

    这种要求也可以使用函数调用的形式,用来判断类型 T 是否定义了指定名字的成员函数。同样,该函数只检查正确性,不会真的被调用。

  2. 类型要求

    类型要求是关键字 typename 后面接一个可选限定的类型名称。该要求是指命名的类型是有效的:

    1
    2
    3
    4
    5
    6
    
     template<typename T>
     concept C = requires
     {
         typename T::inner;      // 需要类型 T 内部有定义类型 inner
         typename S<T>;          // 需要类型 S 对于类型 T 有模板特化
     };
    
  3. 复合要求

    对表达式合法性检查并且对表达式的返回值类型做出要求,在大括号中写出表达式,并且使用右箭头来表示对返回值类型的要求:

    1
    2
    3
    4
    5
    6
    7
    8
    
     template<typename T>
     concept C = requires(T x)
     {
         // 表达式 *x 必须合法
         // 并且 类型 T::inner 必须存在
         // 并且 *x 的结果必须可以转换为 T::inner
         {*x} -> std::convertible_to<typename T::inner>;
     };
    

    其中,std::convertible_to<From, To> 是标准库 concepts 中定义的概念之一,要求类型 From 可以转换为类型 To。在本例中,第一个参数 From 将自动填入 *x 的返回值类型。

  4. 嵌套要求

    可以在要求中指定其他要求,例如:

    1
    2
    3
    4
    5
    6
    7
    
     template<typename T>
     concept Addable = requires(T x, T y)
     {
         // 嵌套要求
         requires std::integral<T>;
         x + y;
     };
    

    其中,std::integral<T> 是标准库 concepts 中定义的概念之一,要求类型 T 必须是整数类型。

除了使用 requires 表达式引入要求集合之外,我们也可以使用现有概念创建一个新的概念,例如:

1
2
3
4
5
template<typename T>
concept Addable = requires(T x, T y) { x + y; };

template<typename T>
concept IntegralAddable = Addable<T> && std::integral<T>;

此例中,我们使用 Addable<T> && std::integral<T> 作为新概念 IntegralAddable 的值。Addablestd::integral 是已有的两个概念,我们使用 && 符号将它们连接,意味着要想满足概念 IntegralAddable,必须既满足 Addable 又满足 std::integral

作为特例,有一种很特殊的概念:

1
2
template<typename T>
concept AlwaysOk = true;

任何类型 T 都会满足 AlwaysOk。这也揭示了概念类型的本质:requires 表达式对要求集合进行检查,若成功则返回 true,这也是我们可以使用 && 来连接不同概念的原因。我们自然会想到,|| 以及 ! 也是可以用在概念中的:

1
2
3
4
5
template<typename T>
concept Addable = requires(T x, T y) { x + y; };

template<typename T>
concept NonIntegralAddable = Addable<T> && !std::integral<T>;

概念 NonIntegralAddable 将接受非整数类型但是实现了加法的类型。

另外,&&, || 以及 ! 同样接受 requires 表达式:

1
2
template<typename T>
concept NonIntegralAddable = !std::integral<T> && requires(T x, T y) { x + y; };

概念的使用

明白如何定义概念之后,我们再看看要如何来使用概念。

第一种方法是使用 requires 关键字引入概念,就像我们之前做的那样:

1
2
template<typename T> requires Addable<T>
T add(T a, T b);

事实上,requires 表达式的位置是可选的,你可以放在函数签名之后:

1
2
3
template<typename T>
T add(T a, T b) requires Addable<T>
{ }

这就有点像 C# 的 T Add<T>(T a, T b) where T: IEnumerable

第二种方法是在模板定义中,使用概念名来替代 typename 关键字:

1
2
template<Addable T>
T add(T a, T b);

这种写法相比于第一种写法更简洁,更提倡这种写法。

第三种写法,就是不定义概念,而是直接将其写在模板函数或模板类的定义上。例如,上述例子我们可以不定义概念 Addable,而是直接写在 add 函数的定义中:

1
2
3
4
template <typename T>
requires requires(T a, T b) { a + b; } // 注意 requires 需要使用两次;
T add(T a, T b)
{ }

1
2
3
4
template<typename T>
T add(T a, T b)
requires requires(T a, T b) { a + b; }  // 注意 requires 需要使用两次;
{ }

在你的要求集合比较短,并且只使用一次不想专门为其定义一个概念时,可以使用这种方法。

最后还有一种方法是引入 auto 关键字:

1
Addable auto add(Addable auto a, Addable auto b);

需要注意的是,这种写法并不等效于前三种写法,因为这种写法允许 a 和 b 不是同一个类型

另一方面,对于需要传入多个模板类型参数的概念,例如 std::convertible_to<From, To>,由 auto 推导的类型始终是第一个模板类型:

1
2
3
4
5
6
// a 的类型由 auto 推导后作为 From 模板类型传递给 std::convertible_to<From, To>
// 而显式指定的 int 类型则作为 To 模板类型传递给 std::convertible_to<From, To>
// 对于拥有更多模板类型参数的概念也是同理,auto 推导的类型永远是第一个模板类型
int toInt(std::convertible_to<int> auto a) {
    return a;
}

在对多个泛型参数进行约束时,我们也同样可以使用 &&, || 以及 !,例如:

1
2
3
4
5
6
template <typename T, typename U>
requires Addable<T> && std::equality_comparable_with<T, U>
bool add_then_eq(T a, T b, U c)
{
    return a + b == c;
}

该模板函数要求 T 类型可相加,并且 T 类型和 U 类型可以比较相等性。

lambda 与概念

在 C++14 中,我们迎来了泛型 lambda,为了不引入复杂的模板语法,C++14 为我们提供了简单易懂的 auto 语法:

1
2
3
4
// 这是一个泛型 lambda,可以传入任意类型的 a、b 参数,甚至 a 和 b 可以不是同一个类型
auto add = [](auto a, auto b) {
    return a + b;
};

然而,在 C++20 中,为了能够让泛型 lambda 也享受到概念语法带来的福利,最终 C++ 委员会还是选择将模板列表带给了 lambda(这何尝不是一种)。

当然,我们仍然可以使用下面这种方法抗拒模板列表:

1
2
3
auto add = [](Addable auto a, Addable auto b) {
    return a + b;
};

但是正如前面所说,这种写法不要求 ab 的类型一样,因此使用时需要确认给不同参数传入不同的类型的情况是否符合预期。

如果引入模板列表,则上述模板 lambda 可以写做:

1
2
3
4
auto add = []<typename T>
requires Addable<T>(T a, T b) {
    return a + b;
};

同样的,requires 子句也可以放在形参列表的后面,也可以将 Addable 替换掉 typename,或者直接将概念 Addable 的定义替换上来。

标准库 concepts

在 C++20 中,标准库新增了头文件 concepts,其中定义了许多常用的概念可以供我们直接使用,此处介绍一些常用的标准库概念:

概念定义
same_as<T, U>TU 为同一类型时才满足
derived_from<Derived, Base>BaseDerived 或是 Derived 的基类时才满足
convertible_to<From, To>From 类型能够隐式和显式转换为 To 类型时才满足
integral<T>T 类型为整数类型时才满足
signed_integral<T>T 类型为有符号整数类型时才满足
unsigned_integral<T>T 类型为无符号整数类型时才满足
floating_point<T>T 类型为浮点类型时才满足
assignable_from<LHS, RHS>RHS 类型的表达式能够赋值给 LHS 左值时才满足
swappable<T>T 类型的左值可交换时才满足
swappable_with<T, U>T 类型的左值和 U 类型的左值可互相交换时才满足
constructible_from<T, Args...>T 类型可以由参数类型集 Args... 构造时才满足
default_initializable<T>T 类型能够默认构造时才满足
move_initializable<T>T 类型能够移动构造时才满足
copy_initializable<T>T 类型能够拷贝构造和移动构造时才满足
equality_comparable<T>当运算符 ==!= 能反应 T 类型上的相等性时才满足
equality_comparable_with<T, U>当运算符 ==!= 能反应 T 类型与 U 类型之间的相等性时才满足
totally_ordered<T>当比较运算符在 T 类型上严格全序时才满足
movable<T>T 类型可移动,即:能移动构造、移动赋值,左值能交换时才满足
copyable<T>T 类型可拷贝,即:既可以拷贝构造,又可移动时才满足
semiregular<T>T 类型既可拷贝,又可以默认构造时才满足
regular<T>T 类型既可拷贝,又可以默认构造,并且可以比较相等性时才满足

需要注意的是,与类型相关的概念,仅仅只是检测其类型是否为标准类型,而非检查其语义。这意味着 intconst int 满足 integral<T>,但是 int &const int & 却不满足 integral<T>,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <vector>
#include <concepts>

template <typename T>
concept IntegralArrayType = requires(T a) {
    { a[0] } -> std::integral;
    a.size();
};

void do_nothing(IntegralArrayType auto a) {}

int main() {
    std::vector<int> vec;
    do_nothing(vec);
}

这样的写法是无法通过编译的,因为 a[0] 在此处返回的类型是 int &,其不满足 std::integral 的约束。

想要让这段代码工作,我们可以引入一个新的概念来表示整数类型或者整数类型的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <vector>
#include <concepts>

template <typename T>
concept Number = std::integral<typename std::remove_reference<T>::type>;

template <typename T>
concept IntegralArrayType = requires(T a) {
    { a[0] } -> Number;
    a.size();
};

void do_nothing(IntegralArrayType auto a) {}

int main() {
    std::vector<int> vec;
    do_nothing(vec);
}
本文由作者按照 CC BY 4.0 进行授权

Qt 计时器 QTimer 的妙用

Android C++ 生成 compile_commands.json