目录
6.2.3 指针变量不再使用时,及时置为NULL,指针使用前检查有效性
1.内存和地址
1.1 内存
生活中,一栋楼的各个房间都会有它的编号,比如:一楼有101,102,103……二楼有201,202,203……有了房间号,我可以快速地找到我要去的房间,提高效率。
同理,在计算机中:计算机上的CPU(中央处理器)在处理数据是,需要的数据是在内存中读取的,处理后的数据也会放回内存中。
人们将内存划分为一个个的内存单元,每个内存单元的大小取一个字节。(1byte = 8bit)
其中,每个内存单元相当于一个宿舍,一个字节空间里存放八个比特,相于八人间,每一个人是一个比特位。
每个内存单元也都有一个编号(这个编号相当于宿舍房间的门牌号),有了这个内存单元的编号,CPU可以快速找到一个内存空间。
生活中我们把门牌号也叫做地址,在计算机中我们把内存单元的编号也称为地址,C语言中给地址起了新名字叫做:指针。
所以我们可以理解为:
内存单元的编号 == 地址 == 指针
1.2 编址
首先,计算机有很多硬件单元,而硬件单元之间要相互协同工作的,而协同,至少相互之间要能够进行数据传递。我们通过“线”将硬件和硬件连接起来。而CPU和内存之间也是有大量的数据交互的,所以两者之间也必须用“线”连接起来。
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,而因为内存中字节很多,所以需要给内存进行编址(就如同宿舍门牌号)。
计算机中的编址,并不是把每一个字节的地址记录下来,而是通过硬件设计完成的。
可以这么理解,32为机器有32根地址总线,每一根都只有两种状态,表示为0和1,那么一根线就能表示两种含义,32根线就能表示2^32种含义,每一种含义都代表一个地址。
地址的信息被下达给内存,在内存上,就可以找到该地址的对应的数据,将数据在通过数据总线传入CPU内寄存器。
所以可知,内存被划分为一个个的内存单元,一个内存单元的大小是1个字节。每个内存单元都会有一个编号,这个编号就是地址,C语言中把地址又称为“指针”。即编号==地址==指针。
2.指针变量和地址
2.1 取地址操作符(&)
在C语言当中创建变量其实就是向内存申请空间。
int main()
{
int a = 10;
printf("%p", &a);
return 0;
}
这段代码的表面意义是创建一个变量 a,并赋值为10。它的深层意义是在内存上申请4个字节的空间,存放10。
为了便于观察每个字节的地址,int a = 0x11223344
上图中4个字节的地址分别是:
0x006818A2
0x006818A3
0x006818A4
0x006818A5
a的地址是地址较小的字节的地址(比如上图的44的地址) ,只要知道了第一个字节的地址,就可以访问到4个字节的数据。
2.2 指针变量和解引用操作符(*)
2.2.1 指针变量
我们通过取地址操作符得到的地址是一个数值,比如:0x006818A2 ,如果想在后续使用它,就需要将它存放在指针变量中。
int main()
{
int a = 0x11223344;
printf("%p", &a);
int* pa = &a;//pa是指针变量 - 存放地址
return 0;
}
指针变量也是一种变量,存放在指针变量中的数值都会理解为地址。
2.2.2 如何拆解指针类型
这里的int* pa = &a 中 int 指的是pa指向的对象是int类型的,* 指的是说明pa是指针变量。
int *pa
int* pa
int * pa
是一样的
指针 和 指针变量
地址 变量 - 存放地址 (口头说的指针一般是指针变量)
2.2.3 解引用操作符
我们将 地址保存起来,后续怎么使用呢?
在现实生活中,我们使用地址要找到一个房间,在房间里可以拿去和存放物品。
在C语言中,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这里引入一个操作符叫解引用操作符(*)。
上面代码的第七行使用了解引用操作符(*),*pa指的是通过pa存放的地址找到它所指的对象a,所以*pa就等价于a。第八行中*pa = 20,就等于将a的值改成20。
通过pa来修改a的值,对a的修改多了一种途径,写代码就会更加灵活。
2.3 指针变量的大小
指针变量是专门用来存放地址的 ,放在里头的任何东西都看作地址。指针变量的大小取决于一个地址的存放需要多大的空间。
在前面讲到的,32位机器假设有32根地址线,每根地址线出来的电信号会转换为1或0。那么我们将32根地址线产生的2进制序列当作一个地址,那么一个地址就是32个bit位,需要4个字节才能储存。
如果使用指针变量用来存放地址,那么指针变量的大小就得是4个字节的空间。
同理的,64位机器,假设有64根地址线,一个地址就是64个bit位,需要8个字节的空间,指针变量的大侠就是8个字节。
#include <stdio.h>
//指针变量的大小取决于地址的大小
//X86下地址是32个bit位
//X64下地址是64个bit位
int main()
{
char* pa = NULL;
short* pb = NULL;
int* pc = NULL;
double* pd = NULL;
printf("%zd\n", sizeof(pa));
printf("%zd\n", sizeof(pb));
printf("%zd\n", sizeof(pc));
printf("%zd\n", sizeof(pd));
return 0;
}
注意,指针变量的大小和类型是无关的,只要指针类型的变量在相同的平台下,大小都是相同的。
3.指针变量类型的意义
指针变量的大小和类型是无关的,只要指针类型的变量在相同的平台下,大小都是相同的,为什么还要有各种各样类型的指针变量呢?
3.1 指针的解引用
对比下面两段代码,观察其调试时的内存变化。
#include <stdio.h>
int main()
{
int a = 0x11223344;
int* pa = &a;
*pa = 0;
return 0;
}
调试如下:
4个字节全都改为0 。
如果将 int 类型改成 char 类型呢?
#include <stdio.h>
int main()
{
int a = 0x11223344;
char* pa = &a;
*pa = 0;
return 0;
}
调试如下:
只修改了第一个字节。
由此可知,指针的类型决定了,对指针解引用的时候有多大的权限(一次可以操作几个字节)。
就比如 char* 的指针解引用就只能访问1个字节,而 int* 的指针解引用可以访问4个字节。
3.2 指针+-整数
我们可以看出, char* 类型的指针变量+1跳过1个字节,int* 类型的指针变量+1跳过了4个字节,这就是指针变量的类型差异带来的变化。
所以,指针的类型决定了指针向前或向后走一步有多大。
3.3 void* 指针
在指针类型中有一种特殊的类型是 void* 类型,可以理解为无具体的指针(或者叫泛型指针)。这种指针可以用来接受收任意地址,但是也有局限性,void* 类型的指针不能直接进行指针的+-整数和解引用的运算。
#include <stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* pc = &a;
return 0;
}
这种写法将一个 int 类型的变量的地址赋给了一个 char* 类型的指针变量,编译器给了如下警告,是因为类型不兼容。
而使用 void* 类型就不会报警。
void* 类型是无法进行解引用运算和指针+-整数运算的:
#include <stdio.h>
int main()
{
int a = 10;
void* pc = &a;
*pc = 10;
pc++;
return 0;
}
根据 void* 指针的性质,可以把它看成是一个“垃圾桶”,什么都能往里放,但是它无法进行其他指针可以的运算。
那么 void* 类型的指针有什么用呢?
一般的,void* 类型的指针式使用在函数参数的部分,用来接受不同的类型的数据地址,这样的设计可以实现泛型编程的效果。
4.const修饰指针
4.1 const修饰变量
变量是可以修改的,如果把变量的地址交给一个指针变量,通过指针变量也可以修改这个变量。
但是如果我们希望一个变量加上一些限制,不能被修改,这就要用到const了。
a的值不能被修改了,但是a的本质还是变量,const仅仅式在语法上做了限制,所以我们习惯上叫a是常变量。
“进不了正门,那就爬窗户”。我们绕过n,使用n的地址去修改n,虽然可以,但是“爬窗户”的行为是危险的。
const修饰指针的时候,const可以放在左边,也可以放在右边,两种写法的作用不同。
1.p里边存放的是地址(a的地址);
2.p是变量,有自己的地址;
3.*p是p指向的一块空间。
#include <stdio.h>
int main()
{
const int a = 10;
int const* p = &a;//限制的是*p
*p = 0;//err //指的是:不能通过p来修改p指向的空间的内容
int b = 20;
p = &b;//ok
printf("%a = %d\n", a);
return 0;
}
const放在 * 的左边限制的是 *p ,意思是不能通过指针变量p来修改p指向的空间的内容,但是p不受限制;
#include <stdio.h>
int main()
{
const int a = 10;
int *const p = &a;const限制的是p
*p = 0;//ok
int b = 20;
p = &b;//err
printf("%a = %d\n", a);
return 0;
}
const放在 * 的右边限制的是p ,意思是p变量不能被修改,没办法再指向其他的变量了,但是*p 不受限制,还是可以通过p来修改p所指向的空间的内容。
5.指针运算
指针的基本运算有三种,分别是:
指针+-整数
指针-指针
指针的关系运算
5.1 指针+-整数
因为数组再内存中是连续存放的,只要知道第一个元素的地址,就能找到后面的所有元素。
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
也可以这么写:
5.2 指针-指针(地址-地址)
指针-指针运算的前提条件是:两个指针指向统一块空间。
指针-指针的绝对值是指针和指针之间的元素的个数。
指针-指针到底有什么作用呢?
这里要补充以下strlen的知识点:
把一串字符串传给strlen的时候,传的不是字符串本身,而是首个字符的地址。不统计\0。
如果我要实现strlen这样的函数:
#include <stdio.h>
int my_strlen(char* s)
{
int count = 0;
while (*s != '\0')
{
count++;
s++;
}
return count;
}
int main()
{
int len = my_strlen("abc");
printf("%d\n", len);
return 0;
}
结合指针-指针的思想:
#include <stdio.h>
int my_strlen(char* s)
{
char* start = s;
while (*s != '\0')
{
s++;
}
return s - start;
}
int main()
{
int len = my_strlen("abc");
printf("%d\n", len);
return 0;
}
5.3 指针的关系运算
指针的大小比较:
注意:arr是数组名,数组名其实就是数组的首元素的地址。
6.野指针
概念:野指针就是指针指向的位置是不可知的、随机的 。
6.1 野指针成因
(1)指针未初始化:
全局变量和静态变量如果不初始化,变量的默认值是0。
局部变量如果不初始化,变量的值是随机的!
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++)
{
*p = 1;
p++;
}
return 0;
}
(3)指针的空间释放
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
这里的n在出test()就销毁了,访问不到。
6.2 如何避免野指针
6.2.1 指针初始化
如果明确知道指针指向哪里就直接复制地址,如果不知道,可以给指针赋值NULL。NULL是C语言常见的标识符常量,其本质是0。
int main()
{
int num = 20;
int* p = #
int* pa = NULL;
return 0;
}
6.2.2 小心指针越界
一个程序向内存申请了哪些空间,通过指针也只能访问那些空间,不能超出范围。
6.2.3 指针变量不再使用时,及时置为NULL,指针使用前检查有效性
当指针变量指向一块区域时,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。只要是NULL指针就不去访问。
6.2.4 避免返回局部变量的地址
比如上面的(3)
7.assert断言
assert.h 头文件定义了宏 assert(),用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。者的宏常常被称为“断言”。
assert(p! = NULL);
程序在运行到这一行语句时,验证变量p是否等于NULL,如果不等于NULL,程序运行,否则终止运行并报错。
assert() 宏接受一个表达式最为参数,如果该表达式为真(返回值非零),assert() 时不起作用的,程序正常运行下去。如果表达式为假(返回值为零),assert() 就会报错,在标准错误流 stderr 中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
使用assert()的好处有:它不仅能自动标识文件出问题的行号,还有一种无需更改代码就能开启或关闭assert()的机制。如果已经确认了程序没有错误,就可以在#define <assert.h> 前定义一个宏 NDEBUG。
然后,重新编译程序,编译器就会禁止文件中所有的 assert() 语句。
如上图,加上 NDEBUG,即使 p = NULL ,程序依然运行没有报错。
如果程序又出现问题,可以移除或者注释掉 #define NDEBUG 指令,再次编译,这样就重新启用了 assert() 语句。
因为引入了额外的检查,增加了程序的运行时间。
一般地,我们在 Debug 中使用 assert(),在 Release 中禁用 assert() 语句。这样在 Debug 版本中有利于程序员排查问题,在 Release 版本中不影响用户的使用效率。
8.指针的使用和传址调用
8.1 strlen 的模拟实现
详见5.2 指针-指针(地址-地址)
8.2 传值调用和传址调用
传值调用:
#include <stdio.h>
Swap(int x, int y)
{
int z = 0;
z = x;
x = y;
y = z;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap(a, b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
这样的一个程序本意上是想通过Swap()函数实现数值的交换,但是输出的结果发现,交换前后不变。通过监视窗口可以看到:
函数的实参传给形参时,形参是实参的一份临时拷贝。对形参的修改不影响实参!
实参a和b有自己的地址,形参x和y也有自己的地址。
当进入Swap()函数后经过的一通交换都是在x和y的地址上进行的,并没有影响到实参a和b。
当出了这个函数,回到现实,a和b依然是赋给的值10和20。
所以想要改变函数外边的实参就需要传址调用:
#include <stdio.h>
Swap(int *pa, int *pb)
{
int z = 0;
z = *pa;
*pa = *pb;
*pb = z;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a=%d b=%d\n", a, b);
Swap(&a, &b);
printf("交换后:a=%d b=%d\n", a, b);
return 0;
}
这次将变量的地址传给了Swap()函数。
传址调用可以让函数和主函数之间产生真正的联系,在函数内部可以修改主函数中的变量。
所以只是需要主函数中的变量值来计算,使用传值调用即可。
函数内部要修改主函数的变量的值,用传址调用。