C 语言数组:不要再叫我是指针了!

在整理有关指针和数组名之间的隐式转换、二维数组、指针数组等笔记时,总感觉自己对指针和数组本身概念的理解比较模糊,因此特地整理此文,梳理思路。

指针和数组之间没有任何关系!指针就是指针,指针变量在 32 位系统下,永远占 4 个字节,其值为一个内存的地址。指针可以指向任何地方,但是不是任何地方都能通过这个指针变量访问到。数组就是数组,其大小与元素的类型和个数有关。定义数组时必须指定其元素的类型和个数。数组可以存任何类型的数据,但不能存函数。

为什么很多人容易把数组和指针混淆,甚至认为指针和数组是一样的?最主要的原因是它们都可以 以指针形式以数组下标形式 这两种形式去访问。

两种访问指针和数组的形式

访问一个指针

1
char *p = "abcdef";

这里定义了一个指针变量 p,本身在栈上占 4 个字节,p 里存储的是一块内存的首地址。这块内存在常量区,其大小为 7 字节。由于这块内存没有名字,对这块内存的访问完全是 匿名 的。我们可以通过两种方式进行访问:

  • 以指针形式访问

    *(p + 4),先取出 p 里存储的地址值,然后加上 4 个字符大小的偏移量,得到新的地址,最后取出新地址上的值。

  • 以数组下标形式访问

    p[4]但是编译器总是把以下标的形式的操作解析为以指针的形式的操作。因此,以下标的形式访问在本质上与以指针的形式访问没有区别,只是写法上不同。此外,比较有趣的是,我们可以将 p[4] 写成 4[p],编译器并不会报错,因为它将 4[p] 解析为 *(4 + p)

访问一个数组

1
char a[] = "123456";

这里定义了一个数组 a,a 拥有 7 个字符类型的元素,其空间大小为 7 字节(包括 '\0')。数组 a 本身在栈上,对 a 元素的访问必须先根据数组的名字 a 找到数组第一个元素的地址,然后根据偏移量找到相应的值。这是一种典型的 具名+匿名 访问。

  • 以指针形式访问

    *(a + 4),a 这时候代表的是数组第一个元素的地址,然后加上 4 个字符的偏移量得到新的地址,最后取出地址上的值。

  • 以下标形式访问

    a[4],同理转换成以指针访问的形式。

总结

由上面的分析,我们可以看到,指针和数组是两个完全不一样的东西,只是它们都可以 以指针形式以下标形式 进行访问。访问指针是完全匿名的访问,而访问数组是典型的 具名+匿名 访问。最后,一定要注意的是 以 XXX 形式访问 这种表达方式。

a 和 &a 的区别

对于 int a[4],a 有两种含义:

  • 指向第一个元素的指针
  • 指向数组的指针

两者的值相等,但意义不同。在多数情况下,a 可以看做是指向第一个元素的指针,即在加减运算中表现为指向 int 类型的指针。(但注意的是,a 不是变量,不可以被赋值)

因此,我们可以这样写:

1
int *p = a; // 等价于 int *p = &a[0];

然而,数组名 a 在以下两种情况下不能当做指向第一个元素的指针:

  1. sizeof(a)

    得到的是数组的大小,即 16 字节。而不是将其理解为 int 类型指针的大小,即 8 字节(假定在 64 位系统下)。但是就像之前说的,它表现为一个指向 int 类型指针。

  2. sizeof(&a)

    得到的是指向长度为 4 的数组的指针(64 位下 8 字节),即 int (*)[4] 类型,运算时位移量是按照整个数组大小进行计算,即 &a + 1 表示的是下一个数组。注意,这里的 4 不能省略,因为指向不确定长度的指针是没有意义的,编译器若不知道指向数组的大小,就无法获得位移量并编译指针的加减运算。它表现得像一个数组指针。

    如果要定义一个指向该数组的指针,应该这么写:

    1
    2
    int (*p)[4] = &a; // 而不是 int *p = &a;
    // sizeof(&a) 的值为 8,指针类型的大小

注意:数组 a 并没有被转换成指针,尽管有的文章这么说。

指针和数组的定义与声明

为了进一步说明指针和数组是两个独立的概念,我们还可以通过定义与声明的角度去理解。

定义为数组,声明为指针

1
2
3
4
// 文件 1 - 定义
char a[100];
// 文件 2 - 声明
extern char *a;

结果:

1
error: redeclaration of 'abc' with a different type: 'char *' vs 'char [100]'

注意到了吗,they are different types!

数组就是数组,指针就是指针,它们是完全不同的两码事。char a[100] 是定义,系统为它分配了 100 字节的内存空间。在文件 2 下,编译器认为 a 是一个指针变量,在 64 位下占 8 个字节。虽然在文件 1 下,编译器知道 a 是一个数组,但在文件 2 下,编译器并不知道这一点,此时大小存在冲突。

此外,在文件 1 中,分配的内存空间中存的是 char 元素,是内容本身;而在文件 2 中,编译器将这些内容理解为 8 个字节的地址。

进一步解释(参考 Expert C Programming)

首先,需要注意 地址 y地址 p 的内容 两者的区别,这相当微妙,而且在大多数编程语言中我们用同一个符号来表示这两样东西。

  • 左值:出现在赋值符左边的符号,编译时可知,表示存储结果的地方。
  • 右值:出现在赋值符右边的符号,运行时才知,表示为地址的内容。

C 语言引入可修改的左值这个术语。数组名是个左值,但不能被修改。标准规定赋值符 必须 用可修改的左值作为它左边的操作数,即只能给可以修改的东西赋值,所以这就是为什么数组名不能被赋值的原因。

再看:

1
2
3
char a[100];
extern char a[]; // √
extern char *a; // ✕

这里的关键之处在于每个符号的地址在编译时可知。所以,如果编译器需要一个地址(可能还需要偏移量)来执行某种操作,它就可以直接进行操作,并不需要增加指令首先取得具体的地址。

相反,对于指针,必须首先知道在运行时取得它的当前值。第一个声明 extern char a[] 提示了 a 是一个数组,也就是一个内存地址(链接时符号都被替换成真正的地址);第二个声明 extern char *a 告诉编译器 a 是一个指针,我们只知道指针本身所在的地址,而不知道它所指向 char 对象的地址(即变量的值)。

定义为指针,声明为数组

1
2
3
4
// 文件 1 - 定义
char *a;
// 文件 2 - 声明
extern char a[100];

结果:

1
error: redeclaration of 'abc' with a different type: 'char [100]' vs 'char *'

同理。

总结

通过上面的分析,我们应该知道数组与指针的的确确是两码事了。他们之间是不可以混淆的,但是我们可以说「以 XXX 的形式」访问数组的元素或指针指向的内容。此外,以后一定要确认你的代码在一个地方定义为指针,在别的地方也只能声明为指针;在一个的地方定义为数组,在别的地方也只能声明为数组。

注意:在指针参数中存在隐式转换,两者是可以转换的(可参考:隐式转换:把 C 语言数组转换为指针)。

指针与数组的特性总结(来源请看参考):

指针 数组
保存数据的地址,任何存入变量的数据都会被当做地址来处理。变量本身的地址由编译器另外存储,我们并不知道在哪里。 保存数据。数组名代表的是数组第一个元素的地址,而不是数组的地址;&数组名 才是整个数组的地址。a 本身的地址由编译器另外存储,我们并不知道在哪里。
间接访问数据:访问是 完全匿名 的。首先取得变量的内容,把它作为地址,然后根据这个地址提取或写入数据。 直接访问数据:数组名就是整个数组的名字,数组内每个元素没有名字。只能通过 具名+匿名 的方式来访问某个元素,不能把整个数组当做一个整体进行读写操作。
通常用于动态数据结构,相关的函数有 malloc 和 free。 通常用于存储固定数目且数据类型相同的元素,隐式分配和销毁。
通常指向匿名数据(也可以指向具名数据) 自身为具名数据(数组名)
初始化时编译器并不为指针所指向的对象分配空间,只分配指针本身,除非在定义时同时赋值给指针一个字符串常量。 数组在初始化时为对象分配了空间。

参考