浅谈函数重载规则

引子

昨天在某个群看到了这样一段代码:

#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. 返回值类型。

而我们常说的函数重载,就是允许两个拥有相同函数名的函数,在形参列表上不同。

因为返回值类型与本文要讨论的主题(函数重载)关系并不密切,因此我们接下来重点讨论形参列表的那些事。

成员函数与非成员函数

首先,根据一个函数是否属于一个类,我们将函数分为成员函数和非成员函数。

而非静态成员函数的形参列表,会在开头多一个指向当前对象的指针作为第一个形参。

(静态成员函数的形参列表也会在开头多一个形参,不过该形参可以匹配任何类型,这里不作深入讨论)

默认参数

有些参数可以具有默认值,为了避免歧义,这些具有默认值的参数必须置于形参列表的最末尾。

#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;
}

变长实参

scanfprintf 中,除了第一个参数是确定的 const char* 类型外,剩下的参数的数量,每个参数的类型,都是不确定的。

在 C 语言的时代,这个问题该怎么解决?

答案是使用变长实参。

int scanf( const char* format, ... );

这就是 scanf 的函数声明。

C 中要求省略号前必须有一个具名形参,而 C++ 中无此要求(事实上如果没有具名形参的话,将无法访问传递给这种函数的实参)。

因为变长实参的相关实现细节与本文主题没有太大关联,这里不再展开,感兴趣的读者可以参考 可变参数入门 – wenge 的博客

可行函数

当我们要调用函数 f 时,相同名字的函数定义可能会非常多,这里面哪些函数是符合要求的呢?

先考虑形参和实参的个数。我们设形参有 $N$ 个,实参有 $M$ 个。

  1. 若 \(N=M\),则符合要求。
  2. 若 \(N<M\),且存在一个省略号形参,则符合要求。
  3. 若 \(N>M\),且自第 \(M+1\) 个形参起均有默认值,则符合要求。

接下来考虑实参和形参间的类型转换。对于每个实参,必须存在一个隐式转换序列,能将其转换为对应的形参类型。

最后考虑引用类型。右值实参不能对应一个左值非 const 引用形参,而左值实参也不能对应一个右值引用的形参。

#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;
}

最佳函数

选出了可行函数之后,如果只有一个可行函数,调用这个函数就行了。

如果有多个呢?我们需要从中找到一个最贴近的函数声明。

简单来说,对于两个可行函数 f1f2,我们需将第 \(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. 否则,若存在一个引用形参,则右值到右值引用的转换优于左值到右值引用的转换。
  4. 否则,比较两个引用形参const 限定的数目,const 限定少的更优。

对于上面比较规则的 3 和 4,下面给出两个例子(来自 cppreference

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&&)
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

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据