CTTCG 20 Overloading on Type Properties
TLDR:几种 SFINAE 的模式
- 对于函数参数列表非空的函数模板,可以用非 varargs 函数实现功能,varargs 作为 fallback。
- 类模板可以用偏特化实现功能重载,用带模板默认参数的原始模板作为 fallback。
- 模板用 tag dispatching 不会有重复定义的问题。
std::enable_if
用在函数模板的模板默认参数上表达更简洁,但用在返回值上可避免重复定义。对比起来,类模板虽然能支持偏特化,但是也没有用返回值 SFINAE 避免重复的好处。(不过 C++20 有了 requires 就无所谓了。)- concept、constexpr if 等。
1 和 2 其实分别适应了函数模板和类模板的特点:函数模板不能偏特化,类模板不是函数因而不能使用 varargs。
几个难点:
- 找不到能匹配的函数:需要设计 fallback。
- 找到了多个能匹配且优先级不能区分的函数(ambiguous):需要设计互斥条件。
- 重复定义同一模板(只有模板默认参数不同):
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
更加简洁。不过也有缺点:
- 算法被写成了一个大函数。
- 可能缺少 SFINAE 安全性保证。在 if constexpr 中发生的匹配失败会导致编译错误。
第五种方案:Concept
功能和 std::enable_if
是等效的,但是由语言直接支持。好处是:
- 表达更直接,代码易读。
requires
语法甚至能用在模板类的非模板函数上,这样就能选择性地实现函数:
template<typename T>
class Container {
public:
// ...
void sort() requires HasLess<T> {
// ...
}
};
- 不会出现
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;