【C 高阶】- 彻底理解数组与指针

C 高阶系列导航


0. 前言

本文将深入浅出地讲述数组与指针之间的共性与关联,适合有一定 C/C++ 功底的同学进阶学习。文中的程序均在 64 位环境下运行,且程序运行的结果会以注释的方式呈现在代码中以便阅读。

全文学习约需 15 分钟。


1. 数组和指针的本质

数组类型和指针类型都是 C 的特殊数据类型,这里的“特殊”是相对于整型、浮点型等基本数据类型来说的。

一般地,数组类型变量统称为数组,指针类型变量统称为指针。

数组和指针均不能单独被定义,即不存在纯数组类型变量或纯指针类型变量。两者定义时除显式为数组或指针外,还必须显式一种基本数据类型。例如以下定义的数组和指针都属于整型类型:

int nums[] = {0, 1, 2};
int* p = &nums;

数组和指针两者的本质如下:

  • 数组为一组相同数据类型的元素的集合,数组大小等于元素大小乘以元素个数,可以使用运算符“[]”从下标 0 开始访问数组中的元素。
  • 指针为存储某一地址的变量,指针大小为一个位宽内存大小(例如 32 位机上为 4 字节)。通过指针能够访问该内存地址,并能够按指针所属的数据类型对以该地址起始的内存进行解析,俗称指针指向内存。

请牢记数组和指针的本质定义,这是彻底理解数组与指针两者之间的共性与关联的关键。


2. 数组的基础语法

先回顾一下数组的基础语法。

现有以下一维数组:

int nums[2] = {4, 5};

变量名称为 nums,所属类型为整型数组即 int[2],这里的“2”指明了该数组中有两个整型元素;{4, 5} 为元素列表,表示使用列表中的元素初始化数组;使用“[]”从下标 0 开始访问数组所包含的元素,nums[0] 所指代的元素为“4”,nums[1] 所指代的元素为“5”。

定义数组时存在如下规则:

  1. 当数组的元素个数 N 大于元素列表中元素的个数 M 时,将会在元素列表的尾部补充 (N - M) 个“0”。例如 int nums[3] = {1}; 等效于 int nums[3] = {1, 0, 0};。因此,在定义数组时常见情况是元素列表为 {0},表示数组的所有元素都初始化为“0”。
  2. 当数组的元素个数 N 小于元素列表中元素的个数 M 时,编译器(GCC)会发出警告,并只使用元素列表的前 N 个元素初始化数组。例如 int nums[2] = {1, 2, 3}; 等效于 int nums[2] = {1, 2};
  3. 当省略数组的元素个数时,编译器会根据元素列表的元素个数自推导出数组的元素个数。例如 int nums[] = {1, 2};,编译器将自推导为 int nums[2] = {1, 2};
  4. 元素列表可以使用“""”括起来,表示该数组为字符串,即数组的每个元素都为字符型,并会在字符串结尾隐式地添加字符 '\0' 作为字符串的结尾。例如 char str[] = "123"; 等效于 char str[] = {'1', '2', '3', '\0'};

二维数组是在一维数组的基础上进行再集合,简单来说就是二维数组的每个元素都是一维数组,且这些子数组具备相同的数据类型、元素个数。

以整型二维数组为例,其采用矩阵方式呈现如下:

int nums[3][3] = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

变量名称为 nums,所属类型为整型二维数组即 int[3][3],第一个“3”指明该二维数组有 3 个数组元素即有 3 个一维数组,第二个“3”指明每个子数组的元素个数为 3 个。

使用“[][]”从下标 0 开始访问数组所包含的元素,第一个“[]”将决定访问第几个子数组,第二个“[]”将决定访问子数组的第几个元素。例如,nums[0][1] 所指代的元素为第一个子数组的第二个元素“2”,nums[1][0] 所指代的元素为第二个子数组的第一个元素“4”,依次类推。

在定义二维数组时,仍遵守以上定义数组的规则,但此时编译器不会根据子数组的元素列表的元素个数自推导出子数组的元素个数。简单来说,第一个“[]”能够不显式指明数组元素即子数组的个数,但第二个“[]”必须显式指明子数组中元素的个数。

还需要说明的是,定义数组时,元素列表中表示子数组的 {} 其实是非必须的,花括号只是提高程序阅读性的技巧。如以下定义二维数组仍是合法的:

int nums[3][3] = {
    1, 2, 3,
    4, 5, 6,
    7, 8, 9
};

对于三维数组、四维数组等多维数组,其原理与二维数组相同,均为对上一维数数组的再集合。在工程中一般至多使用二维数组,因为数组每增加一维其实际元素个数将增长数组元素个数的倍数,其所消耗的内存空间是非常巨大的。


3. 指针和数组的运算

在讲述数组与指针之间的共性前,需先了解关于数组和指针的运算。

指针存储着内存中的某个地址,当对任意数据类型的指针进行加减运算时,实际上都是对该地址值进行运算,俗称指针偏移。

那么,指针偏移的基本单位是多少呢?实践出真理:

int nums[] = {1, 2, 3};
int* p = &nums[0];
printf("p = %p\n", p);                  // print: p = 0x7ffc3836cee0
printf("p + 1 = %p\n", p + 1);          // print: p + 1 = 0x7ffc3836cee4
printf("*(p + 1) = %d\n", *(p + 1));    // print: *(p + 1) = 2

可见,pp + 1 的差值为 4 字节,刚好一指针所属的整型类型的大小,p + 1 为返回 p 指向地址偏移 4 字节后的地址。

实际上,指针偏移的基本单位为指针所属的数据类型大小,指针的加减运算为指针所存储的地址加减所运算的 n 个基本单位的字节。简单来说,如上的 p + 1 等价于 (size_t)p + 1 * sizeof(int)。这里强制转化为 size_t 表示为内存地址类型。

在程序中,当数组名称单独出现时,其值是数组的起始地址,该地址也是第一个元素的起始地址。例如:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);                // print: nums = 0x7ffeedc61450
printf("&nums = %p\n", &nums);              // print: &nums = 0x7ffeedc61450
printf("&nums[0] = %p\n", &nums[0]);        // print: &nums[0] = 0x7ffeedc61450

那么,对数组直接进行加减运算,会得到什么呢?

实践是检验真理的唯一标准:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);			// print: nums = 0x7ffe9a7bb480
printf("nums + 1 = %p\n", nums + 1);	// print: nums + 1 =0x7ffe9a7bb484
printf("&nums[1] = %p\n", &nums[1]);	// print: &nums[1] = 0x7ffe9a7bb484

可见,数组的加减运算与指针是类似的。由于数组 nums 所属的数据类型为整型,因此,nums + 1 等价于 (size_t)nums + 1 * sizeof(int),得到的是数组 nums 的第二个元素的地址。

那么对数组地址和指针地址进行加减运算与对数组和指针直接进行加减运算是否一致呢?答案是否定的。

试验如下:

int nums[] = {1, 2, 3};
printf("nums = %p\n", nums);			// print: nums = 0x7ffd7ecdaf60
printf("nums + 1 = %p\n", nums + 1);	// print: nums + 1 = 0x7ffd7ecdaf64
printf("&nums = %p\n", &nums);			// print: &nums = 0x7ffd7ecdaf60
printf("&nums + 1 = %p\n", &nums + 1);	// print: &nums + 1 = 0x7ffd7ecdaf6c

int* p = nums;
printf("p = %p\n", p);			        // print: p = 0x7ffd7ecdaf60
printf("p + 1 = %p\n", p + 1);	        // print: p + 1 = 0x7ffd7ecdaf64    
printf("&p = %p\n", &p);			    // print: &p = 0x7ffd7ecdaf58
printf("&p + 1 = %p\n", &p + 1);	    // print: &p + 1 = 0x7ffd7ecdaf60  

很明显,&nums&nums + 1 的差值为 12 字节,恰好为数组的大小;而 &p&p + 1 的差值为 8 字节,恰好为指针的大小。这当然不是偶然!

实际上,数组地址 &nums 对应的数据类型为整型数组类型“int[3]”而非整型“int”,指针地址 &p 对应的数据类型为整型指针“int*”而非整型“int”。因此,&nums + 1 将等价于 (size_t)&nums + 1 * sizeof(int[3]),得到的是数组 nums 所占据内存的结尾地址的一下个地址。&p + 1 将等价于 (size_t)&p + 1 * sizeof(int*),得到的是指针 p 所占据内存的结尾地址的一下个地址。


4. 数组与指针的共性

通常来说,使用运算符“*”可以访问指针所指地址上的内容。例如:

int val = 5;
int* p = &val;
printf("*p = %d\n", *p);             // print: *p = 5

同时,指针支持使用运算符“[]”从下标 0 开始访问指针所指地址的偏移地址。简单来说,p[0] 等价于 *(p + 0)p[1] 等价于 *(p + 1)

以下实验得以验证:

int nums[3] = {9, 8, 7};
int* p = nums;
printf("p[0] = %d\n", p[0]);			// print: p[0] = 9
printf("*p = %d\n", *p);				// print: *p = 9
printf("p[1] = %d\n", p[1]);			// print: p[1] = 8
printf("*(p + 1) = %d\n", *(p + 1));	// print: *(p + 1) = 8
printf("p[2] = %d\n", p[2]);			// print: p[2] = 7
printf("*(p + 2) = %d\n", *(p + 2));	// print: *(p + 2) = 7

那么访问数组中的元素时,使用指针方式与使用数组方式完全一致吗?使用反汇编查看一下:

int nums[3] = {0};
int* p = nums;
{
	nums[1] = 5;
2e:	c7 45 e4 05 00 00 00 	movl   $0x5,-0x1c(%rbp)
	p[1] = 5;
35:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
39:	48 83 c0 04          	add    $0x4,%rax
3d:	c7 00 05 00 00 00    	movl   $0x5,(%rax)
	*(p + 1) = 5;
43:	48 8b 45 d8          	mov    -0x28(%rbp),%rax
47:	48 83 c0 04          	add    $0x4,%rax
4b:	c7 00 05 00 00 00    	movl   $0x5,(%rax)
51:	b8 00 00 00 00       	mov    $0x0,%eax
}

依上可知,p[1] 虽然与 nums[1] 形似,但存在本质上的区别。p[1] 完全等价于 *(p + 1),先定位到 p 所指向的内存,再定位到其偏移地址,最后完成赋值操作。而 nums[1] 能够直接定位到目标元素完成赋值操作,在效率上明显高于指针的方式。

可见,数组与指针的共性如下:

  • 两者均支持使用运算符“[]”访问某目标内容。数组访问的是其自身的某一元素,指针访问的是为基于指针所指地址的偏移地址上的内容。
  • 两者独立出现时,均返回的是某一内存地址。数组为数组本身的起始地址,指针为所指向的内存地址。

5. 数组和指针不应混为一谈

正是数组与指针存在以上共性,使许多人把指针和数组混淆使用,在常规情况下混淆使用数组和指针一般也不会产生什么问题,只会降低一些运行效率。但在某些特定的场景下,数组和指针必须严格区分。

以下为一个经典的案例:

/* other.c */
int nums[3] = {9, 8, 7};
/* main.c */
#include <stdio.h>

extern int* nums;

int main(void)
{
    printf("nums[0] = %d\n", nums[0]);
}

以上代码的原意是在文件 mian.c 中使用文件 other.c 所定义的全局数组。程序在编译期间并没有发生任何的警告或报错,能够顺利得到可执行文件,但执行时会发现直接产生了段错误!这是什么原因导致的呢?

熟悉链接过程的朋友应该知道,在 C 中不同文件的相同变量是依赖于其编译得到的符号来进行链接,现使用 nm 命令来查看 mian.c 与 other.c 编译后的符号表:

star@ubuntu:/mnt/hgfs/share/pj_c$ nm object/main.o 
0000000000000000 T main
                 U nums
                 U printf
star@ubuntu:/mnt/hgfs/share/pj_c$ nm object/other.o 
0000000000000000 D nums
star@ubuntu:/mnt/hgfs/share/pj_c$

可见,两源文件中变量 nums 编译后得到的符号均为 nums,于是链接器能够把两者关联起来。但问题在于,在 main.c 中 nums 为整型指针,在 other.c 中 nums 为整型数组,链接器并不会区分变量的数据类型,只按照相同的符号进行链接。这样导致的后果是,在程序中,main.c 中指针 nums 的值与 other.c 中数组 nums 中的值相等,即把 other.c 中数组 nums 本身对应内存上的值作为了 main.c 中指针 nums 所指向的内存的地址!

试验如下:

/* other.c */
int nums[3] = {9, 8, 7};
/* main.c */
#include <stdio.h>

extern int* nums;

int main(void)
{
    printf("nums = %p\n", nums);    // print: nums = 0x000000000009
}

此时,假如在 main.c 中使用“*”或者“[]”访问指针 nums 所指内存,等同于访问地址为 0x000000000009 的内存,那么必然会发生非法的内存访问,产生段错误。

因此,在 mian.c 中的 nums 必须声明为数组类型:extern int nums[];


6. 数组指针和指针数组

当数组与指针关联为复合数据类型时,将产生数组指针和指针数组这两种数据类型。

数组指针和指针数组是中文中比较拗口的说法,很容易把这两个说法混淆。其实,只要对名称进行定语、状语划分,区别这两个名称是非常容易的。

  • 数组指针:数组类型的指针,数组为状语,指针为定语,即数组指针仍然是指针,只是指针指向的内存需要按数组类型进行解析。
  • 指针数组:指针类型的数组,指针为状语,数组为定语,即指针数组仍然为数组,只是该数组所属类型为指针类型,这说明该数组的每个元素都是指针类型。

如果仍然觉得不好理解与记忆,可以类比“整型数组”和“整型指针”这两名称,现只是把“整型数组”中的“整型”替换为“指针”以及把“整型指针”中的“指针”替换为“数组”而已。

指针数组的定义方法为:

int* nums[] = { ... };

数组指针的定义方法为:

int (*nums)[n] = { ... };

其中,“n” 为指针所属的数组类型的元素个数,不能省略。

可见,哪一运算符与变量先结合,将决定变量本质为数组还是指针。由于运算符“[]”的优先级高于运算符“*”,因此不使用 () 辅助定义数组与指针结合的复合数据类型时,实际定义的是指针数组。

以下实验将作进一步的验证:

int (*nums_A)[10];
printf("sizeof(nums_A) = %lu\n", sizeof(nums_A));       // print: sizeof(nums_A) = 8 
int* nums_B[10];
printf("sizeof(nums_B) = %lu\n", sizeof(nums_B));       // print: sizeof(nums_B) = 80 (8 字节 * 10)

7. 数组指针和指针数组的应用

7.1 数组指针的应用

以二维数组为例介绍数组指针的用法:

int nums[][3] = {
	{1, 2, 3},
	{4, 5, 6},
	{7, 8, 9}
};
int (*p)[3] = nums;

printf("p[0][1] = %d\n", p[0][1]);      // print: p[0][1] = 2
printf("p[1][2] = %d\n", p[1][2]);      // print: p[1][1] = 6

数组指针 p 中存放着二维数组 nums 的起始地址,即 p 指向了 nums

如上所示,p[1] 等价于 p + 1(size_t)p + 1 * sizeof(int[3]),此时得到的是二维数组中第二个子数组 nums[1] 的起始地址。现使用整型数组 q 替代 p[1]int[3] q = p[1];(伪代码),则 p[1][] 可视为 q[],此时可以从下标 0 开始访问第二个子数组 nums[1] 中的元素,因此 q[2]p[1][2] 指代的是 nums 第二个子数组的第二个元素即“6”

在工程中,数组指针一般用于接收传参为多维数组的函数的形参类型。

以二维数组作为传参为例:

void Fun_A(int (*p)[3])
{
	printf("p[0][2] = %d\n", p[0][2]);		    // print: p[0][2] = 3
}

void Fun_B(int* p)
{
	// printf("p[0][2] = %d\n", p[0][2]);		// error: subscripted value is neither array nor pointer nor vector
	printf("p[1] = %d\n", p[1]);				// print: p[1] = 2
	printf("p[4] = %d\n", p[4]);				// print: p[4] = 5
}

int main()
{
	int nums[][3] = {
		{1, 2, 3},
		{4, 5, 6},
		{7, 8, 9}
	};

	Fun_A(nums);
	Fun_B(nums);
}

可见,如果形参类型为普通指针时,在函数中只能以“一维数组”的方式对待形参。实际上,函数传参的是二维数组的地址值,传参过程俗称数组“退化”为指针。

当形参的类型为普通的指针时,形参 p 不支持运算符“[]”二次访问,二维数组将被“退化”为“一维数组”。简单来说,在 Fun_B() 中,整型指针 p 指向了 nums 对应的内存首地址,并按整型进行解析。例如 p[4] 等价于 *((size_t)p + 4 * sizeof(int)),对应了 nums[1][1]

当形参的类型为数组指针时,形参 p 能够支持运算符“[]”二次访问,二维数组在函数 Fun_A 中仍形为“二维数组”。在 Fun_A() 中,整型数组指针 p 指向了 nums 对应的内存首地址,并按整型数组 int[3] 进行解析。例如 p[1][2] 等价于 ((int[3])((size_t)p + 1 * sizeof(int[3])))[2] (非标准写法),对应了 nums[1][2]

7.2 指针数组的应用

以二维数组为例介绍指针数组的用法:

int nums[][3] = {
	{1, 2, 3},
	{4, 5, 6},
	{7, 8, 9}
};
int* p[] = {nums[0], nums[1], nums[2]};

printf("p[0][1] = %d\n", p[0][1]);      // print: p[0][1] = 2
printf("p[1][2] = %d\n", p[1][2]);      // print: p[1][2] = 6

int* q = p[1];
printf("q[2] = %d\n", q[2]);            // print: q[2] = 6

指针数组 p 的每个元素的数据类型都是整型指针即 int*,在定义数组时显示地使用了二维数组 nums 中的各个子数对指针数组中的指针元素进行了初始化。

如上所示,p[1] 将访问数组的第二个指针元素,该指针存放的是第二个子数组 nums[1] 的起始地址。现使用整型指针 q 替代指针元素 p[1]int* q = p[1];,则 p[1][2] 可转换为 q[2]q[2] 又等价于 *((size_t)q + 2 * siezof(int)),因此 q[2]p[1][2] 指代的是 nums 第二个子数组的第二个元素即“6”。

在工程中,指针数组一般用于统一管理一组同类的资源,使用指针数组中的指针元素可以访问其所关联的资源。

以下示例程序为使用指针数组 PicBuf 来管理所申请的不同图片类型的缓存的句柄:

enum Sizes
{
    Size_1KB = 1 * 1024,
    Size_10KB = 10 * 1024,
    Size_100KB = 100 * 1024,
    Size_1M = 1 * 1024 * 1024,
};

enum PicType
{
    Face,
    Body,
    Car,
    PicTypeNum,
};

void InitPicBuffer(char* PicBuf[PicTypeNum])
{
    PicBuf[Face] = (char*)malloc(Size_1M);
    memset(PicBuf[Face], 0, Size_1M);

    PicBuf[Body] = (char*)malloc(Size_100KB);
    memset(PicBuf[Body], 0, Size_100KB);

    PicBuf[Car] = (char*)malloc(Size_100KB);
    memset(PicBuf[Car], 0, Size_100KB);
}

void FreePicBuffer(char* PicBuf[PicTypeNum])
{
    for (int i = 0; i < PicTypeNum; i++)
    {
        free(PicBuf[i]);
    }
}

int main(void)
{
    char* PicBuf[PicTypeNum] = {NULL};
    InitBuffer(PicBuf);
    // ...
    FreeBuffer(PicBuf);
}

main() 中, InitPicBuffer()FreePicBuffer() 之间可以使用 PicBuf[] 根据下标来访问所申请的不同类型图片的内存资源。使用枚举作为下标重命名能够直观地展示了资源的名称。关于枚举的使用方法可见另一篇博文:【C 高阶】- 枚举,这里就不再赘述。

其实可以不使用指针数组,只使用普通类型的指针的方法来完成系统资源申请后的管理,但这样设计后指针将得不到统一管理。而使用指针数组有利于统一管理,例如 InitPicBuffer() 完成了统一的资源申请并初始化,FreePicBuffer() 完成了统一的资源释放。


8. 总结

  • 数组为一组相同数据类型的元素的集合,指针为存储某一地址的变量。

  • 关于数组和指针的运算,以整型数组 int nums[n] 和整型指针 int* p 为例。numsp 对应的数据类型均为整型 intnums + 1 等价于 (size_t)nums + 1 * sizeof(int)p + 1 等价于 (size_t)p + 1 * sizeof(int)&nums 对应的数据类型为整型数组 int[n]&p 对应的数据类型为整型指针 int*&nums + 1 等价于 (size_t)&nums + 1 * sizeof(int[n])&p + 1 等价于 (size_t)&p + 1 * sizeof(int*)

  • 虽然数组和指针使用运算符“[]”时形态上一致,数组和指针都返回某一内存地址,但两者存在本质上的区别。数组与指针不应混为一谈。

  • 数组指针为数组类型的指针,指针指向的内存按数组类型解析。指针数组为指针类型的数组,数组的每个元素都为指针。

对于数组和指针,可以通过笔试题《【C 高阶】- 数组和指针笔试题精选》加深学习与理解。

发布了60 篇原创文章 · 获赞 36 · 访问量 5915

猜你喜欢

转载自blog.csdn.net/qq_35692077/article/details/103376820