《C语言陷阱》简体中文经典版1.docx
《《C语言陷阱》简体中文经典版1.docx》由会员分享,可在线阅读,更多相关《《C语言陷阱》简体中文经典版1.docx(39页珍藏版)》请在冰豆网上搜索。
![《C语言陷阱》简体中文经典版1.docx](https://file1.bdocx.com/fileroot1/2023-1/12/f78523b9-6f15-4818-8b7e-01e29decb973/f78523b9-6f15-4818-8b7e-01e29decb9731.gif)
《C语言陷阱》简体中文经典版1
0.介绍:
3
1.1=不是==
4
1.3多字符的标记符
5
1.5字符串和字符
6
2语法陷阱
6
2.1理解声明
6
2.2操作符不具有你所期望的优先级
8
2.3分号的用法
10
2.4Switch语句
11
2.5函数调用
12
2.6else语句问题
12
3链接
13
3.1你必须自己检查外部类型定义
13
4、语义陷阱
14
4.1表达式的运算顺序
15
4.2逻辑操作符&&、||、!
15
4.3下标从0开始
16
4.4C语言并不总是对实参进行匹配
17
4.5、指针不是数组
18
4.6避免使用提喻法
18
4.7空指针并不是空字符串
19
4.8整型溢出
19
4.9移位操作符
20
5库函数
20
5.1getc返回一个整数
20
5.2缓冲输出和内存分配
21
6.1宏不是函数
22
6.2宏并非类型定义
24
7可移植性的缺陷
24
7.1如何命名
24
7.2一个整数有多大?
25
7.3字符是有符号还是无符号?
26
7.4合适的移位是带符号还是不带符号呢?
26
7.5除法怎样进行?
26
7.6一个随机数有多大
27
7.7大小写转换.
27
7.8先释放,再重新分配
28
7.9一个可移植性问题的例子.
29
8可利用的资源
31
C语言陷阱
AndrewKoenig
AT&T贝尔实验室
MurrayHill,新泽西07974
摘要
C语言就象一把雕刻用刀---简单,锋利,在熟手中极其有用。
但象所有锋利的工具一样,C语言会伤害到那些不知驾驭的人。
本文将展示C是如何伤害那些粗心的人,并将介绍如何避免这种伤害。
0.介绍:
对于专家来说,C语言易于使用和实现。
C语言简洁而富于表现力。
很少有约束来防止程序员犯一些粗心的错误。
人们所得到的错误结果及其原因之间常常没有明显的联系。
在这份文献中,我们将看到一些不可预知的结果。
因其不可预知性,我们无法对其做出完备的分类。
但通过监视运行C程序时那些必然发生的事情,我们竭尽全力这样做了。
我们假定读者要对C语言有一定的熟悉程度。
第一章的问题是当把C程序分解为标记符时所遇到的。
第二章跟踪当编译器将C的标记符分为申明,表达式和语句时的问题。
第三章确认C程序由几个单独编译的部分组成。
第四章提示一些错误概念:
即程序还在运行时发生的状况。
第五章检查程序及其调用的库函数之间的关系。
第六章提醒我们所写的代码和实际运行的并不一定一致;预处理程序已经先一步获得代码并做出处理。
最后,第七章我们探讨可移植性问题,看哪些因素导致程序在一个平台可以正常运行而在其他平台上却得不到所要的结果。
1.词法陷阱
编译器的第一个部分通常被称为词法分析器。
词法分析器将程序看成一系列字符并将其分解为标记符。
标记符由一个或多个在被编译程序中具有相对统一意思的字符组成。
例如,在C语言中,标识符“->”与组成他的两个字符之间有着异常明显的区别,且独立于上下文。
另举一个例子,考虑以下表述:
if(x〉big)big=x;
在这个表述中,除关键词if和标识符big的两个实例之外,每个非空的字符都是一个独立的标识符。
实际上C语言分两步被分解为标记符。
首先经过预处理程序。
它必须将程序标识符化以找到定义,有些定义还以宏的形式体现。
接着对宏求值并进行替换。
最后,宏替换的结果再编译形成编译器专用的字符流。
编译器接着第二次将该字符流分解为标记符。
在这一章节中我们将浏览常见的一些对标记符错误理解以及标记符与组成它的字符之间的区别。
最后将我们谈谈预处理的问题。
1.1=不是==
起源于Algol的编程语言如Pascal和Ada用“:
=”表示赋值,用“=”表示比较。
然而,C使用“=”表示赋值,用“==”表示比较。
这是因为赋值使用的频率更多,所以将它用较短的符号表示。
此外,C把赋值看成是一种操作,故可以很容易的写出如“a=b=c”之类的多重赋值,且赋值能够包含到非常复杂的表达式中。
这种便利导致一些潜在的问题:
人们可能不经意的于计划比较处进行赋值操作。
比如以下表达式,看起来好像是在比较x和y是否相等:
<非0-1表示真,0表示假)
if(x=y)
foo();
实际上却是将y的值赋给x,并判断该值是否非0。
看一下下面的循环,它试图在一个文件中跳过空格,TAB,换行符:
while(c==''||c='\t'||c=='\n')
c=getc(f);
程序员在比较'\t'时错用了‘=’。
这个“比较”实际上将'\t'赋给c并将这个新值与0进行比较。
因为'\t'非0,这个比较会一直为真,故这个循环会持续到整个文件结束。
此后的事情将取决于是否允许程序在读到文件末尾后继续读文件。
如果允许,该循环将成为一个死循环。
一些C编译器尝试帮助用户于“e1=e2”之类的情况下提供告警信息。
当你需要将一个值赋给一个变量,然后判断其值是否为零时,为避免告警信息可考虑将这个比较表达的清晰些。
比如将:
if(x=y)
foo();
这句话写成:
if((x=y)!
=0)
foo();<如果要用这种技巧的话,加一行;最简单的办法是不用。
>
当然,这也会令你的意图更为简明。
1.2&和|不是&&和||
因为很多高级语言以=作为比较的关键词,导致我们非常容易于无意中以=取代==。
&和&&,|和||也会出现这种情况,尤其是在C语言中,&和|不同于其他语言中相应的配对。
在第四章我们将进一步分析这些操作符。
<解决方案在哪里?
>
1.3多字符的记号
一些C记号(token)如/,*,和=,都是一个字符长。
其他的如==,/*,标识符等,都包含了不止一个字符。
当编译器遇到后面跟着*的/时,它必须能够判断是将这两个字符看成分别独立的记号还是看成一个记号。
C的参考手册描述了如何进行判决:
“如果输入流在一个给定的字符处已经被分解为记号,则下一个记号将包含尽可能多的字符。
”即,假如/是某个记号的第一个字符,且其后紧跟*,则不管上下文如何,这两个字符都表示注释的开始,下面的表述看起来象是设定y的值为x除以指针p所指向的值:
y=x/*p/*ppointstothedivisor*/;
实际上,由于/*表示注释开始,编译器会简单的忽略代码直至*/的出现。
换句话说,上面的表达式实际上仅仅将x的值赋给y,甚至都没有引用p。
重写上述表达式为:
y=x/*p/*ppointstothedivisor*/;
或者:
y=x/(*p)/*ppointstothedivisor*/;
都会按照注释中的意思进行除法运算。
<这个我没有想过>
此类含糊不清在其他的上下文中也会导致麻烦。
例如,老版本的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。
另一方面,字符串使用双引号,提供一种简洁的写法,将一个指针指向一个未命名的数组,该数组被初始化为双引号之间的字符和一个额外的值为0的字符。
下面的两个程序片断表示相同意思:
printf(“HelloWorld\n”);
charhello[]={'H','e','l','l','o','','W','o','r','l','d','\n',0};
printf(hello);
用指针代替整数或其它常常引发告警信息,故以双引号替代单引号的错误通常可以被编译器发现。
然而在函数调用时有一个例外,此处多数编译器并不检查参数类型。
因此,以
printf('\n');
代替
printf("\n");<找一个严格的工具,只允许一种格式存在,相当于Pascal。
在编译时并不会报错,在运行时却会引发令人惊讶的结果。
因为整型数通常都足够大,可以同时表示几个字符,所以有的C编译器允许字符常量中有多个字符。
这表明当用‘yes’替代“yes”时可能不会被发现。
后者表示四个分别装有y,e,s,null字符的连续地址空间的首地址。
前者表示一个由y,e,s以某种方式定义的字符的整数。
在这两个变量之间的任何相似之处都只是一种巧合。
2语法陷阱
要去深入理解一段程序,仅仅了解组成这些语句的的记号是不够的,你必须要理解这些记号是怎样相互配合来构成变量定义和声明,最后来组合成一段程序的。
在本章,我们来讨论一些不太清晰的语法构造。
2.1理解声明
我曾经和一个人谈论过这方面的问题,他当时正在写一个运行在单个微处理器上的程序,当这台机器启动时,硬件会自动去调用存储在地址为0的这段程序。
为了仿真机器的启动过程,我们必须要去设计一段程序去显式调用这个子程序,经过讨论,采用如下语句来执行:
(*(void(*)())0)();
象这样的表达式对于C程序员来说简直是一个噩梦。
实际上,他们不必害怕,有一个简单的原则:
declareitthewayyouuseit,可以很容易的解释清楚。
为了理解这条语句,我们先看一些简单的c语法。
每个C变量的声明包括两个部分,一部分是类型表示符,另一部分是对应此类型的变量表达式,最简单的表达式就是一个变量,如下所示:
floatf,g;
在此表达式中,f和g的类型是float类型。
由于可以用一个表达式进行变量定义,因此可以自由使用(),如:
float((f));<计算的结果是整形>
定义((f))这个表达式的计算结果是一个float型数,因此f也是一个float型数。
同样原理适用于函数和指针类型,看下面例子:
floatff();
表示表达式ff()是一个float数,因此ff是一个返回float的函数。
与之类似:
float*pf;
意味着*pf是一个浮点数,因此pf是一个指向float数的指针。
<指针里装的是浮点>
根据上面表达式的组合方法,下面语句:
float*g(),(*h)();
表明*g()和(*h)()都是float型表达式,因为()的运算优先级比*高,<是不是?
的确如此!
>因此*g()等同于*(g()),g是一个返回float型指针的函数,h是一个返回float型变量的函数指针。
<在C语言你就要不断地套>
一旦知道怎样去定义一个指定类型的变量,那就很容易写出此指定类型的原型,只要从定义表达式中去掉变量名和分号,然后将整个语句用()包含就行了。
因而语句
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)();
一旦知道怎样声明一个变量,我们就能够将一个常量转换为此类型:
只要在此常量前加上变量类型的声明即可。
因此我们可以用如下的方式将0转换成一个指向返回void型的函数指针:
(void(*)())0
现在可以用(void(*)())0来代替fp进行函数调用,如下所示:
(*(void(*)())0)();
到这里问题就讨论结束了,另外还有一种更清晰方法来实现上面的功能,使用“typedef”出定义一种新的变量类型:
typedefvoid(*funcptr)();
(*(funcptr)0)();
<?
?
?
?
?
老爸,你不是要我去跳楼吗?
>
2.2操作符不具有你所期望的优先级
假设FLAG是一个int型的常量,其中有一bit为1,当想去检测一int型的变量flags的对应bit是否为1,通常采用以下方法:
if(flags&FLAG)...<一个为变量,另一个为常量>
这条语句检查位于()中的运算表达式是否为0,这对于大多数C程序员来说是比较简单的。
但采用以下方式可以使检测语句更为清晰:
if(flags&FLAG!
=0)...<想不到!
=的优先级比&高,加个()>
这条语句更容易理解,但同时产生了一个错误:
因为!
=的优先级比&高,这上面的语句相当于:
if(flags&(FLAG!
=0))...<太灵活了>
程序的执行并不按照预想的进行。
假设有两个int型变量h和l,他们的取值在0-15之间。
当你想给一个8bit的变量r赋值,让它的低4bit与变量l的低4bit相同,高4bit与变量h的低4bit相同,一般的做法如下:
r=h<<4+l;
不幸的是,这条语句也是错误的。
因为‘+’的运算优先级比‘<<'的高,因此上面的语句等效为:
<+的优先级也<<高,还是用()>
r=h<<(4+l);
有两个方法去实现正确的功能:
r=(h<<4)+l;<这个值得推荐。
两个前提条件是其余的位是0bit>
r=h<<4|l;
为了避免出现以上的问题,可以用()来分割所有的语句,但是使用()过多,会对程序的可读性有影响,因此应该尽量记住C语言的运算符的优先级。
很不幸的是,在C语法中,总共有15个运算符,要想全部记住他们的优先级不是一件容易的事,但是我们可以将它们进行分组来帮助记忆。
运算符中具有最高优先级的是那些不参与运算的操作符:
例如下标运算符([]),函数调用,结构体变量运算符(.->),它们的结合方向是自左向右。
<别的书没说过。
>
接着就是一元运算符,它们在参与运算的操作符中具有最高优先级。
因为函数调用的优先级高于一元运算符,因此当p为一函数指针时,必须用(*p)()来调用函数,*p()则表明p为返回一指针的函数。
一元运算符的结合方向是自右向左,<跟上面相反>因此*p++应于*(p++)相同,而不与(*p)++相同。
再接着就是二元运算符,算术运算符在里面具有最高的优先级,再下面是移位运算符,关系运算符,逻辑运算符,条件运算符,赋值运算符,最后是逗号运算符,当判断这些运算符的优先级时,有两点要记住:
1、每个逻辑运算符的优先级低于关系运算符的优先级
2、移位运算符的优先级高于关系运算符,但是低于算术运算符
在不同运算符类别中,没有特别需要注意的地方。
乘法、除法、取余具有相同的优先级,加法、减法具有相同的优先级,两个移位运算符具有相同的优先级。
有一点要注意的是六个关系运算符具有不相同的优先级:
“==”和“!
="的优先级比其他四个要低。
因此下面的表达式的意思是判断a和b比较结果和c和d的比较结果是否相等。
a
三元条件运算符的优先级比上面提及的运算符的都要低,这就允许在选择表达式中有关系运算符的逻辑组合,如下所示:
z=a
d:
e
&&(b{
Z=d;
}
Else
{
Z=e;
}
上面语句同时也说明了赋值运算符的优先级比关系运算符的低,而且所有的复合赋值运算符具有相同的优先级,并且它们的运算方向是从右到左。
因此:
a=b=c
等同于
b=c;a=b;
优先级最低的是逗号运算符,这比较容易理解,因为当一条语句由多个表达式组成时,逗号在这里相当于分号的功能。
在混合优先级判断中,赋值运算符是比较棘手的。
考虑下面的例子,它执行的功能是拷贝一个文件:
while(c=getc(in)!
=EOF)<加个括号>
putc(c,out);
“while”语句中要实现的功能是给变量c赋值,然后与EOF进行比较来终止循环,不幸的是,赋值操作的优先级低于比较操作的优先级,因此c的值是getc(in)与EOF比较的结果,getc(in)的值将被弃掉,因此拷贝生成的文件将是一连串的1。
想实现以上的功能并不困难,可以简单修改如下:
while((c=getc(in))!
=EOF)
putc(c,out);
然而,在复杂语句中,这种优先级混淆的问题是很难被发现的。
在UNIX系统下面的几个不同版本的连接程序中曾经出现过如下的错误语句:
<赋值在别的地方,为什么在这个地方赋>
if((t=BTYPE(pt1->aty)==STRTY)||t==UNIONTY){
此条语句要实现的功能是给变量t赋值,然后判断是否与STRTY相等或者与UNIONTY相等,但是这条语句真正实现的功能并不是这样。
C语言中,逻辑运算符的优先级分配有其历史的原因。
B语言,也就是C语言的前身,也有相当于C语言中的&和|操作符,尽管它们被定义用作位运算符,但是当用于条件上下文时,编译器会自动将它们当作&&和||运算。
2.3分号的用法
语句中一个额外的分号通常会产生小的分歧:
他可以是一条不产生任何影响的空语句,或者是用于使编译器产生一个诊断信息,使之容易去掉。
然而如果在if或while语句后面加一个分号,会产生严重的歧义。
看下面的例子:
if(x[i]>big);<这不是结束了吗?
>
big=x[i];
第一行后面的分号编译时能够通过,但这段程序的功能跟下面的程序完全不同:
if(x[i]>big)
big=x[i];
前面的那段程序相当于:
if(x[i]>big){}
big=x[i];
当x、i、big不是宏,不具有其他影响时,可以再简化为:
big=x[i];
另外一个能产生重大歧义的地方是在一个函数的定义前面有一个类型的声明。
看下面的一段程序:
structfoo{
intx;
}
f()
{
...
}
在第一个}后面忘记写了一个分号,然后函数f就紧跟在后面定义,那这样就定义了函数f返回结构体foo的一个变量。
假如分号存在的话,则f返回的是缺省的int型数。
<该断不断则必乱,变成定义的前提。
>
2.4Switch语句
在C语言中,Switch语句中的各个case分支能够相互作用,这和别的程序语言不同。
现在看一下C和Pascal的两个例子:
switch(color){
case1:
printf("red");
break;
case2:
printf("yellow");
break;
case3:
printf("blue");
break;
}
casecolorof
1:
write('red');
2:
write('yellow');
3:
write('blue')
end
这两段程序做同样的工作:
根据变量color的值为1、2、3,在当前行打印“red”、“yellow”、“blue”字符串。
这两段程序非常相似,仅仅是Pascal程序没有break语句。
这是因为在C语言中case标号是一个真实的标号,程序可以自由的访问它。
现在以另外的方法来看这个问题,下面这段程序是仿照Pascal的样式写的:
switch(color){
case1:
printf("red");
case2:
printf("yellow");
case3:
printf("blue");
}
现在假设color的值等于2,那样程序将打印“yellow”和“blue”。
因为当程序调用完第二个printf()后,就很自然的执行它以后的程序。
这既是C语言switch语句的优点,也是它的缺点。
说它是缺点是因为在程序的编写中很容易漏写一个break,而这样会导致程序的错误执行,并且错误不容易发现。
作为优点,你可以故意漏掉一些break语句,很乐意让多个分支共用相同的处理程序。
特别是在一些具有很多case分支的程序中,你经常会发现一些case分支共用相同的处理程序。
<先把break写出来。
>
下面是一个虚拟机器的解释程序,程序里面包含了一段switch语句去处理不同的指令,在这种机器上,在第二个操作数的符号反转后,加法与减法的操作是一样的,因此程序编写成下面这样是很好的:
caseSUBTRACT:
opnd2=-opnd2;
/*nobreak*/
caseADD:
<用IF语句实现也可以>
...
另外一个典型的例子是编辑器中的一段小程序,它实现的功能是忽略空格,得到一个记号(token),算法中空格、tab键、换行符都是同样处理,除了换行符能使行计数器加1,程序如下所示:
case'\n':
linecount++;
/*nobreak*/
case'\t':
case'':
...
2.5函数调用
跟别的程序设计语言不一样,C语言要求函数调用时必须有参数列表,即使此参数列表是空的。
假设f是一个函数,则
f();<参数为空>
就是进行函数调用的语句,但是
f;<表示地址>
则不作任何事情,它表示这个函数的地址,但是并不进行函数调用。
2.6else语句