第6章 函数
6.1 函数基础
编写函数
举例:编写一个求数的阶乘的程序。n
的阶乘是从1
到n
所有数字的乘积,例如5
的阶乘是120
:1*2*3*4*5 = 120
。
// 用 while 循环
int fact (int val)
{
int ret = 1; // 局部变量,用于保存计算结果
while (val > 1)
ret *= val--; // 把 ret 和 val 的乘积赋给 ret,然后将 val 减 1
return ret; // 返回结果
}
// 普通 for 循环
int fact (int val)
{
if (val < 0)
return -1;
int ret = 1;
// 从 1 连乘到 val
for (int i = 1; i != val + 1; ++i)
ret *= i;
return ret;
}
调用函数
int main()
{
int j = fact(5); // j 等于 120,即 fact(5) 的结果
cout << "5! is " << j << endl;
return 0;
}
函数的调用完成两项工作:
一、用实参初始化函数对应的形参;
二、将控制权转移给被调用函数,此时主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行。
return
语句完成的两项工作:
一、返回return
语句中的值(如果有的话);
二、将控制权从被调函数转移回主调函数。
函数的形参列表
函数的形参列表可以为空,要想顶一个不带形参的函数,最常用的办法是书写一个空的形参列表。不够为了与C语言兼容,也可以使用关键字void
表示函数没有形参:
void f1() {
/* ...*/ } // 隐式地定义空形参列表
void f2(void) {
/* ...*/} // 显式地定义空形参列表
每个形参都是含有一个声明符的声明,即使两个形参的类型一样,也必须把两个类型都写出来:
int f3(int v1, v2) {
/* ...*/ } // 错误
int f4(int v1, int v2) {
/* ...*/ } // 正确
函数返回类型
一种特殊的返回类型是void
,它表示函数不返回任何值。
函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
6.1.1 局部对象
在C++语言中,名字有作用域,对象有生命周期(lifetime)。
函数体是一个语句块,块构成一个新的作用域,形参和函数体内部定义的变量称为局部变量(local variable)。
自动对象
我们把只存在于块执行期间的对象称为自动对象(automatic object)。
形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数题作用域之内,所以一旦函数终止,形参也就被销毁。
局部静态对象
有些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static
类型从而获得这样的对象。
局部静态对象(local static object)在程序执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
// 这段程序将输出从 1 到 10(包括 10 在内)的数组
size_t count_calls()
{
static size_t ctr = 0; // 调用结束后,这个值仍然有效
return ++ctr;
}
int main()
{
for (size_t i = 0; i != 10; ++i)
cout << count_calls() << endl;
return 0;
}
/* Output:
1
2
3
4
5
6
7
8
9
10
*/
在控制流第一次经过ctr
的定义之前,ctr
被创建并初始化为0
。每次调用将ctr
加1
并返回新值。每次执行count_calls
函数时,变量ctr
的值都已经存在并且等于函数上一次推出时ctr
的值。因此,第二次调用时ctr
的值是1
,第三次调用时ctr
的值是2
,以此类推。
练习6.7:编写一个函数,当它第一次被调用时返回 0,以后每次被调用返回值加 1。
/* 练习6.7:编写一个函数,当它第一次被调用时返回 0,以后每次被调用返回值加 1。*/
#include <iostream>
using namespace std;
// 这段程序将输出从 1 到 10(包括 10 在内)的数组
unsigned myCnt()
{
// static size_t ctr = 0; // 调用结束后,这个值仍然有效
// return ++ctr;
static unsigned iCnt = -1; // iCnt 是静态局部变量
++iCnt;
return iCnt;
}
int main()
{
cout << "Please enter any char and press enter to continue." << endl;
char ch;
while (cin >> ch)
{
cout << "The function myCnt() has been called: " << myCnt() << " times." << endl;
}
return 0;
}
/* Output:
Please enter any char and press enter to continue.
s
The function myCnt() has been called: 0 times.
1
The function myCnt() has been called: 1 times.
t
The function myCnt() has been called: 2 times.
-
The function myCnt() has been called: 3 times.
^Z
*/
6.1.2 函数声明
函数声明无须函数体,定义需要函数体。
函数声明也称作函数原型(function prototype)。
在头文件中进行函数声明
函数应该在头文件中声明而在源文件中定义。
练习6.8:编写一个名为Chapter6.h的头文件,令其包含6.1节练习(第184页)中的函数声明。
#ifndef CHAPTER6_H_INCLUDED
#define CHAPTER6_H_INCLUDED
int fact(int );
double myABS(double );
double myABS(double );
#endif // CHAPTER6_H_INCLUDED
补充知识漏洞:
#ifndef
和 #endif
要一起使用,如果丢失#endif
,可能会报错。
在c
语言中,对同一个变量或者函数进行多次声明是不会报错的。所以如果h
文件里只是进行了声明工作,即使不使用# ifndef
宏定义,多个c
文件包含同一个h
文件也不会报错。
但是在c++
语言中,#ifdef
的作用域只是在单个文件中。所以如果h
文件里定义了全局变量,即使采用#ifdef
宏定义,多个c文件包含同一个h文件还是会出现全局变量重定义的错误。
使用#ifndef
可以避免下面这种错误:如果在h
文件中定义了全局变量,一个c
文件包含同一个h
文件多次,如果不加#ifndef
宏定义,会出现变量重复定义的错误;如果加了#ifndef
,则不会出现这种错误。
6.1.3 分离式编译 (separate compilation)
分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
编译和链接多个源文件
不熟悉,待整理 P187
6.2 参数传递
当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by value)。
练习6.13:假设 T 是某种类型的名字,说明以下两个函数声明的区别:一个是 void f(T),另一个是 void f(&T)。
/* 练习6.13:假设 T 是某种类型的名字,说明以下两个函数声明的区别:
一个是 void f(T),另一个是 void f(&T)。*/
#include <iostream>
using namespace std;
void a(int); // 传值参数
void b(int &); // 传引用参数
int main()
{
int s = 0, t = 10;
a(s);
cout << s << endl;
b(t);
cout << t << endl;
return 0;
}
void a(int i)
{
++i;
cout << i << endl;
}
void b(int &j)
{
++j;
cout << j << endl;
}
/*Output:
1
0
11
11
*/
6.2.1 传值参数
指针形参
指针的行为和其他非引用类型一样。当执行指针拷贝操作时,
int n = 0, i = 42;
int *p = &n, *q = &i; // p 指向 n;q 指向 i
*p = 42; // n 的值改变;p 不变
p = q; // p 现在指向了 i;但是 i 和 n 的值都不变
指针形参的行为与之类似:
// 该函数接受一个指针,然后将指针所指的位置为 0
void reset(int *ip)
{
*ip = 0; // 改变指针 ip 所指对象的值
ip = 0; // 只改变了 ip 的局部拷贝,实参未被改变
}
调用reset
函数之后,实参所指的对象被置为 0
,但是实参本身并没有改变:
int i = 42;
reset(&i); // 改变 i 的值而非 i 的地址
cout << "i = " << i << endl; // 输出 i = 0
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。
在C++语言中,建议使用引用类型的形参替代指针。
练习6.10:编写一个函数,使用指针形参交换两个整数的值。在代码中调用该函数并输出交换后的结果,以此验证函数的正确性。
/* 练习6.10:编写一个函数,使用指针形参交换两个整数的值。
在代码中调用该函数并输出交换后的结果,以此验证函数的正确性。*/
#include <iostream>
using namespace std;
// 在函数体内部通过解引用操作改变指针所指的内容
void mySWAP(int *p, int *q)
{
int tmp = *p; // tmp 是一个整数
*p = *q;
*q = tmp;
}
int main()
{
int a = 5, b = 10;
int *r = &a, *s = &b;
cout << "Before the exchange: a = " << a << ", b = " << b << endl;
mySWAP(r, s);
cout << "After the exchange: a = " << a << ", b = " << b << endl;
return 0;
}
/*
Before the exchange: a = 5, b = 10
After the exchange: a = 10, b = 5
*/
练习6.12:改写6.2.1节中练习6.10(第 188 页)的程序,使用引用而非指针交换两个整数的值。
/* 练习6.12:改写6.2.1节中练习6.10(第 188 页)的程序,使用引用而非指针交换两个整数的值。
你觉得哪种方法更易于使用呢?为什么?*/
#include <iostream>
using namespace std;
void mySWAP(int &i, int &j)
{
int tmp = i;
i = j;
j = tmp;
}
int main()
{
int a = 5, b = 10;
cout << "Before the exchange: a = " << a << ", b = " << b << endl;
mySWAP(a, b);
cout << "After the exchange: a = " << a << ", b = " << b << endl;
return 0;
}
/*
Before the exchange: a = 5, b = 10
After the exchange: a = 10, b = 5
*/
// 与使用指针相比,使用引用交换变量的内容从形式上看更简单一些,并且无须额外声明指针变量,也避免了拷贝指针的值。
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 相同
使用引用避免拷贝
拷贝大的类类我选哪个队吸纳过或者容器对象比较低效,甚至有的类类型(包括IO
类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
举例:string
对象非常长,所以应该尽量避免直接拷贝它们,这时使用引用形参是比较明智的选择。又因为比较长度无须改变string
对象的内容,所以把形参定义成对常量的引用:
// 比较两个 string 对象的长度
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
注意:如果函数无须改变应用型参的值,最好将其声明为常量引用。
与值传递相比,引用传递的优势主要体现在三个方面:
一是可以直接操作引用形参所引的对象;
二是使用引用形参可以避免拷贝大的类类型对象或容器类型对象;
三是使用引用形参可以帮助我们从函数中返回多个值
当函数的目的是交换两个参数的内容时应该使用引用类型的形参;
当参数是string
对象时,为了避免拷贝很长的字符串,应该使用引用类型。
在其他情况下可以使用值传递的方式,而无须使用引用传递,例如求整数的绝对值或者阶乘的程序。
使用引用形参返回额外信息
举例:定义一个名为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 = i; // 记录 c 第一次出现的位置
++occurs; // 将出现的次数加 1
}
}
return ret; // 出现次数通过 occurs 隐式地返回
}
// 调用 find_char 函数
auto index = find_char(s, 'o', ctr);
练习6.15:说明 find_char 函数中的三个形参为什么是现在的类型,特别说明为什么 s 是常量引用而 occurs 是普通引用?为什么 s 和 occurs 是引用类型而 c 不是?如果令 s 是普通引用会发生什么情况?如果令 occurs 是常量引用会发生什么情况?
find_char
函数的三个参数的类型设定与该函数的处理逻辑密切相关,原因分别如下:
- 对于待查找的字符串
s
来说,为了避免拷贝长字符串,使用引用类型;同时我们只执行查找操作,无须改变字符串的内容,所以将其声明为常量引用。 - 对于带查找的字符
c
来说,它的类型是char
,只占1
个字节,拷贝的代价很低,而且我们无须操作实参在内存中实际存储的内容,只把它的值拷贝给形参即可,所以不需要使用引用类型。 - 对于字符出现的次数
occurs
来说,因为需要把函数内对实参值的更改反映在函数外部,所以必须将其定义成引用类型;但是不能把它定义成常量引用,否则就不能改变所引的内容了。
6.2.3 const形参和实参
当形参是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
对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。
int i = 42;
const int *cp = &i; // 正确:但是 cp 不能改变 i
const int &r = i; // 正确:但是 r 不能改变 i
const int &r2 = 42; // 正确
int *p = cp; // 错误:p 的类型和 cp 的类型不匹配
int &r3 = r; // 错误:r3 的类型和 r 的类型不匹配
int &r4 = 42; // 错误:不能用字面值初始化一个非常量引用
// reset() 函数
void reset(int &i) // i 是传给 reset 函数的对象的另一个名字
{
i = 0; // 改变了 i 所引对象的值
}
int i = 0;
const<