实用经验 48 函数重载需考虑什么?

一词多义是我们都熟悉、司空见惯的东西。一个词语在不同的语境中具有不同的意义。正因如此才促成了我们语言丰富性。本实用经验所介绍的函数重载就同一词多义非常类似,函数重载就是自然语言一词多义在编程语言中的映射。

1.What

函数重载是指在同一作用域内,可以有一组具有相同函数名,不同参数列表的函数,这组函数被称为重载函数。重载函数通常用来命名一组功能相似的函数,这样做减少了函数名的数量,避免了名字空间的污染,对于程序的可读性有很大的好处。

说明:函数重载和重复声明的区别

  • 两个函数声明的返回类型和形参表完全匹配,第二个函数判定为第一函数的重复声明。
  • 两个函数的声明形式参数表完全相同,但是返回类型不同。第二个函数声明判定为错误。

首先来看一个例子,体会一下函数重载的给我们带来的编程舒适感:实现一个打印函数,既可打印int类型,也可打印string类型。

#include<iostream>
using namespace std;

void Print(int nValue)                                    // 在屏幕上打印一个整数
{
    cout << "print a integer :" << nValue << endl;
}

void Print(string strValue)                               // 在屏幕上打印一个字符串
{
    cout << "print a string :" << strValue << endl;
}

int main()
{
    cout << "print a integer and a string." << endl;
    Print(12);
    Print("hello girl!");
    return 0;
}

上面的代码实现中,Print函数会根据Print()的参数决定是调用Print(int)还是Print (string)函数。Print(12)会调用Print(int)函数。Print(“hello girl”)会调用Print(string)函数。

现在我们对函数的重载有了一个大致的了解了。下面我们看看C++为什么引入函数重载,函数在调用过程中怎么确定如何调用的呢?

2.Why

C++引入函数重载主要有这几个方面的考虑:

(1)编程和阅读代码友好性的角度,试想如果没有重载机制:同一功能的函数你必须取不同的函数名称。如上例,你必须取Print_int,Print_string这样的名称以区别两个函数。

(2)类的构造函数跟类名相同,就是说构造函数都同名。如果没有函数重载机制,要想实例化不同的对象,那是相当的麻烦。

(3)操作符重载,本质上就是函数重载,它大大丰富了已有操作符的含义,方便使用,如+可用于连接字符串等。

注意警惕函数重载的滥用。并不是所有看似可以用函数重载的地方,选择函数重载具是较佳选择。如下某些情况下使用不同的函数名称可以提供较多的信息,是程序易于理解。此处建议不要滥用函数重载。

3.How

编译器处理函数重载,主要解决函数命名冲突、函数调用匹配两个关键问题。函数命名冲突顾名思义就是重载函数具有相同的函数名称,编译器是如何区分它们的。函数调用匹配即编译器在连接过程中如何判定调用哪个重载函数。我们首先看一下命名冲突,然后再看函数调用匹配。

(1)函数命名冲突。为了更好的讲述函数命名冲突问题,我们首先看一下上述举例代码的汇编代码(Windows 下MingGW编译器):

void Print(int nValue)对应汇编代码:请注意它的函数标签名称为__Z5Printi:

00000012 <__Z5Printi>:
void Print(int nValue)             // 在屏幕上打印一个整数
{
  12:	55                   	push   %ebp
  13:	89 e5                	mov    %esp,%ebp
  15:	83 ec 08              	sub    $0x8,%esp
    cout << "print a integer :" << nValue << endl;
  18:	c7 44 24 04 00 00 00 		movl   $0x0,0x4(%esp)
  1f:	     00 
  20:	c7 04 24 00 00 00 00 		movl    $0x0,(%esp)
  27:	e8 00 00 00 00      	 	call    2c <__Z5Printi+0x1a>
  2c:	8b 55 08             	mov    0x8(%ebp),%edx
  2f:	     89 54 24 04         	mov    %edx,0x4(%esp)
  33:	89 04 24             	mov    %eax,(%esp)
  36:	e8 00 00 00 00      	 	call   3b <__Z5Printi+0x29>
  3b:	c7 44 24 04 00 00 00 		movl   $0x0,0x4(%esp)
  42:	00 
  43:	89 04 24             	mov    %eax,(%esp)
  46:	e8 00 00 00 00     	  	call    4b <__Z5Printi+0x39>
}
  4b:	c9                   	leave  
  4c:	c3                   	ret    

void Print(string strValue)函数对应汇编代码:请注意它的函数标签名为__Z5PrintSs。

0000005e <__Z5PrintSs>:
void Print(string strValue)          // 在屏幕上打印一个字符串
{
  5e:	55                   	push   %ebp
  5f: 	89 e5            	mov    %esp,%ebp
  61:	53                   	push   %ebx
  62:	83 ec 14              	sub    $0x14,%esp
  65:	8b 5d 08             	mov    0x8(%ebp),%ebx
    cout << "pint a string :" << strValue << endl;
  68:	c7 44 24 04 4d 00 00 	movl   $0x4d,0x4(%esp)
  6f:	     00 
  70:	c7 04 24 00 00 00 00   	movl   $0x0,(%esp)
  77:	e8 00 00 00 00       		call    7c <__Z5PrintSs+0x1e>
  7c:	89 5c 24 04          	mov    %ebx,0x4(%esp)
  80:	89 04 24             	mov    %eax,(%esp)
  83:	e8 00 00 00 00        	call    88 <__Z5PrintSs+0x2a>
  88:	c7 44 24 04 00 00 00   	movl   $0x0,0x4(%esp)
  8f:	     00 
  90:	89 04 24             	mov    %eax,(%esp)
  93:	e8 00 00 00 00        	call    98 <__Z5PrintSs+0x3a>
}
  98:	83 c4 14             	add    $0x14,%esp
  9b:	5b                   	pop    %ebx
  9c:	5d                   	pop    %ebp
  9d:	c3                   	ret  

可以看出Print函数编译之后名称就不是Print了。这样命名冲突问题就解决了,呵呵。但是函数编译时名称会发生变化。变化机制又是怎样的呢?从上面的两个函数的变化规律我想已经猜出一个八九不离十了吧:前面的__Z5可能是返回类型,Print为函数名称,i表示整型int。Ss表示字符串string。事实也是如此。于是我们可以得到下面的结论:全局重载函数名称变化机制映射关系为:返回类型+函数名+参数列表。

我说了这么多,我们重新看一下函数重载的定义。好像到现在我们还从未提及作用域。下面我们看一下C++中更具一般性的函数重载过程。首先将上述代码改成下述形式。

#include<iostream>
#include<string>
using namespace std;

class CPrint
{
public:
	void Print(int nValue)                                	// 在屏幕上打印一个整数
	{
	    cout << "print a integer :" << nValue << endl;
	}
	void Print(string strValue)                          	// 在屏幕上打印一个字符串
	{
	    cout << "pint a string :" << strValue << endl;
	}
};
int main(int argc, char* argv[])
{
    CPrint printInstance;
    cout << "print a integer and a string" << endl;
    printInstance.Print(12);
    printInstance.Print("hello girl!");
    return 0;
}

你可以在MingGW编译器下查看一下上述代码对应的汇编代码。CPrint::Print(int nValue) 函数汇编后对应__ZN6CPrint5PrintEi。CPrint::Print(string strValue) 函数汇编后对应__ZN6CPrint5PrintESs。根据上面的映射猜想,我们可总结出更为准确的映射机制为:作用域+返回类型+函数名+参数列表。

(2)函数调用匹配。现在接着讲述函数调用匹配问题。为了实现最佳的重载函数调用匹配。编译器按照实参类型和相应形参类型的转化等级,将函数调用匹配分为4个等级,他们是: ① 精确匹配,实参和形参类型完全相同。 ② 类型提升实现函数匹配。③ 通过标准转换实现匹配。④ 通过类类型转换实现匹配。

有了函数调用匹配规则后,编译器会通过①根据函数名称选择候选函数集,②从候选函数中选择可用函数,③从可用函数集中确定最佳函数。3个步骤确定重载函数的调用匹配。

注意

  • 内置类型的提升和转换可能会使函数匹配产生意想不到的结果。这个是设计重载函数需注意的东西。
  • 在调用重载函数时尽量避免强制类型转换。
  • 对应传值调用的函数,形参const与否对重载函数匹配无任何影响,只有在形参为传址或传引用调用时两者才会存在差异。
  • 重载函数定义时尽量避免枚举常量。因为枚举类型在提升时依赖于机器,造成你的程序可移植性下降。

请谨记

  • 合理利用重载函数会给程序可读性带来好处,但不合理的重载函数不但对代码可读性没有帮助,还会污染名字空间。
  • 设计重载函数时尽量避免存在多个函数满足同一个实参匹配,这样会导致重载匹配的二义性问题。
上一篇:逆向脱壳破解分析基础学习笔记七 堆栈图(重点)


下一篇:CPU的工作原理