CTTCG 20 Overloading on Type Properties

TLDR:几种 SFINAE 的模式

  1. 对于函数参数列表非空的函数模板,可以用非 varargs 函数实现功能,varargs 作为 fallback。
  2. 类模板可以用偏特化实现功能重载,用带模板默认参数的原始模板作为 fallback。
  3. 模板用 tag dispatching 不会有重复定义的问题。
  4. std::enable_if 用在函数模板的模板默认参数上表达更简洁,但用在返回值上可避免重复定义。对比起来,类模板虽然能支持偏特化,但是也没有用返回值 SFINAE 避免重复的好处。(不过 C++20 有了 requires 就无所谓了。)
  5. concept、constexpr if 等。

1 和 2 其实分别适应了函数模板和类模板的特点:函数模板不能偏特化,类模板不是函数因而不能使用 varargs。

几个难点:

  1. 找不到能匹配的函数:需要设计 fallback。
  2. 找到了多个能匹配且优先级不能区分的函数(ambiguous):需要设计互斥条件。
  3. 重复定义同一模板(只有模板默认参数不同):std::enable_if 使用不当。可以再增加一个默认参数缓解问题,或者把 std::enable_if 放在返回值位置上。

函数模板的重载

简单提供参数不同的重载

这种方式只能直接对类型本身匹配,不能对行为或类型背后的含义提出要求。

Tag Dispatching 和 std::enable_if

本节讨论了两者的在算法特化场景下的区别。前者能够做到完全互斥,但是写起来可能会比较冗长。后者判断更加灵活,但是需要注意保证互斥性(否则 ambiguous),每次添加一个新的特化都要去检查之前的特化是否需要修改;同时还要考虑重复声明的问题。

举例是标准库的迭代器就有 5 类,而且还有继承关系。这意味着我们可以用 std::is_convertible 作条件。

std::enable_if 的重复声明问题

默认参数的指定在函数模板定义时是不被考虑的。如果有两个模板函数使用了 template <typename T, typename = std::enable_if_t<...>> 的模板参数,就会有重定义错误。一个缓解的方法是再加一个默认参数:

是不是可以结合两者的优势做一个适配器 Trait,将几种 Trait 用 std::conditional 嵌套归类(比如在不使用后向迭代功能时,将双向迭代器归类为前向迭代器),然后再去实现特定 tag 的算法呢?

进行条件判断时要注意安全: std conditional 模板的安全使用

另外一种解决方案是把 std::enable_if 放在返回值的位置,而不是模板的默认参数上。缺点是返回值会看起来很冗长。可能看起来像这样?

template <class T>
std::enable_if_t<std::is_same_v<T, int>,
int> three(T) {
    return 3;
}

第三行是返回值。就是这种格式化方案非常奇怪。

还有一种是将 std::enable_if 所在参数选定为非类型模板参数,并为其提供默认值

template <class T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
T create() {
    return {};
}

template <class T, std::enable_if_t<!std::is_integral_v<T>, int> = 1>
T create() {
    return {};
}

int main() {
    auto y = create<int>();
}

在完全评估 std::enable_if 之前,编译器不能确定两组模板参数相等,而评估完成之后,就会有不满足的分支被淘汰,所以不会遇到重定义错误。

第四种方案:if constexpr

更加简洁。不过也有缺点:

  1. 算法被写成了一个大函数。
  2. 可能缺少 SFINAE 安全性保证。在 if constexpr 中发生的匹配失败会导致编译错误。

第五种方案:Concept

功能和 std::enable_if 是等效的,但是由语言直接支持。好处是:

  1. 表达更直接,代码易读。
  2. requires 语法甚至能用在模板类非模板函数上,这样就能选择性地实现函数:
template<typename T>
class Container {
public:
// ...

void sort() requires HasLess<T> {
    // ...
}

};
  1. 不会出现 std::enable_if 那样占用默认值结果导致函数模板重复定义的情况。见 std enable_if 的重复声明问题

类模板的重载

类模板有一种稳定的解决重复定义(函数模板有这个问题)的方式:偏特化。不过偏特化还是要求不能 ambiguous。

首先提供一个预留有一个默认参数的 fallback。假定类模板需要两个参数,就加第三个参数,给它一个默认值,比如 typename = int,或者 int = 0,实际上我们不会用到这个参数。

然后给预留的默认参数提供偏特化,用上 std::enable_if 等技巧。

尽管类模板有偏特化解决重复定义的方式,类模板中的函数模板(构造函数模板、成员或静态函数模板)不能使用偏特化,所以也有和类模板外的函数模板一样的问题。

如何选择 tag?

在标准库迭代器中,双向迭代器是前向迭代器。如果我们不需要向后迭代,写出来的模板的某一个版本会希望匹配到所有的前向迭代器,包括双向迭代器。但是我们又不希望把双向迭代器和前向迭代器 tag 的匹配情况都写一遍,因为这样可能会有两个模板,导致代码膨胀。

除开条件判断之外,还有一种方法是使用递归继承囊括所有 T(T) 形式的函数重载:

然后用这个模板类的 match 方法去匹配具体的参数,判断被选中函数的返回值类型,从而能够将一个 tag 概括入最贴近的范围。举个例子,如果 MatchOverloads 的模板参数是输入迭代器、前向迭代器和随机访问迭代器,那么对双向迭代器进行匹配,会将其归为前向迭代器。

问题:这是否会有更大的编译开销?感觉不如简单写一个 Trait 类,对几种迭代器做一个匹配。

处理 bool 的上下文转换(contextual conversion)

struct BoolLike {
    explicit operator bool() const { return true; } // explicit conversion to bool
};

这样的类型会被标准库的 std::is_convertable_v 判断成无法转换成 bool,因为标准库的这个 trait 只能判定隐式转换。而事实上 bool 类型还支持特殊的上下文转换:当一个类型被用在条件语句中时,就算其 bool 转换操作符被标志为 explicit,也能够进行转换。书中原文:

An explicit conversion to bool can be used implicitly in certain contexts, including in Boolean conditions for control-flow statements (if, while, for, and do), the built-in !, &&, and || operators, and the ternary operator ?:.

如果我们用是否能够隐式转换作为依据,对于 bool 的判断则而可能过于严格。如果我们只把 bool 对象作为条件使用,可以在模板函数重载上嵌入三元表达式:

#include <utility>     // for declval()
#include <type_traits> // for true_typeand false_type

template<typename T>
class IsContextualBoolT {
private:
    template<typename T> struct Identity;

    template<typename U> static std::true_type
    test(Identity<decltype(declval<U>() ? 0 : 1)>*); // HERE

    template<typename U> static std::false_type
    test(...);
public:
    static constexpr bool value = decltype(test<T>(nullptr))::value;
};

template<typename T>
constexpr bool IsContextualBool = IsContextualBoolT<T>::value;