开始前需要什么 你以前编译并运行过一个小的 C 程序。如果没有,请先读 C 入门指南。本篇从“身边没有计算机帮你检查”这一步开始。 一支笔或铅笔、一块橡皮,以及方格纸或横线纸。在方格纸上画表格更方便。 每课大约 20 分钟。检查题要写在纸上,不要只在脑子里想。 本篇目录 像计算机一样读代码 画出内存 要背熟的语法 手写时常见的错误 给自己打分 01
像计算机一样读代码 学完你能 你能一行一行地手动运行一小段 C 程序,并写下它用到的每一个值。
在纸上写代码时,没有任何东西会告诉你某一行写错了。你唯一的检查办法,是自己把程序运行一遍:一半在脑子里,一半写在纸上。这叫手动跟踪(英文叫 dry run):你扮演计算机,拿着笔,一行一行地运行程序。
计算机不知道你的程序是做什么用的。它严格按每一行写的内容,按顺序执行,并且只记得变量里存着的东西。变量就是一个有名字的位置,里面存着一个值。你在跟踪时也要这样做:按这一行实际写的内容去做,而不是按你原本想写的内容去做。
用的工具叫跟踪表。跟踪表里每个变量占一列,每执行一行代码占一行。分四步来做。
1. 在页边给程序的每一行编号,从 1 开始。 2. 画表格。第一列是“行号”,接着每个变量一列,最后一列叫“备注”,用来写发生了什么,例如“条件为真”,或者程序输出了什么。 3. 开始运行。每执行一行,就加一行记录:先写行号,再写这一行执行完之后每个变量的值。没有变化的值要从上一行抄下来,这样每一行单独看也能读懂。 4. 对没有值的变量,用两种记号。短横线(–)表示这个变量还不存在,问号(?)表示它已经存在,但还没有存进任何值。正确的 C 程序不会去读一个仍然是问号的变量。
循环语句会执行很多次,所以会有很多行记录。对于 for 循环,每当计算机走到条件判断,就加一行:写下更新部分(例如 i++)之后各变量的值,并在备注里写条件判断的结果。循环的最后一行记录,是条件为假的那一次,这之后循环体不会再执行。在 for 括号里声明的变量(例如 i)在循环结束后就不存在了,所以之后要写短横线。
if 语句只占一行记录。如果条件为假,下一行记录就是这个 if 右花括号之后的第一行代码。
如果程序调用了函数,就为这次调用另画一张小表,列出它的参数和它自己的变量,并给每次调用编号(f#1、f#2)。函数返回时,把返回值写在发起调用的那一行的备注里。
新词
手动跟踪(dry run) 不用计算机,拿笔手动运行一个程序。
跟踪表 一张表格:每个变量一列,每执行一行代码一行。
变量 一个有名字的位置,存放一个值,例如 int total。
条件判断 if 或 for 里那个是或否的问题,例如 i <= 4,答案是真或假。
取余(%) 除法之后剩下的部分。7 % 2 是 1,6 % 2 是 0。当 n % 2 等于 0 时,n 是偶数。 完整例子 C11 复制代码
#include <stdio.h>
int main ( void ) {
int total = 0 ;
for ( int i = 1 ; i <= 4 ; i ++ ) {
if ( i % 2 == 0 ) {
total = total + i ;
}
}
printf ( "%d \n " , total );
return 0 ;
}
先预测输出,再看结果 逐步讲解 先做准备。给代码编号 1 到 12:#include 那一行是 1,int main(void) { 是 3,最后一个 } 是 12。页面不显示行号,你要自己数。画四列:行号 i total 备注。计算机从 main 里的第一行开始,也就是第 4 行。 第 4 行 i = – total = 0 int total = 0; 创建 total 并存入 0。变量 i 还不存在,所以写短横线。 第 5 行 i = 1 total = 0 for 这一行创建 i 并存入 1。条件 1 <= 4 为真,所以执行循环体。 第 6 行 i = 1 total = 0 1 % 2 是 1,不是 0,所以 if 的条件为假,if 的内部被跳过。下一行记录又回到 for 那一行。 第 5 行 i = 2 total = 0 i++ 使 i 变成 2。条件 2 <= 4 为真。 第 6 行 i = 2 total = 0 2 % 2 是 0,条件为真,进入 if 内部。 第 7 行 i = 2 total = 2 total = 0 + 2,存入 2。 第 5 行 i = 3 total = 2 i++ 使 i 变成 3。条件为真。 第 6 行 i = 3 total = 2 3 % 2 是 1,条件为假。 第 5 行 i = 4 total = 2 i++ 使 i 变成 4。条件 4 <= 4 为真,因为 <= 包含 4。 第 6 行 i = 4 total = 2 4 % 2 是 0,条件为真。 第 7 行 i = 4 total = 6 total = 2 + 4,存入 6。 第 5 行 i = 5 total = 6 i++ 使 i 变成 5。条件 5 <= 4 为假,循环结束,循环体不再执行。 第 10 行 i = – total = 6 printf 输出 6 和一个换行。变量 i 在循环结束时就不存在了,所以又写短横线。程序输出 6。
常见错误 错误写法 /* meant: run the body 3 times */
for (int i = 0; i <= 3; i++) {
printf("*");
} 条件用的是 <=,所以 i 等于 3 时循环还会执行。i 的取值是 0、1、2、3,一共四轮。跟踪表能立刻显示这一点:数一数条件为真的那些行。要让循环体执行 n 次,就从 0 开始并使用 <。
正确写法 /* runs the body 3 times */
for (int i = 0; i < 3; i++) {
printf("*");
} 7 和 2 都是 int,所以 C 先做整数除法。7 / 2 的结果是 3,然后 C 把 3 存成 3.0,小数部分早就没有了。跟踪时要按程序里实际的类型来算,而不是按你希望的数字来算。让至少一边是 double:7 / 2.0 等于 3.5。
正确写法 double half = 7 / 2.0; 错误写法 int i = 3;
int a = i++; i++ 先把 i 的旧值交出去,然后才加 1。所以 a 是 3,i 是 4。如果你想要新值,就把自增单独写成一行放在前面。刚开始学时,让 ++ 独占一行,这样每次变化在表里都有自己的一行。
正确写法 int i = 3;
i++;
int a = i; 执行 a = b 之后,a 原来的值就没有了,所以 b = a 只是把 b 又抄回给 b。在表里,从那一行起两列的数字相同。交换两个值需要第三个位置,把其中一个值先保存起来。
正确写法 int t = a;
a = b;
b = t; 检查自己 为下面四行代码(编号 1 到 4)做一张跟踪表:int a = 5; int b = 2; a = a - b; b = a * b; 最后 a 和 b 各是多少?
先自己做,再看答案 第 1 行:a = 5,b = –。第 2 行:a = 5,b = 2。第 3 行:a = 3,b = 2。第 4 行:a = 3,b = 6。最后 a 是 3,b 是 6。第 4 行用的是 a 的新值 3,不是原来的 5。
不运行,直接预测:for (int i = 3; i > 0; i--) printf("%d ", i); 会输出什么?哪一行记录让循环结束?
先自己做,再看答案 输出 3 2 1,每个数后面有一个空格。i 取 3、2、1 时条件 i > 0 为真。第三轮之后,i-- 使 i 变成 0,条件为假,循环结束。0 从来没有被输出。
把上面的完整例子里 if 的条件改成 i % 2 == 1。写出新跟踪表里 total 这一列的变化,以及程序的输出。
先自己做,再看答案 现在循环体只在 i = 1 和 i = 3 时执行。total 这一列从 0 开始,i = 1 之后变成 1,i = 2 时条件为假,保持 1,i = 3 之后变成 4,i = 4 时保持 4。程序输出 4。
02
画出内存 学完你能 你能用一套固定的画法,画出 C 程序在任意一行时的栈、堆和静态数据区,并说出 free 会让图发生什么变化。
只看代码,是看不懂带指针的程序的。你需要一张图,说明每个值放在哪里、每个指针指向哪里。在纸上,这张图就是指针版的跟踪表。每次都用同一种画法,出错就会少很多。
C 把值放在三个地方。把它们画成三列,每列写一个标题:静态数据区、栈、堆。
静态数据区放整个运行期间都存在的东西:字符串字面量(例如 "hi")和全局变量。写成字面量的文字是只读的,往里面写数据是错误。
栈为当前正在运行的每个函数放一个栈帧。栈帧是一个方框,里面放这个函数的参数和局部变量。先画 main 的栈帧,放在栈这一列的最上面。每调用一次函数,就在它下面加一个新栈帧。函数返回时,把它的栈帧划掉,里面的东西全部消失。
堆放你用 malloc 要来的内存块。内存块会一直存在,直到你对它调用 free。它不属于任何一个函数的栈帧。
下面是画图的规则。
1. 变量是一个小方框。类型和名字写在旁边,值写在里面,例如 int n 和 3。还没有值的变量里写 ?。 2. 指针也是一个方框,它的值是一支箭头。箭头从方框里出发,指向它所指的东西。同时在方框里写上目标的标签,这样箭头交叉时你仍然能读出来。 3. 给每个堆内存块加标签:H1、H2、H3,按 malloc 被调用的顺序编号。在块的上方写标签、类型和大小,例如 H1 (int[3])。块里的每个单元画成一个带下标的方框:[0]、[1]、[2]。真实地址每次运行都会变,所以图里改用标签。 4. 赋值是复制方框里的内容。p = q 复制的是箭头,不是内存块。执行 int *q = p; 之后,有两个带箭头的方框,但内存块仍然只有一个。 5. 字符串是一个 char 数组,最后一格存 '\0'。char s[] = "hi" 在栈帧里画一个三格的数组。char *t = "hi" 在栈帧里画一个指针,它的箭头指向静态数据区里的只读文字。
free 会让图发生什么变化。free(p) 释放 p 所指的内存块。在纸上这样画:在内存块上打一个大叉,写上 freed。p 方框里的箭头和标签不要改,因为 free 不会改变 p。所有仍然带着这个标签的指针现在都是悬空指针:它们指向你已经不再拥有的内存。通过悬空指针读或写,是未定义行为,意思是语言对程序会发生什么不作任何保证。对同一个块调用两次 free 也是未定义行为。free(p) 之后写 p = NULL;,并在 p 的方框上画一道斜线。NULL 是不指向任何东西的指针值,free(NULL) 什么也不做。
泄漏是指:一个内存块从未被释放,而且图里已经没有任何箭头能到达它。在 main 的最后一行,检查每一个块。如果它上面没有叉,也没有箭头指向它,它就泄漏了。
新词
栈和栈帧 栈为每个正在运行的函数放一个栈帧。栈帧里是这次调用的参数和局部变量,调用返回时就消失。
堆 用 malloc 得到的内存块。它们一直存在,直到你 free 它们。
静态数据区 整个运行期间都存在的内存:字符串字面量和全局变量。
指针、&x 和 *p 指针是存放地址的变量。&x 是 x 的地址。*p 是 p 所指的那个东西。
标签 你在图里给堆内存块起的名字,例如 H1,用来代替真实地址。
空指针 NULL 不指向任何东西的指针值。可以拿它做比较,但不能通过它读写。
悬空指针 仍然存着已被释放的内存块(或已消失的栈帧)标签的指针。
未定义行为 犯了这种错之后,语言不作任何保证:程序可能崩溃、输出乱码,也可能看上去正常。
内存泄漏 一个从未被释放、也没有任何指针能到达的堆内存块。 完整例子 C11 复制代码
#include <stdio.h>
#include <stdlib.h>
int * make ( int n ) {
int * a = malloc ( n * sizeof ( int ));
if ( a == NULL ) {
return NULL ;
}
for ( int i = 0 ; i < n ; i ++ ) {
a [ i ] = ( i + 1 ) * 10 ;
}
return a ;
}
int main ( void ) {
int * p = make ( 3 );
if ( p == NULL ) {
return 1 ;
}
int * q = p ;
q [ 1 ] = 99 ;
/* A: draw the memory here */
printf ( "%d %d %d \n " , p [ 0 ], p [ 1 ], p [ 2 ]);
free ( p );
p = NULL ;
/* B: draw the memory here */
return 0 ;
}
先预测输出,再看结果 逐步讲解 画三列并写上标题:静态数据区、栈、堆。这个程序没有全局变量,也没有需要保存的字符串字面量,所以静态数据区是空的。(格式字符串 "%d %d %d\n" 也是字符串字面量,你可以把它写在那里。)
main 开始运行。在栈的最上面画 main 的栈帧,里面有两个方框:int *p,里面是 ?;int *q,里面是 ?。现在还没有存入任何东西。
调用 make(3)。在 main 的栈帧下面画一个新的栈帧,里面有两个方框:int n,里面是 3;int *a,里面是 ?。实参 3 被复制了一份,所以 n 是一个新方框,里面有它自己的 3。
malloc(n * sizeof(int)) 要来可以放 3 个 int 的空间。画出第一个堆内存块:H1 (int[3]),有 [0]、[1]、[2] 三个单元,里面都是 ?,因为 malloc 不会给它们赋值。a 得到一支指向 H1 的箭头,方框里写上标签 H1。
循环用 a[i] = (i + 1) * 10 把 10、20、30 存进各个单元。循环运行时,i 是 make 栈帧里的第三个方框。循环结束后,i 就消失了。
return a 把箭头复制出 make。划掉 make 的栈帧:n 和 a 都消失了。内存块 H1 还在,因为堆里的块不属于任何栈帧。在 main 里,p 现在存着 H1:画一支从 p 指向 H1 的箭头。
int *q = p; 复制箭头。q 里存着 H1,它的箭头也指向 H1。内存块仍然只有一个。q[1] = 99 修改的就是这一个块,所以单元 [1] 现在是 99。在标记 A 处,图是这样的:main 里 p = H1,q = H1,H1 里依次是 10、99、30。
printf 输出 p[0]、p[1]、p[2],也就是 10、99、30。程序输出 10 99 30。注意 p[1] 是 99,虽然写入时用的是 q。
free(p) 释放 H1:在块上打一个大叉并写上 freed。p 和 q 里仍然都存着 H1,所以它们现在都是悬空指针。接着 p = NULL; 在 p 的方框上画一道斜线。在标记 B 处:p 是 NULL,q 里仍存着 H1,是悬空指针,H1 已经 freed。程序后面没有再用 q,所以是安全的,但如果写 *q,就是未定义行为。没有任何块缺少叉号,所以没有泄漏。
常见错误 错误写法 int *q = p;
p [H1] ----> H1 (int[3]) | 10 | 20 | 30 |
q [H2] ----> H2 (int[3]) | 10 | 20 | 30 | 这张图把 q = p 画成了复制整个内存块。其实不是:复制的只是箭头。这样画,会让你以为通过 q 写数据不会影响 p。看看你复制的那个方框:里面存的是标签,所以复制的就是标签。
正确写法 int *q = p;
p [H1] ----+
v
H1 (int[3]) | 10 | 20 | 30 |
^
q [H1] ----+ 错误写法 free(p);
p [NULL]
H1 (int[3]) | 10 | 20 | 30 | free 不会改变 p。这张图说 p 变成了 NULL,而且内存块还完好,这两点都不对。p 里仍然是 H1,而内存块已经不再属于你。只有 p = NULL 这样的赋值才会改变 p。
正确写法 free(p);
p [H1] ----> H1 (int[3]) FREED
| X | X | X |
p is now dangling; write p = NULL 错误写法 char *s = "hi";
s[0] = 'H'; char *s 只是在栈帧里放了一个指针,它的箭头指向静态数据区里的只读文字。往那段文字里写数据是未定义行为,通常会使程序崩溃。char s[] 则在栈帧里做了一份三格的私有副本,可以修改。
正确写法 char s[] = "hi";
s[0] = 'H'; 错误写法 int *bad(void) {
int x = 5;
return &x;
} x 放在 bad 的栈帧里,bad 返回时这个栈帧就被划掉了。返回出去的箭头于是指向一个已经不存在的栈帧。把两个栈帧画出来,你就能看见这一点。应当直接返回值,或者把值放进堆里的内存块(malloc 的块比栈帧存在得久,但之后调用者必须 free 它)。
正确写法 int good(void) {
int x = 5;
return x;
} 检查自己 画出来:int a = 4; int *p = &a; *p = 9; p 的箭头指向哪里?最后 a 是多少?(&a 表示 a 的地址,*p 表示 p 所指的那个东西。)
先自己做,再看答案 两个方框都在 main 的栈帧里。p 的箭头指向 a 的方框,在栈上,不在堆里。*p = 9 把 9 写进 p 所指的方框,也就是 a。所以 a 是 9。
画出来:int *p = malloc(sizeof(int)); *p = 7; free(p); 下一行可以用 *p 吗?下一行应该写什么?
先自己做,再看答案 内存块 H1 (int) 里存着 7,随后被划掉并标上 freed。方框 p 里仍然是 H1,所以 p 是悬空指针。使用 *p 是未定义行为。下一行应该是 p = NULL;。
找出泄漏:int *p = malloc(4 * sizeof(int)); p = malloc(8 * sizeof(int)); free(p); 画出堆,说明哪个块泄漏了,以及怎么改。
先自己做,再看答案 一共有两个块。第一次 malloc 得到 H1 (int[4]),p 里是 H1。第二次 malloc 得到 H2 (int[8]),p 的箭头移到了 H2。已经没有任何箭头能到达 H1,而且它从未被释放,所以 H1 泄漏。free(p) 只释放了 H2。改法:在第二次 malloc 之前先 free(p),或者用另一个指针保存第一个箭头,最后两个都释放。
03
要背熟的语法 学完你能 你能凭记忆写出下面八个 C 骨架,从最短的开始,每个分号和括号都不缺。
有些代码没法当场推出来,你必须不看资料就知道它的形状。这一课的八个骨架,是 C 里反复要用的。它们都很短,可以背下来。按下面的顺序学,从最短的开始,上一个背牢了,再加下一个。
1. 头文件包含。2. main。3. 函数。4. for 循环。5. 带 typedef 的 struct。6. 带 NULL 检查的 malloc,以及 free。7. 字符串函数。8. printf 的格式符。
背诵的办法:盖住页面,在纸上默写这个骨架,再和页面逐个字符对照。只有完全对、中途没有偷看,才打勾。明天再来一遍。连续三天都能写对的骨架,才算学会。
下面的程序包含全部八个骨架,每个骨架旁边有一条形如 (1) 的注释。先读一遍,再盖住它,在你自己的纸上把每个骨架单独默写出来,每个只写几行。
字符串函数:目标总是放在第一个,就像等号的左边。strcpy(dest, src) 把 src 复制到 dest。strcat(dest, src) 把 src 接在 dest 的末尾。strncpy(dest, src, n) 和 strncat(dest, src, n) 把限制长度 n 放在最后一个参数。要小心:当 src 有 n 个字符或更多时,strncpy 不会写入 '\0'。strcmp(a, b) 在两个字符串相等时返回 0,a 排在 b 前面时返回负数,a 排在 b 后面时返回正数。因为 0 表示相等,所以要写成 strcmp(a, b) == 0。strlen(s) 返回不含 '\0' 的长度。目标数组必须已经有足够的空间,放得下文字和 '\0'。
printf 的格式符:%d 是 int,%u 是 unsigned int,%ld 是 long,%zu 是 size_t(strlen 的返回类型),%f 是 double,%.2f 是保留两位小数的 double,%c 是一个 char,%s 是字符串,%p 是指针,%x 是十六进制的 unsigned int,%% 是百分号。每个格式符都需要一个类型匹配的参数,顺序也要一致。
新词
骨架 一段代码固定不变的形状,细节留待填写。
头文件 像 stdio.h 这样的文件,告诉编译器某个库函数是什么样子。
格式符 printf 格式字符串里形如 %d 的记号,标出某个值要放的位置。
目标 字符串函数把结果写进去的数组。它总是第一个参数。 完整例子 C11 复制代码
/* (1) includes */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* (5) struct with typedef */
typedef struct {
char name [ 20 ];
int score ;
} Player ;
/* (3) a function */
int add ( int a , int b ) {
return a + b ;
}
/* (2) main */
int main ( void ) {
/* (4) for */
int total = 0 ;
for ( int i = 0 ; i < 3 ; i ++ ) {
total = add ( total , i );
}
Player p ;
strcpy ( p . name , "Mia" );
p . score = total ;
/* (6) malloc, NULL check, free */
int * a = malloc ( 3 * sizeof ( int ));
if ( a == NULL ) {
return 1 ;
}
for ( int i = 0 ; i < 3 ; i ++ ) {
a [ i ] = i * 10 ;
}
/* (7) string functions */
char word [ 20 ];
strcpy ( word , "hello" );
strcat ( word , "!" );
if ( strcmp ( word , "hello!" ) == 0 ) {
/* (8) printf specifiers */
printf ( "%s %d %c %.2f %zu \n " , p . name , p . score , word [ 0 ], 3.14159 , strlen ( word ));
}
free ( a );
return 0 ;
}
先预测输出,再看结果 逐步讲解 (1) 头文件。#include <stdio.h> 提供 printf。#include <stdlib.h> 提供 malloc 和 free。#include <string.h> 提供 strcpy、strcat、strcmp 和 strlen。用尖括号,行末没有分号。写完之前,把用到的每个库函数扫一遍,确认各自的头文件都写了。
(2) main。int main(void) { ... return 0; }。返回类型是 int。圆括号里的 void 表示 main 不接收参数。return 0; 表示成功。检查花括号是否配对。
(3) 函数。返回类型 函数名(类型 参数, 类型 参数) { ... return 值; }。这里是 int add(int a, int b) { return a + b; }。每个参数都要写自己的类型。函数必须写在 main 的上面,否则要在 main 上面用“首行加分号”的形式先声明。
(4) for。for (int i = 0; i < n; i++) { ... }。圆括号里有三部分,用两个分号隔开:起点、条件、步进。右括号后面没有分号。要执行 n 次,就从 0 开始并使用 <。
(5) 带 typedef 的 struct。typedef struct { 类型 字段; 类型 字段; } 名字; 右花括号后面的分号不能省。结构体变量用点访问字段,例如 p.score。如果手里是指向结构体的指针,则用箭头,例如 ptr->score。
(6) malloc、NULL 检查和 free。类型 *p = malloc(个数 * sizeof(类型)); if (p == NULL) { return 1; } ... free(p); 写成“个数乘以元素类型的 sizeof”。malloc 之后马上检查 NULL,因为内存不够时 malloc 会返回 NULL。每个 malloc 对应恰好一个 free。
(7) 字符串函数。目标放第一个:strcpy(dest, src)、strcat(dest, src)。比较用 strcmp(a, b) == 0。长度用 strlen(s)。数组 word 有 20 格,放 "hello!"(6 个字符加 \0)绰绰有余。
(8) printf。printf("格式", 参数1, 参数2, ...); 每个参数对应一个格式符,顺序一致。这里 %s 对应 p.name,%d 对应 p.score,%c 对应 word[0],%.2f 对应 3.14159,%zu 对应 strlen(word)。程序输出 Mia 3 h 3.14 6。
常见错误 错误写法 char buf[10];
strcpy("hi", buf); 目标是第一个参数,就像 x = y 的左边。这里的 "hi" 放在只读的静态数据区里,这个调用却想往里面写数据。写的时候念出来:复制到第一个,从第二个复制。
正确写法 char buf[10];
strcpy(buf, "hi"); 错误写法 if (strcmp(a, b)) {
printf("same\n");
} 两个字符串相等时 strcmp 返回 0,而 if 把 0 当作假。所以这段代码只有在两个字符串不同的时候才会输出 same。一定要自己把返回值和 0 比较。
正确写法 if (strcmp(a, b) == 0) {
printf("same\n");
} 错误写法 printf("%d %s\n", 3.5, 'a'); %d 需要 int,传进去的却是 double;%s 需要字符串,传进去的却是单个 char。输出会是乱码,或者程序崩溃。每个格式符都要和对应参数的类型匹配:double 用 %f,char 用 %c。
正确写法 printf("%f %c\n", 3.5, 'a'); malloc 数的是字节,不是 int 的个数。3 个字节放不下 3 个 int(通常需要 12 个字节),写 a[1] 就会越过内存块的末尾。永远写成“个数乘以元素类型的 sizeof”。
正确写法 int *a = malloc(3 * sizeof(int)); 检查自己 在纸上默写一个函数 max2:接收两个 int,返回较大的那个。然后和函数骨架对照。
先自己做,再看答案 int max2(int a, int b) { if (a > b) { return a; } return b; } 对照检查:返回类型 int;两个参数各有类型;花括号配对;每个 return 后有分号;if 的圆括号后面和函数的右花括号后面都没有多余的分号。
写出三行代码:声明一个叫 color 的 char 数组,把 "red" 复制进去,再把 "dish" 接到末尾。color 至少要有多少格?
先自己做,再看答案 char color[8]; strcpy(color, "red"); strcat(color, "dish"); 至少 8 格:reddish 有 7 个字符,再加一格放 \0。
写出为 5 个 double 申请空间的那一行,处理失败的两三行,以及把内存还回去的那一行。
先自己做,再看答案 double *d = malloc(5 * sizeof(double)); if (d == NULL) { return 1; } …… 最后 free(d); NULL 检查紧跟在 malloc 后面,free 放在最后一次使用之后。
04
手写时常见的错误 学完你能 你能把自己写的 C 答案像编译器那样再读一遍,抓住本来会丢分的小错。
在计算机上,编译器会在第一个漏掉的分号处拦住你。在纸上,没有人拦你,而这些小错每一个都会丢分,尽管它们和你是否理解题目毫无关系。所以你必须自己当自己的编译器。
答案写完之后,再读第二遍。这一遍不要问逻辑对不对,只问形式对不对。照下面的清单,用笔逐项打勾。
1. 每条语句都以分号结尾。struct 的定义在右花括号后面也要有分号。 2. 每个 ( [ { 都有配对的另一半。把花括号成对地数一遍。 3. 每个变量在使用之前都已声明,并且带着类型。 4. 用到的每个库函数都写了头文件。 5. = 是存入一个值,== 是提出一个问题。检查每一个 if 和 while。 6. 字符串用双引号,单个字符用单引号。 7. 每个 printf 格式符都有类型正确的参数。 8. 数组下标从 0 到 n - 1。n 个字符的字符串需要 n + 1 格。 9. 点用来访问结构体变量的字段,箭头用来通过指针访问字段。 10. for 或 while 那一行的圆括号后面不能有分号。
下面是最容易丢分的四组错误。每组里第一块是常见的错误写法,第二块是改正后的写法。
常见错误 错误写法 typedef struct {
int x;
int y;
} Point
for (int i = 0; i < 3; i++);
{
printf("%d\n", i);
} 两处分号错误。struct 定义的右花括号后面需要分号,缺了它,编译器会在下一行报错。for 括号后面直接加分号,则循环体是空的:循环空转三次,下面花括号里的代码只执行一次(而且连 i 都看不到)。
正确写法 typedef struct {
int x;
int y;
} Point;
for (int i = 0; i < 3; i++) {
printf("%d\n", i);
} 错误写法 if (n = 0) {
printf("empty\n");
}
char c = "a"; if (n = 0) 把 0 存进 n,而 0 是假,所以循环体永远不会执行,n 还被改掉了。提问要用 ==。另外 "a" 是字符串(两格:a 和 \0),'a' 才是单个 char。char 变量要用单引号。
正确写法 if (n == 0) {
printf("empty\n");
}
char c = 'a'; 错误写法 Node *p = head;
printf("%d\n", p.value);
int n;
scanf("%d", n); p 是指向结构体的指针,所以要用箭头 p->value 访问字段,不能用点。scanf 需要 n 的地址,也就是 &n,才能把读到的数存进去。没有 & 时,scanf 会把 n 的值当作地址,程序通常会崩溃。
正确写法 Node *p = head;
printf("%d\n", p->value);
int n;
scanf("%d", &n); 错误写法 char s[3] = "abc";
if (s == "abc") {
printf("same\n");
} 两处字符串错误。"abc" 需要四格:三个字母加 \0;只有三格就没有结束标记,printf("%s") 会越过数组继续读。而且 == 比较的是两个地址,不是字母,几乎不会为真。比较字母要用 strcmp(s, "abc") == 0,并且要 include <string.h>。
正确写法 char s[4] = "abc";
if (strcmp(s, "abc") == 0) {
printf("same\n");
} 检查自己 找出所有的错误(共四处):typedef struct { int x } Pt; int main(void) { Pt p; p.x = 1; if (p.x = 1) { printf("one\n") } return 0; }
先自己做,再看答案 1. int x 后面缺分号,应写 int x;。2. if 里是存入而不是提问,应写 p.x == 1。3. printf("one\n") 后面缺分号。4. 用到 printf,最上面要写 #include <stdio.h>。按那十条清单依次检查,四处都能找到。
改正字符串的写法:char name[4] = "Emma"; if (name == "Emma") { puts("same"); }
先自己做,再看答案 char name[5] = "Emma";(四个字母加 \0),以及 if (strcmp(name, "Emma") == 0) { puts("same"); },最上面加 #include <string.h>。
改正遍历链表的循环:Node *n = head; while (n != NULL); { n = n.next; }
先自己做,再看答案 有两处错误。while 括号后面的分号使循环体为空,而 n 又永远不变,所以循环永远不会结束。另外 n 是指针,字段要写成 n->next。正确写法:while (n != NULL) { n = n->next; }
05
给自己打分 学完你能 你能对照评分标准诚实地给自己的手写答案打分,找出分数丢在哪里,安排明天的练习,并在限时下写代码。
在纸上,一页写满的答案看起来像是完成了,其实可能是错的。给自己打分能解决这个问题。你拿答案去对照评分标准。评分标准是一组条目,每条值若干分,每条描述一件你能在答案里看到的东西。要严格。真正算数的分数,是一个陌生人会给你的分数。
怎样逐条读评分标准。
1. 读一条。问自己:一个正确的答案里,我到底会看到什么? 2. 在你的答案里找到那样东西,用笔尖点住它。指得出来,就打勾并写下这条的分数;指不出来,这一条就是零分。 3. “差不多”不给半分。只有评分标准写明怎么拆分时,才给一条的部分分数。 4. 检查每一条路径。像“释放内存块”这样的条目,只要你的代码里有一条路线(例如提前 return)跳过了 free,就是零分。 5. 把各条加起来,把总分写成“得分/满分”的形式放在页面上。
怎样找出分数丢在哪里。把每一个丢掉的分放进四个格子之一。思路:我不知道怎么解。记忆:我知道,但记不起语法或函数。小错:在我本来会的东西上犯的小错误,例如漏了分号或写错了符号。时间:我没写完。数一数每个格子。最大的格子告诉你该练什么。思路:重读对应的课。记忆:多做“盖住再默写”。小错:用上一课的编译器检查清单,再加一个很小的测试。时间:练习先做计划。
第二天要做什么。第一,在空白页上不看资料,把同一道题的答案重写一遍。第二,做一道同类型的不同题目。第三,把两份答案再各打一次分。三天后和一周后再重复。当最大的格子换了,说明你在进步。
怎样在限时下写。
1. 先做计划。用掉最初的十分之一时间。写出函数头,再写三四行说明步骤的注释。不要跳过这一步。 2. 代码行之间空一行。你会需要空间补写漏掉的一行。 3. 变量名要清楚:用 count,不要用 c;用 head,不要用 h。名字清楚,就不会把两个变量混在一起。 4. 用一个很小的输入手算测试。代码写完后,用最小的情况走一遍:只有一个元素、空字符串、n 等于 1。这只要一分钟,却能找出大部分边界错误。 5. 大约每分花一分钟。某一部分卡住了,就写一行注释说明你打算怎么做,然后往下写,回头再补。 6. 留出两分钟做编译器检查。
在“练习”页面里,题型为“手写代码”的题目有手写模式:有计时器、参考答案,以及可以打勾的评分标准。完全按这一课的办法用它:先在纸上写,再打分。
新词
评分标准 一组条目,每条值若干分,每条描述一件你能在答案里指出来的东西。
边界情况 最小或最极端的输入,例如只有一个元素或空字符串,代码最容易在这里出错。
编译器检查 把答案再读一遍,只检查形式:分号、括号、类型、头文件。 完整例子 C11 复制代码
#include <stdio.h>
/* A student's answer. It has one bug. */
int smallest ( const int * a , int n ) {
int best = 0 ;
for ( int i = 0 ; i < n ; i ++ ) {
if ( a [ i ] < best ) {
best = a [ i ];
}
}
return best ;
}
int main ( void ) {
int test [] = { 5 , 3 , 8 };
printf ( "%d (expected 3) \n " , smallest ( test , 3 ));
return 0 ;
}
先预测输出,再看结果 逐步讲解 题目:写 int smallest(const int *a, int n),返回 n 个 int 里最小的一个(n 至少为 1)。评分标准有四条,每条一分。(1) 函数头正确:返回 int,接收数组和个数。(2) 查找从 a[0] 开始,而不是从一个随便编的数开始。(3) 循环访问每个元素,且不超出数组。(4) 发现更小的值时更新,并返回结果。
给第 1 条打分。函数头 int smallest(const int *a, int n) 是对的,用笔点住它。+1。
给第 2 条打分。答案把 best 从 0 开始。笔下找不到任何“从 a[0] 开始”的东西。0 分。
给第 3 条打分。循环 for (int i = 0; i < n; i++) 访问 a[0] 到 a[n - 1],并在 a[n] 之前停止。+1。
给第 4 条打分。if (a[i] < best) best = a[i]; 和 return best; 都有。+1。
得分:4 分里得 3 分。现在手算一个很小的测试:{5, 3, 8}。best 从 0 开始。5 < 0、3 < 0、8 < 0 都为假,所以函数返回 0,而不是 3。程序输出 0 (expected 3),证实了这一点。丢掉的那一分放进“思路”格:起始值没有想清楚。改法是 int best = a[0];,并让循环从 i = 1 开始。
常见错误 错误写法 for (int i = 1; i < n - 1; i++) {
if (a[i] < best) {
best = a[i];
}
} 条件 i < n - 1 提前一个元素停止,所以最后一个元素从来没被看过。用一个“最小值在最后”的小测试能立刻发现:{5, 8, 3}。每次都要检查第一个和最后一个元素。
正确写法 for (int i = 1; i < n; i++) {
if (a[i] < best) {
best = a[i];
}
} 错误写法 int sum;
for (int i = 0; i < n; i++) {
sum = sum + a[i];
} sum 只声明了,从没赋值,所以它在跟踪表里是问号,而第一次加法就读了它,结果是乱码。每个累加用的变量,都要在声明的那一行给它一个初始值。
正确写法 int sum = 0;
for (int i = 0; i < n; i++) {
sum = sum + a[i];
} 错误写法 int total(const int *a, int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum = sum + a[i];
}
printf("%d\n", sum);
} 题目说的是“返回总和”,这个函数却把它打印出来,什么也没有返回,调用者拿到的是乱码。这是读题的疏漏:写之前先在题目里找出“返回”“打印”“读取”这些确切的词,并划上线。
正确写法 int total(const int *a, int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum = sum + a[i];
}
return sum;
} 检查自己 评分标准里有一条“在每条路径上都释放内存块”。你的函数在末尾释放了它,但有一个提前 return 跳过了 free。这一条得多少分?为什么?
先自己做,再看答案 0 分,除非评分标准写明了怎么拆分。把笔点在提前 return 那条路径的 free 上,那里没有。要检查代码里的每一条路径,不能只看主路径。
你丢了三分:一分是因为记不得 scanf 里 & 该放哪里,一分是漏了分号,一分是时间不够。各放进哪个格子?明天做什么?
先自己做,再看答案 记忆、小错、时间。明天:凭记忆把 scanf 那一行写三遍;每份答案都用那十条编译器检查清单过一遍;写之前先用最初十分之一的时间做计划。
写出你会用来手算测试“返回字符串长度的函数”的几个很小的输入。
先自己做,再看答案 空字符串(长度 0)、只有一个字符的字符串(长度 1),以及一个普通的单词。如果题目允许,还有 NULL。把每个都用跟踪表一行一行地走一遍。
独立整理的学习笔记,不是官方课程材料。