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类型), 接下来对变量的操作都将是在变量的值未改变的情况下进行的.
- 值得注意的是, 赋值运算表达式是有返回值的, 以此来支持连续赋值 (e.g.
- 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(小端序)
- 特别的, 因为一个字符只有一字节, 而一个int变量一般有四字节, 所以
- 而字符串常量象征的是个地址, 这个地址中存着以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
只是计算函数的首地址
- e.g., 如果f是一个函数, 那么
- 考虑如下片段:
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语言中的数组需要注意以下两点:
- C语言中只有一维数组, 并且数组的大小必须在编译时是一个确定的常量(注: c99标准中实现了可变长数组, 允许在严格小于文件域的作用域内用变量定义数组)
- 对于一个数组符号, 我们只能够得到两个信息: 数组的首地址和元素个数, 也就是说, 所有的数组的内容的访问, 都是通过指针完成的.
- 对于多维数组, 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原型:
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已经被销毁
- 解决方案:
- 将buf声明为static的
- 使用malloc在堆区分配buf, 可以保证buf不会被释放(有意为之的mem leak)
- 很多库函数, 特别是与系统调用有关的, 执行失败时都会设置一个全局的变量 errno
-
/* 库函数 */ if (errno) { /* deal error */}
- 对于errno, 这样使用看似正确, 实际上却是错误的
- 因为在库函数调用成功的情况下, 并未规定一定要将errno复位
-
errno = 0; /* 库函数 */ if (errno) { /* deal error */}
- 比起上一个, 这个代码段似乎更正了错误, 然而它仍是错误的
- 库函数在调用成功时, 既没有强制要求复位errno, 也没禁止设置errno.
- e.g., fopen函数内部可能会调用其他库函数(如: access)来判断指定文件是否存在, 以此来确定是新建还是覆盖. 这个内部调用的库函数就有可能设置errno的值
- 因此, errno正确的用法是
- 先检查库函数调用的返回值
- 根据返回值确定库函数调用是否出错
- 若出错, 再检查errno, 判断错误类型, 进行错误处理
- 所有的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;
- 字符的类型决定了它的取值是0到255还是-128到127
- 如果声明为一般的char变量, 在扩展为int时, 一些编译器会将之当signed处理, 而另一些可能会当unsigned处理
- 因此, 最好的办法是声明unsigned char;
- 一个常见的错误认知是:
- 如果c是一个字符变量, 那么(unsigned) c 就得到与c等价的无符号整数
- 这样, c在被转换成unsigned之前, 会先被转换成int, 因此可能得到非预期的结果
- 正确的方式是使用(unsigned char) c;
- 需要关注两个问题:
- 右移时, 是算术移位(sar), 还是逻辑移位(shr)
- 移位计数允许的取值范围是什么
- 问题1:
- 一般来说, 与被移位对象的数据类型有关, 如果是signed, 那么是算术移位, 否则是逻辑移位
- 问题2:
- 如果被移位对象长n位
- 那么有0 <= 移位计数 < n;
- 值得关注的是, 只有在signed被移位对象非负时, 右移x位才代表除以2的x次幂
- null指针不指向任何对象
- 用于比较或赋值时, 使用null指针是合法的
- 其他任何情况, 出于任何目的使用null都是非法的
- 无用null指针的结果是未定义的
- 有些机器对位置0加了硬件保护, 一但遇到读取, 会立即终止程序
- 而有些机器的位置0是只读, 可以读出一堆"垃圾信息"
- 而有的机器的位置0是可读写的, 这样一旦错误的使用了null指针, 将会造成不可挽回的错误
- 假定
- q = a / b;
- r = a % b;
- a是被除数, b是除数, q是商, r是余数
- c语言提供保保证:
- b x q + r = a;
- 在满足 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'); }
- 可移植性问题分析:
- 该程序把n的十进制的末位数字转换为字符时, 用
+ '0'
来表示对应的字符, 这样做假定了机器的字符集中数字是顺序且无间隔排列的, 这种假定对应可移植程序来说是不应该的 - 第二个问题与 n < 0 时有关, 上面的程序先打印一个符号, 然后将n设置为-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]);
}