7.5. SFINAE技术

模板实参替换失败并非错误这一规则 —— 即SFINAE规则 —— 当初加入语言,是为了实现某些定义狭窄的模板函数。但C++开发者的创造力是无穷的,通过重新利用SFINAE,有意引发替换失败来手动控制重载集。多年来,人们发明了大量基于SFINAE的技术,直到C++20的“概念”使其中大多数技术变得过时。

尽管如此,即使在C++20中,SFINAE的一些用法仍然存在,此外还有大量C++20之前的代码需要你阅读、理解和维护。先来看看,即使有了“概念”,SFINAE仍然有用的那些应用场景。

7.5.1 C++20 中的 SFINAE

即使在C++20中,仍然存在“自然的”类型替换失败。例如,可能想编写如下函数:

template <typename T> typename T::value_type f(T&& t);

这仍然可行,前提是确实希望返回由嵌套类型 value_type 指定类型的值。但在急于回答“是”之前,应该仔细考虑真正希望返回的类型是什么。希望对调用者施加什么样的约定约束?也许 value_type 的存在只是代表了真正的需求,比如类型 T 支持下标操作符,或者可以作为范围用于迭代。这种情况下,可以直接声明这些需求:

template <typename T> auto f(T&& t)
requires( requires { *t.begin(); t.begin() != t.end(); } );

真正需要的是一个具有 begin() 和 end() 成员函数的类型,这些函数返回的值(可能是迭代器)会被解引用并进行比较;如果支持这些操作,这些返回值在我们的用途中就足够接近迭代器了。最后,在前面的例子中,可以让编译器来确定返回类型。这很方便,但缺点是接口 —— 也就是约定 —— 并没有说明返回类型是什么;代码的使用者必须阅读实现细节才能知晓。假设我们返回的是通过解引用迭代器得到的值,就可以明确地将其表达出来:

template <typename T> auto f(T&& t)->decltype(*t.begin())
requires( requires {
  *t.begin();
  t.begin() != t.end();
  ++t.begin();
} );

这是一个非常全面的、与使用端的约定,前提是作为实现者,保证在满足所声明要求的情况下,函数体能够成功编译。否则,这个约定就是不完整的:如果函数体内确实使用了 T::value_type,无论这个类型是否是最终的返回类型,都应该将 typename T::value_type 添加到需求列表中(如果是返回类型,仍然可以对返回类型使用SFINAE)。

当使用依赖类型来声明模板函数参数时,也存在类似的考虑:

template <typename T>
bool find(const T& t, typename T::value_type x);

我们应该问自己,这些是否真的是我们希望施加的约束。假设该函数是在容器 t 中查找值 x,只要 x 能够与容器中存储的值进行比较,我们真的关心 x 的具体类型吗?考虑以下替代方案:

template <typename T, typename X>
bool find(const T& t, X x)
requires( requires {
  *t.begin() == x;
  t.begin() == t.end();
  ++t.begin();
} );

现在,只要求容器具备支持范围for循环所需的一切,并且容器中存储的值能够与 x 进行相等比较。假设所做的仅是遍历容器,并在找到与 x 相等的值时返回 true,这就是我们对调用者所需要求的全部内容。

读者们不应由此推断:在C++20中,“自然的”SFINAE应当被弃用,转而用通过约束关联起来的独立模板参数来替代。我们所建议的仅仅是,应该审视自己的代码,判断通过接口表达并借助SFINAE强制执行的约定,是否真的是你所期望的,抑或仅仅是因为编码方便而采用的权宜之计。在后一种情况下,概念(concepts)提供了一种方式,来表达本意想是要表达此前无法表达的内容(但请继续阅读,还有一些受概念启发的技术,可以在C++20之前使用,满足同样的需求)。另一方面,如果模板函数的最佳写法就是,在使用端提供无效参数时触发替换失败,尽管继续使用SFINAE —— 完全没有必要将所有代码重写为使用概念的形式。

甚至“人为的”SFINAE在C++20中仍然有其用武之地。

7.5.2 SFINAE 与类型特征

在C++20中,“人为”SFINAE最重要的应用是编写类型特征。类型特征并不会消失:即使在代码中用 std::same_as(概念)替代了 std::is_same_v(特征)。

并非所有类型特征都需要使用SFINAE,但其中许多确实需要。这些特征用于检查某些语法特性的存在性,例如某个嵌套类型的是否存在。实现这类特征时面临一个共同问题:如果该类型不具备所需特性,某些代码将无法编译。但并不希望出现编译错误,而是希望得到一个计算结果为 false 的表达式。如何让编译器忽略这个错误呢?就是让该错误出现在SFINAE上下文中。

让我们从一个贯穿本章始终的例子开始:编写一个特征,用于检查某个类型是否具有嵌套类型 value_type。使用SFINAE,因此需要一个模板函数。该函数必须在SFINAE上下文中使用该嵌套类型。实现有多种方式,添加一个依赖于可能失败的表达式的模板参数会很方便:

template <typename T, typename = T::value_type> void f();

第二个参数没有名称 —— 根本不会使用它。如果尝试用不包含嵌套 value_type 的类型(例如 int)来实例化此模板,比如调用 f<int>(),替换过程将会失败,但这并不会导致错误(这正是SFINAE的作用!)。当写下 f(ptr) 却没有可用的函数可调用时,就会产生错误,因此必须提供一个备用的重载版本:

template <typename T> void f(...);

有读者可能会对这个“双重通用”的模板函数 f(...) 感到好奇 —— 它能接受类型、数量的参数,即使没有模板也能做到,那为什么还要使用模板呢?原因在于,当显式指定模板参数类型(如 f<int>())时,必须让编译器将此函数视为一个可能的重载选项(当指定了模板参数类型,所有非模板函数都会排除在重载候选之外)。然而,我们希望这个重载的优先级尽可能低,以便只要第一个重载存在,就优先选择它。因此我们使用 f(...),这是“最后手段的重载”。遗憾的是,f() 和 f(...) 之间的重载仍然有歧义,所以需要至少一个参数。只要能够方便地构造出该类型的对象,参数的具体类型并不重要:

template <typename T, typename = T::value_type>
void f(int);
template <typename T> void f(...);

如果 T::value_type 是一个有效类型,对 f<T>(0) 的调用将选择第一个重载;否则,就只能选择第二个重载。我们唯一需要的是,找到一种方法来确定如果执行调用,将会选择哪个重载,而无需真正执行该调用。

这其实相当简单:可以使用 decltype() 来检查函数返回值的类型(在C++11之前,通常使用 sizeof() 代替)。接下来,只需让这两个重载具有不同的返回类型,两个不同的类型都可以。然后就可以基于这些类型编写条件代码。然而,请记住正在编写一个类型特征,而用于检查某事物是否存在的特征,通常在存在时结果为 std::true_type,不存在时为 std::false_type。因此没有必要把实现复杂化 —— 可以直接让两个重载分别返回期望的类型,并将其用作该特征:

// Example 22
namespace detail {
  template <typename T, typename = T::value_type> void test_value_type(int);
  template <typename T> void test_value_type (...);
}

template <typename T> using has_value_type = decltype(detail::test_value_type <T>(0));

由于这些函数仅在 decltype() 内部使用而永远不会实际调用,只需要提供函数的声明,而无需定义其函数体(但请参见下一节,以了解更完整和细致的解释)。为了避免这些仅供测试使用的函数污染全局命名空间,通常会将它们隐藏在诸如 detail 或 internal 这样的命名空间中。

说到惯例,还应定义两个别名:

template <typename T>
using has_value_type_t = has_value_type<T>::type;

template <typename T> inline constexpr
bool has_value_type_v = has_value_type<T>::value;

现在可以像使用标准类型特征一样使用我们定义的这个特征:

static_assert(has_value_type_v<T>, “I require value_type”);

还有其他几种SFINAE上下文可用于“隐藏”因使用 T::value_type 而可能产生的错误。虽然可以使用尾部返回类型,但这种方式并不方便,已经有一个必须返回的类型了(虽然有办法绕过,但比其他选择更复杂)。此外,如果需要用SFINAE处理构造函数,返回类型这种方式就完全不可行了。

另一种常见的技术是在函数中添加其他参数,替换错误发生在参数类型上,并且这些参数必须带有默认值,以便调用者甚至不知道它们的存在。这种方法在过去更为流行,但现在正逐渐摒弃这种做法:这些虚拟参数可能会干扰重载解析,并且为这类参数找到一个可靠的默认值可能很困难。

另一种正逐渐成为标准实践的技术,是将替换失败置于一个可选的非类型模板参数中:

// Example 22a
template <typename T, std::enable_if_t<
  sizeof(typename T::value_type) !=0, bool> = true>
std::true_type test_value_type(int);

这里有一个非类型模板参数(一个 bool 类型的值),其默认值为 true。类型 T 在该参数中的替换可能会失败,其失败方式与本节前面所有示例中的失败方式相同:如果嵌套类型 T::value_type 不存在,sizeof(typename T::value_type) 的求值将导致替换失败(如果存在,sizeof(...) 的结果总是非零,因此整个逻辑表达式 sizeof(...) != 0 不会失败)。

这种方法的优点是,如果需要同时检查多个条件或多个潜在的失败点,可以很容易地组合多个表达式:

template <typename T, std::enable_if_t<
  sizeof(typename T::value_type) !=0 &&
  sizeof(typename T::size_type) !=0, bool> = true>
std::true_type test_value_type(int);

或者使用更传统的 SFINAE 技巧:

template <typename T,
          bool = sizeof(typename T::value_type)>
std::true_type test_value_type(int);

这种习惯并不好:虽然它有时能奏效,看起来也更容易编写,但有一个缺点。通常,需要声明多个具有不同条件的重载,使得其中只有一个能够成功。可以使用前面介绍的方法来实现:

template <typename T, std::enable_if_t<cond1, bool> = true>
res_t func();

template <typename T, std::enable_if_t<cond2, bool> = true>
res_t func(); // 只要 cond1 和 cond2 中只有一个为真,这样写就是可以的

但不能用非类型模板参数的默认值方式实现类似效果:

template <typename T, bool = cond1> = true>
res_t func();

template <typename T, bool = cond2 > = true>
res_t func();

两个具有相同参数但默认值不同的模板视为重复声明,即使其中一个条件 cond1 或 cond2 总会导致替换失败。最好养成习惯,将(可能失败的)条件放在非类型模板参数的类型中,而不是其默认值中。

为了回顾关于 SFINAE 的知识,再编写一个类型特征。这一次,我们要检查某个类型是否是一个类:

// Example 23
namespace detail {
  template <typename T> std::true_type test(int T::*);
  template <typename T> std::false_type test(...);
}

template <typename T>
using is_class = decltype(detail::test<T>(nullptr));

类与非类之间的关键区别在于,类拥有成员,存在成员指针。这一次,最简便的方法是声明一个成员函数参数,其类型为成员指针(具体是什么类型的成员并不重要,因为我们并不会真正调用该函数)。如果类型 T 不具备成员,则在参数类型 T::* 上的替换将发生失败。

这几乎就是标准类型特征 std::is_class 的定义方式,唯一的区别是标准版本还会排除联合体:std::is_class 不认为联合体是类。但实现 std::is_union 需要编译器内置支持,而非仅靠 SFINAE 就能完成。

我们所学的这些技术,能够编写用于检查类型特定属性的类型特征:该类型是否为指针、是否具有某个嵌套类型或成员等。另一方面,概念则使检查类型的行为变得容易:该类型是否可解引用?两种类型是否可比较?等。我说的是“容易”而非“可能” —— 当然可以用概念来检查非常狭义的特征,也可以用类型特征来检测行为,但后者往往不那么直接。

本章主要面向在应用程序代码中,编写模板和模板库的开发者:如果正在开发像 STL 那样复杂且严谨的库,必须对定义极其精确(也需要一个标准委员会来反复辩论并打磨这些定义,以达到所需的精确度)。但对于其余大多数人而言,“如果 *p 能编译,就调用 f(p)” 这种程度的形式化通常已经足够。在 C++20 中,可以使用概念来实现。如果尚未使用 C++20,则必须使用其中一种 SFINAE 技术。本章讨论了若干此类技术;多年来,社区还发展出了更多方法。

然而,概念的发展对这些实践产生了有趣的影响:除了可以在 C++20 中直接使用的工具之外,标准还为我们提供了一种思考此类问题的方式,这种思维方式具有更广泛的适用性。一些在某种程度上类似于概念的技术(例如,在尾随的 decltype() 中测试行为)正变得越来越流行,而其他一些旧有实践则逐渐淘汰。甚至已有多个尝试,试图使用 C++20 之前的语言特性实现一个“概念库”。当然,完全复制概念是不可能的,甚至无法接近其功能。然而,即使无法使用概念语言本身,仍然可以从其设计思想中获益。因此,可以“以概念之精神”来使用 SFINAE,从而提供一种方式来实现基于 SFINAE 的约束,取代以往那些临时拼凑的技术集合。

7.5.3 概念出现之前的概念实现方法

我们的目标并不是在此实现一个完整的概念库:可以在网上找到这样的库,而本书的重点是设计模式和最佳实践,而非编写特定的库。本节的目的是在众多可用的选项中,精选出少数几种最佳的基于 SFINAE 的技术。这些技术尽可能地契合基于概念的思维方式。我们未选择的方法和技巧未必就差,但本节提供了一套一致、统一,且足以满足绝大多数应用程序开发者需求的 SFINAE 工具和实践。

就像真正“概念”的使用方式一样,我们需要两种类型的实体:概念和约束。

观察概念的使用方式,形式上非常类似于布尔常量变量:

template <typename R> concept Range = ...;
template <typename R> requires(Range<R>) void sort(...);

requires() 子句需要一个布尔值,并不局限于概念(可以考虑表达式 requires(std::is_class_v<T>)),概念 Range<R> 的行为就像一个布尔值。

因此,在尝试模拟概念的行为时,将使用 constexpr bool 变量而不是概念。从将 Range<R> 与 std::is_class_v<T> 进行比较中,还可以推断出,类似 特征 的机制可能是实现概念的最佳选择:毕竟,std::is_class_v 也是一个 constexpr bool 变量。

根据上一节中我们学到的 特征 实现方法,则需要两个重载:

template <typename R> constexpr yes_t RangeTest(some-args);
template <typename R> constexpr no_t RangeTest(...);

第一个重载将对满足 Range 要求的类型 R 有效且优先选用(当弄清楚如何实现)。第二个重载始终可用但永远不会优先选用,只有在没有其他重载可选时才会调用。

可以通过返回类型来判断调用了哪个重载(yes_t 和 no_t 只是尚未选定的某些类型的占位符)。但其实有更简单的方法;对于 Range 的“概念”,真正需要的只是一个常量布尔值,那么为何不让 constexpr 函数直接返回正确的值呢?

template <typename R> constexpr bool RangeTest(some-args) {
  return true;
}

template <typename R> constexpr bool RangeTest(...) {
  return false;
}

template <typename R>
constexpr inline bool Range = RangeTest<R>(0);

最后两条语句(变量和备用重载)已经完整了。“只需”确保当 R 不是范围时,第一个重载会产生替换失败即可。那么,就我们的目的而言,什么是范围呢?我们将把范围定义为具有 begin() 和 end() 的类型。由于正在测试一种特定的行为,这种行为可能会导致编译失败,但不应引发错误,因此应该在 SFINAE 上下文中触发这种失败。正如之前所见,最容易放置这种可能无效代码的地方就是尾部返回类型:

template <typename R>
constexpr auto RangeTest(??? r) -> decltype(
  std::begin(r), // 范围具有 begin() 成员
  std::end(r), // 范围具有 end() 成员
  bool{} // 但函数的返回类型应为 bool
) { return true; }

尾部返回类型允许编写使用参数名称的代码,需要一个类型为 R 的参数 r。当在预期被调用的模板函数中使用 SFINAE 时,这很容易实现。但这个函数永远不会用一个实际的范围来调用。我们可以尝试声明一个类型为 R& 的参数,然后用默认构造的范围 R{} 来调用该函数,但这行不通,constexpr 函数必须具有 constexpr 参数(否则仍然可以调用,但不能在常量表达式中调用,即不能在编译时调用),而 R{} 对于大多数范围来说不会是 constexpr 值。

我们可以完全放弃使用引用,转而使用指针:

// Example 24
template <typename R>
constexpr auto RangeTest(R* r) -> decltype(
  std::begin(*r), // 范围具有 begin() 成员
  std::end(*r), // 范围具有 end() 成员
  bool{} // 但函数的返回类型应为 bool
) { return true; }

template <typename R> constexpr bool RangeTest(...) {
  return false;
}

template <typename R>
constexpr inline bool Range = RangeTest<R>(nullptr);

可能以为“类概念”的SFINAE会极其复杂,但实际上,定义像Range这样的概念只需要以下两行代码:

static_assert(Range<std::vector<int>>);
static_assert(!Range<int>);

这两条语句看起来与C++20中的等价版本完全一样!我们的“概念”甚至可以在C++14中使用,只是C++14中没有内联变量,所以必须使用static代替。

目前关于概念的内容就告一段落了,还需要处理一下约束的问题,在这方面的成果将要有限得多。首先,由于使用的是SFINAE,因此只能对模板函数参数施加限制(C++20的约束甚至可以应用于非模板函数,比如类模板的成员函数)。此外,能编写这些约束的位置也非常有限。最通用的方法是给模板添加一个非模板参数,并在那里测试约束:

template <typename R,
        std::enable_if_t<Range<R>, bool> = true>
void sort(R&& r);

可以将这些样板代码隐藏在一个宏中:

// Example 24
#define REQUIRES(...) \
  std::enable_if_t<(__VA_ARGS__), bool> = true

template <typename R, REQUIRES(Range<R>)> void sort(R&& r);

这个可变参数宏巧妙地解决了宏在参数为代码时常见的问题:逗号会解释为参数之间的分隔符。这当然远不如 C++20 的约束方便,但已经是我们最接近概念的效果了。

现在让我们回到概念上来。之前写的代码是可行的,但存在两个问题:首先,同样存在大量样板代码。其次,必须使用指针来引入函数参数名,以便后续用来测试所需的行为。这限制了所能要求的行为范围,函数可以按引用传递参数,而行为可能取决于所使用的引用类型,但无法对引用形成指针。事实上,我们刚才写的代码在很多情况下都无法编译,模板函数 sort() 的参数 R 的类型会推导为引用类型。为了可靠地使用它,必须检查其底层类型:

// Example 24
template <typename R, REQUIRES(Range<std::decay_t<R>>)>
void sort(R&& r);

如果能使用引用参数,那将会方便得多,但这样就回到了之前遇到的问题:如何调用这样的函数?不能使用对应类型的值(例如 R{}),它不是常量表达式。如果尝试将 R{} 用作默认参数值,也会遇到同样的问题 —— 它仍然不是常量表达式。

与软件工程中的大多数问题一样,这个问题可以通过增加一层间接性来解决:

template <typename R>
constexpr static auto RangeTest(R& r = ???) ->
  decltype(std::begin(r), std::end(r));

template <typename R> // 成功时的重载
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }

template <typename R> // 备用重载
constexpr bool RangeTest(...) { return false; }

template <typename R>
constexpr static bool Range = RangeTest<R>(0);

我们的备用重载保持不变,但现在,当 SFINAE 测试成功时,将调用的重载会在 decltype 上下文中尝试调用 RangeTest(r)(此外,我们又回到了使用 int 而不是指针作为占位符参数)。最后一个问题是,参数 r 的默认值应该使用什么。

在永远不会被调用的代码中获取对象引用的常用方法是 std::declval,因此我们可能会这样写:

template <typename R>
constexpr static auto RangeTest(R& r=std::declval<R>()) ->
  decltype(std::begin(r), std::end(r));

但这将无法编译,错误信息可能会是“std::declval 不得被使用”。这很奇怪,实际上并没有真正使用它(整个函数仅在 decltype() 内部使用),让我们尝试绕过这个问题。毕竟,std::declval 并没有什么魔法,只需要一个能返回我们对象引用的函数即可:

template <typename T> constexpr T& lvalue();

template <typename R>
constexpr static auto RangeTest(R& r = lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));

在符合标准的编译器上,这同样无法编译,但错误信息会有所不同,编译器这次可能会提示:

inline function 'lvalue<std::vector<int>>' is used but not defined."

好的,我们可以定义这个函数,但要确保它永远不会调用:

template <typename T> constexpr T& lvalue() { abort(); }

template <typename R>
constexpr static auto RangeTest(R& r = lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));

添加 { abort(); } 后,情况就完全不同了 —— 现在程序可以编译了,并且(在补全其余缺失部分后)运行时不会触发中止。这正是我们期望的行为:函数 lvalue() 仅在 decltype 内部使用,其具体实现完全无关紧要。这实际上是标准本身的一个问题;如果想深入了解其中的复杂细节,可以查阅核心议题 1581:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0859r0.html。目前,只能保留这个无用的函数体(反正它也不会造成影响)。当然,也可以为默认的右值参数以及 const 左值引用定义类似的函数,并将它们放在某个仅用于实现的命名空间中:

namespace concept_detail {
  template <typename T>
    constexpr const T& clvalue() { abort(); }
  template <typename T> constexpr T& lvalue() { abort(); }
  template <typename T> constexpr T&& rvalue() { abort(); }
}

现在,可以定义用于测试特定引用类型行为的概念了:

// Example 24a
template <typename R>
constexpr static auto RangeTest(
  R& r = concept_detail::lvalue<R>()) ->
  decltype(std::begin(r), std::end(r));

template <typename R>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }

template <typename R>
constexpr bool RangeTest(...) { return false; }

template <typename R>
constexpr static bool Range = RangeTest<R>(0);

约束条件(包括我们的 REQUIRES 宏)的工作方式仍然完全相同(毕竟,概念本身没有改变 —— Range 仍然是一个常量布尔变量)。

样板代码的问题依然存在,由于繁琐的默认参数值,问题变得更严重了。不过,借助一些宏,这个问题最容易解决:

// Example 25
#define CLVALUE(TYPE, NAME) const TYPE& NAME = \
  Concepts::concept_detail::clvalue<TYPE>()

#define LVALUE(TYPE, NAME) TYPE& NAME = \
  Concepts::concept_detail::lvalue<TYPE>()

#define RVALUE(TYPE, NAME) TYPE&& NAME = \
  Concepts::concept_detail::rvalue<TYPE>()

在这三个模板函数(例如 RangeTest)中,第一个函数相当于 C++20 的概念声明 —— 想要要求的行为就在这里编码。除了这些宏之外,实际上已经无法再进一步简化了:

// Example 25
template <typename R> CONCEPT RangeTest(RVALUE(R, r)) ->
  decltype(std::begin(r), std::end(r));

这里也定义了一个宏:

#define CONCEPT constexpr inline auto

这样做与其说是为了缩短代码,不如说是为了让读者(即使不是编译器)清楚地知道,我们正在定义一个概念。将其与 C++20 的语句进行比较:

template <typename R> concept Range =
  requires(R r) { std::begin(r); std::end(r); };

另外两个重载(RangeTest(int) 和 RangeTest(...)),以及概念变量本身的定义,都可以轻松地为概念实现通用化(当然,名称除外)。事实上,从一个概念到另一个概念,唯一变化的声明就是第一个:

template <typename R>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<R>(), bool{}) { return true; }

可以让这个宏适用于概念测试函数,方法是使用可变参数模板:

// Example 25
template <typename... T>
constexpr static auto RangeTest(int) ->
  decltype(RangeTest<T...>(), bool{}) { return true; }

由于所有的参数宏(例如 LVALUE())都包含了每个参数的默认值,该函数总是可以无需参数地调用。注意,我们定义的测试函数可能与函数 RangeTest(int) 发生冲突。在此处不会发生这种情况,int 不是一个有效的范围,但对于其他参数则可能发生。由于控制着这些通用重载以及概念变量本身的定义,可以确保它们使用的参数不会与常规代码中编写的内容发生冲突:

// Example 25
struct ConceptArg {};

template <typename... T>
constexpr static auto RangeTest(ConceptArg, int) ->
  decltype(RangeTest<T...>(), bool{}) { return true; }

template <typename T>
constexpr bool RangeTest(ConceptArg, ...) { return false; }

template <typename R>
constexpr static bool Range = RangeTest<R>(ConceptArg{},0);

这段代码对于所有概念来说都是相同的,除了像 Range 和 RangeTest 这样的名称。一个宏就可以仅通过两个命名参数,就可以生成所有代码:

// Example 25
#define DECLARE_CONCEPT(NAME, SUFFIX) \
template <typename... T> constexpr inline auto \
  NAME ## SUFFIX(ConceptArg, int) -> \
  decltype(NAME ## SUFFIX<T...>(), bool{}){return true;} \
template <typename... T> constexpr inline bool \
  NAME ## SUFFIX(ConceptArg, ...) { return false; } \
template <typename... T> constexpr static bool NAME = \
  NAME ## SUFFIX<T...>(ConceptArg{}, 0)

我们之前没有这样做是为了简洁,但如果想在代码中使用这些类似概念的工具,应该将所有实现细节隐藏在一个命名空间中。

现在,可以按如下方式定义我们的范围概念:

// Example 25
template <typename R> CONCEPT RangeTest(RVALUE(R, r)) ->
  decltype(std::begin(r), std::end(r));
DECLARE_CONCEPT(Range, Test);

得益于可变参数模板,我们的概念不再局限于只有一个模板参数。以下是一个针对两种可相加类型的概念:

// Example 25
template <typename U, typename V> CONCEPT
  AddableTest(CLVALUE(U, u), CLVALUE(V, v)) ->
  decltype(u + v);
DECLARE_CONCEPT(Addable, Test);

作为对比,这是 C++20 版本的样子:

template <typename U, typename V> concept Addable =
  require(U u, V v) { u + v; }

当然,C++20 版本要短得多,也更强大。但 C++14 版本已经是我们能实现的最接近的效果了(虽然这不是唯一的方法,但其他方法的结果也大同小异)。

这些“伪概念”可以像 C++20 概念一样,用于约束模板:

// Example 25
template <typename R, REQUIRES(Range<R>)> void sort(R&& r);

好的,这些“伪概念”并不能完全像 C++20 概念那样使用 —— 仅限于模板函数,并且约束都必须至少涉及一个模板参数。如果想限制模板类中的非模板成员函数,就必须“玩”模板的技巧:

template <typename T> class C {
  template <typename U = T, REQUIRE(Range<U>)> void f(T&);
  ...
}

但最终我们确实得到了相同的结果:对 vector 调用 sort 可以编译,而对非范围类型的对象调用 sort 则无法编译:

std::vector<int> v = ...;
sort(v); // 没问题
sort(0); // 无法通过编译

但我们的伪概念真正不足的地方在于错误信息 —— C++20 编译器通常会告诉我们是哪个概念未被满足以及原因,而模板替换错误信息则很难解读。

顺便提一下,当编写测试以确保某些代码无法编译时,现在可以使用概念(或伪概念)来实现:

// Example 25
template <typename R>
CONCEPT SortCompilesTest(RVALUE(R, r))->decltype(sort(r));

DECLARE_CONCEPT(SortCompiles, Test);

static_assert(SortCompiles<std::vector<int>>);
static_assert(!SortCompiles<int>);

C++20 版本就留给各位读者练习了。

在本章结束之前,再来看一下在模板中使用 SFINAE 和概念的建议和最佳实践。