C++20 为 C++ 模板机制引入了一项重大增强:概念。
在 C++20 中,模板(包括类模板和函数模板)以及非模板函数(通常是类模板的成员)都可以使用约束来指定对模板参数的要求。
这些约束有助于生成更好的错误信息,但当需要根据模板参数的某些属性来选择函数重载或模板特化时,才真正需要。
约束的基本语法非常简单:约束通过关键字 requires 引入,可以放在函数声明之后,也可以放在返回类型之前(在本书中,我们将交替使用这两种方式,以便读者熟悉不同的代码编写风格)。约束表达式通常使用模板参数,且其本身必须求值为一个布尔值,例如:
// Example 13a
template <typename T> T copy(T&& t)
requires (sizeof(T) > 1)
{
return std::forward<T>(t);
}
函数 copy() 要求其参数的类型大小大于一个字节。如果尝试使用 char 类型的参数调用此函数,该调用将无法编译。如果违反约束,就相当于该函数在特定调用中不存在:如果存在另一个重载,即使在没有约束的情况下重载会产生歧义,编译器也会接着考虑另一个重载。
这是一个更复杂的例子:
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
{
if (t1 < t2) return std::forward<T1>(t1);
return std::forward<T2>(t2);
}
类似于 std::min 的函数,但接受两个不同类型参数。这会产生两个问题:首先,返回类型是什么?返回值是两个参数中的一个,但必须为单一的返回类型。可以使用 <type_traits> 头文件中的 std::common_type 特性作为一个合理的答案:对于数值类型,通常执行的是类型提升;对于类类型,在可能的情况下从基类转换为派生类,并且会尊重用户指定的隐式转换。但还有第二个问题:如果表达式 t1 < t2 无法编译,会在函数体中得到一个错误。但因为这个错误很难分析,而且可能具有误导性:其暗示函数体的实现不正确,所以可以通过添加一个 static_assert 来解决第二个问题:
static_assert(sizeof(t1 < t2) > 0);
如果找不到匹配的 operator<(),代码就不应该编译。这里,以一种奇怪的方式来表述这个断言:表达式 t1 < t2 本身通常需要在运行时求值,并且结果很可能是 false。我们需要一个编译时的值,但并不关心哪个参数更小,只关心是否可以进行比较,所以断言的不是比较结果,而是该结果的大小:sizeof() 始终是一个编译时值,而在 C++ 中的大小至少为 1。这个断言唯一可能失败的情况就是,该表达式根本无法编译。
但这仍然没有解决另一个问题:对参数类型的要求,并未包含在函数的接口中。该函数可以使用两种类型调用,然后可能编译成功,也可能失败。使用 C++20 的约束,可以将要求从函数体内的隐式(编译失败)或显式(static_assert)错误,转移到函数声明中,使其成为函数接口的一部分:
// Example 13b
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
requires (sizeof(t1 < t2) > 0)
{
if (t1 < t2) return std::forward<T1>(t1);
return std::forward<T2>(t2);
}
当构建更复杂的约束时,重要的是要记住,约束表达式必须求值为一个 bool 值;在此上下文中不允许进行转换,这就是为什么相似表达式无法工作的原因:
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
requires (sizeof(t1 < t2));
sizeof() 的整数值总是非零,通常会转换为 true,但在约束表达式的上下文中不行。不过,根本不必使用 sizeof() 这种技巧来编写约束。还有另一种更强大的约束表达式,即 requires 表达式,其功能更强大,也更能清晰地表达我们的意图:
// Example 13b
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
requires (requires { t1 < t2; });
requires 表达式以 requires 关键字开头,后跟花括号 {}。花括号内可以包含任意数量的表达式,这些表达式必须能够成功编译,否则整个 requires 表达式的值为 false(这些表达式的结果值无关紧要,只要是有效的 C++ 代码即可)。还可以在其中使用类型、类型特征以及不同类型要求的组合。由于语言的一个特性,requires 表达式周围的括号是可选的,可能会看到像 requires requires { t1 < t2 } 这样的代码,其中第一个和第二个 requires 是完全不同的关键字。
对模板类型的要求可能相当复杂;通常,相同的要求会出现在多个不同的模板中。这些要求的集合可以命名并定义,以便后续使用;这些命名的要求称为概念。每个概念都是一个在约束中使用时于编译时求值的条件。
约束的语法类似于模板:
// Example 13c
template <typename T1, typename T2> concept Comparable =
requires(T1 t1, T2 t2) { t1 < t2; };
我们不会在本书中详细讲解该语法 —— 如需详细了解,请参考 cppreference.com 等权威资料。一个概念可以替代其所包含的要求来使用:
template <typename T1, typename T2>
std::common_type_t<T1, T2> min2(T1&& t1, T2&& t2)
requires Comparable<T1, T2>;
可以约束单个类型的概念也可以用作模板参数的占位符。来看一个 swap() 函数的例子。对于整数类型,有一个技巧可以在不使用临时变量的情况下交换两个值,其依赖于按位异或(XOR)操作的特性。为了演示的目的,假设在特定硬件上,此版本比通常的 swap 实现方式更快。希望编写一个模板函数 mySwap(T& a, T& b),能自动检测类型 T 是否支持 XOR 操作,如果支持则使用该技巧;否则回退到常规的 swap 方法。
首先,需要为支持 XOR 操作的类型定义一个概念:
// Example 14a,b
template <typename T> concept HasXOR =
requires(T a, T b) { a ^ b; };
该概念包含一个 requires 表达式;花括号内的每个表达式都必须能够成功编译,否则该概念的要求就不满足。
现在,可以实现一个基于 XOR 的 swap 模板。可以使用 requires 约束来实现,但有一种更简洁的方式:
template <HasXOR T> void MySwap(T& x, T& y) {
x = x ^ y;
y = x ^ y;
x = x ^ y;
}
概念名 HasXor 可以替代 typename 关键字来声明模板参数。这将我们的 mySwap() 函数限制为仅适用于那些具有 operator^() 的类型,但还需要一个通用情况的重载。在本例中,“通用”并等于“任意”:该类型必须支持移动赋值和移动构造。所以需要另一个概念:
template <typename T> concept Assignable =
requires(T a, T b) {
T(std::move(b));
b = std::move(a);
}
这是一个非常相似的概念,只是有两个表达式;两个表达式都必须有效,该概念才能为真。
第二个 mySwap() 重载接受所有 Assignable 类型。然而,须明确排除具有 XOR 操作的类型,否则会产生重载歧义。这是一个完美的例子,展示了如何将作为模板占位符的概念与作为要求的概念,如何结合起来使用:
template <Assignable T> void MySwap(T& x, T& y)
requires (!HasXOR<T>)
{
T tmp(std::move(x));
x = std::move(y);
y = std::move(tmp);
}
现在,调用 mySwap() 时,将选择基于 XOR 的重载;否则,将使用通用重载(交换不可赋值的类型则根本无法编译)。
最后,让回到本章最初的示例之一:在“类模板”一节中的 ArrayOf2 类模板。回想一下,它有一个成员函数 sum(),该函数对模板类型的要求比类的其他部分严格得多:会将数组元素的值相加。只要不调用 sum(),即使元素没有 operator+() 也不会有问题;但当调用,就会产生语法错误。如果该函数在类型不支持时根本就不是类接口的一部分,那将会更好。可以通过约束来实现:
// Example 15
template <typename T> class ArrayOf2 {
public:
T& operator[](size_t i) { return a_[i]; }
const T& operator[](size_t i) const { return a_[i]; }
T sum() const requires (requires (T a, T b) { a + b; }) {
return a_[0] + a_[1];
}
private:
T a_[2];
}
如果表达式 a + b 无法编译,代码的行为就好像类接口中根本没有声明 sum() 成员函数一样,也可以为此使用一个命名的概念。
我们将在第7章看到更多管理模板参数要求的方法。现在,回顾一下所学内容,并继续使用这些工具来解决常见的 C++ 问题。