第一章 词法“陷阱”
当我们阅读一个句子时,我们并不去考虑组成这个句子的单词中单个字母的 含义,而是把单词作为一个整体来理解。字母本身并没有什么意义,我们总是将字母组成单词,然后给单词赋予一定的意义。
对于用C语言或其他语言编写的程序,道理也是一样的。程序中的单个字符孤立来看并没有什么意义,只有结合上下文才有意义。因此,在p->s = “->”;这个语句中,两处出现的’-'字符的意义大相径庭。
术语“符号”指的是程序的一个基本组成单元,其作用相当于一个句子中的单词。编译器中负责将程序分解为一个一个符号的部分,一般称为 “词法分析器”再看下面一个例子,语句:
if (x > big) big = x;
这个语句的第一个符号是C语言的关键字if,紧接着下一个符号是左括号, 再下一个符号是标识符x,再下一个是大于号,再下一个是标识符big,依次类推。 在C语言中,符号之间的空白(包括空格符、制表符或换行符)将被忽略,因此 上面的语句还可以写成:
if
(
x
>
big
)
big
=
x
;
1.1 =不同与==
C语言使用符号=作为赋值运算,符号==作为比较。赋值运算相对于比较运算出现得更频繁,因此字符数较少的符号=就被赋予了更常用的赋值操作。此外,在C语言中赋值符号被作为一种操作符对待,因而重复进行赋值操作(如a=b=c)可以很容易地书写,并且赋值操作还可以被嵌入到更大的表达式中。
这种使用上的便利性可能导致一个潜在的问题:当程序员本意是作比较运算时,却可能无意中误写成了赋值运算。比如下例,该语句本意似乎是要检查x是 否等于y:
if (x = y)
break;
而实际上是将y的值赋给了x,然后检查该值是否为零。再看下面一个例子,本例中循环语句的本意是跳过文件中的空格符、制表符和换行号:
while(c = ' '|| c == '\t'|| c == '\n')
c = getc (f);
由于程序员在比较字符’ '和变量c时,误将比较运算符==写成了赋值运算符=。因为赋值运算符=的优先级要低于逻辑运算符||,因此实际上是将以下表达式的值赋给了c:
'' || c == '\t' || c == '\n'
因为’ ‘不等于零(’ '的ASCII码值为32),那么无论变量c此前为何值,上述表达式求值的结果都是1,因此循环将一直进行下去直到整个文件结束。文件结束之后循环是否还会进行下去,这取决于getc
库函数的具体实现,在文件指针到达文件结尾之后是否还允许继续读取字符。如果允许继续读取字符,那么循环将一直进行,从而成为一个死循环。
某些C编译器在发现形如e1 = e2
的表达式出现在循环语句的条件判断部分时,会给出警告消息以提醒程序员。当确实需要对变量进行赋值并检查该变量的新值是否为0时,为了避免来自该类编译器的警告,我们不应该简单关闭警告选项,而应该显式地进行比较。也就是说,下例
if (x = y)
foo();
应写做:
if ( (x = y) ! = 0)
foo ();
前面一直谈的是把比较运算误写成赋值运算的情形,另一方面,如果把赋值运算误写成比较运算,同样会造成混淆:
if ( (filedesc == open(argv[i], 0)) < 0)
error();
在本例中,如果函数open执行成功,将返回0或者正数;而如果函数open 执行失败,将返回-1。上面这段代码的本意是将函数open的返回值存储在变量 filedesc
之中,然后通过比较变量filedesc
是否小于0来检査函数open是否执行成功。但是,此处的本应是=。而按照上面代码中的写法,实际进行的操作是比较函数open的返回值与变量filedesc
,然后检查比较的结果是否小于0。因为比较运算符的结果只可能是0或1,永远不可能小于0,所以函数error()将没有机会被调用。如果代码被执行,似乎一切正常,除了变量filedesc
的值不再是函数open的返回值(事实上,甚至完全与函数open无关)。
1.2 &和||不同于&&和||
将按位运算符&与逻辑运算符&&,或者将按位运算符|与逻辑运算符||调换,也是很容易犯的错误。
1.3 词法分析中的“贪心法”
C语言的某些符号,例如/、*、和=,
只有一个字符长,称为单字符符号。 而C语言中的其他符号,例如/*和==
,以及标识符,包括了多个字符,称为多字符符号。当C编译器读入一个字符’/‘后又跟了一个字符’*’,那么编译器就必须做出判断:是将其作为两个分别的符号对待,还是合起来作为一个符号对待。C语言对这个问题的解决方案可以归纳为一个很简单的规则:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略有时被称为“贪心法”,或者,更口语化一点,称为“大嘴法”。
需要注意的是,除了字符串与字符常量,符号的中间不能嵌有空白(空格符、 制表符和换行符)。例如,==是单个符号,而= =则是两个符号,下面的表达式
a---b
与表达式
a-- -b
的含义相同,而与
a - -- b
的含义不同。同样地,如果/是为判断下一个符号而读入的第一个字符,而/之后紧接着*,那么无论上下文如何,这两个字符都将被当作一个符号/*,表示一段注释的开始。
根据代码中注释的意思,下面的语句的本意似乎是用x除以p所指向的值, 把所得的商再赋给y:
y = x/*p /* p指向除数*/;
而实际上,/*
被编译器理解为一段注释的开始,编译器将不断地读入字符, 直到*/
出现为止。也就是说,该语句直接将x的值赋给y,根本不会顾及到后面出现的p。将上面的语句重写如下:
y = x / *p /* p指向除数*/;
或者更加清楚一点,写作:
y = x/(*p) /* p指向除数 */;
这样得到的实际效果才是语句注释所表示的原意。
诸如此类的准二义性(near-ambiguity)问题,在有的上下文环境中还有可能招致麻烦。例如,老版本的C语言中允许使用=+来代表现在+=的含义。这种老版本的C编译器会将
a=-1;
理解为下面的语句
a =- 1;
亦即
a = a - 1:
因此,如果程序员的原意是
a = -1;
那么所得结果将使其大吃一惊。
另一方面,尽管/*
看上去像一段注释的开始,在下例中这种老版本的编译器 会将
a=/*b;
当作
a =/ *b ;
这种老版本的编译器还会将复合赋值视为两个符号,因而可以毫无疑问地处理
a >> = 1;
而一个严格的ANSI C编译器则会报错。
1.4 整型常量
如果一个整型常量的第一个字符是数字0,那么该常量将被视作八进制数。 因此,10与010的含义截然不同。此外,许多C编译器会把8和9也作为八进制数字处理。这种多少有点奇怪的处理方式来自八进制数的定义。例如,0195的含义是1*82+9*81+5*80
,也就是141(十进制)或者0215 (八进制)。我们当然不建议这种用法,ANSI C标准也禁止这种用法。
需要注意这种情况,有时候在上下文中为了格式对齐的需要,可能无意中将十进制数写成了八进制数,例如:
struct {
int part_number;
char *description;
}parttab[] = {
046, " left-handed widget" ,
047, "right-handed widget" ,
125 , "frammis"
};
1.5 字符与字符串
C语言中的单引号和双引号含义迥异,在某些情况下如果把两者弄混,编译器并不会检测报错,从而在运行时产生难以预料的结果。
用单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。因此,对于釆用ASCII字符集的编译器而言,'a’的含义与0141 (八进制)或者97 (十进制)严格一致。
用双引号引发的字符串,代表的却是一个指向无名数组起始字符的指针,该数组被双引号之间的字符以及一个额外的二进制为零的字符’\0’初始化。
下面的这个语句:
printf("Hello world\n");
与
char hello[] = {'H','e','l','l','o',' ','w','o','r','l','d','\n',0};
printf(hello);
是等效的。
因为用单引号括起的一个字符代表一个整数,而用双引号括起的一个字符代表一个指针,如果两者混用,那么编译器的类型检查功能将会检测到这样的错误。 例如:
char *slash = '/';
在编译时将会生成一条错误消息,因为,并不是一个字符指针。然而,某些C编译器对函数参数并不进行类型检査,特别是对printf
函数的参数。因此,如果用
printf('\n');
来代替正确的
printf("\n");
则会在程序运行的时候产生难以预料的错误。
整型数(一般为16位或32位)的存储空间可以容纳多个字符(一般为8位), 因此有的C编译器允许在一个字符常量(以及字符串常量)中包括多个字符。也就是说,用’yes’代替"yes"不会被该编译器检测到。后者(即"yes")的含义是“依次包含’y’、‘e’、‘s’以及空字符’\0’的4个连续内存单元的首地址”。前者(即’yes’)的含义并没有准确地进行定义,但大多数C编译器理解为,“一个整数值,由’y’、‘e’、's’所代表的整数值按照特定编译器实现中定义的方式组合得到“因此,这两者如果在数值上有什么相似之处,也完全是一种巧合而已。