C陷阱与缺陷-疑难问题理解05

第3章 语义“陷阱”

​ 一个句子哪怕其中的每个单词都拼写正确,而且语法也无懈可击,仍然可能有歧义或者并非书写者希望表达的意思。程序也有可能表面看上去是一个意思,而实 际上的意思却相去甚远。本章考察了若干种可能引起上述歧义的程序书写方式。

​ 这一章中还讨论了这样的情形:如果只是肤浅地考察,一切都“显得”合情 合理,而事实上这种情况在所有的C语言实现中给出的结果却都是未定义的。在 某些C语言实现中能够正常工作,而在另一些C语言实现中却又不能工作的情形, 这属于可移植性方面的问题,将在第7章中给予论述。

3.1 指针与数组

​ C语言中指针与数组这两个概念之间的联系是如此密不可分,以至于如果不 能理解一个概念,就无法彻底理解另一个概念。而且,C语言对这些概念的处理, 在某些方面与其他任何为人熟知的程序语言都有所不同。

C语言中的数组值得注意的地方有以下两点;

  1. C语言中只有一维数组,而且数组的大小必须在编译期就作为一个常数确 定下来。然而,C语言中数组的元素可以是任何类型的对象,当然也可以是另外 一个数组。这样,要“仿真"出一个多维数组就不是一件难事。

  2. 对于一个数组,我们只能够做两件事:确定该数组的大小,以及获得指向 该数组下标为0的元素的指针。其他有关数组的操作,哪怕它们乍看上去是以数 组下标进行运算的,实际上都是通过指针进行的。换句话说,任何一个数组下标 运算都等同于一个对应的指针运算,因此我们完全可以依据指针行为定义数组下 标的行为。

    ​ 一旦我们彻底弄懂了这两点以及它们所隐含的意思,那么理解C语言的数组运算就不过是“小菜一碟”。如果不清楚上述两点内容,那么C语言中的数组运算就可能会给编程者带来许多的困惑。需要特别指出的是,编程者应该具备将数 组运算与它们对应的指针运算融汇贯通的能力,在思考有关问题时大脑中对这两种运算能够自如切换、毫无滞碍。许多程序设计语言中都内建有索引运算,在C语言中索引运算是以指针算术的形式来定义的。

    ​ 要理解C语言中数组的运作机制,我们首先必须理解如何声明一个数组。例如,

int a[3];

这个语句声明了a是一个拥有3个整型元素的数组。类似地,

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

声明了b是一个拥有17个元素的数组,其中每个元素都是一个结构,该结构中包括了一个拥有4个整型元素的数组(命名为p)和一个双精度类型的变量(命名为X)。

现在考虑下面的例子,

int calendar[12][31];

这个语句声明了calendar是一个数组,该数组拥有12个数组类型的元素,其中每个元素都是一个拥有31个整型元素的数组。(而不是一个拥有31个数组类型的元素的数组,其中每个元素又是一个拥有12个整型元素的数组。)因此,sizeof(calendar)的值是372(31*12)与sizeof(int)的乘积。

​ 如果calendar不是用于sizeof的操作数,而是用于其他的场合,那么calendar总是被转换成一个指向calendar数组的起始元素的指针。要理解上面这句话的含义,我们首先必须理解有关指针的一些细节。

任何指针都是指向某种类型的变量。例如,如果有这样的语句:

int *ip;

就表明ip是一个指向整型变量的指针。又如果声明,

int i ;

那么我们可以将整型变量i的地址赋给指针ip,就像下面这样:

ip = &i;

而且,如果我们给*ip赋值,就能够改变i的取值:

*ip = 17

如果一个指针指向的是数组中的一个元素,那么我们只要给这个指针加1, 就能够得到指向该数组中下一个元素的指针。同样地,如果我们给这个指针减1, 得到就是指向该数组中前一个元素的指针。对于除1之外其他整数的情形,依此类推。

​ 上面这段讨论暗示了这样一个事实:给一个指针加上一个整数,与给该指针 的二进制表示加上同样的整数,两者的含义截然不同。如果ip指向一个整数,那 么ip+1指向的是计算机内存中的下一个整数,在大多数现代计算机中,它都不同于ip所指向地址的下一个内存位置。

如果两个指针指向的是同一个数组中的元素,我们可以把这两个指针相减。 这样做是有意义的,例如:

int *q =p + i;

那么我们可以通过q-p而得到i的值。值得注意的是,如果p与q指向的不是同一个数组中的元素,即使它们所指向的地址在内存中的位置正好间隔一个数组元素的整数倍,所得的结果仍然是无法保证其正确性的。

本节前面已经声明了a是一个拥有3个整型元素的数组。如果我们在应该出 现指针的地方,却采用了数组名来替换,那么数组名就被当作指向该数组下标为0的元素的指针。因此如果我们这样写,

p = a;

就会把数组a中下标为0的元素的地址赋值给p。注意,这里我们并没有写成:

p = &a;

​ 这种写法在ANSI C中是非法的,因为&a是一个指向数组的指针,而p是一个指向整型变量的指针,它们的类型不匹配。大多数早期版本的C语言实现中, 并没有所谓“数组的地址”这一概念,因此&a或者被视为非法,或者就等于a。

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

p = p + 1;

当然,该语句完全等同于下面的写法:

p++;

除了a被用作运算符sizeof的参数这一情形,在其他所有的情形中数组名a 都代表指向数组a中下标为0的元素的指针。正如我们合乎情理的期待,sizeof(a) 的结果是整个数组a的大小,而不是指向数组a的元素的指针的大小。

从上面的讨论中,我们不难得出一个推论,*a即数组a中下标为0的元素的引用。例如,我们可以这样写:

*a = 84;

这个语句将数组a中下标为0的元素的值设置为84。同样道理,(a+1)是数组a中下标为1的元素的引用,依次类推。概而言之,(a+i)即数组a中下标为i的元素的引用;这种写法是如此常用,因此它被简记为a[i]。

​ 正是这一概念让许多C语言新手难于理解。实际上,由于a+i与i+a的含义 一样,因此a[i]与i[a]也具有同样的含义。也许某些汇编语言程序员会发现后一种 写法很熟悉,但我们绝对不推荐这种写法。

​ 现在我们可以考虑“二维数组”了,正如前面所讨论的,它实际上是以数组为元素的数组。尽管我们也可以完全依据指针编写操纵一维数组的程序,这样做在一维情形下并不困难,但是对于二维数组从记法上的便利性来说采用下标形式就几乎是不可替代的了。还有,如果我们仅仅使用指针来操纵二维数组,我们将不得不与C语言中最为“晦暗不明”的部分打交道,并常常遭遇潜伏着的编译器bug。

让我们回过头来再看前面的几个声明:

int calendar[12][31];
int *p:
int i:

然后,考一考自己,calendar[4]的含义是什么?

​ 因为calendar是一个有着12个数组类型元素的数组,它的每个数组类型元素又是一个有着31个整型元素的数组,所以calendar[4]是calendar数组的第5个元素,是calendar数组中12个有着31个整型元素的数组之一。因此,calendar[4]的行为也就表现为一个有着31个整型元素的数组的行为也就表现为一个有着31个整形元素的数组的行为。例如,sizeof(calendar[4]) 的结果是31与sizeof(int)的乘积。又如,

p = calendar[4];

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

如果calendar[4]是一个数组,我们当然可以通过下标的形式来指定这个数组 中的元素,就像下面这样,

i = calendar[4][7];

我们也确实可以这样做。还是与前面类似的道理,这个语句可以写成下面这样而表达的意思保持不变:

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

这个语句还可以进一步写成,

i = *(*(calendars4)+7);

从这里我们不难发现,用带方括号的下标形式很明显地要比完全用指针来表 达简便得多。

下面我们再看:

p = calendar;

这个语句是非法的。因为calendar是一个二维数组,即“数组的数组”,在此处的上下文中使用calendar名称会将其转换为一个指向数组的指针;而p是一个指向整型变量的指针,这个语句试图将一种类型的指针赋值给另一种类型的指针, 所以是非法的。

很显然,我们需要一种声明指向数组的指针的方法。经过了第2章中对类似 问题不厌其烦的讨论,构造出下面的语句应该不需要费多大力气:

int (*ap)[31];

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

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

这样,monthp将指向数组calendar的第1个元素,也就是数组calendar的12 个有着31个元素的数组类型元素之一。

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

int month;

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

上面的代码段如果釆用指针应该如何表示呢?我们可以很容易地把

calendar[month][day] = 0;

表示为

*(*(calendar + month) + day) = 0;

但是真正有关的部分是哪些呢?

如果指针monthp指向一个拥有31个整型元素的数组,而calendar的元素也是一个拥有31个整型元素的数组,因此就像在其他情况中我们可以使用一个指针遍历一个数组一样,这里我们同样可以使用指针monthp以步进的方式遍历数组calendar:

int (*monthp)[31];
for (monthp = calendar; monthp < &calendar[12]; monthp++)
		/*处理一个月份的情况*/

同样地,我们可以像处理其他数组一样,处理指针monthp所指向的数组的 元素;

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

到目前为止,我们一路行来几乎是“如履薄冰”,而且已经走得太远,在我们跌跤之前,最好趁早悬崖勒马。尽管本节中最后一个例子是合法的ANSIC程序, 但是作者还没有找到一个能够让该程序顺利通过编译的编译器。

3.2 非数组的指针

​ 在C语言中,字符串常量代表了一块包括字符串中所有字符以及一个空字符 (‘\0’)的内存区域的地址。因为C语言要求字符串常量以空字符作为结束标志, 对于其他字符串,C程序员通常也沿用了这一惯例。

假定我们有两个这样的字符串s和t,我们希望将这两个字符串连接成单个字符串r。要做到这一点,我们可以借助常用的库函数strcpy和strcat。下面的方法 似乎一目了然,可是却不能满足我们的目标:

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

之所以不行的原因在于不能确定r指向何处。我们还应该看到,不仅要让r 指向一个地址,而且r所指向的地址处还应该有内存空间可供容纳字符串,这个内存空间应该是以某种方式已经被分配了的。

我们再试一次,记住给r分配一定的内存空间:

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

只要s和t指向的字符串并不是太大,那么现在我们所用的方法就能够正常工作。不幸的是,C语言强制要求我们必须声明数组大小为一个常量,因此我们不够确保r足够大。然而,大多数C语言实现为我们提供了一个库函数malloc, 该函数接受一个整数,然后分配能够容纳同样数目的字符的一块内存。大多数C语言实现还提供了一个库函数strlen,该函数返回一个字符串中所包括的字符数。有了这两个库函数,似乎我们就能够像下面这样操作了 :

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

这个例子还是错的,原因归纳起来有三个。第一个原因,malloc函数有可能无法提供请求的内存,这种情况下malloc函数会通过返回一个空指针来作为“内存分配失败”事件的信号。

第二个原因,给r分配的内存在使用完之后应该及时释放,这一点务必要记住。因为在前面的程序例子中r是作为一个局部变量声明的,因此当离开r作用 域时,r自动被释放了。修订后的程序显式地给r分配了内存,为此就必须显式地释放内存。

第三个原因,也是最重要的原因,就是前面的例程在调用malloc函数时并未分配足够的内存。我们再回忆一下字符串以空字符作为结束标志的惯例。库函数 strlen返回参数中字符串所包括的字符数目,而作为结束标志的空字符并未计算在内。因此,如果strlen(s)的值是n,那么字符串实际需要n+1个字符的空间。所以,我们必须为r多分配一个字符的空间。做到了这些,并且注意检査了函数malloc是否调用成功,我们就得到正确的结果:

char *r,*malloc();
r =malloc(strlen(s) + strlen(t) + 1);
if(!r){
		complain();
		exit(1);
}
strcpy(r, s);
strcat(r, t);
/* 一段时间之后再使用*/
free(r);

3.3 作为参数的旳声明

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

char hello[] = "hello";

声明了 hello是一个字符数组。如果将该数组作为参数传递给一个函数,

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

实际上与将该数组第1个元素的地址作为参数传递给函数的作用完全等效,即:

printf ("%3\11" r &hello [0]);

因此,将数组作为函数参数毫无意义。所以,C语言中会自动地将作为参数的数组声明转换为相应的指针声明。也就是说,像这样的写法:

int strlen (char s[])
{
		/*具体内容*/
}

与下面的写法完全相同:

int strlen(char* s)
{
		/*具体内容*/
)

C程序员经常错误地假设,在其他情形下也会有这种自动地转换。程序员经常在此处遇到麻烦:

extern char *hello;

这个语句与下面的语句有着天渊之别:

extern char hello [];

​ 如果一个指针参数并不实际代表一个数组,即使从技术上而言是正确的,釆 用数组形式的记法经常会起到误导作用。如果一个指针参数代表一个数组,情况 又是如何呢? 一个常见的例子就是函数main的第二个参数:

main(int argc,char* argv[])
{
		/*具体内容*/
}

这种写法与下面的写法完全等价:

main(int argcr char** argv)
{
		/*具体内容*/
}

需要注意的是,前一种写法强调的重点在于argv是一个指向某数组的起始元素的指针,该数组的元素为字符指针类型。因为这两种写法是等价的,所以读者可以任选一种最能清楚反映自己意图的写法。

你可能感兴趣的:(C陷阱与缺陷-疑难问题理解)