本文写于2021年12月,发布在本人的老博客和校科协的网站上;现转载于此。
前言
说到 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
。
1 | template <typename T> |
Q: 你一定注意到了上面的 typedef
,并且对此有些不解。它是做什么的?
A: 它是用来做编译期的对错判断的。
Q: 为什么要这么写?直接用函数返回 true
或 false
难道不好吗?
A: 确实好,但是 C++98 的函数返回值只能在运行时获得。直到 C++11 引入 constexpr
之后,这一问题才得到改善。
Q: 那为什么这么写就能达成我们的目的?
A: 我们应该还记得 C 里的一个运算符,它长得有点像函数,但与它有关的求值却全都发生在编译期。那就是 sizeof
。
1 | const int a = sizeof(int); |
上面的两个赋值语句,其赋值号右边的值均可以在编译期求得。而看到第二个语句,你一定已经恍然大悟。
下面就是我们的 SFINAE 登场的时候了。
我们在结构体内加入另外一个脚手架结构体 reallyHas
:
1 | template <typename U, U u> struct reallyHas; |
我们在参数 U
中可以给出函数指针的类型,在参数 u
中给出成员函数的具体名字。
然后给出两个函数 test
的重载版本:
1 | template <typename U, |
第一个版本返回 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
取代)
1 | enum |
这一过程,我们并不需要函数具体的返回值,而只是对返回值的类型作操作。这冥冥之中也印证了一句话:C++的模板是编译期的多态,是类型的多态(或者也可以说,类型和值本身可以等价)。
当然上面的并不是最终版本,假如我们的 size
有两种可能的版本:
size_t(U::*)() const
size_t(U::*)()
那么我们就无法简单使用上面的做法了。
下面提供一种更加简洁的做法:
1 | template <typename T> |
由于 C++ 整形可以隐式转化为指针,我们仍然会先匹配 yes
版本的 test
。
enable_if
下面我们使用之前的 hasSize
来帮助我们实现目的。我们来引入另外一个工具人:enable_if
。
1 | template <bool, typename T> |
看起来有点懵?不知道它要干嘛?我们继续实现我们的 getSize
函数:
1 | template <typename T> |
两处都得写上 enable_if
,否则会产生二义性。(如果其中一个 enable_if
的参数1为 true
,那么返回值类型是 size_t
,那么另外一个 enable_if
必然没有返回值类型(也就是type成员),所以它会被排除在候选名单之外,假如另外一个函数拥有返回值类型,那么这个时候编译器将会不明白应该调用哪个版本的函数,从而产生 error)
下面来试验一下:
1 | std::vector<int> v = {4, 5, 6, 7}; |
输出结果:
1 | obj has size |
时间来到 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
:
1 | template <typename T> |
在 test
的参数中用到了 decltype
和 std::declval
。用 declval
来查询是否两个 U
类型的变量重载了(或者本身就拥有)operator<
,如果拥有,则匹配成功,否则匹配失败,将会匹配可变长参数版本的 test
。
C++11 版本下我们的许多操作变得更加符合直觉,实现也更加简洁明了。
试验:
1 | class Test1 |
除此之外还有另一种方法:
1 | template <typename T, |
第一个版本的 isComparable
默认参数一定要设为 bool
,也就是 operator<
返回值的类型,原因是:
- 当类模板有默认参数的时候,编译器会更加偏袒那个有默认参数的模板;
- 当带有默认参数的模板与另外一个偏特化模板参数一致的时候,则会优先选择那个偏特化的版本。
于是当 [T = Test1]
,偏特化版本模板的第二个参数也是 bool
,于是选择了第二个偏特化版本的模板。
当 [T = Test2]
,SFINAE 的规则让我们不得不选择第一个版本的模板。
C++14 泛型 lambda
C++14 让我们的匿名函数支持 auto
类型的参数。它的本质其实就是带有模板括号运算符的仿函数。
1 | auto f = [] (auto x) { return x; }; |
因此 SFINAE 的技术也能够适用于它。
我们可以用泛型 lambda
来实现袖珍版的 traits
。
先上效果:
1 | class A { }; |
输出结果:
1 | true |
这个 is_valid
是个啥,好神奇。下面我们就来详细解释一下:
首先,它是一个工厂函数。产生 is_valid_impl
类型的对象。
1 | template <typename 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
。它其实就是一个别名模板,长成这样:
1 | template <typename...> |
现在可以方便地使用 decltype
+ 逗号表达式,来完成一长串的判断,而无需判断返回值类型了(有的时候返回值类型是难以判断的,比如返回值类型带有模板参数)。
下面给出一个终极版 isComparable
:
1 | template <typename T, |
C++20 concepts
如前言所说,C++20 概念库或给 SFINAE 的时代画上一个句号。那么我们也用概念重写的 isComparable
为本文画上一个句号。
1 | template <typename T> |
我们可以这么使用:
1 | // 使用不同的概念我们可以提供不同的重载函数版本(即便参数列表相同) |
结果:
1 | is 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 启蒙读物)