C/C++中数组与指针的关系理解

长期以来,在C/C++中,数组名和指向数组首元素的指针常量到底是以一种什么关系,一直困扰着很多人。很多地方,甚至是一些教科书中都在说,“数组名就是一个指向数组首元素的指针常量”。但事实是,这是一种错误的说法!我们可以在很多场合中把数组名看作一个指向数组首元素的指针常量,但绝不能将这两者当成同一个东西。

真实的关系

数组是数组,指针是指针,这是两种不同的类型

数组既可以表示一种数据类型,也可以表示这种类型的一个对象(非面向对象之对象,下同),表示对象时也可以称之为数组变量,和其他类型变量一样,数组变量有地址也有值。

数组的地址就是数组所占据内存空间的第一块存储单元的编号,而数组的值是由数组所有元素的值构成。

数组名既不是指针,也不是指针变量,而是数组变量的名字,与数组名相对应的是指针变量的变量名,都是符号

空说费劲,看代码说话:

int a[10]; int *const p = a; std::cout << sizeof(a); // 40 std::cout << sizeof(p); // 8

作为数组,a拥有可以存放10个int型数据的空间,可以将一个int型的值存储到a中任意一个元素中。但作为一个指针的p,只能存储一个地址。

sizeof 操作符可以获取到一种数据类型所占据的内存大小,指针类型在x64位机器上的大小是8,而数组类型的大小是所有元素大小之和,上例中即为10个int型的大小,是40。

误解从何而来

可以将数组名赋值给一个指针,而赋值后的指针是指向数组首元素的,这让数组名看起来确像一个指针。

直接输出数组名会得到数组首元素的地址,这让人们误以为“数组名的值就是数组首元素地址“,符合指针的定义。

数组名可以像指针一样运算,对数组的索引和指针的运算看起来也是相同的。

#include <stdio.h> int main(){ int a[] = {1,2,3}; int * p = a; printf("a:\%#x, p:%#x, &a[0]:%#x\n", a, p, &a[0]); printf("*a:\%d, *p:%d, a[0]:%d, p[0]:%d\n", *a, *p, a[0], p[0]); printf("*(a+1):\%d, *(p+1):%d, a[1]:%d, p[1]:%d\n", *(a+1), *(p+1), a[1], p[1]); return 0; }

输出:

a:0x5fcaf0, p:0x5fcaf0, &a[0]:0x5fcaf0 *a:1, *p:1, a[0]:1, p[0]:1 *(a+1):2, *(p+1):2, a[1]:2, p[1]:2 从 &a 与 &a[0] 说起

数组的地址和数组首元素的地址虽然值相同,但意义不同。

值相同是因为,一个变量无论在在内存中占据多大空间,它的地址总是该空间第一个内存单元的地址。而数组的元素依次连续分布在整块数组空间中,数组空间的第一个内存单元被数组首元素占据,必然也同时是数组首元素所占空间的第一块空间单元,所以数组的地址与数组首元素的地址相同。

意义不同是因为,数组地址代表了整块数组所占据的内存空间,而数组首元素的地址只代表了首元素所占据的空间。

&a 表示取数组的地址,其结果是一个指向该数组的指针,它可以赋值给另一个同类型的指针。

&a[0]表示取数组首元素的地址,其结果是指向该数组首元素的指针,可以赋值给另一个同类型的指针。

注意:指向数组的指针和指向数组首元素的指针是两种不同类型的指针。

#include <stdio.h> int main(){ int a[]={1,2,3}; int (* pa)[3]; int * pi; pa = &a; pi = &a[0]; printf("&a=%#x, &a[0]=%#x\n",&a, &a[0]); printf("pa=%#x, sizeof(a)=%d, pa+1=%#x\n", pa, sizeof(a), pa+1); printf("pi=%#x, sizeof(a[0])=%d, pi+1=%#x\n", pi, sizeof(a[0]), pi+1); return 0; }

编译后运行,输如下:

&a=0x5fcaf0, &a[0]=0x5fcaf0 pa=0x5fcaf0, sizeof(a)=12, pa+1=0x5fcafc pi=0x5fcaf0, sizeof(a[0])=4, pi+1=0x5fcaf4

我们发现,取数组地址(&a)得到的指针pa和取数组首元素(&a[0])得到的指针pi是两种不同类型的指针,pa是一个指向有三个int型元素的数组的指针,pi是一个指向int型对象的指针。虽然pi和pa的值相同,但所指的内存空间不同,pi所指的空间处于pa所指空间的内部,而且是内部最靠前的部分。pi和pa所指内存块的大小显然是不同的,因此我们看到pa+1并不等于pi+1。

由指针运算规则可知,pa+1的值就是pa所指空间的下一个空间的地址,所以pa+1的值就是pa的地址向后偏移一段后的地址,这个偏移量就是pa所指的数组a的大小,即12个内存单元。同样,pi+1的值是pi向后偏移4个单位(int型的大小)后的地址。

ps: 看到有些地方说地址就是指针,我觉得这个说法不对。地址就是地址,它是数据对象的一个属性,表明它在内存中的位置。指针本身也有地址,总不能说“地址的地址”吧?此外,指针不光带有地址信息,它还附带有所指对象的类型信息,这就是为什么指针知道如何准确的指向下一个地址。

权威的解释——decay

C11标准中,6.3.2.1 [Lvalues, arrays, and function designators] 第3段,有如下表述:

Except when it is the operand of the sizeof operator, the **_Alignof** operator, or the unary & operator, or is a string literal used to initialize an array, an expression that has type "array of type" is converted to an expression with type "pointer to type" that points to the initial element of the array object and is not an lvalue.

同样的转换在C++中也是一样,在 C++11 标准(ISO/IEC 14882)的 4.2 Array-to-pointer conversion 一节中有如下表述:

An expression of type “array of N T”, “array of runtime bound of T”, or “array of unknown bound of T” can be converted to a prvalue of type “pointer to T”. The result is a pointer to the first element of the array.

可见,除了作为 sizeof 、**_Alignof** 和 & 这3个操作符的操作数以及用于初始化数组的串字面量外, 表达式中的数组都会被自动转换为指向其首元素的指针 ,转换而来的指针不是一个左值(lvalue)。因为这种转换丢失了数组的大小和类型,因此有一个专用的称谓叫做 “decay”

于是,所有的疑惑都有了最权威的解释。

char a[10]; char * p = a; /* 这里的a被转换为 char* 型指针,所以可以赋值给p */ a = p; /* ERROR! a虽然被转换为指针了,但转换后得到的指针无确切地址,不是lvalue,不能被赋值 */ char (*pa) [10] = &a; /* a是&的操作数,没有发生转换,&a意为取数组a的地址,得到一个指向数组a的指针 */ sizeof(a); /* a未发生转换,仍是数组,所以表达式得到的值是数组的大小 */ &a; /* a未发生转换,仍是数组,所以表达式得到的值是数组的地址 */ *a; /* a被转换为指针,操作符*是作用于指针的,而非数组 */ *(a+1); /* a被转换为指针,所以并不是数组a与1相加,而是转换后得到的指针在参与运算 */ a[0]; /* a被转换为指针,所谓数组的下标本质是指针运算 */ a == a; a - a; /* a被转换为指针,本质是指针运算 */

我们发现,一旦有了 decay ,表达式中所有让一个数组看上去像个指针的现象都合情合理了。除赋值外,可用于指针的运算都可以适用于会发生转换的数组。不可赋值是因为先要转换,转换后不是左值。

ps:lvalue这个术语翻译成左值会过分强调它可以作为赋值操作符(=)的左操作数,实际上lvalue的核心在于location,而不是left,虽然它最初被命名为lvalue确是因为left。lvalue意味着在内存中有确切位置(location),可以定位(locator)。所以,数组被转换为指针后不是lvalue的原因是没有确切地址,而不能被赋值是结果。

可能读到这里会有一个疑问,那就是转换后不是左值不可以赋值,那么 a[0] = 'x'; 却怎么解释?注意,转换后得到左值的是a,而非a[0]。什么意思呢?把这个表达式中除去a的部分([0])看成是对转换后得到的指针的继续运算,结果就是数组第一个元素,有确切地址,那么a[0]整体就是一个左值了。 于是赋值成功!

值得注意的是,取址时取到的是数组地址而非转换后指针的地址,因为取址时数组不会发生转换,实际上,转换后得到的指针没有确切地址不是左值,是无法取到地址的。这里多说这么几句是因为,有一些博客中纠结于 “数组名作为一个指针常量有没有被分配空间?分配到的地址是什么?” 这样伤神的问题中。

转换规则的例外情况

C规范中指出了四种不发生转换的例外情况。

前三种情况是说数组作为 sizeof 、**_Alignof** 和 & 这3个操作符的操作数时是不会被转换为指针,这个较好理解,就是这三个操作符直接作用于数组时,不会发生转换,如 sizeof(a) 和 &a 中的a都不会被转换。而像 &a[0] 这样的表达式中,&的优先级不是最高的,所以&的直接作用对象是 a[0] 这个子表达式,此时a转换为指针后进行运算,得到数组的第一个元素,&作用于这个元素后取得其地址,得到的最终结果指向数组首元素的指针。

下面的代码也说明了这种规则的关键是直接作用:

int a[4]; printf("%d\n", sizeof(a)); /* 不转换,输出数组a的大小 16 */ printf("%d\n", sizeof(a+0)); /* 转换,输出指针类型大小 8 */

那么用于初始化数组的串字面量说的又是什么呢?

ISO/IEC 9899:201x ,6.4.5 [String literals] 节中对串字面量的规范可知,编译期间,串字面量所声明的多字节字符序列(multibyte character sequence)会先行合并(如果有多个相邻)并在结尾追加一个零值( '\0' ),然后用此序列初始化一个静态存储周期(static storage duration)的等长度字符数组

所以,串字面量作为一个原始表达式(Primary expressions),其结果是一个字符数组!因为地址可知,它是一个 lvalue 。

需要注意的是,程序中对这种数组试图修改的行为在C11标准中是未定义的,C11标准同样也没有说明对于内容相同的这种数组是否可以可以视为一个(只存储一份)[^ISO/IEC 9899:201x §6.4.5 para7]。

看下面的代码:

char a[4] = "abc"; /* 注意,此处是初始化,而非赋值!*/ char * p = "abc"; a[1] = 'x'; /* OK! */ p[1] = 'x'; /* ERROR! */ printf("%d\n", sizeof("abc")); /* 输出 4 */ printf("%#x\n", &("abc")); /* 本机输出 0x403031 ,证明没有转换,因为转换后非lvalue,无法取值 */

第一行代码中的串字面量 "abc" 的本质是一个长度为4(被追加了'\0')的字符数组,其用于初始化另一个数组a时不会发生转换。这就是所谓的用于初始化数组的串字面量不会decay

第二行代码中的串字面量同第一行中的一样,也是一个长度为4的字符数组,只是是否和上一行的是同一个就不得而知了,C标准没有规定。这个字符数组此刻并未用于初始化一个数组,所以它被转换为指向其首元素的指针,然后用于初始化另一个指针p了。

所以第一行可以认为是用数组初始化数组,第二行是用指针初始化指针。不过因为转换规则的存在,可用于初始化数组的“数组”仅限于串字面量。

第三行很好理解,a是我们新初始化的一个数组,和初始化它的串字面量已经是两回事了,所以修改a是合法的操作。但是第四行在大多数系统中会报错,因为p指向的是一个串字面量,对串字面量的修改行为未被C标准所定义,因为串字面量本质是即一个静态存储周期的字符数组,大多数系统对其有写保护因而修改出错。

如果尝试将串字面量作为 sizeof 、_Alignof 和 & 这3个操作符的操作数,我们发现这个“字符数组”也没有转换。

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/126f9272a3281df6fff7a60282e349b5.html