【C++基础】函数的使用

一、函数的基本使用

函数其实就是封装好的代码块,并且指定一个名字,调用这个名字就可以执行代码并返回一个结果。

1. 函数的定义

一个完整的函数定义主要包括以下部分:

  1. 返回类型:调用函数之后,返回结果的数据类型;
  2. 函数名:用来命名代码块的标识符,在当前作用域内唯一;
  3. 参数列表:参数表示函数调用时需要传入的数据,一般叫做“形参”;放在函数名后的小括号里,可以有0个或多个,用逗号隔开;
  4. 函数体:函数要执行的语句块,用花括号括起来。

函数一般都是一个实现了固定功能的模块,把参数看成输入,返回结果看成输出,函数就是一个输入到输出的映射关系。

我们可以定义一个非常简单的平方函数:
// 平方函数 y = f(x) = x ^ 2
int square(int x)
{
	int y = x * x;
	return y;
}

使用流程控制语句return,就可以返回结果。

2. 函数的调用

调用函数时,使用的是调用运算符,就是跟在函数名后面的一对小括号;括号内是用逗号隔开的参数列表。

这里的参数不是定义时的形参,而是为了初始化形参传入的具体值;为了跟函数定义时的形参列表区分,把它叫作实参。

调用表达式的类型就是函数的返回类型,值就是函数执行返回的结果。

#include<iostream>
using namespace std;

// 平方函数 y = f(x^2)
int square(int x)
{
	return x * x;
}

int main()
{
	int n = 6;
	cout << n << "的平方是:" << square(n) << endl;

	cin.get();
}

这里需要注意:

  1. 实参是形参的初始值,所以函数调用时传入实参,相当于执行了int x = 6的初始化操作;实参的类型必须跟形参类型匹配;
  2. 实参的个数必须跟形参一致;如果有多个形参,要按照位置顺序一一对应;实参和形参的类型必须可以相互转换;
  3. 如果函数本身没有参数,参数列表可以为空,但空括号不能省;
  4. 形参列表中多个参数用逗号分隔,每个都要带上类型,类型相同也不能省略;
  5. 如果函数不需要返回值,可以定义返回类型为void;
  6. 函数返回类型不能是数组或者函数;

3. 局部变量的生命周期

3.1 回顾变量和作用域

之前介绍过变量的作用域,对于花括号内定义的变量,具有块作用域,在花括号外就不可见了。函数体都是语句块,而主函数main本身也是一个函数;所以在main中定义的所有变量、所有函数形参和在函数体内部定义的变量,都具有块作用域,统称为局部变量。局部变量仅在函数作用域内部可见。

// 函数形参x是局部变量,作用域为函数内部
void f(int x)
{
	// 函数内部定义的变量a是局部变量,作用域为函数内部
	int a = 10;
}

int main()
{
	// 主函数中定义的变量b也是局部变量,作用域为主函数内
	int b = 0;
}

3.2 作用域和生命周期

在C++中,作用域指的是变量名字的可见范围;变量不可见,并不代表变量所指代的数据对象就销毁了。这是两个不同的概念:

  1. 作用域:针对名字而言,是程序文本中的一部分,名字在这部分可见;
  2. 生命周期:针对数据对象而言,是程序在执行过程中,对象从创建到销毁的时间段

基于作用域,变量可以分为局部变量和全局变量。对于全局变量而言,名字全局可见,对象也只有在程序结束时才销毁。而对于局部变量代表的数据对象,又可以基于生命周期分为自动对象和静态对象。

3.2.1 自动对象

平常代码中定义的普通局部变量,生命周期为在程序执行到变量定义语句时创建,在程序运行到当前块末尾时销毁。这样的对象称为自动对象。

形参也是一种自动对象。形参定义在函数体作用域内,一旦函数终止,形参也就被销毁了。

对于自动对象来说,它的生命周期和作用域是一致的。

3.2.2 静态对象

如果希望延长一个局部变量的生命周期,让它在作用域外依然保留,可以在定义局部变量时加上static关键字;这样的对象叫做局部静态对象。

局部静态对象只有局部的作用域,在块外依然是不可见的;但是它的生命周期贯穿整个程序运行过程,只有在程序结束时才被销毁,这一点与全局变量类似。

// 显示自身被调用多少次的函数
int callCount()
{
   static int cnt = 0;    // 静态对象只会创建一次
   cout << "我被调用了" << ++cnt << "次!" << endl;
   return cnt;
}
int main()
{
   //cout << cnt << endl;    // 错误,局部变量在作用域外不可见
   callCount();
   callCount();
   callCount();
}

可以发现,静态对象只在第一次执行到定义语句时创建出来,之后即使函数执行结束,它的值依然保持;下一次函数调用时,不会再次创建、也不会重新赋值,而是直接在之前的值基础上继续叠加。
静态对象和自动对象应用的场景不同,所以它们存放的内存区域也是不一样的。静态对象如果不在代码中做初始化,基本类型会被默认初始化为0值。

4. 函数的声明

如果我们将一个函数放在主函数后面,就会出现运行错误:找不到标识符。这是因为函数和变量一样,使用之前必须要做声明。函数只有一个定义,可以定义在任何地方;如果需要调用函数,只需要在调用前做一个声明,告诉编译器存在这个函数就可以了。

函数声明的方式,和函数的定义非常相似;区别在于声明时不需要把函数体写出来,用一个分号替代就可以了。

#include<iostream>
using namespace std;

// 声明函数
int square(int x);
int main()
{
	int n = 6;
	cout << n << "的平方是:" << square(n) << endl;
	cin.get();
}
// 定义函数
int square(int x)
{
	int y = x * x;
	return y;
	return x * x;
}

事实上,由于没有函数体的执行过程,所以形参的名字也完全不需要,可以省略。可以直接这样声明一个函数:

int square(int);

函数声明中包含了返回类型、函数名和形参类型,这就说明了调用这个函数所需要的所有信息。函数声明也被叫做函数原型。一般情况下,把函数声明放在头文件中会更加方便。

5. 分离式编译和头文件

5.1 分离式编译

当程序越来越复杂,我们就会希望代码分散到不同的文件中来做管理。C++支持分离式编译,这就可以把函数单独放在一个文件,独立编译之后链接运行。比如可以把复制字符串的函数单独保存成一个文件copy_string.cpp:

#include<string>
using namespace std;
// 复制字符串
string copyStr(string str, int n)
{
	string result;
	while (n > 0)
	{
		result += str;
		--n;
	}
	return result;
}

然后只要在主函数调用之前做声明就可以了:

#include<iostream>
using namespace std;

// 声明函数
string copyStr(string, int);

int main()
{
	int n = 6;
	cout << copyStr("hello ", n) << endl;

	cin.get();
}

5.2 编写头文件

对于一个项目而言,有些定义可能是所有文件共用的,比如一些常量、结构体/类,以及功能性的函数。于是每次需要引入时,都得做一堆声明——这显然太麻烦了。一个好方法是,把它们定义在同一个文件中,需要时用一句#include统一引入就可以了,就像使用库一样。这样的文件以.h作为后缀,被称为头文件。

比如我们可以把之前的一些功能性的函数(比如求平方、阶乘、复制字符串等),放在一个叫做utils.h的头文件中:

#pragma once
#include<string>

// 平方函数 y = f(x^2)
int square(int x)
{
	int y = x * x;
	return y;
	return x * x;
}

// 求立方和
int cubeSum(int x, int y)
{
	return pow(x, 3) + pow(y, 3);
}

// 求阶乘
int factorial(int n)
{
	int result = 1;
	for (int i = 1; i <= n; i++)
		result *= i;
	return result;
}

// 复制字符串
std::string copyStr(std::string str, int n);

这里有两点需要说明:

  1. #pragma once是一条预处理指令,表示这个头文件的内容只会被编译一次,这就避免了多次引入头文件时的重复定义;
  2. 复制字符串函数copyStr已经在别的文件单独做了定义,这里只要声明就可以;

如果想要使用这些函数,只要在文件中引入头文件即可:

#include "utils.h"

这里文件名没有使用尖括号<>,而是使用了引号;这表示要在当前项目的根目录下寻找文件,而不是到编译器默认的库目录下去找。

二、函数的参数传递

函数在每次调用时,都会重新创建形参,并且用传入的实参对它进行初始化。形参的类型,决定了形参和实参交互的方式;也决定了函数的不同功能。

可以先回忆一下对变量的初始化:对一个变量做初始化,如果用另一个变量给它赋初值,意味着值的拷贝;也就是说,此后这两个变量各自一份数据,各自管理,互不影响。而如果是定义一个引用,绑定另一个变量做初始化,并不会引发值的拷贝;引用和原变量管理的是同一个数据对象。

int i1 = 0;
int i2 = i1;
i2 = 1;      // i1的值仍然是0

int& i3 = i1;
i3 = 10;      // i1的值也变为10

参数传递和变量的初始化类似,根据形参的类型可以分为两种方式:传值(value)和传引用(reference)。

1. 传值参数

1.1 传值参数的使用

直接将一个实参的值,拷贝给形参做初始化的传参方式,就被称为值传递,这样的参数被称为传值参数。之前我们练习过的所有函数,都是采用这种传值调用的方式。

int square(int x)
{
	return x * x;
}
int main()
{
	int n = 6;
	cout << n << "的平方是:" << square(n) << endl;
}

在上面平方函数的调用中,实参n的值被拷贝给了形参x。

1.2 传值的困扰

值传递这种方式非常简单,但是面对这样的需求会有些麻烦:传入一个数据对象,让它经过函数处理之后发生改变。例如,传入一个整数x,调用之后它自己的值要加1。这看起来很简单,但如果直接:

void increase(int x)
{
	++x;
}
int main()
{
	int n = 6;
	increase(n);        // n的值不会增加
}

这样做并不能实现需求。因为实参n的值是拷贝给形参x的,之后x的任何操作,都不会改变n。

1.3 指针形参

使用指针形参可以解决这个问题。如果我们把指向数据对象的指针作为形参,那么初始化时拷贝的就是指针的值;复制之后的指针,依然指向原始数据对象,这样就可以保留它的更改了。

// 指针形参
void increase(int* p)
{
	++(*p);
}
int main()
{
	int n = 0;
	increase( &n );       // 传入n的地址,调用函数后n的值会加1
}

2. 传引用参数

使用指针形参可以解决值传递的问题,不过这种方式函数定义显得有些繁琐,每次调用还需要记住传入变量的地址,使用起来不够方便。

2.1 传引用的参数的使用

C++新增了引用的概念,可以替换必须使用指针的场景。采用引用作为函数形参,可以使函数调用更加方便。这种传参方式叫做传引用参数。之前的例子就可以改写成:

// 传引用
void increase(int& x)
{
	++x;
}
int main()
{
	int n = 0;
	increase( n );        // 调用函数后n的值会加1
}

由于使用了引用作为形参,函数调用时就可以直接传入n的值,而不用传地址了;x只是n的一个别名,修改x就修改了n。对比可以发现,这段代码相比最初尝试写出的传值实现,只是多了一个引用声明&而已。

2.2 传引用避免拷贝

使用引用还有一个非常重要的场景,就是不希望进行值拷贝的时候。实际应用中,很多时候函数要操作的对象可能非常庞大,如果做值拷贝会使得效率大大降低;这时使用引用就是一个好方法。比如,想要定义一个函数比较两个字符串的长度,需要将两个字符串作为参数传入。因为字符串有可能非常长,直接做值拷贝并不是一个好选择,最好的方式就是传递引用:

// 比较两个字符串的长度
bool isLonger(const string & str1, const string & str2)
{
	return str1.size() > str2.size();
}

2.3 使用常量引用做形参

在上面的例子中,比较两个字符串长度,并不会更改字符串本身的内容,所以可以把形参定义为常量引用。这样的好处是,既避免了对数据对象可能的更改,也扩大了调用时能传的实参的范围。因为之前讨论过常量引用的特点,可以用字面值常量对它做初始化,也可以用变量做初始化。所以在代码中,一般要尽量使用常量引用作为形参。

3. 数组参数

3.1 数组参数的基本使用

之前已经介绍过,数组是不允许做直接拷贝的,所以如果想要把数组作为函数的形参,使用值传递的方式是不可行的。与此同时,数组名可以解析成一个指针,所以可以用传递指针的方式来处理数组。比如一个简单的函数,需要遍历int类型数组所有元素并输出,就可以这样声明:

void printArray(const int*);     // 指向int类型常量的指针
void printArray(const int[]);   
void printArray(const int[5]);

由于只是遍历输出,不需要修改数组内容,所以这里使用了const。以上三种声明方式,本质上是一样的,形参的类型都是const int *;虽然第三种方式指定了数组长度,但由于编译器会把传入的数组名解析成指针,事实上的数组长度还是无法确定的。
这就带来另一个问题:在函数中,遍历元素时怎样确定数组的结束?

3.2 数组参数的进阶使用

3.2.1 规定结束标记

一种简单思路是,规定一个特殊的结束标记,遇到这个标记就代表当前数组已经遍历完了。典型代表就是C语言风格的字符串,是以空字符\0为结束标志的char数组。这种方式比较麻烦,而且太多特殊规定也不适合像int这样的数据类型。

3.2.2 把数组长度作为形参

除指向数组的指针外,可以再增加一个形参,专门表示数组的长度,这样就可以方便地遍历数组了。

void printArray(const int* arr, int size)
{
	for (int i = 0; i < size; i++)
		cout << arr[i] << "\t";

	cout << endl;
}
int main()
{
	int arr[6] = { 1,2,3,4,5,6 };
	printArray(arr, 6);
}

在C语言和老式的C++程序中,经常使用这种方法来处理数组。

3.2.3 使用数组引用作为形参

之前的方法依赖指针,所以都显得比较麻烦。更加方便的做法,还是用引用来替代指针的功能。C++允许使用数组的引用作为函数形参,这样一来,引用作为别名绑定在数组上,使用引用就可以直接遍历数组了。

// 使用数组引用作为形参
void printArray(const int(&arr)[6])
{
	for (int num : arr)
		cout << num << "\t";

	cout << endl;
}
int main()
{
	int arr[6] = { 1,2,3,4,5,6 };
	printArray(arr);
}

这里需要注意的是,定义一个数组引用时需要用括号将&和引用名括起来:

int(&arr)[6]        // 正确,arr是一个引用,绑定的是长度为6的int数组
// int & arr[6]     // 错误,这是引用的数组,不允许使用

使用数组引用之后,调用函数直接传入数组名就可以了。还需要注意:使用数组的引用作为入参时,传入数组引用的元素个数必须和入参限定的相同

4. 占位参数

C++中函数的形参列表里可以有占位参数,用来做占位。占位参数只有一个数据类型,但是在调用函数时必须填补该位置,占位参数也可以有默认参数。

void func(int a, int){
	cout << "hello" << endl;
}

void func1(int a, int = 10){
	cout << "hello" << endl;
}

int main()
{
	func(10, 10);//占位参数调用时必须填补该位置
	func1(10);//占位参数如果有默认参数,则调用时可以不填补该位置
}

5. 可变形参

有时候我们并不确定函数中应该有几个形参,这时就需要使用可变形参来表达。C++中表示可变形参的方式主要有三种:

  1. 省略符(…):兼容C语言的用法,只能出现在形参列表的最后一个位置;
  2. 初始化列表initializer_list:跟vector类似,也是一种标准库模板类型;initializer_list对象中的元素只能是常量值,不能更改;
  3. 可变参数模板:这是一种特殊的函数,后面会详细介绍。

三、函数的返回类型

函数可以通过return语句,终止函数的执行并返回函数调用的地方;并且可以给定返回值。返回值的类型由函数声明时的返回类型决定。return语句可以有两种形式:

return;            // 直接返回,无返回值
return  表达式;     // 返回表达式的值

1. 无返回值

当函数返回类型为void时,表示函数没有返回值。可以在函数中需要返回时直接执行 return语句,也可以不写。因为返回类型为void的函数执行完最后一句,会自动加上return返回。

2. 有返回值

如果函数返回类型不为void,那么函数必须执行return,并且每条return必须返回一个值。返回值的类型应该跟函数返回类型一致,或者可以隐式转换为一致。

2.1 函数返回值的原理

函数在调用点会创建一个临时量,用来保存函数调用的结果。当使用return语句返回时,就会用返回值去初始化这个临时量。所以返回值的相关规则,跟变量或者形参的初始化是一致的。

之前写过一个比较字符串长度的isLonger函数,我们可以稍作修改,让它可以返回较长的那个字符串:

// 字符串比较长度,返回较长的
string longerStr(const string& str1, const string& str2)
{
	return str1.size() > str2.size() ? str1 : str2;
}
int main()
{
	string str1 = "hello world!", str2 = "c++ is interesting!";
	cout << longerStr(str1, str2) << endl;
}

调用这个函数,经过判断发现str2较长,这时执行return将返回str2。由于返回类型是string,所以将用str2对一个string临时量做初始化,执行的是值拷贝。最终返回的值,是str2的一个副本。

2.2 返回引用类型

对于string对象,显然做值拷贝并不高效。所以我们依然可以借鉴之前的经验,使用引用类型来做返回值的传递,这样就可以避免值拷贝。

// 返回一个string常量对象的引用,不做值拷贝
const string & longerStr(const string& str1, const string& str2)
{
	return str1.size() > str2.size() ? str1 : str2;
}

这里我们同样把返回值定义成了常量引用,方式和作用跟形参完全一样。函数返回的是形参str1或者str2的引用;而函数中的形参本身又是引用类型,所以最终是实参对象的引用。而如果返回的是一个函数内局部变量的引用,比如:

const string & f()
{
	string str = "test";
	return str;
}

这样做是不安全的:因为str是函数内部的局部对象,函数执行完成后就销毁了;而返回值是它的引用,相当于引用了一个不存在的对象,这可能会导致无法预料的问题。所以,函数返回引用类型时,不能返回局部对象的引用;同样道理,也不应该返回指向局部对象的指针。

2.3 返回类对象后连续调用

如果函数返回一个类的对象,那么我们可以继续调用这个对象的成员函数,这样就形成了链式调用。例如:

longerStr(str1, str2).size();

调用运算符,和访问对象成员的点运算符优先级相同,并且满足左结合律。所以链式调用就是从左向右依次调用,代码可读性会更高。

3. 返回数组指针

与形参的讨论类似,由于数组不能拷贝的特点,函数也无法直接返回一个数组。同样的,我们可通常使用指针来实现返回数组的目标。

int arr[5] = { 1,2,3,4,5 };
int* pa[5];        // 指针数组,pa是包含5个int指针的数组
int(*ap)[5] = &arr;    // 数组指针,ap是一个指针,指向长度为5的int数组

int(*fun(int x))[5];    // 函数声明,fun返回值类型为数组指针

数组指针的定义比较繁琐,为了简化这个定义,我们可以使用关键字typedef来定义一个类型的别名:

typedef int arrayT[5];    // 类型别名,arrayT代表长度为5的int数组
arrayT* fun2(int x);      // fun2的返回类型是指向arrayT的指针

C++ 11新标准还提供了另一种简化方式,用一个->符号跟在形参列表后面,再把类型单独提出来放到最后。这种方式叫做尾置返回类型。

auto fun3(int x) -> int(*)[5];    // 尾置返回类型

因为返回类型放到了末尾,所以前面的类型用了自动推断的auto。

注意:使用数组的指针作为返回值时,数组指针的元素个数必须与返回值限定的相同

4. 返回数组引用

我们还可以使用引用来实现返回数组的目标。

int(&array_test_one(int(&arr)[5]))[5]
{
	return arr;
}
int main()
{
	int arr3[5] = { 1,3,4 };
	array_test_one(arr3);
}

数组的引用返回,同样可以使用typedef关键字或尾置返回类型来实现。

注意:使用数组的引用作为返回值时,返回数组的引用的元素个数必须与返回值限定的相同。

5. 主函数的返回值

主函数main是一个特殊函数,它是我们执行程序的入口。所以C++中对主函数的返回值也有特殊的规定:即使返回类型不是void,主函数也可以省略return语句。如果主函数执行到结尾都没有return语句,编译器就会自动插入一条:

return 0;

主函数的返回值可以看做程序运行的状态指示器:返回0表示运行成功;返回非0值则表示失败。非0值具体的含义依赖机器决定。这也是为什么之前我们在主函数中都可以不写return。

四、函数高阶

1. 内联函数

内联函数是C++为了提高运行速度做的一项优化。函数让代码更加模块化,可重用性、可读性大大提高;不过函数也有一个缺点:函数调用需要执行一系列额外操作,会降低程序运行效率。为了解决这个问题,C++引入了内联函数的概念。使用内联函数时,编译器不再去做常规的函数调用,而是把它在调用点上内联展开,也就是直接用函数代码替换了函数调用。

1.1 内联函数的使用

定义内联函数,只需要在函数声明或者函数定义前加上inline关键字。例如之前写过的函数:比较两个字符串、并返回较长的那个,就可以重写为内联函数:

inline const string& longerStr(const string& str1, const string& str2)
{
	return str1.size() > str2.size() ? str1 : str2;
}

当我们试图打印输出调用结果时:

cout << longerStr(str1, str2) << endl;

编译器会自动把它展开为:

cout << (str1.size() > str2.size() ? str1 : str2) << endl;

这样就大大提高了运行效率。

1.2 内联函数和宏

内联函数是C++新增的特性。在C语言中,类似功能是通过预处理语句#define定义宏来实现的。然而C中的宏本身并不是函数,无法进行值传递;它的本质是文本替换,我们一般只用宏来定义常量。用宏实现函数的功能会比较麻烦,而且可读性较差。所以在C++中,一般都会用内联函数来取代C中的宏。

2. 默认实参

在有些场景中,当调用一个函数时它的某些形参一般都会被赋一个固定的值。为了简单起见,我们可以给它设置一个默认值,这样就不用每次都传同样的值了。这种会反复出现的默认值,称为函数的默认实参。当调用一个有默认实参的函数时,这个实参可以省略。

2.1 定义带默认实参的函数

我们用一个string对象表示学生基本信息,调用函数时应传入学生的姓名、年龄和平均成绩。对于这些参数,我们可以指定默认实参:

// 默认实参
string stuInfo(string name = "", int age = 18, double score = 60) 
{
	string info = "学生姓名:" + name + "\t年龄:" + 
		to_string(age) + "\t平均成绩:" + to_string(score);

	return info;
}

定义默认实参,形式上就是给形参做初始化。这里在整合学生信息时,使用了运算符+进行字符串拼接,并且调用to_string函数将age和score转换成了string。这里需要注意,一旦某个形参被定义了默认实参,那它后面的所有形参都必须有默认实参。也就是说,所有默认实参的指定,应该在形参列表的末尾。

// 错误,默认实参不在形参列表末尾
//string stuInfo(string name = "", int age = 18, double score);    
// 正确,可以前面的形参没有默认实参
string stuInfo(string name, int age = 18, double score = 60);      

2.2 使用默认实参调用函数

函数调用时,如果对某个形参不传实参,那么它初始化时用的就是默认实参的值。由于之前所有形参都定义了默认实参,因此可以用不同的传参方式调用函数:

cout << stuInfo() << endl;                      // "",18, 60.0
cout << stuInfo("张三") << endl;                // "张三",18, 60.0
cout << stuInfo("李四", 20) << endl;            // "李四",20, 60.0
cout << stuInfo("王五", 22, 85.5) << endl;      // "王五",22, 85.5

//cout << stuInfo(19, 92.5) << endl;         // 错误,不能跳过前面的形参给后面传值
//cout << stuInfo(, , 59.5) << endl;         // 错误,只能省略末尾的形参

可以看到,默认实参定义时要优先放到形参列表的尾部;而调用时,只能省略尾部的参数,不能跳过前面的形参给后面传值

3. 函数重载

在C++中,同一作用域下,同一个函数名是可以定义多次的,前提是形参列表不同。这种名字相同但形参列表不同的函数,叫做重载函数。这是C++相对C语言的重大改进,也是面向对象的基础。

3.1 定义重载函数

在上一章数组形参部分,我们曾经实现过几个不同的打印数组的函数,它们是可以同时存在的:

// 使用指针和长度作为形参
void printArray(const int* arr, int size)
{
	for (int i = 0; i < size; i++)
		cout << arr[i] << "\t";

	cout << endl;
}
// 使用数组引用作为形参
void printArray(const int(&arr)[6])
{
	for (int num : arr)
		cout << num << "\t";

	cout << endl;
}
int main()
{
	int arr[6] = { 1,2,3,4,5,6 };
	printArray(arr, 6);           // 传入两个参数,调用第一种实现
	printArray(arr);              // 传入一个参数,调用第二种实现
}

这里需要注意:

  1. 重载的函数,应该在形参的数量或者类型上有所不同;
  2. 形参的名称在类型中可以省略,所以只有形参名不同的函数是一样的;
  3. 调用函数时,编译器会根据传递的实参个数和类型,自动推断使用哪个函数;
  4. 主函数不能重载;

3.2 有const形参时的重载

当形参有const修饰时,要区分它对于实参的要求到底是什么,是否要进行值的拷贝。如果是传值参数,传入实参时会发生值的拷贝,那么实参是变量还是常量其实是没有区别的:

void fun(int x);
void fun(const int x);     // int常量做形参,跟不加const等价
void fun2(int* p);
void fun2(int* const p);    // 指针常量做形参,也跟不加const等价

这种情况下,const不会影响传入函数的实参类型,所以跟不加const的定义是一样的;这叫做顶层const。这时两个函数相同,无法进行函数重载。此时const修饰的是形参本身是个常量。

另一种情况则不同,那就是传引用参数。这时如果有const修饰,就成了常量的引用;对于一个常量,只能用常量引用来绑定,而不能使用普通引用。类似地,对于一个常量的地址,只能由指向常量的指针来指向它,而不能用普通指针。

void fun3(int &x);
void fun3(const int & x);     // 形参类型是常量引用,这是一个新函数 
void fun4(int* p);
void fun4(const int* p);    // 形参类型是指向常量的指针,这是一个新函数

这种情况下,const限制了间接访问的数据对象是常量,这叫做底层const。当实参是常量时,不能对不带const的引用进行初始化,所以只能调用常量引用做形参的函数;而如果实参是变量,就会优先匹配不带const的普通引用,这就实现了函数重载。此时的const指的是引用指向的对象是常量。

我们说引用其实类似于指针常量,而常量引用则类似于指向常量的指针。对于指向常量的指针,无论该指针是否为指针常量,此时的逻辑都跟常量引用一样。

3.3 函数匹配

如果传入的实参跟形参类型不同,只要能通过隐式类型转换变成需要类型,函数也可以正确调用。那假如有几个不同的重载函数,它们的形参类型可以进行自动转换,这时传入实参应该调用哪个函数呢?例如:

void f();
void f(int x); 
void f(int x, int y); 
void f(double x, double y = 1.5);

f(3.14);      // 应该调用哪个函数?

确定到底调用哪个函数的过程,叫做函数匹配。

3.3.1 候选函数

函数匹配的第一步,就是确定候选函数,也就是先找到对应的重载函数集。候选函数有两个要求:

  1. 与调用的函数同名
  2. 函数的声明,在函数的调用点是可见的

所以上面的例子中,一共有4个叫做f的函数,它们都是候选函数。

3.3.2 可行函数

接下来需要从候选函数中,选出跟传入的实参匹配的函数。这些函数叫做可行函数。可行函数也有两个要求:

  1. 形参个数与调用传入的实参数量相等
  2. 每个实参的类型与对应形参的类型相同,或者可以转换成形参的类型

上面的例子中,传入的实参只有一个,是一个double类型的字面值常量,所以可以排除 f() 和 f(int, int) 。而剩下的 f(int) 和 f(double, double = 1.5) 都是匹配的,所以有2个可行函数。

3.3.3 寻找最佳匹配

最后就是在可行函数中,选择最佳匹配。简单来说,实参类型与形参类型越接近,它们就匹配得越好。所以,能不进行转换就实际匹配的,要优于需要转换的。

上面的例子中,f(int) 必须要将double类型的实参转换成int,而f(double, double = 1.5) 不需要,所以后者是最佳匹配,最终调用的就是它。第二个参数会由默认实参1.5来填补。

3.3.4 多参数的函数匹配

如果实参的数量不止一个,那么就需要逐个比较每个参数;同样,类型能够精确匹配的要优于需要转换的。这时寻找最佳匹配的原则如下:

  1. 如果可行函数的所有形参都能精确匹配实参,那么它就是最佳匹配
  2. 如果没有全部精确匹配,那么当一个可行函数所有参数的匹配,都不比别的可行函数差、并且至少有一个参数要更优,那它就是最佳匹配
3.3.5 二义性调用

如果检查所有实参之后,有多个可行函数不分优劣、无法找到一个最佳匹配,那么编译器会报错,这被称为二义性调用。例如:

f(10, 3.14);      // 二义性调用

这时的可行函数为 f(int, int) 和 f(double, double = 1.5)。第一个实参为int类型,f(int, int)占优;而第二个实参为double类型,f(double, double = 1.5)占优。这时两个可行函数分不出胜负,于是就会报二义性调用错误。

3.3.6 注意
  1. 如果参数分别为(int x)(int& x)也可以构成重载,优先选择前者使用。
  2. 同一个文件中,可以对一个函数,进行多次相同的声明,都会被视为一次,不会报错。

3.4 重载与作用域

重载是否生效,跟作用域是有关系的。如果在内层、外层作用域分别声明了同名的函数,那么内层作用域中的函数会覆盖外层的同名实体,让它隐藏起来。即不同的作用域中,是无法重载函数名的。

#include<iostream>
using namespace std;

void print(int i)
{
	cout << "i: " << i << endl;
}
void print(double d)
{
	cout << "d: " << d << endl;
}
void print(string s)
{
	cout << "s: " << s << endl;
}

int main()
{
	// 调用之前做函数声明
	void print(int i);
	print(10);
	print(3.14);     	// 将3.14转换为3,然后调用
	//print("hello");  	// 错误,找不到对应参数的函数定义

	cin.get();
}

如果想让函数正确地重载,应该把函数声明放到同一作用域下:

#include<iostream>
using namespace std;

// 作用域和重载测试
void print(int i)
{
	cout << "i: " << i << endl;
}
void print(double d)
{
	cout << "d: " << d << endl;
}
void print(string s)
{
	cout << "s: " << s << endl;
}

int main()
{
	print(10);
	print(3.14); 
	print("hello"); 

	cin.get();
}

4. 函数指针

一类特殊的指针,指向的不是数据对象而是函数,这就是函数指针。

4.1 声明函数指针

函数指针本质还是指针,它的类型和所指向的对象类型有关。现在指向的是函数,函数的类型是由它的返回类型和形参类型共同决定的,跟函数名、形参名都没有关系。例如之前写过的函数:

string stuInfo(string name = "", int age = 18, double score = 60) 
{
	string info = "学生姓名:" + name + "\t年龄:" + 
		to_string(age) + "\t平均成绩:" + to_string(score);

	return info;
}

它的类型就是:string(string, int, double)。如果要声明一个指向它的指针,只要把原先函数名的位置填上指针就可以了:

string (* fp) (string, int, double);    // 一个函数指针
//这里要注意,指针两侧的括号必不可少。如果去掉括号,这就变成了一个返回string *类型的函数。
string *fp(string, int, double);      // 这是一个函数,返回值为指向string的指针

更加复杂的例子也是一样,例如之前写过的比较字符串长度的函数:

const string& longerStr(const string& str1, const string& str2)
{
	return str1.size() > str2.size() ? str1 : str2;
}

对应类型的函数指针就是:

const string &(*fp) (const string &, const string &);

4.2 使用函数指针

当一个函数名后面跟调用操作符(小括号),表示函数调用;而单独使用函数名作为一个值时,函数会自动转换成指针。这一点跟数组名类似。所以我们可以直接使用函数名给函数指针赋值:

fp = longerStr;       // 直接将函数名作为指针赋给fp
fp = &longerStr;      // 取地址符是可选的,和上面没有区别

也可以加上取地址符&,这和不加&是等价的。赋值之后,就可以通过fp调用函数了。fp做解引用可以得到函数,而这里解引用符*也是可选的,不做解引用同样可以直接表示函数。

cout << fp("hello", "world") << endl;
cout << (*fp)("C++", "is good") << endl;

所以这里能够看出,函数指针完全可以当做函数来使用。在对函数指针赋值时,函数的类型必须精确匹配。当然,函数指针也可以赋nullptr,表示空指针,没有指向任何一个函数。

4.3 函数指针作为形参

有了指向函数的指针,就给函数带来了更加丰富灵活的用法。比如,可以将函数指针作为形参,定义在另一个函数中。也就是说,可以定义一个函数,它以另一个函数类型作为形参。当然,函数本身不能作为形参,不过函数指针完美地填补了这个空缺。这一点上,函数跟数组非常类似。

	//函数的声明
	void selectStr(const string& s1, const string& s2, const string & (const string&, const string&));
	//完整的定义
	void selectStr(const string& s1, const string& s2, const string & (*fp) (const string&, const string&));//函数指针类型
	void selectStr(const string& s1, const string& s2, const string & fp (const string&, const string&));//函数类型,编译器自动将fp解析为函数指针类型

同样,上面两种形式是等价的,*是可选的。很明显,对于函数类型和函数指针类型来说,这样的定义太过复杂,所以有必要使用typedef做一个类型别名的声明。

// 类型别名
typedef const string& Func(const string&, const string&);    	// 函数类型
typedef const string& (*FuncP)(const string&, const string&);	// 函数指针类型

当然,还可以用C++ 11提供的decltype函数直接获取类型,更加简洁:

typedef decltype(longerStr) Func2;
typedef decltype(longerStr) *FuncP2;

这样一来,声明函数指针做形参的新函数,就非常方便了:

void selectStr(const string&, const string&, Func);

4.4 函数指针作为返回值

类似地,函数不能直接返回另一个函数,但是可以返回函数指针。所以可以将函数指针作为另一个函数的返回值。这里需要注意的是,这种场景下,函数的返回类型必须是函数指针,而不能是函数类型。

// 函数指针作为返回值
FuncP fun(int);
//Func fun2(int);      // 错误,不能直接返回函数
Func* fun2(int);

// 尾置返回类型
auto fun3(int) -> const string& (*FuncP)(const string&, const string&); 

另外也可以使用尾置返回类型的方式,指定返回函数指针类型。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值