【C/C++】基础:指针的进阶
摘要:本篇文章在学习基本的指针知识后,对指针进行较为具体的总结,其中内容包括字符指针、数组指针、函数指针、指向函数指针数组的指针。为了更深入与清晰的学习指针,还会穿插介绍指针数组,函数指针数组与指针作比较,同时会介绍指针与数组传参上的区别——数组传参和指针传参。最后还会插入介绍函数指针的应用——回调函数。
一. 指针复习
指针通常指的是指针变量,是用来存放地址的,地址唯一标识一块内存空间。也可以说,指针就是地址。指针的大小与编译平台的位数有关,在32位机器上,指针大小就是4字节,在64位机器上,指针大小为8字节,总之与编译平台标识地址的位数是相等的。在定义指针时,只需要设定好指针类型后,通过 & (取地址) 的方式将相应地址取出,赋值给指针即可。在使用指针时,只需要通过 * (解引用) 的方式访问指针变量记录的地址中的信息。
在使用指针的过程中,需要注意野指针的存在。野指针是指针指向位置不可知的指针,造成的主要原因是指针未初始化,指针越界访问以及指针指向空间的释放等。为此,要规避野指针需要注意对指针初始化,注意指针访问越界,在指向空间的释放前及时置NULL,避免返回局部变量的地址,指针使用之前检查有效性。
二. 字符指针
字符在 C 语言中用关键字 char 表示,那么字符指针就是 char*。针对于char*,不仅可以用来存放字符类型的地址,还可以通过它来表示字符串,示例如下:
void test_char() {
char ch = 'w';
char* pc = &ch;
*pc = 'w';
printf("%c\n", *pc);
const char* pstr = "hello world.";
printf("%s\n", pstr);
}
那么字符指针还是如何存放字符串的呢,其实是将定义的字符指针指向了字符串的首地址,在指向后,可以通过遍历或者直接使用库函数等方式完成对指针的操作,示意图如下:
了解上述内容后,来完成以下一道易错练习:
#include <stdio.h>
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[] 分别来存放常量字符串是通过数组来存放,需要开辟不同的内存块,但是当使用字符指针来存放相同的常量字符串时,这些字符指针会指向相同的内存块中。示意图如下:
三. 数组指针
需要明确的是数组指针是指针还是数组,它指向的是什么,它与指向数组的首地址有什么区别,它的基本语法又是如何表示的呢?进行简要的回答,首先数组指针是一个指针,指向的是一个数组,它一般储存数组的首地址,但是与通过数组名表示的数组首地址的数值一致含义不同。
3.1 指针数组
为了更好的分清数组指针,首先介绍一下指针数组。指针数组所表示的指针的数组,即它是一个数组,数组中的每个元素的类型为指针。它虽然名字写法与数组指针相似,但两者无特别联系,需要注意的是两者分别表示的是什么数据类型,数组指针是指针,指针数组是数组。
int* arr1[10];
char* arr2[4];
char** arr3[5];
3.2 数组指针书写
在上述已经提到,数组指针就是一个指针,试着通过我们熟悉的整型指针与浮点型指针的书写来推出数组指针的书写:
int *pint;
double *pdou;
int *parrint[10];
经过推理,可以看出只需将需要表示的类型与变量名写出后,加上指针符号即可表示指针。那么整型指针的类型为 int [10] , 变量名为 parrint,加上指针符号 * 后,即 int *parrint[10]。不难看出这个并不是数组指针,因为它是在上述所提到过的整型指针数组。既然如此,那么该如何表示呢,其实只需要在 *parrint 外加上 ( ) 即可,即:
int (*parrint) [10];
原因为:[] 的优先级要高于 * 的,所以必须加上 ( ) 来保证变量名先和 * 先结合。如果不加括号,则变量名与 [] 先结合表示数组,加上括号,变量名与 * 相结合,表示指针。
小结一下,指针与数组的区分,主要是看变量名与什么标识符结合,如果与 * 结合则表示为指针,如果与 [ ] 结合则表示为数组。
3.3 数组指针的含义
数组指针存放的是地址,是数组的地址,而数组的地址在C语言中,通过数组的首元素地址来表示。当提到首元素地址时,可能会出现疑惑,在熟悉的内容中,常常会提到通过数组名来表示首元素地址,那么两者都是数组的首元素地址,数值相同,这样的话含义是否一致,数组指针是冗余定义呢?其实不然,通过如下代码完成解释:
#include <stdio.h>
int main()
{
int arr[10] = { 0 };
printf("arr[0] = %p\n", &arr[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;
}
在代码的输出结果可见,通过数组名与取数组的地址的数值都是数组首元素的地址,它们数值一致,但是对其分别加一后出现了不同的结果,数组名加一偏移了一个整型变量,而数组名加一偏移量十个整型变量(注意:为十六进制计算 00CFFBC8 - 00CFFBA0 = 28 ——> 16*2+8 = 40 = 4 *10 ),直接来说二者的步长不同,前者步长为整型变量,后者步长为十个整型变量,即一个代码中元素个数为10的整型数组。因此不难看出二者的区别,数组名表示首元素地址是针对于元素而言,而取数组地址是针对于整个数组而言的。示意图如下:
总结来说,数组指针存放数值的是首元素的地址,实际上是数组的地址,但其意义表示的整个数组,赋值时需要将取整个数组的地址赋于。而对于通过数组名表示的首元素地址,表示的是单个元素,通过相应元素的指针储存。
3.4 数组指针的使用
对于一个指针而言,我们常常会有两种用途,分别用来表示自身数据类型与表示数据拓展类型,通过 int 类型指针进行举例,从而得到数组指针的一般用法:
void test_parr(int(*parrint)[5], int row, int col) {
int i = 0;
int j = 0;
for (i = 0; i < row; i++){
for (j = 0; j < col; j++){
printf("%d ", parrint[i][j]);
}
printf("\n");
}
}
int main() {
int a = 1;
int* pint = &a;
printf("int类型指针表示自身数据类型:\n");
printf("%d \n", *pint);
int arr[] = { 0,1,2,3,4,5 };
pint = arr;
printf("int类型指针表示拓展数据类型:\n");
for (int i = 0; i < 6; i++) {
printf("%d ", *(pint + i));
}
printf("\n");
int arr2[10] = { 1,2,3,4,5,6,7,8,9,0 };
int(*parrint)[10] = &arr2;
printf("int类型数组指针表示自身数据类型:\n");
for (int i = 0; i < 10; i++) {
printf("%d ", (*parrint)[i]);
}
printf("\n");
int arr3[3][5] = { 1,2,3,4,5,6,7,8,9,10 };
printf("int类型数组指针表示拓展数据类型:\n");
test_parr(arr3, 3, 5);
return 0;
}
从上述代码可以得出数组指针的两个用途,首先就是通过数组指针表示自身数据类型,比如 int 类型数组指针,通过指向 int 数组,完成对该数组的遍历,修改等操作,其次是通过数组指针表示拓展数据类型,要知道指针是通过偏移来完成对内存数据的访问的,因此可以通过指针偏移来完成对其他数据类型的访问,比如在上述代码案例中,在 void test_parr(int(*parrint)[5], int row, int col) 函数中设置了一个数组指针作为参数,其中通过 *(parrint+i) 完成了对二维数组中的各个一维数组的遍历。
四.函数指针
4.1 函数地址
首先需要明确函数指针是一个指针,储存的是函数的地址。不过在过往的学习过程中,很少提到函数有地址的概念,以下将通过代码举例,引入函数的地址,再继续了解函数指针。代码如下:
#include <stdio.h>
void test(){
printf("hello world\n");
}
int main()
{
printf("%p\n", test);
printf("%p\n", &test);
return 0;
}
可以看出在取函数地址有两种方式,一种是通过函数名的方式获取,一种是取函数地址名来获取地址。在取出函数地址后赋予函数指针即可。
4.2 函数指针的书写
函数书写的方法与上述提到的数组方法的过程是非常相似的,同样是写出指针类型,再将变量名与指针标识符 * 加上,当然还需要留意运算符的顺序,主要括号的添加。
#include <stdio.h>
void test(int a,int b){
printf("%d\n",a+b);
}
void main(){
void (*pfunc)(int a, int b) = test;
pfunc(1, 1);
void (*pfunc2)(int a, int b) = &test;
(*pfunc2)(2, 2);
}
在此,还可以小结一下书写指针的方法:① 写出指针所指向的类型;② 写出指针变量名与标识符 * ;③ 注意运算符间的关系,添加括号。通过一个后文将会提到的指针举例——指向函数指针数组的指针。
完成了对函数指针书写的了解后,给大家举两个有趣的例子分析一下。
(*(void (*)())0)();
void (*signal(int, void(*)(int)))(int);
例子1:首先寻找突破口,分别是分号与数字0,分号表示它并非函数的实现,对于 0 在它前面的括号为 (void (*)() ,这就是一个返回类型为 void,无参数的函数指针,添加括号后,便是我们熟悉的强制类型转换。在前面添加了 * 后,即是对函数指针的解引用,并且在解引用后添加了括号,便是进行了函数的调用。总的来说,将0转换为函数指针,再通过解引用的方式完成调用。
例子2:同样寻找突破口,分号便代表了并非函数的定义,signal并非关键字,我们可以从此处分析,在左边的括号中,可以看出很像一个参数列表 (int , void(*)(int)) ,只不过第二个变量为返回类型void,参数为int的函数指针。有了参数列表后,可以看看是否确实为函数,剩余的内容作为返回类型,返回类型为 void( * )( int )。总言之,这是一个函数调用,函数名为 signal ,返回类型为 void (*)(int),参数列表为(int, void(*)(int))。
4.3 函数指针的使用
在上述文章提到了指针的主要作用,分别指向自身类型以及指向拓展类型。同样的函数指针也有这两个作用,不仅如此函数指针还有一个更为普遍的作用,便是运用到回调函数中。通过代码来展示其中的作用:
include <stdio.h>
void test(int a,int b){
printf("%d\n",a+b);
}
int main(){
void (*pfun)(int a, int b) = test;
pfun(1 , 1);
(*pfun)(2, 2);
void (*pfun2)(int a, int b) = &test;
pfun2(3, 3);
(*pfun2)(4, 4);
}
表示自身类型:在以上代码中,通过书写函数指针后,分别通过两种方式取出函数地址并完成赋值,最后分别通过直接调用函数地址以及解引用两种方式使用函数。总的来说,表示自身类型使用时,可以通过直接使用函数指针以及使用解引用函数地址的方式来使用函数。
表示拓展类型:可以通过函数指针的偏移来完成对其他类型的使用,此处就不再举例。
最为重要的作用是使用回调函数。回调函数就是一个通过函数指针调用的函数。通过把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,即是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
以下通过两个排序的例子作为说明,要求说明:使用回调函数分别对int类型数组以及对结构体person数组进行排序(姓名与年龄),要求使用qsort函数来实现。代码如下:
void qsort( void *base, size_t count, size_t size, int (*comp)(const void *, const void *) );
int cmp_int(const void* e1, const void* e2) {
return *((int*)e1) - (*(int*)e2);
}
在此处的 comp 便是函数指针,可以通过该函数指针完成回调函数的使用,比如 int cmp_int(const void* e1, const void* e2) 。配合参数的要求,代入各个参数即可达到排序的目的。下面展示测试案例:
#include<stdio.h>
#include<stdlib.h>
#include<string>
struct person
{
int age;
char name[20];
};
int cmp_int(const void* e1, const void* e2) {
return *((int*)e1) - (*(int*)e2);
}
int cmp_person_byAge(const void* e1, const void* e2) {
return ((person*)e1)->age - ((person*)e2)->age;
}
int cmp_person_byName(const void* e1, const void* e2) {
return strcmp(((person*)e1)->name, ((person*)e2)->name);
}
int main() {
int arr[10] = { 3,2,6,4,0,7,9,1,5,8 };
struct person per[3] = { {16,"Tom"},{25,"Jack"},{12,"Mary"} };
printf("对int数组的打印:\n");
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) {
printf("%d ", arr[i]);
}
printf("\n");
printf("排序后的int数组:\n");
qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(arr[0]), cmp_int);
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) {
printf("%d ", arr[i]);
}
printf("\n");
printf("对person数组的打印:\n");
for (int i = 0; i < sizeof(per) / sizeof(per[0]); i++) {
printf("%s ", per[i].name);
printf("%d \n", per[i].age);
}
printf("\n");
printf("按年龄排序后,对person数组的打印:\n");
qsort(per, sizeof(per) / sizeof(per[0]), sizeof(per[0]), cmp_person_byAge);
for (int i = 0; i < sizeof(per) / sizeof(per[0]); i++) {
printf("%s ", per[i].name);
printf("%d \n", per[i].age);
}
printf("\n");
printf("按姓名排序后,对person数组的打印:\n");
qsort(per, sizeof(per) / sizeof(per[0]), sizeof(per[0]), cmp_person_byName);
for (int i = 0; i < sizeof(per) / sizeof(per[0]); i++) {
printf("%s ", per[i].name);
printf("%d \n", per[i].age);
}
}
补充:void* 的作用(放一个超链接)
4.4 补充:函数指针数组
函数指针数组对其进行分析,明确概念是数组,储存的是函数的指针。可以通过使用函数指针数组来完成对代码的简化,达到易读、易修改等目的。而函数指针数组的书写即在函数指针变量后加上中括号即可,**因为此时中括号会与变量名相结合表示数组,而其他部分结合表示数组的类型。**以返回类型为int ,参数为 int 为例:
int (*parr1)(int a);
int (*parr1[10])(int a);
关于函数指针数组的使用,函数指针数组与其他数组一样,都可以个存放相同类型数据,通过下标完成对数组的访问,同时也引申出了函数指针数组的一个重要作用——转移表。通过实现一个简单的计算器来说明函数指针数组的作用:
#include <stdio.h>
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;
}
int main(){
int x, y;
int input = 1;
int ret = 0;
do{
printf("*************************\n");
printf(" 1:add 2:sub \n");
printf(" 3:mul 4:div \n");
printf("*************************\n");
printf("请选择:");
scanf("%d", &input);
switch (input){
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;
case 0:
printf("退出程序\n");
break;
default:
printf("选择错误\n");
break;
}
} while (input);
return 0;
}
#include <stdio.h>
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;
}
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 (*p)(int, int)){
int x = 0;
int y = 0;
int ret = 0;
printf("请输入2个操作数:>");
scanf("%d %d", &x, &y);
ret = p(x, y);
printf("%d\n", ret);
}
int main(){
int input = 0;
do{
menu();
printf("请选择:>");
scanf("%d", &input);
switch (input){
case 1:
calc(Add);
break;
case 2:
calc(Sub);
break;
case 3:
calc(Mul);
break;
case 4:
calc(Div);
break;
case 0:
printf("退出计算器\n");
break;
default:
printf("选择错误\n");
break;
}
} while (input);
}
以上两段相互比较的代码篇幅较长,但是只需要比较两者的核心部分,如果不使用函数指针数组就会出现许多重复冗余的代码,case中都必须要写入重复的代码,并且在使用相应函数上比较麻烦,而且不容易修改与调用。但使用函数指针数组后,代码变得非常简化,易于修改与调用,可读性提高。
//核心代码1
case 1:
printf("输入操作数:");
scanf("%d %d", &x, &y);
ret = add(x, y);
printf("ret = %d\n", ret);
break;
//核心代码2
void calc(int (*p)(int, int)){
int x = 0;
int y = 0;
int ret = 0;
printf("请输入2个操作数:>");
scanf("%d %d", &x, &y);
ret = p(x, y);
printf("%d\n", ret);
}
4.5 拓展:指向函数指针数组的指针
同样的,需要先对指向函数指针数组的指针的概念进行分析,指向函数指针数组的指针是一个指针,是指向数组的指针,这个数组是函数指针数组,数组的元素为函数指针。关于指向函数指针数组的指针的书写,通过上述的方法完成,以指向返回类型为 int,参数列表为 int 的函数指针,大小为十个元素的数组为例:
关于指向函数指针数组的指针的使用,可以指向自身,也可以表示拓展类型,再次不再赘述。
五. 数组与指针传参的区分
在以往学习的过程中,常会对数组与指针传参出现问题。按照以往的经验,笔者习惯将其总结为三点:
- 传参类型与参数类型一致;
- 传参类型与参数类型不一致,但含义一致;
- 传参类型与参数类型不一致,但含义不一致,但可以转换为相同含义;
下面通过实例练习来说明内容:
5.1 一维数组传参
#include <stdio.h>
void test(int arr[])
void test(int arr[10])
void test(int* arr)
void test2(int* arr[20])
void test2(int** arr)
int main(){
int arr[10] = { 0 };
int* arr2[20] = { 0 };
test(arr);
test2(arr2);
}
对于函数 test() 来说,以上都是符合题意的,对于前两个函数定义,属于传参类型与参数类型一致的情况,第三条的第一属于类型不一致但含义相同。对于函数 test2() 来说,传参为一维指针数组名,表示数组首元素的一维指针的地址,在第四行中传入类型与参数类型一致,可以传参,第五行参数类型与传参类型不一致,含义相同的,因为二维指针就是一维指针的地址,即一维指针的指针。
5.2 二维数组传参
void test(int arr[3][5])
{}
void test(int arr[][])
{}
void test(int arr[][5])
{}
void test(int* arr)
{}
void test(int* arr[5])
{}
void test(int(*arr)[5])
{}
void test(int** arr)
{}
int main()
{
int arr[3][5] = { 0 };
test(arr);
}
在主函数中,可以看到传的是二维数组的数组名,即数组首元素地址,为大小为5的一维数组的地址,即数组指针。在第一行中,传参类型与参数类型一致的,可以传参,但是第二第三行不行,原因是二维数组传参,函数形参的设计只能省略第一个[]的数字,这是一个非常重要的知识点。对于第四行第五行,传的类型完全不同,而且无法达成转换,所以不可以传参。第六行,类型不同,但含义一致,故可以传参。第六行中,为二维指针,与一维指针完全不合类型不能转换,故不可传参。
5.3 一级指针传参
#include <stdio.h>
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);
int a = 0;
p = &a;
return 0;
}
一级指针存储的是变量中的地址,理解本质后,可发现主要传递参数分别为自身类型以及数组的首元素地址作为自身类型。
5.4 二级指针传参
#include <stdio.h>
void test(int** ptr) {
printf("num = %d\n", **ptr);
}
int main(){
int n = 10;
int* p = &n;
int** pp = &p;
test(pp);
test(&p);
int* arr[10];
pp = arr;
return 0;
}
二级指针存储的是一级指针的地址,理解本质后,可以发现可以传递给二级指针的分别是二级指针自身以及一级指针的数组的首元素地址,因为其首元素地址表示的就是一级指针的地址,即一级指针的指针为二级指针。
六. 总结
本篇博客通过大量的笔墨介绍了各种指针类型,及其他们易错易混淆点,篇幅较长,但逻辑顺序是紧密的,希望大家可以耐心阅读。由于有许多总结性的知识,以下将会对相应内容进行再次重复梳理。
指针的本质:指针通常指的是指针变量,是用来存放地址的,地址唯一标识一块内存空间。
数组指针与指针数组的区别:指针与数组的区分,主要是看变量名与什么标识符结合,如果与 * 结合则表示为指针,如果与 [ ] 结合则表示为数组。
指针的书写方式:① 写出指针所指向的类型;② 写出指针变量名与标识符 * ;③ 注意运算符间的关系,添加括号。
数组指针和数组首元素地址的区别:数组指针存放数值的是首元素的地址,实际上是数组的地址,但其意义表示的整个数组,赋值时需要将取整个数组的地址赋于。而对于通过数组名表示的首元素地址,表示的是单个元素,通过相应元素的指针储存。
指针的作用:分别用来表示自身数据类型与表示数据拓展类型,需要留意函数指针的回调函数。
函数地址的获取:一种是通过函数名的方式获取,一种是取函数地址名来获取地址。
数组与指针传参的区分(需了解本质后参考作者总结):
- 传参类型与参数类型一致;
- 传参类型与参数类型不一致,但含义一致;
- 传参类型与参数类型不一致,但含义不一致,但可以转换为相同含义;
补充:
- 代码将会放到: https://gitee.com/liu-hongtao-1/c–c–review.git ,欢迎查看!
- 最近长时间拖更,原因是作者的眼睛出了点小问题,文章可能出现许多错别字,欢迎指出,感谢理解!
- 由于博客的markdown格式导入存在问题,无法加粗,因此希望大家可以在gitee下载文章浏览,更为清晰。
- 后面还会对指针专题进行补充,正在安排过于指针的面试题与常见的易错题,希望能帮助大家加深理解,敬请期待。
- 欢迎各位点赞、评论、收藏与关注,大家的支持是我更新的动力,我会继续不断地分享更多的知识!
|