C Programming Language

Expertise #04: Array Name is NOT a Pointer

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-07-01


前置知识

在阅读本文档前,建议你先掌握左值和右值的基本概念,详见 Expertise #03: Lvalue and Rvalue

请注意:本文是面向进阶读者的,在阅读本文档前,请确保你已经具备了较为完整的 C 语言基础。


前言

在我介绍指针的文章中已经提到过这一可能令人震惊的事实: 数组名和指针是不同的。 没人会认为数组等同于指针,但是有很多人认为数组名等同于指针,这是错误的。

本文将会全方位地介绍数组名和指针之间的区别。


错误的理解

有很多人认为数组名等同于指针,通常是基于如下几点事实:

其一,当我们把一个指针指向数组首地址后,指针和数组的地址值相等。例如:

int a[5];
int * p = &a[0];
int * q = a;
printf("p(%u), q(%u)\n", p, q);
printf("(p == q) = %d\n", (p == q));
printf("(p == a) = %d\n", (p == a));

上述代码在我本机上的某次运行结果如下:

p(6356708), q(6356708)
(p == q) = 1
(p == a) = 1

其二,指针和数组的语法几乎可以互换,我们似乎总是可以将指针和数组名进行互换而不改变程序运行的结果,例如下面的代码中,第 33 行对 w, xw,\ x 的初始化语句是等价的,第 44 行对 y, zy,\ z 的初始化语句也是等价的:

int a[5] = {0, 1, 2, 3, 4};
int * p = a;
int w = *(p + 3), x = *(a + 3);
int y = a[1], z = p[1];

其三,在函数传参时,数组和指针作为参数时的语法是等价的,例如下面两种函数声明是等价的,而且在函数体中我们既可以使用数组的语法也可以使用指针的语法:

void f1(int a[], int n);
void f2(int * a, int n);

然而事情并不是表面上看起来这么简单。


简单的区别

为了先向读者证明数组名不是指针,我们先举出两个最简单的区别,虽然这并不涉及问题背后的本质原因。

数组名中的长度信息

事实上数组名包括了两个信息:其一是数组的首地址,其二是数组的长度。我们看下面的代码:

int a[5] = {0, 1, 2, 3, 4};
int * p = a;
printf("%d %d\n", sizeof(a), sizeof(p));

你认为输出的结果会是什么?事实上二者并不相同:

20 4

这是二者之间最简单的区别,但是已经足以说明数组名不等同于指针了。

数组名的不可修改性

我们无法像修改指针的指向那样修改数组名的“指向”,例如:

int a[5];
int b[5];
int * p = a;
a = b;     // compile error
p = b;     // ok
a = &a[2]; // compile error
p = &a[2]; // ok

数组的退化

读者肯定会感到困惑,既然数组名和指针并不相同,那么为什么下面的代码中的逻辑表达式的结果为真:

int a[5];
int * p = a;
printf("(p == a) = %d\n", (p == a));

事实上,当你令 p = a 时,首先数组名 aa 被转换为指针类型,然后赋值表达式才被执行。这个过程被称为数组的 退化 (decay)

数组的退化类似于隐式类型转换(但并不同),就像下面的代码中,浮点字面量 0.5 会被隐式转换为整型,然后再赋值给 xx

int x = 0.5;

现在考虑 p == a 的判断式,事实上 a 会退化成指针,然后再与 p 进行比较。因为数组名中的地址信息是数组首地址 &a[0] ,所以比较的结果为真。

现在我们解释前文所述的最后一个现象。在函数传参时,无论你使用下列哪种语法,此时的 aa 都不再是一个数组名,而是一个指针。下面两种函数声明是等价的:

void f1(int a[], int n);
void f2(int * a, int n);

这个事实很容易被验证,例如:

#include <stdio.h>
 
void f1(int a[]) {
    printf("f1: %d\n", sizeof(a));
}
void f2(int * a) {
    printf("f2: %d\n", sizeof(a));
}
int main(void) {
    int a[5];
    printf("main: %d\n", sizeof(a));
    f1(a);
    f2(a);
    return 0;
}

上述代码的运行结果如下:

main: 20
f1: 4
f2: 4

当我们将数组作为参数传递时,事实上发生了数组的退化,数组的长度信息已经丢失,只有地址信息会被保留并成为指针。

因此,虽然在函数体中我们既可以使用数组的语法也可以使用指针的语法,但是实际上我们都是在对一个指针进行操作。这也是当我们将数组作为参数传递时,需要另外传递一个整数参数来记录数组长度,而不能利用 sizeof 来获取数组长度的原因:

void f1(int a[], int n) {            // correct
    // ...
}
void f2(int a[]) {
    int n = sizeof(a) / sizeof(int); // wrong
    // ...
}

至于将二维数组作为参数传递时,事实上是退化成了数组指针,下面两种函数声明是等价的:

void f1(int a[][10], int n);
void f2(int (*a)[10], int n);

至于更高维的数组则会退化为对应的高维的数组指针,读者可以自行推演。

指针的退化是一个隐式的过程,因而导致许多人误以为数组名等同于指针,堪称是问题的“元凶”。

事实上,指针的退化无处不在。根据 C 语言标准的规定,除以下表达式外,在表达式(不包括声明语句)中出现的任何数组名,都会退化为指针类型。退化得到的指针指向数组的首地址,且不是一个左值:

  • sizeof 运算符;

  • 单元 & 取地址运算符;

  • 用字符串常量初始化数组。

下面是 C99 标准中关于此项规定的原文解释:

6.3.2.1 Lvalues, arrays, and function designators 3 Except when it is the operand of the sizeof 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.


当你混用语法时究竟发生了什么

在前文中以及我介绍指针的文章中已经提到过,指针和数组的语法几乎可以互换,例如:

int a[5];
int p = a;

对于 aapp ,使用 p[i]*(p + i)a[i]*(a + i) 的运行结果都是相同的。然而,这并不意味着这些语法是等价的。

使用 a[i] 的形式访问数组元素的代码,总是会被编译器解释为使用 *(a + i) 的形式,二者完全等价。类似地, p[i]*(p + i) 完全等价。但是 a[i]p[i] 是不同的。

当你试图直接访问数组 aa 中的元素 a[i]a[i] 时,可以看作是经历了如下过程:

  1. ii 的值,将 iiaa 的地址值相加,记为 X = a + i

  2. 访问内存地址 X 并获取其内容。

当你试图通过指向 &a[0] 的指针 pp 访问数组元素 a[i]a[i] 时,可以看作是经历了如下过程:

  1. 访问 p 的内存地址并获取其内容,记为 X

  2. ii 的值,将 iiX 相加,记为 Y = X + i

  3. 访问内存地址 Y 并获取其内容。

因为 aapp 都是变量,所以 aapp 的地址值将在编译期(全局变量)或运行期早些时候(局部变量)被确定。注意,是 pp 这个指针变量本身的地址(即 &p )被确定,这也是区别之所在。

对于数组名 aa ,我们可以直接访问内存地址 &a[0] ;而对于指针 pp ,我们要先获取 pp 中的内容 v ,然后再访问地址 v

我们可以由汇编结果来验证这一定论。考虑如下代码:

int main(void) {
    int a[5] = {0, 1, 2, 3, 4};
    int * p = a;
    int w = a[0];
    int x = *(a + 1);
    int y = p[2];
    int z = *(p + 3);
    return 0;
}

其汇编代码可能如下所示(在不同编译环境下可能会有不同的汇编语句,但主体逻辑是一致的),省略了开头和结尾的一些无关的汇编代码:

 # test.c:1: int main(void) {
	call	___main	 #
 # test.c:2: 	int a[5] = {0, 1, 2, 3, 4};
	movl	$0, 8(%esp) 	 #, a
	movl	$1, 12(%esp)	 #, a
	movl	$2, 16(%esp)	 #, a
	movl	$3, 20(%esp)	 #, a
	movl	$4, 24(%esp)	 #, a
 # test.c:3: 	int * p = a;
	leal	8(%esp), %eax	 #, tmp89
	movl	%eax, 44(%esp)	 # tmp89, p
 # test.c:4: 	int w = a[0];
	movl	8(%esp), %eax	 # a, tmp90
	movl	%eax, 40(%esp)	 # tmp90, w
 # test.c:5: 	int x = *(a + 1);
	movl	12(%esp), %eax	 # MEM[(int *)&a + 4B], tmp91
	movl	%eax, 36(%esp)	 # tmp91, x
 # test.c:6: 	int y = p[2];
	movl	44(%esp), %eax	 # p, tmp92
	movl	8(%eax), %eax	 # MEM[(int *)p_7 + 8B], tmp93
	movl	%eax, 32(%esp)	 # tmp93, y
 # test.c:7: 	int z = *(p + 3);
	movl	44(%esp), %eax	 # p, tmp94
	movl	12(%eax), %eax	 # MEM[(int *)p_7 + 12B], tmp95
	movl	%eax, 28(%esp)	 # tmp95, z
 # test.c:8: 	return 0;
	movl	$0, %eax    	 #, _12
 # test.c:9: }
 	leave	

由此可见,虽然二者语法相同,运行结果也相同,但是运行过程却是不同的。通过指针进行的是间接访问,比直接访问要多出一个步骤,运行效率更低。这也彻底推翻了数组名等同于指针的论断。


数组的地址和首地址

这里还另外提一点:数组的地址和数组的首地址是不同的。

有些人会认为 a , &a[0] , &a 在作为指针使用时是等价的,然而事实并非如此。例如:

int a[5];
printf("size:  %d %d %d\n", sizeof(a), sizeof(&a[0]), sizeof(&a));
printf("addr:  %u %u %u\n", a, &a[0], &a);
printf("addr+: %u %u %u\n", a + 1, &a[0] + 1, &a + 1);

上述代码在我本机上的某次运行结果如下:

size:  20 4 4
addr:  6356716 6356716 6356716
addr+: 6356720 6356720 6356736

由此我们可以发现:

  • 根据 sizeof 的结果可知,数组名 a 代表数组本身,包含长度信息,而 &a[0] , &a 都是指针类型;

  • 根据其地址值可知,三者的地址值都是数组的首地址;

  • 根据其地址值 +1+1 可知, a 会退化为指向整型的指针, &a[0] 是指向整型的指针,而 &a 则是指向长为 55 的整型数组的指针。

前两条很好懂,重点是第三条。我们注意到对三者的地址值 +1+1 的结果不同,对 &a +1+1 相当于对其数值加上了 45=204 * 5 = 20 字节,恰好是整整一个数组的长度。这意味着 &a 是一个数组指针,它代表着数组本身的地址。

不过,由于三者的地址值相同,因此下面的代码事实上是合法的,只不过看起来有些愚蠢:

int a[5];
int * p = (int *)&a;
int (*q)[5] = (int (*)[5])a;

总之,虽然单论地址值而言三者是相同的,但是 &a 的指针类型和另外两者不同。数组的地址和数组的首地址是不同的。


总结

数组名和指针的主要区别如下:

数组名指针(已指向数组首地址的)
内容数组的地址信息和长度信息仅数组的地址信息
可修改性不可修改指向可以修改指向
参与执行表达式时有时会先退化成指针再参与直接参与
函数传参时退化成指针再传递直接传递
访问数组元素时直接访问间接访问

对于 int a[5] ,数组的几个易混淆语法如下:

语法含义类型类型说明符
a数组本身数组--
&a[0]数组的首地址整型指针int *
&a数组的地址长为 55 的整型数组指针int (*)[5]