C 是一种简单的语言,每个函数名称只能对应一个函数。另一方面,C++ 提供了更大的灵活性:
- 你可以定义多个同名函数(函数重载)。
- 你可以重载内置运算符,如
+
和==
。 - 你可以编写函数模板。
- 命名空间有助于避免命名冲突。
我喜欢这些 C++ 特性。利用这些特性,你可以让 str1 + str2
返回两个字符串的拼接结果。你可以有一对 2D 点和另一对 3D 点,并重载 dot(a, b)
使其适用于任意类型。你可以创建一系列类似数组的类,并编写一个通用的排序函数模板,使其适用于所有这些类。
但在利用这些特性时,很容易走得太远。某个时候,编译器可能会意外地拒绝你的代码,并报出类似以下的错误:
error C2666: 'String::operator ==': 2 overloads have similar conversions note: could be 'bool String::operator ==(const String &) const' note: or 'built-in C++ operator==(const char *, const char *)' note: while trying to match the argument list '(const String, const char *)'
和许多 C++ 程序员一样,我在职业生涯中一直在与这些错误作斗争。每次遇到这种情况,我通常会挠头思索,然后在网上查找资料加深理解,接着修改代码直到成功编译。然而,最近在为 Plywood 开发新的运行时库时,我一次次被这些错误阻挠。渐渐地,我意识到,尽管我有丰富的 C++ 经验,但仍然缺少某些关键的理解,而我却不知道那究竟是什么。
幸运的是,现在是 2021 年,关于 C++ 的信息比以往更加全面。尤其要感谢 cppreference.com,让我终于弄清楚自己理解中缺失的部分:编译时,每次函数调用背后都有一个隐藏的算法,而我以前对它缺乏清晰的认识。
这就是编译器在处理函数调用表达式时,确定具体调用哪个函数的过程:

这些步骤被写入了 C++ 标准。每个 C++ 编译器都必须遵循这些步骤,而且这一切都发生在编译时,针对程序中每个被求值的函数调用表达式。事后看来,显然必须有这样的算法。它是 C++ 能同时支持上述所有特性的唯一方式。这就是将这些特性结合在一起后得到的结果。
我想这个算法的整体目的是“按程序员的预期执行”,在某种程度上,它在这方面是成功的。你可以在完全忽略这个算法的情况下走得很远。但当你开始使用多个 C++ 特性时,比如在开发一个库时,最好知道规则。
所以,让我们从头到尾走一遍这个算法。我们将要涵盖的许多内容对于有经验的 C++ 程序员来说可能很熟悉。尽管如此,我认为看到所有步骤如何配合在一起,还是很有启发性的。(至少对我来说是这样。)在过程中,我们会涉及几个高级 C++ 子主题,比如基于参数的查找和 SFINAE,但我们不会深入探讨任何一个子主题。这样,即使你对某个子主题一无所知,你也至少能知道它如何融入到 C++ 在编译时解析函数调用的整体策略中。我认为这是最重要的。
名称查找
我们的旅程从一个函数调用表达式开始。以下面代码中的表达式 blast(ast, 100)
为例。这个表达式显然是要调用一个名为 blast
的函数。但是,具体是哪个呢?
namespace galaxy { struct Asteroid { float radius = 12; }; void blast(Asteroid* ast, float force); } struct Target { galaxy::Asteroid* ast; Target(galaxy::Asteroid* ast) : ast{ast} {} operator galaxy::Asteroid*() const { return ast; } }; bool blast(Target target); template <typename T> void blast(T* obj, float force); void play(galaxy::Asteroid* ast) { blast(ast, 100); }
回答这个问题的第一步是名称查找。在这一步中,编译器会查看到目前为止所有已声明的函数和函数模板,并识别出那些可能通过给定名称被引用的函数。

正如流程图所示,名称查找有三种主要类型,每种类型都有自己的一套规则。
- 成员名查找发生在名称位于
.
或->
符号的右侧时,如foo->bar
。这种查找用于定位类成员。 - 限定名查找发生在名称中包含
::
符号时,如std::sort
。这种类型的名称是显式的。::
符号右侧的部分仅在左侧部分所标识的作用域中进行查找。 - 非限定名查找则不是这两者。当编译器遇到非限定名称时,如
blast
,它会根据上下文在不同的作用域中查找匹配的声明。编译器应该查找的具体位置由一套详细的规则决定。
在我们的例子中,我们有一个非限定名称。当对函数调用表达式执行名称查找时,编译器可能会找到多个声明。我们将这些声明称为候选项。在上面的例子中,编译器找到了三个候选项:

第一个候选项(上图中的圆圈标记)值得特别注意,因为它展示了 C++ 中一个容易忽视的特性:基于参数的查找,简称 ADL。我承认,在我的 C++ 职业生涯中,大部分时间我都没有意识到 ADL 在名称查找中的作用。这里简要总结一下,以防你也和我一样。通常,你不会期望这个函数成为这个特定调用的候选项,因为它是在 galaxy
命名空间内部声明的,而调用来自 galaxy
命名空间外部。代码中也没有 using namespace galaxy
指令来使这个函数可见。那么,为什么这个函数是一个候选项呢?
原因在于,每当你在函数调用中使用非限定名称时——且该名称不指向类成员等——ADL(基于参数的查找)就会生效,名称查找变得更加贪婪。具体来说,除了通常的查找位置外,编译器还会在参数类型的命名空间中查找候选函数——因此有了“基于参数的查找”这一名称。

ADL 规则的完整集比我在这里描述的要复杂,但关键在于 ADL 只适用于非限定名称。对于限定名称,它们在单一作用域中进行查找,ADL 就没有意义了。ADL 还可以在重载内置运算符(如 +
和 ==
)时生效,这使得你可以在编写数学库等时充分利用它。
有趣的是,有些情况下,成员名查找可以找到非限定名查找找不到的候选项。关于这一点,可以参考 Eli Bendersky 的文章了解更多详情。
函数模板的特殊处理
通过名称查找找到的一些候选项是函数,其他的是函数模板。函数模板有一个问题:你不能直接调用它们。你只能调用函数。因此,在名称查找之后,编译器会遍历候选项列表,并尝试将每个函数模板转换为函数。

在我们跟随的例子中,其中一个候选项确实是一个函数模板:

这个函数模板有一个模板参数 T。因此,它期望一个模板参数。调用者 blast(ast, 100)
没有指定任何模板参数,所以为了将这个函数模板转换为函数,编译器必须确定 T 的类型。这时,模板参数推导就起作用了。在这一步,编译器会将调用者传递的函数参数(下图左侧)与函数模板期望的函数参数类型(右侧)进行比较。如果右侧引用了任何未指定的模板参数,比如 T,编译器会使用左侧的信息来推导它们。

在这种情况下,编译器将 T 推导为 galaxy::Asteroid
,因为这样做可以使第一个函数参数 T*
与参数 ast
兼容。模板参数推导的规则本身是一个庞大的话题,但在像这样的简单例子中,它们通常会按照你预期的方式工作。如果模板参数推导失败——换句话说,如果编译器无法以某种方式推导模板参数,使得函数参数与调用者的参数兼容——那么该函数模板将从候选项列表中移除。
任何在候选项列表中存活到这一点的函数模板将进入下一步:模板参数替换。在这一步中,编译器会获取函数模板声明,并用相应的模板参数替换每个模板参数的出现。在我们的例子中,模板参数 T 被替换为其推导出的模板参数 galaxy::Asteroid
。当这一步成功时,我们终于得到了一个可以调用的真正函数签名——而不仅仅是一个函数模板!

当然,也有模板参数替换可能失败的情况。假设这个函数模板接受了第三个参数,如下所示:
template <typename T> void blast(T* obj, float force, typename T::Units mass = 5000);
如果是这种情况,编译器会尝试将 T::Units
中的 T
替换为 galaxy::Asteroid
。结果类型说明符 galaxy::Asteroid::Units
将是错误的,因为结构体 galaxy::Asteroid
实际上没有名为 Units
的成员。因此,模板参数替换会失败。
当模板参数替换失败时,函数模板会从候选项列表中移除——在 C++ 的某个历史时刻,人们意识到这是一项他们可以利用的特性!这一发现引发了一整套元编程技术,这些技术统称为 SFINAE(替换失败不是错误)。SFINAE 是一个复杂且笨重的话题,我在这里只说两点。首先,它本质上是一种通过调整函数调用解析过程来选择你想要的候选项的方式。其次,随着程序员越来越多地转向现代 C++ 元编程技术(如约束和 constexpr if
)来实现相同的目标,SFINAE 可能会逐渐过时。
重载解析
在这一阶段,通过名称查找找到的所有函数模板都已经消失,我们只剩下一个整洁的候选函数集。这也被称为 重载集合。以下是我们例子中的更新后的候选函数列表:

接下来的两个步骤通过确定哪些候选函数是 可行的(换句话说,哪些函数 可以 处理该函数调用),进一步缩小这个列表。

最明显的要求之一是 参数必须兼容;也就是说,一个可行的函数应该能够接受调用者的参数。如果调用者的参数类型与函数的参数类型不完全匹配,至少应该能够 隐式转换 每个参数到其相应的参数类型。让我们看看我们例子中的每个候选函数,看看它的参数是否兼容:

候选项 1
调用者的第一个参数类型 galaxy::Asteroid*
完全匹配。调用者的第二个参数类型 int
可以隐式转换为第二个函数参数类型 float
,因为 int
到 float
是标准转换。因此,候选项 1 的参数是兼容的。
候选项 2
调用者的第一个参数类型 galaxy::Asteroid*
可以隐式转换为第一个函数参数类型 Target
,因为 Target
有一个接受 galaxy::Asteroid*
类型参数的转换构造函数。(顺便说一下,这些类型也可以反向转换,因为 Target
有一个用户定义的转换函数可以转换回 galaxy::Asteroid*
。)然而,调用者传递了两个参数,而候选项 2 只接受一个参数。因此,候选项 2 不可行。

候选项 3
候选项 3 的参数类型与候选项 1 相同,因此它也兼容。
像这个过程中的其他所有内容一样,控制隐式转换的规则本身就是一个独立的话题。最值得注意的规则是,你可以通过将构造函数和转换运算符标记为 explicit
,从而避免它们参与隐式转换。
在使用调用者的参数筛选掉不兼容的候选项后,编译器接着检查每个函数的约束是否满足(如果有的话)。约束是 C++20 中的新特性。它们允许你使用自定义逻辑来消除候选函数(来自类模板或函数模板),而无需依赖 SFINAE。它们还应该为你提供更好的错误信息。我们的例子没有使用约束,因此我们可以跳过这一步。(从技术上讲,标准说约束也会在模板参数推导时被检查,但我略过了这个细节。两个地方的检查有助于确保显示最佳的错误信息。)
决胜规则
在我们这个例子中,到目前为止,我们剩下了两个可行的函数。它们中的任何一个都可以很好地处理原始的函数调用:

事实上,如果上述两个函数中的任何一个是唯一可行的,那么它将处理这个函数调用。但由于有两个可行的函数,编译器现在必须做它在存在多个可行函数时总是做的事情:它必须确定哪个函数是最好的可行函数。要成为最好的可行函数,其中一个必须通过一系列的决胜规则“战胜”其他所有可行函数。

让我们看看前面三个决胜规则。
第一个决胜规则:匹配更好的参数胜出
C++ 最重视调用者的参数类型与函数的参数类型之间的匹配程度。宽泛来说,C++ 更倾向于选择那些需要较少隐式转换的函数。当两个函数都需要转换时,某些转换被认为比其他转换“更好”。例如,这就是决定调用 std::vector
的 operator[]
的常量版本或非常量版本的规则。
在我们跟随的例子中,两个可行函数具有相同的参数类型,所以没有哪个更好。它们打平。因此,我们进入第二个决胜规则。
第二个决胜规则:非模板函数胜出
如果第一个决胜规则没有解决问题,那么 C++ 更倾向于调用非模板函数而不是模板函数。这就是我们例子中决定获胜者的规则;可行函数 1 是一个非模板函数,而可行函数 2 来自一个模板。因此,我们最好的可行函数是来自 galaxy
命名空间的函数:

值得重申的是,前两个决胜规则是按我描述的顺序排列的。换句话说,如果有一个可行函数,其参数与给定参数的匹配程度优于所有其他可行函数,即使它是一个模板函数,它也会获胜。
第三个决胜规则:更专门化的模板胜出
在我们的例子中,最好的可行函数已经找到了,但如果没有找到,我们将进入第三个决胜规则。在这个规则中,C++ 更倾向于调用“更专门化”的模板函数,而不是“更一般化”的函数。例如,考虑以下两个函数模板:
template <typename T> void blast(T obj, float force); template <typename T> void blast(T* obj, float force);
当对这两个函数模板执行模板参数推导时,第一个函数模板接受任何类型作为其第一个参数,而第二个函数模板仅接受指针类型。因此,第二个函数模板被认为是更专门化的。如果这两个函数模板是我们调用 blast(ast, 100)
时名称查找的唯一结果,并且它们都产生了可行函数,那么当前的决胜规则将导致选择第二个函数模板而不是第一个函数模板。决定哪个函数模板比另一个更专门化的规则是另一个庞大的话题。
尽管第二个函数模板被认为是更专门化的,但重要的是要理解,第二个函数模板实际上并不是第一个函数模板的部分特化。相反,它们是两个完全独立的函数模板,恰好共享相同的名称。换句话说,它们是重载的。C++ 不允许函数模板的部分特化。
汇总
除了这里列出的决胜规则之外,还有几个其他的决胜规则。例如,如果太空船 <=>
运算符和重载的比较运算符(如 >
)都是可行的,C++ 会更倾向于选择比较运算符。如果候选项是用户定义的转换函数,还会有其他规则优先于我所展示的规则。尽管如此,我认为我展示的这三个决胜规则是最重要的需要记住的。
不言而喻,如果编译器检查了所有的决胜规则但没有找到一个明确的胜者,编译将失败,并出现类似于本文开头所示的错误信息。
函数调用解析后
我们已经走到了旅程的尽头。编译器现在确切地知道应该调用哪个函数来处理表达式 blast(ast, 100)
。然而,在许多情况下,编译器在解析函数调用后还有更多的工作要做:
- 如果调用的函数是类成员,编译器必须检查该成员的访问说明符,以确定它是否对调用者可访问。
- 如果调用的函数是模板函数,编译器会尝试实例化该模板函数,前提是其定义是可见的。
- 如果调用的函数是虚函数,编译器会生成特殊的机器指令,以便在运行时调用正确的重写版本。
这些都不适用于我们的例子。此外,它们也超出了本文的讨论范围。
这篇文章没有包含任何新的信息。它基本上是对已经由 cppreference.com 描述的算法的简化解释,而 cppreference.com 本身也是 C++ 标准的简化版。然而,本文的目标是传达主要步骤,而不陷入细节。让我们回顾一下,看看有多少细节被跳过了。实际上,这还挺显著的:
- 有一整套关于非限定名查找的规则。
- 其中包括一套关于基于参数的查找的规则。
- 成员名查找也有自己的规则。
- 有一套关于模板参数推导的规则。
- 基于 SFINAE 的元编程技术有一整套。
- 有一套规则来管理隐式转换是如何工作的。
- 约束(和概念)是 C++20 中完全新的特性。
- 有一套规则来确定哪些隐式转换比其他的更好。
- 有一套规则来确定哪个函数模板比另一个更专门化。
是的,C++ 是复杂的。如果你想花更多时间探讨这些细节,Stephan T. Lavavej 在 2012 年制作了一系列非常值得观看的 Channel 9 视频。特别是第一三个视频。(感谢 Stephan 审阅了这篇文章的早期草稿。)
现在,我已经学会了 C++ 如何解析函数调用,作为库开发者,我感到更有信心了。编译错误变得更明显了。我可以更好地为 API 设计决策提供依据。我甚至成功地从这些规则中提炼出了一小套技巧。但那是另一个话题的内容了。