一、内存和地址
1.1 概念
我们都知道计算机的数据必须存储在内存里,为了正确地访问这些数据,必须为每个数据都编上号码,就像门牌号、身份证号一样,每个编号是唯一的,根据编号可以准确地找到某个数据。
生活中我们把门牌号叫地址,而在计算机中我们把内存单元的编号也称为地址。但是在C语⾔中给地址起了一个新的名字:指针。
所以我们可以理解为:
内存单元的编号 == 地址 == 指针
1.2 取地址操作符(&)
理解了内存和地址的关系,我们再回到C语⾔,在C语⾔中创建变量其实就是向内存申请空间!
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { int a = 10;//变量创建的本质:在内存上开辟空间 //要向内存申请个字节的空间,存放数据10 //10 ---> a //0000 0000 0000 0000 0000 0000 0000 1010 //0x 00 00 00 0a // //%a &---取地址操作符 printf("%p\n", &a); return 0; }
整型变量a,在内存中申请4个字节,用于存放整数10,每个字节都有地址
&a取出的是a所占4个字节中第一个字节的地址(地址较小的那个字节的地址)来打印,例如:0x006FFC0C 。
虽然整型变量占用4个字节,但我们只要知道了第⼀个字节地址,顺藤摸瓜访问到4个字节的数据也是可行的。
1.3 指针变量和解引用操作符(*)
数据在内存中的地址称为指针,如果一个变量存储了一份数据的指针(地址),我们就称它为指针变量。
那我们如何使用指针变量呢?
在酒店中,我们可以通过门牌号准确找到每个客户。同理,我们也可以通过每个地址准确找到每个变量。C语言中也是⼀样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,而在这之前我们必须要学习⼀个操作符叫解引用操作符(*)。
例如:
int* p1;//指向一个整型的指针 char* p2;//指向一个字符的指针 float* p3;//指向一个单精度浮点数的指针 double* p4;//指向一个双精度浮点数的指针
并且我们可以通过指针变量进行赋值。
1. *p1 = 4; 2. *p2 = 'a'; 3. *p3 = 5.0;
1.4 void指针和空指针
(1)void*是一种特殊的指针类型,它可以指向任意类型的数据,就是说可以用任意类型的指针对 void 指针赋值。
void*p1; int*p2; p1=p2;
但是却不能把void*指针赋值给任意指针类型,也不能直接对其解引用
例如:
void*p1; int *p2; //这是错误的赋值方式与解引用方式 p2=p1; *p1
(2)NULL 是C语⾔中定义的⼀个标识符常量,值是0,地址也是0,这个地址是⽆法使⽤的。
int*p=NULL;//初始化指针
1.5 指针变量的大小
我们先运行以下代码
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { int num = 10; int* p = # char ch = 'w'; char* pc = &ch; printf("%u\n", sizeof(p)); printf("%u\n", sizeof(pc)); return 0; }
结果为如下所示:
我们发现两种类型的指针变量大小都是8个字节,这是为什么呢?
首先我们要明白指针是用来干什么的?指针是为了存放地址,而地址的大小取决于存储一个地址需要多大的空间。
我们知道,现在常见的计算机分为32位机器和64位机器。地址是由地址总线产生的,32位的机器有32根地址线,地址上传输过来的电信号转换成数字信号后,得到的32个0/1组成的序列就是地址。地址都是32个0/1组成的二进制序列的话,那么存放这个地址所需要的空间大小是4个字节。所以指针变量的大小都是4个字节。
同理64位机器,假设有64根地址线,⼀个地址就是64个⼆进制位组成的⼆进制序列,存储起来就需要8个字节的空间,指针变的大小就是8个字节。
输出结果:
32位机器:4 4
64位机器:8 8
二、指针变量的运算
2.1 指针+/-整数
我们先观察一下如下代码的地址变化
#include <stdio.h> int main() { int n = 10; char* p1 = (char*)&n;//将int*强转为char* int* p2 = &n; printf("%p\n", &n); printf("%p\n", p1); printf("%p\n", p1 + 1);//p1向后移动一位 printf("%p\n", p2); printf("%p\n", p2 + 1);//p2向后移动一位 return 0; }
输出结果如下:
我们可以看出, char* 类型的指针变量+1跳过1个字节, int* 类型的指针变量+1跳过了4个字节。由此我们得出结论:指针的类型决定了指针向前或者向后⾛⼀步有多⼤(距离)。
因为每次代码运行时,系统都会重新分配内存,所以输出结果每次都不会一样,但是规律是一样的。
我们知道数组在内存中是连续存储的(地址由低到高),所以我们只需要首元素的地址就能顺藤摸瓜就能找到后面的所有元素。
代码如下:
#define _CRT_SECURE_NO_WARNINGS #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; }
编译结果如下:
2.2 指针-指针
前提条件:两个指针指向同一块空间。
指针 - 指针得到的是两个指针之间元素的个数。
例如:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int my_strlen(char* str) { char* start = str; while (*str != '\0') { str++; } return str - start; } int main() { char arr[] = "abcdef"; int len = my_strlen(arr); printf("%d\n", len); return 0; }
输出结果为6
2.3 指针的关系运算
我们知道了指针变量本质是存放的地址,而地址本质就是十六进制的整数,所以指针变量也是可以比较大小的。
前面我们通过循环的方式实现对数组的访问,而通过比较指针的大小,我们也可以实现对数组的访问,例如
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int sz = sizeof(arr) / sizeof(arr[0]); //int* p=&arr[0]; int* p = arr;//数组名就是首元素的地址 while (p < arr + sz)//指针大小的比较 { printf("%d ", *p); p++; } return 0; }
编译结果如下:
三、const修饰指针
我们知道变量是可以改变的,但是在有些场景下,我们不希望变量改变,那我们该怎么办呢?这就是我们接下来要讲的const的作用啦。
3.1 指针修饰变量
简单来说,经过const修饰的变量,可以当做一个常量,而常量是不能改变的
1. int a = 1;//a可修改的 2. const int b = 2; 3. b=3;//b不可修改的
但我们可以使用指针来修改:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { const int n = 100; int* p = &n; *p = 10; printf("%d\n", n); return 0; }
运行结果为10
通过代码我们发现,虽然const修饰变量n后,我们无法直接修改,但是当我们把变量的地址取出来,通过解引用操作*p=10,我们依然能够修改n的值。
这就好比我们发现门关了,就从窗户爬进去,这种爬窗户的行为显然是我们所不能接受的。
这显然也不合理,我们应该限制p也不能修改。要做到这种效果,需要我们用const来修饰指针。
3.2 指针修饰指针
我们知道const的作用后,就可以看看下面几段代码。
1. int a = 10; 2. const int* p = &a; 3. *p = 20;//是否可以 4. p = p + 1;//是否可以
通过测试我们发现,*p无法改变成20,但是p可以改变成p+1.
那如果把const调换一下位置,又会出现什么情况呢~
1. int a = 10; 2. int* const p = &a; 3. *p = 20;//是否可以 4. p = p + 1;//是否可以
再次测试之后我们发现,*p可以被赋值为20,但是p不能赋值为p+1了
通过上述测试,我们大致可以总结出两个结论。
const如果放在int*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本⾝的内容可变。
const如果放在int*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。
四、assert断言
assert是一个宏,它的头文件为<assert.h>,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报错终⽌运⾏。这个宏常常被称为“断⾔”。
举一个简单的例子:
assert(a>0);
- 如果a的确大于0,assert判断为真,就会通过。
- 如果a不大于0,assert判断为假,就会报错。
所以assert常常用于检查空指针问题,以防止程序因为空指针的问题而出错。
1. int *p=NULL; 2. assert(p);//空指针是0,0为假,就会报错
但是assert() 也是有缺点的,因为引入了额外的检查,增加了程序的运行时间。
⼀般我们可以在debug中使用,在release版本中选择禁用assert就行,在VS这样的集成开发环境中,在release版本中,直接就是优化掉了。这样在debug版本写有利于程序员排查问题,在release版本不影响用户使用时程序的效率。
五、传值调用与传址调用
5.1 传值调用
学习指针的目的是使用指针解决问题,那什么问题,非指针不可呢?
例如:写⼀个函数,交换两个整型变量的值
⼀番思考后,我们可能写出这样的代码:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> 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 | 0x0000003c41d9fb34 {3} | int * |
▶ | &b | 0x0000003c41d9fb54 {4} | int * |
▶ | &x | 0x0000003c41d9fb10 {3} | int * |
▶ | &y | 0x0000003c41d9fb18 {4} | int * |
在调用Swap1函数时,将a和b传递给了Swap1函数,在Swap1函数内部创建了形参x和y接收a和b的值,x和y确实接收到了a和b的值,不过x的地址和a的地址不⼀样,y的地址和b的地址不⼀样,相当于x和y是独立的空间,那么在Swap1函数内部交换x和y的值,自然不会影响a和b。
当Swap1函数用结束后回到main函数,a和b的没法交换。Swap1函数在使用的时候,是把变量本身直接传递给了函数,这种调用函数的方式我们之前在函数的时候就知道了,这种叫传值调用。
总结:
因为形参只是实参的一份临时拷贝,对形参改变,根本不会改变实参。
5.2 传址调用
那怎么办呢?
我们现在要解决的就是当调用Swap1函数的时候,Swap函数内部操作的就是main函数中的a和b,直接将a和b的值交换了。那么就可以使用指针,在main函数中将a和b的地址传递给Swap1函数,Swap函数里边通过地址间接的操作main函数中的a和b就好了。
修改后的代码:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> 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函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用。
六、野指针
6.1 野指针的成因
那野指针是怎么产生的呢?
一般来说,产生野指针原因有3种:1. 指针未初始化;2. 指针越界访问;3. 指针指向的空间释放
6.1.1 指针未初始化
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { int *p;//局部变量指针未初始化,默认为随机值 *p = 20; return 0; }
编译器会发生报错:
6.1.2 指针越界访问
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> 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; }
运行结果如下:、
6.1.3 指针指向的空间释放
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int* test() { int n = 100; return &n; } int main() { int* p = test(); printf("%d\n", *p); return 0;
运行结果如下:
est函数返回之后,确实把n的地址带回来放到p里边去了。但是有一个现象需要我们注意,这个n是个局部变量,进入函数创建,出函数就销毁了。也即是进入函数后存放n的4个字节拿到了,出函数就还给操作系统了,但是p中还存放着这个地址。如果通过解引用操作给它赋值20,将20这个值放到n里面去,这样就非常危险。
6.2 如何规避野指针的出现
6.2.1 初始化
如果明确知道指针指向哪里就直接赋值地址,如果不知道指针应该指向哪⾥,可以给指针赋值NULL。
NULL 是C语⾔中定义的⼀个标识符常量,值是0,0也是地址,这个地址是无法使用的,读写该地址会报错。
初始化如下:
#define _CRT_SECURE_NO_WARNINGS #include <stdio.h> int main() { int num = 10; int*p1 = # int*p2 = NULL; return 0; }
6.2.2 小心越界访问
⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。要解决越界访问,只能小伙伴们自己去解决,多敲代码。
6.2.3 不访问临时变量的地址
临时变量出了作用域就会销毁,系统会回收该空间,所以我们要尽量避免指针指向已经销毁的空间,尤其在函数中,不能返回临时变量的地址。