2.7 函数
函数是C和C++程序的基本构件,在C++语言中定义函数的方法和基本规则与C语言基本相同。这里仅就C++语言对函数扩充的几方面进行介绍。
2.7.1 函数原型
在早期的C语言中并没有函数原型这一概念,是在C语言标准化时增加的。在C语言程序中,只是推荐使用函数原型,但并不是必须的。
C++语言是一种强类型检查语言,每个函数调用的实参在编译期间都要经过类型检查。如果实参类型与对应的形参类型不匹配,C++语言就会尝试可能的类型转换,若没有类型转换行得通,或实参个数与函数的参数个数不相符,就会产生编译错误。要实现这样的检查,就要求所有的函数必须在调用之前进行声明或定义。
函数原型就是常说的函数声明,只有一条语句,由函数返回类型、函数名和形式参数表三部分构成。参数表中包括所有参数的类型和参数名,参数之间用逗号隔开。形式如下:
rtype f_name(type1 p1,type2 p2,…);
其中,rtype是函数的返回类型,f_name是函数名,type1是参数1的类型,type2是参数2的名称,p1、p2是形式参数名。
函数原型中的参数名可以省略,即上面函数原型中的p1、p2是可省略的。
【例2-9】 函数原型的一个简单例子。
//Eg2-9.cpp #include<iostream.h> double sqrt(double f); //L1 函数原型 void main(){ for(int i=0;i<10;i++) cout<<i<<"*"<<i<<"="<<sqrt(i)<<endl; } double sqrt(double f) { //L2 函数定义 return f*f; }
main之前的函数声明double sqrt(double f)就是函数原型,其中的形参名f是可省略的。在C语言中没有这个声明也可以,但在C++中没有这个函数原型就会引起编译错误。
说明:① 函数定义时的返回类型、函数名、参数个数、参数的次序和类型必须与函数原型相符,但参数名可以不同。下面函数的形参名称与例2-9中的sqrt()函数不同,但它们是同一个函数。
double sqrt(double d){ return d*d; }
② C++与C语言的函数参数声明存在区别。C语言支持传统的函数声明方式,即可将函数参数的类型说明放在函数头和函数体之间,形式如下:
rtype f_name(p1,p2,…,pn) type1 p1, …… typen pn; { …… //函数代码 }
在C语言中,这个函数声明与下面的形式(有人称为现代形式)是等价的:
rtype f_name(type1 p1,type2 p2,…,typen pn) { …… //函数代码 }
C++不支持传统的函数参数声明方式,只支持现代形式的参数声明方式。
③ 如果函数原型中没有指出函数的返回类型(包括主函数main()),C++将默认该函数的返回类型是int类型(在Visual C++ .NET中不允许默认参数,如果一个函数没有返回类型,则必须指明它的返回类型为void)。
int f(int,int); f(int,int); //在Visual C++.NET中,此函数原型因无返回类型而错误 void f1(int i, int j); void main()
上面的两个f()函数完全等价,其返回类型为int。f1()和main()是返回类型为void的函数,在函数体中不需要return语句。
2.7.2 函数默认参数
C++允许为函数提供默认参数,也称为缺省参数。在调用具有默认参数的函数时,如果没有提供调用参数,C++将自动把默认参数作为相应参数的值。
【例2-10】 默认参数的一个应用例子。
//Eg2-10.cpp #include <iostream.h> double sqrt(double f=1.0); //sqrt具有默认参数值,默认时f为1.0 void main(){ cout<<sqrt()<<endl; //调用sqrt时没有提供实参,按默认值f=1.0调用函数 cout<<sqrt(5)<<endl; //调用时提供了实参,默认值就无效了,f=5 } //定义时,不能指定默认参数。若改为double sqrt(double f=1.0),则错 double sqrt(double f) { return f*f; }
说明:① 在指定某个函数的默认值时,如果它有函数原型,就只能在函数原型中指定对应参数的默认值,不能在定义函数时再重复指定参数默认值。当然,若函数是直接定义的,没有函数原型,若要指定参数默认值,在定义时指定就行了。
② 在具有多个参数的函数中指定默认值时,所有默认参数都必须出现在不默认参数的右边。一旦某个参数开始指定默认值,它右边的所有参数都必须指定默认值。
int f(int i1,int i2=2,int i3=0); //正确 int g(int i1,int i2=0,int i3); //错误,i3没有默认值 int h(int i1=0,int i2,int i3=0); //错误,i1默认后,其右边的i2没有默认值
③ 在调用具有默认参数值的函数时,若某个实参默认,其右边的所有实参都应默认。例如:
int f(int i1=1,int i2=2,int i3=0){ return i1+i2+i3; }
针对此函数,有如下调用:
f(); //正确,i1=1,i2=2,i3=0 f(3); //正确,i1=3,i2=2,i3=0 f(2,3); //正确,i1=2,i2=3,i3=0 f(4,5,6); //正确,i1=4,i2=5,i3=6 f(,2,3); //错误,i1默认了,而右边的i2,i3没有默认
2.7.3 函数与引用
1.引用参数
C++引入引用的主要用途是传递函数参数,特别是对于以下三种情况,引用参数(同样适用于指针参数)很有用:需要从函数中返回多于一个值,需要修改实参值本身,传递地址可以节省复制大量数据的内存空间和时间。
在讨论引用参数这个问题之前,先简要谈谈函数参数传递的方法。在C或C++中(其他程序设计语言也不例外),所有函数都要在运行栈(堆栈)中分配的存储区域保存数据,该存储区域一直保存到函数调用结束。C++将在函数运行的栈空间中为每个函数参数提供存储区,存储区大小由参数类型决定。在调用函数时,C++将用实参的值来初始化各个参数,方法是把实参的值复制到对应参数在运行栈的存储单元中,这种参数传递方式也称为按值传递。
按值传递参数时,函数处理的是它在本地的复制值,这些复制值在运行栈中,其修改不会引起实参值的变化。当函数调用完成时,与该函数相对应的存储区域将自动释放,以便被其他函数调用。函数中定义的变量、常数以及函数调用时传递的参数都会因存储区域的释放而变得无效。
例如在下面的程序段中,swap1()是无法实现两数x和y的交换的。当swap1(x,y)调用发生时,将x的值10复制给形参a,将y的值复制给形参b,之后x和a、y和b就无联系了,swap1()实际交换的是形参a和b的值。
void swap1(int a,int b) { int temp=a; a=b; b=temp; } x=10; y=5; swap1(x,y);
如果确实要完成两数的交换,可以通过指针实现。
void swap2(int *a,int *b) { int temp=*a; *a=*b; *b=temp; } x=10; y=5; swap2(&x,&y);
指针作为参数时,C++将把实参的地址复制到指针参数在运行栈中分配的存储单元中。在调用函数swap2()时,C++将把实参x的地址复制到参数a 在栈中的存储单元中,把y的地址复制到参数b在栈中的存储单元中。参数a、b实际指向了x,y的内存单元,因此函数swap2()通过指针参数a、b,就能完成实参x和y对应的内存数据的交换。
指针参数比较复杂,每次都需要使用解引用的方式访问指针所指向的变量值,函数调用的形式(如swap2(&x,&y))让人感觉好像在处理函数变量的地址,阅读困难。
使用引用传递参数能够达到与指针同样的效果,但它的使用形式比指针参数简单,与按值传递参数的使用形式相同。
【例2-11】 用引用参数完成两数交换的函数swap( )。
//Eg2-11.cpp #include<iostream.h> void swap(int &a,int &b) { int temp=a; a=b; b=temp; } void main(){ int x=5; int y=10; swap(x,y); cout<<"x="<<x<<"\ty="<<y<<endl; }
引用作为参数传递的是实参变量本身(引用是变量的左值,即实参的地址),而不是将实参的值复制到函数参数在运行栈中的存储区域中。此外,也可认为引用是变量的别名,在传递引用参数时,引用参数对应实参的别名。因此,对于引用参数,函数操作的是实参本身,而不是实参的拷贝值,这就意味着函数能够改变实参的值,所以本程序的运行结果是:
x=10 y=5
由于引用参数传递的是实参的地址,因此在调用函数时,不能向引用参数传递常数。如对于例2-11的函数swap( ),下面的调用是错误的:
int x=5; swap(3,4); //错误,3,4是常数 swap(x,9); //错误,9是常数 swap(6,x); //错误,6是常数
除了像指针一样用于改变实参的值需要引用参数之外,C++引入引用的另一原因是传递大型的类对象或数据结构。在按值传递参数的情况下,传递小型类对象和结构变量不存在效率问题,但在传递大型结构变量或类对象时,需要进行大量的数据复制(把实参对象或结构变量的值复制到函数参数在运行栈分配的存储区域中),效率就太低了。
【例2-12】 按值传递参数与引用传递参数的效率对比。
//Eg2-12.cpp #include <iostream.h> #include <string.h> struct student{ char name[12]; //学生姓名 char Id[8]; //学号 int age; //年龄 double score[10]; //10科成绩 }; void print(student a) { cout<<a.name<<endl; cout<<a.Id<<endl; cout<<a.age<<endl; for(int i=0;i<10;i++) cout<<a.score[i]<<endl; } void main(){ student x; …… //对x进行赋值的语句省掉了 print(x); cout<<sizeof(x)<<endl; //计算x的内存块大小 }
在调用print(x)打印学生的各项数据时,将把x的各项数据复制到print()函数的运行栈内为参数a分配的存储块中。最后一条语句计算出学生结构的大小是104字节,计算过程如下:
12(name)+8(Id)+4(age)+8×10(score)=104
即向函数print()传递student类型的参数数据时,将完成104字节的数据复制。如果频繁调用此函数,进行参数复制的开销是相当可观的。若将print()的参数改为引用形式:
void print(student &a){…}
则每次调用该函数打印student类型的学生数据时,只需要复制4字节的地址数据(对于32位计算机),比之于104字节的参数复制,效率就高多了。
2.函数返回引用
除了返回值或指针外,函数还可以返回一个引用。返回引用的函数定义形式如下:
rtype &f_name(type1 p1,type2 p2,…);
其中,rtype是返回类型,type1和type2分别是参数p1、p2的数据类型。
当一个函数返回引用时,实际返回了一个变量的内存地址。既然是内存地址,就能够读和写该地址所对应的内存区域中的值,这使函数调用能够出现在赋值语句的左边。
【例2-13】 返回引用的两数相加函数。
//Eg2-13.cpp #include <iostream.h> int temp; int& f(int i1,int i2){ temp=i1+i2; return temp; } void main(){ int t=f(1,3); //L1 cout<<temp<< " "; //L2 f(2,8)++; //L3 cout<<temp<< " "; //L4 f(2,3)=9; //L5 cout<<temp<<endl; //L6 }
本程序的运行结果如下:
4 11 9
由于函数f()返回一个引用值,所以它返回全局变量temp的地址。语句L1中的函数调用f(1,3)将把1和3相加的结果4存入temp,并返回temp的地址,最后把temp中的值复制到t的内存区域中。L1执行后,temp的值成为4,所以L2输出的temp值为4。
语句L3中的函数f(2,8)调用将使temp更改为10,然后将对temp执行自增运算,所以L4输出的temp值为11。
语句L5中的f(2,3)将修改temp的值为5,然后返回temp的地址,再将该地址中的值改为9。这次函数调用其实等效于下面的两条语句,因为f(2,3)返回的是temp变量的地址:
f(2,3); temp=9;
因此,L6输出的temp值为9。
当一个函数返回引用时,return语句必须返回一个变量,而返回值的函数的return则可以返回一个表达式。作为返回值类型的函数,下面的函数g()是正确的:
int g(int i1,int i2){ return i1+i2; }
但是,如下将g( )定义成引用的函数则是错误的:
int &g(int i1,int i2){ return i1+i2; }
原因是返回引用的函数需要return一个变量。但若将g()改为返回常数的引用函数,在C++中是允许的。例如:
const int &g(int i1,int i2){ return i1+i2; }
C++在返回一个表达式时,将首先计算表达式的值,然后生成一个临时变量,并将表达式的值存放到生成的临时变量中。“return i1+i2”的处理过程大致如下:
int temp=i1+i2; return temp;
临时变量temp对程序员并不可见,当函数调用完成时,temp所占用的内存空间就归还给系统了,所以一个返回类型为引用的函数不能返回一个表达式。但是当将函数定义成返回const引用类型的函数时,C++将把temp的地址作为函数的返回值,并且保留temp,直到应用函数结果的变量的生命期结束。
2.7.4 函数与const
用const 限制函数的参数能够保证函数不对参数做任何修改,下面的函数说明这一问题。
int f(int i1,const int i2){ i1++; i2++; return i1+i2; }
本函数中的i1++没有问题,而i2++则是错误的。原因是,i2是const型参数,而const型变量不允许重新赋值,也不允许修改。i2++将使i2增加1,是不允许的。
对于按值传递的函数参数而言,将参数限定为const型意义不大,因为它们不会引起函数调用时实参的变化。但对于指针和引用参数而言,就存在实参被意外修改的危险。在这种情况下,就可以将相关参数限制为const类型的引用或指针。此外,对于返回指针或引用的函数,也可以用const限制其返回值。
【例2-14】 返回const引用的函数。
//Eg2-14.cpp #include<iostream.h> const int& index(int x[],int n){ return x[n]; } void main(){ int a[]={0,1,2,3,4,5,6,7,8,9}; cout<<index(a,6)<<endl; index(a,2)=90; //错误 cout<<a[2]<<endl; }
函数调用index(a,2)返回a[2]的地址,由于函数index()返回的是const 引用,所以不能对它进行修改。如果index()的返回值没有const限制,例如:
int& index(int x[],int n){ return x[n]; }
对于此函数而言,主函数没有错误。“index(a,2)=90;”将首先返回数组元素a[2],再将它改为90,所以main()中的最后一条语句将输出90。
在C++中,函数参数可能会是大型对象,用值传递方式进行参数传递需要进行大量的数据复制,存储空间和运行时间的开销较大,效率较低。但用const限定的指针或引用传递参数,可避免函数对参数对象进行修改,既高效又安全。
2.7.5 函数重载
1.函数重载的概念
函数重载就是允许在同一程序中(确切地讲是指在同一作用域内)定义多个同名函数,这些同名函数可以有不同的返回类型、参数类型、参数个类,以及不同的函数功能。
【例2-15】 重载计算int、float、double三种类型数据绝对值的函数。
//Eg2-15.cpp #include<iostream.h> int abs(int x) {return x>0?x:-x;} float abs(float x) {return x>0?x:-x;} double abs(double x) {return x>0?x:-x;} void main(){ cout<<abs(-9) <<endl; cout<<abs(-9.9f) <<endl; cout<<abs(-9.8) <<endl; }
在函数调用时,C++将根据实参的类型确定到底调用哪个函数,它会调用与实参类型最相符合的那个重载函数。abs(-9)的实参是int类型,所以将调用函数abs(int x);abs(-9.9f)的实参是float类型,所以将调用函数abs(float x);abs(-9.8)的实参是double类型,所以将调用函数abs(double x)。
2.函数重载解析过程
把函数调用与多个同名重载函数中的某个函数相关联的过程称为函数重载解析。在具有多个同名函数的情况下,就需要找到形式参数与实参表达式类型匹配最好的那个函数。C++进行函数参数匹配的原则和次序如下。
① 精确匹配。精确匹配是指实参与函数的形式参数类型完全相同,不需要做任何转换或只需进行要平凡转换(如从数组名到指针,函数名到函数指针,或T到const T等)的参数匹配。
② 提升匹配。提升主要是指从窄类型到宽类型的转换,这种转换没有精度损失,包括整数提升和float到double的提升。整数提升包括从bool到int、char到int、short到int,以及它们的无符号版本,如unsigned short到unsigned int的提升。
③ 标准转换匹配。如int到double、double到int、double到long double,派生类指针到基类指针的转换(将在第4章讲述),如T*到void *、int到unsigned int的转换。
④ 用户定义的类型转换。在C++语言中,程序员可以定义类型转换函数。如果在程序中定义了这样的转换函数,这些转换函数也会用于重载函数的匹配。
【例2-16】 函数重载解析的例子。
//Eg2-16.cpp #include <iostream.h> void f(int i){cout<<i<<endl;} void f(const char*s){cout<<s<<endl;} void main(){ char c='A'; int i=1; short s=2; double ff=3.4; char a[10]="123456789"; f(c); //f(int i) 提升 f(i); //f(int i) 精确匹配 f(s); //f(int i) 提升 f(ff); //f(int i) 转换 f('a'); //f(int i) 提升 f(3); //f(int i) 精确匹配 f("string"); //f(const char*s) 精确匹配 f(a); //f(const char*s) 精确匹配 }
在进行重载函数解析时,将按照“精确匹配→提升→转换→用户定义的类型转换”的次序寻找一个恰当的函数调用。
例如f(i)调用,由于i是int,正好与f(int)的形式参数类型相匹配,所以f(i)就会调用函数f(int)。而对于f(c)调用,由于c是char类型,在重载函数中没有f(char)这样的函数,所以精确匹配失败,接着C++就会尝试能否进行参数提升,恰好char能够提升为int,所以就调用f(int)。
对于f(ff)调用,首先是参数匹配失败,接着是参数提升失败,接下来就会尝试参数类型转换,正好double能够转换成int(要损失数值精度),所以调用函数f(int)。
3.重载函数的注意事项
① 重载函数必须在参数类型,或参数个数,或参数顺序方面有所不同才是正确的。如果两个函数只有返回类型不同,而函数名、参数表都完全相同,就不能称为重载函数,而是属于函数的重复定义,是错误的。下面3个f()函数是正确的函数重载:
int f(int,int); double f(int); int f(char);
下面两个函数只有返回类型不同,是错误的:
int f(int); double f(int);
② 在定义和调用重载函数时,要注意它的二义性。例如:
int f(int& x) {……} double f(int x) {……} int g(unsigned int x) {return x;} double g(double x) {return x;}
函数f()和g()都是正确的重载函数,但是如何调用它们呢?例如:
int a=1; f(a); //错误,产生二义性 g(a); //错误,产生二义性
C++无法确定调用f(int& x)还是f(int x),因为两种调用都是正确的,产生函数调用二义性。同样,由于精确匹配和提升对于g(a)的调用都会失败,因此就会使用转换的原则调用g(a),但int既可以转换成unsigned int,也可以转换成double,则g(a)调用g(unsigned int x)或g(double x)都是正确的,因此会产生二义性。
注意:C++所有的标准转换都是等价的,没有哪个转换存在什么优先权,从int到unsigned int的转换并不比从int到double的转换高一个优先级。