Skip to content

Latest commit

 

History

History
107 lines (85 loc) · 8.35 KB

CTrapsAndPitFalls.md

File metadata and controls

107 lines (85 loc) · 8.35 KB

C陷阱与缺陷

书籍《C陷阱和缺陷》的笔记,书内容并不多,稍有些许经验的C程序员应该都遇到过大部分问题。成书很早于ANSI C标准发布前夕,但是其中内容确实是经久不衰且常见的,基本都遇到过,值得一读。

词法

  • = 不同于 ==
  • & | 不同于 && ||
  • 词法分析中的贪心法:优先解析将多个符号构成的字符序列解析为尽可能长的运算符。
  • 八进制整型常量以0开头,不能使用数字8 9
  • 区分字符与字符串,单引号与双引号,不可互相替代。

语法

  • 函数声明可以按照解方程的方式来理解。怎么声明怎么用,理解(*(void(*)()0))(),类型声明同理。
  • 函数返回一个函数有两种写法:void (*(f()))()或者使用类型别名typedef void (*g_t)(); g_t f();,虽然返回值类型是void (*)()但并不能直接写作(void (*)()) f();,使用后者会更清晰。
  • 运算符优先级:一元后缀、一元前缀、算术运算、移位运算、比较运算、按位运算、逻辑运算、三元条件运算、赋值、逗号。注意结合性,一元前缀、赋值和三元条件运算符是右结合,其他都是左结合。应尽量使用括号明确优先级。
  • 多余的分号的影响,主要在循环中。
  • switch语句,是否每个case都需要break
  • 函数必须使用括号调用。
  • else总是与最近的if匹配,避免出现悬垂的elseif-else块最好使用{}

语义

  • 数组和指针是不同的类型,尽管数组很多时候可以退化成指针,sizeof运算符结果是不一样的,理解多维数组。[]就是指针偏移然后解引用操作的语法糖。
  • 留意字符串末尾的空字符,strlen长度并不是字符串内存的长度。
  • 数组作为形参时自动退化为指针,退化之后不再有长度信息,传递时也是。
  • 指针复制是浅拷贝。
  • 空指针不是空字符串,特别用在printf中效果不一样。
  • 数组循环的边界通常取半闭半开区间,前闭后开,应尽量使用不对称的边界。
  • 求值顺序,C语言中仅&& || ?: ,存在规定的求值顺序,不应该假定任何其他运算符的求值顺序。
  • 区分逻辑运算&& || !和按位运算& | ~
  • 需要详细考虑整数边界和整数运算溢出,特别地需要考虑有符号数中负数比正数多一个。
  • main应该有一个int返回值返回给操作系统,最好不要省略int返回值类型声明。

链接

  • 分别编译,链接器并不知道C语言的细节,仅处理目标文件中符号的引用。
  • 区分声明和extern声明,前者同时定义变量,分配内存空间,后者只是对外部变量的引用。
  • static修饰的全局变量和函数是文件作用域。
  • 不要使用隐式函数声明(即不声明直接用,默认为返回int的函数)、省略函数返回值类型(默认返回int)、声明时不写参数列表,这些在ANSI C中都是合法的。
  • 外部声明extern和定义时类型一定要严格一致,否则链接可能发现不了导致奇怪的错误(能读能写但读出的值不一样)。
  • 外部声明可以放在头文件中,由某一个源文件定义。

库函数

  • getchar函数返回整型,最好使用整型来接,使用字符可能无法和EOF比较(现在来说用char一般也没问题)。
  • fopen打开文件交错进行读写,需要进行fseek
  • setbuf为输出流分配缓冲,fflush刷新缓冲。设置缓冲后如果输出过程中出错,定位错误原因时可能就会找错地方,因为有的数据还在缓冲区没有被输出,可以在调试版本的程序中强制不允许对输出进行缓冲。
  • 可以使用errno检测库函数出现的错误,但应该首先检测函数返回值,如果失败再来查看errno确定失败原因。
  • signal函数是真正意义上的异步,用于注册捕获的异步事件。一个信号可能在C程序执行期间的任何时刻发生,甚至于某些复杂的库函数如malloc中,应避免在处理程序中调用此类函数,处理程序应尽量简单。唯一可移植并安全的处理逻辑是打印出错消息然后使用longjumpexit立刻退出程序。

预处理器

  • 带参宏中的空格,宏名和()之间不能有空格。
  • 宏不是函数,也可以理解为传名调用的函数,可能会对传入的参数多次求值,需要特别注意。
  • 宏只是符号替换,也不是语句,像assert宏就需要避免使用if语句造成可能的else的匹配问题。
  • 宏并不是类型定义,使用typedef进行类型定义更好。

可移植性缺陷

  • 程序尽可能应对标准变更。
  • 整数的大小在不同机器上可能不一致。最好定义一套别名,就算需要更换类型也只改一次就行。当然以现在的观点来看,已经有了stdint.h使用定长类型就好。
  • 字符是有符号的还是无符号的,这是取决于实现的,如果是有符号,那么0x7f-0xff之间的字符转为更宽的整数时会进行符号扩展,需要特别注意。可以先转换为unsigned char以避免。
  • 移位运算符,注意有符号整数、无符号整数的右移由0还是符号位的副本填充,注意移位允许的取值范围不能超过数的长度。
  • 对于空指针NULL,机器上是否支持读写地址0处的值(现代的机器上一般都不支持),使用指针前先做判断是必要的。
  • 除法运算发生的截断,目前的实现来说,余数与被除数符号相同,需要注意这点。
  • 随机数大小有RAND_MAX宏指定,随机数发生的逻辑是否可移植,不同机器上随机数范围是否一致。
  • malloc realloc free一些老的Unix机器上要求realloc之前需要freefree之后还可以realloc,不应该依赖特定实现来编程。

实践建议

  • 直截了当表达意图,如果程序看起来可能被人误解,应使用加括号或重写等手段明确意图。
  • 考察最简单的特例:构思程序和测试程序时都是必要的。比如空数据、只有一个元素、整数边界等情况。程序设计时还可以从空数据等边界情况的处理获得启发。
  • 使用不对称边界。
  • 坚持使用语言中更众所周知的部分,避免使用生僻的语言特性。
  • 防御性编程,不要对程序的用户和编译器实现做过多的假设。

可变参数

大部分人可能对使用很多的printf函数族的实现原理了解甚少。同族函数还有fprintf sprintf

  • printf(stuff)等价于printf(stdout, stuff)
  • 这三个函数返回值都是已传送字符数。sprintf会在末尾打印一个空字符,不计入传送的字符数中。
  • 由于运行时才拿到格式化参数以决定类型,由编译器来检查参数类型是很困难的。
  • printf("%c", c)putchar(c)等价。
  • 格式化中可以加入l长度修饰符、宽度修饰符、精度修饰符、对齐或者符号修饰。

两种不同的变长参数:varargs.hstdarg.h。前者最早源于1981年,后者是标准ANSI C的支持,就目前来说前标准C语言一般都不支持前者。而是支持C语言标准中的后者,比如LLVM或者GCC,包含了varargs.h之后都是直接报错并推荐使用stdarg.h,MSVC中还保有支持。总之前者已经不再支持,不需要了解。后者的实现是这样的:

int my_printf(char* format, ...)
{
    va_list args;
    va_start(args, format);
    double d = va_arg(args, double);
    printf("%lf", d);
    vprintf(format, args);
    va_end(args);
}
  • C标准库中提供了接受了va_list类型的printf函数族。可以直接使用va_list参数。
  • 使用va_list前必须调用va_start va_end初始化以及结束可变参数的访问。
  • 使用Type var = va_arg(list, Type)list中获取指定类型Type的变量的值到var
  • va_list指向栈帧,通过va_arg宏中的类型做sizeof之后取出指定字节大小解释为特定的类型。所以如果变量类型提取错误,那么结果肯定是错误的。
  • printf正是根据格式化字符串中的占位符个数和类型来判断和提取特定数目指定类型的参数,如果占位符和参数不匹配,那么输出结果就是错误的。
  • 可变参数文档