C Traps and Pitfalls - HsuJv/Note GitHub Wiki

第一章: 词法陷阱

= 和 ==

  • c 语言中, = 是赋值运算符, == 是比较;
    • 值得注意的是, 赋值运算表达式是有返回值的, 以此来支持连续赋值 (e.g. a = b = 1;)
    • 因此, 在需要比较判断时, 可能因为将 == 误写为 = 而产生致命错误 (所有非0值都被解释为真)
    • 同理, 在一个单独的复制语句中(e.g. a = 1;), 赋值符号被写成了比较符号(e.g. a == 1;), 会生成一个匿名的int变量(c中没有bool类型), 接下来对变量的操作都将是在变量的值未改变的情况下进行的.

词法分析中的贪心

  • c语言中, 有单个字符长的符号, 如 /, *, = 等, 同时又有多个字符的符号, 如 /*, *=
    • 为了知道 /* 究竟是一个符号, 还是两个符号, 编译器有一个很简单的规则: 每一个符号应该尽可能多的包含字符
    • 虽然编译器处理源文件时会移除空白符, 但这是在词法分析之后进行的, 也就是说, = = 会被解释成两个符号而不是一个
    • 这也解释了为什么早期的g++无法解释 std::vector<std::vector<int>> 类型, 因为 >> 被视作一个符号
  • 老版本的编译器中, += 类符号和 =+ 是通用的
    • 因此 a=-1; 有可能会被解释成 a = a - 1;
    • 另外的, 像这种运算后赋值的算符, 可能会被一些老的编译器解释为两个符号, 这就导致了在中间有空白是合法的(e.g. a >> = 1;)

整型常量, 字符常量与字符串常量

  • 如果一个整型常量的第一个字符是0, 那么这个整型会被视作八进制数
    • 因此, 立即数 10 和 010 是两个完全不同的值
    • 特别的, 有些编译器会把8, 9当成合法的八进制数字, 如 01891 = 1 x 512 + 8 x 64 + 9 x 8 + 1 = 1097, 虽然这并不常见
  • c语言中, 字符常量的实质就是一个int型, 数值是它的ASCII码值
    • 特别的, 因为一个字符只有一字节, 而一个int变量一般有四字节, 所以 int a = 'abc'; 在许多编译器中是合法的, 大多数情况下, 会使 a 的值为 0x00636261(小端序)
  • 而字符串常量象征的是个地址, 这个地址中存着以0结尾的, 字符串中每个字符的ASCII码, 因此, 一个有内容的字符串常量至少占了两个字节的内存

第二章: 语法陷阱

函数声明

  • float a(...)
    • 这个函数声明应该被这样解释: 在对表达式 a(...) 进行求值时, 其类型为float, 也就是说, a是一个返回float类型的函数
    • 这样就不难理解函数指针的定义 float (*a)(....) , 它可以解释为: 在对表达式 (*a)(....) 进行求值时, 其类型为float, 所以 *a是一个返回值为float的函数, a就是指向这类函数的指针变量
  • 同理, 变量的声明也可以这样解释, 这就形成了变量和函数的统一
    • float a; 可以解释为: 在对表达式 a 进行求值时, 其类型为float, 所以a是一个float型变量
    • float ((f)) 可以解释为: 在对表达式 ((f)) 进行求值时, 其类型为float, 所以f是一个float型变量

函数调用

  • C 语言要求, 即使函数调用时不带任何参数, 也应该包括参数列表
    • e.g., 如果f是一个函数, 那么 f() 是一个函数调用语句, 而 f 只是计算函数的首地址

悬挂else

  • 考虑如下片段:
if (x == 0)
    if (y == 0) error();
else
    do_something();
  • 从缩进上看, 该代码原意是判断x是否为0, 如果x是0, 同时y也是0, 程序出错; 如果x不为0, 那么做一些别的处理
  • 但是, 由于没有 {} 包围if的执行语句块, else被匹配到最近一个if, 代码被解释为如果x为0且y为0, 程序出错; 如果x为0但y不为0, do_something

注意分号;

  • C语言中, 分号被作为一个语句的结束标志, 如果多了一个分号, 程序的意思会变的截然不同
  • 例如, 代码片段 if (test) do();
    • 如果多了一个分号, 变成了 if (test); do();
    • 那么这段代码被实际解释成 if (test) {} do();
    • 对与while判断同理
  • 同样, 如果少了一个分号, 有时候也会造成严重的错误
  • 例如, 代码片段 while ((ch = getchar()) != '\n'); do();
    • 代码本意是过滤掉输入缓冲区里的换行符号, 如果少了分号, 变成了 while ((ch = getchar()) != '\n') do();
    • 这时, do() 会根据输入缓冲区里的换行符个数进行调用

第三章: 语义陷阱

指针与数组

  • C语言中的数组需要注意以下两点:
    1. C语言中只有一维数组, 并且数组的大小必须在编译时是一个确定的常量(注: c99标准中实现了可变长数组, 允许在严格小于文件域的作用域内用变量定义数组)
    2. 对于一个数组符号, 我们只能够得到两个信息: 数组的首地址和元素个数, 也就是说, 所有的数组的内容的访问, 都是通过指针完成的.
  • 对于多维数组, e.g., int calendar[12][31];
    • 该语句声明了一个数组, 数组名是calender, 数组有12个元素, 其中每个元素都是一个拥有31个元素的int型数组
    • 三维及三维以上同理
    • 这个语句声明出来的地址是一块完全连续的值, 表达式 calendar 即是这个地址块的首地址, 所以, 无论对我们来说有多少维, 对于c语言, 都被解释成一维数组
  • 作为参数的数组声明
    • C语言中, 将数组名作为参数传递时, 实际传递的是数组首地址(退化成一级指针)
    • 同时, C语言会自动将参数列表的声明转换成想应的指针声明, 即 int strlen(char s[]) {}int strlen(char * s){} 完全等效
    • 但是, 二维即二维以上数组传参时需要显式指明高维的元素数量, 这样编译器才能计算数组指针的"步长"

第四章: 链接

链接器

  • 链接器一般与C编译器分离
    • 每个文件可以单独编译
    • 由链接器将这些独立的编译单元组成一个整体
  • 链接过程
    • 链接器将目标模块(一个.o或.obj文件)看成由一组外部对象(external object)组成
    • 每个外部对象代表一块内存, 并由一个外部名称(符号)标识
    • 被声明为静态的外部对象(静态函数, 静态变量)会进行名称修饰, 以防止命名冲突
    • 链接器输入多个目标模块和库文件, 并将这些外部对象整合成一个载入模块
  • 链接器同时还应该处理命名冲突(一般是完全禁止)

声明与定义

  • 定义 int a;
    • 如果出现在所有函数体外, 则声明了一个外部整型变量a, 并分配空间(即定义了a)
    • C编译器有责任通知链接器所有未初始化的外部变量应被初始化为0
  • 定义 int a = 1;
    • 定义a的同时初始化了a
  • 声明 extern int a
    • 声明一个外部整型变量a, 却没有分配空间
    • extern关键字显示指明了a的内存是在其他目标模块中分配的
    • 从链接器的角度来看, 这是对a的显示引用, 而非定义
  • 多个同名外部变量
    • 如果一个目标模块中有定义 int a = 1, 另一个里面有 int a = 2; 那么, 大多数链接器会(也必须要)拒绝改程序
    • 但是, 如果同名的外部变量在定义时未初始化, 那么大部分链接器会接受该程序(主要是因为初始化的全局变量在data段, 而未初始化的在bss段)

第五章: 库函数

getchar

  • getchar原型: int getchar(void)
  • getchar函数被设计为返回0 - 255 int型的函数, 这就导致了语句 char ch = getchar() 可能会发生不期望的截断

更新顺序文件

  • 许多系统的标准输入/输出库都允许程序打开一个文件, 同时进行写入和读取操作
    • 大部分编程者认为, 只要用类似于 "r+" 模式打开文件句柄, 就可以交替进行读写操作
    • 然而, 事实上为了保证对早期不能同时进行读写操作的系统的向下兼容性, 一个读入操作之后不能紧跟一个输出, vice versa;
    • 如果要同时进行输入输出, 必须在中间插入一个fseek
  • 示例代码:
while (fread((char*)&rec, sizeof(rec, 1, fp)) == 1){
    /* do something */
    if (/* write judge */){
        fseek(fp, -(long)sizeof(rec), 1); // first fseek, reset file status
        fwrite((char*)&rec, sizeof(rec), 1, fp);
        fseek(fp, 0L, 1); // fseek after the fwrite, reset the file status
    }
}

缓冲输出与内存分配

  • 程序输出有两种方式:
    • 即时处理
    • 暂存, 然后大块写入
  • setbuf(stdout, buf)
    • 因为即时处理会造成较高的系统负担(用户态和核心态的不停切换), 所以C语言通常运行可控的输出数据量
    • 这种控制能力一般通过库函数setbuf实现
    • 该语句通知标准输入/输出库, 所有写入到stdout的输出都被缓存到buf数组, 直到buf缓冲区满或者程序调用fflush()时一起输出
  • 错误范例:
int main(){
    int c;
    char buf[BUFSIZ];
    setbuf(stdout, buf);

    while((c = getchar()) != EOF){
        putchar(c);
    }
}
  • 错误分析: 最后一次清空缓冲区会反正在main函数退出之后, 但是这个时候buf已经被销毁
  • 解决方案:
    1. 将buf声明为static的
    2. 使用malloc在堆区分配buf, 可以保证buf不会被释放(有意为之的mem leak)

errno

  • 很多库函数, 特别是与系统调用有关的, 执行失败时都会设置一个全局的变量 errno
  • /* 库函数 */ if (errno) { /* deal error */}
    • 对于errno, 这样使用看似正确, 实际上却是错误的
    • 因为在库函数调用成功的情况下, 并未规定一定要将errno复位
  • errno = 0; /* 库函数 */ if (errno) { /* deal error */}
    • 比起上一个, 这个代码段似乎更正了错误, 然而它仍是错误的
    • 库函数在调用成功时, 既没有强制要求复位errno, 也没禁止设置errno.
    • e.g., fopen函数内部可能会调用其他库函数(如: access)来判断指定文件是否存在, 以此来确定是新建还是覆盖. 这个内部调用的库函数就有可能设置errno的值
  • 因此, errno正确的用法是
    • 先检查库函数调用的返回值
    • 根据返回值确定库函数调用是否出错
    • 若出错, 再检查errno, 判断错误类型, 进行错误处理

signal

  • 所有的C语言实现中都包括有signal库函数, 作为捕获异步事件的一种方式
    • 头文件 #include <signal.h>
    • 原型 void (*signal(int sig, void (*func)(int)))(int)
    • 简化 typedef void (*SIG_HANDLE)(int); typedef SIG_HANDLE signal(int, SIG_HANDLE)
  • 信号
    • 信号是真正意义上的"异步", 理论上说, 一个信号可能在C程序执行的任何时刻发生, 甚至在某些库函数的执行过程中
    • 从安全角度考虑, 信号的处理函数不应该调用复杂的库函数
    • e.g., 假设malloc执行的过程被一个信号中断, 此时malloc中用来跟踪(track)堆内存的数据结构可能只更新部分. 之后, signal函数再调用malloc, 就会使堆内存彻底混乱
    • 同样的, 在signal里用longjmp退出, 也会导致不安全
  • 解决方案
    • 在signal处理函数里设置一个标志然后返回
    • 期待主程序能检查这个标志
    • 然而这样也并不总是安全的:
      • 当一个算术运算错误(溢出或者除以零)引发一个信号后
      • 某些机器会在signal返回后重新执行算术运算
      • 这就再一次引发了相同的信号
      • 所以对于算术运算错误, signal唯一安全, 可移植的操作是打印log, 然后longjmp或exit
  • 结论
    • signal处理函数尽可能简单

第七章: 可移植性缺陷

  • C语言在许多不同的系统平台上都有实现, 各个实现直接有着或多或少的细微差别, 以至于没有两个实现是完全相同的
  • 因此, 可移植性是一个非常宽泛的主题, 详细的讨论参考 <How to Write Portable Software in C (Prentice-Hall)> (Mark Horton)

标识符名称的限制

  • 某些C语言把一个标识符中出现的所有字符都作为有效字符处理
  • 而有些C实现却会自动截断一个长标识符的尾部.
  • 同时, 有些链接器也会有类似的限制
  • e.g.
    • 有些C实现中标识符只有前6位有效
    • 有些链接器将符号全部转换成大写然后处理
  • 因此, 为了保证程序的可移植性(尤其是对古老操作系统的兼容), 需要谨慎的选择外部标识符的名称

整数的大小

  • C语言为编程者提供了3中不同类型的整数
    • short
    • int
    • long
  • C语言能给我们的保证是
    • 上述三种类型的长度是非递减的
    • 一个int整数足够大以容纳任何数组下标
  • 因此, 可移植性最好的办法是定义一个"新的"类型:
    • typedef long data_type;

字符是signed还是unsigned

  • 字符的类型决定了它的取值是0到255还是-128到127
  • 如果声明为一般的char变量, 在扩展为int时, 一些编译器会将之当signed处理, 而另一些可能会当unsigned处理
    • 因此, 最好的办法是声明unsigned char;
  • 一个常见的错误认知是:
    • 如果c是一个字符变量, 那么(unsigned) c 就得到与c等价的无符号整数
    • 这样, c在被转换成unsigned之前, 会先被转换成int, 因此可能得到非预期的结果
  • 正确的方式是使用(unsigned char) c;

移位运算符

  • 需要关注两个问题:
    1. 右移时, 是算术移位(sar), 还是逻辑移位(shr)
    2. 移位计数允许的取值范围是什么
  • 问题1:
    • 一般来说, 与被移位对象的数据类型有关, 如果是signed, 那么是算术移位, 否则是逻辑移位
  • 问题2:
    • 如果被移位对象长n位
    • 那么有0 <= 移位计数 < n;
  • 值得关注的是, 只有在signed被移位对象非负时, 右移x位才代表除以2的x次幂

内存位置0

  • null指针不指向任何对象
    • 用于比较或赋值时, 使用null指针是合法的
    • 其他任何情况, 出于任何目的使用null都是非法的
  • 无用null指针的结果是未定义的
    • 有些机器对位置0加了硬件保护, 一但遇到读取, 会立即终止程序
    • 而有些机器的位置0是只读, 可以读出一堆"垃圾信息"
    • 而有的机器的位置0是可读写的, 这样一旦错误的使用了null指针, 将会造成不可挽回的错误

除法运算时发生的截断

  • 假定
    • q = a / b;
    • r = a % b;
    • a是被除数, b是除数, q是商, r是余数
  • c语言提供保保证:
    1. b x q + r = a;
    2. 在满足 1) 的条件下, b取值向0靠拢

可移植性问题的一个栗子

  • 代表性问题:
    • 下面程序接受两个参数, 一个long, 和一个函数指针, 其作用是把给出的long整型转换成其十进制表示, 并对十进制表示中的每个字符调用函数指针所指向的函数
    void printnum(long n, void(*p)()){
        if (n < 0){
            (*p) ('-');
            n = -n;
        }
        if (n >= 10)
            printnum(n / 10, p);
        (*p)((int)(n % 10) + '0');
    }
    
  • 可移植性问题分析:
    1. 该程序把n的十进制的末位数字转换为字符时, 用 + '0' 来表示对应的字符, 这样做假定了机器的字符集中数字是顺序且无间隔排列的, 这种假定对应可移植程序来说是不应该的
    2. 第二个问题与 n < 0 时有关, 上面的程序先打印一个符号, 然后将n设置为-n, 然而, 在基于补码(绝大多数)的机器上, 允许的负数的可表示范围是大于正数的
  • 解决方案:
void printneg(long n, void (*p)()){
    if (n <= -10)
        printneg(n / 10, p);
    (*p)("0123456789"[-(n % 10)]);
}

void printnum (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]);
}
⚠️ **GitHub.com Fallback** ⚠️