C/C++中数组与指针的关系探究

时间:2022-07-15 05:44:46

数组与指针

长期以来,在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个操作符的操作数,我们发现这个“字符数组”也没有转换。

作为表达式结果的数组

在讨论串字面量本质的时候,我们发现,在转换概念的范围内,所谓数组不光是指在程序中被我们明确定义的数组,也可以是表达式计算的结果。如原始表达式串字面量的结果就是字符数组。果真如此吗?我们来看看下面的情况。

试想对于一个数组a,表达式 (&a)[0] 和表达式 &a[0] 有什么不同?

&a[0] 这个表达式我们在前面已经分析过了,它的结果是一个指向a的首元素的指针。

而表达式 (&a)[0] 的不同之处在于提高了取址操作符&的优先级。于是,在 (&a)[0] 中,数组 a 作为操作符&的操作数,不会发生转换。子表达式 &a 是取数组a的地址得到指向该数组的指针,而接下来的运算就是指针运算了,结果便是数组a本身。

那么作为表达式 (&a)[0] 的结果的数组会不会有转换行为呢?答案是肯定的。

空口无凭,再看代码:

char a[4] = "abc";
char * p;
p = &a[0];
p = (&a)[0];
printf("%d\n", (&a)[0] == &a[0]); /* 输出1*/
printf("%d\n", sizeof((&a)[0])); /* 输出 4,数组a的大小 */
printf("%d\n", sizeof(&a[0])); /* 输出 8,x64系统中指针的大小 */
char (*pa)[4];
pa = &((&a)[0]);

表达式 &a[0] 的结果是char * 型的指针,它可以赋值给同类型的指针p是理所当然的。但奇怪的是,表达式 (&a)[0] 结果是一个数组,竟然也可以赋值给指针p。考虑下转换规则,这个情况就完全合理了,这说明作为计算结果的数组,也会在符合转换条件的情况下发生转换。

将这两个表达式进行逻辑比较,得到的结论是它们是相同的,这也说明表达式 (&a)[0] 发生了转换。我们再尝试将这两个表达式分别作用于操作符 sizeof ,根据规则,作为sizeof的操作数,它们不会发生转换,事实确是如此。

对一个数组取地址会得到指向该数组的指针,我们将表达式 (&a)[0] 作为操作符&的操作数(此时也没有转换)得到的确实是指向数组类型的指针。如果,我们脑洞大一些,将 &a[0] 作为&的操作数会怎么样? &a[0] 是一个指针,可能我们会觉得是不是会得到一个指向指针的指针?答案是不会! &a[0] 是一个指针没错,但没有确切地址,不是lvalue,无法取址, &(&a[0]) 会编译错误!

转换的基础

数组和指针之所以有这么微妙的关系,内存分配才是关键因素。上面讨论的表达式 (&a)[0] 的结果就是数组a本身,如果我们把方括号中的整数0改为1或者2这样的值,那又意味着什么?

char a[4] = "abc";
char * p = &a[1]; /* p指向数组a的第二个元素 */
p = (&a)[1];/* p不指向数组第二个元素 */
printf("%d\n", sizeof((&a)[1])); /* 输出数组大小 4 */
printf("%d\n", (&a)[1] == &a[0]+4 ); /* 输出 1 */
char (*pa)[4] = &a;
printf("%c\n", *(pa+1)[0]); /* 即((&a)[1]))[0], 输出值不确定 */

我们先看看表达式 (&a)[1] 的结果是什么,首先, &a 将得到指向数组a的一个指针,而指针的下标运算 [1] 表示指针所指空间相邻的同大小空间内的对象,虽然我们知道,数组a的旁边有什么并不确定,但至少不应该是个和a一样的数组,因为我们此前并没有定义一个数组的数组。尽管如此,我们发现系统依然照着a的样子,在内存中为我们找到了它旁边一块同样大的空间,并“假装”它保存的也是一个同类型的数组,我们用sizeof测试时会得到数组大小的值。而且 (&a)[1] 这个“数组”还可以转换为一个指向它首元素的指针,我们将它和 &a[0]+4 (可以看作指向从a的首元素起后面的第5个元素的指针,即&a[4])比较发现,它们是相等的。

我们尝试按照取数组a中元素那样,从a旁边这个并不存在的数组中读取数据,虽然输出值没有意义,但这却并非是一种非法操作,C语言允许我们这么做。

顺便说下,数组下标在C语言中是被定义为指针运算的,ISO/IEC 9899:201 §6.5.2.1 [Array subscripting] para 2 中的的原文如下:

A postfix expression followed by an expression in square brackets [] is a subscripted designation of an element of an array object. The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))). Because of the conversion rules that apply to the binary + operator, if E1 is an array object (equivalently, a pointer to the initial element of an array object) and E2 is an integer, E1[E2] designates the E2-th element of E1 (counting from zero).

因此像 a[-1] 这样的负数下标虽然没有意义,但也是合法的。小于0 或大于等于数组元素数的下标值是无意义的,那我们如何看待这些越界的下标呢?实际上,下标是定义给指针的。 E1[E2] 中的两个子表达式E1和E2一个是指针类型一个是整数类型[^ISO/IEC 9899:201x §6.5.2.1 para1],因为转换规则的存在,数组才可以成为E1和E2中的一个。

为什么说是E1和E2中的一个?难道数组类型的不应该是E1吗?看下面的代码示例:

int a[5] = { 0, 1, 2, 3, 4 };
cout << a[2] << endl; // 输出 2
cout << 2[a] << endl; // 输出 2
cout << 5["abcdef"] << endl; // 输出 f

对于a[2]的输出我们没有异议,但后面两个表达式有点"诡异"。前面在引用C11中下标规范时提到, E1[E2](*((E1)+(E2))) 是等同的(identical )。那么就会有:

E1[E2]
(*((E1)+(E2))) 下标定义
(*((E2)+(E1))) 加法交换律
E2[E1] 下标定义

所以, E1[E2]E2[E1] 是等同的。因此,a[2]和2[a]都输出2,而 5["abcdef"] 其实是 "abcdef"[5] ,结果是串字面量(字符数组)中的第5个(从0计)字符f。

我们发现,数组和指针在从内存访问数据上并没有本质区别,数组貌似仅意味着数据连续不间断的存放而已,而数组类型都有相应的指针类型对应,如int型数组对应有指向int型的指针,这种类型信息提供了指针访问内存时每个步长的移动跨度。除此之外,数组好像也不能再给我们太多信息了,它甚至不能做出越界检查。

从转换来看,数组可谓是C语言世界的二等公民,在一切可以的情况下,它都转换为一个指向自身首元素指针。

作为函数参数的数组

函数调用中,作为实参的数组会在函数调用前被转换为一个指向其首元素的指针。因此,数组永远都不会真正地被传递给一个函数。这样的话,看上去将函数的参数声明未数组类型就会变得没有意义。事实上,函数的参数类型列表中但凡被声明为数组类型的,在编译期间会被调整为对应的指针类型,这意味着,在函数体内操作一个被声明为数组的形参,其实是在操作一个指针。

有关函数参数中的数组的调整和转换在C11规范中有多处说明[1],感兴趣可自行查看。

需要注意的是,虽然编译器会做调整,但我们不应将数组类型的形参声明都改为指针类型。因为数组和指针意义不同,在定义函数时不要理会编译器的调整有助于我们写出意义更加明确可读性更高的代码。

void f(char str[]) /* 会被调整为: void f(char *str) */
{
cout << sizeof(str) << endl; /* str是指针,而不是数组 */
if(str[0] == '\0')
str = "none"; /* 这里并不是数组被赋值,而是指针str被赋值 */
cout << str << endl;
}
int main(){
char a[3];
f(a);
a[0] = 'x';
f(a);
return 0;
}

在上面例子中,函数f声明了一个字符数组型的参数str,实际上在编译时str会被调整为指向字符的指针类型。在f内,尽管str被我们显式地声明为数组类型,但函数内部它就是作为一个指针来用的,sizeof操作符给出的结果是指针大小,而且我们看到str可以被直接赋值,要知道数组是没有这个特性的。

字符数组a出现在函数调用时的实参列表中时,并不代表a本身,而是被转换为指向a首元素的指针,因此真正的实参是一个指针而非数组a。如果忽略f中sizeof那一行,那么f的功能就可被描述为:输出一个字符数组,如果字符数组为空则输出“none”。

指向数组的指针

有本书叫做《当我们谈论爱情时我们在谈论什么》,这里我们套用一下。

当我们谈论指向数组的指针时我们在讨论什么?

是的,如果我们并不是特别刻意地说起指向数组的指针时,其实我们想表达的是指向数组首元素的指针。

因为转换规则的存在,可以很容易地得到一个指向数组首元素的指针,通过这个指针的下标运算和自增减运算就可以非常方便的访问数组中各个元素。然而真正意义上的指向数组的指针,是指向整个数组的,这意味着对这种指针的移动会让操作地址偏移跨过整个数组空间,因此对它的下标和自增减运算通常都没有意义(本文前面已经有过描述)。所以,想要通过指向数组的指针访问数组元素是舍近求远并且最终还得绕回来的做法。

int a[3] = {0, 1, 2};
int (*pa)[3]; // pointer to array [3] of int
pa = &a;
printf("%d\n", **pa); // 子表达式*pa的结果是数组a,随后转为指向a首元素的指针,再次*后得到 0
pa++; // 无意义!指向a以外的空间
printf("%d\n", **pa); // ISO/IEC 9899:201x §6.5.3.2 para4,undefined

难道指向数组的指针就没有一点儿用武之地了么?答案是当然有,但真是一点儿。如果我们遇到了数组的数组(也就是二维甚至多维数组)时,就可以使用指向数组的指针。

int D2Matrix[2][4] = {{11, 12, 13, 14},
{21, 22, 23, 24}}; int (*pa)[4]; /* pointer to array [4] of int */
pa = D2Matrix; /* pa = &D2Matrix[0] */
printf("%d\n", **pa); /* prints 11 */
pa++; /* steps over entire (sub)array */
printf("%d\n", **pa); /* prints 21 */
printf("%d\n", (*pa)[1]); /* prints 22 */

[^ISO/IEC 9899:201x §6.4.5 para7]: It is unspecified whether these arrays are distinct provided their elements have the appropriate values. If the program attempts to modify such an array, the behavior is undefined.

[^ISO/IEC 9899:201x §6.5.2.1 para1]: One of the expressions shall have type ''pointer to complete object type'', the other expression shall have integer type, and the result has type ''type''.

参考:

http://c-faq.com/aryptr/

http://*.com/questions/18518255/why-is-array-name-a-pointer-to-the-first-element-of-the-array

http://*.com/questions/1641957/is-an-array-name-a-pointer-in-c

本文原创,与个人博客 zhiheng.me 同步发布,标题:C语言中数组与指针的关系

水平有限,谬误难免,欢迎指正,互相学习!

谢绝恶意评论,喷子绕行!


  1. ISO/IEC 9899:201x: §6.9.1 para10,ISO/IEC 9899:201x : Footnote 103,ISO/IEC 9899:201x : §6.7.6.3 para 7, ISO/IEC 9899:201x : Footnote 93 ↩︎