[修订说明]
第一次修订。改正了文中的大部分错别字和格式错误,并对一些句子依照中文的习惯进
行了改写。
[译序]
那些自认为已经“学完”C 语言的人,请你们仔细读阅读这篇文章吧。路还长,很多东西要
学。我也是……
[概述]
C 语言像一把雕刻刀,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C 会
伤到那些不能掌握它的人。本文介绍 C 语言伤害粗心的人的方法,以及如何避免伤害。
[内容]
0 简介
1 词法缺陷
1.1 = 不是 ==
1.2 & 和 | 不是 && 和 ||
1.3 多字符记号
1.4 例外
1.5 字符串和字符
2 句法缺陷
2.1 理解声明
2.2 运算符并不总是具有你所想象的优先级
2.3 看看这些分号!
2.4 switch 语句
2.5 函数调用
2.6 悬挂 else 问题
3 连接
3.1 你必须自己检查外部类型
4 语义缺陷
4.1 表达式求值顺序
4.2 &&、||和!运算符
4.3 下标从零开始
4.4 C 并不总是转换实参
4.5 指针不是数组
4.6 避免提喻法
4.7 空指针不是空字符串
4.8 整数溢出
4.9 移位运算符
5 库函数
5.1 getc()返回整数
5.2 缓冲输出和内存分配
6 预处理器
6.1 宏不是函数
6.2 宏不是类型定义
7 可移植性缺陷
7.1 一个名字中都有什么?
7.2 一个整数有多大?
7.3 字符是带符号的还是无符号的?
7.4 右移位是带符号的还是无符号的?
7.5 除法如何舍入?
7.6 一个随机数有多大?
7.7 大小写转换
7.8 先释放,再重新分配
7.9 可移植性问题的一个实例
8 这里是空闲空间
参考
脚注
0 简介
C 语言及其典型实现被设计为能被专家们容易地使用。这门语言简洁并附有表达力。但
有一些限制可以保护那些浮躁的人。一个浮躁的人可以从这些条款中获得一些帮助。
在本文中,我们将会看到这些未可知的益处。正是由于它的未可知,我们无法为其进行
完全的分类。不过,我们仍然通过研究为了一个 C 程序的运行所需要做的事来做到这些。
我们假设读者对 C 语言至少有个粗浅的了解。
第一部分研究了当程序被划分为记号时会发生的问题。第二部分继续研究了当程序的记
号被编译器组合为声明、表达式和语句时会出现的问题。第三部分研究了由多个部分组成、
分别编译并绑定到一起的 C 程序。第四部分处理了概念上的误解:当一个程序具体执行时
会发生的事情。第五部分研究了我们的程序和它们所使用的常用库之间的关系。在第六部分
中,我们注意到了我们所写的程序也许并不是我们所运行的程序;预处理器将首先运行。最
后,第七部分讨论了可移植性问题:一个能在一个实现中运行的程序无法在另一个实现中运
行的原因。
1 词法缺陷
编译器的第一个部分常被称为词法分析器(lexical analyzer)。词法分析器检查组成程序的
字符序列,并将它们划分为记号(token)一个记号是一个由一个或多个字符构成的序列,
它在语言被编译时具有一个(相关地)统一的意义。在 C 中, 例如,记号->的意义和组成
它的每个独立的字符具有明显的区别,而且其意义独立于->出现的上下文环境。
另外一个例子,考虑下面的语句:
if(x > big) big = x;
该语句中的每一个分离的字符都被划分为一个记号,除了关键字 if 和标识符 big 的两个实例。
事实上,C 程序被两次划分为记号。首先是预处理器读取程序。它必须对程序进行记号
划分以发现标识宏的标识符。它必须通过对每个宏进行求值来替换宏调用。最后,经过宏替
换的程序又被汇集成字符流送给编译器。编译器再第二次将这个流划分为记号。
在这一节中,我们将探索对记号的意义的普遍的误解以及记号和组成它们的字符之间的
关系。稍后我们将谈到预处理器。
1.1 = 不是 ==
从 Algol 派生出来的语言,如 Pascal 和 Ada,用:=表示赋值而用=表示比较。而 C 语言则
是用=表示赋值而用==表示比较。这是因为赋值的频率要高于比较,因此为其分配更短的符
号。
此外,C 还将赋值视为一个运算符,因此可以很容易地写出多重赋值(如 a = b = c),并
且可以将赋值嵌入到一个大的表达式中。
这种便捷导致了一个潜在的问题:可能将需要比较的地方写成赋值。因此,下面的语句
好像看起来是要检查 x 是否等于 y:
if(x = y)
foo();
而实际上是将 x 设置为 y 的值并检查结果是否非零。再考虑下面的一个希望跳过空格、制表
符和换行符的循环:
while(c == ' ' || c = '\t' || c == '\n')
c = getc(f);
在与'\t'进行比较的地方程序员错误地使用=代替了==。这个“比较”实际上是将'\t'赋给 c,然
后判断 c 的(新的)值是否为零。因为'\t'不为零,这个“比较”将一直为真,因此这个循环会
吃尽整个文件。这之后会发生什么取决于特定的实现是否允许一个程序读取超过文件尾部的
部分。如果允许,这个循环会一直运行。
一些 C 编译器会对形如 e1 = e2 的条件给出一个警告以提醒用户。当你确实需要先对一个
变量进行赋值之后再检查变量是否非零时,为了在这种编译器中避免警告信息,应考虑显式
给出比较符。换句话说,将:
if(x = y)
foo();
改写为:
if((x = y) != 0)
foo();
这样可以清晰地表示你的意图。
1.2 & 和 | 不是 && 和 ||
容易将==错写为=是因为很多其他语言使用=表示比较运算。其他容易写错的运算符还有
&和&&,以及|和||,这主要是因为 C 语言中的&和|运算符于其他语言中具有类似功能的运
算符大为不同。我们将在第 4 节中贴近地观察这些运算符。
1.3 多字符记号
一些 C 记号,如/、*和=只有一个字符。而其他一些 C 记号,如/*和==,以及标识符,具
有多个字符。当 C 编译器遇到紧连在一起的/和*时,它必须能够决定是将这两个字符识别为
两个分离的记号还是一个单独的记号。C 语言参考手册说明了如何决定:“如果输入流到一
个给定的字符串为止已经被识别为记号,则应该包含下一个字符以组成能够构成记号的最长
的字符串”([译注]即通常所说的“最长子串原则”)。因此,如果/是一个记号的第一个字符,
并且/后面紧随了一个*,则这两个字符构成了注释的开始,不管其他上下文环境。
下面的语句看起来像是将 y 的值设置为 x 的值除以 p 所指向的值:
/* p 指向除数 */;
y = x/*p
实际上,/*开始了一个注释,因此编译器简单地吞噬程序文本,直到*/的出现。换句话说,
这条语句仅仅把 y 的值设置为 x 的值,而根本没有看到 p。将这条语句重写为:
y = x / *p
或者干脆是
y = x / (*p)
它就可以做注释所暗示的除法了。
/* p 指向除数 */;
/* p 指向除数 */;
这种模棱两可的写法在其他环境中就会引起麻烦。例如,老版本的 C 使用=+表示现在版
本中的+=。这样的编译器会将
a=-1;
视为
a =- 1;
或
a = a - 1;
这会让打算写
a = -1;
的程序员感到吃惊。
另一方面,这种老版本的 C 编译器会将
a=/*b;
断句为
a =/ *b;
尽管/*看起来像一个注释。
1.4 例外
组合赋值运算符如+=实际上是两个记号。因此,
a + /* strange */ = 1
和
a += 1
是一个意思。看起来像一个单独的记号而实际上是多个记号的只有这一个特例。特别地,
p - > a
是不合法的。它和
p -> a
不是同义词。
另一方面,有些老式编译器还是将=+视为一个单独的记号并且和+=是同义词。
1.5 字符串和字符
单引号和双引号在 C 中的意义完全不同,在一些混乱的上下文中它们会导致奇怪的结果
而不是错误消息。
包围在单引号中的一个字符只是编写整数的另一种方法。这个整数是给定的字符在实现
的对照序列中的一个对应的值。因此,在一个 ASCII 实现中,'a'和 0141 或 97 表示完全相同
的东西。而一个包围在双引号中的字符串,只是编写一个有双引号之间的字符和一个附加的
二进制值为零的字符所初始化的一个无名数组的指针的一种简短方法。
下面的两个程序片断是等价的:
printf("Hello world\n");
char hello[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '\n', 0 };
printf(hello);
使用一个指针来代替一个整数通常会得到一个警告消息(反之亦然),使用双引号来代替
单引号也会得到一个警告消息(反之亦然)。但对于不检查参数类型的编译器却除外。因此,
用
printf('\n');
来代替
printf("\n");
通常会在运行时得到奇怪的结果。([译注]提示:正如上面所说,'\n'表示一个整数,它被转
换为了一个指针,这个指针所指向的内容是没有意义的。)
由于一个整数通常足够大,以至于能够放下多个字符,一些 C 编译器允许在一个字符常
量中存放多个字符。这意味着用'yes'代替"yes"将不会被发现。后者意味着“分别包含 y、e、s
和一个空字符的四个连续存储器区域中的第一个的地址”,而前者意味着“在一些实现定义的
样式中表示由字符 y、e、s 联合构成的一个整数”。这两者之间的任何一致性都纯属巧合。
2 句法缺陷
要理解 C 语言程序,仅了解构成它的记号是不够的。还要理解这些记号是如何构成声明、
表达式、语句和程序的。尽管这些构成通常都是定义良好的,但这些定义有时候是有悖于直
觉的或混乱的。
在这一节中,我们将着眼于一些不明显句法构造。
2.1 理解声明
我曾经和一些人聊过天,他们那时正在在编写在一个小型的微处理器上单机运行的 C 程
序。当这台机器的开关打开的时候,硬件会调用地址为 0 处的子程序。
为了模仿电源打开的情形,我们要设计一条 C 语句来显式地调用这个子程序。经过一些
思考,我们写出了下面的语句:
(*(void(*)())0)();
这样的表达式会令 C 程序员心惊胆战。但是,并不需要这样,因为他们可以在一个简单
的规则的帮助下很容易地构造它:以你使用的方式声明它。
每个 C 变量声明都具有两个部分:一个类型和一组具有特定格式的、期望用来对该类型
求值的表达式。最简单的表达式就是一个变量:
float f, g;
说明表达式 f 和 g——在求值的时候——具有类型 float。由于待求值的是表达式,因此可以
自由地使用圆括号:
float ((f));
这表示((f))求值为 float 并且因此,通过推断,f 也是一个 float。
同样的逻辑用在函数和指针类型。例如:
float ff();
表示表达式 ff()是一个 float,因此 ff 是一个返回一个 float 的函数。类似地,
float *pf;
表示*pf 是一个 float 并且因此 pf 是一个指向一个 float 的指针。
这些形式的组合声明对表达式是一样的。因此,
float *g(), (*h)();
表示*g()和(*h)()都是 float 表达式。由于()比*绑定得更紧密,*g()和*(g())表示同样的东西:g
是一个返回指 float 指针的函数,而 h 是一个指向返回 float 的函数的指针。
当我们知道如何声明一个给定类型的变量以后,就能够很容易地写出一个类型的模型
(cast):只要删除变量名和分号并将所有的东西包围在一对圆括号中即可。因此,由于
float *g();
声明 g 是一个返回 float 指针的函数,所以(float *())就是它的模型。
有了这些知识的武装,我们现在可以准备解决(*(void(*)())0)()了。 我们可以将它分为两
个部分进行分析。首先,假设我们有一个变量 fp,它包含了一个函数指针,并且我们希望
调用 fp 所指向的函数。可以这样写:
(*fp)();
如果 fp 是一个指向函数的指针,则*fp 就是函数本身,因此(*fp)()是调用它的一种方法。(*fp)
中的括号是必须的,否则这个表达式将会被分析为*(fp())。我们现在要找一个适当的表达式
来替换 fp。
这个问题就是我们的第二步分析。如果 C 可以读入并理解类型,我们可以写:
(*0)();
但这样并不行,因为*运算符要求必须有一个指针作为它的操作数。另外,这个操作数必须
是一个指向函数的指针,以保证*的结果可以被调用。因此,我们需要将 0 转换为一个可以
描述“指向一个返回 void 的函数的指针”的类型。
如果 fp 是一个指向返回 void 的函数的指针,则(*fp)()是一个 void 值,并且它的声明将会
是这样的:
void (*fp)();
因此,我们需要写:
void (*fp)();
(*fp)();
来声明一个哑变量。一旦我们知道了如何声明该变量,我们也就知道了如何将一个常数转换
为该类型:只要从变量的声明中去掉名字即可。因此,我们像下面这样将 0 转换为一个“指
向返回 void 的函数的指针”:
(void(*)())0
接下来,我们用(void(*)())0 来替换 fp:
(*(void(*)())0)();
结尾处的分号用于将这个表达式转换为一个语句。
在这里,我们解决这个问题时没有使用 typedef 声明。通过使用它,我们可以更清晰地解
决这个问题:
typedef void (*funcptr)();
(*(funcptr)0)();
2.2 运算符并不总是具有你所想象的优先级
假设有一个声明了的常量 FLAG,它是一个整数,其二进制表示中的某一位被置位(换
句话说,它是 2 的某次幂),并且你希望测试一个整型变量 flags 该位是否被置位。通常的写
法是:
if(flags & FLAG) ...
其意义对于很多 C 程序员都是很明确的:if 语句测试括号中的表达式求值的结果是否为 0。
出于清晰的目的我们可以将它写得更明确:
if(flags & FLAG != 0) ...
这个语句现在更容易理解了。但它仍然是错的,因为!=比&绑定得更紧密,因此它被分析为:
if(flags & (FLAG != 0)) ...
这(偶尔)是可以的,如 FLAG 是 1 或 0(!)的时候,但对于其他 2 的幂是不行的[2]。
假设你有两个整型变量,h 和 l,它们的值在 0 和 15(含 0 和 15)之间,并且你希望将 r
设置为 8 位值,其低位为 l,高位为 h。一种自然的写法是:
r = h << 4 + 1;
不幸的是,这是错误的。加法比移位绑定得更紧密,因此这个例子等价于:
r = h << (4 + l);
正确的方法有两种:
r = (h << 4) + l;
r = h << 4 | l;
避免这种问题的一个方法是将所有的东西都用括号括起来,但表达式中的括号过度就会
难以理解,因此最好还是是记住 C 中的优先级。
不幸的是,这有 15 个,太困难了。然而,通过将它们分组可以变得容易。
绑定得最紧密的运算符并不是真正的运算符:下标、函数调用和结构选择。这些都与左
边相关联。
接下来是一元运算符。它们具有真正的运算符中的最高优先级。由于函数调用比一元运
算符绑定得更紧密,你必须写(*p)()来调用 p 指向的函数;*p()表示 p 是一个返回一个指针的
函数。转换是一元运算符,并且和其他一元运算符具有相同的优先级。一元运算符是右结合
的,因此*p++表示*(p++),而不是(*p)++。
在接下来是真正的二元运算符。其中数学运算符具有最高的优先级,然后是移位运算符、
关系运算符、逻辑运算符、赋值运算符,最后是条件运算符。需要记住的两个重要的东西是:
所有的逻辑运算符具有比所有关系运算符都低的优先级。
移位运算符比关系运算符绑定得更紧密,但又不如数学运算符。
在这些运算符类别中,有一些奇怪的地方。乘法、除法和求余具有相同的优先级,加法
和减法具有相同的优先级,以及移位运算符具有相同的优先级。
还有就是六个关系运算符并不具有相同的优先级:==和!=的优先级比其他关系运算符要
低。这就允许我们判断 a 和 b 是否具有与 c 和 d 相同的顺序,例如:
a < b == c < d
在逻辑运算符中,没有任何两个具有相同的优先级。按位运算符比所有顺序运算符绑定
得都紧密,每种与运算符都比相应的或运算符绑定得更紧密,并且按位异或(^)运算符介
于按位与和按位或之间。
三元运算符的优先级比我们提到过的所有运算符的优先级都低。这可以保证选择表达式
中包含的关系运算符的逻辑组合特性,如:
z = a < b && b < c ? d : e
这个例子还说明了赋值运算符具有比条件运算符更低的优先级是有意义的。另外,所有
的复合赋值运算符具有相同的优先级并且是自右至左结合的,因此
a = b = c
和
b = c; a = b;
是等价的。
具有最低优先级的是逗号运算符。这很容易理解,因为逗号通常在需要表达式而不是语
句的时候用来替代分号。
赋值是另一种运算符,通常具有混合的优先级。例如,考虑下面这个用于复制文件的循
环:
while(c = getc(in) != EOF)
putc(c, out);
这个 while 循环中的表达式看起来像是 c 被赋以 getc(in)的值,接下来判断是否等于 EOF 以
结束循环。不幸的是,赋值的优先级比任何比较操作都低,因此 c 的值将会是 getc(in)和 EOF
比较的结果,并且会被抛弃。因此,“复制”得到的文件将是一个由值为 1 的字节流组成的文
件。
上面这个例子正确的写法并不难:
while((c = getc(in)) != EOF)
putc(c, out);
然而,这种错误在很多复杂的表达式中却很难被发现。例如,随 UNIX 系统一同发布的 lint
程序通常带有下面的错误行:
if (((t = BTYPE(pt1->aty) == STRTY) || t == UNIONTY) {
这条语句希望给 t 赋一个值,然后看 t 是否与 STRTY 或 UNIONTY 相等。而实际的效果却
大不相同[3]。
C 中的逻辑运算符的优先级具有历史原因。B 语言——C 的前辈——具有和 C 中的&和|
运算符对应的逻辑运算符。尽管它们的定义是按位的 ,但编译器在条件判断上下文中将它
们视为和&&和||一样。当在 C 中将它们分开后,优先级的改变是很危险的[4]。
2.3 看看这些分号!
C 中的一个多余的分号通常会带来一点点不同:或者是一个空语句,无任何效果;或者
编译器可能提出一个诊断消息,可以方便除去掉它。一个重要的区别是在必须跟有一个语句
的 if 和 while 语句中。考虑下面的例子:
if(x[i] > big);
big = x[i];
这不会发生编译错误,但这段程序的意义与:
if(x[i] > big)
big = x[i];
就大不相同了。第一个程序段等价于:
if(x[i] > big) { }
big = x[i];
也就是等价于:
big = x[i];
(除非 x、i 或 big 是带有副作用的宏)。
另一个因分号引起巨大不同的地方是函数定义前面的结构声明的末尾([译注]这句话不太
好听,看例子就明白了)。考虑下面的程序片段:
struct foo {
int x;
}
f() {
...
}
在紧挨着 f 的第一个}后面丢失了一个分号。它的效果是声明了一个函数 f,返回值类型是
struct foo,这个结构成了函数声明的一部分。如果这里出现了分号,则 f 将被定义为具有默
认的整型返回值[5]。
2.4 switch 语句
通常 C 中的 switch 语句中的 case 段可以进入下一个。例如,考虑下面的 C 和 Pascal 程序
片断:
switch(color) {
case 1: printf ("red");
break;
case 2: printf ("yellow");
break;
case 3: printf ("blue");
break;
}
case color of
1: write ('red');
2: write ('yellow');
3: write ('blue');
end