《C陷阱与缺陷》读书笔记
前言及导读
1. "It is much harder to understand the best ways of using what one already knows."(运用之妙,存乎一心。)
2. "One way to gain such understanding is to learn what not to do."(学习哪些是不应该做的,不失为一条领悟运用之道的路子。)
3. 程序设计错误反映了程序与程序员对该程序的“心智模式”不一致。可以按以下方面对程序错误进行划分:词法分析、语法分析、语义分析、链接、库函数、C预处理器及可移植性。
一、词法“陷阱”
1. C语言词法分析的“贪心法”
如果可能,继续读入下一个字符,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。
2. 除了字符串与字符常量,符号的中间不能嵌入空白。例如:"/*"表示注释的开始,而"/ *"则不是。
二、语法“陷阱”
1. 在理解复杂的函数声明时,要注意类型强制转换、解引用等用法。
2. 运算符的优先级
(1) 优先级最高: () [] -> .
(2) 优先级最低: 逗号运算符,
(3) 优先级第二高:单目运算符,包括! ~ ++ -- (type) * & sizeof
(4) 优先级第二低:赋值运算符
(5) 算术运算符>移位运算符>关系运算符>逻辑运算符
(6) 关系运算符!=, ==的优先级低于其他关系运算符。
(7) 自右向左结合的运算符:赋值运算符、单目运算符、三目运算符(?:)。
3. 注意作为语句结束标志的分号,if或while语句多了或少了一个分号,可能会导致一个潜伏很深的Bug。
4. 悬挂else可能会引发问题。C语言中的else始终与同一对括号内最近的未匹配的if结合,因此当if, else相互嵌套时应使用括号表明结合方式。
5. 函数名后忘了加括号
func(); // call function func() func; // count the adress of function func() rather than calling it
三、语义“陷阱”
1. 指针与数组
(1) C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确定下来。但由于C语言中数组的元素可以是任意类型,故可由一维数组仿真出多维数组。
(2) 对于一个数组,我们只能做两件事:确实该数组的大小,以及获得指向该数组下标为0的元素的指针,其他有关数组的操作,实际上是通过指针进行的。
(3) 若两个指针指向同一数组中的元素,我们可以将这两个指针相减,否则将两个指针相减是无意义的。
(4) C语言中会自动将作为参数的数组声明转换为相应的指针声明,所以在函数内部对数组名使用sizeof得到的实际是指针的长度。
(5) "extern char *hello" 与"extern char hello[]"是不同的。
2. 空指针并非空字符串。
char *p1 = NULL; char *p2 = "hope"; strcmp(p2, p1); // error printf(p1); // error printf("%s", p); // error
3. 求值顺序
(1) 求值顺序与运算符优先级不是同一回事。
(2) C语言中只有四个运算符(&& || ?: ,)存在规定的求值顺序:
a. &&和||先对左操作数求值,需要时再求右操作数。
b. "A?B:C"先求A,根据A的值求B或C。
c. 逗号运算符先求左操作数,然后该值被丢弃,再求右操作数。
(3) 函数的参数的求值顺序是不确定的。
f(x, y); // it is uncertain to count x or y first. g((x,y)); // g has only one parameter y.
(4) 一般而言使用自增运算符的变量不应该在同一表达式中多次出现。
y[i] = x[i++]; // error, it's uncertain whether the address of y[i] would be counted before i++.
四、链接
1. 不能在两个不同的源文件中定义同名的变量或函数,否则连接时会弹出“禁止重复定义”的错误。
2. 使用static修饰符可将标识符的作用域限制在一个源文件内。
五、库函数
1. getchar()函数一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回EOF。其返回值为int型,若用char型变量来接收,可能会导致错误。
char c; while ((c = getchar()) != EOF) // error. putchar(c);
2. 在调用库函数时,我们应该首先检测作为错误指示的返回值,确定程序执行已经失败,然后再检查errno来搞清楚错误原因。
3. fseek(), fread(), fwrite()函数
为保持与过去不能同时进行读写操作的向下兼容性,一个输入操作不能随后紧跟一个输出操作,反之亦然。如果要同时进行输入和输出操作,必须在其中插入fseek函数的调用。
FILE *fp; struct record rec; ... while (fread((char *)&rec, sizeof(rec), 1, fp) == 1) { ... // do some operation on rec. if (/* rec needs to be rewrited */) { fseek(fp, -(long)sizeof(rec), 1); fwrite((char *) &rec, sizeof(rec), 1, fp); fseek(fp, 0L, 1); // this sentence is needed, or the program woule be error. } }
4. signal函数
信号非常复杂棘手,而且具有一些从本质上而言不可移植的特性,因此我们最好让signal处理函数尽可能简单,并将它们组织在一起。
六、预处理器
1. 不能忽视宏定义中的空格。
#define f (x) ((x)-1) // 等价于f = (x) ((x)-1).
2. 宏的副作用
(1) 引起与优先级有关的问题。(多用括号)
(2) 宏展开可能产生非常庞大的表达式。
#define max(a,b) ((a)>(b)? (a) : (b)) max(a, max(b, max(c,d))); // this sentence will be too long after macro expansion.
(3) 宏并不是语句,编程者有时会试图定义宏的行为与语句类似,但这样做实际上往往很困难。
#define assert if(!(e)) assert_error(__FILE__, __LINE__) if (x > 0 && y > 0) assert(x>y); else assert(y>x); // after macro expansion,the match of if and else would be wrong. if (x > 0 && y > 0) if(!(x>y)) assert_error("foo.c", 37); else // matches if (!(x>y)) if(!(y>x)) assert_error("foo.c", 39);
(4) 宏并不是类型定义。
#define T1 struct foo * T1 a, b; // a's type is (struct foo *), while b's type is (struct foo).
七、可移植性缺陷
1. C语言对各种不同类型的整数的相对长度的规定:
(1) short, int, long三种类型的整数其长度是非递减的。
(2) 一个普通整数(int型)足够大以容纳任何数组下标。
(3) 字符长度由硬件特性决定。
2. 字符转换
(1) 把字符转换为一个较大的整数时,某些编译器可能会作为有符号数处理,另一些编译器则可能作为无符号数处理。如果编程者关注一个最高位是1的字符其数值究竟是正是负,可将该字符声明为无符号字符。
(2) 对char c,使用(unsigned int)c并不确保得到与c等价的无符号整数,因为c将先转换为int型,应该使用(unsigned char)c。
3. 在向右移位时,空出的位可能是由0填充,也可能由符号位的副本填充,视编译器而定。如果编程者关注向右移位时空出来的位,那么可将操作的变量声明为无符号类型,那么空出的位都会被设置为0.
4. 如果被移位的对象长度是n位,那么移位计数的范围是[0,n), 这样限制是为了在硬件上高效地实现移位运算。
5. 对非负整数num, "num /= 2"与"num >>= 2"效果相同,但后者执行速度要快得多。
6. NULL指针并不指向任何对象。因此,除非是用于赋值或比较,出于其他任何目的使用NULL指针都是非法的。
7. 在进行除法运算(a/b=q...r)时,我们希望满足3条性质:(1) q*b+r=a; (2) 若a改变符号,则q改变符号但绝对值不变; (3) b>0时0<=r<b。但实际上这三条性质不可能同时成立,C语言中只保证性质(1)及“a>=0且b>0时, 0<=r<b”。因此,在整数除法中应避免被除数为负的情形。
八、减少程序错误的建议
1. 直截了当地表明意图。
当你编写代码的本意是希望表达某个意思,但这些代码有可能被误解成另一种意思时,请使用括号或其他方式让你的意图尽可能清楚明了。例如:使用括号使优先级更加明确。
有时候我们还应该预料哪些错误可能出现,在编程时提前预防。例如:把常量写在判断相等的比较表达式的左侧。
2. 考察最简单的特例。
无论是构思程序的工作方式还是测试程序的工作情况,这一原则都是适用的。在设计程序时我们应考虑各种特例,比如输入为空或只有一个元素等等。
3. 使用不对称边界(用第一个入界点和第一个出界点表示数值范围)。注意C语言中数组下标从0开始。
4. 注意可移植性。
我们应该坚持只使用C语言中众所周知的部分,避免使用那些“生僻”的语言特性。这样我们能够很方便地将程序移植到一个新的机器或编译器,而且“遭遇”到编译器Bug的可能性也会大大降低。
5. 防御型编程。对程序用户和编译器实现的假设不要太多。