这一章在干嘛?
前面各章我们一直在”用”函数(比如
strlen()),这一章开始自己”造”函数。C++ 程序就像乐高积木:main()是总装车间,函数是一个个标准零件。本章把零件的定义、原型、调用三件套讲清,再逐一攻克处理数组、字符串、结构体的函数,以及两个进阶玩法:函数调用自己的递归,和把函数当参数传的函数指针。
7.1 函数复习
使用一个 C++ 函数,要做三件事:提供函数定义(真正的可执行代码)、提供函数原型(给编译器的说明书)、调用函数。用库函数时前两件别人已做好(如 cstring 头文件里就有 strlen() 的原型),自己写函数则三件都得亲自动手:
// calling.cpp -- 定义、原型和调用一个函数
#include <iostream>
void simple(); // 函数原型
int main()
{
using namespace std;
cout << "main() will call the simple() function:\n";
simple(); // 函数调用
cout << "main() is finished with the simple() function.\n";
return 0;
}
// 函数定义
void simple()
{
using namespace std;
cout << "I'm but a simple function.\n";
}执行到 simple(); 时 main() 暂停、控制权转交,干完活后控制权回到 main() 接着走——就像你读菜谱做到一半打电话问朋友,朋友说完挂断,你从刚才那一步继续。
7.2 定义函数与原型
void 函数(无返回值)通用格式:void functionName(parameterList) { statement(s); return; },return 可省。有返回值的函数必须用 return 把值交回调用者,值的类型要能转换成声明的返回类型:
void cheers(int n) // 无返回值
{
for (int i = 0; i < n; i++)
std::cout << "Cheers! ";
std::cout << std::endl;
}
double cube(double x) // x 乘 x 再乘 x
{
return x * x * x; // 一个 double 值
}cube(1.2) 的”值”就是 1.728。唯一不能当返回值的类型是数组——不过结构体、对象、指针都行(数组可以塞进结构体里曲线返回)。多个 return 时执行到第一个就退出,后面的 else 写出来只为方便阅读。返回值的物理机制是:把返回值复制到约定的 CPU 寄存器或内存位置,调用方按原型说明的类型去取——原型和定义必须一致,就像给快递员和公司前台各留一份相同的取件描述。

▲ 图 7.1 典型的返回值机制
原型为什么必须存在
原型向编译器描述接口:返回值类型、参数个数与类型。以 double volume = cube(side); 为例:原型让编译器检查参数是否给对,并知道函数返回时该”取几个字节、怎么解释”。编译器不能靠”往下翻文件看定义”代替原型:一是效率太低,二是函数可能根本不在本文件里(C++ 允许程序拆成多个独立编译的文件,或函数在库中)。所以必须靠原型提前”挂号”,这也是 C++ 惯例把 main() 放最前面的原因。
原型语法与它给你的保护
原型就是”函数头 + 分号”,参数名可省:double cube(double x);、void cheers(int); 都行。C++ 中空括号 void say_hi(); 等价于 void say_hi(void); 表示无参;ANSI C 中它却表示”懒得声明参数”。C++ 里真正的”不管参数”要用省略号 void say_bye(...);,一般只在对接 printf() 这类变参 C 函数时用。
原型带来三大保护:返回值被正确处理、参数个数被检查、参数类型不匹配时自动转换。例如 cheers(cube(2));:2 先转成 2.0 传给 cube(),返回的 8.0 又转成 int 8 传给 cheers()。当然转换不是万能的——把 8.33E27 塞给 int 会丢数据,整数也永远不会被自动转成结构体或指针。这种编译期检查叫静态类型检查,能把许多运行时才爆的雷提前排掉。
7.3 按值传递
C++ 默认按值传递(pass by value):把实参的值复制一份,赋给函数里新建的变量。调用 cube(side)(side 为 5)时,函数创建新变量 x 并初始化为 5,之后函数玩的是”复印件”,原件毫发无伤。术语约定:传进来的叫实参(argument),接收它的叫形参(parameter),实参传递的本质是”用实参初始化形参”。

▲ 图 7.2 按值传递
包括形参在内,函数内声明的变量都是局部变量:调用时分配内存,函数结束就释放。main() 里的 x 和别的函数里的 x 是两个毫不相干的变量,就像加州的 Albany 和纽约州的 Albany 重名但无关。

▲ 图 7.3 局部变量
多个参数用逗号隔开;注意同类型参数也必须逐个声明,void fufu(float a, b) 是非法的。核心示例 Listing 7.3(twoarg.cpp):n_chars(ch, times) 打印字符 ch 共 times 遍,函数体内 while (n-- > 0) cout << c; 把形参 n 从 times 减到 0,可结束后 main() 打印 times 仍是原值——函数里怎么折腾形参,都动不到外面的实参。顺带一提:读字符用 cin >> ch 而不是 cin.get(ch),因为 >> 会自动跳过空格和换行,而按回车产生的换行符会被 cin.get() 当成有效输入。
void n_chars(char c, int n) // 显示字符 c 共 n 次
{
while (n-- > 0)
cout << c;
}再来个有点数学味的例子
计算彩票中奖概率:从 51 个数里选 6 个,中奖概率是 1/R,其中 R = (51×50×…×46)/(6×5×…×1)。技巧是边乘边除,让中间结果保持小、避免溢出:(10/2)×(9/1) 和 (10×9)/(2×1) 结果一样,但前者中间值只有 5,后者冲到 90。
// lotto.cpp -- 中奖概率(main() 循环读入两个数并调用,此处展示核心函数)
long double probability(unsigned numbers, unsigned picks)
{
long double result = 1.0; // 局部变量
long double n;
unsigned p;
for (n = numbers, p = picks; p > 0; n--, p--)
result = result * n / p;
return result;
}原书 main() 里循环读入 total 和 choices 并调用 probability(total, choices),输入 49 和 6 得到 You have one chance in 1.39838e+007 of winning.——选择越多,中奖越渺茫。这个函数展示了两种局部变量:形参(numbers、picks)从调用者那里拿值,普通局部变量(result、n、p)在函数体内自己产生。
常见坑:以为改形参就能改实参
按值传递下,形参是复印件。想真正修改调用者的数据,得传地址(指针)或引用(第 8 章),而不是祈祷函数里改
n能让外面的times变。
7.4 函数和数组
数组名在大多数语境下会被 C++ 解释为首元素地址:cookies == &cookies[0](例外:数组声明本身、sizeof 数组名 得整个数组大小、&数组名 得整个数组的地址)。因此把数组传给函数,传的其实是地址,函数头写法:
int sum_arr(int arr[], int n) // arr = 数组名,n = 大小方括号只是”提示性记号”——它真正的含义是 int *arr,两者只在函数头/原型中完全等价。函数体里照样可以像数组一样用 arr[i],所以 Listing 7.5(arrfun1.cpp)对 8 个元素的 {1,2,4,8,16,32,64,128} 求和,输出 Total cookies eaten: 255。

▲ 图 7.4 告诉函数数组的位置、类型和元素个数
传地址意味着什么
函数并没有拿到数组的副本,只拿到三样信息:在哪(首地址)、什么类型、有几个(靠你另外传 n),然后直接操作原件。这不违反按值传递——传的那个”值”就是一个地址。好处是省去复制大数组的开销;风险是可能误改原数据,这正是 const 的用武之地。两个重要恒等式(指针加 1 实际移动所指类型的字节数):
arr[i] == *(ar + i) // 值的两种写法
&arr[i] == ar + i // 地址的两种写法下面的程序(Listing 7.6,arrfun2.cpp)把这一点演示得淋漓尽致:
// arrfun2.cpp -- 数组参数演示
#include <iostream>
const int ArSize = 8;
int sum_arr(int arr[], int n);
int main()
{
int cookies[ArSize] = {1, 2, 4, 8, 16, 32, 64, 128};
std::cout << cookies << " = array address, ";
std::cout << sizeof cookies << " = sizeof cookies\n";
int sum = sum_arr(cookies, ArSize);
std::cout << "Total cookies eaten: " << sum << std::endl;
sum = sum_arr(cookies, 3); // 一个"谎言"
std::cout << "First three eaters ate " << sum << " cookies.\n";
sum = sum_arr(cookies + 4, 4); // 另一个"谎言"
std::cout << "Last four eaters ate " << sum << " cookies.\n";
return 0;
}
int sum_arr(int arr[], int n)
{
int total = 0;
std::cout << arr << " = arr, ";
std::cout << sizeof arr << " = sizeof arr\n";
for (int i = 0; i < n; i++)
total = total + arr[i];
return total;
}输出里 cookies 和 arr 打印出相同地址,但 sizeof cookies 是 32(整个数组)、sizeof arr 只有 4(一个指针)——所以必须单独传元素个数,指针自己不知道数组多长。三个关键观察:① 传的确实是地址;② 指针不携带大小信息;③ 既然函数只认”起始地址 + 个数”,你就可以”撒谎”:传 cookies + 4 就是告诉它”数组从第 5 个元素开始”,于是后四个元素被求和(&cookies[4] 与 cookies + 4 完全等价)。
常见坑:想在原型里用方括号传大小
void fillArray(int arr[size]);是错误原型。数组大小必须作为单独的int参数传:void fillArray(int arr[], int size);
区间(range)传法
另一种方案是传两个指针:一个指向开头,一个指向”最后一个元素的下一个位置”(one past the end,STL 正是这么泛化的)。Listing 7.8(arrfun4.cpp)把求和函数改成区间版,调用如 sum_arr(cookies, cookies + ArSize)(全数组)、sum_arr(cookies, cookies + 3)(前 3 个)、sum_arr(cookies + 4, cookies + 8)(后 4 个):
// arrfun4.cpp -- 使用数组区间的函数
int sum_arr(const int * begin, const int * end)
{
const int * pt;
int total = 0;
for (pt = begin; pt != end; pt++)
total = total + *pt;
return total;
}循环条件是 pt != end:当 pt 走到”末尾之后”就停。end - begin 恰好等于区间元素个数。注意 begin 和 end 的顺序不能传反。
用 const 保护数组,并拼成完整程序
只读函数必须声明”我只看不改”:void show_array(const double ar[], int n);——const double ar[] 即 const double *ar,ar[0] += 10; 会被编译器当场拦截。下面的”房产管理”程序(Listing 7.7,arrfun3.cpp)展示自底向上编程:先设计数据(数组)和对数据的操作(填充、显示、重估值三个函数),再拼装 main():
// arrfun3.cpp -- 数组函数与 const
#include <iostream>
const int Max = 5;
// 函数原型
int fill_array(double ar[], int limit);
void show_array(const double ar[], int n); // 不修改数据
void revalue(double r, double ar[], int n);
int main()
{
using namespace std;
double properties[Max];
int size = fill_array(properties, Max);
show_array(properties, size);
if (size > 0)
{
cout << "Enter revaluation factor: ";
double factor;
while (!(cin >> factor)) // 输入非法则要求重输
{
cin.clear();
while (cin.get() != '\n')
continue;
cout << "Bad input; Please enter a number: ";
}
revalue(factor, properties, size);
show_array(properties, size);
}
cout << "Done.\n";
return 0;
}
int fill_array(double ar[], int limit)
{
using namespace std;
double temp;
int i;
for (i = 0; i < limit; i++)
{
cout << "Enter value #" << (i + 1) << ": ";
cin >> temp;
if (!cin) // 输入非法
{
cin.clear();
while (cin.get() != '\n')
continue;
cout << "Bad input; input process terminated.\n";
break;
}
else if (temp < 0) // 负数作为结束信号
break;
ar[i] = temp;
}
return i;
}
// 下面的函数可以使用(但不能修改)ar 指向的数组
void show_array(const double ar[], int n)
{
using namespace std;
for (int i = 0; i < n; i++)
cout << "Property #" << (i + 1) << ": $" << ar[i] << endl;
}
// 把 ar[] 的每个元素乘以 r
void revalue(double r, double ar[], int n)
{
for (int i = 0; i < n; i++)
ar[i] *= r;
}输入五处房产的值与重估因子 0.8 后,各房产同步缩水;也可以中途输负数提前结束输入。这种”先想数据再想操作”的思路正是 OOP 的起点。
7.5 指针与 const
const 和指针组合有两种玩法,方向完全相反:
int sloth = 3;
const int * ps = &sloth; // 指向 const 的指针:不能通过 ps 改值
int * const finger = &sloth; // const 指针:不能改指向*ps是 const、ps不是——ps 可以改指向别人,但不能用它改值;finger是 const、*finger不是——finger 死心塌地指着 sloth,但可以改 sloth 的值;- 双重保险:
const double * const stick = &trouble;指向和值都锁死。

▲ 图 7.5 指向 const 的指针与 const 指针
一个微妙点:const int * pt = &age; 并不要求 age 真的是 const——它只是说”经由 pt 这条路不许改”。你可以直接 age = 20;,但不许 *pt = 20;。赋值规则一句话:
const 数据的地址只能交给指向 const 的指针;普通数据的地址既可以给普通指针,也可以给指向 const 的指针(仅一级间接时)。
const float g_earth = 9.80;
const float * pe = &g_earth; // 合法
const float g_moon = 1.63;
float * pm = &g_moon; // 非法!否则就能用 pm 偷改 const两级间接(指向指针的指针)时这条规则会失效(能绕出改 const 的漏洞),所以编译器禁止混合赋值。此外,const int months[] = {...} 这种 const 数组不能传给参数为 int arr[] 的函数——那等于把 const 地址塞给普通指针,必须把形参声明成 const int arr[]。
通关标准:
能默写三种组合(
const int *、int * const、const int * const)并说清各自锁的是什么;给函数传数组时能习惯性补上const——它既防手滑改数据,又让函数同时能接收 const 和非 const 数组。
7.6 函数和二维数组
二维数组名的类型是”指向含 4 个 int 的数组的指针”,所以原型必须写明列数:
int sum(int (*ar2)[4], int size); // 正确:指针指向 int[4]
int sum(int *ar2[4], int size); // 错误:这是 4 个指针组成的数组
int sum(int ar2[][4], int size); // 等价且更易读的写法括号不能省:int *ar2[4] 会因优先级被解读成”4 个 int 指针的数组”,而函数参数不能是数组。指针类型里写死了列数(4),所以列数不用单独传,行数由 size 决定——同一个函数既能处理 int a[100][4] 也能处理 int b[6][4]。函数体内可以照常使用 ar2[r][c],它其实是 *(*(ar2 + r) + c) 的简写:ar2 + r 定位第 r 行,*(ar2 + r) 得到该行(行内首元素的地址),再 + c、解引用得到具体元素。注意这里不用 const 修饰,因为 ar2 是”指向指针的指针”,超出了 const 一级保护的范围。