这篇文章主要是为了记录一下自己的想法(意淫,可以直接看下面两个链接,大佬总结的很精炼

What exactly is the array name in c? - Stack Overflow

c - Is an array name a pointer? - Stack Overflow

当我们初学C语言的时候,老师可能会说:数组就是指针,可以拿来当指针用。然后就不了了之了。但凡是一个会上网的人都会在网上得到一个答案:数组是数组,指针是指针。然后就懵了,到底哪个是正确的?今天就来聊聊吧。

在理解数组前,先了解一下如何定义变量。或许你会说这很简单,数据类型 变量名就搞定了,像这样

1
int x;

就成功定义一个整形变量x,这当然是没错的。但是在C/C++的语法中还有更深层次的解释。

在**《C++ Primer》中文版第5版** 第45页给出:一条声明语句由一个**基本数据类型(base type)和紧随其后的一个声明符(declarator)**列表组成。

补充:每个声明符命名了一个变量并指定该变量为基本数据类型有关的某种类型

其中,基本数据类型我们很熟悉,比如:charshortintfloat这些,但是声明符是什么?直接这么看不好理解,举一些具体的例子吧

1
int x, *p1, *p2;

在这条声明语句中我定义了三个变量,一个基本数据类型int和三个声明符x*p1*p2,其中xp1p2是各自变量的变量名。别看走眼了。我再举一个更明显的例子

1
int (*p)(char);

这里我定义了一个函数指针,这个被指向的函数接受一个char参数,最后返回一个int变量。在这个声明中,声明符就是(*p)(char)其中 p 是变量名。这个声明符中表明了该变量的变量名,以及该变量与基本数据类型的某种关系。应该有点感觉了吧

下面再来说说数组是什么。

在**《C++ Primer》中文版第5版** 第101页给出定义:数组是一种复合类型复合类型是什么?跟上面解释变量定义在同一页:复合类型(compound type)是指基于其他类型定义的类型。讲的有些虚。结合上面的例子来看。一个变量声明的声明符可以很简单,也可以很复杂。简单的比如变量x,它的声明符就只有一个变量名,于是这个名为x的变量理所当然的就是int类型的变量。但是像指针变量p1p2,它们的声明符不仅由变量名组成还有关键符号。这些关键符号使得它们成为了基于int类型定义的一种复合类型,被称为指针类型。

像这样定义一个数组:

1
int a[5];

是不是更清晰了呢,这句变量声明中基本数据类型为int,声明符为a[5]其中a为变量名,[5]解释了这个变量内含5个元素,是一个数组。总结来说,a是一个有包含5个int类型的数组变量。

说了这么多,只是想说明数组就是一种基于基本数据类型而定义的一种复合类型,在C/C++中这种复合类型被称为数组。它同指针或者C++中的引用一样,都是复合类型。他们之间的关系是平行的,没有谁是谁一说。

但是为什么说数组可以当作指针来用呢?这句话从结果上来说是正确的。我们来分析一下其中的原理

我们先定义一个变量,并给这个变量复制赋值为0x55AA,这很简单,那么这个过程在底层到底发生了什么呢

1
2
int x;
x = 0x55AA;

我们声明一个变量的本质是什么,其实就是开辟一块空间,然后利用这块空间。如何表示这块空间呢,在这个例子中,就是用这个变量的变量名去表示这块空间。假设这个变量的地址为100,那么这个变量的内存分布如下

1
2
3
4
5
x
+----+------+-----+-----+
| AA | 55 | 00 | 00 |
+----+------+-----+-----+
100 101 102 103

从我们人的角度来看,给x赋值只是简单的给变量赋值,但是从编译器的角度来看我们对x的操作就是对用x表示的那块空间的操作。从汇编语言出发或许会更明显,下面是上面的代码编译为汇编的样子

1
2
3
4
5
6
push    rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0x55AA
mov eax, 0
pop rbp
ret

在这段代码中,rbp-4就是x表示的空间的起始地址。但是这个地址表示的一个字节是装不下0x55AA的,所以编译器采用了双字(DWORD PTR)在以rbp-4为起始地址的连续4个字节的空间装填0x55AA,但是编译器是怎么知道用双字的呢,我们在定义xint不是已经给出了答案吗

所以我们对int x应该有更清晰的认知,在C代码中,或者说从人的角度看是x一个变量的变量名,我们用变量名去表示这个变量,但是从编译器的角度来看,x表示的是一块连续的内存空间,这个空间有多大呢,4个字节

同样的,这样的一个想法可以用来解释数组变量,我们定义一个数组C

1
char a[10];

就是在内存中开辟连续8个字节的空间

1
2
3
4
5
6
7
8
9
10
11
   +---+
a: | | a[0]
+---+
| | a[1]
+---+
| | a[2]
+---+
...
+---+
| | a[9]
+---+

用变量名a表示这连续的空间,这个连续的空间有多大呢,编译器可以通过char[10]计算出来,8个字节,这也能够解释为什么sizeof a会等于 10

然后我们就可以通过变量名a去使用这块空间,但问题是怎么用。

1
2
3
int x
x = 2;
int y = x + 1;

x这样的整形变量,编译器将其作为右值来使用时操作的就是x这块空间里的数据。但是像a这样的数组类型呢,编译器会直接使用这块连续空间里的数据吗,好像不能吧,这也不像回事。但是C语言的设计者就一定要它当上右值去使用,怎么办呢?这就提出来了**降级(decay)**的概念。

所谓降级,就是当数组被用作右值时,会变成一个指针,这个指针指向该数组的第一个元素。

1
2
int a[3];
*a = 5;

在这个例子中,先说结论,就是a[0]被赋值为了5。等号左边*a作为一个表达式,表达式的结果不是左值就是右值,很明显这个表达式的结果是一个左值,也就是a[0]。而数组变量a在这个表达式中作为右值使用就被降级为一个指针,这个指针指向数组a的首个元素,指针类型就是int*

这一点在Visual Studio 2022中写下面这样的代码可以验证

1
2
int a[5];
auto p = a;

然后把光标放在p上,它就会给出p的数据类型,即int *p

有了降级这个概念就可以谈谈[],也就是下标运算符

初学时,我们会先入为主的认为这个操作符只有数组才能用,但是又会发现指针也能用这个操作符,像这样

1
2
3
int a[5];
int* p = a; // a 降级为int*指针,指向a数组的首个元素
p[2] = 3;

这个时候书上会说,p[2]等价于*(p + 2),理是这么个理,但为什么指针还是可以用这个操作符。我们不妨换个思路,这个操作符的操作对象其实就是指针。也就是说,这个操作符并不应该给数组类型用的,但是为什么又可以呢?因为降级呀。由于这个操作符的操作对象是指针,但是数组变量在作为右值使用时可以被降级为指针,一个指向数组首个元素的指针,所以像a[i]这样的操作才能说的通。其实在我看来,像*(p + i)这样的操作太繁琐而且不易于阅读,所以设计者设计出p[i]这样的操作符来简化写法,而且易于阅读。

然后再谈谈取址运算符&

1
2
int x;
int* p = &x;

这个我们很熟悉,取地址嘛。但又没有想过为什么a == &a为什么结果为 true 呢

在上面讲过,int x变量就表示了4个字节的连续空间,取址,取这片空间的地址,但是表示这片连续空间的地址用什么表示呢?很自然的应该就是空间的起始地址了,比如说上面的rbp-4就是变量x的起始地址。同理啊,数组变量a表示的是比如20个字节的地址空间,那么&a就是取这片空间的地址,也就是这片空间的起始地址了,很凑巧这也正好是数组首个元素的地址。前面也说了,因为等式左边的a作为右值被降级为指向首个元素的指针。所以这个表达式结果为 true 也就不奇怪了

1
2
3
4
5
6
7
8
9
10
11
   +---+
a: | | a[0] <-- &a
+---+
| | a[1]
+---+
| | a[2]
+---+
...
+---+
| | a[4]
+---+

要补充的是,&a表达式结果的数据类型是数组指针,是一个指向数组的指针,这与表达式a结果的数据类型是int*不同。由于只比较了数据的值没有比较类型,所以才得到了 true。

1
2
int a[5];
auto p = &a; // int (*p)[5]

是这个样子的。

所以有些想当然的表达式是不被允许的

1
2
3
4
int a[5];
int* p = &a; // error: "int (*)[5]"类型的值不能用于初始化"int *"类型的实体
int (*p)[5] = &a; // 正确
int* p = a; // 正确

最后再来说说数组作为函数参数的情况吧。

有时候我们会发现下面这种函数定义的情况

1
2
3
4
5
6
7
8
9
10
11
void f(int b[1]);
void f(int b[2]);
void f(int b[3]);
//甚至是
void f(int b[]);

int main()
{
int a[5];
f(a); // 以上几个函数都可以用这个来调用
}

为什么可以这样呢,似乎变量的数据类型都给不太一样,而且从语法上来说并不支持int b[] = a这样的赋值。这里又涉及到了降级。经过编译器处理后,它们统统都变成了void f(int* b),这样int* b = a,a降级为int*指针,又变得合理了起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void f1(int* a)
{
a[0] = 0;
}

void f2(int a[])
{
a[0] = 0;
}

int main()
{
int a[5];
f1(a);
f2(a);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
f1(int*):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 0
nop
pop rbp
ret
f2(int*):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 0
nop
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]
mov rdi, rax
call f1(int*)
lea rax, [rbp-32]
mov rdi, rax
call f2(int*)
mov eax, 0
leave
ret

从C代码到汇编的变化是这样的,可以发现它们的参数都变成一样的了。在C++中,甚至连void f(int* a)void f(int a[])的函数重载都做不到,因为它们在编译后都是一样的。

为什么要这样做呢,首先从语法上并不允许数组之间的直接赋值;其次在传参数是需要拷贝的,因为调用一个函数而拷贝一个数组这样的资源开销是很大且不值的,还不如用一个指针作为参数来接收,能正确用上数组的情况下还能避免不必要的开销。