发现自己之前对数组指针和指针数组的细节理解还有些不到位,故写一个笔记
前言
之前一直认为C++的数组int a[]
和指针int* a
没什么区别,只不过是声明时候开辟内存的方式有区别,不过似乎在一些细节方面还是有不同
引入
我们先来看这样一个例子:
#include <bits/stdc++.h>
void foo(int test0[], int test1[][3], int* test2[2]);
int main() {
int test0[] = {1, 2, 3};
int test1[2][3] = {{1, 2, 3}, {9, 8, 7}};
int _test1[][3] = {{1, 2, 3}, {9, 8, 7}};
//* 数组第一维长度可以省略,而后面都不能省略
int a1[3] = {1, 2, 3};
int a2[3] = {9, 8, 7};
int* test2[2] = {a1, a2};
auto t0_a = test0;
auto t0_b = &test0;
auto t1_a = test1;
auto t1_b = &test1;
auto t2_a = test2;
auto t2_b = &test2;
foo(test0, test1, test2);
}
void foo(int test0[], int test1[][3], int* test2[2]) {
auto t0_a = test0;
auto t0_b = &test0;
auto t1_a = test1;
auto t1_b = &test1;
auto t2_a = test2;
auto t2_b = &test2;
}
这个例子里面,我声明了test0
、test1
和test2
三个数组,其中test0
是最原始的声明方式, test1
则是二维数组,test2
也是二维数组,但是通过第一维的指针指向第二维的数组实现的。
main
函数底下t0_a
-t2_b
的类型应该是什么呢,这里我借助clangd的自动类型推导:这一堆int *
int (*)[3]
int *(*)[2]
int **
看起来是不是很头大,同时下面foo
函数内同一个t0_b
却和main
函数内的类型不一样,我们接下来慢慢分析。
指针数组和数组指针
数组
我们通过int a[3] = {1, 2, 3}
声明了a
这个数组。它对应的内存在栈上连续分配,也就是一整块连续的,由系统托管的内存。a
作为数组名,代表整个数组本身,cppreference上说数组名是一个左值,而并不是代表一个常量指针,只是数组名在作为表达式使用时候,会发生隐式转换,转换为数组首元素的地址(即&a[0]
)。
参考 https://en.cppreference.com/w/cpp/language/value_category
数组指针
对数组名本身取地址(即&a
),我们会得到一个指向一整个数组的指针(类型为int (*)[]
),不同于前面得到的是指向单个元素的指针(类型为int*
)。
C++中的
int (*)[]
可以类比C#里面的数组指针来理解:在C#中,int[]* t1
里的t1
是指向一整个test0
数组的指针,只不过在C++中声明的语法不一样,变成了int (*t1)[]
。
如果打印值,我们会发现a
和&a
是一样的:
int main() {
int a[3] = {1, 2, 3};
std::cout << a << std::endl;
std::cout << &a << std::endl;
}
这里打印的结果都是0xc47f1ffe74
但是如果我改一下代码:
int main() {
int a[3] = {1, 2, 3};
std::cout << a << std::endl;
std::cout << a + 1 << std::endl;
std::cout << &a + 1 << std::endl;
}
a + 1
和&a + 1
就不一样了
在这里
a
是前面说的,经过隐式转换变成了数组首元素的地址0x54b21ff784
a + 1
则是在首元素地址的基础上加1,步长为sizeof(int)
。int长度4字节,所以对应十六进制的内存地址+4,为0x54b21ff788
&a + 1
则是在整个数组地址的基础上加1,步长为sizeof(int[3])
。对应十六进制地址在a
基础上+12,为0x54b21ff790
指针数组
指针数组本身没什么好说的,不过就是把数组存储的元素换成了指针,譬如int* a[]
。
但是数组隐式转换后也是一个指针,因此存了一个维度的指针的数组,也能作为二维数组使用,只需要把存储的指针指向第二维的数组就行。例如最开始给的例子里面的test2
:
int a1[3] = {1, 2, 3};
int a2[3] = {9, 8, 7};
int* test2[2] = {a1, a2};
这种方式和int[2][3]
的区别便是第二个维度的相邻数组,对应的内存不是连续的,a1
和a2
的长度也可以不一样。
在堆上分配数组
前面说到的方法,即int a[N]
,是在栈上分配的数组,离开作用域内存就被回收了,这里的N必须是带有常量属性的值。如果我们想在堆上手动声明一个数组,则应该采用int* a = new int[N]
的方式,N可以是变量,注意这里的a
没有前面数组的特殊性,是一个纯粹的指针。
作为实参时
现在让我们回顾一开始的例子,在main
函数中,test0
、test1
和test2
都是实参
int main() {
int test0[] = {1, 2, 3};
int test1[2][3] = {{1, 2, 3}, {9, 8, 7}};
int _test1[][3] = {{1, 2, 3}, {9, 8, 7}};
//* 数组第一维长度可以省略,而后面都不能省略
int a1[3] = {1, 2, 3};
int a2[3] = {9, 8, 7};
int* test2[2] = {a1, a2};
auto t0_a = test0;
auto t0_b = &test0;
auto t1_a = test1;
auto t1_b = &test1;
auto t2_a = test2;
auto t2_b = &test2;
foo(test0, test1, test2);
}
t0_a
因为test0
作为表达式时候发生隐式转换,得到数组首元素的指针,类型是int*
t0_b
因为对test0
取地址,结果应该是指向int[3]
类型的指针,最后得到的类型是int (*)[3]
,代表一个指向一个长度为3的数组的指针t1_a
因为test1
发生隐式转换,得到数组第一维的首元素指针(这个首元素是int[3]
类型),所以最后类型还是int (*)[3]
,同样代表一个指向一个长度为3的数组的指针t1_b
对整个test1
取地址,得到的类型是int (*)[2][3]
,这是一个指向一个二维数组的指针t2_a
的类型对应test2
首元素类型(int*
)的指针,所以是int**
t2_b
同理前面的,应该是一个指向类型int* [2]
的指针,这个指针的类型是int* (*)[2]
随后我又把这三个数组传入了foo
函数
作为形参时
void foo(int test0[], int test1[][3], int* test2[2]) {
auto t0_a = test0;
auto t0_b = &test0;
auto t1_a = test1;
auto t1_b = &test1;
auto t2_a = test2;
auto t2_b = &test2;
}
在foo
函数中,test0
、test1
和test2
都是形参 回顾前面clangd的类型推导结果,我们能发现与前面数组是实参时候的差异:不难发现这三个差异都发生在取地址时,原因是数组在传入函数的过程中,其实已经发生了一次隐式转换,变成了该数组第一个元素的指针,这是C++的一个特性,目的是为了简化数组的传递和处理,因为在栈上传递整个数组的开销很大,而传递指针则更加高效。
也就是说foo
函数参数中的test0
,虽然声明为int []
类型,但实际上类型已经是int*
,把int test0[]
改写成int* test0
没有任何实质区别。所以我们的取地址操作在这时候得到的是指针本身的地址,即一个二级指针。
总结
- 我们声明了一个数组,那么他的数组名就是一个左值,代表整个数组本身,在表达式中会发生隐式转换,变成指向第一个元素的指针
- 直接声明数组,如
int[2][3] a = {...}
,得到的数组所对应的内存是连续的,且在栈上分配,离开作用域后内存会被回收 - 对数组取地址,即
&arr
的结果是指向整个数组的指针,前提是arr
是实参 - 数组在作为函数参数时候已经被隐式转换为了该数组第一个元素的指针,对它再取地址得到的是一个二级指针