【C 陷阱与缺陷】(三)语义陷阱

0. 指针与数组

C 语言中数组与指针这两个概念之间的联系密不可分。

关于数组:
  • C 语言中只有一维数组,而且数组大小必须在编译期就作为一个常数确定下来。数组元素可以是任何类型的对象,也可以是另外一个数组。(C99 允许变长数组)
  • 对于一个数组,我们只能够做两件事:确定该数组的大小,以及获得指向该数组下标为 0 的元素的指针。

任何一个数组下标运算都等同于一个对应的指针运算。

声明数组

int a[3];

声明了一个拥有 3 个整型元素的数组。

struct{
int p[4];
double x;
}b[14];

声明了一个拥有 17 个元素的数组,且每个元素都是一个结构。

int calendar[12][31];

声明了拥有 12 个数组类型的元素,其中每个元素都是拥有 31 个整型元素的数组。因此 sizeof(calendar)的值是 12x31 与 sizeof(int)的乘积。

关于指针

任何指针都是指向某种类型的变量。

int *ip;

表明 ip 是一个指向整型变量的指针。

我们可以将整型变量 i 的地址赋值给指针 ip :

int i;
ip = &i;

如果我们给 *ip 赋值,就可以改变 i 的取值:

*ip = 17;
数组与指针

如果一个指针指向的是数组中的一个元素,那么我们只要给这个指针加 1,就能够得到指向该数组中下一个元素的指针。减法同理。

如果两个指针指向的是同一个数组中的元素,那么两个指针相减是有意义的:

int *q = p + i;

我们可以通过 q - p 得到 i 的值。

int a[3];
int* p = a;

数组名被当作指向数组下标为 0 的元素的地址。

注意,我们没有写成:

p = &a;

这样的写法在 ANSI C 中是非法的,因为 &a是一个指向数组的指针,而 p 是指向整型变量的指针,它们了类型并不匹配。

继续我们的讨论,现在 p 指向数组 a 中下标为 0 的元素,p + 1 指向下标为 1 的元素,以此类推。如果希望 p 指向下标为 1 的元素,可以这样写:

p = p + 1;

当然,也可以这样写:

p++;

*a 是数组 a 中下标为 0 的元素的引用。同理,*(a + 1)是数组中下标为 1 的元素的引用,*(a + i)是数组中下标为 i 的元素的引用,简写为 a[i]

由于 a + ii + a的含义一致,因此a[i]i[a]也具有相同的含义。但我们绝不推荐这种写法。

二维数组
int calendar[12][31];

请思考,calendar[4]含义是什么?

calender[4]是 calendar 数组第 5 个元素,是 calendar 数组 12 个拥有着 31 个整型元素的数组之一。sizeof(calendar[4])大小为 31 与 sizeof(int)的乘积。

p = calendar[4];

这个语句使 p 指向了数组 calendar 下标为 0 的元素。

如果 calendar 是数组,我们可以:

i = calender[4][7];

上式等价于:

i = *(calender[4] + 7);

等价于:

i = *(*(calender + 4) + 7);

下面我们再看:

p = calender;

这个语句是非法的。因为 calendar 是一个二维数组,即数组的数组,calendar 是一个指向数组的指针,而 p 是指向整型变量的指针。

我们需要声明一种指向数组的指针,经过上一章的讨论,我们不难得出:

int (*ap)[31];

这个语句的效果是:声明了 *ap 是一个拥有 31 个元素的数组,所以,ap 就是指向这样的数组的指针。因此,我们可以这样写:

int calender[12][31];
int (*monthp)[31];
monthp = calendar;

这样 monthp 指向 calendar 数组的第一个元素,也就是 calendar 的 12 个拥有 31 个整型变量的数组类型的元素之一。

假定在新的一年开始时,我们需要清空 calendar 数组,用下标的形式可以很容易的做到:

int month;
for(month = 0; month < 12; month++){
int day;
for(day = 0; day < 31; day++)
calendar[month][day] = 0;
}

上面的代码用指针应该如何表示?

int (*month)[31] = calander;
for(;month < calendar + 12; month++){
int *day = *month;
for(; day < *month + 31; day++)
*day = 0;
}

原书中的代码为:

int (*monthp)[31];
for(monthp = calendar; monthp < &calendar[12]; monthp++){
int *dayp;
for(dayp = *monthp; dayp < &(*monthp)[31]; dayp++)
*dayp = 0;
}

1. 非数组的指针

假定我们两个这样的字符串 s 和 t,我们希望将这两个字符串连接成单个字符串 r :

char* r;
strcpy(r, s);
strcat(r, t);

我们不确定 r 指向何处,而且 r 所指向的地址处不一定有内存空间可供容纳字符串。这一次,我们为 r 分配空间:

char r[100];
strcpy(r, s);
strcat(r, t);

C 语言强制要求我们必须声明数组大小为一个常量,因此我们不能保证 r 足够大。这时,我们可以利用库函数 malloc :

char *r, *malloc();
r = malloc(strlen(s) + strlen(t));
strcpy(r, s);
strcat(r, t);

这个例子还是错的,原因有 3 :

  1. malloc 函数可能无法提供请求的内存
  2. 给 r 分配的内存在使用完后应该及时释放
  3. strlen(s) 的值如果是 n ,那么字符串 s 的实际长度为 n + 1,因为,strlen 会忽略作为结束标志的空字符。所以,malloc 时,切记给字符串结尾的空字符留有空间。

修改:

char *r, *malloc();
r = malloc(strlen(s) + strlen(t) + 1);
if(!r){
complain();
exit(1);
}
strcpy(r, s);
strcat(r, t);

//一段时间后再使用
free(r);

2. 作为参数的数组声明

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

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

printf 函数调用等价于:

printf("%s\n", &hello[0]);

所以,C 语言中会自动的将作为参数的数组声明转换为相应的指针声明。也就是像这样的写法:

int strlen(char s[]){

}

或:

int strlen(char* s){

}

C 程序员经常错误的假设,在其他情况下也会有这种自动的转换。后面我们会说到:

extern char* hello;

和下面的语句有着天壤之别:

extern char hello[];

另一个常见的例子就是 main 函数的参数:

int main(int argc, char* argv[]){

}

等价于:

int main(int argc, char** argv){

}

需要注意的是,前一种写法强调 argv 是一个指向某数组元素为字符指针的起始元素的指针。因为这两种写法是等价的,所以可以任选一种最能清晰反应自己意图的写法。

3. 避免“举隅法”

指针的复制并不同时复制指针所指向的数据。

char *p, *q;
p = "xyz";

p 的值并不是字符串 "xyz",而是指向该字符串起始元素的指针。因此,如果我们执行下面的语句:

q = p;

现在 p 和 q 是两个指向内存中同一地址的指针。如图:

因此,当我们执行完语句:

q[1] = 'Y';

q 所指向的内存存储的字符串是"xYz",p 所指向的内存中存储的当然也是字符串"xYz" 。

注意:ANSI C 中禁止对 string literal (字符串字面量)作出修改。K&R 对这一行为的说明是:试图修改字符串常量的行为是未定义的。

4. 空指针并非空字符串

常数 0 转换而来的指针不等于任何有效的指针。

#define NULL 0

无论是用 0 还是符号 NULL,效果都是完全相同的。空指针绝不能被解引用

下面的写法是合法的:

if(p == (char*)0){...}

但是如果写成这样:

if(strcmp(p, (char*)0) == 0){...}

就是非法的了。因为库函数 strcmp 的实现中会查看它的指针参数所指向的内存中的内容。

如果 p 是一个空指针,即使

printf(p);

printf("%s\n", p);

的行为也是未定义的。

5.边界计算与不对称边界

如果一个数组有 10 个元素,那么这个数组下标允许取值范围是什么呢?

在 C 语言中,这个数组下标的范围是 0 ~ 9 。

栏杆错误

也称差一错误(off-by-one error)。

解决这种问题的通用原则:

  • 首先考虑最简单情况下的特例,然后将得到的结果外推。
  • 仔细计算边界,绝不掉以轻心。
不对称边界

解决差一错误的一个方法是使用不对称边界的思想。

比如,一个字符串中由下标为 16 到下标为 37 的字符元素组成的字串,如何表示这个范围?

我们采用不对称边界:x >= 16 && x <38而不是采用x >= 16 && x <= 37。这样,这个字串的长度明显就是 38 - 16,也就是 22 。

用 for 循环遍历一个大小为 10 的数组:

for(i = 0; i < 10; i++){

}

而非:

for(i = 0; i <= 9; i++){

}

6. 求值顺序

C 语言中只有 4 个运算符(&&||?:,)存在规定的求值顺序。

  • 运算符 && 和 || 首先对左操作数求值,只有在需要时才对右操作数求值。
  • 运算符 ?: 有 3 个操作数:在 a ? b : c中,首先对 a 求值,根据 a 的值再对操作数 b 或 操作数 c 求值。
  • 逗号运算符从左向右一次求值。(求值然后丢弃再继续求值。)

运算符 && 和 || 对于保证检查操作按照正确的顺序执行至关重要。例如在语句

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

中,就必须保证仅当 y 非 0 时才对 x / y 求值。

下面这种从数组 x 中复制前 n 个元素到数组 y 中的做法是不正确的:

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

问题出在哪里呢?上面的代码假设 y[i]的地址在 i 的自增操作指向前被求值,这一点并没有任何保证。

同样的道理,下面的代码也是错误的:

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

应该使用这一种写法:

i = 0;
while(i < n){
y[i] = x[i];
i++;
}

或:

for(i = 0; i < n; i++){
y[i] = x[i];
}

7. 运算符 && 和 || 与 运算符 & 和 |

按位运算 &,|,^ ,~ 对操作数的处理方式是将其视为一个二进制的位序列,分别对其每一位进行操作。

逻辑运算 &&,||,! 对操作数的处理方式是将其视为要么是“真” 要么是“假”。通常将 0 视为 假,非 0 视为 真。它们的结果只可能是 1 或 0 。

需要注意的是逻辑运算中的 && 和 || 是有求值顺序的。

考虑下面的代码段,其作用是在表中查询一个特定的元素:

i = 0;
while(i < tabsize && tab[i] != x)
i++;

假定我们无意中用 & 替换了 &&:

i = 0;
while(i < tabsize & tab[i] != x)
i++;

这个循环也可能正常工作,但这仅仅是因为两个侥幸的原因:

  1. while 循环中的表达式 & 两侧都是比较运算,其结果只会是 1 或 0 。因此 x && y 和 x & y 会具有相同的结果。然而,如果两个比较运算中的任意一个使用除 1 之外的非 0 的数表示“真”,那么这个循环就不能正常个工作了。
  2. 对于数组结尾后的下一个元素(实际上是不存在的),只要程序不去修改该元素的值,而仅仅读取它的值,一般情况下是不会有什么危害的。运算符 && 和 & 不同,& 要求 两侧的操作数都必须被求值。因此,在后一个代码中,最后一次循环当 i 等于 tabsize 时,尽管 tab[i] 并不存在,程序依然会查看 tab[i] 的值。

8. 整数溢出

C 语言中存在两类整数算术运算,有符号运算与无符号运算。在无符号运算中,没有所谓“溢出”一说:所有无符号运算都是以 2 的 n 次方为模,这里 n 是结果中的位数。

如果算数运算符中的一个操作数是无符号整数一个是有符号整数,有符号整数会被转换为无符号整数。“溢出”同样不会发生。

但是当两个操作数都为有符号整数时,溢出就可能发生,而且“溢出”的结果是未定义的。

例如,假定 a 和 b 为连个非负整形变量,我们要检查 a + b 是否会“溢出”,一种想当然的方式:

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

这并不能正常运行。当 a + b 确实发生“溢出”时,所有关于结果如何的假设都是不可靠的。例如,有的计算机上,加法运算将设置内部寄存器为四种状态之一:正,负,零和溢出。在这种机器上,上面 if 语句的检测就会失效。

一种正确的方式为将 a 和 b 强转为无符号整数:

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

此处的 INT_MAX 是一个已定义常量,代表可能的最大整数值。ANSI C 标准在<limits.h>中定义了 INT_MAX 。

不需要用到无符号整数运算的另一种可行的办法是:

if(a > INT_MAX - b)
complain();

9. 为 main 函数提供返回值

已在 【C 必知必会】系列详细讲解过。不再赘述。

参考资料《C 缺陷与陷阱》