连载丨C语言的词法“陷阱”
2016-10-24 C语言陷阱和缺陷 启元俱乐部
前言
“那些自认为已经“学完”C 语言的人, 请你们仔细读阅读这篇文章吧。 路还长, 很多东西要学。 我也是……”。C 语言像一把雕刻刀,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C 会伤到那些不能掌握它的人。本文介绍 C 语言伤害粗心的人的方法,以及如何避免伤害。
某一天你遇到了一个冒泡法排序的程序,你看了半天也没看出来是什么东西,实现什么功能。再翻开你的程序,你做的东西,漏洞百出,不过还是在对别人炫耀着自己的C语言有多么厉害,架构多么清晰。
这时候应该静下来好好学习C语言了,基本可以这么说,C 语言像一把雕刻刀,锋利,并且在技师手中非常有用,它可以让你编程时得心应手。和任何锋利的工具一样,C 会伤到那些不能掌握它的人,使你一招毙命。在今后的一段时间里,我们先来看看贝尔实验室的Andrew Koenig 创作的一本短书《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 所指向的值: y = x/*p /* p 指向除数 */; 实际上,/*开始了一个注释,因此编译器简单地吞噬程序文本,直到*/的出现。换句话说,这条语句仅仅把 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 例外
组合赋值运算符如 =实际上是两个记号。因此, a /* strange */ = 1 和 a = 1 是一个意思。看起来像一个单独的记号而实际上是多个记号的只有这一个特例。特别地, p - > a 是不合法的。它和 p -> a 不是同义词。 另一方面,有些老式编译器还是将= 视为一个单独的记号并且和 =是同义词。
1.5 字符串和字符
单引号和双引号在 C 中的意义完全不同,在一些混乱的上下文中它们会导致奇怪的结果而不是错误消息。 包围在单引号中的一个字符只是书写整数的另一种方法。这个整数是给定的字符在实现的对照序列中的一个对应的值。因此,在一个 ASCII 实现中,'a'和 0141 或 97 表示完全相同的东西。而一个包围在双引号中的字符串,只是书写一个有双引号之间的字符和一个附加的二进制值为零的字符所初始化的一个无名数组的指针的一种简短方法。 下面的两个程序片断是等价的: printf("Hello worldn"); char hello[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', 'n', 0 }; printf(hello); 使用一个指针来代替一个整数通常会得到一个警告消息(反之亦然),使用双引号来代替单引号也会得到一个警告消息(反之亦然)。但对于不检查参数类型的编译器却除外。因此,用 printf('n'); 来代替 printf("n"); 通常会在运行时得到奇怪的结果。 由于一个整数通常足够大,以至于能够放下多个字符,一些 C 编译器允许在一个字符常量中存放多个字符。这意味着用'yes'代替"yes"将不会被发现。后者意味着“分别包含 y、e、s 和一个空字符的四个连续存贮器区域中的第一个的地址”,而前者意味着“在一些实现定义的样式中表示由字符 y、e、s 联合构成的一个整数”。这两者之间的任何一致性都纯属巧合。
1.6 课后习题
练习1-1. 某些C编译器允许嵌套注释。请写一个测试程序,要求:无论是对允许嵌套注释的编译器,还是对不允许嵌套注释的编译器,该程序都能正常通过编译(无错误消息出现),但是这两种情况下程序执行的结果却不相同。
提示:在用双引号括起的字符串中,注释符/*属于字符串的一部分,而在注释出现的双引号""又属于注释的一部分。
练习1-2. 如果由你来实现一个C编译器,你是否会允许嵌套注释?如果你使用的C编译器允许嵌套注释,你会用到编译器的这一特性吗?你对第二个问题的回答是否会影响到你对第一个问题的回答?
练习1-3. 为什么n-->0.而不是n- ->0?
练习1-4. a+++++b的含义是什么?