前言
说到 C++ 的模板技术,有一个术语不得不提:SFINAE (读作 Sfee-nay,Substitution Failure is Not An Error )。这个技术使得 C++ 这样的静态语言在一定程度上可以实现类似反射的功能 (可以根据类型的特征,表现出不同的行为)。在 C++20 标准概念库发布之后,许多运用到 SFINAE 技术的场景都可以被概念取代,这一古老的技术也许也将退出历史舞台。
当然,这不是一件值得悲伤的事情,这说明标准委员会在积极地寻求摆脱历史的包袱的途径。
这篇文章旨在向想要了解 SFINAE 的读者介绍这一技术的发展历史。
什么是 SFINAE?
任何人,看到那样一长串的英文解释,或许都会懵逼。替换失败不是一个错误?什么鬼?
更加具体地说,这句话的意思是,在模板实例化的过程中,替换失败不是一个错误。
C++98 的做法
下面我将以判断一个类是否拥有 size_t size()
方法为例,来深入 SFINAE。
我们希望,假如这个类拥有 size
方法,那么就调用这个方法,否则使用另外一个泛化版本的方法。
traits
我们定义一个结构体 hasSize
作为类的特征,假如这个类拥有 size
方法,那么 hasSize::value
将会是 true
(或1),否则为 false
。
template <typename T> struct hasSize { // Compile-time Boolean typedef char yes; typedef int no; };
Q: 你一定注意到了上面的 typedef
,并且对此有些不解。它是做什么的?
A: 它是用来做编译期的对错判断的。
Q: 为什么要这么写?直接用函数返回 true
或 false
难道不好吗?
A: 确实好,但是 C++98 的函数返回值只能在运行时获得。直到 C++11 引入 constexpr
之后,这一问题才得到改善。
Q: 那为什么这么写就能达成我们的目的?
A: 我们应该还记得 C 里的一个运算符,它长得有点像函数,但与它有关的求值却全都发生在编译期。那就是 sizeof
。
const int a = sizeof(int); const bool b = sizeof(int) == sizeof(char);
上面的两个赋值语句,其赋值号右边的值均可以在编译期求得。而看到第二个语句,你一定已经恍然大悟。
下面就是我们的 SFINAE 登场的时候了。
我们在结构体内加入另外一个脚手架结构体 reallyHas
:
template <typename U, U u> struct reallyHas;
我们在参数 U
中可以给出函数指针的类型,在参数 u
中给出成员函数的具体名字。
然后给出两个函数 test
的重载版本:
template <typename U, typename = reallyHas<size_t(U::*)() const, &U::size>> static yes test(U) { } // Fallback: static no test(...) { }
第一个版本返回 yes
,接受 U
类型的变量为参数,模板参数列表里第一个是 U
,第二个参数是我们之前的脚手架 reallyHas
。
第二个版本接受可变长参数。
匹配 test
版本的过程中,会发生这样的事:
test
从参数中推导出U
的具体类型,代入模板的第一个参数,然后把所有的U
替换成这个类型。- 替换 (Substitution) 完毕,接着编译器会去查找实例化后
test
中和替换后U
有关的部分(本例中就是size_t(U::*)() const
类型的&U::size
),假如它们不存在,那么这次替换就宣告失败 (Failure)。但替换失败不是一个错误 (Error),编译器会接着匹配,直到所有候选名单 (candidates) 的成员都不匹配,才会报错。 - 随着第一个匹配失败,模板去匹配可变长参数版本的
test
。这个版本无论如何一定能匹配成功,而它的返回值类型是no
。
然后我们使用一个枚举常量 value
来接受结果:(C++11 之后便被 constexpr
取代)
enum { value = sizeof(test(T())) == sizeof(yes) };
这一过程,我们并不需要函数具体的返回值,而只是对返回值的类型作操作。这冥冥之中也印证了一句话:C++的模板是编译期的多态,是类型的多态。
当然上面的并不是最终版本,假如我们的 size
有两种可能的版本:
size_t(U::*)() const
size_t(U::*)()
那么我们就无法简单使用上面的做法了。
下面提供一种更加简洁的做法:
template <typename T> struct hasSize { typedef char yes; typedef int no; template <typename U, U u> struct reallyHas; template <typename U> static yes test(reallyHas<size_t(U::*)(), &U::size> *) { } template <typename U> static yes test(reallyHas<size_t(U::*)() const, &U::size> *) { } template <typename> static no test(...) { } enum { value = sizeof(test<T>(int())) == sizeof(yes) }; };
由于 C++ 整形可以隐式转化为指针,我们仍然会先匹配 yes
版本的 test
。
enable_if
下面我们使用之前的 hasSize
来帮助我们实现目的。我们来引入另外一个工具人:enable_if
。
template <bool, typename T> struct enable_if { typedef T type; }; template <typename T> struct enable_if<false, T> { };
看起来有点懵?不知道它要干嘛?我们继续实现我们的 getSize
函数:
template <typename T> typename enable_if<hasSize<T>::value, size_t>::type getSize(const T &obj) { std::cout << "obj has size" << std::endl; return obj.size(); } template <typename T> /* disable if: */ typename enable_if<!hasSize<T>::value, size_t>::type getSize(const T &obj) { std::cout << "obj has no size" << std::endl; return sizeof(obj); }
两处都得写上 enable_if
,否则会产生二义性。(如果其中一个 enable_if
的参数1为 true
,那么返回值是 size_t
,那么另外一个 enable_if
必然没有返回值,所以它会被排除在候选名单之外,假如另外一个函数拥有返回值,那么这个时候编译器将会不清楚应该调用哪个版本的函数,从而产生 error)
下面来试验一下:
std::vector<int> v = {4, 5, 6, 7}; char c = 'c'; std::cout << getSize(v) << std::endl; std::cout << getSize(c) << std::endl;
输出结果:
obj has size 4 obj has no size 1
时间来到 C++11
我们在讲述 C++98 的解决方法的时候,已经说过太多次:许多东西到了 C++11 会有更好的解决办法。
现在我们终于可以介绍 C++11 了。
其实本来并没有 C++11,它最早的名字叫做 C++0x,因为人们坚信在二十一世纪的前十年 C++11 的标准就能够实现,然而实际上直到2011年,C++11 才正式发布。
C++11 为模板编程带来了许多的便利。
- 首先是编译期表达式类型推导
decltype
。 - 接着是
std::declval
,这是一个模板函数,它允许我们构造一个类型T
的临时量,而无需我们提供参数对其构造。 - 还有我们之前说过好多次的
constexpr
,也是千呼万唤始出来。 std::enable_if
,它进标准了。- 当然还有新的标准库头文件,
type_traits
,它为我们提供了许许多多方便的traits
,我们不需要再自己手动实现了。
在 C++11 中,我们将使用另外一个例子——判断一个类是否是可以比较大小的。(这里以小于号为例)
我们写一个类模板 isComparable
:
template <typename T> struct isComparable { template <typename U> static constexpr bool test(decltype(std::declval<U>() < std::declval<U>()) *) { return true; } template <typename> static constexpr bool test(...) { return false; } // C++11 initializer list static constexpr bool value { test<T>(int()) }; };
在 test
的参数中用到了 decltype
和 std::declval
。用 declval
来查询是否两个 U
类型的变量重载了(或者本身就拥有)operator<
,如果拥有,则匹配成功,否则匹配失败,将会匹配可变长参数版本的 test
。
C++11 版本下我们的许多操作变得更加符合直觉,实现也更加简洁明了。
试验:
class Test1 { public: bool operator<(Test1); }; class Test2 { }; std::cout << isComparable<Test1>::value << std::endl; // 1 std::cout << isComparable<Test2>::value << std::endl; // 0
除此之外还有另一种方法:
template <typename T, typename = bool> struct isComparable: std::false_type // 继承而来的 value 成员为 false,下面类似。 { }; template <typename T> struct isComparable<T, decltype(std::declval<T>() < std::declval<T>())>: std::true_type { };
第一个版本的 isComparable
默认参数一定要设为 bool
,也就是 operator<
返回值的类型:
- 当类模板有默认参数的时候,编译器会更加偏袒那个有默认参数的模板;
- 当带有默认参数的模板与另外一个偏特化模板参数一致的时候,则会优先选择那个偏特化的版本。
于是当 [T = Test1]
,偏特化版本模板的第二个参数也是 bool
,于是选择了第二个偏特化版本的模板。
当 [T = Test2]
,SFINAE 的规则让我们不得不选择第一个版本的模板。
C++14 泛型 lambda
C++14 让我们的匿名函数支持 auto
类型的参数。它的本质其实就是带有模板括号运算符的仿函数。
auto f = [] (auto x) { return x; }; // equivalent to: struct Unnamed { template <typename T> auto operator()(T x) { return x; } } functor;
因此 SFINAE 的技术也能够适用于它。
我们可以用泛型 lambda
来实现袖珍版的 traits
。
先上效果:
class A { }; class B { public: bool operator<(B const &) const { } }; auto hasLess = is_valid([] (auto &&x) -> decltype(x < x) { }); std::cout << std::boolalpha; std::cout << hasLess(43) << std::endl; std::cout << hasLess(A()) << std::endl; std::cout << hasLess(B()) << std::endl; auto comparableWith = is_valid([] (auto &&x, auto &&y) -> decltype(x < y) { }); std::cout << comparableWith(43, "Abc"s) << std::endl; std::cout << comparableWith(43, 72.2) << std::endl; std::cout << comparableWith(B(), B()) << std::endl; std::cout << comparableWith(A(), B()) << std::endl;
输出结果:
true false true false true true false
这个 is_valid
是个啥,好神奇。下面我们就来详细解释一下:
首先,它是一个工厂函数。产生 is_valid_impl
类型的对象。
template <typename F> struct is_valid_impl { private: F _f; template <typename... Ts> static constexpr auto test(Ts&&... ts) -> decltype(_f(ts...), true) { return true; } // fallback static constexpr bool test(...) { return false; } public: constexpr explicit is_valid_impl(F &&f): _f(f) { } template <typename... Us> constexpr auto operator()(Us&&... us) { return test(us...); } }; template <typename F> is_valid_impl<F> is_valid(F &&f) { return is_valid_impl<F>(std::forward<F>(f)); }
is_valid_impl
的工作原理:
- 依靠
is_valid_impl
构造函数把仿函数对象_f
初始化。 operator()
从调用时的实参列表推导出来Us...
将运算委任给test
函数。- 首先匹配第一个版本的
test
,这个过程有 SFINAE 的参与:decltype
时,将形参代入_f
,而我们的_f
形如:[] (auto &&x, auto &&y) -> decltype(x < y) { }
,如果参数无法进行某些指定操作,或者参数长度不匹配,那么第一个版本的test
被SFINAE out
。否则匹配成功,返回true
。 - 匹配失败,这个时候就进入第二个版本的
test
,其无论如何都会返回false
。
由于 C++14 参数推导还不够智能,所以我们这里不得不使用一个工厂函数来帮助我们推导 F
的类型,而在后续标准,我们可以不再需要这个工厂函数,而直接使用构造函数了。
C++17 void_t
C++17 引入了一个 类模板std::void_t
,它可以干啥呢?接受一长串的类型,但自己永远是 void
。它其实就是一个别名模板,长成这样:
template <typename...> using void_t = void;
现在可以方便地使用 decltype
+ 逗号表达式,来完成一长串的判断,而无需判断返回值类型了(有的时候返回值类型是难以判断的,比如返回值类型带有模板参数)。
下面给出一个终极版 isComparable
:
template <typename T, typename = void> struct isComparable: std::false_type { }; template <typename T> struct isComparable<T, std::void_t<decltype(std::declval<T>() < std::declval<T>(), std::declval<T>() > std::declval<T>(), std::declval<T>() >= std::declval<T>(), std::declval<T>() <= std::declval<T>(), std::declval<T>() == std::declval<T>(), std::declval<T>() != std::declval<T>())>>: std::true_type { };
C++20 concepts
如前言所说,C++20 概念库或给 SFINAE 的时代画上一个句号。那么我们也用概念重写的 isComparable
为本文画上一个句号。
template <typename T> concept is_bool = std::is_convertible_v<T, bool>; template <typename T> concept isComparable = requires (T t) { {t > t} -> is_bool; {t < t} -> is_bool; {t >= t} -> is_bool; {t <= t} -> is_bool; {t == t} -> is_bool; {t != t} -> is_bool; }; template <typename T> concept isNotComparable = !isComparable<T>;
我们可以这么使用:
// 使用不同的概念我们可以提供不同的重载函数版本(即便参数列表相同) void foo(isComparable auto&&) { std::cout << "is comparable" << std::endl; } void foo(isNotComparable auto&&) { std::cout << "is not comparable" << std::endl; } class Test { }; foo(1); foo(Test());
结果:
is comparable is not comparable
当然我们可以把 concepts 和上面的那些类模板结合起来,用来做空基类优化,不过那就不是本文要讨论的内容了。
后记
尽管我曾在前言说过,我们毋须为 SFINAE 技术的退出而悲伤,但我认为 SFINAE 技术是老一代 C++ 工程师智慧的结晶。二十多年过去,C++ 标准从跛脚逐步开始走向完善,使用 C++ 抽象的方法日趋成熟,我想这其中不无他们的功劳。在模板技术发展的过程中,许多东西都事出偶然,然而如果没有前人的不懈尝试,这些偶然又怎会成为已经发生的必然?
当然,SFINAE 作为 C++ 本身的一个语言规则,它仍然会在底层发挥作用。不得不直接倚赖底层的东西去解决上层的问题,这是 C++ 过去的缺陷。
人们始终不停地在探索这个语言的极限,我想这才是 C++ 吸引人的地方。
如果这篇文章能给读者带来一丝启发,那就再好不过了。
参考
Jean Guegant: An introduction to C++’s SFINAE concept: compile-time introspection of a class member [https://jguegant.github.io/blogs/tech/sfinae-introduction.html] (我的 SFINAE 启蒙读物)