首页 > 代码库 > 《C陷阱与缺陷》读书笔记

《C陷阱与缺陷》读书笔记

1. 词法“陷阱”

  • = 不同于 == , 可以通过if( 1 == a )来避免
  • & | 不同于 && ||

  • 词法分析中的“贪心法”

    编译器将程序分解成符号的方法是,从左到右一个字符一个字符地读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略被称为“贪心法”,也称为“大嘴法”
    举例:
    a---b 等价于 a-- - b
    y = x/*p 编译器理解为一段注释的开始,改为 y = x / *p
    a++++b的含义是((a++)++)+b,但是a++的结果不是左值,故该式错误
    老版本的C语言中允许使用 =+ 来代表 += 的含义。所以 a=-1; 被理解为 a =- 1; 即 a -= 1; 而程序员的原意可能是 a = -1;

  • 整型常量 10不等于010

  • 字符与字符串
    1. 用单引号引起的一个字符实际上代表一个整数,整数值对应于该字符在编译器采用的字符集中的序列值。如‘a‘为0141或97
    2. 用双引号引起的字符串,代表的却是一个指向无名数组起始字符的指针。
      所以
      char *slash = ‘/‘; 编译错误
      printf(‘\n‘); 会在程序运行的时候产生难以预料的错误,而不会给出编译器诊断信息。
    3. 整型数的存储空间可以容纳多个字符,因此有的C编译器允许在一个字符常量中包括多个字符。如‘yes‘代替"yes"不会被编译器检测到。前者的含义并没有准确定义。
  • 某些C编译器允许嵌套注释 。写一个测试程序:无论是对允许嵌套注释的编译器,还是对不允许嵌套注释的编译器,该程序都能正常通过编译,但含义却不同。

    /*/*0*/**/1
    允许嵌套注释,解释为 /* /* /0 */ * */ 1 即 1
    不允许嵌套注释,解释为 /* / */ 0* /**/ 1 即 0*1

2. 语法“陷阱”

  • 理解函数声明

    1. float *g(), (*h)();
      ()的结合优先级高于*,故g是一个函数,其返回值为float*;h是一个函数指针,h所指向函数的返回值为float
    2. 知道如何声明一个给定类型的变量,那么该类型的类型转换符可以通过以下方式得到:
      只需要把声明中的变量名和声明末尾的分号去掉,再将剩余的部分用一个括号整个“封装”起来即可。
    3. 例子:从 float (*h)(); 得到 (float (*)()) 表示一个“指向返回值为float的函数的指针”的类型转换符

    void (*signal(int , void(*)(int)))(int);

  • 运算符的优先级问题

    if( flag & FLAG != 0 ) ==> if( (flag & FLAG) != 0 ) 运算符 != 的优先级高于 &
    r = hi<<4 + low; ==> r = (hi<<4) + low; 运算符 + 的优先级高于 <<
    算术5个 > 移位2个 > 关系6个 > 按位3个( &|^, 不包括~ ) > 逻辑2个( && ||, 不包括! )
    注意:上面的移位也属于按位运算符,此外,按位运算符还有~
    f3f86004-4ade-4a21-ab41-c0df5b9de972_4_files/2c7a98154770f7cab404ebfc5c170a73.bmp

  • 注意作为语句结束标志的分号 struct logrec{};
  • switch语句 case穿透 break;

  • 函数调用

    如果f是一个函数
    f();
    f; 计算函数f的地址,却并不调用

  • “悬挂”else引发的问题:else始终与同一对括号内最近的未匹配的 if 结合;代码缩进

  • C语言允许初始化列表中出现多余的逗号,如

    int days[] = { 31, 28, 31, 30, 31, 30,                 31, 31, 30, 31, 30, 31,               };

    原因:每一行都是以逗号结尾的,这种相似性能够方便自动化的程序设计工具的处理。

3. 语义“陷阱”

  • 指针与数组

    1. C语言中只有一维数组,且数组的大小必须编译时确定。然而,数组的元素可以是任何类型的对象,当然也可以是另外一个数组,故可以仿真一个多维数组。
    2. 对于一个数组,只能做两件事:通过sizeof 确定该数组的大小;获得指向数组下标为0的元素的指针。换句话说,任何一个数组下标运算都等同于一个对应的指针运算。
    3. 例子: int calendar[12][31]; 如果calendar不是用于sizeof 的操作数,而用于其他场合,则calendar总是被转换为一个指向calendar数组起始元素的指针
      如果两个指针指向同一个数组中的元素,则两指针相减有意义
      a+i 等同于 i+a,故 a[i] 等同于 i[a]
  • 非数组的指针

    在C语言中,字符串常量代表了一块包括字符串中所有字符以及一个空字符的内存区域的地址。
    char *r; strcpy(r,s); 错误,r无法提供容纳字符串的内存空间
    char r[100]; strcpy(r,s); 字符数组的大小必须足以容纳包括‘\0‘在内的字符串s

  • 作为参数的数组声明

    C语言中会自动地将作为参数的数组声明转换为相应的指针声明。
    然而,extern char *hello; 却与 extern char hello[]; 有着天壤之别

  • 空指针并非空字符串

    当我们将0赋值给一个指针变量时,绝对不能企图使用该指针所指向的内存中存储的内容。
    if( p == (char*) 0 ) ... // ok
    if( strcmp(p, (char*)0 ) == 0 ) ... // error,库函数strcmp的实现中会包括查看它的指针参数所指向内存中的内容的操作。
    如果p是一个空指针,printf(p); 和 printf("%s", p); 的行为也是未定义的

  • 边界计算与不对称边界

    a[10] ==> [0,10) ,10-0=10即为元素数量,避免栏杆问题

  • 求值顺序

    C语言中只有四个运算符存在规定的求值顺序: && || ?: ,

  • 整数溢出

    1. 在无符号算术运算中,没有“溢出”一说:所有的无符号运算都是以2的n次方为模
    2. 如果算术运算符的两个操作数,一个无符号,一个有符号,那么有符号整数会被转换为无符号,故“溢出”也不可能发生
    3. 当两个操作数都是有符号整数时,“溢出”可能发生,且其结果是未定义的,作任何假设都是不安全的。
    4. 例子:
      假定a和b是两个非负整型变量,需要检查a+b是否会“溢出”,想当然的 if( a+b<0 ) complain();是错误的,因为关于结果的任何假设都不可靠
      if( (unsigned)a + (unsigned)b > INT_MAX ) complain(); 注意是unsigned int可以存放的最大值是INT_MAX的近两倍,所以大于号左边不会溢出;另外,根据上面的第2点,其实只要对加号两边其中的一个操作数进行强制转换即可
      或者 if( a > INT_MAX-b) complain():
  • 为函数main提供返回值,return 0; 或 exit(0);

    大多数C语言实现都通过函数main的返回值来告知操作系统该函数的执行是成功还是失败。如果main函数并不返回任何值,那么有可能看上去执行失败。

4. 连接

  • 什么是连接器

    1. 连接器一般与C编译器分离,它不知道C语言的诸多细节,然而它却能够理解机器语言和内存布局。编译器的责任是把C源程序“翻译”成对连接器有意义的形式,这样连接器就能够“读懂”C源程序了。
    2. 典型的连接器把编译器或汇编器生成的若干个目标模块,整合成一个被称为载入模块可执行文件的实体,该实体能够被操作系统直接执行。其中,某些目标模块是直接作为输入提供给连接器的;而另外一些目标模块则是根据连接过程的需要,从包括有类似printf函数的库文件中取得的。也就是说,连接器的输入是一组目标模块和库文件,输出是一个载入模块.
    3. 连接器通常把目标模块看出是由一组外部对象组成的。每个外部对象代表着机器内存的某个部分,并通过一个外部名称来识别。程序中的每个函数和每个外部变量,如若没被声明为static,就都是一个外部对象。经过static的“名称修饰”,则不会与其他源文件中的同名函数或同名变量发生命名冲突。当不同的目标模块中包含相同名称的外部对象,此时,连接器需要处理命名冲突。
    4. splint静态程序分析工具
      静态程序分析是指使用自动化工具软件对程序源代码进行检查,以分析程序行为的技术,应用于程序的正确性检查、安全缺陷检测、程序优化等。它的特点就是不执行程序,相反,通过在真实或模拟环境中执行程序进行分析的方法称为“动态程序分析”。
  • 声明与定义

    extern关键字

  • 命名冲突与static修饰符

    如果一个函数仅仅被同一个源文件中的其他函数调用,就应该将该函数声明为static

  • 形参、实参与返回值

    函数未显式指定返回值,则假定其返回值为 int

  • 检查外部类型

    定义处long n; 但是,声明处 extern int n;
    定义处 char filename[] = "/etc/passwd"; 但是,声明处 extern char *filename; 修改,统一改成都使用char [] 或 char *

  • 头文件

    全局变量,函数原型,结构体定义,#define,typedef

5. 库函数

  • C语言没有定义输入/输出语句,通过库函数实现输入/输出操作
  • 返回整数的 getchar 函数,否则,无法和 EOF(int类型)比较
  • 缓冲输出,函数setbuf(stdout, buf); 常量BUFSIZ
  • 使用外部变量errno检测错误

    char *strerror(int errnum);
    void perror(const char *s);

  • 库函数signal

6. 预处理器

  • 不能忽视宏定义中的空格:这一规则不适用于宏调用,只对宏定义适用

  • 宏不是函数

    将宏定义中的每个参数都用括号括起来
    确保宏中的参数没有副作用,如不能使用自增操作符++

  • 宏并不是语句

    #define assert(e) if(!e) assert_error(__FILE__, __LINE__) 避免如此使用,避免造成#define替换后if 配对出错

  • 宏并不是类型定义(使用#define创建的“新类型”当含有指针* 时会出问题),类型定义使用typedef

7. 可移植性缺陷

  • 整数大小

    3种整型类型short,int,long其长度非递减;ANSI标准要求short和int至少16位,long至少32位

  • 字符是有符号整数还是无符号整数

    1. 只有在需要把一个字符值转换为一个较大的整数时,这个问题才变得重要起来
    2. 编译器在转换char类型到int类型时,需要作出选择:将字符作为有符号还是无符号数处理?前者,应同时复制符号位;后者,编译器只需要在多余的位上直接填充0即可。
    3. 如果一个字符的最高位是1,编译器是将该字符当作有符号数,还是无符号数呢?这关系到一个8位字符的取值范围是-128到127,还是0到255。而这一点,又反过来影响到程序员对哈希表或转换表等的设计方式。
    4. 如果编程者关注一个最高位是1的字符其数值究竟是正还是负,可以讲这个字符声明为无符号字符(unsigned char),确保在将该字符转换为整数时都只需将多余的位填充为0即可。
    5. 一个常见的错误认识是:如果c是一个字符变量,使用(unsigned)c就可得到与c等价的无符号整数。这是会失败的,因为在将字符c转换为无符号整数时,c将首先被转换为int型整数,而此时可能得到非预期的结果。正确的方式是使用(unsigned char)c,因为一个unsigned char类型的字符在转换为无符号整数时无需首先转换为int型整数,而是直接进行转换。
  • 内存位置0

    所有对NULL指针的操作都是未定义的

  • 大小写转换

    使用库函数toupper和tolower,而不要使用(c+‘A‘-‘a‘)之类的,因为在EBCDIC字符集中,字母并不是连续存储的,所以‘A‘与‘a‘可能并不是像在ASCII字符集中一样差值固定为32,而是64

  • 移位运算符

    1. 右移时,如果被移位的对象是有符号数,那么C语言实现既可以用0填充(逻辑右移),也可以用符号位填充(算术右移)
    2. 移位操作的位数必须>=0,而严格小于n。加上这个限制条件,能够在硬件上高效地实现移位运算
  • 除法运算时发生的截断

    q = a / b
    r = a % b