前言:
大家好呀,我是Humble,今天要给大家开一个新坑
我们将花大概五个博客左右的篇章去理解C语言的指针
然后,我们会配上课后习题 ,方便大家去复习
喜欢的话就点个赞支持一个博主吧,也欢迎各位订阅这个专栏
废话不多说,开始指针的第一篇吧!
一.内存和地址
1.内存
指针这个概念在我们初次接触时可能觉得比较抽象,我们可以拿生活中的案例去类比:
在生活中,我们会给一栋楼的每个房间一个房间号,有了房间号,我们就能快速的找到房间’
其实在计算机中,内存空间的管理也是如此
我们会把内存划分成一个个的单元,每个内存单元的大小取1个字节
每个内存单元也都会有一个编号,就相当于现实中的房间号,有了这个编号,计算机的CPU在处理数据时也能快速的找到对应的内存空间
于是乎,我们就引出了指针
在计算机中我们把内存单元的编号叫做地址,而C语言给地址起了一个新的名字,指针
所以我们可以这样理解:
内存单元的编号==地址==指针
2.编址的理解
关于硬件编址,首先,必须理解,计算机内是有很多的硬件单元,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。 但是硬件与硬件之间是互相独立的,那么如何通信呢?
答案很简单,用"线"连起来。 而CPU和内存之间也是有大量的数据交互的,所以,两者必须也用线连起来。 不过,我们今天关心一组线,叫做地址总线。
我们可以这样理解,32位机器有32位地址总线
每根线只有两态,表示0,1(电脉冲有无),那么一根线,就能表示2种含义,2根线就能表示4种含 义,依次类推。32根地址线,就能表示2的32次方种含义,每一种含义都代表一个地址。 地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入 CPU内的寄存器中
二.指针变量和地址
当我们对C语言的内存与指针的关系有了一定的理解
我们就可以讲上一章的操作符没讲过的两个单目操作符&(取地址)和*(解引用)了
先来看一下&吧
1.取地址操作符(&)
在C语言中,创建变量其实就是在向内存申请空间
比如我们在main函数中写了 int a = 10;就表示创建了整形变量a,内存中申请了4个字节的空间用于存放整数10;而其实每个字节都有对应的地址,这时我们就可以用&操作符得到a的地址了
解释一下,&a取出的是a所占的4个字节中地址最小的字节的地址
我们知道在a所占的四个字节是连续的(因为在变量创建时向内存申请的就是一块连续的空间)
所以虽然整形变量占4个字节,且我们只能打印第一个字节,但正因为我们知道了第一个字节的地址,我们就可以顺藤摸瓜找到剩余的数据
2.指针变量
我们通过取地址操作符(&)拿到了地址,这个地址是一个数值,有时候也是需要存起来的,方便后期的使用,那我们把这样的值存放在哪里呢?
答案是:指针变量中
指针变量也是一种变量,这种变量就是用来存放地址的
最后在注意一个点
我们知道,指针其实是地址,而在口头语中,指针的本意我们 几乎不会去说,且我们习惯在口头表述中将指针变量简化的说成指针。
故,我们通常说的指针其实都是指针变量,而非原本的指针的含义,大家要注意区分
3.如何拆解指针类型
我们看到pa的类型是 int* ,我们该如何理解指针的类型呢?
int a = 10;
int * pa = &a;
这里的pa左边是int*,*是在说明pa是指针变量,而前面的int是在说明pa指向的是整形类型的对象
同理如果有一个char类型的变量ch,ch的地址要放在什么类型的指针变量中,聪明的你应该知道了吧
char ch = 'w';
pc = &ch;//pc 的类型怎么写呢?
4.解引用操作符(*)
我们将地址保持起来的目的是因为未来要使用,那怎么使用呢?
我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针) 指向的对象,
这里必须学习一个操作符叫解引用操作符(*)
请看下面的代码
*pa 的意思就是通过pa中存放的地址,找到指向的空间, *pa其实就是a变量了;
所以*pa = 0,这个操作符是把a改成了0.
这里是把a的修改交给了pa来操作,这样对a的修改,就多了一种途径,写代码就会更加灵活,
说到这,不知道大家有没有发现,其实我们的&操作符和*操作符它们其实是一对
一来一去,可以取地址,拿到地址又可以解引用,希望大家也能去体会它们之间的关系
所以至此,我们的操作符算是真正告一段落了(鼓掌鼓掌鼓掌)
5.指针变量的大小
int main()
{
int a = 10;
int* pa = &a;//取出a的地址并存储到指针变量pa中
return 0;
}
前面我们讲了指针变量(例如上面的pa),那么它的大小究竟是多少呢?
下面是一个思考的方式:
1.指针变量是专门用来存放地址的,所以指针变量的大小取决于一个地址的存放需要多大空间
那一个地址有多大呢?我们来到2
2.32位机器上地址线是32根,地址的2进制序列就是32bit位,要把这个地址存起来,需要4个字节的空间(32bit/8=4字节)所以32位机器上指针变量的大小都是4个字节
好,为了验证这个说法,我们来看下面的代码
同理,64位机器上指针变量的大小应该为8个字节
我们也看一下
总结一下:
32位平台下地址是32个bit位,指针变量大小是4个字节
64位平台下地址是64个bit位,指针变量大小是8个字节
注意:指针变量的和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的
大家看到这可能会有这样一个疑问:
既然指针变量的大小都是4或者8个字节,我们为什么还要有这么多不同的类型呢?
其实指针类型是有特殊意义的,这就涉及到我们接下来要给大家说的指针变量类型的意义了,我们接下来继续学习
三. 指针变量类型的意义
1.指针的解引用
对比,下面2段代码,主要在调试时观察内存的变化
int main()
{
int n = 0x11223344;
int* pi = &n;
*pi = 0;
return 0;
}
int main()
{
int n = 0x11223344;
char* pc = (char*)&n;
*pc = 0;
return 0;
}
下面先来看第一段代码 ,我们来到*pi=0这条语句执行以前看一下 n 此刻存的11 22 33 44
(因为是十六进制,所以两个数就表示1个字节)
当我们执行*pi=0后,请看下面的图
这四个字节全部变成了0 即00 00 00 00
那我们在来看一下第二段代码调试的过程
第一张图是跟第一段代码一样的,我们重点看一下下一张图,当我们执行了*pi=0这条语句后,看一下发生了什么变化
我们发现,这次只有一个字节44 变成了00!
所以从调试我们可以直观的看到,代码1会将n的4个字节全部改为0,但是代码2只是将n的第⼀个字节改为0
这样我们也就能看出指针类型的意义了
指针的类型决定了,对指针解引用的时候有多大的权限(一次能操作几个字节) 比如:
char* 的指针解引用就只能访问一个字节,而 int* 的指针的解引用就能访问四个字节
2.指针+-整数
先看一段代码,运行观察地址
int main()
{
int n = 10;
char* pc = (char*)&n;
int* pi = &n;
printf("%p\n", &n);
printf("%p\n", pc);
printf("%p\n", pc + 1);
printf("%p\n", pi);
printf("%p\n", pi + 1);
return 0;
}
我们可以看出, char* 类型的指针变量+1(也就是这里的pc+1,即第三个printf
)跳过1个字节, int* 类型的指针变量+1(也就是这里的pi+1,即第五个printf)跳过了4个字节。 这就是指针变量的类型差异带来的变化
结论:指针变量的类型决定了指针向前或者向后走一步有多大(距离)
这再次向我们说明了指针变量类型的意义
3.void*指针
在指针类型中有一种特殊的类型是 void* 类型的,可以理解为无具体类型的指针(或者叫泛型指 针),这种类型的指针可以用来接受任意类型地址。
但是也有局限性, void* 类型的指针不能直接进行指针的+-整数和解引用的运算
举个例子:
int main()
{
int a = 10;
int* pa = &a;
char* pc = &a;
return 0;
}
在上面的代码中,将一个int类型的变量的地址赋值给⼀个char*类型的指针变量。编译器给出了⼀个警 告(如上图),是因为类型不兼容。而使用void*类型就不会有这样的问题
这就是void类型的包容性
但它也有自己的局限性
请看下面的代码,当我们使用void*类型的指针接收地址:
int main()
{
int a = 10;
void* pa = &a;
void* pc = &a;
*pa = 10;
*pc = 0;
return 0;
}
*pa++;
*pc++;
同理,*pa++;
*pc++;
也是不被允许的
这里我们可以看到, void* 类型的指针可以接收不同类型的地址,但是无法直接进行指针运算
那么 void* 类型的指针到底有什么用呢?
一般 void* 类型的指针是使用在函数参数的部分,用来接收不同类型数据的地址,这样的设计可以使得一个函数来处理多种类型的数据(当我不知道要处理的是int char 还是别的什么的时候,可以先给一个void,深入的内容我们在下面几篇将指针的博客中会讲解,感兴趣的小伙伴不要忘记订阅我的专栏哦)
四.const修饰指针
变量是可以修改的,如果把变量的地址交给一个指针变量,通过指针变量的也可以修改这个变量。 但是如果我们希望一个变量加上一些限制,不能被修改,怎么做呢?这就是const的作用
int main()
{
int m = 0;
m = 20;//m是可以修改的
const int n = 0;
n = 20;//n是不能被修改的 如果我们强行运行的话会发现程序会报错
return 0;
}
上述代码中n是不能被修改的,其实n本质是变量,只不过被const修饰后,在语法上加了限制,只要我 们在代码中对n修改,就不符合语法规则,就报错,致使没法直接修改n
但是如果我们绕过n,使用n的地址,去修改n就能做到了,虽然这样做是在打破语法规则
int main()
{
const int n = 0;
printf("n = %d\n", n);
int*p = &n;
*p = 20;
printf("n = %d\n", n);
return 0;
}
我们可以看到这里n确实修改了,但是我们还是要思考一下:
为什么n要被const修饰呢?就是为了 不能被修改
如果p拿到n的地址就能修改n,这样就打破了const的限制,这是不合理的!!!
所以应该让 p拿到n的地址也不能修改n 那接下来怎么做呢?
请看下面的代码
我们在* 的左边+上const
这时,我们如果运行,就会发现n的值不能修改了
这里还要给大家讲几个重要的点:
const修饰指针变量的时候, const可以放在*的左边,const也可以放在*的右边
所以其实
const int *p
int const *p 它们的效果是一样的(当然不要重复放)
当我们写成 int *const p的时候这时上面代码中n的值照样会被改
所以 const如果放在*的左边,,它限制的是*p,但没有限制p
const如果放在*的右边,,它限制的是p,但没有限制*p
最后再总结一遍:
const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变,即*p是受限制的,*p =20是err的 但是指针变量本身的内容可变,即p = &b 是ok的
const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,即即p = &b 是err的但是指针指向的内容,可以通过指针改变,即*p =20是ok的
五.指针运算
我们学习指针,目的就是要拿来运算的,所以接下来我们来讲一下指针的运算
指针的基本运算有三种,分别是:
1.指针+- 整数
2. 指针-指针
3. 指针的关系运算
下面我们先来讲一下 指针+- 整数
1.指针+-整数
其实指针+-整数我们在上面已经见过了,比如上面代码中的pc+1或者说pc++
我们直接看下面的代码:
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;
}
上面的代码中,我们用指针实现了打印数组
这个不难理解,接下来我们再来讲一种指针的运算:指针-指针
2.指针-指针
我们来看下面的一段代码:
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("%d\n", &arr[9] - &arr[0]);
return 0;
}
它的结果是多少呢?是36吗?
我们运行一下
结果是9,这就是指针-指针,它得到的是指针与指针之间的元素个数
但是,这个指针-指针不能乱来,指针-指针运算的前提条件是:两个指针指向同一块空间
什么意思呢?请看下面的代码
int arr[100]= {1,2,3,4,5,6,7,8,9,10};
char ch[20] = {0};
printf("%d\n",&ch[0]-&arr[0]);
这个代码是不能计算的,因为我们不知道它俩之间隔多远,而且要按哪个类型计算我们也不知道
所以指针与指针之间是不能乱减的
那么指针-指针有什么用呢?
我们举个例子:
int my_strlen(char* s)
{
int count = 0;
while (*s != '\0')
{
count++;
s++;
}
return count;
}
int main()
{
printf("%d\n", my_strlen("abc"));
return 0;
}
下面是用了指针-指针的
int my_strlen(char *s)
{
char *p = s;
while(*p != '\0' )
p++;
return p-s;
}
int main()
{
printf("%d\n", my_strlen("abc"));
return 0;
}
上面的代码中的第二个就可以看出指针-指针的作用
3.指针的关系运算
指针的关系运算其实就是指针(地址)比较大小
我们来举个例子:
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]);
while(p<arr+sz) //指针的⼤⼩⽐较
{
printf("%d ", *p);
p++;
}
return 0;
}
这里的p 与arr 就是指针与指针的大小比较
六.野指针
概念: 野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
1.野指针成因
1. 指针未初始化
int main()
{
int *p;//局部变量指针未初始化,默认为随机值
*p = 20;
return 0;
}
2. 指针越界访问
int main()
{
int arr[10] = {0};
int *p = &arr[0];
int i = 0;
for(i=0; i<=11; i++)
{
//当指针指向的范围超出数组arr的范围时,p就是野指针
*(p++) = i;
}
return 0;
}
3.指针指向的空间释放
int* test()
{
int n = 100;
return &n;
}
int main()
{
int*p = test();
printf("%d\n", *p);
return 0;
}
2.如何规避野指针
要规避野指针,我们就要根据从成因入手,一个一个解决
1.指针初始化
如果明确知道指针指向哪里就直接赋值地址,如果不知道指针应该指向哪里,可以给指针赋值NULL.
初始化如下:
int main()
{
int num = 10;
int*p1 = #
int*p2 = NULL;
return 0;
}
2.小心指针越界
一个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是 越界访问
3.1避免返回局部变量的地址
如造成野指针的第3个例子,不要返回局部变量的地址
3.2指针变量不再使用时,及时置NULL,指针使用之前检查有效性
当指针变量指向一块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的 时候,我们可以把该指针置为NULL。因为约定俗成的一个规则就是:只要是NULL指针就不去访问, 同时使用指针之前可以判断指针是否为NULL
int main()
{
int arr[10] = {1,2,3,4,5,67,7,8,9,10};
int *p = &arr[0];
for(i=0; i<10; i++)
{
*(p++) = i;
}
p = NULL;
//下次使⽤的时候,判断p不为NULL的时候再使⽤
//...
p = &arr[0];//重新让p获得地址
if(p != NULL) //判断
{
//...
}
return 0;
}
七.assert断言
断言,assert,是assert.h头文件定义的宏,
用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行
使用断言可以创建更稳定,品质更好且不易于出错的代码
assert(p!=NULL);
在程序运行到这一行码语句时,它会验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示
这是在避免野指针时的一个用法,值得注意的是,()里放的只要是表达式,断言都可以被执行
assert() 宏接受一个表达式作为参数。如果该表达式为真(返回值非零), assert() 不会产生任何作用,程序继续运行,如果该表达式为假(返回值为零), assert() 就会报错,
当然我们在使用前要包含assert.h的头文件
#include <assert.h>
当我们已经确定程序没有问题的时候,我们只要在#include<assert.h>语句的前面加上一个宏定义,程序就不会执行这句断言了
代码如下:
#define NDEBUG
#include <assert.h>
八.传值调用和传址调用
好,我们前面学了那么多指针的知识,我们的目的是什么?当然是为了使用
既然是为了使用,就意味着有些问题非指针不可
我们之前学过函数,知道将一个功能封装到函数中可以加强主函数的可读性,比如下面这个代码,我们将加法功能封装成一个函数
int add(int a,int b)
{
return a + b;
}
int main()
{
int m = 10;
int n = 20;
int x = add(m, n);
printf("m+n=%d",x);
return 0;
}
这没什么问题,然后我们想以同样的做法,写一个函数,交换两个变量的值
一番思考后,我们可能写出下面这样的代码:
void Swap1(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap1(a, b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
当我们运行代码时,结果如下:
我们发现其实没发生交换的效果,这是为什么呢?
调试一下,看看原因吧
我们发现在main函数内部,创建了a和b,
a的地址是0x000000b8832ff824
名称 | 值 | 类型 | |
---|---|---|---|
▶ | &a | 0x000000b8832ff824 {10} | int * |
b的地址是0x000000b8832ff844
名称 | 值 | 类型 | |
---|---|---|---|
▶ | &b | 0x000000b8832ff844 {20} | int * |
在调用 Swap1函数时,将a和b传递给了Swap1函数,在Swap1函数内部创建了形参x和y接收a和b的值,
但是
x的地址是0x000000b8832ff800
名称 | 值 | 类型 | |
---|---|---|---|
▶ | &x | 0x000000b8832ff800 {10} | int * |
y的地址是0x000000b8832ff808
名称 | 值 | 类型 | |
---|---|---|---|
▶ | &y | 0x000000b8832ff808 {20} | int * |
x和y确实接收到了a和b的值,不过x的地址和a的地址不一样,y的地址和b的地址不一样,相当于x和y是独独立空间,那么在Swap1函数内部交换x和y的值, 自然不会影响a和b,当Swap1函数调用结束后回到main函数,a和b的没法交换。
像Swap1函数在使用的时候,就是把变量本身直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用
结论:实参传递给形参的时候,形参会单独创建一份临时空间来接收实参,对形参的修改不影响实 参。 所以Swap是失败的了
那我们该怎么办呢?
那就要说到指针的传址调用了
我们现在要解决的就是:当调用Swap函数的时候,Swap函数内部操作的就是main函数中的a和b,直接将a和b的值交换了。那么就可以使用指针了,在main函数中将a和b的地址传递给Swap函数,Swap 函数里边通过地址间接的操作main函数中的a和b,并达到交换的效果就好
代码如下:
void Swap2(int* px, int* py)
{
int tmp = 0;
tmp = *px;
*px = *py;
*py = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap2(&a, &b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
运行一下,结果如下:
我们可以看到实现成Swap2的方式,顺利完成了任务,这里调用Swap2函数的时候是将变量的地址传递给了函数,
这种函数调用方式叫:传址调用
看到这可能有小伙伴疑惑那我们未来写函数是用传值调用还是传址调用呢?
这里跟大家说明一下:
如果函数中只是需要实参的值来实现计算,那我们就采用传值调用,像add函数
而如果函数内部要修改原来实参的值,就需要传址调用,像这里的Swap函数
九.课后习题
温馨提示:大家也可以自己去牛客网找到一些适合自己的题目去做哦
结语:
好了,今天的分享就到这里了
最后,希望大家点个赞或者关注吧(感谢感谢)
让我们在接下来的时间里一起成长,一起进步吧!