查看原文
其他

洞悉C++函数重载决议

cpluspluser CppMore 2023-04-20
本篇主题比较复杂,分成五六篇来写也毫不为过,但为了保证内容的完整性及连贯性,最终还是写成了一篇。
因此篇幅较长,可以多读几遍,定能解除不少疑惑,对语言有更加深刻的理解。

大家可以尝试问自己一个问题:

调用一个重载函数,编译器是如何找到最佳匹配函数的?

若是你不能清楚地表述这个流程,就说明对函数重载决议缺乏认识。
函数重载决议也的确是许多C++开发者都听过,但却从来没有真正理解过的一个概念,基本上也没有书籍深入讲解过这一重要概念,然而对语言的深刻理解往往就是建立在对这些基本概念的理解之上。
那么理解这个有什么用呢?
对于库作者,理解重载决议必不可少。因为重载涉及函数,函数又是变化的最小单元之一,可以说重载决议贯穿了「定制点」的发展历程。只有理解重载决议,才能理解各种定制点的表现方式,比如ADL二段式、CPOs、Deducing this。
对于使用者,若是不理解重载决议,就不能理解定制点,也就无法真正理解各种库的设计思路,使用起来难免会束手束脚。
同时,重载决议还涉及ADL、TAD、SFINAE、Concepts、Forwarding reference等等概念,若不理解重载决议,对这些相关概念的理解也将难以深入。
总而言之,重载决议与C++中的许多概念都有着千丝万缕的联系,且重载解析本身就是一件非常复杂的工作,这也是其难度颇高的原因。
接下来,就让我们拨开重重云雾,一探其究竟。


1

重载决议的基本流程

函数的标识主要分为两部分,名称和参数。

当函数名称唯一时,调用过程相对简单,直接查找即可。C语言就属此列,它的函数名称必须唯一。

当函数名称相同,但参数类型不同时,在许多语言中依旧合法,此时这些名称相同的函数就称为重载函数。

C++就是支持重载函数的语言之一,那么它要如何来确定函数名的唯一性?

实际上,编译器会通过一种称为Name mangling(名称修饰)的技术来为每个重载函数生成唯一的名称。虽然重载函数的名称是相同的,但其参数不同,因此通过名称+参数再辅以一些规则,生成唯一的名称其实并非难事。

但这仍非实现重载函数的关键与难点所在。名称是唯一产生了,但是用户并不知道,也并不能直接通过该名称来调用函数。用户调用的还是重载函数名称本身,此时就需要一套机制来解析实际调用的函数到底是哪个,该机制就是「重载决议」,由C++标准制定。

简言之,只要遇到名称相同的函数,重载决议就会出现,用于找出最佳匹配函数。

那么问题又来了,它是如何知道存在哪些名称相同的函数?

这便是在重载决议出现之前的一项工作,称为Name Lookup(名称查找)。

这一阶段,会根据调用的函数名称,查找函数的所有声明。若函数具有唯一的名称,那么就不会触发重载决议;若查找到多个相同的函数名称,这些函数声明就会被视为一个overload set(重载集)。

函数又分为普通函数和函数模板,在Name Lookup阶段都会被查找到。但是函数模板只有实例化之后才能被使用,因此如果存在函数模板,还需要对模板进行特殊的处理,这个阶段就称为Template Handling(模板处理)。

经过上述两个阶段的处理,得到的重载集就称为candidate functions(候选函数),重载决议的工作就是在这些candidate functions中,找出最适合的那一个函数。

总结一下,当你调用一个重载函数时,编译器首先会进行Name Lookup,找出所有函数声明,然后对函数模板进行Template Handling,实例化出模板函数,产生candidate functions,接着重载决议出现,找出最佳匹配函数。

而实际的最佳匹配函数调用,则是通过Name mangling产生的函数名称完成的。


2

Name Lookup

首先来看第一阶段,Name Lookup。该阶段仅仅进行名称查找,并不做任何额外检查。

Name Lookup的工作主要可以分为两大部分。

第一部分为Qualified Name Lookup(有修饰名称查找),这主要针对的是带有命名空间的函数调用,或是成员函数。

第二部分为Unqualified Name Lookup(无修饰名称查找),这种针对的就是普通函数的调用。

下面依次进行讨论。

2.1

Qualified Name Lookup

带修饰的名称查找并不算复杂,这又可以主要分为两类查找。

一类是Class Member Lookup,表示对于类成员的名称查找;另一类是Namespace Member Lookup,表示对于命名空间下的名称查找。

其实还可以包含枚举名称,因为它也可以使用作用域解析操作符"::"进行访问,但一法通万法,不必单独细论。

以下单独讨论主要的两类。


2.1.1

Class Member Lookup

类成员查找,是在访问类成员时进行名称查找的规则。

成员本质上来说还是两种类型,变量与函数。换个角度来看,成员又可分为静态成员和动态成员,静态成员可以通过"::"进行访问,动态成员可以通过"."或"->"进行访问。

也就是说,当你使用如上三种方式访问某个变量或函数时,就可能会触发Class Member Lookup。

首先来看前者,即使用"::"访问时的规则。示例如下:

// Example from ISO C++

class X {};
class C {
    class X {};
    static const int number = 50;
    static X arr[number];
};

X C::arr[number]; // #1

可以将#1处的定义从"::"拆分为前后两部分。

对于前面的名称X和C,将会在其定义的命名空间进行查找,此处即为全局空间,于是查找到全局作用域下的X和C类。

对于后面的名称arr和number,将会在C类的作用域下进行查找,它们将作为类成员进行查找。

此时就是"::"前面的类型名,告诉编译器后面的名称应该通过Class Member Lookup进行查找。如果搜索发现前面是个命名空间,则会在相应的作用域下查找。

由于X是在全局作用域下查找到的,所以并不会找到内部类X,于是该声明会产生编译错误。

接着来看后者,关于"."和"->"的规则。看一个简单的例子:

struct S {
    void f() {}
};

S s;
s.f();
S* ps = &s;
ps->f();

此处要调用f函数,因为使用了"."或"->"操作符,而操作符前面又是一个类,所以f的查找将直接使用Class Member Lookup,在类的作用域下进行查找。

这种调用一目了然,查找起来也比较方便,便不在此多加着墨,下面来看另一类带修饰的名称查找。


2.1.2

Namespace Member Lookup

命名空间成员查找,是在访问命名空间下的元素时进行名称查找的规则。

当你使用"::"访问元素的时候,就有可能会触发Namespace Member Lookup。

比如,当把"::"单独作为前缀时,则会强制Name Lookup在全局空间下进行查找。如下述例子:

void f()// #1

namespace mylib {

    void f()// #2

    void h() {
        ::f(); // calls #1
        f();   // calls #2
    }

// namespace mylib

此时,若是没有在全局作用域下搜索到相应的函数名称,也不会调用#2,而是产生编译错误。若是要在外部访问命名空间内部的f(),则必须使用mylib::f(),否则Name Lookup会找到全局作用域下的#1。

下面再来看一个稍微复杂点的例子:

1// Example from ISO C++
2
3int x;
4namespace Y {
5    void f(float);
6    void h(int);
7}
8
9namespace Z {
10    void h(double);
11}
12
13namespace A {
14    using namespace Y;
15    void f(int);
16    void g(int);
17    int i;
18}
19
20namespace B {
21    using namespace Z;
22    void f(char);
23    int i;
24}
25
26namespace AB {
27    using namespace A;
28    using namespace B;
29    void g();
30}
31
32void h() {
33    AB::g();       // #1
34    AB::f(1);      // #2
35    AB::f('c');    // #3
36    AB::x++;       // #4
37    AB::i++;       // #5
38    AB::h(16.8);   // #6
39}

这里一共有6处调用,下面分别来进行分析。

第一处调用,#1。

Name Lookup发现AB是一个命名空间,于是在该空间下查找g()的定义,在29行查找成功,于是可以成功调用。

第二处调用,#2。

Name Lookup同样先在AB下查找f()的定义,注意,查找的时候不会看参数,只看函数名称

然而,在AB下未找到相关定义,可是它发现这里还有了两个using-directives,于是接着到命名空间A和B下面查找。

之后,它分别查找到了A::f(int)和B::f(char)两个结果,此时重载决议出现,发现A::f(int)是更好的选择,遂进行调用。

第三处调用,#3。

它跟#2的Name Lookup流程完全相同,最终查找到了A::f(int)和B::f(char)。于是重载决议出现,发现后者才是更好的选择,于是调用B::f(char)。

第四处调用,#4。

Name Lookup先在AB下查找x的定义,没有找到,于是再到命名空间A和B下查找,依旧没有找到。可是它发现A和B中也存在using-directives,于是再到命名空间Y和Z下面查找。然而,还是没有找到,最终编译失败。

这里它并不会去查找全局作用域下的x,因为x的访问带有修饰。

第五处调用,#5。

Name Lookup在AB下查找失败,于是转到A和B下面查找,发现存在A::i和B::i两个结果。但是它们的类型也是一样,于是重载决议失败,产生ambiguous(歧义)的错误。

最后一处调用,#6。

同样,在AB下查找失败,接着在A和B下进行查找,依旧失败,于是接着到Y和Z下面查找,最终找到Y::h(int)和Z::h(double)两个结果。此时重载决议出现,发现后者才是更好的选择,于是最终选择Z::h(double)。

通过这个例子,相信大家已经具备分析Namespace Member Lookup名称查找流程的能力。

接着再补充几个需要注意的点。

第一点,被多次查找到的名称,但是只有一处定义时,并不会产生ambiguous。

1namespace X {
2    int a;
3}
4
5namespace A {
6    using namespace X;
7}
8
9namespace B {
10    using namespace X;
11}
12
13namespace AB {
14    using namespace A;
15    using namespace B;
16}
17
18AB::a++; // OK

这里,Name Lookup最终查找了两次X::a,但因为实际只存在一 处定义,于是一切正常。

第二点,当查找到多个定义时,若其中一个定义是类或枚举,而其他定义是变量或函数,且这些定义处于同一个命名空间下,则后者会隐藏前者,即后者会被选择,否则ambiguous。

可以通过以下例子来进行理解:

1// Example from ISO C++
2
3namespace A {
4    struct x {};
5    int x;
6    int y;
7}
8
9namespace B {
10    struct y {};
11}
12
13namespace C {
14    using namespace A;
15    using namespace B;
16    int i = C::x; // #1
17    int j = C::y; // #2
18}

先看#1,由于C中查找x失败,进而到A和B中进行查找,发现A中有两处定义。一处定义是类,另一处定义是变量,于是后者隐藏前者,最终选择int x;这处定义。

而对于#2,最终查找到了A::y和B::y两处定义,由于定义不在同一命名空间下,所以产生ambiguous。

到此,对Qualified Name Lookup的内容就基本覆盖了,下面进入Unqualified Name Lookup。

2.2

Unqualified Name Lookup

无修饰的名称查找则略显复杂,却会经常出现。

总的来说,也可分为两大类。

第一类为Usual Unqualified Lookup,即常规无修饰的名称查找,也就是普遍情况会触发的查询。

第二类为Argument Dependant Lookup,这就是鼎鼎大名的ADL,译为实参依赖查找。由其甚至发展出了一种定制点表示方式,称为ADL二段式,标准中的std::swap, std::begin, std::end, operator<<等等组件就是通过该法实现的。

但是本文并不会涉及定制点的讨论,因为这是我正在写的书中的某一节内容:)  内容其实非常之多之杂,本篇文章其实就是为该节扫除阅读障碍而特意写的,侧重点并不同。我额外写过一篇介绍定制点的文章【使用Concepts表示变化「定制点」】,各位可作开胃菜。

以下两节,分别讲解这两类名称查找。


2.2.1

Usual Unqualified Lookup

普通的函数调用都会触发Usual Unqualified Lookup,先看一个简单的例子:

1void f(char);
2
3void f(double);
4
5namespace mylib {
6    void f(int);
7
8    void h() {
9        f(3);   // #1
10        f(.0);  // #2
11    }
12}

对于#1和#2,Name Lookup会如何查找?最终会调用哪个重载函数?

实际上只会查找到f(int),#1直接调用,#2经过了隐式转换后调用。

为什么呢?记住一个准则,根据作用域查找顺序,当Name Lookup在某个作用域找到声明之后,便会停止查找。关于作用域的查找顺序,后面会介绍。

因此,当查找到f(int),它就不会再去全局查找其他声明。

注意:即使当前查找到的名称实际无法成功调用,也并不改变该准则。看如下例子:

1void f(int);
2
3namespace mylib {
4    void f(const char*);
5
6    void h() {
7        f(3);   // #1 Error
8    }
9}

此时,依旧只会查找到f(const char*),即使f(int)才是正确的选择。由于没有相应的隐式转换,该代码最终编译失败。

那么具体的作用域查找顺序是怎样的?请看下述例子:

1// Example from ISO C++
2
3namespace M {
4
5    class B { // S3
6    };
7}
8
9// S5
10namespace N {
11    // S4
12    class Y : public M::B {
13        // S2
14        class X {
15            // S1
16            int a[i]; // #1
17        };
18    };
19}

#1处使用了变量i,因此Name Lookup需要进行查找,那么查找顺序将从S1-S5。所以,只要在S1-S5的任何一处声明该变量,就可以被Name Lookup成功找到。

接着来看另一个查找规则,如果一个命名空间下的变量是在外部重新定义的,那么该定义中涉及的其他名称也会在对应的命名空间下查找。

简单的例子:

1// Example from ISO C++
2
3namespace N {
4    int i = 4;
5    extern int j;
6}
7
8int i = 2;
9int N::j = i; // j = 4

由于N::j在外部重新定义,因此变量i也会在命名空间N下进行查找,于是j的值为4。如果在N下没有查找到,才会查找到全局的定义,此时j的值为2。

而对于友元函数,查找规则又不相同,看如下例子:

1// Example from ISO C++
2
3struct A {
4    typedef int AT;
5    void f1(AT);
6    void f2(float);
7    template <class T> void f3();
8};
9
10struct B {
11    typedef char AT;
12    typedef float BT;
13    friend void A::f1(AT);   // #1
14    friend void A::f2(BT);   // #2
15    friend void A::f3<AT>(); // #3
16};

此处,#1的AT查找到的是A::AT,#2的BT查找到的是B::BT,而#3的AT查找到的是B::AT。

这是因为,当查找的名称并非模板参数时,首先会在友元函数的原有作用域进行查找,若没查找到,则再在当前作用域进行查找。对于模板参数,则直接在当前作用域进行查找。



2.2.2

Argument Dependant Lookup

终于到了著名的ADL,这是另一种无修饰名称查找方式。

什么是ADL?其实概念很简单,看如下示例。

namespace mylib {
    struct S {};
    void f(S);
}

int main() {
    mylib::S s;
    f(s); // #1,OK
}

按照Usual Unqualified Lookup是无法查找到#1处调用的声明的,此时编译器就要宣布放弃吗?并不会,而是再根据调用参数的作用域来进行查找。此处,变量s的类型为mylib::S,于是将在命名空间mylib下继续查找,最终成功找到声明。

由于这种方式是根据调用所依赖的参数进行名称查找的,因此称为实参依赖查找。

那么有没有办法阻止ADL呢?其实很简单。

namespace mylib {
    struct S {};
    void f(S) {
        std::cout << "f found by ADL\n";
    }
}

void f(mylib::S) {
    std::cout << "global f found by Usual Unqualified Lookup\n";
}

int main() {
    mylib::S s;
    (f)(s); // OK, calls global f
}

这里存在两个定义,本应产生歧义,但当你给调用名称加个括号,就可以阻止ADL,从而消除歧义。

实际上,ADL最初提出来是为了简化重载调用的,可以看如下例子。

int main() {
    // std::operator<<(std::ostream&, const char*)
    // found by ADL.
    std::cout << "dummy string\n";

    // same as above
    operator<<(std::cout"dummy string\n");
}

如果没有ADL,那么Unqualified Name Lookup是无法找到你所定义的重载操作符的,此时你只能写出完整命名空间,通过Qualified Name Lookup来查找到相关定义。

但这样代码写起来就会非常麻烦,因此,Unqualified Name Lookup新增加了这种ADL查找方式。

在编写一个数学库的时候,其中涉及大量的操作符重载,此时ADL就尤为重要,否则像是"+","=="这些操作符的调用都会非常麻烦。

后来ADL就被广泛运用,普通函数也支持此种查找方式,由此还诞生了一些奇技淫巧。

不过,在说此之前,让我们先熟悉一下常用的ADL规则,主要介绍四点。

第一点,当实参类型为函数时,ADL会根据该函数的参数及返回值所属作用域进行查找。

例子如下:

1namespace B {
2    struct R {};
3    void g(...) {
4        std::cout << "g found by ADL\n";
5    }
6}
7
8namespace A {
9    struct S {};
10    typedef B::R (*pf)(S); 
11
12    void f(pf) {
13        std::cout << "f found by ADL\n";
14    }
15}
16
17B::R bar(A::S) {
18    return {};
19}
20
21int main() {
22    A::pf fun = bar;
23    f(fun); // #1, OK
24    g(fun); // #2, OK
25}

#1和#2处,分别调用了两个函数,参数为另一个函数,根据该条规则,ADL得以查找到A::f()与B::g()。

第二点,若实参类型是一个类,那么ADL会从该类或其父类的最内层命名空间进行查找。

例子如下:

1namespace A {
2    // S2
3    struct Base {};
4}
5
6namespace M {
7    // S3 not works!
8    namespace B {
9        // S1
10        struct Derived : A::Base {};
11    }
12}
13
14int main() {
15    M::B::Derived d;
16    f(d); // #1
17}

此处,若要通过ADL找到f()的定义,可以将其声明放在S1或S2处。

第三点,若实参类型是一个类模板,那么ADL会在特化类的模板参数类型的命名空间下进行查找;若实参类型包含模板模板参数,那么ADL还会在模板模板参数类型的命名空间下查找。

例子如下:

1namespace C {
2    struct Final {};
3    void g(...) {
4        std::cout << "g found by ADL\n";
5    }
6};
7
8namespace B {
9    template <typename T>
10    struct Temtem {};
11
12    struct Bar {};
13    void f(...) {
14        std::cout << "f found by ADL\n";
15    }
16}
17
18namespace A {
19    template <typename T>
20    struct Foo {};
21}
22
23int main() {
24    // class template arguments
25    A::Foo<B::Bar> foo;
26    f(foo); // OK
27
28    // template template arguments
29    A::Foo<B::Temtem<C::Final>> a;
30    g(a); // OK
31
32}

代码一目了然,不多解释。

第四点,当使用别名时,ADL会无效,因为名称并不是一个函数调用。

看这个例子:

1// Example from ISO C++
2
3typedef int f;
4namespace N {
5    struct A {
6        friend void f(A&);
7        operator int();
8        void g(A a) {
9            int i = f(a); // #1
10        }
11    };
12}

注意#1处,并不会应用ADL来查询函数f,因为它其实是int,相当于调用int(a)。

说完了这四点规则,下面来稍微说点ADL二段式相关的内容。

看下面这个例子:

1namespace mylib {
2
3    struct S {};
4
5    void swap(S&, S&) {}
6
7    void play() {
8        using std::swap;
9
10        S s1, s2;
11        swap(s1, s2); // OK, found by Unqualified Name Lookup
12
13        int a1, a2;
14        swap(a1, a2); // OK, found by using declaration
15    }
16}

然后,你要在某个地方调用自己提供的这个定制函数,此处是play()当中。

但是调用的地方,你需要的swap()可能不只是定制函数,还包含标准中的版本。因此,为了保证调用形式的唯一性,调用被分成了两步。

  • 使用using declaration

  • 使用swap()

这样一来,不同的调用就可以被自动查找到对应的版本上。然而,只要稍微改变下调用形式,代码就会出错:

1namespace mylib {
2
3    struct S {};
4
5    void swap(S&, S&) {} // #1
6
7    void play() {
8        using namespace std;
9
10        S s1, s2;
11        swap(s1, s2); // OK, found by Unqualified Name Lookup
12
13        int a1, a2;
14        swap(a1, a2); // Error
15    }
16}

这里将using declaration写成了using directive,为什么就出错了?

其实,前者将std::swap()直接引入到了局部作用域,后者却将它引入了与最近的命名空间同等的作用域。根据前面讲过的准则:根据作用域查找顺序,当Name Lookup在某个作用域找到声明之后,便会停止查找。编译器查找到了#1处的定制函数,就立即停止,因此通过using directive引入的std::swap()实际上并没有被Name Lookup查找到。

这个细微的差异很难发现,标准在早期就犯了这个错误,因此STL中的许多实现存在不少问题,但由于ABI问题,又无法直接修复。这也是C++20引入CPOs的原因,STL2 Ranges的设计就采用了这种新的定制点方式,以避免这个问题。

在这之前,标准发明了另一种方式来解决这个问题,称为Hidden friends。

1namespace mylib {
2
3    struct S {
4        // Hidden friends
5        friend void swap(S&, S&) {}
6    };
7
8    void play() {
9        using namespace std;
10
11        S s1, s2;
12        swap(s1, s2); // OK, found by ADL
13
14        int a1, a2;
15        swap(a1, a2); // OK
16    }
17}

就是将定制函数定义为友元版本,放在类的内部。此时将不会再出现名称被隐藏的问题,这个函数只能被ADL找到。

Hidden friends的写法在STL中存在不少,想必大家曾经也不知不觉中使用过。

好,更多关于定制点的内容本文不再涉及,下面进行另一个内容。



2.3

Template Name Lookup

以上两节Name Lookup内容只涉及零星关于模板的名称查找,本节专门讲解这部分查找,它们还是属于前两节的归类。

首先要说的是对于typename的使用,在模板当中声明一些类型,有些地方并不假设其为类型,此时只有在前面添加typename,Name Lookup才视其为类型。

不过自C++20之后,需要添加typename的地方已越来越少,已专门写过文章,请参考:新简化!typename在C++20不再必要

其次,介绍一个非常重要的概念,「独立名称」与「依赖名称」。

什么意思呢?看一个例子。

// Example from ISO C++

int j;

template <class T>
struct X {

    void f(T t, int i, char* p) {
        t = i; // #1
        p = i; // #2
        p = j; // #3
    }
};

在Name Lookup阶段,模板还没有实例化,因此此时的模板参数都是未知的。对于依赖模板参数的名称,就称其为「依赖名称」,反之则为「独立名称」。

依赖名称,由于Name Lookup阶段还未知,因此对其查找和诊断要晚一个阶段,到模板实例化阶段。

独立名称,其有效性则在模板实例化之前,比如#2和#3,它们诊断就比较早。这样,一旦发现错误,就不必再继续向下编译,节省编译时间。

查找阶段的变化对Name Lookup存在影响,看如下代码:

1// Example from ISO C++
2
3void f(char);
4
5template <class T>
6void g(T t) {

7    f(1);    // non-dependent
8    f(T(1)); // dependent
9    f(t);    // dependent
10    dd++;    // non-dependent
11}
12
13enum E { e };
14void f(E);
15
16double dd;
17void h() {
18    g(e);   // calls f(char),f(E),f(E)
19    g('a'); // calls f(char),f(char),f(char)
20}

在h()里面有两处对于g()的调用,而g()是个函数模板,于是其中的名称查找时间并不相同。

f(char)是在g()之前定义的,而f(E)是在之后定义的,按照普通函数的Name Lookup,理应是找不到f(E)的定义的。

但因为存在独立名称和依赖名称,于是独立名称会先行查找,如f(1)和dd++,而变量dd也是在g()之后定义的,所以无法找到名称,dd++编译失败。对于依赖名称,如f(T(1))和f(t),它们则是在模板实例化之后才进行查找,因此可以查找到f(E)。

一言以蔽之,即使把依赖名称的定义放在调用函数之后,由于其查找实际上发生于实例化之后,故也可成功找到。

事实上,存在术语专门表示此种查找方式,称为Two-phase Name Lookup(二段名称查找),在下节还会进一步讨论。

接着来看一个关于类外模板定义的查找规则。

看如下代码:

1// Example from ISO C++
2
3template <class T>
4struct A {

5    struct B {};
6    typedef void C;
7    void f();
8    template<class U> void g(U);
9};
10
11template <class B>
12void A<B>:
:f() {
13    B b; // #1
14}
15
16template <class B>
17template <class C>
18void A<B>:
:g(C) {
19    B b; // #2
20    C c; // #3
21}

思考一下,#1,#2,#3分别分别查找到的是哪个名称?(这个代码只有clang支持)

实际上,#1和#2最终查找到的都是A::B,而#3却是模板参数C。

注意第16-17行出现的两个模板,它们并不能合并成一个,外层模板指的是类模板,而内层模板指的是函数模板。

因此,规则其实是:对于类外模板定义,如果成员不是类模板或函数模板,则类模板的成员名称会隐藏类外定义的模板参数;否则模板参数获胜。

而如果类模板位于一个命名空间之内,要在命名空间之外定义该类模板的成员,规则又不相同。

1// Example from ISO C++
2
3namespace N {
4    class C {};
5    template <class T> class B {
6        void f(T);
7    };
8}
9
10template <class C>
11void N:
:B<C>::f(C) {
12    C b; // #1
13}

此处,#1处的C查找到的是模板参数。

如果是继承,那么也会隐藏模板参数,代码如下:

1// Example from ISO C++
2
3struct A {
4    struct B {};
5    int a;
6    int Y;
7};
8
9template <class B, class a>
10struct X :
 A {
11    B b; // A::B
12    a b; // A::a, error, not a type name
13};

这里,最终查找的都是父类当中的名称,模板参数被隐藏。

然而,如果父类是个依赖名称,由于名称查找于模板实例化之前,所以父类当中的名称不会被考虑,代码如下:

1// Example from ISO C++
2
3typedef double A;
4template <class T>
5struct B {

6    typedef int A;
7};
8
9template <class T>
10struct X :
 B<T> {
11    A a; // double
12};

这里,最终X::A的类型为double,这是识别为独立名称并使用Unqualified Name Lookup查找到的。若要访问B::A,那么声明改为B<T>::A a;即可,这样一来就变为了依赖名称,且采用Qualified Name Lookup进行查找。

最后,说说多继承中包含依赖名称的规则。

还是看一个例子:

1// Example from ISO C++
2
3struct A {
4    int m;
5};
6
7struct B {
8    int m;
9};
10
11template <class T>
12struct C :
 A, T {
13    int f() return this->m; } // #1
14    int g() return m; }       // #2
15};
16
17template int C<B>::f(); // ambiguous!
18template int C<B>::g(); // OK

此处,多重继承包含依赖名称,名称查找方式并不相同。

对于#1,使用Qualified Name Lookup进行查找,查询发生于模板实例化,于是存在两个实例,出现ambiguous。

而对于#2,使用Unqualified Name Lookup进行查找,此时相当于是独立名称查找,查找到的只有A::m,所以不会出现错误。


2.4

Two-phase Name Lookup

因为模板才产生了独立名称与依赖名称的概念,依赖名称的查找需要等到模板实例化之后,这就是上节提到的二段名称查找。

依赖名称的存在导致Unqualified Name Lookup失效,此时,只有使用Qualified Name Lookup才能成功查找到其名称。

举个非常常见的例子:

1struct Base {
2    // non-dependent name
3    void f() {
4        std::cout << "Base class\n";
5    }
6};
7
8struct Derived : Base {
9    // non-dependent name
10    void h() {
11        std::cout << "Derived class\n";
12        f(); // OK
13    }
14};
15
16
17int main() {
18    Derived d;
19    d.h();
20}
21
22// Outputs:
23// Derived class
24// Base class

这里,f()和h()都是独立名称,因此能够通过Unqualified Name Lookup成功查找到名称,程序一切正常。

然而,把上述代码改成模板代码,情况就大不相同了。

1template <typename T>
2struct Base {
3    void f() {
4        std::cout << "Base class\n";
5    }
6};
7
8template <typename T>
9struct Derived : Base<T> {
10    void h() {
11        std::cout << "Derived class\n";
12        f(); // error: use of undeclared identifier 'f'
13    }
14};
15
16
17int main() {
18    Derived<int> d;
19    d.h();
20}

此时,代码已经无法编译通过了。

为什么呢?当编译器进行Name Lookup时,发现f()是一个独立名称,于是在模板定义之时就开始查找,然而很可惜,没有查找到任何结果,于是出现未定义的错误。

那么它为何不在基类当中查找呢?这是因为它的查找发生在第一阶段的Name Lookup,此时模板还没有实例化,编译器不能草率地在基类中查找,这可能导致查找到错误的名称。

更进一步的原因在于,模板类支持特化和偏特化,比如我们再添加这样的代码:

template <>
struct Base<char> {
    void f() {
        std::cout << "Base<char> class\n";
    }
};

若是草率地查找基类中的名称,那么查找到的将不是特化类当中的名称,查找出错。所以,在该阶段编译器不会在基类中查找名称。

那么,如何解决这个问题呢?

有两种办法,代码如下:

template <typename T>
struct Derived : Base<T> {
    void h() {
        std::cout << "Derived class\n";
        this->f();    // method 1
        Base<T>::f(); // method 2
    }
};

这样一来,编译器就能够成功查找到名称。

原理是这样的:
通过这两种方式,就可以告诉编译器该名称是依赖名称,必须等到模板实例化之后才能进行查找,届时将使用Qualified Name Lookup进行查找。这就是二段名称查找的必要性。

在调用类函数模板时依旧存在上述问题,一个典型的例子:

1struct S {
2    template <typename T>
3    static void f() {
4        std::cout << "f";
5    }
6};
7
8template <typename T>
9void g(T* p) {
10    T::f<void>();            // #1 error!
11    T::template f<void>();   // #2 OK
12}
13
14int main() {
15    S s;
16    g(&s);
17}

此处,由于f()是一个函数模板,#1的名称查找将以失败告终。

因为它是一个依赖名称,编译器只假设名称是一个标识符(比如变量名、成员函数名),并不会认为它们是类型或函数模板。

原因如前面所说,由于模板特化和偏特化的存在,草率地假设会导致名称查找错误。此时,就需要显式地告诉编译器它们是一个类型或是函数模板,告诉编译器如何解析。

这也是需要对类型使用typename的原因,而对于函数模板,则如#2那样添加一个template,这样就可以告诉编译器这是一个函数模板,<>当中的名称于是被解析为模板参数。

#1失败的原因也显而亦见,编译器将f()当成了成员函数,将<>解析为了比较符号,从而导致编译失败。

至此,关于Name Lookup的内容就全部结束了,下面进入重载决议流程的第二阶段——模板处理。


3

Function Templates Handling

Name Lookup查找的名称若是包含函数模板,那么下一步就需要将这些函数模板实例化。

模板实例化有两个步骤,第一个步骤是Template Argument Deduction,对模板参数进行推导;第二个步骤是Template Argument Substitution,使用推导出来的类型对模板参数进行替换。

下面两节分别介绍模板参数推导与替换的细节。


3.1

Template Argument Deduction

模板参数本身是抽象的类型,并不真正存在,因此需要根据调用的实参进行推导,从而将类型具体化。

TAD就描述了如何进行推导,规则是怎样的。

先来看一个简单的例子,感受一下基本规则。

1// Example from ISO C++
2
3template <class T, class U = double>
4void f(T t = 0, U u = 0) {

5}
6
7
8int main() {
9    f(1'c');         // f<int, char>(1, 'c');
10    f(1);              // f<int, double>(1, 0)
11    f();               // error: T cannot be duduced
12    f<int>();          // f<int, double>(0, 0)
13    f<intchar>();    // f<int, char>(0, 0)
14}

调用的实参是什么类型,模板参数就自动推导为所调用的类型。如果模板参数具有默认实参,那么可以从其推导,也可以显式指定模板参数,但若没有任何参数,则不具备推导上下文,推导失败。

这里存在令许多人都比较迷惑的一点,有些时候推导的参数并不与调用实参相同。

比如:

1template <class T>
2void f(T t) {
}
3
4int main() {
5    const int i = 1;
6    f(i); // T deduced as int, f<int>(int)
7}

这里实参类型是const int,但最后推导的却是int。

这是因为,推导之时,所有的top-level修饰符都会被忽略,此处的const为top-level const,于是const被丢弃。本质上,其实是因为传递过去的参数变量实际上是新创建的拷贝变量,原有的修饰符不应该影响拷贝之后的变量。

那么,此时如何让编译器推导出你想要的类型呢?

第一种办法,显示指定模板参数类型。

f<const int>(i); // OK, f<const int>(const int)

第二种办法,将模板参数声明改为引用或指针类型。

template <class T>
void f(T& t) {
 }

f(i); // OK, f<const int>(const int&)

为什么改为引用或指针就可以推导出带const的类型呢?

这是因为此时变量不再是拷贝的,它们访问的依旧是实参的内存区域,如果忽略掉const,它们将能够修改const变量,这会导致语义错误。

因此,如果你写出这样的代码,推导将会出错:

template <class T>
void f(T t1, T* t2) {
 }

int main() {
    const int i = 1;
    f(i, &i); // Error, T deduced as both int and const int
}

因为根据第一个实参,T被推导为int,而根据第二个实参,T又被推导为const int,于是编译失败。

若你显示指定参数,那么将可以消除此错误,代码如下:

template <class T>
void f(T t1, T* t2) {
 }

int main() {
    const int i = 1;
    f<const int>(i, &i); // OK, T has const int type
}

此时,T的类型只为const int,冲突消除,于是编译成功。

下面介绍可变参数模板的推导规则。

看如下例子:

template <class T, class... Ts>
void f(T, Ts...) {

}

template <class T, class... Ts>
void g(Ts..., T) {

}


int main() {
    f(1'c'.0); // f<int, char, double>(int, char, double)
    //g(1, 'c', .0); // error, Ts is not deduced
}

此处规则为:参数包必须放到参数定义列表的最末尾,TAD才会进行推导。

但若是参数包作为类模板参数出现,则不必遵循此顺序也可以正常推导。

template <class...>
struct Tuple {
};

template <class T, class... Ts>
void g(Tuple<Ts...>, T) {

}

g(Tuple<int>{}, .0); // OK, g<int, double>(Tuple<int>, double)

如果函数参数是一个派生类,其继承自类模板,类模板又采用递归继承,则推导实参为其直接基类的模板参数。示例如下:

1// Example from ISO C++
2
3template <class...> struct X;
4template <> struct X<> {};
5template <class T, class... Ts>
6struct X<T, Ts...> :
 X<Ts...> {};
7struct D : X<int> {};
8
9template <class... Ts>
10int f(const X<Ts...>&) {

11    return {};
12}
13
14
15int main() {
16    int x = f(D()); // deduced as f<int>, not f<>
17}

这里,最终推导出来的类型为f<int>,而非f<>。

下面介绍forwarding reference的推导规则。

对于forwarding reference,如果实参为左值,则模板参数推导为左值引用。看一个不错的例子:

1// Example from ISO C++
2
3template <class T> int f(T&& t);
4template <class T> int g(const T&&);
5
6int main() {
7    int i = 1;
8    //int n1 = f(i);  // #1, f<int&>(int&)
9    //int n2 = f(0);  // #2, f<int>(int&&);
10    int n3 = g(i);    // #3, g<int>(const int&&)
11                      // error: bind an rvalue reference to an lvalue
12}

此处,f()的参数为forwarding reference,g()的参数为右值引用。

因此,当实参为左值时,f()的模板参数被推导为int&,g()的模板参数则被推导为int。而左值无法绑定到右值,于是编译出错。

再来看另一个例子:

1// Example from ISO C++
2
3template <class T>
4struct A {

5    template <class U>
6    A(T&& t, U&& u, int*);
 // #1
7
8    A(T&&, int*); // #2
9};
10
11template <class T> A(T&&, int*) -> A<T>; // #3
12
13int main() {
14    int i;
15    int *ip;
16    A a{i, 0, ip}; // error: cannot deduce from #1
17    A b{0, i, ip}; // use #1 to deduce A<int> and #1 to initialize
18    A c{i, ip};    // use #3 to deduce A<int&> and #2 to initialize 
19}

对于#1,U&&为forwarding reference,而T&&并不是,因为它不是函数模板参数。

于是,当使用#1初始化对象时,若第一个实参为左值,则T&&被推导为右值引用。由于左值无法绑定到右值,遂编译出错。但是第二个参数可以为左值,U会被推导为左值引用,次再施加引用折叠,最终依旧为左值引用,可以接收左值实参。

若要使类模板参数也变为forwarding reference,可以使用CTAD,如#3所示。此时,T&&为forwarding reference,第一个实参为左值时,就可以正常触发引用折叠。


3.2

Template Argument Substitution

TAD告诉编译器如何推导模板参数类型,紧随其后的就是使用推导出来的类型替换模板参数,将模板实例化。

这两个步骤密不可分,故在上节当中其实已经涉及了部分本节内容,这里进一步扩展。

这里只讲三个重点。

第一点,模板参数替换存在失败的可能性。

模板替换并不总是会成功的,比如:

struct A { typedef int B; };

template <class T> void g(typename T::B*) // #1
template <class T> void g(T);             // #2

g<int>(0); // calls #2

Name Lookup查找到了#1和#2的两个名称,然后对它们进行模板参数替换。然而,对于#1的参数替换并不能成功,因为int不存在成员类型B,此时模板参数替换失败。

但是编译器并不会进行报错,只是将其从重载集中移除。这个特性就是广为熟知的SFINAE(Substitution Failure Is Not An Error),后来大家发现该特性可以进一步利用起来,为模板施加约束。

比如根据该原理可以实现一个enable_if工具,用来约束模板。

1namespace mylib {
2
3    template <booltypename = void>
4    struct enable_if {};
5
6    template <typename T>
7    struct enable_if<true, T> {
8        using type = T;
9    };
10
11    template <bool C, typename T = void>
12    using enable_if_t = typename enable_if<C, T>::type;
13
14// namespace mylib
15
16
17template <typename T, mylib::enable_if_t<std::same_as<T, double>, bool> = true>
18void f() {
19    std::cout << "A\n";
20}
21
22template <typename T, mylib::enable_if_t<std::same_as<T, int>, bool> = true>
23void f() {
24    std::cout << "int\n"
25}
26
27int main() {
28    f<double>();  // calls #1
29    f<int>();     // calls #2
30}

enable_if早已加入了标准,这个的工具的原理就是利用模板替换失败的特性,将不符合条件的函数从重载集移除,从而实现正确的逻辑分派。

SFINAE并非是专门针对类型约束而创造出来的,使用起来比较复杂,并不直观,已被C++20的Concepts取代。

第二点,关于trailing return type与normal return type的本质区别。

这二者的区别本质上就是Name Lookup的区别:normal return type是按照从左到右的词法顺序进行查找并替换的,而trailing return type因为存在占位符,打乱了常规的词法顺序,这使得它们存在一些细微的差异。

比如一个简单的例子:

namespace N {
    using X = int;
    X f();
}

N::X N::f();      // normal return type
auto N::f() -> X; // trailing return type

根据前面讲述的Qualified Name Lookup规则,normal return type的返回值必须使用N::X,否则将在全局查找。而trailing return type由于词法顺序不同,可以省略这个命名空间。

当然trailing return type也并非总是比normal return type使用起来更好,看如下例子:

// Example from ISO C++

template <class T> struct A { using X = typename T::X; };

// normal return type
template <class T> typename T::X f(typename A<T>::X);
template <class T> void f(...);

// trailing return type
template <class T> auto g(typename A<T>::X) -> typename T::X;
template <class T> void g(...);


int main() {
    f<int>(0); // #1 OK
    g<int>(0); // #2 Error
}

通常来说,这两种返回类型只是形式上的差异,是可以等价使用的,但此处却有着细微而本质的区别。

#1都能成功调用,为什么改了个返回形式,#2就编译出错了?

这是因为:

在模板参数替换的时候,normal return type遵循从左向右的词法顺序,当它尝试替换T::X,发现实参类型int并没有成员X,于是依据SFINAE,该名称被舍弃。然后,编译器发现通用版本的名称可以成功替换,于是编译成功。

而#2在模板参数替换的时候,首先跳过auto占位符,开始替换函数参数。当它尝试使用int替换A<T>::X的时候,发现无法替换。但是A<T>::X并不会触发SFINAE,而是产生hard error,于是编译失败。

简单来说,此处,normal return type在触发hard error之前就触发了SFINAE,所以可以成功编译。

第三点,forwarding reference的模板参数替换要点。

看一个上周我在群内分享的一个例子:

1template <class T>
2struct S {

3    static void g(T&& t) {}
4    static void g(const T& t) {}
5};
6
7
8template <class T> void f(T&& t) {}
9template <class T> void f(const T& t) {}
10
11int main() {
12    int i = 1;
13
14    f<int&>(i);    // #1 OK
15    S<int&>::g(i); // #2 Error
16}

为什么#1可以通过编译,而#2却不可以呢?

首先来分析#2,编译失败其实显而亦见。

由于调用显式指定了模板参数,所以其实并没有参数推导,int&用于替换模板参数。对于T&&,替换为int&&&,1折叠后变为int&;对于const T&,替换为const (int&)&,等价于int& const&,而C++不支持top-level reference,int& const声明本身就是非法的,所以const被抛弃,剩下int&&,折叠为int&。

于是重复定义,编译错误。

而对于#1,它包含两个函数模板。若是同时替换,那么它们自然也会编译失败。但是,根据4.3将要介绍的规则:如果都是函数模板,那么更特殊的函数模板胜出。const T&比T&&更加特殊,因此f(T&&)最终被移除,只存在f(const T&)替换之后的函数,没有错误也是理所当然。


4

Overload Resolution

经过Name Lookup和Template Handling两个阶段,编译器搜索到了所有相关重载函数名称,这些函数就称为candidate functions(候选函数)。

前文提到过,Name Lookup仅仅只是进行名称查找,并不会检查这些函数的有效性。因此,candidate functions只是「一级筛选」的结果。

重载决议,就是要在一级筛选的结果之上,选择出最佳的那个匹配函数。

比如:参数个数是否匹配?实参和形参的类型是否相同?类型是否可以转换?这些都属于筛选准则。

因此,这一步也可以称之为「二级筛选」。根据筛选准则,剔除掉无效函数,剩下的结果就称为viable functions(可行函数)。

存在viable functions,就表示已经找到可以调用的声明了。但是,这个函数可能存在多个可用版本,此时,就需要进行「终极筛选」,选出最佳的匹配函数,即best viable function。终极筛选在标准中也称为Tiebreakers(决胜局)。

终极筛选之后,如果只会留下一个函数,这个函数就是最终被调用的函数,重载决议成功;否则的话重载决议失败,程序错误。

接下来,将从一级筛选开始,以一个完整的例子,为大家串起整个流程,顺便加深对前面各节内容的理解。


4.1

Candidate functions

一级筛选的结果是由Name Lookup查找出来的,包含成员和非成员函数。

对于成员函数,它的第一个参数是一个额外的隐式对象参数,一般来说就是this指针。

对于静态成员函数,大家都知道它没有this指针,然而事实上它也存在一个额外的隐式对象参数。究其原因,就是为了重载决议可以正常运行。

可以看如下例子来进行理解。

struct S {
    void f(long) {
        std::cout << "member version\n";
    }
    static void f(int) {
        std::cout << "static member version\n";
    }
};

int main() {
    S s;
    s.f(1); // calls static member version
}

此时,这两个成员函数实际上为:

f(S&, long); // member version
f(implicit object parameter, int); // static member version

如果静态成员函数没有这个额外的隐式对象,那么其一,将可以定义一个参数完全相同的非静态成员;其二,重载决议将无法选择最佳的那个匹配函数(此处long需要转换,不是最佳匹配函数)。

静态成员的这个隐式对象参数被定义为可以匹配任何参数,仅仅用于在重载决议阶段保证操作的一致性。

对于非成员函数,则可以直接通过Unqualified Name Lookup和Qualified Name Lookup找到。同时,模板实例化后也会产生成员或非成员函数, 除了有些因为模板替换失败被移除,剩下的名称共同组成了candidate functions。


4.2

Viable functions

二级筛选要在candidate functions的基础上,通过一些筛选准则来剔除不符合要求的函数,留下的就是viable functions。

筛选准则主要看两个方面,一个是看参数匹配程度,另一个是看约束满足程度。

约束满足就是看是否满足Concepts,这是C++20之后新增的一项检查。

具体的检查流程如下所述。

第一步,看参数个数是否匹配。

假设实参个数为N,形参个数为M,则存在三种比较情况。

如果N等于M,这种属于个数完全匹配,此类函数将被留下。

如果N小于M,此时就需要看candidate functions是否存在默认参数,如果不存在,此类函数被淘汰。

如果N大于M,此时就需要看candidate functions是否存在可变参数,如果不存在,此类函数被淘汰。

第二步,是否满足约束。

第一轮淘汰过后,剩下的函数如果存在Concepts约束,这些约束应该被满足。如果不满足,此类函数被淘汰。

第三步,看参数是否匹配。

实参类型可能和candidate functions完全匹配,也可能不完全匹配,此时这些参数需要存在隐式转换序列。可以是标准转换,也可以是用户自定义转换,也可以是省略操作符转换。

这三步过后,留下的函数就称为viable functions,它们都有望成为最佳匹配函数。


4.3

Tiebreakers

终极筛选也称为决胜局,重载决议的最后一步,将进行更加严格的匹配。

第一,它会看参数的匹配程度。

如前所述,实参类型与viable functions可能完全匹配,也可能需要转换,此时就存在更优的匹配选项。

C++的类型转换有三种形式,标准转换、自定义转换和省略操作符转换。

标准转换比自定义转换更好,自定义转换比省略操作符转换更好。

对于标准转换,可以看下表。

它们的匹配优先级也是自上往下的,即Exact Match比Promotion更好,Promotion比Conversion更好,可以理解为完全匹配、次级匹配和低级匹配。

看一个简单的例子:

void f(int);
void f(char);

int main() {
    f(1); // f(int) wins
}

此时,viable functions就有两个。而实参类型为int,f(int)不需要转换,而f(char)需要将int转换为char,因此前者胜出。

如果实参类型为double,由于double转换为int和char属于相同等级,因此谁也不比谁好,产生ambiguous。

再来看一个例子:

1// Example from IOS C++
2
3void f(const int*, short);
4void f(int*, int);
5
6int main() {
7    int i;
8    short s = 0;
9    f(&i, s);    // #1 Error, ambiguous
10    f(&i, 1L);   // #2 OK, f(int*, int) wins
11    f(&i, 'c');  // #3 OK, f(int*, int) wins
12}

这里存在两个viable functions,存在一场决胜局。

#1处调用,第一个实参类型为int*,第二个实参类型为short。对于前者来说,f(int*, int)是更好的选择,而对于后者来说,f(const int*, short)才是更好的选择。此时将难分胜负,因此产生ambiguous。

#2处调用,第二个实参类型为long,打成平局,但f(int*, int)在第一个实参匹配中胜出,因此最终被调用。

#3处调用,第二个实参类型为char,char转换为int比转换为short更好,因此f(int*, int)依旧胜出。

对于派生类,则子类向直接基类转换是更好的选择。

struct A {};
struct B : A {};
struct C : B {};

void f(A*) {
    std::cout << "A*";
}
void f(B*) {
    std::cout << "B*";
}

int main() {
    C* pc;
    f(pc); // f(B*) wins
}

这里,C向B转换,比向A转换更好,所以f(B*)胜出。

最后再来看一个例子,包含三种形式的转换。

1struct A {
2    operator int();
3};
4
5void f(A) {
6    std::cout << "standard conversion wins\n";
7}
8
9void f(int) {
10    std::cout << "user defined conversion wins\n";
11}
12
13void f(...) {
14    std::cout << "ellipsis conversion wins\n";
15}
16
17int main() {
18    A a;
19    f(a);
20}

最终匹配的优先级是从上往下的,标准转换是最优选择,自定义转换次之,省略操作符转换最差。

第二,如果同时出现模板函数和非模板函数,则非模板函数胜出。

例子如下:

1void f(int) {
2    std::cout << "f(int) wins\n";
3}
4
5template <class T>
6void f(T) {

7    std::cout << "function templates wins\n";
8}
9
10int main() {
11    f(1); // calls f(int)
12}

但若是非模板函数还需要参数转换,那么模板函数将胜出,因为模板函数可以完全匹配。

第三,如果都是函数模板,那么更特殊的模板函数胜出。

什么是更特殊的函数模板?其实指的就是更加具体的函数模板。越抽象的模板参数越通用,越具体的越特殊。举个例子,语言、汉语和普通话,语言可以表示汉语,汉语可以表示普通话,因此语言比汉语更抽象,汉语比普通话更抽象,普通话比汉语更特殊,汉语又比语言更特殊。

越抽象越通用,越具体越精确,越精确就越可能是实际的调用需求,因此更特殊的函数模板胜出。

比如在3.2节第三点提到的例子,const T&为何比T&&更特殊呢?这是因为,若形参类型为T,实参类型为const U,则T可以推导为const U,前者就可以表示后者。若是反过来,形参类型为const T,实参类型为U,此时就无法推导。因此const T&要更加特殊。

第四,如果都函数都带有约束,那么满足更多约束的获胜。

例子如下:

1// Example from ISO C++
2
3template<typename T> concept C1 = requires(T t) { --t; };
4template<typename T> concept C2 = C1<T> && requires(T t) { *t; };      
5
6template<C1 T> void f(T);          // #1
7template<C2 T> void f(T);          // #2
8template<class T> void g(T);       // #3
9template<C1 T> void g(T);          // #4
10
11int main() {
12    f(0);       // selects #1
13    f((int*)0); // selects #2
14    g(true);    // selects #3 because C1<bool> is not satisfied         
15    g(0);       // selects #4
16}

第五,如果一个是模板构造函数,一个是非模板构造函数,那么非模板版本获胜。

例子如下:

1template <class T>
2struct S {

3    S(T, T, int);                    // #1
4    template <class U> S(T, U, int); // #2
5};
6
7int main() {
8    // selects #1, generated from non-template constructor
9    S s(1, 2, 3);
10}

究其原因,还是非模板构造函数更加特殊。

以上所列的规则都是比较常用的规则,更多规则大家可以参考cppreference。

通过这些规则,就可以找出最佳匹配的那个函数。如果最后只剩下一个viable function,那么它就是best viable function。如果依旧存在多个函数,那么ambiguous。

大家也许还不是特别清楚上述流程,那么接下来,我将以一个完整的例子来串起整个流程。


5

走一遍完整的流程

一个完整的示例,代码如下:

1namespace N {
2    struct Base {};
3    struct Derived : Base {};
4    void foo(Base* s, char);                // #1
5    void foo(Derived* s, int, bool = true)// #2
6    void foo(Derived* s, short);            // #3
7}
8
9struct S {
10    N::Derived* d;
11    S(N::Derived* deri) : d{deri} {}
12    operator N::Derived*() const { return d; }
13};
14
15void foo(S);                                // #4
16template <class T> void foo(T* t, int c);   // #5
17void foo(...);                              // #6
18
19int main() {
20    N::Derived d;
21    foo(&d, 'c'); // which one will be matched?
22}

最终哪个函数能够胜出,让我们来逐步分析。

第一步,编译器会进行Name Lookup,查找名称。

可以看到,代码中一共有七个重载函数,但是只会被查找到六个。因为foo(&d, 'c')调用时没有添加任何作用域限定符,所以编译器不会使用Qualified Name Lookup进行查找。

在查找到的六个名称当中,其中有三个是通过ADL查找到的,还有三个是通过Usual Unqualified Lookup查找到的。

第二步,编译器发现其中包含函数模板,于是进行Template Handling。

首先,编译器根据调用实参,通过Template Argument Deduction推导出实参类型,实参类型如上图A1和A2所示。

接着,编译器分析函数模板中包含的模板参数,其中P1为模板参数。于是,需要进行Template Argument Substitution,将P1替换为实参类型,T被替换为N::Derived。

如果模板替换失败,根据SFINAE,这些函数模板将被移除。

最后,替换完成的函数就和其他的函数一样,它们共同构成了candidate functions,一级筛选到此结束。

第三步,编译器正式进入重载决议阶段,比较candidate functions,选择最佳匹配函数。

首先,进行二级筛选,筛选掉明显不符合的候选者。

调用参数为2个,而第4个候选者只有1个参数,被踢出局;第2个候选者具有3个参数,但是它的第三个参数设有缺省值,因此依旧被留下。

此外,这些候选函数也没有任何约束,因此在这一局只剔除了一个函数,剩下的函数就称为viable functions。

viable functions之所以称为可行函数,就是因为它们其实都可以作为最终的调用函数,只是谁更好而已。

其次,进行终级筛选,即决胜局。在此阶段,需要比较参数的匹配程序。

对于派生类,完全匹配比直接基类好,直接基类比间接基类好,因此第1个候选者被踢出局。

第6个候选者为省略操作符,它将永远是最后才会被考虑的对象,也是最差的匹配对象。于是,2、3、5进行决战。

它们的第一个参数都是完全匹配,因此看第二个参数。char转换为int比short更好,因此第3个候选者被踢出局。

剩下第2、5个候选者,第2个候选者虽然有三个参数,但因为有缺省值,所以并不影响,也不会被作为决胜因素,所以第5个候选者暂时还无法取胜。

然后,编译器发现第2个候选者为非模板函数,第5个候选者为模板函数。模板函数和非模板函数同时出现时,非模板函数胜出,于是第5个候选者被踢出局。

最后,只留下了第2个候选者,它成为了best viable function,胜利者。

但是,大家可别以为竞选出胜利者就一定可以调用成功。事实上,它们只针对的是声明,如果函数没有定义,依旧会编译失败。


6

Name Mangling

重载函数的名称实际上是通过Name Mangling生成的新名称,大家如果去看编译后的汇编代码就能够看到这些名称。

像是Compiler Explorer,它实际上是为了让你看着方便,显示的是优化后的名称,去掉勾选Demangle identifiers就能够看到实际函数名称。

那么接下来,就来介绍一下Name Mangling的实际手法。标准并没有规定具体实现方式,因此编译器的实际可能不尽相同,下面以gcc为例进行分析。

下面是使用gcc编译过后的一个例子。

如图所示,编译器为每个重载函数都生成一个新名称,新名称是绝对唯一的。

基本的规则如下图所示。

除了基本规则,还有很多比较复杂的规则,这里再举几个常见的名称。

namespace myNS {
    struct myClass {
        // mangles as _ZN4myNS7myClass6myFuncEv
        void myFunc() {}
    };

    // mangles as _ZN4myNS3fooENS_7myClassE
    void foo(myClass) {}
}

template <class T>
void foo(T, int) {
}

// mangles as _Z3fooIfEvT_i
template void foo(float, int);

规则不难理解,大家可以自己找下规律。其中,I/E中间的是模板参数,T_表示第1个模板参数。

由于C语言没有重载函数,所以它也没有Mangling操作。如果你使用混合编译,即某些文件使用C编译,某些文件使用C++编译,就会产生链接错误。

举个例子,有如下代码:

1// lib.cpp
2int myFunc(int a, int b) {
3    return a + b;
4}
5
6// main.cpp
7#include <iostream>
8
9int myFunc(int a, int b);
10
11int main() {
12    std::cout << "The answer is " << myFunc(411);
13}

使用C++编译并链接,结果如下图。

编译器在编译main.cpp时,发现其中存在一个未解析的引用int myFunc(int a, int b);,于是在链接文件lib.cpp中找到了该定义。之所以能够找到该定义,是因为这两个文件都是使用C++编译的,编译时main.cpp中的声明经过Name Mangling变为_Z6myFuncii,实际查找的并不是myFunc这个名称。而lib.cpp中的名称也经过了Name Mangling,因此能够链接成功。

但是,如果其中一个文件使用C进行编译,另一个使用C++进行编译,链接时就会出现问题。如下图所示。

由于main.cpp是用C++编译的,因此实际查找的名称为_Z6myFuncii。而lib.cpp是用C编译的,并没有经过Name Mangling,它的名称依旧为myFunc,因此出现未定义的引用错误。

常用解法是使用一个extern关键字,告诉编译器这个函数来自C,不要进行Name Mangling。

1// main.cpp
2extern "C" int myFunc(int a, int b);
3
4int main() {
5    std::cout << "The answer is " << myFunc(411);
6}

如此一来,就可以解决这个问题。

通常来说,可以使用预处理条件语句,分别提供C和C++版本的代码,这样使用任何方式就都可以编译成功。


7

总结

本篇的内容相当之多,完整地包含了重载决议的整个流程。

能读到这里,相信大家已经收获满满,对整个流程已经有了清晰的认识。

因此,这个总结算是一个课后作业,请大家对照下图回想本篇内容,如果对所有概念都十分清楚,那么恭喜你已经理解了重载决议!

最后,写作不易,还请大家多多点赞、在看、转发,你的支持就是我不断创作的动力。


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存