C陷阱与缺陷》的一些心得体会-----1

浏览: 172 发布日期: 2016-11-30 分类: c
前言和导读

  “得心应手的工具在初学时的困难程度往往超过那些容易上手的工具。”比较认同这句话。我至今觉得自己其实还是个刚入了门的初学者。

 

第一章  “词法”陷阱

 

  由于之前学过编译原理,对编译器词法分析(主要是符号识别过程)比较了解,理解起来不困难。

  在讲到"="和"=="、"|"和"||"、"&"和"&&"时,联想起以前见过一些程序中出现了类似于"#define ||  OR"这样的语句。当时以为可能是为了照顾习惯其他语言的使用者的阅读偏好,现在看来这样做确实可以避免一些错误。当然使用不使用这种编程风格就是另外一回事了。对于词法分析的运用到的贪心法,之前虽然知道原理和规则,但确实没有意识到这是种贪心法。这样一来,对于容易引起编译器错误的格式,写成另一种格式更好一些。

y = x/*p // 本需要进行指针取值、除法和赋值,这个颜色已表示编译器并不这么认为,因此只能写成下一种形式
y = x/(*p)
复制代码
  至于为整型常量用0补首位以便对齐,反而使得编译器将其误认为八进制数的情况倒是从来遇到也没考虑到过。

  “单引号' '中的字符上代表一个整数,双引号" " 引起的字符串代表的是一个指向无名数组起始字符的指针。”前半句以前就知道,后半句有点意思。后半句解释了为什么char *slash = '/' 这个语句有错误。同时,书中指出整型一般为16位或32位,可以容纳多个字符(一般为8位),所以用单引号的'yes'或许能够被一些编译器正确识别,只是巧合而已。有的编译器会将'yes'多余字符忽略,只取第一个整数'y';有的则依次取值、覆盖再取值,最后结果是只取了最后一个整数相当于's'。

 

11月16日

第二章  “语法”陷阱

 

  模拟调用首地址为0的子例程的语句(*(void(*)())0) ()以及它从(*fp)()生成而来的过程。把常数0转型为“指向返回值为void的函数的指针”的类型就是(void(*)()) 0,用它代替fp就生成了这个语句。使用typedef会更方便直观。根据补充阅读,在编程中使用typedef目的一般有两个,一个是给变量一个易记且意义明确的新名字,另一个是简化一些比较复杂的类型声明。前者早已知道,后者确实是个盲点,因此以前总把typedef当做另一种#define。

typedef void (*funcptr)();(*(funcptr)0)();
复制代码
利用这个特性,不难理解signal函数的声明。它接受两个参数(一个整型的信号编号和指向用户定义的信号处理函数的指针),返回值是一个指向调用前的用户信号处理函数的指针。

void (*signal(int,void(*)(int)))(int)/*下面是简化后的函数声明*/typedef void (*HDNDLER)(int);HANDLER signal(int, HANDLER)
复制代码
  关于运算符优先级,以前的处理方式是无脑加括号,完全不关注;但是括号太多了确实会造成阅读困难。根据原书所示做出归纳。

View Code?1.优先级最高者其实并不是真正意义上的运算符,包括数组下标、函数调用操作符、结构成员选择操作符,它们自左向右结合;
2.单目运算符次之,自右向左结合;
3.双目运算符又次之,其中算术运算符最高,移位运算符次之,关系运算符再次之,然后是逻辑运算符,赋值运算符,最后是三目运算符。
     其中最重要的有两点:逻辑运算符低于关系运算符、移位运算符比算术运算符低但比关系运算符高。其他一些细节:
    * / %高于+ - ,+ -高于移位;
    关系运算符中==和!=低于其他4中(< <= > >=);
    无论是逻辑运算还是按位运算,“与”总比“或”优先级高。
4.三目运算符更低,所有运算符中逗号最低

  多余的和缺失的分号会造成的错误一般不会出现。但是所给的例子比较醒目地提醒了结构定义后如果不加分号的结果——可能导致其后定义的函数返回类型为这种结构:

struct logrec{......}main() //当然一般使用void main()
复制代码
  有时候,switch...case...结构中不一定每一个分支语句都需要break,书中字符处理的例子就不再重复。

  没有参数的函数调用也应该有一个空的参数列表。

  else总与最近的if配对。

 

  其他的一些感想:对于词法分析,空格、换行确实是可行的分隔方法;然而在语法分析时却不那么好用了。多余的空格和换行符往往会被删掉,因此必须明白语句结构的配对原则和分号" ; "及逗号" , "的正确使用,还有大括号" { } "的配对。当然这个结论应该是初学伊始就牢牢树立的观点了。有的语言特点和编程风格(特别是通过宏定义改变语法结构的外观)不少是从包括C语言的前身以及其他语言的编程风格继承而来,但后者并不是必要的。

 

11月16日~17日

第三章  “语义”陷阱

 

  数组和指针:数组名实际上是一个指向数组首元素的指针,只有在作为sizeof()的参数时例外。

  虽然C语言只有一维数组,但用一维数组模拟多维数组是可以的。

  a[i]是*(a+i)的简记,这也就可以解释为什么数组首元下标从0开始。也正因此,a[i]和i[a]具有相同的含义。汇编中后者并不少见,但作者不推荐这种写法。在多维数组中,下标表示法比*(*(calender+4)+7)这样的表示方法简单多了。

  字符串拷贝中暗含的陷阱:malloc分配空间可能失败;分配后需要及时释放;分配内存大小的限定。三者结合起来的例子:

三个陷阱
复制代码
  C中无法将一个数组作为函数参数直接传递。如果使用数组名作为参数,那么数组名会立刻被转换为指向该数组第1个元素的指针。char hello[] = "hello"声明了一个字符数组后,printf("%s\n",hello)和printf("%s\n",&hello[0])是等效的(同样是在终端显示hello)。但这种情况限于其作为函数参数的情况,书中指出,假设这种自动转换在其他情形下成立是错误的,"extern char *hello"与"extern char hello[]"有着天壤之别,这是第四章内容,尚未涉及。由此延伸而来,如果一个指针参数并不实际代表一个数组,即使从技术上而言是正确的,采用数组形式的记法经常会起到误导作用。反之,如果一个指针参数代表一个数组,以main函数第二个参数为例来说明:

main(int argc, char* argv[]) {  } //强调argv是一个指向某数组的起始元素的指针,该数组的元素为字符指针类型main(int argc, char** argv) {  }//两种写法完全等价,可以任选一种最能清楚反应自己意图的写法
复制代码
  避免“举隅法”(不必深究这个语言学名词),包含的“陷阱”即为混淆指针和指针所指的数据。比如char *p,*q;p="ABC";q=p;。
  除了0,C语言将一个整数转换为一个指针。最后得到的结果取决于编译器实现。而0,编译器保证其转换而来的指针不等于任何有效的指针。#define NULL 0 是出于代码文档化的考虑。因此是不能使用赋值为0的指针变量所指向的内存中存储的内容的,这是未定义的。相关的语句在不同计算机上会有不同效果,第七章会详细讨论这个问题。

  在“边界计算和不对称计算”这个“陷阱”处,作者以int i,a[10]举例,说明了为什么for(i=0; i<10; i++)   a[i] = 0 ;比for(i=0; i<=9; i++)   a[i] = 0 ;要好:入界点和出界点恰好为0和10,并且对于下标为从0开始的C语言,出界点恰是数组元素个数。对于这个问题的另一种考虑方法:把上界视作某序列第一个被占用的元素,把下界视作第一个被释放的元素。这种考虑方式处理不同类型的缓冲区时很有用,所举例子是一个指向缓冲区的指针,让它总是指向第一个未占用的字符,这样对其赋值就有 *bufptr++ = c;的形式。对于例子还有更多细节可以揣摩。

边界计算与不对称边界的偏好
#define N 1024static char buffer[N];void bufwrite(char *p, int n){    while (--n >= 0)/*大多数C语言实现中--n>=0至少与等效的n-->0一样快,某些C实现中甚至更快。前者首先n减去1,结果与0比较;后者先保存n,从n中减1,然后比较保存值与0*/        if (bufptr == &buffer[N])/*虽然buffer[N]并不存在,但目的是比较bufptr与缓冲区后第一个字符的地址,&buffer[N]确实是这个地址。它的地址是真实存在的,数组中实际不存在的“溢界”元素地址位于数组所占内存之后,是可以用于赋值和比较的。当然如果要引用这个元素,就是非法操作了*/            flushbuffer();        *bufptr++ = *p++;    }}
  在之后的另一个例子中,作者认为,技巧性很强的代码,“如果没有很好的理由,我们不应该尝试去做。但如果是‘师出有名’,那么理解这样的代码应该如何写就很重要了。”对于那个具体的例子,“只要我们记住前面的两个原则,特例外推法和仔细计算边界,我们应该完全有信心做对。”

另一个例子
/*变量定义同上一个例子*/void memcpy(char *dest, const char *source, int k){    while (--k>= 0)        *dest++ = *source++;}/*memcpy是个库函数,这里是一个简易实现*//*本例是利用memcpy一次转移一批字符到缓冲区*/void bufwrite(char *p, int n){    while (n > 0) {        int k, rem;        if (bufptr == &buffer[N])            flushbuffer();        rem = N -(bufptr - buffer);        k = n > rem?rem: n;        memcpy(bufptr, p, k);        bufptr += k ;        p += k;        n -= k;      }}
  作者提到,之前讨论了运算符优先级的问题,“求值顺序则完全是另一码事”。前者是保证a + b * c应该解释成a + (b * c)而不是(a+b) * c这样一类的规则,求值顺序是保证if (cout != 0 && sum/count < smallaverage) {...}即使count为0也不会产生用0作除数的错误的规则。这里有一篇别人的日志可以参考。要点在于,C语言中只有四个运算符(&&、||、? :和,)存在规定的求值顺序。特别指出用于分隔函数参数的逗号并非逗号运算符。其他运算符对其操作数求值的顺序是未定义的,赋值运算符并不能保证任何求值顺序。

i = 0;while (i < n)    y[i] = x[i++];//这里有个假设:y[i]的地址在i的自增操作执行前被求值,但实际并没有任何保证。有的C语言实现可能如此,有的则相反。// y[i++] = x[i];同样有这种问题/*改进*/i = 0;while (i < n) {    y[i] = x[i++];    i++;}/*简写*/for (i = 0; i < n; i++)    y[i] = x[i];
复制代码
  作者特别提到,虽然用&、|、~和&&、||、!对应运算相互替代时,程序运行结果可能是正确的,并详细解释了一下,但这侥幸成分很大而且绝少有C编译器能够检测出,因此这种错误还是应该要避免。

  无符号数运算不会溢出:所有无符号运算都是以2的n次方为模,在这个意义上确实没有“溢出”这一说。相关解释可以参考这里。对有符号数运算溢出的检测方法:

if ((unsigned)a + (unsigned)b > INTMAX) //INT_MAX代表可能的最大数值,ANSI C在<limits.h>定义//其他C语言实现中也许要自己定义    complain();/*另一种可行方法*/if (a > INT_MAX - b)    complain();
复制代码
  为main提供返回值的原因:大多数C语言实现都通过main返回值来告诉操作系统该函数执行是成功还是失败,一般0代表成功,非0为失败。不给出返回值的结果是隐含地返回了某个“垃圾”整数(未显式声明返回类型则默认为整型)。

第四章  连接

  连接器并不理解C语言,然而它能理解机器语言和内存布局。作者强调连接器并不能处理连接时和C语言相关的一些错误,如果C语言提供了lint,要善加利用。

  每个外部对象都必须在程序某个地方进行定义。这就意味着如果一个程序中包括了语句extern int a;就应该在别的某个地方包括语句int a;。同时为了免两次定义同一个外部对象(无论有无初值)可能引起的错误,唯一的解决办法是每个外部变量只定义一次。

  static可以把变量和函数的作用域限制在一个源文件中,避免命名冲突。

  函数必须在调用它之前进行定义或声明,否则它的返回类型就默认为整型,这样当它与函数连接时就会得到错误的结果。为了表明形参实参可能导致的错误,作者用在不同情形下可以接受不同类型的参数的函数printf和scanf举例来进行了说明。

 
#include <stdio.h>int main(){    int i;    char c;    for (i = 0; i < 5; i++) {        scanf("%d", &c);        printf("%d ",i);    }    return 0;}//一种可能的输出结果//0 0 0 0 0 1 2 3 4//而不是//0 1 2 3 4/*原因在于scanf期望读入一个指向整数的指针,然而得到的却是指向字符的指针。整数所占存储空间大于字符所占,所以字符c附近内存会被覆盖。可能的输出结果是c附近存放的是i的低端部分,每次输入都会覆盖为0*/
  检查外部类型,比如在两个文件中的extern int n;和long n;,运行存在着很多可能情况:编译器检测到冲突并返回诊断消息;使用的C语言实现对int和long在内部表示上是一样的,很巧合地,可以正常工作;二者虽然需要存储空间大小不同,但它们共享存储空间恰好可以保证赋给其中之一的值对另一个有效(比如低端部分共享存储空间),本来错误的程序因某种巧合却能正常工作,类似第二种情况;共享存储空间时给一个赋值却相当于对另一个赋予不同的值,不能正常工作。这样的引申例子:char filename[] = "/etc/passwd";和extern char* filename;,虽然前者的引用filename的值将得到指向该数组起始元素的指针,但filename类型是“字符数组”,而不是后者的“字符指针”,二者无法以一种合乎情理的方式共存。

/* 改法 1 */char filename[] = "/etc/passwd"; /* 文件1 */extern char filename[];                /* 文件2 *//*  改法 2 */char* filename = "/etc/passwd";  /* 文件1 */extern char* filename;                /* 文件2 */
复制代码
  在这章的最后,作者表示解决外部对象在哪声明的好方法是使用头文件。

 

第五章  库函数

  本章主要讨论对于常用库函数的使用。对于getchar:

#include <stdio.h>int main (){    char c;    while ((c = getchar()) != EOF)        putchar();}
复制代码
c被声明为char而不是int,无法容下所有可能的字符,特别是可能无法容下EOF。最终结果的三种可能:某些合法输入的字符被“截断”后使得c取值与EOF相同;另一种可能是c根本不能取到EOF这个值;出于巧合能够正常工作,这个巧合是编译器比较getchar返回值和EOF。这里作者没有给出解决方案,印象中The C Programming Language里类似程序是把getchar()放在while循环体内部的,不过手头的书遗失了,暂无法确认。
  对于fread和fwrite的举例是对使用过程加入fseek的用法,没有特别留意。

  对于setbuf,不是很明白作者举的例子的目的,而且程序本身运行起来难以发现问题。参考了一个帖子,分析的比较全面:点击查看。练习5-1可以看作是对这个问题的补充。

  使用外部变量errno检测错误,调用库函数后应该先确定失败再检测errno,而不是先设置errno=0、调用库函数后检测errno。原因是这个库函数可能要调用其他的库函数,如果调用到的其他库函数设置了errno,即使库函数返回没有错误,也会使得errno非0。

  在异步问题上,信号非常棘手,具有一些本质上的不可移植特性。具体的例子就不重述了,解决问题最好采取“守势”,让库函数signal尽可能简单,并把它们(应该是指信号和signal函数)组织在一起。

 

第六章  预处理器

  宏定义时空格的使用可能会带来错误,#define f (x) ((x)-1)不同于#define f(x) ((x)-1)。然而这不适用于宏调用,后者定义后,f(3)和f (3)都是2。宏不是函数,在使用类似函数的宏定义时,一般把参数加括号,避免宏展开后产生的结合性问题。有时即使采用了括号,宏仍可能造成副作用,比如做替换时,参数被计算了不止一次。后面举例toupper()有误,不知是PDF扫描问题还是原书第一版的问题,查阅了一下ctype.h,c += 'A' ? 'a'应为c += 'A' - 'a'。

  宏不是语句,assert的真实定义比较出乎意料,而这个发掘过程表明了用宏代替语句会有非常大的困难。

  宏不是类型定义,不同于typedef。以前总是混淆这一点,但是下面的例子确实很能说明问题。

#define FOOTYPE struct footypedef struct foo FOOTYPE;//两句看上去功能相同#define T1 struct foo *typedef struct foo *T2;//T1与T2似乎一样/*问题出现在声明多个变量时*/T1 a, b;//被扩展为struct foo * a, bT2 a, b;
复制代码

第七章  可移植性缺陷

 

  作者提到了对于C标准的变化细节以及是否采用的看法、标识符应尽可能不以大小写区分、不同机器的整数长度大小。

  如果特别关心一个将要用到的变量最高位是0还是1可以将它声明为无符号数。得到一个字符变量c的与c等价的无符号整数不是(unsigned) c,这时c会先被转换成int从而可能导致非预期结果。正确方式是(unsigned char) c。

  移位比除法(除以2的幂)快得多,但有符号数不推荐这么做。

  null指针不指向任何对象,除非用于比较运算和赋值,出于任何其他目的使用null指针都是非法的。这条说得简单明确,很好记。作者又提到,由于对内存位置0的读写可能存在读保护、只读、可写三种情况,所以含有调用null指向的内容的程序在不同的机器上有不同的结果。

  除法截断问题提到的负数被除数和负数余数究竟应该怎样计算我以前从未考虑过,但作为C语言的运算符,/和%运算必须考虑到这一点。对于设计这种除法(q = a /b; r = a % b)的三原则,C语言只取了其一(q*b+r == a),以及当a>=0且b>0时,保证|r|<|b|且r>=0。对于负数除法构造哈希表有可行的方法,但作者认为更好的做法是把哈希函数的自变量设置为无符号数。

  作者介绍到如果程序中(伪)随机数函数rand,移植时必须做“剪裁”,其根源分歧始于C语言移植到VAX-11之时,它与PDP-11支持的最大整数长度不同,当时出现了两种解决方案:和PDP-11保持一致、和自身最大长度保持一致。

  后来作者又提到C语言发展过程中一位开发人员的改写宏toupper和tolower的小历史,最终的结果是两者被改写为函数,而用_toupper和_tolower的关键字重新引入宏,实现原先宏的功能。

  早期的C实现中realloc要求待重新分配的内存必须首先被释放。

  本章最后给出的提高可移植性的例子充分考虑了各种情况,但有说明多数情况下这么做是为了确保边界条件的正确。这段文字比较长,就不收录了,值得注意的是 "0123456789"[n%10]这种写法。

 

第八章  建议与答案

  这里指出有个预料把==误写成=这种错误出现并预防的方法:把常量放在判断相等的比较表达式的左侧,如‘\t’ == c 。这样写使得发生错误时编译器可以捕获。避免使用”生僻“的语言特性(即非众所周知的部分)预防Bug和方便移植。

  防御性编程?很生疏的词汇,了解了一下。这么做的原因是再怎么不可能的事在某些时候还是可能发生的,所以应该充分考虑异常情况,毕竟C编译器不可能捕获所有的程序错误。

 

附录

  printf(s)和printf("%s",s)不同,前者会把s含有的%后当作格式项,如果不是%%这样的内容而后又没有参数,会带来麻烦。

  预处理器的作用范围不能到达字符的内部。下面的例子给出了对于相关一个问题的解决方法:

#define NAMESIZE 14char name[NAMESIZE];......printf("...%.NAMESIZE ...", ... , name, ...);//需要改进printf("...%.*s ...", ... , .NAMESIZE, name, ...);//用*替换修饰符,在参数列表里使用


返回顶部