第4章 概念、需求和约束详解
# 第4章 概念、需求和约束详解
本章将讨论概念、需求和约束的一些细节。此外,下一章将列出并讨论C++20标准库提供的所有标准概念。
# 4.1 约束
为了给泛型参数指定需求,你需要用到约束。约束在编译时用于决定是否实例化和编译一个模板。
你可以对函数模板、类模板、变量模板和别名模板进行约束。普通约束通常通过requires
子句来指定。例如:
template<typename T>
void foo(const T& arg) requires MyConcept<T>
...
2
3
在模板参数或auto
前面,你也可以直接使用概念作为类型约束:
template<MyConcept T>
void foo(const T& arg)
...
2
3
或者:
void foo(const MyConcept auto& arg)
...
2
# 4.2 requires子句
requires
子句使用requires
关键字和一个编译期布尔表达式来限制模板的可用性。这个布尔表达式可以是:
- 一个临时的编译期布尔表达式
- 一个概念
- 一个
requires
表达式
所有约束也可以用在任何可以使用布尔表达式的地方(特别是作为if constexpr
的条件)。
# 4.2.1 在requires子句中使用&&和||
为了在requires
子句中组合多个约束,我们可以使用&&
运算符。例如:
template<typename T>
requires (sizeof(T) > 4) // 临时布尔表达式
&& requires { typename T::value_type; } // requires表达式
&& std::input_iterator<T> // 概念
void foo(T x) {
// ...
}
2
3
4
5
6
7
约束的顺序并不重要。
我们也可以使用||
运算符来表达“可选”约束。例如:
template<typename T>
requires std::integral<T> || std::floating_point<T>
T power(T b, T p);
2
3
指定可选约束很少有必要,并且不应随意使用,因为在requires
子句中过度使用||
运算符可能会消耗编译资源(即显著降低编译速度)。
单个约束也可以涉及多个模板参数。通过这种方式,约束可以对多个类型(或值)之间的关系进行限制。例如:
template<typename T, typename U>
requires std::convertible_to<T, U>
auto f(T x, U y) {
// ...
}
2
3
4
5
&&
和||
是仅有的两个可以在不使用括号的情况下组合多个约束的运算符。对于其他情况,需要使用括号(从形式上看,这是将一个临时布尔表达式传递给requires
子句)。
# 4.3 临时布尔表达式
为模板制定约束的基本方法是使用requires
子句:requires
后面跟着一个布尔表达式。在requires
之后,约束可以使用任何编译期布尔表达式,而不仅仅是概念或requires
表达式。这些表达式尤其可以使用:
- 类型谓词,如类型特性
- 编译期变量(用
constexpr
或constinit
定义) - 编译期函数(用
constexpr
或consteval
定义)
让我们来看一些使用临时布尔表达式限制模板可用性的例子:
- 仅当
int
和long
的大小不同时可用:
template<typename T>
requires (sizeof(int) != sizeof(long))
// ...
2
3
- 仅当
sizeof(T)
不太大时可用:
template<typename T>
requires (sizeof(T) <= 64)
// ...
2
3
- 仅当非类型模板参数
Sz
大于零时可用:
template<typename T, std::size_t Sz>
requires (Sz > 0)
// ...
2
3
- 仅对原始指针和
nullptr
可用:
template<typename T>
requires (std::is_pointer_v<T> || std::same_as<T, std::nullptr_t>)
// ...
2
3
std::same_as
是一个新的标准概念。你也可以使用标准类型特性std::is_same_v<>
:
template<typename T>
requires (std::is_pointer_v<T> || std::is_same_v<T, std::nullptr_t>)
// ...
2
3
- 仅当参数不能用作字符串时可用:
template<typename T>
requires (! std::convertible_to<T, std::string>)
// ...
2
3
std::convertible_to
是一个新的标准概念。你也可以使用标准类型特性std::is_convertible_v<>
:
template<typename T>
requires (! std::is_convertible_v<T, std::string>)
// ...
2
3
- 仅当参数是指向整数值的指针(或类似指针的对象)时可用:
template<typename T>
requires std::integral<std::remove_reference_t<decltype(*std::declval<T>())>>
// ...
2
3
注意,operator*
通常会产生一个不是整数类型的引用。因此,我们按如下步骤操作:
- 假设我们有一个
T
类型的对象:std::declval<T>()
- 对该对象调用
operator*
:*
- 获取其类型:
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); // 最大公约数(前向声明)
template<typename T, int Min, int Max>
requires (gcd(Min, Max) > 1) // 如果最大公约数大于1则可用
// ...
2
3
4
5
- 临时禁用一个模板:
template<typename T>
requires false // 禁用模板
// ...
2
3
在复杂的情况下,你需要在整个requires
表达式或其部分周围加上括号。只有当你仅使用标识符、::
和<...>
,并可选地结合&&
和||
时,才可以省略括号。例如:
requires std::convertible_to<T, int> // 这里不需要括号
&&
(! std::convertible_to<int, T>) // ! 使得需要括号
2
3
# 4.4 requires表达式
requires
表达式(与requires
子句不同)为指定对一个或多个模板参数的多个需求提供了一种简单而灵活的语法。你可以指定:
- 所需的类型定义
- 必须有效的表达式
- 对表达式产生的类型的需求
requires
表达式以requires
开头,后面跟着一个可选的参数列表,然后是一个需求块(所有需求都以分号结尾)。例如:
template<typename Coll>
// ...
requires {
typename Coll::value_type::first_type;
typename Coll::value_type::second_type;
}
// 元素/值具有first_type
// 元素/值具有second_type
2
3
4
5
6
7
8
可选参数列表允许你引入一组“虚拟变量”,这些变量可用于在requires
表达式的主体中表达需求:
template<typename T>
// ...
requires(T x, T y) {
x + y; // 支持 +
x - y; // 支持 -
}
2
3
4
5
6
这些参数永远不会被参数替换。因此,声明它们是按值还是按引用通常无关紧要。
这些参数还允许我们引入(子类型的)参数:
template<typename Coll>
// ...
requires(Coll::value_type v) {
std::cout << v; // 支持输出运算符
}
2
3
4
5
这个需求检查Coll::value_type
是否有效,以及这种类型的对象是否支持输出运算符。
注意,在这个参数列表中的类型成员不必用typename
限定。
当仅使用这个来检查Coll::value_type
是否有效时,需求块的主体中不需要任何内容。然而,这个块不能是空的。因此,在这种情况下,你可以简单地使用true
:
template<typename Coll>
// ...
requires(Coll::value_type v) {
true ; // 因为块不能为空,所以是一个虚拟需求
}
2
3
4
5
# 4.4.1 简单需求
简单需求只是必须格式正确的表达式。这意味着调用必须能编译通过。这些调用不会实际执行,也就是说操作的结果是什么并不重要。例如:
template<typename T1, typename T2>
// ...
requires(T1 val, T2 p) {
*p; // T2必须支持operator*
p[0]; // 以int为索引时,T2必须支持operator[]
p->value();// 必须可以调用无参数的成员函数value()
*p > val; // 支持将operator*的结果与T1进行比较
p == nullptr; // 支持将T2与nullptr进行比较
}
2
3
4
5
6
7
8
9
最后一个调用并不要求p
是nullptr
(要要求这一点,你必须检查T2
是否是std::nullptr_t
类型)。相反,我们要求可以将T2
类型的对象与nullptr
进行比较。
通常使用||
运算符没有意义。像*p > val || p == nullptr;
这样的简单需求,并不是要求左边或右边的子表达式有一个可行。它表达的需求是我们可以用||
运算符组合两个子表达式的结果。
要要求两个子表达式中的任意一个可行,你必须使用:
template<typename T1, typename T2>
// ...
requires(T1 val, T2 p) {
*p > val; // 支持将operator*的结果与T1进行比较
}
|| requires(T2 p) { // 或者
p == nullptr; // 支持将T2与nullptr进行比较
}
2
3
4
5
6
7
8
还要注意,这个概念并不要求T
是整数类型:
template<typename T>
// ...
requires {
std::integral<T>; // 错误:并不要求T是整数
// ...
};
2
3
4
5
6
这个概念只要求表达式std::integral<T>
有效,这对所有类型都成立。要求T
是整数,你必须这样表述:
template<typename T>
// ...
std::integral<T> && // 正确,要求T是整数
requires {
// ...
};
2
3
4
5
6
或者,如下所示:
template<typename T>
// ...
requires {
requires std::integral<T>; // 正确,要求T是整数
// ...
};
2
3
4
5
6
# 4.4.2 类型要求
类型要求是在使用类型名称时必须格式正确的表达式。这意味着指定的名称必须被定义为有效的类型。
例如:
template<typename T1, typename T2> ... requires {
typename T1::value_type; // T1需要有类型成员value_type
typename std::ranges::iterator_t<T1>; // T1需要有迭代器类型
typename std::common_type_t<T1, T2>; // T1和T2必须有一个公共类型
}
2
3
4
5
对于所有类型要求,如果类型存在但为void
,则满足该要求。
请注意,你只能检查类型的名称(类名、枚举类型名,来自typedef
或using
定义的类型名)。你不能使用类型来检查其他类型声明:
template<typename T>
... requires {
typename int ; // 错误:无效的类型要求
typename T&; // 错误:无效的类型要求
}
2
3
4
5
测试后者的方法是声明一个相应的参数:
template<typename T> ... requires(T&) {
true ; // 一些占位要求
};
2
3
同样,该要求检查使用传递的类型定义另一种类型是否有效。例如:
template<std::integral T> class MyType1 {
...
};
template<typename T> requires requires {
typename MyType1<T>; // 为T实例化MyType1是有效的
}
void mytype1(T) {
...
}
mytype1(42); // 没问题
mytype1(7.7); // 错误
2
3
4
5
6
7
8
9
10
11
12
13
因此,以下要求并不能检查类型T
是否有标准的哈希函数:
template<typename T>
concept StdHash = requires {
typename std::hash<T>; // 无法检查std::hash<>是否为T定义
};
2
3
4
要求标准哈希函数的方法是尝试创建或使用它:
template<typename T>
concept StdHash = requires {
std::hash<T>{}; // 可以,检查我们是否可以为T创建一个标准哈希函数
};
2
3
4
请注意,简单要求仅检查要求是否有效,而不检查其是否得到满足。因此:
- 使用总是产生值的类型函数没有意义:
template<typename T> ... requires {
std::is_const_v<T>; // 没用:总是有效(无论它产生什么结果)
}
2
3
要检查是否为const
,应使用:
template<typename T>
... std::is_const_v<T> // 确保T是const
2
在requires
表达式内部,可以使用嵌套要求(见下文)。
- 使用总是产生类型的类型函数没有意义:
template<typename T> ... requires {
typename std::remove_const_t<T>; // 没用:总是有效(产生一个类型)
}
2
3
该要求仅检查类型表达式是否产生一个类型,而这总是成立的。
使用可能具有未定义行为的类型函数也没有意义。例如,类型特征std::make_unsigned<>
要求传递的参数是除bool
之外的整数类型。如果传递的类型不是整数类型,就会出现未定义行为。因此,在没有对调用std::make_unsigned<>
的类型进行约束时,不应将其作为要求:
template<typename T> ... requires {
std::make_unsigned<T>::type; // 作为类型要求没用(可能有效或未定义行为)
}
2
3
在这种情况下,该要求要么得到满足,要么导致未定义行为(这可能意味着该要求仍然得到满足)。相反,你还应该对可以使用嵌套要求的类型T
进行约束:
template<typename T> ... requires {
requires (std::integral<T> && ! std::same_as<T, bool>);
std::make_unsigned<T>::type; // 没问题
}
2
3
4
# 4.4.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>;
}
2
3
4
5
6
7
请注意,->
后面的类型约束将结果类型作为其第一个模板参数。这意味着:
- 在第一个要求中,我们要求当对
T
类型的对象使用operator&
时,std::input_or_output_iterator
概念得到满足(std::input_or_output_iterator<decltype(&x)>
产生true
)。你也可以这样指定:
{ &x } -> std::is_pointer_v<>;
- 在最后一个要求中,我们要求可以将两个
T
类型对象的operator==
结果用作bool
(当将两个T
类型对象的operator==
结果和bool
作为参数传递时,std::convertible_to
概念得到满足)。
requires
表达式也可以表达对关联类型的需求。例如:
template<typename T> ... requires(T coll) {
{ *coll.begin() } -> std::convertible_to<T::value_type>;
}
2
3
然而,你不能使用嵌套类型来指定类型要求。例如,你不能用它们来要求调用operator *
的返回值是一个整数值。问题在于返回值是一个引用,你必须先解引用:
std::integral<std::remove_reference_t<T>>
并且你不能在requires
表达式的结果中使用带有类型特征的嵌套表达式:
template<typename T>
concept Check = requires(T p) {
{ *p } -> std::integral<std::remove_reference_t<>>; // 错误
{ *p } -> std::integral<std::remove_reference_t>; // 错误
};
2
3
4
5
你要么先定义一个相应的概念:
template<typename T>
concept UnrefIntegral = std::integral<std::remove_reference_t<T>>;
template<typename T>
concept Check = requires(T p) { { *p } -> UnrefIntegral; // 没问题
};
2
3
4
5
6
或者,你必须使用嵌套要求。
# 4.4.4 嵌套要求
嵌套要求可用于在requires
表达式内部指定额外的约束。它们以requires
开头,后面跟着一个编译期布尔表达式,该表达式本身可能又是或使用一个requires
表达式。嵌套要求的好处是,我们可以确保一个编译期表达式(使用requires
表达式的参数或子表达式)产生特定的结果,而不仅仅是确保该表达式有效。
例如,考虑一个概念,它必须确保对于给定类型,operator *
和operator []
产生相同的类型。通过使用嵌套要求,我们可以这样指定:
template<typename T>
concept DerefAndIndexMatch = requires (T p) {
requires std::same_as<decltype(*p),
decltype(p[0])>;
};
2
3
4
5
好处是,这里我们有一种简单的语法来表示“假设我们有一个T
类型的对象”。我们在这里不必使用requires
表达式;不过,代码就必须使用std::declval<>()
:
template<typename T>
concept DerefAndIndexMatch = std::same_as<decltype(*std::declval<T>()),
decltype(std::declval<T>()[0])>;
2
3
再举个例子,我们可以使用嵌套要求来解决刚刚提到的对表达式指定复杂类型要求的问题:
template<typename T>
concept Check = requires(T p) {
requires std::integral<std::remove_cvref_t<decltype(*p)>>;
};
2
3
4
请注意requires
表达式内部的以下区别:
template<typename T> ... requires {
! std::is_const_v<T>; // 错误:检查是否可以调用is_const_v<>
requires ! std::is_const_v<T>; // 没问题:检查T是否不是const
}
2
3
4
这里,我们分别在没有和有requires
的情况下使用了类型特征is_const_v<>
。然而,只有第二个要求是有用的:
- 第一个表达式仅要求检查是否为
const
并取反结果是有效的。这个要求总是满足的(即使T
是const int
),因为进行这个检查总是有效的。这个要求没有价值。 - 带有
requires
的第二个表达式必须得到满足。如果T
是int
,则满足该要求,但如果T
是const int
,则不满足。
# 4.5 概念详解
通过定义一个概念,你可以为一个或多个约束引入一个名称。
模板(函数模板、类模板、变量模板和别名模板)可以使用概念来约束它们的能力(通过requires
子句或作为模板参数的直接类型约束)。然而,概念也是编译期布尔表达式(类型谓词),你可以在任何需要检查类型的地方使用它们(例如在if constexpr
条件中)。
# 4.5.1 定义概念
概念的定义如下:
template< ... >
concept name = ... ;
2
等号是必需的(你不能声明一个未定义的概念,并且这里不能使用花括号)。在等号后面,你可以指定任何可转换为true
或false
的编译期表达式。
概念很像类型为bool
的constexpr
变量模板,但类型没有显式指定:
template<typename T>
concept MyConcept = ... ;
std::is_same<MyConcept< ... >, bool> // 产生true
2
3
4
这意味着在编译期和运行时,只要需要布尔表达式的值,你都可以使用概念。然而,你不能获取其地址,因为它背后没有对象(它是一个纯右值)。
模板参数不能有约束(你不能使用概念来定义概念)。你不能在函数内部定义概念(所有模板都是如此)。
# 4.5.2 概念的特殊能力
概念具有特殊的能力。
例如,考虑以下概念:
template<typename T>
concept IsOrHasThisOrThat = ... ;
2
与布尔变量模板的定义(这是定义类型特征的常用方式)相比:
template<typename T>
inline constexpr bool IsOrHasThisOrThat = ... ;
2
我们有以下区别:
- 概念并不代表代码。它们没有类型、存储、生命周期,或者与对象相关的任何其他属性。
在编译时针对特定的模板参数实例化概念时,其实例化结果只会是真或假。因此,在可以使用
true
或false
的任何地方,都可以使用概念,并且能获得这些字面量的所有属性。 - 概念不必声明为
inline
。它们隐式地就是内联的。 - 概念可用作类型约束:
template<IsOrHasThisOrThat T>
...
2
变量模板不能这样使用。
- 概念是为约束命名的唯一方式,这意味着你需要借助它们来判断一个约束是否是另一个约束的特殊情况。
- 概念具有包含关系。为了让编译器判断一个约束是否意味着另一个约束(因此是特殊情况),这些约束必须被定义为概念。
# 4.5.3 用于非类型模板参数的概念
概念也可以应用于非类型模板参数(NTTP)。例如:
template<auto Val>
concept LessThan10 = Val < 10;
template<int Val>
requires LessThan10<Val>
class MyType {
...
};
2
3
4
5
6
7
8
作为一个更实用的例子,我们可以使用一个概念来约束非类型模板参数的值为2的幂次方:
// lang/conceptnttp.cpp
#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; // ERROR
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
概念PowerOf2
将一个值而不是类型作为模板参数(这里使用auto
,这样就不要求特定的类型):
template<auto Val>
concept PowerOf2 = std::has_single_bit(static_cast<unsigned>(Val));
2
当新的标准函数std::has_single_bit()
对传递的值返回true
时(只有一位被设置意味着这个值是2的幂次方),该概念就被满足。注意,std::has_single_bit()
要求我们传入一个无符号整数值。通过转换为无符号类型,程序员可以传递有符号整数值,并拒绝那些无法转换为无符号整数值的类型。
然后,这个概念被用于要求Memory
类(它接受一个类型和一个大小)仅接受大小为2的幂次方的值:
template<typename T, auto Val>
requires PowerOf2<Val>
class Memory {
// ...
};
2
3
4
5
注意,你不能这样写:
template<typename T, PowerOf2 auto Val>
class Memory {
// ...
};
2
3
4
这是对Val
的类型提出了要求;然而,概念PowerOf2
约束的不是类型,而是值。
# 4.6 使用概念作为类型约束
如前所述,概念可以用作类型约束。类型约束可以用在不同的地方:
- 在模板类型参数的声明中
- 在使用
auto
声明的调用参数的声明中 - 在复合需求中作为一项需求
例如:
template<std::integral T> // 模板参数的类型约束
class MyClass {
// ...
};
auto myFunc(const std::integral auto& val) { // auto参数的类型约束
// ...
};
template<typename T>
concept MyConcept = requires(T x) {
{ x + x } -> std::integral; // 返回类型的类型约束
};
2
3
4
5
6
7
8
9
10
11
12
13
这里,我们使用了一元约束,它针对单个参数或表达式返回的类型进行调用。
多参数类型约束
你也可以使用多参数约束,参数类型或返回值会被用作第一个参数:
template<std::convertible_to<int> T> // 需要转换为int类型
class MyClass {
// ...
};
auto myFunc(const std::convertible_to<int> auto& val) { // 需要转换为int类型
// ...
};
template<typename T>
concept MyConcept = requires(T x) {
{ x + x } -> std::convertible_to<int>; // 需要转换为int类型
};
2
3
4
5
6
7
8
9
10
11
12
13
另一个常用的例子是使用std::invocable
或std::regular_invocable
概念来约束可调用对象(函数、函数对象、lambda表达式)的类型,要求可以传递特定数量和类型的参数。例如,要要求传递一个接受int
和std::string
的操作,可以这样声明:
template<std::invocable<int, std::string> Callable>
void call(Callable op);
2
或者:
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) {
// ...
}
2
3
不过,你也可以如下方式对auto
使用类型约束:
- 约束声明:
std::floating_point auto val1 = f(); // 仅当f()返回浮点值时有效
for (const std::integral auto& elem : coll) { // 仅当元素为整数值时有效
// ...
}
2
3
4
- 约束返回类型:
std::copyable auto foo(auto) { // 仅当foo()返回可复制的值时有效
// ...
}
2
3
- 约束非类型模板参数:
template<typename T, std::integral auto Max>
class SizedColl {
// ...
};
2
3
4
这对接受多个参数的概念也同样适用:
template<typename T, std::convertible_to<T> auto DefaultValue>
class MyType {
// ...
};
2
3
4
另一个示例可查看对lambda表达式作为非类型模板参数的支持。
# 4.7 使用概念进行约束涵盖
两个概念之间可以存在涵盖关系。也就是说,可以对一个概念进行定义,使其对一个或多个其他概念进行限制。这样做的好处是,在重载解析时,当两个约束都满足的情况下,相比于约束较少的泛型代码,会优先选择约束更严格的泛型代码。
例如,假设我们引入以下两个概念:
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> &&
requires(T obj) {
// 涵盖GeoObject概念
// 额外约束
obj.setColor(Color{});
{obj.getColor()} -> std::convertible_to<Color>;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ColoredGeoObject
概念明确涵盖了GeoObject
概念,因为它明确表述了类型T
还必须满足GeoObject
概念的约束。
因此,当我们为这两个概念重载模板,且两个概念都满足时,不会出现歧义错误。重载解析会优先选择涵盖其他概念的那个概念:
template<GeoObject T>
void process(T) // 用于不提供setColor()和getColor()的对象
{
...
}
template<ColoredGeoObject T>
void process(T) // 用于提供setColor()和getColor()的对象
{
...
}
2
3
4
5
6
7
8
9
10
11
约束涵盖只有在使用概念时才有效。当一个概念/约束比另一个更特殊时,不会自动发生涵盖。
约束和概念并非仅基于需求进行涵盖。考虑以下示例:
这实际上是标准化过程中讨论此特性时用到的示例。感谢维勒·沃蒂莱宁(Ville Voutilainen)指出这一点。
// 在头文件中声明:
template<typename T>
concept GeoObject = requires(T obj) {
obj.draw();
};
// 在另一个头文件中声明:
template<typename T>
concept Cowboy = requires(T obj) {
obj.draw();
obj = obj;
};
2
3
4
5
6
7
8
9
10
11
12
假设我们为GeoObject
和Cowboy
重载一个函数模板:
template<GeoObject T>
void print(T) {
...
}
template<Cowboy T>
void print(T) {
...
}
2
3
4
5
6
7
8
9
对于同时拥有draw()
成员函数的Circle
或Rectangle
,我们不希望仅仅因为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); // 错误:存在歧义(如果两个约束都为真)
2
3
4
5
6
7
8
9
10
11
12
13
而使用概念时,这段代码就可以正常工作:
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); // 正确
2
3
4
5
6
7
8
9
10
11
12
13
这种行为的一个原因是,详细处理概念之间的依赖关系需要耗费编译时间。
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::default_initializable
又涵盖了其他几个概念,如std::movable
、std::copy_constructible
和std::destructible
)。std::sortable
涵盖std::permutable
,并且当两个参数类型相同时,这两个概念都涵盖std::indirectly_swappable
。
# 4.7.1 间接涵盖
约束甚至可以间接涵盖。这意味着,即使两个重载或特化的约束不是直接相互定义的,重载解析仍可能优先选择其中一个。
感谢亚瑟·奥德怀尔(Arthur O’Dwyer)指出这一点。
例如,假设你定义了以下两个概念:
template<typename T>
concept RgSwap = std::ranges::input_range<T> && std::swappable<T>;
template<typename T>
concept ContCopy = std::ranges::contiguous_range<T> && std::copyable<T>;
2
3
4
5
现在,当我们为这两个概念重载两个函数,并传递一个同时符合这两个概念的对象时,不会产生歧义:
template<RgSwap T>
void foo1(T) {
std::cout << "foo1(RgSwap)\n";
}
template<ContCopy T>
void foo1(T) {
std::cout << "foo1(ContCopy)\n";
}
foo1(std::vector<int>{}); // 正确:两个概念都符合,ContCopy约束更严格
2
3
4
5
6
7
8
9
10
11
原因是ContCopy
涵盖了RgSwap
,因为:
contiguous_range
概念是基于input_range
概念定义的。(它意味着random_access_range
,而random_access_range
意味着bidirectional_range
,bidirectional_range
意味着forward_range
,forward_range
又意味着input_range
。)copyable
概念是基于swappable
概念定义的。(它意味着movable
,而movable
意味着swappable
。)
然而,对于以下声明,当两个概念都符合时,就会产生歧义:
template<typename T>
concept RgSwap = std::ranges::sized_range<T> && std::swappable<T>;
template<typename T>
concept ContCopy = std::ranges::contiguous_range<T> && std::copyable<T>;
2
3
4
5
这是因为contiguous_range
和sized_range
这两个概念互不包含。
同样,对于以下声明,两个概念也互不涵盖:
template<typename T>
concept RgCopy = std::ranges::input_range<T> && std::copyable<T>;
template<typename T>
concept ContMove = std::ranges::contiguous_range<T> && std::movable<T>;
2
3
4
5
一方面,ContMove
的约束更严格,因为contiguous_range
意味着input_range
;另一方面,RgCopy
的约束更严格,因为copyable
意味着movable
。
为避免混淆,不要对概念之间的涵盖关系做过多假设。如有疑问,应明确指定所需的所有概念。
# 4.7.2 定义可交换概念
为了正确实现涵盖关系,需要格外小心。一个很好的例子是std::same_as
概念的实现,该概念用于检查两个模板参数是否具有相同类型。
为了理解定义这个概念并非易事,让我们假设我们像下面这样定义自己的SameAs
概念:
template<typename T, typename U>
concept SameAs = std::is_same_v<T, U>; // 定义SameAs概念
2
对于如下情况,这个定义已经足够:
template<typename T, typename U>
concept SameAs = std::is_same_v<T, U>;// 定义SameAs概念
template<typename T, typename U>
requires SameAs<T, U> // 使用SameAs概念
void foo(T, U) {
std::cout << "foo() for parameters of same type" << "\n";
}
template<typename T, typename U>
requires SameAs<T, U> && std::integral<T> // 再次使用SameAs概念
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
14
15
16
这里,第二个foo()
的定义涵盖了第一个,这意味着当传递两个相同整型类型的参数,且两个foo()
的约束都满足时,会调用第二个foo()
。
然而,请注意,如果我们在第二个foo()
的约束中调用SameAs<>
时稍微修改一下参数顺序会发生什么:
template<typename T, typename U>
requires SameAs<T, U> // 使用SameAs概念
void foo(T, U) {
std::cout << "foo() for parameters of same type" << "\n";
}
template<typename T, typename U>
requires SameAs<U, T> && std::integral<T> // 以不同顺序使用SameAs概念
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
问题在于编译器无法检测到SameAs<>
是可交换的。对于编译器来说,模板参数的顺序很重要,因此第一个需求不一定是第二个需求的子集。
为了解决这个问题,我们必须以一种使参数顺序无关紧要的方式来设计SameAs
概念。这需要一个辅助概念:
template<typename T, typename U>
concept SameAsHelper = std::is_same_v<T, U>;
template<typename T, typename U>
concept SameAs = SameAsHelper<T, U> && SameAsHelper<U, T>; // 使其可交换
2
3
4
5
现在,对于IsSame<>
来说,参数顺序不再重要:
template<typename T, typename U>
requires SameAs<T, U> // 使用SameAs
void foo(T, U) {
std::cout << "foo() for parameters of same type" << "\n";
}
template<typename T, typename U>
requires SameAs<U, T> && std::integral<T> // 以不同顺序使用SameAs
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
编译器可以发现第一个构建块SameAs<U,T>
是SameAs
定义的子概念的一部分,因此其他构建块SameAs<T,U>
和std::integral<T>
是一种扩展。所以,现在第二个foo()
会被优先选择。
C++20标准库提供的概念(如std::same_as
)中就包含了这样巧妙的设计细节。因此,当它们符合你的需求时,你应该使用标准库中的概念,而不是自行定义。
template<typename T, typename U>
requires std::same_as<T, U> // 标准的same_as<>是可交换的
void foo(T, U) {
std::cout << "foo() for parameters of same type" << "\n";
}
template<typename T, typename U>
requires std::same_as<U, T> && 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
对于你自己定义的概念,应尽可能多地考虑各种使用(和误用)情况,考虑得越多越好。与任何优秀的软件一样,概念也需要精心设计并进行测试。