第五章 · 函数¶
本页最后更新于 ,内容可能过时
《C语言程序设计(第3版)》第 5 章课后习题,共 16 题。
1 · 星号空心三角形¶
编写一个函数,已知参数 n,在屏幕的中间显示一个用星号字符绘制的空心的三角形图案。
例如,n=4,函数显示的图案为:
答案
#include <stdio.h>
/* 显示 n 行空心三角形;每行前面先输出 start 个空格,用于在屏幕中间显示 */
void triangle(int n, int start)
{
int i, j;
for (i = 1; i <= n; i++) {
for (j = 0; j < start; j++)
putchar(' ');
if (i == 1 || i == n) {
for (j = 0; j < i; j++) {
putchar('*');
if (j < i - 1)
printf(" ");
}
} else {
putchar('*');
for (j = 0; j < 2 * (i - 1) - 1; j++)
putchar(' ');
putchar('*');
}
putchar('\n');
}
}
int main(void)
{
triangle(4, 0);
return 0;
}
n = 4、start = 0 时的输出:
解析
三角形的每一行都由"若干空格 + 若干星号"拼成。样张里两个星号之间固定隔 1 个空格,所以每一列折合 2 个字符宽(一个 * 加一个空格);底边有 n 个星号。
关键点:
- 中间行的两个星号落在第 0 列和第
2 * (i - 1)列上,之间要空2 * (i - 1) - 1格。i = 2空 1 格得* *,i = 3空 3 格得* *。 - 第 1 行和底边是整行星号,其余行是空心的,判据是
i == 1 || i == n。底边星号的列号是0, 2, 4, …,正好是"打一个*再打一个空格"重复n次,所以照这个循环写就行。 - 题干要求"在屏幕的中间显示",每行前面要留一段空白。按 80 列宽算,底边宽
2 * n - 1(n = 4时是 7),左边留白(80 - 7) / 2 = 36格。函数把留白数当形参start传进来,图案本身和它的位置就分开了。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 4 行,与题干样张逐行一致;把调用换成
start = 36再跑一次,4 行都是第 37 列才出现星号,即每行前面 36 个空格。
样张画的是图案本身
题干样张第一行的 * 顶在最左边,没有画居中留白。要落在屏幕中间就按上面第 3 条算留白;不需要居中时把 start 传 0 即可。
易错:
- 星号之间多打了空格(写成 2 个以上),画出来比样张宽
- 中间行的空格数写成
2 * (i - 1),多打一格,右边的星号偏出去- 漏掉整行都是星号的底边,或者把第一行也当成空心行
2 · 随机出题的乘法测验¶
为低年级小学生编写一个两个整数乘法的测验程序。程序利用随机函数产生两个整数,并给出算式请小学生输入解答。程序对正确的解答给予鼓励;对不正确的解答给出正确的答案。
另外,为了让程序更有实用性,输入整数的范围也可由用户指定,如一位数乘法、两位数乘法等。使用随机函数的程序有以下要求:
(1)在程序前面包含以下代码:
(2)主函数先用以下代码为随机函数初始化:
(3)用以下代码随机取 1~9 的整数 k:
为了提高小学生的学习兴趣,程序的回答也希望有所改变,如对正确的回答可以有多种选择,如“Very good!”、“Excellent!”、“Keep up the good work!” 等。
同样,对于错误的响应也可以有多种选择,如“No. Please try again.”、“Wrong. Try once more.”、“No. Keep trying.” 等。
利用随机函数选择一种回答。
答案
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
/* 随机挑一句鼓励的话 */
void good(void)
{
switch (1 + rand() % 3) {
case 1: printf("Very good!\n"); break;
case 2: printf("Excellent!\n"); break;
default: printf("Keep up the good work!\n"); break;
}
}
/* 随机挑一句"再试一次"的话,并把正确答案告诉学生 */
void bad(int right)
{
switch (1 + rand() % 3) {
case 1: printf("No. Please try again.\n"); break;
case 2: printf("Wrong. Try once more.\n"); break;
default: printf("No. Keep trying.\n"); break;
}
printf("正确的答案是 %d。\n", right);
}
int main(void)
{
int i, u, v, ans, max;
srand(time(NULL));
printf("请输入乘数上限(如 9 表示一位数乘法、99 表示两位数乘法):");
if (scanf("%d", &max) != 1 || max < 1)
return 0;
for (i = 0; i < 3; i++) {
u = 1 + rand() % max;
v = 1 + rand() % max;
printf("%d * %d = ? ", u, v);
if (scanf("%d", &ans) != 1)
return 0;
if (ans == u * v)
good();
else
bad(u * v);
}
return 0;
}
解析
题目给的三段代码分别解决三件事:取随机数要包含的头文件、给随机数发生器播种、在一段范围里取一个整数。程序把这三件事串起来,就是"出题 → 收答案 → 给回答"的循环。
关键点:
srand(time(NULL));是播种。不播种的话rand()每次都从同一串数开始,出的题每回一样,测验就没有意义了。time(NULL)取当前时间(秒),每次运行都不同,所以种子也就不同。- 在
1 ~ max里取随机整数写成1 + rand() % max。rand() % max落在0 ~ max - 1,加 1 之后就是1 ~ max。上限由学生输入:输 9 出一道一位数乘法,输 99 出一道两位数乘法。题目里给的k = 1 + rand() % 9就是这个写法在max = 9时的样子。 - 回答也用随机数挑:
1 + rand() % 3得到 1、2、3,对应switch的三个分支。鼓励的话和"再试一次"的话各挑各的,两边都随机。 - 答错时要把正确答案打出来,所以
bad()带一个形参right。这里正好用上"实参把值传给形参"这件事。 scanf的返回值是成功读到的项数,判一下可以挡住空输入和非法输入。
把 srand(time(NULL)) 换成固定种子,题目和回答就都能复现:三个算式依次是 2 * 1、7 * 2、3 * 3;第 1、3 题答对,走的是鼓励分支(这次挑到的是 Excellent!),第 2 题答 12 与正确答案 14 不符,走的是"给出正确答案"分支(这次挑到的是 No. Keep trying.)。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在
-fsyntax-only下通过、0 条警告;把种子固定成srand(2026)后喂入9 2 12 9,输出依次是2 * 1 = ? Excellent!、7 * 2 = ? No. Keep trying.加正确的答案是 14。、3 * 3 = ? Excellent!。
一位数、两位数的范围怎么写
上限是"乘数的最大值",不是位数。一位数乘法传 9(得 1 ~ 9),两位数乘法传 99(得 1 ~ 99)。
这个范围内也会出现一位数,属于正常现象;非要保证两位数,就把两个乘数换成 10 + rand() % 90。
易错:
- 忘了
srand(),每次运行都出同一套题- 取范围时漏掉
+ 1,rand() % max会取到 0,出现0 * 5 = ?这样的算式- 回答只在"对"或"错"里各写死一句话,没有随机挑选
- 答错时只打"错了",没有把正确答案告诉学生
3 · 猜数游戏¶
编写一个玩猜数游戏的程序。程序随机产生某个范围内的正整数,让人猜这个整数。
当猜中时输出鼓励信息;当未猜中时给出猜得太小或太大的提示信息。
答案
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void)
{
int secret, guess;
srand(time(NULL));
secret = 1 + rand() % 100;
printf("我已经想好了一个 1 到 100 之间的正整数,请你猜:\n");
for (;;) {
printf("请输入你的猜测:");
if (scanf("%d", &guess) != 1)
break;
if (guess == secret) {
printf("猜对了,真棒!\n");
break;
} else if (guess < secret) {
printf("太小了,再试一次。\n");
} else {
printf("太大了,再试一次。\n");
}
}
return 0;
}
解析
程序分成两半:先随机定一个答案,再反复读猜测、按大小给提示,直到猜中为止。
关键点:
- 答案用
1 + rand() % 100取,范围是 1~100;同题 2 一样,先用srand(time(NULL))播种。 - 循环写成
for (;;),出口有两个:猜中时break,输入读不到数时也break(否则scanf一直失败会变成死循环)。 - 三种情况互斥,用
if / else if / else串起来最清楚:相等就是猜中,小于答案提示"太小",剩下的必然是太大。 - 提示信息要说清方向,学生才知道下一次往哪边猜。
把随机答案固定成 42 再跑,输入依次是 50、30、42:第一次提示"太大了,再试一次。",第二次提示"太小了,再试一次。",第三次输出"猜对了,真棒!"并结束。
中途那两条提示正好把答案夹在 30 到 50 之间。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在
-fsyntax-only下通过、0 条警告;把secret = 1 + rand() % 100;换成int secret = 42;、喂入50 30 42后运行,三行提示依次是"太大了,再试一次。""太小了,再试一次。""猜对了,真棒!"。易错:
- 用
=代替==比较(guess == secret才是比较)- 只写"猜错了",不说太大还是太小
- 循环没有出口,猜中之后还在继续要输入
- 忘了
srand(),每次运行都是同一个答案
4 · 两个递归程序的功能¶
读程序,指出以下程序的功能。
程序 1:
#include<stdio.h>
void main()
{ int c;
if((c=getchar())!='\n')
{ main(); printf("%c",c);
} else printf("\n");
}
程序 2:
#include<stdio.h>
int mul(int,int);
void main()
{ int u,v,w;
printf("Enter two integers!\n"); scanf("%d%d",&u,&v);
if(v>=0) w=mul(u,v);
else w=-mul(u,-v);
printf("The result is %d\n",w);
}
int mul(int a,int b)
{ if(a==0||b==0) return 0;
if(b==1) return a;
return a+mul(a,b-1);
}
答案
程序 1 的功能:把从键盘输入的一行字符按相反的顺序输出。读到换行符为止,换行符本身不打印,而是在最内层先打一个换行;也就是说,这一行字符被"倒着"打了一遍。
这里是用 main 的递归调用代替了循环。
程序 2 的功能:读入两个整数 u 和 v,用递归函数 mul() 算出它们的乘积并输出。mul() 没有用 *,而是把乘法拆成加法:mul(a, b) 等于 b 个 a 相加。
v 为负时先取绝对值算,再补上负号。
解析
两个程序都是"递归代替循环"的例子,而递归的位置决定了输出顺序。
程序 1 的执行过程(输入 abc 加回车):
- 第一次调用
getchar()读到a,不是换行,于是先把递归调用main()做掉,而把printf("%c", c)留到后面再说。 - 第二、三次调用读到
b、c,同样先往下递归。 - 第四次调用读到换行符,走
else分支打一个换行,然后返回。 - 返回的路上才轮到前面几次调用留下的
printf,它们按"后进先出"的顺序打印,于是打出的字符依次是c、b、a。
所以输出是"先一个换行、再 cba",末尾没有再打换行。输入别的内容时读法一样,效果就是整行逆序。
这也解释了为什么这个程序必须靠递归、不能改成顺序语句:要是把 printf 挪到递归调用前面,打出来的就是原顺序了。
程序 2 的核心是 mul() 的递归定义:
mul(a, 0)和mul(0, b)返回 0;mul(a, 1)返回a;- 其余情况返回
a + mul(a, b - 1),也就是把a一个一个加上去,加了b次。
取 u = 5, v = 3 时,mul(5, 3) = 5 + mul(5, 2) = 5 + 5 + mul(5, 1) = 5 + 5 + 5 = 15。main() 里那句 if (v >= 0) … else w = -mul(u, -v); 是在处理负乘数:v 为负时先用 -v 当正数算,最后再取负号,所以 -5 乘 3 与 5 乘 -3 都得到 -15。
两个程序按题干的写法都能编过,问题出在 void main() 上:gcc 15.2.0(-O2 -std=gnu23 -Wall)会给出警告。把两个程序分别存成 q4p1_orig.c、q4p2_orig.c 送编译,警告原文是:
q4p2_orig.c:3:6: warning: return type of ‘main’ is not ‘int’ [-Wmain]
q4p2_orig.c:5:38: warning: ignoring return value of ‘scanf’ declared with attribute ‘warn_unused_result’ [-Wunused-result]
核实输出时只做了两处最小修改,其余一字未动:
- 程序 1 的
void main()改成int main(void) - 程序 2 的
void main()改成int main(void)
程序 2 里那句 scanf 的返回值仍然没被使用,所以修改版还是会带一条 -Wunused-result 警告,这是题干写法本身带的,没有动它。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):最小修改后的程序 1 喂入
abc加回车,输出的 4 个字节是"换行、c、b、a",末尾不带换行;程序 2 喂入5 3输出The result is 15,喂入-5 3输出The result is -15,喂入7 0输出The result is 0。
void main() 是教材年代的写法
标准里 main 的返回类型只能是 int,gcc 在 -Wmain 下警告。2013 年的教材里 void main() 很常见,照题干读程序没问题,但自己写程序要写 int main(void)。
另外,void main 没有返回值,进程的退出状态是寄存器里的残留值(这次跑程序 1 得到的是 97),改成 int main 之后就是规范里的 0。
程序 2 的 mul() 只对非负的第二个实参收敛
mul(a, b) 每递归一次 b 减 1,直到 b 等于 1 或 0 才停。要是直接拿一个负的 b 进来,b 只会越减越小,递归永远不结束。main() 里先判 v >= 0、负数走 -mul(u, -v),正是为了不让负乘数进到 mul() 里。
易错:
- 把程序 1 的功能说成"输入一行再输出一行"(它输出的是逆序)
- 以为程序 1 的输出末尾有换行(换行在最前面,最后打印的是第一个字符)
- 把
mul()读成"用乘法求积",没看出它是靠加法递归实现的- 觉得
-mul(u, -v)里的两个负号多余
5 · 时分秒与当天秒数¶
编写一个函数,用时间时、分和秒作为函数的 3 个形参,函数返回自 0 点钟到指定时间的秒数,并用这个函数计算同一天两个时间之间的秒数。
答案
#include <stdio.h>
/* 返回从 0 点钟到 h 时 m 分 s 秒的秒数 */
int seconds(int h, int m, int s)
{
return h * 3600 + m * 60 + s;
}
/* 同一天两个时间之间的秒数 */
int diff(int h1, int m1, int s1, int h2, int m2, int s2)
{
return seconds(h2, m2, s2) - seconds(h1, m1, s1);
}
int main(void)
{
printf("%d\n", seconds(0, 0, 1)); /* 0 点过 1 秒 */
printf("%d\n", seconds(12, 0, 0)); /* 中午 12 点 */
printf("%d\n", seconds(23, 59, 59)); /* 一天里的最后一秒 */
printf("%d\n", diff(8, 30, 0, 12, 45, 30)); /* 8:30:00 到 12:45:30 */
printf("%d\n", diff(12, 45, 30, 8, 30, 0)); /* 反过来减 */
return 0;
}
解析
思路:先把"时、分、秒"按 1 小时 3600 秒、1 分钟 60 秒折成秒数相加,这就是本题要写的第一个函数;两个时间的差直接调用它两次相减,不必重新写一遍换算。
关键点:
h * 3600 + m * 60 + s是换算的全部内容。三个形参的类型都是int,返回类型也是int——最大的秒数是一天的 86 399,不会溢出。- 第二个函数
diff()把 6 个形参分成两组,各调用一次seconds()再相减。题干说"用这个函数计算两个时间之间的秒数",指的就是这一层复用。 - 相减的顺序决定正负:前一个时间在后一个之前,差是正数;反过来减就是负数。题目没有限定先后,两种都算对,写清哪个减哪个就行。
- 形参的顺序按"时、分、秒"排,调用时别把分和秒的位置写反(
diff(8, 30, 0, 12, 45, 30)里 30 是分、45 是分)。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 5 行,依次是
1、43200、86399、15330、-15330。其中15330秒就是 4 小时 15 分 30 秒,正是 8:30:00 到 12:45:30 的间隔。
为什么 12 点是 43200 秒
12 × 3600 = 43200,正好是 86400 的一半;23:59:59 是 86399,即一天的最后一秒。这两个值可以拿来核对换算有没有写错。
易错:
h * 3600写成h * 60,把小时和分钟按同一个数折算了- 求差时只算秒数不换算,比如拿 30 减 0 当结果(正确做法是两个时间各自折成秒数再减)
- 形参顺序写乱,把
diff(8, 30, 0, …)里的分和秒对调,算出来的差就不是 15330 秒了
6 · 完全数的判断与列举¶
如果一个整数(整数>1)的各因子(包括 1,但不包括整数自身)之和等于该整数,称这样的整数为完全数。
例如,因为 6=1+2+3,所以 6 是完全数。
编写一个已知整数判断其是否是完全数的函数,并用该函数输出 1 000 之内的所有完全数。
答案
解析
思路:判断一个数是不是完全数,只能把它所有真因子加起来跟它自己比。所以先写一个"求和再比较"的函数,主函数只负责在 2 到 1000 之间挨个调用它。
关键点:
- 因子从 1 开始试,所以循环变量初值是 1——
1也是因子,漏掉它就把 6 也判掉了。 - 循环上界取
n / 2:一个整数的真因子不可能超过它的一半(n自己不算)。试到n / 2就够,写i < n多跑一倍并且要额外把n自己排除掉。 - 判据是
sum == n而不是sum >= n。函数的返回类型写成int,用 0 和 1 表示真假即可。 - 主函数从 2 开始(题干说明整数大于 1),一个数一个数地过。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序在 1 000 之内筛出三个完全数,依次是
6、28、496。手工核对:28 的真因子 1、2、4、7、14 相加得 28;496 的真因子相加也正好得 496。
1000 之内的完全数只有三个
6、28、496 之后的下一个是 8 128,已经超过 1 000。所以本题的输出就是这三个数,多一个少一个都说明筛法有问题。
易错:
- 循环从
i = 2开始,漏掉因子 1(6 会被判成不是完全数)- 循环写成
i <= n,把n自己也算进因子和里,sum至少是n + 1,结果一个完全数都筛不出来- 判据用
sum > n或sum >= n- 只试到
sqrt(n)(那样会漏掉大于平方根的那些因子)
7 · 质数的判断与列举¶
编写判断已知正整数是否是质数的函数,并利用该函数输出 1 000 之内的所有质数。
答案
#include <stdio.h>
/* 判断 n 是否是质数:小于 2 不是;否则试除到 sqrt(n) */
int isPrime(int n)
{
int i;
if (n < 2)
return 0;
for (i = 2; i * i <= n; i++)
if (n % i == 0)
return 0;
return 1;
}
int main(void)
{
int n, k = 0;
for (n = 2; n <= 1000; n++)
if (isPrime(n)) {
printf("%4d", n);
if (++k % 10 == 0)
printf("\n");
}
if (k % 10 != 0)
printf("\n");
printf("共 %d 个\n", k);
return 0;
}
解析
思路:质数是"除了 1 和它本身,没有别的因子"的正整数。判断的办法是拿 2、3、4……挨个去除,只要除得尽就不是质数;除到平方根就够了,再往后的因子一定和前面某个因子配对。
关键点:
- 先挡掉
n < 2的情况:1 不是质数,0 和负数也不是。这一步漏了,1 会被当成质数打出来。 - 循环条件写
i * i <= n,等价于"试除到sqrt(n)",但不用#include <math.h>,也不会有浮点误差。写成i <= n / 2结果一样,只是多跑很多次;写成i < n也行,慢一些。 - 主函数边筛边排版:每打满 10 个数换一行(
%4d让每列对齐)。最后那个if (k % 10 != 0)是给不满一行的末尾补一个换行。 - 顺带把个数统计出来,便于核对。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案程序输出 17 行、每行 10 个数(末行 8 个),共
168个质数,从2起、到997止。
平方根上界为什么够
如果 n = a * b 且 a <= b,那么 a <= sqrt(n)。也就是说,只要 n 有真因子,其中必有一个不超过 sqrt(n),试到 sqrt(n) 就能把它抓住;一个都没抓到,n 就是质数。
易错:
- 忘了挡 1,把 1 打进质数表
- 循环条件写成
i <= n,n % n == 0恒成立,每个数都会被判成合数,连 2 都筛不出来- 用
#include <math.h>的sqrt()又忘了-lm,链接不过- 输出时忘了换行处理,168 个数全挤在一行
8 · 递归求最大公约数¶
试按下面的定义编写求两整数最大公约数的递归函数 gcd():
要求函数 gcd() 能输出形参值 a 和 b,以便能观察函数 gcd() 的递归计算过程。
答案
#include <stdio.h>
/* 按 gcd(a,b) = a(a=b)、gcd(a-b,b)(a>b)、gcd(a,b-a)(a<b)的递归定义求最大公约数 */
int gcd(int a, int b)
{
printf("gcd(%d, %d)\n", a, b); /* 每次进入函数先报一次形参值 */
if (a == b)
return a;
if (a > b)
return gcd(a - b, b);
return gcd(a, b - a);
}
int main(void)
{
printf("result = %d\n", gcd(12, 8));
return 0;
}
求 gcd(12, 8) 时的输出:
解析
思路:题干给的三行定义就是辗转相减法。递归分三层:两个数相等时已经到出口,直接返回它俩;前一个大就把"大减小"的结果接着递归;后一个大就反着减。
题目额外要求打印形参,所以进函数第一件事就是打印。
关键点:
- 打印语句要放在递归调用之前(也就是函数一进来就打)。放在后面的话,打印顺序就变成从出口往回退的顺序,看不到"一路减下去"的过程。
- 出口是
a == b,返回a。因为每一步都在保持"两个数的最大公约数不变"这个性质,减到最后相等时那个相等的值就是最大公约数。 - 两次递归调用各有各的条件:
a > b走gcd(a - b, b),剩下的a < b走gcd(a, b - a)。第二种写法不能省掉return。 - 两个形参都必须是正整数。若其中一个为 0,比如
gcd(0, 5):a < b成立,接下来还是gcd(0, 5),永远出不去。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序打印 3 行
gcd(...)再打印结果:gcd(12, 8)→gcd(4, 8)→gcd(4, 4),最后result = 4。第一次 12 减 8 得 4,第二次 4 小于 8 改成用 8 减 4 得 4,第三次两数相等,返回 4——和手工算的 12 与 8 的最大公约数一致。
减法的递归深度比取余大得多
每递归一次,两个数里较大的那个只减少一份小的那个。求 gcd(1000000, 1) 要递归一百万次,栈很可能撑不住。
这一定义是教材用来演示递归的,实际要用还是辗转相除(用 %)快。不过本题要求按给的定义写,就不换成取余了。
易错:
- 把
printf写在递归调用之后,打印出来的顺序是反的- 出口写成
a == 0 || b == 0时返回 0(那样gcd(12, 8)会一路减到gcd(4, 0)返回 0)- 两次递归调用的条件写成一样的(
a > b和a < b要分清楚,前者减a,后者减b)- 第一次调用就传了 0 或负数进去
9 · 递归逆置整数¶
试用递归函数,返回与所给十进制整数相反顺序的整数,如已知整数是 1 234,函数返回值是 4 321。
答案
#include <stdio.h>
/* 辅助递归:把 n 的末位不断接到 acc 的后面 */
static int rev(int n, int acc)
{
if (n == 0)
return acc;
return rev(n / 10, acc * 10 + n % 10);
}
/* 返回与 n 相反顺序的整数,如 1234 返回 4321 */
int reverse(int n)
{
return rev(n, 0);
}
int main(void)
{
printf("%d\n", reverse(1234));
printf("%d\n", reverse(7));
printf("%d\n", reverse(1200));
printf("%d\n", reverse(1234567891));
return 0;
}
解析
思路:一个整数的末位是 n % 10,去掉末位是 n / 10。"逆序"就是把每次取下来的末位依次接到结果的右边——先取下来的 1 最后要落在最高位上。
所以递归里带一个形参 acc 记住"已经攒出来的那部分",每往下递归一层就先把它左移一位(acc * 10),再把当前的末位加上去。
以 reverse(1234) 为例,递推过程是:
rev(1234, 0)→rev(123, 4)rev(123, 4)→rev(12, 43)rev(12, 43)→rev(1, 432)rev(1, 432)→rev(0, 4321)n变成 0,到出口,返回acc,得 4321。
关键点:
- 出口是
n == 0。写n < 10也可以(那时acc * 10 + n一步算完),但n == 0能把 0 本身也处理掉。 - 带一个形参
acc存中间结果,就不需要事先知道这个数有几位;不用pow()或log10()去算位数,也就没有浮点误差的麻烦。 - 接位的动作是
acc * 10 + n % 10,那个乘 10 一步都不能省——省掉就成了把各位数字加起来。 - 函数
rev()只给本文件用,加上static修饰;reverse()只当入口,把初值 0 传进去。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序依次输出
4321、7、21、1987654321。另外单独试过reverse(0)得0、reverse(-123)得-321。
末位的 0 会消失
reverse(1200) 得 21 而不是 0021——0021 这个写法在整数里就是 21。这不是算错了,是"相反顺序的整数"这个说法本身丢掉了前导零。
要是想保留位数,就得按字符串或数组来存,不能返回整数。
易错:
- 接位时漏掉
acc * 10- 用
pow(10, 位数 - 1)算每一位的权重,位数用log10()数出来,浮点误差会让大数算错- 返回类型用
int却拿它逆置一个接近十位的数(1234567891逆过来是1987654321,还在int范围内,再大就溢出了)- 递归里忘了写
return,函数没有返回值
10 · 记录函数被调用的次数¶
试给出一种解决方案,能让函数知道自己被别的函数调用多少次。
答案
用静态局部变量。在函数里声明一个带 static 的计数器,它只在第一次调用时初始化,函数返回后这个变量仍然存在,下一次进来接着加。
#include <stdio.h>
/* 静态局部变量:只在第一次调用时初始化,函数返回后它的值仍然保留 */
int counter(void)
{
static int count = 0;
return ++count;
}
int main(void)
{
int i;
for (i = 0; i < 3; i++)
printf("%d ", counter());
printf("\n");
return 0;
}
连续调用 counter() 依次得到 1、2、3。
解析
这题的关键是找出一个"满足两个条件的变量":既只归这一个函数管,又要在函数返回后还活着。自己数一数 C 里的几种变量,符合条件的只有静态局部变量。
为什么普通局部变量不行:它在函数被调用时才建立、返回时就销毁,每次进来都重新初始化一遍。所以每调用一次都是 1,永远数不上去。
三种可行的方案:
- 静态局部变量(首选):
static int count = 0;写在函数里,作用域还是本函数,别的函数看不见;但存储位置在静态存储区,生存期是整个程序运行期间,初始化只做一次。 - 文件作用域的外部变量:在文件开头写一个
int count = 0;,函数里加。能实现,但别的函数也能随手改它,函数不再"自己知道"——共享面太大。 - 由调用者把计数器传进来:函数的形参多一个
int *count,每次调用时(*count)++;。计数存在调用者那边,函数自己不保存状态。
关键点:
static修饰局部变量只改变生存期,不改变作用域——它仍然只能在声明它的那个函数里用。- 初始化只发生一次。
static int count = 0;这句话在后面的调用里会被跳过,所以计数不会被清零。 - 顺序上要"先加再返回"(
return ++count;)才能得到 1、2、3;写return count++;得到的是 0、1、2。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序输出
1 2 3;把static去掉、写成普通局部变量的对照程序输出1 1 1。
别在同一个 printf 里连写几次调用
printf("%d %d %d\n", counter(), counter(), counter()); 这种写法,三个实参的求值顺序标准没有规定(gcc 实际上是从右往左算),打出来是倒过来的 3 2 1。计数并没有错,是"谁先算"没被规定。
要按顺序看,就一次调用一条语句。
易错:
- 用普通局部变量做计数器,每次都从 0 开始
- 看到
3 2 1不是计数反了,是实参求值顺序的问题- 把
static写成全局变量,虽然能数,但解答偏离了"函数自己知道"这个要求- 初始化语句写成
count = 0;(那每次调用都会清零),必须是static int count = 0;带着初始化
11 · 四个变量的存储类别¶
试分别指出以下数据对象的存储类别。
(1)x 是函数的整型形参。
(2)y 是某函数专用,并能保留函数前次调用结果的变量。
(3)z 只能让同一源程序文件中的函数共享的变量。
(4)d 是能让程序中许多函数共享的变量。
答案
| 序号 | 答案 |
|---|---|
| (1) | auto,自动存储类别(形参) |
| (2) | static,静态局部变量 |
| (3) | static,静态外部变量(文件作用域) |
| (4) | 外部变量(其他文件用 extern 声明即可共享) |
解析
四小题对应存储类别的四种基本情况,判据只有两条:变量在哪个范围里可见(作用域),以及它活多久(生存期)。
逐项说明:
- (1)形参的存储类别是自动的(
auto)。函数被调用时给它分配空间,函数返回时随栈一起释放,所以每次调用都要重新传值。C 里能写在形参上的存储类说明符只有register一个,它也只是给编译器的优化建议,平时一个字都不用写。 - (2)"某函数专用"说的是作用域限于本函数,"保留前次调用结果"说的是生存期不止一次调用——两条合起来只有静态局部变量,写法是
static int y;写在函数体内。 - (3)"只能让同一源程序文件中的函数共享"说的是文件作用域加内部链接,也就是在文件开头写
static int z;,这叫静态外部变量。它跟普通全局变量的区别只在链接:别的源文件里写extern int z;也引用不到它。 - (4)"让程序中许多函数共享"就是外部变量(全局变量),在文件开头(所有函数之外)写
int d;。它在整个程序里都有外部链接,别的源文件写一句extern int d;就能用同一个变量。
编译、链接探测的结果和上面的判断一致:
- 一个源文件里写
static int z;、另一个源文件里写extern int z;,两个文件一起链接时报undefined reference to ‘z’,也就是文件外确实看不见它。 - 一个源文件里写
int d = 10;、另一个文件里extern int d;,链接通过;第二个文件把d改成 20 之后,第一个文件里读到的就是 20(输出a=10 d=10、a=20 d=20),说明许多函数能共享同一个外部变量。 - 静态局部变量确实能保留上一次的值:同一个函数连着调用 5 次,依次返回 1、2、3、4、5,没有哪一次回到初值。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面三条探测各自跑过,报错原文与输出就是正文里列的这些;另外
auto int x = 3;这种写法在-std=gnu23和-std=c89下都能编译。
C23 给 auto 添了新用法
C23 起 auto 还能当类型说明符用,写 auto x = 3; 是"由初值推导类型"的意思,x 是 int(本机在 -std=gnu23 下编译运行,输出 3)。本题问的是形参的存储类别,答案仍是自动存储类别 auto,和教材年代一致;新用法只是提醒一下 auto 这个关键字如今有两种身份。
易错:
- (2)答成
auto或register(都保不住上一次的值)- (3)答成外部变量(那样别的源文件也能用,与"只能让同一源文件共享"不符)
- (4)答成
static(加static就成了文件私有的)- 把"存储类别"理解成"数据类型"(
int、float是类型,不是存储类别)
12 · TwoRound 宏的意义¶
试简要叙述以下宏定义的意义。
答案
这个宏的意义是:把 x 按两位小数做四舍五入,返回处理后的值。名字里的 Two 指的就是两位小数。
把它当成一个语句看,四步依次是:
(x)*100:小数点右移两位。原来小数点后第三位被顶到了个位上,整数部分则退到百位以上。+0.5:给这个数加半个 1。个位上的值原本就是"第三位小数",加上 0.5 之后,只要它不小于 5 就会进到十位上去。(int):截掉小数部分。到这一步,得到的正是"乘 100 之后四舍五入"的整数。/100.0:除回 100,变回小数,也就是保留两位小数的结果。
例如 TwoRound(3.14159) 是 3.14,TwoRound(2.5) 是 2.50,TwoRound(2) 得 2.00。
解析
宏展开是纯文本替换,TwoRound(3.14159) 展开之后就是:
实参 x 在宏体里只出现一次,而且写成了 (x),所以传 1 + 2 这样的表达式进来也不会被运算符优先级拆开——这两点比一般函数式宏安全。
可它仍然是个宏,不是函数,两处地方要留神。
第一处是小数本身的二进制表示。浮点数存不进精确的十进制小数,1.005 和 2.675 在 double 里分别是:
两个都略小于字面值,可它们乘 100 之后的表现却不一样:
1.005 * 100得100.49999999999998578915,加上 0.5 是100.99999999999998578915,(int)截断得100,所以TwoRound(1.005)是1.00,不是想当然的1.01。2.675 * 100按双精度舍入,正好落在267.5上,加上 0.5 得268.0,截断得268,所以TwoRound(2.675)是2.68。
两次的差别全在"乘 100 那一步舍入到了哪一边",跟这个宏的写法无关;换成别的两位小数,只要第三位是 5,就有踩中的可能。
第二处是 (int) 的截断方向。C 里整型转换是向零截断的,负数上就表现为"绝对值被缩小":TwoRound(-1.005) 先算 -100.4999… + 0.5 = -99.9999…,截断得 -99,结果 -0.99。
要在负数上也做四舍五入,得按符号减 0.5。
宏体还有个括号问题:整体没有括号,100 / TwoRound(2.5) 展开成 100 / (int)(2.5*100+0.5) / 100.0,那个 100 被卷进了整数除法里,算出来是 0.000000。
要给别人用,宏体应该写成 ((int)((x)*100+0.5)/100.0)。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):
TwoRound取1.005、2.675、3.14159、2.5、0.125、-1.005、2时依次得1.00、2.68、3.14、2.50、0.13、-0.99、2.00;printf("%f\n", 100 / TwoRound(2.5))输出0.000000。
名字与 0.5 的分工
+0.5 负责"入",(int) 负责"舍":加 0.5 之后凡是够到下一个整数的就进上去,不够的被截掉。/100.0 里的 .0 也不能省,写成 /100 就是整数除法,结果只剩整数部分。
易错:
- 说成"保留两位有效数字"(是两位小数)
- 认为
TwoRound(1.005)得1.01(浮点表示决定它得1.00)- 拿它当函数,写在
100 / TwoRound(x)这种位置- 以为
(int)本身是四舍五入(它只是截断,四舍五入是靠+0.5实现的)- 把
/100.0写成/100
13 · 判断闰年的宏¶
写出判断某年为闰年的宏定义。
答案
宏展开后是个值为 0 或 1 的表达式,可以直接当条件用:if (LEAP(2024)) …。
解析
闰年的规则是两句:能被 4 整除的是闰年;但整百年份要能被 400 整除才算(所以 1900 年不是闰年,2000 年是)。把两句合成一个表达式,就是"% 4 == 0 而且不是 % 100 == 0,或者干脆 % 400 == 0"。
关键点:
y在宏体里出现 4 次,每次都要写成(y)。少了括号,实参是个表达式时就会被优先级拆开:写#define BAD2(y) ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0),拿BAD2(1996 + 4)去问 2000 年,算的是1996 + 4 % 4,得 0,把闰年判成了平年(本机编译运行验证过)。- 整个表达式外面还要加一层括号。少了它,
!LEAP(y)展开后!会先跟%结合:换成#define BAD(y) (y) % 4 == 0 && (y) % 100 != 0 || (y) % 400 == 0送编译,BAD(2024)得 1、!BAD(2024)也得 1,自相矛盾;gcc 对这种写法还会给出-Wparentheses警告:suggest parentheses around ‘&&’ within ‘||’。 - 两个条件的位置不能颠倒。写成
((y) % 4 == 0 || (y) % 400 == 0)会把 1900 判成闰年;只写(y) % 400 == 0又会漏掉 2024 这种普通闰年。 - 反过来写也行:
((y) % 4 == 0 && ((y) % 100 != 0 || (y) % 400 == 0))。% 4 != 0时后半截本来就不会算,两种写法的结果一样。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):答案的宏取
1900得0、2000得1、2024得1、2023得0、2026得0;拿它筛 1896 到 2096 之间每 4 年一个的年份,1900 被挡在外面、2000 在名单里。
宏的参数会被求值多次
LEAP(y) 展开后 y 出现 4 次,所以实参不能带副作用:写 LEAP(year++) 会让 year 自增 4 次。要反复用的判闰年逻辑,写成 static inline 函数更稳(参数只求值一次,也有类型检查)。
易错:
- 只写
(y) % 4 == 0,把 1900 判成闰年y不加括号,实参写成表达式时算错- 整体不加括号,宏嵌进
!或&&里结果就不对- 把
% 100 != 0写成% 100 == 0(条件反了)
14 · 三整数取最大的宏¶
写出从 3 个整数中找出最大数的宏。
解析
思路:先在两数之间取大,再拿这个结果和第三个数比。用嵌套的条件运算符(?:)一层就写完,不需要中间变量。
关键点:
- 外层先判
a > b:成立就在a和c之间取大,不成立就在b和c之间取大。三条分支各自返回一个数,没有漏洞。 - 三个参数每次都写成
(a)、(b)、(c),整体再套一层括号。这是函数式宏的两条基本规矩:参数不加括号会被优先级拆开,整体不加括号会被外层运算符抢走优先级。 - 更一般的写法和它等价:先
#define MAX2(a, b) ((a) > (b) ? (a) : (b)),再#define MAX3(a, b, c) MAX2(MAX2(a, b), c)。两层括号都在,展开后同样安全。 - 宏没有类型,什么类型都能比:传
int、char、double都行,比较按各自的类型规则走。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):
MAX3(3, 7, 5)得7,MAX3(9, 7, 5)得9,MAX3(3, 7, 15)得15,MAX3(-3, -7, -5)得-3,MAX3('a', 'B', 'c')得99('c'的码值)。
参数带副作用时不能用
宏体里每个参数都出现两次,实参带副作用就会被算两次。拿 MAX3(i++, -5, 3)(i 初值 0)试一下:两次比较都成立,i++ 真的执行了两次,i 变成 2。
要是写成 printf("%d %d\n", MAX3(i++, 5, 3), i);,gcc 还会报 warning: operation on 'i' may be undefined [-Wsequence-point]——同一个表达式里既改 i 又读 i,属于未定义行为。要多次求值安全的版本,写成函数。
易错:
- 参数或整体漏括号,宏嵌进别的表达式里算错
- 只比较了两个数(比如只写
(a) > (b) ? (a) : (b),漏掉c)- 拿带
++、--的变量当实参- 把
?:的嵌套层次写错,a比b大却在b、c之间取大
15 · 输出整数数组的宏¶
写出输出整数数组的宏定义,要求数组元素个数和一行输出多少个整数作为宏的形参。
答案
/* a 是数组名,n 是元素个数,per 是一行输出多少个 */
#define PRINT_ARRAY(a, n, per) \
do { \
int _i; \
for (_i = 0; _i < (n); _i++) { \
printf("%d ", (a)[_i]); \
if ((_i + 1) % (per) == 0) \
printf("\n"); \
} \
if ((n) % (per) != 0) \
printf("\n"); \
} while (0)
用法:PRINT_ARRAY(a, 7, 3); 把 a 的前 7 个元素按每行 3 个打出来。
解析
思路:把"遍历数组"和"每打满 per 个换一行"两件事合在一个宏里。元素个数 n 和每行个数 per 都是宏的形参,换数组、换排版都只改实参。
关键点:
(a)[_i]里的括号不能省。实参如果是b + 1这样的表达式,写成a[_i]展开后成了b + 1[_i],意思全变。(n)、(per)也都要加括号,这两处都要参与比较和取余。- 换行的判据是
(_i + 1) % (per) == 0:下标从 0 起,所以要用"第几个"(_i + 1)去取余,不能用下标本身。 - 循环之后补一句
if ((n) % (per) != 0) printf("\n");。最后一行的元素个数不满per时,循环里那个判据不会触发,末尾就没换行——补一句让输出总以换行收尾。 - 整个宏用
do { … } while (0)包住。这样它在语法上就是一条完整的语句:写PRINT_ARRAY(a, 7, 3);带分号没问题,放在if (…) PRINT_ARRAY(…); else …里也不会把else挂错。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):数组
{1, 2, 3, 4, 5, 6, 7}配per = 3,输出三行1 2 3、4 5 6、7;配per = 10,输出一行1 2 3 4 5 6 7,末尾有换行。
数字后面跟着一个空格
每次打一个元素,宏里跟的都是 printf("%d ", (a)[_i]);,所以每行行尾会多出一个空格(上面的输出按判题同款口径剥掉了行尾空白)。要整齐也可以在循环里改判据、只在元素之间补空格,但那样打出来的就不是上面这一段文本了。
易错:
- 数组名不加括号,传表达式进来就出错
- 用下标
_i % per判换行,第一行就提前换了- 忘了最后那个补换行的
if,末行不满时输出没有以换行结尾- 不用
do { } while (0)包住,宏展开成一段裸语句,放进if / else里出错
16 · 三个输出宏与头文件¶
分别写出一行输出 1 个数据项、一行输出 2 个数据项和一行输出 3 个数据项的宏定义,然后将这些定义的正文组织成一个头文件,并要求该头文件具有不会让同一个源程序文件重复包含的能力。
答案
头文件(例如存成 print_items.h):
/* 数组输出宏:一行分别输出 1 个、2 个、3 个数据项 */
#ifndef PRINT_ITEMS_H
#define PRINT_ITEMS_H
#include <stdio.h>
/* 通用宏:一行输出 k 个数据项 */
#define PRINT_ITEMS(a, n, k) \
do { \
int _i; \
for (_i = 0; _i < (n); _i++) { \
printf("%d ", (a)[_i]); \
if ((_i + 1) % (k) == 0) \
printf("\n"); \
} \
if ((n) % (k) != 0) \
printf("\n"); \
} while (0)
/* 三个宏只是把 k 固定成 1、2、3 */
#define PRINT_1(a, n) PRINT_ITEMS(a, n, 1)
#define PRINT_2(a, n) PRINT_ITEMS(a, n, 2)
#define PRINT_3(a, n) PRINT_ITEMS(a, n, 3)
#endif
用的时候:
解析
三个宏的正文只差"一行几个数"这一个参数,所以先写一个带 k 的通用宏,再用三个短宏把 k 固定下来。这样后面要改输出格式,只改一处;要加一个"一行 4 个",再加一行 #define 就行。
头文件卫士是 #ifndef / #define / #endif 三句:
#ifndef PRINT_ITEMS_H:如果这个名字还没被定义过,就往下走。#define PRINT_ITEMS_H:一进来就把它定义上,这样第二次包含时上面那个#ifndef就不成立,整段被跳过。#endif:把整段收住。PRINT_ITEMS_H这个名字习惯按文件名加下划线大写来写,重名概率很低。
注意卫士只包住头文件自己的内容,别把 #include <stdio.h> 也漏在外面——宏体里用到了 printf,所以这一句要在头文件里带上(有卫士挡着,重复包含时不会被插进来第二遍)。
本机验证:把上面这个头文件连着 #include 两次,编译通过、0 条警告;换成一份没有卫士的头文件、里面写了一句 int arr_size = 7;,同样包含两次,编译直接报 error: redefinition of ‘arr_size’,并指出前面那次定义在哪儿。可见卫士的作用是让"重复包含"对头文件里定义的任何东西都不产生第二次定义。
真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):数组
{1, 2, 3, 4, 5, 6, 7}分别送进三个宏,PRINT_1打出 7 行、每行 1 个数,PRINT_2打出 4 行(末行 1 个),PRINT_3打出 3 行(末行 1 个);每个数字后面跟一个空格。
宏定义重复出现并不报错
单单重复 #define 同一个宏(内容一样)是允许的,所以只放宏定义的头文件重复包含往往看不出问题。麻烦出在头文件里有变量定义、结构体定义的时候,那时第二次定义就是硬错误。卫士是提前把这类问题挡掉,代价只有三行。
还有一种写法是 #pragma once,gcc 支持,但它不在 C 标准里。
易错:
- 头文件里忘了
#include <stdio.h>,宏体用到的printf靠包含它的源文件去提供- 卫士的宏名和别处撞了(比如用了太普通的
ITEMS)#endif后面的注释写不写都行,但#ifndef与#endif必须配成对- 三个宏各抄一遍正文,改一处要改三处
习题自测登记:完成本章习题后在此登记