您好,欢迎访问三七文档
C陷阱与缺陷.txt求而不得,舍而不能,得而不惜,这是人最大的悲哀。付出真心才能得到真心,却也可能伤得彻底。保持距离也就能保护自己,却也注定永远寂寞。[修订说明]第一次修订。改正了文中的大部分错别字和格式错误,并对一些句子依照中文的习惯进行了改写。[译序]那些自认为已经“学完”C语言的人,请你们仔细读阅读这篇文章吧。路还长,很多东西要学。我也是……[概述]C语言像一把雕刻刀,锋利,并且在技师手中非常有用。和任何锋利的工具一样,C会伤到那些不能掌握它的人。本文介绍C语言伤害粗心的人的方法,以及如何避免伤害。[内容]0简介1词法缺陷1.1=不是==1.2&和|不是&&和||1.3多字符记号1.4例外1.5字符串和字符2句法缺陷2.1理解声明2.2运算符并不总是具有你所想象的优先级2.3看看这些分号!2.4switch语句2.5函数调用2.6悬挂else问题3连接3.1你必须自己检查外部类型4语义缺陷4.1表达式求值顺序4.2&&、||和!运算符4.3下标从零开始4.4C并不总是转换实参4.5指针不是数组4.6避免提喻法4.7空指针不是空字符串4.8整数溢出4.9移位运算符5库函数5.1getc()返回整数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词法缺陷编译器的第一个部分常被称为词法分析器(lexicalanalyzer)。词法分析器检查组成程序的字符序列,并将它们划分为记号(token)一个记号是一个由一个或多个字符构成的序列,它在语言被编译时具有一个(相关地)统一的意义。在C中,例如,记号-的意义和组成它的每个独立的字符具有明显的区别,而且其意义独立于-出现的上下文环境。另外一个例子,考虑下面的语句:if(xbig)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所指向的值: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(Helloworld\n);charhello[]={'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变量声明都具有两个部分:一个类型和一组具有特定格式的、期望用来对该类型求值的表达式。最简单的表达式就是一个变量:floatf,g;说明表达式f和g——在求值的时候——具有类型float。由于待求值的是表达式,因此可以自由地使用圆括号:float((f));这表示((f))求值为float并且因此,通过推断,f也是一个float。同样的逻辑用在函数和指针类型。例如:floatff();表示表达式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转换为一个“指向
本文标题:C陷阱与缺陷
链接地址:https://www.777doc.com/doc-3818381 .html