目录
15.7 SFINAE(Substitution Failure Is Not An Error)
参考cpp-templates-2nd/第15章 模板实参推导.md at master · r00tk1ts/cpp-templates-2nd (github.com)
如果每个函数模板都要显式地指定模板实参,那么代码一下子就变得笨重起来(型如:concat
)。幸运的是,C++编译器常常可以自动判断模板实参类型,这是通过一个十分高效的过程——模板实参推导——来完成的。
- template<typename T>
- T max(T a, T b)
- {
- return b < a ? a : b;
- }
-
- auto g = max(1, 1.0);
这里第一个调用实参的类型是int
,因此我们原生的max()
模板的参数T
会被姑且推导成int
。然而,第二个调用实参是double
类型,基于此,T
会被推导为double
:这就与前一个推导产生了矛盾。注意:我们称之为“推导过程失败”,而不是“程序非法”。毕竟,可能存在另一个名为max
(函数模板可以像普通函数那样被重载;参考P15节1.5和第16章)的模板,它的推导可以成功。
即使所有被推导的模板实参都可以一致地确定(即不产生矛盾),推导过程仍然可能会失败。这种情况发生于:在函数声明中,进行替换的模板实参可能会导致无效的结构。请看下例:
- template<typename T>
- typename T::ElementT at(T a, int i)
- {
- return a[i];
- }
-
- void f(int* p)
- {
- int x = at(p, 7);
- }
这里T
被推导为int*
(T
出现的地方只有一种参数类型,因此显然不会有矛盾)。然而,将T
替换为int*
在C++中对于返回类型T::ElementT
来说显然是非法的,因此推导还是失败了。
比仅是一个T
要复杂得多的参数类型也可以匹配给定的实参类型。这里有一些相当基础的例子:
- template<typename T>
- void f1(T*);
-
- template<typename E, int N>
- void f2(E(&)[N]);
-
- template<typename T1, typename T2, typename T3>
- void f3(T1 (T2::*)(T3*));
-
- class S {
- public:
- void f(double*);
- };
-
- void g(int*** ppp)
- {
- bool b[42];
- f1(ppp); // deduces T to be int**
- f2(b); // deduces E to be bool and N to be 42
- f3(&S::f); // deduces T1 = void, T2 = S, and T3 = double
- }
复杂的类型声明都是用比它更简单的结构(例如指针、引用、数组、函数声明;成员指针声明;模板ID等)来组成的,匹配过程从最顶层结构开始处理,向下递归到各种组成元素。可以说基于这一方法,大部分类型声明结构都可以进行匹配,而这些结构也被称为“推导上下文“。
SFINAE(替换失败并非错误)原则在P129节8.4中介绍过,它是模板实参推导中在重载决议期间防止不相干的函数模板产生错误的关键先生。
例如,考虑这样一对函数模板,它们从给定的容器或数组榨取起始的迭代器:
- template<typename T, unsigned N>
- T* begin(T (&array)[N])
- {
- return array;
- }
-
- template<typename Container>
- typename Container::iterator begin(Container& c)
- {
- return c.begin();
- }
-
- int main()
- {
- std::vector<int> v;
- int a[10];
-
- ::begin(v); // OK: only container begin() matches, because the first deduction fails
- ::begin(a); // OK: only array begin() matches, because the second substitution fails
- }
第二个begin()
调用的实参是一个数组,也会部分失败:
begin()
推导成功,T
被推导为int
,N
被推导为10
。begin()
来说,推导需要将Container
替换为int[10]
,这本身没有问题,但是如此产生的返回类型Container::iterator
却是无效的(因为数组类型并没有嵌套的名为iterator
的类型)。在其他上下文中,试图访问一个本不存在的嵌套类型会立即导致一个编译期错误。而在模板实参的替换中,SFINAE会将这种错误转换成推导失败,并且不再将这一函数模板纳入考虑。因此,第二个begin()
候选会被忽略,第一个begin()
函数模板的特化体会被调用。SFINAE阻止了那些无效类型或表达式的生成,包括因歧义或非法访问控制所产生的错误,它们发生在函数模板替换的立即上下文中。比起定义“函数模板替换的立即上下文”,对“不在该上下文中”进行定义可能更为容易。具体来说,在函数模板替换过程中,为了推导而发生的下面这些实例化期间的事,都不在函数模板替换的立即上下文中:
此外,任何由替换过程所触发的特殊成员函数的隐式定义也不属于替换的立即上下文。除这些以外,其余部分都被算在立即上下文中。
因此,如果在替换函数模板声明的模板参数时需要类模板实例化(因为该类被引用了),则实例化过程产生的错误并不在函数模板替换的即时上下文中,因此它会产生一个真正的错误(即使另一个函数模板可以无错误地匹配上)。例如:
- template<typename T>
- class Array {
- public:
- using iterator = T*;
- };
-
- template<typename T>
- void f(Array
::iterator first, Array::iterator last) ; -
- template<typename T>
- void f(T*, T*);
-
- int main()
- {
- f<int&>(0, 0); // ERROR: substituting int& for T in the first function template
- // instantiates Array
, which then fails - }
本例与前例最主要的差别在于失败发生的位置。前例中,失败发生在形成一个类型为typename Container::iterator
之时,它在begin()
函数模板替换的立即上下文中。而本例中,失败发生在Array
的实例化体中,尽管它是由函数模板上下文所触发,但实际上是发生在类模板Array
的上下文中。因此,SFINAE原则并不适用,编译器会产生一个错误。
这里有一个C++14的例子——基于推导返回类型(P296节15.10.1)——在函数模板定义的实例化时导致错误:
- template<typename T> auto f(T p) {
- return p->m;
- }
-
- int f(...);
-
- template<typename T> auto g(T p) -> decltype(f(p));
-
- int main()
- {
- g(42);
- }
调用g(42)
会推导T
为int
。这使得g()
声明的替换需要我们去确定f(p)
的类型(p
现在已知为类型int
),然后再确定f()
的返回类型。f()
有两个候选者。非模板候选者是匹配的,但它不是一个良选,这是因为它匹配的是一个省略型参数。不幸的是,模板候选者有一个推导的返回类型,因而我们必须实例化它的定义来确定该返回类型。该实例化会因为p->m
无效而失败(因为p
是int
),并且该错误发生在替换上下文之外(因为它在随后的函数定义实例化体中),这就导致本次失败会产生一个错误。为此,我们推荐在可以容易地显式化指定返回类型时,避免使用推导返回类型。
通常来说,模板推导会尝试去找到一个函数模板参数的替换,使得参数化类型P与类型A等同。然而,当无法达成这一条件,而P在推导上下文中又包含了一个模板参数时,一些差别也可以容忍:
const/volatile
限定const
或/和volatile
限定符的转换)来转换成一个替换的P类型。如果P在推导上下文中不包含模板参数,那么所有的隐式转换都是合法的。例如:
- template<typename T> int f(T, typename T::X);
-
- struct V {
- V();
- struct X {
- X(double);
- };
- }v;
- int r = f(v, 7.0); // OK: T is deduced to V through the first parameter,
- // which causes the second parameter to have type V::X
- // which can be constructed from a double value
函数调用的默认实参可以在函数模板中指定,正如普通函数:
- template<typename T>
- void init(T* loc, T const& val = T())
- {
- *loc = val;
- }
事实上,如上例所示,函数调用的默认实参可以依赖于模板参数。这种依赖型默认实参仅在没有提供显式的实参时才会被实例化。这一原则保证了下方示例的合法性:
- class S {
- public:
- S(int, int);
- };
-
- S s(0, 0);
-
- int main()
- {
- init(&s, S(7,42)); // T() is invalid for T = S, but the default
- // call argument T() needs no instantiation
- // because an explicit argument is given
- }
即使默认实参不具有依赖性,它也依然无法被用于推导模板实参。这意味着在C++中,下面的写法是非法的:
- template<typename T>
- void f(T x = 42)
- {
- }
-
- int main()
- {
- f<int>(); // OK: T = int
- f(); // ERROR: cannot deduce T from default call argument
- }
与默认实参一样,异常规范也仅仅在它们被需要时才会实例化。这意味着他们不会参与模板实参推导。例如:
- template<typename T>
- void f(T, int) noexcept(nonexistent(T())); // #1
-
- template<typename T>
- void f(T, ...); // #2 (C-style vararg function)
-
- void test(int i)
- {
- f(i, i); // ERROR: chooses #1, but the expression nonexistent(T()) is ill-formed
- }
函数标记#1处的noexcept
规范尝试调用一个nonexistent
函数。通常来说,函数模板声明中这样的错误会直接触发模板实参推导失败(SFINAE),然后再通过选择标记#2处的函数使用省略型参数匹配是重载决议中最差的匹配,参考附录C)来匹配调用f(i, i)
。然而,由于异常规范并没有参与到模板实参推导,重载决议还是会选择标记#1,这就导致当noexcept
规范在随后实例化时,程序出现问题。