本周小贴士#49:参数依赖查找

本文深入探讨了C++中的函数查找机制,包括名称查找和重载解析阶段。介绍了参数依赖查找(ADL)的概念,它是如何通过参数类型关联的命名空间进行查找的。同时,文章通过示例解释了ADL可能带来的影响,如类型别名、迭代器使用和重载运算符的解析。最后,提到了重构时需要注意的问题,并强调理解这些机制能帮助更好地诊断和解决问题。
摘要由CSDN通过智能技术生成

作为totw/49最初发表于2013年7月14日

概要

诸如func(a, b, c)这样的函数调用表达式,在这个表达式中函数没有使用::作用域操作符,这样的函数调用是不受限的。当C++代码涉及一个命名不受限的函数时,编译器将搜索一个匹配的函数声明。令人感到惊讶的(与其他语言不同)是,除了调用者词法作用域外,这搜索的作用域还会扩展到与函数参数类型相关的命名空间。这个额外的查找被称为参数依赖查找(ADL)。它肯定会发生在你的代码中,如果你对它是如何工作的,有一个基本的了解,那么你将会更好。

名称查找的基础

函数调用必须由编译器映射到唯一函数定义。这个匹配的过程是在两个独立的串行处理阶段完成。首选,名称查找通过一些作用域搜索规则,来产生一组与函数名称匹配的重载。然后,重载解析通过名称查找并且尝试在调用点根据参数选择最佳匹配。请记住这一区别。首先进行名称查找,接着它不会尝试确定一个函数是否匹配良好。它甚至不会考虑参数的个数。它仅在作用域中搜索函数名称。重载解析本身是一个复杂的话题,但此刻它不是我们的关注点。只需要知道它是单独的处理阶段,用来从名称查找中获取输入。

namespace b {
void func();
namespace internal {
void test() { func(); } // ok: finds 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);  // error: finds only 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&)甚至无法被重载来看到。
作用域搜索顺序的一个重要含义是,在搜索作用域中更早出现的重载会隐藏后面作用域中的重载。

参数依赖查找(ADL)

如果函数调用传递了参数,那么将启动更多并行的名称查找。这些额外的查询会考虑函数调用的每个参数的关联命名空间。与词法作用域命名称查找不同的是,这些参数依赖查找不会在封闭的作用域中查询。
词法作用域名称查找和所有的参数依赖查找合并在一起,以便形成最终的一组函数重载。

简单的示例

考虑以下代码:

namespace aspace {
struct A {};
void func(const A&);  // 通过参数依赖查找'A'得到
}  // 作用域bspace

namespace bspace {
void func(int);  // 通过词法名称查找得到
void test() {
  aspace::A a;
  func(a);  // aspace::func(const aspace::A&)
}
}  // 作用域bspace

启动两个名称查找来解决对func(a)的调用。词法作用域名称查找开始于bspace::test()的局部函数作用域。它没有找到func,然后进入命名空间bspace的作用域,在其中找到了func(int)并停止。由于ADL,另一个名称查找开始在参数a相关的命名空间中进行。在这种情况下,这是唯一的命名空间。这种查找会找到aspace::func(const aspace::A&)并停止。因此,重载解析会接受2个候选值。这些是来自词法名称查找的‘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相关的命名空间)前面进行搜索,每一个都在查找被调用函数名。以下的示例显示一些效果:

namespace aspace {
struct A {};
template <typename T> struct AGeneric {};
void func(const A&);
template <typename T> void find_me(const T&);
}  // 命名空间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>());
}
}  // 命名空间bspace

提示

借助于在你脑海中的基本的名称查找机制,当你工作在真实的C++代码中时,考虑以下的提示,它们会帮助到你。

类型别名

有时,确定一组与类型相关的命名空间会花费一些侦测工作。typedef和使用声明能够为类型引入别名。在这些情况中,在一系列要搜索的命名空间被选择前,别名被完整地解析并展开它们的源类型。这是一种typdef和使用声明可能被误导的方式,因为它们可能导致你错误地预测ADL将搜索哪个命名空间。如下所示:

namespace cspace {
// 成功:注意这里搜索的是aspace,而不是bspace
void test() {
  func(bspace::AliasForA());
}
}  // 命名空间cspace

迭代器警告

小心迭代器。你并不真的知道它们与什么命名空间相关,因此不要依赖ADL来解析涉及迭代器的函数调用。它们可能只是指向元素的指针,或者它们可能是在一些私有实现的命名空间中,这些命名空间与容器的命名空间无关。

namespace d {
int test() {
  std::vector<int> vec(a);
  // 这个可能能够编译,也可能不能编译
  return count(vec.begin(), vec.end(), 0);
}
}  // 命名空间d

上面的代码依赖于std::vector::iterator是int*(可能是)还是在有count重载(如std::count())的命名空间中的某个类型。这可能工作在某些平台上而不能在其他平台上,或者工作在检测迭代器的调试版本中,而不能工作在优化版本中。最好只限定函数名。如果要调用std::count(),请使用这种方式拼写。

重载的运算符

可以将操作符(例如+或<<)看作一种函数名,例如操作符+(a,b)或操作符<<(a,b),并且也没有限定。ADL的最重要用途之一是在打印日志中使用操作符<<。通常,我们看到类似std::cout<<obj;对于这些ojb,我们假设类型为O::Obj。这个语句就像形式为操作符<<(std::ostream&, const O::Ojb&)的未限定函数调用,它会从std::ostream&参数的std命名空间中找到重载,从O::Obj参数的O命名空间中找到重载,当然还有从调用处的词法作用域中找到任意重载。
重要的是,将这些操作符放置在与它们要操作的用户自定义类型相同的命名空间中:在命名空间O中的这种情况。如果操作符<<被放置在如::(全局命名空间)这样的外部命名空间中,那么该操作符将工作一段时间,直到有人无辜地将无关操作符<<放入其他类型的命名空间’O’中。它需要一些纪律,但是遵循这个简单的规则,即在相同命名空间中,在靠近类型定义旁边,定义所有的操作符和其他相关的非成员函数,这会避免大量的冲突。

基本类型

请注意,基本类型(如int,double等)是与全局命名空间无关。它们不关联命名空间。它们不提供任何命名空间去进行ADL。指针和数组类型是与它们指向的内容或元素类型相关。

重构陷阱

将参数类型更改为未限定的函数调用这样的重构,可能会影响预期的重载。仅将类型移到命名空间并且在旧命名空间中保留typdef,并不能帮助提升兼容性,这实际上会使得问题难以诊断。当移动类型至新的命名空间时要小心。
同样,将函数移到新的命名空间中并保留using声明可能意味着不能再找到未限定的调用。糟糕的是,它们依然会通过找你你未期待的不同函数来进行编译。当移动函数进新的命名空间时要小心。

最后的思考

相对而言,很少有程序员理解函数查找相关的精确规则和边界情况。语言规范包含13页规则,这些规则涉及精确地进行命名查找,包括特殊情况,友元函数的细节,封闭的函数的类作用域,会让你的脑袋转上好几年。尽管一切非常复杂,如果你牢记并行名称查找的基本概念,那么你将有坚固的基础来理解你的函数调用和操作符解析是如何进行的。现在,你将能够看到,在你调用函数或操作符时,类似的远程声明最终是如何被选择的。当这些发生时,你就能够更好的诊断令人困惑地构建错误,像解析歧义或是名称隐藏效应。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值