文章目录
序言
技术提高关键有三,一是输入,二是理解,三是输出。后二者皆受输入质量影响,低者产生浅理解弱输出,高者产生深理解强输出。是以书籍择取于学习者而言,至关重要。
限于所识,错误之处在所难免,敬请高明者匡正。
一、洞悉函数重载决议
大家可以尝试问自己一个问题:
调用一个重载函数,编译器是如何找到最佳匹配函数的?
若是你不能清楚地表述这个流程,就说明对函数重载决议缺乏认识。
函数重载决议也的确是许多 C++ 开发者都听过,但却从来没有真正理解过的一个概念,基本上也没有书籍深入讲解过这一重要概念,然而对语言的深刻理解往往就是建立在对这些基本概念的理解之上。
那么理解这个有什么用呢?
对于库作者,理解重载决议必不可少。因为重载涉及函数,函数又是变化的最小单元之一,可以说重载决议贯穿了「定制点」的发展历程。只有理解重载决议,才能理解各种定制点的表现方式,比如 ADL 二段式、CPOs、Deducing this。
对于使用者,若是不理解重载决议,就不能理解定制点,也就无法真正理解各种库的设计思路,使用起来难免会束手束脚。
同时,重载决议还涉及 ADL、TAD、SFINAE、Concepts、Forwarding reference 等等概念,若不理解重载决议,对这些相关概念的理解也将难以深入。
总而言之,重载决议与 C++ 中的许多概念都有着千丝万缕的联系,且重载解析本身就是一件非
常复杂的工作,这也是其难度颇高的原因。
接下来,就让我们拨开重重云雾,一探其究竟。
1.1、重载决议的基本流程
函数的标识主要分为两部分,名称和参数。
当函数名称唯一时,调用过程相对简单,直接查找即可。C 语言就属此列,它的函数名称必须唯一。
当函数名称相同,但参数类型不同时,在许多语言中依旧合法,此时这些名称相同的函数就称为重载函数。
C++ 就是支持重载函数的语言之一,那么它要如何来确定函数名的唯一性?
实际上,编译器会通过一种称为 (名称修饰)的技术来为每个重载函数生成唯一的名称。虽然重载函数的名称是相同的,但其参数不同,因此通过名称 + 参数再辅以一些规则,生成唯一的名称其实并非难事。
但这仍非实现重载函数的关键与难点所在。名称是唯一产生了,但是用户并不知道,也并不能直接通过该名称来调用函数。用户调用的还是重载函数名称本身,此时就需要一套机制来解析实际调用的函数到底是哪个,该机制就是「」,由 C++ 标准制定。
简言之,只要遇到名称相同的函数,重载决议就会出现,用于找出最佳匹配函数。
那么问题又来了,它是如何知道存在哪些名称相同的函数?
这便是在重载决议出现之前的一项工作,称为(名称查找)。
这一阶段,会根据调用的函数名称,查找函数的所有声明。若函数具有唯一的名称,那么就不会触发重载决议;若查找到多个相同的函数名称,这些函数声明就会被视为一个 overload set(重载集)。
函数又分为普通函数和函数模板,在 Name Lookup 阶段都会被查找到。但是函数模板只有实例化之后才能被使用,因此如果存在函数模板,还需要对模板进行特殊的处理,这个阶段就称为(模板处理)。
经过上述两个阶段的处理,得到的重载集就称为 candidate functions(候选函数),重载决议的工作就是在这些 candidate functions 中,找出最适合的那一个函数。
总结一下,当你调用一个重载函数时,编译器首先会进行 Name Lookup,找出所有函数声明,然后对函数模板进行 Template Handling,实例化出模板函数,产生 candidate functions,接着重载决议出现,找出最佳匹配函数。
而实际的最佳匹配函数调用,则是通过 Name mangling 产生的函数名称完成的。
1.2、Name Lookup
首先来看第一阶段,Name Lookup。该阶段仅仅进行名称查找,并不做任何额外检查。
Name Lookup 的工作主要可以分为两大部分。
第一部分为 (有修饰名称查找),这主要针对的是带有命名空间的函数调用,或是成员函数。
第二部分为 (无修饰名称查找),这种针对的就是普通函数的调用。
下面依次进行讨论。
1.2.1、Qualified Name Lookup
带修饰的名称查找并不算复杂,这又可以主要分为两类查找。
一类是 ,表示对于类成员的名称查找;另一类是 ,表示对于命名空间下的名称查找。
其实还可以包含枚举名称,因为它也可以使用作用域解析操作符”::” 进行访问,但一法通万法,不必单独细论。
以下单独讨论主要的两类。
1.2.1.1、Class Member Lookup
类成员查找,是在访问类成员时进行名称查找的规则。
成员本质上来说还是两种类型,变量与函数。换个角度来看,成员又可分为静态成员和动态成员,静态成员可以通过”::” 进行访问,动态成员可以通过”.” 或”->” 进行访问。
也就是说,当你使用如上三种方式访问某个变量或函数时,就可能会触发 Class Member Lookup。
首先来看前者,即使用”::” 访问时的规则。示例如下:
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,在类的作用域下进行查找。
这种调用一目了然,查找起来也比较方便,便不在此多加着墨,下面来看另一类带修饰的名称查找。
1.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。
下面再来看一个稍微复杂点的例子:
int x;
namespace Y {
void f(float);
void h(int);
}
namespace Z {
void h(double);
}
namespace A {
using namespace Y;
void f(int);
void g(int);
int i;
}
namespace B {
using namespace Z;
void f(char);
int i;
}
namespace AB {
using namespace A;
using namespace B;
void g();
}
void h() {
AB::g(); // #1
AB::f(1); // #2
AB::f('c'); // #3
AB::x++; // #4
AB::i++; // #5
AB::h(16.8); // #6
}
这里一共有 6 处调用,下面分别来进行分析。
Name Lookup 发现 AB 是一个命名空间,于是在该空间下查找 g() 的定义,在 29 行查找成功,于是可以成功调用。
Name Lookup 同样先在 AB 下查找 f() 的定义,注意,查找的时候不会看参数,只看函数名称。
然而,在 AB 下未找到相关定义,可是它发现这里还有了两个 using-directives,于是接着到命名空间 A 和 B 下面查找。
之后,它分别查找到了 A::f(int) 和 B::f(char) 两个结果,此时重载决议出现,发现 A::f(int) 是更好的选择,遂进行调用。
它跟 #2 的 Name Lookup 流程完全相同,最终查找到了 A::f(int) 和 B::f(char)。于是重载决议出现,发现后者才是更好的选择,于是调用 B::f(char)。
Name Lookup 先在 AB 下查找 x 的定义,没有找到,于是再到命名空间 A 和 B 下查找,依旧没有找到。可是它发现 A 和 B 中也存在 using-directives,于是再到命名空间 Y 和 Z 下面查找。然而,还是没有找到,最终编译失败。
这里它并不会去查找全局作用域下的 x,因为 x 的访问带有修饰。
Name Lookup 在 AB 下查找失败,于是转到 A 和 B 下面查找,发现存在 A::i 和 B::i 两个结果。但是它们的类型也是一样,于是重载决议失败,产生 ambiguous(歧义)的错误。
同样,在 AB 下查找失败,接着在 A 和 B 下进行查找,依旧失败,于是接着到 Y 和 Z 下面查找,最终找到 Y::h(int) 和 Z::h(double) 两个结果。此时重载决议出现,发现后者才是更好的选择,于是最终选择 Z::h(double)。
通过这个例子,相信大家已经具备分析 Namespace Member Lookup 名称查找流程的能力。
接着再补充几个需要注意的点。
第一点,被多次查找到的名称,但是只有一处定义时,并不会产生 ambiguous。
namespace X {
int a;
}
namespace A {
using namespace X;
}
namespace B {
using namespace X;
}
namespace AB {
using namespace A;
using namespace B;
}
AB::a++; // OK
这里,Name Lookup 最终查找了两次 X::a,但因为实际只存在一处定义,于是一切正常。
第二点,当查找到多个定义时,若其中一个定义是类或枚举,而其他定义是变量或函数,且这些定义处于同一个命名空间下,则后者会隐藏前者,即后者会被选择,否则 ambiguous。
可以通过以下例子来进行理解:
namespace A {
struct x {};
int x;
int y;
}
namespace B {
struct y {};
}
namespace C {
using namespace A;
using namespace B;
int i = C::x; // #1
int j = C::y; // #2
}
先看 #1,由于 C 中查找 x 失败,进而到 A 和 B 中进行查找,发现 A 中有两处定义。一处定义是类,另一处定义是变量,于是后者隐藏前者,最终选择 int x; 这处定义。
而对于 #2,最终查找到了 A::y 和 B::y 两处定义,由于定义不在同一命名空间下,所以产生ambiguous。
到此,对 Qualified Name Lookup 的内容就基本覆盖了,下面进入 Unqualified Name Lookup。
1.2.2、Unqualified Name Lookup
无修饰的名称查找则略显复杂,却会经常出现。
总的来说,也可分为两大类。
第一类为 ,即常规无修饰的名称查找,也就是普遍情况会触发的查询。
第二类为 ,这就是鼎鼎大名的 ADL,译为实参依赖查找。由其甚至发展出了一种定制点表示方式,称为 ADL 二段式,标准中的 std::swap, std::begin, std::end, operator«等等组件就是通过该法实现的。
但是本文并不会涉及定制点的讨论,因为这是我正在写的书中的某一节内容:) 内容其实非常之多之杂,本篇文章其实就是为该节扫除阅读障碍而特意写的,侧重点并不同。我额外写过一篇介绍定制点的文章【使用 Concepts 表示变化「定制点」】,各位可作开胃菜。
以下两节,分别讲解这两类名称查找。
1.2.2.1、Usual Unqualified Lookup
普通的函数调用都会触发 Usual Unqualified Lookup,先看一个简单的例子:
void f(char);
void f(double);
namespace mylib {
void f(int);
void h() {
f(3); // #1
f(.0); // #2
}
}
对于 #1 和 #2,Name Lookup 会如何查找?最终会调用哪个重载函数?
实际上只会查找到 f(int),#1 直接调用,#2 经过了隐式转换后调用。
为什么呢?记住一个准则,。关于作用域的查找顺序,后面会介绍。
因此,当查找到 f(int),它就不会再去全局查找其他声明。
注意:即使当前查找到的名称实际无法成功调用,也并不改变该准则。看如下例子:
void f(int);
namespace mylib {
void f(const char*);
void h() {
f(3); // #1 Error
}
}
此时,依旧只会查找到 f(const char*),即使 f(int) 才是正确的选择。由于没有相应的隐式转换,该代码最终编译失败。
那么具体的作用域查找顺序是怎样的?请看下述例子:
namespace M {
class B { // S3
};
}
// S5
namespace N {
// S4
class Y : public M::B {
// S2
class X {
// S1
int a[i]; // #1
};
};
}
#1 处使用了变量 i,因此 Name Lookup 需要进行查找,那么查找顺序将从 S1-S5。所以,只要在 S1-S5 的任何一处声明该变量,就可以被 Name Lookup 成功找到。
接着来看另一个查找规则,。
简单的例子:
namespace N {
int i = 4;
extern int j;
}
int i = 2;
int N::j = i; // j = 4
由于 N::j 在外部重新定义,因此变量 i 也会在命名空间 N 下进行查找,于是 j 的值为 4。如果在 N 下没有查找到,才会查找到全局的定义,此时 j 的值为 2。
而对于友元函数,查找规则又不相同,看如下例子:
struct A {
typedef int AT;
void f1(AT);
void f2(float);
template <class T> void f3();
};
struct B {
typedef char AT;
typedef float BT;
friend void A::f1(AT); // #1
friend void A::f2(BT); // #2
friend void A::f3<AT>(); // #3
};
此处,#1 的 AT 查找到的是 A::AT,#2 的 BT 查找到的是 B::BT,而 #3 的 AT 查找到的是 B::AT。
这是因为,。
1.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 会根据该函数的参数及返回值所属作用域进行查找。
例子如下:
namespace B {
struct R {};
void g(...) {
std::cout << "g found by ADL\n";
}
}
namespace A {
struct S {};
typedef B::R (*pf)(S);
void f(pf) {
std::cout << "f found by ADL\n";
}
}
B::R bar(A::S) {
return {};
}
int main() {
A::pf fun = bar;
f(fun); // #1, OK
g(fun); // #2, OK
}
#1 和 #2 处,分别调用了两个函数,参数为另一个函数,根据该条规则,ADL 得以查找到 A::f()与 B::g()。
第二点,若实参类型是一个类,那么 ADL 会从该类或其父类的最内层命名空间进行查找。
例子如下:
namespace A {
// S2
struct Base {};
}
namespace M {
// S3 not works!
namespace B {
// S1
struct Derived : A::Base {};
}
}
int main() {
M::B::Derived d;
f(d); // #1
}
此处,若要通过 ADL 找到 f() 的定义,可以将其声明放在 S1 或 S2 处。
第三点,若实参类型是一个类模板,那么 ADL 会在特化类的模板参数类型的命名空间下进行查找;若实参类型包含模板模板参数,那么 ADL 还会在模板模板参数类型的命名空间下查找。
例子如下:
namespace C {
struct Final {};
void g(...) {
std::cout << "g found by ADL\n";
}
};
namespace B {
template <typename T>
struct Temtem {};
struct Bar {};
void f(...) {
std::cout << "f found by ADL\n";
}
}
namespace A {
template <typename T>
struct Foo {};
}
int main() {
// class template arguments
A::Foo<B::Bar> foo;
f(foo); // OK
// template template arguments
A::Foo<B::Temtem<C::Final>> a;
g(a); // OK
}
代码一目了然,不多解释。
第四点,当使用别名时,ADL 会无效,因为名称并不是一个函数调用。
看这个例子:
typedef int f;
namespace N {
struct A {
friend void f(A&);
operator int();
void g(A a) {
int i = f(a); // #1
}
};
}
注意 #1 处,并不会应用 ADL 来查询函数 f,因为它其实是 int,相当于调用 int(a)。
说完了这四点规则,下面来稍微说点 ADL 二段式相关的内容。
看下面这个例子:
namespace mylib {
struct S {};
void swap(S&, S&) {}
void play() {
using std::swap;
S s1, s2;
swap(s1, s2); // OK, found by Unqualified Name Lookup
int a1, a2;
swap(a1, a2); // OK, found by using declaration
}
}
然后,你要在某个地方调用自己提供的这个定制函数,此处是 play() 当中。
但是调用的地方,你需要的 swap() 可能不只是定制函数,还包含标准中的版本。因此,为了保证调用形式的唯一性,调用被分成了两步。
- 使用 using declaration
- 使用 swap()
这样一来,不同的调用就可以被自动查找到对应的版本上。然而,只要稍微改变下调用形式,
代码就会出错:
namespace mylib {
struct S {};
void swap(S&, S&) {} // #1
void play() {
using namespace std;
S s1, s2;
swap(s1, s2); // OK, found by Unqualified Name Lookup
int a1, a2;
swap(a1, a2); // Error
}
}
这里将 using declaration 写成了 using directive,为什么就出错了?
其实,前者将 std::swap() 直接引入到了局部作用域,后者却将它引入了与最近的命名空间同等的作用域。根据前面讲过的准则:根据作用域查找顺序,当 Name Lookup 在某个作用域找到声明之后,便会停止查找。编译器查找到了 #1 处的定制函数,就立即停止,因此通过 using directive 引入的 std::swap() 实际上并没有被 Name Lookup 查找到。
这个细微的差异很难发现,标准在早期就犯了这个错误,因此 STL 中的许多实现存在不少问题,但由于 ABI 问题,又无法直接修复。这也是 C++20 引入 CPOs 的原因,STL2 Ranges 的设计就采用了这种新的定制点方式,以避免这个问题。
在这之前,标准发明了另一种方式来解决这个问题,称为 Hidden friends。
namespace mylib {
struct S {
// Hidden friends
friend void swap(S&, S&) {}
};
void play() {
using namespace std;
S s1, s2;
swap(s1, s2); // OK, found by ADL
int a1, a2;
swap(a1, a2); // OK
}
}
就是将定制函数定义为友元版本,放在类的内部。此时将不会再出现名称被隐藏的问题,这个函数只能被 ADL 找到。
Hidden friends 的写法在 STL 中存在不少,想必大家曾经也不知不觉中使用过。
好,更多关于定制点的内容本文不再涉及,下面进行另一个内容。
1.2.3、Template Name Lookup
以上两节 Name Lookup 内容只涉及零星关于模板的名称查找,本节专门讲解这部分查找,它们还是属于前两节的归类。
首先要说的是对于 typename 的使用,在模板当中声明一些类型,有些地方并不假设其为类型,此时只有在前面添加 typename,Name Lookup 才视其为类型。
不过自 C++20 之后,需要添加 typename 的地方已越来越少。
其次,介绍一个非常重要的概念,「独立名称」与「依赖名称」。
什么意思呢?看一个例子。
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 存在影响,看如下代码:
void f(char);
template <class T>
void g(T t) {
f(1); // non-dependent
f(T(1)); // dependent
f(t); // dependent
dd++; // non-dependent
}
enum E { e };
void f(E);
double dd;
void h() {
g(e); // calls f(char),f(E),f(E)
g('a'); // calls f(char),f(char),f(char)
}
在 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)。
一言以蔽之,即使把依赖名称的定义放在调用函数之后,由于其查找实际上发生于实例化之后,故也可成功找到。
事实上,存在术语专门表示此种查找方式,称为 (二段名称查找),在下节还会进一步讨论。
接着来看一个关于类外模板定义的查找规则。
看如下代码:
template <class T>
struct A {
struct B {};
typedef void C;
void f();
template<class U> void g(U);
};
template <class B>
void A<B>::f() {
B b; // #1
}
template <class B>
template <class C>
void A<B>::g(C) {
B b; // #2
C c; // #3
}
思考一下,#1,#2,#3 分别分别查找到的是哪个名称?(这个代码只有 clang 支持)
实际上,#1 和 #2 最终查找到的都是 A::B,而 #3 却是模板参数 C。
注意第 16-17 行出现的两个模板,它们并不能合并成一个,外层模板指的是类模板,而内层模板指的是函数模板。
因此,规则其实是:对于类外模板定义,如果成员不是类模板或函数模板,则类模板的成员名称会隐藏类外定义的模板参数;否则模板参数获胜。
而如果类模板位于一个命名空间之内,要在命名空间之外定义该类模板的成员,规则又不相同。
namespace N {
class C {};
template <class T> class B {
void f(T);
};
}
template <class C>
void N::B<C>::f(C) {
C b; // #1
}
此处,#1 处的 C 查找到的是模板参数。
如果是继承,那么也会隐藏模板参数,代码如下:
struct A {
struct B {};
int a;
int Y;
};
template <class B, class a>
struct X : A {
B b; // A::B
a b; // A::a, error, not a type name
};
这里,最终查找的都是父类当中的名称,模板参数被隐藏。
然而,如果父类是个依赖名称,由于名称查找于模板实例化之前,所以父类当中的名称不会被考虑,代码如下:
typedef double A;
template <class T>
struct B {
typedef int A;
};
template <class T>
struct X : B<T> {
A a; // double
};
这里,最终 X::A 的类型为 double,这是识别为独立名称并使用 Unqualified Name Lookup 查找到的。若要访问 B::A,那么声明改为 B::A a; 即可,这样一来就变为了依赖名称,且采用 Qualified Name Lookup 进行查找。
最后,说说多继承中包含依赖名称的规则。
还是看一个例子:
struct A {
int m;
};
struct B {
int m;
};
template <class T>
struct C : A, T {
int f() { return this->m; } // #1
int g() { return m; } // #2
};
template int C<B>::f(); // ambiguous!
template int C<B>::g(); // OK
此处,多重继承包含依赖名称,名称查找方式并不相同。
对于 #1,使用 Qualified Name Lookup 进行查找,查询发生于模板实例化,于是存在两个实例,出现 ambiguous。
而对于 #2,使用 Unqualified Name Lookup 进行查找,此时相当于是独立名称查找,查找到的只有 A::m,所以不会出现错误。
1.2.4、Two-phase Name Lookup
因为模板才产生了独立名称与依赖名称的概念,依赖名称的查找需要等到模板实例化之后,这就是上节提到的二段名称查找。
依赖名称的存在导致 Unqualified Name Lookup 失效,此时,只有使用 Qualified Name Lookup 才能成功查找到其名称。
举个非常常见的例子:
struct Base {
// non-dependent name
void f() {
std::cout << "Base class\n";
}
};
struct Derived : Base {
// non-dependent name
void h() {
std::cout << "Derived class\n";
f(); // OK
}
};
int main() {
Derived d;
d.h();
}
// Outputs:
// Derived class
// Base class
这里,f() 和 h() 都是独立名称,因此能够通过 Unqualified Name Lookup 成功查找到名称,程序一切正常。
然而,把上述代码改成模板代码,情况就大不相同了。
template <typename T>
struct Base {
void f() {
std::cout << "Base class\n";
}
};
template <typename T>
struct Derived : Base<T> {
void h() {
std::cout << "Derived class\n";
f(); // error: use of undeclared identifier 'f'
}
};
int main() {
Derived<int> d;
d.h();
}
此时,代码已经无法编译通过了。
为什么呢?当编译器进行 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
}
};
这样一来,编译器就能够成功查找到名称。
原理是这样的:通过这两种方式,就可以告诉编译器该名称是依赖名称,必须等到模板实例化之后才能进行查找,届时将使用 Qualified Name Lookup 进行查找。这就是二段名称查找的必要性。
在调用类函数模板时依旧存在上述问题,一个典型的例子:
struct S {
template <typename T>
static void f() {
std::cout << "f";
}
};
template <typename T>
void g(T* p) {
T::f<void>(); // #1 error!
T::template f<void>(); // #2 OK
}
int main() {
S s;
g(&s);
}
此处,由于 f() 是一个函数模板,#1 的名称查找将以失败告终。
因为它是一个依赖名称,编译器只假设名称是一个标识符(比如变量名、成员函数名),并不会认为它们是类型或函数模板。
原因如前面所说,由于模板特化和偏特化的存在,草率地假设会导致名称查找错误。此时,就需要显式地告诉编译器它们是一个类型或是函数模板,告诉编译器如何解析。
这也是需要对类型使用 typename 的原因,而对于函数模板,则如 #2 那样添加一个 template,这样就可以告诉编译器这是一个函数模板,<> 当中的名称于是被解析为模板参数。
#1 失败的原因也显而亦见,编译器将 f() 当成了成员函数,将 <> 解析为了比较符号,从而导致编译失败。
至此,关于 Name Lookup 的内容就全部结束了,下面进入重载决议流程的第二阶段——模板处理。