CXYVIP官网源码交易平台_网站源码_商城源码_小程序源码平台-丞旭猿论坛
CXYVIP官网源码交易平台_网站源码_商城源码_小程序源码平台-丞旭猿论坛
CXYVIP官网源码交易平台_网站源码_商城源码_小程序源码平台-丞旭猿论坛

洞悉C++函数重载决议-源码交易平台丞旭猿

大家可以尝试问自己一个问题:

调用一个重载函数,编译器是如何找到最佳匹配函数的?

若是你不能清楚地表述这个流程,就说明对函数重载决议缺乏认识。

函数重载决议也的确是许多C++开发者都听过,但却从来没有真正理解过的一个概念,基本上也没有书籍深入讲解过这一重要概念,然而对语言的深刻理解往往就是建立在对这些基本概念的理解之上。

那么理解这个有什么用呢?

对于库作者,理解重载决议必不可少。因为重载涉及函数,函数又是变化的最小单元之一,可以说重载决议贯穿了「定制点」的发展历程。只有理解重载决议,才能理解各种定制点的表现方式,比如ADL二段式、CPOs、Deducing this。

对于使用者,若是不理解重载决议,就不能理解定制点,也就无法真正理解各种库的设计思路,使用起来难免会束手束脚。

同时,重载决议还涉及ADL、TAD、SFINAE、Concepts、Forwarding reference等等概念,若不理解重载决议,对这些相关概念的理解也将难以深入。

总而言之,重载决议与C++中的许多概念都有着千丝万缕的联系,且重载解析本身就是一件非常复杂的工作,这也是其难度颇高的原因。

接下来,就让我们拨开重重云雾,一探其究竟。

1 重载决议的基本流程

函数的标识主要分为两部分,名称和参数。

当函数名称唯一时,调用过程相对简单,直接查找即可。C语言就属此列,它的函数名称必须唯一。

当函数名称相同,但参数类型不同时,在许多语言中依旧合法,此时这些名称相同的函数就称为重载函数。

C++就是支持重载函数的语言之一,那么它要如何来确定函数名的唯一性?

实际上,编译器会通过一种称为Name mangling(名称修饰)的技术来为每个重载函数生成唯一的名称。虽然重载函数的名称是相同的,但其参数不同,因此通过名称+参数再辅以一些规则,生成唯一的名称其实并非难事。

但这仍非实现重载函数的关键与难点所在。名称是唯一产生了,但是用户并不知道,也并不能直接通过该名称来调用函数。用户调用的还是重载函数名称本身,此时就需要一套机制来解析实际调用的函数到底是哪个,该机制就是「重载决议」,由C++标准制定。

简言之,只要遇到名称相同的函数,重载决议就会出现,用于找出最佳匹配函数。

那么问题又来了,它是如何知道存在哪些名称相同的函数?

这便是在重载决议出现之前的一项工作,称为Name Lookup(名称查找)。

这一阶段,会根据调用的函数名称,查找函数的所有声明。若函数具有唯一的名称,那么就不会触发重载决议;若查找到多个相同的函数名称,这些函数声明就会被视为一个overload set(重载集)。

函数又分为普通函数和函数模板,在Name Lookup阶段都会被查找到。但是函数模板只有实例化之后才能被使用,因此如果存在函数模板,还需要对模板进行特殊的处理,这个阶段就称为Template Handling(模板处理)。

经过上述两个阶段的处理,得到的重载集就称为candidate functions(候选函数),重载决议的工作就是在这些candidate functions中,找出最适合的那一个函数。

总结一下,当你调用一个重载函数时,编译器首先会进行Name Lookup,找出所有函数声明,然后对函数模板进行Template Handling,实例化出模板函数,产生candidate functions,接着重载决议出现,找出最佳匹配函数。

而实际的最佳匹配函数调用,则是通过Name mangling产生的函数名称完成的。

2 Name Lookup

首先来看第一阶段,Name Lookup。该阶段仅仅进行名称查找,并不做任何额外检查。

Name Lookup的工作主要可以分为两大部分。

第一部分为Qualified Name Lookup(有修饰名称查找),这主要针对的是带有命名空间的函数调用,或是成员函数。

第二部分为Unqualified Name Lookup(无修饰名称查找),这种针对的就是普通函数的调用。

下面依次进行讨论。

2.1Qualified Name Lookup

带修饰的名称查找并不算复杂,这又可以主要分为两类查找。

一类是Class Member Lookup,表示对于类成员的名称查找;另一类是Namespace Member Lookup,表示对于命名空间下的名称查找。

其实还可以包含枚举名称,因为它也可以使用作用域解析操作符”::”进行访问,但一法通万法,不必单独细论。

以下单独讨论主要的两类。

2.1.1Class Member Lookup

类成员查找,是在访问类成员时进行名称查找的规则。

成员本质上来说还是两种类型,变量与函数。换个角度来看,成员又可分为静态成员和动态成员,静态成员可以通过”::”进行访问,动态成员可以通过”.”或”->”进行访问。

也就是说,当你使用如上三种方式访问某个变量或函数时,就可能会触发Class Member Lookup。

首先来看前者,即使用”::”访问时的规则。示例如下:

// Example from ISO C++classX{};classC{classX{};staticconstintnumber =50;staticX arr[number];};X C::arr[number];// 1

可以将1处的定义从”::”拆分为前后两部分。

对于前面的名称X和C,将会在其定义的命名空间进行查找,此处即为全局空间,于是查找到全局作用域下的X和C类。

对于后面的名称arr和number,将会在C类的作用域下进行查找,它们将作为类成员进行查找。

此时就是”::”前面的类型名,告诉编译器后面的名称应该通过Class Member Lookup进行查找。如果搜索发现前面是个命名空间,则会在相应的作用域下查找。

由于X是在全局作用域下查找到的,所以并不会找到内部类X,于是该声明会产生编译错误。

接着来看后者,关于”.”和”->”的规则。看一个简单的例子:

structS{voidf(){}};S s;s.f();S* ps = &s;ps->f();

此处要调用f函数,因为使用了”.”或”->”操作符,而操作符前面又是一个类,所以f的查找将直接使用Class Member Lookup,在类的作用域下进行查找。

这种调用一目了然,查找起来也比较方便,便不在此多加着墨,下面来看另一类带修饰的名称查找。

2.1.2Namespace Member Lookup

命名空间成员查找,是在访问命名空间下的元素时进行名称查找的规则。

当你使用”::”访问元素的时候,就有可能会触发Namespace Member Lookup。

比如,当把”::”单独作为前缀时,则会强制Name Lookup在全局空间下进行查找。如下述例子:

voidf();// 1namespacemylib {voidf();// 2voidh(){::f();// calls 1f();// calls 2}}// namespace mylib

此时,若是没有在全局作用域下搜索到相应的函数名称,也不会调用2,而是产生编译错误。若是要在外部访问命名空间内部的f(),则必须使用mylib::f(),否则Name Lookup会找到全局作用域下的1。

下面再来看一个稍微复杂点的例子:

1// Example from ISO C++23intx;4namespaceY {5voidf(float);6voidh(int);7}89namespaceZ {10voidh(double);11}1213namespaceA {14usingnamespaceY;15voidf(int);16voidg(int);17inti;18}1920namespaceB {21usingnamespaceZ;22voidf(char);23inti;24}2526namespaceAB {27usingnamespaceA;28usingnamespaceB;29voidg();30}3132voidh(){33AB::g();// 134AB::f(1);// 235AB::f(c);// 336AB::x++;// 437AB::i++;// 538AB::h(16.8);// 639}

这里一共有6处调用,下面分别来进行分析。

第一处调用,1。

Name Lookup发现AB是一个命名空间,于是在该空间下查找g()的定义,在29行查找成功,于是可以成功调用。

第二处调用,2。

Name Lookup同样先在AB下查找f()的定义,注意,查找的时候不会看参数,只看函数名称

然而,在AB下未找到相关定义,可是它发现这里还有了两个using-directives,于是接着到命名空间A和B下面查找。

之后,它分别查找到了A::f(int)和B::f(char)两个结果,此时重载决议出现,发现A::f(int)是更好的选择,遂进行调用。

第三处调用,3。

它跟2的Name Lookup流程完全相同,最终查找到了A::f(int)和B::f(char)。于是重载决议出现,发现后者才是更好的选择,于是调用B::f(char)。

第四处调用,4。

Name Lookup先在AB下查找x的定义,没有找到,于是再到命名空间A和B下查找,依旧没有找到。可是它发现A和B中也存在using-directives,于是再到命名空间Y和Z下面查找。然而,还是没有找到,最终编译失败。

这里它并不会去查找全局作用域下的x,因为x的访问带有修饰。

第五处调用,5。

Name Lookup在AB下查找失败,于是转到A和B下面查找,发现存在A::i和B::i两个结果。但是它们的类型也是一样,于是重载决议失败,产生ambiguous(歧义)的错误。

最后一处调用,6。

同样,在AB下查找失败,接着在A和B下进行查找,依旧失败,于是接着到Y和Z下面查找,最终找到Y::h(int)和Z::h(double)两个结果。此时重载决议出现,发现后者才是更好的选择,于是最终选择Z::h(double)。

通过这个例子,相信大家已经具备分析Namespace Member Lookup名称查找流程的能力。

接着再补充几个需要注意的点。

第一点,被多次查找到的名称,但是只有一处定义时,并不会产生ambiguous。

1namespaceX {2inta;3}45namespaceA {6usingnamespaceX;7}89namespaceB {10usingnamespaceX;11}1213namespaceAB {14usingnamespaceA;15usingnamespaceB;16}1718AB::a++;// OK

这里,Name Lookup最终查找了两次X::a,但因为实际只存在一 处定义,于是一切正常。

第二点,当查找到多个定义时,若其中一个定义是类或枚举,而其他定义是变量或函数,且这些定义处于同一个命名空间下,则后者会隐藏前者,即后者会被选择,否则ambiguous。

可以通过以下例子来进行理解:

1// Example from ISO C++23namespaceA {4structx{};5intx;6inty;7}89namespaceB {10structy{};11}1213namespaceC {14usingnamespaceA;15usingnamespaceB;16inti = C::x;// 117intj = C::y;// 218}

先看1,由于C中查找x失败,进而到A和B中进行查找,发现A中有两处定义。一处定义是类,另一处定义是变量,于是后者隐藏前者,最终选择int x;这处定义。

而对于2,最终查找到了A::y和B::y两处定义,由于定义不在同一命名空间下,所以产生ambiguous。

到此,对Qualified Name Lookup的内容就基本覆盖了,下面进入Unqualified Name Lookup。

2.2Unqualified Name Lookup

无修饰的名称查找则略显复杂,却会经常出现。

总的来说,也可分为两大类。

第一类为Usual Unqualified Lookup,即常规无修饰的名称查找,也就是普遍情况会触发的查询。

第二类为Argument Dependant Lookup,这就是鼎鼎大名的ADL,译为实参依赖查找。由其甚至发展出了一种定制点表示方式,称为ADL二段式,标准中的std::swap, std::begin, std::end, operator<<等等组件就是通过该法实现的。

但是本文并不会涉及定制点的讨论,因为这是我正在写的书中的某一节内容:)  内容其实非常之多之杂,本篇文章其实就是为该节扫除阅读障碍而特意写的,侧重点并不同。

以下两节,分别讲解这两类名称查找。

2.2.1UsualUnqualified Lookup

普通的函数调用都会触发Usual Unqualified Lookup,先看一个简单的例子:

1voidf(char);23voidf(double);45namespacemylib {6voidf(int);78voidh(){9f(3);// 110f(.0);// 211}12}

对于1和2,Name Lookup会如何查找?最终会调用哪个重载函数?

实际上只会查找到f(int),1直接调用,2经过了隐式转换后调用。

为什么呢?记住一个准则,根据作用域查找顺序,当Name Lookup在某个作用域找到声明之后,便会停止查找。关于作用域的查找顺序,后面会介绍。

因此,当查找到f(int),它就不会再去全局查找其他声明。

注意:即使当前查找到的名称实际无法成功调用,也并不改变该准则。看如下例子:

1voidf(int);23namespacemylib {4voidf(constchar*);56voidh(){7f(3);// 1 Error8}9}

此时,依旧只会查找到f(const char*),即使f(int)才是正确的选择。由于没有相应的隐式转换,该代码最终编译失败。

那么具体的作用域查找顺序是怎样的?请看下述例子:

1// Example from ISO C++23namespaceM {45classB{// S36};7}89// S510namespaceN {11// S412classY:publicM::B {13// S214classX{15// S116inta[i];// 117};18};19}

1处使用了变量i,因此Name Lookup需要进行查找,那么查找顺序将从S1-S5。所以,只要在S1-S5的任何一处声明该变量,就可以被Name Lookup成功找到。

接着来看另一个查找规则,如果一个命名空间下的变量是在外部重新定义的,那么该定义中涉及的其他名称也会在对应的命名空间下查找。

简单的例子:

1// Example from ISO C++23namespaceN {4inti =4;5externintj;6}78inti =2;9intN::j = i;// j = 4

由于N::j在外部重新定义,因此变量i也会在命名空间N下进行查找,于是j的值为4。如果在N下没有查找到,才会查找到全局的定义,此时j的值为2。

而对于友元函数,查找规则又不相同,看如下例子:

1// Example from ISO C++23structA{4typedefintAT;5voidf1(AT);6voidf2(float);7template<classT>voidf3();8};910structB{11typedefcharAT;12typedeffloatBT;13friendvoidA::f1(AT);// 114friendvoidA::f2(BT);// 215friendvoidA::f3();// 316};

此处,1的AT查找到的是A::AT,2的BT查找到的是B::BT,而3的AT查找到的是B::AT。

这是因为,当查找的名称并非模板参数时,首先会在友元函数的原有作用域进行查找,若没查找到,则再在当前作用域进行查找。对于模板参数,则直接在当前作用域进行查找。

2.2.2Argument Dependant Lookup

终于到了著名的ADL,这是另一种无修饰名称查找方式。

什么是ADL?其实概念很简单,看如下示例。

namespacemylib {structS{};voidf(S);}intmain(){mylib::S s;f(s);// 1,OK}

按照Usual Unqualified Lookup是无法查找到1处调用的声明的,此时编译器就要宣布放弃吗?并不会,而是再根据调用参数的作用域来进行查找。此处,变量s的类型为mylib::S,于是将在命名空间mylib下继续查找,最终成功找到声明。

由于这种方式是根据调用所依赖的参数进行名称查找的,因此称为实参依赖查找。

那么有没有办法阻止ADL呢?其实很简单。

namespacemylib {structS{};voidf(S){std::cout<<"f found by ADL\n";}}voidf(mylib::S){std::cout<<"global f found by Usual Unqualified Lookup\n";}intmain(){mylib::S s;(f)(s);// OK, calls global f}

这里存在两个定义,本应产生歧义,但当你给调用名称加个括号,就可以阻止ADL,从而消除歧义。

实际上,ADL最初提出来是为了简化重载调用的,可以看如下例子。

intmain(){// std::operator<<(std::ostream&, const char*)// found by ADL.std::cout<<"dummy string\n";// same as aboveoperator<<(std::cout,"dummy string\n");}

如果没有ADL,那么Unqualified Name Lookup是无法找到你所定义的重载操作符的,此时你只能写出完整命名空间,通过Qualified Name Lookup来查找到相关定义。

但这样代码写起来就会非常麻烦,因此,Unqualified Name Lookup新增加了这种ADL查找方式。

在编写一个数学库的时候,其中涉及大量的操作符重载,此时ADL就尤为重要,否则像是”+”,”==”这些操作符的调用都会非常麻烦。

后来ADL就被广泛运用,普通函数也支持此种查找方式,由此还诞生了一些奇技淫巧。

不过,在说此之前,让我们先熟悉一下常用的ADL规则,主要介绍四点。

第一点,当实参类型为函数时,ADL会根据该函数的参数及返回值所属作用域进行查找。

例子如下:

1namespaceB {2structR{};3voidg(...){4std::cout<<"g found by ADL\n";5}6}78namespaceA {9structS{};10typedefB::R (*pf)(S);1112voidf(pf){13std::cout<<"f found by ADL\n";14}15}1617B::Rbar(A::S){18return{};19}2021intmain(){22A::pf fun = bar;23f(fun);// 1, OK24g(fun);// 2, OK25}

1和2处,分别调用了两个函数,参数为另一个函数,根据该条规则,ADL得以查找到A::f()与B::g()。

第二点,若实参类型是一个类,那么ADL会从该类或其父类的最内层命名空间进行查找。

例子如下:

1namespaceA {2// S23structBase{};4}56namespaceM {7// S3 not works!8namespaceB {9// S110structDerived:A::Base {};11}12}1314intmain(){15M::B::Derived d;16f(d);// 117}

此处,若要通过ADL找到f()的定义,可以将其声明放在S1或S2处。

第三点,若实参类型是一个类模板,那么ADL会在特化类的模板参数类型的命名空间下进行查找;若实参类型包含模板模板参数,那么ADL还会在模板模板参数类型的命名空间下查找。

例子如下:

1namespaceC {2structFinal{};3voidg(...){4std::cout<<"g found by ADL\n";5}6};78namespaceB {9template<typenameT>10structTemtem{};1112structBar{};13voidf(...){14std::cout<<"f found by ADL\n";15}16}1718namespaceA {19template<typenameT>20structFoo{};21}2223intmain(){24// class template arguments25A::Foo foo;26f(foo);// OK2728// template template arguments29A::Foo> a;30g(a);// OK3132}

代码一目了然,不多解释。

第四点,当使用别名时,ADL会无效,因为名称并不是一个函数调用。

看这个例子:

1// Example from ISO C++23typedefintf;4namespaceN {5structA{6friendvoidf(A&);7operatorint();8voidg(A a){9inti = f(a);// 110}11};12}

注意1处,并不会应用ADL来查询函数f,因为它其实是int,相当于调用int(a)。

说完了这四点规则,下面来稍微说点ADL二段式相关的内容。

看下面这个例子:

1namespacemylib {23structS{};45voidswap(S&, S&){}67voidplay(){8usingstd::swap;910S s1, s2;11swap(s1, s2);// OK, found by Unqualified Name Lookup1213inta1, a2;14swap(a1, a2);// OK, found by using declaration15}16}

然后,你要在某个地方调用自己提供的这个定制函数,此处是play()当中。

但是调用的地方,你需要的swap()可能不只是定制函数,还包含标准中的版本。因此,为了保证调用形式的唯一性,调用被分成了两步。

  • 使用using declaration

  • 使用swap()

这样一来,不同的调用就可以被自动查找到对应的版本上。然而,只要稍微改变下调用形式,代码就会出错:

1namespacemylib {23structS{};45voidswap(S&, S&){}// 167voidplay(){8usingnamespacestd;910S s1, s2;11swap(s1, s2);// OK, found by Unqualified Name Lookup1213inta1, a2;14swap(a1, a2);// Error15}16}

这里将using declaration写成了using directive,为什么就出错了?

其实,前者将std::swap()直接引入到了局部作用域,后者却将它引入了与最近的命名空间同等的作用域。根据前面讲过的准则:根据作用域查找顺序,当Name Lookup在某个作用域找到声明之后,便会停止查找。编译器查找到了1处的定制函数,就立即停止,因此通过using directive引入的std::swap()实际上并没有被Name Lookup查找到。

这个细微的差异很难发现,标准在早期就犯了这个错误,因此STL中的许多实现存在不少问题,但由于ABI问题,又无法直接修复。这也是C++20引入CPOs的原因,STL2 Ranges的设计就采用了这种新的定制点方式,以避免这个问题。

在这之前,标准发明了另一种方式来解决这个问题,称为Hidden friends。

1namespacemylib {23structS{4// Hidden friends5friendvoidswap(S&, S&){}6};78voidplay(){9usingnamespacestd;1011S s1, s2;12swap(s1, s2);// OK, found by ADL1314inta1, a2;15swap(a1, a2);// OK16}17}

就是将定制函数定义为友元版本,放在类的内部。此时将不会再出现名称被隐藏的问题,这个函数只能被ADL找到。

Hidden friends的写法在STL中存在不少,想必大家曾经也不知不觉中使用过。

好,更多关于定制点的内容本文不再涉及,下面进行另一个内容。

2.3Template Name Lookup

以上两节Name Lookup内容只涉及零星关于模板的名称查找,本节专门讲解这部分查找,它们还是属于前两节的归类。

首先要说的是对于typename的使用,在模板当中声明一些类型,有些地方并不假设其为类型,此时只有在前面添加typename,Name Lookup才视其为类型。

不过自C++20之后,需要添加typename的地方已越来越少,已专门写过文章,请参考:新简化!typename在C++20不再必要

其次,介绍一个非常重要的概念,「独立名称」与「依赖名称」。

什么意思呢?看一个例子。

// Example from ISO C++intj;template<classT>structX{voidf(T t,inti,char* p){t = i;// 1p = i;// 2p = j;// 3}};

在Name Lookup阶段,模板还没有实例化,因此此时的模板参数都是未知的。对于依赖模板参数的名称,就称其为「依赖名称」,反之则为「独立名称」。

依赖名称,由于Name Lookup阶段还未知,因此对其查找和诊断要晚一个阶段,到模板实例化阶段。

独立名称,其有效性则在模板实例化之前,比如2和3,它们诊断就比较早。这样,一旦发现错误,就不必再继续向下编译,节省编译时间。

查找阶段的变化对Name Lookup存在影响,看如下代码:

1// Example from ISO C++23voidf(char);45template<classT>6voidg(Tt) {7f(1);// non-dependent8f(T(1));// dependent9f(t);// dependent10dd++;// non-dependent11}1213enumE { e };14voidf(E);1516doubledd;17voidh(){18g(e);// calls f(char),f(E),f(E)19g(a);// calls f(char),f(char),f(char)20}

在h()里面有两处对于g()的调用,而g()是个函数模板,于是其中的名称查找时间并不相同。

f(char)是在g()之前定义的,而f(E)是在之后定义的,按照普通函数的Name Lookup,理应是找不到f(E)的定义的。

但因为存在独立名称和依赖名称,于是独立名称会先行查找,如f(1)和dd++,而变量dd也是在g()之后定义的,所以无法找到名称,dd++编译失败。对于依赖名称,如f(T(1))和f(t),它们则是在模板实例化之后才进行查找,因此可以查找到f(E)。

一言以蔽之,即使把依赖名称的定义放在调用函数之后,由于其查找实际上发生于实例化之后,故也可成功找到。

事实上,存在术语专门表示此种查找方式,称为Two-phase Name Lookup(二段名称查找),在下节还会进一步讨论。

接着来看一个关于类外模板定义的查找规则。

看如下代码:

1// Example from ISO C++23template<classT>4structA{5structB{};6typedefvoidC;7voidf();8template<classU>voidg(U);9};1011template<classB>12voidA::f() {13B b;// 114}1516template<classB>17template18voidA::g(C) {19B b;// 220C c;// 321}

思考一下,1,2,3分别分别查找到的是哪个名称?(这个代码只有clang支持)

实际上,1和2最终查找到的都是A::B,而3却是模板参数C。

注意第16-17行出现的两个模板,它们并不能合并成一个,外层模板指的是类模板,而内层模板指的是函数模板。

因此,规则其实是:对于类外模板定义,如果成员不是类模板或函数模板,则类模板的成员名称会隐藏类外定义的模板参数;否则模板参数获胜。

而如果类模板位于一个命名空间之内,要在命名空间之外定义该类模板的成员,规则又不相同。

1// Example from ISO C++23namespaceN {4classC{};5template<classT>classB{6voidf(T);7};8}910template<classC>11voidN::B::f(C) {12C b;// 113}

此处,1处的C查找到的是模板参数。

如果是继承,那么也会隐藏模板参数,代码如下:

1// Example from ISO C++23structA{4structB{};5inta;6intY;7};89template<classB,classa>10structX:A {11B b;// A::B12a b;// A::a, error, not a type name13};

这里,最终查找的都是父类当中的名称,模板参数被隐藏。

然而,如果父类是个依赖名称,由于名称查找于模板实例化之前,所以父类当中的名称不会被考虑,代码如下:

1// Example from ISO C++23typedefdoubleA;4template<classT>5structB{6typedefintA;7};89template<classT>10structX:B {11A a;// double12};

这里,最终X::A的类型为double,这是识别为独立名称并使用Unqualified Name Lookup查找到的。若要访问B::A,那么声明改为B::A a;即可,这样一来就变为了依赖名称,且采用Qualified Name Lookup进行查找。

最后,说说多继承中包含依赖名称的规则。

还是看一个例子:

1// Example from ISO C++23structA{4intm;5};67structB{8intm;9};1011template<classT>12structC:A, T {13intf(){returnthis->m; }// 114intg(){returnm; }// 215};1617templateintC::f();// ambiguous!18templateintC::g();// OK

此处,多重继承包含依赖名称,名称查找方式并不相同。

对于1,使用Qualified Name Lookup进行查找,查询发生于模板实例化,于是存在两个实例,出现ambiguous。

而对于2,使用Unqualified Name Lookup进行查找,此时相当于是独立名称查找,查找到的只有A::m,所以不会出现错误。

2.4Two-phase Name Lookup

因为模板才产生了独立名称与依赖名称的概念,依赖名称的查找需要等到模板实例化之后,这就是上节提到的二段名称查找。

依赖名称的存在导致Unqualified Name Lookup失效,此时,只有使用Qualified Name Lookup才能成功查找到其名称。

举个非常常见的例子:

1structBase{2// non-dependent name3voidf(){4std::cout<<"Base class\n";5}6};78structDerived:Base {9// non-dependent name10voidh(){11std::cout<<"Derived class\n";12f();// OK13}14};151617intmain(){18Derived d;19d.h();20}2122// Outputs:23// Derived class24// Base class

这里,f()和h()都是独立名称,因此能够通过Unqualified Name Lookup成功查找到名称,程序一切正常。

然而,把上述代码改成模板代码,情况就大不相同了。

1template<typenameT>2structBase{3voidf(){4std::cout<<"Base class\n";5}6};78template<typenameT>9structDerived:Base {10voidh(){11std::cout<<"Derived class\n";12f();// error: use of undeclared identifier f13}14};151617intmain(){18Derived<int> d;19d.h();20}

此时,代码已经无法编译通过了。

为什么呢?当编译器进行Name Lookup时,发现f()是一个独立名称,于是在模板定义之时就开始查找,然而很可惜,没有查找到任何结果,于是出现未定义的错误。

那么它为何不在基类当中查找呢?这是因为它的查找发生在第一阶段的Name Lookup,此时模板还没有实例化,编译器不能草率地在基类中查找,这可能导致查找到错误的名称。

更进一步的原因在于,模板类支持特化和偏特化,比如我们再添加这样的代码:

template<>structBase {voidf(){std::cout<<"Base class\n";}};

若是草率地查找基类中的名称,那么查找到的将不是特化类当中的名称,查找出错。所以,在该阶段编译器不会在基类中查找名称。

那么,如何解决这个问题呢?

有两种办法,代码如下:

template<typenameT>structDerived:Base {voidh(){std::cout<<"Derived class\n";this->f();// method 1Base::f();// method 2}};

这样一来,编译器就能够成功查找到名称。

原理是这样的:

通过这两种方式,就可以告诉编译器该名称是依赖名称,必须等到模板实例化之后才能进行查找,届时将使用Qualified Name Lookup进行查找。这就是二段名称查找的必要性。

在调用类函数模板时依旧存在上述问题,一个典型的例子:

1structS{2template<typenameT>3staticvoidf(){4std::cout<<"f";5}6};78template<typenameT>9voidg(T* p){10T::f<void>();// 1 error!11T::templatef<void>();// 2 OK12}1314intmain(){15S s;16g(&s);17}

此处,由于f()是一个函数模板,1的名称查找将以失败告终。

因为它是一个依赖名称,编译器只假设名称是一个标识符(比如变量名、成员函数名),并不会认为它们是类型或函数模板。

原因如前面所说,由于模板特化和偏特化的存在,草率地假设会导致名称查找错误。此时,就需要显式地告诉编译器它们是一个类型或是函数模板,告诉编译器如何解析。

这也是需要对类型使用typename的原因,而对于函数模板,则如2那样添加一个template,这样就可以告诉编译器这是一个函数模板,<>当中的名称于是被解析为模板参数。

1失败的原因也显而亦见,编译器将f()当成了成员函数,将<>解析为了比较符号,从而导致编译失败。

至此,关于Name Lookup的内容就全部结束了,下面进入重载决议流程的第二阶段——模板处理。

3 Function Templates Handling

Name Lookup查找的名称若是包含函数模板,那么下一步就需要将这些函数模板实例化。

模板实例化有两个步骤,第一个步骤是Template Argument Deduction,对模板参数进行推导;第二个步骤是Template Argument Substitution,使用推导出来的类型对模板参数进行替换。

下面两节分别介绍模板参数推导与替换的细节。

3.1Template Argument Deduction

模板参数本身是抽象的类型,并不真正存在,因此需要根据调用的实参进行推导,从而将类型具体化。

TAD就描述了如何进行推导,规则是怎样的。

先来看一个简单的例子,感受一下基本规则。

1// Example from ISO C++23template<classT,classU=double>4voidf(Tt= 0,Uu= 0) {5}678intmain(){9f(1,c);// f(1, c);10f(1);// f(1, 0)11f();// error: T cannot be duduced12f<int>();// f(0, 0)13f<int,char>();// f(0, 0)14}

调用的实参是什么类型,模板参数就自动推导为所调用的类型。如果模板参数具有默认实参,那么可以从其推导,也可以显式指定模板参数,但若没有任何参数,则不具备推导上下文,推导失败。

这里存在令许多人都比较迷惑的一点,有些时候推导的参数并不与调用实参相同。

比如:

1template<classT>2voidf(Tt) {}34intmain(){5constinti =1;6f(i);// T deduced as int, f(int)7}

这里实参类型是const int,但最后推导的却是int。

这是因为,推导之时,所有的top-level修饰符都会被忽略,此处的const为top-level const,于是const被丢弃。本质上,其实是因为传递过去的参数变量实际上是新创建的拷贝变量,原有的修饰符不应该影响拷贝之后的变量。

那么,此时如何让编译器推导出你想要的类型呢?

第一种办法,显示指定模板参数类型。

f<constint>(i);// OK, f(const int)

第二种办法,将模板参数声明改为引用或指针类型。

template<classT>voidf(T&t) {}f(i);// OK, f(const int&)

为什么改为引用或指针就可以推导出带const的类型呢?

这是因为此时变量不再是拷贝的,它们访问的依旧是实参的内存区域,如果忽略掉const,它们将能够修改const变量,这会导致语义错误。

因此,如果你写出这样的代码,推导将会出错:

template<classT>voidf(Tt1,T*t2) {}intmain(){constinti =1;f(i, &i);// Error, T deduced as both int and const int}

因为根据第一个实参,T被推导为int,而根据第二个实参,T又被推导为const int,于是编译失败。

若你显示指定参数,那么将可以消除此错误,代码如下:

template<classT>voidf(Tt1,T*t2) {}intmain(){constinti =1;f<constint>(i, &i);// OK, T has const int type}

此时,T的类型只为const int,冲突消除,于是编译成功。

下面介绍可变参数模板的推导规则。

看如下例子:

template<classT,class...Ts>voidf(T,Ts...) {}template<classT,class...Ts>voidg(Ts...,T) {}intmain(){f(1,c,.0);// f(int, char, double)//g(1, c, .0); // error, Ts is not deduced}

此处规则为:参数包必须放到参数定义列表的最末尾,TAD才会进行推导。

但若是参数包作为类模板参数出现,则不必遵循此顺序也可以正常推导。

template<class...>structTuple{};template<classT,class...Ts>voidg(Tuple,T) {}g(Tuple<int>{},.0);// OK, g(Tuple, double)

如果函数参数是一个派生类,其继承自类模板,类模板又采用递归继承,则推导实参为其直接基类的模板参数。示例如下:

1// Example from ISO C++23template<class...>structX;4template<>structX<> {};5template<classT,class...Ts>6structX :X {};7structD:X<int> {};89template<class...Ts>10intf(constX&) {11return{};12}131415intmain(){16intx = f(D());// deduced as f, not f<>17}

这里,最终推导出来的类型为f,而非f<>。

下面介绍forwarding reference的推导规则。

对于forwarding reference,如果实参为左值,则模板参数推导为左值引用。看一个不错的例子:

1// Example from ISO C++23template<classT>intf(T&&t);4template<classT>intg(constT&&);56intmain(){7inti =1;8//int n1 = f(i);  // 1, f(int&)9//int n2 = f(0);  // 2, f(int&&);10intn3 = g(i);// 3, g(const int&&)11// error: bind an rvalue reference to an lvalue12}

此处,f()的参数为forwarding reference,g()的参数为右值引用。

因此,当实参为左值时,f()的模板参数被推导为int&,g()的模板参数则被推导为int。而左值无法绑定到右值,于是编译出错。

再来看另一个例子:

1// Example from ISO C++23template<classT>4structA{5template<classU>6A(T&&t,U&&u,int*);// 178A(T&&,int*);// 29};1011template<classT>A(T&&,int*) ->A;// 31213intmain(){14inti;15int*ip;16A a{i,0, ip};// error: cannot deduce from 117A b{0, i, ip};// use 1 to deduce A and 1 to initialize18A c{i, ip};// use 3 to deduce A and 2 to initialize19}

对于1,U&&为forwarding reference,而T&&并不是,因为它不是函数模板参数。

于是,当使用1初始化对象时,若第一个实参为左值,则T&&被推导为右值引用。由于左值无法绑定到右值,遂编译出错。但是第二个参数可以为左值,U会被推导为左值引用,次再施加引用折叠,最终依旧为左值引用,可以接收左值实参。

若要使类模板参数也变为forwarding reference,可以使用CTAD,如3所示。此时,T&&为forwarding reference,第一个实参为左值时,就可以正常触发引用折叠。

3.2Template Argument Substitution

TAD告诉编译器如何推导模板参数类型,紧随其后的就是使用推导出来的类型替换模板参数,将模板实例化。

这两个步骤密不可分,故在上节当中其实已经涉及了部分本节内容,这里进一步扩展。

这里只讲三个重点。

第一点,模板参数替换存在失败的可能性。

模板替换并不总是会成功的,比如:

structA{typedefintB; };template<classT>voidg(typenameT::B*)// 1template<classT>voidg(T);// 2g<int>(0);// calls 2

Name Lookup查找到了1和2的两个名称,然后对它们进行模板参数替换。然而,对于1的参数替换并不能成功,因为int不存在成员类型B,此时模板参数替换失败。

但是编译器并不会进行报错,只是将其从重载集中移除。这个特性就是广为熟知的SFINAE(Substitution Failure Is Not An Error),后来大家发现该特性可以进一步利用起来,为模板施加约束。

比如根据该原理可以实现一个enable_if工具,用来约束模板。

1namespacemylib {23template<bool,typename=void>4struct enable_if {};56template<typenameT>7structenable_if {8usingtype = T;9};1011template<boolC,typenameT =void>12usingenable_if_t=typenameenable_if::type;1314}// namespace mylib151617template<typenameT, mylib::enable_if_t<std::same_asdouble>,bool> =true>18voidf() {19std::cout<<"A\n";20}2122template<typenameT, mylib::enable_if_t<std::same_asint>,bool> =true>23voidf() {24std::cout<<"int\n";25}2627intmain(){28f<double>();// calls 129f<int>();// calls 230}

enable_if早已加入了标准,这个的工具的原理就是利用模板替换失败的特性,将不符合条件的函数从重载集移除,从而实现正确的逻辑分派。

SFINAE并非是专门针对类型约束而创造出来的,使用起来比较复杂,并不直观,已被C++20的Concepts取代。

第二点,关于trailing return type与normal return type的本质区别。

这二者的区别本质上就是Name Lookup的区别:normal return type是按照从左到右的词法顺序进行查找并替换的,而trailing return type因为存在占位符,打乱了常规的词法顺序,这使得它们存在一些细微的差异。

比如一个简单的例子:

namespaceN {usingX =int;Xf();}N::X N::f();// normal return typeautoN::f() -> X;// trailing return type

根据前面讲述的Qualified Name Lookup规则,normal return type的返回值必须使用N::X,否则将在全局查找。而trailing return type由于词法顺序不同,可以省略这个命名空间。

当然trailing return type也并非总是比normal return type使用起来更好,看如下例子:

// Example from ISO C++template<classT>structA{usingX =typenameT::X; };// normal return typetemplate<classT>typenameT::Xf(typenameA::X);template<classT>voidf(...);// trailing return typetemplate<classT>autog(typenameA::X) ->typenameT::X;template<classT>voidg(...);intmain(){f<int>(0);// 1 OKg<int>(0);// 2 Error}

通常来说,这两种返回类型只是形式上的差异,是可以等价使用的,但此处却有着细微而本质的区别。

1都能成功调用,为什么改了个返回形式,2就编译出错了?

这是因为:

在模板参数替换的时候,normal return type遵循从左向右的词法顺序,当它尝试替换T::X,发现实参类型int并没有成员X,于是依据SFINAE,该名称被舍弃。然后,编译器发现通用版本的名称可以成功替换,于是编译成功。

而2在模板参数替换的时候,首先跳过auto占位符,开始替换函数参数。当它尝试使用int替换A::X的时候,发现无法替换。但是A::X并不会触发SFINAE,而是产生hard error,于是编译失败。

简单来说,此处,normal return type在触发hard error之前就触发了SFINAE,所以可以成功编译。

第三点,forwarding reference的模板参数替换要点。

看一个上周我在群内分享的一个例子:

1template<classT>2structS{3staticvoidg(T&& t){}4staticvoidg(constT& t){}5};678template<classT>voidf(T&&t) {}9template<classT>voidf(constT&t) {}1011intmain(){12inti =1;1314f<int&>(i);// 1 OK15S<int&>::g(i);// 2 Error16}

为什么1可以通过编译,而2却不可以呢?

首先来分析2,编译失败其实显而亦见。

由于调用显式指定了模板参数,所以其实并没有参数推导,int&用于替换模板参数。对于T&&,替换为int&&&,1折叠后变为int&;对于const T&,替换为const (int&)&,等价于int& const&,而C++不支持top-level reference,int& const声明本身就是非法的,所以const被抛弃,剩下int&&,折叠为int&。

于是重复定义,编译错误。

而对于1,它包含两个函数模板。若是同时替换,那么它们自然也会编译失败。但是,根据4.3将要介绍的规则:如果都是函数模板,那么更特殊的函数模板胜出。const T&比T&&更加特殊,因此f(T&&)最终被移除,只存在f(const T&)替换之后的函数,没有错误也是理所当然。

4 Overload Resolution

经过Name Lookup和Template Handling两个阶段,编译器搜索到了所有相关重载函数名称,这些函数就称为candidate functions(候选函数)。

前文提到过,Name Lookup仅仅只是进行名称查找,并不会检查这些函数的有效性。因此,candidate functions只是「一级筛选」的结果。

重载决议,就是要在一级筛选的结果之上,选择出最佳的那个匹配函数。

比如:参数个数是否匹配?实参和形参的类型是否相同?类型是否可以转换?这些都属于筛选准则。

因此,这一步也可以称之为「二级筛选」。根据筛选准则,剔除掉无效函数,剩下的结果就称为viable functions(可行函数)。

存在viable functions,就表示已经找到可以调用的声明了。但是,这个函数可能存在多个可用版本,此时,就需要进行「终极筛选」,选出最佳的匹配函数,即best viable function。终极筛选在标准中也称为Tiebreakers(决胜局)。

终极筛选之后,如果只会留下一个函数,这个函数就是最终被调用的函数,重载决议成功;否则的话重载决议失败,程序错误。

接下来,将从一级筛选开始,以一个完整的例子,为大家串起整个流程,顺便加深对前面各节内容的理解。

4.1Candidate functions

一级筛选的结果是由Name Lookup查找出来的,包含成员和非成员函数。

对于成员函数,它的第一个参数是一个额外的隐式对象参数,一般来说就是this指针。

对于静态成员函数,大家都知道它没有this指针,然而事实上它也存在一个额外的隐式对象参数。究其原因,就是为了重载决议可以正常运行。

可以看如下例子来进行理解。

structS{voidf(long){std::cout<<"member version\n";}staticvoidf(int){std::cout<<"static member version\n";}};intmain(){S s;s.f(1);// calls static member version}

此时,这两个成员函数实际上为:

f(S&,long);// member versionf(implicit object parameter,int);// static member version

如果静态成员函数没有这个额外的隐式对象,那么其一,将可以定义一个参数完全相同的非静态成员;其二,重载决议将无法选择最佳的那个匹配函数(此处long需要转换,不是最佳匹配函数)。

静态成员的这个隐式对象参数被定义为可以匹配任何参数,仅仅用于在重载决议阶段保证操作的一致性。

对于非成员函数,则可以直接通过Unqualified Name Lookup和Qualified Name Lookup找到。同时,模板实例化后也会产生成员或非成员函数, 除了有些因为模板替换失败被移除,剩下的名称共同组成了candidate functions。

4.2Viable functions

二级筛选要在candidate functions的基础上,通过一些筛选准则来剔除不符合要求的函数,留下的就是viable functions。

筛选准则主要看两个方面,一个是看参数匹配程度,另一个是看约束满足程度。

约束满足就是看是否满足Concepts,这是C++20之后新增的一项检查。

具体的检查流程如下所述。

第一步,看参数个数是否匹配。

假设实参个数为N,形参个数为M,则存在三种比较情况。

如果N等于M,这种属于个数完全匹配,此类函数将被留下。

如果N小于M,此时就需要看candidate functions是否存在默认参数,如果不存在,此类函数被淘汰。

如果N大于M,此时就需要看candidate functions是否存在可变参数,如果不存在,此类函数被淘汰。

第二步,是否满足约束。

第一轮淘汰过后,剩下的函数如果存在Concepts约束,这些约束应该被满足。如果不满足,此类函数被淘汰。

第三步,看参数是否匹配。

实参类型可能和candidate functions完全匹配,也可能不完全匹配,此时这些参数需要存在隐式转换序列。可以是标准转换,也可以是用户自定义转换,也可以是省略操作符转换。

这三步过后,留下的函数就称为viable functions,它们都有望成为最佳匹配函数。

4.3Tiebreakers

终极筛选也称为决胜局,重载决议的最后一步,将进行更加严格的匹配。

第一,它会看参数的匹配程度。

如前所述,实参类型与viable functions可能完全匹配,也可能需要转换,此时就存在更优的匹配选项。

C++的类型转换有三种形式,标准转换、自定义转换和省略操作符转换。

标准转换比自定义转换更好,自定义转换比省略操作符转换更好。

对于标准转换,可以看下表。

它们的匹配优先级也是自上往下的,即Exact Match比Promotion更好,Promotion比Conversion更好,可以理解为完全匹配、次级匹配和低级匹配。

看一个简单的例子:

voidf(int);voidf(char);intmain(){f(1);// f(int) wins}

此时,viable functions就有两个。而实参类型为int,f(int)不需要转换,而f(char)需要将int转换为char,因此前者胜出。

如果实参类型为double,由于double转换为int和char属于相同等级,因此谁也不比谁好,产生ambiguous。

再来看一个例子:

1// Example from IOS C++23voidf(constint*,short);4voidf(int*,int);56intmain(){7inti;8shorts =0;9f(&i, s);// 1 Error, ambiguous10f(&i,1L);// 2 OK, f(int*, int) wins11f(&i,c);// 3 OK, f(int*, int) wins12}

这里存在两个viable functions,存在一场决胜局。

1处调用,第一个实参类型为int*,第二个实参类型为short。对于前者来说,f(int*, int)是更好的选择,而对于后者来说,f(const int*, short)才是更好的选择。此时将难分胜负,因此产生ambiguous。

2处调用,第二个实参类型为long,打成平局,但f(int*, int)在第一个实参匹配中胜出,因此最终被调用。

3处调用,第二个实参类型为char,char转换为int比转换为short更好,因此f(int*, int)依旧胜出。

对于派生类,则子类向直接基类转换是更好的选择。

structA{};structB:A {};structC:B {};voidf(A*){std::cout<<"A*";}voidf(B*){std::cout<<"B*";}intmain(){C* pc;f(pc);// f(B*) wins}

这里,C向B转换,比向A转换更好,所以f(B*)胜出。

最后再来看一个例子,包含三种形式的转换。

1structA{2operatorint();3};45voidf(A){6std::cout<<"standard conversion wins\n";7}89voidf(int){10std::cout<<"user defined conversion wins\n";11}1213voidf(...){14std::cout<<"ellipsis conversion wins\n";15}1617intmain(){18A a;19f(a);20}

最终匹配的优先级是从上往下的,标准转换是最优选择,自定义转换次之,省略操作符转换最差。

第二,如果同时出现模板函数和非模板函数,则非模板函数胜出。

例子如下:

1voidf(int){2std::cout<<"f(int) wins\n";3}45template<classT>6voidf(T) {7std::cout<<"function templates wins\n";8}910intmain(){11f(1);// calls f(int)12}

但若是非模板函数还需要参数转换,那么模板函数将胜出,因为模板函数可以完全匹配。

第三,如果都是函数模板,那么更特殊的模板函数胜出。

什么是更特殊的函数模板?其实指的就是更加具体的函数模板。越抽象的模板参数越通用,越具体的越特殊。举个例子,语言、汉语和普通话,语言可以表示汉语,汉语可以表示普通话,因此语言比汉语更抽象,汉语比普通话更抽象,普通话比汉语更特殊,汉语又比语言更特殊。

越抽象越通用,越具体越精确,越精确就越可能是实际的调用需求,因此更特殊的函数模板胜出。

比如在3.2节第三点提到的例子,const T&为何比T&&更特殊呢?这是因为,若形参类型为T,实参类型为const U,则T可以推导为const U,前者就可以表示后者。若是反过来,形参类型为const T,实参类型为U,此时就无法推导。因此const T&要更加特殊。

第四,如果都函数都带有约束,那么满足更多约束的获胜。

例子如下:

1// Example from ISO C++23template<typenameT> concept C1 = requires(T t) { --t; };4template<typenameT> concept C2 = C1 && requires(T t) { *t; };56templatevoidf(T);// 17templatevoidf(T);// 28template<classT>voidg(T);// 39templatevoidg(T);// 41011intmain(){12f(0);// selects 113f((int*)0);// selects 214g(true);// selects 3 because C1 is not satisfied15g(0);// selects 416}

第五,如果一个是模板构造函数,一个是非模板构造函数,那么非模板版本获胜。

例子如下:

1template<classT>2structS{3S(T, T,int);// 14template<classU>S(T,U,int);// 25};67intmain(){8// selects 1, generated from non-template constructor9Ss(1,2,3);10}

究其原因,还是非模板构造函数更加特殊。

以上所列的规则都是比较常用的规则,更多规则大家可以参考cppreference。

通过这些规则,就可以找出最佳匹配的那个函数。如果最后只剩下一个viable function,那么它就是best viable function。如果依旧存在多个函数,那么ambiguous。

大家也许还不是特别清楚上述流程,那么接下来,我将以一个完整的例子来串起整个流程。

5 走一遍完整的流程

一个完整的示例,代码如下:

1namespaceN {2structBase{};3structDerived:Base {};4voidfoo(Base* s,char);// 15voidfoo(Derived* s,int,bool=true);// 26voidfoo(Derived* s,short);// 37}89structS{10N::Derived* d;11S(N::Derived* deri) : d{deri} {}12operatorN::Derived*()const{returnd; }13};1415voidfoo(S);// 416template<classT>voidfoo(T*t,intc);// 517voidfoo(...);// 61819intmain(){20N::Derived d;21foo(&d,c);// which one will be matched?22}

最终哪个函数能够胜出,让我们来逐步分析。

第一步,编译器会进行Name Lookup,查找名称。

可以看到,代码中一共有七个重载函数,但是只会被查找到六个。因为foo(&d, c)调用时没有添加任何作用域限定符,所以编译器不会使用Qualified Name Lookup进行查找。

在查找到的六个名称当中,其中有三个是通过ADL查找到的,还有三个是通过Usual Unqualified Lookup查找到的。

第二步,编译器发现其中包含函数模板,于是进行Template Handling。

首先,编译器根据调用实参,通过Template Argument Deduction推导出实参类型,实参类型如上图A1和A2所示。

接着,编译器分析函数模板中包含的模板参数,其中P1为模板参数。于是,需要进行Template Argument Substitution,将P1替换为实参类型,T被替换为N::Derived。

如果模板替换失败,根据SFINAE,这些函数模板将被移除。

最后,替换完成的函数就和其他的函数一样,它们共同构成了candidate functions,一级筛选到此结束。

第三步,编译器正式进入重载决议阶段,比较candidate functions,选择最佳匹配函数。

首先,进行二级筛选,筛选掉明显不符合的候选者。

调用参数为2个,而第4个候选者只有1个参数,被踢出局;第2个候选者具有3个参数,但是它的第三个参数设有缺省值,因此依旧被留下。

此外,这些候选函数也没有任何约束,因此在这一局只剔除了一个函数,剩下的函数就称为viable functions。

viable functions之所以称为可行函数,就是因为它们其实都可以作为最终的调用函数,只是谁更好而已。

其次,进行终级筛选,即决胜局。在此阶段,需要比较参数的匹配程序。

对于派生类,完全匹配比直接基类好,直接基类比间接基类好,因此第1个候选者被踢出局。

第6个候选者为省略操作符,它将永远是最后才会被考虑的对象,也是最差的匹配对象。于是,2、3、5进行决战。

它们的第一个参数都是完全匹配,因此看第二个参数。char转换为int比short更好,因此第3个候选者被踢出局。

剩下第2、5个候选者,第2个候选者虽然有三个参数,但因为有缺省值,所以并不影响,也不会被作为决胜因素,所以第5个候选者暂时还无法取胜。

然后,编译器发现第2个候选者为非模板函数,第5个候选者为模板函数。模板函数和非模板函数同时出现时,非模板函数胜出,于是第5个候选者被踢出局。

最后,只留下了第2个候选者,它成为了best viable function,胜利者。

但是,大家可别以为竞选出胜利者就一定可以调用成功。事实上,它们只针对的是声明,如果函数没有定义,依旧会编译失败。

6 Name Mangling

重载函数的名称实际上是通过Name Mangling生成的新名称,大家如果去看编译后的汇编代码就能够看到这些名称。

像是Compiler Explorer,它实际上是为了让你看着方便,显示的是优化后的名称,去掉勾选Demangle identifiers就能够看到实际函数名称。

那么接下来,就来介绍一下Name Mangling的实际手法。标准并没有规定具体实现方式,因此编译器的实际可能不尽相同,下面以gcc为例进行分析。

下面是使用gcc编译过后的一个例子。

如图所示,编译器为每个重载函数都生成一个新名称,新名称是绝对唯一的。

基本的规则如下图所示。

除了基本规则,还有很多比较复杂的规则,这里再举几个常见的名称。

namespacemyNS {structmyClass{// mangles as _ZN4myNS7myClass6myFuncEvvoidmyFunc(){}};// mangles as _ZN4myNS3fooENS_7myClassEvoidfoo(myClass){}}template<classT>voidfoo(T,int) {}// mangles as _Z3fooIfEvT_itemplatevoidfoo(float,int);

规则不难理解,大家可以自己找下规律。其中,I/E中间的是模板参数,T_表示第1个模板参数。

由于C语言没有重载函数,所以它也没有Mangling操作。如果你使用混合编译,即某些文件使用C编译,某些文件使用C++编译,就会产生链接错误。

举个例子,有如下代码:

1// lib.cpp2intmyFunc(inta,intb){3returna + b;4}56// main.cpp7include89intmyFunc(inta,intb);1011intmain(){12std::cout<<"The answer is "<< myFunc(41,1);13}

使用C++编译并链接,结果如下图。

编译器在编译main.cpp时,发现其中存在一个未解析的引用int myFunc(int a, int b);,于是在链接文件lib.cpp中找到了该定义。之所以能够找到该定义,是因为这两个文件都是使用C++编译的,编译时main.cpp中的声明经过Name Mangling变为_Z6myFuncii,实际查找的并不是myFunc这个名称。而lib.cpp中的名称也经过了Name Mangling,因此能够链接成功。

但是,如果其中一个文件使用C进行编译,另一个使用C++进行编译,链接时就会出现问题。如下图所示。

由于main.cpp是用C++编译的,因此实际查找的名称为_Z6myFuncii。而lib.cpp是用C编译的,并没有经过Name Mangling,它的名称依旧为myFunc,因此出现未定义的引用错误。

常用解法是使用一个extern关键字,告诉编译器这个函数来自C,不要进行Name Mangling。

1// main.cpp2extern"C"intmyFunc(inta,intb);34intmain(){5std::cout<<"The answer is "<< myFunc(41,1);6}

如此一来,就可以解决这个问题。

通常来说,可以使用预处理条件语句,分别提供C和C++版本的代码,这样使用任何方式就都可以编译成功。

7 总结

本篇的内容相当之多,完整地包含了重载决议的整个流程。

能读到这里,相信大家已经收获满满,对整个流程已经有了清晰的认识。

因此,这个总结算是一个课后作业,请大家对照下图回想本篇内容,如果对所有概念都十分清楚,那么恭喜你已经理解了重载决议!

– EOF –

加主页君微信,不仅C/C++技能+1

主页君日常还会在个人微信分享C/C++开发学习资源技术文章精选,不定期分享一些有意思的活动岗位内推以及如何用技术做业余项目

加个微信,打开一扇窗

推荐阅读点击标题可跳转

1、C++ 虚函数表剖析

2、C++23 | Ranges 的修复与完善

3、为什么永远不会有语言取代 C / C++ ?

关注『CPP开发者』

看精选C/C++技术文章

点赞和在看就是最大的支持❤️

声明:本文部分素材转载自互联网,如有侵权立即删除 。

© 版权声明
THE END
喜欢就支持一下吧
点赞0赞赏 分享
相关推荐
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容