每周技巧 #49:实参依赖查找
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #49: Argument-Dependent Lookup。
原文最初作为 totw/49 发布于 2013 年 7 月 14 日。
“……无论选择沿着那条正在消失的法律主义胡言乱语小径走下去……”– Antonin Scalia,U.S. v Windsor dissenting opinion
概览
像 func(a,b,c) 这样的函数调用表达式,如果函数名没有使用 :: 作用域运算符限定,就称为非限定调用。当 C++ 代码通过非限定名称引用函数时,编译器会搜索匹配的函数声明。让一些人惊讶、也和其他语言不同的是,除了调用点的词法作用域之外,搜索作用域集合还会被函数实参类型关联的命名空间扩展。这种额外查找叫做实参依赖查找(Argument-Dependent Lookup,ADL)。它肯定正在你的代码里发生,所以对它的工作方式有基本理解,会让你轻松很多。
名称查找基础
编译器必须把一个函数调用映射到单个函数定义。这个匹配过程分为两个相互独立、顺序执行的阶段。首先,名称查找应用一些作用域搜索规则,产生一组名称与函数名匹配的重载。然后,重载决议接收名称查找产生的这些重载,并尝试为调用点给出的实参选择最佳匹配。请记住这个区别。名称查找先发生,而且它不会判断某个函数是不是好匹配。它甚至不会考虑实参数量。它只是在作用域中搜索函数名。重载决议本身是一个复杂主题,但这不是我们现在的重点。只要知道,它是一个独立处理阶段,输入来自名称查找。
遇到非限定函数调用时,针对这个函数名可能会发生若干独立的搜索序列,每个搜索序列都试图把名称匹配到一组重载。最明显的搜索,是从调用点词法作用域开始向外搜索:
|
|
这里的名称查找还和 ADL 无关(func() 没有实参)。它只是从函数调用位置开始向外搜索:从局部函数作用域(如果适用)向外,到类作用域、外围类作用域和基类(如果适用),再到命名空间作用域,继续到外围命名空间,最后到全局 :: 命名空间。
随着名称查找按作用域逐渐变宽的序列推进,一旦找到任何具有目标名称的函数,这个过程就会停止,无论该函数的参数是否与调用点提供的实参兼容。当遇到某个包含至少一个目标名称函数声明的作用域时,该作用域里的重载就成为这次名称查找的结果。
下面的例子说明了这一点:
|
|
很容易以为 func(s) 表达式会跳过显然不匹配的 b::internal::func(int),继续向下一个外层作用域查找 b::func(const string&),但这是错误的。名称查找不考虑实参类型。它在 b::internal 中找到了叫 func 的东西就停止,把“显然不匹配”的评估留给重载决议阶段。b::func(const string&) 甚至不会被重载决议看到。
作用域搜索顺序的一个重要含义是:搜索顺序中更早出现的作用域里的重载,会隐藏后续作用域里的重载。
实参依赖查找(ADL)
如果函数调用传入了实参,就会启动更多并行的名称查找。这些额外查找会考虑函数调用每个实参的每个关联命名空间。不同于词法作用域名称查找,这些依赖实参的查找不会继续进入外围作用域。
词法作用域名称查找和所有 ADL 的结果会合并到一起,形成最终的函数重载集合。
简单情况
考虑下面的代码:
|
|
为解析对 func(a) 的调用,会启动两次名称查找。词法作用域名称查找从 bspace::test() 的局部函数作用域开始。它在那里找不到 func,于是进入命名空间 bspace 的作用域,在那里找到 func(int) 并停止。另一次名称查找来自 ADL,从实参 a 关联的命名空间开始。在这个例子中,只有命名空间 aspace。这次查找找到 aspace::func(const aspace::A&) 并停止。因此,重载决议会收到两个候选:来自词法名称查找的 bspace::func(int),以及来自单次 ADL 查找的 aspace::func(const aspace::A&)。在重载决议中,func(a) 调用解析到 aspace::func(const aspace::A&)。bspace::func(int) 这个重载不适合该实参类型,因此被重载决议拒绝。
词法名称搜索和每个额外的、由 ADL 触发的名称搜索,可以看作并行发生,每个搜索都返回一组候选函数重载。所有这些搜索结果会被扔进一个袋子里,然后通过重载决议竞争,决定最佳匹配。如果最佳匹配打平,编译器会报二义性错误:“只能有一个。”如果没有任何重载是好匹配,那也是错误。所以更精确地说,“必须恰好有一个”,只是这句话在电影预告片里听起来没那么酷。
类型关联的命名空间
前面的例子是简单情况,但更复杂的类型可以关联许多命名空间。与某个类型关联的命名空间集合,包括出现在实参类型完整名称任意部分中的任何类型所属的命名空间,包括模板参数类型所属的命名空间。它也包括直接和间接基类的命名空间。例如,一个展开为 a::A<b::B, c::internal::C*> 的单个实参,会产生从 a、b 和 c::internal 命名空间开始的搜索(以及与组成类型 a::A、b::B 或 c::internal::C 关联的任何其他命名空间),每个搜索都查找被调用的函数名。下面的例子展示了其中一些效果:
|
|
提示
在基本名称查找机制还清楚地留在脑中时,考虑下面这些提示。它们可能会在你处理真实 C++ 代码时有所帮助。
类型别名
有时,确定与一个类型关联的命名空间集合需要一点侦探工作。typedef 和 using 声明可以为类型引入别名。在这种情况下,选择要搜索的命名空间列表之前,别名会被完全解析并展开成它们的源类型。这也是 typedef 和 using 声明可能有些误导人的地方之一,因为它们可能让你错误预测 ADL 会搜索哪些命名空间。如下所示:
|
|
小心迭代器
使用迭代器时要小心。你并不真正知道它们关联了哪些命名空间,所以不要依赖 ADL 来解析涉及迭代器的函数调用。它们可能只是指向元素的指针,也可能位于实现私有的某个命名空间里,而那个命名空间和容器命名空间毫无关系。
|
|
上面的代码依赖于 std::vector<int>::iterator 到底是 int*(这是可能的),还是位于某个拥有 count 重载(例如 std::count())的命名空间中的类型。它可能在某些平台上工作、另一些平台上不工作;也可能在带插桩迭代器的 debug 构建中工作,但在 optimized 构建中不工作。更好的做法是直接限定函数名。如果你想调用 std::count(),就这样写出来。
重载运算符
运算符(例如 + 或 <<)可以被看作一种函数名,例如 operator+(a,b) 或 operator<<(a,b),它们也是非限定的。ADL 最重要的用途之一,是查找日志记录中使用的 operator<<。我们通常会看到类似 std::cout << obj; 的代码,其中 obj 假设类型为 O::Obj。这条语句类似于一种形如 operator<<(std::ostream&, const O::Obj&) 的非限定函数调用,它会从 std::ostream 参数找到 std 命名空间中的重载,从 O::Obj 参数找到 O 命名空间中的重载,当然也会包含调用点词法作用域搜索捡到的任何重载。
把这类运算符放在它要操作的用户定义类型所在的同一命名空间里非常重要:在这个例子中,就是命名空间 O。如果 operator<< 被放在外层命名空间(例如全局命名空间 ::)中,这个运算符会工作一阵子,直到有人很无辜地在命名空间 O 中为某个其他类型放入一个无关的 operator<<。遵循一条简单规则需要一点纪律,但能避免日后大量困惑:把所有运算符和其他关联的非成员函数,都定义在类型定义旁边、同一个命名空间中。
基本类型
注意,基本类型(例如 int、double 等)并不关联全局命名空间。它们不关联任何命名空间。它们不会向 ADL 贡献任何命名空间。指针和数组类型会关联其所指类型或元素类型。
重构陷阱
会改变非限定函数调用实参类型的重构,可能会影响哪些重载会被考虑,以及是否有重载会被考虑。仅仅把一个类型移动到某个命名空间,并在旧命名空间里留下一个 typedef 以保持兼容,并没有帮助,实际上只会让问题更难诊断。把类型移动到新命名空间时要小心。
类似地,把函数移动到新命名空间,并在原处留下一个 using 声明,可能意味着非限定调用再也找不到它。遗憾的是,它们仍然可能通过找到另一个你并不想调用的函数而成功编译。把函数移动到新命名空间时也要小心。
最后的想法
相对来说,很少有程序员理解函数查找涉及的精确规则和边界情况。语言规范里有 13 页规则,详细说明名称搜索到底会纳入什么内容,包括特殊情况、友元函数细节和外围类作用域,足以让你头晕很多年。尽管存在这些复杂性,只要你记住“并行名称搜索”这个基本想法,就能比较稳地理解你的函数调用和运算符是如何解析的。你现在也能看出,看似遥远的声明为什么会在你调用函数或运算符时最终被选中。遇到二义性或名称隐藏效果这类令人困惑的构建错误时,你也会更有能力诊断它们。