这一章在干嘛?

前面各章我们一直在”用”函数(比如 strlen()),这一章开始自己”造”函数。C++ 程序就像乐高积木:main() 是总装车间,函数是一个个标准零件。本章把零件的定义、原型、调用三件套讲清,再逐一攻克处理数组、字符串、结构体的函数,以及两个进阶玩法:函数调用自己的递归,和把函数当参数传的函数指针

7.1 函数复习

7.2 定义函数与原型

7.3 按值传递

7.4 函数和数组

7.5 指针与 const

7.6 函数和二维数组

7.7 函数和 C 风格字符串

7.8 函数和结构体

7.9 函数和 string 与 array 对象

7.10 递归

7.11 函数指针

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() 里循环读入 totalchoices 并调用 probability(total, choices),输入 49 和 6 得到 You have one chance in 1.39838e+007 of winning.——选择越多,中奖越渺茫。这个函数展示了两种局部变量:形参(numberspicks)从调用者那里拿值,普通局部变量(resultnp)在函数体内自己产生。

常见坑:以为改形参就能改实参

按值传递下,形参是复印件。想真正修改调用者的数据,得传地址(指针)或引用(第 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;
}

输出里 cookiesarr 打印出相同地址,但 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 *arar[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 * constconst 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 一级保护的范围。

7.7 函数和 C 风格字符串

字符串的三种形态——char 数组、字符串常量、指向字符串的 char 指针——统统是 char * 类型,所以都能作为参数传给同一个函数:

char ghost[15] = "galloping";
char * str = "galumphing";
int n1 = strlen(ghost);       // ghost 是 &ghost[0]
int n2 = strlen(str);         // 指针
int n3 = strlen("gamboling"); // 字符串常量的地址

与普通数组的区别:字符串自带终止空字符 \0,因此不用传长度,函数自己找到 \0 就停。Listing 7.9(strgfun.cpp)统计字符出现次数,对 “minimum” 数出 3 个 ‘m’,对 “ululate” 数出 2 个 ‘u’:

// strgfun.cpp -- 使用字符串参数的函数
unsigned int c_in_str(const char * str, char ch)
{
    unsigned int count = 0;
    while (*str)    // *str 为 '\0' 时退出
    {
        if (*str == ch)
            count++;
        str++;    // 指针移到下一个字符
    }
    return count;
}

核心套路是标准的字符串遍历模板:while (*str) { ...; str++; }——只要当前字符不是空字符就继续走。

返回字符串:函数不能直接返回数组,但可以返回字符串的地址。Listing 7.10(strgback.cpp)用 new 创建一个由 n 个相同字符组成的串并返回指针:

// strgback.cpp -- 返回 char 指针的函数
char * buildstr(char c, int n)
{
    char * pstr = new char[n + 1];
    pstr[n] = '\0'; // 终止字符串
    while (n-- > 0)
        pstr[n] = c; // 从后往前填
    return pstr;
}
// main() 中每次调用后都要 delete [] ps; 释放内存

注意两个细节:n 个可见字符要申请 n+1 字节给空字符留位;填串从后往前(n-- 先用后减),省掉一个下标变量。这个设计的代价是:调用方要记得 delete [],忘了就内存泄漏——第 12 章会用类的构造/析构函数把这事自动化。

7.8 函数和结构体

结构体比数组好伺候:它像基本类型一样是”一个整体”,可以按值传递、整体赋值、当返回值,没有”数组名变地址”的花活;想要地址得显式写 &。缺点是大结构按值传递要整份复制,费时费内存——这时可以改传地址。

按值传递结构体

// travel.cpp -- 结构体与函数
#include <iostream>
struct travel_time { int hours; int mins; };
const int Mins_per_hr = 60;
 
travel_time sum(travel_time t1, travel_time t2);
void show_time(travel_time t);
 
int main()
{
    using namespace std;
    travel_time day1 = {5, 45};    // 5 小时 45 分
    travel_time day2 = {4, 55};    // 4 小时 55 分
    travel_time trip = sum(day1, day2);
    cout << "Two-day total: ";
    show_time(trip);
    travel_time day3 = {4, 32};
    cout << "Three-day total: ";
    show_time(sum(trip, day3));    // 返回值直接当参数
    return 0;
}
 
travel_time sum(travel_time t1, travel_time t2)
{
    travel_time total;
    total.mins = (t1.mins + t2.mins) % Mins_per_hr;
    total.hours = t1.hours + t2.hours +
        (t1.mins + t2.mins) / Mins_per_hr;
    return total;
}
 
void show_time(travel_time t)
{
    using namespace std;
    cout << t.hours << " hours, " << t.mins << " minutes\n";
}

输出为 Two-day total: 10 hours, 40 minutesThree-day total: 15 hours, 12 minutestravel_time 用起来和内置类型没区别:声明变量、当返回值、当参数都行。show_time(sum(trip, day3)) 先算 sum() 的返回值,再把那个值(而不是函数本身)传给 show_time()

再看坐标转换(Listing 7.12,strctfun.cpp):直角坐标 rect{x, y}极坐标 polar{distance, angle},用勾股定理求距离、atan2(y, x) 求角度(别用 atan(),它分不清相差 180° 的方向):

// strctfun.cpp -- 结构体参数函数
#include <iostream>
#include <cmath>
 
struct polar { double distance; double angle; };
struct rect { double x; double y; };
 
polar rect_to_polar(rect xypos);
void show_polar(polar dapos);
 
int main()
{
    using namespace std;
    rect rplace;
    polar pplace;
    cout << "Enter the x and y values: ";
    while (cin >> rplace.x >> rplace.y)  // 巧妙利用 cin
    {
        pplace = rect_to_polar(rplace);
        show_polar(pplace);
        cout << "Next two numbers (q to quit): ";
    }
    cout << "Done.\n";
    return 0;
}
 
polar rect_to_polar(rect xypos)
{
    polar answer;
    answer.distance =
        sqrt(xypos.x * xypos.x + xypos.y * xypos.y);
    answer.angle = atan2(xypos.y, xypos.x);
    return answer;    // 返回一个 polar 结构体
}
 
void show_polar(polar dapos)
{
    using namespace std;
    const double Rad_to_deg = 57.29577951;
    cout << "distance = " << dapos.distance;
    cout << ", angle = " << dapos.angle * Rad_to_deg;
    cout << " degrees\n";
}

输入 30 40 得到 distance = 50, angle = 53.1301 degrees。这里有个值得学的技巧:while (cin >> rplace.x >> rplace.y) 用输入本身当循环条件——cin >> 表达式的值还是 cin 对象,测试语境下被转成 bool(成功为 true)。输入 q 时检测到非数字,返回 false 结束循环,所有合法数字都能正常接收,不像”输入负数退出”那种做法会牺牲一部分数字的输入资格。(非数字输入会设置错误状态,若后面还要输入,记得 cin.clear() 复位。)


▲ 图 7.6 直角坐标(Micromips 相对 Byteville)


▲ 图 7.7 极坐标(Micromips 相对 Byteville)

传结构体的地址

想省去整份复制的开销,就传地址(Listing 7.13,strctptr.cpp)。三个改动要点:调用时传 &pplace;形参声明为 const polar *(只读时加 const);成员访问用 -> 而不是 .rect_to_polar() 则改为传两个指针——一个指向待转换的(const),一个指向接收结果的(不能 const,因为要写),不再返回结构体:

// strctptr.cpp -- 指向结构体的指针参数(结构与调用处同上,函数改为)
void rect_to_polar(const rect *pxy, polar *pda);
void show_polar(const polar *pda);
// 调用处:rect_to_polar(&rplace, &pplace); show_polar(&pplace);
 
void show_polar(const polar *pda)
{
    using namespace std;
    const double Rad_to_deg = 57.29577951;
    cout << "distance = " << pda->distance;
    cout << ", angle = " << pda->angle * Rad_to_deg;
    cout << " degrees\n";
}
 
void rect_to_polar(const rect *pxy, polar *pda)
{
    pda->distance =
        sqrt(pxy->x * pxy->x + pxy->y * pxy->y);
    pda->angle = atan2(pxy->y, pxy->x);
}

对用户来说它和按值版本表现一模一样——区别全在幕后:一个玩副本,一个玩原件。

7.9 函数和 string 与 array 对象

string 对象更像结构体而不是数组:可整体赋值、可整体传参。声明 string 对象数组就和声明 int 数组一样自然。Listing 7.14(topfive.cpp)让用户输入 5 个最爱的天文景观并回显:string list[SIZE]; 声明数组,getline(cin, list[i]); 读入每个元素,display() 的形参 const string sa[] 是指向 string 对象的指针,sa[i] 就是第 i 个 string 对象,用法与内置类型无异。

C++11 的 array 模板类同理。要修改对象就传地址,只读就按值传(Listing 7.15,arrobj.cpp,四季开销统计):

//arrobj.cpp -- 使用 array 对象的函数(C++11)
#include <iostream>
#include <array>
#include <string>
// 常量数据
const int Seasons = 4;
const std::array<std::string, Seasons> Snames =
    {"Spring", "Summer", "Fall", "Winter"};
// 修改 array 对象的函数
void fill(std::array<double, Seasons> * pa);
// 不修改 array 对象的函数
void show(std::array<double, Seasons> da);
 
int main()
{
    std::array<double, Seasons> expenses;
    fill(&expenses);
    show(expenses);
    return 0;
}
 
void fill(std::array<double, Seasons> * pa)
{
    using namespace std;
    for (int i = 0; i < Seasons; i++)
    {
        cout << "Enter " << Snames[i] << " expenses: ";
        cin >> (*pa)[i];
    }
}
 
void show(std::array<double, Seasons> da)
{
    using namespace std;
    double total = 0.0;
    cout << "\nEXPENSES\n";
    for (int i = 0; i < Seasons; i++)
    {
        cout << Snames[i] << ": $" << da[i] << endl;
        total += da[i];
    }
    cout << "Total Expenses: $" << total << endl;
}

注意 fill() 里的 (*pa)[i]pa 是指针,*pa 才是对象,括号是为了压过运算符优先级。show() 按值传会复制整个对象,效率不高;指针写法高效但别扭。两个痛点(效率 + 书写)都将在第 8 章用引用一招化解。

7.10 递归

C++ 函数可以调用自己(但 main() 不行),这叫递归(recursion)。标准模板是:把递归调用放进 if 里,保证链条终会断开:

void recurs(argumentlist)
{
    statements1
    if (test)
        recurs(arguments)
    statements2
}

只要 test 为真,每次调用执行完 statements1 就钻进下一层;test 变假后,从最深层开始逐层”回填”执行 statements2。于是:statements1 按调用顺序执行一遍,statements2 按相反顺序执行一遍。

// recur.cpp -- 使用递归
#include <iostream>
void countdown(int n);
 
int main()
{
    countdown(4); // 调用递归函数
    return 0;
}
 
void countdown(int n)
{
    using namespace std;
    cout << "Counting down ... " << n << endl;
    if (n > 0)
        countdown(n-1);    // 函数调用自己
    cout << n << ": Kaboom!\n";
}
Counting down ... 4   ← 第 1 层,递归层数加深
Counting down ... 3   ← 第 2 层
Counting down ... 2   ← 第 3 层
Counting down ... 1   ← 第 4 层
Counting down ... 0   ← 第 5 层,最后一次递归调用
0: Kaboom!            ← 第 5 层,开始回退
1: Kaboom! / 2: Kaboom! / 3: Kaboom! / 4: Kaboom!  ← 依次回退到第 1 层

每层递归都有自己的一组变量——打印 &n 会看到 5 个不同地址的 n,同一层在”下钻”和”回退”两个阶段地址相同。

递归更炫的用法是分而治之(divide-and-conquer):把任务反复拆成两个更小的同类任务。Listing 7.17(ruler.cpp)递归画标尺:main() 先把 66 格的字符数组填成空格、两端放 '|',然后循环 6 次以递增的 level 调用 subdivide() 并打印,每打一行就重置数组。核心的递归函数如下:

// ruler.cpp -- 用递归细分标尺
void subdivide(char ar[], int low, int high, int level)
{
    if (level == 0)
        return;
    int mid = (high + low) / 2;
    ar[mid] = '|';
    subdivide(ar, low, mid, level - 1);
    subdivide(ar, mid, high, level - 1);
}


▲ 程序输出:每多一层递归,标尺上的刻度就密一倍

subdivide() 每次调用自己两次(左半、右半),调用次数按 2 的幂爆炸式增长(1→2→4→8…,6 层填满 64 个位置)。所以这种递归层数少时优雅简洁,层数多就是灾难。

常见坑:忘了终止条件或终止条件够不着

递归没有 if 刹车,或刹车条件永远不成立,函数就会一层层无限钻下去,耗尽内存栈后崩溃。写递归先写”什么时候停”,再写”怎么拆小”。

7.11 函数指针

函数也有地址——就是它机器码的起始位置。把函数 A 的地址传给函数 B,B 就能在运行时”换着花样”调用不同函数。三步走:

第一步,取地址:函数名不带括号就是地址。process(think) 传的是函数地址,thought(think()) 传的是函数返回值,天差地别。

第二步,声明指针:照抄目标函数原型,把函数名换成 (*pf)

double pam(int);        // 原型
double (*pf)(int);      // pf 指向"一个 int 参数、返回 double"的函数
pf = pam;               // pf 现在指向 pam()

括号是保命的:double (*pf)(int) 是函数指针,double *pf(int) 则是”返回指针的函数”。赋值时签名和返回类型必须完全匹配,pf = ned;(签名不符)和 pf = ted;(返回类型不符)都会被编译器拒绝。

第三步,用指针调用(*pf)(5)pf(5) 都合法(C++ 的历史妥协——两种写法逻辑上矛盾,但都被允许)。(*pf)(5) 更丑,却时刻提醒你”这里用的是函数指针”。

完整示例(Listing 7.18,fun_ptr.cpp):estimate() 的估时算法可以随时替换:

// fun_ptr.cpp -- 函数指针
#include <iostream>
double betsy(int);
double pam(int);
 
// 第二个参数是"一个 int 参数、返回 double"的函数的指针
void estimate(int lines, double (*pf)(int));
 
int main()
{
    using namespace std;
    int code;
    cout << "How many lines of code do you need? ";
    cin >> code;
    cout << "Here's Betsy's estimate:\n";
    estimate(code, betsy);
    cout << "Here's Pam's estimate:\n";
    estimate(code, pam);
    return 0;
}
 
double betsy(int lns) { return 0.05 * lns; }
 
double pam(int lns)
{
    return 0.03 * lns + 0.0004 * lns * lns;
}
 
void estimate(int lines, double (*pf)(int))
{
    using namespace std;
    cout << lines << " lines will take ";
    cout << (*pf)(lines) << " hour(s)\n";
}

输入 30 行代码时,Betsy 估 1.5 小时、Pam 估 1.26 小时。将来 Ralph 想用自己的算法,不必改 estimate() 一行代码,只需提供签名一致的 ralph()——这就是回调思想,也是虚函数的底层机制。

函数指针数组与 auto/typedef

签名相同的多个函数可以放进函数指针数组,用循环逐个调用:

const double * f1(const double ar[], int n);  // 三个原型写法不同但签名相同
const double * (*pa[3])(const double *, int) = {f1,f2,f3};  // 函数指针数组
auto pb = pa;          // 指向数组首元素(C++11 自动推断)
auto pc = &pa;         // 指向整个数组(多一级间接)

[] 优先级高于 *,所以 *pa[3] 表示”3 个指针的数组”;要表示”指向数组的指针”得写 (*pd)[3]。手写全称是这般惨烈:

const double *(*(*pd)[3])(const double *, int) = &pa;

所以 C++11 的 autotypedef 是救命的:

typedef const double *(*p_fun)(const double *, int);  // p_fun 成为类型名
p_fun pa[3] = {f1,f2,f3};       // 3 个函数指针的数组
p_fun (*pd)[3] = &pa;           // 指向该数组的指针

还要区分 pa&pa:数值可能相同,但 pa 是”首元素(单个指针)的地址”,&pa 是”整个数组的地址”,类型不同、加 1 跨越的距离也不同。原书 Listing 7.19(arfupt.cpp)用三个签名相同的小函数完整演示了单指针、指针数组、二级指针、指向数组的指针四种调用姿势,输出里”地址:值”成对出现——地址是 av 数组元素的位置,值是该位置的 double。这些写法看着眼晕,但虚函数(第 13 章)的常见实现正是这套技术,好在编译器替你兜底。

通关标准:

能独立完成:① 给数组处理函数正确搭配 const 与大小参数;② 把按值传结构体改为传地址(别忘了 ->);③ 手写一个带终止条件的递归函数并解释它的下钻/回退过程;④ 不查书写出 double (*pf)(int) 并用它调用函数。