第 15 章 模板实参推导
如果每个函数模板都要显式地指定模板实参,那么代码一下子就变得笨重起来(型如:concat<std::string, int>(s, 3)
)。幸运的是,C++编译器常常可以自动判断模板实参类型,这是通过一个十分高效的过程——模板实参推导——来完成的。
本章中我们将详述模板实参推导这一过程的细节。C++世界的诸多大道产生的结果向来直观,模板实参推导也不例外。深入理解本章还可以使我们日后避免遇到出人意料的情景。
模板实参推导起初是为了简化函数模板的调用而被发明出来,但随着发展,它已被扩展到各种其他用途,其中包括:根据initializer确定变量的类型。
15.1 推导过程
基本的推导过程会去比较“函数调用的实参类型”与“函数模板对应位置的参数化类型”,然后针对要被推导的一到多个参数,分别尝试去推断一个正确的替换项。每个“实参-参数对”都会独立分析,并且如果最终得出的结论有矛盾,那么推导过程就以失败告终。
考虑下面的例子:
1 |
|
这里第一个调用实参的类型是int
,因此我们原生的max()
模板的参数T
会被姑且推导成int
。然而,第二个调用实参是double
类型,基于此,T
会被推导为double
:这就与前一个推导产生了矛盾。注意:我们称之为“推导过程失败”,而不是“程序非法”。毕竟,可能存在另一个名为max
(函数模板可以像普通函数那样被重载;参考P15节1.5和第16章)的模板,它的推导可以成功。
即使所有被推导的模板实参都可以一致地确定(即不产生矛盾),推导过程仍然可能会失败。这种情况发生于:在函数声明中,进行替换的模板实参可能会导致无效的结构。请看下例:
1 |
|
这里T
被推导为int*
(T
出现的地方只有一种参数类型,因此显然不会有矛盾)。然而,将T
替换为int*
在C++中对于返回类型T::ElementT
来说显然是非法的,因此推导还是失败了。
我们仍然需要挖掘实参-参数的匹配是如何进行的。我们会使用下面的术语来进行描述:匹配类型A(调用实参的类型)和参数化类型P(调用参数的声明)。如果调用参数被声明为引用,那么P就是引用背后的类型,A是实参的类型。如果调用参数并非引用,那么P就是参数类型,而A类型则会经历数组和函数类型到指针类型的退化、以及忽略顶层const
和volatile
限定符,最终获取。例如:
1 |
|
对调用f(arr)
来说,arr
数组类型会退化为类型double*
,也就是被推导出来的T
的类型。在f(seven)
中const
限定符被忽略了,因此T被推导为int
。g(arr)
的推导则恰恰相反,T
被推导为类型double[20]
(没有发生退化)。类似地,g(seven)
有一个类型为int const
的左值实参,并且因为在匹配引用参数时,const
和volatile
限定符不会被去除,T
会被推导成int const
。然而,g(7)
想要推导T
为int
(非类的右值表达式永远不会有cv限定),这一推导最终会失败,这是因为实参7
无法作为一个int&
类型的参数被传递(译者注:右值不能传参给左值引用)。
引用型参数不会退化这一事实,对于参数为字符串字面量的场合来说可能会令人诧异。再来看看使用引用型参数的max()
模板声明:
1 |
|
对于表达式max("Apple", "Pie")
来说,我们合理的期望T
能被推导为char const*
。然而事与愿违,Apple
的类型是char const[6]
、Pie
的类型是char const[4]
。由于推导涉及了引用型参数,这里并不会进行数组到指针的退化,因此若想要推导成功,T
必须既得是char[6]
又得是char[4]
。显然,这绝无可能。可以参考P115节7.4中对于如何处理这一场景的一个探讨。
15.2 推导上下文
比仅是一个T
要复杂得多的参数类型也可以匹配给定的实参类型。这里有一些相当基础的例子:
1 |
|
复杂的类型声明都是用比它更简单的结构(例如指针、引用、数组、函数声明;成员指针声明;模板ID等)来组成的,匹配过程从最顶层结构开始处理,向下递归到各种组成元素。可以说基于这一方法,大部分类型声明结构都可以进行匹配,而这些结构也被称为“推导上下文“。然而,有一些结构不能作为推导上下文。诸如:
- 限定类型的名称。例如,形如
Q<T>::X
的类型名称永远不会用来推导模板参数T
。 - 不仅仅是非类型参数的非类型表达式。例如,形如
S<I+1>
的类型名称永远不会用于推导I
。再比如,T
也不会通过匹配形如int(&)[sizeof(S<T>)]
类型的参数来推导。
这些限制合乎常理,因为通常来说,推导并不是唯一的(甚至不一定是有限的),尽管有时候会很容易忽略这些限定类型的名称。此外,不能推导的上下文并不直接意味着:对应的程序有错误、甚至是前面分析过的参数不能再次进行类型推导。为了阐释这一事实,考虑下面这个更为错综复杂的例子:
1 |
|
在函数模板fppm()
中,子结构X<N>::I
是一个不可推导上下文。然而,具有成员指针类型(即X<N>::*p
)的成员类型部分X<N>
是一个可推导上下文。于是,可以根据这个可推导上下文获得参数N
,然后把N
放入不可推导上下文X<N>::I
,就能获得与实参&X<33>::f
相配的类型。因此基于这个实参-参数对的推导就是成功的。
反之,对于完全依赖推导上下文的参数类型来说,有可能会产生推导矛盾。例如,假设我们已恰当地声明过类模板X
和Y
:
1 |
|
第二个调用的问题在于两个实参对于参数T
的推导存在矛盾(对此二例,函数调用实参都是临时的对象,这一对象借由调用类模板X
的默认构造器而获得)。
15.3 特殊的推导情景
还有一些特殊的情景:用于推导的实参-参数对(A, P)并非来源于函数调用的实参和函数模板的参数。第一种情景出现在取函数模板地址的时候。此时,P是函数模板声明的参数化类型(即下面f
的类型),而A是被赋值(或者初始化)的指针(即下面的pf
)所代表的函数类型。例如:
1 |
|
在本例中,P是void(T, T)
,而A是void(char, char)
。推导随着T
被char
替换而成功,而pf
用特化体f<char>
的地址进行初始化。
类似地,函数类型在一些其他特殊情况下也被P和A所使用:
- 确定重载函数模板之间的偏序
- 将某个显式特化体与某个函数模板匹配
- 将某个显式实例化体与某个模板匹配
- 将某个友元函数模板特化体与某个模板匹配
- 将占位(replacement)
operator delete
或是operator delete[]
与对应的占位operator new
或operator new[]
模板匹配。
这些话题中的部分内容,以及类模板偏特化中模板实参推导的使用,会在第16章中进行展开。
另一种特殊情况和类型转换运算符模板一起出现。例如:
1 |
|
在这种情况下,对于实参-参数对(P, A),它的获取过程就好像涉及到了我们试图转换的类型的实参和转换运算符的返回类型的参数一样。下面的代码阐释了这一情景:
1 |
|
这里,我们试图把S
转换为类型int(&)[20]
,因此,类型A就是int[20]
,而类型P为T
。T
用int[20]
替换,推导得以成功。
最后,对于auto
占位类型来说,也需要一些特殊的处理。这会在P303节15.10.4中进行讨论。
15.4 初始化列表(initializer list)
当函数调用的实参是一个初始化列表时,该实参是没有特定类型的,因此通常来说,对于给定实参-参数对(A, P),不会进行任何推导,因为这里并不存在A。例如:
1 |
|
然而,如果在移除引用、顶层const和volatile限定后,参数类型P
与某个具有可推导模式的类型P'
的std::initializer_list<P'>
等价,则推导过程会将初始化列表的每个元素类型与P'
进行比较,仅当所有元素具有相同类型时,推导才会成功。
1 |
|
类似地,如果参数类型P
是对具有元素类型P'
的数组类型的引用,其中P'
是具有可推导模式的某个类型,那么推导过程也会将初始化列表的每个元素的类型与P'
进行比较,当且仅当所有元素具有相同的类型时,推导才会成功。此外,如果(数组)边界有一个可推导模式(即,使用一个非类型模板参数),那么该边界会被推导为初始化列表中元素的数量。
15.5 参数包
推导过程会逐一匹配每个实参到每个参数来确定模板实参的值。然而在对可变模板进行模板实参推导时,参数和实参之间1比1的关系就被打破了,这是因为一个参数包可以匹配多个实参。在本例中,同一个参数包(P)被匹配到了多个实参(A),并且每次匹配都会为P中的任何模板参数包产生附加值:
1 |
|
此处对首个函数参数的推导很简单,毕竟它并没有卷入任何参数包。第二个函数参数,rest
,是一个函数参数包。它的类型是一个包展开(Rest...
),其模式为类型Rest
:该模式用作P,与第二和第三调用参数的类型A进行比较。当匹配第一个A时(类型double
),模板参数包Rest
的第一个值被推导为double
。类似地,与第二个A进行匹配时,模板参数包Rest
的第二个值被推导为int*
。因此,推导确定了参数包Rest
的值序列为{double, int*}
。替换以上推导结果就可以得到函数类型void(int, double, int*)
,它与函数调用的每个实参类型相匹配。
由于对函数参数包进行推导使用了扩展的模式进行比较,所以该模式可以是任意复杂的,并且多个模板参数和参数包的值可以从每个实参类型中确定。考虑下面的函数h1()
和h2()
的推导行为:
1 |
|
对h1()
和h2()
来说,P都是引用类型,它们分别与非限定版本的引用相匹配,再次用于推导每个参数类型(分别为pair<T, Rest>
和pair<Ts, Rest>
的引用)。由于所有的参数和实参都是类模板pair
的特化,因此进行了模板实参的比较。对h1()
来说,第一个模板实参T
不是参数包,因此它的值是独立地对每个实参进行推导的。如果推导的结果出现矛盾(正如对h1
的第二次调用那样),推导就会失败。对于h1()
和h2()
中的第二个pair
模板实参Rest
、以及h2()
中的第一个pair
模板实参Ts
,推导会根据A的每个实参类型来确定一连串的参数包的值。
参数包的推导不仅限于“实参-参数对”来自调用参数的函数参数包。实际上,在函数参数列表或模板参数列表末尾的包展开处推导都会被使用。例如,考虑一个简单的Tuple
类型上的两个相似操作:
1 |
|
在f1()
和f2()
中,模板参数包都是将Tuple
类型内嵌的包展开模式与调用实参所提供的Tuple
类型进行比较,为一致的模板参数包推导出正确的值。函数f1()
对两个函数参数使用相同的模板参数包Types
,确保只有当两个函数调用实参有相同的Tuple
特化体类型时,才能推导成功。而f2()
则为每个函数参数各使用了一个参数包,因此两个调用参数可以不同——也就可以使用Tuple
的两种特化体类型。
15.5.1 字面量操作符模板
字面量操作符模板的实参通过一种独特的方式来确定。下面的例子进行了阐释:
1 |
|
这里,#2处的初始化器包含了一个用户定义的字面量(它会转换成对字面操作符模板的调用,使用的模板实参列表为<'1','2','1'>
)。因此,字面量操作符的实现体可能如下:
1 |
|
它会为121.5_B7输出'1' '2' '1' '.' '5'
。
请注意,仅在没有后缀的情况下仍然有效的数值字面量才支持此技术。例如:
1 |
|
参考P599节25.6对这一特性的应用:编译期计算整型字面量。
15.6 右值引用
C++11引入的右值引用促生了许多新技术,包括移动语义和完美转发。本节会描述右值引用与推导之间的交互。
15.6.1 引用折叠法则
开发者不允许直接声明“引用的引用”:
1 |
|
然而,当通过模板参数替换、类型别名或是decltype
结构构造类型时,“引用的引用”将被允许。例如:
1 |
|
判定像是这种组织结构的类型结果的规则,就是众所周知的引用折叠法则。首先,任何应用于内部引用顶层的const
或volatile
限定都会被舍弃(也就是说,只有内层引用的底层限定才会被保留)。此后,这两种引用会根据表15.1推导出单一引用,这种推导方式可以总结为一句话:“如果某个引用是左值引用,那么结果也一定是左值引用,否则就是右值引用”。
内层引用 | 外层引用 | 结果引用 |
---|---|---|
& | & | & |
& | && | & |
&& | & | & |
&& | && | && |
展示这一规则的更多示例:
1 |
|
这里volatile
被应用在RCI
这一引用类型(int const&
的别名)的顶层,因此会被丢弃掉。这一类型的顶层又放置了一个右值引用,但是由于底层类型是一个左值引用(左值引用在引用折叠规则中“更优先”),所以最终的类型保留为int const&
(或者RCI
类型、一个等价的别名)。类似地,RRI
的顶层const会被丢弃,在右值引用类型上应用一个右值引用,最后的结果依然是一个右值引用类型(可以绑定到像42这样的右值上)。
15.6.2 转发引用
如同P91节6.1所介绍的那样,当函数参数是一个转发引用(函数模板参数中的右值引用)时,模板实参推导会呈现另一种表现形式。此时,模板实参推导不仅会考虑函数调用实参的类型,同时也会考虑该实参是左值还是右值。如果实参是一个左值,那么模板实参推导所确定的类型就是该实参类型的左值引用类型,引用折叠规则会确保所替换的参数可以成为一个左值引用。如果实参不是左值,那么模板参数所推导的类型就是实参类型,而替代的参数是该类型的右值引用。例如:
1 |
|
在调用f(i)
中,模板参数T
被推导为int&
,因为表达式i
是一个类型为int
的左值。T
替换int&
到参数类型T&&
中需要引用折叠,这里我们使用规则&
+&&
->&
来得出结论:参数类型为int&
,如此就可以完美的接受int
类型的左值。相对的,在调用f(2)
中,实参2
是一个右值,模板参数因此直接被推导为右值的类型(即int
)。这里不需要进行引用折叠,其结果直接就是int&&
(同样地,对实参来说这是一个合适的参数类型)。
当T
被推导为一个引用类型时,对于模板的实例化来说有些有趣的效果。例如,使用类型T
声明的局部变量,在用左值实例化后,会有一个引用类型,而此时它就需要一个初始化器:
1 |
|
这就意味着函数f()
的定义需要很小心地使用类型T
,或者函数模板本身根本不为左值参数生效。为了解决这一困境,std::remove_reference
类型萃取常常被用来确保x
不是一个引用:
1 |
|
15.6.3 完美转发
右值引用特殊的推导规则和引用折叠法则组合在一起使得编写一个接受任何实参的函数模板来捕捉其表征属性(它的类型、是左值还是右值)成为了可能。函数模板此后可以“转发”这一实参给另一个函数,恰如此例:
1 |
|
上例所展示的技术被称为完美转发(perfect forwarding),因为通过forwardToG()
间接调用g()
的效果与直接调用g()
相同:没有额外的拷贝,选择的重载函数g()
也一模一样。
static_cast
的使用需要一些额外的解释。在每个forwardToG()
的实例化体中,参数x
要么是一个左值引用,要么是一个右值引用。而无论如何,表达式x
本身一定是一个(其引用类型的)左值。static_cast
会将x
转换为其原始类型(不管左值还是右值)。类型T&&
要么折叠成一个左值引用(如果原本的实参是一个左值,那么T
就是一个左值引用),要么是一个右值引用(原本的实参就是一个右值),因此static_cast
的结果就有了一致的类型,不论原本的实参是左值也好、右值也罢,如此,就实现了完美转发。
如P91节6.1所介绍的那样,C++标准库提供了一个函数模板std::forward<>()
(在头文件<utility>
中),它被用来取代static_cast
进行完美转发。相比晦涩难懂的static_cast
结构来说,使用这一模板对开发者来说更加表意,同时也防止了诸如少写了一个&
所导致的错误。那么,上面的例子可以更为简明地写成这个样子:
1 |
|
可变模板的完美转发
完美转发与可变模板搭配在一起,可以让函数模板接受任意数量的函数调用实参并将它们逐一转发到另一个函数:
1 |
|
forwardToG()
的实参会为参数包Ts
分别被推导出合适的值(见P275节15.5),因此类型以及每个参数的左值性或右值性都会被捕获。包展开(见P201节12.4.1)在调用g()
时会将每个实参都应用上述的完美转发技术进行转发。
尽管它拥有一个“完美转发”的名字,但实际上,从它不能捕获表达式所有感兴趣属性的意义上来说,完美转发实际上并不“完美”。例如,它无法区分左值是不是一个位域(bit-field)左值,也无法捕获表达式是否有特定的常量值。后者尤其在我们处理空指针常量时常常导致问题(它是一个整型类型、常量零值)。由于表达式常量值不会被完美转发所捕获,下例中的重载解析对直接调用g()
和转发调用g()
来说,表现上会有所区别:
1 |
|
这也是为什么使用nullptr
(C++11所引入)取代空指针常量的一个原因:
1 |
|
我们所有完美转发的例子都聚焦于传递的函数实参要如何保留其精准的类型以及它是一个左值或是右值。当转发函数调用的返回值需要传递给另一个函数时,也面临着同样的问题(类型和值的分类,对左值和右值的概括在附录B中进行了讨论)。可以借助C++11引入的decltype
语法(在P298节15.10.2中描述),使用这样一个有些繁琐的惯用法来解决:
1 |
|
请注意,return
语句的表达式被拷贝到了decltype
类型里,因此返回表达式的准确类型会被计算出来。尾随返回类型被使用(即,函数名称前的auto
占位符和指示返回类型的->
),使得函数参数包xs
也在decltype
类型的作用域。该转发函数会“完美地”转发所有实参给g()
,然后再“完美地”转发其返回值给调用者。
C++14引入了额外的特性来简化这一情景:
1 |
|
使用decltype(auto)
做返回类型会指示编译器通过函数定义来推导返回类型。参见P296节15.10.1和P301节15.10.3。
15.6.4 意外的推导
对完美转发来说,右值引用的特殊推导规则非常有用。然而,有时候它们可能会令人惊讶,这是因为函数模板通常会泛化函数签名中的类型,不会影响它所允许的参数是何种类型(左值或右值)。考虑下例:
1 |
|
抽象出一个像int_lvalues
那样的函数的开发者,可能会对函数模板anything
可以接受左值而感到诧异。幸运的是,只有当函数参数写成特定的模板参数&&
的形式时(作为函数模板的一部分且命名的模板参数是由该函数模板所声明),才会应用这一推导行为。因此,下面这些例子的情形都不会应用推导规则:
1 |
|
尽管模板推导规则有着这些令人惊讶的行为,在实践中,这种行为导致问题的情况并不经常出现。当出现问题时,你可以组合使用SFINAE(参考P129节8.4和P284节15.7)和诸如std::enable_if
的类型萃取来约束模板只能接受右值:
1 |
|
15.7 SFINAE(Substitution Failure Is Not An Error)
SFINAE(替换失败并非错误)原则在P129节8.4中介绍过,它是模板实参推导中在重载解析期间防止不相干的函数模板产生错误的关键先生。
例如,考虑这样一对函数模板,它们从给定的容器或数组榨取起始的迭代器:
1 |
|
第一个begin()
调用的实参是std::vector<int>
,它试图为两个begin()
函数模板做模板实参推导:
- 对数组
begin()
的模板实参推导失败了,因为std::vector
不是一个数组,所以被忽略。 - 模板实参推导对容器
begin
成功了,Container
被推导成std::vector<int>
,因此函数模板可以被实例化,也可以被调用。
第二个begin()
调用的实参是一个数组,也会部分失败:
- 对数组
begin()
推导成功,T
被推导为int
,N
被推导为10
。 - 对容器
begin()
来说,推导需要将Container
替换为int[10]
,这本身没有问题,但是如此产生的返回类型Container::iterator
却是无效的(因为数组类型并没有嵌套的名为iterator
的类型)。在其他上下文中,试图访问一个本不存在的嵌套类型会立即导致一个编译期错误。而在模板实参的替换中,SFINAE会将这种错误转换成推导失败,并且不再将这一函数模板纳入考虑。因此,第二个begin()
候选会被忽略,第一个begin()
函数模板的特化体会被调用。
15.7.1 立即上下文
SFINAE阻止了那些无效类型或表达式的生成,包括因歧义或非法访问控制所产生的错误,它们发生在函数模板替换的立即上下文中。比起定义“函数模板替换的立即上下文”,对“不在该上下文中”进行定义可能更为容易。具体来说,在函数模板替换过程中,为了推导而发生的下面这些实例化期间的事,都不在函数模板替换的立即上下文中:
- 类模板的定义(即,类模板本身以及其基类列表)
- 函数模板的定义(即,函数模板本身,对构造函数来说,是其构造初始化器)
- 变量模板初始化
- 默认实参
- 默认成员初始化
- 异常规范(exception specification)
此外,任何由替换过程所触发的特殊成员函数的隐式定义也不属于替换的立即上下文。除这些以外,其余部分都被算在立即上下文中。
因此,如果在替换函数模板声明的模板参数时需要类模板实例化(因为该类被引用了),则实例化过程产生的错误并不在函数模板替换的即时上下文中,因此它会产生一个真正的错误(即使另一个函数模板可以无错误地匹配上)。例如:
1 |
|
本例与前例最主要的差别在于失败发生的位置。前例中,失败发生在形成一个类型为typename Container::iterator
之时,它在begin()
函数模板替换的立即上下文中。而本例中,失败发生在Array<int&>
的实例化体中,尽管它是由函数模板上下文所触发,但实际上是发生在类模板Array
的上下文中。因此,SFINAE原则并不适用,编译器会产生一个错误。
这里有一个C++14的例子——基于推导返回类型(P296节15.10.1)——在函数模板定义的实例化时导致错误:
1 |
|
调用g(42)
会推导T
为int
。这使得g()
声明的替换需要我们去确定f(p)
的类型(p
现在已知为类型int
),然后再确定f()
的返回类型。f()
有两个候选者。非模板候选者是匹配的,但它不是一个良选,这是因为它匹配的是一个省略型参数。不幸的是,模板候选者有一个推导的返回类型,因而我们必须实例化它的定义来确定该返回类型。该实例化会因为p->m
无效而失败(因为p
是int
),并且该错误发生在替换上下文之外(因为它在随后的函数定义实例化体中),这就导致本次失败会产生一个错误。为此,我们推荐在可以容易地显式化指定返回类型时,避免使用推导返回类型。
SFINAE设计之初,是旨在消除由函数模板重载所带来的因非意图匹配而产生的奇怪错误,正如容器begin
这一例子。然而,探测无效表达式或类型的能力可以实现卓越的编译期技巧,以允许我们判断某个特定的语法是否是合法的。这些技巧将在P416节19.4中进行讨论。
在P424节19.4.4中,有一个特别的例子:让类型萃取SFINAE-friendly来避免立即上下文所产生的问题。
15.8 推导的限制
模板实参推导是一个强大的特性,对于大部分函数模板调用来说它消除了显式地指定模板实参的必要性,并且还使能了函数模板重载(见P15节1.5)和类模板偏特化(见P347节16.4)。然而,开发者可能会在使用模板时遇到一些使用上的限制,这些限制会在本节中进行讨论。
15.8.1 合法的实参转换
通常来说,模板推导会尝试去找到一个函数模板参数的替换,使得参数化类型P与类型A等同。然而,当无法达成这一条件,而P在推导上下文中又包含了一个模板参数时,一些差别也可以容忍:
- 如果原始的参数使用了引用声明,被替换的P类型相比A类型可以有进一步的
const/volatile
限定 - 如果A类型是一个指针或是类成员指针类型,它可以通过限定转换(换句话说,就是一种增加
const
或/和volatile
限定符的转换)来转换成一个替换的P类型。 - 除非推导发生于类型转换操作符模板,替代的P类型可以是A类型的基类或是指向其基类的指针。举个例子:
1 |
|
如果P在推导上下文中不包含模板参数,那么所有的隐式转换都是合法的。例如:
1 |
|
仅当严格匹配不可行时才会考虑宽松的匹配要求。即便附加了这些转换,推导也仅仅在可以找到满足A类型到P类型的合适替换时才会成功。
请注意,这些规则的适用范围相当狭隘,例如它不考虑为使调用成功而可行的函数实参的各种转换。比如,对下面max()
函数模板的调用(该模板在P269节15.1介绍):
1 |
|
这里,模板实参推导根据第一个实参会把T
推导为std::string
,而第二个实参会把T
推导为char[6]
,所以模板实参推导会失败,这是因为两个参数使用的是同一个模板实参。这种失败可能有些令人诧异,因为字符串字面量"hello"
可以被隐式转换成std::string
,并且调用::max<std::string>(s, "helloa")
是可行的。
或许还有更令人惊讶的:当两个实参有着从公共基类继承下来的不同的类类型时,推导并不会将公共基类作为推导类型的候选者进行考虑。可参考P7节1.2关于这一议题的讨论以及可行的解决方案。
15.8.2 类模板实参
C++17之前,模板实参推导仅仅应用于函数和成员函数模板。特别地,类模板的实参不会根据其中某一个构造器的实参来进行推导。例如:
1 |
|
这一限制在C++17中被解除——参考P313节15.12。
15.8.3 默认调用实参
函数调用的默认实参可以在函数模板中指定,正如普通函数:
1 |
|
事实上,如上例所示,函数调用的默认实参可以依赖于模板参数。这种依赖型默认实参仅在没有提供显式的实参时才会被实例化。这一原则保证了下方示例的合法性:
1 |
|
即使默认实参不具有依赖性,它也依然无法被用于推导模板实参。这意味着在C++中,下面的写法是非法的:
1 |
|
15.8.4 异常规范
与默认实参一样,异常规范也仅仅在它们被需要时才会实例化。这意味着他们不会参与模板实参推导。例如:
1 |
|
函数标记#1处的noexcept
规范尝试调用一个nonexistent
函数。通常来说,函数模板声明中这样的错误会直接触发模板实参推导失败(SFINAE),然后再通过选择标记#2处的函数使用省略型参数匹配是重载解析中最差的匹配,参考附录C)来匹配调用f(i, i)
。然而,由于异常规范并没有参与到模板实参推导,重载解析还是会选择标记#1,这就导致当noexcept
规范在随后实例化时,程序出现问题。
相同的规则适用于列出潜在异常类型的异常规范:
1 |
|
然而,这些“动态的”异常规范自C++11起就不再推荐使用(deprecated),它们在C++17中被移除。
15.9 显式的函数模板实参
当函数模板实参无法被推导时,通过尾随在函数模板名后显式地指定亦然可行。例如:
1 |
|
对可推导的模板参数来说这也是可行的:
1 |
|
一旦一个模板实参被显式指定了,其对应的参数就不再被推导。同时,函数调用的参数也被允许进行类型转换(对推导调用来说是不行的)。上例中,实参2
在compute<double>(2)
调用中会被隐式转换成double
。
也可以显式指定模板实参的其中一部分。然而,被显式指定的部分必须始终按模板参数从左到右排好顺序。因此,那些不能被推导的(或者最可能被显式指定的)参数应该放在最前面。例如:
1 |
|
有时候,通过指定一个空模板实参列表对于确保所选的函数是一个模板实例也很有用,此时模板实参还是会进行推导:
1 |
|
这里f(42)
会选择非模板函数,因为对于重载解析来说,相比函数模板,它更倾向于选择普通的函数(如果两者是等价的)。然而,对于f<>(42)
来说,模板实参列表的存在打破了这一规则,非模板函数不再可选(即使没有指定实际的模板实参)。
在友元函数声明的上下文中,显式模板实参列表的存在会产生一个有趣的效用。考虑下面的例子:
1 |
|
当使用普通的标识符命名一个友元函数时,该函数仅仅会在最近一层的封闭作用域内进行查找,如果没有找到的话,就会在该作用域内声明一个新的实体(但它会保留“不可见性”,除非通过ADL查找;参考P220节13.2.2)。这就是我们的第一个友元声明:在N
作用域内没有找到f
的声明,所以会声明一个不可见的N::f()
。
然而,当使用标识符尾随模板实参列表来命名友元函数时,模板必须在那一刻对一般查找是可见的,一般查找会向上搜索任意层作用域(根据其所需要)。因此,我们第二个声明会找到全局的函数模板f()
,但是编译器会提出一个错误:返回类型不匹配(由于没有执行ADL,故前一个友元函数的声明会被忽略)。
显式指定的模板实参使用SFINAE法则来替换:如果在某个函数模板替换的立即上下文中出现了错误,那么它就会被丢弃,但是其他模板依然可能会成功。例如:
1 |
|
这里,#1处候选者在int*
替换T
时会失败,但在#2处却会成功,因此也就会选择#2这一候选。事实上,如果在替换之后仅余一个候选者,那么带有显式模板实参的函数模板名称看起来非常像一个普通的函数名称,包括在许多情况下退化为函数指针类型。也就是说,替换上面的main()
为:
1 |
|
这会产生合法的编译单元。然而,像是下面的例子:
1 |
|
这种用法就是非法的,因为f<int*>
并没有标识着某一个单一的函数。
可变函数模板也可以使用显式模板实参:
1 |
|
有趣的是,包可以被部分显式指定、部分显式推导:
1 |
|
15.10 初始化器和表达式推导
C++11引入了声明这样一种变量的能力:其类型可以根据initializer推导。C++11也提供了一种机制来表示某个命名实体(变量或函数)或是表达式的类型。这些机制十分易用,C++14和C++17对这一主题又进行了补充。
15.10.1 auto类型指示符
auto
类型指示符在很多地方有着用武之地(主要是命名空间作用域和局部作用域),它会根据变量的初始化器推导变量类型。此时,auto
被称作为一个占位符类型(另一个占位符类型是decltype(auto)
),我们会在P298节15.10.2中对它进行描述。例如:
1 |
|
上例中的两个auto
,避免了去书写两个又臭又长的类型名称:容器的迭代器类型和迭代器的值类型:
1 |
|
auto
的推导机制与模板实参推导机制相同。类型指示符auto
取代模板类型参数T
,然后推导可以继续进行,这就好像变量是一个函数参数,而其initializer是相应的函数实参。对例子中第一个auto
来说,对应的情景如下:
1 |
|
T
是auto
要推导的类型。这样做的直接后果之一是,类型为auto
的变量永远不会是引用类型。第二个auto
使用了auto&
来展示了如何产生一个推导类型的引用。它的推导与下面的函数模板和调用等价:
1 |
|
这里,element
永远是引用类型,它的initializer无法产生一个临时对象。
组合auto
与右值引用亦是可行的,但是这样做就让它看起来像是一个转发引用,因为auto&& r = ...;
的推导模型基于这样一个函数模板:
template<typename T> void f(T&& fr); // auto replaced by template parameter T
这就解释了下面的例子:
1 |
|
在泛型代码中,这一技巧经常被用来绑定那些未知的函数或操作符调用结果的值类别(左值或是右值),而无需拷贝它们的结果。例如,常常推荐用这样的方式在循环中声明迭代值:
1 |
|
这里我们不知道容器迭代器接口的签名,但是使用auto&&
可以让我们确信在迭代时不会引入额外的值拷贝。如果需要完美转发边界值,那么std::forward<T>()
可以像往常那样对变量使用。这使能了一种“延迟的”完美转发。可以参考P167节11.3的示例。
除了引用,我们还可以组合使用auto
指示符来让某个变量拥有const
,成为指针或是成员指针等等,但是auto
必须是其声明的“主”类型。它不能嵌套在模板实参或类型指示符后面的声明符中作为一部分而存在。下面的示例予以了解释:
1 |
|
至于为什么C++不支持上例中所有的情景,并没有什么技术上的原因,只不过是,C++委员会认为它所带来的额外实现成本以及潜在的滥用性超出了它的收益。
为了避免同时搞晕开发者和编译器,在C++11中古式的auto
用法(作为一个存储类型指示符而存在)不再被允许(今后也一样):
1 |
|
auto
的古式用法(继承自C语言)一直是冗余的。大多数编译器通常可以将该用途与占位符区别开来(其实大可不必),以提供从旧C++代码到新C++代码的过渡。只不过,auto
的古式用法在实践中非常罕见。
返回类型的推导
C++14新增了另一个推导auto
占位符的情景,它出现于函数返回类型。例如:
auto f() { return 42; }
定义了一个返回类型为int
的函数(42
的类型)。它也可以使用尾缀返回类型语法来表示:
auto f() -> auto { return 42; }
后者的第一个auto
宣布了尾缀返回类型,第二个auto
是一个推导的占位符类型。只不过,没有什么理由去支持更啰嗦的语法。
对lambda来说有着相同的默认机制存在:如果没有显式地指定返回类型,lambda表达式返回的类型会按照auto
来推导:
1 |
|
函数可以脱离定义而单独声明。对于返回类型需要推导的情景也是一样:
1 |
|
但是,在这种情况下,前向声明的用法非常有限,因为在使用函数的任何位置,该定义都必须可见。也许令人惊讶的是,提供带有“已解决的”返回类型的前向声明是无效的。例如:
1 |
|
通常,由于风格上的偏爱,仅在将成员函数定义移到类定义外部时,前向声明推导的返回类型的函数才有作用:
1 |
|
可推导的非类型参数
在C++17之前,非类型参数只能通过指定的类型来声明。然而,这一类型可以是一个模板参数类型。例如:
1 |
|
在本例中,需要指定非类型模板实参的类型——即指定int
和42,这可能很乏味。因此,C++17增加了声明非类型模板参数的能力,这些参数的实际类型是从相应的模板实参推导出来的。它们如下所声明:
template<auto V> struct S;
于是就可以用S<42>* ps;
。这里S<42>
的类型V
会被推导成int
,这是因为42
的类型是int
。如果我们写作S<42u>
,那么V
的类型就会被推导成unsigned int
(参考P294节15.10.1了解推导auto
类型指示符的更多细节)。
请注意,对非类型模板参数类型的一般约束仍然有效。例如:S<3.14>* pd; // ERROR: floating-point nontype argument
具有这种可推导的非类型参数的模板定义通常还需要表示相应参数的实际类型。这可以通过decltype
语法来完成(参考P298节15.10.2)。例如:
1 |
|
auto
非类型模板参数在参数化类成员的模板时也很有用。例如:
1 |
|
这里我们使用了一个辅助类模板PMClassT
的一个偏特化(参考P347节16.4)来借由成员指针类型追溯到它的“父”类类型。有了auto
模板参数,我们只需要指定成员指针常量&S::i
作为模板实参。在C++17之前,我们还得指定一个成员指针类型,如OldCounterHandle<int S::*, &S::i>
,看起来很笨重很冗余。
如你所愿,这一特性也可以为非类型参数包使用:
1 |
|
triplet
实例展示了每个非类型参数都可以被单独地推导。与多重可变声明场景(参考P303节15.10.4)不同的是,这里不需要每个推导都是相同的。
如果我们想强制每个非类型模板参数都相同,也是可以实现的:
1 |
|
然而,此场景中模板实参列表不能为空。
可以参考P50节3.4中一个使用了auto
作为模板参数类型的完整例子。
15.10.2 用decltype表示表达式的类型
尽管auto
的使用可以避免书写变量类型,但想要使用该变量类型时就没那么容易了。decltype
关键字解决了这一问题:它允许开发者表示某一个表达式或是声明的精准类型。然而,开发者应谨慎对待decltype
产生的细微差别,具体取决于传递的参数是声明的实体还是一个表达式:
- 如果
e
是某个实体(诸如变量、函数、枚举或是数据成员)或类成员访问的名称,decltype(e)
产生的是该实体或表示的类成员的声明类型。因此,decltype
可以用来检查变量的类型。当你想要完全匹配现有的声明的类型时,这很有用。例如,考虑下面的两个变量y1
和y2
:
1 |
|
依赖于x
的初始化器,y1
的类型与x
可能相同、也可能不同:它依赖于+
的行为。如果x
被推导为一个int
,那么y1
也会是int
。而如果x
被推导为char
,y1
会是一个int
,因为char
和1
(定义为int
类型)相加得到的是int
。对y2
类型使用的decltype(x)
保证了y2
始终与x
具有相同的类型。
- 否则,如果e是任何其他表达式,则
decltype(e)
生成一个反映该表达式的类型和值类别的类型,如下所示:- 如果
e
是类型T
的左值(lvalue),decltype(e)
产生的是T&
。 - 如果
e
是类型T
的将亡值(xvalue),decltype(e)
产生的是T&&
。 - 如果
e
是类型T
的纯右值(prvalue),decltype(e)
产生的是T
。
- 如果
可以参考附录B关于值分类的详细描述。这些差别可以通过下面的例子来演示:
1 |
|
前四个表达式中,decltype
为变量s
所使用:
decltype(s) // declared type of entity a designated by s
这意味着decltype
产生的是s
声明的类型——std::string&&
。后四个表达式中,decltype
的操作数不是一个名称(而是一个表达式(s)
,名称在小括号中),此时,类型会反映出(s)
的值类别:decltype((s)) // check the value category of (s)
我们的表达式按名称指代一个变量,因此它是一个左值:根据上面的规则,这意味着decltype(s)
是一个std::string
的普通引用(即左值)。这是C++中为数不多的几个地方之一,用括号括起来的表达式除了影响运算符的关联性之外,还可以改变程序的含义。
decltype
会计算任意表达式e
的类型这一事实在各个地方都可能有所帮助。具体而言,decltype(e)
会保留表达式的充足信息,从而可以“完美地”描述返回表达式e
本身的函数的返回类型:decltype
会计算该表达式的类型,同时将表达式的值类别传播给函数的调用者。例如,考虑一个简单的转发函数g()
,它返回被调用的f()
的返回结果:
1 |
|
g()
的返回类型依赖于f()
的返回类型。如果f()
返回的是一个int&
,g()
的返回类型的计算会首先判断表达式f()
是否具有类型int
。该表达式是一个左值,因为f()
返回的是左值引用,因此g()
声明的返回类型就会是int&
。类似地,如果f()
的返回类型是一个右值引用类型,f()
的调用就是一个将亡值,而decltype
会产生一个右值引用类型,它严格匹配f()
返回的类型。本质上,这种形式的decltype
拿到了任意表达式的主要特征(其类型和值类别),并以能够完美转发返回值的方式在类型系统中对其进行编码。
decltype
在auto
推导不足以产生值的情景中也十分有用。例如,假设我们有一个变量pos
,它是某种未知的迭代器类型,我们希望创建一个变量element
,该element
可以通过pos
解引用来获取。写成:
auto element = *pos;
然而,这始终都会对元素进行一次拷贝。如果我们写成auto& element = *pos;
,那我们拿到的始终是该元素的引用,但如果迭代器的operator*
返回的是一个值类型,程序就会出错。为了解决该问题,我们可以使用decltype
来保留迭代器operator*
返回结果的值或引用性:
decltype(*pos) element = *pos;
当迭代器提供的是引用时,就会产生一个引用类型,否则,就会进行值拷贝。它的主要缺陷在于它需要将初始化表达式书写两次:第一次在decltype
中(这里不会进行计算),第二次在实际的初始化器中。C++14引入了decltype(auto)
语法来解决这一问题,我们马上就会讨论到。
15.10.3 decltype(auto)
C++14增加了一个组合使用auto
和decltype
的特性:decltype(auto)
。正如auto
这一类型指示符一样,它是一个类型占位符,并且变量的类型、返回类型或模板实参的类型由关联的表达式类型(初始化器、返回值或模板实参)确定。然而,与auto
单单使用模板实参推导法则来确定类型有所不同,实际的类型是通过对表达式直接应用decltype
语法来确定的。举个例子来说明:
1 |
|
y
的类型借由应用于初始化表达式的decltype
获取,这里ref
是一个int const&
。相对地,auto
类型推导法则产生的则是类型int
。
另一个例子展示了索引std::vector
(产生一个左值)时的区别:
1 |
|
这就干净利落地解决了前面示例的问题:
decltype(*pos) element = *pos;
我们可以重写为:
decltype(auto) element = *pos;
对于返回类型来说它也常常十分便利。考虑下面的例子:
1 |
|
如果container[idx]
产生的是左值,我们希望传递左值给调用者(调用者应该希望拿到地址来修改它):此时需要一个左值引用类型,decltype(auto)
可以解析出来。如果产生的是一个纯右值,那么引用类型会导致引用悬挂,但是幸运的是,在这种情景下,decltype(auto)
会产生一个对象类型(而非引用类型)。
与auto
不一样的是,decltype(auto)
不允许指示符或声明操作符去修改它的类型。例如:
1 |
|
同时也请注意初始化器中的小括号可能很关键(因为它们对decltype
结构来说本身很关键,如P91节6.1所讨论):
1 |
|
这尤其意味着括号可能对return语句的有效性产生严重影响:
1 |
|
自C++17起,decltype(auto)
还可以对可推导的非类型参数使用(见P296节15.10.1)。下面的例子进行了演示:
1 |
|
在#1处,c
没有小括号包裹,推导出的类型就是c
类型本身(即int
)。因为c
是42
的常量表达式,它就等价于S<42>
。在#2处,小括号的包裹导致decltype(auto)
会推导出一个引用类型int&
,它可以绑定到全局变量v
(类型为int
)。因此,这样声明的类模板会依赖于v
的引用,v
值的改变都会影响类S
的行为(参考P167节11.4了解更多细节)。(S<v>
如果没有小括号的话,会产生一个错误,因为decltype(v)
是一个int
,此时期望的是一个类型为int
的常量实参值。然而,v
并不是一个常量int
值。)
请注意,两种情况的性质有所不同。因此,我们认为此类非类型模板参数可能会引起意外,并且预计不会被广泛使用。
最后,给出关于在函数模板中使用推导的非类型参数的注解:
1 |
|
本例中,函数模板f<>()
的参数N
的类型由S
的非类型参数类型推导。这是可行的,因为形如X<...>
的名称(X
是一个类模板)是一个可推导上下文。
然而,也有一些模式是无法被推导的:
1 |
|
本例中,decltype(V)
是一个不可推导上下文:并没有匹配实参42
的独一无二的V
值(例如,decltype(7)
与decltype(42)
产生相同的类型)。因此,非类型模板参数必须被显式地指定,才能使函数调用变得可行。
15.10.4 auto推导的特殊情况
除却简单的auto
推导规则,还存在着一些特殊的情况。第一种情况发生在变量的初始化器是一个初始化列表的场景。对应的函数调用推导必定会失败,因为我们无法通过初始化列表实参来推导出一个模板参数的类型:
1 |
|
然而,如果我们的函数有着如下更特定的参数:
1 |
|
那么推导就会成功。使用初始化列表来拷贝初始化(即,使用=初始化)一个auto
变量就定义而言,可以写成更加具体的参数:
1 |
|
在C++17之前,auto
变量与之对应的直接初始化(即,不使用=)也可以像这样处理,但是在C++17中对此进行了调整,以更好地满足大部分开发者所期望的行为:
1 |
|
在C++17之前,两种初始化都是合法的,oops
和val
都会由类型initializer_list<int>
进行初始化。
有趣的是,为拥有推导占位符类型作为返回类型的函数返回一个花括号初始化列表是不合法的:
1 |
|
这是因为函数作用域中的初始化列表是一个对象,它指向更底层的数组对象(每个元素值在列表中指定),在函数返回时它就过期了。允许这一语法通行就相当于认可悬垂引用的有效性。
另一种特殊的场景发生在多个变量使用同一个auto
进行声明的地方,如下所示:
auto first = container.begin(), last = container.end();
此处,推导会为每个声明独立进行。换句话说,这里会为first
引入模板类型参数T1
,为last
引入另一个模板类型参数T2
。当且仅当两个推导都成功,且T1
和T2
具有相同的推导类型时,这些声明才是合法的。这会滋生一些有趣的案例:
1 |
|
这里,共享的auto
声明了两对变量。cp
和d
推导出同样的类型char
,因此代码有效。然而f
和e
的声明却因为计算c+1
时char
和int
的型别提升,导致推导结果不一致而最终产生错误。
推导返回类型的占位符也可能会出现某种平行的特殊情况。考虑下面的例子:
1 |
|
本例中,每个返回语句都会独立进行推导,但是二者推导的结果却不一致,因此程序非法。如果返回表达式递归地调用函数,那么也不会发生推导,程序也是非法的,除非前面的推导已经确定了返回类型。这就意味着下面的代码是非法的:
1 |
|
但是下面的这段等效的代码却是合法的:
1 |
|
推导的返回类型还有另一种特殊的情景,即推导的变量类型或推导的非类型参数类型中没有对应项:
1 |
|
但是f1()
和f2()
都是合法的,并且推导出一个void
返回类型。然而,如果返回类型的样式不匹配void
,比如这样的情景就是非法的:
auto* f3() { } // ERROR: auto* cannot deduce as void
如你所愿,使用了推导返回类型的任何函数模板都需要该模板的即时实例化以确定返回类型。然而,出现SFINAE(参考P129节8.4和P284节15.7)时会产生一个令人惊讶的后果。考虑下面的例子:
1 |
|
这里相比decltype(t+u)
,addB()
所使用的decltype(auto)
会在重载解析期间引起一个错误:addB()
模板函数体必须被完全实例化以确定其返回类型。调用addB()
的实例化体并不在即时上下文中(参考P285节15.7.1),因此不会被SFINAE过滤,而是产生一个错误。因此一定要牢记:推导返回类型绝不仅仅是一个复杂的显式返回类型的缩写,它们在使用上要非常小心(即,要理解它们不应该在依赖于SFINAE属性的其他函数模板签名中被调用)。
15.10.5 结构化绑定
C++17增加了一种新的特性,名为结构化绑定(structured bindings)。它常常使用一个小例子来介绍:
1 |
|
调用g()
产生了一个值(本例中时一个简单的聚合类类型MaybeInt),它可以被分解成
“元素”(即MaybeInt
的数据成员)。该调用产生的值就好像有一个标识符中括号列表[b, N]
被不同的变量名所替换。假设该名称为e
,那么初始化就等同于:
auto const&& e = g();
然后中括号中的每个标识符会绑定到e
的对应元素上。因此,你可以认为[b, N]
就是e
中标识符的每个名字(我们会在下面讨论绑定的细节)。
语法上,结构化绑定必须总是有一个auto
类型,它可以使用const
或volatile
限定符以及&
和&&
声明运算符来扩展(但是不能用*
指针声明符或是其他结构)。它的后面跟随着一个中括号列表,其中至少得有一个标识符(让人想起lambda表达式的捕获列表)。后面必须要有一个初始化器。
三种不同类别的实体可以初始化一个结构化绑定:
- 第一种是简单的类类型,其中所有的非静态数据成员都是public权限(如上例)。为了应用这一场景,所有的非静态数据成员都必须是public权限(要么全部直接属于类本身,要么全部属于相同的、明确的公共基类;不得涉及匿名联合体)。在这种情况下,带括号的标识符的数量必须等于成员的数量,并且在结构化绑定范围内使用这些标识符之一就等于使用由
e
表示的对象的相应成员(具有所有相关属性;例如,如果相应的成员是位字段,则无法获取其地址)。 - 第二种是数组。考虑下例:
1 |
|
毫不奇怪,中括号中的初始化器只是未命名数组变量的相应元素的简写形式。数组元素的数量必须等于括号内的初始化器的数量。
还有另一个例子:
1 |
|
行#1是特别的:通常来说,上面描述的实体e
应该按照下面的形式来推导:auto e = f();
然而,这种推导会退化为指向数组的指针,但是数组的结构化绑定却不会如此。反之,e
被推导为一个数组类型的变量,类型与初始化器一致。此后该数组从初始化器中逐个元素拷贝:对于内置数组来说这是个不太寻常的概念。最后,x
和y
分别成为了表达式e[0]
和e[1]
的别名。
而行#2处则没有引入数组拷贝,它也遵循auto
的法则。因此假想的e
按照如下方式声明:auto& e = f();
它会得到一个数组引用,x
和y
再次分别成为表达式e[0]
和e[1]
的别名(调用f()
所返回数组的成员左值引用)。
- 最后,第三个选项是允许类似
std::tuple
的类拥有通过模板基础协议get<>
分解元素的能力。这里我们把E
视为表达式(e)
的类型(e
的概念同上)。由于E
是表达式的类型,它永远不会是一个引用类型。如果表达式std::tuple_size<E>::value
是一个合法的整型常量表达式,它必须与中括号标识符的数量相等(并且协议会乱入,优先于选项一,但不优先于数组的选项二)。让我们用n0,n1,n2等表示括号中的标识符。如果e
具有名为get
的任何成员,则行为就像将这些标识符按如下声明:
1 |
|
如果e
被推导为拥有引用类型,或是:
std::tuple_element<i, E>::type&& ni = e.get<i>();
如果e
没有成员get
,则相应的声明会变成:
std::tuple_element<i, E>::type& ni = get<i>(e);
或是std::tuple_element<i, E>::type&& ni = get<i>(e);
get
只会在关联的类和命名空间中查找。(在所有情景中,get
都被假设为一个模板,因此跟随的<
是一个尖括号(而非小于号)。)std::tuple
,std::pair
和std::array
模板都实现了这一协议,下面的代码因而合法:
1 |
|
然而,对于添加std::tuple_size
,std::tuple_element
的特化并不困难,函数模板或是成员函数模板get<>()
会让这一机制对任何类或枚举类型都能正常工作。例如:
1 |
|
注意,你只需要包含<utility>
头文件来使用两个类元组(tuple-like)的访问协助函数std::tuple_size<>
和std::tuple_element<>
。
此外,还要注意上述的第三种情况(使用类元组协议)会执行一个真实的中括号初始化并绑定到实际的引用变量上;它们不是另一个表达式的别名(与第一、二类的类类型和数组的情况有所不同)。这很有趣,因为该引用初始化可能出错;例如,它可能会抛出异常,而异常如今是不可避免的。然而,C++标准化委员会也曾就不要关联标识符与初始化的引用进行过讨论,但是最后还是对每个标识符使用了get<>()
表达式。这就使得结构化绑定在使用时,“第一个”值必须在“第二个”值被访问前进行测试(例如,基于std::optional
)。
译者注: 这一大段不太会翻译,因为我本身也不了解结构化绑定这一特性。
15.10.6 泛型lambda
lambda一经问世,很快就成了C++11中最流行的特性,一部分原因在于它们显著地简化了C++标准库和许多其他流行的C++库中仿函数结构(functional constructs)的使用,而这归功于lambda简洁的语法。然而,在模板中lambda变得非常繁琐,这是因为它需要拼出参数和返回类型。例如,考虑这样一个函数模板,它在一个序列中寻找第一个负数值:
1 |
|
在这一函数模板中,lambda最复杂的一部分就是它的参数类型。C++14引入了泛型lambda的概念,使得一个或多个参数类型可以使用auto
来推导型别,而不用具体的写出:
1 |
|
对lambda参数auto
的处理与使用初始化器的变量类型的auto
处理相似:它同样由一个引入的模板类型参数T
来取缔。然而,与变量场景不同的是,推导不会立刻执行,这是因为在lambda被创建的时候实参还是未知的。反之,lambda本身是个泛型,引入的模板类型参数被添加到了它的模板参数列表中。因此,上面例子的lambda可以使用任何实参类型来调用,只要该实参类型支持< 0
操作符且其结果可以被转换为bool
即可。举个例子,这一lambda可以被int
或是float
值来调用。
为了理解lambda泛型的意义,我们先考虑一个非泛型lambda的实现模型:
1 |
|
C++编译器将该表达式翻译成一个新发明的lambda特定类类型的实例。这一实例被称作闭包(closure)或闭包对象(closure object),类类型被称作闭包类型(closure type)。闭包类型有一个函数调用操作符,因此该闭包就是一个函数对象。对于这一lambda来说,闭包类型可能类似下面的类定义(为了方便与简洁,我们省略了函数到函数指针值的转换):
1 |
|
如果你检查lambda的型别分类,std::is_class<>
始终会得到true
值(参考P705节D.2.1)。
因此,lambda表达式生成的是该类(闭包类型)的对象。例如:
1 |
|
创建了一个编译器内部特定的类SomeCompilerSpecificNameX
的闭包对象:
1 |
|
如果lambda想要捕获局部变量:
1 |
|
这些捕获将被设计成相关类类型的初始化成员:
1 |
|
对泛型lambda来说,函数调用操作符是一个成员函数模板,所以我们简单的泛型lambda:
[] (auto i) { return i < 0; }
会被转移成下面的类(同样地,忽略了函数转换,在泛型lambda场景中它是一个转换函数模板):
1 |
|
成员函数模板会在闭包被调用时进行实例化,而不是在lambda表达式出现的地方。例如:
1 |
|
这里,lambda表达式出现于main()
中,所以这里会创建一个关联的闭包。然而,闭包的调用操作符并没有在此处实例化。反之,invoke()
函数模板使用了闭包类型作为第一个参数类型,int
作为第二和第三个参数类型进行了实例化。invoke
的实例化被称为闭包的拷贝(依然是一个与原始lambda关联的闭包),并且它实例化了operator()
闭包模板来满足实例化调用f(ps...)
。
15.11 别名模板
别名模板的推导是“透明的“。这意味着当别名模板与模板实参一起出现时,别名的定义(即=右侧的类型)就会被实参所替换,产生的结果正是为推导所用。例如,模板实参推导对下面的三个调用都会成功:
1 |
|
在第一个调用中(f1()
),intStaack
对别名模板DequeStack
的使用对推导没有作用:指定类型DequeStack<int>
被视为类型Stack<int, std::deque<int>>
。
第二个和第三个调用推导行为一直,因为f2()
的DequeStack<T>
和f3()
的Stack<T, std::deque<T>>
是等价的。对模板实参推导的目标来说,模板别名是透明的:它们可以用来区分和简化代码,但是对于推导如何进行确不会产生效果。
请注意,这是因为别名模板不能特化(参考章节16了解模板特化这一话题的更多细节)才行得通。假设下面的代码可行:
1 |
|
此时,我们无法将A<T>
与void
类型匹配,并得出结论T
必须为void
,因为A<int>
和A<void>
都等价于void
。不可能做到这一点的事实保证,别名的每次使用都可以根据其定义进行一般性的扩展,从而使别名可以进行透明地推导。
15.12 类模板实参推导
C++17引入了一种新的推导:从变量声明的初始化器或函数符号型别转换来推导类模板参数。例如:
1 |
|
请注意,所有的参数都必须由推导过程或默认实参来确定。显式地指定一部分参数并推导剩下的参数是行不通的。例如:
1 |
|
15.12.1 推导指引
考虑P288节15.8.2的一个示例,我们施加一些小变化:
1 |
|
新增的这种模板风格的结构叫做推导指引。它看起来有点像函数模板,但是它与函数模板在语法上有很多不同:
- 看起来像尾缀返回类型的部分不能写成一个传统的返回类型。我们称这个指定的类型(本例中为
S<T>
)指引类型(guided type)。 - 没有前导
auto
关键字来指示尾缀返回类型。 - 推导指引的“名称”必须是同作用域内更早出现的类模板的非受限名称。
- 指引的指引类型必须是一个模板ID,它的模板名称与指引名称一致。
- 可以使用
explicit
说明符声明。
在S x(12);
这一声明中,说明符S
被称为占位类类型(placeholder class type)。当使用这样的占位符时,被声明的变量名称必须紧随其后,并且后面一定要有初始化器。下面的代码是非法的:
S *p = &x; // ERROR: syntax not permitted
如上例所书写的指引,声明S x(12);
通过将与类S
的推导指引视为重载集合,并尝试使用初始化器针对该重载集合来进行重载解析,对变量的类型进行推导。在这一场景中,集合内仅仅有一个指引在其中,它会成功地推导T
为int
,指引的指引类型为S<int>
。这一指引类型因此被选为声明的类型。
请注意,如果类模板名称后面的多个声明都需要推导,那么每个声明都需要产生相同的类型。例如,使用上面的声明:
S s1(1), S2(2.0); // ERROR: deduces S both as S<int> and S<double>
这与C++11中auto
占位符类型的限制相似。
在前面的例子中,我们声明的推导指引与类S
中声明的构造函数S(T b)
之间有一个隐式的联系。然而,这种联系并不是必要的,这意味着推导指引也可以为聚合类模板所使用:
1 |
|
如果没有推导指引,我们必须始终显式地指定模板实参(即使在C++17中也一样):
1 |
|
但是如果有了上面的指引,就可以写成:
A a4 = {42}; // OK
这里有一个微妙之处在于,初始化器必须也是一个合法的聚合类初始化器,也就是说,它必须是一个花括号初始化列表。下面的一些替换是不被允许的:
1 |
|
15.12.2 隐式推导指引
通常,对于类模板中的每个构造函数都需要一个推导指引。这使得类模板实参推导的设计者为推导引入了一种隐式地机制。为类主模板的每个构造函数和构造函数模板都引入了一个等价的隐式推导指引,如下所述:
- 隐式指引的模板参数列表由类模板的模板参数、构造函数模板的模板参数(构造函数模板的场合)构成。构造函数模板的模板参数会保留任何默认实参。
- 指引的“类函数”参数会从构造函数或构造函数模板中拷贝。
- 指引的指引类型就是模板的名称,其参数是从类模板中获取的模板参数。
让我们应用到一个原始的类模板示例:
1 |
|
模板参数列表为typename T
,类函数参数列表就是(T b)
,指引类型也就是S<T>
。因此,我们获得了一个指引,它与我们此前书写的那个用户声明的指引等价:即,为了达成我们想要的效果,该指引完全不必要!也就是说,仅书写原始的简单类模板(无需推导指引),我们还是可以有效地写成S x(12);
,其中x
的类型依然是期望的S<int>
。
推导指引有一个不幸的歧义。考虑一下我们简单的类模板S
和下面的实例化语句:
1 |
|
我们已经看到了x
有着类型S<int>
,但是x
和y
应该是什么类型呢?这两种类型直觉上应该是S<S<int>>
和S<int>
。委员会在富有争议的情况下决定,这两种情况下都应为S<int>
。为什么这是有争议的呢?考虑使用vector
类型的一个相似的例子:
1 |
|
换句话说,拥有单个元素的花括号初始化器的推导与拥有多个元素的花括号初始化器有所差别。通常来说,人们只希望要其中的某一个结果,但是两者确并不一致。然而在泛型代码中,很容易忽视这一细小的差别:
1 |
|
这里当T
被推导为vector
类型时,v
在ps
参数包为空或非空的情景下,v
的类型是不一样的。
隐式模板指引本身的添加并没有争议。反对将它们引入的主要观点是该功能会自动将接口添加到现有库中。为了理解这一说法,再次考虑我们前面的类模板S
。它的定义自C++引入类模板时就是有效的。假设,S
的作者扩展了库,让S
以更缜密的方式定义:
1 |
|
在C++17之前,这样的转变(不太常见)不会影响现有的代码。然而,在C++17中它们禁用了隐式推导指引。让我们书写一个与隐式推导指引相仿的推导指引:模板参数列表和指引类型无需改变,但是类函数参数现在需要写成ArgType
的形式,也就是typename ValueArg<T>::Type
:
template<typename> S(typename ValueArg<T>::Type) -> S<T>;
回想一下P271节15.2,类似ValueArg<T>::
的名称限定符不是一个推导上下文。因此这种形式的推导指引是没有用的,它无法解析S x(12);
这样的声明。换句话说,库的作者执行了这一转换可能会破坏其在C++17中的客户端代码。
这种情况下库的作者要怎么办呢?我们的建议就是小心地考虑每一个构造函数,在库剩余的生命期内是否希望它作为隐式推导指引的来源。如果不希望,就用诸如typename ValueArg<X>::Type
来替换每一个可推导的类型为X
的构造函数参数的实例。很不幸,没有更简单的方法去把隐式推导指引摘除。
15.12.3 其他细微之处
注入式类名称
考虑下例:
1 |
|
这段代码在C++14中是合法的:X(b, e)
中的X
是注入式类名称,在该上下文中等价于X<T>
(参考P221节13.2.3)。然而,对类模板实参推导这一规则来说,X
会自然而然地等价于X<Iter>
。
为了保留向后兼容性,类模板实参推导在模板名称是注入式类名称的场合下会被禁用。
转发引用
思考另一个例子:
1 |
|
显然,这里的目的是通过拷贝构造函数所关联的隐式推导指引架构T
推导为std::string
。然而,将隐式推导指引显式地声明出来反而发生令人惊讶的事:
1 |
|
回想P277节15.6中模板实参推导的T&&
的行为:作为一个转发引用,如果调用实参是一个左值类型,那么T
也会被推导成引用类型。在上例中,推导过程中的实参就是表达式s
,它是一个左值。隐式指引#1会把T
推导为std::string
,但是需要的实参会被调整成std::string const
。而指引#2则会将T
推导成一个引用类型std::string&
并产生一个相同类型的参数(这是因为引用折叠法则),这是一个更好的匹配候选,因为无需对类型添油加醋,附上一个const
属性。
这一结果可能会令人惊讶,也可能会造成实例化错误(当类模板参数在不允许引用类型的上下文中使用时),更有甚者,会静默地生成非预期的实例(比如,生成悬垂引用)。
C++标准委员会因此决定,对于隐式推导指引,如果T
是一个类模板参数(与构造函数模板参数对应;为那些特殊的推导规则而保留),在执行T&&
的推导时,特殊的推导规则会被禁用。因此上面的例子可以将T
推导为std::string
,如你所愿。
explicit 关键字
推导指引可以使用关键字explicit
修饰。此时它仅仅会考虑直接的初始化场景,而不会考虑拷贝初始化场景。例如:
1 |
|
注意这里的z1
初始化使用了拷贝初始化,因此声明了explicit
的推导指引#2就不会被考虑。
拷贝构造和初始化列表
考虑下面的类模板:
1 |
|
为了理解隐式指引的效果,我们用显式地声明它们:
1 |
|
现在看看下面的例子:
auto x = Tuple{1,2};
这显然会选择第一个指引,因此第一个构造函数:x
就是一个Tuple<int, int>
。让我们继续看看下面的例子,它们使用了x
拷贝的语法:
Tuple a = x;
Tuple b(x);
对a
和b
来说,两个指引都可以匹配。第一个指引会选择类型Tuple<Tuple<int, int>>
,拷贝构造器关联的指引会生成Tuple<int, int>
。幸运的是,第二个指引更加匹配,因此a
和b
都会从x
拷贝构造出来。
现在。考虑使用花括号列表的例子:
Tuple c{x, x};
Tuple d{x};
例子中的第一个x
仅仅可以匹配第一个指引,因此会产生Tuple<Tuple<int,int>, Tuple<int, int>>
。这完全符合直觉,不足为奇。第二个示例则会将d
推导为类型Tuple<Tuple<int>>
。然而,它被视为一个拷贝构造(即,更倾向于第二个隐式指引)。这也会发生在functional-notation转换的场景:auto e = Tuple{x};
这里,e
被推导为一个Tuple<int, int>
,而非Tuple<Tuple<int>>
。
指引仅为推导所用
推导指引并非函数模板:它们仅仅用来推导模板参数,并不会被“调用”。这意味着不论是通过引用还是通过值来传递实参对指引声明并不重要。例如:
1 |
|
注意看推导指引并没有完全与Y
的两个构造函数保持一致。然而,这并没有什么关系,因为指引仅仅为推导所用。给定类型为X<TT>
的xtt
左值或是右值,它都会选择推导类型Y<TT>
。然后,初始化会在Y<TT>
的构造器上执行重载解析以判断需要调用哪一个(这取决于xtt
是左值还是右值)。
15.13 后记
函数模板的模板实参推导本就是C++原始设计的一部分。实际上,显式模板实参的使用在很多年之后才成了C++的一部分。
SFINAE是一个术语,它在本书的第一版就介绍过了。这一术语很快就在C++开发者委员会中盛行。然而,在C++98中,SFINAE并没有那么强大:它仅仅适用于一个有限的类型操作符集合,并且没有覆盖任意表达式或访问控制。由于越来越多的技术开始依赖于SFINAE(参考P416节19.4),推广SFINAE显而易见。Steve Adamczyk和John Spicer开发了在C++11中实现的措辞(见论文N2634)。尽管标准中的措词更改相对较小,但事实证明某些编译器的实现工作量不成比例。
auto
类型指示符以及decltype
语法最早在C++03中新增,但最终是C++11才正式引入。它们率先由Bjarne Stroustrup和Jaakko Jarvi发明(详见他们的论文N1607和N2343,里面分别有auto
类型指示符和decltype
)。
Stroustrup在他的原始C++实现(Cfront)中就已经考虑过auto
语法。这一特性在C++11中引入,auto
作为一个存储指示符的原始意义(从C语言继承)被保留下来,所以需要一个没有歧义的规则来决定该关键字应该如何解析。在Edison Design Group的前端实现这一特性的过程中,David Vandevoorde发现对于C++11开发者来说这可能会产生很多意外(N2337)。在审查了这一议题后,标准委员会决定抛弃auto
的传统使用方法(在C++03程序中使用auto
关键字的任何地方,都可以忽略它),见论文N2546(David Vandevoorde和Jens Maurer撰写)。这是在不首先弃用该功能的情况下从该语言中删除该功能的不寻常先例,但此后事实证明这是英明的决定。
GNU的GCC编译器接受一个扩展的typeof
语法,它与decltype
特性并没有什么差异,开发者曾一度发现它在模板编程中非常有用。不幸的是,这是在C语言的上下文中开发的功能,并不完全适合C ++。因此,C ++委员会无法按原样合并它,但也不能对其进行修改,因为这将破坏依赖GCC行为的现有代码。这就是为什么decltype
没有被拼写成typeof
的缘由。Jason Merrill和其他人提出了有力的论据,认为最好有不同的运算符,而不是(依赖于)目前的decltype(x)
和decltype((x))
之间的细微差别,但他们并没有说服力来更改最终规范。
在C++17中使用auto
声明非类型模板参数的能力主要由Mike Spertus发明,齐心协力的还有James Touton, David Vandevoorde和其他人。这一特性的规格更改记录在P0127R2中。有趣的是,尚不清楚是否有意使用decltype(auto)
代替auto
成为该语言的一部分(显然,委员会未对此进行讨论,但超出了规范)。
Mike Spertus也驱动了C++17中类模板实参推导的开发,Richard Smith和Faisal Vali 贡献了显著的技术理念(包括推导指引)。论文P0091R3中具有被选为下一个语言标准的工作文件的规格说明。
结构化绑定主要由Herb Sutter所驱动,他与Gabriel Dos Reis和Bjarne Stroustrup撰写了论文P0144R1以提出这一特性。在委员会讨论期间进行了许多调整,包括使用方括号来分隔可分解的标识符。 Jens Maurer将提案翻译成标准的最终规范(P0217R3)。