菜鸟笔记
提升您的技术认知

深析C语言的灵魂 -- 指针


一、指针基础知识

在开始我们指针的进阶内容之前,我们先来回顾一下与指针相关的基础知识:

1、什么是指针

  1. 指针是内存中一个最小单元的编号,也就是地址
  2. 我们一般口语中说的指针,通常指的是指针变量,也就是用来存放内存地址的变量。

2、指针变量的大小

  1. 在32位的机器上,地址由32个0/1组成二进制序列组成,所以地址需要用4个字节的空间来存储,则一个指针变量的大小就应该是4个字节。
  2. 在64位机器上,地址由64个0/1组成二进制序列组成,所以地址需要用8个字节的空间来存储,则一个指针变量的大小就应该是8个字节。
  3. 总结:在 X86 (32位平台) 环境下,一个指针变量的大小 (一个地址的大小) 是四个字节;在 X64 (64位平台) 环境下,一个指针变量的大小 (一个地址的大小) 是八个字节

3、指针类型的意义

  1. 指针的类型决定了指针进行解引用操作时的访问权限 (解引用时能向后访问几个字节的空间) ;
  2. 指针的类型决定了指针 ± 整数时候的步长 (+1跳过几个字节) ;

4、指针的运算

  1. 指针 ± 整数:指针移动整数个元素的大小;
  2. 指针 - 指针:得到指针之间元素的个数;
  3. 指针的关系运算:比较两个地址的大小;

5、野指针的成因及规避方法

野指针的成因

  1. 指针未初始化;
  2. 指针越界访问;
  3. 指针指向的空间被释放;

野指针的规避方法

  1. 使用已初始化的指针;
  2. 小心指针越界;
  3. 当指针指向的空间被释放的同时把该指针置为 NULL;
  4. 避免返回局部变量的地址 (离开该变量的生命周期该变量就会被销毁);
  5. 指针使用之前检查其有效性;

二、指针进阶知识

1、字符指针

什么是字符指针

顾名思义,字符指针就是用来存放字符地址的指针。

字符指针的两种使用方法

第一种:

int main()
{
  
    char ch = 'w';
    char *pc = &ch;
    *pc = 'w';
    return 0;
}

第二种:

int main()
{
  
    const char* pstr = "hello world";  //这里是把一个字符串放到pstr指针变量里了吗?
    printf("%s\n", pstr);
    return 0;
}

第一种使用方法很简单,这里我不再赘述;难点是第二种使用方法:在第二个例子中,我们并不是把 “hello world” 这整个字符串放到 pstr 指针变量中,而且 pstr 是指针变量,只能存放四个字节的内容,也存不下这整个字符串;

其实我们是把 “hello world” 这个字符串中首字符的地址,即 ‘h’ 的地址放入 pstr 中,然后我们可以以 %s 的形式把整个字符串打印出来;

同时,“hello world” 这样的字符串被我们称为常量字符串,它是存储在字符常量区的,我们可以通过 pstr 来访问它,但是不能修改它的内容 (因为它是常量),所以这里我们用 const 关键字来修饰 char*,防止有人误该 “hello world” 中的内容。

笔试题练习

下面程序的输出结果是什么?

int main()
{
  
	char str1[] = "hello world";
	char str2[] = "hello world";
	const char* str3 = "hello world";
	const char* str4 = "hello world";
	if (str1 == str2)
		printf("str1 and str2 are same\n");
	else
		printf("str1 and str2 are not same\n");
	if (str3 == str4)
		printf("str3 and str4 are same\n");
	else
		printf("str3 and str4 are not same\n");
	return 0;
}

解析

str1 和 str2 是数组,数组空间在栈区上开辟,所以操作系统会给 str1 和 str2 分别分配一块空间,并把空间里的内容初始化为 “hello world”,同时数组名代表首元素地址,所以 str1 != str2;

对于 str3 和 str4 来说,由于 “hello world” 存放在字符常量区,所以 “hello world” 只会存在一份,只需要让它们同时指向 “hello world” 的空间即可,所以 str3 和 str4 其实存放的都是字符常量区中 “hello world” 中 字符 ‘h’ 的地址,所以 str3 == str4;

2、指针数组

指针数组是什么

顾名思义,指针数组是一个数组,而且是用来存放指针变量的数组;所以指针数组就是存放指针的数组。

指针数组的定义

int* arr[10];
# arr的类型:int* [10]   //去掉变量名剩下的就是变量类型
# arr先和[10]结合,表示arr是一个数组,数组里面有10个元素,每个元素的类型是int*

char* str[10];
# str的类型 char* [10]   //去掉变量名剩下的就是变量类型
# str先和[10]结合,表示str是一个数组,数组里面有10个元素,每个元素的类型是char*

指针数组的使用

int main()
{
  
	int a = 10;
	int b = 20;
	int c = 30;
	int* arr[3] = {
   &a, &b, &c };
	int i = 0;
	for (i = 0; i < 3; i++)
	{
  
		*(arr[i]) = i;
	}
	printf("%d %d %d\n", a, b, c);
	return 0;
}

3、数组指针

指针数组是什么

顾名思义,数组指针就是一个指针,这个指针指向的是一个数组;所以数组指针就是指向数组的指针。

数组指针的定义

int (*arr)[10];
# arr的类型:int (*)[10]  //去掉变量名剩下的就是变量类型
# arr首先和*结合,表示它是一个指针,然后和[10]结合,表示它指向的是一个数组,数组里面有10个元素,每个元素的类型是int;

char* (*arr)[10];
# arr的类型:int* (*)[10]  //去掉变量名剩下的就是变量类型
# arr首先和*结合,表示它是一个指针,然后和[10]结合,表示它指向的是一个数组,数组里面有10个元素,每个元素的类型是char*;

数组名和&数组名的区别

#include <stdio.h>
int main()
{
  
	int arr[10] = {
   0 };
	printf("arr = %p\n", arr);
	printf("&arr= %p\n", &arr);
	printf("arr+1 = %p\n", arr + 1);
	printf("&arr+1= %p\n", &arr + 1);
	return 0;
}

如上所示:数组名和&数组名所得到的地址的起始位置是相同的,但是数组名加1跳过的是一个整形,即4个字节;而&数组名加1跳过的是一个数组,即40个字节;

所以:数组名表示首元素的地址,+1 跳过一个数组元素;而&数组名表表示整个数组的地址,+1 跳过整个数组。

数组指针的使用

int main()
{
  
	int arr[10] = {
   1,2,3,4,5,6,7,8,9,0 };
	int(*p)[10] = &arr;  //把整个数组的地址赋值给数组指针变量p
	int i = 0;
	for (i = 0; i < 10; i++)
	{
  
		//*p找到整个数组,而数组名代表整个数组,所以*p相当于得到数组名,
		//而数组名又表示首元素的地址,所以*p最终的效果是得到数组首元素的地址
		//首元素的地址 +i 再解引用得到数组的各个元素
		printf("%d ", *(*p) + i);  
	}
	return 0;
}

虽然上面的使用是正确的,但是我们通常不这样用,因为我们可以直接用 arr[i] 来得到数组的每个元素;数组指针通常用于二维数组:

void print_arr(int(*arr)[5], int row, int col)
{
  
    int i = 0;
    int j = 0;
    for (i = 0; i < row; i++)
    {
  
        for (j = 0; j < col; j++)
        {
  
            //arr[i] 相当于 *(arr+i)
            //所以 arr[i][j] 相当于 *(*(arr+i)+j)
            //arr[i] 找到二维数组具体的某一行,而行号代表那一行,同时行号又表示那一行首元素的地址
            //所以 arr[i][j] 就可以找到二维数组中具体某一行的具体某一个元素
            printf("%d ", arr[i][j]);
        }
        printf("\n");
    }
}

int main()
{
  
    int arr[3][5] = {
   1,2,3,4,5,6,7,8,9,10 };
    //数组名arr,表示首元素的地址
    //但是二维数组的首元素是二维数组的第一行
    //所以这里传递的arr,其实相当于第一行的地址,是一维数组的地址
    //一维数组的地址用数组指针来接收
    print_arr(arr, 3, 5);
    return 0;
}

练习题

下面的代码分别表示什么意思?

int arr[5];
# arr和[5]结合,表示arr是一个数组,数组里面有5个元素,每个元素的类型是int;所以这里表示正常的一维整形数组;

int *parr1[10];
# parr1和[10]结合,表示parr1是一个数组,数组里面有10个元素,每个元素的类型是int*;所以这里表示指针数组;

int (*parr2)[10];
# parr2首先和*结合,表示它是一个指针,然后和[10]结合,表示它指向一个数组,数组里面有10个元素,每个元素的类型是int;所以这里表示数组指针;

int (*parr3[10])[5];
# parr3和[10]结合,表示这是一个数组,数组里面有10个元素,每个元素的类型是int (*)[5];所以这里是存放数组指针的数组;

4、数组参数、指针参数

我们在写代码的时候难免要把【数组】或者【指针】传给函数,那函数的参数该如何设计呢?下面我们来探讨这个问题。

一位数组传参

void test(int arr[]) {
  };
# true 数组传参,用数组接受

void test(int arr[10]) {
  };
# true 数组传参,用数组接受,数组元素个数可写可不写

void test(int* arr) {
  };
# true 整形的地址,用整形指针来接收,上面的两种传参方式本质上是这种

void test2(int* arr[20]) {
  };
# true 数组传参,用数组接受

void test2(int** arr) {
  };
# true 整形指针的地址,用二级指针来接收

int main()
{
  
	int arr[10] = {
   0 };
	int* arr2[20] = {
   0 };
	test(arr);  //数组名,首元素地址,整形的地址
	test2(arr2); //数组名,首元素地址,整形指针的地址
	return 0;
}

二维数组传参

void test(int arr[3][5]) {
  };
# true 二维数组传参,用二维数组来接收

void test(int arr[][]) {
  };
# false 二维数组传参可以不指定行,但必须指定列

void test(int arr[][5]) {
  };
# true 二维数组传参,用二维数组来接收,行号可写可不写

void test(int* arr) {
  };
# false 一位数组的地址不能用整形指针来接收

void test(int* arr[5]) {
  };
# false 一维数组的地址不能用指针数组来接收

void test(int(*arr)[5]) {
  };
# true 一维数组的地址用数组指针来接收,数组里面有5个元素

void test(int** arr) {
  };
# false 一位数组的地址不能用整形二级指针来接收

int main()
{
  
	int arr[3][5] = {
   0 };
	test(arr);  //二维数组的数组名代表第一行的地址,即一维数组的地址
}

一级指针传参

void print(int* p, int sz)  //一级指针用指针变量来接收
{
  
	int i = 0;
	for (i = 0; i < sz; i++)
	{
  
		printf("%d\n", *(p + i));
	}
}

int main()
{
  
	int arr[10] = {
   1,2,3,4,5,6,7,8,9 };
	int* p = arr;  //数组名代表首元素地址,即整形的地址
	int sz = sizeof(arr) / sizeof(arr[0]);
	print(p, sz);  //将一级指针p传给函数
	return 0;
}

二级指针传参

void test(int** ptr)
{
  
	printf("num = %d\n", **ptr);
}

int main()
{
  
	int n = 10;
	int* p = &n;  //一级指针变量
	int** pp = &p;  //二级指针变量
	test(pp);  //二级指针
	test(&p);  //一级指针的地址,即二级指针
	return 0;
}

5、函数指针

什么是函数指针

我们知道,任何类型的变量都是要在内存中占用空间的,而每个内存空间都会有自己的编号,也就是地址,那么对于函数来说,函数也会在内存中占用空间,所以函数也是有地址的;而函数指针就是用来存放函数地址的指针。

int Add(int x, int y)
{
  
	return x + y;
}

int main()
{
  
	int a = 10;
	int b = 20;
	int c = Add(a, b);
	printf("%p\n", &Add);
	printf("%p\n", Add);
	return 0;
}

函数指针的定义

int (*p1)(int, int) = &Add;
int (*p1)(int, int) = Add;
# 我们上面已经知道了,函数名和&函数名都代表函数的地址,所以上面这两种写法其实是一样的,都是把Add函数的地址赋给了函数指针p1;
# p1的类型:int (*)(int, int)  //去掉变量名剩下的就是变量类型
# p1首先和*结合,表示它是一个指针,然后和(int, int)结合,表示它指向的是一个函数,函数的参数是int,int,返回值也是int;

函数指针的使用

int Add(int x, int y)
{
  
	return x + y;
}

int main()
{
  
	int (*p1)(int, int) = &Add;
	//int (*p1)(int, int) = Add;

	int ret1 = Add(2, 3);
	int ret2 = (*Add)(2, 3);
	printf("%d %d\n", ret1, ret2);

	int ret3 = p1(2, 3);
	int ret4 = (*p1)(2, 3);
	int ret5 = (*********p1)(2, 3);
	printf("%d %d %d\n", ret3, ret4, ret5);

	return 0;
}

我们日常在调用函数的时候,都是直接函数名 + 函数参数,但是在学习了函数指针之后我们可能会有一个疑惑,既然函数名也代表函数的地址,那我们在调用函数的时候是不是应该先对函数进行解引用,然后再进行传参等操作?

其实对于函数来说,调用是不需要解引用的 (C语言就是这样设计的,大家当作一个特例记住就行,不用深究) ,当然,我们对它解引用编译器也不会报错,说白了对函数就行解引用只是为了让我们能够更好的理解指针,而编译器会自动忽略掉函数前面的*号;

就像我们上面例子中的 ret1 和 ter2,ret3 和 ret4 ,其实对于编译器来说他们都是一样的,甚至我们像 ret5 那样在函数前面加上若干个*号编译器也不会报错,因为编译器会将其忽略。

两段有趣的代码

第一段代码:

(*(void (*)())0)();
  • (void (*)())0:首先,0前面括号中的 void (*)() 是一个函数指针类型,该指针指向的函数的参数为空,返回值也为空;其次,我们知道,(int*)0 是把0强制类型转换为int*类型,即把0当作一个地址,该地址存放的是一个整数;所以 (void (*)())0 是把0强制类型转换为函数指针类型,即把0当作一个参数为空,返回值也为空的函数的地址。
  • (*(void (*)())0)():现在我们知道了(void (*)())0 代表的是0地址处的函数,且该函数的参数为空,所以我们现在调用该函数;首先用*对函数(不加*也行)解引用,然后传参(参数为空);
  • 所以实际上上面的代码完成的是一次函数调用,调用0地址处的函数。

上面这段出自于《C陷阱与缺陷》这本书的第二章,该书中对此问题的描述如下: 所以说,上面这段是有着实际意义的,并不是我们为了炫技而设计出的无用的代码。(上面提到的子例程是函数的意思)

第二段代码:

void (*signal(int, void(*)(int)))(int);
  • signal ( int, void (*) (int) ):对于这种复杂的语句,我们一般从函数名开始分析,我们发现,signal 是一个函数的函数名,该函数有两个参数,第一个参数是一个整形,第二个参数是一个函数指针,该指针指向的函数的参数是 int,返回值是 void;
  • void (*) (int):我们把 signal ( int, void (*) (int) ) 从代码中抽离出去就得到了函数的返回值,可以看到,该函数的返回值也是一个函数指针,该指针指向的函数的参数是 int,返回值是 void;
  • 所以实际上上面这段代码是一个函数声明,声明的函数的第一个参数是整形,第二个参数是参数为 int,返回值为 void 的函数指针,返回值也是参数为 int,返回值为 void 的函数指针。

上面这段也出自于《C陷阱与缺陷》这本书的第二章,紧挨着我们上面的函数调用:

《C陷阱与缺陷》这本书是十分经典的一本C语言书籍,里面提到了许多C语言中可能会出现的一些错误,特别是指针方面的错误,希望大家都能抽时间看看这本书,我把这本书的电子版放到了阿里云盘中,有需要的可以自取。

阿里云盘链接:https://www.aliyundrive.com/s/WHvpjWmFqDo
提取码: zg83

6、函数指针的用途

当我们学习了函数指针的相关知识过后,可能大家会有这样一个疑惑,既然我们可以在代码中直接来调用函数,那为什么还要通过函数指针来间接调用函数呢?这不是多此一举吗?我们说,存在即合理,其实只是我们没有还见过函数指针的真正用途而已,而并不能说函数指针没用;

实际上,函数指针是C语言中一种特别高明的存在,我们在用C语言完成比较大型的工程项目的时候,函数指针会被经常用到;而函数指针数组最常用的两个用途就是回调函数和转移表;回调函数是指通过函数指针来间接调用函数,转移表其实就是函数指针数组。

回调函数

回调函数就是一个通过函数指针调用的函数;如果我们把函数的指针(地址)作为参数传递给另一 函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数;回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进 行响应。(C语言中的库函数 qsort 就是回调函数使用的经典例子)

下面我通过一个简易的计算器来具体体现函数指针的回调函数的用法:

int add(int a, int b)
{
  
	return a + b;
}

int sub(int a, int b)
{
  
	return a - b;
}

int mul(int a, int b)
{
  
	return a * b;
}

int div(int a, int b)
{
  
	return a / b;
}

void menu()
{
  
	printf("****************************\n");
	printf("*****   1. Add   2. Sub*****\n");
	printf("*****   3. Mul   4. Div*****\n");
	printf("*****      0. Exit     *****\n");
	printf("****************************\n");
}

int main()
{
  
	int x, y;
	int input = 1;
	int ret = 0;
	do
	{
  
		menu();
		printf("请选择:");
		scanf("%d", &input);
		switch (input)
		{
  
		case 0:
			printf("退出程序\n");
			break;
		case 1:
			printf("输入操作数:");
			scanf("%d %d", &x, &y);
			ret = add(x, y);
			printf("ret = %d\n", ret);
			break;
		case 2:
			printf("输入操作数:");
			scanf("%d %d", &x, &y);
			ret = sub(x, y);
			printf("ret = %d\n", ret);
			break;
		case 3:
			printf("输入操作数:");
			scanf("%d %d", &x, &y);
			ret = mul(x, y);
			printf("ret = %d\n", ret);
			break;
		case 4:
			printf("输入操作数:");
			scanf("%d %d", &x, &y);
			ret = div(x, y);
			printf("ret = %d\n", ret);
			break;
		default:
			printf("选择错误\n");
			break;
		}
	} while (input);
	return 0;
}

上面我们完成了一个简易的计算器,但是我们发现它存在一个问题,那就是case 1 、case 2、case 3、case 4 中的代码除了调用函数的那句不一样之外,其他三句完全一样,造成了代码冗余,我们设想能不能设计一个函数,把这句代码都放入一个函数中去,从而实现代码的复用,这时候就要用到我们的回调函数了。

经过改造后的代码如下:

int add(int a, int b)
{
  
	return a + b;
}

int sub(int a, int b)
{
  
	return a - b;
}

int mul(int a, int b)
{
  
	return a * b;
}

int div(int a, int b)
{
  
	return a / b;
}

void menu()
{
  
	printf("****************************\n");
	printf("*****   1. Add   2. Sub*****\n");
	printf("*****   3. Mul   4. Div*****\n");
	printf("*****      0. Exit     *****\n");
	printf("****************************\n");
}

void calc(int(*pf)(int, int))
{
  
	int x = 0;
	int y = 0;
	int ret = 0;
	printf("输入操作数:");
	scanf("%d %d", &x, &y);
	ret = pf(x, y);
	printf("ret = %d\n", ret);
}

int main()
{
  
	int input = 1;
	do
	{
  
		menu();
		printf("请选择:");
		scanf("%d", &input);
		switch (input)
		{
  
		case 0:
			printf("退出程序\n");
			break;
		case 1:
			calc(add);
			break;
		case 2:
			calc(sub);
			break;
		case 3:
			calc(mul);
			break;
		case 4:
			calc(div);
			break;
		default:
			printf("选择错误\n");
			break;
		}
	} while (input);
	return 0;
}

上面代码中我们把前面冗余的代码全部封装到了 calc 函数中,通过把 calc 函数的参数设置为函数指针来实现了回调函数。

7、函数指针数组

什么是函数指针数组

顾名思义,函数指针数组就是用来存放函数指针的数组。

函数指针数组的定义

int (*parr1[10])(int);
# parr1的类型:int (*[10])(int)  //去掉变量名剩下的就是变量类型
# parr1和[10]结合,表示它是一个数组,数组里面有10个元素,每个元素的类型是一个函数指针,该指针指向的函数的参数为int,返回值为int;

函数指针数组的使用

int Add(int x, int y)
{
  
	return x + y;
}

int Sub(int x, int y)
{
  
	return x - y;
}

int Mul(int x, int y)
{
  
	return x * y;
}

int Div(int x, int y)
{
  
	return x / y;
}

int main()
{
  
	int (*parr[4])(int, int) = {
   Add, Sub, Mul, Div };  //将函数地址放入数组中
	int a = 10;
	int b = 20;
	int i = 0;
	for (i = 0; i < 4; i++)
	{
  
		printf("%d\n", (parr[i])(a, b));  //知道需要函数地址,调用函数
	}
	return 0;
}

8、函数指针数组的用途

上面我们已经提到,函数指针数组用于转移表。

下面我们继续通过对计算器的改造来体现转移表的作用:经过改造后的代码如下

int add(int a, int b)
{
  
	return a + b;
}

int sub(int a, int b)
{
  
	return a - b;
}

int mul(int a, int b)
{
  
	return a * b;
}

int div(int a, int b)
{
  
	return a / b;
}

void menu()
{
  
	printf("****************************\n");
	printf("*****   1. Add   2. Sub*****\n");
	printf("*****   3. Mul   4. Div*****\n");
	printf("*****      0. Exit     *****\n");
	printf("****************************\n");
}

int main()
{
  
	int x, y;
	int input = 1;
	int ret = 0;
	int(*p[5])(int x, int y) = {
   0, add, sub, mul, div };  //转移表
	while (1)
	{
  
		menu();
		printf("请选择:");
		scanf("%d", &input);
		if ((input > 0 && input <= 4))
		{
  
			printf("输入操作数:");
			scanf("%d %d", &x, &y);
			ret = (*p[input])(x, y);  //回调函数
			printf("ret = %d\n", ret);
		}
		else if (input == 0)
		{
  
			printf("退出程序\n");
			break;
		}
		else
		{
  
			printf("输入错误\n");
		}
	}
	return 0;
}

上面我们把各个函数的地址存放到一个函数指针数组中去,实现了一个转移表,然后通过访问数组里面的元素,配合回调函数,从而达到了我们简化代码的目的。

9、指向函数指针数组的指针

指向函数指针数组的指针就是一个指针,指针指向的是一个数组,数组里面的每个元素是函数指针。

其定义和使用如下:

void test(const char* str)
{
  
	printf("%s\n", str);
}

int main()
{
  
	//函数指针pfun
	void (*pfun)(const char*) = test;
	//函数指针的数组pfunArr
	void (*pfunArr[5])(const char* str);
	pfunArr[0] = test;
	//指向函数指针数组pfunArr的指针ppfunArr
	void (*(*ppfunArr)[5])(const char*) = &pfunArr;
	return 0;
}