Post

C++20之概念

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,而不是某种“抽象概念”。
  • 支持概念库的编译器最低版本是GCC 10和Clang 13,编译时需要添加选项-std=c++20。参见C++ compiler support

2.概念

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

2.1 概念的定义和使用

2.1.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

2.1.2 在模板中使用概念

在定义模板时,有两种使用概念的方式。

第一种方式是在模板参数列表之后添加requires子句,关键字requires后面必须跟着bool常量表达式、概念、概念的合取/析取或requires表达式。例如:

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

template<class T>
    // requires clause with a requires expression
    requires requires (T x) { x * x; }
T mul(T a, T b) { return a * b; }

template<class T>
    // requires clause with a disjunction of constant expressions
    requires std::is_integral_v<T> || std::is_floating_point_v<T>
T abs(T x) { return x >= 0 ? x : -x; }

第二种方式是将概念用于模板参数声明,将关键字class/typename替换为概念。例如:

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

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

1
2
3
4
5
6
// T must be derived from U
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>

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

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

template<class T>  // requires T to be addable, and the result is of type T
T add(T a, T b) { return a + b; }

int main() {
    int i = add(1, 2);  // OK
    std::string s1 = "abc", s2 = "def", s = add(s1, s2);  // OK
    const char* c = add("abc", "def");  // error: 'const char *' is not addable
    std::vector<int> v1, v2, v = add(v1, v2);  // error: 'std::vector<int>' is not addable
    return 0;
}

https://godbolt.org/z/Wbq473s1s

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

https://godbolt.org/z/3ov84Y3h6

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

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

2.2 requires表达式

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

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

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

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

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

2.2.1 简单要求

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

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;  // the expression “a + b” must be valid
};

template<class T>
concept Swappable = requires (T& a, T& b) {
    std::swap(a, b);  // std::swap overload for type T must be provided
};

template<class T>
concept Callable = requires (T f) {
    f();  // T must support operator()
};

2.2.2 类型要求

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

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

概念Container要求模板参数C是“容器”,其中typename C::iterator;要求C具有成员类型iterator

2.2.3 复合要求

复合要求(compound requirement)的形式为

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

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

1
2
3
4
5
6
template<class P, class T>
concept Predicate = requires (P p, T x) {
    // the expression p(x) must be valid
    // AND its result must be of type bool
    {p(x)} -> std::same_as<bool>;
};

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

2.2.4 嵌套要求

嵌套要求(nested requirement)即嵌套的requires表达式。例如:

1
2
3
4
5
template<class P, class T>
concept Pointer = requires(P ptr) {
    requires std::same_as<T&, decltype(*ptr)>;
    requires std::copyable<P>;
};

其中,第一个要求等价于{*ptr} -> std::same_as<T&>。第二个要求如果直接写为std::copyable<P>;是没有意义的,因为简单要求只判断表达式是否合法,不会判断其值为真或假,而这个表达式对任何类型都是合法的。

2.3 示例

下面通过几个例子说明概念的用途。

2.3.1 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)。

注:std::vector早在C++20之前就存在了,因此标准库中的std::vector没有使用概念,而是利用SFINAE实现的(参见3.1节末尾)。

2.3.2 advance()

下面考虑另一个示例。定义一个函数模板advance(it, n),用于将迭代器it向前移动n个元素。

C++有多种迭代器类别。对于输入迭代器,advance()需要在循环中使用++运算符,时间复杂度为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” 的缩写,即“替换失败不是错误”。

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

“替换失败”是指代入模板参数后导致参数类型或返回类型非良构(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),匹配成功。

使用SFINAE可以避免不必要的模板实例化。如果模板参数没有任何约束,即使实参不满足要求,编译器在重载解析阶段也可能选择该模板,在模板实例化阶段才报错。相反,如果使用SFINAE,则编译器在重载解析阶段就会排除这个模板,并尝试其他重载,节省了模板实例化的开销,错误消息也更加明确。

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

3.1 std::enable_if

C++11引入了类模板std::enable_if,位于头文件<type_traits>中,定义如下:

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是整数类型时,否则第二个参数类型替换失败。因此f(8)调用f<int>(8, 0)
  • 重载(2)要求T是浮点类型时,否则返回类型替换失败。因此f(2.5)调用f<double>(2.5)
  • 重载(3)要求T是类类型时,否则第二个模板参数替换失败。因此f(C{})调用f<C, void>(C{})

对于第1节中add()的例子,可以在返回类型中使用std::enable_if实现:

1
2
3
template<class T>
std::enable_if_t<std::is_same_v<decltype(std::declval<T>() + std::declval<T>()), T>, T>
add(T a, T b) { return a + b; }

其中,std::declval<T>()用于在未求值上下文中表示一个T类型的值,decltype用于获取表达式的声明类型。由于std::enable_if_t的第一个参数必须是布尔值,因此还进一步验证了+的结果类型为T

如果类型T满足上述约束,则返回类型等价于std::enable_if_t的第二个参数T。如果T不支持+运算符,则decltype中的表达式是非良构的,导致替换失败。

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

也可以通过在后置返回类型中利用SFINAE实现对模板参数的约束,但无法验证结果类型为T

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

对于2.3.1节中std::vector构造函数的例子,可以使用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,位于头文件<type_traits>中,定义如下:

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;
}

3.3 陷阱

只有在类模板或函数模板定义中使用SFINAE才是有意义的。对于普通函数(即使是类模板的成员函数),替换失败会直接导致编译错误。

考虑下面的例子,类模板SmartCache是管理一些资源的缓存。如果资源类型提供了close()函数,则定义close_all()函数调用所有资源的close(),否则没有该函数。下面的实现是错误的:

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
30
31
32
33
34
35
36
37
38
39
40
#include <type_traits>
#include <vector>

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

template<class T>
struct has_close<T, std::void_t<decltype(std::declval<T>().close())>> : std::true_type {};

template<class ResourceType>
class SmartCache {
    std::vector<ResourceType> resources_;

public:
    // ...

    // WRONG
    typename std::enable_if<has_close<ResourceType>::value>::type close_all() {
        for (auto& res : resources_)
            res.close();
    }
};

class FileResource {
public:
    void close() { /*...*/ }
};

class MemoryResource {
    // no close()
};

int main() {
    SmartCache<FileResource> file_cache;
    file_cache.close_all();

    SmartCache<MemoryResource> memory_cache;
    // memory_cache.close_all();  // SmartCache<MemoryResource> has no close_all()
    return 0;
}

这段程序会编译失败:

https://godbolt.org/z/3s43hsnba

1
error: no type named 'type' in 'struct std::enable_if<false, void>'

这是因为编译器在实例化SmartCache<MemoryResource>时,会对close_all()的返回类型进行模板参数替换。MemoryResource类没有成员函数close(),因此has_close<MemoryResource>::valuefalse。而std::enable_if<false>中不存在成员类型type,导致替换失败。这里的“替换失败”发生在模板实例化时,而不是重载解析阶段,不适用SFINAE规则,因此直接导致编译错误。

正确的做法是让SFINAE依赖推导出来的模板参数。给close_all()函数增加一个模板参数R,默认为类模板参数ResourceType,并将R(而不是ResourceType)用于std::enable_if的条件判断:

1
2
3
4
5
template<class R = ResourceType>
typename std::enable_if<has_close<R>::value>::type close_all() {
    for (auto& res : resources_)
        res.close();
}

这样SmartCache<MemoryResource>就能够实例化成功,因为未发生任何替换失败——close_all()本身还是一个函数模板。只有在调用memory_cache.close_all()时才会对其进行实例化,代入R = MemoryResource导致替换失败,编译器将其从候选集中排除;而又没有其他的close_all()重载,因此报错“没有匹配的函数调用”,这是符合预期的。

https://godbolt.org/z/zncq63ccc

1
error: no matching function for call to 'SmartCache<MemoryResource>::close_all()'

在C++20中,可以分别使用概念和约束来代替has_closestd::enable_if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class T>
concept Closeable = requires(T t) {
    t.close();
};

template<class ResourceType>
class SmartCache {
    // ...

    void close_all() requires Closeable<ResourceType> {
        for (auto& res : resources_)
            res.close();
    }
};

另一种方法是使用C++17引入的constexpr if语句,语法更简洁、更易读。

1
2
3
4
5
6
void close_all() {
    if constexpr (has_close<ResourceType>::value) {
        for (auto& res : resources_)
            res.close();
    }
}

https://godbolt.org/z/M56GxMoh3

这样SmartCache对于任何资源类型都会提供close_all(),只是当资源类型不支持close()时什么都不做。

4.参考

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