SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)主要是指在函数模板重载时,当模板形参替换为指定的实参或由函数实参推导出模板形参的过程中出现了失败,则放弃这个重载而不是抛出一个编译失败。它是模板推导的一个特性,虽然在C++03标准中没有明确禁止它,但是那时该特性并没有在标准中明确规定哪些符合SFINAE,哪些应该抛出编译错误。这样,也就很少有编译器会支持它,毕竟这个特性的开发代价可不小。有一些看起来顺理成章的代码却是无法通过编译的。比如提案文档中的这个例子:
template <int I> struct X {};
char foo(int);
char foo(float);
template <class T> X<sizeof(foo((T)0))> f(T)
{
return X<sizeof(foo((T)0))>();
}
int main()
{
f(1);
}上面的代码在不支持C++11的编译器上很有可能是无法成功编译的(请注意,该例子要使用GCC4.3之前的版本编译,因为GCC4.3已经逐步开始支持C++0x)。主要原因是编译器无法推导像sizeof(foo((T)0))这样的表达式。虽然在我们看来这是一个简单的表达式,但是要让编译器处理它可不容易,何况当时还没有明确的标准。这种情况明显地限制了C++ 模板的推导能力,所以在C++11标准中明确规范了SFINAE规则,可以发现上面的代码在任何一个支持C++11的编译器中都能顺利地编译通过。
在 SFINAE 规则中,模板形参的替换有两个时机,首先是在模板推导的最开始阶段,当明确地指定替换模板形参的实参时进行替换;其次在模板推导的最后,模板形参会根据实参进行推导或使用默认的模板实参。这个替换会覆盖到函数模板和模板形参中的所有类型和表达式。
以上这些都由编译器处理完成,程序员不必追溯太多细节。对于程序员而言,需要清楚的是哪些情况符合替换失败,而哪些情况会引发编译错误。实际上最初在区分替换失败和编译错误的时候有许多模糊不清的地方,后来标准委员会发现定义编译错误比替换失败更加容易,所以他们提出了编译错误的情况,而剩下的就是替换失败。
标准中规定,在直接上下文中使用模板实参替换形参后,类型或者表达式不符合语法,那么替换失败;而替换后在非直接上下文中产生的副作用导致的错误则被当作编译错误,这其中就包括以下几种。
1.处理表达式外部某些实体时发生的错误,比如实例化某模板或生成某隐式定义的成员函数等。
2.由于实现限制导致的错误,关于这一点可以理解为,虽然我们写出的可能是正确的代码,但是编译器实现上的限制造成了错误甚至编译器崩溃都被认为是编译错误。
3.由于访问违规导致的错误。
4.由于同一个函数的不同声明的词法顺序不同,导致替换顺序不同或者根本无法替换产生的错误。
template<class T>
T foo(T& t)
{
T tt(t);
return tt;
}
void foo(…) {}
int main()
{
double x = 7.0;
foo(x);
foo(5);
}在上面的代码中,编译器会将foo(x)调用的函数模板推导为double foo(double&),而且推导出来的函数是符合语法的。另外,编译器也会尝试用template<class T> T foo(T& t)来推导foo(5),但是编译器很快发现无论怎么推导都无法满足语法规则,所以编译器无奈之下只能产生一次替换失败并将这个调用交给void foo(…)。可以看到,这份代码虽然经历了一次替换失败,但是还是能编译成功。现在我们在保持foo函数定义不变的情况下,改变foo函数的实参,让代码产生一个编译错误:
class bar
{
public:
bar() {};
bar(bar&&) {};
};
int main()
{
bar b;
foo(b);
}在上面的代码中,编译器会尝试用template<class T> T foo(T& t)来推导foo(b),其结果为bar foo(bar&)。请注意,这里在直接上下文中最终的替换结果是符合语法规范的,所以它并不会引发替换失败,更加不会让编译器调用void foo(…),这个时候的编译器坚信这样替换是准确无误的。但问题是当替换完成并且进行下一步的编译工作时,编译器发现bar这个类根本无法生成隐式的复制构造函数,想使用替换失败为时已晚,只能抛出一个编译错误。继续看下面一条编译错误的例子:
template<class T>
T foo(T*)
{
return T();
}
void foo(…) {}
class bar
{
bar() {};
};
int main()
{
foo(static_cast<bar *>(nullptr));
}上面的代码会编译报错,原因和上一个例子有些不同,这里的原因是访问违规。不过整体的推导过程非常相似,首先编译器会尝试用template<class T> T foo(T*)来推导foo(static_cast<bar *>(nullptr)),其结果为bar foo(bar*),同样,这里的替换结果也符合语法规范,所以编译器顺利地进行下面的编译。但是由于类bar的构造函数是一个私有函数,以至于foo函数无法构造它,因此就造成了编译错误。最后,下面的例子展示了多个词法顺序不同的声明导致函数替换编译错误的情况:
template <class T> struct A { using X = typename T::X; };
template <class T> typename T::X foo(typename A<T>::X);
template <class T> void foo(…) { }
template <class T> auto bar(typename A<T>::X) -> typename T::X;
template <class T> void bar(…) { }
int main()
{
foo<int>(0);
bar<int>(0);
}在上面的代码中,foo<int>(0)可以编译通过,bar<int>(0)则不行。因为在foo<int> (0)中T::X并不符合语法规范且这是一个直接上下文环境,所以在模板替换的时候会发生替换失败,最后使用template <class T> void foo(…)的函数版本。但是bar<int>(0)和foo<int>(0)不同,它的模板声明方法是一个返回类型后置,这样在推导和替换的时候会优先处理形参。而参数类型A<int>::X实例化了一个模板,它不是一个直接上下文环境,所以不会产生替换失败,编译器也就不会尝试重载另外一个bar的声明从而导致编译错误。
到此为止我们花了很大篇幅来叙述替换导致编译错误,却很少提及SFINAE规则的用法,原因之前也提到过,但是这里有必要再重申一次:除了上述会导致编译错误的情况外,其他的错误均是替换失败。明确了编译错误的条件后,我们就可以自由地使用SFINAE规则了:
struct X {};
struct Y { Y(X) {} }; // X 可以转化为 Y
X foo(Y, Y) { return X(); }
template <class T>
auto foo(T t1, T t2) -> decltype(t1 + t2) {
return t1 + t2;
}
int main()
{
X x1, x2;
X x3 = foo(x1, x2);
}上面的代码是标准文档中的一个例子,在这个例子中foo(x1, x2)会优先使用auto foo(T t1, T t2) -> decltype(t1 + t2)来推导,不过很明显,x1 + x2不符合语法规范,所以编译器产生一个替换失败继而使用重载的版本X foo(Y, Y),而这个版本形参Y正好能由X转换得到,于是编译成功。再来看一个非类型替换的SFINAE例子:
#include <iostream>
template <int I> void foo(char(*)[I % 2 == 0] = 0) {
std::cout << "I % 2 == 0" << std::endl;
}
template <int I> void foo(char(*)[I % 2 == 1] = 0) {
std::cout << "I % 2 == 1" << std::endl;
}
int main()
{
char a[1];
foo<1>(&a);
foo<2>(&a);
foo<3>(&a);
}在上面的代码中,函数模板foo针对int类型模板形参的奇偶性重载了两个声明。当模板实参满足条件I % 2 == 0或I % 2 == 1时,会替换出一个数量为1的char类型的数组指针char(*)[1],这是符合语法规范的,相反,不满足条件时替换的形参为char(*)[0],很明显这将产生一个替换失败。最终我们看到的结果是,编译器根据实参的奇偶性选择替换后语法正确的函数版本进行调用:
I % 2 == 1
I % 2 == 0
I % 2 == 1上面的两个例子非常简单,无法体现出SFINAE的实际价值,下面让我们结合decltype关键字来看一看SFINAE是怎么在实际代码中发挥作用的:
#include <iostream>
class SomeObj1 {
public:
void Dump2File() const
{
std::cout << "dump this object to file" << std::endl;
}
};
class SomeObj2 {
};
template<class T>
auto DumpObj(const T &t)->decltype(((void)t.Dump2File()), void())
{
t.Dump2File();
}
void DumpObj(…)
{
std::cout << "the object must have a member function Dump2File" << std::endl;
}
int main()
{
DumpObj(SomeObj1());
DumpObj(SomeObj2());
}以上代码的意图是检查对象类型是否有成员函数Dump2File,如果存在,则调用该函数;反之则输出警告信息。为了完成这样的功能,我们需要用到返回类型后置以及decltype关键字。之所以要用到返回类型后置的方法是因为这里需要参数先被替换,再根据参数推导返回值类型。而使用decltype关键字有两个目的,第一个目的当然是设置函数的返回值了,第二个目的是判断实参类型是否具有Dump2File成员函数。请注意这里的写法decltype(((void)t.Dump2File()), void()),在括号里利用逗号表达式让编译器从左往右进行替换和推导,逗号右边的是最终我们想设置的函数返回值类型,而逗号左边则检查了对象t的类型是否具有Dump2File成员函数。如果成员函数存在,即符合语法规则,可以顺利地调用模板版本的函数;反之则产生替换失败,调用另一个重载版本的DumpObj函数。于是以上代码的最终输出结果如下:
dump this object to file
the object must have a member function Dump2File如果我们继续发散一下上面采用的方法,就会发现该方法不仅能用在无参数的成员函数上,对于有参数的成员函数同样适用。至于具体怎么改进,这就留给读者自由发挥吧。
虽然SFINAE的概念和规则描述起来多少有点复杂,但是我们发现其使用起来却十分自然,编译器基本上能按照我们预想的步骤进行编译。正如例子中看到的,SFINAE的引入使模板匹配更加精准,它能让某些实参享受特殊待遇的函数版本,让剩下的一部分使用通用的函数版本,毫无疑问,这样的特性对于C++的泛型能力来说是一个很大的增强。