洞悉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.1
Qualified Name Lookup
带修饰的名称查找并不算复杂,这又可以主要分为两类查找。
一类是Class Member Lookup,表示对于类成员的名称查找;另一类是Namespace Member Lookup,表示对于命名空间下的名称查找。
其实还可以包含枚举名称,因为它也可以使用作用域解析操作符"::"进行访问,但一法通万法,不必单独细论。
以下单独讨论主要的两类。
2.1.1
Class Member Lookup
类成员查找,是在访问类成员时进行名称查找的规则。
成员本质上来说还是两种类型,变量与函数。换个角度来看,成员又可分为静态成员和动态成员,静态成员可以通过"::"进行访问,动态成员可以通过"."或"->"进行访问。
也就是说,当你使用如上三种方式访问某个变量或函数时,就可能会触发Class Member Lookup。
首先来看前者,即使用"::"访问时的规则。示例如下:
// Example from ISO C++
class X {};
class C {
class X {};
static const int number = 50;
static X arr[number];
};
X C::arr[number]; // #1
可以将#1处的定义从"::"拆分为前后两部分。
对于前面的名称X和C,将会在其定义的命名空间进行查找,此处即为全局空间,于是查找到全局作用域下的X和C类。
对于后面的名称arr和number,将会在C类的作用域下进行查找,它们将作为类成员进行查找。
此时就是"::"前面的类型名,告诉编译器后面的名称应该通过Class Member Lookup进行查找。如果搜索发现前面是个命名空间,则会在相应的作用域下查找。
由于X是在全局作用域下查找到的,所以并不会找到内部类X,于是该声明会产生编译错误。
接着来看后者,关于"."和"->"的规则。看一个简单的例子:
struct S {
void f() {}
};
S s;
s.f();
S* ps = &s;
ps->f();
此处要调用f函数,因为使用了"."或"->"操作符,而操作符前面又是一个类,所以f的查找将直接使用Class Member Lookup,在类的作用域下进行查找。
这种调用一目了然,查找起来也比较方便,便不在此多加着墨,下面来看另一类带修饰的名称查找。
2.1.2
Namespace Member Lookup
命名空间成员查找,是在访问命名空间下的元素时进行名称查找的规则。
当你使用"::"访问元素的时候,就有可能会触发Namespace Member Lookup。
比如,当把"::"单独作为前缀时,则会强制Name Lookup在全局空间下进行查找。如下述例子:
void f(); // #1
namespace mylib {
void f(); // #2
void h() {
::f(); // calls #1
f(); // calls #2
}
} // namespace mylib
此时,若是没有在全局作用域下搜索到相应的函数名称,也不会调用#2,而是产生编译错误。若是要在外部访问命名空间内部的f(),则必须使用mylib::f(),否则Name Lookup会找到全局作用域下的#1。
下面再来看一个稍微复杂点的例子:
1// Example from ISO C++
2
3int x;
4namespace Y {
5 void f(float);
6 void h(int);
7}
8
9namespace Z {
10 void h(double);
11}
12
13namespace A {
14 using namespace Y;
15 void f(int);
16 void g(int);
17 int i;
18}
19
20namespace B {
21 using namespace Z;
22 void f(char);
23 int i;
24}
25
26namespace AB {
27 using namespace A;
28 using namespace B;
29 void g();
30}
31
32void h() {
33 AB::g(); // #1
34 AB::f(1); // #2
35 AB::f('c'); // #3
36 AB::x++; // #4
37 AB::i++; // #5
38 AB::h(16.8); // #6
39}
这里一共有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。
1namespace X {
2 int a;
3}
4
5namespace A {
6 using namespace X;
7}
8
9namespace B {
10 using namespace X;
11}
12
13namespace AB {
14 using namespace A;
15 using namespace B;
16}
17
18AB::a++; // OK
这里,Name Lookup最终查找了两次X::a,但因为实际只存在一 处定义,于是一切正常。
第二点,当查找到多个定义时,若其中一个定义是类或枚举,而其他定义是变量或函数,且这些定义处于同一个命名空间下,则后者会隐藏前者,即后者会被选择,否则ambiguous。
可以通过以下例子来进行理解:
1// Example from ISO C++
2
3namespace A {
4 struct x {};
5 int x;
6 int y;
7}
8
9namespace B {
10 struct y {};
11}
12
13namespace C {
14 using namespace A;
15 using namespace B;
16 int i = C::x; // #1
17 int j = C::y; // #2
18}
先看#1,由于C中查找x失败,进而到A和B中进行查找,发现A中有两处定义。一处定义是类,另一处定义是变量,于是后者隐藏前者,最终选择int x;这处定义。
而对于#2,最终查找到了A::y和B::y两处定义,由于定义不在同一命名空间下,所以产生ambiguous。
到此,对Qualified Name Lookup的内容就基本覆盖了,下面进入Unqualified Name Lookup。
2.2
Unqualified Name Lookup
无修饰的名称查找则略显复杂,却会经常出现。
总的来说,也可分为两大类。
第一类为Usual Unqualified Lookup,即常规无修饰的名称查找,也就是普遍情况会触发的查询。
第二类为Argument Dependant Lookup,这就是鼎鼎大名的ADL,译为实参依赖查找。由其甚至发展出了一种定制点表示方式,称为ADL二段式,标准中的std::swap, std::begin, std::end, operator<<等等组件就是通过该法实现的。
但是本文并不会涉及定制点的讨论,因为这是我正在写的书中的某一节内容:) 内容其实非常之多之杂,本篇文章其实就是为该节扫除阅读障碍而特意写的,侧重点并不同。我额外写过一篇介绍定制点的文章【使用Concepts表示变化「定制点」】,各位可作开胃菜。
以下两节,分别讲解这两类名称查找。
2.2.1
Usual Unqualified Lookup
普通的函数调用都会触发Usual Unqualified Lookup,先看一个简单的例子:
1void f(char);
2
3void f(double);
4
5namespace mylib {
6 void f(int);
7
8 void h() {
9 f(3); // #1
10 f(.0); // #2
11 }
12}
对于#1和#2,Name Lookup会如何查找?最终会调用哪个重载函数?
实际上只会查找到f(int),#1直接调用,#2经过了隐式转换后调用。
为什么呢?记住一个准则,根据作用域查找顺序,当Name Lookup在某个作用域找到声明之后,便会停止查找。关于作用域的查找顺序,后面会介绍。
因此,当查找到f(int),它就不会再去全局查找其他声明。
注意:即使当前查找到的名称实际无法成功调用,也并不改变该准则。看如下例子:
1void f(int);
2
3namespace mylib {
4 void f(const char*);
5
6 void h() {
7 f(3); // #1 Error
8 }
9}
此时,依旧只会查找到f(const char*),即使f(int)才是正确的选择。由于没有相应的隐式转换,该代码最终编译失败。
那么具体的作用域查找顺序是怎样的?请看下述例子:
1// Example from ISO C++
2
3namespace M {
4
5 class B { // S3
6 };
7}
8
9// S5
10namespace N {
11 // S4
12 class Y : public M::B {
13 // S2
14 class X {
15 // S1
16 int a[i]; // #1
17 };
18 };
19}
#1处使用了变量i,因此Name Lookup需要进行查找,那么查找顺序将从S1-S5。所以,只要在S1-S5的任何一处声明该变量,就可以被Name Lookup成功找到。
接着来看另一个查找规则,如果一个命名空间下的变量是在外部重新定义的,那么该定义中涉及的其他名称也会在对应的命名空间下查找。
简单的例子:
1// Example from ISO C++
2
3namespace N {
4 int i = 4;
5 extern int j;
6}
7
8int i = 2;
9int N::j = i; // j = 4
由于N::j在外部重新定义,因此变量i也会在命名空间N下进行查找,于是j的值为4。如果在N下没有查找到,才会查找到全局的定义,此时j的值为2。
而对于友元函数,查找规则又不相同,看如下例子:
1// Example from ISO C++
2
3struct A {
4 typedef int AT;
5 void f1(AT);
6 void f2(float);
7 template <class T> void f3();
8};
9
10struct B {
11 typedef char AT;
12 typedef float BT;
13 friend void A::f1(AT); // #1
14 friend void A::f2(BT); // #2
15 friend void A::f3<AT>(); // #3
16};
此处,#1的AT查找到的是A::AT,#2的BT查找到的是B::BT,而#3的AT查找到的是B::AT。
这是因为,当查找的名称并非模板参数时,首先会在友元函数的原有作用域进行查找,若没查找到,则再在当前作用域进行查找。对于模板参数,则直接在当前作用域进行查找。
2.2.2
Argument Dependant Lookup
终于到了著名的ADL,这是另一种无修饰名称查找方式。
什么是ADL?其实概念很简单,看如下示例。
namespace mylib {
struct S {};
void f(S);
}
int main() {
mylib::S s;
f(s); // #1,OK
}
按照Usual Unqualified Lookup是无法查找到#1处调用的声明的,此时编译器就要宣布放弃吗?并不会,而是再根据调用参数的作用域来进行查找。此处,变量s的类型为mylib::S,于是将在命名空间mylib下继续查找,最终成功找到声明。
由于这种方式是根据调用所依赖的参数进行名称查找的,因此称为实参依赖查找。
那么有没有办法阻止ADL呢?其实很简单。
namespace mylib {
struct S {};
void f(S) {
std::cout << "f found by ADL\n";
}
}
void f(mylib::S) {
std::cout << "global f found by Usual Unqualified Lookup\n";
}
int main() {
mylib::S s;
(f)(s); // OK, calls global f
}
这里存在两个定义,本应产生歧义,但当你给调用名称加个括号,就可以阻止ADL,从而消除歧义。
实际上,ADL最初提出来是为了简化重载调用的,可以看如下例子。
int main() {
// std::operator<<(std::ostream&, const char*)
// found by ADL.
std::cout << "dummy string\n";
// same as above
operator<<(std::cout, "dummy string\n");
}
如果没有ADL,那么Unqualified Name Lookup是无法找到你所定义的重载操作符的,此时你只能写出完整命名空间,通过Qualified Name Lookup来查找到相关定义。
但这样代码写起来就会非常麻烦,因此,Unqualified Name Lookup新增加了这种ADL查找方式。
在编写一个数学库的时候,其中涉及大量的操作符重载,此时ADL就尤为重要,否则像是"+","=="这些操作符的调用都会非常麻烦。
后来ADL就被广泛运用,普通函数也支持此种查找方式,由此还诞生了一些奇技淫巧。
不过,在说此之前,让我们先熟悉一下常用的ADL规则,主要介绍四点。
第一点,当实参类型为函数时,ADL会根据该函数的参数及返回值所属作用域进行查找。
例子如下:
1namespace B {
2 struct R {};
3 void g(...) {
4 std::cout << "g found by ADL\n";
5 }
6}
7
8namespace A {
9 struct S {};
10 typedef B::R (*pf)(S);
11
12 void f(pf) {
13 std::cout << "f found by ADL\n";
14 }
15}
16
17B::R bar(A::S) {
18 return {};
19}
20
21int main() {
22 A::pf fun = bar;
23 f(fun); // #1, OK
24 g(fun); // #2, OK
25}
#1和#2处,分别调用了两个函数,参数为另一个函数,根据该条规则,ADL得以查找到A::f()与B::g()。
第二点,若实参类型是一个类,那么ADL会从该类或其父类的最内层命名空间进行查找。
例子如下:
1namespace A {
2 // S2
3 struct Base {};
4}
5
6namespace M {
7 // S3 not works!
8 namespace B {
9 // S1
10 struct Derived : A::Base {};
11 }
12}
13
14int main() {
15 M::B::Derived d;
16 f(d); // #1
17}
此处,若要通过ADL找到f()的定义,可以将其声明放在S1或S2处。
第三点,若实参类型是一个类模板,那么ADL会在特化类的模板参数类型的命名空间下进行查找;若实参类型包含模板模板参数,那么ADL还会在模板模板参数类型的命名空间下查找。
例子如下:
1namespace C {
2 struct Final {};
3 void g(...) {
4 std::cout << "g found by ADL\n";
5 }
6};
7
8namespace B {
9 template <typename T>
10 struct Temtem {};
11
12 struct Bar {};
13 void f(...) {
14 std::cout << "f found by ADL\n";
15 }
16}
17
18namespace A {
19 template <typename T>
20 struct Foo {};
21}
22
23int main() {
24 // class template arguments
25 A::Foo<B::Bar> foo;
26 f(foo); // OK
27
28 // template template arguments
29 A::Foo<B::Temtem<C::Final>> a;
30 g(a); // OK
31
32}
代码一目了然,不多解释。
第四点,当使用别名时,ADL会无效,因为名称并不是一个函数调用。
看这个例子:
1// Example from ISO C++
2
3typedef int f;
4namespace N {
5 struct A {
6 friend void f(A&);
7 operator int();
8 void g(A a) {
9 int i = f(a); // #1
10 }
11 };
12}
注意#1处,并不会应用ADL来查询函数f,因为它其实是int,相当于调用int(a)。
说完了这四点规则,下面来稍微说点ADL二段式相关的内容。
看下面这个例子:
1namespace mylib {
2
3 struct S {};
4
5 void swap(S&, S&) {}
6
7 void play() {
8 using std::swap;
9
10 S s1, s2;
11 swap(s1, s2); // OK, found by Unqualified Name Lookup
12
13 int a1, a2;
14 swap(a1, a2); // OK, found by using declaration
15 }
16}
然后,你要在某个地方调用自己提供的这个定制函数,此处是play()当中。
但是调用的地方,你需要的swap()可能不只是定制函数,还包含标准中的版本。因此,为了保证调用形式的唯一性,调用被分成了两步。
使用using declaration
使用swap()
这样一来,不同的调用就可以被自动查找到对应的版本上。然而,只要稍微改变下调用形式,代码就会出错:
1namespace mylib {
2
3 struct S {};
4
5 void swap(S&, S&) {} // #1
6
7 void play() {
8 using namespace std;
9
10 S s1, s2;
11 swap(s1, s2); // OK, found by Unqualified Name Lookup
12
13 int a1, a2;
14 swap(a1, a2); // Error
15 }
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。
1namespace mylib {
2
3 struct S {
4 // Hidden friends
5 friend void swap(S&, S&) {}
6 };
7
8 void play() {
9 using namespace std;
10
11 S s1, s2;
12 swap(s1, s2); // OK, found by ADL
13
14 int a1, a2;
15 swap(a1, a2); // OK
16 }
17}
就是将定制函数定义为友元版本,放在类的内部。此时将不会再出现名称被隐藏的问题,这个函数只能被ADL找到。
Hidden friends的写法在STL中存在不少,想必大家曾经也不知不觉中使用过。
好,更多关于定制点的内容本文不再涉及,下面进行另一个内容。
2.3
Template Name Lookup
以上两节Name Lookup内容只涉及零星关于模板的名称查找,本节专门讲解这部分查找,它们还是属于前两节的归类。
首先要说的是对于typename的使用,在模板当中声明一些类型,有些地方并不假设其为类型,此时只有在前面添加typename,Name Lookup才视其为类型。
不过自C++20之后,需要添加typename的地方已越来越少,已专门写过文章,请参考:新简化!typename在C++20不再必要。
其次,介绍一个非常重要的概念,「独立名称」与「依赖名称」。
什么意思呢?看一个例子。
// Example from ISO C++
int j;
template <class T>
struct X {
void f(T t, int i, char* p) {
t = i; // #1
p = i; // #2
p = j; // #3
}
};
在Name Lookup阶段,模板还没有实例化,因此此时的模板参数都是未知的。对于依赖模板参数的名称,就称其为「依赖名称」,反之则为「独立名称」。
依赖名称,由于Name Lookup阶段还未知,因此对其查找和诊断要晚一个阶段,到模板实例化阶段。
独立名称,其有效性则在模板实例化之前,比如#2和#3,它们诊断就比较早。这样,一旦发现错误,就不必再继续向下编译,节省编译时间。
查找阶段的变化对Name Lookup存在影响,看如下代码:
1// Example from ISO C++
2
3void f(char);
4
5template <class T>
6void g(T t) {
7 f(1); // non-dependent
8 f(T(1)); // dependent
9 f(t); // dependent
10 dd++; // non-dependent
11}
12
13enum E { e };
14void f(E);
15
16double dd;
17void h() {
18 g(e); // calls f(char),f(E),f(E)
19 g('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++
2
3template <class T>
4struct A {
5 struct B {};
6 typedef void C;
7 void f();
8 template<class U> void g(U);
9};
10
11template <class B>
12void A<B>::f() {
13 B b; // #1
14}
15
16template <class B>
17template <class C>
18void A<B>::g(C) {
19 B b; // #2
20 C c; // #3
21}
思考一下,#1,#2,#3分别分别查找到的是哪个名称?(这个代码只有clang支持)
实际上,#1和#2最终查找到的都是A::B,而#3却是模板参数C。
注意第16-17行出现的两个模板,它们并不能合并成一个,外层模板指的是类模板,而内层模板指的是函数模板。
因此,规则其实是:对于类外模板定义,如果成员不是类模板或函数模板,则类模板的成员名称会隐藏类外定义的模板参数;否则模板参数获胜。
而如果类模板位于一个命名空间之内,要在命名空间之外定义该类模板的成员,规则又不相同。
1// Example from ISO C++
2
3namespace N {
4 class C {};
5 template <class T> class B {
6 void f(T);
7 };
8}
9
10template <class C>
11void N::B<C>::f(C) {
12 C b; // #1
13}
此处,#1处的C查找到的是模板参数。
如果是继承,那么也会隐藏模板参数,代码如下:
1// Example from ISO C++
2
3struct A {
4 struct B {};
5 int a;
6 int Y;
7};
8
9template <class B, class a>
10struct X : A {
11 B b; // A::B
12 a b; // A::a, error, not a type name
13};
这里,最终查找的都是父类当中的名称,模板参数被隐藏。
然而,如果父类是个依赖名称,由于名称查找于模板实例化之前,所以父类当中的名称不会被考虑,代码如下:
1// Example from ISO C++
2
3typedef double A;
4template <class T>
5struct B {
6 typedef int A;
7};
8
9template <class T>
10struct X : B<T> {
11 A a; // double
12};
这里,最终X::A的类型为double,这是识别为独立名称并使用Unqualified Name Lookup查找到的。若要访问B::A,那么声明改为B<T>::A a;即可,这样一来就变为了依赖名称,且采用Qualified Name Lookup进行查找。
最后,说说多继承中包含依赖名称的规则。
还是看一个例子:
1// Example from ISO C++
2
3struct A {
4 int m;
5};
6
7struct B {
8 int m;
9};
10
11template <class T>
12struct C : A, T {
13 int f() { return this->m; } // #1
14 int g() { return m; } // #2
15};
16
17template int C<B>::f(); // ambiguous!
18template int C<B>::g(); // OK
此处,多重继承包含依赖名称,名称查找方式并不相同。
对于#1,使用Qualified Name Lookup进行查找,查询发生于模板实例化,于是存在两个实例,出现ambiguous。
而对于#2,使用Unqualified Name Lookup进行查找,此时相当于是独立名称查找,查找到的只有A::m,所以不会出现错误。
2.4
Two-phase Name Lookup
因为模板才产生了独立名称与依赖名称的概念,依赖名称的查找需要等到模板实例化之后,这就是上节提到的二段名称查找。
依赖名称的存在导致Unqualified Name Lookup失效,此时,只有使用Qualified Name Lookup才能成功查找到其名称。
举个非常常见的例子:
1struct Base {
2 // non-dependent name
3 void f() {
4 std::cout << "Base class\n";
5 }
6};
7
8struct Derived : Base {
9 // non-dependent name
10 void h() {
11 std::cout << "Derived class\n";
12 f(); // OK
13 }
14};
15
16
17int main() {
18 Derived d;
19 d.h();
20}
21
22// Outputs:
23// Derived class
24// Base class
这里,f()和h()都是独立名称,因此能够通过Unqualified Name Lookup成功查找到名称,程序一切正常。
然而,把上述代码改成模板代码,情况就大不相同了。
1template <typename T>
2struct Base {
3 void f() {
4 std::cout << "Base class\n";
5 }
6};
7
8template <typename T>
9struct Derived : Base<T> {
10 void h() {
11 std::cout << "Derived class\n";
12 f(); // error: use of undeclared identifier 'f'
13 }
14};
15
16
17int main() {
18 Derived<int> d;
19 d.h();
20}
此时,代码已经无法编译通过了。
为什么呢?当编译器进行Name Lookup时,发现f()是一个独立名称,于是在模板定义之时就开始查找,然而很可惜,没有查找到任何结果,于是出现未定义的错误。
那么它为何不在基类当中查找呢?这是因为它的查找发生在第一阶段的Name Lookup,此时模板还没有实例化,编译器不能草率地在基类中查找,这可能导致查找到错误的名称。
更进一步的原因在于,模板类支持特化和偏特化,比如我们再添加这样的代码:
template <>
struct Base<char> {
void f() {
std::cout << "Base<char> class\n";
}
};
若是草率地查找基类中的名称,那么查找到的将不是特化类当中的名称,查找出错。所以,在该阶段编译器不会在基类中查找名称。
那么,如何解决这个问题呢?
有两种办法,代码如下:
template <typename T>
struct Derived : Base<T> {
void h() {
std::cout << "Derived class\n";
this->f(); // method 1
Base<T>::f(); // method 2
}
};
这样一来,编译器就能够成功查找到名称。
在调用类函数模板时依旧存在上述问题,一个典型的例子:
1struct S {
2 template <typename T>
3 static void f() {
4 std::cout << "f";
5 }
6};
7
8template <typename T>
9void g(T* p) {
10 T::f<void>(); // #1 error!
11 T::template f<void>(); // #2 OK
12}
13
14int main() {
15 S s;
16 g(&s);
17}
此处,由于f()是一个函数模板,#1的名称查找将以失败告终。
因为它是一个依赖名称,编译器只假设名称是一个标识符(比如变量名、成员函数名),并不会认为它们是类型或函数模板。
原因如前面所说,由于模板特化和偏特化的存在,草率地假设会导致名称查找错误。此时,就需要显式地告诉编译器它们是一个类型或是函数模板,告诉编译器如何解析。
这也是需要对类型使用typename的原因,而对于函数模板,则如#2那样添加一个template,这样就可以告诉编译器这是一个函数模板,<>当中的名称于是被解析为模板参数。
#1失败的原因也显而亦见,编译器将f()当成了成员函数,将<>解析为了比较符号,从而导致编译失败。
至此,关于Name Lookup的内容就全部结束了,下面进入重载决议流程的第二阶段——模板处理。
3
Function Templates Handling
Name Lookup查找的名称若是包含函数模板,那么下一步就需要将这些函数模板实例化。
模板实例化有两个步骤,第一个步骤是Template Argument Deduction,对模板参数进行推导;第二个步骤是Template Argument Substitution,使用推导出来的类型对模板参数进行替换。
下面两节分别介绍模板参数推导与替换的细节。
3.1
Template Argument Deduction
模板参数本身是抽象的类型,并不真正存在,因此需要根据调用的实参进行推导,从而将类型具体化。
TAD就描述了如何进行推导,规则是怎样的。
先来看一个简单的例子,感受一下基本规则。
1// Example from ISO C++
2
3template <class T, class U = double>
4void f(T t = 0, U u = 0) {
5}
6
7
8int main() {
9 f(1, 'c'); // f<int, char>(1, 'c');
10 f(1); // f<int, double>(1, 0)
11 f(); // error: T cannot be duduced
12 f<int>(); // f<int, double>(0, 0)
13 f<int, char>(); // f<int, char>(0, 0)
14}
调用的实参是什么类型,模板参数就自动推导为所调用的类型。如果模板参数具有默认实参,那么可以从其推导,也可以显式指定模板参数,但若没有任何参数,则不具备推导上下文,推导失败。
这里存在令许多人都比较迷惑的一点,有些时候推导的参数并不与调用实参相同。
比如:
1template <class T>
2void f(T t) {}
3
4int main() {
5 const int i = 1;
6 f(i); // T deduced as int, f<int>(int)
7}
这里实参类型是const int,但最后推导的却是int。
这是因为,推导之时,所有的top-level修饰符都会被忽略,此处的const为top-level const,于是const被丢弃。本质上,其实是因为传递过去的参数变量实际上是新创建的拷贝变量,原有的修饰符不应该影响拷贝之后的变量。
那么,此时如何让编译器推导出你想要的类型呢?
第一种办法,显示指定模板参数类型。
f<const int>(i); // OK, f<const int>(const int)
第二种办法,将模板参数声明改为引用或指针类型。
template <class T>
void f(T& t) { }
f(i); // OK, f<const int>(const int&)
为什么改为引用或指针就可以推导出带const的类型呢?
这是因为此时变量不再是拷贝的,它们访问的依旧是实参的内存区域,如果忽略掉const,它们将能够修改const变量,这会导致语义错误。
因此,如果你写出这样的代码,推导将会出错:
template <class T>
void f(T t1, T* t2) { }
int main() {
const int i = 1;
f(i, &i); // Error, T deduced as both int and const int
}
因为根据第一个实参,T被推导为int,而根据第二个实参,T又被推导为const int,于是编译失败。
若你显示指定参数,那么将可以消除此错误,代码如下:
template <class T>
void f(T t1, T* t2) { }
int main() {
const int i = 1;
f<const int>(i, &i); // OK, T has const int type
}
此时,T的类型只为const int,冲突消除,于是编译成功。
下面介绍可变参数模板的推导规则。
看如下例子:
template <class T, class... Ts>
void f(T, Ts...) {
}
template <class T, class... Ts>
void g(Ts..., T) {
}
int main() {
f(1, 'c', .0); // f<int, char, double>(int, char, double)
//g(1, 'c', .0); // error, Ts is not deduced
}
此处规则为:参数包必须放到参数定义列表的最末尾,TAD才会进行推导。
但若是参数包作为类模板参数出现,则不必遵循此顺序也可以正常推导。
template <class...>
struct Tuple {};
template <class T, class... Ts>
void g(Tuple<Ts...>, T) {
}
g(Tuple<int>{}, .0); // OK, g<int, double>(Tuple<int>, double)
如果函数参数是一个派生类,其继承自类模板,类模板又采用递归继承,则推导实参为其直接基类的模板参数。示例如下:
1// Example from ISO C++
2
3template <class...> struct X;
4template <> struct X<> {};
5template <class T, class... Ts>
6struct X<T, Ts...> : X<Ts...> {};
7struct D : X<int> {};
8
9template <class... Ts>
10int f(const X<Ts...>&) {
11 return {};
12}
13
14
15int main() {
16 int x = f(D()); // deduced as f<int>, not f<>
17}
这里,最终推导出来的类型为f<int>,而非f<>。
下面介绍forwarding reference的推导规则。
对于forwarding reference,如果实参为左值,则模板参数推导为左值引用。看一个不错的例子:
1// Example from ISO C++
2
3template <class T> int f(T&& t);
4template <class T> int g(const T&&);
5
6int main() {
7 int i = 1;
8 //int n1 = f(i); // #1, f<int&>(int&)
9 //int n2 = f(0); // #2, f<int>(int&&);
10 int n3 = g(i); // #3, g<int>(const int&&)
11 // error: bind an rvalue reference to an lvalue
12}
此处,f()的参数为forwarding reference,g()的参数为右值引用。
因此,当实参为左值时,f()的模板参数被推导为int&,g()的模板参数则被推导为int。而左值无法绑定到右值,于是编译出错。
再来看另一个例子:
1// Example from ISO C++
2
3template <class T>
4struct A {
5 template <class U>
6 A(T&& t, U&& u, int*); // #1
7
8 A(T&&, int*); // #2
9};
10
11template <class T> A(T&&, int*) -> A<T>; // #3
12
13int main() {
14 int i;
15 int *ip;
16 A a{i, 0, ip}; // error: cannot deduce from #1
17 A b{0, i, ip}; // use #1 to deduce A<int> and #1 to initialize
18 A c{i, ip}; // use #3 to deduce A<int&> and #2 to initialize
19}
对于#1,U&&为forwarding reference,而T&&并不是,因为它不是函数模板参数。
于是,当使用#1初始化对象时,若第一个实参为左值,则T&&被推导为右值引用。由于左值无法绑定到右值,遂编译出错。但是第二个参数可以为左值,U会被推导为左值引用,次再施加引用折叠,最终依旧为左值引用,可以接收左值实参。
若要使类模板参数也变为forwarding reference,可以使用CTAD,如#3所示。此时,T&&为forwarding reference,第一个实参为左值时,就可以正常触发引用折叠。
3.2
Template Argument Substitution
TAD告诉编译器如何推导模板参数类型,紧随其后的就是使用推导出来的类型替换模板参数,将模板实例化。
这两个步骤密不可分,故在上节当中其实已经涉及了部分本节内容,这里进一步扩展。
这里只讲三个重点。
第一点,模板参数替换存在失败的可能性。
模板替换并不总是会成功的,比如:
struct A { typedef int B; };
template <class T> void g(typename T::B*) // #1
template <class T> void g(T); // #2
g<int>(0); // calls #2
Name Lookup查找到了#1和#2的两个名称,然后对它们进行模板参数替换。然而,对于#1的参数替换并不能成功,因为int不存在成员类型B,此时模板参数替换失败。
但是编译器并不会进行报错,只是将其从重载集中移除。这个特性就是广为熟知的SFINAE(Substitution Failure Is Not An Error),后来大家发现该特性可以进一步利用起来,为模板施加约束。
比如根据该原理可以实现一个enable_if工具,用来约束模板。
1namespace mylib {
2
3 template <bool, typename = void>
4 struct enable_if {};
5
6 template <typename T>
7 struct enable_if<true, T> {
8 using type = T;
9 };
10
11 template <bool C, typename T = void>
12 using enable_if_t = typename enable_if<C, T>::type;
13
14} // namespace mylib
15
16
17template <typename T, mylib::enable_if_t<std::same_as<T, double>, bool> = true>
18void f() {
19 std::cout << "A\n";
20}
21
22template <typename T, mylib::enable_if_t<std::same_as<T, int>, bool> = true>
23void f() {
24 std::cout << "int\n";
25}
26
27int main() {
28 f<double>(); // calls #1
29 f<int>(); // calls #2
30}
enable_if早已加入了标准,这个的工具的原理就是利用模板替换失败的特性,将不符合条件的函数从重载集移除,从而实现正确的逻辑分派。
SFINAE并非是专门针对类型约束而创造出来的,使用起来比较复杂,并不直观,已被C++20的Concepts取代。
第二点,关于trailing return type与normal return type的本质区别。
这二者的区别本质上就是Name Lookup的区别:normal return type是按照从左到右的词法顺序进行查找并替换的,而trailing return type因为存在占位符,打乱了常规的词法顺序,这使得它们存在一些细微的差异。
比如一个简单的例子:
namespace N {
using X = int;
X f();
}
N::X N::f(); // normal return type
auto N::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 <class T> struct A { using X = typename T::X; };
// normal return type
template <class T> typename T::X f(typename A<T>::X);
template <class T> void f(...);
// trailing return type
template <class T> auto g(typename A<T>::X) -> typename T::X;
template <class T> void g(...);
int main() {
f<int>(0); // #1 OK
g<int>(0); // #2 Error
}
通常来说,这两种返回类型只是形式上的差异,是可以等价使用的,但此处却有着细微而本质的区别。
#1都能成功调用,为什么改了个返回形式,#2就编译出错了?
这是因为:
在模板参数替换的时候,normal return type遵循从左向右的词法顺序,当它尝试替换T::X,发现实参类型int并没有成员X,于是依据SFINAE,该名称被舍弃。然后,编译器发现通用版本的名称可以成功替换,于是编译成功。
而#2在模板参数替换的时候,首先跳过auto占位符,开始替换函数参数。当它尝试使用int替换A<T>::X的时候,发现无法替换。但是A<T>::X并不会触发SFINAE,而是产生hard error,于是编译失败。
简单来说,此处,normal return type在触发hard error之前就触发了SFINAE,所以可以成功编译。
第三点,forwarding reference的模板参数替换要点。
看一个上周我在群内分享的一个例子:
1template <class T>
2struct S {
3 static void g(T&& t) {}
4 static void g(const T& t) {}
5};
6
7
8template <class T> void f(T&& t) {}
9template <class T> void f(const T& t) {}
10
11int main() {
12 int i = 1;
13
14 f<int&>(i); // #1 OK
15 S<int&>::g(i); // #2 Error
16}
为什么#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.1
Candidate functions
一级筛选的结果是由Name Lookup查找出来的,包含成员和非成员函数。
对于成员函数,它的第一个参数是一个额外的隐式对象参数,一般来说就是this指针。
对于静态成员函数,大家都知道它没有this指针,然而事实上它也存在一个额外的隐式对象参数。究其原因,就是为了重载决议可以正常运行。
可以看如下例子来进行理解。
struct S {
void f(long) {
std::cout << "member version\n";
}
static void f(int) {
std::cout << "static member version\n";
}
};
int main() {
S s;
s.f(1); // calls static member version
}
此时,这两个成员函数实际上为:
f(S&, long); // member version
f(implicit object parameter, int); // static member version
如果静态成员函数没有这个额外的隐式对象,那么其一,将可以定义一个参数完全相同的非静态成员;其二,重载决议将无法选择最佳的那个匹配函数(此处long需要转换,不是最佳匹配函数)。
静态成员的这个隐式对象参数被定义为可以匹配任何参数,仅仅用于在重载决议阶段保证操作的一致性。
对于非成员函数,则可以直接通过Unqualified Name Lookup和Qualified Name Lookup找到。同时,模板实例化后也会产生成员或非成员函数, 除了有些因为模板替换失败被移除,剩下的名称共同组成了candidate functions。
4.2
Viable 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.3
Tiebreakers
终极筛选也称为决胜局,重载决议的最后一步,将进行更加严格的匹配。
第一,它会看参数的匹配程度。
如前所述,实参类型与viable functions可能完全匹配,也可能需要转换,此时就存在更优的匹配选项。
C++的类型转换有三种形式,标准转换、自定义转换和省略操作符转换。
标准转换比自定义转换更好,自定义转换比省略操作符转换更好。
对于标准转换,可以看下表。
它们的匹配优先级也是自上往下的,即Exact Match比Promotion更好,Promotion比Conversion更好,可以理解为完全匹配、次级匹配和低级匹配。
看一个简单的例子:
void f(int);
void f(char);
int main() {
f(1); // f(int) wins
}
此时,viable functions就有两个。而实参类型为int,f(int)不需要转换,而f(char)需要将int转换为char,因此前者胜出。
如果实参类型为double,由于double转换为int和char属于相同等级,因此谁也不比谁好,产生ambiguous。
再来看一个例子:
1// Example from IOS C++
2
3void f(const int*, short);
4void f(int*, int);
5
6int main() {
7 int i;
8 short s = 0;
9 f(&i, s); // #1 Error, ambiguous
10 f(&i, 1L); // #2 OK, f(int*, int) wins
11 f(&i, 'c'); // #3 OK, f(int*, int) wins
12}
这里存在两个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)依旧胜出。
对于派生类,则子类向直接基类转换是更好的选择。
struct A {};
struct B : A {};
struct C : B {};
void f(A*) {
std::cout << "A*";
}
void f(B*) {
std::cout << "B*";
}
int main() {
C* pc;
f(pc); // f(B*) wins
}
这里,C向B转换,比向A转换更好,所以f(B*)胜出。
最后再来看一个例子,包含三种形式的转换。
1struct A {
2 operator int();
3};
4
5void f(A) {
6 std::cout << "standard conversion wins\n";
7}
8
9void f(int) {
10 std::cout << "user defined conversion wins\n";
11}
12
13void f(...) {
14 std::cout << "ellipsis conversion wins\n";
15}
16
17int main() {
18 A a;
19 f(a);
20}
最终匹配的优先级是从上往下的,标准转换是最优选择,自定义转换次之,省略操作符转换最差。
第二,如果同时出现模板函数和非模板函数,则非模板函数胜出。
例子如下:
1void f(int) {
2 std::cout << "f(int) wins\n";
3}
4
5template <class T>
6void f(T) {
7 std::cout << "function templates wins\n";
8}
9
10int main() {
11 f(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++
2
3template<typename T> concept C1 = requires(T t) { --t; };
4template<typename T> concept C2 = C1<T> && requires(T t) { *t; };
5
6template<C1 T> void f(T); // #1
7template<C2 T> void f(T); // #2
8template<class T> void g(T); // #3
9template<C1 T> void g(T); // #4
10
11int main() {
12 f(0); // selects #1
13 f((int*)0); // selects #2
14 g(true); // selects #3 because C1<bool> is not satisfied
15 g(0); // selects #4
16}
第五,如果一个是模板构造函数,一个是非模板构造函数,那么非模板版本获胜。
例子如下:
1template <class T>
2struct S {
3 S(T, T, int); // #1
4 template <class U> S(T, U, int); // #2
5};
6
7int main() {
8 // selects #1, generated from non-template constructor
9 S s(1, 2, 3);
10}
究其原因,还是非模板构造函数更加特殊。
以上所列的规则都是比较常用的规则,更多规则大家可以参考cppreference。
通过这些规则,就可以找出最佳匹配的那个函数。如果最后只剩下一个viable function,那么它就是best viable function。如果依旧存在多个函数,那么ambiguous。
大家也许还不是特别清楚上述流程,那么接下来,我将以一个完整的例子来串起整个流程。
5
走一遍完整的流程
一个完整的示例,代码如下:
1namespace N {
2 struct Base {};
3 struct Derived : Base {};
4 void foo(Base* s, char); // #1
5 void foo(Derived* s, int, bool = true); // #2
6 void foo(Derived* s, short); // #3
7}
8
9struct S {
10 N::Derived* d;
11 S(N::Derived* deri) : d{deri} {}
12 operator N::Derived*() const { return d; }
13};
14
15void foo(S); // #4
16template <class T> void foo(T* t, int c); // #5
17void foo(...); // #6
18
19int main() {
20 N::Derived d;
21 foo(&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编译过后的一个例子。
如图所示,编译器为每个重载函数都生成一个新名称,新名称是绝对唯一的。
基本的规则如下图所示。
除了基本规则,还有很多比较复杂的规则,这里再举几个常见的名称。
namespace myNS {
struct myClass {
// mangles as _ZN4myNS7myClass6myFuncEv
void myFunc() {}
};
// mangles as _ZN4myNS3fooENS_7myClassE
void foo(myClass) {}
}
template <class T>
void foo(T, int) {}
// mangles as _Z3fooIfEvT_i
template void foo(float, int);
规则不难理解,大家可以自己找下规律。其中,I/E中间的是模板参数,T_表示第1个模板参数。
由于C语言没有重载函数,所以它也没有Mangling操作。如果你使用混合编译,即某些文件使用C编译,某些文件使用C++编译,就会产生链接错误。
举个例子,有如下代码:
1// lib.cpp
2int myFunc(int a, int b) {
3 return a + b;
4}
5
6// main.cpp
7#include <iostream>
8
9int myFunc(int a, int b);
10
11int main() {
12 std::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.cpp
2extern "C" int myFunc(int a, int b);
3
4int main() {
5 std::cout << "The answer is " << myFunc(41, 1);
6}
如此一来,就可以解决这个问题。
通常来说,可以使用预处理条件语句,分别提供C和C++版本的代码,这样使用任何方式就都可以编译成功。
7
总结
本篇的内容相当之多,完整地包含了重载决议的整个流程。
能读到这里,相信大家已经收获满满,对整个流程已经有了清晰的认识。
因此,这个总结算是一个课后作业,请大家对照下图回想本篇内容,如果对所有概念都十分清楚,那么恭喜你已经理解了重载决议!
最后,写作不易,还请大家多多点赞、在看、转发,你的支持就是我不断创作的动力。