函数
简介
本章首先介绍函数的定义和声明,包括参数如何传入函数以及如何返回结果。在C++语言中允许重载函数,也就是不同的函数可以使用同一个名字。所以我们接下来介绍重载函数的方法,以及编译器如何从函数的若干重载形式中选取一个与调用匹配的版本。最后我们将介绍一些关于函数指针的知识。
函数是一个命名了的代码块,我们通过调用函数执行相应的代码。函数可以有0个或者多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以有多个函数。
6.1 函数基础
一个典型的函数(function)定义包括以下几部分:返回类型(return type)、函数名字、有0个或多个**形参(parameter)**组成的列表以及函数体(function body)。其中,形参以逗号隔开、形参的列表位于一对圆括号之内。函数执行的操作在语句块中说明,该语句块成为函数体。
我们通过调用运算符(call operator)来执行函数。调用运算符的形式是一对圆括号,它作用于一对表达式,该表达式 是指向函数或者指向函数的指针;圆括号之内是一个用括号隔开的实参(argument)列表,我们 用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
编写函数
举个例子,我们准备编写一个求数的阶乘的程序。n的阶乘是从1到n所有数字的乘积。程序如下所示:
int fact(int val){
int ret=1;//局部变量用于保存结果
while(ret>1)
ret *= val--;//把ret和val的乘积赋给ret,然后将val减1
return ret;//返回结果
}
函数的名字是fact,它作用于一个整型参数,返回一个整型数。在while循环内部,在每次迭代时用后置递减运算符将val的值减1.return语句负责结束fact并返回ret的值。
调用函数
要调用fact函数,必须提供一个整数值,调用得到的结果也是一个整数:
int main(){
int j=fact(5);
cout<<"5! is "<<j<<endl;
return 0;
}
函数的调用完成两项工作:一是实参初始化函数对应的形参,二是将控制权转移给被调用函数。 此时,主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行。
执行函数的第一步时(隐式地)定义并初始化它的形参。因此当调用fact函数时,首先创建一个名为val的int变量,然后将它初始化为调用时所用的实参5。
当遇到一条return语句时函数结束执行过程。和函数调用一样,return语句也完成两项工作:一是返回return语句的值(如果有的话),二是将控制权从被调函数转移回主调函数。 函数的返回值用于初始化调用表达式的结果,之后继续完成调用所在的表达式的剩余部分。因此我们对fact函数的调用过程等价于如下形式:
int val =5;
int ret =1;
while(val>1)
ret *=val--;
int j =ret;
形参和实参
实参是形参的初始值。第一个实参初始化第一个形参,依次类推。尽管实参和形参存在对应关系,但是并没有规定实参的求值顺序。编译器能以任何可行的顺序对实参求值。
实参的类型必须与形参的类型匹配,这一点与之前的规则是一致的,我们知道在初始化过程中初始值的类型也必须与初始化对象的类型匹配。函数有几个形参,我们就必须提供相同数量的实参。因为函数调用规定实参数量应与形参数量一致,所以形参一定会被初始化。
在上面的例子中,fact函数只有一个int类型的形参,所以每次我们调用它的时候,都必须提供一个能转换成int的实参:
fact("hello");//错误,实参类型不正确
fact();//错误,实参数量不足
fact(42,10,0);//错误,实参数量过多
fact(3.14);//正确,该实参能转换成int类型
因为不能将const char* 转换成int,所以第一个调用失败。第二个和第三个调用也会失败,因为传入的实参数量不对。最后一个调用是合法的,因为double可以转换成int。执行调用时,实参隐式地转换int类型(截去小数部分)。
函数的形参列表
函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。不过为了和C语言兼容,也可以使用关键字void表示函数没有形参:
void f1(){}//隐式地定义空形参列表
void f2(){}//显示地定义空形参列表
形参列表中的形参通常用逗号隔开,其中每一个形参都是含有一个声明符的声明。即使两个形参的类型一样,也必须把两个类型都写出来:
int f3(int v1,v2){}//错误
int f3(int v1,int v2){}//正确
任意两个形参不能同名,而且函数最外层作用域中的局部变量也不能使用与函数形参一样的名字。
形参名是可选的,但是由于我们无法使用未命名的形参,所以形参一般都应该有个名字。偶尔,函数确实有个别形参不会被使用到,则此类形参通常不命名以表明在函数体内不会使用它。不管怎么样,是否设置未命名的形参并不影响调用时提供的实参数量。即使某个形参不被使用,也必须为它提供一个实参。
函数返回类型
大多数类型都能用作函数的返回类型。一种特殊的返回类型是void,它表示函数不返回任何值。函数的返回类型不能是数字类型或函数类型,但可以是指向数组或函数的指针。 我们将在6.3.3节介绍如何定义一种特殊的函数,它的返回值是数组的指针(或引用),在6.7节将介绍如何返回指向函数的指针。
6.1.1 局部对象
在C++语言中,名字有作用域,对象有生命周期(lifetime)。理解这两个概念很重要。
名字的作用域是程序文本的一部分,名字在其中可见。
对象的生命周期是程序执行过程中该对象存在的一段时间。
如我们所知,函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。形参和函数体内部定义的变量统称为局部变量(local variable) 。它们对函数而言是局部的,仅在函数的作用域内可见,同时局部变量还会隐藏(hide)在外层作用域中同名的其他所有声明中。
在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的声明周期依赖于定义的方式。
自动对象
对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只在于块执行期间的对象称为自动对象(automatic object)。当块执行结束时,块中创建的自动对象就变成了未定义的了。
形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。
我们用传递给函数的实参初始化形参对应的自动对象。对于局部变量对应的自动对象,则分为两种情况:如果变量定义本身含有初始值,就用这个初始值进行初始化;否则,如果变量定义本身不含初始值,执行默认初始化。这意味着内置类型的未初始化局部变量将产生未定义的值。
局部静态对象
某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止时才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
举个例子,下面的函数统计它自己被调用了多少次,这样的函数也许没什么实际意义,但是足够说明问题:
size_t count_calls(){
static size_t ctr=0;//调用结束后,这个值仍然有效
return ++ctr;
}
int main(){
for(size_t i=0;i!=10;++i)
cout<<cout_calls()<<endl;
return 0;
}
这段程序将输出从1到10 之内的数字。在控制流第一次经过ctr定义之前,ctr被创建并初始化为0.每次调用将ctr加1并返回新值。每次执行cout_calls函数时,变量ctr的值都已经存在并且等于函数上一次退出时ctr的值。因此,第二次调用时,ctr的值为1,第三次调用时ctr的值是2,依次类推,但调用这个函数后,这个值被加1。
如果局部静态对象没有显式的初始值,它将执行值初始化,内置类型的局部静态变量初始化为0.
6.1.2 函数声明
和其他名字一样,函数的名字也必须在使用前声明。类似于变量,函数只能定义一次,但可以声明多次。唯一的例外是如15.3节将要介绍的,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。
函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号代替即可。
因为函数的声明不包含函数体,所以就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:
//我们选择beg和end作为形参的名字以表示这两个迭代器划定了输出值的范围
void print(vector<int>::const_iterator beg,vector<int>::const_iterator end);
函数的三要素(返回类型,函数名,形参列表)描述了函数的接口,说明了调用该函数所需的所有信息。函数声明也被称作函数原型(function prototype)。
在头文件中进行函数声明
我们之前建议,在头文件中声明变量,在源文件中定义。与之类似,函数也应该在头文件中声明而非源文件中定义。
看起来把函数的声明直接放在该函数的源文件中是合法的,也比较容易被人接受;但这么做可能会很繁琐而且容易出错。相反,如果我们把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且一旦我们向改变函数的接口,只需改变一条声明即可。
定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。
6.1.3 分离式编译
随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同的文件中。例如,可以把6.1节练习的函数存在一个文件里,把使用这些函数的代码存在其他源文件中。为了允许编写程序按照逻辑关系将其划分开来,C++语言支持所谓的分离式编译(sparate compilation)。分离式编译允许我们把程序分隔到几个文件中去,每个文件独立编译。
编译和链接多个源文件
举个例子,假设fact函数的 定义位于一个名为fact.cc的文件中,它的声明位于名为Chapter6.h的头文件中。显然与其他所有用到fact函数的文件一样,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 -f # generates fact.o
$ CC factMain.o fact.o # generates factMain.exe or a.out
$ CC factMain.o fact.o -o main # generate 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的值不变
传值参数的机理完全一样,函数对形参做的所有操作都不会影响实参。例如,在fact函数内对变量val执行递减操作:
ret *= val--;//将val的值减1
尽管fact函数改变了val的值,但是这个改动不会影响传入fact的实参。调用fact(i)不会改变i的值。
指针形参
指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值:
int n=0,i=42;
int *p = &n,*q=&i;//p指向n;q指向i
*p=42;//n的值改变;p不变
p=q;//p现在指向了i;但是i和n的值都不变
指针形参的行为与之类似:
//void reset(int *ip){
*ip=0;//改变指针ip所指对象的值
ip=0;//只改变了ip的局部拷贝,实参未被改变
}
调用reset函数之后,实参所指的对象被置为0,但是实参本身(指针)并没有改变(可以输出重置前后的&ip,发现其并未改变):
int i =42;
reset(&i);//改变i的值,而非i的地址
cout<<"i="<<i<<endl;//输出i=0
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参替代指针。 与指针相比,使用引用交换变量的内容从形式上看跟简单一些,并且无须额外声明指针变量,也避免了拷贝指针的值。
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相同
引用形参的行为与之类似。通过使用引用形参,允许函数改变一个或多个实参的值。 举个例子我们可以改写上一小节的reset程序,使其接受的参数是引用类型而非指针:
void reset(int &i){//i是传给reset函数的对象的另一个名字
i=0;//改变了i所引对象的值。
}
和其他引用一样,引用形参绑定初始化它的对象。当调用这一版本的reset函数时,i绑定我们传给函数的int对象,此时改变i也就改变了i所引对象的值。此例中,被改变的对象是传入reset的实参。
调用这一版本的reset函数时,我们直接传入对象,而非传递对象的地址:
int j = 42;
reset (j);
cout<<"j= " <<j<<endl;
在上述调用过程中,形参i仅仅是j的又一个名字。在reset内部对i的使用即是对j的使用。
** 使用引用避免拷贝**
拷贝大的类类型或者容器对象是比较低效的,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
举个例子,我们准备编写一个函数比较两个string对象的长度。因为string对象可能会非常长,所以应该尽量避免直接拷贝它们,这时使用引用形参是比较明智的选择。又因为比较长度无须改变string对象的内容,所以把形参定义成对常量的引用:
//比较两个string对象的长度
bool isShorter(const string &s1,const string &s2){//常量引用
return s1.size()<s2.size();
}
如6.2.3节要介绍的**,当函数无须修改引用形参的值时最好使用常量引用。**
使用引用形参返回额外信息
一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效途径。举个例子,我们定义一个名为find_char的函数,它返回在string对象中某个指定字符第一次出现的位置。同时我们也希望函数能返回该字符出现的总次数。
实现这个功能我们可以定义一个新的数据类型,让它包含位置和数量两个成员。还有另一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数:
//返回s中c第一次出现的位置所以
//引用形参occurs负责统计c出现的总次数
string::size_type find_char(const string &s,char c,string::size_type &occurs){
auto ret = s.size();//第一次出现的位置(如果有的话)
occurs = 0;
for (decltype(ret) i=0;i!=s.size();++i){
if(s[i]==c){
if(ret == s.size())//ret之前就已经初始化为s.size(),如果出现过则不等于s.size()
ret = i;//记录c第一次出现的位置
++occurs;
}
}
return ret;//出现次数通过occurs隐式地返回
}
当我们调用find_char函数时,必须传入三个实参:作为查找范围的一个string对象、要找的字符以及用于保存字符出现次数的size_type对象。假设s是一个sting对象,ctr是一个size_type对象,则我们通过如下形式调用find_char函数:
auto index = find_char(s,'o',ctr);
调用完成后,如果string对象中确实存在‘o’,那么ctr的值就是o出现的次数,index指向o第一次出现的位置;否则如果string对象中没有o,index等于s.size()而ctr等于0。
6.2.3 const 形参和实参
当形参是const时,必须要注意我们 在2.4.3节关于顶层const的讨论。如前所述,顶层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对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。
int i=42;
const int *cp=&i;//正确,但是cp不能改变i:指向常量的指针不能用于改变其所指对象的值
const int &r=i;//正确,但是r不能改变i,r是常量引用,不能通过r改变i的值,可以通过其他方式改变
const int &r2=42;//正确,允许为一个常量引用绑定非常量的对象、字面值,甚至是一个表达式
int *p=cp;//错误,p的类型和cp的类型不匹配,p是一个普通指针
int *r3=r;//错误,r3的类型和r不匹配,r3是一个普通引用
int &r4=42;//错误,不能用字面值初始化一个非常量引用,普通引用只能绑定到对象,而不能与字面值或某个表达式的计算结果绑定在一起
将同样的初始化规则应用到参数传递上可得如下形式:
int i=0;
const int ci=i;
string::size_type ctr = 0;
reset(&i);//调用形参类型是int*的reset函数
reset(&ci);//错误,不能用指向const int对象的指针初始化int*
reset(i);//调用形参类型是int&的reset函数
reset(ci);//错误,不能把普通引用绑定到const对象ci上
reset(42);//错误,不能把普通引用绑定到字面值上
reset(ctr);//错误,类型不匹配,ctr是无符号类型
find_char("Hello World!",'o',ctr);//正确:find_char的第一个形参是对常量的引用(常量引用),可以用绑定非常量的对象、字面值甚至是一个一般表达式
要想调用引用版本的reset,只能使用int类型的对象,而不能使用字面值、求值结果为int的表达式、需要转换的对象或者const int 类型的对象。类似的,要想调用指针版本的reset,只能使用int*。另一方面,我们能传递一个字符串字面值作为find_char的第一个实参,这是因为该函数的引用形参是常量引用,而C++允许我们用字面值初始化常量引用。
尽量使用常量引用
把函数不会改变的形参定义成普通引用是一种比较常见的错误,这么做会给函数的调用者一种误导,即函数可以修改它的实参的值。此外使用普通引用而非常量引用也会极大地限制函数所能接受的实参类型。我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用实参。
这种错误绝不像看起来那么简单,他可能造成出人意料的后果。以6.2.2节的find_char函数为例,那个函数(正确地)将它的string类型的形参定义成常量引用。假如我们把它定义成普通的stirng&:
//不良设计:第一个形参的类型应该是const string&
string::size_type find_char(string &s,char c,string::size_type &occurs);
则只能将find_char作用于string对象。类似
find_char("Hello world",'o',ctr);
将在编译时发生错误。
还有一个更难察觉的问题,假如其他函数(正确地)将它们的形参定义成常量引用,那么第二个版本的find_char无法在此类函数中正常使用。举个例子,我们希望在一个判断string对象是否是句子的函数中使用find_char:
bool is_sentence (const string &s){
//如果在s的末尾有且只有一个句号,则s是一个句子
string::size_type ctr=0;
return find_char(s,'.',ctr)==s.size()-1 && ctr==1;//句号在最后,且只有一个
}
如果find_char 的第一个形参类型是stirng&,那么上面这条调用find_char的语句将在编译时发生错误。原因在于s是常量引用,但find_char被(不正确地)定义成只能接受普通引用。
正确的修改思路是改正find_char函数的形参。如果实在不能修改find_char,就在is_sentence 内部定义一个string类型的变量,令其为s的副本,然后把这个string对象传递给find_char。
6.2.4 数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,分别是:不允许拷贝数组(3.5.1)以及使用数组时(通常)会将其转换成指针(3.5.3)。因为不能拷贝数组,所以无法使用值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
//尽管形式不同,但三个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风格字符串存储在字符数组中,并且最后一个字符后面跟着一个空字符。函数在处理C风格字符串时遇到空字符停止:
void print(const char *cp){
if(cp){//若cp不是一个空指针
while(*cp)//若指针所指的字符不是空字符
cout<<*cp++;//输出当前字符并将指针向前移动一个位置
}
}
这种 方法适用于那些有明显结束标记并且标记不会与普通数据混淆的情况,但是对于像int这样所以取值都是合法值的数据就不太有效了。
使用标准库规范
管理数组实参的第二种技术是传递指向数组首元素和尾元素的指针,这种方法受到标准库技术的启发,关于其细节将在第Ⅱ部分详细介绍。使用该方法,我们可以按照如下形式输出元素内容:
void print(const int *beg,const int *end){
//输出beg和end(不含end)的所有元素
while(beg!=end)
cout<<*beg++<<endl;//输出当前元素并将指针向前移动一个位置
}
while循环使用解引用运算符和后置递增运算符输出当前元素并在数组内将beg向后移动一个元素,当beg和end相等时结束循环。
为了调用这个函数,我们需要传入两个指针:一个指向要输出的首元素,另一个指向尾元素的下一个位置:
int j[2]={0,1};
//j转换成它首元素的指针
//第二个实参是指向j的尾后元素的指针
print(begin(j),end(j));//begin和end函数,参见第3.5.3节
只要调用者能够正确计算指针所指的位置,那么上述代码就是安全的。我们使用标准库begin和end函数提供所需的指针。
显式传递一个表示数组大小的形参
第三种管理数组实参的方法是专门定义一个表示数组大小的形参,在C程序和过去的C++程序中常常使用这种方法。使用这种方法,可以将print函数重写成如下形式:
//const int ia[]等价于const int* ia
//size表示数组的大小,将它显式地传给函数用于控制对ia元素的访问
void print(const int ia[],size_t size){
for(size_t i=0;i!=size;++i)
cout<<ia[i]<<endl;
}
这个版本的程序通过形参size的值确定要输出多少个元素,调用print函数时必须传入表示数组大小的值:
int j[]={0,1}//大小为2的整型数组
print(j,end(j)-begin(j));
只要传递给函数的size值不超过数组的实际的大小,函数就是安全的。
数组形参和const
我们的三个print函数都把数组定义成了指向const的指针,关于引用的讨论同样适用于指针。当函数不需要对数组元素执行写操作时,数组的形参应该指向const指针。只有当函数确实需要改变元素值的时候,才把形参定义成指向非常量的指针。
数组引用实参
C++语言允许将变量定义成数组的引用(3.5.1),同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上。
//正确,形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10]){
for (auto elem:arr)
cout<<elem<<endl;
}
这里我们需要注意&arr两端的括号必不可少(3.5.1):
f(int &arr[10])//错误,将arr声明成了 引用的数组 (数组中存着10个int型引用)
f(int (&arr)[10])//正确,arr是具有10个整数的整型数组的引用
因为数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print函数的可用性,我们只能将函数作用于大小为10的数组:
int i= 0,j[2]={0,1};
int k[10] ={0,1,2,3,4,5,6,7,8,9};
print(&i);//错误,实参不是含有10个整数的数组
print(j);//错误,实参不是含有10个整数的数组
print(k);//正确,实参是含有10个整数的数组
16.1.1节将要介绍我们应该如何编写这个函数,使其可以给引用类型的形参传递任意大小的数组。
传递多维数组
我们曾经介绍过,在C++语言中没有真正的多维数组(3.6节),所谓的多维数组其实是数组的数组。
和所有数组一样,当将多维数组传给函数时,真正传递的是数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面的维度)的大小都是数组类型的一部分,不能省略:
//matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*matrix)[10],int rowSize){/*...*/}
//将matrix声明成指向含有十个整数的数组的指针
int *matrix[10];//10个指针构成的数组
我们也可以使用数组的语法定义函数,此时编译器会一如既往的忽略掉第一个维度,所以最好不要把它包括在形参列表内:
//等价定义
void print(int matrix[] [10],int rowSize){/*...*/}
matrix的声明看起来是个二维数组,实际上形参是指向含有10个整数的数组的指针。
6.2.5 main:处理命令行选项
main函数是演示C++程序如何向函数传递数组的好例子。到目前为止,我们定义的main函数都只有空形参列表:
int 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[0]也可以指向一个空字符串
argv[1]= "-d";
argv[2]="-o";
argv[3]="ofile";
argv[4]="data0"
argv[5]=0;
当使用argv中的实参时,一定要记得可选的实参从argv[1]开始;argv[0]保存程序的名字,而非用户输入。
6.2.6 含有可变形参的函数
有时我们无法提前预知应该向函数传递几个实参。例如我们想要编写代码输出程序产生的错误信息,此时最后用同一个函数实现该项功能,以便对所有错误的处理能够整齐划一。然而,错误信息的种类不同,所以调用错误输出函数时传递的实参也各不相同。
为了能编写处理不同数量实参的函数,C++11新标准提供了两种主要的方法:
如果所有的实参类型相同,但函数的实参数量未知,可以传递一个名为initializer_list的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,关于它的细节将在16.4节介绍。
C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。本节将简要介绍省略符形参,不过需要注意的是,这种功能一般只用于与C函数交换的接口程序。
initializer_list形参
它用于表示某种特定类型的值的数组。这个类型定义在同名的头文件中,它提供的操作在本书198页表6.1:
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;//元素类型是string
initializer_list<int> li;//元素类型是int
和vector不一样的是,initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。
我们使用如下的形式编写输出错误信息的函数,使其可以作用于可变数量的实参:
void error_msg(initializer_list<string> il){
for(auto beg = il.begin();beg!=il.end();++beg)
cout<<*beg<<" ";
cout<<endl;
}
作用于initizlizer_list对象上的begin和end操作类似于vector对应的成员。end()成员提供一个指向列表尾后元素的指针。在循环体内,解引用beg以访问当前元素并输出它的值。
如果像向initializer_list形参中传递一个值的序列,则必须把序列放在一个花括号内:
//expected和actual是string对象
if(expected!=actual)
error_msg({"functionX",expected,actural});
else
error_msg({"functionX","okay"});
在上面的代码中,我们调用了同一个函数error_msg,但是两次调用传递的参数数量不同。
含有initializer_list形参的函数也可以同时拥有其他形参。例如,调试系统可能有个名为ErrCode的类用来表示不同类型的错误,因此我们可以改写之前的程序,使其包含一个initializer_list形参和一个ErrCode形参:
void error_msg(ErrCode e,initializer_list<string> il){
cout<<e.msg()<<":";
for(const auto &elem:il)
cout<<elem<<" ";
cout<<endl;
}
因为initializer_list包含begin和end成员,所以我们可以使用范围for循环处理其中的元素。和之前的版本类似,这段程序遍历传给il形参的列表值,每次迭代时访问一个元素。
为了调用这个版本的error_msg函数,需要额外传递一个ErrCode实参:
if(expected!=actual)
error_msg(ErrCode(42),{"functionX",expected,actual});
else
error_msg(ErrCode(0),{"functionX","okay"});
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs。(?)
注意,省略符形参应该仅仅用于C和C++通用的类型,大多数类类型在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list,...);
void foo(...);
第一种形式指定了foo函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参对应的实参无须类型检查。在第一种形式中,形参声明后面的逗号符是可选的。
6.3 返回类型和return语句
return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。return语句有两种形式:
return;
return expression;
6.3.1 无返回值函数
没有返回值的函数只能用在返回类型是void的函数中。返回void函数的不要求非得有return语句,因为这类函数会在最后一句后面隐式地执行return。
通常情况下,void函数如果想在它的中间位置提前退出,可以使用return语句。return的这种用法有点类似于我们用break语句退出循环。例如,我们可以编写一个swap函数,使其在参与交换的值相等时什么也不做直接退出:
void swap(int &v1,int &v2){
//如果两个值相等则不需要交换,直接退出
if(v1==v2)
return;
int temp=v2;
v2=v1;
v1=temp;;
//此处无须显式的return语句
}
一个返回类型是void的函数也能使用return语句的第二种形式,不过此时return语句的expression必须是另一个返回void的函数。 强行令void函数返回其他类型的表示式将产生编译错误。
6.3.2 有返回值函数
return语句的第二种形式提供了函数的结果。只要函数的返回类型不是void,则该函数内的每条return语句必须返回一个值,且必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型。
尽管C++无法确保结果的正确性,但是可以保证每个return语句的结果类型正确。也许无法顾及所有情况,但是编译器仍然尽量确保具有返回值的函数只能通过一条有效的return语句退出。例如:
//因为含有不正确的返回值,所以这段代码无法通过编译
bool str_subrange(const string &str1,const string &str2){
//大小相同:此时用普通的相等性判断结果作为返回值
if(str1.size()===str2.size())
return str1==str2;
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;//错误,没有返回值,编译器将报告这一个错误
}
//错误,在for循环遍历字符串的时候可能不返回任何值就结束了函数的执行,而编译器可能检查不出这一错误
所以在上述程序中的循环语句后面应该也加一条return语句来返回结果,如果没有这个程序就是错误的。很多编译器都无法发现此类错误。
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
必须注意当函数返回局部变量时的初始化规则。例如我们书写一个函数,给定计时器、单词和结束符之后,判断计数值是否大于1:如果是,返回单词的复数形式;如果不是,返回单词原型:
//如果ctr的值大于1,返回word的复数形式
string make_plural(size_t ctr ,const string &word,const string &ending){
return (ctr>1) ? (word + ending) :word;
}
该函数的返回类型是string,意味着返回值将被拷贝到调用点。因此,该函数将返回word的副本或者一个未命名的临时string对象。
同其他引用类型一样,如果函数返回引用,则该引用仅是它所引对象的一个别名。举个例子来说明,假定某个函数挑出两个string形参中较短的那个并返回其引用:
const string &shorterstring (const string &s1,const string &s2){
return (s1.size()<=s2.size()) ? s1:s2;
}
其中形参和返回类型都是const string的引用,不管是调用函数还是返回结果都不会真正拷贝string对象。
不要返回局部对象的引用或指针
函数完成之后,它所占用的存储空间也随之被释放掉。因此函数终止意味着局部变量的引用将指向不再有效的内存区域:
//严重错误,这个函数试图返回局部对象的引用
const string &mainip(){
strinig ret;
//以某种方式改变一下ret
if(!ret.empty())
return ret;//错误,返回局部对象的引用!
else
return "Empty";//错误,"Empty"是一个局部临时量
}
上面两条return语句都将返回未定义的值,也就是说,试图使用manip函数的返回值将引发未定义的行为。对于第一条return语句来说,显然它返回的是局部变量的引用。在第二条return语句中,字符串字面值转换成一个局部临时string对象,对于manip来说,该对象和ret一样是局部的。当函数结束时临时对象占用的空间也就随之释放掉了,所以两条return语句都指向了不再可用的内存空间。
要想确保返回值安全,我们不妨提问:引用所引的是在函数之前以及存在的哪个对象?
同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。
返回类类型的函数和调用运算符
和其他运算符一样,调用运算符也有优先级和结合律。调用运算符的优先级与点运算符和箭头运算符相同,并且也符合左结合律。因此,如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员。
例如,我们可以通过以下形式得到较短string对象的长度:
//调用string对象的size成员,该string对象由shortstring函数返回的
auto sz = shortstring(s1,s2).size();
因为上述提到的运算符都满足左结合律,所以shortstring的结果是点运算符的左侧运算对象,点运算符可以得到string对象的size成员,size又是第二个调用运算符的左侧运算对象。
引用返回左值
函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值:
char &get_val(string &str,string::size_type ix){
return str[ix];//
}
int main(){
string s("a value");
cout<<s<<endl;
get_val(s,0)='A';//将s[0]的值改为A
cout<<s<<endl;
return 0;
}
把函数调用放在赋值语句的左侧可能看起来有点奇怪,其实这没什么特别的。返回值是引用,因此调用是个左值,和其他左值一样它也能出现在赋值运算符的左侧。
如果返回类型是常量引用,我们不能给调用的结果赋值,这一条和我们熟悉的情况是一样的:
shorterstring("hi","bye")="X";//错误,返回值是个常量
列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表。 类似于其他返回结果,此处的列表**也用来对表示函数返回的临时量进行初始化。**如果列表为空,临时量执行值初始化;否则返回值由函数的返回类型决定。
举个例子,回忆6.2.6节的error_msg函数,该函数的输入是一组可变数量的string实参,输出由这些string对象组成的错误信息。在下面的函数中我们返回一个vector对象,用它存放错误信息的string对象:
vector<string> process(){
//...
//expected和actual是string对象
if(expected.empty())
return {};//此时process函数返回的vector对象是空的
else if(expected==actual)
return {"functionX","okay"};
else
return {"functionX",expected,actual};
}
如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占的空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本定义初始值如何使用。
主函数main的返回值
之前介绍过,如果函数的返回类型不是void,那么它必须返回一个值。但是这条规则有个例外:我们允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。
我们之前介绍过,main函数的返回值可以看作是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,我们可以使用这两个变量分别 表示成功与失败:
int main(){
if(some_failure)
return EXIT_FALLURE;//定义在cstdlib头文件中
else
return EXIT_SUCCESS;//定义在cstdlib头文件中
}
因为它们是预处理变量,所以既不能在前面加上std::,也不能在using声明中出现。
递归
如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数(recursive function)。举个例子,我们可以使用递归函数来重新实现求阶乘的功能:
int factorial (int val){
if(val>1)
return factorial(val-1)*val;
return 1;//factorial(1)=1
}
在递归函数中,一定有某条路径是不包含递归调用的;否则,函数将永远递归下去,换句话说,函数将不断地调用它自身直到程序栈空间耗尽为止。我们有时候会说这种函数含有递归循环(recursion loop)。在factorial函数中,递归终止的条件是val等于1。
main函数不能调用自己。
6.3.3 返回数组指针
因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针 或引用。虽然从语法上来说**,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最简单的方法就是使用类型别名**:
typedef int arrT[10];//arrT是一个类型别名,他表示的类型是含有10个整数的数组
using arrT = int[10];//arrT的等价声明,
arrT* func(int i);//func返回一个指向含有10个 整数的数组的指针
其中arrT是含有10个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func函数接受一个int实参,返回一个指向包含10个整数的数组的指针。
声明一个返回数组指针的函数
要想声明func时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:
int arr[10];//arr是一个含有10个整数的数组
int *p1[10];//p1是一个含有10个指针的数组
int (*p2)[10] = &arr;//p2是一个指针,它指向含有10个整数的数组
和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面而且形参列表应该优先于数组的维度。因此,返回数组指针的函数形式如下所示:
Type (*function (parameter_list))[dimension]
类似于其他数组的声明,Type表示元素的类型,dimension表示数组的大小。(*function (parameter_list))两端的括号必须存在,就像我们定义p2时两端必须有括号一样,如果没有,函数的返回类型将是指针的数组。
举个具体的例子,下面这个func函数的声明没有使用类型别名:
int (*func(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];
因为我们把函数的返回类型放在了形参后面,所以可以清楚地看到func函数返回的是一个指针,指向了含有10个整数的数组。
使用decltype
还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。 例如下面的函数返回一个指针,该指针根据参数i的不同指向已知数组的某一个:
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的类型一致。因为odd是数组,所以arrPtr返回一个指向5个整数的数组的指针。有一个地方需要注意:decltype并不负责把数组类型转换成对应的指针,所以decltype的结果是个数组,要想表示arrPtr返回指针还必须在函数声明时加一个*符号。
6.4 函数重载
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载(overload)函数。例如,在6.2.4节中我们定义了几个名为print的函数:
void print(const char *cp);
void print(const int *beg,const int *end);
void print(const int ia[],size_t size );
这些函数接受的形参类型不一样,但是执行的操作非常类似。当调用这些函数时,编译器会根据传递的实参类型推断想要的是哪个函数:
int j[2]={0,1};
print("Hello world");//调用print(const char*)
print(begin(j),end(j));/调用print(const int*,const int*)
print(j,end(j)-begin(i));//调用print(const int* ,size_t)
函数的名字仅仅让编译器知道它调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。 另外main函数不能重载。
定义重载函数
有一种典型的数据库应用,需要创建几个不同的函数分别根据名字、电话、账号号码等信息查找记录。函数重载使得我们可以定义一组函数,它们的名字都是lookup,但是查找的依据不同。我们能通过以下形式中的任意一种调用lookup函数:
Record lookup(const Account&);//根据Acount查找记录
Record lookup(const Phone&);//根据Phone查找记录
Record lookup(const Name&);//根据Name查找记录
Account acct;
Phone phone;
Record r1=lookup(acct);//调用接受Account的版本
Record r2=lookup(phone);//调用接受Phone的版本
对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。
不允许两个函数除了返回类型外其他所有的要素都相同。 假设有两个函数,它们的形参列表一样,但是返回类型不同,则第二个函数的声明是错误的:
Record lookup(const Account&);
bool lookup(const Account&);//错误,与上一个函数相比只有返回类型不同
判断两个形参的类型是否相异
有时候两个形参列表看起来不一样,但实际上是相同的:
//每对声明的是同一个函数
Record lookup(const Account &acct);
Record lookup(const Account&);//省略了形参的名字
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&);//Telno和Phone的类型相同
在第一对声明中,第一个函数给它的形参起了名字,第二个函数没有。形参的名字仅仅起到帮助记忆的作用,有没有它并没有形参列表的内容。
第二对声明看起来类型不同,但事实上Telno只是Phone的别名而已。
重载和const形参
如6.2.3节介绍的,顶层const(2.4.3)不影响传入函数的对象。一个拥有顶层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不能转换成其他类型,所以我们只能把const对象(或指向const的指针)传递给const形参。相反的,因为非常量可以转换成const,所以上面的4个函数都能作用于非常量对象或指向非常量对象的指针。 不过我们在6.6.1节会介绍,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
建议:合适不应该重载函数
尽管函数重载能够减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。因为有时候给函数起不同的名字能使程序更易理解。举个例子,下面是负责移动屏幕光标的函数:
Screen& moveHome();
Screen& moveAbs(int,int);
Screen& moveRel(int,int,string direction);
乍一看,似乎可以把这组函数统一命名为move,从而实现函数的重载:
Screen& move();
Screen& move(int,int);
Screen& move(int,int,string direction);
其实不然,重载之后这些函数失去了名字中本来拥有的信息。尽管这些函数确实都是在移动光标,但具体移动的方式却各不相同。以moveHome为例,他表示的是移动光标的一种特殊实例。一般来说,是否重载函数要看哪个更容易理解:
myScreen.moveHome();//我们认为这个更容易理解
myScreen.move();
const_cast和重载
在4.11.3节中我们说过,const_cast在重载函数的情景中最有用。举个例子,回忆6.3.2节的shorterstring函数:
//比较两个string对象的长度,返回较短的那个引用
const string &shorterstring(const string &s1,const string &s2){
return s1.size()<=s2.size() ? s1:s2;
}
这个函数的参数和返回类型都是const string的引用。我们可以对两个非常量的string实参调用这个函数,但返回的结果仍然是const string的引用。因此我们需要一种新的shorterstring函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用const_cast可以做到这一点:
string &shorterstring(string &s1,string &s2){
auto &r=shorterstring(const_cast<const string&>(s1),const_cast<const string&>(s2));
return const_cast<string&>(r);
}
在这个版本的函数中,首先将它的实参强制转换成对const的引用,然后调用了shorterstring函数的const版本。const版本返回对const string的引用,这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的string&,这显然是安全的。
调用重载的函数
定义了一组重载函数后,我们需要以合理的实参调用它们。函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫做重载确定(overload resolution)。编译器首先将调用的实参与重载集合中的每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
大多数情况下,程序员很容易判断某次调用是否合法以及当调用合法时应该调用哪个函数。因为重载集中的函数区别明显,它们要不然是参数数量不同,要不就是参数类型毫无关系。但还有另外一些情况下要想选择函数就比较困难了:比如说当两个重载函数参数数量相同且参数类型可以相互转换时。我们将在6.6节介绍当函数调用存在类型转换时编译器处理的方法。
现在我们需要掌握的是,当调用重载函数时有三种可能的情况:
编译器找到一个实参最佳匹配(best match)的函数,并生成调用该函数的代码。
找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match
)的错误信息。
有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用(ambiguous call)。
6.4.1 重载与作用域
一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明。
对于刚接触C++的程序员来说,不太容易理清作用域和重载的关系。其实,重载对作用域的一般性质并没有什么改变:如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。 在不同作用域中无法重载函数名:
string read();
void print(const string &);
void print(double);//重载print函数
void fooBar(int ival){
bool read=false;//新作用域:隐藏了外层的read
string s=read();//错误,因为隐藏了外层read,所以read是一个布尔值而非函数
//不好的习惯:通常来说,在局部作用域中声明函数不是一个好的选择
void print(int);//新作用域,隐藏了之前的print
print("Value: ");//错误,print(const string &)被隐藏掉了
print(ival);//正确,当前print(int)可见
print(3.14);//正确,虽然print(double)被隐藏掉了,但是可以调用print(int)(double可以转换成int)
}
调用read函数会引发错误这很好理解:因为当编译器处理调用read的请求时,找到的是定义在局部作用域中的read。这个名字是个布尔变量,而我们显然无法调用一个布尔值,因此该语句非法。
调用print函数的过程非常相似。在fooBar内声明的print(int)隐藏了之前两个print函数,因此只有一个print函数是可用的:该函数以int值作为参数。
当我们调用print函数时,编译器首先寻找对该函数名的声明,找到的是那个接受int值的那个局部声明。一旦在当前的作用域中找到了所需的名字,编译器就会忽略外层作用域中的同名实体。(要是没找到呢?)剩下的就是检查函数调用是否有效了。
在C++语言,名字查找发生在类型检查之前。
当我们为print函数传入一个double类型的值时,重复上述过程。编译器在当前作用域内发现了print(int)函数,double类型的实参转换成int类型,因此调用是合法的。
假设我们把print(int)和其他print函数声明放在同一个作用域中,则它将成为另一种重载形式。因为此时编译器能看到所有三个函数,上述调用的处理结果将完全不同:
void print(const string&);
void print(double);
void print(int);
void fooBar(int ival){
print("Value: ");//调用print(const string&)
print(ival);//调用print(int)
print(3.14);//调用print(double)
}
6.5 特殊用途语言特性
本节我们介绍三种函数相关的语言特性,这些特性对大多数程序都有用,它们分别是:默认实参、内联函数和constexpr函数,以及在程序调试过程中常用的一些功能。
6.5.1 默认实参
某些函数有这样一种形参,在函数的很多次调用中它们都被赋予了一个相同的值,此时我们把这个反复出现的值称为函数的默认实参(default argument)。调用含有 默认实参的函数时,可以包含该实参,也可以忽略该实参。
例如,我们使用string对象表示窗口的内容。一般情况下,我们希望该窗口的高、宽和背景字符都是以默认值。但同时我们也应该允许用户为这几个参数自由指定与默认值不同的数值。为了使得窗口函数既能接纳默认值 ,也能接受用户指定的值,我们把他定义成如下形式:
typedef string::size_type sz;//关于typedef参见2.5.1节
string screen(sz ht=24,sz wid=80,char backgrnd=' ');
其中我们为每一个形参都提供了默认实参,默认实参作为形参的初始值出现在形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
使用默认实参调用函数
如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了。例如,screen函数为它的所有形参都提供了默认实参,所以我们可以使用0、1、2或3个实参调用该函数:
string window;
window=screen();//等价于screen(24,80,' ')
window=screen(66);
window=screen(66,256);
window=screen(66,256,'#');
在函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如,要想覆盖backgrnd的默认值,必须为ht和wid提供实参:
window=screen(, ,'?');//错误,只能省略尾部的实参
window=screen('?');//调用screen('?',80,' ');
上述代码第二个调用需要注意的是:这是合法的调用,但实际效果与书写的意图不符。因为?是一个char而函数最左侧的形参类型是string::size_type,是一种无符号整数类型,所以char类型可以转换成函数最左侧形参的类型。当该调用发生时,char类型的实参隐式地转换成string::size_type,然后作为height的值传递给函数。
当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
默认实参声明
对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定
//表示高度和宽度的形参没有默认值
string screen(sz,sz,char=' ');
我们不能修改一个已经存在的默认值:
string screen(sz,sz,char='*');//错误,重复声明
但是可以按照如下形式添加默认实参:
string screen(sz=24,sz=80,char);//正确,添加默认实参
通常应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
默认实参初始值
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参:
//wd、def和ht的声明必须出现在函数之外
sz wd=80;
char def=' ';
sz ht();
string screen(sz=ht(),sz=wd,char=def);
string window=screen();//调用screen(ht(),80,' ');
用默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:
void f2(){
def='*';//改变默认实参的值
sz wd=100;//隐藏了外层定义的wd,但是没有改变默认值
window =screen();//调用screen(ht(),80,'*')
}
我们在函数f2内部改变了def的值,所以对screen的调用将会传递这个更新过的值。另一方面,虽然我们的函数还声明了一个局部变量用于隐藏外层的wd,但是该局部变量与传递给screen的默认实参没有任何关系。
6.5.2 内联函数和constexpr函数
在6.3.2节中我们编写了一个小函数,它的功能是比较两个string形参的长度并返回长度较小的string的引用。把这种规模较小的操作定义成函数有很多好处,主要包括:
1.阅读和理解shorterstring函数的调用要比读懂等级的条件表达式要容易很多。
2.使用函数可以确保行为的统一,每次相关操作都能保证按照同样的方式进行。
3.如果我们需要修改计算过程,显然修改函数要比先找到等价表达式所有出现的地方再逐一修改更容易。
4.函数可以被其他应用重复利用,省去了程序员重新编写的代价。
然而使用shorterstring函数也存在一个潜在的缺点:调用函数一般比求等价表达式的值要慢一点。在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能要拷贝实参;程序转向一个新的位置继续执行。
内联函数可避免函数调用的开销
将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。假设我们把shorterstring函数定义成内联函数,则如下调用
cout<<shorterstring(s1,s2)<<endl;
将在编译过程中展开成类似下面的形式:
cout<<(s1.size()<s2.size()?s1:s2)<<endl;
从而消除了shorterstring函数运行时的开销。
在shorterstring函数的返回类型前面加上关键字inline,这样就可以将它声明成内联函数了:
//内联版本:
inline const string &shorterstring(const string &s1,const string &s2){
return s1.size()<=s2.size() ? s1:s2;
}
内联函数只是向编译器发出的一个请求,编译器可以选择忽略这个请求。一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。
constexpr函数
constexpr函数(constexpr function)是指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return语句。
constexpr int new_sz(){return 42;}
constexpr int foo=new_sz();//正确:foo是一个常量表达式
我们把new_sz定义成成无参数的constexpr函数。因为编译器能在程序编译时验证new_sz函数返回的是常量表达式,所以可以用new_sz函数初始化constexpr类型的变量foo。
执行该初始化任务时,编译器把对constexpr函数的调用替换成结果值。为了能在编译时随时展开,constexpr函数被隐式地指定为内联函数。
constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。 例如,constexpr函数中可以有空语句、类型别名以及using声明。
我们允许constexpr函数的返回值并非一个常量:
//如果arg是常量表达式,则scale(arg)也是常量表达式
constexpr size_t scale(size_t cnt) {return new_sz()*cnt;}
当scale的实参是常量表达式时,它的返回值也是常量表达式;反之不然:
int arr[scale(2)];//正确,因为2是常量表达式,所以scale(2)是常量表达式
int i=2;//i不是常量表达式
int a2[scale(i)];//错误,scale(i)不是常量表达式
当我们给scale函数传入字面值2的常量表达式时,它的返回类型也是常量表达式。此时编译器用相应的结果值替换对scale函数的调用。
如果我们用一个非常量表达式调用scale函数,比如int类型的对象i,则返回值是一个非常量表达式。当把scale函数用在需要常量表达式的上下文中时,由编译器负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误信息。 所以我们说constexpr函数不一定返回常量表达式
把内联函数和constexpr函数放在头文件内
和其他函数不一样,内联函数和cosntexpr函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过对于给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。 基于这个原因,内联函数和constexpr函数通常定义在头文件中。
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)。后续函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。在这个例子中,有4个名为f的候选函数。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数。 这些新选出的函数称为可行函数(viable function)。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参相同,或者能转换成形参的类型。
在上面的例子中,我们可以根据实参的数量从候选函数中排除掉两个(第一个和第三个)而第二个和第四个都可以用一个实参调用。其中最后那个函数本应该接受两个double值,但是因为它含有一个默认实参,所以只用一个实参也能调用它。因此我们知道:如果函数含有默认实参,则我们调用该函数时 传入的实参数量可能少于它实际使用的实参数量。
在使用实参数量初步判别了候选函数后,接下来考察实参的类型是否与形参匹配。和一般的函数调用类似,实参与形参匹配的含义可能是它们具有相同的类型,也可能是实参类型和形参类型满足转换规则。在上面的例子中,剩下的两个函数都是可行 。如果没找到可行函数,编译器将报告无匹配函数的错误。
寻找最佳匹配(如果有的话)
函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。下一节将详细介绍最佳匹配的细节,它的基本思想是:实参类型与形参类型越接近越好。
在我们的例子中,如果调用f(int),实参不得不从double转换成int。而另一个可行函数f(double,double)则与实参精确匹配。精确匹配要比需要类型转换的匹配更好,因此,编译器把f(5.6)解析成对含有两个double形参的函数的调用,并使用默认值填补第二个未提供的实参。
含有多个形参的函数匹配
当实参的数量有两个或更多时,函数匹配就比较复杂了。对于前面那些名为f的函数,我们来分析如下的调用会发生什么情况:
(42,2.56);
选择可行函数的方法和只有一个实参时一样,编译器选择那些形参数量满足要求且实参类型和形参能够匹配的函数。此例中,可行函数包括f(int,int)和f(double,double)。接下来编译器以此检查每个实参以确定哪个函数是最佳匹配。如果有且只有一个函数满足下列条件,则匹配成功。
1.该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
2.至少有一个实参的匹配优于其他可行函数提供的匹配。
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用时错误的。编译器将报告二义性调用的信息。
在上面的调用中, 只考虑第一个实参时,我们发现函数f(int,int)能够精准匹配;要想匹配第二个函数,int类型的实参必须转换成double类型。显然需要内置类型转换的匹配劣于精准匹配,因此就第一个实参来说,f(int,int)比f(double,double)更好。
接着考虑第二个实参2.56,此时f(double,double)是精准匹配;要想调用f(int,int)必须将2.56从double类型转换成init类型。因此就第二个实参来说,f(double,double)更好。
编译器最终将因为这个调用具有二义性而拒绝其请求:因为每一个可行函数各自在一个实参上实现了更好的匹配,从整体上来说无法判断孰优孰劣。看起来我们可以通过强制类型转换其中一个实参来实现函数的匹配,但在设计良好的系统中,不应该对实参进行强制类型转换。
调用重载函数时,应该尽量避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6.1 实参类型转换
为了确定最佳匹配,编译器将实参类型到形参类型的转换分了几个等级:
1.精确匹配,包括以下几种情况:
实参类型和形参类型相同。
实参从数组类型或函数类型转换成对应的指针类型(6.7节)
向实参添加顶层const或者从实参中删除顶层const。
2.通过const转换实现的匹配(4.11.2节)
3.通过类型提升实现的匹配(4.11.1节)
4.通过算术类型转换(4.11.1节)或者指针转换(4.11.2节)实现的匹配。
5.通过类类型转换实现的匹配(14.9节)。
需要类型提升和算术类型转换的匹配
内置类型的提升和转换可能在函数匹配时产生意想不到的结果,但幸运的是,在设计良好的系统中函数很少会含有与下面例子类型的形参。
分析函数调用前,我们应该知道小整形一般都会提升到int类型或更大的整数类型。假设有两个函数,一个接受int、另一个接受short,则只有当调用提供的是short类型的值时才会选择short版本的函数。有时候即使实参是一个很小的整数值,也会直接将它提升成int类型;此时使用short版本反而会导致类型转换:
void ff(int);
void ff(short);
ff('a');//char提升至int,调用f(int)
所有的算术类型转换的级别都一样。例如,从int向unsigned int的转换并不比从int向double的转换级别高。举个具体的例子:
void manip(long);
void manip(float);
manip(3.14);//错误,二义性调用
字面值3.14的类型时double,它既能转换成long也能转换成float。因为存在两种可能的算术类型转换,所以该调用具有二义性。
函数匹配和const实参
如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向了const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:
Record lookup(Account&);//函数的参数是Account的引用
Record lookup(const Account&);//函数的参数是一个常量引用
const Account a;
Account b;
lookup(a);//调用第二个函数
lookup(b);//调用第一个函数
在第一个调用中,我们传入的是const对象a。因为不能把普通引用绑定到const对象上,所以此例中唯一可行的函数是以常量引用作为形参的那个函数,并且调用该函数与实参a精确匹配。
在第二个调用中,我们传入的是非常量对象b。对于这个调用来说,两个函数都是可行的,因为我们既可以使用b初始化常量引用也可以用它初始化非常量引用。 然而用非常量对象初始化常量引用需要类型转换,接受非常量形参的版本则与b精确匹配。因此选非常量版本的函数。
指针类型的形参也类似。如果两个函数的唯一区别是它的指针形参指向常量或非常量,则编译器能通过实参是否是常量决定选用哪个函数:如果实参是非常量指针,调用形参是普通指针的函数;如果实参是指向常量的指针,则调用形参是const*的函数。
6.7 函数指针
函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由他的返回类型和形参类型共同决定,与函数名无关。例如:
//比较两个string对象的长度
bool lengthcompare(const string &,const string &);
该函数的类型是bool(const string&,const string&)。要想声明一个可以指向该函数的指针,只需要用指针替换函数名即可:
//pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool类型
bool (*pf)(const string &,const string &);//未初始化
从我们声明的名字开始观察,pf前面有个,因此pf是指针*;右侧是形参列表,表示pf指向的是函数;再观察左侧,发现函数的返回类型是布尔值。因此,pf就是一个指向函数的指针,其中该函数的参数是两个const string的引用,返回值是bool类型。
注意:*pf两端的括号必不可少。如果不写这对括号,则pf是一个返回值为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 的整型常量表达式,表示该指针没有指向任何一个函数:
string::size_type sumLength(const string&,cosnt string&);
bool cstringcompare(cosnt char*,cosnt char*);
pf=0;
pf=sumlength;//错误,返回类型不匹配,pf指向的函数返回值类型为bool
pf=cstringcompare;//错误,形参类型不匹配,pf指向的函数形参类型为两个const string&
pf=lengthcompare;//正确,函数和指针的类型精准匹配
重载函数的指针
当我们使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。如果定义了指向重载函数的指针
void ff(int*);
void ff(unsigned int);
void (*pf1)(unsigned int)=ff;//pf1指向ff(unsigned)
编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配:
void (*pf2)(int)=ff;//错误,没有任何一个ff与pf2指向的函数的形参列表匹配
double (*pf3)(int*)=ff;//错误,ff和pf3指向的函数的返回类型不匹配
函数指针形参
和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用:
//第三个形参是函数类型,他会自动转换成指向函数的指针
void usebigger(cosnt string &s1,const string &s2,bool pf(const string &,cosnt string &));
//等价的声明:显式地将形参定义成指向函数的指针
void usebigger(const string &s1,cosnt string &s2,bool (*Pf) (const string &,const string &));
我们可以直接把函数作为实参使用,此时它会自动转换成指针:
//自动将函数作为实参使用,此时他会自动转换成指针
usebigger(s1,s2,lengthcompare);
我们可以看到,usebigger的声明语句直接使用函数指针类型显得冗长而烦琐。类型别名和decltype能让我们简化使用函数指针的代码:
//Func和Func2是函数类型
typedef bool Func(const string&,const string&);
//定义了一个名为Func,含有两个形参,返回类型为bool的函数类型
typedef decltype(lenghtcompare) Func2;//等价的类型
//定义了一个名为Func2,返回类型为decltype(lenghtcompare)的函数类型
//FuncP和FuncP2是指向函数的指针
typedef bool(*FuncP)(const string&,const string&);
//定义了一个名为Funcp的指向函数的指针,指向的函数有两个形参,返回值为bool
typedef decltype(lengthcompare) *FuncP2;//等价的类型
//定义了一个名为Funcp2的指向函数的指针,返回值为declytpe(lengthcompare)
我们使用typedef定义自己的类型。Func和Func2是函数类型,而FuncP和FuncP2是指针类型。需要注意的是,decltype返回函数类型,此时不会将函数类型自动转换成指针类型。因为decltype的结果是函数类型,所以只有在结果前面加上*才能得到指针。
经过上面的定义,我们可以使用如下的形式重新声明useBigger:
//useBigger的等价声明,其中使用了类型别名
void useBigger(const string&,const string&,Func);//编译器自动地将Func表示的函数类型转换成指针。
void useBigger(const string&,const string&,FuncP2);
返回指向函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。和之前一样,要想声明一个返回函数指针的函数,最简单的办法是使用类型别名:
using F=int(int*,int);//F是函数类型,不是指针
using PF=int(*)(int*,int);//PF是指针类型
需要注意的是,和函数类型的形参不一样,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:
PF f1(int);//正确,PF是指向函数的指针,f1返回指向函数的指针
F f1(int);//错误,F是函数类型,f1不能返回一个函数
F *f1(int);//正确,显式地指定返回类型是指向函数的指针
当然我们也可以用下面的形式直接声明f1:
int (*f1(int)) (int*,int);
按照由内向外的顺序阅读这条声明语句:我们首先看到f1有形参列表(int),所以f1是一个函数;f1前面有*,所以f1返回的是一个指针;进一步观察,指针类型本身也包含形参列表(int,int),因此指针指向函数,该函数的返回类型是int。
还记得我们之前提到的尾置返回类型吗?也可以用它声明一个返回函数指针的函数:
auto f1(int) -> int (*)(int*,int);
将auto和decltype用于函数指针类型
如果我们明确知道返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程。假定有两个函数,它们的返回类型都是string::size_type,并且各有两个const string&类型的形参,此时我们编写第三个函数,它接受string类型的参数,返回一个指针,该指针指向前两个函数中的一个:
stirng::size_type sumlength(const string&,const string&);
string::size_type largerlength(const string&,const string&);
//根据其形参的取值,getFcn函数返回指向sumLength或者largerlength的指针
decltype(sumlength) *getFcn(const string &);
声明getFcn函数唯一需要注意的地方是牢记当我们将decltype作用于某个函数时,它返回函数类型而非指针类型。因此,我们应该显式地加上*以表明我们需要返回指针。
小结
函数是命名了的计算单元,它对程序(哪怕是不大的程序)的结构化至关重要。每个函数都包含返回类型、名字、形参列表(可能为空)以及函数体。函数体是一个块,当函数被调用的时候执行该块的内容。此时,传递给函数的实参类型必须与对应的形参类型相容。
在C++中,函数可以被重载:同一个名字可用于定义多个函数,只要这些函数的形参数量或形参类型不同就行。根据调用时所使用的实参,编译器可以自动地选定被调用的函数。从一组重载函数中选取最佳函数的过程称为函数匹配。
习题解答
6.1节练习
练习6.1 实参和形参的区别是什么?
解答:
形参出现在函数定义的地方。形参规定了一个函数所接受数据的类型和数量。
实参出现在函数调用的地方。它的作用是初始化形参,并且这种初始化过程是一一对应的。
练习6.2 请指出下列函数哪个有错误,为什么?应该如何修改这些错误呢?
int f(){
string s;
//...
return s;
}
因为函数体返回的结果类型是string,而函数的返回值类型是int,二者不一致且不能自动转换。
f2(int i){...}
函数确实返回值类型,如果该函数确实不需要任何返回指针,则应该在前面加上void。
int calc(int v1,int v1){}
同一个函数如果有多个形参,则它们的名字不能重复。
double square (double x) return x*x;
函数体必须放在一对花括号内。
练习6.3 编写你自己的fact函数,上级检查是否正确。
int fact(int val){
if(val<0)
return -1;
int ret = 1;
for(int i=1;i!=val+1;++i)
ret *=val;
return val;
}
练习6.4 编写一个与用户交互的函数,要求用户输入一个数组,计算生成该数字的阶乘。在main函数中调用该函数。
#include <iostream>
using namespace std;
int fact(int val){
if(val<0)
return -1;
int ret = 1;
for(int i=1;i!=val+1;++i)
ret *=val;
return val;
}
int main(){
int num;
cout<<"请输入一个整数:"<<endl;
cin>>num;
cout<<num<<"的阶乘是:"<<fact (num)<<endl;
return 0;
}
练习6.5 编写一个函数输出其实参的绝对值。
#include <iostream>
using namespace std;
double ABS(double val){
if(val<0)
return val*-1;
else
return val;
}
int main(){
double num;
cout<<"请输入一个数:"<<endl;
cin>>num;
cout<<num<<"的绝对值是:"<<ABS (num)<<endl;
return 0;
}
#include <iostream>
#include <cmath>
using namespace std;
double ABS(double val){
return abs(val);
}
int main(){
double num;
cout<<"请输入一个数:"<<endl;
cin>>num;
cout<<num<<"的绝对值是:"<<ABS (num)<<endl;
return 0;
}
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;
}
return 0;
}
练习6.7 编写一个函数,当它第一次被调用时返回0,以后每次被调用返回值加1。
#include <iostream>
using namespace std;
unsigned mycnt(){
static unsigned icnt=0;//icnt 是静态局部变量
++icnt;
return icnt;
}
int main(){
cout<<"请输入任意字符后按回车键继续"<<endl;
char ch;
while(cin>>ch){
cout<<"函数mycnt()的执行次数是:"<<mycnt()<<endl;
}
return 0;
}
6.1.2节练习
练习6.8 编写一个名为Chapter6.h的头文件,令其包含6.1节练习中的函数声明。
解答:
#ifndef CHAPTER6_H_INCLUDED
#define CHAPTER6_H_INCLUDED
int fact(int );
double myABS(double );
double myABS2(double );
#endif //CHAPTER6_H_INCLUDED
6.1.3节练习
练习6.9 编写你自己的fact.cc和factMain.cc,这两个文件都应该包含上一个小节的练习中编写的Chapter6.h头文件。通过这些文件,理解你的编译器是如何支持分离式编译的。
fact.cc文件的内容
#include "Chapter6.h"
using namespace std;
int fact (int val){
if(val<0)
return -1;
int ret=1;
for(int i=1;i!=val+1;++i)
ret *= i;
return ret;
}
Chapter6.h的内容
#ifndef CHAPTER6_H_INCLUDED
#define CHAPTER6_H_INCLUDED
int fact(int );
double myABS(double );
double myABS2(double );
#endif //CHAPTER6_H_INCLUDED
factMain.cc的内容
#include <iostream>
#include "Chapter6.h"
#include "fact.cc"
using namespace std;
int main(){
int num;
cout<<"请输入一个整数:";
cin>>num;
cout<<num<<"的阶乘是:"<<fact(num)<<endl;
return 0;
}
6.2.1 节练习
练习6.10 编写一个函数,使用指针形参交换两个整数的值。在代码中调用该函数并输出交换后的结果,以此来验证函数的正确性。
#include <iostream>
using namespace std;
void myswap(int *p ,int *q){
int temp =*p;
*p=*q;
*q=temp;
}
int main(){
int a=5,b=10;
int *r=&a,*s=&b;
cout<<"交换前:a="<<a<<",b="<<b<<endl;
myswap(r,s);
cout<<"交换后:a="<<a<<",b="<<b<<endl;
return 0;
}
#include <iostream>
using namespace std;
void myswap(int &p ,int &q){
int temp =p;
p=q;
q=temp;
}
int main(){
int a=5,b=10;
int &r=a,&s=b;
cout<<"交换前:a="<<a<<",b="<<b<<endl;
myswap(r,s);
cout<<"交换后:a="<<a<<",b="<<b<<endl;
return 0;
}
6.2.2 节练习
练习6.11 编写并验证你自己的reset函数,使其作用于引用类型的参数。
#include <iostream>
using namespace std;
void reset(int &i){
i=0;
}
int main(){
int num=10;
cout<<"重置前:num = "<<num<<endl;
reset(num);
cout<<"重置后:num = "<<num<<endl;
return 0;
}
练习6.12 改写6.2.1节中练习6.10,使用引用而非指针交换两个整数的值。你觉得哪种方法更易于使用?为什么?
#include <iostream>
using namespace std;
void myswap(int &p ,int &q){
int temp =p;
p=q;
q=temp;
}
int main(){
int a=5,b=10;
int &r=a,&s=b;
cout<<"交换前:a="<<a<<",b="<<b<<endl;
myswap(r,s);
cout<<"交换后:a="<<a<<",b="<<b<<endl;
return 0;
}
与指针相比,使用引用交换变量的内容从形式上看跟简单一些,并且无须额外声明指针变量,也避免了拷贝指针的值。
练习6.13 假设T是某种类型的名字,说明以下两个函数声明的区别:一个是voidf(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是常量引用会发生什么情况?
解答:
对于待查找的字符串来说,为了避免拷贝长字符串,使用引用类型;同时因为我们只执行查找操作,无须改变字符串的内容,所以将其声明为常量引用。
对于待查找的字符c来说,它的类型是char,只占一个字节,拷贝的代价很低,而且我们无须操作实参在内存中实际存储的内容,只把它的值拷贝给形参即可,所以不需要使用引用类型。
对于字符出现次数occurs来说,因为需要把函数内对实参的值的更改反映在函数外部,所以必须将其定义成引用类型;但是不能把它定义成常量引用,否则就无法改变它所引的内容了。
6.2.3节练习
练习6.16 下面的这个函数虽然合法,但是不算特别有用。指出它的局限性并设法改善。
bool is_empty(string &s){return s.empty();}
解答:
本题的函数将参数类型设为非常量引用,这么做有几个缺陷:1、这么做会给函数的调用者一种误导,即函数可以修改它的实参的值。2、使用普通引用而非常量引用也会极大地限制函数所能接受的实参类型。我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用实参。根据上面的分析,该函数应该改为:
bool is_empty(const string &s){return s.empty();}
练习6.17 编写一个函数,判断string对象中是否含有大写字母。编写另一个函数,把string对象全部改写成小写形式。在这两个函数中你使用的形参类型相同吗?为什么?
解答:
当函数对参数的操作不同时,应该选择适当的参数类型。如果需要修改参数的内容,则将其设置为普通类型;否则,如果不需要对参数内容做出任何修改,最好设置成常量引用类型。 第一个函数只是判断(读取),所以设置成常量引用;第二个函数可能需要修改参数,则设置成普通引用。
#include <iostream>
#include <string>
using namespace std;
bool hasupper(const string &str){//判断字符串是否含有大写字母
for(auto c:str){//遍历字符串中的每一个字符
if(isupper(c))
return true;
}
return false;//不返回true说明没有大写字母,返回false
}
void changetolower(string &str){//把字符串中的大写字母全部转成小写
for(auto &c:str){
if(isupper(c)){
c=tolower(c);
}
}
}
int main(){
cout<<"请输入一个字符串:"<<endl;
string str;
cin>>str;
if(hasupper(str)){
changetolower(str);
cout<<"转换后的字符串是:"<<str<<endl;
}
else
cout<<"该字符串不含大写字母,无须转换"<<endl;
return 0;
}
练习6.18 为下面的函数编写函数声明,从给定的名字中推测函数具备的功能。
(a)名为compare的函数,返回布尔值,两个参数都是matrix (矩阵)类的引用。
(b)名为change_val的函数,返回vector<int>
的迭代器,有两个参数:一个是int,另一个是vector<int>
的迭代器。
解答:
bool compare(const matrix&,const matrix& )
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);
clac(23.4,55.1);
cout("acda",'a');
calc(66);
sum(vec.begin(),vec.end(),3.8);
解答:
第一行非法,函数声明只包含一个参数,而函数调用提供了两个参数,无法编译通过。
第二行合法,字面值常量可以作为常量引用形参的值,字符’a’作为char类型形参的值也是可以的。
第三行合法,66虽然是int类型,但是在调用函数时自动转换为double类型。
第四行合法,vec.begin()和vec.end()的类型都是形参所对应的类型,第三个实参3.8可以自动转换成形参所需的int类型。
练习6.20 引用形参什么时候应该常量引用?如果形参应该是常量引用,而我们将其设为了普通引用,会发生什么情况?
解答:
当函数对参数的操作不同时,应该选择适当的参数类型。如果需要修改参数的内容,则将其设置为普通类型;否则,如果不需要对参数内容做出任何修改,最好设置成常量引用类型。 函数将参数类型设为非常量引用,这么做有几个缺陷:1、这么做会给函数的调用者一种误导,即函数可以修改它的实参的值。2、使用普通引用而非常量引用也会极大地限制函数所能接受的实参类型。我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用实参。
6.2.4节练习
练习6.21 编写一个函数,令其接受两个参数:一个是int型的数,另一个是int指针。函数比较int的值和指针所指的值,返回比较大的那个。在该函数中指针的类型应该是什么?
#include <iostream>
#include <ctime>
#include <cstdlib>
using namespace std;
int mycompare(const int val,const int *p){//实际上这两个形参都不会被修改,所以定义成const类型
return (val>*p) ? val: *p;
}
int main(){
srand((unsigned) time (NULL));
int a[10];
for(auto &i:a)//为数组a随机赋初值
i=rand()%100;
cout<<"请输入一个数:";
int j;
cin>>j;
cout<<"您输入的数与数组首元素中较大的是:"<<mycompare(j,a)<<endl;
cout<<"数组的全部元素是:"<<endl;
for(auto i:a)
cout<<i<<" ";
cout<<endl;
return 0;
}
练习6.22 编写一个函数,令其交换两个int指针。
解答:
有两种理解:一是交换指针本身的值,即指针所指的内存地址;二是交换指针所指的内容。
#include <iostream>
using namespace std;
void swappointer(int *p,int *q){//该函数既不交换指针也不交换指针所指的内容
int *temp =p;
p=q;
q=temp;
}
void swappointer2(int *p,int *q){//该函数交换指针所指的内容
int temp=*p;
*p=*q;
*q=temp;
}
void swappointer3(int *&p, int *&q){//该函数交换指针本身的值,即交换指针所指的内存地址
int *temp =p;
p=q;
q=temp;
}
int main(){
int a=5,b=10;
int *p=&a,*q=&b;
cout<<"交换前:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
swappointer(p,q);
cout<<"交换后:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
cout<<endl;
a=5,b=10;
p=&a,q=&b;
cout<<"交换前:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
swappointer2(p,q);
cout<<"交换后:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
cout<<endl;
a=5,b=10;
p=&a,q=&b;
cout<<"交换前:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
swappointer3(p,q);
cout<<"交换后:"<<endl;
cout<<"p的值是:"<<p<<",q的值是:"<<q<<endl;
cout<<"p所指的值是:"<<*p<<",q所指的值是:"<<*q<<endl;
return 0;
}
练习6.23 参考本节介绍的几个print函数,根据理解编写你自己的版本。一次调用每个函数使其输入下面定义的i和j:
int i= 0,j[2]={0,1};
解答:
实现了三个版本的print函数,第一个版本不控制指针的边界,第二个版本由调用者指定数组的维度,第三个版本使用C++11新规定的begin和end函数限定数组边界。
#include <iostream>
using namespace std;
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<<endl;
}
}
int main(){
int i=0,j[2]={0,1};
print1(&i);
print2(j,sizeof(j)/sizeof(*j));
print3(begin(j),end(j));
return 0 ;
}
练习6.24 描述下面这个函数的行为,如果代码存在问题,请指出并修正。
void print(const int ia[10]){
for(size_t i=0;i!=10;++i)
cout<<ia[i]<<endl;
解答:
由之前的分析可知,print函数的参数实际上等同于一个常量整型指针const int *,形参ia的维度10只是我们期望的数组维度,实际上不一定。即使实参数组的真实维度不是10,也可以正常调用print函数。
上述print函数的定义存在一个风险,即虽然我们期望传入的数组维度是10,但实际上任意维度的数组都可以传入。如果传入的数组维度较大,print函数输出数组的前10个元素,不会引发错误;如果传入的数组维度不足10,则print函数将强行输出一些未定义的值。 应该修改为:
void print(const int ia[],const int sz){
for(size_t i=0;i!=sz;++i)
cout<<ia[i]<<endl;
}
6.2.5节练习(仍有疑问)
练习6.25 编写一个main函数,令其接受两个实参。把实参的内容连接成一个string对象并输出。
#include <iostream>
using namespace std;
int main(int argc,char **argv){
string str;
for(int i=0;i!=argc;++i)
str+=argv[i];
cout<<str<<endl;
return 0;
}
练习6.26 编写一个程序,使其接受本节所示的选项;输出传递给main函数的实参的内容。
#include <iostream>
using namespace std;
int main(int argc,char **argv){
for(int i=0;i!=argc;++i)
cout<<"argv["<<i<<"]:"<<argv[i]<<endl;
return 0;
}
6.2.6节练习
练习6.27 编写一个函数,它的参数是initializer_list<int>
类型的对象,函数的功能是计算列表中所有元素的和。
注意,icount的参数是initializer_list对象,而不是vector或数组
#include <iostream>
using namespace std;
int icount(initializer_list<int> il){
int count=0;
//遍历il的每一个元素
for(auto val:il)
count+=val;
return count;
}
int main(){
//使用列表初始化的方式构建initializer_<int>对象
//然后把它作为实参传递给icount函数
//注意,icount的参数是initializer_list对象,而不是vector或数组
initializer_list<int> list1={1,6,9};
cout<<"1,6,9的和是:"<<icount(list1)<<endl;
cout<<"4,5,9,18的和是:"<<icount({4,5,9,18})<<endl;
cout<<"10,10,10,10的是:"<<icount({10,10,10,10})<<endl;
return 0;
}
练习6.28 在error_msg函数的第二个版本中包含ErrCode类型的参数,其中循环内的elem是什么类型?
void error_msg(ErrCode e,initializer_list<string> il){
cout<<e.msg()<<":";
for(const auto &elem:il)
cout<<elem<<" ";
cout<<endl;
}
解答:
initializer_list<string>
的所有元素类型都是 string,而且,这个对象的元素永远都是常量值。因此const auto &elem:il 推断得到的elem类型都是const string&。使用引用是为了避免拷贝长字符串,把它定义为常量是因为我们只需读取字符串的内容,而不需要修改它。
练习6.29 在范围for循环中使用initializer_list对象时,应该将循环控制变量声明成引用类型吗?为什么?
解答:
引用类型的优势主要是可以直接操作所引用的对象以及避免拷贝较为复杂的类类型对象和容器对象。因为initializer_list对象的元素永远都是常量值,所以我们不可能通过设定引用类型来更改循环控制变量的内容。只有当initializer_list对象的元素是类类型或容器类型(string)时,才有必要把范围for循环的循环控制变量设为引用类型,这样做是为了避免拷贝较为复杂的对象。。
6.3.2节练习
练习6.30 编译200页的str_subrange函数,看看你的编译器是如何处理函数中的错误的。
无法编译通过,编译器发现了一个严重错误,即for循环中的return语句是非法的。函数的返回值是布尔值,而该条return语句没有返回任何值。
事实上,程序还存在另一个严重错误,按照程序的逻辑,for循环有可能不会中途退出而是一直执行完毕,此时显然缺少一条return语句处理这种情况。遗憾的是,编译器无法发现这一错误。
练习6.31 什么情况下返回的引用无效?什么情况下返回的常量的引用无效?
如果引用所引的是函数开始之前就以及存在的对象,则返回该引用是有效的;如果引用所引的是函数的局部变量,则随着函数结束局部变量也失效了,此时返回的引用无效。当不希望返回的对象被修改时返回常量的引用。
练习6.32 下面的函数合法吗?如果合法,说明其功能;如果不合法,修改其中的错误并解释原因。
int &get(int *arry,int index){return arry[index];}
int main{
int ia[10];
for(int i=0;i!=10;++i)
get(ia,i)=i;
}
该函数是合法的。get函数接受一个整型指针,该指针实际上指向一个整型数组的首元素,另外还接受一个整数表示数组的某个元素。它的返回值类型是整型引用,引用的对象是array数组的某个元素。当get函数执行完毕后,调用者得到实参数组arry中索引为index的元素的引用。
在mian函数中,首先创建一个包含10个整数的数组ia。由于ia定义在main函数的内部,所以ia不会执行默认初始化操作。如果我们此时输出ia的每个元素的值,得到的结果将是未定义的。 接下来进入循环,每次循环使用get函数得到ia中第i个元素的引用,为该引用赋值i,也就是为第i个元素赋值i。
练习6.33 编写一个递归函数,输出vector对象的内容。
#include <iostream>
#include <vector>
using namespace std;
void print(vector<int> vint,unsigned index){
unsigned sz=vint.size();
if(!vint.empty()&&index<sz){
cout<<vint[index]<<endl;
print(vint,index+1);
}
}
int main(){
vector<int> v={1,3,5,7,9,11,13,15};
print(v,0);
return 0;
}
练习6.34 如果factorial函数的停止条件如下所示,将发生什么情况?
if(val!=0)
理论上用户传入factorial函数的参数可以是负数。按照原程序的逻辑,参数为负数时函数的返回值为1。如果修改递归函数的停止条件,则当参数的值为负时,会依次递归下去,执行连续乘法直至溢出。因此不能把if语句的条件改为上述形式。
练习6.35 在调用factorial函数时,为什么我们传入的值是val-1而非val–?
如果把val-1改成val–,则出现一种我们不期望看到的情况,即变量的递减操作与读取变量值的操作共存于同一条表达式,这时有可能产生未定义的值。
6.3.3节练习
练习6.36 编写一个函数的声明,使其返回数组的引用并且该数组包含10个string对象。不要使用尾置返回类型、decltype或者类型别名。
因为数组不能直接被拷贝,所以函数不能直接返回数组,但是可以返回数组的指针或引用。
string (&func())[10];
上述声明的含义是func()表示调用func函数无须任何实参,
(&func())表示函数的返回结果是一个引用,
(&func()[10]表示引用的对象是一个维度为10的数组,
string (&func())[10]表示数组的元素是string对象。
练习6.37 为上一题的函数再写三个声明,一个使用类型别名,另一个室友尾置返回类型,最后一个使用decltype关键字。你觉得哪个形式最好?为什么?
//使用类型别名:
typedef string arr[10];
arr& func();
//使用尾置返回类型:
auto func() ->string(&) [10];
//使用decltype关键字:
string str[10];
decltype(str) &func();
练习6.38修改arrPtr函数,使其返回数组的引用。
数组也是一个对象,所以可以定义数组的引用。要想为数组的引用赋值,只需要把数组名赋给该引用即可。
int odd[]={1,3,5,7,9};
int even[]={2,4,6,8};
//返回一个引用,该引用所引的对象是一个含有5个整数的数组
decltype(odd) &arrPtr(int i){
return (i%2) ? odd : even;//返回数组的引用
6.4节练习
练习6.39 说明在下面的每组声明中的第二条声明语句是何含义。如果有非法声明,请指出来。
int calc(int,int)
int calc(const int,const int)
int get();
double get();
int *reset(int *);
double *reset(double *);
第一段代码的第二个声明是非法的。它的意图是声明另外一个函数,该函数只接受整型常量作为实参,但是因为顶层const不影响传入函数的对象,所以一个拥有顶层const的形参无法与另一个没有顶层const的形参区别开来。
第二段代码的第二个声明是非法的。它的意图是通过函数的返回值来区分两个同名的函数,但因为C++规定重载函数必须在形参数量或形参类型上有所区别,如果两个同名函数的形参数量和类型都一样,那么即使返回类型不同也不行。
第三段代码中断两个函数是重载关系,他们的形参类型有区别。
6.5.1节练习
练习6.40 下面的哪个声明是错误的?为什么?
int ff(int a, int b=0,int c=0);
char *init(int ht =24,int wd ,char bckgrnd);
解答:
第二个声明显然是错误的。虽然它们都用到了默认实参,但是C++规定一旦某个形参被赋予了默认形参,则它后面的所有形参都必须有默认实参。这一规定是为了防范可能出现的二义性。
练习6.41 下面的哪个调用是非法的?为什么?哪个调用虽然合法但显然与程序的初衷不符?为什么?
char *init(int ht,int wd=80,char bckgrnd=' ');
init();
init(24,10);
init(14,'*'):
解答:
第一个调用是非法的,该函数第一个形参并未设定默认实参,后两个是默认实参,所以要想调用该函数,至少要提供一个实参。
第二个调用合法,本次调用提供了两个实参,第一个实参对应第一个形参ht,第二个实参对应第二个形参wd,其中wd的默认实参没有用到,第三个形参bckgrnd使用它的默认实参。
第三个调用在语法上是合法的,但与程序的原意不符。从语法上来说,第一个实参对应第一个形参,第二个实参的类型虽然是char,但可以自动转换成第二个形参所需的int类型,所以编译可以通过,但这显然违背了程序的原意,正常情况下,字符*应该被用来构成背景。
练习6.42 给make_plural函数(6.3.2节)的第二个形参赋予默认实参‘s’,利用新版本的函数输出单词success和failure的单数和复数形式。
解答:
对于大部分名词来说,复数是在单词末尾加s得到,一小部分是需要在末尾加上es。我们可以把s作为默认实参,大多数情况下不必考虑这个参数,只有在遇到末尾是es的单词时才专门处理。
#include <iostream>
#include <string>
using namespace std;
string make_plural(size_t ctr,const string &word,const string &ending="s"){
return (ctr>1)? word + ending :word;
}
int main(){
cout<<"success的单数形式是:"<<make_plural(1,"success","es")<<endl;
cout<<"success的复数形式是:"<<make_plural(2,"success","es")<<endl;
//一般情况下调用该函数只需要两个实参
cout<<"failure的单数形式是:"<<make_plural(1,"failure")<<endl;
cout<<"faliure的复数形式是:"<<make_plural(2,"failure")<<endl;
return 0;
}
6.5.2节练习
练习6.43 你会把下面的哪个声明和定义放在头文件中?哪个放在源文件中?为什么?
inline bool eq(const BigInt&,const BigInt&){...}
void putValues(int *arr,int size);
函数的声明应该放在头文件中,同时内联函数的声明也应该放在头文件中。
解答:
第一行定义应该放在头文件中。因为内联函数的定义对编译器而言必须是可见的,以便编译器能够在调用点内联展开该函数的代码,所以仅有函数的原型不够。并且,与一般函数不同,内联函数有可能在程序中定义不止一次,此时必须保证在所有源文件中定义完全相同,把内联函数的定义放在头文件中可以确保这一点。
第二行是函数声明,应该放在头文件。
练习6.44 将6.2.2节的isshorter函数改写成内联函数。
解答:
只需在普通函数前面加上关键字inline,就可以将该函数设置为内联了。
练习6.45 回顾之前的练习中编写的函数,它们应该是内联函数吗?如果应该,请改写;如果不应该,说明原因。
解答:
决定一个函数是否应该是内联函数有很多评判标准。一般来说,内联机制适用于规模较小、流程直接、频繁调用的函数。一旦函数被定义成内联,则在编译阶段就展开该函数,以消除运行时产生的额外开销。如果函数的规模很大(上百行)不利于展开或者函数只被调用了一两次,就没必要内联。
练习6.46 能把isshorter函数定义成constexpr函数吗?
解答:
constexpr函数是指能用于常量表达式的函数,constexpr函数 的返回类型和所有形参都得是字面值类型,而且函数体中必须有且仅有一条return语句。
显然isshorter函数不符合constexpr函数的要求,它虽然只有一条return语句,但是返回的结果调用了标准库string类的size()函数和<比较符,无法构成常量表达式,因此不能。
6.6节练习
练习6.49 什么是候选函数?什么是可行函数?
解答:
见上文。
练习6.50 已知有第217页对函数f的声明,对于下面的每一个调用列出可行函数。其中哪个函数是最佳匹配?如果调用不合法,是因没有可匹配的函数还是因为调用具有二义性?
f(2.56,42)
f(42)
f(42,0)
f(2.56,3.14)
第一个调用的可行函数是void f(int,int)和void f(double,double=3.14).但最终的结果是这两个可行函数各自在一个形参实现了更好的匹配,所以无法判断孰优孰劣,因此编译器将因为这个调用具有二义性而拒绝其请求。
第二个调用的可行函数是void f(int)和void f(double,double=3.14),其中最佳匹配是void f(int)。
第三个调用的可行函数是void f(int,int)和void f(double,double=3.14),其中最佳匹配是void f(int,int)
第四个调用的可行函数是void f(int,int)和void f(double,double=3.14),其中最佳匹配是void f(double ,double=3.14)。
练习6.51 编写函数f的4个版本,令其各输出一条可以区分的消息。验证上一个练习的答案,如果你回答错了,反复研读本节。
#include <iostream>
using namespace std;
void f(){
cout<<"该函数无须参数"<<endl;
}
void f(int){
cout<<"该函数有一个整型参数"<<endl;
}
void f(int,int){
cout<<"该函数有两个整型参数"<<endl;
}
void f(double,double=3.14){
cout<<"该函数有两个双精度浮点型参数,其中一个为默认实参"<<endl;
}
int main(){
f(2.56,42);//编译器会产生二义性而拒绝请求
f(42);
f(42,0);
f(2.56,3.14);
return 0;
}
6.6.1节练习
练习6.52 已知有如下声明:
void manip(int,int);
double dobj;
请指出下列调用每个类型转换的等级。
manip('a','z');
manip(55.4,dobj);
解答:
第一个调用发生的参数类型转换是类型提升,字符型实参自动提升成整型。第二个调用发生的参数类型转换是算术类型转换,双精度浮点型自动转换成整型。
练习6.53 说明下面每组声明中的第二条语句会产生什么影响,并指出哪些不合法。
int calc(int&,int&);
int calc(const int&,const int&);
int calc(char*,char*);
int calc(const char*,const char*);
int calc(char*,char*);
int calc(char* const ,char* const);
解答:
第一组调用合法,两个函数的区别是它们的引用类型的形参是否引用了常量,属于底层const,可以把两个函数区分开。
第二组调用合法,两个函数的区别是它们的指针类型的形参是否指向了常量,属于底层const,可以把两个函数区分开。
第三个调用不合法,两个函数的区别是它们的指针类型的形参本身是否是常量,属于顶层const,根据本节介绍的匹配规则可知,向实参添加顶层const或者从或者从实参中删除顶层const属于精准匹配,无法区分两个函数。
6.7节练习
练习6.54 编写函数的声明,令其接受两个int形参并且返回类型也是int;然后声明一个vector对象,令其元素是指向该函数的指针。
int func(int,int)
vector<decltype(func)*> vF;//星号不能忘
练习6.55 编写4个函数,分别对两个int值执行加减乘除运算;在上一题创建的vector对象中保存指向这些值的指针。
#include <iostream>
#include <vector>
using namespace std;
int func1(int a,int b){
return a+b;
}
int func2(int a,int b){
return a-b;
}
int func3(int a,int b){
return a*b;
}
int func4(int a,int b){
return a/b;
}
int main(){
decltype(func1) *p1=func1,*p2=func2,*p3=func3,*p4=func4;
vector<decltype(func1)*>vF={p1,p2,p3,p4};
return 0;
}
练习6.56 调用上述vector对象中的每个元素并输出其结果。
#include <iostream>
#include <vector>
using namespace std;
int func1(int a,int b){
return a+b;
}
int func2(int a,int b){
return a-b;
}
int func3(int a,int b){
return a*b;
}
int func4(int a,int b){
return a/b;
}
void compute(int a,int b,int (*p)(int,int)){//第三个形参是指向函数的指针
cout<<p(a,b)<<endl;
}
int main(){
int i=5,j=10;
decltype(func1) *p1=func1,*p2=func2,*p3=func3,*p4=func4;
vector<decltype(func1)* >vF={p1,p2,p3,p4};
for(auto p:vF){
compute(i,j,p);//遍历vector中的每一个元素,依次调用四则运算函数
}
return 0;
}