上一节我们聊了返回值、作用域和对象生命周期,今天我们先来聊聊一个很方便的东西:函数重载和参数默认值,然后再来聊聊和它相关的一个重要逻辑:如何根据实参匹配对应的同名不同实现的函数?

通常来说,一个函数有一个名字,以及一个声明和定义。但是有时候,我们可能要做同一件事,但是却要根据传入的参数类型和个数,决定怎么做这件事

这时候,就轮到函数重载出马了。

快速开始:了解函数重载

函数重载允许我们创建相同名字不同实现

先来看看下面的代码。我们创建了三个版本的 addNumbers 函数,它们的参数和返回值相互不同。请先观察,然后预测一下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int addNumbers(int i1, int i2, int i3)
{
return i1 + i2 + i3;
}
int addNumbers(int i1, int i2)
{
return i1 + i2;
}
double addNumbers(double d1, double d2)
{
return d1 + d2;
}
int main()
{
cout << addNumbers(1, 2) << endl;
cout << addNumbers(1.1, 1.2) << endl;
cout << addNumbers(1, 2, 3) << endl;
return 0;
}

输出:

1
2
3
3
2.3
6

这里的重点是不同实现。如果我们的两种实现的参数和返回值一模一样,那么肯定会报错。这就提出来了一个核心问题:

同名函数有有多个实现,怎么才能算不同呢?

仔细看看这个问题。先说结论,要满足以下条件:

  • (核心)函数参数必须有个数或者类型上的不同,即参数列表必须不同
  • (关于 const 修饰)两种实现之间,顶层 const 不影响其参数类型;底层 const 对参数类型有影响。也就是说,如果类型本身相同,那么必须有底层 const 上的不同,才能认为是不同参数列表。
  • 返回值、形参名字并不影响判断。换句话说,如果只有返回值或形参的名字不同,参数列表完全一致,那么并不能算作两种不同实现。

上面的代码已经足够说明判断不同的核心了:

  • 第一个和第二个,是两个 int 类型返回值的函数,参数的个数不一样,因此函数实现不同
  • 1 和 3,2 和 3,参数的类型完全不同,因此函数实现不同。

对于 const 来说,我们这里仅举一个顶层 const 的例子来说明。关于顶层和底层 const,以及具体如何判断它们,请参阅之前的文章

1
2
3
4
5
6
7
8
int addNumbers(int i1,const int i2)
{
return i1 + i2;
}
int addNumbers(int i1, int i2)
{
return i1 + i2;
}

上面的代码无法通过编译,因为同名函数的多个实现仅存在顶层 const 的不同,不能算作重载,编译器会提示重复定义了一个函数。

当我们实现了不同版本的函数之后,另一个问题浮现出来了:我们调用的时候,怎么根据实参来匹配函数的不同是实现呢?

通常来说,我们可以一眼看出来,毕竟个数和类型不同。但是当类型可以随意转换时,问题就复杂了起来,这称为函数匹配,也叫重载确定。比如上面的代码:

1
2
3
cout << addNumbers(1, 2) << endl; // 两个 int
cout << addNumbers(1.1, 1.2) << endl; // 两个 double
cout << addNumbers(1, 2, 3) << endl; // 三个 int

要想彻底理解函数匹配问题,而不是凭借感觉,我们先需要理解函数默认参数,然后再回来继续了解匹配问题。

函数默认值

在 C++ 中,我们可以把一个参数设置为默认值,这样调用的时候就不需要传入了。比如:

1
2
3
4
5
6
7
8
9
int addNumbers(int i1, int i2, int i3 = 5)
{
return i1 + i2 + i3;
}
int main()
{
cout << addNumbers(1, 2) << endl; // 8
return 0;
}

第三个参数根本没有传入,却被设定为了 8。这就是默认值的作用。

C++ 对默认值有以下规定:

  • 无论有多少,默认值必须位于末尾。如果一个参数有默认值,它本身以及它后面的参数,一定都有默认值
  • 默认值可能影响参数匹配,但是不影响判断是否为不同的定义。比如,下面的代码是完全可以过编译的:
1
2
3
4
5
6
7
8
int addNumbers(int i1, int i2, int i3 = 5)
{
return i1 + i2 + i3;
}
int addNumbers(int i1, int i2)
{
return i1 + i2;
}

虽然可以通过编译,但是你无法只传入两个实参来调用第二种定义——

1
cout<<addNumbers(1,2)<<endl; // Error

此外:

  • 默认值可以是作用域中处于有效区间的的变量。如果是这样的话,函数在调用时会根据名字查找对应的内容

比如说,下面的程序是完全合法的,不妨先猜一猜,会输出什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int global = 0;
int addNumbers(int i1, int i2 = global)
{
return i1 + i2;
}
int main()
{
cout << addNumbers(1, 2) << endl; // 没有用默认值
cout << addNumbers(1) << endl; // 用了一下默认值
cout << global << endl; // 我们之前把实参 2 复制给了形参 i2,对 global 有影响吗?有什么影响?
global = 4; // 现在 global 变成了 4,对程序又有什么影响呢?
cout << addNumbers(1) << endl;
return 0;
}

输出:

1
2
3
4
3
1
0
5

现在我们来详细拆解一下究竟发生了什么。

  • 首先,我们的 addNumbers 函数处于全局作用域中,在其定义处,是同样位于全局作用域的 global 名字的有效区间,因此,可以找到 global 这个变量
  • 然后我们多次调用了这个函数。第一次调用没有用到默认值,将实参 2 用于初始化 i2,而非 global。global 仍然为 0
  • 然后,我们用省略默认值的方式,把 global 用于初始化 i2
  • 之后,我们输出了 global,此时它没有被任何人修改过,因此仍然为 0
  • 再次,我们更改了 global 的值为 4。这个时候,我们正处于名字的有效区间内,所以修改全局变量成功
  • 之后,我们再次通过省略默认值的方式,调用了函数。调用时才会初始化形参,所以现在 i2 由 global 进行初始化,由于 global 现在是 4,所以 i2 初始化为 4,函数结果 4+1 = 5

根据上面的讲解,我们已经对默认值有了相当程度的认知,现在是时候回到那个问题了——到底是如何根据实参列表,匹配对应的函数定义的?

函数匹配

C++ 针对函数匹配问题,有自己的一套流程:

  1. 确定候选函数
  2. 确定可行函数
  3. 确定最佳匹配
  4. 有单个最佳匹配吗?如果有,调用;如果没有,编译报错

我们从头开始来看。

确定候选函数

候选函数指的是,在调用处可见的函数(调用点位于函数名字的有效区间)。
比如,给出下面的函数声明和调用(f 函数定义已略去):

1
2
3
4
5
6
void f(int); // 可见
void f(double); // 可见
int main(){
f(1.14); // 2 个候选函数
}
void f(int,int); // 不可见,不在有效区间内

根据上一讲关于作用域的知识,你应该能轻易判断出来,调用点上方的两个函数是可见的,它们即为候选函数。C++ 匹配函数时,第一步就是选出候选函数,这样就能初步确定范围。

确定可行函数

我们现在得到了候选函数的列表,但是究竟调用函数是否可行,这是一个问题。接下来,我们将确定可行函数,进一步缩小选择的范围。要想知道一个函数是否是调用的可行函数,要满足以下条件:

  • 所选函数的形参个数与实参个数匹配。当函数有默认值,实参个数必须位于无默认值形参数量总形参数量之间。(简单来说,就是有默认值的形参,有没有对应的实参都无所谓)
  • 所选函数的形参类型与实参类型匹配,或可以相互转换。

比如,我们来个极端点的例子:

1
2
3
4
5
6
7
8
9
10
void f(string s); // 1
void f(int i1); // 2
void f(int i1, int i2 = 3); // 3
void f(double d1, double d2 = 114.514); // 4
int main()
{
f(2.1); // 234
f(2.1, 2.3); // 34
return 0;
}

上方代码中的两个调用,分别匹配了哪些候选函数、可行函数?(其实注释已经告诉你一部分答案了)

候选函数很简单,每个调用,四种函数重载都是候选,不多说。现在来确定可行函数。

先来看第一个。它只有一个参数,2.1,是一个 double 类型的实参。

首先匹配个数,1、2 两种个数都匹配。3、4 两种形参有默认值,它们可以匹配的实参个数都是 1-2 个(至少一个,最多接受两个),因此和调用都匹配。

然后匹配类型。在上面出现的类型中,实参的 double 类型,转换为:

  • int,可行
  • string,无法转换

因此,排除第一个。得出可行函数,2、3、4。

对于第二个调用,我们同样的操作,它的实参是两个 double 类型的参数:

先匹配个数,显然 1、2 两种只有一个形参,不匹配,排除。剩余 2、3 两种可接受 1-2 个形参,2 在范围内,匹配。

然后匹配类类型。double 允许转换为 int,全部匹配。

因此,得出可行函数,3、4。

通过这两个例子,我们已经充分说明了问题,是时候进入下一步了:确定最佳匹配。

确定最佳匹配

我们继续上面的例子,这次我们直接加上定义,让代码先跑一下,看看到底最终会调用哪一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void f(string s)
{
cout << "Type1" << endl;
}
void f(int i1)
{
cout << "Type2" << endl;
}
void f(int i1, int i2 = 3)
{
cout << "Type3" << endl;
}
void f(double d1, double d2 = 114.514)
{
cout << "Type4" << endl;
}
int main()
{
f(2.1);
f(2.1, 2.3);
return 0;
}

先运行以下,看看结论:

1
2
Type4
Type4

看起来都落到了第四种重载上。这究竟是为什么,我们拆开来看。

既然要确定最佳匹配(相比于其它版本,有一个版本的函数有明显优势),我们肯定要有优先级——什么样版本的函数参数列表,和调用最相近?

先给出优先级:

  1. 类型完全一致,不需要转换 / 将实参从数组转换为指针 / 对实参添加、移除顶层 const 修饰(这几种情况,称作精确匹配
  2. 对底层 const 进行转换,也就是为实参添加底层 const 修饰
  3. 对实参进行类型提升转换,即把小类型,提升为大类型(如 short 到 int,char 到 int)
  4. 进行算术转换(如 int 到 double,double 到 int)
  5. 进行类转换(还没学,先记一下,类转换的优先级非常低)

同一优先级内没有优劣之分。

要注意的一点是,第三条中,一般小的整型数会提升到 int,而不是 short 等类型。比如,char 类型的实参在调用时,会优先匹配 char,再匹配 int,最后是 short。这一点可以参考整形提升部分的规则,但这不是本文的重点,我们这里不再赘述。

我们回到例子中来。我们从可行函数列表开始,对可行函数从优先级高的开始进行选择:

首先是 f(2.1): 首选 f(double d1, double d2 = 114.514),因为精确匹配了 double 类型。另外几种都需要转换,因此已经找到无需转换的最佳匹配了。

其次是 f(2.1,2.3):首选 f(double d1, double d2 = 114.514),因为精确匹配了 double 类型。另外几种都需要转换,因此已经找到无需转换的最佳匹配了。

……上面几个有点太简单了,我们换几个调用:

1
2
3
f(2);
f(2,2.1);
f(2.1,2);

你自己试一下咯。看看到底调用的是哪一个方案。

哎,如果你真的动手试过了,就会发现编译器直接报错了,根本不给你运行的机会。

为什么?这就要引出一个概念了——二义性调用

二义性调用

回到示例代码,为了让你看清楚,这次我也省略掉了定义,只保留声明,然后编号了。

1
2
3
4
5
6
7
8
9
10
11
void f(string s); // 1
void f(int i1); // 2
void f(int i1, int i2 = 3); // 3
void f(double d1, double d2 = 114.514); // 4
int main()
{
f(2);
f(2,2.1);
f(2.1,2);
return 0;
}

候选函数四种都是。

先来看 f(2),可行函数是 2、3、4。现在来确定最佳匹配:

  • double 类型肯定没有精确匹配的 int 类型高,因此我们先把目光集中在 2、3 两种上
  • 我们发现,2、3 全部都是精确匹配,属于同一优先级,因此没有孰优孰劣的区分(默认值是可有可无的,不会对优先级产生影响)。
  • 因此,编译错误,哪个都没有明显优势,称为二义性调用

再来看 f(2,2.1),可行函数是 3、4:

  • 先看第一个参数,对于 int 来说,3 是精确匹配,优先级高于算术转换的 4
  • 再看第二个参数,对于 double 来说,4 是精确匹配,优先级高于算术转换的 3
  • 因此,编译错误,哪个都没有明显优势,存在二义性调用

对于 f(2.1,2),它和前一个几乎一模一样,因此不再赘述,你可以自己尝试分析一下。

由此我们可以得出结论,编译器默认会选择匹配程度最高的方案,但当谁都没有明显优势的时候,编译器会报错

为了澄清到底什么是明显优势(一种实现的形参列表明显与实参列表更加相似),彻底搞清楚什么时候会产生二义性调用,我们先把参数个数升级到三个,然后再揭示规则:

1
2
3
4
5
6
7
void f(int i1, int i2 = 3, int i3 = 5);
void f(double d1, double d2 = 114.514, double d3 = 114.514);
int main()
{
f(2, 2.1, 2.2);
return 0;
}

先猜猜看,上面的代码能不能通过编译呢?从直觉上来看,似乎可以——有两个可行函数,第二个精确匹配的个数更多,所以调用第二个。

但实际上你自己试一下,就会发现仍然报错。

为什么?我们现在给出,一个版本的函数实现 A 优于另一个实现 B 的绝对优势判断准则,必须满足以下两条(小提示,请回到上一小节,参考相似优先级列表):

  • A 的所有参数,相比于 B,匹配程度(参数类型相似程度)都不差(相等或更加相似)
  • A 的至少一个参数,相比于 B,匹配程度

现在再来看例子,试着分析一下绝对优势:

调用点有三个参数,第一个是 int,第二、第三个是 double 类型。

先看第一个参数,对第一种的函数是精确匹配,优于第二种。再看后面两个参数,对第二种的函数是精确匹配,优于第一种。

现在套用判断准则。

1 优于 2 吗?

  • 1 存在第二、第三个参数比 2 匹配程度差,不符合第一条
  • 1 有第一个参数优于 2,符合第二条

由于必须满足两条,因此不成立,1 不优于 2。

2 优于 1 吗?

  • 2 存在第一个参数比 1 匹配程度差,不符合第一条
  • 2 有第二、第三个参数优于 1,符合第二条

同理,不成立,2 不优于 1。

因此,谁也没有绝对优势,存在二义性。

经过上面的讲解,我们已经对函数重载规则、匹配方式有了相当程度的认知。本节的重点是不同实现是否有区别的判断、默认值放在末尾的规范、匹配的方法和优先级,每一个都进行了深入的讲解。下一节,我们将把函数与指针结合起来,先讲解函数指针,然后再聊聊指针的高级用法。