Post

C++20之概念

1.引言

在编写C++模板时,有时需要对模板参数进行约束。例如:

1
2
template<class T>  // requires T to be addable
T add(T a, T b) { return a + b; }

其中,函数模板add()要求模板参数T必须支持+运算,但只是通过注释以文字形式说明,编译器并不知道这一约束。

在C++20之前,可以利用SFINAE规则对模板参数进行约束。但这种方式存在代码可读性差、错误消息难以理解、代码难以复用等问题。C++20引入了一个新的语言特性——概念。概念能够改进模板错误消息,提高模板代码的可读性,还允许对模板参数进行更强大的约束。本文首先介绍概念的用法,之后介绍C++20之前的替代方案SFINAE。

注:本文提到的“概念”特指C++20的新特性concept,而不是某种“抽象概念”。

2.概念

概念(concept)是对模板参数的一组命名的约束/要求,是一种在编译时求值的类型断言,以编译器可理解的方式提供了一种模板参数检查机制。当模板参数不满足要求时,编译器将给出更加明确的错误消息。

2.1 概念的定义和使用

概念的定义形式如下:

1
2
template <模板参数列表>
concept 概念名 = 约束表达式;

其中,“约束表达式”必须是bool类型的编译时常量表达式,包括:

  • constexpr bool变量
  • 头文件<type_traits>中定义的辅助类
  • requires表达式
  • 其他概念
  • 约束表达式的合取/析取

例如:

1
2
3
4
5
6
7
8
template<class T>
concept Any = true;

template<class T>
concept Integral = std::is_integral_v<T>;

template<class T>
concept SignedIntegral = Integral<T> && std::is_signed_v<T>;

这里定义了三个概念:

  • Any对于任意模板参数T都满足。
  • Integral要求T为整数类型(例如boolcharintlong等)。
  • SignedIntegral要求T为有符号整数类型。

使用概念时,编译器会将模板参数代入约束表达式。如果满足约束,则概念的值为true,否则为false。这一过程是在编译时完成的。例如:

1
2
3
4
static_assert(Integral<int>);
static_assert(!Integral<double>);
static_assert(SignedIntegral<long>);
static_assert(!SignedIntegral<unsigned char>);

对于第1节中的例子,可以定义概念Addable,表示“支持+运算”这一约束:

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

其中,=后面的部分叫做requires表达式{}中的一个或多个语句用于断言这些表达式是合法的,即能够编译通过(并不真正求值)。如果模板参数满足所有的要求,则requires表达式结果为true

在定义模板时,有两种使用概念的方式。第一种方式是requires子句

1
2
3
template<class T>
    requires Addable<T>
T add(T a, T b) { return a + b; }

这意味着“要求模板参数T必须满足概念Addable”。关键字requires后面必须是bool常量表达式,例如概念、概念的合取/析取或requires表达式。例如:

1
2
3
4
5
6
7
template<class T>
    requires std::is_integral_v<T>
T add(T a, T b) { return a + b; }

template<class T>
    requires requires (T x) { x + x; }
T add(T a, T b) { return a + b; }

第二种方式是将概念用于模板参数声明:

1
2
template<Addable T>
T add(T a, T b) { return a + b; }

此时概念接受的实参比形参列表少一个,因为后面的模板参数会隐式地用作第一个实参。例如:

1
2
3
4
5
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
 
template<Derived<Base> T>
void f(T); // T is constrained by Derived<T, Base>

使用概念能够改进模板错误消息。例如,下面的代码没有使用概念,当模板匹配失败时,编译器会输出大量无关的、难以理解的错误消息。

https://godbolt.org/z/hjG64MGY6

相反,使用概念,编译器将给出更加明确的错误消息。

https://godbolt.org/z/faMxa37oY

注意:如果模板参数不满足概念的要求,编译器会将该模板从重载候选集中排除,并尝试匹配其他重载,而不是导致编译错误;只有当未匹配到任何重载时才会报错。见2.3节示例。

标准库头文件<concepts>定义了一组常用的概念。

2.2 requires表达式

requires表达式是描述类型约束的bool表达式,可用于概念定义或模板定义的requires子句。语法如下:

1
2
requires { 要求序列 }
requires (参数列表) { 要求序列 }

“要求序列”由一个或多个要求/约束(requirement)组成,用分号分隔。每个要求可以是以下形式之一:

  • 简单要求
  • 类型要求
  • 复合要求
  • 嵌套要求

如果将模板参数代入到requires表达式中会导致非法类型或表达式,则requires表达式的结果为false。如果模板参数满足所有的要求,则requires表达式结果为true

(1)简单要求

简单要求是任意表达式语句,断言该表达式是合法的(能够编译通过)。表达式并不会被求值,只检查语言正确性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T>
concept Addable = requires (T a, T b) {
    a + b;
};

template<class T>
concept Swappable = requires (T& a, T& b) {
    std::swap(a, b);
};

template<class T>
concept Callable = requires (T f) {
    f();
};

注:将std::is_integral_v<T>这种约束放在requires表达式中是没有意义的,因为这个表达式对于任何类型都是“合法”的,只有放在requires子句或概念定义中才会判断其值为真或假。

(2)类型要求

类型要求是typename后面跟着一个类型名称,验证指定的类型存在。例如:

1
2
3
4
5
6
template<class C>
concept Container = requires (C c) {
    typename C::iterator;
    c.begin();
    c.end();
};

概念Container要求模板参数是“容器”,即具有成员类型iterator以及成员函数begin()end()

(3)复合要求

复合要求的形式为

1
{ 表达式 } -> 类型约束;

断言“表达式”合法,且返回类型满足“类型约束”。例如:

1
2
3
4
template<class P, class T>
concept Predicate = requires (P p, T x) {
    {p(x)} -> std::same_as<bool>;
};

概念Predicate要求模板参数P是“T的谓词”,即可使用T类型的对象调用,并且返回类型为bool

(4)嵌套要求

嵌套要求即嵌套的requires表达式。

2.3 示例

下面通过一个std::vector构造函数重载的问题说明概念的用途。std::vector构造函数具有以下两种形式的重载:

1
2
3
4
5
6
7
8
9
10
11
12
template<class T>
class vector {
public:
    // (1)
    explicit vector(size_t n, const T& val = T());

    // (2)
    template<class Iter>
    vector(Iter first, Iter last);
    
    // ...
};

其中,重载(1)构造包含n个val的向量,重载(2)使用范围[first, last)内的元素构造向量。因此,可以像这样创建向量:

1
2
vector<int> v1(5, 2);
vector<int> v2(v1.begin(), v1.end());

预期v1的构造函数会调用重载(1),v2调用重载(2)。然而,按照上面的定义,v1实际上会调用重载(2),从而导致编译错误(整数不支持迭代器操作)。这是因为两个参数的类型相同,因此重载(2)在重载解析中的优先级更高。

为了避免这一问题,C++标准规定:只有当模板参数Iter满足“输入迭代器”要求时,重载(2)才参与重载解析。要实现这一点,可以使用头文件<iterator>提供的概念std::input_iterator对模板参数Iter进行约束:

1
2
template<std::input_iterator Iter>
vector(Iter first, Iter last);

这样,在初始化v1时,由于参数类型int不满足要求,编译器会将重载(2)从候选集中排除,从而选择重载(1)。

下面考虑另一个示例。定义一个函数模板advance(),用于将给定的迭代器向前移动n个元素。C++有多种迭代器类别。对于输入迭代器,需要在循环中使用++运算符,时间复杂度为O(n);对于随机访问迭代器,可以直接使用+=运算符,时间复杂度为O(1)。因此,可以定义下面两个重载,并分别使用概念std::input_iteratorstd::random_access_iterator对模板参数进行约束:

1
2
3
4
5
6
7
8
9
10
11
// (1)
template<std::input_iterator Iter>
void advance(Iter& it, int n) {
    while (n-- > 0) ++it;
}

// (2)
template<std::random_access_iterator Iter>
void advance(Iter& it, int n) {
    it += n;
}

注:标准库头文件<iterator>提供了实现同样功能的函数std::advance(),但并不是使用概念实现的,因为引入该函数时还没有“概念”的概念。

3.SFINAE

SFINAE是 “Substitution Failure Is Not An Error” 的缩写,即“替换失败不是错误”。

在函数模板的重载解析中会应用这一规则:当模板参数替换失败时,编译器会将该模板从重载候选集中排除,而不是导致编译错误。 这一特性被用于模板元编程。

“替换失败”是指代入模板参数后导致参数类型或返回类型非良构(ill-formed),例如类型不包含指定的成员。SFINAE错误的完整列表见SFINAE - cppreference “Type SFINAE” 和 “Expression SFINAE” 两节。

考虑下面的例子:

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

// (1)
template<class T>
void f(typename T::type) { std::cout << "f(T::type)\n"; }

// (2)
template<class T>
void f(T) { std::cout << "f(T)\n"; }

struct A { using type = int; };

int main() {
    f<A>(0);    // calls (1)
    f<int>(0);  // calls (2)
    return 0;
}
  • 调用f<A>(0)时:将模板参数A代入重载(1),由于类A具有成员类型type,匹配成功。
  • 调用f<int>(0)时:首先选择重载(1),而int没有成员type,匹配失败;继续尝试重载(2),匹配成功。

对于第1节中的例子,可以通过在返回类型中利用SFINAE实现对模板参数的约束:

1
2
3
4
template<class T>
auto add(T a, T b) -> decltype(a + b) {
    return a + b;
}

如果类型T不支持+运算,则decltype中的表达式a + b是非良构的,导致替换失败。

注意,使用SFINAE可以避免不必要的模板实例化。如果不使用SFINAE(即直接使用第1节中的定义),假设传入两个vector参数,则编译器在重载解析阶段会选择该模板,而在模板实例化阶段才报错。相反,如果使用SFINAE,则编译器在重载解析阶段就会排除这个模板,并报错“没有匹配的重载”,节省了模板实例化的开销,错误消息也更加明确。

标准库提供了一些能够更方便地利用SFINAE的特性。

3.1 std::enable_if

C++11引入了模板类std::enable_if,定义如下:

1
2
3
4
5
template<bool B, class T = void>
struct enable_if {};
 
template<class T>
struct enable_if<true, T> { typedef T type; };

如果Btrue,则将T作为成员类型type;否则没有成员type。C++14引入了辅助类型std::enable_if_t

1
2
template<bool B, class T = void>
using enable_if_t = typename enable_if<B, T>::type;

std::enable_if允许基于条件启用或禁用特定的重载。将(编译时求值)的条件作为模板参数B,并将其成员类型type用于模板定义。如果条件为假,则成员类型type不存在,导致替换失败,从而利用SFINAE规则将这个重载从候选集中排除。

std::enable_if有多种用法,包括:

  • 作为额外的函数参数
  • 作为返回类型
  • 作为模板参数

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <type_traits>

// (1)
template<class T>
void f(T x, std::enable_if_t<std::is_integral_v<T>, int> = 0) {
    std::cout << "f() enabled for integral type\n";
}

// (2)
template<class T>
std::enable_if_t<std::is_floating_point_v<T>> f(T x) {
    std::cout << "f() enabled for floating point type\n";
}

// (3)
template<class T, class = std::enable_if_t<std::is_class_v<T>>>
void f(T x) {
    std::cout << "f() enabled for class type\n";
}

struct C {};

int main() {
    f(8);    // calls (1)
    f(2.5);  // calls (2)
    f(C{});  // calls (3)
    return 0;
}
  • 重载(1):当T是整数类型时,等价于void f(T x, int = 0),否则第二个参数类型替换失败。因此f(8)调用f<int>(8, 0)
  • 重载(2):当T不是浮点类型时,返回类型替换失败。因此f(2.5)调用f<double>(2.5)
  • 重载(3):当T不是类类型时,第二个模板参数替换失败。因此f(C{})调用f<C, void>(C{})

对于2.3节的例子,可以这样使用std::enable_if实现:

1
2
3
4
5
template<class Iter>
using is_input_iterator = std::is_convertible<typename std::iterator_traits<Iter>::iterator_category, std::input_iterator_tag>;

template<class Iter, class = std::enable_if_t<is_input_iterator<Iter>::value>>
vector(Iter first, Iter last);

可以看到,与使用概念相比,代码可读性差了许多。

3.2 std::void_t

C++17引入了模板类std::void_t,定义如下:

1
2
template<class...>
using void_t = void;

std::void_t仅仅是void的别名,但可以检测其模板参数是否会导致替换失败,从而利用SFINAE规则。

例如,可以这样定义模板is_iterable来检查一个类型是否是“可迭代的”(即有begin()end()成员函数):

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

template<class T, class = void>
struct is_iterable : std::false_type {};

template<class T>
struct is_iterable<T, std::void_t<
        decltype(std::declval<T>().begin()),
        decltype(std::declval<T>().end())>> : std::true_type {};

struct C {};

int main() {
    static_assert(is_iterable<std::vector<int>>::value);
    static_assert(!is_iterable<C>::value);
    static_assert(!is_iterable<int>::value);
    return 0;
}

4.参考

This post is licensed under CC BY 4.0 by the author.