(原文链接:https://abseil.io/tips/49 译者:clangpp@gmail.com)
每周贴士 #49: 参数依赖查找
- 最初发布于:2013-07-14
“…whatever disappearing trail of its legalistic argle-bargle one chooses to follow…” –Antonin Scalia, U.S. v Windsor dissenting opinion
(译者注:译者水平太洼,此处保留原文,欢迎在评论区给出翻译建议)
概述
形如func(a,b,c)
(没有作用域操作符::
)的函数调用被称为非限定的。当C++代码以非限定方式使用一个函数时,编译器会查找与之匹配的函数声明。让人惊讶(而且和其他语言不同)的是,除了调用者的词法域,查找范围还会包括函数参数类型所在的命名空间。这个额外的查询被称作参数依赖查找(Argument-Dependent Lookup ,简称ADL)。这件事儿就发生在你的代码里,所以你最好对它有个基本的了解。
命名查找基础知识
编译器需要把一个函数调用映射到唯一的函数定义。匹配过程分为两个独立且串行的处理步骤。首先,命名查找通过一些作用域查找规则生成一个重载函数构成的集合。然后,重载匹配在这个集合中查找最好地匹配函数实参的那个重载函数。记住这个区别。命名查询先发生,而且它不会试图对一个函数是否很好地匹配实参做任何判断。它甚至不会考虑函数参数的数量,而仅仅是在可选范围内查找函数名。“重载匹配”是一个独立的复杂问题,但那不是本文的主题。现在只需要知道它是一个独立的步骤,以命名查找的结果作为输入。
(编译器,译者注)遇到一个非限定的函数调用的时候,会对函数名进行一系列相互独立的查找过程,每次查找都会试图找到一堆重载函数。最显而易见的是从调用点的词法域逐层向外进行查找:
namespace b {
void func();
namespace internal {
void test() { func(); } // 成功:找到b::func()。
} // b::internal
} // b
这里的命名查找还没有涉及到ADL(func()
没有参数)。它只是一个简单的向外的查找:从函数的调用点,向外扩展到本地函数作用域(如果有的话),到类作用域,更外的类作用域(如果当前类是定义在一个类中的嵌套类),基类(如果有的话),然后到命名空间作用域,更外的命名空间,最后到全局(::
)命名空间。
在逐渐展宽的作用域中机型查找函数名,这个进程在找到第一个同名的函数时就会停止,不论这个函数的形参列表能不能匹配函数调用点的实参列表。如果该作用域中存在至少一个(或多个,译者注)同名的函数声明,那么这个作用域中这些重载函数集合就是命名查找的结果集合。
举个例子说明一下:
namespace b {
void func(const string&); // b::func
namespace internal {
void func(int); // b::internal::func
namespace deep {
void test() {
string s("hello");
func(s); // 错误:只找到了b::internal::func(int)。
}
} // b::internal::deep
} // b::internal
} // b
一个吸引人(但不正确)的想法是,func(s)
表达式会跳过显然不匹配实参的b::internal::func(int)
,然后继续在更外层作用域中找到b::func(const string&)
。然而,命名查找不考虑参数类型。它找到个名叫func
的玩意儿,然后止步于b::internal
,把评估“显然不匹配”这活儿交给“重载匹配”那一步去完成。“重载匹配”压根儿就没见到过b::func(const string&)
。
“按作用域顺序查找”有一个重要的影响,较早检查到的作用域内的重载函数会掩藏掉较晚检查到的作用域内的重载函数。
参数依赖查找(Argument-Dependent Lookup, 简称ADL)
如果一个函数调用传递了实参,一些其他的命名查找会开始同步执行。这些额外的查找会考虑每一个实参关联的命名空间。与词法查找不一样的是,这些依赖实参的查找不会继续向更外层的作用域拓展。
词法域查找结果和参数依赖查找结果合并在一起,构成了最终的函数重载集合。
简单的情况
考虑如下代码:
namespace aspace {
struct A {};
void func(const A&); // 根据'a'的参数依赖查找找到了这个函数
} // namespace aspace
namespace bspace {
void func(int); // 词法域命名查找找到了这个函数
void test() {
aspace::A a;
func(a); // aspace::func(const aspace::A&)
}
} // namespace bspace
func(a)
的调用执行了两次命名查找。词法y域命名查找开始于bspace::test()
的本地函数作用域。没找着,继续看命名空间bspace
,找到func(int)
,结束。另一次命名查找,也就是参数依赖查找,开始于实参a
所在的命名空间。在这个例子中,只有命名空间aspace
(译者注:而不包括更外层的命名空间)。这次找到了aspace::func(const aspace::A&)
,结束。重载解析收到了两个候选项。它们是词法命名查找得到的’bspace::func(int)‘和一次参数依赖查找得到的’aspace::func(const aspace::A&)’。重载解析阶段,func(a)
选择了aspace::func(const aspace::A&)
。bspace::func(int)
重载没有很好地匹配参数类型,因此被重载解析拒绝了。
词法命名查找结果和各次参数以来查找结果可以被认为同时发生,每次查找都返回一堆候选的函数重载。所有查找结果都被丢进同一个筐,在重载解析阶段竞优选出最佳匹配。如果最佳匹配有平局,编译器会产生一个歧义错误:“只能选一个。”如果没有好的匹配,那就是另一个编译错误。所以更准确地说是,“有且只有一个答案”,如果电影预告片这么搞,那基本是烂片。
类型关联的命名空间
前面的例子是简单情况,但是更复杂的类型可以关联很多个命名空间。一个类型关联的命名空间,包括了出现在了实参类型全名中的任何类型所在的命名空间,其中包含了模板参数类型。另外还包括直接或间接的基类所在的命名空间。例如,一个展开成a::A<b::B, c::internal::C*>
的实参类型,会导致从a
,b
和c::internal
这几个命名空间(以及组成类型a::A
,b::B
,或c::internal::C
关联的任何其他命名空间)开始的命名查找,各自寻找调用的函数名。下面的例子展示了一些该效应:
namespace aspace {
struct A {};
template <typename T> struct AGeneric {};
void func(const A&);
template <typename T> void find_me(const T&);
} // namespace aspace
namespace bspace {
typedef aspace::A AliasForA;
struct B : aspace::A {};
template <typename T> struct BGeneric {};
void test() {
// 成功:查找基类所在的命名空间。
func(B());
// 成功:查找模板参数所在的命名空间。
find_me(BGeneric<aspace::A>());
// 成功:查找模板类所在的命名空间。
find_me(aspace::AGeneric<int>());
}
} // namespace bspace
贴士
趁着你脑子里的基本命名查找规则还热乎,下面的贴士也许会在你实战C++代码的时候帮到你。
类型别名
有时候决定一个类型关联的命名空间还得费一番功夫。typedef
和使用声明(using declarations)可以引入类型别名。在这种情况下,决定要查找的命名空间之前,类型别名会被完全展开成为其原始类型。这是一种typedef
和使用声明(using declarations)带人进沟的的方式,因为它们让你对参数依赖查找选择的命名空间做出错误预测。展示如下:
namespace cspace {
// 成功:注意这里查找了aspace,而不是bspace。
void test() {
func(bspace::AliasForA());
}
} // namespace cspace
迭代器的坑
用迭代器的时候要小心。你可不知道它们关联了哪些命名空间,所以对有迭代器参数的函数进行重载解析,不要依赖参数依赖查找。它们可能仅仅是指向元素的指针,也可能是跟容器命名空间毫无关系的实现相关的私有命名空间。
namespace d {
int test() {
std::vector<int> vec(a);
// 可能编译,可能挂!
return count(vec.begin(), vec.end(), 0);
}
} // namespace d
以上代码依赖于std::vector<int>::iterator
是int*
(有可能)还是某个在其命名空间中有count重载(如std::count()
)的类型。这段代码可能在有些平台上能跑通,但在其他平台上就不行,也可能在调试环境下跟加了测试条件的迭代器能好好玩,但是在优化环境下就不行。最好就直接限定(译者注:哪个作用域里的)函数名。如果你想调用std::count()
,就(译者注:连作用域std::
一起)拼写出来。
重载的操作符
一个操作符(例如+
或<<
)可以被想象成一类函数名,例如operator+(a,b)
或operator<<(a,b)
,而且是非限定的。参数依赖查找最重要的作用之一就是在打印日志的时候查找operator<<
。通常我们会见到对某个obj
调用的std::cout << obj;
,假设其类型是O::Obj
。这个表达式就像一个形如operator<<(std::ostream&, const O::Obj&)
的非限定的函数调用,会在std::ostream
所在的std
命名空间,O::Obj
所在的O
命名空间,当然还有调用处所在的任何外部词法域查找函数重载。
基础类型
需要注意的是,基础数据类型(如int
,double
,等等)并不是关联到全局命名空间。它们不关联到任何命名空间。它们不会给参数依赖查找提供任何可选命名空间。指针和数组类型关联到它们所指对象或元素类型。
重构的坑
如果重构改变了非限定的函数实参类型,它有可能影响函数重载解析。如果仅仅是把类型挪到新的命名空间,在旧的命名空间里留一个typedef
以保持后向兼容,那除了增加找到问题的难度以外没有任何作用。把类型挪到新的命名空间的时候要小心。
类似地,如果把一个函数挪到新的命名空间,在旧的命名空间里留一个using
声明,那就有可能导致非限定函数调用再也找不到它了。缺德的是,如果编译器找到一个你本不打算调用的函数重载,代码可能还会编译通过。把函数挪到新的命名空间的时候要小心。
写在最后
能了解函数查找的准确规则和边界情况的工程师凤毛麟角。语言标准花了13页来说明命名查找的准确规则,包括了特殊情况,友元函数的细节,以及外包(enclosing)类作用域,能让你的小脑袋瓜转上好几年。除了这些复杂性之外,如果你还记得并行命名查找的基本思路,那你就可以对函数调用和操作符重载解析成竹在胸了。在你调用函数或操作符的时候,你就能看到大概多远的那个函数声明会被选上了。在(重载解析)歧义或命名隐藏效应发生的时候,你会更有能力诊断这些令人费解的编译错误。