了解指针的相关知识
1.内存和地址
要想了解指针我们就需要先来了解以下内存和地址的相关概念和知识
在这之前我们先来了解一下一个常识
就是 在内存地址中 每个内存单元 的大小 都是一个字节 也就是八个比特
了解了这个常识之后 我们就可以来看一下内存和地址的相关知识了
首先在内存中 内存的空间是被分成了一个个单元的 类似于房间一样 每个房间的大小是1Byte
并且这一个个内存单元都有这相应的编号 也就是内存的地址
有了这个地址 CPU就可以快速的寻找到对应的内存空间 这样的话 CPU寻找内存空间的效率就会大大提高
因此这里我们可以理解为 内存单元的编号 == 地址 == 指针
这个指针是C语言的一个给地址取的名字
接下来我们再深入研究一下数据是如何被存储到内存空间的? 地址又是如何编写到每一个内存单元的?
CPU是如何从内存准确的拿取数据 又是 如何准确的写入数据到内存空间中的呢?
我们先来看一张图片
CPU 和 内存 作为相互独立的两个硬件 实际上它们之间的大量的数据传递依靠的是 “各种线”
首先 1.地址总线
地址总线是 CPU能够从内存空间精确的 读/写 数据的一个最重要的线
它会告诉CPU需要 读/写 的数据唯一内存的哪一个地址上 也就是会告诉CPU 要去那个内存单元的地址
并且地址总线的数量是跟 位数 有关系 的 如果是32 位的电脑就有32跟地址总线
这每根地址线 在使用的时候都会产生电子脉冲 就是 1/0的信号
也就是说一共会有 2的32次方 个可能性
2.控制总线
控制总线 的作用 是告诉CPU 本次 的操作 是要 读/输入 数据 还是 写/输出 数据
(注意了 读和写数据的角度 是站在CPU的角度上去看的 数据进CPU就是输入 / 读 反之则是输出/写)
其实就是告诉CPU 本次去内存空间 究竟是读 还是 写
3.数据总线
每次CPU 从 内存空间 拿取数据的时候 数据的传递都是经过的数据总线
现在我们来思考一个问题 :地址是如何编写到每一个内存单元的?
这个是所有计算机行业都规定俗称的一个规矩
就是说每个内存单元的地址编码 都是事先编写好的 并不是由记下来的 是本来就被编写好的
数据是如何被根据地址被存储到内存空间的?
首先我们要知道程序的运行 在一开始的时候用的都是编译器分配给他的虚拟地址
再有操作系统和硬件 实现 虚拟地址到 物理地址 也就是真正的内存空间内
总结:
内存会被划分为一个个内存单元 每个内存单元的大小是一个字节
每个内存单元都会被给上一个编号== 地址 == 指针 (C语言中称为指针)
2指针变量和地址
2.1取地址操作符(&)
在先前的学习中 我已经知道了 scanf中会用到 &这个操作符
那么这个操作符实际上就是取了一个变量的地址
看一个案例 分析一下是如何取得
int main()
{
int a = 10; // 创建变量的实质 就是在开辟了一个内存空间
// %p 专门用来打印地址的 打印出来的地址是16进制
printf("%p\n", &a);
printf("%x\n", &a); // 这里就可以验证 获得的地址是16进制的
return 0;
}
上面的int a = 10;
a是整形 大小是四个字节 于是它就会向内存空间申请四个内存单元用来存储 10 这个
但是有一个需要思考的地方
每个内存单元都有地址 那对于a来说是四个地址都指向a吗
实际上并不是的 四个地址 我们真正调用出 a的地址 是四个地址里面最小的那个地址
在上述的图中 0x006FFF70 就是指向a的地址 我们通过&a 获取的地址也是个
但是注意了 每次运行的地址都是不一样的
2.2指针变量
int* p = &a 这里的p就是指针变量
int a = 10;
int* p = &a; // 这里的int* 是一个类型 p是指针变量
printf("%p\n", p);
这里的p打印出来的就是a的地址
那我们要如何去理解 int* p = &a呢
首先我们知道 内存空间是创建了4个字节的大小去存储a这个变量的 并且我们取地址的时候取得是这四个内存单元的首地址
而int* p = &a 就是创建了一个p变量 也为p创建了一个内存空间 去存放了a变量的首地址
我们再来看int* 要如何去理解呢
int 代表这个指针变量p 指向的对象a是int类型
*代表着这个是p是指针变量
2.3解引用操作符
我们来看一个实例
// 解引用操作
int a = 10;
*p = 100; // 这里的*p 代表 指向p变量指针存放的地址 也就是等价于 a
printf("%d\n", a); // 这里的结果是100 说明已经a的值通过 *p = 100 修改了
return 0;
指针变量的大小
我们都知道一个变量 的创建 是需要申请一个内存空间的 至于这个空间有多大 有关于它的类型
那么指针变量的大小我们又如何知道呢?
其实指针变量内存放的是一个地址 那么存放地址需要多大的内存空间 那么指针变量需要的内存空间就有多大
这个是根据电脑是多少位的实时变动的
如果是32位的电脑
那么地址就是32位 的二进制数 那么就需要32个比特位来存放这个地址 也就是4个字节
如果是64位的电脑 那么就需要64个比特位 也就是8个字节 大小
在VS2022上也可以进行验证
注意了 指针变量的大小 和它指向的对象的类型 是没有关系的
3.指针变量类型的意义
3.1指针的解引用
尽管每个指针变量的大小和它指向的对象类型无关
但并不是无意义的
指针类型决定了指针在进行解引用操作的时候 能够访问多大的空间!
因此 如果指针类型和 它所指向的对象类型无关 那么在解引用操作时 就会出现错误
我们来看一个实例:
char *pa 只字符类型 因此在解引用的时候只会有一个字节的访问空间
虽然这个指针内存放着int a的地址 但是在解引用的时候只有一个字节空间 因此 *pa = 0这个操作
只能操作 地址内一个字节空间 出现了 00 33 22 11 也就是只修改了 44 --> 00
结论: 指针类型决定了 指针解引用时会有多大的权限(一次能访问多少个字符)
3.2指针±整数
我们来看一个实例:
结论:
指针的类型决定了 指针的步长 就是向前向后走一步有多大的距离
type* p + i
就是跳过了i个type类型的数据
也就是跳过了 i * sizeof(type)个字节
3.3 void 指针
在指针的类型中是有一种特殊的类型 叫做 void*类型的 可以理解成无具体类型的指针 也叫做泛型指针
// void 指针 可以用来接受任意类型的地址
int main()
{
int a = 10;
int* pa = &a;
void* pv = &a; // 之前我们说过 如果指针类型 和 所指向的变量类型不一致的话 是会出错的
// 但是在void 指针 内就没有任何问题
double d = 3.1415;
void* pd = &d; // 没有问题的
//void 指针 不能进行指针的 + -整数和解引用的操作
*pd = 100;
*pd + 1;
return 0;
}
但是需要注意的是 泛型指针也有局限性
它不能进行指针的±整数和解引用的操作
4 const修饰指针
4.1 const 修饰变量
我们来看一段实例
const int a = 10; // const 修饰a之后 a 不能被修改了 这
// 这并不意味着a变成了常量 实际上 a还是变量 被const修饰后变成了常变量
a = 1; // 报错
printf("%d\n",a);
// 可以通过数组来验证 a是否是常量
arr[a] = 10; // 如果a被const 修饰后就会变成常量那么这里就不会报错 而实际上报错了 因此还是变量
// 注意了 在c++里面 const修饰后的变量就是常量
return 0;
尽管我们说这是一个无法被修改的变量 那它真的不能被修改了嘛
实际上不是的 我们可以通过解引用的操作来改变这个变量
const int n = 10;
int* p = &n;
*p = 20; //
printf("%d\n", n);
return 0;
但是这样是不好的 我们加const就是为了 n不被改变 但是我们还是打破了这个保护 改变了n
因此我们就需要学习const 修饰指针变量
4.2 const 修饰指针变量
在讲之前 我们再来重述一下关于指针的三个重要概念
int a = 10;
int* p = &a
*p = 100
在上述代码中
- p是一个指针变量,里面存放着变量a的地址
- *p 是解引用 *p是指针指向的对象
- p自己也是一个变量 ,也有自己的地址 &p
接下来我们来看两个实例
// const 修饰指针会有两种情况
//1. const 放在了*号左边
// 这种情况限制的是*p 也就是你不能再通过*p去改变 p所指向的对象内容了
// 但是p本身所指向谁是可以改变了 可以让p去指向其他对象
int main9()
{
const int a = 10;
int b = 100;
const int* p = &a;
//*p = 20; // error *p被const修饰了
p = &b; // 可以
printf("%d\n", *p);
return 0;
}
//2. const 放到了*右边 这个时候修饰的是p 也就是不能去改变p本身所指向的对象了
// 但是可以通过*p去改变p指向对象的内容
int main10()
{
const int a = 10;
int b = 100;
int* const p = &a;
*p = 20; // 可以
//p = &b; // error p被const修饰了
printf("%d\n", *p);
return 0;
}
在知道这两种情况之后 以后我们需要对指针进行一些限制的时候 就可以进行相应的操作了
5.指针运算
5.1 指针±整数
我们来看实例:
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
// 打印数组内容
// 之前我们都是用下标来实现的
int sz = sizeof(arr) / sizeof(arr[1]);
//for (int i = 0; i < sz; i++)
//{
// printf("%d ", arr[i]);
//}
// 我们现在用指针来实现
// 我们知道 1. 数组在内存中是连续存放的
// 2. 数组的下标增长 地址是从低到高的
int* p = &arr[0]; // 指针类型要和数组类型是一个类型
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p++; // p + 1 我们知道 是让p所指向的地址跳过一个int的大小的距离 也就是四个字节
}
还有一种写法
//for (int i = 0; i < sz; i++)
//{
// printf("%d ", *(p+i));
//}
return 0;
}
5.2 指针-指针
指针 - 指针的绝对值 得到的是指针和指针之间元素的个数
注意了 指针-指针的前提是 同类型的两个指针要指向同一个空间
有了这个知识我们来看下面的一个实例
5.2.1有关strlen函数的一个复现
int my_strlen(char* p)
{
//int count = 0;
//while (*p != '\0')
//{
// count++;
// p++;
//}
//return count;
// 还有一种写法
char* p1 = p;
while (p1 != '\0')
{
p++;
}
return p - p1; // 找到\0的指针 - 首元素的指针 得到的就是这个字符串的长度
}
int main()
{
char ch[] = "abcdef";
// 数组名在座位参数的时候
// 其实就是 数组首元素的地址
// ch 等价于 &ch[0]
int len = my_strlen(ch);
printf("%d\n", len);
return 0;
}
5.3指针的关系运算
来看一个实例
// 指针的关系运算
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[1]);
int* p = &arr[0];
while (p < arr + sz) // arr + sz 代表的是&arr[10]的地址 尽管不存在这个下标 但是这个地址是存在的 并且p并没有指向&arr[10] 也就不会造成野指针的问题
// 让地址跟地址之间去比较
{
printf("%d ", *p);
p++;
}
return 0;
}
6.野指针
概念: 野指针就是指针指向的空间是不可知的(随机的 未知的 没有明确限制的)
6.1 野指针的成因
造成野指针的原因是什么呢
1.指针变量没有初始化
int* p;
// 指针变量也是局部变量 我们之前讲过函数栈帧的内容 一个局部变量在没有初始化的时候 都是随机值
*p = 20; // 这个指针都没有固定的地址给它指向 怎么进行解引用操作呢
printf("%d\n",*p);
2.指针的越界访问
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[1]);
int* p = &arr[0];
while (p < 11) // 这里不是10是11就会出现野指针的问题
{
printf("%d ", *p);
p++;
}
return 0;
}
为什么会出现野指针的问题呢
因为这个数组一共只有10个元素 当p++ +到10的时候 说明此时指针指向的是&arr[10]这个地址
并且还使用了这个不属于数组的地址
但是实际上 这个地址的空间并不属于这个数组的内存空间 是指针的越界访问了
3.指针指向的空间释放
#include <stdio.h>
intx test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n",*p); // 在这里想解引用的时候 指针所指向的内存空间也就被销毁了
return 0;
}
其实就是指针所指向的内存地址的那个内存空间已经被销毁了
因为函数在返回这个n的地址的时候 已经把变量n的空间给销毁了 这个test的栈帧都被销毁了
这个时候这个p就是野指针了
6.2 如何规避野指针
1. 指针初始化
如果我们明确知道一个指针变量要指向谁 那就直接把相应地址给它就行了
如果我们不知道要给指针初始化什么的话 就给指针赋值一个NULL
int* p = NULL;
NULL就是让p变成一个空指针 给它一个地址0 是无法使用的 也就是没有指向任何地方
要注意一个问题
int* p = NULL;
*p = 20; // 这里是会报错的
因为指向的地址是0 无法使用
我们再来看一个情况
int main()
{
int a = 10;
int* p = &a;
//int* p = NULL; // 这里是不会出错的 因为NULL 不等于 NULL 为假 不执行*p = 20;
if (p != NULL)
{
*p = 20;
printf("%d\n", *p);
}
return 0;
}
但是上述的例子 有个前提是 指针一开始就已经初始化了
因此我们要养成良好习惯 创建指针时要记得初始化 并且后面要加上判断
当不是空指针的时候才去进行解引用之类的一些操作
2.小心指针越界
一个程序向内存申请了那些空间,通过指针也只能访问那些空间 ,不能超出范围去访问
不然就是越界访问了
3.指针变量不再使用时 要及时置NULL 指针使用之前检查有效性
当我们的指针变量指向一块区域的时候,我们可以通过指针去访问该区域,当后期这个区域空间被销毁后 或者不再使用这个指针访问这个空间的时候 我们要及时把该指针设置成NULL。
有一个约定俗成的规划:只要是NULL指针就不去访问,同时 使用指针之前,可以去判断指针是否为NULL
我们来看一个实例:
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[1]);
int* p = &arr[0];
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p++;
}
// 此时的p指针已经越界了 因为最后一步p++了之后就指向了&arr[10] 这个空间是不属于数组arr的
// 这个时候 我们就会给指针一个NULL
p = NULL;
// 下次使用的时候 就直接让指针重新获得地址
p = &arr[0];
if (p != NULL)//判断 // 使用之前检查一下
{
//....
}
return 0;
}
4.避免去返回局部变量的地址
拿我们前面举过的例子来看一下:
#include <stdio.h>
intx test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n",*p); // 在这里想解引用的时候 指针所指向的内存空间也就被销毁了
return 0;
}
这里我们访问test函数的时候 局部变量n被创建出来, 并且地址也会再函数栈帧销毁之前将地址返回出来给到指针变量p, 这个时候p拿着返回的地址 ,等到在要去寻找这个地址的时候 是找不到的 因为内存空间已经被销毁了
这个时候p就变成野指针了
7.assert断言
assert(p != NULL);
这句话的意思其实就是 判断p是否为空指针
# define NDEBUG // 这个是assert的开关 出现了这句代码 就意味着官迷了arrest
# include<assert.h>
int main()
{
int a = 10;
int* p = &a;
assert(p != NULL); // 如果p不是空指针的话 这里什么都不会发生
*p = 20;
printf("%d\n", *p);
}
如果需要判断多个条件 那么就多写几个assert 或者 是 把多个条件都放进去
assert() 的使用是对程序员非常友好的
当然assert 也是有缺点的 那就是引入了额外的检查 增加了程序的运行时间了
还有一个需要注意的点
8.指针的使用和传址调用
8.1 strlen函数的模拟实现
其实我们前面已经写过一个strlen的复现了
但是我们可以在前面代码的基础上进行优化
# include<stdio.h>
# include<assert.h>
// 实现strlen功能的重现(模拟实现)
// strlen(arr) 统计字符串中\0之前的元素的个数 arr就是作为首元素地址传入进去打的
size_t my_strlen(const char* s) // 我们还要思考 我们并不想这个指针进行解引用操作 去修改数组内容 因此我们要做一些限制
// 将int换成size_t 因为长度总会是正数 而不会是负数
{
int count = 0;
// 我们还要考虑如果有人输入的是一个NULL要怎么办
// 这里我们引入assert
assert(s != NULL);
while (*s != '\0')
{
count++;
s++;
}
return count;
}
int main()
{
char arr[] = "abcdefg";
size_t len = my_strlen(arr); // 改成size_t 来接受size_t的返回值
printf("%zd\n", len);
return 0;
}
这个代码我们再来对比一下我们之前写的
int my_strlen(char* p)
{
int count = 0;
while (*p != '\0')
{
count++;
p++;
}
return count;
}
int main()
{
char ch[] = "abcdef";
// 数组名在座位参数的时候
// 其实就是 数组首元素的地址
// ch 等价于 &ch[0]
int len = my_strlen(ch);
printf("%d\n", len);
return 0;
}
我们可以发现经过优化后的代码更加的合理
8.2 传值调用和传址调用
我们来看一个实例
写一个函数,来交换两个整形变量的值
void Swap(int x, int y)
{
int z = 0;
z = x;
x = y;
y = z;
}
int main()
{
int a = 10;
int b = 20;
printf("交换前 a = %d b = %d\n", a, b);
Swap(a,b);
printf("交换后 a = %d b = %d\n", a, b);
return 0;
}
由于形参是实参的一个临时拷贝 形参和实参的内存空间不是同一个内存空间
在函数中对形参进行修改的时候,不会对实参产生影响 因此 交换失败了
我们可以有一个思路 就是通过指针来使函数内的操作与实参建立联系
让函数接受a 和 b 的地址 用指针的解引用操作去完成两个整形的交换
下面是修改后的代码
void Swap(int* pa, int* pb) // 接受两个地址
{
int z = 0;
z = *pa;
*pa = *pb;
*pb = z;
}
int main()
{
int a = 10;
int b = 20;
printf("交换前 a = %d b = %d\n", a, b);
Swap(&a,&b);
printf("交换后 a = %d b = %d\n", a, b);
return 0;
}
上面这个代码的操作叫做传址调用 (传地址过去操作)
有2个需要注意的地方:
- 地址是无法改变的, 因此不要试图通过去改变地址的方式
- return只能返回一个值 像题目中的两个值的情况是无法借助return来实现的
总结:
传址调用:可以让函数和主函数之间建立真正的联系,在函数内部可以修改主函数的变量
所以在以后函数中 如果只需要主调函数中的变量值来进行计算的话 就用传值调用
如果需要在函数内部中修改主调函数的变量的值 那就使用传址调用!
p(a,b);
printf(“交换后 a = %d b = %d\n”, a, b);
return 0;
}
**由于形参是实参的一个临时拷贝 形参和实参的内存空间不是同一个内存空间**
**在函数中对形参进行修改的时候,不会对实参产生影响 因此 交换失败了**
我们可以有一个思路 就是通过指针来使函数内的操作与实参建立联系
让函数接受a 和 b 的地址 用指针的解引用操作去完成两个整形的交换
#### 下面是修改后的代码
```c
void Swap(int* pa, int* pb) // 接受两个地址
{
int z = 0;
z = *pa;
*pa = *pb;
*pb = z;
}
int main()
{
int a = 10;
int b = 20;
printf("交换前 a = %d b = %d\n", a, b);
Swap(&a,&b);
printf("交换后 a = %d b = %d\n", a, b);
return 0;
}
上面这个代码的操作叫做传址调用 (传地址过去操作)
有2个需要注意的地方:
- 地址是无法改变的, 因此不要试图通过去改变地址的方式
- return只能返回一个值 像题目中的两个值的情况是无法借助return来实现的
总结:
传址调用:可以让函数和主函数之间建立真正的联系,在函数内部可以修改主函数的变量
所以在以后函数中 如果只需要主调函数中的变量值来进行计算的话 就用传值调用
如果需要在函数内部中修改主调函数的变量的值 那就使用传址调用!