1、 C 语言陷阱和缺陷1原著:Andrew Koenig - AT该语句中的每一个分离的字符都被划分为一个记号,除了关键字 if 和标识符 big 的两个实例。事实上,C 程序被两次划分为记号。首先是预处理器读取程序。它必须对程序进行记号划分以发现标识宏的标识符。它必须通过对每个宏进行求值来替换宏调用。最后,经过宏替换的程序又被汇集成字符流送给编译器。编译器再第二次将这个流划分为记号。在这一节中,我们将探索对记号的意义的普遍的误解以及记号和组成它们的字符之间的关系。稍后我们将谈到预处理器。1.1 = 不是 =从 Algol派生出来的语言,如 Pascal 和 Ada,用:= 表示赋值而用= 表示
2、比较。而 C语言则是用=表示赋值而用= 表示比较。这是因为赋值 的频率要高于比较,因此为其分配更短的符号。此外,C 还将赋值视为一个运算符,因此可以很容易地写出多重赋值(如 a = b = c),并且可以将赋 值嵌入到一个 大的表达式中。这种便捷导致了一个潜在的问题:可能将需要比较的地方写成赋值。因此,下面的语句好像看起来是要检查 x 是否等于 y:if(x = y)foo();而实际上是将x 设置为 y 的值并检查结果是否非零。在考虑下面的一个希望跳过空格、制表符和换行符的循环:while(c = | c = t | c = n)c = getc(f);在与t进行比较的地 方程序员错误地使用
3、= 代替了=。这个“比较”实际上是将t赋给 c,然后判断 c 的(新的)值是否为零。因为 t不为零,这个“比较”将一直为真,因此这个循环会吃尽整个文件。这之后会发生什么取决于特定的实现是否允许一个程序读取超过文件尾部的部分。如果允许,这个循环会一直运行。一些 C 编译器会对形如 e1 = e2 的条件给出一个警告以提醒用户。当你趋势需要先对一个变量进行赋值之后再检查变量是否非零时,为了在这种编译器中避免警告信息,应考虑显式给出比较符。换句话说,将:if(x = y)foo();改写为:if(x = y) != 0)foo();这样可以清晰地表示你的意图。1.2 实际上,/*开始了一个注释,因此
4、编译器简单地吞噬程序 文本,直到*/ 的出现。换句话说,这条语句仅仅把 y 的值设置为 x 的值,而根本没有看到 p。将这条语句重写为:y = x / *p /* p 指向除数 */;或者干脆是y = x / (*p) /* p 指向除数 */;它就可以做注释所暗示的除法了。这种模棱两可的写法在其他环境中就会引起麻烦。例如,老版本的 C 使用=+表示现在版本中的+=。这样的编译器会将a=-1;视为a =- 1;或a = a - 1;这会让打算写a = -1;的程序员感到吃惊。另一方面,这种老版本的C 编译器会将a=/*b;断句为a =/ *b;尽管/*看起来像一个注释。1.4 例外组合赋值运算
5、符如+=实际上是两个记号。因此,a + /* strange */ = 1和a += 1是一个意思。看起来像一个单独的记号而实际上是多个记号的只有这一个特例。特别地,p - a是不合法的。它和p - a不是同义词。另一方面,有些老式编译器还是将=+视为一个单独的记号并且和+=是同义词。1.5 字符串和字符单引号和双引号在 C 中的意义完全不同,在一些混乱的上下文中它们会导致奇怪的结果而不是错误消息。包围在单引号中的一个字符只是书写整数的另一种方法。这个整数是给定的字符在实现的对照序列中的一个对应的值。因此,在一个 ASCII 实现中,a和 0141 或97表示完全相同的东西。而一个包围在双引号
6、中的字符串,只是书写一个有双引号之间的字符和一个附加的二进制值为零的字符所初始化的一个无名数组的指针的一种简短方法。线面的两个程序片断是等价的:printf(“Hello worldn“);char hello = H, e, l, l, o, , w, o, r, l, d, n, 0 ;printf(hello);使用一个指针来代替一个整数通常会得到一个警告消息(反之亦然),使用双引号来代替单引号也会得到一个警告消息(反之亦然)。但对于不检查参数类型的编译器却除外。因此,用printf(n);来代替printf(“n“);通常会在运行时得到奇怪的结果。由于一个整数通常足够大,以至于能够放下
7、多个字符,一些 C 编译器允许在一个字符常量中存放多个字符。这意味着用yes代替“yes“将不会被发现。后者意味着“分别包含 y、 e、s 和一个空字 符的四个连续存贮器区域中 的第一个的地址” ,而前者意味着“在一些实现定 义的样式中表 示由字符 y、e 、s 联合构成的一个整数” 。这两者之间的任何一致性都纯属巧合。2 句法缺陷要理解 C 语言程序,仅了解构成它的记号是不够的。还要理解这些记号是如何构成声明、表达式、语句和程序的。尽管这些构成通常都是定义良好的,但这些定义有时候是有悖于直觉的或混乱的。在这一节中,我们将着眼于一些不明显句法构造。2.1 理解声明我曾经和一些人聊过天,他们那时
8、在书写在一个小型的微处理器上单机运行的 C程序。当这台机器的开关打开的时候,硬件会调用地址为 0 处的子程序。为了模仿电源打开的情形,我们要设计一条 C 语句来显式地调用这个子程序。经过一些思考,我们写出了下面的语句:(*(void(*)()0)();这样的表达式会令 C 程序员心惊胆战。但是,并不需要这样,因为他们可以在一个简单的规则的帮助下很容易地构造它:以你使用的方式声明它。每个 C 变量声明都具有两个部分:一个类型和一组具有特定格式的期望用来对该类型求值的表达式。最简单的表达式就是一个变量:float f, g;说明表达式 f和 g在求值的时候具有类型 float。由于待求值的时表达式
9、,因此可以自由地使用圆括号: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 是一个返回指
10、float指针的函数,而 h 是一个指向返回 float 的函数的指针。当我们知道如何声明一个给定类型的变量以后,就能够很容易地写出一个类型的模型(cast):只要删除变量名和分号并将所有的东西包围在一对圆括号中即可。因此,由于float *g();声明 g 是一个返回 float 指针的函数 ,所以(float *()就是它的模型。有了这些知识的武装,我们现在可以准备解决(*(void(*)()0)() 了。 我们可以将它分为两个部分进行分析。首先,假设我们有一个变量 fp,它包含了一个函数指针,并且我们希望调用 fp 所指向的函数。可以这样写:(*fp)();如果 fp 是一个指向函数的指
11、针,则*fp 就是函数本身,因此(*fp)() 是调用它的一种方法。(*fp)中的括号是必须的,否则这个表达式将会被分析为*(fp() 。我们现在要找一个适当的表达式来替换 fp。这个问题就是我们的第二步分析。如果 C 可以读入并理解类型,我们可以写:(*0)();但这样并不行,因为*运算符要求必须有一个指针作为他的操作数。另外,这个操作数必须是一个指向函数的指针,以保证*的结果可以被调用。因此,我们需要将0转换为一个可以描述“ 指向一个返回 void 的函数的指针”的类型。如果 fp 是一个指向返回 void 的函数的指针,则(*fp)()是一个 void 值,并且它的声明将会是这样的:vo
12、id (*fp)();因此,我们需要写:void (*fp)();(*fp)();来声明一个哑变量。一旦我们知道了如何声明该变量,我们也就知道了如何将一个常数转换为该类型:只要从变量的声明中去掉名字即可。因此,我们像下面这样将0 转换为一个“ 指向返回 void 的函数的指针” :(void(*)()0接下来,我们用(void(*)()0 来替换 fp:(*(void(*)()0)();结尾处的分号用于将这个表达式转换为一个语句。在这里,我们就解决了这个问题时没有使用 typedef 声明。通过使用它,我们可以更清晰地解决这个问题:typedef void (*funcptr)();(*(fu
13、ncptr)0)();2.2 运算符并不总是具有你所想象的优先级假设有一个声明了的常量 FLAG 是一个整数,其二进制表示中的某一位被置位(换句话说,它是 2 的某次幂),并且你希望测试一个整型变量 flags该位是否被置位。通常的写法是:if(flags big = xi;这不会发生编译错误,但这段程序的意义与:if(xi big)big = xi;就大不相同了。第一个程序段等价于:if(xi big) big = xi;也就是等价于:big = xi;(除非 x、i 或 big 是带有副作用的宏)。另一个因分号引起巨大不同的地方是函数定义前面的结构声明的末尾译注:这句话不太好听,看例子就明
14、白了。考虑下面的程序片段: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 (“bl
15、ue“);break;case color of1: write (red);2: write (yellow);3: write (blue);end这两个程序片断都作相同的事情:根据变量 color 的值是1、2 还是 3 打印red、 yellow或 blue(没有新行符)。这两个程序片断非常相似,只有一点不同:Pascal程序中没有 C中相应的 break 语句。C 中的 case 标签是真正的标签:控制流程可以无限制地进入到一个 case 标签中。看看另一种形式,假设 C 程序段看起来更像 Pascal:switch(color) case 1: printf (“red“);cas
16、e 2: printf (“yellow“);case 3: printf (“blue“);并且假设 color 的值是2。则该程序将打印 yellowblue,因为控制自然地转入到下一个 printf()的调用。这既是 C 语言switch语句的优点又是它的弱点。说它是弱点,是因为很容易 忘记一个 break 语句,从而导致程序出现隐晦的异常行为。说它是优点,是因为通过故意去掉 break 语句,可以很容易实现其他方法难以实现的控制结构。尤其是在一个大型的 switch 语句中,我们经常发现对一个 case 的处理可以简化其他一些特殊的处理。例如,设想有一个程序是一台假想的机器的翻译器。这
17、样的一个程序可能包含一个 switch 语句来处理各种操作码。在这样一台机器上,通常减法在对 其第二个运算数进行变号后就变成和加法一样了。因此,最好可以写出这样的语句:case SUBTRACT:opnd2 = -opnd2;/* no break; */case ADD:.另外一个例子,考虑编译器通过跳过空白字符来查找一个记号。这里,我们将空格、制表符和新行符视为是相同的,除了新行符还要引起行计数器的增长外:case n:linecount+;/* no break */case t:case :.2.5 函数调用和其他程序设计语言不同,C 要求一个函数调用必须有一个参数列表,但可以没有参数
18、。因此,如果 f 是一个函数,f();就是对该函数进行调用的语句,而f;什么也不做。它会作为函数地址被求值,但不会调用它 6。2.6 悬挂 else问题在讨论任何语法缺陷时我们都不会忘记提到这个问题。尽管这一问题不是 C 语言所独有的,但它仍然伤害着那些有着多年经验的 C 程序员。考虑下面的程序片断:if(x = = 0)if(y = = 0) error();else z = x + y;f(写这段程序的程序员的目的明显是将情况分为两种:x = 0 和 x != 0。在第一种情况中,程序段什么都不做,除非 y = 0 时调用 error()。第二种情况中,程序设置 z = x + y 并以
19、z 的地址作为参数调用 f()。然而, 这段程序的实际效果却大为 不同。其原因是一个 else 总是 与其最近的 if 相关联。如果我们希望这段程序能够按照实际的情况运行,应该这样写:if(x = = 0) if(y = = 0)error();else z = x + y;f(换句话说,当 x != 0 发生时什么也不做。如果要达到第一个例子的效果,应该写:if(x = = 0) if(y = =0)error();else z = z + y;f(3 链接一个 C 程序可能有很多部分组成,它们被分别编译,并由一个通常称为链接器、链接编辑器或加载器的程序绑定到一起。由于编译器一次通常只能看到
20、一个文件,因此它无法检测到需要程序的多个源文件的内容才能发现的错误。在这一节中,我们将看到一些这种类型的错误。有一些 C 实现,但不是所有的,带有一个称为lint 的程序来捕获这些错误。如果具有一个这样的程序,那么无论怎样地强调它的重要性都不过分。3.1 你必须自己检查外部类型假设你有一个C 程序,被划分为两个文件。其中一个包含如下声明:int n;而令一个包含如下声明:long n;这不是一个有效的 C 程序,因为一些外部名称在两个文件中被声明为不同的类型。然而,很多实现检测不到这个错误,因为编译器在编译其中一个文件时并不知道另一个文件的内容。因此,检查类型的工作只能由链接器(或一些工具程序
21、如 lint)来完成;如果操作系统的链接器不能识别数据类型,C 编译器也没法过多地强制它。那么,这个程序运行时实际会发生什么?这有很多可能性:1. 实现足够聪明,能够检测到类型冲突。则我们会得到一个诊断消息,说明 n在两个文件中具有不同的类型。 2. 你所使用的实现将 int 和long 视为相同的类型。典型的情况是机器可以自然地进行 32 位运算。在这种情况下你的程序或许能够工作,好象你两次都将变量声明为long(或 int)。但这种程序的工作纯属偶然。 3. n 的两个实例需要不同的存储,它们以某种方式共享存储区,即对其中一个的赋值对另一个也有效。这可能发生,例如,编译器可以将int 安排
22、在long的低位。不论这是基于系统的还是基于机器的,这种程序的运行同样是偶然。4. n 的两个实例以另一种方式共享存储区,即对其中一个赋值的效果是对另一个赋以不同的值。在这种情况下,程序可能失败。这种情况发生的里一个例子出奇地频繁。程序的某一个文件包含下面的声明:char filename = “etc/passwd“;而另一个文件包含这样的声明:char *filename;尽管在某些环境中数组和指针的行为非常相似,但它们是不同的。在第一个声明中,filename 是一个字符数组的名字。尽管使用数组的名字可以产生数组第一个元素的指针,但这个指针只有在需要的时候才产生并且不会持续。在第二个声明
23、中,filename 是一个指针的名字。这个指针可以指向程序员让它指向的任何地方。如果程序员没有给它赋一个值,它将具有一个默认的 0 值(null)译注:实际上,在C 中一个未初始化的指针通常具有一个随机的值,这是很危险的 !。这两个声明以不同的方式使用存储区,他们不可能共存。避免这种类型冲突的一个方法是使用像 lint 这样的工具(如果可以的话)。为了在一个程序的不同编译单元之间检查类型冲突,一些程序需要一次看到其所有部分。典型的编译器无法完成,但 lint 可以。避免该问题的另一种方法是将外部声明放到包含文件中。这时,一个外部对象的类型仅出现一次 7。4 语义缺陷一个句子可以是精确拼写的并
24、且没有语法错误,但仍然没有意义。在这一节中,我们将会看到一些程序的写法会使得它们看起来是一个意思,但实际上是另一种完全不同的意思。我们还要讨论一些表面上看起来合理但实际上会产生未定义结果的环境。我们这里讨论的东西并不保证能够在所有的 C 实现中工作。我们暂且忘记这些能够在一些实现中工作但可能不能在另一些实现中工作的东西,直到第 7 节讨论可以执行问题为止。4.1 表达式求值顺序一些 C 运算符以一种已知的、特定的顺序对其操作数进行求值。但另一些不能。例如,考虑下面的表达式:a 1 的值,这是不可能为 0 的。译注:(-1) / 2 的结果是 0。5 库函数每个有用的 C程序都会用到库函数,因为
25、没有办法把输入和输出内建到语言中去。在这一节中,我们将会看到一些广泛使用的库函数在某种情况下会出现的一些非预期行为。5.1 getc()返回整数考虑下面的程序:#include main() char c;while(c = getchar() != EOF)putchar(c);这段程序看起来好像要讲标准输入复制到标准输出。实际上,它并不完全会做这些。原因是 c 被声 明为字符而不 是整数。这意味着它将不能接收可能出现的所有字符包括 EOF。因此这里有两种可能性。有时一些合法的输入字符会导致 c 携带和EOF 相同的值,有时又会使 c无法存放 EOF 值。在前一种情况下,程序会在文件的中间停
26、止复制。在后一种情况下,程序会陷入一个无限循环。实际上,还存在着第三种可能:程序会偶然地正确工作。C 语言参考手册严格地定义了表达式(c = getchar() != EOF)的结果。其 6.1 节中声明:当一个较长的整数被转换为一个较短的整数或一个 char 时,它会被截去左侧;超出的位被简单地丢弃。7.14 节声明:存在着很多赋值运算符,它们都是从右至左结合的。它们都需要一个左值作为左侧的操作数,而赋值表达式的类型就是其左侧的操作数的类型。其值就是已经付过值的左操作数的值。这两个条款的组合效果就是必须通过丢弃getchar()的结果的高位,将其截短为字符,之后这个被截短的值再与 EOF 进
27、行比较。作为这个比较 的一部分,c 必须被扩展为一个整数,或者采取将左侧的位用 0 填充,或者适当地采取符号扩展。然而,一些编译器并没有正确地实现这个表达式。它们确实将 getchar()的值的低几位赋给 c。但在 c 和 EOF 的比较中,它们却使用了 getchar()的值!这样做的编译器会使这个事例程序看起来能够“正确地” 工作。5.2 缓冲输出和内存分配当一个程序产生输出时,能够立即看到它有多重要?这取决于程序。例如,终端上显示输出并要求人们坐在终端前面回答一个问题,人们能够看到输出以知道该输入什么就显得至关重要了。另一方面,如果输出到一个文件中,并最终被发送到一个行式打印机,只有所有
28、的输出最终能够到达那里是重要的。立即安排输出的显示通常比将其暂时保存在一大块一起输出要昂贵得多。因此,C实现通常允许程序员控制产生多少输出后在实际地写出它们。这个控制通常约定为一个称为 setbuf()的库函数。如果 buf 是一个具有适当大小的字符数组,则setbuf(stdout, buf);将告诉 I/O 库写入到 stdout 中的输出要以 buf 作为一个 输出缓冲,并且等到 buf 满了或程序员直接调用 fflush()再实际写出。缓冲区的合适的大小在 中定义为 BUFSIZ。因此,下面的程序解释了通过使用setbuf() 来讲标准输入复制到标准输出:#include main()
29、 int c;char bufBUFSIZ;setbuf(stdout, buf);while(c = getchar() != EOF)putchar(c);不幸的是,这个程序是错误的,因为一个细微的原因。要知道毛病出在哪,我们需要知道缓冲区最后一次刷新是在什么时候。答案;主程序完成之后,作为库在将控制交回到操作系统之前所执行的清理的一部分。在这一时刻,缓冲区已经被释放了!有两种方法可以避免这一问题。首先,是用静态缓冲区,或者将其显式地声明为静态:static char bufBUFSIZ;或者将整个声明移到主函数之外。另一种可能的方法是动态地分配缓冲区并且从不释放它:char *mallo
30、c();setbuf(stdout, malloc(BUFSIZ);注意在后一种情况中,不必检查 malloc()的返回值,因为如果它失败了,会返回一个空指针。而 setbuf()可以接受一个空指针作为其第二个参数,这将使得 stdout 变成非缓冲的。这会运行得很慢,但它是可以运行的。6 预处理器运行的程序并不是我们所写的程序:因为 C 预处理器首先对其进行了转换。出于两个主要原因(和很多次要原因),预处理器为我们提供了一些简化的途径。首先,我们希望可以通过改变一个数字并重新编译程序来改变一个特殊量(如表的大小)的所有实例 9。其次,我们可能希望定义一些东西,它们看起来象函数但没有函数调用所
31、需的运行开销。例如,putchar()和 getchar()通常实现为宏以避免对每一个字符的输入输出都要进行函数调用。6.1 宏不是函数由于宏可以象函数那样出现,有些程序员有时就会将它们视为等价的。因此,看下面的定义:#define max(a, b) (a) (b) ? (a) : (b)注意宏体中所有的括号。它们是为了防止出现 a 和 b是带有比优先级低的表达式的情况。一个重要的问题是,像 max()这样定义的宏每个操作数都 会出现两次并且会被求值两次。因此,在这个例子中,如果 a 比 b 大,则 a 就会被求值两次:一次是在比较的时候,而另一次是在计算 max()值的时候。这不仅是低效的
32、,还会发生错误:biggest = x0;i = 1;while(i (xi+) ? (biggest) : (xi+);首先,biggest 与 xi+进行比较。由于 i 是 1 而x1 是 3,这个关系是“假”。其副作用是,i 增长到 2。由于关系是“ 假” ,xi+的值要赋给 biggest。然而,这时的 i 变成2 了,因此赋给biggest 的值是 x2的值,即 1。避免这些问题的方法是保证max() 宏的参数没有副作用:biggest = x0;for(i = 1; i 中putc()宏的定义:#define putc(x, p) (-(p)-_cnt = 0 ? (*(p)-_p
33、tr+ = (x) : _flsbuf(x, p)putc()的第一个参数是一个要写入 到文件中的字符,第二个参数是 一个指向一个表示文件的内部数据结构的指针。注意第一个参数完全可以使用如*z+之类的东西,尽管它在宏中两次出现,但只会被求值一次。而第二个参数会被求值两次(在宏体中,x 出现了两次,但由于 它的两次出现分别在一个:的两边,因此在 putc()的一个实例中它们之中有且仅有一个被求值)。由于 putc()中的文件参数可能带有副作用,这偶尔会出现问题。不过,用户手册文档中提到:“由于 putc()被实现为宏,其对待 stream 可能会具有副作用。特别是 putc(c, *f+)不能正
34、确地工作。” 但是putc(*c+, f)在这个实现中是可以工作的。有些 C 实现很不小心。例如,没有人能正确处理 putc(*c+, f)。另一个例子,考虑很多 C 库中出现的 toupper()函数。它将一个小写字母转换为相 应的大写字母,而其它字符不变。如果我们假设所有的小写字母和所有的大写字母都是相邻的(大小写之间可能有所差距),我们可以得到这样的函数:toupper(c) if(c = a if(biggest b) biggest = b;if(biggest c) biggest = c;if(biggest d) biggest = d;比较好一些。6.2 宏不是类型定义宏的一
35、个通常的用途是保证不同地方的多个事物具有相同的类型:#define FOOTYPE struct fooFOOTYPE a;FOOTYPE b, c;这允许程序员可以通过只改变程序中的一行就能改变 a、b 和 c 的类型,尽管 a、b和 c 可能声明在很远的不同地方。使用这样的宏定义还有着可移植性的优势所有的 C 编译器都支持它。很多 C编译器并不支持另一种方法:typedef struct foo FOOTYPE;这将 FOOTYPE 定义为一个与 struct foo 等价的新类型。这两种为类型命名的方法可以是等价的,但 typedef 更灵活一些。例如,考虑下面的例子:#define T1 struct foo *typedef struct foo * T2;这两个定义使得 T1 和 T2都等价于一个struct foo 的指针 。但看看当我们试图在一行中声明多于一个变量的时候会发生什么:T1 a, b;T2 c, d;第一个声明被扩展为: