多级指针与多维数组详解

指针与数组是 C/C++ 编程中非常重要的元素,同时也是较难以理解的。其中,多级指针与 “多维” 数组更是让很多人云里雾里,其实,只要掌握一定的方法,理解多级指针和 “多维” 数组完全可以像理解一级指针和一维数组那样简单。

基础知识

首先,先声明一些常识,如果你对这些常识还不理解,请先去弥补一下基础知识:

  • 实际上并不存在多维数组,所谓的多维数组本质上是用一维数组模拟的。

  • 数组名是一个常量(意味着不允许对其进行赋值操作),其代表数组首元素的地址。

  • 数组与指针的关系是因为数组下标操作符[],比如,int a[3][2]相当于((a+3)+2) 。

  • 指针是一种变量,也具有类型,其占用内存空间大小和系统有关,一般32位系统下,sizeof(指针变量)=4。

  • 指针可以进行加减算术运算,加减的基本单位是sizeof(指针所指向的数据类型)。

  • 对数组的数组名进行取地址(&)操作,其类型为整个数组类型。

  • 对数组的数组名进行sizeof运算符操作,其值为整个数组的大小(以字节为单位)。

  • 数组作为函数形参时会退化为指针。

指针

一个指针包含两方面:

  • 地址值;
  • 所指向的数据类型。

解引用操作符(dereference operator)会根据指针当前的地址值,以及所指向的数据类型,访问一块连续的内存空间(大小由指针所指向的数据类型决定),将这块空间的内容转换成相应的数据类型,并返回左值。

有时候,两个指针的值相同,但数据类型不同,解引用取到的值也是不同的,例如,

1
2
3
4
5
char str[] ={0123};       /* 以字符的 ASCII 码初始化 */  

char * pc = &str[0];        /* pc 指向 str[0],即 0 */  

int * pi = (int *) pc;      /* 指针的 “值” 是个地址,32 位。 */

此时,pc 和 pi 同时指向 str[0],但 pc 的值为 0(即,ASCII 码值为 0 的字符);而 pi 的值为 50462976。或许把它写成十六进制会更容易理解:0x03020100(4 个字节分别为 3,2,1,0)。我想你已经明白了,因为小端字节序, 且指针 pi 指向的类型为 int,因此在解引用时,需要访问 4 个字节的连续空间,并将其转换为 int 返回。

一维数组与数组指针

假如有一维数组如下:

char a[3];

该数组一共有 3 个元素,元素的类型为 char,如果想定义一个指针指向该数组,也就是如果想把数组名 a 赋值给一个指针变量,那么该指针变量的类型应该是什么呢?前文说过,一个数组的数组名代表其首元素的地址,也就是相当于 & a[0],而 a[0] 的类型为 char,因此 & a[0] 类型为 char *,因此,可以定义如下的指针变量:

char * p = a;//相当于char * p = &a[0]

以上文字可用如下内存模型图表示。

大家都应该知道,a 和 & a[0] 代表的都是数组首元素的地址,而如果你将 & a 的值打印出来,会发现该值也等于数组首元素的地址。请注意我这里的措辞,也就是说,&a 虽然在数值上也等于数组首元素地址的值,但是其类型并不是数组首元素地址类型,也就是char *p = &a是错误的。

前文第 6 条常识已经说过,对数组名进行取地址操作,其类型为整个数组,因此,&a 的类型是 char (*)[3],所以正确的赋值方式如下:

char (*p)[3] = &a;

注意

  • 很多人对类似于a+1,&a+1,&a[0]+1,sizeof(a),sizeof(&a)等感到迷惑,其实只要搞清楚指针的类型就可以迎刃而解。比如在面对 a+1 和 & a+1 的区别时,由于 a 表示数组首元素地址,其类型为 char *,因此 a+1 相当于数组首地址值 + sizeof(char);而 & a 的类型为char (*)[3],代表整个数组,因此 & a+1 相当于数组首地址值 + sizeof(a)。
  • sizeof(a) 代表整个数组大小,前文第 7 条说明,但是无论数组大小如何,sizeof(&a) 永远等于一个指针变量占用空间的大小,具体与系统平台有关

二维数组与数组指针

假如有如下二维数组:

char a[3][2];

由于实际上并不存在多维数组,因此,可以将 a[3][2] 看成是一个具有 3 个元素的一维数组,只是这三个元素分别又是一个一维数组。实际上,在内存中,该数组的确是按照一维数组的形式存储的,存储顺序为 (低地址在前):a[0][0]、a[0][1]、a[1][0]、a[1][1]、a[2][0]、a[2][1]。(此种方式也不是绝对,也有按列优先存储的模式)

为了方便理解,我画了一张逻辑上的内存图,之所以说是逻辑上的,是因为该图只是便于理解,并不是数组在内存中实际的存储模型(实际模型为前文所述)。

如上图所示,我们可以将数组分成两个维度来看,首先是第一维,将 a[3][2] 看成一个具有三个元素的一维数组,元素分别为:a[0]、a[1]、a[2],其中,a[0]、a[1]、a[2] 又分别是一个具有两个元素的一维数组 (元素类型为 char)。从第二个维度看,此处可以将 a[0]、a[1]、a[2] 看成自己代表” 第二维” 数组的数组名,以 a[0]为例,a[0](数组名)代表的一维数组是一个具有两个 char 类型元素的数组,而 a[0]是这个数组的数组名 (代表数组首元素地址),因此 a[0] 类型为 char *,同理 a[1]和 a[2]类型都是 char *。而 a 是第一维数组的数组名,代表首元素地址,而首元素是一个具有两个 char 类型元素的一维数组,因此 a 就是一个指向具有两个 char 类型元素数组的数组指针,也就是 char(*)[2]。

也就是说,如下的赋值是正确的:

1
2
3
char (*p)[2]  = a;  //a为第一维数组的数组名,类型为char (*)[2]

char * p = a[0]; //a[0]维第二维数组的数组名,类型为char *

同样,对 a 取地址操作代表整个数组的首地址,类型为数组类型 (请允许我暂且这么称呼),也就是 char (*)[3][2],所以如下赋值是正确的:

char (*p)[3][2] = &a;

若做如下定义:

1
2
3
4
5
int a[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11};  

int ** p;  

p = (int**)a;       // 不做强制类型转换会报错

说明:

  • p 是一个二级指针,它首先是一个指针,指向一个 int*;

  • a 是二维数组名,它首先是一个指针,指向一个含有 4 个元素的 int 数组;

由此可见,a 和 p 的类型并不相同,如果想将 a 赋值给 p,需要强制类型转换。

为什么二维数组名传递给二级指针是不安全的?

假如我们将 a 强制转换之后赋值给 p :

p = (int**)a;

既然 p 是二级指针,那么 当 **p 时会出什么问题呢?

  • 首先看一下 p 的值,p 指向 a[0][0],即 p 的值为 a[0][0] 的地址;

  • 再看一下 p 的值,p 所指向的类型是 int,占 4 字节,根据前面所讲的解引用操作符的过程:从 p 指向的地址开始,取连续 4 个字节的内容。 * p得到的正式 a[0][0] 的值,即 0。

  • 再看一下 **p 的值,诶,报错了?当然报错了,因为你访问了地址为 0 的空间,而这个空间你是没有权限访问的。

二维数组和二级指针相关的参数匹配

三维数组与数组指针

假设有三维数组:

char a[3][2][2];

同样,为了便于理解,特意画了如下的逻辑内存图。分析方法和二维数组类似,首先,从第一维角度看过去,a[3][2][2] 是一个具有三个元素 a[0]、a[1]、a[2] 的一维数组,只是这三个元素分别又是一个 “二维” 数组, a 作为第一维数组的数组名,代表数组首元素的地址,也就是一个指向一个二维数组的数组指针,其类型为 char ()[2][2]。从第二维角度看过去,a[0]、a[1]、a[2] 分别是第二维数组的数组名,代表第二维数组的首元素的地址,也就是一个指向一维数组的数组指针,类型为 char()[2];同理,从第三维角度看过去,a[0][0]、a[0][1]、a[1][0]、a[1][1]、a[2][0]、a[2][1] 又分别是第三维数组的数组名,代表第三维数组的首元素的地址,也就是一个指向 char 类型的指针,类型为 char *。

由上可知,以下的赋值是正确的:

1
2
3
4
char (*p)[3][2][2] = &a;//对数组名取地址类型为整个数组
char (*p)[2][2] = a;
char (*p) [2] = a[0];//或者a[1]、a[2]
char *p = a[0][0];//或者a[0][1]、a[1][0]...

多级指针

所谓的多级指针,就是一个指向指针的指针,比如:

1
2
3
4
5
char *p = "my name is chenyang.";

char **pp = &p;//二级指针

char ***ppp = &pp;//三级指针

假设以上语句都位于函数体内,则可以使用下面的简化图来表达多级指针之间的指向关系。

多级指针通常用来作为函数的形参,比如常见的 main 函数声明如下:

int main(int argc,char ** argv)

因为当数组用作函数的形参的时候,会退化为指针来处理,所以上面的形式和下面是一样的。

int main(int argc,char* argv[])

argv 用于接收用户输入的命令参数,这些参数会以字符串数组的形式传入,类似于:

1
2
3
4
5
//模拟用户传入的参数
char * parm[] = {"parm1","parm2","parm3","parm4"};

//模拟调用main函数,实际中main函数是由入口函数调用的(glibc中的入口函数默认为_start)
main(sizeof(parm)/sizeof(char *),parm);

多级指针的另一种常见用法是,假设用户想调用一个函数分配一段内存,那么分配的内存地址可以有两种方式拿到:第一种是通过函数的返回值,该种方式的函数声明如下:

1
2
3
4
5
void * get_memery(int size)
{
void *p = malloc(size);
return p;
}

第二种获取地址的方法是使用二级指针,代码如下:

1
2
3
4
5
6
7
8
9
10
int get_memery(int** buf,int size)
{
*buf = (int *)malloc(size);
if(*buf == NULL)
return -1;
else
return 0;
}
int *p = NULL;
get_memery(&p,10);