浅谈函数重载规则
StudyingFather
2020-09-04 23:18:24
## 引子
昨天在某个群看到了这样一段代码:
```cpp
#include <iostream>
#include <string>
using namespace std;
void foo(string str) { cout << "string" << endl; }
void foo(bool b) { cout << "bool" << endl; }
void foo(char c) { cout << "char" << endl; }
int main() {
foo("hello, world");
return 0;
}
```
现在的问题是,这段程序的输出是什么?或者说,`foo("hello, world");` 调用的究竟是哪个函数?
要解决这个问题,我们就要详细讨论一下函数重载的规则。
注释:
1. 以下内容均在 C++11 标准下讨论。
2. 不讨论模板,列表初始化等特性。
## 函数?
在解决函数重载的问题前,我们先讨论下函数本身。
简单来说,一个函数需要包含如下三个要素:
1. 函数名;
2. 形参列表(这个函数能接纳几个参数);
3. 返回值类型。
而我们常说的函数重载,就是允许两个拥有相同函数名的函数,在形参列表上不同。
因为返回值类型与本文要讨论的主题(函数重载)关系并不密切,因此我们接下来重点讨论形参列表的那些事。
### 成员函数与非成员函数
首先,根据一个函数是否属于一个类,我们将函数分为成员函数和非成员函数。
而非静态成员函数的形参列表,会在开头多一个指向当前对象的指针作为第一个形参。
(静态成员函数的形参列表也会在开头多一个形参,不过该形参可以匹配任何类型,这里不作深入讨论)
### 默认参数
有些参数可以具有默认值,为了避免歧义,这些具有默认值的参数必须置于形参列表的最末尾。
```cpp
#include <iostream>
using namespace std;
int dist(int x1, int y1, int x2 = 0, int y2 = 0) {
return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);
}
int main() {
cout << dist(6, 5) << endl; // 等价于 dist(6,5,0,0)
return 0;
}
```
### 变长实参
在 `scanf` 和 `printf` 中,除了第一个参数是确定的 `const char*` 类型外,剩下的参数的数量,每个参数的类型,都是不确定的。
在 C 语言的时代,这个问题该怎么解决?
答案是使用变长实参。
```cpp
int scanf( const char* format, ... );
```
这就是 `scanf` 的函数声明。
C 中要求省略号前必须有一个具名形参,而 C++ 中无此要求(事实上如果没有具名形参的话,将无法访问传递给这种函数的实参)。
因为变长实参的相关实现细节与本文主题没有太大关联,这里不再展开,感兴趣的读者可以参考 [可变参数入门 - wenge 的博客](https://www.luogu.com.cn/blog/wenge/variable-arguments)。
## 可行函数
当我们要调用函数 `f` 时,相同名字的函数定义可能会非常多,这里面哪些函数是符合要求的呢?
先考虑形参和实参的个数。我们设形参有 $N$ 个,实参有 $M$ 个。
1. 若 $N=M$,则符合要求。
2. 若 $N<M$,且存在一个省略号形参,则符合要求。
3. 若 $N>M$,且自第 $M+1$ 个形参起均有默认值,则符合要求。
接下来考虑实参和形参间的类型转换。对于每个实参,必须存在一个隐式转换序列,能将其转换为对应的形参类型。
最后考虑引用类型。右值实参不能对应一个左值非 `const` 引用形参,而左值实参也不能对应一个右值引用的形参。
```cpp
#include <iostream>
using namespace std;
void fun(string &str) { cout << "string&" << endl; }
void fun(string str) { cout << "string" << endl; }
int main() {
fun("Hello, world!");
// 这里传入一个右值实参,而第一个函数形参为左值非 const 引用,故第一个函数不可行
return 0;
}
```
## 最佳函数
选出了可行函数之后,如果只有一个可行函数,调用这个函数就行了。
如果有多个呢?我们需要从中找到一个最贴近的函数声明。
简单来说,对于两个可行函数 `f1` 和 `f2`,我们需将第 $i$ 实参和第 $i$ 形参之间的隐式转换序列进行比较。`f1` 优于 `f2`,当且仅当 `f1` 的所有实参的隐式转换序列均不劣于 `f2`,且存在一个 `f1` 实参的隐式转换序列优于 `f2` 相应实参的转换序列。
将所有函数比较过后,若存在一个函数优于其他所有函数,则最终将调用该函数,否则将找不到合适的函数调用,编译失败。
## 隐式转换序列比较
简单来说,隐式转换序列可以分为三类:标准转换序列,用户定义转换序列,省略号转换序列。
标准转换序列由下列部分按顺序构成:
- 下列三者中的最多一个:左值转右值,数组转指针,函数转指针。
- 下列两者中的最多一个:数值提升,数值转换。
- 最多一个限定转换。
而用户定义转换序列,简单来说就是利用构造函数完成转换。
例如之所以能向一个接受 `std::string` 的函数传入一个类型为 `const char*` 的变量,原因在于存在构造函数 `std::string(const CharT* s,const Allocator& alloc = Allocator());`。
省略号转换序列,自然是利用省略号形参完成的转换。
C++ 标准指出,在比较各实参-形参的转换序列时,标准转换序列总是优于用户定义的转换序列,而用户定义的转换序列总是优于省略号转换序列。
到这里开头的问题就能够很好解释了。虽然 `std::string` 看上去与实参类型更像,但 `const char*` 到 `std::string` 的转换属于用户定义的转换序列(由构造函数定义转换规则)。
而 `const char*` 到 `bool` 的转换属于标准转换序列(可以通过数值转换将指针转换为布尔值)。
因此经过比较,调用形参类型为 `bool` 的函数更优。
如果两个序列都是标准转换序列呢?这时候我们需要比较两个标准转换序列的等级。
我们将转换操作分为三个等级:
1. 完全一致:不转换,左值转右值,限定性转换。
2. 提升:整型提升,浮点提升。
3. 转换:整型转换,浮点转换,浮点转整型,指针转换,布尔转换。
一个转换序列的等级,等于其最劣操作的等级。
接下来我们就可以讨论两个标准转换序列 S1 和 S2 谁更优了:
1. 若 S1 是 S2 的子序列,则 S1 更优(S1 与 S2 相比少了冗余操作)。
2. 否则,若 S1 等级优于 S2,则 S1 更优。
3. 否则,若存在一个引用形参,则右值到右值引用的转换优于右值到 `const` 限定左值引用的转换。
4. 否则,比较两个**引用形参**被 `const` 限定的数目,`const` 限定少的更优。
对于上面比较规则的 3 和 4,下面给出两个例子(来自 [cppreference](https://zh.cppreference.com/w/cpp/language/overload_resolution#.E9.9A.90.E5.BC.8F.E8.BD.AC.E6.8D.A2.E5.BA.8F.E5.88.97.E7.9A.84.E6.8E.92.E8.A1.8C))
```cpp
int i;
int f1();
int g(const int&);
int g(const int&&);
int j = g(i); // 传入左值,只能调用 g(const int&)
int k = g(f1()); // 传入右值,因为第一个函数的左值引用有 const 限定,故两者皆可行
// 但转换为右值引用更优,故调用 g(const int&&)
```
```cpp
int f(const int &);
int f(int &);
int g(const int &);
int g(int);
int i;
int j = f(i); // 两个 f 函数均接受引用,但 f(int&) 少一个 const 限定,因而更优
int k = g(i); // 左值 i -> const int& 排行为准确匹配
// 左值 i -> 右值 int 排行为准确匹配
// 两者等级相同,无法比出最优函数
// 无法编译
```
## 后记
事实上函数重载需要考虑的情况还有不少(比如 C++11 中的列表初始化等新特性),但是因为上面的内容已经足够解释开头的问题了,就先写到这里吧。
剩下的之后看心情会继续填坑。
## Reference
- [重载决议 - cppreference](https://zh.cppreference.com/w/cpp/language/overload_resolution)