奶奶都能看懂的 C++ —— 函数重载、参数默认值与匹配
上一节我们聊了返回值、作用域和对象生命周期,今天我们先来聊聊一个很方便的东西:函数重载和参数默认值,然后再来聊聊和它相关的一个重要逻辑:如何根据实参匹配对应的同名不同实现的函数?
通常来说,一个函数有一个名字,以及一个声明和定义。但是有时候,我们可能要做同一件事,但是却要根据传入的参数类型和个数,决定怎么做这件事。
这时候,就轮到函数重载出马了。
快速开始:了解函数重载
函数重载允许我们创建相同名字的不同实现。
先来看看下面的代码。我们创建了三个版本的 addNumbers 函数,它们的参数和返回值相互不同。请先观察,然后预测一下输出:
1 | |
输出:
1 | |
这里的重点是不同实现。如果我们的两种实现的参数和返回值一模一样,那么肯定会报错。这就提出来了一个核心问题:
同名函数有有多个实现,怎么才能算不同呢?
仔细看看这个问题。先说结论,要满足以下条件:
- (核心)函数参数必须有个数或者类型上的不同,即参数列表必须不同
- (关于 const 修饰)两种实现之间,顶层 const 不影响其参数类型;底层 const 对参数类型有影响。也就是说,如果类型本身相同,那么必须有底层 const 上的不同,才能认为是不同参数列表。
- 返回值、形参名字并不影响判断。换句话说,如果只有返回值或形参的名字不同,参数列表完全一致,那么并不能算作两种不同实现。
上面的代码已经足够说明判断不同的核心了:
- 第一个和第二个,是两个 int 类型返回值的函数,参数的个数不一样,因此函数实现不同
- 1 和 3,2 和 3,参数的类型完全不同,因此函数实现不同。
对于 const 来说,我们这里仅举一个顶层 const 的例子来说明。关于顶层和底层 const,以及具体如何判断它们,请参阅之前的文章。
1 | |
上面的代码无法通过编译,因为同名函数的多个实现仅存在顶层 const 的不同,不能算作重载,编译器会提示重复定义了一个函数。
当我们实现了不同版本的函数之后,另一个问题浮现出来了:我们调用的时候,怎么根据实参来匹配函数的不同是实现呢?
通常来说,我们可以一眼看出来,毕竟个数和类型不同。但是当类型可以随意转换时,问题就复杂了起来,这称为函数匹配,也叫重载确定。比如上面的代码:
1 | |
要想彻底理解函数匹配问题,而不是凭借感觉,我们先需要理解函数默认参数,然后再回来继续了解匹配问题。
函数默认值
在 C++ 中,我们可以把一个参数设置为默认值,这样调用的时候就不需要传入了。比如:
1 | |
第三个参数根本没有传入,却被设定为了 8。这就是默认值的作用。
C++ 对默认值有以下规定:
- 无论有多少,默认值必须位于末尾。如果一个参数有默认值,它本身以及它后面的参数,一定都有默认值
- 默认值可能影响参数匹配,但是不影响判断是否为不同的定义。比如,下面的代码是完全可以过编译的:
1 | |
虽然可以通过编译,但是你无法只传入两个实参来调用第二种定义——
1 | |
此外:
- 默认值可以是作用域中处于有效区间的的变量。如果是这样的话,函数在调用时会根据名字查找对应的内容
比如说,下面的程序是完全合法的,不妨先猜一猜,会输出什么?
1 | |
输出:
1 | |
现在我们来详细拆解一下究竟发生了什么。
- 首先,我们的
addNumbers函数处于全局作用域中,在其定义处,是同样位于全局作用域的 global 名字的有效区间,因此,可以找到 global 这个变量 - 然后我们多次调用了这个函数。第一次调用没有用到默认值,将实参 2 用于初始化
i2,而非 global。global 仍然为 0 - 然后,我们用省略默认值的方式,把 global 用于初始化
i2 - 之后,我们输出了 global,此时它没有被任何人修改过,因此仍然为 0
- 再次,我们更改了 global 的值为 4。这个时候,我们正处于名字的有效区间内,所以修改全局变量成功
- 之后,我们再次通过省略默认值的方式,调用了函数。调用时才会初始化形参,所以现在 i2 由 global 进行初始化,由于 global 现在是 4,所以 i2 初始化为 4,函数结果 4+1 = 5
根据上面的讲解,我们已经对默认值有了相当程度的认知,现在是时候回到那个问题了——到底是如何根据实参列表,匹配对应的函数定义的?
函数匹配
C++ 针对函数匹配问题,有自己的一套流程:
- 确定候选函数
- 确定可行函数
- 确定最佳匹配
- 有单个最佳匹配吗?如果有,调用;如果没有,编译报错
我们从头开始来看。
确定候选函数
候选函数指的是,在调用处可见的函数(调用点位于函数名字的有效区间)。
比如,给出下面的函数声明和调用(f 函数定义已略去):
1 | |
根据上一讲关于作用域的知识,你应该能轻易判断出来,调用点上方的两个函数是可见的,它们即为候选函数。C++ 匹配函数时,第一步就是选出候选函数,这样就能初步确定范围。
确定可行函数
我们现在得到了候选函数的列表,但是究竟调用函数是否可行,这是一个问题。接下来,我们将确定可行函数,进一步缩小选择的范围。要想知道一个函数是否是调用的可行函数,要满足以下条件:
- 所选函数的形参个数与实参个数匹配。当函数有默认值,实参个数必须位于无默认值形参数量到总形参数量之间。(简单来说,就是有默认值的形参,有没有对应的实参都无所谓)
- 所选函数的形参类型与实参类型匹配,或可以相互转换。
比如,我们来个极端点的例子:
1 | |
上方代码中的两个调用,分别匹配了哪些候选函数、可行函数?(其实注释已经告诉你一部分答案了)
候选函数很简单,每个调用,四种函数重载都是候选,不多说。现在来确定可行函数。
先来看第一个。它只有一个参数,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 | |
先运行以下,看看结论:
1 | |
看起来都落到了第四种重载上。这究竟是为什么,我们拆开来看。
既然要确定最佳匹配(相比于其它版本,有一个版本的函数有明显优势),我们肯定要有优先级——什么样版本的函数参数列表,和调用最相近?
先给出优先级:
- 类型完全一致,不需要转换 / 将实参从数组转换为指针 / 对实参添加、移除顶层 const 修饰(这几种情况,称作精确匹配)
- 对底层 const 进行转换,也就是为实参添加底层 const 修饰
- 对实参进行类型提升转换,即把小类型,提升为大类型(如 short 到 int,char 到 int)
- 进行算术转换(如 int 到 double,double 到 int)
- 进行类转换(还没学,先记一下,类转换的优先级非常低)
同一优先级内没有优劣之分。
要注意的一点是,第三条中,一般小的整型数会提升到 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 | |
你自己试一下咯。看看到底调用的是哪一个方案。
哎,如果你真的动手试过了,就会发现编译器直接报错了,根本不给你运行的机会。
为什么?这就要引出一个概念了——二义性调用。
二义性调用
回到示例代码,为了让你看清楚,这次我也省略掉了定义,只保留声明,然后编号了。
1 | |
候选函数四种都是。
先来看 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 | |
先猜猜看,上面的代码能不能通过编译呢?从直觉上来看,似乎可以——有两个可行函数,第二个精确匹配的个数更多,所以调用第二个。
但实际上你自己试一下,就会发现仍然报错。
为什么?我们现在给出,一个版本的函数实现 A 优于另一个实现 B 的绝对优势判断准则,必须满足以下两条(小提示,请回到上一小节,参考相似优先级列表):
- A 的所有参数,相比于 B,匹配程度(参数类型相似程度)都不差(相等或更加相似)
- A 的至少一个参数,相比于 B,匹配程度高
现在再来看例子,试着分析一下绝对优势:
调用点有三个参数,第一个是 int,第二、第三个是 double 类型。
先看第一个参数,对第一种的函数是精确匹配,优于第二种。再看后面两个参数,对第二种的函数是精确匹配,优于第一种。
现在套用判断准则。
1 优于 2 吗?
- 1 存在第二、第三个参数比 2 匹配程度差,不符合第一条
- 1 有第一个参数优于 2,符合第二条
由于必须满足两条,因此不成立,1 不优于 2。
2 优于 1 吗?
- 2 存在第一个参数比 1 匹配程度差,不符合第一条
- 2 有第二、第三个参数优于 1,符合第二条
同理,不成立,2 不优于 1。
因此,谁也没有绝对优势,存在二义性。
经过上面的讲解,我们已经对函数重载规则、匹配方式有了相当程度的认知。本节的重点是不同实现是否有区别的判断、默认值放在末尾的规范、匹配的方法和优先级,每一个都进行了深入的讲解。下一节,我们将把函数与指针结合起来,先讲解函数指针,然后再聊聊指针的高级用法。








