现在的位置: 首页 > 综合 > 正文

c 缺陷和陷阱

2014年02月02日 ⁄ 综合 ⁄ 共 8748字 ⁄ 字号 评论关闭

《C陷阱与缺陷》读书笔记
前记
2010年上半学期软件学院课程不是太多,有了很多空余时间,于是利用一个月的时间把《C陷阱与缺陷》这本书看了一遍。正如ACCU主席Francis Glassborow所说“本书所提示的知识,至少能够帮助你减少C代码和初级C++代码中90%的Bug”,这本书真的是C和C++方面的经典书籍。                                                                          

《C陷阱与缺陷》是由Andrew Koenig所著,高巍译。Andrew Koenig是AT&T大规模程序研发部(前贝尔实验室)成员,不仅有着多年的C++开发,研究和教学经验,而且还亲身参与了C++的演化和变革,对C++的变化和发展起到重要的影响。
正文
《C陷阱与缺陷》读书笔记
各章简介
第一章 记法“陷阱”:考察在程序被记法分析器分解成各个符号的过程中可能出现的问题。
第二章 语法“陷阱”:如果没有正确理解这些语法细节,将会出现怎么的错误?
第三章 语义“陷阱”:有关语义误解的问题。
第四章 连接:有关组成C程序的若干部分的连接问题。
第五章 库函数:库函数的误用。
第六章 预处理器:与预处理器有关的内容。
第七章 可移植性缺陷:可移植性问题。
第八章 建议:有关预防性程序设计的一些建议。
                                                                                              第一章 词法“陷阱”
  程序中的单个字符鼓励看来并没有什么意义,只有结合上下文才有意义。
 编译器中负责将程序分解为一个一个符号的部分,一般称为“语法分析器”。
 
 1.1   =不同于==
  while (c='' || c==' ' || c==' ')
     c=getc(f);
  这个循环将一直进行到文件的结束,是否死循环取决于getc的实现。如果确实需要在条件判断部分使用赋值,应该显式地进行比较:
  if ((x=y)!=0)
     foo();
 
 1.2  & 和 | 不同于 && 和 ||
 1.3 语法分析中的“贪心法“
  当C编译器读入一个字符后又跟了一个字符,那么编译器就必须做出判断:是将其作为两个分别的符号对待,还是合起来作为一个符号对待。C语言对这个问题的解决方案可以归纳为一个很简单的规则:每一个符号应该包含尽可能多的字符。
 a---b 与 a -- - b 的含义相同,而与  a - -- b 的含义不同。
 1.4 整型常量
  如果一个整型常量的第一个字符是数字0,那么该常量将被视作八进制数。
 1.5 字符与字符串
  C语言中的单引号和双引号含义迥异,在某些情况下如果把两者弄混,编译器并不会检测报错,从而在运行是产生难以预料的结果。
  用单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。
  用双引号引起的字符串,代表的却是一个指向无名数字起始字符的指针,该数组被双引号之间的字符以及一个额外的二进制为零的字符''初始化。
  然而,某些C编译器对函数参数并不进行类型检查,特别是对printf函数的参数。因此, 如果用
     printf(' ');
 来代替正确的
     printf(" ");
则会在程序运行的时候产生难以预料的错误,而不会给出编译器诊断信息。
  整型数(一般为16位或32为)的存储空间可以容纳多个字符(一般为8位),因此有个C编译器允许在一个字符常量(以及字符串常量)中包括多个字符。也就是说,用'yes'代替"yes"不会被该编译器检测到。后者的含义是“一次包括'y''e''s'以及空字符''的4个连续内存单元的首地址“。前者的含义并没有准确的进行定义,但大多数编译器理解为,“一个整数值,由'y''e''s'所代表的整数值按照特定编译器实现中定义的方式组合得到“。
  (注:在Borland C++ v5.5 和 LCC v3.6中采取的做法是,忽略多余的字符,最后的整数值即第一个字符的整数值;而在Visual C++ 6.0 和 GCC v2.95中采取的做法是,依次用后一个字符覆盖前一个字符,最后得到的整数值即最后一个字符的整数值。)

                                                                                                  第二章 语法“陷阱”
 2.1 理解函数声明
  (*(void(*)())0) ();
  任何复杂表达式其实只有一条简单的规则:按照使用的方式来声明。
  任何C变量的声明都由两部分组成:类型以及一组类似表达式的声明符(declarator)。声明符从表面上看与表达式有些类似,对它求值应该返回一个声明中给定类型的结果。
  因为声明符与表达式的相似,所以我们也可以在声明符中任意使用括号:
   float ((f));
  这个声明的含义是:当对其求值时,((f))的类型为浮点类型,由此可以推知,f也是浮点类型。
  各种形式的声明还可以组合起来,就像在表达式中进行组合一样。因此,
 float *g(),(*h)()表示*g()与(*h)()是浮点表达式。因为()结合优先级高于*,*g()也就是*(g()):g是一个函数,该函数的返回值类型为指向浮点数的指针。同理,可以得出h是一个函数指针,h所指向函数的返回值为浮点类型。
  一旦我们知道了如何声明一个给定类型的变量,那么该类型的类型转换符就很容易得到了:只需要把声明中的变量名和声明末尾的分号去掉,再将剩余的部分用一个括号整个“封装”起来即可。例如,因为下面的声明:
   float (*h)();
  表示h是一个指向返回值为浮点类型的函数的指针,因此,(float (*)())表示一个“指向返回值为浮点类型的函数的指针”的类型转换符。
  (*fp)(); -> (*0)(); -> (*(void (*)())0)();
 2.2 运算符的优先级问题
 优先级最高者其实并不是真正意义上的运算符,包括:数组下标,函数调用操作符各结构成员选择操作符。他们都是自左于右结合,因此 a.b.c的含义是(a.b).c。
  ()  []  ->  .
  单目运算符的优先级仅次于前述运算符。在所有的真正意义上的运算符中,它们的优先级最高。单目运算符是自右至左结合。因此*p++会被编译器解释成*(p++)。
 ! ~ ++ == = (type) * & sizeof
 优先级比单目运算符要低的,接下来就是双目运算符。在双目运算符中,算术运算符的优先级最高,移位运算符次之,关系运算符再次之,接着是逻辑运算符,赋值运算符,最后是条件运算符。
 *  /  %
 +  -
 <<  >>
 <  <=  >  >=
 == !=
 &
 ^
 |
 &&
 ||
 ?:
我们需要记住的最重要的两点是:
1.任何一个逻辑运算符的优先级低于任何一个关系运算符。
2.移位去处符的优先级比算术运算符要低,但是比关系运算符要高。

 2.3 主义作为语句结束标志的分号
 2.4 关于switch语句
  case ' ':
     linecount++;
  case ' ':
  case '':
     .......
 2.5 函数调用
  f();是一个函数调用语句,而f; 计算函数f的地址,却并不调用该函数。
 2.6 “悬挂”else引发的问题
  if (x == 0)
     if (y == 0) error();
  else{
     z = x + y;
     f(&z);
   }
 
                                                                                                  第三章 “语义”陷阱
 3.1 指针和数组
  C语言中的数组值得注意的地方有以下两点:
  1.C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确定下来。然而,C语言中数组的元素可以是任何类型的对象,当然也就可以是另外一个数组。
  (注:C99标准允许变长数组(VLA)。GCC编译器中实现了变长数组,但细节与C99标准不完全一致。)
  2.对于一个数组,我们只能够做两件事:确定该数组的大小,以及获得指向该数组下标为0的元素的指针。其他有关数组的操作,哪怕他们看上去是以数组下标进行运算的,实际上都是通过指针进行的。换句话说,任何一个数组下标运算都等同于一个对应的指针运算,因此我们完全可以依据指针行为定义数组下标的行为。
  很多程序设计语言中都内建有索引运算,在C语言中索引运算是以指针算术的形式来定义的。
  如果一个指针指向的是数组中的一个元素,那么我们只要给这个指针加1,就能够得到指向该数组中下一个元素的指针。同样地,如果我们给这个指针减1,得到就是指向该数组中前一个元素的指针。
  int calendar[12][31];
  int *p;
  则p = calendar; 是非法的。因为calendar是一个二维数组,即“数组的数组”,在此处的上下文中使用calendar名称会将其转换为一个指向数组的指针;而p是一个指向整型变量的指针,这个语句试图将一种类型的指针赋值给另一种类型的指针。
  要构造一个指向数组的指针的方法:
  int calendar[12][31];
  int (*monthp)[31];
  monthp = calendar;
  这样,monthp将指向数组calendar的第一个元素,也就是数组calendar的12个有着31个元素的数组类型元素之一。
 3.2 非数组的指针
  在C语言中,字符串常量代表了一块包括字符串中所有字符以及一个空字符('')的内存区域的地址。
  假定我们有两个字符串s和t,我们希望将这两个字符串连接成单个字符串t。
  考虑:
  char *r,*malloc();
  r = mallor(strlen(s) + strlen(t));
  strcpy(r,s);
  strcat(r,t);
  这个例子的错误有3点:
  1,malloc函数有可能无法提供请求的内存。
  2,显式地分配了内存必须显式地释放内存。
  3,malloc函数并未分配足够的内存。
  正确是方法:
※  char *r,*malloc();
   r = malloc(strlen(s) + strlen(t) + 1);
   if(!r) {
     complain();
     exit(1);
    }
   strcpy(r,s);
   strcat(r,t);
 
   /*一段时间之后*/
   free(r);
 3.3 作为参数的数组声明
  在C语言中,我们没有办法可以将一个数组作为函数参数直接传递。如果我们使用数组名作为参数,那么数组名会立刻被转换为指向该数组第1个元素的指针。
  因此,将数组作为函数参数毫无意义。所以,C语言中会自动地将作为参数的数组声明转换为相应的指针声明。
 
 3.4 避免“举x法”
  需要记住的是,复制指针并不同时复制指针所指向的数据。
 3.5 空指针并非空字符串
  出了一个重要的例外情况,在C语言中将一个整型转换为一个指针,最后得到的结果都取决于具体的C编译器实现。这个特殊的情况就是常数0,编译器保证由0转换而来的指针不等于任何有效的指针。
  #define Null 0
  需要记住的重要一点是,当常数0被转换为指针使用时,这个指针绝对不能被解除引用(dereference)。 换句话说,当我们将0赋值给一个指针变量时,绝对不能企图使用该指针所指向的内存中存储的内容。
 3.6 边界计算与不对称边界
  在所有常见的程序设计错误中,最难于察觉的一类是“栏杆错误”,也常被称为“差一错误”(off-by-one error)。
  避免“栏杆错误”的两个通用原则:
  (1) 首先考虑最简单情况下的特例,然后将得到的结果外推。
  (2) 仔细计算边界,绝不掉以轻心。
 用第一个入界点和第一个出界点来表示一个数值范围 能够降低这类错误发生的可能性。
  比如整数x满足边界条件x>=16且x<=37我们可以说x>=16且x<38,这里下界是“入界点”,即包括在取值范围之中;而上界是“出界点”,即不包括在取值范围之中。
  另一种考虑不对称边界的方式是,把上界视作某序列中第一个被占用的元素,而把下界视作序列中第一个被释放的元素。
 
 3.7 求值顺序
  C语言中只有四个运算符(&&, ||, ?: 和 ,)存在规定的求值顺序。运算符&&和运算符||首先对左侧操作数求值,只在需要时才对右侧操作数求值。运算符?:有三个操作数: 在a?b:c中,操作数a首先被求值,根据a的值首先被求值,根据a的值再求操作数b或c的值。而逗号运算符,首先对左侧操作数求值,然后该值被“丢弃”,再对右侧操作数求值。
 3.8 运算符&&, || 和 !
  运算符&和运算符&&不同,运算符&两侧的操作数必须被求值。
 3.9 整数溢出
  C语言中存在两类整数算术运算,有符号运算与无符号运算。在无符号算术运算中,没有所谓“溢出”一说:所有的无符号运算都是以2的n次方为模,这里n是结果中的位数。如果算术运算符的一个操作数是有符号整数,另一个是无符号整数,那么有符号整数会被转换为无符号整数,“溢出”也不可能发生。当两个操作数都是有符号整数时,“溢出”有可能发生。当一个运算的结果发生“溢出”时,作出任何假设都是不安全的。
 3.10 为函数main提供返回值
  一个返回值为整型的函数如果返回失败,实际上是隐含地返回了某个“垃圾”整数。只要该数值不被用到,就无关紧要。
第四章连接
   编译器一般每次只处理一个文件。编译器的责任是把C源程序翻译成对连接器有意义的形式。
    许多系统中的连接器是独立于C语言实现的,因此如果链接时候错误原因是与C语言相关的,连接器无法判断错误原因。但连接器能够理解机器语言和内存布局。
    典型的连接器把由汇编器或编译器生成的若干个目标模块,整合成一个被称为载入模块或可执行文件的实体。
    连接器通常把目标模块看成是由一组外部对象组成的。每个外部对象代表着机器内存中的某个部分,并通过一个外部名称来 识别。因此,程序中的每个函数和每个外部变量,如果没有被声明为static,就都是一个外部对象。static的不会与其它源程序文件中的同名函数或同 名变量发生冲突。对于非satatic的函数或变量的名称冲突的解决办法将在后面讨论。除了外部对象外,目标模块中海可能包括了对其他模块中的外部对象的引用,当连接器读入一个目标模块时,他必须解析出这些引用,并作出标记说明这些外部对象不再是未定义的。
    连接器的输入是一组目标模块文件和库文件。输出是一个载入模块。

避免外部变量的函数的冲突和不一致等问题的办法:
每个外部对象只在一个头文件里声明,需要用到该外部对象的所有模块都应该包括这个头文件。定义该外部对象的模块也应该包括这个头文件。
第五章库函数
主要讲了一些常见的库函数,以及编程者在使用他们过程中可能出错之处。

 如果要同时对打开的文件进行读写操作,需要在其中插入fseek函数。如:
 FILE *fp;
 struct record rec;
 ……
 while( fread( (char *)&rec, sizeof(rec), 1, fp) == 1 )
 {
         /* handle rec here*/
         if(/*we have to write rec back to file*/)
         {
                 fseek(fp, -(long)sizeof(rec), 1);
                 fwrite( (char*)&rec, sizeof(rec), 1, fp);
                 fseek(fp, 0L, 1);
         }
 }

 使用errno
 /*调用库函数*/
 if(返回了错误值)
         检查errno

 使用signal库函数:
 #include <signal.h>
 signal( signal type, handler function);
 但一个信号可能在C程序执行期间的任何时刻上发生。甚至可能出现在某些复杂库函数(如malloc)的执行过程中。因此从安全角度考虑,信号函数不应该调用上述类型的库函数。

第六章预处理器

6.1 宏中的空格
 对于#define f (x) ((x)-1),它展开后为 (x) ((x)-1),而 #define f(x) ((x)-1) 展开后为 ((x)-1) ,并且对这个宏定义,f(3) 和 f (3)这两种调用方式求值后都为2。
6.2 宏并不是函数
    从表面上看,宏玉函数的行为非常相似,但它们并不完全等同。假如:
 #define abs(x) x>0?x:-x
 则对 abs(a-b)展开后得到 a-b>0?a-b:-a-b ,其中-a-b即(-a)-b 与我们期望的 -(a-b) 不一致;
 又,对abs(a)+1 展开后得到 a>0?a:-a+1 (与上类似,与期望不符)。
 为此,应该将宏定义中每一个参数用括号括起来,并且将整个表达式也括起来,以预防引起优先级有关的问题。正确的定义应该是这样的
 #define abs(x) (((x)>0)?(x):(-x))
    再来看一个例子,假如有宏定义
 #define max(a,b) ((a)>(b)?(a):(b))
 程序中有这样的代码:
 biggest = x[0];
  i=1;
 while(i<n)
      biggest = max(biggest,x[i++]);
 如果max为一个真正的函数,则上述代码无误,而如果max为一个宏,那就不能正常工作。原因是对宏扩展后
 biggest = ((biggest)>(x[i++])?(biggest):(x[i++]));
其中这部分 x[i++] 有可能被执行两次,与我们的期望不符。解决办法是确保宏max中的参数没有副作用,像这样:
 biggest = x[0];
 for(i = 1;i<n;i++)
      biggest = max(biggest,x[i]);
    由此,我们看到宏和函数的一些区别。
6.3 宏并不是语句
    考虑这样一个例子
 #define assert(e) if(!(e)) assert_error(__FILE_,_LINE_)
 则对以下代码
 if(x>0 && y>0)
     assert(x>y);
 else
     assert(y>x);
 展开后得到,
 if(x>0 && y>0)
     if(!(x>y)) assert_error("foo.c",37);
 else
     if(!(y>x)) assert_error("foo.c",39);
 注意到,else并不是与第一个if 匹配,这与我们的期望不符。解决办法是,将宏assert定义为一个表达式而不是一个语句:
 #define assert(e) ((void)((e)||_assert_error(_FIL_,_LINE_)))
 上述定义利用了对 || 两侧操作数依次顺序求值的性质。
6.4 宏不是类型定义
    考虑下面的代码
 #define T1 struct foo *
 typedef struct foo *T2;
 从两个定义来看,T1和T2从概念上完全符同,都是指向结构foo的指针。但是
 T1 a,b;
 T2 a,b;
 对于第一个声明展开后,struct foo *a,b; ,a为指针,b不是指针,与期望不符。
第七章可移植性缺陷

 1、移位运算符
        1)在向右移位时,空出的位是由0填充还是1,还是由符号位的副本填充?
           如果被移位对象是无符号数,那么由0填充;如果是有符号数,那么是0 或符号位的副本。
        2)移位操作的位数允许的取值范围是什么?
           如果被移位对象的长度是n位,那么移位计数必须大于或等于0,而严格小于n。
 2、一个例子
        输入一个long型整数和一个函数指针。程序把给出的long型整数转换为其10进制表示,并且对10进制表示中的每个字符都调用函数指针所指向的函数。
 void
 printneg( long n, void (*p)() )
 {
         long q;
         int r;
         q = n / 10;
         r = n % 10;
         if( r > 0 ){
                 r - = 10;
                 q++;
         }
         if (n <= -10)
                 printneg( q, p);
         (*p) ( "0123456789"[ -r ] );
 }

 void
 printnum (long n, void (*p)() )
 {
         if ( n < 0) {
                 (*p)('-');
                 pirntneg(n, p);
         }else
                 printneg(-n, p);
 }
 移植性需考虑的地方:
 1)机器的字符表不同。
 2)有的机器是one's complement,有的机器是two's complement的。基于2的补码的计算机,所允许表示的附属取值范围要大于正数取值范围,所以有时取负值的运算会导致溢出。
 3)各机器对取模运算的定义不同。
 
第八章 建议
 将惯用的c == '\t' 写作 '\t' == c。一旦写错成=号,编译器就能检查出来。

 

抱歉!评论已关闭.