目录
函数是一个命名了的代码块,我们通过调用函数执行相应的代码。函数可以有 0 或 多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。
6.1 函数基础
一个典型的函数(function)定义包括以下部分:返回类型(return type)、函数名字、由 0 或多个形参(parameter)组成的列表及函数体。其中,形参以逗号隔开,形参的列表位于一对圆括号之内。函数执行的操作在语句块中说明,该语句块称为函数体(function body)。
我们通过调用运算符(call opeerator)来执行函数。调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针:圆括号之内是一个用逗号隔开的实参(argument)列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
编写函数
举个例子,我们准备编写一个求数的阶乘的程序。
//求数的阶乘
int fact(int val)
{
int ret = 1;
while (val > 1)
{
ret *= val--;
}
return ret;
}
调用函数
要调用 fact 函数,必须提供一个整数值,调用得到的结果也是一个整数。
int main()
{
int ret = fact(5);//求5的阶乘,结果是 120
cout << "5! = " << ret << endl;
return 0;
}
函数的调用完成两项工作:一是用实参初始化函数对应的形参,二是将控制权转义给被调用函数。此时,主调函数(calling function)的执行被暂时终端,被调函数(called function)开始执行。
- 执行函数的第一步:(隐式地)定义并初始化它的形参。这里首先创建一个名为 val 的 int 变量,然后将它初始化为调用时所用的实参 5 。
- 当遇到一条 return 语句时,函数结束执行过程。和函数调用一样,return 语句也完成两项工作:一是返回 return 语句中的值(如果有的话),二是将控制权从被调函数转移回主调函数。
形参和实参
实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推。
实参的类型必须与对应的形参类型匹配。数量也必须一致。
函数的形参列表
函数的形参列表可以为空,但是不能省略。
函数的返回类型
函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
一种特殊的返回类型是 void ,它表示函数不返回任何值。
6.1 节练习
练习 6.1:实参和形参的区别是什么?
形参出现在函数定义的地方,形参列表可以包含0个、1个或多个形参,多个形参以逗号分隔。形参规定了一个函数所接受数据的类型和数量。
实参出现在函数调用的地方,实参的数量和形参一样多。实参的主要作用是初始化形参,并且这种初始化过程是一一对应的,即第一个实参初始化第一个形参、第二个实参初始化第二个形参,以此类推。实参的类型必须与对应的形参列表类型匹配。
//练习6.4:编写一个与用户交互的函数,要求用户输入一个数字,计算生成该数字的阶乘,在main函数中调用该函数
int test01(int n)
{
int ret = 1;
if (n < 0)
{
return -1;
}
for (int i = 1; i != n + 1; ++i)
{
ret *= i;
}
return ret;
}
int main()
{
int n = 0;
cout << "请输入一个整数:";
cin >> n;
cout << n << "的阶乘是:" << test01(n) << endl;
system("pause");
return 0;
}
//练习6.5:编写一个函数,输出其实参的绝对值
double myABS(double val)
{
if (val < 0)
{
return val * -1;
}
else
return val;
//2.调用 cmath 头文件的 abs 函数计算绝对值
return abs(val);
}
6.1.1 局部对象
在C++ 语言中,名字有作用域,对象有生命周期(lifetime)。理解这两个概念非常重要。
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命周期是程序执行过程中该对象存在的一段时间。
形参和函数体内部定义的变量统称为局部变量(local variable),仅在函数的作用域内可见,同时局部变量还会隐藏(hide)在外层作用域中同名的其他所有声明中。
在函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。
自动对象
对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象(automatic object)。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。
形参是一种自动对象。
局部静态对象
某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成 static 类型从而获得这样的对象。局部静态变量(local static object)在程序执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
6.1.1 节练习
练习6.6:说明形参、局部变量以及局部静态变量的区别。编写一个函数,同时用到这三种形式。
形参和定义在函数体内部的变量统称为局部变量,它们对于函数而言是局部的,仅在函数的作用域内可见。函数体内的局部变量又分成普通局部变量和静态局部变量,对于形参和普通局部变量来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象。这几个概念的区别是:
- 形参是一种自动对象,函数开始时为形参申请内存空间,我们用调用函数时提供的实参初始化形参对应的自动对象。
- 普通变量对应的自动对象也容易理解,我们在定义该变量的语句处创建自动对象,如果定义语句提供了初始值,则用该值初始化;否则,执行默认初始化。当该变量所在块结束后,变量失效。
- 局部静态变量比较特殊,它的生命周期贯穿函数调用及之后的时间。局部静态变量对应的对象称为局部静态对象,它的生命周期从定义语句处开始,直到程序结束才终止。
#include<iostream>
using namespace std;
//同时使用形参、普通局部变量和静态局部变量
double myADD(double val1, double val2)//val1 和 val2 是形参
{
double result = val1 + val2; //result 是普通局部变量
static unsigned iCnt = 0; //iCnt是静态局部变量
++iCnt;
cout << "该函数已经累计执行了" << iCnt << "次" << endl;
return result;
}
int main()
{
double num1, num2;
cout << "请输入两个数:";
while (cin >> num1 >> num2)
{
cout << num1 << "与" << num2 << "的求和结果是:"
<< myADD(num1, num2) << endl;
}
system("pause");
return 0;
}
测试了一下,如果去掉 static,每次输出的执行次数都是 1 次。
//练习6.7:编写一个函数,当它第一次被调用时返回0,以后每次被调用返回值加1
unsigned myCnt()
{
static unsigned iCnt = -1;
++iCnt;
return iCnt;
}
int main()
{
cout << "请输入任意字符后按回车键继续" << endl;
char ch;
while (cin >> ch)
{
cout << "函数 myCnt() 的执行次数是:" << myCnt() << endl;
}
return 0;
}
6.1.2 函数声明
函数的名字必须在使用之前声明,函数只能定义一次,但可以声明多次。
函数声明无须函数体,用一个分号替代即可。
因为函数的声明不包含函数体,所以也就无须形参的名字。
函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型(function prototype)。
在头文件中进行函数声明
我们建议变量在头文件中声明,在源文件中定义。与之类似,函数也应该在头文件中声明而在源文件中定义。
含有函数声明的头文件应该被包含到定义函数的源文件中。
6.1.2 节练习
练习 6.8:编写一个名为 Chapter6.h 的头文件令其包含 6.1 节练习中的函数声明
#pragma once
#ifdef CHAPTER6_H_INCLUDED
#define CHAPTER6_H_INCLUDED
int f();
void f2(int i);
int calc(int v1, int v2);
double square(double x);
double myABS(double);
double myABS2(double);
int fact(int);
#endif // CHAPTER6_H_INCLUDED
6.1.3 分离式编译
C++ 语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
编译和链接多个源文件
假设 fact 函数的定义位于一个名为 fact.cc 的文件中,它的声明位于 Chapter6.h 的头文件中。 fact.cc应该包含 Chapter6.h 头文件。另外,我们在名为 factMain.cc的文件中创建 main 函数,main函数将调用 fact 函数。要生成可执行文件(executable file),必须告诉编译器我们要用到的代码在哪里。对于上述几个文件来说,编译过程如下所示:
$ CC factMain.cc fact.cc # generates factMain.exe or a.out
$ CC factMain.cc fact.cc -o main # generates main or main.exe
其中, CC 是编译器的名字,$是系统提示符, # 后面是命令行下的注释语句。接下来运行可执行文件,就会执行我们定义的 main 函数。
如果修改了其中一个源文件,那么只需重新编译那个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是 .obj(Windows)或 .o(UNIX)的文件,后缀名的含义是该文件包含对象代码(object code)。
在我们的系统中,编译过程如下:
$ CC -c factMain.cc # generates factMain.o
$ CC -c fact.cc # generates fact.o
$ CC factMain.o fact.o # generates factMain.exe or a.out
$ CC factMain.o fact.o -o main # generates main or main.exe
6.2 参数传递
形参初始化的机理与变量初始化一样。
形参的类型决定了形参和实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。
当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by value)。
6.2.1 传值参数
当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值:
int n = 0; //int类型的初始变量
int i = n; //i 是 n的值的副本
i = 42; // i 的值改变; n 的值不变
传值参数的机理完全一样,函数对形参做的操作都不会影响实参。
指针形参
指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地修改它所指的对象,所以通过指针可以修改它所指对象的值:
int n = 0, i = 42;
int *p = &n, *q = &i; //p指向 n;q指向 i
*p = 42; //n的值改变;p不变
p = q; //p现在指向了 i;但是 i 和 n的值都不变
指针形参的行为与之类似。
6.2.1 节练习
//编写一个函数,使用指针形参交换两个整数的值。在代码中调用该函数并输出交换后的结果,以此验证函数的正确性
void mySwap(int *p,int *q)
{
int tmp = *p;
*p = *q;
*q = tmp;
}
int main()
{
int a = 10,b = 20;
cout << "交换前" << endl;
cout << "a= " << a << " b= " << b << endl;
mySwap(&a, &b);
cout << "交换后" << endl;
cout << "a= " << a << " b= " << b << endl;
system("pause");
return 0;
}
6.2.2 传引用参数
回忆过去所学知识,我们知道对于引用的操作实际上是作用在引用所引的对象上。
int n = 0, i = 42;
int &r = n; //r 绑定了 n(即 r 是 n 的另一个名字)
r = 42; //现在 n 的值是 42
r = i; //现在 n 的值和 i 相同
i = r; //i 的值和 n相同
引用形参的行为与之类似。通过使用引用形参,允许函数改变一个或多个实参的值。
举例,
void reset(iny &i) //i是传给 reset函数的对象的另一个名字
{
i = 0; //改变了 i 所引对象的值
}
int j =42;
reset(j); //直接传入对象,而无需传递对象的地址
cout<<" j = "<<j<<endl; //输出 j = 0
使用引用避免拷贝
拷贝大的类型对象或者容器对象比较低效,甚至有的类型(包括 IO 类型在内)根本不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参来访问该类型的对象。
如果函数无须改变引用形参的值,最好将其声明为常量引用。
使用引用形参返回额外信息
一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。
6.2.2 节练习
//练习6.11:编写并验证你自己的 reset 函数,使其作用于引用类型的参数
void reset(int &i)
{
i =0;
}
//练习6.12:改写6.2.1节中练习6.10的程序,使用引用而非指针交换两个整数的值。你觉得哪种方法更易于使用呢?为什么?
void mySwap2(int &p, int &q)
{
int tmp = p;
p = q;
q = tmp;
}
//调用函数
int a=10,b=20;
mySwap(a,b);
与使用指针相比,使用引用交换变量的内容从形式上看更简单一些,并且无需额外声明指针变量,也避免了拷贝指针。
练习6.13:假设 T 是某种类型的名字,说明以下两个函数声明的区别:一个是 void f(T),另一个是 void f(&T)。
void f(T) 的形参采用的是传值方式,也就是说,实参的值被拷贝给形参,形参和实参是两个相互独立的变量,在函数 f 内部对形参所做的任何改动都不会影响实参的值。
void f(&T) 的形参采用的传引用方式,此时形参是对应的实参的别名,形参绑定到初始化它的对象。如果我们改变了形参的值,也就是改变了对应实参的值。
练习6.14:举一个形参应该是引用类型的例子,再举一个形参不能是引用类型的例子。
适合使用引用类型形参的例子:第一、当函数的目的是交换两个参数的内容时应该使用引用类型的形参;第二、当参数是 string 对象时,为了避免拷贝很长的字符串应该使用引用类型。
在其他情况下可以使用值传递的方式,而无须使用引用传递,例如求整数的绝对值或者阶乘的程序。
练习6.15:说明 find_char 函数中的三个形参为什么是现在类型,特别说明为什么 s 是常量引用而 occurs 是普通引用?为什么 s 和 occurs 是引用类型而 c 不是?如果令 s 是普通引用会发生什么情况?如果令 occurs 是常量引用会发生什么情况?
- 对于待查找的字符串 s 来说,为了避免拷贝长字符串,使用引用类型;同时我们只执行查找操作,无须改变字符串的内容,所以将其声明为常量引用。
- 对于待查找的字符 c 来说,它的类型是 char ,只占 1 字节,拷贝的代价很低,而且我们无须操作实参在内存中实际存储的内容,只把它的值拷贝给形参即可,所以不需要使用引用类型。
- 对于字符出现次数 occurs 来说,因为需要把函数内对实参值的更改反映在函数外部,所以必须将其定义成引用类型;但是不能把它定义成常量引用,否则就不能改变所引的内容了。
6.2.3 const 形参和实参
当形参是 const 时,必须要注意关于顶层 const(2.4.3 节) 的讨论。如前所述,顶层 const 作用于对象本身:
const int ci = 42; //不能改变 ci,const 是顶层的
int i = ci; //正确:当拷贝 ci 时,忽略了它的顶层 const
int * const p = &i; //const 是顶层的,不能给 p 赋值
*p = 0; //正确:通过 p 改变对象的内容是允许的,现在 i 变成了 0
和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层 const。换句话说,形参的顶层 const 被忽略掉了。当形参有顶层 const 时,传给它常量对象或者非常量对象都是可以的:
void fcn(const int i){/* fcn 能够读取 i,但不能向 i 写值*/}
调用 fcn 函数时,既可以传入 const int 也可以传入 int。忽略掉形参的顶层 const 可能产生意想不到的效果:
void fcn(const int i) { /* fcn 能够读取 i,但是不能向 i 写值 */}
void fcn(int i) {/*...*/} //错误:重复定义了 fcn(int)
在C++ 语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。因为顶层 const 被忽略掉了,所以在上面的代码中传入两个 fcn 函数的参数可以完全一样。因此第二个 fcn 是错误的,尽管形式上有差异,但实际上它的形参和第一个 fcn 的形参没什么不同。
指针或引用形参与 const
形参的初始化方式和变量的初始化方式是一样的。我们可以使用非常量初始化一个底层 const 对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。
将同样的初始化规则应用到参数传递上可得如下形式:
要想调用引用版本的 reset,只能使用 int 类型的对象,而不能使用字面值、求值结果为 int 的表达式、需要转换的对象或者 const int 类型的对象。
类似地,要想调用指针版本的 reset 只能使用 int*。
另一方面,我们能传递一个字符串字面值作为 find_char 的第一个实参,这是因为该函数的引用形参是常量引用,而C++ 允许我们用字面值初始化常量引用。
尽量使用常量引用
把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型。就像刚刚看到的,我们不能把 const 对象、字面值或者需要类型转换的对象传递给普通的引用形参。
6.2.3 节练习
练习6.16:下面的这个函数虽然合法,但是不算特别有用。指出它的局限性并设法改善。
bool is_empty(string &s) { return s.empty();}
本题的程序把参数类型设为非常量引用,这样做有几个缺陷:一是容易给使用者一种误导,即程序允许修改变量 s 的内容;二是限制了该函数所能接受的实参类型,我们无法把 const 对象、字面值常量或者需要进行类型转换的对象传递给普通的引用形参。
应该修改为:
bool is_empty(const string &s) { return s.empty();}
//练习6.17:编写一个函数,判断 string 对象中是否含有大写字母。
bool hasUpper(const string &s)
{
bool ret;
for (auto c : s)
{
if (isupper(c))
{
ret = true;
}
ret = false;
}
return ret;
}
//编写另一个函数,把 string 对象全都改成小写形式。
void toLower(string &s)
{
for (auto &c : s)
{
c = tolower(c);
}
}
//在这两个函数中你使用的形参类型相同吗?为什么?
不同,第一个函数无须修改参数的内容,因此将其设为常量引用类型;第二个函数需要修改参数的内容,
所以应该设定为非常量引用类型
练习 6.18:为下面的函数编写函数声明,从给定的名字中推测函数具备的功能。
(a)名为 compare 的函数,返回布尔值,两个参数都是 matrix 类的引用。
bool compare(const matrix &,const matrix &);
(b)名为 change_val 的函数,返回 vector<int> 的迭代器,有两个参数:一个是 int ,另一个是 vector<int> 的迭代器。
vector<int>::iterator change_val(int , vector<int>::iterator);
练习 6.19:假定有如下声明,判断哪个调用合法、哪个调用不合法。对于不合法的函数调用,说明原因。
double calc(double);
int count(const string &,char);
int sum(vector<int>::iterator,vector<int>::iterator,int);
vector<int> vec(10);
(a)calc(23.4, 55.1); (b)count("abcda", 'a');
(c)calc(66); (d)sum(vec.begin(),vec.end(),3.8);
(a)不合法,函数声明和函数调用的参数个数不匹配。
(b)合法,字面值常量可以作为常量引用形参的值,字符 'a' 作为 char 类型形参的值也是可以的。
(c)合法,66 在调用函数时可以自动转换为 double 类型。
(d)合法,v.begin() 和 v.end() 的类型都是形参所需的 vector<int>::iterator,第三个实 参3.8 可以自动转换为形参所需的 int 类型。
练习 6.20:引用形参什么时候应该是常量引用?如果形参应该是常量引用,而我们将其设为了普通引用,会发生什么情况?
如果需要修改参数的内容,则将其设置为普通引用类型;否则,如果不需要对参数内容做任何更改,最好设为常量引用类型。
可能会遇到几个问题:一、容易给使用者一种误导,即程序允许修改实参的内容;二、限制了该函数所能接受的实参类型,无法把 const 对象、字面值常量或者需要类型转换的对象传递给普通的引用形参。
6.2.4 数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组;以及使用数组时(通常)会将其转换成指针。因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
//尽管形式不同,但这三个print 函数是等价的
//每个函数都有一个 const int* 类型的形参
void print(const int*);
void print(const int []); //可以看出来,函数的意图是作用于一个数组
void print(const int [10]); //这里的维度表示我们期望数组含有多少元素,实际不一定
尽管表现形式不同,但上面的三个函数是等价的:每个函数的唯一形参都是 const int * 类型的。当编译器处理对 print 函数的调用时,只检查传入的参数是否是 const int* 类型。
int i = 0, j[2] = {0, 1};
print(&i); //正确:&i的类型是 int*
print(j); //正确:j 转换成 int* 并指向 j[0]
如果我们传给 print 函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。
因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用的技术。
使用标记指定数组长度
管理数组实参的第一种方法是要求数组本身包含一个结束标记。使用这种方法的典型示例是 C 风格字符串。C 风格字符串存储在字符数组中,并且在最后一个字符后面跟着一个空字符。
这种方法适用于那些有明显结束标记且该标记不会与普通数据混淆的情况,但是对于像 int 这样所有取值都是合法值的数据就不太有效了。
使用标准库规范
管理数组实参的第二种技术是传递指向数组首元素和尾后元素的指针。
我们可以使用标准库 begin 和 end 函数提供所需的指针。
显式传递一个表示数组大小的形参
第三种管理数组实参的方法是专门定义一个表示数组大小的形参 。
数组形参和 const
当函数不需要对数组元素执行写操作的时候,数组形参应该是指向 const 的指针。只有当函数确实要改变元素值的时候,才把形参定义成非常量的指针。
数组引用形参
C++允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上。
//正确:形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10])
{
for(auto elem : arr)
cout<< elem << endl;
}
&arr 两端的括号必不可少。
f (int &arr[10]) //错误:将 arr 声明成了引用的数组
引用的数组:字面意思可以理解为定义一个数组,数组中存放的元素都是"引用",但这个明显就是不合法的,因为引用只是取了一个别名,本身并不占用内存,而数组是会占用内存的,所以引用的数组是错误的。
f (int (&arr)[10]) //正确: arr 是具有10个整数的整型数组的引用
传递多维数组
所谓多维数组其实就是数组的数组。
当多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略。
6.2.4 节练习
//练习6.21:编写一个函数,令其接受两个参数:一个是 int 型的数,另一个是 int 指针。
//函数比较 int 的值和指针所指的值,返回较大的那个。在该函数中指针的类型应该是什么?
int myCompare(const int a, const int * b)
{
if (a > *b)
{
return a;
}
else if (a == *b)
{
cout << "两个一样大" << endl;
}
else
{
return *b;
}
}
//练习6.22:编写一个函数,令其交换两个 int 指针
//这里交换的指针本身的值,即交换指针所指的内存地址
void swapPointer(int* &p, int* &q)
{
int *tmp = p;
p = q;
q = tmp;
}
int main()
{
int a = 10, b = 20;
int *p = &a, *q = &b;
cout << "交换前:" << endl;
cout << "p= " << p << " q= " << q << endl;
swapPointer(p, q);
cout << "交换后:" << endl;
cout << "p= " << p << " q= " << q << endl;
return 0;
}
//这里交换的指针指向的内容
void swapPointer(int* p, int* q)
{
int tmp = *p;
*p = *q;
*q = tmp;
}
//练习6.23:参考本节介绍的几个 print 函数,根据自己的理解写出函数。
//依次调用每个函数使其输入下面定义的 i 和 j:
//int i = 0,j[2] = {0,1}
//参数是常量整型指针
void print1(const int *p)
{
cout << *p << endl;
}
//参数有两个,分别是常量整型指针和数组的容量
void print2(const int *p, const int sz)
{
int i = 0;
while (i != sz)
{
cout << *p++ << endl;
++i;
}
}
//参数有两个,分别是数组的首尾边界
void print3(const int *b, const int *e)
{
for (auto q = b; q != e; ++q)
{
cout << *q;
}
}
//函数调用
int i = 0, j[2] = { 0,1 };
print1(&i);
print1(j);
print2(&i, 1);
//计算得到数组 j 的容量
print2(j, sizeof(j) / sizeof(*j));
auto b = begin(j);
auto e = end(j);
print3(b, e);
练习6.24:描述下面这个函数的行为。如果代码中存在问题,请指出并改正。
void print(const int ia[10])
{
for (size_t i = 0; i != 10; ++i)
{
cout << ia[i] << endl;
}
}
存在问题:上面的10只表示我们期望传入的数组维度是10,但实际上任意维度的数组都可以传入。如果传入的数组维度不足10,则 print 函数将强行输出一些未定义的值。如果传入的数组维度较大,print 函数输出数组的前 10 个元素。可修改为以下程序:
void print(const int ia[10],const int sz)
{
for (size_t i = 0; i != sz; ++i)
{
cout << ia[i] << endl;
}
}
6.2.5 main:处理命令行选项
到目前为止,我们定义的 main 函数都只有空形参列表。
然而,有时我们确实需要给 main 传递实参,一种常见情况是用户通过设置一组选项来确定函数所要执行的操作。例如,假定 main 函数位于可执行文件 prog 之内,我们可以向程序传递下面的选项:
prog -d -o ofile data0
这些命令行选项通过两个(可选的)形参传递给 main 函数:
int main(int argc, char *argv[]) { ... }
第二个形参 argv 是一个数组,它的元素是 C风格字符串的指针;第一个形参 argc 表示数组中字符串的数量。因为第二个形参是数组,所以 main 函数也可以定义成:
int main(int argc, char **argv) { ... } //argv 指向 char*
当实参传递给 main 函数之后, argv 的第一个元素指向程序的名字或一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0。
以上面提供命令行为例, argc 应该等于 5, argv 应该包含如下的C风格字符串:
argv [0] = "prog";
argv [1] = "-d";
argv [2] = "-o";
argv [3] = "ofile";
argv [4] = "data0";
argv [5] = 0;
当使用 argv 的实参时,一定要记得可选的实参从 argv[1] 开始;argv[0]保存程序的名字,而非用户输入。
6.2.5 节练习
#include<iostream>
#include<string>
using namespace std;
//练习6.25:编写一个 main 函数,令其接受两个实参。把实参的内容连接成一个 string 对象并输出来
int main(int argc, char *argv[])
{
string str;
for (int i = 0; i != argc; ++i)
{
str += argv[i];
}
cout << str << endl;
return 0;
}
#include<iostream>
#include<string>
using namespace std;
//练习6.26:编写一个 main 函数,令其接受本节所示的选项;输出传递给 main 函数的实参的内容
int main(int argc, char *argv[])
{
for (int i = 0; i != argc; ++i)
{
cout << "argv[" << i << "]:" << argv[i] << endl;
}
return 0;
}
6.2.6 含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11 新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个名为 initializer_list 的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板(将在16.4节介绍)。
C++ 还有一种特殊的形参类型(即省略符),可以用它专递可变数量的实参。本节将简要介绍省略符形参,不过需要注意的是,这种功能一般只用于与 C 函数交互的接口程序。
initalizer_list 形参
如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用 initializer_list 类型的形参。initializer_list 是一种标准库类型,用于表示某种特定类型的值的数组。initializer_list类型定义在同名的头文件中,它提供的操作如下表:
initializer_list<T> lst; | 默认初始化;T类型元素的空列表 |
initializer_list<T> lst {a,b,c} | lst 的元素数量和初始值一样多; lst 的元素是对应初始值的副本;列表中的元素的 const |
lst2(lst); lst2 = lst; | 拷贝或赋值一个 initializer_list 对象不会拷贝列表中元素;拷贝后,原始列表和副本共享元素 |
lst.size() | 列表中的元素数量 |
lst.begin() | 返回指向 lst 中首元素的指针 |
lst.end() | 返回指向 lst 中尾元素下一位置的指针 |
和 vector 一样,initializer_list 也是一种模板类型,定义 initializer_list 对象时,必须说明列表中所含元素的类型。
initializer_list<string> ls; //initializer_list 的元素类型是 string
initializer_list<int> li; //initializer_list 的元素类型是 int
和 vector 不一样的是,initializer_list 对象中的元素永远是常量值,我们无法改变 initializer_list 对象中元素的值。
initializer_list 也有 begin 和 end 函数。
省略符形参
省略符形参是为了便于 C++ 程序访问某些特殊的 C 代码而设置的,这些代码使用了名为 varargs 的C 标准库功能。通常,省略符形参不应用于其他目的。
省略符形参应该仅仅应用于 C 和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list,...);
void foo(...);
第一种形式指定了 foo 函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参所对应的实参无须类型检查。在第一种形式中,形参声明后面的逗号是可选的。
6.2.6 节练习
//练习6.27:编写一个函数,它的参数是 initializer_list<int>类型的对象,
//函数的功能是计算列表中所有元素的和
int iCount(initializer_list<int> li)
{
int count = 0;
//遍历 li 的每一个元素
for (auto val : li)
{
count += val;
}
return count;
}
//调用函数
cout << "1,6,9的和是:" << iCount({ 1,6,9 }) << endl;
练习6.28:在 error_msg 函数的第二个版本中包含 ErrCode 类型的参数,其中循环内的 elem 是什么类型?
initializer_list<string>的所有元素类型都是 string ,因此 const auto &elem : il 推断得到的 elem 的类型是 const string&。使用引用是为了避免拷贝长字符串,把它定义为常量的原因的我们只需读取字符串的内容,不需要修改它。
练习6.29:在范围 for 循环中使用 initializer_list 对象时,应该将循环控制变量声明成引用类型吗?为什么?
引用类型的优势主要是可以直接操作所引用的对象以及避免拷贝较为复杂的类型对象和容器对象。因为 initializer_list 对象的元素永远是常量值,所以我们不可能通过设定引用类型来更改循环控制变量的内容。只有当 initializer_list 对象的元素类型是类类型或容器类型(比如 string)时,才有必要把范围 for 循环的循环控制变量设为引用类型。
6.3 返回类型和 return 语句
return 语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。return 语句有两种形式:
return;
return expression;
6.3.1 无返回值函数
没有返回值的 return 语句只能用在返回类型是 void 的函数中。返回 void 的函数不要求非得有 return 语句,因为在这类函数的最后一句后面会隐式地执行 return。
通常情况下, void 函数如果想在它的中间位置提前退出,可以使用 return 语句。例如,可以编写一个 swap 函数,使其在参与交换的值相等时什么也不做直接退出:
void swap(int &v1, int &v2)
{
//如果两个值相等,则不需要交换,直接退出
if (v1 == v2)
{
return;
}
int tmp = v2;
v2 = v1;
v1 = tmp;
//此处无显式的 return 语句
}
一个返回值是 void 的函数也能使用 return 语句的第二种形式,不过此时 return 语句的 expression 必须是另一个返回 void 的函数。强行令 void 函数返回其他类型的表达式将产生错误。
6.3.2 有返回值函数
return 语句的第二种形式提供了函数的结果。只要函数的返回类型不是 void ,则该条函数内的每条 return 语句必须返回一个值。return 语句返回值的类型必须与函数的返回类型相同,或者可以隐式转换。
在含有 return 语句的循环后面应该也有一条 return 语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
不要返回局部对象的引用或指针
函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。
const string &manip()
{
string ret;
//以某种方式改变一下 ret
if (!ret.empty())
{
return ret; //错误:返回局部对象的引用!
}
else
{
return "empty";//错误:"empty"是一个局部临时量
}
}
上面的两条 return 语句都将返回未定义的值,也就是说,试图使用 manip 的返回值将引发未定义的行为。
如前所述,返回局部对象的引用是错误的;同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的值。
返回类类型的函数和调用运算符
和其他运算符一样,调用运算符也有优先级和结合律。调用运算符的优先级与点运算符和箭头运算符相同,并且也符合左结合律。因此,如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员。
引用返回左值
函数的返回类型决定函数调用是否是左值(4.1.1节)。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值。
列表初始化返回值
C++11 新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。
如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。
主函数 main 的返回值
我们允许 main 函数没有 return 语句直接结束。如果控制到达了 main函数的结尾处而且没有return 语句,编译器将隐式地插入一条返回 0 的return 语句。
main 函数的返回值可以看做是状态指示器。返回 0 表示执行成功,其他值表示执行失败,其中非 0 值的具体含义依机器而定。
递归
如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数(recursive function)。
main 函数不能调用它自己。
6.3.2 节练习
练习6.30:编译下面的函数,看看你的编译器是如何处理函数中的错误的。
bool str_subrange(const string &str1, const string &str2)
{
//大小相同:此时用普通的相等性判断结果作为返回值
if (str1.size() == str2.size())
return str1 == str2; //正确:== 运算返回布尔值
//得到较短string 对象的大小
auto size = (str1.size() < str2.size())
? str1.size() : str2.size();
//检查两个string对象的对应字符是否相等,以较短的字符串长度为限
for (decltype(size) i = 0; i != size; ++i)
{
if (str1[i] != str2[i])
return;//错误1:没有返回值,编译器将报告这一错误
}
//错误2:控制流可能尚未返回任何值就结束了函数的执行
//编译器可能检查不出这一错误
}
练习 6.31:什么情况下返回的引用无效?什么情况下返回常量的引用无效?
如果引用所引的是函数开始之前就已经存在的对象,则返回该引用是有效的;如果引用所引的是函数的局部变量,则随着函数结束局部变量也失效了,此时返回的引用无效。
当不希望返回的对象被修改时,返回对常量的引用。
//练习6.33:编写一个递归函数,输出 vector对象的内容
void print(vector<int> vInt, unsigned index)
{
unsigned sz = vInt.size();
if (!vInt.empty() && index << sz)
{
cout << vInt[index] << endl;
print(vInt, index + 1);
}
}
6.3.3 返回数组指针
因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。
从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是可以使用类型别名简化这一任务。
typedef int arrT[10]; //arrT是一个类型别名,它表示的类型是含有10个
using arrT= int[10]; //arrT是等价声明
arrT* func(int, i); //func 返回一个指向含有10个整数的数组的指针
声明一个返回数组指针的函数
要想在声明 func 时不使用类型别名,我们必须牢记被定义的名字后面数组的维度。
int arr[10]; //arr是一个含有10个整数的数组
int *p1[10]; //p1是一个含有10个指针的数组
int (*p2)[10]; //p2是一个指针,它指向含有10个整数的数组
和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。但是,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下:
Type (*function(parameter_list)) [dimension]
Type 表示元素的类型,dimension 表示数组的大小。(*function(parameter_list))两端的括号必须存在。如果不存在,函数的返回将是指针的数组。具体例子如下:
int (*(int i))[10];
可以按照以下的顺序来逐层理解该声明的含义:
- func(int i)表示调用 func函数时需要一个 int 类型的实参。
- (*func(int i))意味着我们可以对函数调用的结果执行解引用操作。
- (*func(int i))[10]表示解引用 func 的调用将得到一个大小是 10 的数组。
- int (*func(int i))[10]表示数组中的元素是 int 类型。
使用尾置返回类型
在C++11 新标准中还有一种可以简化上述 func 声明的方法,就是使用尾置返回类型(trailing return type)。尾置返回类型跟在形参后面并以一个 -> 符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本该出现返回类型的地方放置一个 auto:
//func 接受一个 int 类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*)[10];
使用 decltype
还有一种情况, 如果我们知道函数返回的指针将指向哪个数组,就可以使用 decltype 关键字声明返回类型。比如下面的例子:
int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
//返回一个指针,该指针指向含有 5 个整数的数组
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? &odd : &even;//返回一个指向数组的指针
}
arrPtr 使用关键字 decltype 表示它的返回类型是个指针,并且该指针所指的对象与 odd 的类型一致。
6.3.3 节练习
//练习 6.36:编写一个函数声明,使其返回数组的引用并且该数组包含10个 string对象。
//不要使用尾置返回类型、decltype或者类型别名。
string(&function())[10];
//练习6.37:为上一题的函数再写三个声明,一个使用类型别名,另一个使用尾置返回类型
//最后一个使用 decltype关键字。你觉得哪种最好,为什么?
//使用类型别名
typedef string arrT[10];//arrT是一个类型别名,它表示的类型是含有10个 string 元素的数组
arrT& func1();
//使用尾置返回类型
auto func2()->string(&)[10];
//使用decltype关键字
string arr[10];
decltype(arr) & func3();
//练习 6.38:修改 arrPtr 函数,使其返回数组的引用。
int odd[] = { 1,3,5,7,9 };
int even[] = { 0,2,4,6,8 };
//返回一个引用,该引用所引的对象是一个含有 5 个整数的数组
decltype(odd) &arrPtr(int i)
{
return (i % 2) ? odd : even;
}
6.4 函数重载
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载(overloaded)函数。
函数重载可以在一定程度上减轻程序员起名字、记名字的负担。
main 函数不能重载。
定义重载函数
对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。只有返回类型不同是错误的。
判断两个形参的类型是否相异
有时候两个形参列表看起来不一样,但实际上是相同的:
//每对声明的是同一个函数
Record lookup(const Account &acct);
Record lookup(const Account &); //省略了形参名字
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); //Telno 和 Phone 的类型相同
重载和 const 形参
顶层 const 不影响传入函数的对象。一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区别分开来:
Record lookup(Phone);
Record lookup(const Phone); //重复声明了Record lookup(Phone)
Record lookup(Phone*);
Record lookup(Phone* const); //重复声明了Record lookup(Phone*)
另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的 const 是底层的:
//对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同
//定义了 4 个独立的函数
Record lookup(Account &); //函数作用于 Account 的引用
Record lookup(const Account &); //新函数,作用于常量引用
Record lookup(Account *); //新函数,作用于指向 Account 的指针
Record lookup(const Account *); //新函数,作用于指向常量的指针
建议:何时不应该重载函数
是否重载函数要看哪个更容易理解。
const_cast 和重载
调用重载的函数
函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫重载确定(overloaded resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
当调用重载函数时有三种可能的结果:
- 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用(ambiguous call)。
6.4 节练习
(a)第二个声明非法。因为顶层 const 不影响传入函数的对象,所以一个拥有顶层 const 的形参无法与另一个没有顶层 const 的形参区分开来。
(b)第二个声明非法。C++规定重载函数必须在形参数量或形参类型上有所区别。如果两个同名函数的形参数量和类型都一样,那么即使返回类型不同也不行。
(c)的两个函数是重载关系,它们的形参类型有区别。
6.4.1 重载与作用域
一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明。
重载对作用域的一般性质并没有什么改变:如果我们在内层作用域中声明名字,它将隐藏外层作用域声明的同名实体。在不同作用域中无法重载函数名。
string read();
void print(const string &);
void print(double); //重载print函数
void fooBar(int iVal)
{
bool read = false;//新作用域:隐藏了外层的read
string s = read();//错误:read是一个布尔值,而非函数
//不好的习惯:通常来说,在局部作用域中声明函数不是一个好的选择
void print(int);
print("value:"); //错误:void print(const string &)被隐藏掉了
print(iVal); //正确:当前print(int)可见
print(3.14); //正确:调用print(int);void print(double)被隐藏掉了
}
当调用 print 函数时,编译器首先寻找对该函数名的声明,找到的是接受 int 值的那个局部声明。一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。
在C++语言中,名字查找发生在类型检查之前。
6.5 特殊用途语言特性
本节将介绍三种函数相关的语言特性,它们分别是:默认实参、内联函数和 constexpr函数。
6.5.1 默认实参
某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。
注意,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
使用默认实参调用函数
如果想使用默认实参,只要在调用函数的时候省略该实参就可以了。
当设计含有默认实参的函数时,其中一项任务是设置形参的顺序,尽量让不怎么使用默认值的形参出现在起那么,让那些经常使用默认值的形参出现在后面。
默认实参声明
对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。
不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。也就是说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。
默认实参初始值
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。
6.5.1节练习
(b)是错误的,C++规定一旦某个形参被赋予了默认实参,则它后面的所有形参都必须有默认实参。
(a)是非法的,该函数有两个默认实参,但是总计有三个形参,其中第一个形参并未设定默认实参,所以想要调用该函数,至少需要提供一个实参。
(c)虽然合法,但与程序员的初衷不符。第二个实参的类型是 char,但可以自动转换成 wd 所需的 int 类型,编译可以通过,但是这显然违背了程序的原意。
6.5.2 内联函数和 constexpr
内联函数可避免函数调用的开销
将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。
在函数的返回类型前面加上关键字 inline就可以将它声明成内联函数了。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个 75 行的函数也不大可能在调用点内联地展开。
constexpr 函数
constexpr 函数(constexpr function)是指能用于常量表达式的函数。定义 constexpr 函数的方法与其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条 return 语句。
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz(); //正确:foo 是一个常量表达式
constexpr 函数不一定返回常量表达式。
把内联函数和 constexpr 函数放在头文件中
和其他函数不一样,内联函数和 constexpr 函数可以在程序中多次定义。但是它的多个定义必须完全一致,基于这个原因,内联函数和 constexpr 函数通常定义在头文件中。
6.5.2 节练习
(a)应该放在头文件中。因为内联函数的定义对编译器而言必须是可见的,以便编译器能够早调用点内联地展开该函数的代码,所以仅有函数的原型不够。并且,与一般函数不同,内联函数有可能在程序中定义不止一次,此时必须保证所有源文件定义完全相同,把内联函数的定义放在头文件中可以确保这一点。
(b)是函数声明,应该放在头文件中。
6.5.3 调试帮助
C++程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assert 和 NDEBUG。
assert 预处理宏
assert是一种预处理宏(preprocessor marco)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:
assert(expr);
首先对 expr 求值,如果表达式为假(即 0),assert 输出信息并终止程序的执行。如果表达式为真(即非 0 ),assert 什么也不做。
assert 宏定义在 cassert 头文件中。预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供 using 声明。也就是说,我们应该使用 assert 而不是 std::assert,也不需要为 assert 提供 using 声明。
和预处理变量一样,宏名字在程序内必须唯一。最好不要为了其他目的使用 assert。
assert 宏常用于检查“不能发生”的条件。例如,一个对输入文本进行操作的程序可能要求所有给定单词的长度都大于某个阈值。此时,程序可以包含一条如下所示的语句:
assert(word.size() > threshold);
NDEBUG 预处理变量
assert 的行为依赖于一个名为 NDEBUG 的预处理变量的状态。如果定义了 NDEBUG,则 assert 什么也不做。默认状态下没有定义 NDEBUG,此时 assert 将执行运行时检查。
我们可以使用一个 #define 语句定义 NDEBUG ,从而关闭调试状态。
定义 NDEBUG 能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert 应该仅用于验证那些确实不可能发生的事情。我们可以把 assert 当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。
6.6 函数匹配
确定候选函数和可行函数
以下面这组函数及其调用为例:
void f();
void f(int );
void f(int, int);
void f(double, double = 3.14)
f(5.6); //调用 void f(double, double)
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为候选函数(candidate function)。候选函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。
第二步是考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数(viable function)。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
6.6.1 实参类型转换
为了确定最佳匹配,编译器将实参类型到形参类型的转换分成几个等级。
1、精确匹配,包括以下情况:
- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针类型
- 向实参添加顶层 const 或者从实参中删除顶层 const
2、通过 const 转换实现的匹配
3、通过类型提升实现的匹配
4、通过算术类型转换或指针转换实现的匹配
5、通过类类型转换实现的匹配。
6.6.1 节练习
(a)类型提升 (b)算术类型转换
6.7 函数指针
函数指针指向的是函数而非对象。和其他指针一样,函数指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如:
bool lengthCompare (const string &,const string &);
该函数的类型是 bool (const string&, const string &)。要想声明一个可以指向该函数的指针,只需要用指针替换函数名即可:
// pf 指向一个函数,该函数的参数是两个 const string 的引用,返回值是 bool 类型
bool (*pf) (const string &,const string &); //未初始化
*pf 两端的括号必不可少。如果不写这对括号,则 pf s是一个返回值为 bool 指针的函数:
//声明一个名为 pf 函数,该函数返回 bool* bool *pf(const string &,const string &);
使用函数指针
当我们把函数名作为一个值使用时,该函数自动地转换成指针。例如,按照如下形式我们可以将 lengthCompare 的地址赋给 pf:
pf = lengthCompare; //pf 指向名为 lengthCompare 的函数
pf = &lengthCompare; //等价的赋值语句:取地址符是可选的
此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:
bool b1 = pf("hello","goodbye"); //调用 lengthCompare 函数
bool b2 = (*pf)("hello","goodbye"); //一个等价的调用
bool b3 = lengthCompare("hello","goodbye");//另一个等价的调用
在指向不同函数类型的指针间不存在转换规则。但我们可以为该函数指针赋一个 nullptr 或者值为 0 的整型常量表达式,表示该指针没有指向任何一个数。
重载函数的指针
如果定义了指向重载函数的指针,编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配。
函数指针形参
和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用。
//第三个形参是函数类型,它会自动地转换成指向函数的指针
void useBigger(const string &s1, const string &s2,
bool pf(const string &, const string &));
//等价的声明:显式地将形参定义成指向函数的指针
void useBigger(const string &s1, const string &s2,
bool (*pf)(const string &, const string &));
我们可以直接把函数作为实参使用,此时它会自动转换成指针:
//自动将函数 lengthCompare转换成指向该函数的指针
useBigger(s1, s2, lengthCompare);
我们可以使用类型别名和 decltype 简化函数指针的代码:
//Func 和 Func2 是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; //等价的类型
//FuncP 和 FuncP2是指向函数的指针
typedef bool (*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; //等价的类型
可以使用如下形式重新声明 useBigger:
//useBigger 的等价声明,其中使用了类型别名
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);
这两个声明语句声明的是同一个函数,在第一条语句中,编译器自动地将 Func 表示的函数类型转换成指针。
返回指向函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。
using F = int(int*, int); //F 是函数类型,不是指针
using PF = int(*)(int*, int); //PF 是指针类型
我们使用类型别名将 f 定义成函数类型,将 pf 定义成指向函数类型的指针。必须时刻注意的是,和函数类型的形参不一样,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:
PF f1(int); //正确:PF是指向函数的指针,f1返回指向函数的指针
F f1(int); //错误:F 是函数类型,f1 不能返回一个函数
F *f1(int); //正确:显式地指定返回类型是指向函数的指针
也可以用下面的形式直接声明 f1:
int (*f1(int))(int*, int);
按照从内向外的顺序阅读这条声明语句:我们看到 f1 有形参列表,所以 f1 是个函数; f1 前面有 *,所以 f1 返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是 int。
我们还可以用尾置返回类型的方式:
auto f1(int) -> int (*)(int*, int);
将 auto 和 decltype 用于函数指针类型
如果我们明确知道返回的函数是哪一个,就能使用 decltype 简化函数指针返回类型的过程。
6.7 节练习
//练习6.54:编写函数的声明,令其接受两个 int 形参并且返回类型也是 int;
//然后声明一个 vector 对象,令其元素是指向该函数的指针
int func(int, int);
vector<decltype(func)*> v;
//练习6.55:编写4个函数,分别对两个 int 值执行加、减、乘、除运算;
//在上一题创建的 vector 对象中保存指向这些函数的指针。
int add(int a, int b)
{
return a + b;
}
int sub(int a, int b)
{
return a - b;
}
int mul(int a, int b)
{
return a * b;
}
int divv(int a, int b)
{
if (b != 0)
{
return a / b;
}
return -1;
}
decltype(add) *p1 = add, *p2 = sub, *p3 = mul, *p4 = divv;
vector<decltype(add)*> v = { p1,p2,p3,p4 };
//练习6.56:调用上述 vector 对象中的每个元素并输出结果
void Compute(int a, int b, int(*p)(int, int))
{
cout << p(a, b) << endl;
}
int main()
{
int i = 5, j = 10;
decltype(add) *p1 = add, *p2 = sub, *p3 = mul, *p4 = divv;
vector<decltype(add)*> v = { p1,p2,p3,p4 };
for (auto p : v)
{
Compute(i, j, p);
}
system("pause");
return 0;
}