C++函数调用全解析

564 阅读11分钟

翻译文章。原文链接:function call

C 是一种简单的语言。一个函数只能对应一个变量名。而C++提供了更大的灵活性:

  • 可以具有同名的多个函数(重载)。
  • 可以内置操作符如+==
  • 函数模板
  • 用命名空间来避免命名冲突。

有了这些功能,可以返回两个字符串的串联str1 + str2,可以重载函数以处理不同的类型。

但是,使用时却容易出现问题。在某些时候,编译器可能会报错:

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++程序员一样,我整个职业生涯都在为这样的错误而挣扎。每次发生这种情况时,我通常都会在网上搜索,然后更改代码,直到代码编译通过。

幸运的是,现在是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++的很容易被忽视的特征:参数依赖查找argument-dependent lookup),或者简称为ADL。通常,你不会把这个函数当成目前函数调用查找的候选项,因为它在名称空间galaxy内被声明,而函数调用来自命名空间之外。代码中也没有using namespace galaxy。那么,为什么这个函数是候选项呢?

这是因为当你使用非受限函数调用,并且这个函数名不是类成员时,就会触发ADL ,而函数名查找也变得更加“贪婪”。除了通常的位置外,编译器还查找参数命名空间中的候选函数,因此称为"参数依赖查找"。

例如上面这个例子,也会查找galaxy命名空间的候选函数。可以让ADL实现一些方便的功能,如+==

函数模板的特殊处理

通过函数名查找找到的一些候选项是函数,一些是函数_模板_。**函数模板有一个问题:你无法调用它们。**我们只能调用函数。因此,在查找函数名后,编译器会浏览候选列表,尝试将函数模板转换为函数。

还是之前的例子,有一个候选项就是函数模板:

image-20210520104031826

这个函数模板有一个模板参数。而我们的函数调用blast(ast, 100)没有指定任何模板参数,所以要将此函数模板转换为函数,编译器必须找出它的类型,这就是模板参数推演的用武之地。在此步骤中,编译器将函数调用传递的参数类型(下图左侧)与函数模板所期望的参数类型(右侧)进行比较。如果右侧有模板参数对应不上,如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。所以模板参数替换将失败。

当模板参数替换失败时,函数模板就会从候选列表中删除 。在C++历史的某个节点,人们意识到这一点也是可以利用的功能。这一发现本身就导致了一整套元编程技术,统称为SFINAE(substitution failure is not an error)。SFINAE是一个复杂的东西,这里我只说两件事。首先,它本质上是一种将函数调用解析过程操纵到选择你想要的候选项的方法。其次,随着时间的推移,它可能会失宠,因为程序员越来越多地转向现代C++元编程技术,实现同样的事情,完全可以用其他的东西,如l constraintsconstexpr if

重载的分辨率

在这个阶段,在函数名查找过程中找到的模板都不见了(被推演替换或者排除掉了),只剩下一组整洁的候选函数。这也被称为重载集。以下是之前例子的候选函数列表:

在这个阶段,在名称查找过程中发现的所有功能模板都不见了,我们只剩下一组漂亮整洁的候选函数。这也被称为超载集。以下是我们示例的候选功能更新列表:

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

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

候选项 1

第一个参数类型完全匹配,第二个参数类型int可以隐式转换为第二个函数参数类型float。因此,候选 1 的参数是兼容的。

候选项 2

第一个参数类型是隐式转换为第一个函数参数类型,因为有一个转换构造器,接受galaxy::Asteroid*类型的参数。即:

struct Target {
    galaxy::Asteroid* ast;
    Target(galaxy::Asteroid* ast) : ast{ast} {}
    operator galaxy::Asteroid*() const { return ast; }
};

但是,函数调用有两个参数,候选项 2 只接受一个参数。因此,候选项2是不可行的

候选项 3

参数类型完全相同,因此它也兼容。

这个过程中有一部分用到了隐式转换,你可以声明explict禁止隐式转换。

决战时刻

在上面例子中,有两个可行的函数。他们中的任何一个都可以很好地处理原始的函数调用:

那么选谁呢?我不是全天下唯一一个为两个函数动心的男人吧!

编译器现在有多种可行的函数选择:它必须选一个最好的函数。要成为最佳可行的函数,它必须"赢"所有其他可行的函数,而这由一系列的规则决定。

让我们来看看前三个规则。

第一个规则: 匹配参数更好的获胜

C++最重视函数调用参数类型与函数参数类型的匹配程度。它更喜欢隐性转换少的函数。当两个函数都需要转换时,某些转换被认为比其他转换"更好"。例如调用std::vector的操作符[]是选const还是non-const版本。

在之前的示例中,两个可行函数具有相同的参数类型,平局,到第二个规则。

第二个规则:非模板函数获胜

如果第一个规则不能解决它,那么C++更喜欢调用非模板函数而不是模板函数。

很明显:

值得重申的是, 前两个平局是按照我描述的方式订购的。换句话说,如果有一个可行的功能,其参数比所有其他可行的功能更匹配给定的参数,它会赢,即使它是一个模板函数

第三个规则:更细化的模板获胜

在我们的示例中,已经找到了最佳可行的函数,但如果没有,我们将转到第三个规则。C++更喜欢将"更细化"的模板函数。例如,考虑以下两个函数模板:

template <typename T> void blast(T obj, float force);
template <typename T> void blast(T* obj, float force);

当对这两个函数模板执行模板参数推演时,第一个函数模板接受任何类型作为其第一个参数,但第二个函数模板仅接受指针类型。因此第二个函数模板更细化。如果这两个函数模板是blast(ast, 100)函数名查找的唯一结果,并且都是可行的,则当前的规则将选择第二个模板。决定哪个函数模板比另一个更细化的规则是另一个大课题。

但是要知道,第二个函数模板实际上并不是第一个函数模板的部分细化。相反,它们是两个完全独立的函数模板,只是碰巧共享同一个名称。换句话说,它们重载了。C++不允许函数模板的部分细化

不用说,如果编译器没有找到一个明确的赢家,则编译失败时会出现类似于开头的错误消息。

至此,全景解析完成,再来看一下这张图就一目了然了。