《C陷阱与缺陷》作者Andrew R. Koenig是C/C++的大师。书中所涉及的问题均来自作者的编程实践,是很多人常犯错误的经验总结,是一本短小精悍的阅读材料。由于创作于上世纪80年代,一些内容现在看来也显得陈旧了。
全书根据层次由浅至深分成了8个章节。
1. 词法陷阱
1.1 =不同于==
assign和equal操作符要分清。
if (x = y) foo();
应该显式写成
if ((x = y) != 0) foo();
1.2 &和|不同于&&和||
bitwise和logic要分清。
1.3 词法分析中的贪心法
x = a---b;
相当于
x = a-- - b;
y = x/*p;
准二义性会导致出错,因为/*被识别成了注释开始。
a=-1;
在老版本编译器会被理解成
a =- 1;
也就是a--;
那么,练习里的问题: a+++++b会被识别成什么呢? ((a++)++)+b,编译出错。要是加上空格:a++ + ++b就没问题了。
1.4 整型常量
010是8进制,和10不一样。
1.5 字符与字符串
'c'和"string"要分清,"string"后面还有个额外的'\0'。
int i = 'yes';
i的值是多少? 's' + 'e' << 8 + 'y' << 16。
2. 语法陷阱
2.1 理解函数声明
如何写一段程序在计算机启动时调用首地址为0位置的子程序呢?
(*(void(*)())0)();
这里要用一个函数指针对0做类型转换。
fp是一个函数指针,那么
fp();
相当于
(*fp)();
signal库函数是怎么定义的呢?
void (*signal(int, void(*)()))(int);
可以用typedef来简化:
typedef void (*HANDLER)(int); HANDLER signal(int, HANDLER);
2.2 运算符的优先级问题
下面的代码和预期不符:
if (flags & FLAG != 0) ...
r = hi << 4 + low;
while (c = getc(in) != EOF) putc(c, out);
背不下这张有15个优先级的表,就勤加括号吧。
运算符 | 解释 | 结合方式 |
() [] -> . | 括号(函数等),数组,两种结构成员访问 | 由左向右 |
! ~ ++ -- -
* & (类型) sizeof |
否定,按位否定,增量,减量,负,
间接,取地址,类型转换,求大小 |
由右向左 |
* / % | 乘,除,取模 | 由左向右 |
+ - | 加,减 | 由左向右 |
<< >> | 左移,右移 | 由左向右 |
< <= >= > | 小于,小于等于,大于等于,大于 | 由左向右 |
== != | 等于,不等于 | 由左向右 |
& | 按位与 | 由左向右 |
^ | 按位异或 | 由左向右 |
| | 按位或 | 由左向右 |
&& | 逻辑与 | 由左向右 |
|| | 逻辑或 | 由左向右 |
? : | 条件 | 由右向左 |
= += -= *= /=
&= ^= |= <<= >>= |
各种赋值 | 由右向左 |
, | 逗号(顺序) | 由左向右 |
下面的代码是什么意思?
*pa++;
*pb->f();
第一行等价于*(pa++); 指针pa先自增,然后返回pa自增前指向的值。
第二行等价于*(pb->f()); 先调用pb->f(),然后返回函数f()调用返回值指向的值。
2.3 注意作为语句结束标志的分号
下面的几段代码都有逻辑错误:
if (x[i] > big); big = x[i];
if (n<3) return logrec.date = x[0]; logrec.time = x[1]; logrec.code = x[2];
struct logrec { int date; int time; int code; } main() { ... }
2.4 switch语句
控制流程能够依次通过并执行各个case部分,所以绝大多数时候不要忘记加break; 好处是同样逻辑代码不用写多次。
2.5 函数调用
若f是一个函数,那么 f; 只计算f的地址,并不调用函数。
2.6 dangling else引发的问题
if (x == 0) if (y == 0) error(); else { z = x + y; f(&z); }
Coding standard: 即使一条语句也加括号就没这个问题咯。
练习里的问题:
int days[] = {31, 28, 31,};
为什么初始化列表会有一个多余的逗号?
3. 语义陷阱
3.1 指针与数组
C语言中只有一维数组,大小在编译期就确定了。
int calendar[12][31]; int (*monthp) [31]; monthp = calendar;
那么calendar[4]代表什么?
数组名的两种用途:
- sizeof的参数计算数组大小
- 代表指向数组下标为0的元素的指针
a[i] 和 *(a+i)是等价的。又因为a+i和i+a是一样的,所以a[i]和i[a]也等价。。。
3.2 非数组的指针
char *r; r = malloc(strlen(s), strlen(t) + 1); if (!r) { complain(); exit(1); } strcpy(r, s); strcpy(r, t); ... free(r);
注意3点:malloc可能失败;字符串最后有个'\0',strlen不算在里面;动态分配内存最后要free。
3.3 作为参数的数组声明
此时和指针声明等价。
main(int argc, char* argv[]) {}
和下面的表达等价
main(int argc, char** argv) {}
3.4 避免举隅法synecdoche
复制指针并不复制指针指向的数据。
3.5 空指针并非空字符串
#define NULL 0
空指针不能被解引用(dereference),空字符串是""。
3.6 边界计算与不对称边界
前闭后开区间。
int a[10]; for (int i = 0; i < 10; i++) a[i] = 0;
下界是入界点,上届是出界点。取值范围大小是上下界之差;取值范围为空则上下界相等。
有一数组buffer[N]; 地址&buffer[N]可用来赋值和比较,但不能引用该元素。
--n >= 0 可能比n-- > 0 高效。
作者长篇大论在这里写了一个例子,练习里又提到,都没看进去。
3.7 求值顺序
if (count != 0 && sum/count < smalaverage) printf("average < %g\n", smallaverage);
条件表达式短路。
只有4个运算符&&、||、?:和, 规定了求值顺序。分隔参数的逗号不是运算符,f(x, y)中x和y的计算顺序不定,而g((x, y))先计算x再计算y,实参是y的值。
i = 0; while (i < n) y[i] = x[i++];
结果太依赖于求值顺序。。。
3.8 运算符&&、||和!
巧合可能不出错,但不要和bitwise操作符混用了(1.2)。
3.9 整数溢出
有符号vs无符号。两个操作数都是有符号可能发生溢出。<limit.h>里定义了INT_MAX。
if (a > INT_MAX - b) complain();
3.10 为main函数提供返回值
告知操作系统成功(0)还是失败(非0)。return 0; 或 exit(0);
4. 链接
4.1 什么是链接器?
分别编译(separate compilation)是一个重要思想。
典型的链接器把由汇编器和编译器生成的若干目标模块整合成一个被称为载入模块或可执行文件的实体,该实体能够被操作系统直接执行。输入一组目标模块和库文件,输出一个载入文件。
非static的函数和变量都是外部对象。链接器的一个重要作用是处理命名冲突。
链接器必须解析所有定义的对外部对象的引用并标记这些对象不是未定义的。
lint
4.2 声明与定义
int a;
全局变量a定义同时默认初始化为0,
int a = 3;
定义的同时初始化为3,
extern int a;
只是声明没有定义。必须在别的地方定义。
4.3 命名冲突与static修饰符
static将作用域限制在一个源文件中,适用于全局变量和函数。
4.4 形参、实参与返回值
函数必须在调用之前定义和声明。
实参可以转换为匹配的形参。实参char、short会自动提升为int,float会自动提升为double。
怀旧:K&R老式编译器的写法:
int isvowel(); /* call isvowel*/ ... int isvowel(c) char c; { return c == 'a' && c == 'e' && c == 'i' && c == 'o' && c == 'u'; }
相当于
int isvowel(int i) { char c = i; return c == 'a' && c == 'e' && c == 'i' && c == 'o' && c == 'u'; }
类型提升可能有风险。
4.5 检查外部类型
同名外部对象必须声明和定义的类型一致。
4.6 头文件
每个外部对象只在一个地方声明——头文件。
5. 库函数
5.1 返回整数的getchar函数
#include <stdio.h> main() { char c; while ((c = getchar()) != EOF) putchar(c); }
有潜在风险。
5.2 更新顺序文件
FILE* fp; struct record rec; ... while (fread( (char*)&rec, sizeof(rec), 1, fp) == 1) { /* do something to rec */ if (/*rec must be rewritten */) { fseek (fp, -(long)sizeof(rec), 1); fwrite ((char*)&rec, sizeof(rec), 1, fp); fseek (fp, 0L, 1); } }
fread和fwrite函数之间必须插入fseek。
5.3 缓冲输出与内存分配
#include <stdio.h> main() { int c; char buf[BUFSIZ]; setbuf(stdout, buf); while ((c = getchar()) != EOF) putchar(c); }
程序有错:运行结束前buf数组已被释放。
static char buf[BUFSIZ];
或
setbuf(stdout, malloc(BUFSIZ));
5.4 使用erro检测错误
/* call library routine */ if (error return) /* examine errno */
库函数调用成功也可能设置errno。
5.5 库函数signal
信号处理是真正的异步。
signal处理函数唯一安全、可移植的操作是打印一条出错消息,然后使用longjmp或exit立即退出程序。
6. 预处理器
预处理器的两个作用:显示变量实现一处修改,避免函数调用的开销。(考虑const和inline)
6.1 不能忽视宏定义中的空格
#define f (x) ((x)-1)
f(x)代表(x) ((x)-1)。
6.2 宏不是函数
#define abs(x) (((x) >= 0) ? (x) : -(x))
abs(a-b) 会被展开成 a-b >=0 ? a-b : -a-b
abs(a)+1 会被展开成 a >=0 ? a : -a+1
#define max(a, b) ((a) > (b) ? (a) : (b))
biggest = max(biggest, x[i++]); 会被展开成 biggest = biggest > x[i++] ? biggest : x[i++];
#define putc(x, p) \ (--(p)->cnt >= 0 ? (*(p)->_ptr++ = (x)) : _flsbuf(x, p))
#define toupper(c) \ ((c) >= 'a' && (c) <= 'z' ? (c) + ('A' - 'a') : (c))
都可能有问题。
另外宏展开可能产生庞大的表达式。max(a, max(b, max(c, d)))
6.3 宏不是语句
#define assert(e) \ ((void) ((e) || _assert_error(__FILE__, __LINE__)))
这是assert宏的正确定义。
6.4 宏不是类型定义
#define T1 struct foo* typedef struct foo* T2; T1 a, b; T2 c, d;
第一条被扩展成 struct foo* a, b;
7. 可移植性缺陷
7.1 应对C语言标准变更
作者讨论的是K&R C和ANSI C,但是现在仍有现实意义。关于可移植性的决策:是否应使用新特性?该特性给编程带来巨大的便利,但是程序丧失了与之前的兼容。
7.2 标识符名称的限制
ANSI C89 标准:外部名称前6个字符不同,且不区分大小写。。。
7.3 整数的大小
short <= int <= long
int足够容纳所有的数组下标
字符长度由硬件特性决定,8位、9位。。。
定义一个新的类型用起来更为清晰:typedef long int32_t;
7.4 字符是有符号还是无符号
char可能是unsigned char,也可能是signed char。gcc里可以设置-funsigned-char / -fsigned-char。
7.5 移位运算符
向右移位:unsigned填0;signed算术移位填符号位,逻辑移位填0.
被移位的对象长度为n,移位计数必须 >=0, <n。
负数操作移位和乘除2的幂不一样。
7.6 内存位置0
NULL只能用于赋值和比较,不能用于引用。
7.7 除法运算发生的截断
q = a / b;
r = a % b;
我们希望有下面3条性质:
- q * b + r == a
- 改变a的正负号,q随之改变,但绝对值不变
- b>0时,r>=0且r<b
这3条性质不能同时满足。
c语言只保证q*b+r==a;a>=0且b>0时保证|r|<|b|且r>=0。
7.8 随机数的大小
rand函数,RAND_MAX:随机数的最大取值。
7.9 大小写转换
toupper、tolower的实现。
函数版本
int toupper(c) { if (c >= 'a' && c <= 'z') return c + 'A' - 'a'; } int tolower(c) { if (c >= 'A' && c <= 'Z') return c + 'a' - 'A'; }
宏版本
#define _toupper(c) ((c) + 'A' - 'a') #define _tolower(c) ((c) + 'a' - 'A')
toupper(*p++)可能有风险。
7.10 首先释放,然后再分配
realloc 函数的实现。剩余内存够大返回原来地址;剩余内存不足申请新的内存,内容拷贝,再释放原先内存;申请失败返回NULL,原来内存不变。
7.11 可移植性的一个问题
void printnum(long n, void (*p)());
将n转换成十进制表示,对每个字符调用函数指针所指向的函数。
void printneg(long n, void (*p)()) { long q; int r; q = n / 10; r = n % 10; if (r > 0) { r -= 10; q++; } if (n <= -10) printneg(q, p); (*p) ("0123456789"[-r]); } void printnum() { if (n < 0) { (*p) ('-'); printneg(n, p); } else { printneg(-n, p); } }
最后整了这么个实现。保证取到最小负数的时候仍能正常工作。
8. 建议与答案
建议
- 不要说服自己相信“皇帝的新装”。review再好也不如实际跑一下测测。
- 直截了当的表明意图。
- 考察最简单的特例。
- 使用不对称边界。
- 注意潜伏在暗处的bug。软件的生命期往往长于硬件。
- 防御型编程。加强出错处理。
答案
附录A printf, varargs, stdarg
A.1 printf函数族
printf(stuff); 相当于 fprintf(stdout, stuff);
sprintf输出数据写入第一个参数(字符数组)。
%格式码
简单格式
%d 有符号整数 %u 无符号整数 %o 八进制 %x 十六进制小写 %X 十六进制大写 %s 字符串 %c 单个字符 %g 最短表示浮点数 %f 小数点表示浮点数 %e 小写科学计数法浮点数 (默认六位有效数字)%% 百分号
修饰符
l long型
域宽 如:%2d 默认右对齐 %2% 也不例外
精度 如:%.2d 整数位数 不足之前补0 %.2f 小数点后位数 %.2g 有效数字位数 %.14s 字符数
标志 默认右对齐 - 左对齐 当宽度修饰存在才有意义 + 输出正负号 如:%+d 空白符 非负数之前插入一个空格 如:% e # 微调 如: %#o %#x %#.0f
可变域宽与精度 * 替换域宽或精度 具体值在之后跟随 如:printf("%*.*s\n", 12, 5, str);
ANSI C新增的格式码 %p 打印指针 %n 已打印字符数
详细可看 http://www.cplusplus.com/reference/cstdio/printf/
A.2 varargs.h实现可变参数表
。。。放弃非标过时的东东吧。
A.3 stdargs.h
以固定参数作为可变参数的基础。
#include <stdio.h> #include <stdargs.h> void error (char* format, ...) { va_list ap; va_start(ap, format); fprintf(stderr, "error: "); vfprintf(stderr, format, ap); va_end(stderr, "\n"); exit(1); }
#include <stdargs.h> int printf () { va_list ap; int n; va_start(ap, format); n = vprintf(format, ap); va_end(ap); return n; }