《C 陷阱与缺陷》笔记

2015-06-07 Tech 孙耀珠

词法陷阱(Lexical pitfalls)

  • 由 ALGOL 派生的编程语言如 Pascal 和 Ada,使用 := 作为赋值运算符,而 = 作为比较运算符。C 语言则使用了另一种表示法,以 = 为赋值运算符,以 == 为比较运算符。
  • 在 C 语言中,&| 是按位运算符,而 &&|| 是逻辑运算符。另外,^ 表示按位异或,而不是乘方。
  • C 编译器在词法分析时遵从贪心法,比如 y = x/*p/* 会被理解为注释的开始,而不是 y = x / *p
  • 0 开头的整型字面量将被视为八进制,因此切忌用 0 来占位对齐。
  • 单引号引起的一个字符实际上代表一个整数,而用双引号引起的字符串代表一个无名的字符数组。如果误用单引号引起一个字符串,使用 Clang 等编译器会得到最后一个字符的整数值,而其他编译器也可能得到第一个字符的整数值。

语法陷阱(Syntactic pitfalls)

  • 变量声明的含义为,右边表达式的值为左边所述的类型。同样的逻辑对于指针和函数声明也适用,如 const char *(*f)() 表示 f 指向的函数返回值也是一个指针,它指向字符串常量。同时只需要将变量名去掉并在最外面加上圆括号,便可以得到该类型的类型转换符。
  • 关于运算符优先级的常见错误:
if (flags & FLAG != 0) ...
// <=> if (flags & (FLAGS != 0)) ...

r = hi<<4 + low
// <=> r = hi << (4 + low)
// SHOULD BE: r = hi<<4 | low

while (c=getc(in) != EOF) putc(c, out);
// <=> c = (getc(in) != EOF)
运算符 结合性 分类
() [] -> .  
! ~ ++ -- - (type) * & sizeof 单目运算符
* / % 算术运算符
+ -  
<< >> 移位运算符
< <= > >= 关系运算符
== !=  
& 按位运算符
^  
|  
&& 逻辑运算符
||  
?: 三目运算符
assignments 赋值运算符
,  
  • switch 语句的 case 只是一个标号,分支结束不加 break 控制流程会穿过下一个 case 标号。
  • 调用无参函数时仍需要括号,否则单独的函数名只是计算函数的地址,而不会调用它。
  • else 始终跟最近的 if 匹配,即使这两句没有被外层花括号包围。
if (x == 0)
    if (y == 0) f();
else
    g();

/* 等价于 */

if (x == 0) {
    if (y == 0) f();
    else g();
}

语义陷阱(Semantic pitfalls)

  • 数组的所有操作都是通过指针实现的,如 a[i] 等价于 *(a + i),因此该表达式也可以写成 i[a]
  • 除了进行 &sizeof 运算,数组名都会被转换为一个指向其起始元素的指针。而 &array 会返回一个指向数组的指针类型,其值仍为起始元素的地址。
  • 对于二维数组,它实际上相当于以数组为元素的数组,其下标和数组名的行为也是类似的。
  • 如果使用数组名作为函数参数,那么它会被立即转换为指针。因此 C 语言会自动把作为参数的数组声明转换为相应的指针声明。如 size_t strlen(char s[]) 等价于 size_t strlen(char *s)
  • 在 C 语言中,字符串字面量是一个编译时便初始化好的字符数组,对其做出修改可能会触发 bus error(OS X)。
  • &&|| 遵循短路求值的原则,只有当左操作数无法确定逻辑运算的结果时,才对右操作数求值。
  • 如果没有为函数声明返回类型,那么返回类型默认为 int。如果在主函数中没有写 return 语句,Clang 等编译器会自动加上 return 0

连接(Linkage)

  • 通常 C 编译器(cc)等组件只负责独立地将每个源文件(.c)编译为目标文件(.o),因此利用目标文件和库文件生成可执行文件的工作都交给与 C 语言不相关的连接器(ld),包括处理命名冲突和外部引用。
  • extern 关键字可以声明外部变量,该变量的定义既可以在同一源文件内,也可以在不同源文件中。
  • 同一工程中不允许出现同名的全局变量或函数,这时使用 static 关键字可以将其作用域限制在源文件内,以解决命名冲突的问题。
  • 如果一个函数在被定义或声明前被调用,那么它的返回值默认为 int
  • 如果没有对函数形参类型进行声明,则调用时 float 类型参数会自动转换为 double 类型,charshort 会自动转换为 int 类型。scanfprintf 函数对参数的处理便是如此。
  • 由于无法得知 C 语言的实现细节,连接器不检查不同源文件中的外部变量和函数声明和定义是否一致。
  • 为避免上述问题,所有的外部声明应集中在头文件中;且实现这些定义的源文件也应包含此头文件,编译成功即可确保声明的正确性。

库函数(Library functions)

  • getchar 函数的返回值为 int 类型,如果读取成功会将 unsigned char 转换为 int 返回,否则返回 EOF(-1)。若将 getchar 返回值赋给 char 类型,可能会导致 255 与 -1 混淆。
  • ANSI C 可通过 stdarg.h 实现可变参数列表,譬如:
#include <stdarg.h>

int printf(char *format, ...)
{
    va_list ap; int n;
    
    va_start(ap, format);
    n = vprintf(format, ap);
    va_end(ap);
    return n;
}

预处理器(Preprocessor)

  • 在宏定义中,宏名和形参列表之间不可以有空格。
  • 尽量将宏定义的各个参数以及整个结果表达式用括号括起来,以免引起与优先级相关的问题。
  • 要确保宏中的参数没有副作用,譬如:
#define max(a, b) ((a) > (b) ? (a) : (b))
int i = 1, biggest = x[0];
while (i < n)
    biggest = max(biggest, x[i++]);
// <=> biggest = ((biggest) > (x[i++]) ? (biggest) : (x[i++]))
// i++ 可能执行两次
  • 尽量不要将宏定义为语句,否则会出现难以意料的结果,譬如:
if (x > 0 && y > 0)
    assert(x > y);
else
    assert(y > x);

#define assert(e) if (!(e)) assert_error(__FILE__, __LINE__)
// 如果这样定义,会出现 if-else 的嵌套问题
#define assert(e) { if (!(e)) assert_error(__FILE__, __LINE__); }
// 如果这样定义,花括号后会多出一个分号
#define assert(e) do { if (!(e)) assert_error(__FILE__, __LINE__); } while (0)
// 这是一个可行的定义
#define assert(e) ((e) || assert_error(__FILE__, __LINE__))
// 这是另一个可行的定义
  • 尽量不要用宏代替 typedef,如果 #define IP int *,则 IP p1, p2; 中的 p2 将是整型而不是整型指针。

可移植性缺陷(Portability pitfalls)

  • ANSI 标准要求 shortint 至少是 16 位,long 至少是 32 位,C99 要求 long long 至少 64 位,但没有规定确切的大小。
Data model short int long long long pointers / size_t OS
ILP32 16 32 32 64 32 Most 32-bit
LLP64 16 32 32 64 64 Windows 64-bit
LP64 16 32 64 64 64 Most Unix and Unix-like 64-bit
  • char 默认是 signed 还是 unsigned 因环境而异,如 Android NDK 中的 GCC 默认是 unsigned char
  • long double 的实现也因编译器而异,可能是双精度的同义词、扩展精度(extended precision, 80-bit)、四倍精度(quadruple precision, 128-bit)、一对双精度浮点数(double-double arithmetic, 64-bit + 64-bit)。为了字节对齐,扩展精度(10 字节)可能会被存储为 12 / 16 字节。
  • C99 规定求余结果与被除数同号,相应地,整数除法向零取整;而 C99 以前对此没有明确的定义。

See also
C 语言应试笔记 | 梦断代码