第3章 概念、要求和约束
# 第3章 概念、要求和约束
本章将介绍C++的新特性——概念(concepts),以及concept
和requires
这两个关键字。该特性用于根据指定的要求来限制泛型代码的可用性。
可以肯定的是,概念是C++的一个里程碑,因为概念为编写泛型代码时的一个常见需求提供了语言特性:指定要求。尽管以前有一些解决方法,但现在我们有了一种简单易读的方式来指定泛型代码的要求,在要求不满足时能获得更好的诊断信息,在泛型代码无法正常工作(即使它可能编译通过)时禁用它,并且可以根据不同类型在不同的泛型代码之间进行切换。
# 3.1 概念和要求的示例动机
考虑下面这个返回两个值中较大值的函数模板:
template<typename T>
T maxValue(T a, T b) {
return b < a? a : b;
}
2
3
4
只要对参数执行的操作(使用operator<
进行比较和复制)是有效的,这个函数模板就可以用于两个相同类型的参数。
然而,传递两个指针时,比较的将是它们的地址,而不是它们所指向的值。
# 3.1.1 逐步改进模板
# 使用requires
子句
为了解决这个问题,我们可以给模板加上一个约束,使得在传递原始指针时它不可用:
template<typename T>
requires (!std::is_pointer_v<T>)
T maxValue(T a, T b) {
return b < a? a : b;
}
2
3
4
5
这里,约束是在requires
子句中表述的,该子句由requires
关键字引入(还有其他表述约束的方式)。
为了指定模板不能用于原始指针的约束,我们使用标准类型特性std::is_pointer_v<>
(它产生标准类型特性std::is_pointer<>
的value
成员)。有了这个约束,我们就不能再将函数模板用于原始指针了:
int x = 42; int y = 77; | |
---|---|
std::cout << maxValue(x, y) << "\n"; | // 可行:求int类型的最大值 |
std::cout << maxValue(&x, &y) << "\n"; | // 错误:约束未满足 |
这个要求是在编译时进行检查的,对编译后的代码性能没有影响。它仅仅意味着模板不能用于原始指针。当传递原始指针时,编译器的行为就好像该模板不存在一样。
# 定义和使用概念
很可能,我们不止一次需要对指针进行约束。因此,我们可以为这个约束引入一个概念:
template<typename T>
concept IsPointer = std::is_pointer_v<T>;
2
概念是一种模板,它为应用于传递的模板参数的一个或多个要求引入一个名称,这样我们就可以将这些要求用作约束。在等号后面(这里不能使用花括号),我们必须将要求指定为一个在编译时求值的布尔表达式。在这种情况下,我们要求用于实例化IsPointer<>
的模板实参必须是原始指针。
我们可以使用这个概念来约束maxValue()
模板,如下所示:
template<typename T>
requires (!IsPointer<T>)
T maxValue(T a, T b)
{
return b < a? a : b;
}
2
3
4
5
6
# 使用概念进行重载
通过使用约束和概念,我们甚至可以对maxValue()
模板进行重载,为指针和其他类型分别提供一个实现:
template<typename T>
requires (!IsPointer<T>)
T maxValue(T a, T b) // 针对非指针的maxValue()
{
return b < a? a : b; // 比较值
}
template<typename T>
requires IsPointer<T>
auto maxValue(T a, T b) // 针对指针的maxValue()
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
5
6
7
8
9
10
11
12
13
注意,仅用一个概念(或多个用&&
组合的概念)来约束模板的requires
子句不再需要括号。取反的概念始终需要括号。
现在我们有两个同名的函数模板,但对于每种类型,只有其中一个是可用的:
int x = 42; int y = 77; | |
---|---|
std::cout << maxValue(x, y) << "\n"; | // 调用针对非指针的maxValue() |
std::cout << maxValue(&x, &y) << "\n"; | // 调用针对指针的maxValue() |
因为针对指针的实现将返回值的计算委托给指针所指向的对象,所以第二次调用使用了两个maxValue()
模板。当传递指向int
的指针时,我们用T
为int*
实例化针对指针的模板,并用T
为int
实例化针对非指针的基本maxValue()
模板。
现在,这甚至可以递归工作。我们可以求指向int
的指针的指针的最大值:
int* xp = &x;
int* yp = &y;
std::cout << maxValue(&xp, &yp) << "\n"; // 调用针对int**的maxValue()
2
3
# 概念的重载决议
重载决议认为带有约束的模板比没有约束的模板更特殊。因此,仅对指针的实现进行约束就足够了:
template<typename T>
T maxValue(T a, T b) // 针对类型T的值的maxValue()
{
return b < a? a : b; // 比较值
}
template<typename T>
requires IsPointer<T>
auto maxValue(T a, T b) // 针对指针的maxValue()(优先级更高)
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
5
6
7
8
9
10
11
12
不过要注意:一次使用引用、一次使用非引用进行重载可能会导致歧义。
通过使用概念,我们甚至可以使某些约束优先于其他约束。然而,这需要使用包含其他概念的概念。
# 类型约束
如果约束是应用于一个参数的单个概念,有一些快捷方式来指定约束。首先,在声明模板参数时,你可以直接将其指定为类型约束:
template<IsPointer T> // 仅用于指针
auto maxValue(T a, T b) {
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
此外,在使用auto
声明参数时,你可以将概念用作类型约束:
auto maxValue(IsPointer auto a, IsPointer auto b)
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
这对于按引用传递的参数也同样适用:
auto maxValue3(const IsPointer auto& a, const IsPointer auto& b)
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
注意,通过直接约束两个参数,我们改变了模板的规范:我们不再要求a
和b
必须具有相同的类型。我们只要求它们都是任意类型的类似指针的对象。
当使用模板语法时,等效的代码如下所示:
template<IsPointer T1, IsPointer T2> // 仅用于指针
auto maxValue(T1 a, T2 b)
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
5
我们可能还应该允许基本的比较值的函数模板使用不同的类型。一种方法是指定两个模板参数:
template<typename T1, typename T2>
auto maxValue(T1 a, T2 b) // 针对值的maxValue()
{
return b < a? a : b; // 比较值
}
2
3
4
5
另一种选择是也使用auto
参数:
auto maxValue(auto a, auto b) // 针对值的maxValue()
{
return b < a? a : b; // 比较值
}
2
3
4
现在我们可以传递一个指向int
的指针和一个指向double
的指针。
# 后置requires
子句
考虑maxValue()
的指针版本:
auto maxValue(IsPointer auto a, IsPointer auto b)
{
return maxValue(*a, *b); // 比较指针所指向的值
}
2
3
4
这里仍然有一个不明显的隐式要求:解引用后,值必须是可比较的。
编译器在(递归)实例化maxValue()
模板时会检测到这个要求。然而,错误消息可能会有问题,因为错误出现得较晚,而且在针对指针的maxValue()
声明中看不到这个要求。
为了让指针版本在其声明中直接要求指针所指向的值必须是可比较的,我们可以给函数添加另一个约束:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires IsComparableWith<decltype(*a), decltype(*b)> {
return maxValue(*a, *b);
}
2
3
4
这里,我们使用了后置requires
子句(trailing requires clause),它可以在参数列表之后指定。它的好处是可以使用参数名,甚至可以组合多个参数名来表述约束。
# 标准概念
在上一个示例中,我们没有定义IsComparableWith
概念。我们可以使用requires
表达式(稍后介绍)来定义它;不过,我们也可以使用C++标准库中的概念。
例如,我们可以这样声明:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires std::totally_ordered_with<decltype(*a), decltype(*b)> {
return maxValue(*a, *b);
}
2
3
4
std::totally_ordered_with
概念接受两个模板参数,用于检查传入类型的值是否可以使用==
、!=
、<
、<=
、>
和>=
运算符进行比较。
标准库中有许多用于常见约束的标准概念,它们位于std
命名空间中(有时会使用子命名空间)。
例如,我们还可以使用std::three_way_comparable_with
概念,它除了要求支持上述比较运算符外,还要求支持新的<=>
运算符(该概念也因此得名)。要检查两个相同类型对象的比较支持情况,可以使用std::totally_ordered
概念。
# requires表达式
到目前为止,maxValue()
模板对于非原始指针的类似指针类型(如智能指针)不起作用。如果代码也需要为这些类型进行编译,我们最好定义指针是可以对其使用operator*
的对象。
自C++20起,这样的要求很容易指定:
template<typename T>
concept IsPointer = requires(T p) { *p; }; // 表达式 *p 必须格式正确
2
这里没有使用针对原始指针的类型特性,而是通过概念来表述一个简单的要求:对于传入类型T
的对象p
,表达式*p
必须有效。
这里我们再次使用requires
关键字来引入requires
表达式,它可以为类型和参数定义一个或多个要求。通过声明类型为T
的参数p
,我们可以简单指定这样的对象必须支持哪些操作。
我们还可以对多个操作、类型成员以及表达式产生的受限类型提出要求。例如:
template<typename T>
concept IsPointer = requires(T p) {
*p; // operator * 必须有效
p == nullptr; // 可以与nullptr比较
{p < p} -> std::same_as<bool>; // operator < 产生bool类型
};
2
3
4
5
6
我们指定了三个要求,这些要求都适用于我们为其定义此概念的类型T
的参数p
:
- 该类型必须支持
operator *
。 - 该类型必须支持
operator <
,且该运算符必须产生bool
类型。 - 该类型的对象必须可以与
nullptr
进行比较。
请注意,我们不需要两个类型为T
的参数来检查是否可以调用<
。运行时值并不重要。不过要注意,在指定表达式产生的结果类型时存在一些限制(例如,不能直接写bool
,而必须使用std::same_as<bool>
)。
还要注意,我们这里并不要求p
是等于nullptr
的指针,只要求可以将p
与nullptr
进行比较。不过,这就排除了迭代器,因为一般情况下,迭代器不能与nullptr
进行比较(除非它们碰巧被实现为原始指针,例如std::array<>
类型通常就是这种情况)。
同样,这是一个编译时约束,对生成的代码没有影响;我们只是决定代码对哪些类型进行编译。因此,将参数p
声明为值还是引用并不重要。
你也可以在requires
子句中直接使用requires
表达式作为临时约束(这看起来有点奇怪,但一旦你理解了requires
子句和requires
表达式的区别,并且知道两者都需要requires
关键字,就会觉得很合理):
template<typename T>
requires requires(T p) { *p; } // 使用临时要求约束模板
auto maxValue(T a, T b) {
return maxValue(*a, *b);
}
2
3
4
5
# 3.1.2 一个使用概念的完整示例
现在,我们已经介绍了所有必要的知识,可以来看一个使用概念的完整示例程序,该程序用于计算普通值和类似指针对象的最大值:
// lang/maxvalue.cpp
#include <iostream>
// 类似指针对象的概念:
template<typename T>
concept IsPointer = requires(T p) {
*p; // operator * 必须有效
p == nullptr; // 可以与nullptr比较
{p < p} -> std::convertible_to<bool>; // < 产生bool类型
};
// 普通值的maxValue()函数:
auto maxValue(auto a, auto b) {
return b < a ? a : b;
}
// 指针的maxValue()函数:
auto maxValue(IsPointer auto a, IsPointer auto b)
requires std::totally_ordered_with<decltype(*a), decltype(*b)> {
return maxValue(*a, *b); // 返回指针所指向值的最大值
}
int main() {
int x = 42;
int y = 77;
std::cout << maxValue(x, y) << "\n";
std::cout << maxValue(&x, &y) << "\n";
int* xp = &x;
int* yp = &y;
std::cout << maxValue(&xp, &yp) << "\n";
double d = 49.9;
std::cout << maxValue(xp, &d) << "\n";
}
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
// 两个int的最大值
// 指针所指向值的最大值
// 指针的指针的最大值
// int指针和double指针的最大值
2
3
4
请注意,我们不能使用maxValue()
来检查两个迭代器值的最大值:
std::vector coll{0, 8, 15, 11, 47};
auto pos = std::find(coll.begin(), coll.end(), 11); // 查找特定值
if (pos != coll.end()) {
// 第一个值和找到的值的最大值:
auto val = maxValue(coll.begin(), pos); // 错误
}
2
3
4
5
6
原因是我们要求参数可以与nullptr
进行比较,而迭代器并不一定需要支持这一点。这是否符合你的需求是一个设计问题。不过,这个示例表明,仔细考虑通用概念的定义非常重要。
# 3.2 约束和概念的应用场景
你可以使用requires
子句或概念来约束几乎所有形式的泛型代码:
- 函数模板:
template<typename T> requires ...
void print(const T&) {
// ...
}
2
3
4
- 类模板:
template<typename T> requires ...
class MyType {
// ...
}
2
3
4
- 别名模板
- 变量模板
- 甚至可以约束成员函数
对于这些模板,你既可以约束类型参数,也可以约束值参数。不过要注意,不能约束概念:
template<std::ranges::sized_range T> // 错误
concept IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
2
相反,你需要按如下方式进行指定:
template<typename T>
concept IsIntegralValType = std::ranges::sized_range<T> &&
std::integral<std::ranges::range_value_t<T>>;
2
3
# 3.2.1 约束别名模板
下面是一个约束别名模板(泛型using
声明)的示例:
template<std::ranges::range T>
using ValueType = std::ranges::range_value_t<T>;
2
该声明等同于:
template<typename T>
requires std::ranges::range<T>
using ValueType = std::ranges::range_value_t<T>;
2
3
此时,ValueType<>
仅为范围类型(range)定义:
ValueType<int> vt1; | 错误 |
---|---|
ValueType<std::vector<int>> vt2; | int |
ValueType<std::list<double>> vt3; | double |
# 3.2.2 约束变量模板
下面是一个约束变量模板的示例:
template<std::ranges::range T>
constexpr bool IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
2
这同样等同于:
template<typename T>
requires std::ranges::range<T>
constexpr bool IsIntegralValType = std::integral<std::ranges::range_value_t<T>>;
2
3
此时,布尔变量模板仅为范围类型定义:
bool b1 = IsIntegralValType<int>; | 错误 |
bool b2 = IsIntegralValType<std::vector<int>>; | true |
bool b3 = IsIntegralValType<std::list<double>>; | false |
# 3.2.3 约束成员函数
requires
子句也可以是成员函数声明的一部分。通过这种方式,程序员可以根据需求和概念指定不同的接口。
考虑以下示例:
// lang/valorcoll.hpp
#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';
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这里,我们定义了一个ValOrColl
类,它可以持有单个值或类型为T
的一组值。该类提供了两个print()
成员函数,并使用标准概念std::ranges::range
来决定调用哪一个:
- 如果类型
T
是一个集合,约束条件满足,因此两个print()
成员函数都可用。不过,由于第二个print()
成员函数带有约束,在重载决议时会优先选择它,该函数会遍历集合中的元素。 - 如果类型
T
不是集合,只有第一个print()
函数可用,因此会调用它。
例如,你可以这样使用该类:
// lang/valorcoll.cpp
#include "valorcoll.hpp"
#include <vector>
int main() {
ValOrColl o1 = 42;
o1.print();
ValOrColl o2 = std::vector{1, 2, 3, 4};
o2.print();
}
2
3
4
5
6
7
8
9
10
11
该程序的输出如下:
42
1 2 3 4
2
注意,你只能以这种方式约束模板。不能使用requires
来约束普通函数:
void foo() requires std::numeric_limits<char>::is_signed // 错误
{
// ...
}
2
3
4
C++标准库中约束成员函数的一个示例是const
视图(view)中begin()
函数的条件可用性。
# 3.2.4 约束非类型模板参数
可以约束的不只是类型,你还可以约束作为模板参数的值(非类型模板参数,NTTP,Non-Type Template Parameter)。例如:
template<int Val>
concept LessThan10 = Val < 10;
2
或者更通用的:
template<auto Val>
concept LessThan10 = Val < 10;
2
这个概念可以这样使用:
template<typename T, int Size> requires LessThan10<Size>
class MyType {
// ...
};
2
3
4
我们稍后会讨论更多示例。
# 3.3 概念和约束在实践中的典型应用
将需求用作约束是很有用的,原因有以下几点:
- 约束有助于我们理解模板的限制,并且在需求不满足时获得更易于理解的错误消息。
- 约束可用于在代码无意义的情况下禁用泛型代码:
- 对于某些类型,泛型代码可能能够编译,但无法正确运行。
- 我们可能需要修正重载决议,当存在多个有效选项时,重载决议决定调用哪个操作。
- 约束可用于重载或特化泛型代码,以便为不同类型编译不同的代码。
让我们通过逐步开发另一个示例来仔细研究这些原因。通过这种方式,我们还可以介绍一些关于约束、需求和概念的额外细节。
# 3.3.1 使用概念理解代码和错误消息
假设我们想要编写泛型代码,将一个对象的值插入到集合中。借助模板,我们可以将其实现为泛型代码,一旦知道传递对象的类型,就会针对这些类型进行编译:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) {
coll.push_back(val);
}
2
3
4
这段代码并非总是能编译通过。传递参数的类型存在一个隐式要求:对于类型为Coll
的容器,必须支持对类型为T
的值调用push_back()
。你也可以认为这是多个基本要求的组合:
- 类型
Coll
必须支持push_back()
。 - 必须存在从类型
T
到Coll
元素类型的转换。 - 如果传递的参数具有
Coll
的元素类型,那么该类型必须支持复制(因为新元素是使用传递的值进行初始化的)。
如果这些要求中的任何一个不满足,代码就无法编译。例如:
std::vector<int> vec;
add(vec, 42); // 正确
add(vec, "hello"); // 错误:从字符串字面量到int没有转换
std::set<int> coll;
add(coll, 42); // 错误:std::set<>不支持push_back()
std::vector<std::atomic<int>> aiVec;
std::atomic<int> ai{42};
add(aiVec, ai); // 错误:无法复制/移动原子类型
2
3
4
5
6
7
8
当编译失败时,错误消息可能会非常清晰,比如当在模板的顶层找不到push_back()
成员时:
prog.cpp: 在实例化‘void add(Coll&, const T&) [with Coll = std::debug::set<int>; T = int]’时:
prog.cpp:17:18: 从这里开始需要
prog.cpp:11:8: 错误:‘class std::set<int>’没有名为‘push_back’的成员
11 | coll.push_back(val);
| ~~~~~^~~~~~~~~
2
3
4
5
然而,泛型错误消息也可能非常难以阅读和理解。例如,当编译器处理不支持复制的需求时,问题会在std::vector<>
的实现深处被检测到。我们会得到40到90行的错误消息,需要仔细查找才能找到不满足的需求:
...
prog.cpp:11:17: 从‘void add(Coll&, const T&) [with Coll = std::vector<std::atomic<int> >; T = std::atomic<int>]’开始需要
prog.cpp:25:18: 从这里开始需要
.../include/bits/stl_construct.h:96:17: 错误:使用已删除的函数‘std::atomic<int>::atomic(const std::atomic<int>&)’
96 | -> decltype(::new((void*)0) _Tp(std::declval<_Args>()...))
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
...
2
3
4
5
6
7
你可能认为可以通过定义并使用一个概念来检查是否可以执行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);
}
2
3
4
5
6
7
8
9
现在,找不到push_back()
时的错误消息可能如下:
prog.cpp:27:4: 错误:没有匹配的函数调用‘add(std::set<int>&, int)’
27 | add(coll, 42);
| ~~~^~~~~~~~~~
prog.cpp:14:6: 附注:候选函数: ‘template<class Coll, class T> requires... ’
14 | void add(Coll& coll, const T& val)
| ^~~
prog.cpp:14:6: 附注:模板参数推导/替换失败:
prog.cpp:14:6: 附注:约束不满足
prog.cpp: 在替换‘template<class Coll, class T> requires... [with Coll = std::set<int>; T = int]’时:
prog.cpp:27:4: 从这里开始需要
prog.cpp:8:9: 为满足‘SupportsPushBack<Coll, T>’需要
[with Coll = std::set<int, std::less<int>, std::allocator<int> >; T = int]
prog.cpp:8:28: 在‘Coll c’, ‘T v’的需求中
[with T = int; Coll = std::set<int, std::less<int>, std::allocator<int> >]
prog.cpp:9:16: 附注:所需表达式‘c.push_back(v)’无效
9 | c.push_back(v);
| ~~~~~~~~~~~^~~
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
然而,当传递原子类型时,对于可复制性的检查仍然会在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);
}
2
3
4
5
这里,我们使用标准概念std::convertible_to
来要求传递参数T
的类型可以(隐式或显式地)转换为集合的元素类型。
现在,如果需求不满足,我们可以得到一个包含不满足的概念和错误位置的错误消息。例如:
...
prog.cpp:11:17: 在替换‘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: 从这里开始需要
.../include/concepts:72:13: 为满足‘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: 附注:表达式‘is_convertible_v<_From, _To> [with _From = std::atomic<int>; _To = std::atomic<int>]’计算结果为‘false’
72 | concept convertible_to = is_convertible_v<_From, _To>
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
...
2
3
4
5
6
7
8
有关检查参数约束的算法的另一个示例,请查看rangessort.cpp
。
批注: 2 这只是一个可能的错误消息示例,不一定与特定编译器的情况完全匹配。
# 3.3.2 使用概念禁用泛型代码
假设我们想为上述add()
函数模板提供一种特殊实现。在处理浮点型值时,应该有不同或额外的操作。
一种简单的方法可能是为double
类型重载函数模板:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) //用于通用值类型
{
coll.push_back(val);
}
template<typename Coll>
void add(Coll& coll, double val) //用于浮点型值类型
{
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
2
3
4
5
6
7
8
9
10
11
12
不出所料,当我们将double
作为第二个参数传递时,会调用第二个函数;否则,将使用通用参数:
std::vector<int> iVec;
add(iVec, 42); // 没问题:调用T为int的add()
std::vector<double> dVec;
add(dVec, 0.7); // 没问题:调用针对double的第二个add()
2
3
4
当传递double
类型参数时,两个函数重载都匹配。第二个重载更优先,因为它与第二个参数完全匹配。
然而,如果传递float
类型参数,会出现以下情况:
float f = 0.7;
add(dVec, f); // 糟糕:调用T为float的第一个add()
2
原因在于重载决议有时很微妙。同样,两个函数都可以被调用。重载决议有一些通用规则,例如:
- 没有类型转换的调用优先于有类型转换的调用。
- 普通函数的调用优先于函数模板的调用。
但在这里,重载决议必须在有类型转换的调用和函数模板的调用之间做出选择。根据规则,在这种情况下,带有模板参数的版本更优先。
# 修正重载决议
修正错误的重载决议非常简单。我们不应使用特定类型声明第二个参数,而应仅要求插入的值具有浮点型。为此,我们可以使用新的标准概念std::floating_point
来约束浮点型值的函数模板:
template<typename Coll, typename T> requires std::floating_point<T>
void add(Coll& coll, const T& val) {
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
2
3
4
5
因为我们使用的概念应用于单个模板参数,所以也可以使用简写表示法:
template<typename Coll, std::floating_point T> void add(Coll& coll, const T& val)
{
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
2
3
4
5
或者,我们可以使用auto
参数:
void add(auto& coll, const std::floating_point auto& val) {
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
2
3
4
现在,对于add()
函数,我们有两个可以调用的函数模板:一个没有特定要求,另一个有特定要求:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) //用于通用值类型
{
coll.push_back(val);
}
template<typename Coll, std::floating_point T>
void add(Coll& coll, const T& val) //用于浮点型值类型
{
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
2
3
4
5
6
7
8
9
10
11
12
这就足够了,因为重载决议也会优先选择具有更多约束的重载或特化版本:
std::vector<int> iVec;
add(iVec, 42); // 没问题:调用用于通用值类型的add()
std::vector<double> dVec;
add(dVec, 0.7); // 没问题:调用用于浮点型值类型的add()
2
3
4
# 不要过度区分
如果两个重载或特化具有约束条件,那么重载决议能够确定哪个更好就显得很重要。为了实现这一点,函数签名的差异不应过大。
如果签名差异过大,约束更多的重载可能不会被优先选择。例如,如果我们将浮点型值的重载声明为按值传递参数,那么传递浮点型值时就会产生歧义:
template<typename Coll, typename T>
void add(Coll& coll, const T& val) // 注意:按const引用传递
{
coll.push_back(val);
}
template<typename Coll, std::floating_point T>
void add(Coll& coll, T val) // 注意:按值传递
{
... // 针对浮点型值的特殊代码
coll.push_back(val);
}
std::vector<double> dVec;
add(dVec, 0.7); // 错误:两个模板都匹配,无法确定优先选择
2
3
4
5
6
7
8
9
10
11
12
13
14
15
后一个声明不再是前一个声明的特殊情况。我们只是有两个不同的函数模板,都可以被调用。
如果你确实想要不同的签名,就必须约束第一个函数模板,使其不适用于浮点型值。
# 缩小转换的限制
在这个例子中我们还有另一个有趣的问题:两个函数模板都允许我们将double
类型值传递给add
函数,以插入到int
类型的集合中:
std::vector<int> iVec;
add(iVec, 1.9); // 糟糕:插入1
2
原因是存在从double
到int
的隐式类型转换(这是因为与C编程语言兼容)。这种可能会丢失部分值的隐式转换称为缩小转换。这意味着上面的代码会先将1.9转换为1,然后再插入,并且能够正常编译。
如果你不想支持缩小转换,有多种选择。一种方法是通过要求传递的值类型与集合的元素类型匹配,来完全禁用类型转换:
requires std::same_as<typename Coll::value_type, T>
然而,这也会禁用有用且安全的类型转换。
因此,更好的方法是定义一个概念,用于判断一种类型是否可以在不发生缩小转换的情况下转换为另一种类型,这可以通过一个简短而巧妙的要求来实现:
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]>;
};
2
3
4
5
6
然后,我们可以使用这个概念来制定相应的约束:
template<typename Coll, typename T>
requires ConvertsWithoutNarrowing<T, typename Coll::value_type> void add(Coll& coll, const T& val)
{
...
}
2
3
4
5
# 包含约束
实际上,定义上述用于缩小转换的概念时,可能无需要求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]>;
};
2
3
4
然而,如果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 " ;
}
2
3
4
5
6
7
8
9
10
11
如果不指定ConvertsWithoutNarrowing
包含std::convertible_to
,当使用两个可以在不发生缩小转换的情况下相互转换的参数调用foo()
时,编译器会在这里引发歧义错误。
同样,概念可以包含其他概念,这意味着在重载决议中,它们被视为更特殊的情况。实际上,C++标准概念构建了一个相当复杂的包含关系图。
我们稍后将讨论包含关系的详细内容。
# 3.3.3 使用要求调用不同函数
最后,我们应该让add()
函数模板更加灵活:
- 我们可能还希望支持那些仅提供
insert()
而不是push_back()
来插入新元素的集合。 - 我们可能希望支持传递一个集合(容器或范围)来插入多个值。
是的,你可能会认为这些是不同的函数,应该使用不同的名称,如果可行的话,使用不同的名称通常会更好。然而,C++标准库就是一个很好的例子,它展示了统一不同API所能带来的好处。例如,你可以使用相同的泛型代码遍历所有容器,尽管在内部,不同容器使用非常不同的方式移动到下一个元素并访问其值。
# 使用概念调用不同函数
刚刚介绍了概念,一种“显而易见”的方法可能是引入一个概念,以确定是否支持某个特定的函数调用:
template<typename Coll, typename T>
concept SupportsPushBack = requires(Coll c, T v) { c.push_back(v); };
2
请注意,我们也可以定义一个只需要集合作为模板参数的概念:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll, Coll::value_type val) {
coll.push_back(val);
};
2
3
4
请注意,在这里使用Coll::value_type
时,我们不必使用typename
。从C++20开始,当通过上下文可以明确限定成员必须是类型时,不再需要typename
。
还有其他多种声明这个概念的方式:
- 你可以使用
std::declval<>()
来获取元素类型的值:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll) {
coll.push_back(std::declval<typename Coll::value_type&>());
};
2
3
4
在这里,你可以看到概念和要求的定义不会生成代码。这是一个未求值的上下文,我们可以使用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>>());
};
2
3
4
一般来说,当需要集合的元素类型时,使用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);
}
2
3
4
5
6
7
8
在这种情况下,我们这里不需要一个名为SupportsInsert
的要求,因为带有额外要求的add()
更特殊,这意味着重载决议会优先选择它。然而,只有少数容器支持仅用一个参数调用insert()
。为了避免与其他重载和add()
调用产生问题,我们可能最好在这里也添加一个约束。
因为我们将要求定义为一个概念,所以甚至可以将其用作模板参数的类型约束:
template<SupportsPushBack Coll, typename T> void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
2
3
4
作为一个概念,我们还可以将其用作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);
}
2
3
4
5
6
7
8
9
# 用于if constexpr
的概念
我们也可以直接在if constexpr
条件中使用SupportsPushBack
概念:
if constexpr (SupportsPushBack<decltype(coll)>) {
coll.push_back(val);
}
else {
coll.insert(val);
}
2
3
4
5
6
# 将requires
与if constexpr
结合使用
我们甚至可以跳过引入概念这一步,直接将requires
表达式作为编译期if
的条件:
if constexpr (requires { coll.push_back(val); }) {
coll.push_back(val);
}
else {
coll.insert(val);
}
2
3
4
5
6
这是在泛型代码中在两个不同函数调用之间进行切换的好方法。当引入一个概念不值得时,特别推荐使用这种方法。
# 概念与变量模板
你可能会疑惑,为什么使用概念比使用bool
类型的变量模板(就像类型特性那样)更好,比如下面这样:
template<typename T>
constexpr bool SupportsPushBack = requires(T coll) {
coll.push_back(std::declval<typename T::value_type>());
};
2
3
4
概念具有以下优点:
- 它们具有包含性。
- 可以直接在模板参数或
auto
前面用作类型约束。 - 在使用临时需求时,可以与编译期
if
一起使用。
如果你不需要这些优点,那么是更倾向于定义概念还是bool
类型的变量模板,就成了一个有趣的问题,后面会详细讨论。
# 插入单个和多个值
为了提供一个处理作为单个集合传递的多个值的重载,我们可以简单地为它们添加约束。标准概念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());
}
2
3
4
5
6
7
8
9
10
11
同样,只要重载将此作为附加约束,这些函数就会被优先选用。
概念std::ranges::input_range
是为处理范围(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));
}
2
3
4
5
6
7
8
9
10
11
这些辅助函数是函数对象,使用它们可以避免参数依赖查找(ADL,Argument-Dependent Lookup)问题。
# 处理多个约束
通过将所有有用的概念和需求结合起来,我们可以将它们放在一个函数的不同位置。
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));
}
2
3
4
5
6
为了禁用窄化转换,我们使用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));
}
2
3
4
5
6
7
8
这两种声明函数模板的方式是等效的。
# 完整示例
前面几个小节提供了很大的灵活性。所以,让我们把所有选项整合起来,给出至少一个完整的示例:
// lang/add.cpp
#include <iostream>
#include <vector>
#include <set>
#include <ranges>
#include <atomic>
// 用于带有push_back()的容器的概念:
template<typename Coll>
concept SupportsPushBack = requires(Coll coll, Coll::value_type val) {
coll.push_back(val);
};
// 用于禁用窄化转换的概念:
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():
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():
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); // 正常:对于T为int,调用push_back()
std::set<int> iSet;
add(iSet, 42); // 正常:对于T为int,调用insert()
short s = 42;
add(iVec, s); // 正常:对于T为short,调用push_back()
long long ll = 42;
//add(iVec, ll); // 错误:窄化
//add(iVec, 7.7); // 错误:窄化
std::vector<double> dVec;
add(dVec, 0.7); // 正常:对于浮点型,调用push_back()
add(dVec, 0.7f); // 正常:对于浮点型,调用push_back()
//add(dVec, 7); // 错误:窄化
// 插入集合:
add(iVec, iSet);
add(iSet, iVec);
// 正常:将集合元素插入到向量中
// 正常:将向量元素插入到集合中
// 甚至可以插入原始数组:
int vals[] = {0, 8, 18};
add(iVec, vals); // 正常
//add(dVec, vals); // 错误:窄化
}
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
如你所见,我决定在各种函数模板中,对其中一个模板参数使用概念SupportsPushBack
和if constexpr
。
# 以前的解决方案
在C++20之前,就已经可以对模板进行约束了。然而,以前的这些方法通常不太容易使用,而且存在明显的缺点。
模板替换失败不是错误(SFINAE)
在C++20之前,禁用模板可用性的主要方法是SFINAE。“SFINAE”(发音类似“斯费奈”)代表“替换失败不是错误”,意思是如果泛型代码的声明格式不正确,我们可以忽略它,而不是抛出编译期错误。
例如,为了在push_back()
和insert()
之间进行切换,在C++20之前,我们可以这样声明函数模板:
template<typename Coll, typename T>
auto add(Coll& coll, const T& val) -> decltype(coll.push_back(val)) {
return coll.push_back(val);
}
template<typename Coll, typename T>
auto add(Coll& coll, const T& val) -> decltype(coll.insert(val)) {
return coll.insert(val);
}
2
3
4
5
6
7
8
9
因为我们将对push_back()
的调用作为第一个模板声明的一部分,如果不支持push_back()
,这个模板就会被忽略。第二个模板中对insert()
的相应声明是必要的(重载解析会忽略返回类型,如果两个函数模板都可以使用,就会报错)。
然而,这是一种非常微妙的设置需求的方式,程序员很容易忽略。
std::enable_if<>
对于更复杂的禁用泛型代码的情况,从C++11开始,C++标准库提供了std::enable_if<>
。
这是一个类型特性,它接受一个布尔条件,如果条件为false
,会产生无效代码。通过在声明内部的某个位置使用std::enable_if<>
,也可以“通过SFINAE排除”泛型代码。
例如,你可以使用std::enable_if<>
类型特性,以如下方式排除浮点型调用add()
函数模板:
// 禁用针对浮点型值的模板:
template<typename Coll, typename T,
typename = std::enable_if_t<! std::is_floating_point_v<T>>>
void add(Coll& coll, const T& val)
{
coll.push_back(val);
}
2
3
4
5
6
7
技巧是插入一个额外的模板参数,以便能够使用std::enable_if<>
特性。如果该特性不禁用模板,它会产生void
(你可以指定它产生另一种类型作为可选的第二个模板参数)。
这样的代码编写和阅读起来都很困难,而且存在一些微妙的缺点。例如,为了提供具有不同约束的add()
的另一个重载,你需要再添加一个模板参数。否则,对于编译器来说,会有两个相同函数的不同重载,因为std::enable_if<>
不会改变函数签名。此外,你必须确保每次调用时只有一个不同的重载可用。
概念提供了一种可读性更强的表述约束的方式。这包括具有不同约束的模板重载,只要只有一个约束满足,就不会违反单一定义规则;甚至当一个约束包含另一个约束时,多个约束都可以满足。
# 3.4 语义约束
概念可以检查语法和语义两方面的约束:
- 语法约束指的是在编译时,我们能够检查某些功能需求是否得到满足(例如 “是否支持特定操作?” 或 “特定操作是否会产生特定类型?”)。
- 语义约束指的是某些只能在运行时检查的需求得到满足(例如 “操作是否具有相同的效果?” 或 “对特定值执行相同的操作是否总是产生相同的结果?”)。
有时,概念允许程序员通过提供一个接口来指定语义约束是否满足,从而将语义约束转换为语法约束。
# 3.4.1 语义约束示例
让我们来看一些语义约束的例子。
# std::ranges::sized_range
语义约束的一个例子是概念std::ranges::sized_range
。它保证在常量时间内计算出范围中的元素数量(可以通过调用成员函数size()
,或者计算起始位置和结束位置的差值来实现)。
如果一个范围类型提供了size()
(作为成员函数或独立函数),默认情况下这个概念就被满足。如果想要不满足这个概念(例如,因为它需要遍历所有元素来得到结果),可以而且应该将std::disable_sized_range<Rg>
设置为true
:
class MyCont {
// ...
std::size_t size() const; // 假设这个操作开销很大,所以这不是一个sized range
};
// 不满足std::ranges::sized_range概念
constexpr bool std::ranges::disable_sized_range<MyCont> = true;
2
3
4
5
6
# 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
保证不会修改传递的操作和参数的状态。
然而,我们无法通过编译器检查这两个概念之间的差异。因此,std::regular_invocable
概念只是记录了指定API的意图。通常,为了简单起见,仅使用std::invocable
。
# std::weakly_incrementable与std::incrementable
除了某些语法差异外,incrementable
和weakly_incrementable
这两个概念之间也存在语义差异:
incrementable
要求对同一值的每次递增都产生相同的结果。weakly_incrementable
仅要求类型支持递增操作符。对同一值进行递增可能会产生不同的结果。
因此:
- 当满足
incrementable
时,你可以从一个起始值多次迭代遍历一个范围。 - 当仅满足
weakly_incrementable
时,你只能对一个范围进行一次迭代。使用相同的起始值进行第二次迭代可能会产生不同的结果。
这种差异对迭代器很重要:输入流迭代器(从流中读取值的迭代器)只能迭代一次,因为下一次迭代会产生不同的值。因此,输入流迭代器满足weakly_incrementable
概念,但不满足incrementable
概念。然而,这些概念无法用于检查这种差异:
std::weakly_incrementable<std::istream_iterator<int>> | // 结果为true |
---|---|
std::incrementable<std::istream_iterator<int>> | // 糟糕:结果也为true |
原因是这种差异是一种语义约束,无法在编译时检查。因此,这些概念可用于记录约束:
template<std::weakly_incrementable T>
void algo1(T beg, T end); // 单遍算法
template<std::incrementable T>
void algo2(T beg, T end); // 多遍算法
2
3
4
5
注意,我们在这里为算法使用了不同的名称。由于我们无法检查约束的语义差异,程序员有责任不传递输入流迭代器:
algo1(std::istream_iterator<int>{std::cin}, // 正常
std::istream_iterator<int>{});
algo2(std::istream_iterator<int>{std::cin}, // 糟糕:违反约束
std::istream_iterator<int>{});
2
3
4
然而,基于这种差异,你无法区分两个实现:
template<std::weakly_incrementable T>
void algo(T beg, T end); // 单遍实现
template<std::incrementable T>
void algo(T beg, T end); // 多遍实现
2
3
4
5
如果你在这里传递一个输入流迭代器,编译器会错误地使用多遍实现:
algo(std::istream_iterator<int>{std::cin}, // 糟糕:调用了错误的重载
std::istream_iterator<int>{});
2
幸运的是,这里有一个解决方案,因为对于这种语义差异,C++98就已经引入了迭代器特性,迭代器概念会使用这些特性。如果你使用这些概念(或相应的范围概念),一切都会正常工作:
template<std::input_iterator T>
void algo(T beg, T end); // 单遍实现
template<std::forward_iterator T>
void algo(T beg, T end); // 多遍实现
algo(std::istream_iterator<int>{std::cin}, // 正常:调用了正确的重载
std::istream_iterator<int>{});
2
3
4
5
6
7
8
你应该优先使用更具体的迭代器和范围概念,它们也符合新的和略有修改的迭代器类别。
# 3.5 概念的设计准则
让我们来看看一些关于如何使用概念的准则。请注意,概念是新特性,我们仍在学习如何更好地使用它们。此外,随着对概念支持的不断改进,一些准则可能会随时间变化。
# 3.5.1 概念应分组需求
为类型的每个属性或功能引入一个概念,这种做法粒度肯定太细了。这样会导致出现过多的概念,编译器需要处理这些概念,而我们也都必须将它们指定为约束条件。
因此,概念应该提供通用和典型的方面,以区分不同类别的需求或类型。不过,也存在一些特殊情况。
C++标准库提供了一个遵循这种方法的设计示例。大多数概念用于对类型进行整体分类,如范围、迭代器、函数等。然而,为了支持包含关系并确保概念的一致性,也提供了一些基本概念(如std::movable
)。
这导致了一个相当复杂的包含关系图。描述C++标准概念的章节会相应地对概念进行分组。
# 3.5.2 谨慎定义概念
概念具有包含关系,这意味着一个概念可以是另一个概念的子集,因此在重载解析时,更受约束的概念会被优先选择。
然而,需求和约束可以用多种方式定义。对于编译器来说,要确定一组需求是否是另一组需求的子集可能并不容易。
例如,当一个针对两个模板参数的概念是可交换的(即两个参数的顺序无关紧要)时,就需要仔细设计这个概念。有关详细信息和示例,请参考关于std::same_as
概念定义的讨论。
# 3.5.3 概念与类型特性和布尔表达式
概念不仅仅是在编译时计算布尔结果的表达式。通常,你应该优先使用概念,而不是类型特性和其他编译时表达式。
不过,概念有一些优点:
- 它们具有包含性。
- 可以直接在模板参数或
auto
前面用作类型约束。 - 可以与前面介绍的编译期
if
(if constexpr
)一起使用。
# 受益于包含性
概念的主要优点是它们具有包含性。类型特性不具备这一特性。
考虑以下示例,我们使用两个定义为类型特性的需求对函数foo()
进行重载:
template<typename T, typename U>
requires std::is_same_v<T, U> // 使用特性
void foo(T, U) {
std::cout << "foo() for parameters of same type " << '\n';
}
template<typename T, typename U>
requires std::is_same_v<T, U> && std::is_integral_v<T>
void foo(T, U) {
std::cout << "foo() for integral parameters of same type " << '\n';
}
foo(1, 2); // 错误:存在歧义,两个需求都为真
2
3
4
5
6
7
8
9
10
11
12
13
问题在于,如果两个需求都计算为true
,两个重载都匹配,并且没有规则来确定其中一个优先于另一个。因此,编译器会因歧义错误而停止编译。
如果我们使用相应的概念,编译器会发现第二个需求是一个特化,并且在两个需求都满足时会优先选择它:
template<typename T, typename U>
requires std::same_as<T, U> //使用概念
void foo(T, U) {
std::cout << "foo() for parameters of same type " << '\n';
}
template<typename T, typename U>
requires std::same_as<T, U> && std::integral<T>
void foo(T, U) {
std::cout << "foo() for integral parameters of same type " << '\n';
}
foo(1, 2); // 正常:优先选择第二个foo()
2
3
4
5
6
7
8
9
10
11
12
13
# 受益于与if constexpr一起使用
C++17引入了编译期if
,它允许我们根据某些编译期条件在不同代码之间进行切换。
例如(如前面介绍的):
template<typename Coll, typename T>
void add(Coll& coll, const T& val) //针对浮点型值类型
{
if constexpr(std::is_floating_point_v<T>) {
// ... 针对浮点型值的特殊代码
}
coll.push_back(val);
}
2
3
4
5
6
7
8
当泛型代码必须为不同类型的参数提供不同的实现,但签名相同时,使用这种方法比提供重载或特化模板更具可读性。
然而,你不能使用if constexpr
来提供不同的API,以便他人稍后添加其他重载或特化,或者在某些情况下完全禁用这个模板。不过,请记住,你可以基于需求对成员函数进行约束,以启用或禁用API的某些部分。
# 3.6 补充说明
自C++98以来,C++语言设计者一直在探索如何使用概念来约束模板参数。在C++编程语言中引入概念有多种方法(例如,可查看Bjarne Stroustrup的 http://wg21.link/n1510 (opens new window))。然而,在C++20之前,C++标准委员会一直未能就一种合适的机制达成一致。
在C++11工作草案中,甚至采用了一种非常丰富的概念方法,但后来由于过于复杂而被放弃。之后,基于http://wg21.link/n3351 (opens new window) ,Andrew Sutton、Bjarne Stroustrup和Gabriel Dos Reis在 http://wg21.link/n3580 (opens new window) 中提出了一种名为Concepts Lite的新方法。因此,从 http://wg21.link/n4549 (opens new window) 开始,开启了一个概念技术规范。
随着时间的推移,特别是根据范围库的实现经验,进行了各种改进。
http://wg21.link/p0724r0 (opens new window) 提议将概念技术规范应用于C++20工作草案。最终被接受的措辞由Andrew Sutton在 http://wg21.link/p0734r0 (opens new window) 中制定。
之后,又提出并接受了各种修正和改进。最明显的一项是按照 http://wg21.link/p1754r1 (opens new window) 的提议,将标准概念的名称改为 “标准格式”(仅包含小写字母和下划线)。