教材《C程序设计》第五版(谭浩强)第7章,书页 167–216,共 50 页|建议 2h
课前一句话
前六课你学的是"一条一条写指令",从这一课开始学"把指令打包成零件,再拿零件组装程序"。
学完这一课,你能:自己定义函数、把数组整个交给函数去改、用递归解决"答案藏在答案里"的问题,并且能一眼看穿"改了形参为什么实参不变""数组传进去为什么原数组被改了"这类题。
本章是全书的分水岭:第 8 章指针拿函数参数当主战场,第 9、10 章的结构体和文件操作全靠函数组织。
一、先补地基:本章要解决的问题
1. 一个真实的痛点
要求输出:
****************
How do you do!
****************三行 printf 搞定。但如果这个页眉要在程序里出现 100 次呢?你会复制 100 遍——这就是教材 7.1 说的第一个问题:程序冗长、不精练。
第二个问题更隐蔽:800 行全塞在 main 里,某天算错了,你要在 800 行里找那一行。教材原话是:使主函数变得庞杂、头绪不清,使阅读和维护程序变得困难。
第三个问题是分工:你和同学合作,总不能两个人同时改同一个 main。
三个问题指向同一个答案:把"干一件事"的代码打包、起名,用的时候叫名字。
2. 整章只用这一个比喻:车间里的机器
教材 7.1 自己的比喻是"组装计算机"——事先生产好电源、主板、风扇,装配时用什么就从仓库取什么,绝不会"用到电源时临时手工生产一个电源"。我把它拧紧成贯穿全章的模型:你是一个车间主任,车间里摆着一排机器。
| 车间里的东西 | C 语言里对应的东西 |
|---|---|
| 车间主任 | main 函数 |
| 一台加工机器 | 一个函数 |
| 机器的名字(如"打孔机") | 函数名 |
| 机器上的进料口 | 形式参数(形参) |
| 你实际倒进去的原料 | 实际参数(实参) |
| 机器的出料口 | 返回值(return) |
| 贴在墙上的机器说明书 | 函数声明(函数原型) |
| 按下启动按钮 | 函数调用 |
| 机器内部的临时零件 | 局部变量 |
| 车间中央的公共仓库 | 全局变量 |
| 一台"断电也记得上次数"的机器 | static 局部变量 |
| 一台机器里装了个缩小版的自己 | 递归 |
| 只准本车间用、别的车间调不动的机器 | 内部函数(static 函数) |
后面每讲一个点,我都会回到这张表说"现在说的是车间里的哪样东西"。
3. 本章在全书的位置
第2章 算法(怎么想)→ 第3–6章 数据类型/分支/循环/数组(怎么算)
→ 第7章 函数(怎么组织)← 你在这里
→ 第8章 指针(函数参数是指针最大的舞台)一句话:前面是"会写程序",这一章是"会组织程序"。
二、知识点讲解
7.1 为什么要用函数
【一句话】把一段"干一件事"的代码打包起名,以后按名字叫它干活。
拆名字:function
function 在英文中的意思既是"函数",也是"功能"。所以"函数"这个词本身就把答案说完了——函数就是功能,函数名就是给这个功能起的名字。
教材原话:函数就是功能。每一个函数用来实现一个特定的功能。函数的名字应反映其代表的功能。
为什么用(3 条)
- 避免重复:功能只写一次,用多少次调多少次。
- 便于分工:一个模块一个人写,各自编译调试,提高调试效率。
- 便于阅读维护:
main里只看"流程",不看"细节"。
两类函数(两个角度)
角度一:从用户使用看
| 类型 | 谁写的 | 怎么用 |
|---|---|---|
| 库函数 | 编译系统提供 | #include 头文件后直接用(printf、sin、abs) |
| 用户自定义函数 | 你自己写 | 自己定义、自己声明 |
教材提醒:库函数只提供最基本、最通用的一些函数,不可能包括实际应用中的所有函数——所以本章教你自己造机器。
角度二:从函数形式看
| 类型 | 特点 | 例子 |
|---|---|---|
| 无参函数 | 调用时不传数据,一般用来执行指定的一组操作 | print_star() 输出 18 个星号 |
| 有参函数 | 调用时通过参数传数据,一般会得到一个函数值 | max(a,b) 求两数大者 |
教材补充:无参函数可以带回或不带回函数值,但一般以不带回函数值的居多。
一个 C 程序的组成——7.1 的 6 条说明考点
- 一个 C 程序由一个或多个程序模块组成,每个模块作为一个源程序文件。一个源程序文件可以为多个 C 程序共用。
- 一个源程序文件由一个或多个函数及其他内容组成。一个源程序文件是一个编译单位——编译时以源程序文件为单位,不是以函数为单位。
- C 程序的执行从 main 开始,调用其他函数后流程返回 main,在 main 中结束整个程序。
- 所有函数都是平行的、独立的,函数不能嵌套定义;函数间可以互相调用,但不能调用 main 函数——main 是被操作系统调用的。
- 从用户使用角度看:库函数和用户自己定义的函数。
- 从函数形式看:无参函数和有参函数。
易错第 2、4 条最爱考:
- "编译单位是源程序文件,不是函数"——判断题常把这里改掉。
- "函数可以嵌套调用,但不能嵌套定义"——这两句经常被互换来坑你。
7.2 怎样定义函数
7.2.1 为什么要定义函数
【一句话】不造机器,就没法按按钮。
C 语言要求:程序中用到的所有函数,必须"先定义,后使用"。
教材的追问很直白:如果事先不定义,编译系统怎么知道 max 是什么、要实现什么功能呢!
定义函数要交代 4 件事:
- 函数的名字,以便按名调用。
- 函数的类型,即函数返回值的类型。
- 参数的名字和类型,以便调用时传数据。无参函数不需要这项。
- 函数要完成什么操作,即功能。这是最重要的,在函数体中解决。
7.2.2 定义函数的三种写法
(1)无参函数
类型名 函数名() // 或:类型名 函数名(void)
{
函数体
}括号里的 void 表示"空",即没有参数。函数体包括声明部分和语句部分。
(2)有参函数
int max(int x, int y) // 定义函数,有两个参数
{
int z; // 声明部分:定义临时变量
z = x > y ? x : y; // 执行语句部分:把 x 和 y 中的大者赋给 z
return(z); // 把 z 作为 max 函数的值带回主调函数
}拆开看:第 1 个 int 表示函数值是整型;max 是函数名;括号里两个形参 x、y 都是整型;调用时主调函数把实参的值传给形参;花括号内是函数体。
(3)空函数
void dummy()
{
}函数体是空的,调用时什么也不做。那为什么还要定义它?
教材讲得很实在:编程初期可以在将来准备扩充功能的地方写个空函数(函数名取将来实际要用的名字,如 merge()、matproduct()、concatenate()、shell(),分别代表合并、矩阵相乘、字符串连接、希尔排序),先占位,等以后用一个编好的函数代替它。这样程序结构清楚,可读性好,以后扩充新功能方便,对程序结构影响不大。
函数定义的 4 个要素
| 要素 | 写在哪 | 例子 |
|---|---|---|
| 函数名 | 首部 | max |
| 函数值类型 | 首部最左 | int |
| 形参表 | 首部括号内 | (int x, int y) |
| 函数体 | 花括号内 | { int z; ... return(z); } |
易错定义函数的 3 个坑
- 首行末尾不能加分号。写成
int max(int x,int y);再接{ ... },编译器会以为你只声明了一个函数,后面那对花括号就成了语法错误。 > 记忆法:定义是造机器,机器是实心的(带壳);声明是贴说明书,说明书是一张纸(一个分号完事)。 - 形参类型不能省。
int max(x, y)是老式写法,现代 C 不允许。 - 不能嵌套定义。所有机器都摆在同一个车间地面上,谁也不装在谁肚子里。
7.3 调用函数
7.3.1 函数调用的形式
【一句话】函数名(实参表列) —— 按按钮。
print_star(); // 调用无参函数
c = max(a, b); // 调用有参函数无参函数"实参表列"可以没有,但括号不能省略;多个实参用逗号隔开。
3 种调用方式考点
| 方式 | 长什么样 | 要求 |
|---|---|---|
| 1. 函数调用语句 | print_star(); | 不要求带回值,只要求完成操作。作为语句,末尾要有分号 |
| 2. 函数表达式 | c = 2 * max(a, b); | 要求函数带回确定的值参加运算 |
| 3. 函数参数 | m = max(a, max(b, c)); | 函数调用作为另一个函数调用的实参 |
第 3 种再举两例:printf("%d", max(a, b)); 把 max(a,b) 当 printf 的一个参数;m = max(a, max(b, c)); 里 max(b,c) 先算出 b、c 的大者,再作为外层 max 的实参,最终 m 是 a、b、c 三者最大。
易错分号到底加不加
教材专门写了这条:
调用函数并不一定要包括分号,只有作为函数调用语句才需要有分号。如果作为函数表达式或函数参数,函数调用本身是不必有分号的。不能写成
printf("%d", max(a, b););——max(a,b)后面多了一个分号。
判断法:看这个函数调用是"一句话"还是"一个值"。是一句话,就得分号收尾;是一个值,它就是表达式的一部分,不能自己带分号。
7.3.2 函数调用时的数据传递
【一句话】实参的值单向复制给形参,形参怎么改都传不回去。
| 名称 | 简称 | 出现位置 | 别名 |
|---|---|---|---|
| 形式参数 | 形参 | 定义函数时,函数名后括号里的变量名 | 虚拟参数 |
| 实际参数 | 实参 | 调用函数时,函数名后括号里的参数 | —— |
实参可以是常量、变量或表达式。教材把实参→形参的数据传递叫 "虚实结合"。
三条规则考点
- 实参可以是常量、变量或表达式,如
max(3, a+b),但必须有确定的值。 - 实参与形参的类型应相同或赋值兼容。若实参
float a = 3.5传给int x,则先将 3.5 转换成整数 3,再送到 x。字符型与 int 型可以互相通用。 - 值传递,单向传递。只能由实参传给形参,不能反过来。实参和形参在内存中占有不同的存储单元。
值传递的图景
main 函数 max 函数
┌──────────┐ ┌──────────┐
│ a = 2 │ ─── 复制 2 ──→ │ x = 2 │
│ b = 3 │ ─── 复制 3 ──→ │ y = 3 │
└──────────┘ └──────────┘
实参的盒子 形参的盒子
(原样不动) (随便改,改不到那边)7.3.3 函数调用的过程【考点·5 步】
- 定义时指定的形参,在未出现函数调用时并不占内存中的存储单元;调用发生时才临时分配。
- 将实参的值传递给对应形参。
- 执行函数期间,利用形参进行有关运算。
- 通过
return语句将函数值带回主调函数。返回值的类型应与函数类型一致。 - 调用结束,形参单元被释放;实参单元仍保留并维持原值,没有改变。
第 5 步换句话说:形参是实参的复印件,你在复印件上涂改,原件不会变。
7.3.4 函数的返回值
【一句话】return 后面那个值,就是这台机器的出料。
(1)返回值靠 return 获得
- 需要带回函数值,被调用函数中必须包含 return 语句;不需要则可以不要。
- 一个函数中可以有多个 return,执行到哪一个,哪一个就起作用。
return z;与return(z);等价;后面可以是表达式:
int max(int x, int y)
{
return(x > y ? x : y); // 求值和返回一个语句解决
}(2)函数值的类型
定义函数时要指定函数值的类型:
int max(float x, float y) // 函数值为整型
char letter(char c1, char c2) // 函数值为字符型
double min(int x, int y) // 函数值为双精度型(3)函数类型决定返回值类型考点
如果函数值的类型和 return 语句中表达式的值不一致,则以函数类型为准,数值型数据可自动转换。
教材例 7.3 专门讲这件事:max 定义为 int,函数体里 z 是 float,算出 z = 2.6。返回时按赋值规则——先将 2.6 转换为 int,得到 2,它就是函数返回值。如果把 main 里的 c 改成 float、用 %f 输出,会打印 2.000000,因为函数值就是整数 2。
教材建议:这样做往往使程序不清晰、可读性降低、容易弄错,应当做到函数类型与 return 返回值的类型一致。
(4)void 类型
不带回值的函数应定义为 void。系统就保证不使函数带回任何值,即禁止在调用函数中使用被调用函数的返回值。此时函数体中不得出现 return 语句。
教材脚注:过去的 C 标准允许不写函数类型,此时系统默认 int(Visual C++ 6.0 仍如此)。但不应提倡,应养成一律指定函数类型的习惯。
7.3 小结表
| 概念 | 一句话 | 关键细节 |
|---|---|---|
| 调用形式 | 函数名(实参表列) | 无参也要写括号 |
| 3 种调用方式 | 语句 / 表达式 / 参数 | 只有语句要分号 |
| 数据传递 | 值传递,单向 | 实参 → 形参,不可逆 |
| 调用过程 | 分配→传值→执行→返回→释放 | 实参保持原值 |
| 返回值 | return 表达式; | 类型以函数类型为准 |
| 无返回值 | 定义为 void | 函数体中不得有 return |
7.4 对被调用函数的声明和函数原型
【一句话】先打招呼,后使唤。
调用一个函数需要 3 个条件
- 被调用的函数必须是已经定义的函数(库函数或自定义函数)。但仅有这一条还不够。
- 如果使用库函数,应在文件开头用
#include把所需信息"包含"进来。如stdio.h里包含了输入输出库函数的声明;使用数学库函数要用#include <math.h>。h 是头文件的后缀,表示 header file。 - 如果使用自定义函数,而该函数定义在调用它的函数后面(同一文件),应该在主调函数中对该函数作声明。
声明到底在干什么
声明的作用是把函数名、函数参数的个数和参数类型等信息通知编译系统,以便在遇到函数调用时,编译系统能正确识别函数并检查调用是否合法。
教材例 7.4 讲得很透:main 在 add 前面,而编译是从上到下逐行进行的。没有声明,编译到调用那一行时无法确定 add 是不是函数名,也无法判断实参类型和个数是否正确,因而无法检查。
在编译阶段发现错误,比在运行阶段发现错误划算得多。
函数声明 vs 函数定义【考点·必背对比表】
| 函数定义 | 函数声明 | |
|---|---|---|
| 内容 | 函数名、函数值类型、形参及类型、函数体 | 函数名、函数类型、形参的类型个数顺序,无函数体 |
| 是不是独立模块 | 是一个完整的、独立的函数单位 | 不是模块,只是一条通知 |
| 位置 | 独立,与其他函数平行 | 主调函数的声明部分,或所有函数之前 |
| 结尾 | 首部后没有分号 | 有分号 |
| 作用 | 确立函数功能 | 通知编译系统,便于对照检查调用 |
函数原型(function prototype)
函数的首行(函数首部)称为函数原型。 所以写声明有个可靠办法:照写已定义函数的首行,再加一个分号。
教材解释了原理:函数首部包含了检查调用是否合法的基本信息(函数名、函数值类型、参数个数、参数类型和参数顺序)。检查时要求:函数名、函数类型、参数个数和参数顺序必须与声明一致,实参类型必须与声明中的形参类型相同(或赋值兼容),否则按出错处理。
两种声明写法
float add(float x, float y); // (1)带形参名
float add(float, float); // (2)只写类型编译系统只关心和检查参数个数和参数类型,不检查参数名。所以形参名可写可不写,叫什么无所谓:
float add(float a, float b); // 参数名不用 x、y,而用 a、b,合法教材还给了一条实用建议——用有意义的参数名有利于理解程序:
void print(int num, char sex, float score); // 大体可猜出是输出学号、性别、成绩的函数
void print(int, float, char); // 则无从知道形参的含义外部声明
如果已在所有函数之前(函数外部)对本文件要调用的函数作了声明,则各函数中不必再声明:
char letter(char, char); // 以下 3 行在所有函数之前,且在函数外部
float f(float, float);
int i(float, float);
int main()
{
// 这里要调用 letter、f、i,不必再声明
}写在所有函数前面的外部声明在整个文件范围中有效。
易错声明和定义最容易混的 3 处
- 位置:定义在函数外面(平行摆放);声明常写在某个函数的声明部分。
- 分号:定义首行没分号,声明有分号。这是最快的判别法。
- 能不能省:定义不能省;声明在"被调用函数定义在调用者之前"时可以省。
7.5 函数的嵌套调用
【一句话】A 干活干到一半,叫 B 来帮忙,B 干完回来,A 接着干。
C 语言的函数定义是互相平行、独立的:一个函数内不能再定义另一个函数,即不能嵌套定义,但可以嵌套调用函数。
执行过程(教材 7 步)
- 执行 main 函数的开头部分;
- 遇调用语句,调用函数 a,流程转去 a;
- 执行 a 的开头部分;
- 遇调用语句,调用函数 b,流程转去 b;
- 执行 b,如再无嵌套调用,则完成 b 的全部操作;
- 返回到 a 中调用 b 的位置;
- 继续执行 a 中尚未执行的部分,直到 a 结束;
- 返回 main 中调用 a 的位置;
- 继续执行 main 的剩余部分直到结束。
用"车间"比喻
主任(main)走到 1 号机(a)按启动 → 1 号机干到一半,内部去按 2 号机(b)的按钮 → 2 号机干完 → 1 号机接着干剩下的 → 干完 → 主任继续做下一件事。
注意:1 号机不会消失,它只是暂停在那里等着。这个"等着"就是后面递归能用起来的原因,也是第 8 章讲函数调用栈的伏笔。
考点
- "C 语言中函数可以嵌套定义"——错。
- "C 语言中函数可以嵌套调用"——对。
7.6 函数的递归调用
【一句话】函数自己调用自己。
定义
在调用一个函数的过程中又出现直接或间接地调用该函数本身,称为递归调用。
- 直接递归:
f函数里又调用f。 - 间接递归:
f1调f2,f2又调f1。
C 语言的特点之一就在于允许函数的递归调用。
【考点·最关键】递归的两大要素
教材原话:程序中不应出现无终止的递归调用,而只应出现有限次数的、有终止的递归调用,这可以用 if 语句来控制。
所以一个能用的递归必须同时有:
- 递归公式:把大问题化成"小一号的同类问题",如
age(n) = age(n-1) + 2。 - 终止条件:什么时候不再往下叫,如
age(1) = 10。
缺任何一个,程序要么算不出来,要么栈溢出崩溃。
递归的两个阶段:回溯 + 递推
以例 7.6(5 个学生年龄)为例:
回溯阶段(一路问下去) 递推阶段(一路算回来)
age(5) = age(4) + 2 age(5) = 18
age(4) = age(3) + 2 age(4) = 16
age(3) = age(2) + 2 →→→ age(3) = 14
age(2) = age(1) + 2 age(2) = 12
age(1) = 10 ← 终止条件 age(1) = 10教材原话:一个递归的问题可以分为"回溯"和"递推"两个阶段。要经历若干步才能求出最后的值。显而易见,如果要求递归过程不是无限制进行下去,必须具有一个结束递归过程的条件。
递归 vs 递推(迭代)
教材对比得很清楚:
求 n! 可以用递推方法,即从 1 开始,乘 2,再乘 3……一直乘到 n。这种方法容易理解,也容易实现。递推法的特点是从一个已知的事实(如 1! = 1)出发,按一定规律推出下一个事实(如 2! = 1! × 2),再从这个新的事实出发再往下推。
| 递推(迭代) | 递归 | |
|---|---|---|
| 出发点 | 从已知事实出发,往前推 | 从目标出发,往回问 |
| 实现方式 | 循环(for / while) | 函数调用自己 |
| 5! 的写法 | for(i=2;i<=5;i++) t*=i; | fac(5) = 5 * fac(4) |
| 内存开销 | 小 | 大(每层都要保存现场) |
易错递归 3 个坑
- 没有终止条件 → 无限递归 → 栈溢出(stack overflow),Windows 下表现是程序直接崩掉。
- 终止条件写错(比如
if(n==0) return 0;)→ 结果全错或永远到不了终点。 - 数据范围溢出:教材特别提示,例 7.7 中变量是 int 型,int 能表示的最大数为 2147483647,n = 12 时运行正常(12! = 479001600),输入 13 就得不到预期结果,可将 f、y 和 fac 定义为 float 或 double 型。
教材的"和尚搬盘子"(例 7.8 Hanoi)
把"移动 64 个盘子"变成"移动 63 个 + 移 1 个 + 移动 63 个",而"移动 63 个"又能变"移动 62 个 + 移 1 个 + 移动 62 个"……一直下放到"移动 1 个",这就是终止条件。
教材还补了个有趣的数:移动 64 个盘子需要 (2⁶⁴ − 1) 次,若每次 1 秒,需 (2⁶⁴ − 1) 秒,大约相当于 6×10¹¹ 年,即约 600 亿年。所以有人戏称,当老和尚移完 64 个盘子之时,"世界末日"也到了。
7.7 数组作为函数参数
【一句话】数组元素传"值",数组名传"地址"。
先补地基
调用有参函数时需要提供实参,例如 sin(x)、sqrt(2.0)、max(a,b)。实参可以是常量、变量或表达式。
数组元素的作用与变量相当——凡是变量可以出现的地方,都可以用数组元素代替。因此数组元素也可以用作函数实参,用法与变量相同,向形参传递数组元素的值。
此外,数组名也可以作实参和形参,传递的是数组第一个元素的地址。
这句话是整节的"总纲",后面三小节全在展开它。
7.7.1 数组元素作函数实参
【一句话】数组元素可以作实参,但不能作形参。
为什么不能作形参?教材的解释:
因为形参是在函数被调用时临时分配存储单元的,不可能为一个数组元素单独分配存储单元(数组是一个整体,在内存中占连续的一段存储单元)。
用车间比喻:形参是机器上的进料口,是一个凿出来的洞。你不能把"仓库第 3 排第 5 格里的那包料"本身做成进料口。
传递方式:把实参的值传给形参,是 "值传递",单向传递。
所以:数组元素作实参,和普通变量作实参一模一样。函数里改了形参,原数组元素不受影响。
7.7.2 一维数组名作函数参数【考点·本章第二大重点】
【一句话】数组名传的是首元素地址,形参数组和实参数组共占同一段内存。
核心区别(教材原话)
用数组元素作实参时,向形参变量传递的是数组元素的值,而用数组名作函数实参时,向形参(数组名或指针变量)传递的是数组首元素的地址。
教材的 4 条说明考点
- 用数组名作函数参数,应该在主调函数和被调用函数分别定义数组。例 7.10 中
array是形参数组名,score是实参数组名,分别在其所在函数中定义,不能只在一方定义。 - 实参数组与形参数组类型应一致(今都为 float),如不一致,结果将出错。
- 形参数组的大小是不起任何作用的,因为编译系统并不检查形参数组大小,只是将实参数组的首元素的地址传给形参数组名。
score[n]和array[n]指的是同一单元,具有相同的值。 - 形参数组可以不指定大小,在数组名后面跟一个空的方括号:
float average(float array[]),效果相同。
内存图景(教材图 7.14 简化)
实参数组 a: a[0] a[1] a[2] ... a[9]
↓
起始地址 1000(同一个地址)
↓
形参数组 b: b[0] b[1] b[2] ... b[9]若 a 的首元素地址为 1000,则 b 的首元素地址也是 1000,a[0] 与 b[0] 同占一个单元。假如改变了 b[0] 的值,也就意味着 a[0] 的值也改变了。
也就是说,形参数组中各元素的值如发生变化,会使实参数组元素的值同时发生变化。这一点与变量作函数参数的情况不同,务请注意。程序里常有意利用这一特点改变实参数组元素的值(如排序)。
教材的"指针伏笔"
在学习了第 8 章(指针)以后,可以知道编译系统把形参数组处理为指针变量(例如把
float array[]转换为float *array),该指针变量用来接收从实参数组传过来的地址。C 语言允许用指针变量或数组作为形参,二者是等价的;对数组元素的访问,下标法和指针法也是完全等价的。
用形参数组是为了便于理解,形参数组与实参数组各元素一一对应,形象好懂。
7.7.3 多维数组名作函数参数
【一句话】可以省第一维的大小,绝不能省第二维。
int array[3][10]; // 合法
int array[][10]; // 也合法,且与上面等价
int array[3][]; // ❌ 不合法
int array[][]; // ❌ 不合法为什么第 2 维不能省?教材的解释:
二维数组是由若干个一维数组组成的,在内存中数组是按行存放的,因此在定义二维数组时必须指定列数(即一行中包含几个元素)。
大白话:编译器要靠"一行有几个元素"才能算出"第 i 行从哪开始"。不知道列数,它就没法定位。
教材补的两条
- 在第 2 维大小相同的前提下,形参数组的第一维可以与实参数组不同。如实参
int score[5][10];,形参写成int array[][10];或int array[8][10];均可以。编译系统不检查第一维的大小。 - 调用时把实参二维数组的第 1 行起始地址传给形参数组,因为两个数组列数相同,所以
a[i][j]与array[i][j]同占一个存储单元,具有同一个值。
7.7 小结:四种"传参"对比考点
| 实参类型 | 传的是什么 | 形参写法 | 函数里改形参,实参变不变 |
|---|---|---|---|
| 普通变量 | 值 | int x | 不变 |
数组元素 a[i] | 值 | int x | 不变 |
一维数组名 a | 首元素地址 | int a[] 或 int *a | 变 |
二维数组名 a | 首行首元素地址 | int a[][10] | 变 |
7.8 局部变量和全局变量
【一句话】变量也有"活动范围"——在哪儿定义,就在哪儿管用。
先补地基
本章以前,变量都在函数开头定义,只在本函数有效。本章的程序有多个函数,自然会有问题:在一个函数中定义的变量,在其他函数中能否被引用?在不同位置定义的变量,在什么范围内有效?
这就是变量的作用域问题。定义变量可能有 3 种情况:
- 在函数的开头定义;
- 在函数内的复合语句内定义;
- 在函数的外部定义。
前两种产生局部变量,第三种产生全局变量。
7.8.1 局部变量
在一个函数内部定义的变量只在本函数范围内有效;在复合语句内定义的变量只在本复合语句范围内有效。这些称为局部变量。
教材的比喻
高一甲班有一学生叫王建国,高一乙班也有一学生叫王建国,二者不是同一个人。不同的班允许有同名的学生,互不干扰。高一甲班点名时,只有该班的王建国喊"到",乙班的王建国不在甲班活动。他们的活动范围局限在本班——这些名字的有效范围是局部的。
4 条说明考点
- main 中定义的变量也只在 main 中有效,并不因为在主函数中定义而在整个文件或程序中有效。主函数也不能使用其他函数中定义的变量。
- 不同函数中可以使用同名的变量,它们代表不同的对象,互不干扰,在内存中占不同单元。
- 形式参数也是局部变量。其他函数不能直接引用某函数的形参。
- 复合语句内定义的变量只在本复合语句内有效,这种复合语句也称为"分程序"或"程序块":
int main()
{
int a, b; // a, b 在此范围内有效
{
int c;
c = a + b; // c 在此复合语句内有效
} // 离开这对花括号,c 就无效了,内存单元被释放
return 0;
}7.8.2 全局变量
在函数之外定义的变量称为外部变量,外部变量是全局变量(也称全程变量)。
- 全局变量可以为本文件中其他函数所共用。它的有效范围为从定义变量的位置开始到本源文件结束。
- 注意:在函数内定义的变量是局部变量,在函数外定义的变量是全局变量。(最简洁的判据。)
教材的例子
int p = 1, q = 5; // 定义外部变量(作用域从这行到文件结束)
float f1(int a) // 定义函数 f1
{
int b, c; // 定义局部变量(只在 f1 内有效)
return 0; // 略去函数体细节
}
char c1, c2; // 真正的外部变量(在函数外,从这行到文件结束有效)
char f2(int x, int y) // 定义函数 f2
{
return 0; // 略去函数体细节
}
// 结论:main 和 f2 里 p、q、c1、c2 都能用;
// 而 f1 位置靠前,只能用 p、q,用不了后面才定义的 c1、c2。p、q、c1、c2 都是全局变量,但作用范围不同:在 main 和 f2 中可以使用 p、q、c1、c2,但在 f1 中只能使用 p、q,不能用 c1、c2(因为 c1、c2 定义在 f1 之后)。
教材的比喻
国家有统一的法律法规,各省还可以制定地方法规。在甲省,国家法规和甲省法规都有效;在乙省,则国家法规和乙省法规有效。显然,甲省的法规在乙省无效。
全局变量的作用
设置全局变量是增加了函数间数据联系的渠道。同一文件中的所有函数都能引用全局变量的值,在一个函数中改变了它,就能影响到其他函数——相当于各函数间有直接的传递通道。
由于函数的调用只能带回一个函数返回值,因此有时可以利用全局变量来增加函数间的联系渠道,通过函数调用能得到一个以上的值。
这是全局变量最主要的实战用途(例 7.14:调一个函数同时拿回平均分、最高分、最低分)。
命名习惯
为便于区别,C 程序设计人员中有个习惯(但非规定):将全局变量名的第 1 个字母用大写表示。
例 7.15:同名屏蔽考点
问题:同一源文件中全局变量与局部变量同名,会怎样?
三个选项:(1)出错;(2)局部变量无效、全局变量有效;(3)在局部变量的作用范围内,局部变量有效,全局变量被"屏蔽"。
答案是(3)。
#include <stdio.h>
int a = 3, b = 5; // a, b 是全局变量
int main()
{
int max(int a, int b); // 函数声明。a, b 是形参
int a = 8; // a 是局部变量
printf("max=%d\n", max(a, b));
return 0;
}
int max(int a, int b)
{
int c;
c = a > b ? a : b;
return(c);
}运行结果:max=8
教材分析:局部变量 a 的作用范围是那两行,在此范围内全局变量被局部变量屏蔽,相当于全局变量在此不存在;而全局变量 b 在此范围内有效。所以 max(a,b) 相当于 max(8,5),值为 8。而 max 函数里,形参 a、b 也是局部变量,全局变量在函数范围内不起作用,它们的值由实参传来,即 8 和 5。
一句话:近的遮远的。同一个名字,谁离得近(作用域更内层)谁说话。
为什么要少用全局变量(教材 3 条)
- 全局变量在程序的全部执行过程中都占用存储单元,而不是仅在需要时才开辟。
- 它使函数的通用性降低了:如果在函数中引用了全局变量,执行情况会受到外部变量影响;把一个函数移到另一文件,还要考虑把外部变量一起移过去,若与其他文件变量同名就会出问题——降低了程序的可靠性和通用性。
- 使用全局变量过多会降低程序的清晰性:很难判断每个瞬时各个外部变量的值,程序容易出错。
教材总结:划分模块时要求模块的"内聚性"强、与其他模块的"耦合性"弱。一般要求把函数做成一个相对的封闭体,除了通过"实参—形参"的渠道与外界发生联系外,没有其他渠道。这样的程序移植性好、可读性强。
7.9 变量的存储方式和生存期选学
本节考试以选择/填空为主。核心只有三件事:static 局部变量只初始化一次、auto/register/extern 各管什么、作用域 vs 生存期。概念清楚即可,不必展开。
7.9.1 动态存储方式与静态存储方式
从作用域(空间)看,变量分全局和局部;从生存期(时间)看,有的变量全程存在,有的只在函数调用时临时分配。
| 方式 | 含义 |
|---|---|
| 静态存储方式 | 在程序运行期间由系统分配固定的存储空间 |
| 动态存储方式 | 在程序运行期间根据需要进行动态的分配 |
内存中供用户使用的存储空间分 3 部分:程序区、静态存储区、动态存储区。
| 存放区 | 放什么数据 |
|---|---|
| 静态存储区 | 全局变量。程序开始执行时分配,执行完毕释放,运行中占据固定单元 |
| 动态存储区 | ① 函数形式参数;② 没有用 static 声明的局部变量(即自动变量);③ 函数调用时的现场保护和返回地址 |
| CPU 寄存器 | 寄存器变量 |
教材补一句:如果在一个程序中两次调用同一函数,两次分配给局部变量的存储空间的地址可能是不相同的。
C 的存储类别 4 种考点
每一个变量和函数都有两个属性:数据类型和数据的存储类别。
| 关键字 | 中文 | 主要用途 |
|---|---|---|
auto | 自动的 | 局部变量(可省略) |
static | 静态的 | 局部变量 / 全局变量 |
register | 寄存器的 | 局部变量 |
extern | 外部的 | 全局变量、函数 |
根据存储类别,可以知道变量的作用域和生存期。
7.9.2 局部变量的存储类别
1. 自动变量(auto)
函数中的局部变量,如果不专门声明为 static,都是动态分配存储空间的,函数调用结束时自动释放。auto 可以省略:
int b, c = 3; // 与 auto int b, c = 3; 完全等价程序中大多数变量属于自动变量。
2. 静态局部变量(static 局部变量)【考点·必考】
【一句话】函数调用结束后,它的值不消失,下次进来接着用。
##### 4 条性质考点
- 属于静态存储类别,在静态存储区分配单元,在程序整个运行期间都不释放。而自动变量在动态存储区,函数调用结束后即释放。
- 对静态局部变量是在编译时赋初值的,即只赋初值一次。以后每次调用不再重新赋初值,只保留上次调用结束时的值。而自动变量的初值是在函数调用时给的,每调用一次重新给一次初值。
- 如果定义时不赋初值:静态局部变量编译时自动赋初值 0(数值型)或 '\0'(字符型);而自动变量的值是一个不确定的值。
- 虽然静态局部变量在函数调用结束后仍然存在,但其他函数是不能引用它的。它仍然是局部变量。
##### 什么时候用
需要保留函数上一次调用结束时的值时(例 7.16 输出 7、8、9;例 7.17 用它连续求 1!~5!)。
##### 易错
- "离开函数后值还在,但别的函数不能用它"——选择题爱考这句。
- 不要以为 static 就是"只执行一次",它是"只初始化一次",每次调用还是会执行到它。
- 教材提醒:静态存储要多占内存,还降低可读性,调用次数多时往往弄不清它的当前值。若非必要,不要多用静态局部变量。
3. 寄存器变量(register)
允许将局部变量的值放在 CPU 的寄存器中,需要用时直接取出参加运算,不必再到内存存取,以提高执行效率。
register int f; // 定义 f 为寄存器变量现实提醒:现在优化的编译系统能自动识别使用频繁的变量并放进寄存器,用 register 声明的必要性不大,只需知道有这种变量即可。
##### 考点3 种局部变量的存储位置
| 局部变量种类 | 存储位置 |
|---|---|
| 自动变量 | 动态存储区 |
| 静态局部变量 | 静态存储区 |
| 寄存器变量 | CPU 中的寄存器 |
7.9.3 全局变量的存储类别
【一句话】全局变量都在静态存储区,extern 和 static 改的是作用域,不是存储方式。
全局变量都存放在静态存储区,生存期固定,存在于程序的整个运行过程;但作用域从什么位置起、到什么位置止,需要指定不同的存储类别。
| 想做的事 | 写法 | 关键点 |
|---|---|---|
| 在本文件内扩大作用域 | 在引用前写 extern int A; | 定义在后面的变量,前面的函数才用得上 |
| 跨文件使用 | 一个文件定义,另一个文件写 extern A; | 两处都定义会报"重复定义" |
| 限制在本文件 | 定义时加 static int A; | 叫静态外部变量,别的文件看不见 |
教材补充:
- 提倡把外部变量的定义放在引用它的所有函数之前,这样就省掉了 extern 声明。
- 用 extern 声明时类型名可以省写:
extern A, B, C;合法(它不是定义变量)。 - 系统遇到 extern 时,先在本文件找定义,找不到再在连接时从其他文件找,再找不到就按出错处理。
- 用 extern 跨文件扩展作用域要十分慎重,因为一个文件改了它的值,会影响另一文件函数的执行结果。
【易错·超高频】
不要误认为对外部变量加 static 声明后才采取静态存储方式(存放在静态存储区),而不加 static 的是采取动态存储。
声明局部变量的存储类型和声明全局变量的存储类型的含义是不同的:
- 对局部变量:指定变量存储的区域(静态区/动态区)以及由此产生的生存期问题。
- 对全局变量:都是编译时分配、都存在静态存储区,声明存储类型的作用是作用域的扩展问题。
用 static 声明一个变量的两条作用(教材总结)
- 对局部变量:分配在静态存储区,整个程序执行期间不释放。
- 对全局变量:作用域只限于本文件模块。
易错关键字不能单独使用
int a; // 先定义整型变量
static a; // ❌ 企图再声明为静态变量 —— 编译时会被认为"重新定义"7.9.4 存储类别小结
对一个数据的定义,需要指定两种属性:数据类型和存储类别:
static int a; // 静态局部整型变量 或 静态外部整型变量
auto char c; // 自动变量,在函数内定义
register int d; // 寄存器变量,在函数内定义
extern b; // 将已定义的外部变量 b 的作用域扩展至此按作用域分:
局部变量 ─┬─ 自动变量(动态局部变量):离开函数,值就消失
├─ 静态局部变量:离开函数,值仍保留
└─ 寄存器变量:离开函数,值就消失
全局变量 ─┬─ 静态外部变量(只限本文件引用)
└─ 外部变量(即非静态的外部变量,允许其他文件引用)作用域 vs 生存期考点
- 作用域(可见性):从空间角度。变量在某范围内有效,在此范围内"可见"。
- 生存期(存在性):从时间角度。变量值在某一时刻存在,该时刻就属于它的生存期,称它此时"存在"。
二者有联系但不是同一回事。
表 7.2 各种类型变量的作用域和存在性【必背表】
| 变量存储类别 | 函数内·作用域 | 函数内·存在性 | 函数外·作用域 | 函数外·存在性 |
|---|---|---|---|---|
| 自动变量和寄存器变量 | √ | √ | × | × |
| 静态局部变量 | √ | √ | × | √ |
| 静态外部变量 | √ | √ | √(只限本文件) | √ |
| 外部变量 | √ | √ | √ | √ |
(√ 表示"是",× 表示"否"。)
教材的解读:自动变量和寄存器变量在函数内外的"可见性"和"存在性"是一致的;静态外部变量和外部变量也一致;而静态局部变量的可见性和存在性不一致——离开函数后,变量值存在,但不能被引用。
这张表里唯一一行"可见"和"存在"打架的,就是静态局部变量。记住这一点,整张表就不会记混。
static 对局部变量和全局变量的作用不同
- 对局部变量:使变量由动态存储方式改为静态存储方式。
- 对全局变量:使变量局部化(局部于本文件),但仍为静态存储方式。
凡有 static 声明的,其作用域都是局限的:或局限于本函数内,或局限于本文件内。
7.10 关于变量的声明和定义
【一句话】声明是"告诉你有这么个东西",定义是"真给你造出来"。
教材一开始就坦白:定义与声明这两个名词"始终存在着混淆","不仅许多初学者没有搞清楚,连不少介绍 C 语言的教材也没有给出准确的介绍"。
函数:区别很明显
一个函数一般由声明部分和执行语句组成。
- 函数的声明是函数的原型,而函数的定义是对函数功能的定义。
- 对被调用函数的声明放在主调函数的声明部分中,而函数的定义不在声明部分的范围内,它是一个独立的模块。
变量:稍微复杂一些
| 写法 | 是否需要建立存储空间 | 名称 |
|---|---|---|
int a; | 需要 | 定义性声明(defining declaration),简称定义(definition) |
extern a; | 不需要 | 引用性声明(referencing declaration) |
广义地说,声明包括定义,但并非所有的声明都是定义。
- 对
int a;而言,它既是声明,又是定义; - 对
extern a;而言,它是声明而不是定义。
【考点·一句话结论】
在函数中出现的对变量的声明(除了用 extern 声明的以外)都是定义。在函数中对其他函数的声明不是函数的定义。
记住这一句,这一节的分就全拿了。
外部变量的定义 vs 声明
| 定义 | 声明 | |
|---|---|---|
| 次数 | 只能有一次 | 同一文件中可以有多次 |
| 位置 | 所有函数之外 | 函数之内或函数之外 |
| 系统据此分配存储单元 | 是(根据定义,不是根据声明) | 否 |
| 能否初始化 | 可以 | 不能 |
所谓"声明",作用是声明该变量是一个已在其他地方定义过的外部变量,仅仅是为了扩展该变量的作用范围而作的声明。
7.11 内部函数和外部函数选学
【一句话】函数能不能被"别的文件"调用。
变量有作用域,函数有没有类似问题?答案是有。有的函数可被本文件和其他文件的函数调用,有的只能被本文件的函数调用。
函数本质上是全局的——定义一个函数的目的就是要被另外的函数调用。不加声明的话,一个文件中的函数既可被本文件其他函数调用,也可被其他文件函数调用。
7.11.1 内部函数
只能被本文件中其他函数所调用的函数称为内部函数。定义时在函数名和函数类型前面加 static:
static int fun(int a, int b)内部函数又称静态函数。使用它可以使函数的作用域只局限于所在文件,这样不同文件中即使有同名内部函数也互不干扰。
通常把只能由本文件使用的函数和外部变量放在文件开头,前面都冠以 static 使之局部化,其他文件不能引用。这就提高了程序的可靠性。
7.11.2 外部函数
定义时在函数首部最左端加 extern,则此函数是外部函数,可供其他文件调用:
extern int fun(int a, int b)C 语言规定,如果在定义函数时省略 extern,则默认为外部函数。本书前面所用的函数都是外部函数。
调用其他文件的外部函数时,需要声明(即使在本文件中调用一个函数,也要用函数原型进行声明)。声明时可以加 extern,也可以省写。
教材例 7.20:删字符(4 个文件)
题目:有一个字符串,内有若干个字符,输入一个字符,要求程序将该字符删去,用外部函数实现。
教材拆成 4 个文件:file1.c(main)、file2.c(enter_string)、file3.c(delete_string)、file4.c(print_string)。main 里三个声明:
extern void enter_string(char str[]);
extern void delete_string(char str[], char ch);
extern void print_string(char str[]);算法(值得单独记):对 str 数组中的字符逐个检查,不是指定要删除的字符就仍存放在数组中。
控制两个下标:i 一路往前走检查每个字符;j 只在"要保留"时前进,把保留的字符搬到前面。因为 i 总是大于或等于 j,最后保留下来的字符不会覆盖未被检测处理的字符。
注意:最后要将结束符
'\0'也复制到被保留的字符后面。
函数原型与 #include 的关系(教材的收尾)
由此可以进一步理解函数原型的作用。用函数原型能够把函数的作用域扩展到定义该函数的文件之外(不必使用 extern)。只要在使用该函数的每一个文件中包含该函数的函数原型即可。函数原型通知编译系统:该函数在本文件中稍后定义,或在另一文件中定义。
利用函数原型扩展函数作用域最常见的例子就是 #include:
sin函数的原型是double sin(double x);- 要求程序员在调用库函数时先从手册查出原型并一一写出来,十分麻烦而困难。
- 为减少困难,在头文件 math.h 中包括了所有数学函数的原型和其他有关信息,用户只须
#include <math.h>,就能合法地调用各种数学库函数。
这一段是本章的点睛之笔:你以前写的每一行 #include <stdio.h>,本质上就是贴了一整面墙的说明书。
三、现场写代码
本章例题多,我挑 8 个最典型的逐个讲(题目 → 思路 → 完整代码 → 逐行解释 → 运行结果),其余例题在最后的速览表里补齐。
例 7.1 用函数输出页眉(无参函数 + 声明)
题目:想输出以下结果,用函数调用实现。
****************
How do you do!
****************思路:上下各有一行"*"号,不必重复写这段代码。用一个 print_star 函数输出一行"*"号,再写一个 print_message 函数输出中间一行文字,用主函数分别调用即可。
#include <stdio.h>
int main()
{
void print_star(); // 声明 print_star 函数
void print_message(); // 声明 print_message 函数
print_star(); // 调用 print_star 函数
print_message(); // 调用 print_message 函数
print_star(); // 调用 print_star 函数
return 0;
}
void print_star() // 定义 print_star 函数
{
printf("****************\n"); // 输出一行 * 号
}
void print_message() // 定义 print_message 函数
{
printf("How do you do!\n"); // 输出一行文字信息
}逐行解释
#include <stdio.h>:把标准输入输出库的"说明书墙"贴进来,才认得printf。void print_star();:在主任的操作手册里写一条机器说明书,末尾有分号,是声明。print_star();:按第 1 台机器的按钮,作为语句末尾有分号。return 0;:主任下班。void print_star():首行没有分号,后面跟花括号,这是定义。printf("****************\n");:输出 16 个星号再换行(\n是换行符)。
为什么定义写在 main 后面还要声明? 编译器是从上往下逐行看的。看到 print_star(); 时它还没见过定义,不给一份说明书,它就不知道这是函数,甚至会以为是变量。
运行结果
****************
How do you do!
****************程序分析(教材原话):这两个函数都指定为 void,意为函数无类型,即无函数值——执行这两个函数后不会把任何值带回 main 函数。
例 7.2 求两个整数中的大者(有参函数 + 值传递)
题目:输入两个整数,输出其中值较大者,用函数来找到大数。
思路:① 函数名应见名知义,定为 max;② 两个数是整数,大者也是整数,所以函数值应为 int;③ 需要两个整型参数。
#include <stdio.h>
int main()
{
int max(int x, int y); // 对 max 函数的声明
int a, b, c;
printf("please enter two integer numbers:");
scanf("%d,%d", &a, &b); // 输入两个整数
c = max(a, b); // 调用 max 函数,大数赋给 c
printf("max is %d\n", c); // 输出大数 c
return 0;
}
int max(int x, int y) // 定义函数,有两个参数
{
int z; // 定义临时变量
z = x > y ? x : y; // 把 x 和 y 中的大者赋给 z
return(z); // 把 z 作为 max 函数的值带回 main 函数
}逐行解释
int max(int x, int y);:声明。告诉编译器"有个叫 max 的机器,两个 int 进料口,int 出料"。int a, b, c;:a、b 是原料,c 装出料。scanf("%d,%d", &a, &b);:格式串里是逗号,输入时必须用逗号隔开(如12,-34)。c = max(a, b);:把 a、b 的值复制给 x、y,机器跑起来,出料赋给 c。z = x > y ? x : y;:条件表达式,等价"若 x>y 取 x,否则取 y"。return(z);:把 z 从出料口送出去。
运行结果
please enter two integer numbers:12,-34
max is 12必须想明白的:a 和 x 是两个不同的盒子。调用时把 a 里的 12 抄一份放进 x,把 b 里的 -34 抄一份放进 y。之后在 max 里怎么折腾 x、y,a、b 都不会变。
例 7.4 求两实数之和(函数原型 + float)
题目:输入两个实数,用一个函数求出它们之和。
思路:用 add 函数实现,它为 float 型,两个参数也为 float 型。特别要注意:要对 add 函数进行声明。
#include <stdio.h>
int main()
{
float add(float x, float y); // 对 add 函数作声明
float a, b, c;
printf("Please enter a and b:");
scanf("%f,%f", &a, &b); // 输入两个实数
c = add(a, b); // 调用 add 函数
printf("sum is %f\n", c); // 输出两数之和
return 0;
}
float add(float x, float y) // 定义 add 函数
{
float z;
z = x + y;
return(z); // 把 z 的值作为函数值返回
}逐行解释
float add(float x, float y);:声明。函数类型 float 写在最前面,形参类型 float 写在每个形参前面。scanf("%f,%f", &a, &b);:%f是 float 的格式符;scanf里变量前必须加 &。float add(float x, float y):定义,首行末尾没有分号。z = x + y;核心运算;return(z);送出结果。
运行结果
Please enter a and b:3.6,6.5
sum is 10.100000教材想让你体会的:main 在 add 前面,而编译是从上到下逐行进行的。没有声明,编译到调用那一行时无法确定 add 是不是函数名,也无法判断实参类型和个数是否正确,因而无法检查。在编译阶段发现错误,比在运行阶段划算得多。
例 7.5 求 4 个数中的最大者(嵌套调用)
题目:输入 4 个整数,找出其中最大的数,用函数的嵌套调用来处理。
思路:main 调用 max4,max4 里再多次调用 max2(求两数大者),逐层推出 4 者最大。
#include <stdio.h>
int main()
{
int max4(int a, int b, int c, int d); // 对 max4 的函数声明
int a, b, c, d, max;
printf("Please enter 4 interger numbers:");
scanf("%d %d %d %d", &a, &b, &c, &d); // 输入 4 个数
max = max4(a, b, c, d); // 调用 max4 函数
printf("max= %d \n", max); // 输出 4 个数中的最大者
return 0;
}
int max4(int a, int b, int c, int d) // 定义 max4 函数
{
int max2(int a, int b); // 对 max2 的函数声明
int m;
m = max2(a, b); // 第 1 次调用 max2
m = max2(m, c); // 第 2 次调用 max2
m = max2(m, d); // 第 3 次调用 max2
return(m); // 把 m 作为函数值带回 main 函数
}
int max2(int a, int b) // 定义 max2 函数
{
if (a >= b)
return a; // 若 a≥b,将 a 作为函数返回值
else
return b; // 若 a<b,将 b 作为函数返回值
}逐行解释
int max2(int a, int b);:在 max4 的函数体里声明 max2——因为 max2 定义在 max4 后面。main 里不需要声明 max2,因为 main 没直接调用它。m = max2(a, b);:先求 a、b 中的大者放进 m。m = max2(m, c);:再拿 m 和 c 比,得到 a、b、c 三者最大。m = max2(m, d);:再拿 m 和 d 比,得到四者最大。if (a >= b) return a; else return b;:两个 return,执行到哪个哪个起作用。
运行结果
Please enter 4 interger numbers:12 45 -6 89
max= 89教材的程序分析:第 1 次调用得到 a、b 中的大者;第 2 次得到 m 和 c 中的大者,即 a、b、c 的最大者;第 3 次得到 m 和 d 中的大者,即四者最大。这是一种递推方法:先求 2 个数的大者,再以此为基础求 3 个数的大者,再求 4 个数的。
教材给的两种精简写法:max2 可写成 return(a >= b ? a : b);;max4 可取消 m,写成 return max2(max2(max2(a,b), c), d);(把函数调用作为函数参数)。
例 7.6 五学生年龄(递归入门)
题目:5 个学生坐在一起。问第 5 个多大,他说比第 4 个大 2 岁;问第 4 个,说比第 3 个大 2 岁……最后问第 1 个,他说 10 岁。问第 5 个多大。
思路:
age(n) = 10 (n = 1)
age(n) = age(n-1) + 2 (n > 1)#include <stdio.h>
int main()
{
int age(int n); // 对 age 函数的声明
printf("NO.5, age: %d\n", age(5)); // 输出第 5 个学生的年龄
return 0;
}
int age(int n) // 定义递归函数
{
int c;
if (n == 1) // 如果 n 等于 1
c = 10; // 年龄为 10
else
c = age(n - 1) + 2; // 年龄是前一个学生的年龄加 2
return(c); // 返回年龄
}逐行解释
age(5)直接作为 printf 的参数——这是 7.3.1 讲的"函数参数"调用方式。if (n == 1) c = 10;:终止条件,到这一层就不再往下叫了。c = age(n - 1) + 2;:递归公式,这一句会让程序再开一层,n 减 1。return(c);:每层把自己的结果还给上一层。
运行结果
NO.5, age:18递归执行图(教材图 7.9)
age(5) ─→ c = age(4) + 2 age(5) = 18
age(4) ─→ c = age(3) + 2 age(4) = 16
age(3) ─→ c = age(2) + 2 age(3) = 14
age(2) ─→ c = age(1) + 2 age(2) = 12
age(1) ─→ c = 10 age(1) = 10教材的关键提醒:age 函数共被调用 5 次(age(5)~age(1)),其中 age(5) 是 main 调用的,其余 4 次是在 age 中调用自己的,即递归调用 4 次。而且某一次调用 age 时并不是立即得到值,而是一次又一次递归,到 age(1) 时才有确定的值,然后再递推出 age(2)~age(5)。
例 7.7 递归求 n!(递归的"标准模板")
题目:用递归方法求 n!。
思路:
n! = 1 (n = 0 或 n = 1)
n! = n × (n-1)! (n > 1)#include <stdio.h>
int main()
{
int fac(int n); // fac 函数声明
int n;
int y;
printf("input an integer number:");
scanf("%d", &n); // 输入要求阶乘的数
y = fac(n);
printf("%d!=%d\n", n, y);
return 0;
}
int fac(int n) // 定义 fac 函数
{
int f;
if (n < 0)
printf("n<0, data error!");
else if (n == 0 || n == 1)
f = 1; // n=0 或 1 时, n! = 1
else
f = fac(n - 1) * n;
return(f);
}逐行解释
if (n < 0) printf(...):防御性检查,负数没有阶乘(教材原程序只提示,不返回)。else if (n == 0 || n == 1) f = 1;:终止条件,注意 0! = 1。else f = fac(n - 1) * n;:递归公式,注意乘的顺序是fac(n-1) * n。return(f);:每层返回自己的 f。
运行结果
input an integer number:10
10!=3628800递归过程(教材图 7.10)
fac(5) ─→ f = fac(4) * 5 fac(5) = 120
fac(4) ─→ f = fac(3) * 4 fac(4) = 24
fac(3) ─→ f = fac(2) * 3 fac(3) = 6
fac(2) ─→ f = fac(1) * 2 fac(2) = 2
fac(1) ─→ f = 1 fac(1) = 1教材的数据范围提示:int 型 4 字节,最大 2147483647,n = 12 时正常(12! = 479001600),输入 13 就得不到预期结果。可将 f、y 和 fac 定义为 float 或 double 型。
例 7.10 求平均成绩(数组名作参数)
题目:有一个一维数组,内放 10 个学生成绩,求平均成绩。
思路:不用数组元素作实参,而是用数组名作实参,形参也用数组名,在 average 中引用各元素求平均并返回。
#include <stdio.h>
int main()
{
float average(float array[10]); // 函数声明
float score[10], aver;
int i;
printf("input 10 scores:\n");
for (i = 0; i < 10; i++)
scanf("%f", &score[i]);
printf("\n");
aver = average(score); // 调用 average 函数
printf("average score is %5.2f\n", aver);
return 0;
}
float average(float array[10]) // 定义 average 函数
{
int i;
float aver, sum = array[0];
for (i = 1; i < 10; i++)
sum = sum + array[i]; // 累加学生成绩
aver = sum / 10;
return(aver);
}逐行解释
float average(float array[10]);:声明。这个 10 只是给人看的,编译器不看。float score[10], aver;:实参数组在这边定义——实参数组和形参数组各自在自己的函数里定义。aver = average(score);:传的是score(数组名),不是score[0],传过去的是首元素地址。sum = array[0];:先放第一个,再从第 2 个开始累加。aver = sum / 10;:求平均。
运行结果
input 10 scores:
85 67 78 98 67 99 54 88 57 61
average score is 75.40教材 4 条说明:① 主调函数和被调用函数分别定义数组;② 实参与形参数组类型应一致,不一致结果出错;③ 形参数组大小不起作用,编译系统不检查,只传首元素地址;④ 形参数组可以不指定大小(float array[]),效果相同。
例 7.12 选择法排序(数组名传地址的"实锤")
题目:用选择法对数组中 10 个整数按由小到大排序。
思路:先将 10 个数中最小的数与 a[0] 对换,再将余下数中最小的与 a[1] 对换……共比较 9 轮。
以 5 个数为例:
| 阶段 | a[0]~a[4] | 说明 |
|---|---|---|
| 初始 | 3 6 1 9 4 | 未排序 |
| 第 1 轮 | 1 6 3 9 4 | 最小的 1 与 a[0] 对换 |
| 第 2 轮 | 1 3 6 9 4 | 余下 4 个中最小的 3 与 a[1] 对换 |
| 第 3 轮 | 1 3 4 9 6 | 余下 3 个中最小的 4 与 a[2] 对换 |
| 第 4 轮 | 1 3 4 6 9 | 余下 2 个中最小的 6 与 a[3] 对换,完成 |
#include <stdio.h>
int main()
{
void sort(int array[], int n);
int a[10], i;
printf("enter array:\n");
for (i = 0; i < 10; i++)
scanf("%d", &a[i]);
sort(a, 10); // 调用 sort 函数,a 为数组名,大小为 10
printf("The sorted array:\n");
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
printf("\n");
return 0;
}
void sort(int array[], int n)
{
int i, j, k, t;
for (i = 0; i < n - 1; i++)
{
k = i;
for (j = i + 1; j < n; j++)
if (array[j] < array[k])
k = j;
t = array[k];
array[k] = array[i];
array[i] = t;
}
}逐行解释
void sort(int array[], int n);:返回类型 void——排序结果通过改数组体现,不需要返回值。sort(a, 10);:把数组名 a 和长度 10 一起传进去。传长度是因为函数内部不知道数组有多长。int i, j, k, t;:i 是"当前要填的位置",j 是扫描指针,k 记住"本轮最小值的下标",t 是交换用的临时变量。for (i = 0; i < n - 1; i++):n 个数只需 n-1 轮。k = i;:先假设第 i 个最小。for (j = i + 1; j < n; j++):从 i 后面开始扫。if (array[j] < array[k]) k = j;:发现更小的就更新 k。t = array[k]; array[k] = array[i]; array[i] = t;:三只杯子法交换(第 2 章"交换两瓶醋和酱油"的伏笔回收)。- 没有 return,因为不需要。
运行结果
enter array:
45 2 9 0 -3 54 12 5 66 33
The sorted array:
-3 0 2 5 9 12 33 45 54 66教材的程序分析(本节最重要的一句):
可以看到在
sort(a,10);之前和之后,a 数组中各元素的值是不同的。原来是无序的,执行后 a 数组已经排好序了,这是由于形参数组 array 已用选择法进行排序了,形参数组改变也使实参数组随之改变。
这就是"数组名传地址"的现实意义:函数里排的是 array,排完 main 里的 a 也跟着好了。如果传的是值,a 一点变化都不会有。
例 7.16 静态局部变量(static 只初始化一次)
题目:考察静态局部变量的值。
#include <stdio.h>
int main()
{
int f(int); // 函数声明
int a = 2, i;
for (i = 0; i < 3; i++)
printf("%d\n", f(a)); // 输出 f(a) 的值
return 0;
}
int f(int a)
{
auto int b = 0; // 自动局部变量
static int c = 3; // 静态局部变量
b = b + 1;
c = c + 1;
return(a + b + c);
}逐行解释
auto int b = 0;:b 是自动变量,每次进函数都重新变成 0(auto 可省,写出来是为了对比)。static int c = 3;:c 只在编译时赋一次初值 3,以后每次进来都保留上次的值。b = b + 1;:b 变 1;c = c + 1;:c 加 1。return(a + b + c);:三个数相加返回。
运行结果
7
8
9教材的分析 + 表 7.1:第 1 次调用时 a=2,b 初值 0,c 初值 3,结束时 b=1、c=4、和为 7。由于 c 是静态局部变量,函数调用结束后并不释放,仍保留 c 的值为 4。第 2 次调用时 b 的初值仍为 0,而 c 的初值为 4(上次结束时的值)。
| 第几次调用 | 调用时 b | 调用时 c | 结束时 b | 结束时 c | a+b+c |
|---|---|---|---|---|---|
| 第 1 次 | 0 | 3 | 1 | 4 | 7 |
| 第 2 次 | 0 | 4 | 1 | 5 | 8 |
| 第 3 次 | 0 | 5 | 1 | 6 | 9 |
注:c 是静态局部变量,函数调用结束后并不释放,保留其当前值。
这张表就是"static 只初始化一次"的最好证明:b 每次都从 0 开始,c 每次接着上次的值往上加。
其余例题速览表
| 例题 | 考点 | 关键结论 |
|---|---|---|
| 例 7.3 | 返回值类型转换 | max 定义为 int,return 的 z 是 float 2.6 → 返回 2(按赋值规则转换,不是四舍五入) |
| 例 7.8 | Hanoi 塔(递归王炸) | 终止条件 n==1;三步:hanoi(n-1,one,three,two) → move(one,three) → hanoi(n-1,two,one,three);移动 n 个盘需 2ⁿ−1 步 |
| 例 7.9 | 数组元素作实参 | 打擂台找最大,同时记下下标 n,输出时 n+1(下标从 0 开始) |
| 例 7.11 | 数组名 + 长度作参数 | average(score1,5)、average(score2,10)——同一函数处理不同长度的数组,秘诀是多传一个 int n |
| 例 7.13 | 二维数组作参数 | 形参写 int array[][4],第二维必须与实参一致,求 3×4 矩阵最大值为 34 |
| 例 7.14 | 全局变量带回多个值 | Max、Min 是全局变量,average 里改、main 里读;返回值只带回平均分 |
| 例 7.15 | 同名屏蔽 | 全局 a=3,main 里局部 a=8 → max(a,b) 相当于 max(8,5) = 8 |
| 例 7.17 | static 求阶乘 | static int f = 1; f = f * n; 每次在上次 f 基础上再乘 n,输出 1!~5! = 1,2,6,24,120 |
| 例 7.18 | extern 扩展作用域 | 外部变量定义在 main 之后,main 开头用 extern int A,B,C; 声明才能用 |
| 例 7.19 | extern 跨文件 | file1.c 定义 int A;,file2.c 写 extern A; |
| 例 7.20 | 外部函数 + 删字符 | 4 个文件;i 扫描、j 保留;最后别忘了 str[j] = '\0' |
四、书上习题精讲
本章书末共 18 题(书页 215–216),逐题覆盖。
第 1 题
题目:写两个函数,分别求两个整数的最大公约数和最小公倍数,用主函数调用这两个函数,并输出结果。两个整数由键盘输入。
思路:最大公约数用辗转相除法——a % b 的余数记作 t,然后 a = b, b = t,直到 b == 0,此时的 a 就是答案。最小公倍数 = a * b / 最大公约数。
#include <stdio.h>
int gcd(int a, int b) // 求最大公约数
{
int t;
while (b != 0)
{
t = a % b;
a = b;
b = t;
}
return a;
}
int lcm(int a, int b) // 求最小公倍数
{
return a * b / gcd(a, b);
}
int main()
{
int m, n;
printf("请输入两个正整数:");
scanf("%d %d", &m, &n);
printf("最大公约数 = %d\n", gcd(m, n));
printf("最小公倍数 = %d\n", lcm(m, n));
return 0;
}解析:gcd 定义在 lcm 之前,所以 lcm 里用 gcd 不必再声明(定义在前面就能直接用);这就是嵌套调用。例:输入 12 18 → 6 和 36。
第 2 题
题目:求方程 ax²+bx+c=0 的根,用 3 个函数分别求当 b²−4ac 大于 0、等于 0 和小于 0 时的根并输出结果。从主函数输入 a,b,c 的值。
思路:判别式 disc = b²−4ac。三种情况:大于 0 有两个不等实根;等于 0 有两个相等实根;小于 0 有一对共轭复根(实部 p = −b/(2a),虚部 q = √(−disc)/(2a))。一个函数只能带回一个值,这里要带回两个根,正好用全局变量。
#include <stdio.h>
#include <math.h>
float x1, x2, disc, p, q; // 全局变量,用来带回多个结果
void greater_than_zero(float a, float b)
{
x1 = (-b + sqrt(disc)) / (2 * a);
x2 = (-b - sqrt(disc)) / (2 * a);
}
void equal_to_zero(float a, float b)
{
x1 = x2 = (-b) / (2 * a);
}
void smaller_than_zero(float a, float b)
{
p = -b / (2 * a);
q = sqrt(-disc) / (2 * a);
}
int main()
{
float a, b, c;
printf("请输入 a b c: ");
scanf("%f %f %f", &a, &b, &c);
disc = b * b - 4 * a * c;
printf("b*b-4ac = %f\n", disc);
if (disc > 0)
{
greater_than_zero(a, b);
printf("有两个不等实根: x1=%f, x2=%f\n", x1, x2);
}
else if (disc == 0)
{
equal_to_zero(a, b);
printf("有两个相等实根: x1=x2=%f\n", x1);
}
else
{
smaller_than_zero(a, b);
printf("有两个共轭复根: x1=%f+%fi, x2=%f-%fi\n", p, q, p, q);
}
return 0;
}解析:5 个变量定义在所有函数之外,所以是全局变量,三个函数都能改、main 都能读。disc 在 main 里先算好。sqrt 需要 #include <math.h>。例:a=1,b=−5,c=6 → disc=1 → x1=3, x2=2。
第 3 题
题目:写一个判素数的函数,在主函数输入一个整数,输出是否为素数的信息。
思路:素数 = 大于 1、只能被 1 和自身整除的整数。只需试除到 √n。返回 1 表示素数,0 表示不是。
#include <stdio.h>
#include <math.h>
int prime(int n)
{
int i, k;
if (n <= 1) return 0; // 1 和负数都不是素数
k = (int)sqrt((double)n); // 只试除到 根号 n
for (i = 2; i <= k; i++)
if (n % i == 0) return 0; // 能整除,不是素数
return 1;
}
int main()
{
int n;
printf("请输入一个整数:");
scanf("%d", &n);
if (prime(n))
printf("%d 是素数\n", n);
else
printf("%d 不是素数\n", n);
return 0;
}解析:(int)sqrt((double)n) 是强制类型转换(sqrt 参数和返回值都是 double)。循环里直接 return 0 提前退出,不用 flag 变量。函数名 prime 见名知义,呼应 7.1。
第 4 题
题目:写一个函数,使给定的一个 3×3 的二维整型数组转置,即行列互换。
思路:把 a[i][j] 和 a[j][i] 互换。只遍历上三角(j 从 i+1 开始),否则会换两次、换回原样。
#include <stdio.h>
void convert(int array[3][3])
{
int i, j, t;
for (i = 0; i < 3; i++)
for (j = i + 1; j < 3; j++) // 只走上三角,避免换两次
{
t = array[i][j];
array[i][j] = array[j][i];
array[j][i] = t;
}
}
int main()
{
int a[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
int i, j;
printf("转置前:\n");
for (i = 0; i < 3; i++)
{
for (j = 0; j < 3; j++) printf("%3d", a[i][j]);
printf("\n");
}
convert(a); // 二维数组名作实参
printf("转置后:\n");
for (i = 0; i < 3; i++)
{
for (j = 0; j < 3; j++) printf("%3d", a[i][j]);
printf("\n");
}
return 0;
}解析:返回类型 void——转置结果通过修改实参数组体现。j = i + 1 是关键:若从 0 开始,(0,1) 换一次、(1,0) 又换回来,白忙。%3d 对齐输出。
第 5 题
题目:写一个函数,使输入的一个字符串按反序存放,在主函数中输入和输出字符串。
思路:首尾两两交换,只需走一半。
#include <stdio.h>
#include <string.h>
void reverse(char str[])
{
int i, n = strlen(str);
char t;
for (i = 0; i < n / 2; i++)
{
t = str[i];
str[i] = str[n - 1 - i];
str[n - 1 - i] = t;
}
}
int main()
{
char s[100];
printf("请输入一个字符串:");
scanf("%s", s); // 数组名本身就是地址,不加 &
reverse(s);
printf("反序后:%s\n", s);
return 0;
}解析:strlen 求实际长度(不含 '\0'),需要 <string.h>。i < n / 2 保证只走一半。scanf("%s", s) 里 s 前面不加 &,因为数组名本身代表地址。注意 %s 遇空格就停,只能读一个"单词"。
第 6 题
题目:写一个函数,将两个字符串连接。
思路:先找到 s1 的末尾,然后从那里开始把 s2 的字符一个个抄过去。
#include <stdio.h>
void connect(char s1[], char s2[])
{
int i = 0, j = 0;
while (s1[i] != '\0') i++; // 找到 s1 的末尾
while ((s1[i] = s2[j]) != '\0') // 从末尾开始逐个复制
{
i++;
j++;
}
}
int main()
{
char a[200] = "Hello, ";
char b[] = "world!";
connect(a, b);
printf("%s\n", a);
return 0;
}解析:第二个 while 用赋值表达式当条件:复制过去的值不是 '\0' 就继续,是 '\0' 就结束——而且这个 '\0' 已经被复制过去了,不用另外补。实参数组 a 必须够大(200),C 不检查数组越界。
第 7 题
题目:写一个函数,将一个字符串中的元音字母复制到另一字符串,然后输出。
思路:逐个检查,是元音(a e i o u 及其大写)就复制过去,最后补 '\0'。
#include <stdio.h>
void copy_vowel(char s1[], char s2[])
{
int i = 0, j = 0;
while (s1[i] != '\0')
{
if (s1[i] == 'a' || s1[i] == 'e' || s1[i] == 'i' ||
s1[i] == 'o' || s1[i] == 'u' ||
s1[i] == 'A' || s1[i] == 'E' || s1[i] == 'I' ||
s1[i] == 'O' || s1[i] == 'U')
{
s2[j] = s1[i];
j++;
}
i++;
}
s2[j] = '\0'; // 千万别忘了字符串结束符
}
int main()
{
char a[] = "Hello, C Program!";
char b[100];
copy_vowel(a, b);
printf("原串: %s\n", a);
printf("元音: %s\n", b);
return 0;
}解析:i 是源串指针、j 是目标串指针,两个下标各走各的(与例 7.20 删字符同一套思路)。最后 s2[j] = '\0' 必须写,否则 printf 会一直往后读。
第 8 题
题目:写一个函数,输入一个 4 位数字,要求输出这 4 个数字字符,但每两个数字间空一个空格。如输入 1990,应输出"1 9 9 0"。
思路:千位 n/1000、百位 n/100%10、十位 n/10%10、个位 n%10,每位加 '0' 变成字符。
#include <stdio.h>
void output(int n)
{
printf("%c %c %c %c\n",
n / 1000 + '0',
n / 100 % 10 + '0',
n / 10 % 10 + '0',
n % 10 + '0');
}
int main()
{
int n;
printf("请输入一个 4 位数字:");
scanf("%d", &n);
output(n);
return 0;
}解析:n/100 % 10 取百位——先除以 100 甩掉低两位,再对 10 取余留最后一位。+ '0' 把数字 1 变成字符 '1'('0' 的 ASCII 码是 48)。用 %c 输出。
第 9 题
题目:编写一个函数,由实参传来一个字符串,统计此字符串中字母、数字、空格和其他字符的个数,在主函数中输入字符串以及输出上述的结果。
思路:4 个计数器。一个函数只能 return 一个值,这里要带回 4 个值,所以用全局变量(与第 2 题同一套路)。
#include <stdio.h>
int letter = 0, digit = 0, space = 0, other = 0; // 全局变量计数
void count(char str[])
{
int i;
for (i = 0; str[i] != '\0'; i++)
{
if ((str[i] >= 'a' && str[i] <= 'z') ||
(str[i] >= 'A' && str[i] <= 'Z'))
letter++;
else if (str[i] >= '0' && str[i] <= '9')
digit++;
else if (str[i] == ' ')
space++;
else
other++;
}
}
int main()
{
char s[100];
printf("请输入一行字符:\n");
fgets(s, 100, stdin);
count(s);
printf("字母=%d 数字=%d 空格=%d 其他=%d\n", letter, digit, space, other);
return 0;
}解析:4 个全局变量定义在所有函数之外,count 里改、main 里读。判断字母两边都要写('a'~'z' 和 'A'~'Z')。用 fgets 而不是已被废弃的 gets。
第 10 题
题目:写一个函数,输入一行字符,将此字符串中最长的单词输出。
思路:两个内层循环——先跳过空格,记下单词起点;再走到下一个空格或串尾,算出长度;比当前最长还长就刷新"最长起点"和"最长长度"。
#include <stdio.h>
void longest(char str[])
{
int i = 0, start, len, maxlen = 0, best = 0;
while (1)
{
while (str[i] == ' ') i++; // 跳过空格
if (str[i] == '\0') break; // 到串尾,结束
start = i; // 记下单词起点
while (str[i] != ' ' && str[i] != '\0') i++; // 走到单词末尾
len = i - start;
if (len > maxlen) // 刷新最长记录
{
maxlen = len;
best = start;
}
}
for (i = best; i < best + maxlen; i++)
putchar(str[i]);
putchar('\n');
}
int main()
{
char s[200];
printf("请输入一行字符:\n");
fgets(s, 200, stdin); // 读一行
longest(s);
return 0;
}解析:外层 while (1) 是"死循环 + break"的写法,因为单词个数不确定。best 和 maxlen 是"记分板"。最后用 putchar 一个个输出,只能输出那一段,不能直接打印整串。
第 11 题
题目:写一个函数,用"起泡法"对输入的 10 个字符按由小到大顺序排列。
思路:相邻两个比较,逆序就换。走一趟就把最大的"冒"到最右边。n 个数走 n−1 趟,第 i 趟只需比到 n−1−i。
#include <stdio.h>
void sort(char s[], int n)
{
int i, j;
char t;
for (i = 0; i < n - 1; i++) // 共 n-1 趟
for (j = 0; j < n - 1 - i; j++) // 每趟比较 n-1-i 次
if (s[j] > s[j + 1]) // 逆序就交换
{
t = s[j];
s[j] = s[j + 1];
s[j + 1] = t;
}
}
int main()
{
char a[10] = {'d', 'a', 'f', 'c', 'b', 'e', 'h', 'g', 'j', 'i'};
int i;
sort(a, 10);
for (i = 0; i < 10; i++) printf("%c ", a[i]);
printf("\n");
return 0;
}解析:n - 1 - i 表示每过一趟,后面就多一个已排好的,不用再比。交换三件套不能省。运行结果 a b c d e f g h i j。与例 7.12 的选择法对比:起泡法是"相邻两两比",选择法是"挑最小放前面"。
第 12 题
题目:用牛顿迭代法求根。方程为 ax³+bx²+cx+d=0,系数 a,b,c,d 的值依次为 1,2,3,4,由主函数输入。求 x 在 1 附近的一个实根。求出根后由主函数输出。
思路:牛顿迭代公式 x(n+1) = x(n) − f(x(n))/f'(x(n)),其中 f(x)=ax³+bx²+cx+d,f'(x)=3ax²+2bx+c。从 x₀=1 出发反复迭代,直到 |x(n+1) − x(n)| < 10⁻⁵。
#include <stdio.h>
#include <math.h>
float f(float x, float a, float b, float c, float d)
{
return a * x * x * x + b * x * x + c * x + d;
}
float f1(float x, float a, float b, float c, float d) // 导数
{
return 3 * a * x * x + 2 * b * x + c;
}
float root(float a, float b, float c, float d)
{
float x0 = 1, x1;
do
{
x1 = x0 - f(x0, a, b, c, d) / f1(x0, a, b, c, d);
if (fabs(x1 - x0) < 1e-5) break; // 收敛了
x0 = x1;
} while (1);
return x1;
}
int main()
{
float a, b, c, d;
printf("请输入 a b c d: ");
scanf("%f %f %f %f", &a, &b, &c, &d);
printf("方程的一个实根为 x = %f\n", root(a, b, c, d));
return 0;
}解析:f 和 f1 都是四个参数的函数,把 a,b,c,d 一路传下去(也可做成全局变量)。fabs 求浮点绝对值(abs 是整数的),在 <math.h> 里。1e-5 就是 0.00001。do...while(1) + break 是"至少做一次、条件在中间判断"的写法。
第 13 题
题目:用递归方法求 n 阶勒让德多项式的值,递归公式为
P₀(x) = 1
P₁(x) = x
Pₙ(x) = ((2n−1)·x·Pₙ₋₁(x) − (n−1)·Pₙ₋₂(x)) / n (n ≥ 1)思路:标准的递归模板——两个终止条件(n=0、n=1),一个递归公式,照着公式翻译就行。
#include <stdio.h>
float p(int n, float x)
{
if (n == 0)
return 1.0;
else if (n == 1)
return x;
else
return ((2 * n - 1) * x * p(n - 1, x) - (n - 1) * p(n - 2, x)) / n;
}
int main()
{
int n;
float x;
printf("请输入 n 和 x: ");
scanf("%d %f", &n, &x);
printf("P%d(%f) = %f\n", n, x, p(n, x));
return 0;
}解析:一个函数里两个 return 做终止,第三条 return 是递归。公式里的 (n−1) 别忘了乘 p(n-2,x),很多同学会漏。n 虽是 int,但表达式里有 float,会自动转成浮点除法。例:n=2, x=0.5 → (0.75−1)/2 = −0.125。
第 14 题
题目:输入 10 个学生 5 门课的成绩,分别用函数实现下列功能:① 计算每个学生的平均分;② 计算每门课的平均分;③ 找出所有 50 个分数中最高的分数所对应的学生和课程;④ 计算平均分方差:σ = (1/n)Σxᵢ² − (Σxᵢ/n)²,其中 xᵢ 为某一学生的平均分。
思路:二维数组 score[10][5] 存成绩,两个一维数组分别存"每个学生的平均分"和"每门课的平均分",四个功能各写一个函数。
#include <stdio.h>
#define STUD 10
#define COUR 5
float score[STUD][COUR];
float stu_ave[STUD], course_ave[COUR];
void input()
{
int i, j;
printf("请输入 10 个学生 5 门课的成绩:\n");
for (i = 0; i < STUD; i++)
for (j = 0; j < COUR; j++)
scanf("%f", &score[i][j]);
}
void student_average() // ① 每个学生的平均分
{
int i, j;
float sum;
for (i = 0; i < STUD; i++)
{
sum = 0;
for (j = 0; j < COUR; j++) sum += score[i][j];
stu_ave[i] = sum / COUR;
}
}
void course_average() // ② 每门课的平均分
{
int i, j;
float sum;
for (j = 0; j < COUR; j++)
{
sum = 0;
for (i = 0; i < STUD; i++) sum += score[i][j];
course_ave[j] = sum / STUD;
}
}
void highest() // ③ 最高分及其学生、课程
{
int i, j, si = 0, cj = 0;
float max = score[0][0];
for (i = 0; i < STUD; i++)
for (j = 0; j < COUR; j++)
if (score[i][j] > max)
{
max = score[i][j];
si = i;
cj = j;
}
printf("最高分 %.1f,是第 %d 个学生的第 %d 门课\n", max, si + 1, cj + 1);
}
float variance() // ④ 平均分方差
{
int i;
float sum = 0, sq = 0, mean;
for (i = 0; i < STUD; i++)
{
sum += stu_ave[i];
sq += stu_ave[i] * stu_ave[i];
}
mean = sum / STUD;
return sq / STUD - mean * mean;
}
int main()
{
int i, j;
input();
student_average();
course_average();
highest();
printf("\n各学生平均分:\n");
for (i = 0; i < STUD; i++) printf("学生%d: %.2f\n", i + 1, stu_ave[i]);
printf("\n各课平均分:\n");
for (j = 0; j < COUR; j++) printf("课程%d: %.2f\n", j + 1, course_ave[j]);
printf("\n平均分方差:%.4f\n", variance());
return 0;
}解析:#define 定义符号常量,改个数只改一处。三个数组都是全局变量,因为多个函数要用同一份数据——这是全局变量最正当的用法。① 是水平求和(固定 i 遍历 j),② 是垂直求和(固定 j 遍历 i),循环嵌套顺序不同、结果完全不同,这是本题最大的坑。③ 输出时 +1,因为下标从 0 开始。
第 15 题
题目:写几个函数:① 输入 10 个职工的姓名和职工号;② 按职工号由小到大顺序排序,姓名顺序也随之调整;③ 要求输入一个职工号,用折半查找法找出该职工的姓名,从主函数输入要查找的职工号,输出该职工姓名。
思路:两个数组平行存放:num[i] 存职工号、name[i] 存姓名。排序时换职工号必须同步换姓名。折半查找要求数组已有序,所以必须先排序再查。
#include <stdio.h>
#include <string.h>
#define N 10
int num[N];
char name[N][20];
void input()
{
int i;
printf("请输入 %d 个职工的职工号和姓名:\n", N);
for (i = 0; i < N; i++)
scanf("%d %s", &num[i], name[i]);
}
void sort() // ② 按职工号升序,姓名同步
{
int i, j, t;
char temp[20];
for (i = 0; i < N - 1; i++)
for (j = 0; j < N - 1 - i; j++)
if (num[j] > num[j + 1])
{
t = num[j]; num[j] = num[j + 1]; num[j + 1] = t;
strcpy(temp, name[j]);
strcpy(name[j], name[j + 1]);
strcpy(name[j + 1], temp);
}
}
void search(int key) // ③ 折半查找
{
int low = 0, high = N - 1, mid, found = -1;
while (low <= high)
{
mid = (low + high) / 2;
if (num[mid] == key) { found = mid; break; }
else if (num[mid] < key) low = mid + 1;
else high = mid - 1;
}
if (found >= 0)
printf("职工号 %d 的姓名是:%s\n", key, name[found]);
else
printf("查无此人:%d\n", key);
}
int main()
{
int key;
input();
sort();
printf("请输入要查找的职工号:");
scanf("%d", &key);
search(key);
return 0;
}解析:name[N][20] 是字符二维数组,name[i] 就是第 i 个姓名字符串。字符串赋值不能用 =,必须用 strcpy。排序时三对都要换。折半查找的前提是已排序——所以 main 里 input → sort → search 的顺序不能变。10 个元素最多查 4 次。
第 16 题
题目:写一个函数,输入一个十六进制数,输出相应的十进制数。
思路:从左到右扫字符串,每读一位就 n = n*16 + 这一位的值。'0'~'9' 的值是 c-'0','a'~'f' 是 c-'a'+10,'A'~'F' 是 c-'A'+10。
#include <stdio.h>
int htoi(char s[])
{
int i = 0, n = 0;
while (s[i] != '\0')
{
if (s[i] >= '0' && s[i] <= '9') n = n * 16 + (s[i] - '0');
else if (s[i] >= 'a' && s[i] <= 'f') n = n * 16 + (s[i] - 'a' + 10);
else if (s[i] >= 'A' && s[i] <= 'F') n = n * 16 + (s[i] - 'A' + 10);
else break; // 遇到非法字符就停
i++;
}
return n;
}
int main()
{
char s[20];
printf("请输入一个十六进制数:");
scanf("%s", s);
printf("对应的十进制数是:%d\n", htoi(s));
return 0;
}解析:n = n*16 + ... 是进制转换的通用套路——几进制就乘几。三种情况分别处理数字、小写、大写字母。例:输入 1A3 → 1 → 1×16+10=26 → 26×16+3=419。
第 17 题
题目:用递归法将一个整数 n 转换成字符串。例如,输入 483,应输出字符串"483"。n 的位数不确定,可以是任意位数的整数。
思路:递归的经典用法——要输出 483,先输出 48,再输出 3;要输出 48,先输出 4,再输出 8。也就是"先处理前面的大半截,再输出最后一位"。
#include <stdio.h>
void convert(int n)
{
int i;
if ((i = n / 10) != 0) // 还有更高的位
convert(i); // 递归处理前面那一截
putchar(n % 10 + '0'); // 输出最后一位
}
int main()
{
int n;
printf("请输入一个整数:");
scanf("%d", &n);
if (n < 0) // 处理负数
{
putchar('-');
n = -n;
}
convert(n);
putchar('\n');
return 0;
}解析:(i = n / 10) != 0 把"赋值"和"判断"合成一句。递归在前、输出在后,这是"倒着输出"的关键——如果把两句换位置(先 putchar 再递归),输出就变成 384 了。负数在 main 里单独处理。
第 18 题
题目:给出年、月、日,计算该日是该年的第几天。
思路:先把前 month−1 个月的天数加起来,再加上 day。闰年且月份大于 2 时多加 1 天。闰年判断:(year%4==0 && year%100!=0) || year%400==0。
#include <stdio.h>
int day_of_year(int year, int month, int day)
{
int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int i, sum = day;
for (i = 1; i < month; i++)
sum += days[i];
if (month > 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))
sum += 1; // 闰年二月多一天
return sum;
}
int main()
{
int y, m, d;
printf("请输入年 月 日:");
scanf("%d %d %d", &y, &m, &d);
printf("%d 年 %d 月 %d 日是这一年的第 %d 天\n",
y, m, d, day_of_year(y, m, d));
return 0;
}解析:days[13] 故意开到 13 个元素,days[0] 空着不用,这样下标和月份一一对应,可读性最好。month > 2 不能少:1 月和 2 月不受闰年影响。闰年三个条件缺一不可。例:2024 年 3 月 1 日 → 31+29+1 = 61;2023 年 12 月 31 日 → 365。
习题覆盖核对表
| 题号 | 类型 | 考的知识点 | 覆盖 |
|---|---|---|---|
| 1 | 编程 | 函数定义、嵌套调用、辗转相除 | ✅ 完整代码 |
| 2 | 编程 | 3 个函数 + 全局变量带回多个值 | ✅ 完整代码 |
| 3 | 编程 | 函数返回 1/0 作判断 | ✅ 完整代码 |
| 4 | 编程 | 二维数组名作参数(void 返回) | ✅ 完整代码 |
| 5 | 编程 | 一维数组名作参数、字符串 | ✅ 完整代码 |
| 6 | 编程 | 数组名作参数 + 字符串结束符 | ✅ 完整代码 |
| 7 | 编程 | 双下标扫描 | ✅ 完整代码 |
| 8 | 编程 | 数值拆位 + 字符转换 | ✅ 完整代码 |
| 9 | 编程 | 全局变量计数 | ✅ 完整代码 |
| 10 | 编程 | 字符串扫描 + 记分板 | ✅ 完整代码 |
| 11 | 编程 | 起泡排序 + 数组名传址 | ✅ 完整代码 |
| 12 | 编程 | 牛顿迭代 + 多参数传递 | ✅ 完整代码 |
| 13 | 编程 | 递归(双终止条件) | ✅ 完整代码 |
| 14 | 编程 | 二维数组 + 4 个功能函数 | ✅ 完整代码 |
| 15 | 编程 | 平行数组 + 排序 + 折半查找 | ✅ 完整代码 |
| 16 | 编程 | 进制转换 | ✅ 完整代码 |
| 17 | 编程 | 递归(递归在前、输出在后) | ✅ 完整代码 |
| 18 | 编程 | 数组 + 闰年判断 | ✅ 完整代码 |
🛠 随课练习(只给题不给答案)
🔮 猜输出 1
#include <stdio.h>
void swap(int x, int y)
{
int t;
t = x;
x = y;
y = t;
}
int main()
{
int a = 3, b = 8;
swap(a, b);
printf("%d %d\n", a, b);
return 0;
}问:输出什么?为什么 swap 明明换了,a 和 b 却没变?
🔮 猜输出 2
#include <stdio.h>
int f(int a)
{
int b = 0;
static int c = 3;
b = b + 1;
c = c + 1;
return(a + b + c);
}
int main()
{
int a = 2, i;
for (i = 0; i < 3; i++)
printf("%d ", f(a));
return 0;
}问:输出什么?把 static 去掉又会输出什么?
🔮 猜输出 3
#include <stdio.h>
void change(int a[], int n)
{
int i;
for (i = 0; i < n; i++)
a[i] = a[i] + 1;
}
int main()
{
int b[5] = {1, 2, 3, 4, 5};
int i;
change(b, 5);
for (i = 0; i < 5; i++)
printf("%d ", b[i]);
return 0;
}问:输出什么?如果把 change(b, 5) 改成 change(b[0], 5) 会怎样?
🔮 猜输出 4
#include <stdio.h>
int f(int n)
{
if (n == 1)
return 1;
else
return n * f(n - 1);
}
int main()
{
printf("%d\n", f(4) + f(2));
return 0;
}问:输出什么?f 一共被调用了几次?
🛠 变式题 1(照猫画虎)
仿照例 7.2 的 max,写一个函数 int min(int a, int b) 求两数中较小者。要求:① 写出完整的函数定义;② 在 main 中写出正确的函数声明;③ 在 main 中调用它并输出结果。
🛠 变式题 2(改需求)
例 7.10 的 average 只能处理 10 个元素。请改造成 float average(float array[], int n),让同一个函数能求任意长度数组的平均值。改造完后,用同一个函数分别求 {98.5, 97, 91.5, 60, 55}(5 个)和 {67.5, 89.5, 99, 69.5, 77, 89.5, 76.5, 54, 60, 99.5}(10 个)的平均分。
🛠 变式题 3(改方向)
例 7.12 的选择法排序是"由小到大"。请改成由大到小。只改一个比较符号就够了,请指出改哪一处、为什么。
🛠 变式题 4(补全)
下面是用递归求 1 + 2 + ... + n 的函数框架,请补全两个空:
int sum(int n)
{
if (____________) return 1; // 终止条件
return ____________; // 递归公式
}🐛 改错题 1
// ❌ 错误示例(故意)
#include <stdio.h>
int max(int x, int y); // ①
{
int z;
z = x > y ? x : y;
return(z);
}
int main()
{
int a = 3, b = 5;
printf("%d\n", max(a, b));
return 0;
}问:哪里错了?怎么改?
🐛 改错题 2
#include <stdio.h>
void swap(int x, int y)
{
int t;
t = x;
x = y;
y = t;
}
int main()
{
int a = 3, b = 8;
swap(a, b);
printf("a=%d, b=%d\n", a, b);
return 0;
}问:程序能编译通过,但输出是 a=3, b=8,为什么?在不学指针的前提下,有没有办法让 swap 真正起作用?(提示:想想第 2 题的全局变量。)
🐛 改错题 3
#include <stdio.h>
int fac(int n)
{
return n * fac(n - 1);
}
int main()
{
printf("%d\n", fac(5));
return 0;
}问:这个程序运行会发生什么?为什么?怎么改?
📈 加练题(你随时喊,我随时追加到这里)
(暂无。想加练就说"再出几道 XX 题",我出的题会标上日期追加在这块。)
💬 问答区(你问过的问题 + 我的解答)
(暂无。你问的问题和我的解答会按 Q/A 整理进这里。)
该带走的 6 句话
- 函数就是功能——把一段"干一件事"的代码打包、起名、按名字调用;
function这个词本身就是"功能"。 - 定义是造机器(首部不带分号),声明是贴说明书(首部带分号),调用是按按钮(语句要分号,表达式不要)。
- 函数可以嵌套调用,绝不能嵌套定义;C 程序从 main 开始、在 main 结束,main 不能被其他函数调用。
- 值传递是单向的:实参 → 形参,形参怎么改都改不了实参(两个不同的存储单元)。
- 数组名作实参传的是首元素地址,形参数组和实参数组共占同一段内存,改形参就是改实参;二维数组作形参,第二维不能省。
- 递归必须有两样东西:递归公式 + 终止条件;static 局部变量只初始化一次,离开函数值还在,但别的函数用不了。
📚 教材覆盖清单(第7章,用函数实现模块化程序设计)
这张表把本课和教材逐节对上:从网页可以回查书页,从书页也能找到网页位置。标「未展开」的,就是教材里有、本课没讲的部分,已明确列出。
| 教材节号 | 标题 | 书页 | 网站对应 | 状态 |
|---|---|---|---|---|
| 7.1 | 为什么要用函数 | p.167–168 | 7.1 为什么要用函数 | 精讲 |
| 7.2 | 怎样定义函数 | p.169–170 | 7.2 怎样定义函数 | 精讲 |
| 7.2.1 | 为什么要定义函数 | p.169 | 7.2.1 为什么要定义函数 | 精讲 |
| 7.2.2 | 定义函数的方法 | p.170 | 7.2.2 定义函数的三种写法 | 精讲 |
| 7.3 | 调用函数 | p.171–175 | 7.3 调用函数 | 精讲 |
| 7.3.1 | 函数调用的形式 | p.171 | 7.3.1 函数调用的形式 | 精讲 |
| 7.3.2 | 函数调用时的数据传递 | p.172–173 | 7.3.2 函数调用时的数据传递 | 精讲 |
| 7.3.3 | 函数调用的过程 | p.174 | 7.3.3 函数调用的过程【考点·5 步】 | 精讲 |
| 7.3.4 | 函数的返回值 | p.174–175 | 7.3.4 函数的返回值 | 精讲 |
| 7.4 | 对被调用函数的声明和函数原型 | p.176–178 | 7.4 对被调用函数的声明和函数原型 | 精讲 |
| 7.5 | 函数的嵌套调用 | p.179–180 | 7.5 函数的嵌套调用 | 精讲 |
| 7.6 | 函数的递归调用 | p.181–188 | 7.6 函数的递归调用 | 精讲 |
| 7.7 | 数组作为函数参数 | p.189–195 | 7.7 数组作为函数参数 | 精讲 |
| 7.7.1 | 数组元素作函数实参 | p.189–190 | 7.7.1 数组元素作函数实参 | 精讲 |
| 7.7.2 | 一维数组名作函数参数 | p.191–193 | 7.7.2 一维数组名作函数参数【考点·本章第二大重点】 | 精讲 |
| 7.7.3 | 多维数组名作函数参数 | p.194–195 | 7.7.3 多维数组名作函数参数 | 精讲 |
| 7.8 | 局部变量和全局变量 | p.196–200 | 7.8 局部变量和全局变量 | 精讲 |
| 7.8.1 | 局部变量 | p.196 | 7.8.1 局部变量 | 精讲 |
| 7.8.2 | 全局变量 | p.197–200 | 7.8.2 全局变量 | 精讲 |
| 7.9 | 变量的存储方式和生存期 | p.201–210 | 7.9 变量的存储方式和生存期选学 | 略讲 |
| 7.9.1 | 动态存储方式与静态存储方式 | p.201 | 7.9.1 动态存储方式与静态存储方式 | 略讲 |
| 7.9.2 | 局部变量的存储类别 | p.202–204 | 7.9.2 局部变量的存储类别 | 略讲 |
| 7.9.3 | 全局变量的存储类别 | p.205–208 | 7.9.3 全局变量的存储类别 | 略讲 |
| 7.9.4 | 存储类别小结 | p.209–210 | 7.9.4 存储类别小结 | 略讲 |
| 7.10 | 关于变量的声明和定义 | p.211 | 7.10 关于变量的声明和定义 | 精讲 |
| 7.11 | 内部函数和外部函数 | p.212–214 | 7.11 内部函数和外部函数选学 | 略讲 |
| 7.11.1 | 内部函数 | p.212 | 7.11.1 内部函数 | 略讲 |
| 7.11.2 | 外部函数 | p.213–214 | 7.11.2 外部函数 | 略讲 |
| 习题 | 第7章书末习题 | p.215–216 | 本课「书上习题精讲」 | 逐题精讲(第1–18题) |
| 插图 | 本课范围内教材插图 | p.167–214 | 用文字/ASCII 重画 | 未逐张复刻 |
| 附录 | 附录 A–E | p.357–370 | 全书对照页 | 未单独成课 |
- 教材插图(流程图、内存示意图等)本课用文字/ASCII 重画,未逐张复刻,页码见上表。
- 附录 A–E(p.357–370)是速查表,未单独成课;全站对照页列出。
- 标「本课补充」的小节(先补地基、比喻、随课练习等)不是教材原文,是为理解而加的。