概念是C++ 的一个里程碑,因为概念为编写泛型代码时,需要提供了一种语言特性: 指定需求。虽然有一些变通方法,但现在有了一种简单易读的方法来指定泛型代码的需求,在需求不满足时可得到更好的判断,在泛型代码不起作用时禁用(即使可编译),并可在不同类型的泛型代码之间进行切换。
1. 概念和需求的动机
下面的函数模板,返回两个值中的最大值:
template<typename T>
T maxValue(T a, T b)
{
return b < a ? a : b;
}
此函数模板可以为具有相同类型的两个实参调用,前提是对形参执行的操作(使用小于操作符
进行比较,并进行复制) 有效。
当传递的是两个指针时,将比较其地址,而不是所引用的值。
1.1 使用requires 子句
为了解决这个问题,可以为模板配置一个约束,这样在传递原始指针时就不可用了:
template<typename T>
requires (!std::is_pointer_v<T>)
T maxValue(T a, T b)
{
return b < a ? a : b;
}
约束是在requires 子句中表述的,该子句是通过关键字requires 引入的(还有其他方式可用来表
述约束)。
为了指定模板不能用于原始指针的约束,可以使用标准类型特征std::is_pointer_v<>(生成标准
类型特征std::is_pointer<> 的值成员)[类型特征是在C++11 中作为标准类型函数引入的,C++17 中
引入了以_v 后缀的使用方式]。有了这个约束,就不能再为原始指针使用函数模板了:
int x = 42;
int y = 77;
std::cout << maxValue(x, y) << std::endl; //OK: maximum value of ints
std::cout << maxValue(&x, &y) << std::endl; //ERROR: constraint not met
该要求是编译时检查,对编译后代码的性能没有影响。所以模板不能用于原始指针,当传递原
始指针时,编译器的行为就好像模板不存在一样。
1.2 定义和使用概念
可能要不止一次地需要指针约束,所以可以为约束引入一个概念。
template<typename T>
concept IsPointer = std::is_pointer_v<T>;
这里IsPointer是概念名,std::is_pointer_v<T>称为约束表达式。约束表达式应该是一个bool类型的纯右值常量表达式,当实参替换形参后,如果表达式计算结果为true,那么该实参满足约束条件,概念的计算结果为true。反之,在实参替换形参后,如果表达式计算结果为false或者替换结果不合法,则该实参无法满足约束条件,概念的计算结果为false。
概念是一个模板,应用于传递的模板参数的一个或多个需求引入名称,以便可以将这些需求用
作约束。在等号之后(这里不能使用大括号),必须将需求指定为在编译时求值的布尔表达式,则要
求用于特化IsPointer<> 的模板实参必须是裸指针。
可以使用这个概念来约束maxValue() 模板:
template<typename T>
requires (!IsPointer<T>)
T maxValue(T a, T b)
{
return b < a ? a : b;
}
1.3 重载概念
通过使用约束和概念,甚至可以重载maxValue() 模板,为指针和其他类型分别提供实现:
template<typename T>
requires (!IsPointer<T>)
T maxValue(T a, T b) // maxValue() for non-pointers
{
return b < a ? a : b; // compare values
}
template<typename T>
requires IsPointer<T>
auto maxValue(T a, T b) // maxValue() for pointers
{
return maxValue(*a, *b); // compare values the pointers point to
}
注意,仅用一个概念(或多个概念与&& 组合) 约束模板的requires 子句不再需要括号,否定的
概念总是需要括号。
现在有了两个同名的函数模板,但每种类型只能使用其中一个:
int x = 42;
int y = 77;
std::cout << maxValue(x, y) << std::endl; //calls maxValue() for non-pointers
std::cout << maxValue(&x, &y) << std::endl; //calls maxValue() for pointers
指针的实现将返回值的计算委托给指针所引用的对象,所以第二次调用同时使用了maxValue()
模板。将指针传递给int 时,实例化T 为int* 的指针模板,并将T 为int 的非指针基本模板maxValue()。
现在可以进行递归了,可以请求指向int 类型指针的指针的最大值:
int* xp = &x;
int* yp = &y;
std::cout << maxValue(&xp, &yp) << '\n'; // calls maxValue() for int**
1.4 使用概念解析的重载
重载解析认为有约束的模板比没有约束的模板更特化,所以只约束对指针实现就够了:
template<typename T>
T maxValue(T a, T b) // maxValue() for a value of type T
{
return b < a ? a : b; // compare values
}
template<typename T>
requires IsPointer<T>
auto maxValue(T a, T b) // maxValue() for pointers (higher priority)
{
return maxValue(*a, *b); // compare values the pointers point to
}
重载使用引用和非引用就可能出现歧义,所以要谨慎。
使用概念,甚至可以使用某些约束,但这需要使用包含其他概念的概念。
1.5 类型约束
若约束是应用于参数的单个概念,则有几种方法可以简化约束的说明。可以在声明模板形参时,
直接将其指定为类型约束:
template<IsPointer T> // only for pointers
auto maxValue(T a, T b) //a和b类型必须一样
{
return maxValue(*a, *b); // compare values the pointers point to
}
使用auto 声明参数时,可以将该概念用作类型约束:
auto maxValue(IsPointer auto a, IsPointer auto b)//a和b的类型不要求一样
{
return maxValue(*a, *b); // compare values the pointers point to
}
这也适用于通过引用传递的参数:
auto maxValue3(const IsPointer auto& a, const IsPointer auto& b)
{
return maxValue(*a, *b); // compare values the pointers point to
}
通过直接约束这两个形参,改变了模板的规范: 不再要求a 和b 必须具有相同的类型,只要求
两者都是类指针对象就好。
当使用模板语法时,等效代码如下所示:
template<IsPointer T1, IsPointer T2> // only for pointers
auto maxValue(T1 a, T2 b)
{
return maxValue(*a, *b); // compare values the pointers point to
}
还应该允许比较值的基本函数模板使用不同的类型。一种方法是指定两个模板参数:
template<typename T1, typename T2>
auto maxValue(T1 a, T2 b) // maxValue() for values
{
return b < a ? a : b; // compare values
}
另一种选择是使用auto 参数:
auto maxValue(auto a, auto b) // maxValue() for values
{
return b < a ? a : b; // compare values
}
现在可以传递一个指向整型类型的指针和一个指向双精度类型的指针。
1.6 尾接requires子句
maxValue()的指针版本:
auto maxValue(IsPointer auto a, IsPointer auto b)
{
return maxValue(*a, *b); // compare values the pointers point to
}
还有一个不明显的隐含要求: 解引用之后,值必须可比较。
编译器在(递归地) 实例化maxValue() 模板时检测到该需求。错误消息有一个问题,因为错误发生较晚,并且在指针的maxValue() 声明中该要求是不可见的。
为了让指针版本在声明中直接要求指针所指向的值必须具有可比性,可以在函数中添加另一个
约束:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires IsComparableWith<decltype(*a), decltype(*b)>
{
return maxValue(*a, *b);
}
使用后面的requires 子句,可以在参数列表之后指定。好处是可以使用一个参数的名称,甚至
可以组合多个参数名称来制定约束。
1.7 标准概念
前面的例子中,没有定义IsComparableWith 这个概念。可以使用require 表达式(稍后会介绍),
也可以使用C++ 标准库的概念:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires std::totally_ordered_with<decltype(*a), decltype(*b)>
{
return maxValue(*a, *b);
}
概念std::totally_ordered_with 接受两个模板形参,用于检查传递的类型的值是否支持可比较操作符==、!=、<、<=、> 和>=。
标准库为常见约束提供了许多标准概念,在命名空间std 中提供(有时使用子命名空间)。
还可以使用概念std::three_way_comparable_with,这要求支持操作符<=>(为概念提供名称)。要检查是否支持对相同类型的两个对象进行比较,可以使用std::totally_ordered 这个概念。
1.8 requires 表达式
maxValue() 模板不适用于非原始指针的类指针类型,例如:智能指针。若代码也要为这些类型
编译,最好将指针定义为可以调用解引用操作符的对象。
C++20 中,就很容易指定:
template<typename T>
concept IsPointer = requires(T p) { *p; }; // expression *p has to be well-formed
这个概念没有对原始指针使用类型特性,而是提出了一个简单的要求: 表达式*p 必须对类型T
的对象p 有效。
再使用requires 关键字来引入一个requires 表达式,其可以定义类型和参数的一个或多个需求。
通过声明类型为T 的形参p,就可以指定必须支持这种对象的哪些操作。
还可以要求多个操作、类型成员以及表达式产生约束类型。例如:
template<typename T>
concept IsPointer = requires(T p) {
*p; // operator * has to be valid
p == nullptr; // can compare with nullptr
{p < p} -> std::same_as<bool>; // operator < yields bool
};
这里指定了三个要求,都适用于定义这个概念的类型,为T 的参数p:
• 该类型必须支持解引用操作符。
• 该类型必须支持小于操作符,该操作符必须产生bool 类型。
• 该类型的对象必须与nullptr 比较。
不需要两个T 类型的形参来检查是否可以调用小于操作符,运行时值无关紧要,但对于如何指
定表达式产生的结果有一些限制(例如,不能只指定bool 而,不指定std::same_as<>)。
这里不要求p 是一个等于nullptr 的指针,只要求可以将p 与nullptr 进行比较。这就排除了迭
代器,其不能与nullptr 进行比较(除非碰巧实现为原始指针,例如std::array<> 类型通常就是这种情
况)。
这是一个编译时约束,对生成的代码没有影响,只决定代码编译哪种类型,所以将形参p 声明
为值还是引用就无关紧要了。
也可以直接在requires 子句中使用requires 表达式作为临时约束(这看起来有点滑稽,但当理解了requires 子句和requires 表达式之间的区别,并且两者都需要关键字requires,就有意义了):
template<typename T>
requires requires(T p) { *p; } // constrain template with ad-hoc requirement
auto maxValue(T a, T b)
{
return maxValue(*a, *b);
}
使用概念的完整例子
已经介绍了所有必要的内容,再来看一个完整的示例程序,用于计算普通值和指针类对象的最
大值:
#include <iostream>
// concept for pointer-like objects:
template<typename T>
concept IsPointer = requires(T p) {
*p; // operator * has to be valid
p == nullptr; // can compare with nullptr
{p < p} -> std::convertible_to<bool>; // < yields bool
};
// maxValue() for plain values:
auto maxValue(auto a, auto b)
{
return b < a ? a : b;
}
// maxValue() for pointers:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires std::totally_ordered_with<decltype(*a), decltype(*b)>
{
return maxValue(*a, *b); // return maximum value of where the pointers refer to
}
int main()
{
int x = 42;
int y = 77;
std::cout << maxValue(x, y) << '\n'; // maximum value of ints
std::cout << maxValue(&x, &y) << '\n'; // maximum value of where the pointers point to
int* xp = &x;
int* yp = &y;
std::cout << maxValue(&xp, &yp) << '\n'; // maximum value of pointer to pointer
double d = 49.9;
std::cout << maxValue(xp, &d) << '\n'; // maximum value of int and double pointer
}
不能使用maxValue() 来检查两个迭代器值的最大值:
std::vector coll{0, 8, 15, 11, 47};
auto pos = std::find(coll.begin(), coll.end(), 11); // find specific value
if (pos != coll.end()) {
// maximum of first and found value:
auto val = maxValue(coll.begin(), pos); // ERROR
}
原因是要求形参与nullptr 可比较,而迭代器不需要支持nullptr。这是否是一个设计问题?所以
对于概念的定义非常重要。
2 使用约束和概念
可以使用requires 子句或概念来约束几乎所有形式的泛型代码:
• 函数模板:
template<typename T>
requires ...
void print(const T&) {
...
}
• 类模板:
template<typename T>
requires ...
class MyType {
...
}
• 别名模板
• 变量模板
• (甚至可以约束) 成员函数
对于这些模板,可以约束类型和值参数。
但这里不能约束概念:
template<std::ranges::sized_range T> // ERROR
concept IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
必须这样指定:
template<typename T>
concept IsIntegralValType = std::ranges::sized_range<T> &&
std::integral<std::ranges::range_value_t<T>>;
2.1 约束别名模板
下面是一个约束别名模板的例子(使用声明的泛型):
template<std::ranges::range T>
using ValueType = std::ranges::range_value_t<T>;
该声明等价于:
template<typename T>
requires std::ranges::range<T>
using ValueType = std::ranges::range_value_t<T>;
类型ValueType<> 现在只可对范围类型进行定义:
ValueType<int> vt1; // ERROR
ValueType<std::vector<int>> vt2; // int
ValueType<std::list<double>> vt3; // double
2.2 约束变量模板
下面是一个约束变量模板的例子:
template<std::ranges::range T>
constexpr bool IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
这相当于:
template<typename T>
requires std::ranges::range<T>
constexpr bool IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
类型IsIntegralValType<> 现在只可在范围中定义:
bool b1 = IsIntegralValType<int>; // ERROR
bool b2 = IsIntegralValType<std::vector<int>>; // true
bool b3 = IsIntegralValType<std::list<double>>; // false
2.3 约束成员函数
requires 子句也可以是成员函数声明的一部分,开发者就可以根据需求和概念指定不同的API。
#include <iostream>
#include <ranges>
template<typename T>
class ValOrColl {
T value;
public:
ValOrColl(const T& val)
: value{val} {
}
ValOrColl(T&& val)
: value{std::move(val)}
{
}
void print() const
{
std::cout << value << '\n';
}
void print() const requires std::ranges::range<T>
{
for (const auto& elem : value)
{
std::cout << elem << ' ';
}
std::cout << '\n';
}
};
定义了一个类ValOrColl,可以保存一个值或一个值的集合,作为T 类型的值。提供了两个print()
成员函数,类使用标准概念std::ranges::range 来决定使用哪一个:
• 若类型T 是一个集合,则满足约束,所以两个print() 成员函数都可用。但重载解析首选第二
个print(),因为该成员函数有约束,将遍历集合的元素。
• 若类型T 不是集合,则只有第一个print() 可用,因此可以使用。
例如,可以这样使用这个类:
#include "valorcoll.hpp"
#include <vector>
int main()
{
ValOrColl o1 = 42;
o1.print();
ValOrColl o2 = std::vector{1, 2, 3, 4};
o2.print();
}
该程序应有以下输出:
42
1 2 3 4
这种方式只能约束模板,不能使用requires 来约束普通函数:
void foo() requires std::numeric_limits<char>::is_signed // ERROR
{
...
}
C++ 标准库中约束成员函数的一个例子是,const 视图的begin() 的条件可用性。
2.4 约束非类型模板参数
可以约束的不仅仅是类型,还可以约束作为模板参数的值(非类型模板参数(NTTP))。例如:
template<int Val>
concept LessThan10 = Val < 10;
或者更通用的情况:
template<auto Val>
concept LessThan10 = Val < 10;
可以这样使用:
template<typename T, int Size>
requires LessThan10<Size>
class MyType {
...
};
3 概念和约束在实践中的应用
使用需求作为约束可能有有很多原因:
• 约束帮助我们理解模板上的限制,并在需求破坏时获得更容易理解的错误消息。
• 约束可以用于在代码没有意义的情况下禁用泛型代码:
– 对于某些类型,泛型代码可能可以编译,但不能做正确的事情。
– 可能必须修复重载解析,若有多个有效选项,重载解析将决定使用哪个操作。
• 约束可用于重载或特化泛型代码,以便针对不同的类型编译不同的代码。
3.1 理解代码和错误消息
假设要编写将对象的值插入到集合中的泛型代码,可以将其实现为泛型代码。当明确了传递的对象类型,就会对其进行编译:
template<typename Coll, typename T>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
这段代码并不总是可以编译。对于传递的参数的类型有一个隐含的要求: 对于类型为Coll 的容
器,必须支持对类型为T 的值的push_back()。
也可以认为这是多个基本需求的组合:
• 类型Coll 必须支持push_back()。
• 必须进行从类型T 到Coll 元素类型的转换。
• 若传递的实参具有元素类型Coll,则该类型必须支持复制(使用传递值初始化)。
若违反这些要求中的一个,代码将无法编译。例如:
std::vector<int> vec;
add(vec, 42); // OK
add(vec, "hello"); // ERROR: no conversion from string literal to int
std::set<int> coll;
add(coll, 42); // ERROR: no push_back() supported by std::set<>
std::vector<std::atomic<int>> aiVec;
std::atomic<int> ai{42};
add(aiVec, ai); // ERROR: cannot copy/move atomics
当编译失败时,错误消息会非常清楚,例如:模板的参数类型没有找到成员push_back() 时:
prog.cpp: In instantiation of ’void add(Coll&, const T&)
[with Coll = std::__debug::set<int>; T = int]’:
prog.cpp:17:18: required from here
prog.cpp:11:8: error: ’class std::set<int>’ has no member named ’push_back’
11 | coll.push_back(val);
| ~~~~~^~~~~~~~~
然而,一般的错误消息也很难阅读和理解。例如,当编译器必须处理不支持复制的需求时,问
题就会在std::vector<> 实现的深处出现。会看到得到40 到90 行错误信息,从而必须仔细寻找违反
的需求:
...
prog.cpp:11:17: required from ’void add(Coll&, const T&)
[with Coll = std::vector<std::atomic<int> >; T =
,→ std::atomic<int>]’
prog.cpp:25:18: required from here
.../include/bits/stl_construct.h:96:17:
error: use of deleted function
’std::atomic<int>::atomic(const std::atomic<int>&)’
96 | -> decltype(::new((void*)0) _Tp(std::declval<_Args>()...))
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
读者们可能认为可以通过定义和使用一个检查,确定是否可以执行push_back() 调用的概念来
改善这种情况:
template<typename Coll, typename T>
concept SupportsPushBack = requires(Coll c, T v) {
c.push_back(v);
};
template<typename Coll, typename T>
requires SupportsPushBack<Coll, T>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
没有找到push_back() 的错误信息现在可能如下所示:
prog.cpp:27:4: error: no matching function for call to ’add(std::set<int>&, int)’
27 | add(coll, 42);
| ~~~^~~~~~~~~~
prog.cpp:14:6: note: candidate: ’template<class Coll, class T> requires ...’
14 | void add(Coll& coll, const T& val)
| ^~~
prog.cpp:14:6: note: template argument deduction/substitution failed:
prog.cpp:14:6: note: constraints not satisfied
prog.cpp: In substitution of ’template<class Coll, class T> requires ...
[with Coll = std::set<int>; T = int]’:
prog.cpp:27:4: required from here
prog.cpp:8:9: required for the satisfaction of ’SupportsPushBack<Coll, T>’
[with Coll = std::set<int, std::less<int>, std::allocator<int> >; T
,→ = int]
prog.cpp:8:28: in requirements with ’Coll c’, ’T v’
[with T = int; Coll = std::set<int, std::less<int>,
,→ std::allocator<int> >]
prog.cpp:9:16: note: the required expression ’c.push_back(v)’ is invalid
9 | c.push_back(v);
在传递原子类型时,仍然会在std::vector<> 的代码深处检测可复制性检查(这一次是在检查概
念时,而不是在编译代码时)。
当指定使用push_back() 的基本约束作为要求时,情况会有所改善:
template<typename Coll, typename T>
requires std::convertible_to<T, typename Coll::value_type>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
使用标准概念std::convertible_to 来要求,(隐式或显式) 将传递的实参T 的类型转换为集合的元
素类型。
若违背需求,就会得到一条错误消息,其中包含违反的概念和位置。例如[这只是一种可能的
消息例子,不一定与特定编译器的情况相匹配]:
...
prog.cpp:11:17: In substitution of ’template<class Coll, class T>
requires convertible_to<T, typename Coll::value_type>
void add(Coll&, const T&)
[with Coll = std::vector<std::atomic<int> >; T = std::atomic<int>]’:
prog.cpp:25:18: required from here
.../include/concepts:72:13: required for the satisfaction of
’convertible_to<T, typename Coll::value_type>
[with T = std::atomic<int>;
Coll = std::vector<std::atomic<int>,
std::allocator<std::atomic<int> > >]’
.../include/concepts:72:30: note: the expression ’is_convertible_v<_From, _To>
[with _From = std::atomic<int>; _To = std::atomic<int>]’
evaluated to ’false’
72 | concept convertible_to = is_convertible_v<_From, _To>
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
...
3.2 使用概念禁用泛型代码
假设为上面介绍的add() 函数模板提供一个特殊的实现。在处理浮点值时,应该发生一些不同
的事。
一种简单的方法可能是为double 重载函数模板:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) // for generic value types
{
coll.push_back(val);
}
template<typename Coll>
void add(Coll& coll, double val) // for floating-point value types
{
... // special code for floating-point values
coll.push_back(val);
}
当传递一个double 类型的参数作为第二个参数时,调用第二个函数; 否则,使用泛型参数:
std::vector<int> iVec;
add(iVec, 42); // OK: calls add() for T being int
std::vector<double> dVec;
add(dVec, 0.7); // OK: calls 2nd add() for double
当传递double 类型时,两个函数重载匹配。首选第二个重载,因为其与第二个参数完美匹配。
若传递一个浮点数,可以有以下效果:
float f = 0.7;
add(dVec, f); // OOPS: calls 1st add() for T being float
原因在于重载解析的一些微妙细节,这两个函数都可以调用。重载解析有一些通用规则,例如:
• 没有类型转换的调用优先于具有类型转换的调用。
• 调用普通函数优于调用函数模板。
重载解析必须在调用类型转换和调用函数模板之间做出决定。按照规则,优先选择带有模板参
数的版本。
3.2.1 修复重载解析
错误的重载解析的修复非常简单。不需要用特定类型声明第二个形参,只需要求要插入的值为
浮点类型即可,可以使用新的标准概念std::floating_point 来约束浮点值的函数模板:
template<typename Coll, typename T>
requires std::floating_point<T>
void add(Coll& coll, const T& val)
{
... // special code for floating-point values
coll.push_back(val);
}
因为使用的概念只适用于单个模板形参,所以可以使用速记表示法:
template<typename Coll, std::floating_point T>
void add(Coll& coll, const T& val)
{
... // special code for floating-point values
coll.push_back(val);
}
或者,使用auto 参数:
void add(auto& coll, const std::floating_point auto& val)
{
... // special code for floating-point values
coll.push_back(val);
}
对于add(),现在有两个可以调用的函数模板: 一个不带特定需求,另一个带特定需求:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) // for generic value types
{
coll.push_back(val);
}
template<typename Coll, std::floating_point T>
void add(Coll& coll, const T& val) // for floating-point value types
{
... // special code for floating-point values
coll.push_back(val);
}
这就足够了,因为重载解析也更喜欢有约束的重载或特化,而不是那些约束较少或没有约束的
重载或特化:
std::vector<int> iVec;
add(iVec, 42); // OK: calls add() for generic value types
std::vector<double> dVec;
add(dVec, 0.7); // OK: calls add() for floating-point types
3.2.2 非必须,则相同
若两个重载或特化都有约束,重载解析可以决定哪一个更好,这一点很重要。为了支持这一点,
签名不应有太大的差异。
若签名差异太大,则可能不适用更受约束的重载。例如,声明浮点值的重载以按值接受实参,
则传递浮点值会产生二义性:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) // note: pass by const reference
{
coll.push_back(val);
}
template<typename Coll, std::floating_point T>
void add(Coll& coll, T val) // note: pass by value
{
... // special code for floating-point values
coll.push_back(val);
}
std::vector<double> dVec;
add(dVec, 0.7); // ERROR: both templates match and no preference
后一项声明不再是前一项声明的特例,只有两个不同的函数模板都可以使用。
若确实希望使用不同的签名,则必须约束第一个函数模板不能用于浮点值。
3.2.3 窄化限制
这个例子中还有一个有趣的问题: 两个函数模板都允许我们传递一个double 类型的值,将其添
加到int 类型的集合中:
std::vector<int> iVec;
add(iVec, 1.9); // OOPS: add 1
原因是有从double 到int 的隐式类型转换(由于与编程语言C 兼容),这种可能丢失部分值的隐
式转换称为数据窄化。所以上面的代码在插入之前编译,会将值1.9 转换为1。
若不希望数据窄化,有多个选项。一种选择是通过要求传递的值类型与集合的元素类型匹配,
完全禁用类型转换:
requires std::same_as<typename Coll::value_type, T>
但这也会禁用有用和安全的类型转换。
出于这个原因,最好定义一个概念来确定一个类型是否可以在不缩小的情况下转换为另一个类型,这在一个简短的需求中是可能的[详细请见:http://wg21.link/p0870 中介绍检查窄化转换的技巧]:
template<typename From, typename To>
concept ConvertsWithoutNarrowing =
std::convertible_to<From, To> &&
requires (From&& x) {
{ std::type_identity_t<To[]>{std::forward<From>(x)} }
-> std::same_as<To[1]>;
};
可以使用这个概念来制定相应的约束:
template<typename Coll, typename T>
requires ConvertsWithoutNarrowing<T, typename Coll::value_type>
void add(Coll& coll, const T& val)
{
...
}
代码示例:
#include <iostream>
#include <cstdint>
template<typename From, typename To>
concept ConvertsWithoutNarrowing =
std::convertible_to<From, To> &&
requires (From&& x) {
{ std::type_identity_t<To[]>{std::forward<From>(x)} }
-> std::same_as<To[1]>;
};
template <typename T>
struct my_optional
{
template <typename U = T>
my_optional(U&& u) requires ConvertsWithoutNarrowing<U, T>
{ }
};
int main(void)
{
//int value{3}; // ERROR
std::uint8_t value = 3;
my_optional<std::uint8_t> optional_int{value};
return 0;
}
在上述代码中,如果value是int类型的,则编译时将会报错,禁止{}初始化的窄向转换。
包含约束
定义上面的窄化转换的概念可能就足够了,而不需要std::convertible_to 概念,剩下的部分会进
行隐式检查
template<typename From, typename To>
concept ConvertsWithoutNarrowing = requires (From&& x) {
{
std::type_identity_t<To[]>{std::forward<From>(x)} } -> std::same_as<To[1]>;
};
若ConvertsWithoutNarrowing 概念也检查std::convertible_to 概念, 编译器可以检测到
ConvertsWithoutNarrowing 比std::convertible_to 更受约束。术语是ConvertsWithoutNarrowing 包含std::convertible_to。这允许开发者做以下事情:
template<typename F, typename T>
requires std::convertible_to<F, T>
void foo(F, T)
{
std::cout << "may be narrowing\n";
}
template<typename F, typename T>
requires ConvertsWithoutNarrowing<F, T>
void foo(F, T)
{
std::cout << "without narrowing\n";
}
若没有指定ConvertsWithoutNarrowing 包含std::convertible_to,编译器在调用带有两个参数的foo() 时将引发歧义错误,这两个参数相互转换而不进行窄化。
以同样的方式,概念可以包含其他概念,多以更特化地用于重载解析。事实上,C++ 标准概念构建了一个相当复杂的包含图。
3.3 使用需求调用不同的函数
最后,应该让add() 函数模板更灵活:
• 支持只提供insert() 而不是push_back() 来插入新元素的集合。
• 支持传递一个集合(容器或范围) 来插入多个值。
这些是不同的函数,应该有不同的名字,通常使用不同的名字会更好。C++ 标准库是一个很好
的例子,说明如果协调不同的API。例如,可以使用相同的泛型代码遍历所有容器,尽管在内部,容
器使用非常不同的方式转到下一个元素并访问其值。
3.3.1 使用概念调用不同的函数
刚刚介绍了概念,“显而易见”的方法可能是引入一个概念来找出是否支持某个函数调用:
template<typename Coll, typename T>
concept SupportsPushBack = requires(Coll c, T v) {
c.push_back(v);
};
也可以定义一个只需要集合作为模板形参的概念:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll, Coll::value_type val) {
coll.push_back(val);
};
不必在这里使用typename 来使用Coll::value_type。从C++20 开始,当上下文明确限定成员必
须是类型时,不再需要typename。
还有其他方式来声明这个概念:
• 可以使用std::declval<>() 来获取元素类型值:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll) {
coll.push_back(std::declval<typename Coll::value_type&>());
};
可以看到概念和需求的定义并没有创建代码,是一个未求值的上下文,可以使用std::declval<>()
来表示“假设有一个这种类型的对象”,无论将coll 声明为值,还是非const 引用都无关紧要。
这里的& 很重要。若没有&,只需要使用移动语义插入一个右值(比如一个临时对象)。对于
&,我们创建了一个左值,因此需要push_back() 进行复制。
• 可以使用std::ranges::range_value_t 来代替value_type 的成员:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll) {
coll.push_back(std::declval<std::ranges::range_value_t<Coll>>());
};
当需要集合的元素类型时,使用std::ranges::range_value_t<> 使代码更具泛型(例如,也适用于
原始数组)。因为这里需要成员push_back(),所以也需要成员的value_type。
使用一个参数的SupportPushBack 概念,这里可以提供两个实现:
template<typename Coll, typename T>
requires SupportsPushBack<Coll>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
template<typename Coll, typename T>
void add(Coll& coll, const T& val)
{
coll.insert(val);
}
这里不需要命名需求SupportsInsert,因为带有附加需求的add() 更特殊,所以重载解析更偏向
它。但只有少数容器支持只使用一个参数调用insert(),为了避免其他重载和add() 调用的问题,最
好在这里也有一个约束。
这里将需求定义为一个概念,甚至可以将它用作模板形参的类型约束:
template<SupportsPushBack Coll, typename T>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
作为一个概念,也可以将其用作auto 作为参数类型的类型约束:
void add(SupportsPushBack auto& coll, const auto& val)
{
coll.push_back(val);
}
template<typename Coll, typename T>
void add(auto& coll, const auto& val)
{
coll.insert(val);
}
3.3.2 if constexpr 的概念
也可以在if constexpr 条件中直接使用SupportsPushBack 这个概念:
if constexpr (SupportsPushBack<decltype(coll)>)
{
coll.push_back(val);
}
else
{
coll.insert(val);
}
组合requires 和if constexpr
甚至可以跳过引入的概念,直接将requires 表达式作为条件传递给编译时if:
if constexpr (requires { coll.push_back(val); })
{
coll.push_back(val);
}
else
{
coll.insert(val);
}
这是在泛型代码中切换两个不同函数调用的好方法。当引入一个不值得的概念时,建议这样做。
3.3.3 概念与变量模板
为什么使用概念比使用bool 类型的变量模板更好(就像类型特性一样),比如:
template<typename T>
constexpr bool SupportsPushBack = requires(T coll)
{
coll.push_back(std::declval<typename T::value_type>());
};
概念有以下好处:
• 可包含。
• 可以直接用作模板参数或auto 前面的类型约束。
• 若使用特殊需求,可以与编译时一起使用。
若不需要这些,选择概念定义还是bool 类型的变量模板的问题就变得有趣了,稍后将详细讨论
这个问题。
3.3.4 插入单个和多个值
为了提供处理作为一个集合传递的多个值的重载, 可以简单地添加约束。标准概念
std::ranges::input_range 可用于此:
template<SupportsPushBack Coll, std::ranges::input_range T>
void add(Coll& coll, const T& val)
{
coll.insert(coll.end(), val.begin(), val.end());
}
template<typename Coll, std::ranges::input_range T>
void add(Coll& coll, const T& val)
{
coll.insert(val.begin(), val.end());
}
同样,只要重载将此作为附加约束,这些函数将是首选。
概念std::ranges::input_range 是一个用于处理范围的概念,范围是可以使用begin() 和end() 迭代
的集合。但范围不需要将begin() 和end() 作为成员函数,所以处理范围的代码应该使用范围库提供
的std::ranges::begin() 和std::ranges::end():
template<SupportsPushBack Coll, std::ranges::input_range T>
void add(Coll& coll, const T& val)
{
coll.insert(coll.end(), std::ranges::begin(val), std::ranges::end(val));
}
template<typename Coll, std::ranges::input_range T>
void add(Coll& coll, const T& val)
{
coll.insert(std::ranges::begin(val), std::ranges::end(val));
}
这些辅助程序是函数对象,因此使用它们可以避免ADL 问题。
3.3.5 处理多重约束
通过汇集所有有用的概念和需求,可以将它们放在不同位置的函数中。
template<SupportsPushBack Coll, std::ranges::input_range T>
requires ConvertsWithoutNarrowing<std::ranges::range_value_t<T>,
typename Coll::value_type>
void add(Coll& coll, const T& val)
{
coll.insert(coll.end(), std::ranges::begin(val), std::ranges::end(val));
}
要禁用窄化转换, 可以使用std::ranges::range_value_t 将范围的元素类型传递给
ConvertsWithoutNarrowing。std::ranges::range_value_t 是另一个范围工具, 用于在迭代范围时
获取元素的类型。
也可以在requires 从句中将它们组合在一起:
template<typename Coll, typename T>
requires SupportsPushBack<Coll> &&
std::ranges::input_range<T> &&
ConvertsWithoutNarrowing<std::ranges::range_value_t<T>,
typename Coll::value_type>
void add(Coll& coll, const T& val)
{
coll.insert(coll.end(), std::ranges::begin(val), std::ranges::end(val));
}
声明函数模板的两种方式等价。
3.3.6 完整的例子
前面的小节提供了很大的灵活性,现在把所有的选项放在一起,就有一个完整的例子:
#include <iostream>
#include <vector>
#include <set>
#include <ranges>
#include <atomic>
// concept for container with push_back():
template<typename Coll>
concept SupportsPushBack = requires(Coll coll, Coll::value_type val) {
coll.push_back(val);
};
// concept to disable narrowing conversions:
template<typename From, typename To>
concept ConvertsWithoutNarrowing =
std::convertible_to<From, To> &&
requires (From&& x)
{
{
std::type_identity_t<To[]>{std::forward<From>(x)
}
} -> std::same_as<To[1]>;
};
// add() for single value:
template<typename Coll, typename T>
requires ConvertsWithoutNarrowing<T, typename Coll::value_type>
void add(Coll& coll, const T& val)
{
if constexpr (SupportsPushBack<Coll>)
{
coll.push_back(val);
}
else
{
coll.insert(val);
}
}
// add() for multiple values:
template<typename Coll, std::ranges::input_range T>
requires ConvertsWithoutNarrowing<std::ranges::range_value_t<T>,
typename Coll::value_type>
void add(Coll& coll, const T& val)
{
if constexpr (SupportsPushBack<Coll>)
{
coll.insert(coll.end(),
std::ranges::begin(val), std::ranges::end(val));
}
else
{
coll.insert(std::ranges::begin(val), std::ranges::end(val));
}
}
int main()
{
std::vector<int> iVec;
add(iVec, 42); // OK: calls push_back() for T being int
std::set<int> iSet;
add(iSet, 42); // OK: calls insert() for T being int
short s = 42;
add(iVec, s); // OK: calls push_back() for T being short
long long ll = 42;
// add(iVec, ll); // ERROR: narrowing
// add(iVec, 7.7); // ERROR: narrowing
std::vector<double> dVec;
add(dVec, 0.7); // OK: calls push_back() for floating-point types
add(dVec, 0.7f); // OK: calls push_back() for floating-point types
// add(dVec, 7); // ERROR: narrowing
// insert collections:
add(iVec, iSet); // OK: insert set elements into a vector
add(iSet, iVec); // OK: insert vector elements into a set
// can even insert raw array:
int vals[] = {0, 8, 18};
add(iVec, vals); // OK
// add(dVec, vals); // ERROR: narrowing
}
3.4. 语义约束
概念可同时检查语法和语义约束:
• 语法约束在编译时,可以检查是否满足某些功能需求(“是否支持特定的操作?”或“特定操
作是否产生特定类型?”)。
• 语义约束满足了某些只能在运行时检查的需求(“操作是否具有相同的效果?”或“对特定值
执行相同的操作是否总是产生相同的结果?”)。
有时,概念允许开发者通过接口来指定是否满足语义约束,从而将语义约束转换为语法约束。
来看一些语义约束的例子。
std::ranges::sized_range
语义约束的第一个例子是概念std::ranges::sized_range,其保证可以在常量时间内计算一个范围内的元素数量(通过成员函数size() 或计算开始和结束之间的差值)。
若范围类型提供size()(作为成员函数或独立函数),则默认情况下满足此概念。要从这个概念中
退出(例如,迭代所有元素以产生结果),可以将std::disable_size_range<Rg> 设置为true:
class MyCont {
...
std::size_t size() const; // assume this is expensive, so that this is not a sized range
};
// opt out from concept std::ranges::sized_range:
constexpr bool std::ranges::disable_sized_range<MyCont> = true;
std::ranges::range 与std::ranges::view
语义约束的一个示例是概念std::ranges::view。除了一些语法约束外,还保证移动构造函数/赋
值、复制构造函数/赋值(如可用) 和析构函数具有恒定的复杂性(所花费的时间不取决于元素的数
量)。
实现者可以通过公开地从std::ranges::view_base 或std::ranges::view_interface<> 派生,或者通过
将模板特化std::ranges::enable_view<Rg> 设置为true 来提供相应的保证。
std::invocable 和std::regular_invocable
语义约束的简单示例是std::invocable 和std::regular_invocable 概念之间的区别,后者保证不修
改传递的操作和传递的参数的状态。
但不能用编译器检查这两个概念之间的区别,所以std::regular_invocable 这个概念记录了指定
API 的意图。简单起见,这里只使用了std::invocable。
std::weakly_incrementable 和std::incrementable
incrementable 和weak_incrementable 这两个概念除了在语法上有一定的区别外,在语义上也有
区别:
• incrementable 要求相同值的每次递增都产生相同的结果。
• weakly_incrementable 只要求类型支持自增操作符,增加相同的值,可能会产生不同的结果。
因此:
• 当满足incrementable 时,可以从一个起始值在一个范围内迭代多次。
• 当仅满足weakly_incrementable 条件时,只能在一个范围内迭代一次。具有相同起始值的第二
次迭代可能产生不同的结果。
这种差异对迭代器很重要: 输入流迭代器(从流中读取值的迭代器) 只能迭代一次,因为下一次
迭代产生不同的值,所以输入流迭代器满足weakly_incrementable 概念,但不满足可递增概念,但
这些概念不能用于检查这种差异。
4 约束
要指定对泛型参数的需求,需要约束,这些约束在编译时用于决定是否实例化和编译模板。
可以约束函数模板、类模板、变量模板和别名模板。
普通的约束通常使用requires 子句来指定。例如:
template<typename T>
void foo(const T& arg)
requires MyConcept<T>
...
在模板参数或auto 前面,也可以直接使用概念作为类型约束:
template<MyConcept T>
void foo(const T& arg)
...
或者
void foo(const MyConcept auto& arg)
...
4.1. 需求项
requires 子句使用关键字requires 和编译时布尔表达式来限制模板的可用性。布尔表达式可以
是:
• 编译时的布尔表达式
• 概念
• requires 表达式
可以使用布尔表达式的地方都可以使用有约束(特别是以if constexpr 作为条件的)。
4.1.1 requires 子句中使用&& 和||
要在requires 子句中组合多个约束,可以使用运算符&&。例如:
template<typename T>
requires (sizeof(T) > 4) // ad-hoc Boolean expression
&& requires { typename T::value_type; } // requires expression
&& std::input_iterator<T> // concept
void foo(T x) {
...
}
约束顺序无所谓。
还可以使用运算符|| 表示“可选”约束。例如:
template<typename T>
requires std::integral<T> || std::floating_point<T>
T power(T b, T p);
很少需要指定可选约束,也不应该随意指定,在requires 子句中过度使用运算符|| 可能会增加
编译资源的负担(使编译明显变慢)。
单个约束还可以涉及多个模板参数,约束可以在多个类型(或值) 之间施加影响。例如:
template<typename T, typename U>
requires std::convertible_to<T, U>
auto f(T x, U y) {
...
}
操作符&& 和|| 是唯一可以用来组合多个约束而不必使用括号的操作符。对于其他所有内容,
使用括号(正式地将一个特殊的布尔表达式传递给requires 子句)。
4.2. 特别的布尔表达式
为模板制定约束的基本方法是使用requires 子句:requires 后跟一个布尔表达式。requires 之后,
约束可以使用编译时布尔表达式,而不仅是概念或requires 表达式。这些表达可以使用:
• 类型谓词,如类型特征
• 编译时变量(用constexpr 或constinit 定义)
• 编译时函数(用constexpr 或consteval 定义)
来看一些使用特殊布尔表达式来限制模板可用性的例子:
• 当int 和long 的大小不同时:
template<typename T>
requires (sizeof(int) != sizeof(long))
...
• 仅当sizeof(T) 不是太大时:
template<typename T>
requires (sizeof(T) <= 64)
...
•仅当非类型模板参数Sz 大于零时:
template<typename T, std::size_t Sz>
requires (Sz > 0)
...
• 仅对裸指针和nullptr 时:
template<typename T>
requires (std::is_pointer_v<T> || std::same_as<T, std::nullptr_t>)
...
std::same_as 是一个新的标准概念,也可以使用标准类型特性std::is_same_v<>:
template<typename T>
requires (std::is_pointer_v<T> || std::is_same_v<T, std::nullptr_t>)
...
• 当参数不能用作字符串时:
template<typename T>
requires (!std::convertible_to<T, std::string>)
...
std::convertible_to 是一个新的标准概念,也可以使用标准类型特性std::is_convertible_v<>:
template<typename T>
requires (!std::is_convertible_v<T, std::string>)
...
• 仅当参数是指向整型值的指针(或类指针对象) 时可用:
template<typename T>
requires std::integral<std::remove_reference_t<decltype(*std::declval<T>())>>
...
解引用操作符通常产生一个非整型的引用,所以:
– 假设有一个类型为T 的对象:std::declare<T>()
– 为对象调用解引用操作符:*
– 获取其类型:decltype()
– 删除引用:std::remove _reference_v<>
– 检查是否为整型:std::integral<>
约束也可以通过std::optional<int> 进行。
std::integral 是一个新的标准概念,也可以使用标准类型特征:std::is_integral_v<>.
• 当非类型模板参数Min 和Max 的最大公约数(GCD) 大于1 时:
template<typename T>
constexpr bool gcd(T a, T b); // greatest common divisor (forward declaration)
template<typename T, int Min, int Max>
requires (gcd(Min, Max) > 1) // available if there is a GCD greater than 1
• 暂时禁用模板:
template<typename T>
requires false // disable the template
...
在一些特殊情况下,需要在整个表达式或部分表达式周围加上括号。若只使用标识符,可以将::
和<…> 与&& 和|| 组合使用。例如:
requires std::convertible_to<T, int> // no parentheses needed here
&&
(!std::convertible_to<int, T>) // ! forces the need for parentheses
4.3. 需求表达式
需求表达式(不同于requires 子句) 提供了一种简单而灵活的语法,用于在一个或多个模板参数
上指定多个需求:
• 必需的类型定义
• 表达式必须有效
• 对表达式产生类型的要求
表达式以requires 开头,后跟一个可选的参数列表,然后是一个需求块(都以分号结束)。例如:
template<typename Coll>
... requires {
typename Coll::value_type::first_type; // elements/values have first_type
typename Coll::value_type::second_type; // elements/values have second_type
}
可选参数列表允许引入一组“虚拟变量”,可用于在表达式的主体中表达需求:
template<typename T>
... requires(T x, T y) {
x + y; // supports +
x - y; // supports -
}
这些形参永远不会由实参取代,所以通过值或引用声明都可以。
参数还允许引入子类型(参数):
template<typename Coll>
... requires(Coll::value_type v) {
std::cout << v; // supports output operator
}
要求检查Coll::value_type 是否有效,以及该类型的对象是否支持输出操作符。
此参数列表中的类型成员不必用typename 限定。
当使用此方法检查Coll::value_type 是否有效时,在需求块的主体中不需要任何内容,但不能为
空,所以可以简单地使用true:
template<typename Coll>
... requires(Coll::value_type v) {
true; // dummy requirement because the block cannot be empty
}
4.3.1 简单的需求
简单的需求就是必须格式良好的表达式,必须进行编译。调用不会执行,从而操作产生的结果
并不重要。
template<typename T1, typename T2>
... requires(T1 val, T2 p)
{
*p; // operator* has to be supported for T2
p[0]; // operator[] has to be supported for int as index
p->value(); // calling a member function value() without arguments has to be possible
*p > val; // support the comparison of the result of operator* with T1
p == nullptr; // support the comparison of a T2 with a nullptr
}
最后一个调用不要求p 是nullptr(要做到这一点,必须检查T2 是否是类型std::nullptr_t)。相反,
可以要求将T2 类型的对象与nullptr 类型的对象进行比较。
通常使用运算符|| 没什么意义。一个简单的需求,例如
*p > val || p == nullptr;
不要求左子表达式或右子表达式可能成立,其规定了可以用运算符|| 组合两个子表达式结果的
要求。
要满足这两个子表达式中的一个,必须使用:
template<typename T1, typename T2>
... requires(T1 val, T2 p) {
*p > val; // support the comparison of the result of operator* with T1
}
|| requires(T2 p) { // OR
p == nullptr; // support the comparison of a T2 with nullptr
}
注意,这个概念并不要求T 是整型:
template<typename T>
... requires {
std::integral<T>; // OOPS: does not require T to be integral
...
};
概念只要求表达式std::integral<T> 有效,这是所有类型的情况。T 是整型的要求必须这样表述:
template<typename T>
... std::integral<T> && // OK, does require T to be integral
requires {
...
};
或者
template<typename T>
... requires {
requires std::integral<T>; // OK, does require T to be integral
...
};
4.3.2 类型的需求
类型需求是在使用类型名称时必须格式良好的表达式,所以名称必须定义为有效类型。
template<typename T1, typename T2>
... requires {
typename T1::value_type; // type member value_type required for T1
typename std::ranges::iterator_t<T1>; // iterator type required for T1
typename std::common_type_t<T1, T2>; // T1 and T2 have to have a common type
}
对于所有类型需求,若类型存在但为空,则满足需求。
只能检查给定类型的名称(类名、枚举类型的名称,来自typedef 或using),不能使用类型检查
其他类型声明:
template<typename T>
... requires {
typename int; // ERROR: invalid type requirement
typename T&; // ERROR: invalid type requirement
}
测试后者(typename T&)的方法是声明相应的形参:
template<typename T>
... requires(T&) {
true; // some dummy requirement
};
同样,需求检查使用传递的类型来定义另一个类型是否有效:
template<std::integral T>
class MyType1 {
...
};
template<typename T>
requires requires {
typename MyType1<T>; // instantiation of MyType1 for T would be valid
}
void mytype1(T) {
...
}
mytype1(42); // OK
mytype1(7.7); // ERROR
因此,以下需求不检查类型T 是否存在标准哈希函数:
template<typename T>
concept StdHash = requires {
typename std::hash<T>; // does not check whether std::hash<> is defined for T
};
需要标准哈希函数的方法是尝试创建或使用:
template<typename T>
concept StdHash = requires {
std::hash<T>{}; // OK, checks whether we can create a standard hash function for T
};
注意,简单的需求只检查需求是否有效,而不检查需求是否满足。出于这个原因:
• 使用总是产生值的类型函数没有意义:
template<typename T>
... requires {
std::is_const_v<T>; // not useful: always valid (doesn’t matter what it yields)
}
要检查const 性,需要使用:
template<typename T>
... std::is_const_v<T> // ensure that T is const
requires 表达式中,可以使用嵌套的需求(见下文)。
• 使用产生类型的类型函数没有意义:
template<typename T>
... requires {
typename std::remove_const_t<T>; // not useful: always valid (yields a type)
}
需求只检查类型表达式是否产生类型,这总是正确的。
使用可能具有未定义行为的类型函数也没有意义。类型特性std::make_unsigned<> 要求传
递的参数是整型,而非bool 型。若传递的类型不是整型,则有未定义行为,所以不应该使用
std::make_unsigned<> 作为需求,而不限制调用其类型:
template<typename T>
... requires {
std::make_unsigned<T>::type; // not useful as type requirement (valid or undefined behavior)
}
这种情况下,需求只能满足或导致未定义行为(这可能需求仍然满足)。相反,应该约束可以使
用嵌套需求的类型T:
template<typename T>
... requires {
requires (std::integral<T> && !std::same_as<T, bool>);
std::make_unsigned<T>::type; // OK
}
4.3.3 复合需求
复合需求允许将简单需求和类型需求结合起来,可以指定一个表达式(大括号内),然后添加以
下一个或两个:
• noexcept 要求表达式保证不抛出异常
• -> type-constraint 将概念应用于表达式的求值
下面是一些例子:
template<typename T>
... requires(T x) {
{ &x } -> std::input_or_output_iterator;
{ x == x };
{ x == x } -> std::convertible_to<bool>;
{ x == x }noexcept;
{ x == x }noexcept -> std::convertible_to<bool>;
}
-> 之后的类型约束将结果类型作为其第一个模板参数:
• 第一个需求中,需求在对类型为T 的对象使用运算符& 时满足std::input_or_output_iterator 的
概念(std::input_or_output_iterator<decltype(&x)> 产生true)。
也可以这样指定:
{ &x } -> std::is_pointer_v<>;
• 最后一个需求中,需求可以将两个T 类型对象的== 操作符的结果用作bool(当将两个T 类型
对象和bool 类型对象的== 操作符的结果作为参数传递时,满足std::convertible_to 的概念)。
需求表达式还可以表达对关联类型的需求:
template<typename T>
... requires(T coll) {
{ *coll.begin() } -> std::convertible_to<T::value_type>;
}
但不能使用嵌套类型指定类型需求,例如:不能使用它们来要求使用解引用操作符的返回值产
生整数值。其中的问题是,返回值是一个引用,必须先解引用:
std::integral<std::remove_reference_t<T>>
而且不能在requires 表达式的结果中,使用带有类型特性的嵌套表达式:
template<typename T>
concept Check = requires(T p) {
{ *p } -> std::integral<std::remove_reference_t<>>; // ERROR
{ *p } -> std::integral<std::remove_reference_t>; // ERROR
};
要么先定义相应的概念:
template<typename T>
concept UnrefIntegral = std::integral<std::remove_reference_t<T>>;
template<typename T>
concept Check = requires(T p) {
{ *p } -> UnrefIntegral; // OK
};
要么使用嵌套需求。
4.3.4 嵌套需求
嵌套需求可用于在requires 表达式中指定附加约束。以requires 开头,后跟一个编译时布尔表
达式,该表达式本身也可能是或使用requires 表达式。嵌套需求的好处是,可以确保编译时表达式
(使用所需表达式的参数或子表达式) 产生特定的结果,而不是仅仅确保表达式有效。
例如,考虑一个概念,必须确保对于给定类型,解引用和[](下标) 操作符产生相同的类型。通
过使用嵌套需求,可以这样指定:
template<typename T>
concept DerefAndIndexMatch = requires (T p)
{
requires std::same_as<decltype(*p), decltype(p[0])>;
};
好的方面是,对于“假设有一个T 类型的对象”,这里有一个简单的语法,不必在这里使用
requires 表达式,但代码必须使用std::declval<>():
template<typename T>
concept DerefAndIndexMatch = std::same_as<decltype(*std::declval<T>()),
decltype(std::declval<T>()[0])>;
另一个例子,可以使用嵌套需求来解决刚才,在表达式上指定复杂类型需求的问题:
template<typename T>
concept Check = requires(T p) {
requires std::integral<std::remove_cvref_t<decltype(*p)>>;
};
请注意requires 表达式中的区别:
template<typename T>
... requires {
!std::is_const_v<T>; // OOPS: checks whether we can call is_const_v<>
requires !std::is_const_v<T>; // OK: checks whether T is not const
}
这里,使用的类型特性是is_const_v<> 带/不带requires,而只有第二个需求有用:
• 第一个表达式只要求检查const 性并对结果取反有效,这个需求总是可满足(即使T 是const
int)。因为做这个检查总是有效的,所以这个需求毫无价值。
• 第二个需求表达式必须满足。若T 是int 则满足要求,但若T 是const int 则不满足。
4.4. 概念详解
通过定义概念,可以为一个或多个约束引入名称。
模板(函数、类、变量和别名模板) 可以使用概念来约束其能力(通过requires 子句或作为模板
参数的直接类型约束),但概念也是布尔编译时表达式(类型谓词),可以在需要检查类型的地方使用
(if constexpr 条件中)。
4.4.1 定义概念
概念定义如下:
template< ... >
concept name = ... ;
等号是必需的(不能在没有定义的情况下声明一个概念,也不能在这里使用大括号)。等号之后,
可以指定可转换为true 或false 的编译时表达式。
概念很像bool 类型的constexpr 变量模板,但没有显式指定类型:
template<typename T>
concept MyConcept = ... ;
std::is_same<MyConcept< ... >, bool> // yields true
无论在编译时还是在运行时,都可以在需要布尔表达式值的地方使用一个概念。但不接受地址,
因为其后面没有对象(地址是一个纯右值)。
模板参数可能没有约束(不能使用概念来定义概念)。
不能在函数中定义概念(所有模板都是如此)。
4.4.2 概念的特殊能力
概念具有特殊的能力。
例如,考虑以下概念:
template<typename T>
concept IsOrHasThisOrThat = ... ;
与布尔变量模板的定义(定义类型特征的常用方式) 相比:
template<typename T>
inline constexpr bool IsOrHasThisOrThat = ... ;
有以下不同:
• 概念并不表示代码,没有类型、存储、生命周期或与对象相关的其他属性。
通过在编译时为特定模板参数实例化它们,实例化只会变为true 或false,所以可以在使用true
或false 的地方使用,可以得到这些字面量的所有属性。
• 概念不必声明为内联的,其隐式内联。
• 概念可以用作类型约束:
template<IsOrHasThisOrThat T>
...
变量模板不能这样使用。
• 概念是给约束命名的唯一方法,所以需要其来决定一个约束是否是另一个约束的特殊情况。
• 包含的概念。为了让编译器决定一个约束是否决定另一个约束(因此是特殊的),必须将约束
公式化为概念。
4.4.3 非类型模板参数的概念
概念也可以应用于非类型模板参数(NTTP)。例如:
template<auto Val>
concept LessThan10 = Val < 10;
template<int Val>
requires LessThan10<Val>
class MyType {
...
};
作为一个可用的例子,可以使用一个概念将非类型模板形参的值约束为2 的幂:
#include <bit>
template<auto Val>
concept PowerOf2 = std::has_single_bit(static_cast<unsigned>(Val));
template<typename T, auto Val>
requires PowerOf2<Val>
class Memory {
...
};
int main()
{
Memory<int, 8> m1; // OK
Memory<int, 9> m2; // ERROR
Memory<int, 32> m3; // OK
Memory<int, true> m4; // OK
...
}
概念PowerOf2 接受一个值而不是类型作为模板参数(这里使用auto,不需要指定类型):
template<auto Val>
concept PowerOf2 = std::has_single_bit(static_cast<unsigned>(Val));
当新的标准函数std::has_single_bit() 对传递的值产生true(只设置一个位意味着值是2 的幂) 时,
这个概念就满足了,std::has_single_bit() 要求我们有一个无符号整型值。通过强制转换为unsigned,
开发者可以传递有符号整型值,并拒绝不能转换为无符号整型值的类型。
接下来,使用这个概念要求Memory 类只接受2 的幂次大小和类型:
template<typename T, auto Val>
requires PowerOf2<Val>
class Memory {
...
};
注意,不能这样写:
template<typename T, PowerOf2 auto Val>
class Memory {
...
};
这就对Val 类型提出了要求,但概念PowerOf2 不约束其类型,并限制了值。
4.5. 使用概念作为类型约束
如前所述,概念可以用作类型约束。可以在不同的地方使用类型约束:
• 模板类型参数的声明中
• 用auto 声明的调用参数的声明中
• 作为复合需求中的一个需求
例如:
template<std::integral T> // type constraint for a template parameter
class MyClass {
...
};
auto myFunc(const std::integral auto& val) { // type constraint for an auto parameter
...
};
template<typename T>
concept MyConcept = requires(T x) {
{ x + x } -> std::integral; // type constraint for return type
};
使用一元约束,对表达式返回的单个参数或类型调用一元约束。
具有多参数的类型约束
也可以使用带有多个参数的约束,将参数类型或返回值用作第一个参数:
template<std::convertible_to<int> T> // conversion to int required
class MyClass {
...
};
auto myFunc(const std::convertible_to<int> auto& val) { // conversion to int required
...
};
template<typename T>
concept MyConcept = requires(T x) {
{ x + x } -> std::convertible_to<int>; // conversion to int required
};
另一个经常使用的例子是约束可调用对象(函数、函数对象、Lambda) 的类型,可以使用
std::invocable 或std::regular_invocable 概念传递一定数量的特定类型的参数,例如:要求传递一个接
受int 和std::string 的操作,则必须声明:
template<std::invocable<int, std::string> Callable>
void call(Callable op);
or:
void call(std::invocable<int, std::string> auto op);
std::invocable 和std::regular_invocable 的区别在于后者保证不修改传递的操作和参数。这是一
种语义上的差异,只有助于记录意图,所以只使用std::invocable。
类型约束和auto
类型约束可以在所有可以使用auto 的地方使用,该特性的主要应用是对用auto 声明的函数参
数使用类型约束。例如:
void foo(const std::integral auto& val)
{
...
}
也可以对auto 使用类型约束:
• 约束声明:
std::floating_point auto val1 = f(); // valid if f() yields floating-point value
for (const std::integral auto& elem : coll) { // valid if elements are integral values
...
}
• 约束返回类型:
std::copyable auto foo(auto) { // valid if foo() returns copyable value
...
}
• 约束非类型模板参数:
template<typename T, std::integral auto Max>
class SizedColl {
...
};
也适用于接受多个参数的概念:
template<typename T, std::convertible_to<T> auto DefaultValue>
class MyType {
...
};
另一个例子,请参阅对Lambda 作为非类型模板参数的支持。
4.6. 用概念包含约束
两个概念可以有一个包含关系,可以指定一个概念,使其限制一个或多个其他概念。这样做的
好处是,当两个约束都得到满足时,重载解析更倾向于使用约束较多的泛型代码,而不是使用约束
较少的泛型代码。
例如,假设引入以下两个概念:
template<typename T>
concept GeoObject = requires(T obj) {
{ obj.width() } -> std::integral;
{ obj.height() } -> std::integral;
obj.draw();
};
template<typename T>
concept ColoredGeoObject =
GeoObject<T> && // subsumes concept GeoObject
requires(T obj) { // additional constraints
obj.setColor(Color{});
{ obj.getColor() } -> std::convertible_to<Color>;
};
因为它显式地规定了类型T 也必须满足概念GeoObject 的约束,所以概念ColoredGeoObject 显
式地包含了概念GeoObject。
当为两个概念重载模板并且两个概念都满足时,不会得到歧义错误,重载解析更倾向于包含其
他概念的概念:
template<GeoObject T>
void process(T) // called for objects that do not provide setColor() and getColor()
{
...
}
template<ColoredGeoObject T>
void process(T) // called for objects that provide setColor() and getColor()
{
...
}
约束包含仅在使用概念时起作用。当一个概念/约束比另一个更特殊时,不存在自动包含。
约束和概念不会仅仅基于需求进行包含:
// declared in a header file:
template<typename T>
concept GeoObject = requires(T obj) {
obj.draw();
};
// declared in another header file:
template<typename T>
concept Cowboy = requires(T obj) {
obj.draw();
obj = obj;
};
假设为GeoObject 和Cowboy 重载函数模板:
template<GeoObject T>
void print(T) {
...
}
template<Cowboy T>
void print(T) {
...
}
对于Circle 或Rectangle(都有draw() 成员函数),我们不希望这样,因为Cowboy 的概念更特殊,
对print() 的调用更倾向于对Cowboy 的print() 调用,所以希望看到在本例中有两个可能的print() 函
数发生冲突。
检查假设的工作只针对概念进行评估。若没有使用概念,则具有不同约束的重载不明确:
template<typename T>
requires std::is_convertible_v<T, int>
void print(T) {
...
}
template<typename T>
requires (std::is_convertible_v<T, int> && sizeof(int) >= 4)
void print(T) {
...
}
print(42); // ERROR: ambiguous (if both constraints are true)
当使用概念时,下面的代码可以工作:
template<typename T>
requires std::convertible_to<T, int>
void print(T) {
...
}
template<typename T>
requires (std::convertible_to<T, int> && sizeof(int) >= 4)
void print(T) {
...
}
print(42); // OK
产生这种行为的一个原因是,处理概念之间的依赖关系需要更多编译的时间。
C++ 标准库提供的概念经过精心设计,以便在有意义时包含其他概念。事实上,标准概念构建
了一个相当复杂的包含图。例如:
• std::random_access_range 包含了std::bidirectional_range,两者都包含了std::forward_range,三
者都包含了std::input_range,所以都包含了std::range。
但是,std::sized_range 只包含std::range,而不包含其他的概念。
• std::regular 包含了std::semiregular,而两者都包含了std::copyable 和std::default_initializable(其
中包含了其他几个概念,如std::movable、std::copy_constructible 和std::destructible)。
• std::sortable 包含std::permutable,两者都包含std::indirectly_swappable,这两个参数都是相同
的类型。
5. 约束检查顺序
如下代码:
template <class C>
concept ConstType = std::is_const_v<C>;
template <class C>
concept IntegralType = std::is_integral_v<C>;
template <ConstType T>
requires std::is_pointer_v<T>
void foo(IntegralType auto) requires std::is_same_v<T, char *const>
{}
上面的代码分别使用概念ConstType、模板形参列表尾部
requires std:: is_pointer_v <T>和函数模板声明尾部
requires std::is_ integral_v<T>来约束模板实参,还使用
概念IntegralType约束了auto占位符类型的函数形参。对于函数
模板调用:
foo<int>(1.5);
编译器究竟应该用什么顺序检查约束条件呢?事实上,标准文档
给出了明确的答案,编译器应该按照以下顺序检查各个约束条件。
1.模板形参列表中的形参的约束表达式,其中检查顺序就是形参
出现的顺序。也就是说使用concept定义的概念约束的形参会被优先
检查,放到刚刚的例子中foo<int>();最先不符合的是ConstType
的约束表达式std::is_const_v<C>。
2.模板形参列表之后的requires子句中的约束表达式。这意味
着,如果foo的模板实参通过了前一个约束检查后将会面临
std::is_pointer_v<T>的检查。
3.简写函数模板声明中每个拥有受约束auto占位符类型的形参
所引入的约束表达式。还是放到例子中看,如果前两个约束条件已经
满足,编译器则会检查函数实参是否满足IntegralType的约束。
4.函数模板声明尾部requires子句中的约束表达式。所以例子
中最后检查的是std::is_same_v<T, char * const>。
为了更好地理解约束的检查顺序,让我们来分别编译以下5句代
码,看一看编译器输出日志(以GCC为例):
foo<int>(1.5);
foo<const int>(1.5);
foo<int * const>(1.5);
foo<int * const>(1);
foo<char * const>(1);
对于foo<int>(1.5);,不满足所有约束条件,编译器报错
提示不满足ConstType<T>的约束。
对于foo<const int>(1.5);,满足ConstType<T>,但
是不满足其他条件,编译器报错提示不满足
std::is_pointer_v<T>的约束。
对于foo<int * const>(1.5);,满足前两个条件,但是
不满足其他条件,编译器报错提示不满足IntegralType<auto>的
约束。
对于foo<int * const>(1);,满足前3个条件,但是不满
足其他条件,编译器报错提示不满足std::is_same_v<T, char *
const>的约束。
foo<char * const>(1);满足所有条件,编译成功。