C Traps and Pitfalls 摘记(1)

文章目录
  1. 1. 词法“陷阱”
    1. 1.1. 整型常量
    2. 1.2. 字符与字符串
  2. 2. 语法”陷阱“
    1. 2.1. 运算符优先级判定
    2. 2.2. switch穿透
  3. 3. 语义“陷阱”
    1. 3.1. 指针与数组
    2. 3.2. 作为参数的数组声明
    3. 3.3. 求值顺序
    4. 3.4. 整数溢出

词法“陷阱”

我们把符号(token)表示为程序的一个基本信息单元。每个符号都蕴含着一些信息,可能是数据,可能是操作。编译器中把程序(我认为是源程序代码)分解成一个一个符号的部分,称为词法分析器

一个例子:

1
if (x > big) big = x;

把上述符号分隔开,每行一个,我们可以得到

1
2
3
4
5
6
7
8
9
10
if
(
x
>
big
)
big
=
x
;

那么C语言如何处理一些令人产生疑惑的语句呢?书中(引用一)的第一章介绍了一种方法。

词法分析中的“贪心法”

C语言中分为单字符符号如 /、*、和 =,还有多字符符号如 /* 和 == 。

当C编译器读入一个字符 ‘/‘ 后跟又跟了一个字符 ’*‘,那么编译器就必须做出判断:是将其作为两个分别的符号对待,还是合起来作为一个符号对待。

C语言对这个问题的解决方案可以归纳为一个简单的规则:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个字符一个字符读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分;如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略有时被称为“贪心法”或者”大嘴法”。

需要注意:除了字符串与字符常量,符号的中间不能嵌有空白(空格符、制表符和换行符)。
a---ba -- - b是一样的,但与a - -- b的含义不同。

这里有个表现其存在的例子,y = x/*p可以理解为是x除以p指针指向的值,将其所得的商赋给y,但是/*被编译器理解为一段注释的开始,编译会不断的读入字符,直到*/出现为止。这样,这个语句会变成y = x

老版本的C语言支持=+来代表+=的含义,即a=-1会被理解为a =- 1,亦即a = a - 1,此时你的原意可能是a = -1

整型常量

如果一个整型常量的第一个字符是数字0,那么会被编译器看作八进制数,意义也就变了。有些编译器还允许8和9在定义8进制数的时候出现,这种处理方法在ANSI C标准禁止这种用法,就不细究了。但是我们要注意的是在某些情况下,我们需要对齐,进行了前补零,此时的意义就不是照我们原来所想的了。

字符与字符串

printf函数的第一个参数原型是一个const char*,但是某些C编译器对函数参数并不进行类型检查,我们可能将双引号引起的字符串无意间写成单引号引起的字符串。此时会在程序运行的时候产生难以预料的错误,而不会给出编译器诊断信息。
单引号引起的字符串,大多数C编译器会理解为一个整数值,而其值,是由里面的每一个字符所代表的整数值按照特定编译器实现中的定义的方式组合得到。

在 Borland C++ v.5.5 和 LCC v3.6 中,做法是忽略多余的字符,以第一个字符的整数值作为其代表的整数值。
在 Visual C++ 6.0 和 GCC v2.95 中,做法是依次用后一个字符覆盖前一个字符,最后得到的整数值即最后一个字符的整数值
在 MSVC 15.0 中,做法是所有字符的整数值从左到右顺序链接起来得到的整数值,例如`123`字符串的整数值为3224115,即31 32 33

语法”陷阱“

运算符优先级判定

对于程序员书写表达式来说,优先级问题肯定是无法回避的问题,虽然用添加括号可以避免过于复杂的情况,不用花费过多精力去理解上。但其实可以将运算符恰当分组,理解各组运算符之间的相对优先级,提高我们的编程效率。

  1. 优先级最高的是() [] -> .,它们不是真正意义上的运算符,但是它们自左向右的结合,这是很自然的。
  2. 其次是各类单目运算符! ~ ++ -- + - (type) * & sizeof,它们都是自右向左的结合,这意味着我们可以写出*p++这种语法,会被编译器理解为*(p++),也是很自然的。但是要注意的是*p()是对一个函数p求值后取内容。
  3. 其次是各类双目算式以及移位运算符,在这其中* / %的级别最高,+ -其次,然后是<< >>,这意味着x<<1 + 3会被理解成x<<(1 + 3),实际编程中这种错误常常出现。它们都是自右向左的结合。
  4. 再之后是双目关系以及逻辑运算符。它们都是自左向右的结合。同时,我们可以写出a < b == c < d,这个意义是ab的大小关系是否和cd的大小关系一样。
  5. 最后是三目运算符和赋值运算符。它们是自右向左的结合,比较特殊。故我们可以写出a += b = c这种写法,被编译器理解为a += (b = c)
  6. 逗号运算符是优先级最低的运算符。

switch穿透

C语言switch的穿透特性,既是它的优势所在,也是它的一大弱点。如果程序员有意略去break语句,可以表达其他方式很难方便加以实现的程序控制结构。反之,如果忘记填上break语句,则会造成一些难以理解的程序行为。当我们要特意略去break语句时,应该在对应case部分的末尾填上注释,方便以后维护。

语义“陷阱”

指针与数组

指针和数组这两个概念在C语言中密不可分,理解其中一个概念必须同时也要理解另一个概念。
C语言中的数组值得注意的地方有以下两点:

  1. C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确定下来(除去VLA)。然而,C语言中数组的元素可以是任何类型的对象,当然也就可以是另一个数组。这样,“仿真”出一个多维数组不是难事。
  2. 对于一个数组,我们只能够做两件事:确定该数组的大小,以及获得指向该数组下标为0的元素的指针。其他有关数组的操作,哪怕它们看上去是以数组下标进行运算的,实际上都是通过指针进行的。

一旦我们彻底弄懂了这两点以及它们隐含的意思,那么理解C语言的数组运算就十分简单,我们分两种情况讨论。

  1. 当数组被当作sizeof的操作数
    sizeof这个运算符会计算操作数所占用的空间,当数组被当作操作数时,sizeof会如实计算这个数组所占用的内存空间,例如 求取int a[2][3]内存空间,即sizeof(a),会得到2 * 3 * sizeof(int)的结果。

  2. 当数组不是用作sizeof的操作数
    这种情况下,数组总是会被转换成一个指向该数组的起始元素的指针(我称这种行为叫做数组的指针退化),那么此时再使用sizeof去操作这个变量,得到的其实是指针的所占用的内存空间大小。

作为参数的数组声明

在c语言中,我们没有办法可以将一个数组作为函数参数直接传递。如果我们使用数组名作为参数,那么数组名会立即被转换为指向该数组第一个元素的指针。例如,

1
2
char hello[] = "hello";
printf("%s\n", hello);

我们声明了hello是一个字符数组,但是在printf中,我们将该数组作为参数时传递给函数时,会被像上文一样,“数组总是会被转换成一个指向该数组的起始元素的指针”。
因此,将数组作为函数参数毫无意义。所以在C语言中会将作为参数的数组声明转换为相应的指针声明。例如,下面两种函数的声明写法是等效的。

1
2
int strlen(char s[]);
int strlen(char *s);

但是注意,在使用extern来声明一个全局变量时,要注意数组和指针的不同,例如:

1
2
extern char *hello;
extern char hello[];

这两者声明的并不是同一个变量。

求值顺序

C语言中的某些运算符总是以一种已知的、规定的顺序来对其操作数进行求值,而另外一些则不是这样。例如在a < b && c < d这个表达式中,C语言的定义中说明了a < b应当首先被求值,如果a确实小于b,此时再对c < d求值,否则,则表达式肯定为假,就无需再对c < d求值了。
这个例子揭示了一种运算类型,也是经常所说的:短路运算。亦即当二元逻辑运算符的前半部分可以确定整个表达式的值时,就无需再对后半部分求值了。
短路运算会导致一些问题,例如当某个变量的变化依赖于表达式的后半部分时,因为短路运算,导致变量的变化可能会有不预期的变化。在实际编程时,首先要避免此类代码的出现,但当这种逻辑简单明了时,也需要添加注释,以便维护。例如常见的:

1
2
if(y != 0 && x/y > tolerance)
complain();

一个典型的避免除零错误的判定,逻辑简单明了,短路运算在这其中发挥了重要的作用,此时的注释是可选的。

此外,C语言中只有四个运算符(&& || ?: ,)存在规定的求值顺序。而其他的所有运算符对其操作数的求值是未定义的。特别的,赋值运算符并不保证任何求值顺序。下面这种从数组x中复制前n个元素到数组y中的做法是不正确的。

1
2
3
i = 0;
while(i < n)
y[i] = x[i++];

上述代码假设y[i]的地址将在i的自增操作执行之前被求值,这一点并没有任何保证!在C语言的某些实现上,有可能在i自增之前被求值,而在另外一些实现上,有可能与此相反。遇到这种情况时,请把自增操作和赋值操作独立分成两个语句。

整数溢出

在C语言中存在两类整数算术运算,有符号运算和无符号运算。在无符号运算中,没有所谓的“溢出”说法。而在有符号整数与无符号整数的混合运算中,有符号整数会被转化为无符号整数,“溢出”也不可能发生。但是当两个操作数都是有符号整数时,“溢出”就有可能发生,而且“溢出”的结果是未定义的。当一个运算的结果发生“溢出”时,作出任何假设都是不安全的。

假定ab是两个非负整型变量,我们需要检查a+b是否会“溢出”。一个想当然的方法是这样的:

1
2
if(a + b < 0)
complain();

这并不能正常运行,当a+b确定发生“溢出”时,所有关于结果如何的假设都不再可靠。例如,在某些机器上,加法运算将设置一个内部寄存器为四种状态之一:正、负、零和溢出。在这种机器上,C编译器完全有理由这样来实现上面的例子:在a+b之后,检查该内部寄存器的标志是否为“负”。当发生“溢出”时,这个内部寄存器的状态是溢出而不是负,那么判定检查就会失败。

一种正确的做法是在相加之前就把ab强制转换为无符号整型:

1
2
if((unsigned)a + (unsigned)b > INT_MAX)
complain();

另外一种不需要用到无符号算术运算的另一种可行方法是:

1
2
if(a < INT_MAX - b)
complain();