Note

函数(function)是 C 程序的积木块。本章解决三个问题:如何定义和调用函数、函数之间如何传递数据(参数与返回值)、编译器如何帮你查错(原型),并初窥递归与指针。示例均出自原书清单,关键程序完整保留。

9.1 复习函数

函数是一段为完成特定任务而设计的自包含代码单元,既能产生动作(如 printf()),也能返回值(如 strlen())。用函数的理由:避免重复劳动、让程序模块化(modular)——更易读、易改、易调试。程序员常把函数看作黑盒(black box):只关心输入和输出,不问内部细节。组织”读数→排序→求平均→画图”的程序时,main() 可以写成一行行函数调用,提纲挈领。

9.2 创建并使用简单函数

清单 9.1(lethead1.c)用打印 40 个星号的 starbar() 函数制作信头:

/* lethead1.c */
#include <stdio.h>
#define NAME "GIGATHINK, INC."
#define ADDRESS "101 Megabuck Plaza"
#define PLACE "Megapolis, CA 94904"
#define WIDTH 40
 
void starbar(void); /* prototype the function */
 
int main(void)
{
    starbar();
    printf("%s\n", NAME);
    printf("%s\n", ADDRESS);
    printf("%s\n", PLACE);
    starbar();    /* use the function */
    return 0;
}
void starbar(void)    /* define the function */
{
    int count;
 
    for (count = 1; count <= WIDTH; count++)
    putchar('*');
    putchar('\n');
}

starbar 标识符出现在三种场合,务必分清:

  1. 原型(prototype)void starbar(void);——第一个 void 表示不返回值,括号里的 void 表示不接收参数,分号说明这是声明而非定义。
  2. 调用(call)starbar();——程序跳去执行该函数,完毕后返回调用处的下一行。
  3. 定义(definition):给出实际代码,函数头后没有分号,编译器以此区分定义与声明。

/图:lethead1.c 的控制流程

/图:简单函数的结构

/图:函数体的组成——声明语句、循环语句与函数语句

原型可放在 main() 之前,也可放在 main() 内部,效果相同。starbar() 与 main() 可同文件也可分文件。starbar() 里的 count 是局部变量(local variable),只属于该函数——别的函数再定义 count 也是独立变量,互不冲突。

9.3 函数参数(Function Arguments)

把”打印什么字符、打几次”参数化,就得到通用函数 show_n_char(ch, num)。它的定义只有几行:

void show_n_char(char ch, int num)
{
    int count;
 
    for (count = 1; count <= num; count++)
    putchar(ch);
}

调用方式多种多样:show_n_char('*', WIDTH)(常量)、show_n_char(SPACE, spaces)(变量)、show_n_char(SPACE, (WIDTH - strlen(PLACE)) / 2)(表达式)。配合这些调用即可让信头文字自动居中。

形参(Formal Parameters)与实参(Actual Arguments)

  • 形参是函数头里声明的变量(ch、num),函数私有,每次调用被赋值。ANSI C 要求每个参数单独写类型:void dubs(int x, int y, int z) 有效,void dibs(int x, y, z) 无效。
  • 实参是调用时括号里的表达式,先求值,再把值拷贝给对应形参。函数既不知道也不关心值从哪来。

/图:形参与实参的关系

Success

被调函数操作的是副本,调用函数的原始数据天然受保护——这就是”按值传递(pass by value)“的价值。

原型写法:void show_n_char(char, int);,变量名可省略(原型中的名字不创建变量,只是说明)。

9.4 用 return 从函数返回值

参数解决”向内传”,return 解决”向外传”。清单 9.3(lesser.c)的 imin() 返回两个整数中较小者;专门测试函数的小程序叫驱动程序(driver)

int imin(int n,int m)    /* 清单 9.3 中的函数部分 */
{
    int min;
 
    if (n < m)
    min = n;
    else
    min = m;
 
    return min;
}

要点:

  • return 终止函数并把控制权交回调用函数——即使它不是最后一条语句,其后的代码永远不会执行。
  • 返回值可以是任意表达式:return (n < m) ? n : m; 一行即可。
  • 返回值类型与声明的函数类型不同时,自动转换为声明类型(如声明返回 int 的函数里 return 1.5625,实际返回 1)。
  • 调用函数看不见被调函数的局部变量——lesser = min; 是错的,min 只存在于 imin() 内部,必须靠 return 把值寄回来。
  • return;(不带表达式)只用于 void 函数,作用是提前结束。

函数类型:有返回值就声明成返回值的类型,无返回值声明 void;老式 C 默认 int 已被 C99 废除。注意类型指的是返回值,与参数无关:double klink(int a, int b) 收两个 int、返回 double。标准库的函数原型按家族放在头文件里:stdio.h 放 I/O 函数、math.h 放数学函数(如 double sqrt(double);)——声明告诉编译器函数长什么样,定义才提供代码

9.5 ANSI C 函数原型(Prototyping)

老式声明 int imax(); 不说明参数,错误便溜进来。原书清单 9.4(misuse.c)用老式声明后分别调用 imax(3)(少一个参数)和 imax(3.0, 5.0)(类型不对),程序照样编译运行,输出却是 1245120、1074266112 这类垃圾值。

原因:调用函数把实参压入栈(stack),被调函数按自己的形参类型去读——双方互不通气。imax(3) 只压一个整数,imax() 却读两个;imax(3.0, 5.0) 压入两个 64 位 double,imax() 只读前 64 位当两个 int。

ANSI 解决方案是原型:同时声明返回类型、参数个数与类型。清单 9.5(proto.c)只改了一行:

int imax(int, int);    /* prototype */

结果:imax(3) 直接编译报错(参数太少);imax(3.0, 5.0) 由编译器自动转换imax(3, 5),正常返回 5(可能附带精度警告,如 imax(3.9, 5.4) 相当于 imax(3, 5))。

补充规则:

  • 声明”确实无参数”必须写 void:void print_name(void);。只写 void print_name(); 等于放弃参数检查。
  • printf() 这类可变参数函数用省略号做部分原型:int printf(char *, ...);

Success

原型让编译器替你抓住”参数个数、类型不对”这类事后极难排查的错误。另一种等效做法:把整个函数定义放在第一次使用之前,定义本身就兼任原型,短函数常用。

9.6 递归(Recursion)

C 允许函数调用自己,即递归(recursion)。递归方案往往比循环更优雅,但效率更低、更耗内存。

递归揭秘

清单 9.6(recur.c)展示递归的进出路径:

/* recur.c -- recursion illustration */
#include <stdio.h>
void up_and_down(int);
 
int main(void)
{
    up_and_down(1);
    return 0;
}
 
void up_and_down(int n)
{
    printf("Level %d: n location %p\n", n, &n); /* 1 */
    if (n < 4)
    up_and_down(n+1);
    printf("LEVEL %d: n location %p\n", n, &n); /* 2 */
}

输出呈对称的”先上后下”:

Level 1: n location 0x0012ff48
Level 2: n location 0x0012ff3c
Level 3: n location 0x0012ff30
Level 4: n location 0x0012ff24
LEVEL 4: n location 0x0012ff24
LEVEL 3: n location 0x0012ff30
LEVEL 2: n location 0x0012ff3c
LEVEL 1: n location 0x0012ff48

递归的基本事实:

  1. 每层调用有自己的变量——四层调用创建四个各叫 n 的独立变量(地址各不相同),返回第 1 层时最初的 n 仍是 1。
  2. 每次调用对应一次返回,程序逐层退回,不会从最深层直接跳回 main()。
  3. 递归调用之前的语句按调用顺序执行(Level 1→4)。
  4. 递归调用之后的语句按相反顺序执行(Level 4→1)——这正是”逆序输出”类问题的钥匙。
  5. 代码本身不复制,函数调用只是跳回同一组指令的开头,类似循环。
  6. 必须有终止条件,且每次调用都要让参数朝终止方向变化。

尾递归与阶乘

递归调用位于函数末尾的形式叫尾递归(tail recursion),行为最像循环。清单 9.7(factor.c)用两种方式算阶乘(n! = n×(n-1)!,0! = 1),核心是这两个函数:

long fact(int n) // loop-based function
{
    long ans;
 
    for (ans = 1; n > 1; n--)
    ans *= n;
 
    return ans;
}
 
long rfact(int n) // recursive version
{
    long ans;
 
    if (n > 0)
    ans = n * rfact(n-1);
    else
    ans = 1;
 
    return ans;
}

驱动程序限制输入 0–12,因为 13! 已超出本系统 long 的范围。递归版就是数学定义的照搬,到 n 为 0 时返回 1 收尾。两者结果一致,通常选循环:每次递归都要在栈上压入一整套新变量,更耗内存也更慢;但有些问题没有简单的循环解法。

递归与逆序:二进制打印

打印整数的二进制形式:先算出的是最低位(n % 2)却要最后打印——“递归调用前算、递归调用后打印”正好实现逆序。清单 9.8(binary.c):

/* binary.c -- prints integer in binary form */
#include <stdio.h>
void to_binary(unsigned long n);
 
int main(void)
{
    unsigned long number;
    printf("Enter an integer (q to quit):\n");
    while (scanf("%ul", &number) == 1)
    {
    printf("Binary equivalent: ");
    to_binary(number);
    putchar('\n');
    printf("Enter an integer (q to quit):\n");
    }
    printf("Done.\n");
 
    return 0;
}
 
void to_binary(unsigned long n) /* recursive function */
{
    int r;
 
    r = n % 2;
    if (n >= 2)
    to_binary(n / 2);
    putchar('0' + r);
 
    return;
}

输入 9 得 1001,输入 255 得 11111111。'0' + r 利用字符编码的连续性把 0/1 变成 ‘0’/‘1’(更稳妥的写法是 putchar(r ? '1' : '0');)。不用递归也行,但得先把各位数字存进数组再倒序打印。

Warning

递归必须有明确的终止条件,且每层调用都要向终止方向推进参数。漏写 if 测试或忘了让参数变化,递归就会无限自我调用,直到栈内存耗尽、程序崩溃。

递归的优缺点

优点:某些问题(逆序、树的遍历)递归解法最简单。缺点:可能迅速耗尽内存,且难写难维护。反面教材是双递归的斐波那契函数:

long Fibonacci(int n)
{
    if (n > 2)
    return Fibonacci(n-1) + Fibonacci(n-2);
    else
    return 1;
}

代码是数学定义的照搬,但 Fibonacci(40) 会让调用数每层翻倍——变量数量指数级增长,很快耗光内存导致程序崩溃。追求效率时务必警惕。

9.7 多源代码文件与头文件

C 的函数一律平等:任何函数可调用其他函数、也可被任何函数调用(main() 仅特殊在程序从它开始)。C 函数不能嵌套定义。

多文件编译:Unix 用 cc file1.c file2.c,Linux 用 gcc file1.c file2.c,均生成可执行文件和各 .o 目标文件;只改一个文件时可与其他 .o 增量链接。Windows/Mac 集成环境是项目导向——把所有 .c 加入项目即可,别把 .h 加入项目(#include 指令自会管理头文件)。

若 main() 与函数定义分居两个文件,前者仍需要原型。把原型和 define 常量统一放进头文件(header file),各源文件 include 它即可——标准库正是这样做的。原书酒店房费程序分三个文件:usehotel.c(main + 菜单循环)、hotel.c(menu/getnights/showprice 三个函数)、hotel.h(常量与原型)。头文件内容如下:

/* hotel.h -- constants and declarations for hotel.c */
#define QUIT       5
#define HOTEL1 80.00
#define HOTEL2 125.00
#define HOTEL3 155.00
#define HOTEL4 200.00
#define DISCOUNT 0.95
#define STARS "**************************"
// shows list of choices
int menu(void);      // returns code for selected hotel
// returns number of nights desired
int getnights(void);
// computes price from rate and nights
void showprice(double rate, int nights);

注意 #include "hotel.h" 用双引号,表示在当前工作目录查找。房费按”第二晚打 95 折、第三晚再打 95 折”逐晚累加,showprice() 里用 factor *= DISCOUNT 实现。

9.8 求地址:& 运算符

指针(pointer) 是存储地址的变量。一元运算符 & 给出变量的地址:printf("%d %p\n", pooh, &pooh); 输出如 24 0B76,%p 是地址专用说明符(十六进制)。

原书清单 9.12(loccheck.c)在 main() 和 mikado() 里各定义了 pooh、bah 并打印地址,输出证明:两对同名变量地址不同,是四个独立变量;mikado(pooh) 只把 main() 中 pooh 的(2)传给 mikado() 的形参 bah。与 FORTRAN 不同,C 的被调函数永远不会神秘地改动调用函数的原始变量。

9.9 更改调用函数中的变量:swap 的失败与成功

交换两个变量需要临时变量:temp = x; x = y; y = temp;。把它装进函数(清单 9.13,swap1.c,形参 u、v 按值接收 x、y)后运行:

Originally x = 5 and y = 10.
Now x = 5 and y = 10.

失败!函数内部确实交换了 u、v,但那是副本,main() 的 x、y 毫发未动。return 也救不了:一次只能返回一个值,这里需要传回两个。出路只有指针。

9.10 指针入门:& 与 *

指针是其值为内存地址的变量。ptr = &pooh; 后说”ptr 指向 pooh”;ptr 是变量(可改指向),&pooh 是常量。

间接(解引用)运算符 *:已知 ptr = &bah;,则 val = *ptr; 等价于 val = bah;——经由指针间接取值。别把它和乘法的二元 * 混淆。

声明指针必须指明指向的类型——不同类型占用字节数和存储格式不同,指针运算与解引用都依赖它:

int * pi;          /* pi 是指向 int 的指针 */
char * pc;         /* pc 是指向 char 的指针 */
float * pf, * pg;  /* pf、pg 都是指向 float 的指针 */

int * pi; 读作:pi 是指针,*pi 是 int。指针的值是地址,多数系统内部按无符号整数表示,但指针不是整数类型(不能相乘),是独立的新类型,所以 printf() 专门有 %p。

用指针实现成功的 swap

清单 9.15(swap3.c)传地址版:

/* swap3.c -- using pointers to make swapping work */
#include <stdio.h>
 
void interchange(int * u, int * v);
 
int main(void)
{
    int x = 5, y = 10;
 
    printf("Originally x = %d and y = %d.\n", x, y);
    interchange(&x, &y); /* send addresses to function */
    printf("Now x = %d and y = %d.\n", x, y);
 
    return 0;
}
 
void interchange(int * u, int * v)
{
    int temp;
 
    temp = *u;    /* temp gets value that u points to */
    *u = *v;
    *v = temp;
}

输出 Now x = 10 and y = 5.——成功。原理:interchange(&x, &y)地址,形参 u、v 是指向 int 的指针;u 的值是 &x,故 *u 就是 x 本身。temp = *u; 取到 x 的值(写成 temp = u; 就错了,那是地址),*u = *v; 相当于 x = y;。函数借此”伸手”到 main() 改数据。原型可省名:void interchange(int *, int *);

/图:声明并使用指针

/图:按字节编址系统中变量的名称、地址与值

Warning

想让函数修改变量,必须传地址function2(&x))且形参声明为对应类型的指针;只想用值计算才传值(function1(x))。形式用错——把地址赋给普通 int 形参、或忘了 * 解引用——都是典型错误。scanf(“%d”, &num) 用的正是传地址形式。

变量:名称、地址与值

编译运行后,计算机眼中的变量只有地址和值两个属性。普通变量以值为第一位、& 派生地址;指针变量以地址为第一位、* 派生值。打印地址多半只为好奇,& 和 * 的真正威力在于像 swap3.c 那样符号化地操纵地址及其内容

Success

一函数一任务是组织程序的最佳实践;参数向内传值、return 向外传值、指针向外改值——三条通信通道各司其职,数据完整性就有了保障。

要点回顾

  • 定义、原型、调用三位一体;形参是私有局部变量,实参按值拷贝。
  • 原型检查参数个数与类型:类型不符自动转换,个数不符直接报错。
  • 递归六要素:每层独立变量、逐层返回、调用前按序执行、调用后逆序执行、代码不复制、必须有终止条件;尾递归最像循环;双递归有内存爆炸风险。
  • 多文件程序:原型和常量进头文件,各源文件 include
  • & 取地址、* 解引用;要在函数里改变调用函数的变量,传地址并用指针形参。