一、函数的基本使用
函数其实就是封装好的代码块,并且指定一个名字,调用这个名字就可以执行代码并返回一个结果。
1. 函数的定义
一个完整的函数定义主要包括以下部分:
- 返回类型:调用函数之后,返回结果的数据类型;
- 函数名:用来命名代码块的标识符,在当前作用域内唯一;
- 参数列表:参数表示函数调用时需要传入的数据,一般叫做“形参”;放在函数名后的小括号里,可以有0个或多个,用逗号隔开;
- 函数体:函数要执行的语句块,用花括号括起来。
函数一般都是一个实现了固定功能的模块,把参数看成输入,返回结果看成输出,函数就是一个输入到输出的映射关系。
我们可以定义一个非常简单的平方函数:
// 平方函数 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();
}
这里需要注意:
- 实参是形参的初始值,所以函数调用时传入实参,相当于执行了int x = 6的初始化操作;实参的类型必须跟形参类型匹配;
- 实参的个数必须跟形参一致;如果有多个形参,要按照位置顺序一一对应;实参和形参的类型必须可以相互转换;
- 如果函数本身没有参数,参数列表可以为空,但空括号不能省;
- 形参列表中多个参数用逗号分隔,每个都要带上类型,类型相同也不能省略;
- 如果函数不需要返回值,可以定义返回类型为void;
- 函数返回类型不能是数组或者函数;
3. 局部变量的生命周期
3.1 回顾变量和作用域
之前介绍过变量的作用域,对于花括号内定义的变量,具有块作用域,在花括号外就不可见了。函数体都是语句块,而主函数main本身也是一个函数;所以在main中定义的所有变量、所有函数形参和在函数体内部定义的变量,都具有块作用域,统称为局部变量。局部变量仅在函数作用域内部可见。
// 函数形参x是局部变量,作用域为函数内部
void f(int x)
{
// 函数内部定义的变量a是局部变量,作用域为函数内部
int a = 10;
}
int main()
{
// 主函数中定义的变量b也是局部变量,作用域为主函数内
int b = 0;
}
3.2 作用域和生命周期
在C++中,作用域指的是变量名字的可见范围;变量不可见,并不代表变量所指代的数据对象就销毁了。这是两个不同的概念:
- 作用域:针对名字而言,是程序文本中的一部分,名字在这部分可见;
- 生命周期:针对数据对象而言,是程序在执行过程中,对象从创建到销毁的时间段
基于作用域,变量可以分为局部变量和全局变量。对于全局变量而言,名字全局可见,对象也只有在程序结束时才销毁。而对于局部变量代表的数据对象,又可以基于生命周期分为自动对象和静态对象。
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);
这里有两点需要说明:
- #pragma once是一条预处理指令,表示这个头文件的内容只会被编译一次,这就避免了多次引入头文件时的重复定义;
- 复制字符串函数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++中表示可变形参的方式主要有三种:
- 省略符(…):兼容C语言的用法,只能出现在形参列表的最后一个位置;
- 初始化列表initializer_list:跟vector类似,也是一种标准库模板类型;initializer_list对象中的元素只能是常量值,不能更改;
- 可变参数模板:这是一种特殊的函数,后面会详细介绍。
三、函数的返回类型
函数可以通过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); // 传入一个参数,调用第二种实现
}
这里需要注意:
- 重载的函数,应该在形参的数量或者类型上有所不同;
- 形参的名称在类型中可以省略,所以只有形参名不同的函数是一样的;
- 调用函数时,编译器会根据传递的实参个数和类型,自动推断使用哪个函数;
- 主函数不能重载;
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 候选函数
函数匹配的第一步,就是确定候选函数,也就是先找到对应的重载函数集。候选函数有两个要求:
- 与调用的函数同名
- 函数的声明,在函数的调用点是可见的
所以上面的例子中,一共有4个叫做f的函数,它们都是候选函数。
3.3.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 多参数的函数匹配
如果实参的数量不止一个,那么就需要逐个比较每个参数;同样,类型能够精确匹配的要优于需要转换的。这时寻找最佳匹配的原则如下:
- 如果可行函数的所有形参都能精确匹配实参,那么它就是最佳匹配
- 如果没有全部精确匹配,那么当一个可行函数所有参数的匹配,都不比别的可行函数差、并且至少有一个参数要更优,那它就是最佳匹配
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 注意
- 如果参数分别为
(int x)
和(int& x)
也可以构成重载,优先选择前者使用。 - 同一个文件中,可以对一个函数,进行多次相同的声明,都会被视为一次,不会报错。
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&);
另外也可以使用尾置返回类型的方式,指定返回函数指针类型。