本章的其余部分,将全部围绕添加到模板声明中,以限制模板参数的“人为”替换失败展开。在本节中,我们将介绍使用 C++20 编写这些限制的新方法。在下一节中,会介绍如果无法使用 C++20,但仍希望对模板施加约束时可以采取的措施。
C++20 通过引入概念和约束,改变了使用 SFINAE 来限制模板参数的方式。尽管这一整体特性通常称为“概念”,但其中最重要的是约束部分。以下内容并非对这些特性的完整或正式描述,而是最佳实践的演示(现在说“模式”可能还为时过早,因为社区仍在确定哪些做法可以接受)。指定约束的第一种方法是编写一个 requires 子句,其形式为:
requires(constant-boolean-expression)
关键字 requires 和括号中的常量(编译时)表达式必须紧接在模板参数之后出现,或者作为函数声明的最后一个元素:
// Example 19
template <typename T> requires(sizeof(T) == 8) void f();
template <typename T> void g(T p) requires(sizeof(*p) < 8);
与上一节类似,写在声明末尾的约束可以用名称引用函数参数,而写在参数列表后的约束只能引用模板参数(这两种语法之间其他更细微的差异不在本章讨论范围内)。与上一节不同的是,如果约束检查失败,编译器通常会给出清晰的诊断信息,而不是简单地报告“未找到函数 f”以及模板推导失败。
在 requires 子句的常量表达式中可以使用什么?实际上,可以在编译时计算的内容都可以,只要最终结果是 bool 类型。通常使用类型特征(type traits),如 std::is_convertible_v 或 std::is_default_constructible_v,来限制类型。
如果表达式较为复杂,可以使用 constexpr 函数来简化:
template <typename V> constexpr bool valid_type() {
return sizeof(T) == 8 && alignof(T) == 8 &&
std::is_default_constructible_v<T>;
}
template <typename T> requires(valid_type<T>()) void f();
但有一种之前没见过的特殊表达式 —— requires 表达式。该表达式可用于检查某个表达式是否能够编译(从技术上讲,即“是否有效”):
requires { a + b; }
假设变量 a 和 b 在使用该表达式的上下文中已定义,如果表达式 a + b 有效,则此表达式求值为 true。如果知道要测试的类型,但没有具体的变量怎么办?可以使用 requires 表达式的第二种形式:
requires(A a, B b) { a + b; }
这里的类型 A 和 B 通常指代模板参数或某些依赖类型。
我们说的是“任意表达式是否有效”,而不是“任意代码是否有效”。这是一个重要的区别。例如,不能写:
requires(C cont) { for (auto x: cont) {}; }
并且要求类型 C 满足范围 for 循环的所有要求。大多数情况下,会通过测试诸如 cont.begin() 和 cont.end() 这样的表达式来实现。然而,也可以通过将代码隐藏在 Lambda 表达式中,来构造更复杂的要求
requires(C cont) {
[](auto&& c) {
for (auto x: cont) { return x; };
}(cont);
}
如果这样的代码出了问题,而需要去解读错误信息,那可就麻烦了。
当在模板约束中使用 requires 表达式时,模板的限制不再是某个具体的类型特征,而是对类型所需行为的约束:
// Example 20
template <typename T, typename P>
void f(T i, P p) requires( requires { i = *p; } );
template <typename T, typename P>
void f(T i, P p) requires( requires { i.*p; } );
首先,这里确实有两个 requires 关键字(括号可选,因此可能会看到这种约束写成 requires requires)。第一个 requires 引入了一个约束,即一个 requires 子句。第二个 requires 则开始了一个 requires 表达式。
第一个函数 f() 中的表达式是有效的,前提是第二个模板参数 p 可以解引用(可以是一个指针,但不一定是),并且解引用的结果可以赋值给第一个参数 i。我们并不要求赋值两侧的类型相同,甚至不要求 *p 能够转换为 T(通常情况下可以,但不是必须的)。我们只需要表达式 i = *p 能够编译通过即可。
最后,若没有现成可用的合适变量,可以将其声明为 requires 表达式的参数:
// Example 20
template <typename T, typename P>
requires(requires(T t, P p) { t = *p; }) void f(T i, P p);
template <typename T, typename P>
requires(requires(T t, P p) { t.*p; }) void f(T i, P p);
这两个示例还表明,可以使用约束来实现 SFINAE 式的重载控制:如果某个约束检查失败,该模板函数将从重载决议集合中移除,重载决议将继续寻找其他可行的候选函数。
正如之前已经看到的,有时需要检查的不是一个表达式,而是一个依赖类型;同样可以在 requires 表达式内部完成这种检查:
requires { typename T::value_type; }
requires 表达式的求值结果为 bool,可以用于逻辑表达式中:
requires(
requires { typename T::value_type; } &&
sizeof(T) <= 32
)
可以这样组合多个 requires 表达式,也可以在一个表达式内部编写更多代码:
requires(T t) { typename T::value_type; t[0]; }
这里,要求类型 T 拥有名为 value_type 的嵌套类型,并且支持接受整数索引的下标运算符。
最后,有时不仅需要检查某个表达式能否编译,还需要检查其结果是否具有特定类型(或满足某些类型要求)。这可以通过 requires 表达式的复合形式来实现:
requires(T t) { { t + 1 } -> std::same_as<T>; }
要求表达式 t + 1 能够编译,并且其结果的类型与变量 t 本身的类型相同。最后一部分使用了一个概念来实现;将在下一节中读到有关它们的内容,现在可以将其视为编写 std::is_same_v 类型特征的另一种方式。
说到概念……目前为止所描述的一切,在一本 C++20 书籍中都会归在“概念”这个标题下,只是我们还没有提到概念本身。
概念其实就是一组被命名的要求 —— 也就是刚刚学习的那些要求。在某种程度上,类似于 constexpr 函数,只不过操作的是类型。当有一组经常引用的要求,或者想要为某组要求赋予一个有意义的名称时,就可以使用概念。例如,一个“范围”由一个非常简单的要求定义:必须拥有一个 begin 迭代器和一个 end 迭代器。每次声明一个接受范围参数的函数模板时,都可以编写一个简单的 requires 表达式,但为这个要求赋予一个名称会更加方便和易读:
// Example 21
template <typename R> concept Range = requires(R r) {
std::begin(r);
std::end(r);
};
刚刚引入了一个名为 Range 的概念,有一个模板类型参数 R;该类型必须拥有 begin 和 end 迭代器(我们使用 std::begin(), 而非成员函数 begin() 的原因在于,C 风格数组也是范围,但它们没有成员函数)。
C++20 提供了一个 范围 库以及一组相应的概念(包括 std::ranges::range,在实际代码中应使用它,而不是我们自己编写的 Range),但“范围”的概念非常适合作为教学示例,我们将用它来演示后续的例子。
当有了一个命名的概念,就可以在模板约束中直接使用,而无需每次都详细写出具体要求:
// Example 21
template <typename R> requires(Range<R>) void sort(R&& r);
正如你所看到的,概念可以在 requires 子句中使用,就像使用一个 bool 类型的 constexpr 变量一样。事实上,概念也可以用于诸如 static_assert 这样的上下文中:
static_assert(Range<std::vector<int>>);
static_assert(!Range<int>);
对于那些概念本身就是全部要求的简单模板声明,C++ 提供了一种更简洁的方式来表达:
// Example 21
template <Range R> void sort(R&& r);
模板声明中,概念名称可以替代 typename 关键字使用。这样做会自动将相应的类型参数限制为,满足该概念的类型。如有必要,仍然可以使用 requires 子句来定义其他约束。最后,概念也可以与 C++20 新的模板语法一起使用:
// Example 21
void sort(Range auto&& r);
这三种声明方式效果相同,选择哪种方式主要取决于编码风格和便利性。
我们已经了解了如何使用概念和约束来对函数模板的参数施加限制。requires 子句可以出现在模板参数之后,也可以位于函数声明的末尾;这两个位置都属于 SFINAE 上下文,其中任一位置发生的替换失败,都不会导致整个程序的编译中断。在这方面,概念与替换失败在本质上并无不同:虽然可以在非 SFINAE 上下文中使用约束,但如果在那里发生替换失败,仍然会导致编译错误。例如,不能通过使用约束来断言某个类型不具有名为 value_type 的嵌套类型:
static_assert(!requires{ typename T::value_type; });
可能期望当要求不满足时,requires 表达式求值为 false,但在这种情况下,根本无法编译(会收到错误信息,提示 T::value_type 不是一个有效类型)。
然而,概念可以实现一些以前无法实现的限制,特别是对类模板的限制。在最简单的形式中,可以使用概念来限制类模板的类型参数:
// Example 21
template <Range R> class C { ... };
这个类模板只能用满足 Range 概念的类型进行实例化。然后,还可以对单个成员函数(无论它们是否为模板)施加约束:
// Example 21
template <typename T> struct holder {
T& value;
holder(T& t) : value(t) {}
void sort() requires(Range<T>) {
std::sort(std::begin(value), std::end(value));
}
};
现在,这个类模板本身可以用类型进行实例化。只有当类型满足 Range 约束时,接口才包含成员函数 sort()。
这是约束与旧式 SFINAE 之间一个非常重要的区别:人为制造的替换失败,只有在函数模板的推导类型参数替换过程中发生时才有效。在本章前面,我们曾为一个成员函数添加一个虚拟的模板类型参数,只是为了能够制造一个 SFINAE 失败。而使用概念后,这一切都不再需要了。
概念和约束是为模板参数指定限制的最佳方式,使得多年来发明的许多 SFINAE 技巧过时。然而,并非每个人都能立即使用 C++20。此外,即使在使用概念的情况下,某些 SFINAE 技术仍然有其用武之地。在最后一节中,我们将介绍这些技术,并探讨如果无法使用 C++20,但仍希望对模板类型施加约束时,可以采取的措施。