这个推导有错误吗?有的话请希望别人指点的客套话。

【简答题】 某公安局的刑侦员A、B、C、D对某案的涉嫌犯李、赵做了如下断定:A.我认为赵不是凶犯。B.如果李是凶犯,那么赵就不是凶犯。C.或者李是凶犯,或者赵是凶犯。D.我看李和赵都是凶犯。事后证明,四个人做出的上述判断只有一个是错误的,请问凶犯是谁?请写出推导过程。

扫描下方二维码进入小程序查看题目答案

十亿美元(billion dollar)的错误 / bug 貌似是美国的一个梗,大概的意思是,对于那些市值上几千亿的大企业,如果一个错误能够导致市值下跌个百分之零点几,就已经是十亿左右了。

但我不确定这是不是最早的出处,毕竟在商业领域这样的说法也很常见。

以下示例代码来自 Go 的 标准库文档[2]

如果为每个函数模板都显式地指定模板实参,那么很快代码就会显得又笨又重(比如:concat<std::string, int>(s, 3))。幸运的是,C++编译器常常可以自动判断模板实参类型,这是通过一个十分高效的过程——模板实参推导——来完成的。

本章中我们将详述模板实参推导这一过程的细节。和C++其他的知识一样,大多数规则通常会产生一个直观的结果,模板实参推导也不例外。此外,对这一章的深刻理解可以使我们以后避免遇到出人意料的情景。

尽管模板实参推导是为了简化函数模板的调用而首先开发的,但是自那以后,它已扩展到适用于其他几种用途,包括从其初始值设定项(initializer)确定变量的类型。

基本的推导过程会比较“函数调用的实参类型”与“函数模板对应位置的参数化类型”,然后针对要被推导的一到多个参数,分别尝试去推断一个正确的替换项。每个实参-参数对都会独立分析,并且如果最终得出的结论产生矛盾(differ),那么推导过程就失败了。

这里第一个调用实参的类型是int,因此我们原生的max()模板的参数T会被姑且推导成int。然而,第二个调用实参是double类型,基于此,T会被推导为double:这就与前一个推导产生了矛盾。注意:我们称之为“推导过程失败”,而不是“程序非法”。毕竟,可能对于另一个名为max(函数模板可以像普通函数那样被重载;参考P15节1.5和第16章)的模板来说推导过程可能是成功的。

即使所有被推导的模板实参都可以一致地确定(即不产生矛盾),推导过程仍然可能会失败。这种情况发生于:在函数声明中,进行替换的模板实参可能会导致无效的结构。请看下例:

这里T被推导为int*(T出现的地方只有一种参数类型,因此显然不会有解析矛盾)。然而,将T替换为int*在C++中对于返回类型T::ElementT显然是非法的,因此推导就失败了。

我们仍然需要探索实参-参数匹配如何进行。我们会使用下面的概念来进行描述:匹配类型A(来自实参的类型)和参数化类型P(来自参数的声明)。如果被声明的参数是一个引用,那么P就是所引用的类型,A仍然是实参的类型。如果声明的参数不是引用,那么P就是声明的参数类型,而A类型的获取则会经过数组和函数类型到指针类型的退化,并且会忽略顶层const和volatile限定符。例如:

对调用f(arr)来说,arr数组类型会退化为类型double*,作为推导的T的类型。在f(seven)const限定符被忽略了,因此T被推导为int。相反地,g(x)推导T为类型double[20](没有发生退化)。与此类似地,g(seven)有一个类型为int const的左值实参,并且因为在匹配引用参数时,constvolatile限定符不会被去除,T会被推导为int const。然而,注意由于g(7)想要推导Tint(因为非类的右值表达式永远不会有constvolatile限定类型),这一调用会失败,因为实参7无法作为一个int&类型的参数被传递。

引用类型的参数不会退化这一事实,对于参数为字符串字面量的场合来说可能会令人惊讶。重新审视我们的使用引用类型的max()模板声明:

const[4]。这里并不会进行数组到指针的退化(因为推导涉及了引用类型的参数),因此想要推导成功,T必须既得是char[6]又得是char[4]。显然,这绝无可能。可以参考P115节7.4中对于如何处理这一场景的一个探讨。

T复杂得多的参数化类型也可以匹配一个给定的实参类型。这里有一些相当基础的例子:

复杂的类型声明都是用比它更基本的结构(例如指针、引用、数组、函数声明;成员指针声明;模板ID等)来组成的,匹配过程从最顶层结构开始处理,一路递归其各种组成元素。可以说基于这一方法,大部分类型声明结构都可以进行匹配,而这些结构也被称为“推导上下文“。然而,有些结构不能作为推导上下文。例如:

  • 受限的类型名称。例如,形如Q<T>::X的类型名称永远不会用来推导模板参数T
  • 除了非类型参数之外,模板参数还包含其他成分的非类型表达式。例如,形如S<I+1>的类型名称永远不会用于推导I。再比如,T也不会通过匹配形如int(&)[sizeof(S<T>)]类型的参数来推导。这些限制合乎常理,因为通常来说,推导并不是唯一的(甚至不一定是有限的),尽管有时候会很容易忽略这些受限的类型名称。此外,一个不能推导的上下文并不会自动地表明:所对应的程序有错误,或者甚至前面分析过的参数不能再次进行类型推导。为了阐释这一事实,考虑下面这个更为错综复杂的例子:

在函数模板fppm()中,子结构X<N>::I是一个不可推导的上下文。然而,具有成员指针类型(即X<N>::*p)的成员类型部分X<N>是一个可推导上下文。于是,可以根据这个可推导上下文获得参数N,然后把N放入不可推导的上下文X<N>::I,就能获得与实参&X<33>::f相配的类型。因此基于这个实参-参数对的推导就是成功的。

相反,对于完全依赖推导上下文的参数类型来说,有可能会产生推导矛盾。例如,假设我们已恰当地声明过类模板XY

第二个调用的问题在于两个实参对于参数T的推导是不同的,而这显然是无效的(两种情景里,函数调用实参都是一个临时的对象,这一对象借由调用类模板X的默认构造器而获得)。

15.3 特殊的推导情况

存在一些特殊的情况:用于推导的实参-参数对(A, P)并不是分别来自于函数调用的实参和函数模板的参数。第一种情况出现在取函数模板地址的时候。此时,P是函数模板声明的参数化类型(即下面f的类型),而A是被赋值(或者初始化)的指针(即下面的pf)所代表的函数类型。例如:

类似地,函数类型在一些其他特殊情况下也被P和A所使用:

  • 确定重载函数模板之间的偏序
  • 将某个显式特化体与某个函数模板匹配
  • 将某个显式实例化体与某个模板匹配
  • 将某个友元函数模板特化体与某个模板匹配

这些话题中的部分内容,以及类模板偏特化中模板实参推导的使用,会在第16章中进行展开。

另一种特殊情况和转型运算符模板一起出现。例如:

在本例中,实参-参数对(P, A)的获取涉及到我们试图进行转型的实参和转换运算符的返回类型。下面的代码清楚地说明了这种情况:

这里,我们试图把S转型为int(&)[20],因此,类型A为int[20],而类型P为T。当Tint[20]替换时,推导就是成功的。

最后,对于auto占位类型来说,也需要一些特殊的对待。这会在P303节15.10.4中进行讨论。

当函数调用的实参是一个初始化列表时,该实参是没有一个特定的类型的,因此通常来说,对于给定实参-参数对(A, P),不会进行任何推导,因为这里并不存在A。例如:

pattern)),推导过程会将初始化列表的每个元素类型与P'进行比较,只有在所有的元素类型都有相同的类型时,推导才是成功的。 deduce/initlist.cpp

类似地,如果参数类型P是一个P'类型数组的引用(有一个可推导的模式),推导过程也会将初始化列表的每个元素的类型与P'进行比较,当且仅当所有元素都有相同的类型时,推导成功。此外,如果(数组)边界有一个可推导模式(即,仅命名一个非类型模板参数),那么该边界会被推导为初始化列表中元素的数量。

推导过程会逐一匹配每个实参到每个参数来确定模板实参的值。然而在对可变模板进行模板实参推导时,参数和实参之间1比1的关系就被打破了,这是因为一个参数包可以匹配多个实参。在本例中,同一个参数包(P)被匹配到了多个实参(A),并且每次匹配都会为P中的任何模板参数包产生附加值:

此处对首个函数参数的推导很简单,毕竟它并没有卷入任何参数包。第二个函数参数,rest,是一个函数参数包。它的类型是一个包展开(Rest...),其模式为类型Rest:该模式用作P,与第二和第三调用参数的类型A进行比较。当匹配第一个A时(类型double),模板参数包Rest的第一个值被推导为double。类似地,与第二个A进行匹配时,模板参数包Rest的第二个值被推导为int*。因此,推导确定了参数包Rest的值序列为{double, int*}。替换这一推导以及第一个函数参数的结果就可以得到函数类型void(int, double, int*),它与函数调用的每个实参类型相匹配。

由于函数参数包使用了展开的模式进行比较,因此该模式可以任意复杂,并且可以从每种实参类型中确定多个模板参数和参数包的值。考虑下面的函数h1()h2()的推导行为:

Rest>)的引用类型。由于所有的参数和实参都是类模板pair的特化体,因此将比较模板参数。对h1()来说,首个模板实参T并非参数包,因此它的值会为每个实参所独立推导。如果推导的结果出现矛盾(正如第二个h1调用那样),推导就失败了。对第二个在h1()h2()pair模板实参Rest、以及h2()的第一个模板实参Ts来说,推导会根据A的每个实参类型来确定模板参数包的值。

参数包的推导不限于其中“实参-参数对”来自调用参数的函数参数包。实际上,在函数参数列表或模板参数列表末尾的包展开处推导都会被使用。例如,考虑简单类型Tuple上的两个相似操作:

f1()f2()中,模板参数包都是将Tuple类型内嵌的包展开模式与调用实参所提供的Tuple类型进行比较,为一致的模板参数包推导出正确的值。函数f1()对两个函数参数使用相同的模板参数包Types,确保只有当两个函数调用实参有相同的Tuple特化体类型时,才能推导成功。而f2()则为每个函数参数各使用了一个参数包,因此两个调用参数可以不同——也就可以使用Tuple的两种特化体类型。

15.5.1 字面量操作符模板

字面量操作符模板的实参通过一种特殊的方式来确定。下面的例子进行了阐释:

这里,#2处的初始化器包含了一个用户定义的字面量(它会转换成对字面操作符模板的调用,使用的模板实参列表为<'1','2','1'>)。因此,字面量操作符的实现体可能如下:

请注意,仅在没有后缀的情况下仍然有效的数值字面量才支持此技术。例如:

参考P599节25.6对这一特性的应用:编译期计算整型字面量。

C++11引入的右值引用促生了许多新技术,包括移动语义和完美转发。本节会描述右值引用与推导之间的交互。

开发者不允许直接声明“引用的引用”:

然而,当通过模板参数替换、类型别名或是decltype结构构造类型时,“引用的引用”将被允许。例如:

从诸如这种结构中确定类型的结果的规则就是众所周知的引用折叠法则。首先,任何应用于内部引用顶层的constvolatile限定符都会被舍弃(也就是说,只有内层引用的底层限定符才会被保留)。此后,这两种引用会根据表15.1推导出一个单一引用,这种推导方式可以总结为一句话:“如果某个引用是左值引用,那么结果也一定是左值引用,否则就是右值引用”。

表15.1 引用折叠法则

另一个例子展示了这些规则的作用:

这里volatile被应用在RCI这一引用类型(int const&的别名)的顶层,因此会被丢弃掉。这一类型的顶层又放置了一个右值引用,但是由于底层类型是一个左值引用(左值引用在引用折叠规则中“更优先”),所以最终的类型保留为int const&(或者RCI类型、一个等价的别名)。类似地,RRI的顶层const会被丢弃,在右值引用类型上应用一个右值引用,最后的结果依然是一个右值引用类型(可以绑定到像42这样的右值上)。

如同P91节6.1所介绍的那样,当函数参数是一个转发引用(函数模板参数中的右值引用)时,模板实参推导会呈现另一种表现形式。此时,模板实参推导不仅会考虑函数调用实参的类型,同时也会考虑该实参是左值还是右值。如果实参是一个左值,那么模板实参推导所确定的类型就是该实参类型的左值引用类型,引用折叠规则会确保所替换的参数可以成为一个左值引用。如果实参不是左值,那么模板参数所推导的类型就是实参类型,而替代的参数是该类型的右值引用。例如:

在调用f(i)中,模板参数T被推导为int&,因为表达式i是一个类型为int的左值。T替换int&到参数类型T&&中需要引用折叠,这里我们使用规则&+&&->&来得出结论:参数类型为int&,如此就可以完美的接受int类型的左值。相对的,在调用f(2)中,实参2是一个右值,模板参数因此直接被推导为右值的类型(即int)。这里不需要进行引用折叠,其结果直接就是int&&(同样地,对实参来说这是一个合适的参数类型)。

T被推导为一个引用类型时,对于模板的实例化来说有些有趣的效果。例如,使用类型T声明的局部变量,在用左值实例化后,会有一个引用类型,而此时它就需要一个初始化器:

这就意味着函数f()的定义需要很小心地使用类型T,或者函数模板本身根本不为左值参数生效。为了解决这一困境,std::remove_reference型别萃取常常被用来确保x不是一个引用:

右值引用特殊的推导规则和引用折叠法则组合在一起使得编写一个接受任何实参的函数模板以及捕捉其显著的特性(其类型、以及它是一个左值还是右值)成为了可能。函数模板此后可以“转发”这一实参给另一个函数,诸如此例:

上例所展示的技术被称为完美转发(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结构来说,使用这一模板对开发者来说更加表意,同时也防止了诸如少写了一个&所导致的错误。那么,上面的例子可以更为简明地写成这个样子:

完美转发与可变模板搭配在一起,可以让函数模板接受任意数量的函数调用实参并将它们逐一转发到另一个函数:

forwardToG()的实参会为参数包Ts分别被推导出合适的值(见P275节15.5),因此类型以及每个参数的左值性或右值性都会被捕获。包展开(见P201节12.4.1)在调用g()时会将每个实参都应用上述的完美转发技术进行转发。

尽管它拥有一个“完美转发”的名字,但实际上,从它不能捕获表达式所有感兴趣属性的意义上来说,完美转发实际上并不“完美”。例如,它无法区分左值是不是一个位域(bit-field)左值,也无法捕获表达式是否有特定的常量值。后者尤其在我们处理空指针常量时常常导致问题(它是一个整型类型、常量零值)。由于表达式常量值不会被完美转发所捕获,下例中的重载解析对直接调用g()和转发调用g()来说,表现上会有所区别:

这也是为什么使用nullptr(C++11所引入)取代空指针常量的另一个原因:

我们所有完美转发的例子都聚焦于传递的函数实参要如何保留其精准的类型以及它是一个左值或是右值。当转发函数调用的返回值给另一个函数时,也要面临同样的问题(类型和值的分类,对左值和右值的概括在附录B中进行了讨论)。C++11引入的decltype语法(在P298节15.10.2中描述),有一个有些冗长的惯用手法;

请注意,return语句的表达式被拷贝到了decltype类型里,因此返回表达式的准确类型会被计算出来。尾随返回类型被使用(即,函数名称前的auto占位符和指示返回类型的->),使得函数参数包xs也在decltype类型的作用域。该转发函数会“完美地”转发所有实参给g(),然后再“完美地”转发其返回值给调用者。

C++14引入了额外的特性来简化这一情景:

对完美转发来说,右值引用的特殊推导规则非常有用。然而,有时候它们可能会令人意外,这是因为函数模板通常会泛化函数签名中的类型,不会影响它所允许的参数是何种类型(左值或右值)。考虑下例:

抽象出一个像int_lvalues那样的函数的开发者,可能会对函数模板anything可以接受左值而感到惊奇。幸运的是,只有当函数参数写成特定的模板参数&&的形式时(作为函数模板的一部分且命名的模板参数是由该函数模板所声明),才会应用这一推导行为。因此,下面这些例子的情形都不会应用推导规则:

尽管模板推导规则有着这些令人惊奇的行为,在实践中,这种行为导致问题的情况并不经常出现。当出现问题时,你可以组合使用SFINAE(参考P129节8.4和P284节15.7)和诸如std::enable_if的型别萃取来约束模板只能接受右值:

SFINAE(替换失败并非错误)原则在P129节8.4中介绍过,它是模板实参推导中在重载解析期间防止不相干的函数模板产生错误的关键人。

例如,考虑这样一对函数模板,它们从给定的容器或数组榨取起始的迭代器:

  • 对数组begin()的模板实参推导失败了,因为std::vector不是一个数组,所以被忽略。
  • 模板实参推导对容器begin成功了,Container被推导成std::vector<int>,因此函数模板可以被实例化,也可以被调用。

第二个begin()调用的实参是一个数组,也会部分失败:

  • 对数组begin()推导成功,T被推导为intN被推导为10
  • 对容器begin()来说,推导需要将Container替换为int[10],这本身没有问题,但是如此产生的返回类型Container::iterator却是无效的(因为数组类型并没有嵌套的名为iterator的类型)。在其他上下文中,试图访问一个本不存在的嵌套类型会立即导致一个编译期错误。而在模板实参的替换中,SFINAE会将这种错误转换成推导失败,并且不再将这一函数模板纳入考虑。因此,第二个begin()候选会被忽略,第一个begin()函数模板的特化体会被调用。

SFINAE防止了那些试图形成的无效类型或表达式,包括因歧义或非法访问控制所产生的错误,它们发生在函数模板替换的即时上下文中。比起定义“函数模板替换的即时上下文”,定义“不在该上下文中”可能更为容易。具体来说,在函数模板替换过程中,为了推导而发生的下面这些实例化期间的事,都不在函数模板替换的即时上下文中:

  • 类模板的定义(即,类模板本身以及其基类列表)
  • 函数模板的定义(即,函数模板本身,对构造函数来说,是其构造初始化器)

此外,任何由替换过程所触发的特殊成员函数的隐式定义也不属于替换的即时上下文。除此之外,尽属即时上下文。

因此,如果在替换函数模板声明的模板参数时需要类模板实例化(因为该类被引用了),则实例化过程产生的错误并不在函数模板替换的即时上下文中,因此它会产生一个真正的错误(即使另一个函数模板可以无错误地匹配上)。例如:

本例与前例最主要的差别在于失败发生的位置。前例中,失败发生在形成一个类型为typename Container::iterator之时,它在begin()函数模板替换的即时上下文中。而本例中,失败发生在Array<int&>的实例化体中,尽管它是由函数模板上下文所触发,但实际上是发生在类模板Array的上下文中。因此,SFINAE原则并不适用,编译器会产生一个错误。

这里有一个C++14的例子——基于推导返回类型(P296节15.10.1)——在函数模板定义的实例化时导致错误:

调用g(42)会推导Tint。这使得g()声明的替换需要我们去确定f(p)的类型(p现在已知为类型int),然后再确定f()的返回类型。f()有两个候选者。非模板候选者是匹配的,但它不是一个良选,这是因为它匹配的是一个省略型参数。不幸的是,模板候选者有一个推导的返回类型,因而我们必须实例化它的定义来确定该返回类型。该实例化会因为p->m无效而失败(因为pint),并且该错误发生在替换上下文之外(因为它在随后的函数定义实例化体中),这就导致本次失败会产生一个错误。为此,我们推荐在可以容易地显式化指定返回类型时,避免使用推导返回类型。

SFINAE设计之初,是旨在消除由函数模板重载所带来的因非意图匹配而产生的奇怪错误,正如容器begin这一例子。然而,探测无效表达式或类型的能力可以实现卓越的编译时技巧,以允许我们判断某个特定的语法是否是合法的。这些技巧将在P416节19.4中进行讨论。

在P424节19.4.4中,有一个特别的例子:让型别萃取SFINAE-friendly来避免及时上下文所产生的问题。

模板实参推导是一个强大的特性,对于大部分函数模板调用来说它消除了显式地指定模板实参的必要性,并且还使能了函数模板重载(见P15节1.5)和类模板偏特化(见P347节16.4)。然而,开发者可能会在使用模板时遇到一些使用上的限制,这些限制会在本节中进行讨论。

通常来说,模板推导会尝试去找到一个函数模板参数的替换,以使得参数化类型P与类型A等同。然而,当无法达成这一条件,而P在推导上下文中又包含一个模板参数时,下面的一些差别也是可以忍受的:

  • 如果原始的参数使用了引用声明,被替换的P类型相比A类型可以有进一步的const/volatile限定
  • 如果A类型是一个指针或是类成员指针类型,它可以通过限定转换(换句话说,就是一种增加const或/和volatile限定符的转换)来转换成一个替换的P类型。
  • 除非推导发生于转换操作符模板,替代的P类型可以是A类型的基类或是指向其基类的指针。例如:

如果P在推导上下文中不包含模板参数,那么所有的隐式转换都是合法的。例如:

仅当不可能完全匹配时才考虑宽松的匹配要求。即使有了这些附加的转换,推导也仅仅会在可以找到满足A类型到P类型的合适替换才会成功。

请注意,这些规则的范围非常狭窄,忽略了(例如)可应用于函数实参以使调用成功的各种转换。例如,考虑下面的max()函数模板调用,该模板在P269节15.1介绍:

这里,模板实参推导根据第一个实参会把T推导为std::string,而第二个实参会把T推导为char[6],所以模板实参推导会失败,这是因为两个参数使用的是同一个模板实参。这种失败可能有些令人诧异,因为字符串字面量"hello"可以被隐式转换成std::string,并且调用::max<std::string>(s,

可能更令人惊讶的是:当两个实参有着从公共基类继承下来的不同的类类型时,推导并不会将公共基类作为推导类型的候选者进行考虑。可参考P7节1.2关于这一议题的讨论以及可行的解决方案。

C++17之前,模板实参推导仅仅应用于函数和成员函数模板。特别地,类模板的实参不会根据其中某一个构造器的实参来进行推导。例如:

这一限制在C++17中被解除——参考P313节15.12。

默认函数调用实参可以再函数模板中指定,正如普通函数所操作的那样:

事实上,如示例所展示,默认函数调用实参可以依赖于模板参数。这种依赖型默认实参仅在没有提供显式的实参时才会被实例化。这一原则是的下面的例子依然合法:

即使当默认调用实参并非依赖型,它也无法被用于推导模板实参。这意味着在C++中,下面的写法是非法的:

与默认调用实参一样,异常规范也仅仅在它们被需要时才会实例化。这意味着他们不会参与模板实参推导。例如:

函数标记#1处的noexcept规范尝试调用一个nonexistent函数。通常来说,函数模板声明中这样的错误会直接触发模板实参推导失败(SFINAE),然后再通过选择标记#2处的函数使用省略型参数匹配是重载解析中最差的匹配,参考附录C)来匹配调用f(i, i)。然而,由于异常规范并没有参与到模板实参推导,重载解析还是会选择标记#1,这就导致当noexcept规范在随后实例化时,程序格式错误。

相同的规则适用于列出潜在异常类型的异常规范:

然而,这些“动态的”异常规范自C++11起就不再推荐使用(deprecated),它们在C++17中被移除。

15.9 显式的函数模板实参

当函数模板实参无法被推导时,通过函数模板名后尾随的方式显式地指定亦是可行的。例如:

对可推导的模板参数来说这也是可行的:

一旦一个模板实参被显式指定了,其对应的参数就不会再被推导。顺带地,对函数调用参数将允许进行类型转换(对推导调用来说这是不可行的)。上例中,实参2compute<double>(2)调用中会被隐式转换成double

显式指定模板实参的一部分亦是可行的。然而,显示指定的那些必须始终得按模板参数从左到右的顺序。因此,那些不能被推导的(或者最可能被显式指定的)参数应该放在最前面。例如:

有时候,通过指定一个空模板实参列表对于确保所选的函数是模板实例也是有用的,此时模板实参还是会进行推导:

这里f(42)会选择非模板函数,因为对于重载解析来说,相比函数模板,它更倾向于选择普通的函数(如果两者是等价的)。然而,对于f<>(42)来说,模板实参列表的存在打破了这一规则,非模板函数不再可选(即使没有指定实际的模板实参)。

在友元函数声明的上下文中,显式模板实参列表的存在会产生一个有趣的效果。考虑下面的例子:

当使用普通的标识符命名一个友元函数时,该函数仅仅会在最近一层的封闭作用域内查找,并且如果它没有被找到的话,就会在该作用域内声明一个新的实体(但它会保留“不可见性”,除非通过ADL查找;参考P220节13.2.2)。这就是我们第一个友元声明:在N作用域内没有f的声明,所以会声明一个不可见的N::f()

然而,当标识符尾随模板实参列表来命名友元函数时,模板必须在那一刻对普通查找是可见的,普通查找会向上搜索任意层作用域(根据其所需要)。因此,我们第二个声明会找到全局的函数模板f(),但是编译器会提出一个错误:返回类型不匹配(由于没有执行ADL,故前一个友元函数的声明会被忽略)。

显式指定的模板实参使用SFINAE法则来替换:如果在某个函数模板的替换的即时上下文中出现了错误,那么它就会被丢弃,但是其他模板依然可能会成功。例如:

这里,#1处候选者在int*替换T时会失败,但是#2处会成功,因此也就会选择#2这一候选。事实上,如果在替换之后仅余一个候选者,那么带有显式模板实参的函数模板名称看起来非常像一个普通的函数名称,包括在许多情况下退化为指向函数的指针类型。也就是说,替换上面的main()为:

这会产生合法的编译单元。然而,下面的例子中:

这种用法就是非法的,因为f<int*>并没有标识着某一个单一的函数。

可变函数模板也可以使用显式模板实参:

有趣的是,包可以被部分显式指定、部分显式推导:

15.10 初始化器和表达式推导

C++11引入了声明这样一种变量的能力:其类型是从初始化器所推导的。C++11也提供了一种机制来表示某个命名实体(变量或函数)或是表达式的类型。这些机制十分易用,C++14和C++17对这一主题又进行了补充。

auto类型指示符在很多地方有着用武之地(主要是命名空间作用域和局部作用域),它会根据变量的初始化器推导变量类型。此时,auto被称作为一个占位符类型(另一个占位符类型是decltype(auto)),我们会在P298节15.10.2中对它进行描述。例如:

本例的两个auto的使用规避了书写两个又臭又长的类型名称:容器的迭代器类型和迭代器的值类型:

auto的推导机制与模板实参推导相同。类型指示符auto取代模板类型参数T,然后推导继续进行,就好像变量是一个函数参数,而其初始化器是相应的函数实参。对例子中第一个auto来说,对应的情况如下:

Tauto要推导的类型。这样做的直接后果之一是,类型为auto的变量永远不会是引用类型。第二个auto使用了auto&来阐释了如何产生一个推导类型的引用。它的推导与下面的函数模板和调用等价:

这里,element永远是引用类型,它的初始化器无法产生一个临时对象。

组合auto与右值引用亦是可行的,但是这样做就变成了一个转发引用,因为auto&& r = ...;的推导模型基于这样一个函数模板:

这就解释了下面的例子:

在泛型代码中这一技巧经常被用来绑定函数或操作符调用的结果(它们的值类别(左值或是右值)并不知晓),而无需拷贝它们的结果。例如,常常推荐这样的方式在循环中声明迭代值:

这里我们不知道容器迭代器接口的签名,但是使用auto&&可以让我们确信在迭代时不会引入额外的值拷贝。如果需要完美转发边界值,那么std::forward<T>()可以像往常那样对变量使用。这使能了一种“延迟的”完美转发。可以参考P167节11.3的示例。

除了引用,我们还可以组合使用auto指示符来让某个变量拥有const,成为指针或是成员指针等等,但是auto必须是其声明的“主”类型。它不能嵌套在模板实参或类型指示符后面的声明符中作为一部分而存在。下面的例子阐释了这些可能性:

关于为什么C++不支持上例中所有的场景,并没有什么技术上的原因,但是C++委员会认为它所带来的额外实现成本以及潜在的滥用性都超出了它的收益。

为了避免同时搞晕开发者和编译器,在C++11中古式的auto用法(作为一个存储类型指示符而存在)不再被允许(今后也一样):

auto的古式用法(继承自C语言)一直是冗余的。大多数编译器通常可以将该用途与占位符区别开来(尽管它们不必如此),以提供从旧C++代码到新C++代码的过渡途径。只不过,auto的古式用法在实践中非常罕见。

C++14新增了另一个推导auto占位符的情景,它出现于函数返回类型。例如:

定义了一个返回类型为int的函数(42的类型)。它也可以使用尾缀返回类型语法来表示:

后者的第一个auto宣布了尾缀返回类型,第二个auto是一个推导的占位符类型。只不过,没有什么理由去支持更啰嗦的语法。

对lambda来说有着相同的默认机制存在:如果没有显式地指定返回类型,lambda表达式返回的类型会按照auto来推导:

函数可以脱离定义而单独声明。对于返回类型需要推导的情景也是一样:

但是,在这种情况下,前向声明的用法非常有限,因为在使用函数的任何位置,该定义都必须可见。也许令人惊讶的是,提供带有“已解决的”返回类型的前向声明是无效的。例如:

通常,由于风格上的偏爱,仅在将成员函数定义移到类定义外部时,前向声明推导的返回类型的函数才有作用:

在C++17之前,非类型参数只能通过指定的类型来声明。然而,这一类型可以是一个模板参数类型。例如:

在本例中,需要指定非类型模板实参的类型——即指定int和42,这可能很乏味。因此,C++17增加了声明非类型模板参数的能力,这些参数的实际类型是从相应的模板实参推导出来的。它们如下所声明:

请注意,对非类型模板参数类型的一般约束仍然有效。例如:

具有这种可推导的非类型参数的模板定义通常还需要表示相应参数的实际类型。这可以通过decltype语法来完成(参考P298节15.10.2)。例如:

auto非类型模板参数在参数化类成员的模板时也很有用。例如:

这里我们使用了一个辅助类模板PMClassT的一个偏特化(参考P347节16.4)来借由成员指针类型追溯到它的“父”类类型。有了auto模板参数,我们只需要指定成员指针常量&S::i作为模板实参。在C++17之前,我们还得指定一个成员指针类型,如OldCounterHandle<int

如你所愿,这一特性也可以为非类型参数包使用:

triplet实例展示了每个非类型参数都可以被单独地推导。与多重可变声明场景(参考P303节15.10.4)不同的是,这里不需要每个推导都是相同的。

如果我们想强制每个非类型模板参数都相同,也是可以实现的:

然而,此场景中模板实参列表不能为空。

可以参考P50节3.4中一个使用了auto作为模板参数类型的完整例子。

尽管auto的使用可以避免书写变量类型,但想要使用该变量类型时就没那么容易了。decltype关键字解决了这一问题:它允许开发者表示某一个表达式或是声明的精准类型。然而,开发者应谨慎对待decltype产生的细微差别,具体取决于传递的参数是声明的实体还是一个表达式:

  • 如果e是某个实体(诸如变量、函数、枚举或是数据成员)或类成员访问的名称,decltype(e)产生的是该实体或表示的类成员的声明类型。因此,decltype可以用来检查变量的类型。当你想要完全匹配现有的声明的类型时,这很有用。例如,考虑下面的两个变量y1y2

依赖于x的初始化器,y1的类型与x可能相同、也可能不同:它依赖于+的行为。如果x被推导为一个int,那么y1也会是int。而如果x被推导为chary1会是一个int,因为char1(定义为int类型)相加得到的是int。对y2类型使用的decltype(x)保证了y2始终与x具有相同的类型。

  • 否则,如果e是任何其他表达式,则decltype(e)生成一个反映该表达式的类型和值类别的类型,如下所示:

可以参考附录B关于值分类的详细描述。这些差别可以通过下面的例子来演示:

前四个表达式中,decltype为变量s所使用:

这意味着decltype产生的是s声明的类型——std::string&&。后四个表达式中,decltype的操作数不是一个名称(而是一个表达式(s),名称在小括号中),此时,类型会反映出(s)的值类别:

我们的表达式按名称指代一个变量,因此它是一个左值:根据上面的规则,这意味着decltype(s)是一个std::string的普通引用(即左值)。这是C++中为数不多的几个地方之一,用括号括起来的表达式除了影响运算符的关联性之外,还可以改变程序的含义。

decltype会计算任意表达式e的类型这一事实在各个地方都可能有所帮助。具体而言,decltype(e)会保留表达式的充足信息,从而可以“完美地”描述返回表达式e本身的函数的返回类型:decltype会计算该表达式的类型,同时将表达式的值类别传播给函数的调用者。例如,考虑一个简单的转发函数g(),它返回被调用的f()的返回结果:

g()的返回类型依赖于f()的返回类型。如果f()返回的是一个int&g()的返回类型的计算会首先判断表达式f()是否具有类型int。该表达式是一个左值,因为f()返回的是左值引用,因此g()声明的返回类型就会是int&。类似地,如果f()的返回类型是一个右值引用类型,f()的调用就是一个将亡值,而decltype会产生一个右值引用类型,它严格匹配f()返回的类型。本质上,这种形式的decltype拿到了任意表达式的主要特征(其类型和值类别),并以能够完美转发返回值的方式在类型系统中对其进行编码。

decltypeauto推导不足以产生值的情景中也十分有用。例如,假设我们有一个变量pos,它是某种未知的迭代器类型,我们希望创建一个变量element,该element可以通过pos解引用来获取。写成:

然而,这始终都会对元素进行一次拷贝。如果我们写成auto& element = *pos;,那我们拿到的始终是该元素的引用,但如果迭代器的operator*返回的是一个值类型,程序就会出错。为了解决该问题,我们可以使用decltype来保留迭代器operator*返回结果的值或引用性:

当迭代器提供的是引用时,就会产生一个引用类型,否则,就会进行值拷贝。它的主要缺陷在于它需要将初始化表达式书写两次:第一次在decltype中(这里不会进行计算),第二次在实际的初始化器中。C++14引入了decltype(auto)语法来解决这一问题,我们马上就会讨论到。

C++14增加了一个组合使用autodecltype的特性:decltype(auto)。正如auto这一类型指示符一样,它是一个类型占位符,并且变量的类型、返回类型或模板实参的类型由关联的表达式类型(初始化器、返回值或模板实参)确定。然而,与auto单单使用模板实参推导法则来确定类型有所不同,实际的类型是通过对表达式直接应用decltype语法来确定的。举个例子来说明:

y的类型借由应用于初始化表达式的decltype获取,这里ref是一个int const&。相对地,auto类型推导法则产生的则是类型int

另一个例子展示了索引std::vector(产生一个左值)时的区别:

这就干净利落地解决了前面示例的问题:

对于返回类型来说它也常常十分便利。考虑下面的例子:

如果container[idx]产生的是左值,我们希望传递左值给调用者(调用者应该希望拿到地址来修改它):此时需要一个左值引用类型,decltype(auto)可以解析出来。如果产生的是一个纯右值,那么引用类型会导致引用悬挂,但是幸运的是,在这种情景下,decltype(auto)会产生一个对象类型(而非引用类型)。

auto不一样的是,decltype(auto)不允许指示符或声明操作符去修改它的类型。例如:

同时也请注意初始化器中的小括号可能很关键(因为它们对decltype结构来说本身很关键,如P91节6.1所讨论):

这尤其意味着括号可能对return语句的有效性产生严重影响:

自C++17起,decltype(auto)还可以对可推导的非类型参数使用(见P296节15.10.1)。下面的例子进行了演示:

在#1处,c没有小括号包裹,推导出的类型就是c类型本身(即int)。因为c42的常量表达式,它就等价于S<42>。在#2处,小括号的包裹导致decltype(auto)会推导出一个引用类型int&,它可以绑定到全局变量v(类型为int)。因此,这样声明的类模板会依赖于v的引用,v值的改变都会影响类S的行为(参考P167节11.4了解更多细节)。(S<v>如果没有小括号的话,会产生一个错误,因为decltype(v)是一个int,此时期望的是一个类型为int的常量实参值。然而,v并不是一个常量int值。)

请注意,两种情况的性质有所不同。因此,我们认为此类非类型模板参数可能会引起意外,并且预计不会被广泛使用。

最后,给出关于在函数模板中使用推导的非类型参数的注解:

本例中,函数模板f<>()的参数N的类型由S的非类型参数类型推导。这是可行的,因为形如X<...>的名称(X是一个类模板)是一个可推导上下文。

然而,也有一些模式是无法被推导的:

本例中,decltype(V)是一个不可推导上下文:并没有匹配实参42的独一无二的V值(例如,decltype(7)decltype(42)产生相同的类型)。因此,非类型模板参数必须被显式地指定,才能使函数调用变得可行。

除却简单的auto推导规则,还存在着一些特殊的情况。第一种情况发生在变量的初始化器是一个初始化列表的场景。对应的函数调用推导必定会失败,因为我们无法通过初始化列表实参来推导出一个模板参数的类型:

然而,如果我们的函数有着如下更特定的参数:

那么推导就会成功。使用初始化列表来拷贝初始化(即,使用=初始化)一个auto变量就定义而言,可以写成更加具体的参数:

在C++17之前,auto变量与之对应的直接初始化(即,不使用=)也可以像这样处理,但是在C++17中对此进行了调整,以更好地满足大部分开发者所期望的行为:

有趣的是,为拥有推导占位符类型作为返回类型的函数返回一个花括号初始化列表是不合法的:

这是因为函数作用域中的初始化列表是一个对象,它指向更底层的数组对象(每个元素值在列表中指定),在函数返回时它就过期了。允许这一语法通行就相当于认可悬垂引用的有效性。

另一种特殊的场景发生在多个变量使用同一个auto进行声明的地方,如下所示:

此处,推导会为每个声明独立进行。换句话说,这里会为first引入模板类型参数T1,为last引入另一个模板类型参数T2。当且仅当两个推导都成功,且T1T2具有相同的推导类型时,这些声明才是合法的。这会滋生一些有趣的案例:

这里,共享的auto声明了两对变量。cpd推导出同样的类型char,因此代码有效。然而fe的声明却因为计算c+1charint的型别提升,导致推导结果不一致而最终产生错误。

推导返回类型的占位符也可能会出现某种平行的特殊情况。考虑下面的例子:

本例中,每个返回语句都会独立进行推导,但是二者推导的结果却不一致,因此程序非法。如果返回表达式递归地调用函数,那么也不会发生推导,程序也是非法的,除非前面的推导已经确定了返回类型。这就意味着下面的代码是非法的:

但是下面的这段等效的代码却是合法的:

推导的返回类型还有另一种特殊的情景,即推导的变量类型或推导的非类型参数类型中没有对应项:

但是f1()f2()都是合法的,并且推导出一个void返回类型。然而,如果返回类型的样式不匹配void,比如这样的情景就是非法的:

如你所愿,使用了推导返回类型的任何函数模板都需要该模板的即时实例化以确定返回类型。然而,出现SFINAE(参考P129节8.4和P284节15.7)时会产生一个令人惊讶的后果。考虑下面的例子: deduce/resulttypetmpl.cpp

这里相比decltype(t+u)addB()所使用的decltype(auto)会在重载解析期间引起一个错误:addB()模板函数体必须被完全实例化以确定其返回类型。调用addB()的实例化体并不在即时上下文中(参考P285节15.7.1),因此不会被SFINAE过滤,而是产生一个错误。因此一定要牢记:推导返回类型绝不仅仅是一个复杂的显式返回类型的缩写,它们在使用上要非常小心(即,要理解它们不应该在依赖于SFINAE属性的其他函数模板签名中被调用)。

C++17增加了一种新的特性,名为结构化绑定(structured bindings)。它常常使用一个小例子来介绍:

调用g()产生了一个值(本例中时一个简单的聚合类类型MaybeInt),它可以被分解成 “元素”(即MaybeInt的数据成员)。该调用产生的值就好像有一个标识符中括号列表[b, N]被不同的变量名所替换。假设该名称为e,那么初始化就等同于:

然后中括号中的每个标识符会绑定到e的对应元素上。因此,你可以认为[b, N]就是e中标识符的每个名字(我们会在下面讨论绑定的细节)。

语法上,结构化绑定必须总是有一个auto类型,它可以使用constvolatile限定符以及&&&声明运算符来扩展(但是不能用*指针声明符或是其他结构)。它的后面跟随着一个中括号列表,其中至少得有一个标识符(让人想起lambda表达式的捕获列表)。后面必须要有一个初始化器。

三种不同类别的实体可以初始化一个结构化绑定:

  1. 第一种是简单的类类型,其中所有的非静态数据成员都是public权限(如上例)。为了应用这一场景,所有的非静态数据成员都必须是public权限(要么全部直接属于类本身,要么全部属于相同的、明确的公共基类;不得涉及匿名联合体)。在这种情况下,带括号的标识符的数量必须等于成员的数量,并且在结构化绑定范围内使用这些标识符之一就等于使用由e表示的对象的相应成员(具有所有相关属性;例如,如果相应的成员是位字段,则无法获取其地址)。
  2. 第二种是数组。考虑下例:

毫不奇怪,中括号中的初始化器只是未命名数组变量的相应元素的简写形式。数组元素的数量必须等于括号内的初始化器的数量。

行#1是特别的:通常来说,上面描述的实体e应该按照下面的形式来推导:

然而,这种推导会退化为指向数组的指针,但是数组的结构化绑定却不会如此。反之,e被推导为一个数组类型的变量,类型与初始化器一致。此后该数组从初始化器中逐个元素拷贝:对于内置数组来说这是个不太寻常的概念。最后,xy分别成为了表达式e[0]e[1]的别名。

而行#2处则没有引入数组拷贝,它也遵循auto的法则。因此假想的e按照如下方式声明:

它会得到一个数组引用,xy再次分别成为表达式e[0]e[1]的别名(调用f()所返回数组的成员左值引用)。

  1. 最后,第三个选项是允许类似std::tuple的类拥有通过模板基础协议get<>分解元素的能力。这里我们把E视为表达式(e)的类型(e的概念同上)。由于E是表达式的类型,它永远不会是一个引用类型。如果表达式std::tuple_size<E>::value是一个合法的整型常量表达式,它必须与中括号标识符的数量相等(并且协议会乱入,优先于选项一,但不优先于数组的选项二)。让我们用n0,n1,n2等表示括号中的标识符。如果e具有名为get的任何成员,则行为就像将这些标识符按如下声明:

如果e被推导为拥有引用类型,或是:

如果e没有成员get,则相应的声明会变成:

get只会在关联的类和命名空间中查找。(在所有情景中,get都被假设为一个模板,因此跟随的<是一个尖括号(而非小于号)。)std::tuplestd::pairstd::array模板都实现了这一协议,下面的代码因而合法:

然而,对于添加std::tuple_sizestd::tuple_element的特化并不困难,函数模板或是成员函数模板get<>()会让这一机制对任何类或枚举类型都能正常工作。例如:

此外,还要注意上述的第三种情况(使用类元组协议)会执行一个真实的中括号初始化并绑定到实际的引用变量上;它们不是另一个表达式的别名(与第一、二类的类类型和数组的情况有所不同)。这很有趣,因为该引用初始化可能出错;例如,它可能会抛出异常,而异常如今是不可避免的。然而,C++标准化委员会也曾就不要关联标识符与初始化的引用进行过讨论,但是最后还是对每个标识符使用了get<>()表达式。这就使得结构化绑定在使用时,“第一个”值必须在“第二个”值被访问前进行测试(例如,基于std::optional)。

译者注: 这一大段不太会翻译,因为我本身也不了解结构化绑定这一特性。

lambda一经问世,很快就成了C++11中最流行的特性,一部分原因在于它们显著地简化了C++标准库和许多其他流行的C++库中仿函数结构(functional constructs)的使用,而这归功于lambda简洁的语法。然而,在模板中lambda变得非常繁琐,这是因为它需要拼出参数和返回类型。例如,考虑这样一个函数模板,它在一个序列中寻找第一个负数值:

在这一函数模板中,lambda最复杂的一部分就是它的参数类型。C++14引入了泛型lambda的概念,使得一个或多个参数类型可以使用auto来推导型别,而不用具体的写出:

对lambda参数auto的处理与使用初始化器的变量类型的auto处理相似:它同样由一个引入的模板类型参数T来取缔。然而,与变量场景不同的是,推导不会立刻执行,这是因为在lambda被创建的时候实参还是未知的。反之,lambda本身是个泛型,引入的模板类型参数被添加到了它的模板参数列表中。因此,上面例子的lambda可以使用任何实参类型来调用,只要该实参类型支持< 0操作符且其结果可以被转换为bool即可。举个例子,这一lambda可以被int或是float值来调用。

为了理解lambda泛型的意义,我们先考虑一个非泛型lambda的实现模型:

C++编译器将该表达式翻译成一个新发明的lambda特定类类型的实例。这一实例被称作闭包(closure)或闭包对象(closure object),类类型被称作闭包类型(closure type)。闭包类型有一个函数调用操作符,因此该闭包就是一个函数对象。对于这一lambda来说,闭包类型可能类似下面的类定义(为了方便与简洁,我们省略了函数到函数指针值的转换):

因此,lambda表达式生成的是该类(闭包类型)的对象。例如:

如果lambda想要捕获局部变量:

这些捕获将被设计成相关类类型的初始化成员:

对泛型lambda来说,函数调用操作符是一个成员函数模板,所以我们简单的泛型lambda:

会被转移成下面的类(同样地,忽略了函数转换,在泛型lambda场景中它是一个转换函数模板):

成员函数模板会在闭包被调用时进行实例化,而不是在lambda表达式出现的地方。例如:

这里,lambda表达式出现于main()中,所以这里会创建一个关联的闭包。然而,闭包的调用操作符并没有在此处实例化。反之,invoke()函数模板使用了闭包类型作为第一个参数类型,int作为第二和第三个参数类型进行了实例化。invoke的实例化被称为闭包的拷贝(依然是一个与原始lambda关联的闭包),并且它实例化了operator()闭包模板来满足实例化调用f(ps...)

别名模板的推导是“透明的“。这意味着当别名模板与模板实参一起出现时,别名的定义(即=右侧的类型)就会被实参所替换,产生的结果正是为推导所用。例如,模板实参推导对下面的三个调用都会成功: deduce/aliastemplate.cpp

std::deque<T>>是等价的。对模板实参推导的目标来说,模板别名是透明的:它们可以用来区分和简化代码,但是对于推导如何进行确不会产生效果。

请注意,这是因为别名模板不能特化(参考章节16了解模板特化这一话题的更多细节)才行得通。假设下面的代码可行:

此时,我们无法将A<T>void类型匹配,并得出结论T必须为void,因为A<int>A<void>都等价于void。不可能做到这一点的事实保证,别名的每次使用都可以根据其定义进行一般性的扩展,从而使别名可以进行透明地推导。

15.12 类模板实参推导

C++17引入了一种新的推导:从变量声明的初始化器或函数符号型别转换来推导类模板参数。例如:

请注意,所有的参数都必须由推导过程或默认实参来确定。显式地指定一部分参数并推导剩下的参数是行不通的。例如:

考虑P288节15.8.2的一个示例,我们施加一些小变化:

新增的这种模板风格的结构叫做推导指引。它看起来有点像函数模板,但是它与函数模板在语法上有很多不同:

  • 看起来像尾缀返回类型的部分不能写成一个传统的返回类型。我们称这个指定的类型(本例中为S<T>)指引类型(guided type)。
  • 没有前导auto关键字来指示尾缀返回类型。
  • 推导指引的“名称”必须是同作用域内更早出现的类模板的非受限名称。
  • 指引的指引类型必须是一个模板ID,它的模板名称与指引名称一致。
  • 可以使用explicit说明符声明。

S x(12);这一声明中,说明符S被称为占位类类型(placeholder class type)。当使用这样的占位符时,被声明的变量名称必须紧随其后,并且后面一定要有初始化器。下面的代码是非法的:

如上例所书写的指引,声明S x(12);通过将与类S的推导指引视为重载集合,并尝试使用初始化器针对该重载集合来进行重载解析,对变量的类型进行推导。在这一场景中,集合内仅仅有一个指引在其中,它会成功地推导Tint,指引的指引类型为S<int>。这一指引类型因此被选为声明的类型。

请注意,如果类模板名称后面的多个声明都需要推导,那么每个声明都需要产生相同的类型。例如,使用上面的声明:

这与C++11中auto占位符类型的限制相似。

在前面的例子中,我们声明的推导指引与类S中声明的构造函数S(T b)之间有一个隐式的联系。然而,这种联系并不是必要的,这意味着推导指引也可以为聚合类模板所使用:

如果没有推导指引,我们必须始终显式地指定模板实参(即使在C++17中也一样):

但是如果有了上面的指引,就可以写成:

这里有一个微妙之处在于,初始化器必须也是一个合法的聚合类初始化器,也就是说,它必须是一个花括号初始化列表。下面的一些替换是不被允许的:

通常,对于类模板中的每个构造函数都需要一个推导指引。这使得类模板实参推导的设计者为推导引入了一种隐式地机制。为类主模板的每个构造函数和构造函数模板都引入了一个等价的隐式推导指引,如下所述:

  • 隐式指引的模板参数列表由类模板的模板参数、构造函数模板的模板参数(构造函数模板的场合)构成。构造函数模板的模板参数会保留任何默认实参。
  • 指引的“类函数”参数会从构造函数或构造函数模板中拷贝。
  • 指引的指引类型就是模板的名称,其参数是从类模板中获取的模板参数。

让我们应用到一个原始的类模板示例:

模板参数列表为typename T,类函数参数列表就是(T b),指引类型也就是S<T>。因此,我们获得了一个指引,它与我们此前书写的那个用户声明的指引等价:即,为了达成我们想要的效果,该指引完全不必要!也就是说,仅书写原始的简单类模板(无需推导指引),我们还是可以有效地写成S

推导指引有一个不幸的歧义。考虑一下我们简单的类模板S和下面的实例化语句:

我们已经看到了x有着类型S<int>,但是xy应该是什么类型呢?这两种类型直觉上应该是S<S<int>>S<int>。委员会在富有争议的情况下决定,这两种情况下都应为S<int>。为什么这是有争议的呢?考虑使用vector类型的一个相似的例子:

换句话说,拥有单个元素的花括号初始化器的推导与拥有多个元素的花括号初始化器有所差别。通常来说,人们只希望要其中的某一个结果,但是两者确并不一致。然而在泛型代码中,很容易忽视这一细小的差别:

这里当T被推导为vector类型时,vps参数包为空或非空的情景下,v的类型是不一样的。

隐式模板指引本身的添加并没有争议。反对将它们引入的主要观点是该功能会自动将接口添加到现有库中。为了理解这一说法,再次考虑我们前面的类模板S。它的定义自C++引入类模板时就是有效的。假设,S的作者扩展了库,让S以更缜密的方式定义:

在C++17之前,这样的转变(不太常见)不会影响现有的代码。然而,在C++17中它们禁用了隐式推导指引。让我们书写一个与隐式推导指引相仿的推导指引:模板参数列表和指引类型无需改变,但是类函数参数现在需要写成ArgType的形式,也就是typename ValueArg<T>::Type:

回想一下P271节15.2,类似ValueArg<T>::的名称限定符不是一个推导上下文。因此这种形式的推导指引是没有用的,它无法解析S x(12);这样的声明。换句话说,库的作者执行了这一转换可能会破坏其在C++17中的客户端代码。

这种情况下库的作者要怎么办呢?我们的建议就是小心地考虑每一个构造函数,在库剩余的生命期内是否希望它作为隐式推导指引的来源。如果不希望,就用诸如typename ValueArg<X>::Type来替换每一个可推导的类型为X的构造函数参数的实例。很不幸,没有更简单的方法去把隐式推导指引摘除。

这段代码在C++14中是合法的:X(b, e)中的X是注入式类名称,在该上下文中等价于X<T>(参考P221节13.2.3)。然而,对类模板实参推导这一规则来说,X会自然而然地等价于X<Iter>

为了保留向后兼容性,类模板实参推导在模板名称是注入式类名称的场合下会被禁用。

显然,这里的目的是通过拷贝构造函数所关联的隐式推导指引架构T推导为std::string。然而,将隐式推导指引显式地声明出来反而发生令人惊讶的事:

回想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修饰。此时它仅仅会考虑直接的初始化场景,而不会考虑拷贝初始化场景。例如:

注意这里的z1初始化使用了拷贝初始化,因此声明了explicit的推导指引#2就不会被考虑。

为了理解隐式指引的效果,我们用显式地声明它们:

这显然会选择第一个指引,因此第一个构造函数:x就是一个Tuple<int, int>。让我们继续看看下面的例子,它们使用了x拷贝的语法:

int>。幸运的是,第二个指引更加匹配,因此ab都会从x拷贝构造出来。

现在。考虑使用花括号列表的例子:

int>>。这完全符合直觉,不足为奇。第二个示例则会将d推导为类型Tuple<Tuple<int>>。然而,它被视为一个拷贝构造(即,更倾向于第二个隐式指引)。这也会发生在functional-notation转换的场景:

推导指引并非函数模板:它们仅仅用来推导模板参数,并不会被“调用”。这意味着不论是通过引用还是通过值来传递实参对指引声明并不重要。例如:

注意看推导指引并没有完全与Y的两个构造函数保持一致。然而,这并没有什么关系,因为指引仅仅为推导所用。给定类型为X<TT>xtt左值或是右值,它都会选择推导类型Y<TT>。然后,初始化会在Y<TT>的构造器上执行重载解析以判断需要调用哪一个(这取决于xtt是左值还是右值)。

函数模板的模板实参推导本就是C++原始设计的一部分。实际上,显式模板实参的使用在很多年之后才成了C++的一部分。

SFINAE是一个术语,它在本书的第一版就介绍过了。这一术语很快就在C++开发者委员会中盛行。然而,在C++98中,SFINAE并没有那么强大:它仅仅适用于一个有限的类型操作符集合,并且没有覆盖任意表达式或访问控制。由于越来越多的技术开始依赖于SFINAE(参考P416节19.4),推广SFINAE显而易见。Steve Adamczyk和John Spicer开发了在C++11中实现的措辞(见论文N2634)。尽管标准中的措词更改相对较小,但事实证明某些编译器的实现工作量不成比例。

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))之间的细微差别,但他们并没有说服力来更改最终规范。

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)。

我要回帖

更多关于 希望别人指点的客套话 的文章

 

随机推荐