简介:面向C语言初学者与课程复习者的基础学习文档,围绕计算机与编程语言、变量与输入、表达式与运算符、程序设计等入门模块展开,帮助读者建立从编译与解释执行、C语言标准到基础语法的整体认识。内容结合最大公约数、英制身高单位转换、时间差计算等示例,讲解scanf返回值、变量初始化、运算符优先级与关系判断等常见知识点,便于对照练习和查漏补缺。压缩包内共1个PDF文件,大小约1.57MB,适合在电脑或平板上阅读、检索与标注。已有13516人学习,说明其作为入门参考资料具有一定参考价值。文档侧重基础概念与代码片段的结合,可用于课前预习、课后复盘或自学时梳理语法脉络,但更偏向知识要点整理,适合作为C语言入门阶段的辅助材料。
1. 一份按周排布的 C 语言讲义:从「算最大公约数」开始的六周路线
很多人读 C语言 PDF资料 的第一反应是从第一页翻到最后一页,结果卡在「解释执行和编译执行有什么区别」这种开场白上,翻了三遍还没写过一行能跑的代码。这份基础文档的组织方式恰好相反:它按 First Week 到 Sixth Week 分成六个阶段,每个阶段都挂着一两个具体的小程序,从 32 和 26 的最大公约数开始,一路推到整数分解、猜数字、三层循环凑零钱。它面向的是刚接触 C 语言、需要有人把知识点按依赖顺序排好的人,也适合拿它当课堂讲义模板的讲授者——每节都能直接抽出例题改成作业。真正值钱的地方不是知识点本身,而是那条「先会算、再会判、再会循环、最后才谈类型边界」的推进顺序。
2. 讲义骨架与编译环境:六周内容怎么串成一条依赖链
拿到一份文档类资源,先别急着看内容,先看它怎么组织。这六周不是随便切的六个文件夹,而是一条单向依赖链:前一周没吃透,后一周的例题基本没法独立写出来。搞清楚这条链,你就知道卡住的时候该往回翻哪一周,而不是从目录第一行重新开始。
2.1 六周目录的依赖链与知识点映射
把讲义摊开看,每一周承担的任务非常明确,前两周解决「怎么表达数据」,中间三周解决「怎么控制流程」,最后一周解决「数据在内存里到底长什么样」。
| 周次 | 主题 | 核心知识点 | 代表例题 | 依赖的前置 |
|---|---|---|---|---|
| First Week | 程序设计与 C 语言 | 编译与解释、C 标准演进、第一个程序 | 辗转相除求 gcd | 无 |
| Second Week | 计算 | 变量初始化、scanf 返回值、运算符优先级、类型提升 | 英尺英寸转换、时间差 | 第一周 |
| Third Week | 判断 | 关系运算、if-else、switch-case、注释 | 分段函数、单一出口写法 | 第二周表达式 |
| Fourth Week | 循环 | while / do-while、整数分解、猜数字 | 数位数、逆序输出 | 第三周判断 |
| Fifth Week | 循环控制 | for 循环、break 接力、goto、辗转相除 | 阶乘、凑零钱、正序分解 | 第四周循环 |
| Sixth Week | 数据类型 | 补码、sizeof、整型范围、浮点精度、字符与逃逸字符 | 大小写转换、强制类型转换 | 全部 |
这张表最值得注意的一点是:前三周里出现的所有例题,都不需要数组、不需要函数拆分,全部塞在main里。这不是偷懒,而是刻意把变量作用域、控制流这些东西先压到一个函数里讲透,避免新手在「函数传参到底传的是值还是地址」上提前翻车。我一般建议按周建六个目录,每个目录下按小节编号存.c文件,比如w3/3-2-switch-type.c,后面回头查某道例题时能一秒定位。
2.2 本地编译环境:gcc 标准开关与第一个可执行文件
讲义里的代码有一个不太显眼但很关键的前提:很多写法只有在 C99 及以上才合法,比如在for语句里定义循环变量for (int i = 1; ...)、在代码中间定义变量、用//写行注释、用const定义常量。如果你用默认参数编译,某些老版本编译器会直接报错,而报错信息往往指向语法而不是标准,新手很容易以为自己抄错了。
# 按 C99 编译, 打开常用警告, 输出带调试信息 gcc -std=c99 -Wall -Wextra -g gcd.c -o gcd ./gcd # 想看预处理、汇编这些中间产物时 gcc -std=c99 -E gcd.c -o gcd.i # 只做预处理 gcc -std=c99 -S gcd.c -o gcd.s # 生成汇编 gcc -std=c99 -c gcd.c -o gcd.o # 生成目标文件参数说明:-std=c99是这份讲义最该加上的开关,它决定了for (int i = ...)能不能过;-Wall -Wextra会把你「定义了变量没用」「if (a = b)这种疑似赋值」的问题提前喊出来,比运行时报错好排查得多;-g保留符号表,配合gdb单步时能看到变量名而不是地址。如果你手上是更老的编译器,把-std=c99换成-std=gnu99通常也能过,但gnu99带了一些扩展,和讲义的标准写法不完全一致,学习阶段还是老老实实用c99。
提示:讲义里 First Week 那段
int main(int argc, char const *argv[])的写法,在只跑例题时完全用不到这两个参数,换成int main(void)更干净,也能避开「未使用参数」的警告。
2.3 从 .c 到可执行文件:预处理、编译、汇编、链接四步
First Week 讲「解释」和「编译」的区别时,只用了「翻译」这个比喻,但落到实际操作上,gcc gcd.c -o gcd这一条命令其实偷偷干了四件事。把这个链路搞清楚,后面遇到「明明代码没错但链接报错」「头文件找不到」这类问题,你就知道该在哪一步停下来看。
第一步预处理,处理#include、#define和注释,注释会被替换成一个空格,这一点讲义 Third Week 里专门提过;第二步编译,把 C 代码翻译成汇编;第三步汇编,把汇编转成机器码目标文件;第四步链接,把目标文件和标准库拼成可执行文件。讲义里所有例题都只依赖stdio.h、stdlib.h、time.h这几个标准头,所以链接阶段从来不会出问题,这也是它敢把例题全写成单文件的原因。
#include <stdio.h> int main(void) { int u = 32; int v = 26; while (v != 0) { int temp = u % v; u = v; v = temp; } printf("%d\n", u); return 0; }这段代码跑出来的结果是2。看着简单,但它同时用到了三样东西:变量在代码中间定义(C99)、while循环、以及%取余。如果你用-std=c89编译,第二行的int u = 32;没问题,但while里那句int temp就会报错,因为早期标准要求所有变量声明集中在块的开头。这就是为什么我一直强调编译开关要和讲义对齐——不是代码错了,是标准不认。
3. 计算类示例拆解:辗转相除、scanf 返回值与整数分解
第二周和第四、五周的例题是整个讲义里复用率最高的部分,很多后面章节的题目都是在这里加一层判断或换一种循环写出来的。把这几个例子彻底吃透,比刷十道同类题有用。
3.1 辗转相除法:循环不变式与那个必须存在的 temp
讲义在 First Week 和 Fifth Week 两次给出最大公约数,第一次是文字步骤,第二次才是完整代码。这两次之间隔了三周,其实是故意的:第一次你只需要理解算法,第二次你得理解为什么代码里非得有个临时变量。
#include <stdio.h> int main(void) { int a, b, t; if (scanf("%d %d", &a, &b) != 2) { return 1; /* 输入不合法直接退出, 别带着脏数据往下算 */ } while (b != 0) { t = a % b; /* 先存余数 */ a = b; /* 再把 b 顶上去 */ b = t; /* 最后把余数放进 b */ printf("a=%d, b=%d, t=%d\n", a, b, t); } printf("gcd=%d\n", a); return 0; }逻辑说明:循环开始时a和b的公约数集合,与循环结束时新的a、b的公约数集合完全相同,这是整个算法的立足点,也就是常说的循环不变式。三轮交换之后b变成 0,此时a就是最大公约数。参数上唯一需要注意的是t这个临时变量不能省:如果写成a = b; b = a % b;,第二句里的a已经是新的b了,算出来的是b % b,永远是 0,循环第一轮就结束,输出结果直接错成b的原值。这种错误编译器不会报,只能靠打印中间变量发现,所以上面代码里我留了那句printf,调试阶段别删。
scanf返回值的判断也值得说一句。讲义 Second Week 把返回值分成三类:正整数表示成功读入的参数个数,0表示第一个参数就不匹配,EOF(通常是 -1)表示输入流结束。上面代码用!= 2兜住了全部异常情况,比只判断== 2更稳妥,因为EOF也是「不等于 2」的一种。
3.2 scanf 的三类返回值:把输入校验写进主流程
新手写输入校验最常见的写法是scanf完直接用,等程序跑出个离谱结果再回头找原因。讲义里反复强调返回值,其实是在教你一个习惯:凡是读输入,就当成一次可能失败的函数调用,失败要处理。
#include <stdio.h> int main(void) { int a = 0, b = 0; int r = scanf("%d %d", &a, &b); if (r == 2) { printf("两个都读到了: %d %d\n", a, b); } else if (r == 1) { printf("只读到第一个: a=%d\n", a); } else if (r == 0) { printf("第一个就不匹配, 多半是分隔符或格式串写错了\n"); } else { printf("输入结束, 返回值是 %d\n", r); } return 0; }逻辑说明:r == 1这种情况在实际使用里出现频率最高,典型场景是格式串写"%d %d"但用户输入了3,4,逗号不是空白字符,第二个%d卡住,函数提前返回。r == 0则说明第一个转换就失败了,比如输入,3 4。参数上要注意EOF不要写死成-1去比较,标准头里定义了EOF宏,直接用宏名,换平台不会有问题;另外在 Windows 终端下按Ctrl+Z再回车表示输入结束,Linux 下是Ctrl+D,这两个在讲义的 Second Week 里都有写,但很多人第一次跑的时候还是会在终端里愣半天。
3.3 整数与浮点混算:英尺英寸转换里的 12.0
这是整份讲义里最容易「抄对了但结果不对」的一段。代码本身只有几行,坑全在12和12.0这一个字符的差别上。
#include <stdio.h> int main(void) { int foot = 0, inch = 0; printf("请分别输入身高的英尺和英寸,如输入\"5 7\"表示5英尺7英寸:"); if (scanf("%d %d", &foot, &inch) != 2) { return 1; } double meter = (foot + inch / 12.0) * 0.3048; printf("身高是%f米。\n", meter); return 0; }逻辑说明:inch / 12.0里的12.0是double字面量,整数inch在参与这个除法时会自动提升为double,结果保留小数;如果写成inch / 12,两个操作数都是int,做的是整数除法,7 除以 12 直接得 0,整个换算结果就退化成foot * 0.3048,看着程序能跑、输出也像模像样,就是数值不对。这正是讲义 Second Week 那句「当浮点数和整数一起运算时,整数会自动改为浮点数」的实际含义——规则是自动的,但前提是表达式里得先出现一个浮点数。
参数上,0.3048是英尺到米的换算系数,精度足够,不用改成0.30480。输出用%f打印double就行,讲义的 Sixth Week 里专门说了printf会把float自动提升为double,所以这里不存在格式串不匹配的问题。真正要注意的是别在scanf里给double用%f,读入时必须用%lf,这两个方向不一样。
3.4 整数分解的两个方向:逆序 ret*10+digit 与正序 mask
第四周讲「任意位数的逆序」,第五周讲「正序分解整数」,这两个例题合起来看才完整,因为它们用的是同一套取位手法,只是组合方向相反。
/* 逆序: 700 -> 7 */ int x = 700; int ret = 0; while (x > 0) { int digit = x % 10; /* 取个位 */ ret = ret * 10 + digit; /* 已有结果整体左移一位, 再补上新的一位 */ x /= 10; /* 去掉个位 */ } /* 正序: 700 -> 7 0 0 */ int mask = 1; int t = x; while (t > 9) { /* t 用来试探位数, 不破坏 x */ t /= 10; mask *= 10; /* 循环结束时 mask 是最高位的权值, 700 对应 100 */ } do { int d = x / mask; /* 取出当前最高位 */ printf("%d", d); if (mask > 9) { printf(" "); } x %= mask; /* 去掉已输出的那一位 */ mask /= 10; } while (mask > 0);逻辑说明:逆序版靠ret * 10 + digit每次把旧结果推高一位,个位变十位,新数字填到个位,天然就把顺序倒过来了。正序版反过来,先用一个只做除法的临时变量t算出最高位权值mask,再用x / mask取高位、x %= mask削掉高位、mask /= 10降低权值,三步一轮,输出就是正序。
参数上有个讲义没写、但你必须自己补的分支:x == 0。逆序版用的是while (x > 0),输入 0 时循环一次都不进,ret保持 0,结果凑巧对;正序版算mask的那个while (t > 9)对 0 也不进,mask保持 1,do-while会输出一个0,也对。但负数就全崩了,-700 % 10在 C 里结果是-0也就是 0,后面的取位全乱。稳妥做法是在入口判断符号,先取绝对值,输出前补个负号。这种边界在课堂例题里常常被省略,自己动手时不能省。
4. 判断与循环控制:switch 落点、接力 break 与 goto out 的写法
第三周到第五周的内容看着是语法,实际上讲的全是「控制流怎么组织」。同样一个分段函数,讲义给了三种写法,还专门比较了单一出口和多出口的取舍,这段是整份资料里少有的带工程口味的讨论。
4.1 switch-case 的落点、穿透与 default
讲义对 switch 的解释很到位:它不是「判断」,而是「基于计算结果的跳转」。控制表达式的值算出来之后,程序直接跳到匹配的case标号处,标号只是路标,不构成块的边界。
#include <stdio.h> int main(void) { int type = 0; if (scanf("%d", &type) != 1) { return 1; } switch (type) { case 1: case 2: printf("你好\n"); break; case 3: printf("晚上好\n"); /* 这里没有 break, 会继续往下穿透 */ case 4: printf("再见\n"); break; default: printf("啊,什么啊?\n"); break; } return 0; }逻辑说明:输入 1 或 2,两个 case 共用同一段代码,输出你好;输入 3 时先输出晚上好,因为没有break,接着穿透到 case 4 再输出再见;输入 4 只输出再见。参数上要注意两点:控制表达式必须是整型,浮点和字符串都不能直接放进去;case后面的值必须是常量表达式,C99 起允许用const常量,但绝不能用变量。
穿透这个特性在讲义里被当成知识点讲,实际写业务代码时却是 bug 高发区。我的习惯是:凡是故意穿透的,两个 case 之间空一行并写一行注释说明;凡是写完break的,一个都不落。这样别人扫一眼就能区分「设计如此」和「忘了写」。
4.2 单一出口与 if-else 链的取舍
同一道分段函数,讲义给了非单一出口和多出口两个版本,然后表态更支持单一出口。这个观点值得展开说一下,因为新手往往觉得「哪样代码短就哪样写」。
/* 非单一出口: 三个 printf */ int x = -3; if (x < 0) { printf("%d\n", -1); } else if (x == 0) { printf("%d\n", 0); } else { printf("%d\n", 2 * x); } /* 单一出口: 先算后打印, 打印只有一处 */ int f; if (x < 0) { f = -1; } else if (x == 0) { f = 0; } else { f = 2 * x; } printf("%d\n", f);逻辑说明:多出口版在三个分支里各写一次输出,以后想改输出格式(比如加个前缀、改成写文件),三处都得改,漏一处就是不一致。单一出口版把「算」和「打印」拆开,输出逻辑收在一处,改起来只有一个入口。代价是多了个中间变量f,如果分支里有提前return或者需要抛错误,单一出口会显得绕。
参数上没什么可调的,这是个纯结构选择。我的判断标准是:分支里只做赋值、最后统一输出,走单一出口;分支里有资源申请、错误返回、提前退出,就走多出口,别硬凑。讲义里那句「为了方便阅读,一般都带{}」也是同理,即使分支只有一行语句也把大括号写上,避免以后加第二行时忘了补括号踩坑。
4.3 三层循环里接力 break 与 goto out
凑零钱这道题是全篇最能体现「控制流到底怎么跳」的例题:三层嵌套循环,找到第一组解就整体退出。break只能跳出最内层,所以要接力写三次判断,或者用goto。
/* 接力 break 版 */ int x = 10; int one, two, five; int exit = 0; for (one = 1; one < x * 10; one++) { for (two = 1; two < x * 10 / 2; two++) { for (five = 1; five < x * 10 / 5; five++) { if (one + two * 2 + five * 5 == x * 10) { printf("可以用%d个1角加%d个2角加%d个5角得到%d元\n", one, two, five, x); exit = 1; break; /* 只跳出 five 这一层 */ } } if (exit == 1) break; /* 跳出 two 这一层 */ } if (exit == 1) break; /* 跳出 one 这一层 */ }逻辑说明:exit是个标志位,最内层找到解后置 1,往外每一层检查一次就往外跳一层,三层跳完正好退出。goto版把这三段判断换成一个标号:
for (one = 1; one < x * 10; one++) { for (two = 1; two < x * 10 / 2; two++) { for (five = 1; five < x * 10 / 5; five++) { if (one + two * 2 + five * 5 == x * 10) { printf("可以用%d个1角加%d个2角加%d个5角得到%d元\n", one, two, five, x); goto out; /* 一次跳到最外层之后 */ } } } } out: return 0;参数上,x * 10是把「元」换成「角」,x * 10 / 2是两角硬币最多能放几枚,x * 10 / 5同理,这三个上界决定了循环次数的量级。x = 10时三层循环的迭代次数在一千以内,跑起来无感;如果x放大到 100,就是百万级,得考虑换算法而不是硬循环。
goto在讲义里被谨慎地介绍,标注了「跳到后面只是格式习惯」。我的经验是:往同一个函数后半段跳、只用于跳出多层循环这种场景,goto反而比接力break清楚;但绝不能往回跳,那才是真正的翻车现场,循环变量可能永远不满足出口条件。
4.4 for / while / do-while 的选择规则
第五周给了一条很实用的判断规则:固定次数用for,至少执行一次用do-while,其余用while。这条规则背后是三种循环在语义上的差别,不是风格偏好。
#include <stdio.h> int main(void) { int n = 0; if (scanf("%d", &n) != 1 || n < 0) { return 1; } int fact = 1; int i = n; for (; n > 1; n--) { /* 循环变量在外部声明, 结束后 i 还保留原值 */ fact *= n; } printf("%d!=%d\n", i, fact); return 0; }逻辑说明:讲义里提到了for (int i = 1; i <= n; i++)的写法,那是 C99 才允许的,循环变量i的作用域只在循环内,出了循环就访问不到;如果你需要在循环结束后继续用这个变量(比如输出「5!=120」时要把原来的 n 打出来),就得像上面这样在外面声明,或者在进循环前先存一份。这也是讲义说的「如果使用循环之后还想去访问循环语句中控制那个变量,使用 while 循环」。
参数上必须提醒一句:int的fact在n = 13时就溢出了,13! 是 6227020800,超过 32 位有符号 int 的上限 2147483647,再往上算出来是负数。想验证到 20! 得换long long并用%lld输出。讲义的例题规模小,不会碰到这个问题,但你自己拿它当练习的时候,n 一放大就会看到莫名其妙的负数,别以为是编译器坏了。
5. 类型边界与避坑:补码、unsigned、浮点相等这几处最容易翻车
第六周讲数据类型,篇幅不长,但几乎每一段都能对应一个真实的错误。下面这五条是我在这份资料上照着敲代码时真踩过的,按「现象 → 原因 → 解决」记下来,遇到类似症状可以直接对照。
5.1 现象:if (a = b) 分支永远进得去
现象:写分段函数时把if (x == 0)敲成了if (x = 0),结果不管输入什么,程序都走了同一个分支,或者干脆一个分支都不进。
原因:赋值也是表达式,x = 0的结果就是被赋的值 0,0 在条件判断里当假处理;如果写成x = 5,结果非零恒为真。C 语言的条件只要求结果是零或非零,编译器不会拦你,除非开了警告。
解决:编译时永远带-Wall,它会给出「suggest parentheses around assignment used as truth value」的提示;真需要在条件里赋值(比如while ((ch = getchar()) != EOF)),多套一层括号明确表达意图。讲义 Third Week 专门把这条列为「错误使用==和=」,值得抄在笔记本第一页。
5.2 现象:unsigned 相减得到一个巨大的数
现象:unsigned int a = 3, b = 5;然后打印a - b,输出是 4294967294 而不是 -2。
原因:无符号整数的减法结果仍然按无符号解释,-2 的补码0xFFFFFFFE被当成纯二进制数读出来,就是 4294967294。讲义 Sixth Week 那句「unsigned 的初衷并非扩展数能表达的范围,而是为了做纯二进制运算」说的就是这个机制。
解决:做有意义的减法就别用 unsigned,除非你确实在做位运算或哈希;循环里for (unsigned i = n; i >= 0; i--)这种写法更是经典死循环,因为i永远不小于 0。需要下界判断时改用int,或者把循环条件改成i-- > 0的形式。
#include <stdio.h> int main(void) { unsigned int a = 3, b = 5; printf("%u\n", a - b); /* 4294967294, 补码被当成纯二进制读 */ printf("%d\n", (int)(a - b)); /* -2, 强转回有符号才符合直觉 */ return 0; }5.3 现象:两个 float 相加后判断相等失败
现象:float a = 1.345f, b = 1.123f;相加得c,用c == 2.468判断,结果是不相等,打印出来c是 2.4679999 或 2.4680001。
原因:float只有大约 7 位有效十进制数字,1.345 和 1.123 在二进制里都存不下精确值,相加之后的舍入误差让结果和字面量 2.468 差了一点点。2.468这个字面量本身是double,比较时c会被提升,误差依然存在。
解决:比较浮点数一律用差值绝对值和一个足够小的阈值,需要#include <math.h>并链接-lm。金额计算这种要求精确的场景,讲义给的建议很干脆:用整数按分存储,别碰浮点。
#include <stdio.h> #include <math.h> int main(void) { float a = 1.345f, b = 1.123f; float c = a + b; if (fabs(c - 2.468) < 1e-5) { printf("在误差范围内相等, c=%.10f\n", c); } else { printf("不相等, c=%.10f\n", c); } return 0; }5.4 现象:scanf 返回 0 之后循环变成死循环
现象:写「不断读入整数直到输入 -1」的循环,输入一个字母之后程序开始疯狂刷屏,停不下来。
原因:scanf("%d", &x)遇到不匹配的字符时,既不消费这个字符,也不给它赋值,直接返回 0。循环条件里如果只看x != -1,x保持上一次的旧值,输入缓冲区里的那个字母还在原地,下一轮照样读不动,于是无限循环。
解决:检查返回值,失败时把当前行剩余字符读掉再重试。这样既能给出提示,也不会陷入死循环。
#include <stdio.h> int main(void) { int x = 0; int sum = 0, count = 0; while (scanf("%d", &x) == 1 && x != -1) { sum += x; count++; } if (count > 0) { printf("%f\n", 1.0 * sum / count); /* 1.0 保证做浮点除法 */ } return 0; }这段是讲义 Fourth Week 求平均数例题的加固版,原版用do-while加一个if (number != -1)的判断,逻辑没问题,但没有处理输入失败。参数上1.0 * sum / count里的1.0不能省,否则sum / count是整数除法,小数部分直接丢掉。
5.5 现象:(short)32768 打印出 -32768
现象:把一个超过short上限的数强转成short,打印出来是负数,和数学直觉完全相反。
原因:short通常是 2 字节 16 位,能表示 -32768 到 32767。32768 的二进制是1000 0000 0000 0000,最高位被解释成符号位,按补码读出来正好是 -32768。讲义 Sixth Week 讲补码时用「-1 是 11111111」举例,同一个机制。
解决:强转前先确认目标类型的范围,或者改用long、long long。判断范围可以用sizeof配合<limits.h>里的宏,别靠记忆。
| 类型 | 常见字节数 | 常见范围 | 格式化符 |
|---|---|---|---|
| char | 1 | -128 ~ 127 | %d / %c |
| short | 2 | -32768 ~ 32767 | %hd |
| int | 4 | -2147483648 ~ 2147483647 | %d |
| long | 4 或 8 | 随平台变化 | %ld |
| long long | 8 | ±9.2e18 量级 | %lld |
| float | 4 | 约 7 位有效数字 | %f |
| double | 8 | 约 15 位有效数字 | %lf(读入) |
#include <stdio.h> int main(void) { printf("short 占 %zu 字节\n", sizeof(short)); printf("(short)32768 = %d\n", (short)32768); /* 常见输出 -32768 */ printf("(int)10.9 = %d\n", (int)10.9); /* 截断, 不是四舍五入 */ return 0; }参数上注意sizeof返回的是size_t,用%zu打印最稳;强转是截断不是四舍五入,(int)10.9得 10;另外强转只是「用这个变量算出一个新值」,变量本身的类型和值都不会变,讲义 Sixth Week 特意强调了这一点,很多人以为(double)a之后a就变成浮点了。
6. 二次开发:把六周讲义改成能自动对拍的题库
这份讲义的例题有个共同特点:输入输出都很短,结果确定,非常适合做自动对拍。我把它拆成六周目录之后,在根目录放了一个脚本,改完任何一道例题跑一遍就能知道有没有写错,比人工对着屏幕核对快得多。
#!/usr/bin/env bash # 用法: ./check.sh 在当前目录跑全部用例 set -u CC=${CC:-gcc} STD="-std=c99 -Wall -Wextra" run_case() { local src="$1" input="$2" expect="$3" local bin="/tmp/case_$$" if ! $CC $STD "$src" -o "$bin" 2>/tmp/err_$$; then echo "编译失败: $src"; cat /tmp/err_$$; return 1 fi local out out=$(printf '%s' "$input" | "$bin") if [ "$out" = "$expect" ]; then echo "PASS $src" else echo "FAIL $src"; echo " 期望: [$expect]"; echo " 实际: [$out]" fi } run_case w1/gcd.c "32 26" "2" run_case w3/switch.c "3" "晚上好 再见" run_case w4/count.c "700" "3" run_case w5/reverse.c "700" "7"这个脚本有三个关键点:set -u让未定义变量直接报错,避免路径写错时静默通过;编译错误单独重定向到临时文件,失败时打出来,能一眼看出是语法问题还是逻辑问题;期望输出用双引号包住允许换行,多行输出也能精确比对。要扩成题库,把用例抽成一个数据文件更合适:
| 字段 | 含义 | 示例 |
|---|---|---|
| id | 题号,跟周次绑定 | w3-02 |
| src | 源文件相对路径 | w3/switch.c |
| stdin | 标准输入 | 3 |
| stdout | 期望输出 | 晚上好\n再见 |
| std | 编译标准 | c99 |
| tags | 知识点标签 | switch, break 穿透 |
字段设计上我建议把tags一并记上,后面想按知识点生成一份「switch 专项十题」之类的练习时,直接按标签筛就行,不需要重新翻 PDF 找题。另一个值得做的改造是把每道例题的原始版本和你的答案分开放,比如origin/和mine/,对拍时只编mine/,这样改错了还能随时对照原文。
提示:run_case 里的临时文件名带了
$$(当前进程号),是为了避免并行跑多个脚本时互相覆盖;如果你打算用xargs -P并发跑,临时目录改成mktemp -d更稳。
从那以后我每改一道例题,都先跑一遍对拍脚本再去看下一周的内容,不然很容易出现「看着改对了、其实把某个边界条件改没了」的情况——血泪经验是,讲义的例题太短,短到错误常常藏在正常输入下看不出来的地方,只有拿固定输入反复对拍才抓得住。希望帮到你。
本文还有配套的精品资源,点击获取