数组并非指针

C编程新手最常听到的说法之一就是“数组和指针是相同的”。不幸的是,这是一种非常危险的说法,并不完全正确。
ANSI C标准6.5.4.2节建议:

注意以下声明的区别:
extern int *x;
extern int y[];
第一条语句声明x是个int型的指针;第二条语句声明y是个int型数组,长度尚未确定(不完整的类型),其存储在别处定义。

标准并没有做更细的规定。许多C语言书籍对数组与指针何时相同、何时不同含糊其辞,对于这个应该重点阐述的话题只是一带而过。先看一个例子:

1
2
3
4
5
文件1:
int mango[100];

文件2:
extern int *mango;

这里,文件1定义了数组mango,但文件2声明它为指针。这有什么错误吗?无论如何,“每个人都知道”在C语言中,数组和指针非常相似。问题在于“每个人”这种说法是错误的!这相当于把整数和浮点数混为一谈:

1
2
3
4
5
文件1:
int guava;

文件2:
extern float guava;

上面这个int和float的例子非常明显,类型不匹配,没人会指望这样的代码能够运行。但是为什么人们会认为指针和数组始终应该是可以互换的呢?

答案是对数组的引用总是可以写成对指针的引用,而且确实存在一种指针和数组的定义完全相同的上下文环境。

不幸的是,这只是数组的一种极为普通的用法,并非所有情况下都是如此。但是,人们却自然而然的归纳并假定在所有的情况下数组和指针都是等同的,包括上面完全错误的“数组定义等同于指针的外部声明”这种情况。

声明与定义

在搞清这个问题之前,需要在头脑里重新整理一些基本的C语言术语。记住,C语言中的对象必须有且只有一个定义,但它可以有多个extern声明。这里所说的对象跟C++中的对象并无关系,这里的对象只是跟链接器有关的东西,比如函数和变量。

定义是一种特殊的声明,它创建了一个对象;声明简单的说明了在其他地方创建的对象的名字,它允许你使用这个名字。

定义 只能出现在一个地方 确定对象的类型并分配内存,用于创建新的对象。例如:int my_array[100];
声明 可以多次出现 描述对象的类型,用于指代其他地方定义的对象(例如在其他文件里)例:extern int my_array[];

区分定义和声明:
1.声明相当于普通的声明:它所说明的并非自身,而是描述其他地方的创建的对象。
2.定义相当于特殊的声明:它为对象分配内存。

extern 对象声明告诉编译器对象的类型和名字,对象的内存分配则在别处进行。由于并未在声明中为数组分配内存,所以并不需要提供关于数组长度的信息。对于多维数组,需要提供除最左边一维之外其他维的长度——这就给编译器足够的信息产生相应的代码。

对数组和对指针的引用的不同之处

首先需要注意的是“地址y”和“地址y的内容”之间的区别。这是一个相当微妙之处,因为在大多数编程语言中我们用同一个符号来表示这两样东西,由编译器根据上下文环境判断它的具体含义。

以一个简单的赋值为例:

1
x = y;
x = y
在这个上下文环境里,符号x的含义是x所代表的地址。 在这个上下文里,符号y的含义是y所代表的地址的内容。
这被称为左值 这被称为右值
左值在编译时可知,左值表示存储结果的地方。 右值直到运行时才知。如无特别说明,右值表示“y的内容”。

c语言引入“可修改的左值”这个术语。
它表示左值允许出现在赋值语句的左边,这个奇怪的术语是为了与数组名区分。
数组名也用于确定对象在内存中的位置,也是左值,但它不能作为赋值的对象。因此,数组名是个左值但不是可修改的左值。
标准规定赋值符必须用可修改的左值作为它左边一侧的操作数。
用通俗的话说,只能给可以修改的东西赋值。

编译器为每个变量分配一个地址(左值),这个地址在编译时可知,而且该变量在运行时一直保存于这个地址。存储于变量中的值(右值)只有在运行时才可知。如果需要用到变量中存储的值,编译器就发出指令从指定地址读入变量值并将它存于寄存器中。

这里的关键之处在于每个符号的地址在编译时可知。所以,如果编译器需要一个地址(可能还需要加上偏移量)来执行某种操作,它就可以直接进行操作,并不需要增加指令首先取得具体的地址。相反,对于指针,必须首先在运行时取得它的当前值,然后才能对它进行解除引用操作(作为以后进行查找的步骤之一。)

1.对数组进行下标引用的步骤:

1
2
3
char a[9] = "abcdefgh";
...
c = a[i];

首先,编译器符号表有数组a的地址,假设为9980;
运行时步骤1:取i的值,将它与9980相加(获得a[i]对应的偏移地址)
运行时步骤2:取地址(9980+i)的内容。

这就是为什么extern char a[]与extern char a[100]等价的原因。这两个声明都提示a是一个数组,也就是一个内存地址,数组内的字符可以从这个地址找到。编译器并不需要知道数组总共有多长,因为数组长度只用于表示偏离起始地址的最大偏移量。从数组提取一个字符,只要简单的用符号表里a的地址加上下标的偏移量,所需要的字符就位于这个地址中。

如果声明extern char *p,它将告诉编译器p是一个指针,它指向的对象是一个字符。为了取得这个字符,必须得到地址p的内容,把内容再作为字符的地址,并从这个地址中取得这个字符。指针的访问要灵活的多,但需要增加一次额外的提取。

2.对指针的引用的步骤:

1
2
3
char *p;
...
c = *p;

首先,编译器有符号p的地址,假设为4624;
运行时步骤1:取地址4624的内容,为5081;
运行时步骤2:取地址5081的内容。

3.对指针进行下标引用的步骤:

1
2
3
char *p = "abcdefgh";
...
c = p[i];

首先,编译器符号表有一个p,假设地址为4624;
运行时步骤1:取地址4624的内容,为5081;
运行时步骤2:取得i的值,并将它与5081相加;
运行时步骤3:取地址(5081+i)的内容。

对照1、3的访问方式:

1
2
char a[] = "abcdefgh"; ... a[3];
char *p = "abcdefgh"; ... p[3];

在这两种情况下,都可以取得字符‘d’,但两者的途径非常不一样。

定义为指针,但以数组方式引用,编译器将会:
a.取得符号表中p的地址,提取存储于此处的指针。
b.把下标所表示的偏移量与指针的值相加,产生一个地址。
c.访问上面这个地址,取得字符。

编译器已被告知p是一个指向字符的指针。p[i]表示”从p所指的地址开始,前进i步,每步都是一个字符(即每个元素的长度为一个字节)。“如果是其他类型的指针(如int或double),其步长(每步的字节数)也各不相同。

既然把p声明成指针,那么不管p原先定义为指针还是数组,都会按照上面所示的三个步骤进行操作,但是只有当p原来定义为指针时这个方法才是正确的。

定义为数组,但被声明为指针的问题

假设p原先的定义是char p[10],p在外部文件被声明为extern char *p。
当用p[i]这种形式提取这个声明的指针p的内容时,实际上得到的是一个字符。但按照上面的方法,编译器却把取到的字符当成是一个地址,把ACSII字符解释为地址显然是牛头不对马嘴,它很可能会污染程序地址空间的内容,并出现莫名其妙的错误。

指针的外部声明与数组定义不匹配的问题很容易修正,只要修改声明,使之与定义相匹配即可,如下所示:

1
2
3
4
5
文件1:
int mango[100];

文件2:
extern int mango[];

mango数组的定义分配了100个int的空间。
而指针定义 int *raisin;则申请一个地址容纳该指针。
指针的名字是raisin,它可以指向任何一个int变量(或int数组)。
指针变量raisin本身始终位于同一个地址,但它的内容在任何时候都可以不同,指向不同地址的int变量。这些不同的int变量可以有不同的值。
mango数组的地址并不能改变,在不同的时候它的内容可以不同,但它总是表示100个连续的内存空间。

数组和指针的其他区别

比较数组和指针的另外一个方法就是比对两者的特点。

指针 数组
保存数据地址 保存数据
间接访问数据,首先取得指针的内容,把它作为地址,然后从这个地址提取数据。如果指针有一个下标[i],就把指针的内容加上i作为地址,从中提取数据 直接访问数据,a[i]只是简单的以a+i为地址取得数据。
通常用于动态数据结构 通常用于存储固定数目且数据类型相同的元素
相关的函数为malloc()、free() 隐式分配和删除
通常指向匿名数据 自身即为数据名

数组和指针都可以在它们的定义中用字符串常量进行初始化。尽管看上去一样,底层的机制却不相同。
定义指针时,编译器并不为指针所指向的对象分配空间,它只分配指针本身的空间,除非在定义时同时赋给指针一个字符串常量进行初始化。例如,下面的定义创建了一个字符串常量(为其分配了内存):

1
char *p = "breadfruit";

注意只有对字符串常量才是如此。不能指望为浮点数之类的常量分配空间,如:

1
float *pip = 3.141;//错误!无法通过编译。

在ANSI C中,初始化指针时所创建的字符串常量被定义为只读。如果试图通过指针修改这个字符串的值,程序就会出现未定义的行为。在有些编译器中,字符串常量被存放在只允许读取的文本段中,以防止它被修改。

数组也可以用字符串常量进行初始化:

1
char a[] = "gooseberry";

与指针不同,由字符串常量初始化的数组是可以修改的。其中的单个字符在以后可以改变,比如下面的语句:

1
strncpy(a, "black", 5);

就将数组的值修改为”blackberry”。