深入理解指针

1.内存和指针

在讲内存和地址之前,我们想有个生活中的案例:假设有⼀栋宿舍楼,把你放在楼⾥,楼上有100个房间,但是房间没有编号,你的⼀个朋友来找你玩,如果想找到你,就得挨个房⼦去找,这样效率很低,但是我们如果根据楼层和楼层的房间的情况,给每个房间编上号,如:

有了房间号,如果你的朋友得到房间号,就可以快速的找房间,找到你。
Alt Alt
⽣活中,每个房间有了房间号,就能提⾼效率,能快速的找到房间。

如果把上面的例子对照到计算机中,又是怎么样呢?

其实也是把内存划分为⼀个个的内存单元,每个内存单元的⼤⼩取1个字节。计算机中常⻅的单位(补充):⼀个⽐特位可以存储⼀个2进制的位1或者0

其中,每个内存单元,相当于⼀个学⽣宿舍,⼀个字节空间⾥⾯能放8个⽐特位,就好⽐同学们住
的⼋⼈间,每个⼈是⼀个⽐特位。每个内存单元也都有⼀个编号(这个编号就相当于宿舍房间的⻔牌号),有了这个内存单元的编
号,CPU就可以快速找到⼀个内存空间。
在这里插入图片描述
⽣活中我们把⻔牌号也叫地址,在计算机中我们把内存单元的编号也称为地址。C语⾔中给地址起了新的名字叫:指针
所以我们可以理解为:
内存单元的编号 == 地址 == 指针

但这是不严谨的说法,因为指针是存放地址的变量

1.2如何理解编址

CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,而因为内存中字节很多,所以需要给内存进行编址(就如宿舍很多,需要给宿舍编号⼀样)。
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。首先,必须了解,计算机有很多的硬件单元,而硬件单元之间是要互相协同工作的,也就是至少可以进行数据传递。但硬件单元想要做到通信,要用“线”连起来。
⽽CPU和内存之间也是有⼤量的数据交互的,所以,两者必须也⽤线连起来。
不过,我们今天关心⼀组线,叫做地址总线
硬件编址也是如此:在这里插入图片描述
我们可以简单理解,32位机器有32根地址总线,
每根线只有两态,表示0,1【电脉冲有⽆】,那么
⼀根线,就能表示2种含义,2根线就能表示4种含
义,依次类推。32根地址线,就能表示2^32种含
义,每⼀种含义都代表⼀个地址。
地址信息被下达给内存,在内存上,就可以找到
该地址对应的数据,将数据在通过数据总线传⼊
CPU内寄存器。
在编址过程中,硬件通常会使用一种叫做地址解码器的电路来将CPU发送的地址信号转换成实际的内存单元或者其他外设的选择信号。地址解码器根据特定的编址方案(如线性地址空间或分段地址空间)将地址信号映射到相应的内存位置。
这种硬件设计的方式使得计算机可以快速、高效地访问内存,同时也减轻了软件开发人员的负担,因为他们无需手动管理每个字节的地址。相反,他们可以使用抽象的内存模型来编写程序,而硬件会负责实际的地址映射和访问。

2.指针变量和地址

2.1 取地址操作符(&)

理解了内存和地址的关系,我们再回到C语⾔,在C语⾔中创建变量其实就是向内存申请空间,比如:

#include <stdio.h>
int main()
{
int a = 10;
return 0;
}

在这里插入图片描述

⽐如,上述的代码就是创建了整型变量a,内存中申请4个字节,⽤于存放整数10,其中每个字节都有地址,上图中4个字节的地址分别是:

  1. 0x0019FEDC
  2. 0x0019FEDD
  3. 0x0019FEDE
  4. 0x0019FEDF

那我们如何能得到a的地址呢?

#include <stdio.h>
int main()
{
int a = 10;
&a;//取出a的地址
printf("%p\n", &a);
return 0;
}

在这里插入图片描述 在这里插入图片描述

按照我画图的例⼦,会打印处理:0019FEDC, &a取出的是a所占4个字节中地址较⼩的字节的地址

2.2 指针变量和解引用操作符(*)

2.2.1 指针变量

那我们通过取地址操作符(&)拿到的地址是⼀个数值,⽐如:0x0019FEDC,这个数值有时候也是需要
存储起来,⽅便后期再使⽤的,那我们把这样的地址值存放在哪⾥呢?答案是:指针变量中。
⽐如:

#include <stdio.h>
int main()
{
int a = 10;
int * pa = &a;//取出a的地址并存储到指针变量pa中
return 0;
}

指针变量也是⼀种变量,这种变量就是⽤来存放地址的,存放在指针变量中的值都会理解为地址。

2.2.2 如何拆解指针类型

我们看到pa的类型是 int* ,我们该如何理解指针的类型呢?

int a = 10;
int * pa = &a;

这⾥pa左边写的是 int* , * 是在说明pa是指针变量,⽽前⾯的 int 是在说明pa指向的是整型(int)类型的对象。
在这里插入图片描述
如果这里是char类型的变量b,b的地址自然要放在char*类型的指针变量中。

2.2.3 解引用操作符

我们将地址保存起来,未来是要使⽤的,那怎么使⽤呢?在现实⽣活中,我们使⽤地址要找到⼀个房间,在房间⾥可以拿去或者存放物品。C语⾔中其实也是⼀样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这⾥必须学习⼀个操作符叫解引⽤操作符 (*)。

#include <stdio.h>

int main()
{
int a = 100;
int* pa = &a;
*pa = 0;
return 0;
}

上面代码中第7行就使用了解引用操作符, *pa 的意思就是通过pa中存放的地址,找到指向的空间,
pa其实就是a变量了;所以pa=0,这个操作符是把a改成了0。

2.3 指针变量的大小

32位机器假设有32根地址总线,则一个地址就是32个bit位,需要4个字节大小储存,同理对于64位机器,则需要8个字节。
而指针变量是用来存放地址的,在32位机器下,指针的大小就是4个字节的大小。
注意:指针的大小不关乎类型,只要是指针类型的变量,其大小就是该平台下所对应的大小。

3.指针变量类型的意义

3.1 指针的解引用

指针变量类型的不同,意味着我们在解引用后对指向的空间的操作受到指针类型所限制的字节数。

#include <stdio.h>
int main()
{
int n = 0x11223344;
int *pi = &n;
*pi = 0;
return 0;
}
#include <stdio.h>
int main()
{
int n = 0x11223344;
char *pc = (char *)&n;
*pc = 0;
return 0;
}

从调试模式下,我们可以看出前者会将n的4个字节都改为0,而后者指挥改变n的第一个字节为0。
这是因为int类型的指针解引用后可以访问n地址上空间的4个字节,而char类型对应的是1个字节。

3.2 指针±整数

在这里插入图片描述
从上面的代码可以看出,指针+1表明是使指针对应的地址加一个字节,所以将指针进行±可以找到高位的地址或者低位地址。
所以不用的指针类型,可以跳过不同大小的字节。
但是这里强调一下void*类型的指针变量(泛型指针),它可以接受类型的地址,但不能直接对其进行加减和解引用(可以强制转换类型后进行操作)

4.const修饰指针

4.1 不同的const用法

int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的
return 0;
}

const 也可以用在指针上

int main()
{
int a = 10;
int* p1 = &a; //p1的值可以修改,a的值可以修改
int b = 20;
const int* p2 = &b; //p2指向的元素不可以修改‘*p1 = 0’这会使得编译器报错
int c =30;
int* const p3 = &c; //p3存放的地址不可以改变,例如: p3 = d;同样会使得编译器报错
int d = 40;
const int* const p2 = &d; //这中情况便很好理解了,p的地址和p指向的元素都不可以修改
return 0;
}

用指针间接改变元素的值的行为,就好比是是狂飙里的高启强去找老默去咔嚓人。

5.指针运算

5.1 指针±整数

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

现在p存放的是数组的首元素地址,因为数组中的元素在内存中是连续储存的,通过这个地址+1我们可以得到数组中的第二个元素。

#include <stdio.h>
//指针+- 整数
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int *p = &arr[0];
int i = 0;
int sz = sizeof(arr)/sizeof(arr[0]);
for(i=0; i<sz; i++)
{
printf("%d ", *(p+i));//p+i 这⾥就是指针+整数
}
return 0;
}

在这里插入图片描述

5.2 指针-指针

我们先从下面的代码中寻找答案
在这里插入图片描述

因为’a’到’\0’需要跳过3个元素,所以显而易见指针-指针为指针之间相差的元素个数

6.野指针

6.1 指针初始化or不初始化

所谓“野”,顾名思义就是指针指向了未知的地址,这便是没有对指针进行初始化的原因,即默认指向了随机的地址。
这是非常不安全的行为。

6.2 指针变量不再使⽤时,及时置NULL,指针使用之前检查有效性

if(p!=NULL//p为一个指针变量
{
//p指针为有效值时执行代码
}

我们可以把野指针想象成野狗,野狗放任不管是⾮常危险的,所以我们可以找⼀棵树把野狗拴起来,
就相对安全了,给指针变量及时赋值为NULL,其实就类似把野狗栓起来,就是把野指针暂时管理起
来。
所以在使用完指针后应当对指针置NULL,不指向任何地址,避免访问越界

7.assert断言

我们在代码中加上头文件 <assert.h>,定义了assert()函数,我们可以用它在运行后面的程序前检验指针
如果assert()接受的表达式返回值为假(0),assert()会报错,反之会正常进行执行程序。

assert(p != NULL);

当我们想用一个开关停止“断言”,我们需要在<assert.h>之前定义宏NDEBUG。

#define NDEBUG //程序中的断言不会产生任何作用
#include <assert.h>

但是这是在Windows调试模式下的情况,在release模式下没有定义宏NDEBUG,也会使得”断言“失效。

8.指针的使用和传址调用

8.1 传值调用

这里我们想写一个函数,交换a,b的值
在这里插入图片描述

通过打印出来的结果我们可以看出,这是行不通的,原因在于函数的形参和实参是被开辟了独立的空间,即地址不同,所以对形参的任何操作不会影响到实参。

在这里插入图片描述
而这次的swap函数完成了两个变量值的交换,这是因为调用函数的实参将变量的地址传给形参,形参用指针接收和存放地址到变量中,对指针解引用就是取出了这个地址上的元素的,这时对元素操作和修改,就是改变了同一个变量的值进行了修改。

9. 数组名的理解

9.1 arr和&arr[0]

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

arr是数组名,本质就是地址

二者都是表示数组的首元素地址,通过下面代码我们来初步理解:

#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("&arr[0] = %p\n", &arr[0]);
printf("arr = %p\n", arr);
return 0;
}

在这里插入图片描述

9.2 特殊情况

{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int sz1 = sizeof(arr);//此时arr表示整个数组的地址,计算的整个数组的所占字节大小!!
int sz2 = sizeof(&arr);//&arr又代表什么?
printf("arr: %zd",sz1);
printf("&arr:%zd ",sz2);
return 0;
}

在这里插入图片描述
所以sizeof(arr)计算的是数组整体的地址所占的大小,除此之外,任何地⽅使用数组名,数组名都表示首元素的地址。
而**&arr**实际上类型是指向数组的指针,类型为 int (*)[10],它指向一个包含10个 int 类型元素的数组。而上面我们提到过,指针在平台下所占的字节大小是相同的,我用的是32位下的环境,所以为4。
在此基础上我们来了解对&arr±整数的意义,上面我们知道对指针±整数意味着改变指针指向的地址,如果我们对上面的&arr+1,它的地址会有什么变化:

printf("arr: %p",&arr);
printf("&arr:%p",&arr+1);

在这里插入图片描述
从中我们可以看出两个地址之前相差的字节数为40,这是因为&arr的类型为数组指针类型,类型为 int (*)[10],对其执行加法操作时,指针会向后移动一个数组的大小,而不是移动一个元素的大小。

9.3 一维数组传参的本质

include <stdio.h>
void test(int arr[])
{
int sz2 = sizeof(arr)/sizeof(arr[0]);
printf("sz2 = %d\n", sz2);
}
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int sz1 = sizeof(arr)/sizeof(arr[0]);
printf("sz1 = %d\n", sz1);
test(arr);
return 0;
}

在这里插入图片描述
数组名是数组⾸元素的地址;那么在数组传参时候,传递的是数组名,也就是说本质上数组传参传递的是数组⾸元素的地址,所以函数形参的部分理论上应该使⽤指针变量来接收⾸元素的地址。
那么在函数内部我们写sizeof(arr) 计算的是⼀个地址的大小(单位字节)而不是数组的大小(单位字节)。正是因为函数的参数部分是本质是指针,所以在函数内部是没办法求的数组元素个数的。

9.4 二维数组传参的本质

9.4.1 函数参数的类型

知道了函数的参数部分本质是指向实参地址类型的指针,同理我们可以推断出二维数组传参,函数内部的参数是指向二维数组首元素地址的指针,类型为int (*),这样在函数内部访问后面的元素就可以一次跳过一个字节来访问。
在这里插入图片描述

从打印结果来看在sizeof(表达式)中,表达式的类型不同决定了返回的字节大小不同。

9.4.2 形参为数组形式

#include <stdio.h>
void test(int a[3][5], int r, int c)
{
int i = 0;
int j = 0;
for(i=0; i<r; i++)
{
for(j=0; j<c; j++)
{
printf("%d ", a[i][j]);
}
printf("\n");
}
}
int main()
{
int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6},{3,4,5,6,7}};
test(arr, 3, 5);
return 0;
}

9.4.3 形参为指针形式

第一行的⼀维数组的类型就是 int [5] ,所以第一行的地址的类型就是数组指针类型 int(*)[5] 。那就意味着⼆维数组传参本质上也是传递了地址,传递的是第一行这个⼀维数组的地址,那么形参也是可以写成指针形式的。如下:

#include <stdio.h>
void test(int (*p)[5], int r, int c)
{
int i = 0;
int j = 0;
for(i=0; i<r; i++)
{
for(j=0; j<c; j++)
{
printf("%d ", *(*(p+i)+j));
}
printf("\n");
}
}
int main()
{
int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6},{3,4,5,6,7}};
test(arr, 3, 5);
return 0;
}

总结:⼆维数组传参,形参的部分可以写成数组,也可以写成指针形式

9.5 二级指针

在这里插入图片描述
*ppa等价于pa
**ppa等价于pa ,等价于a;

在这里插入图片描述

10. 指针数组

10.1 不同类型的指针数组

我们类比一下,整型数组是存放整型的数组,字符数组是存放字符的数组。
在这里插入图片描述
指针数组的每个元素都是⽤来存放地址(指针)的。
在这里插入图片描述
指针数组的每个元素是地址,⼜可以指向⼀块区域。

10.2 指针数组模拟二维数组

#include <stdio.h>
int main()
{
int arr1[] = {1,2,3,4,5};
int arr2[] = {2,3,4,5,6};
int arr3[] = {3,4,5,6,7};
//数组名是数组⾸元素的地址,类型是int*的,就可以存放在parr数组中
int* parr[3] = {arr1, arr2, arr3};
int i = 0;
int j = 0;
for(i=0; i<3; i++)
{
for(j=0; j<5; j++)
{
printf("%d ", parr[i][j]);
}
printf("\n");
}
return 0;
}

在这里插入图片描述
parr 中的每个元素是指向不同数组的指针,这些指针存储在内存中的位置并不一定是连续的,所以这并非是实际上的二维数组。
即使每个数组 arr1、arr2 和 arr3 在内存中是连续存储的,但 parr 中的指针变量之间的间隔会导致整个二维数组在内存中不是连续存储的。

11. 不同类型的指针变量

11.1 字符指针变量

首先我们来看一道代码:

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

答案不是,这里的"hello bit”其实是常量字符串,本质是把字符串 hello bit. ⾸字符的地址放到了pstr中。

在这里插入图片描述

#include <stdio.h>
int main()
{
char str1[] = "hello bit.";
char str2[] = "hello bit.";
const char *str3 = "hello bit.";
const char *str4 = "hello bit.";
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;
}

在这里插入图片描述
C/C++会把常量字符串存储到单独的⼀个内存区域,当几个指针指向同⼀个字符串的时候,他们实际会指向同⼀块内存。

在 C 语言中,字符串常量是静态存储的,它们在编译时就已经存在,并且在程序运行期间不会被修改。因此,将字符串常量存储在只读内存段中可以节省动态内存的使用,因为每个相同的字符串常量只需要存储一次,而不管它被引用了多少次。
另外,将字符串常量存储在只读内存段中还有一个好处是增加了程序的安全性。因为字符串常量是只读的,试图修改它们的行为是非法的,并且会导致程序崩溃或产生其他未定义的行为。这有助于防止一些潜在的编程错误,例如意外修改字符串常量的内容。

11.2 数组指针变量

int (*p)[10];

解释:p先和结合,说明p是⼀个指针变量变量,然后指着指向的是⼀个大小为10个整型的数组。所以
p是⼀个指针,指向⼀个数组,叫数组指针
这里要注意:[ ]的优先级要⾼于 * 号的,所以必须加上()来保证p先和结合。

数组指针变量是用来存放数组地址的,那怎么获得数组的地址呢?就是我们之前学习的 &数组名 。

int arr[10] = {0};
&arr;//得到的就是数组的地址

如果要存放个数组的地址,就得存放在数组指针变量中,如下:

int(*p)[10] = &arr;

在这里插入图片描述
我们在调试模式下能够看到 &arr 和 p 的类型是完全⼀致的。
数组指针类型解析:
在这里插入图片描述

11.3 函数指针变量

从字面意思上我们不难知道,函数指针变量是用来存放函数的地址的,未来可以通过地址能够调用函数的。

11.3.1 如何获取函数的地址

我们做个测试:

#include <stdio.h>
void test()
{
printf("hehe\n");
}
int main()
{
printf("test: %p\n", test);
printf("&test: %p\n", &test);
return 0;
}

在这里插入图片描述
通过打印出来的结果,我们知道了函数是也有地址的,函数名就是函数的地址,当然也可以通过 &函数名的方式获得函数的地址。
所以 函数名 == &数组名

如果我们要将函数的地址存放起来,就得创建函数指针变量咯,函数指针变量的写法其实和数组指针非常类似。如下:
在这里插入图片描述

11.3.2 函数指针变量的使用

通过函数指针调用指针指向的函数。

#include <stdio.h>
int Add(int x, int y)
{
return x+y;
}
int main()
{
int(*pf3)(int, int) = Add;
printf("%d\n", (*pf3)(2, 3));
printf("%d\n", pf3(3, 5));
return 0;
}

在这里插入图片描述
前者是间接调用,通过对地址解引用找到函数并传递参数,后者是直接调用。

11.4 函数指针数组

11.4.1 回调函数的理解

如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数
时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条
件发生时由另外的一方调用的,用于对该事件或条件进行响应。

我们不难看出,这个数组是用来存放指向函数地址的指针,这样我们可以轻松通过数组中的不同函数指针元素来调用不用的函数。
此方法也叫回调函数,可以有效避免代码冗余影响观感,提高了可维护性和灵活性。
假设我们要设计一个简易的计算器,并实现加减乘除的功能,我们首先要分别定义4个函数,在没有改造成 回调函数之前,我们要在每一次使用函数都要在源代码中加上scanf(),这显然很麻烦。这里我们写一个单独的函数,用来接收不同函数的地址,完成两个数的各种功能:
在这里插入图片描述
在这里插入图片描述

11.4.2 回调函数的实现

以下是对回调函数函数指针数组的实际应用:

#define _CRT_SECURE_NO_WARNINGS 1
#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 cala(int(*pf)(int, int))
{	
	int a = 0, b = 0;
	printf("请输入两个整数\n");
	scanf("%d %d", &a, &b);
	int num = pf(a, b);
	printf("%d\n", num);
}
int main()
{
	int(*PF[4])(int, int) = {Add,Sub,Mul,Div};
	int num1 = PF[0](2, 3);
	int num2 = PF[1](2, 3);
	int num3 = PF[2](2, 3);
	int num4 = PF[3](2, 3);
	//我们也可以采用自定义的cala()函数,将不同的函数地址作为参数传入
	int input = 0;
	do
	{	
		printf("请输入1~4的整数进行相应的运算,0表示退出\n");
		scanf("%d", &input);
		switch (input)
		{
		case 1:
			cala(Add);
			break;
		case 2:
			cala(Sub);
			break;
		case 3:
			cala(Mul);
			break;
		case 4:
			cala(Div);
			break;
		default:
			break;
		}
	} while (input);
	
	printf("num1:%d\n", num1);
	printf("num2:%d\n", num2);
	printf("num3:%d\n", num3);
	printf("num4:%d", num4);

	return 0;
}

在这里插入图片描述

以上便是我对于指针多方面的理解, 希望我们可以共同进步!我们下期再见!😊

  • 23
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值