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为整数类型(例如bool、char、int、long等)。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_iterator和std::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; };
如果B为true,则将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>::value为false。而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_close和std::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()时什么都不做。