本文是《C语言期末速成》系列第 6 篇。函数是 C 语言的基本组成单位——把大问题拆成小零件,每个零件一个函数。这一章除了语法,更要理解两件事:值传递为什么”传不回来”,以及递归到底怎么一层层算。这两点是函数部分的题眼。


上一篇C语言期末速成 5:程序的三大基本结构

考点清单

  • 函数是 C 语言的基本组成单位;C 程序由函数组成,有且仅有一个 main
  • 函数不可以嵌套定义,但可以嵌套调用
  • 函数名缺省返回值类型时默认为 int
  • 实参可以是常量、变量、表达式,甚至函数的调用结果add(add(7,8),9)
  • 传值(值传递):形参的变化不影响实参;传地址:形参的变化会影响实参
  • 函数声明需要:函数名、返回类型、参数类型(形参名可省略)
  • 递归必须有结束条件,否则无限递归
  • 常用库函数:absfabssqrtpow(a,b)sin

一、函数长什么样

1.1 定义

返回类型 函数名(形参列表) {
    函数体;
    return 返回值;
}
int add(int x, int y) {   /* int 是返回类型,x、y 是形式参数 */
    return x + y;
}

几个硬规则:

  • 函数名缺省返回类型时默认为 intfun(int a) 等价于 int fun(int a)
  • 不能在函数内再定义函数(嵌套定义非法),但可以嵌套调用(函数里调另一个函数,甚至调自己——递归);
  • 返回值用 return 带回,没有返回值用 void

1.2 调用:程序是怎么”跳走又回来”的

int add(int x, int y) {
    int z = x + y;
    return z;
}

int main(void) {
    int a = 5, b = 6, c;
    c = add(a, b);        /* ① 把 a、b 的值传给形参 ② 程序跳去执行 add ③ 拿回返回值 */
    printf("%d\n", c);    /* 11 */
    return 0;
}

执行流程:

  1. 主程序从上往下顺序执行;
  2. 碰到 add(a,b),把实参 a、b 的值复制给形参 x、y;
  3. 程序中断在调用处,跳去执行 add 的函数体;
  4. add 里算出 zreturn z 把值交回来;
  5. 程序回到 add(a,b) 这个调用点,用返回值继续往下。

这里 a、b 叫实参(实际参数),x、y 叫形参(形式参数)——它们之间的关系是本章的核心(见第三节)。

1.3 实参的花样:函数调用也能当实参

实参可以是常量、变量、表达式,甚至另一个函数调用的结果

int sum = add(add(7, 8), 9);
/* 内层 add(7,8)=15,外层 add(15,9)=24,sum = 24 */

先算内层再算外层——嵌套调用由内而外


二、函数调用栈:理解”跳走再回来”的机器视角(加深)

第 1 篇的内存图上,栈区域正是函数调用的舞台。每次函数调用,系统会压入一个”栈帧”,里面装着:该函数的形参、局部变量、以及”返回地址”(调用结束后该回哪一行)。

main 的栈帧
┌────────────┐
│ a=5 b=6 c= │
├────────────┤
│ add 的栈帧  │  ← 调用 add 时压入
│ x=5 y=6 z= │
│ 返回地址    │
└────────────┘
  • 调用时压栈:新栈帧盖在上面,所以 add 里的 x、y、z 和 main 里的 a、b 是两份独立的空间
  • 返回时弹栈:add 的栈帧被销毁,局部变量随之消失,程序沿返回地址回到调用点。

这个”压栈-弹栈”机制同时解释了三个现象:值传递改不了实参(形参是复制品)、递归能层层深入(每层一个栈帧)、局部变量会”失忆”(返回后栈帧销毁)。


三、值传递 vs 地址传递:传值传址(必考)

先立一个总纲,它是本章的灵魂:

C 语言里所有的参数传递都是”值传递”——所谓”传地址”,本质也是把”地址这个值”复制一份传过去。区别只在于:传的是普通值还是地址值。形参始终是实参的复制品,改形参改不到实参;但”传地址”时,形参手里握着实参的地址,能顺着地址摸到实参本身去修改。

记住这条,下面所有结论都不用背。

3.1 传值:形参是”复印件”

void swap(int a, int b) {
    int t;
    t = a; a = b; b = t;    /* 换的是复印件 */
}

int main(void) {
    int x = 1, y = 3;
    swap(x, y);             /* 把 x、y 的"值"传过去 */
    printf("%d %d\n", x, y);/* 1 3 —— 没换过来! */
    return 0;
}

为什么没换过来? 因为调用时,swap 里的 a、b 是 x、y 的拷贝swap 内部交换的是拷贝,x、y 本人纹丝不动。值传递时,形参的任何变化都不会影响实参。

3.2 传地址:把”抽屉钥匙”传过去

想真正修改实参,就得把实参的地址传给函数,函数用指针接收,然后通过指针操作原来的变量:

void swap(int *a, int *b) {
    int t;
    t = *a; *a = *b; *b = t;  /* *a 就是 main 里的 x,*b 就是 y */
}

int main(void) {
    int x = 1, y = 3;
    swap(&x, &y);             /* 传地址! */
    printf("%d %d\n", x, y);  /* 3 1 —— 换过来了 */
    return 0;
}

地址传递时,形参(指针)虽然也是拷贝,但它拷的是”实参的地址”,函数能顺着地址找到并修改实参本身。所以结论是:

传值,形参变不了实参;传地址,形参绝大多数能改实参。

选择哪种:需要改调用者的变量就传地址(如 swapscanf("%d",&x));只是”拿数据算结果”就传值。

3.3 一个经典判断题

int fun(int a) { a = a * 2; return a; }
int x = 5, y = fun(x);
/* x 还是 5;y = 10 */

x 传给 fun 的是拷贝,a = a*2 只影响拷贝,x 不变。


四、全局变量与局部变量:作用域与默认值

按声明位置,变量分三类:

种类声明位置默认值生命周期
全局变量所有函数之外自动初始化为 0int=0char='\0'、指针=NULL)程序运行全程
局部变量函数体内不定值(垃圾值),必须自己初始化函数调用期间
形式参数函数参数列表由实参传入函数调用期间

两个必考点:

① 默认值天差地别

int g;          /* 全局:g 自动是 0 */
int main(void) {
    int x;      /* 局部:x 是垃圾值,不可靠 */
    printf("%d %d\n", g, x);   /* g 是 0,x 是随机残留值 */
    return 0;
}

局部变量不初始化就是垃圾值——这一点和第 11 章的 static int x(默认为 0)要严格区分。

② 同名遮蔽

局部变量和全局变量同名时,函数内优先使用局部那个(局部”遮蔽”全局):

int g = 10;                    /* 全局 */
int main(void) {
    int g = 20;                /* 同名局部,遮蔽全局 */
    printf("%d\n", g);         /* 20:用的是局部 */
    return 0;
}

五、函数声明:提前”打招呼”

调用函数时,编译器需要知道它的返回类型、名字、参数类型,否则不知道该怎么调用。函数可以定义在使用之后,但必须先声明

int add(int x, int y);      /* 函数声明(原型) */

int main(void) {
    printf("%d\n", add(3, 4));   /* 先声明后调用,合法 */
    return 0;
}

int add(int x, int y) { return x + y; }   /* 定义放在后面 */

声明必须有:函数名、返回类型、参数类型;可以没有:形参名。

int *fun(int *a, int *b);     /* 形参名完整 */
int *fun(int *, int *);       /* 形参名省略,合法 */

函数的正确声明怎么写(典型考题)

已知定义是 int *fun(int a[], int b[]),那么声明可以写成:

int *fun(int *a, int *b);    /* 数组当指针,合法 */
int *fun(int b[], int c[]);  /* 形参名随便换,合法 */
int *fun(int *, int *);      /* 名字都不写,合法 */

理解:形参列表中数组的写法等价于指针(第 7、8 章详细展开),声明只关心类型不关心名字。


六、递归:函数调自己

5.1 两个必要条件

递归 = 函数直接或间接调用自己。但必须有结束条件,否则会无限递归直到栈溢出:

  1. 递归出口(终止条件):满足时直接返回,不再递归;
  2. 递归推进:每次调用都把问题规模缩小一点,逼近出口。

5.2 阶乘:最经典的递归

int fact(int n) {
    if (n <= 1) return 1;      /* 递归出口 */
    return n * fact(n - 1);    /* 规模缩小:n-1 */
}
/* fact(4) = 4 × fact(3) = 4×3×fact(2) = 4×3×2×fact(1) = 4×3×2×1 = 24 */

递归树(fact(4) 的展开):

fact(4)
  └─ 4 × fact(3)
        └─ 3 × fact(2)
              └─ 2 × fact(1)
                    └─ 1      ← 出口,开始"逐层回填"

先一路下钻到出口,再自下而上把结果带回来。

5.3 斐波那契:双递归

int fib(int n) {
    if (n == 1 || n == 2) return 1;              /* 前两项是 1 */
    return fib(n - 1) + fib(n - 2);              /* 第 n 项 = 前两项之和 */
}
/* 1,1,2,3,5,8,13,... */

注意双递归调用次数增长极快(fib(40) 会调用天文数字次),工程上常改用迭代

5.4 递归转迭代(加深)

能用递归解决的问题,几乎都能用循环改写(递归本质靠栈,循环靠显式变量):

/* 阶乘的迭代版 */
int fact_iter(int n) {
    int p = 1;
    for (int i = 1; i <= n; i++) p *= i;
    return p;
}

/* 斐波那契的迭代版 */
int fib_iter(int n) {
    int a = 1, b = 1, c;
    for (int i = 3; i <= n; i++) { c = a + b; a = b; b = c; }
    return b;
}

递归代码简洁、思路直观(尤其树形问题),迭代省内存、快。考试中递归必考,至少要能徒手算出简单递归的结果——画递归树是万能办法。

5.5 判断质数(背这个函数)

int isPrime(int n) {
    if (n < 2) return 0;
    for (int i = 2; i * i <= n; i++)   /* 只要试到 √n 即可 */
        if (n % i == 0) return 0;
    return 1;
}

判断质数只需试除到 √n(因为 n 若有因数,必有一个 ≤ √n),比试到 n/2 更高效。


七、库函数:别人写好的函数仓库

库函数是已经写好、放在库中的函数,我们只负责正确调用:

  • abs(x):整数绝对值;
  • fabs(x):浮点数绝对值;
  • sqrt(x):开平方;
  • pow(a, b)ab 次幂,2^3 写作 pow(2, 3)
  • sin(x)cos(x) 等数学函数。

使用数学库函数通常要 #include <math.h>,链接时可能还需要 -lm。考试填空题里 pow(a,b) 是重点——它俩参数顺序是底数在前,指数在后

#include <stdio.h>
#include <math.h>

int main(void) {
    printf("%.2f\n", sqrt(9));     /* 3.00 */
    printf("%.0f\n", pow(2, 3));   /* 8 */
    printf("%d\n", abs(-5));       /* 5 */
    return 0;
}

易错点 / 陷阱

  1. 函数嵌套定义:C 里不允许(不能在一个函数体里定义另一个函数),但嵌套调用可以。
  2. 值传递想改实参swap(x, y) 换的是拷贝,实参不变;要改必须传地址。
  3. 形参名在声明里漏掉:声明里形参名可省,但类型不能省。
  4. 递归没出口:没有结束条件的递归会栈溢出崩溃。
  5. 缺省返回类型fun(...) 默认返回 int,但正式代码建议写清楚。
  6. pow(2,3) 写成 pow(3,2):底数在前、指数在后。
  7. return 后语句还执行return 之后的语句不会执行。

自测小练习

第 1 题int add(int x,int y){return x+y;},求 add(add(3,4),add(5,6)) 的值。

第 2 题:写一个值传递的交换函数,调用后实参不变——请说明原因。

第 3 题void fun(int *p){ *p = 100; }int x=1; fun(&x); 后 x 是多少?为什么?

第 4 题:用手算 fact(5) 的递归过程,写出每一步。

第 5 题:判断对错:函数 swap 里能用 void 作返回值类型,因为没有值需要返回。

参考答案
  1. add(3,4)=7add(5,6)=11add(7,11)=18
  2. 值传递把实参的值复制给形参,形参是独立拷贝,交换形参不影响实参。
  3. x = 100&x 是地址,p 指向 x,*p = 100 修改了 x 本身。
  4. fact(5)=5×fact(4)=5×4×fact(3)=5×4×3×fact(2)=5×4×3×2×fact(1)=120
  5. 对。void 表示没有返回值,只做”副作用”(如交换)的函数正好用它。

下一篇C语言期末速成 7:数组

评论区