指针是我们学习C语言中绕不开的一个话题。那么指针究竟是什么?为什么它如此重要?它的用法有哪一些呢?接下来进行指针的详解。
注:接下来针对指针的讲解都基于C语言展开以便于更好的理解。
1.内存和地址
(1)内存
我们知道,计算机的数据的存储和读取都是在内存中实现的,CPU通过从内存中读取数据从而进行计算,并且通过内存来将计算好的数据存储在其中。
内存被划分为一个一个内存单元,每个内存单元的大小是1字节,也就是8bit位。
(2)地址
我们都知道,bit位和字节的大小可以说是非常小的了,在如此庞大的数据库,内存中,我们需要找到指定的那一段,倘若盲目从头到尾地找是很难找到效率也是很低的,所以我们就需要给内存单元编号,这样我们按照编号来寻找就会很快找到。
那么我们把这个所谓的编号就叫做地址。
地址也就是指针。
所以满足:
内存单元的编号=地址=指针
(3)深入了解存储和编址
事实上,那么多的内存数据是不可能被真正记录下来的,实际上它们都是通过硬件设计完成的。
在连接CPU和内存时,我们有一组线叫做数据总线,每根线只有两态,表示0,1【电脉冲有无】,那么一根线,就能表示2种含义,2根线就能表示4种含义,依次类推。32根地址线,就能表示2^32种含义,每⼀种含义都代表⼀个地址。足够来存放我们所有的内存信息。
地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传⼊ CPU内寄存器。
2.指针变量和地址
(1)取地址操作符(&)
相信在scanf(" ",& )的语句中我们已经见过了这个操作符。那么它究竟是干什么用的呢?
我们知道在C语言中创建变量就是在向内存申请空间。
int a = 10;
一个简单的整型变量a实际就是向内存申请了4个字节的空间来存储整数10。
既然有存储操作,那就肯定会有地址。
我们要怎么找到变量a的地址呢?就要用到取地址操作符&。
int a = 10; &a; printf("%p",&a);
通过取地址得到a的地址。
但是需要注意的是:&a取出的是a所占4个字节中地址较小的字节的地址。因为我们只要知道了最小的那个地址,那么四个字节的所有地址也就可以按顺序从而得知了,没有必要全部打印出来。
(2)指针变量
指针是指地址,那么如果我们通过取地址操作符(&)拿到的地址是⼀个数值,这个数值可能也会需要存储起来方便以后再使用。我们会把这样的地址数值存放在:指针变量中。
int a = 10; int* pa = &a;//取出a的地址并存储到指针变量pa中
指针变量顾名思义就是存放指针的变量,存放在指针变量中的值都会被理解为地址。
int * pa = &a;
这个*号的位置只要类型和变量中间即可。
int*中的int是指某种类型,而*就是说明后面的pa是指针变量。
所以指针变量的定义就是:
类型 * 变量名
(3)解引用操作符
既然我们可以使用指针变量将指针保存起来,那么我们要使用的时候应该如何操作呢?
将指针保存的过程我们看作进入一间有编号的房间,那么要使用它的时候我们打开这扇门的过程可以叫做解码,那么也就会用到解引用操作符。
通过解引用这个操作我们就能得到指针所指向的地址里的内容。
int a = 100; int* pa = &a; *pa = 0;//解引用操作符使得pa的值发生了变化,但地址仍然不变 return 0;
int* pa定义指针变量
*pa解引用指针变量并且改变其值
3.指针变量的大小
我们知道,在32位计算机中有32根数据总线,由于1根数据总线存储的是0或1,那么也就说如果我们需要编号一个地址,就需要32bit位来存储,也就是4个字节。
同理,在64位计算机中就是8个字节。
所以我们可以得知,指针变量的大小取决于地址的大小而不取决于类型。无论是int、char等等,指针变量大小都是一个地址的大小。
4.指针变量类型的意义
(1)能决定指针解引用时的权限
虽然指针变量大小是固定的,但当我们使用不同类型的时候,是可以管理操作的字节数的。
我们知道int类型4个字节,char类型1个字节
int n = 0x11223344; int *pi = &n; *pi = 0; return 0; //调试过程中发现四个字节都变成了0
nt n = 0x11223344; char *pc = (char *)&n; *pc = 0; return 0; //调试时发现只有第一个字节变成了0
可以看到解引用的时候不同的类型改变的字节数就是完全不一样的。
也就是说虽然指针变量的大小不会改变,但是它可以被类型给区分从而得出不同的操作结果。
(2)决定指针的步幅大小
int n = 10; char *pc = (char*)&n; int *pi = &n; printf("%p\n", &n); printf("%p\n", pc); printf("%p\n", pc+1);//走了1个字节 printf("%p\n", pi); printf("%p\n", pi+1);//走了4个字节 return 0;
当我们使用指针+或-整数时,指针会随之行动,那么不同的类型就会决定它每走一个整数移动的步幅。这在需要的时候可以节省不少步数。
5.void*指针
在指针类型中有⼀种特殊的类型是 void* 类型的,可以理解为无具体类型的指针(或者叫泛型指 针),这种类型的指针可以用来接受任意类型地址。
int a = 10; int* pa = &a; char* pc = &a;//a已经被定义为是int*类型,此时不能是char*类型,会出现兼容问题 return 0;
如果我们使用void*类型
int a = 10; void* pa = &a;//使用void*类型使得编译器不会报错 *pa = 10;
但是需要注意的是。它也有局限性, void* 类型的指针不能直接进行指针的+-整数和解引用的运算,倘若使用编译器仍会检查出错误。
6.const修饰指针
通过上述了解我们知道指针变量是可以被修改的,如果我们不想其被修改,我们就需要把它锁住。
这时候就要用到const。
int m = 0; m = 20;//m是可以修改的; const int m = 0; m = 20;//m此时是不能被修改的 return 0;
当m被const修饰之后,语法上就有了改变,编译器就会锁死这个变量,从而不能修改。
需要注意,const只是在语法上锁住了变量,但在本质上还是变量,称为常变量。
不过我们可以投机取巧,先将其解引用再修改,就可以跳过const的限制。
const int n = 0; printf("n = %d\n", n); int*p = &n; *p = 20; printf("n = %d\n", n);
但是我们知道const的作用就是为了使其不能被修改,如果我们还是想达到这个目的,可以在此基础上对指针变量来进行修饰。
其实const的使用是非常灵活的,它既可以放在*p又可以修饰p,其结果是不同的。
A int n = 10; int m = 20; const int* p = &n; *p = 20; p = &m; B int n = 10; int m = 20; int const* p = &n; *p = 20; p = &m; //A和B两种情况都将const放在*左边,此时限制的是*p 修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。 但是指针变量本⾝的内容可变。 C int n = 10; int m = 20; int *const p = &n; *p = 20; p = &m; //C情况将const放在*右边,此时限制的是p 修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指 向的内容,可以通过指针改变。 D int n = 10; int m = 20; int const * const p = &n; *p = 20; p = &m; //D情况*p和p都受到了限制。
7.指针运算
(1)指针+-整数
为了更好展示这种运算的过程,我们使用数组来进行+-整数的运算。
int arr[5] = {1, 2, 3, 4, 5}; int *p = &arr[2]; p = p + 2; // p指向arr[4] p = p - 1; // p指向arr[3]
抑或是:
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 指针+整数 }
当打印*(p+i)的值时,i作为整数是在不断增加的,那么在此基础上指针的值也会随之增加,在数组上的体现就是向前进i。
(2)指针与指针
指针与指针之间的运算就相当于地址与地址之间的运算,它得到的是两个不同地址间的元素个数。
我们可以类比为日期的运算:
日期+(-)日期=天数
日期+(-)天数=日期
指针之间的运算也是如此。
但是我们需要一个前提条件:两个指针必须指向的是同一块空间。否则计算时就会不确定计算空间的位置。
int a[] = {1, 2, 3, 4, 5 ,6, 7, 8}; int *start = &a[0], *end = &a[7]; printf("(end) - (start) = %d\n", (end) - (start)); //指针之间相减得到的就是它们之间的元素个数:6
(3)指针的关系运算
关系运算其实就是比较指针的大小。
int arr[5] = {1, 2, 3, 4, 5}; int *p1 = &arr[2]; int *p2 = &arr[4]; if (p1 < p2) { printf("p1指向的元素在数组中的位置比p2指向的元素靠前\n"); } //关系运算比较的是地址的大小
8.野指针及其规避方法
(1)介绍野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的),相当于野指针指向了一个未知门牌号的房间,一切都是未知危险的。
(2)野指针的成因
a.指针未初始化
当一个指针变量被声明但没有被初始化时,它可能会包含一个随机的内存地址,这可能会导致野指针的出现。
int *ptr; *ptr = 10; // 未初始化的指针ptr被用来赋值,可能导致野指针
b.指针越界访问
如若指针指向的位置超出了所规定的范围,会被认为越界从而导致野指针。
int arr[10] = {0}; int *p = &arr[0]; int i = 0; for(i=0; i<=11; i++) { //当指针指向的范围超出数组arr的范围时,p就是野指针 *(p++) = i; }
c.指针指向的内存被释放
当一个指针指向的内存被释放或者超出了作用域,但指针本身并没有被置空,这也可能导致野指针的出现。
int* test() { int n = 100; return &n; } int main() { int*p = test(); printf("%d\n", *p); return 0; //函数test()返回一个指向局部变量n的指针,然后在main()函数中使用这个指针。 //当test()函数执行完毕后,n的内存空间就被释放了,但是指向n的指针仍然被返回并在main()函数中被使用。 //这样就会导致指针p成为野指针,因为它指向的内存空间已经不再有效。
d.指针指向的对象被销毁或被移动
当一个指针指向的对象被销毁或者释放,但指针本身并没有被置空,可能导致野指针的出现。
当一个指针指向的对象被移动或者重新分配内存,但指针本身并没有被更新,也可能导致野指针的出现。
int *ptr; { int num = 10; ptr = # } *ptr = 20; //num对象被销毁,但指针ptr并没有被置空,可能导致野指针
int *ptr = 10; int *new_ptr = 10; *new_ptr = 20; ptr = new_ptr; // ptr指向的对象被移动到new_ptr,但ptr本身并没有被更新,可能导致野指针
(3)规避野指针
在指针的使用过程中,我们需要尽量避免野指针的出现才能使得指针的使用风险降低
a.指针置空
既然野指针是未知指向方向的指针,那么我们可以直接给它赋值NULL。
NULL是C语⾔中定义的⼀个标识符常量,值是0,0也是地址,这个地址是无法使用的,读写该地址会报错。
那么如果我们使用它来赋值野指针,那么它就会指向一个无法使用的地址,自然也就不会影响到其他地址的使用了。实际上它自己也就成为了一个有固定指向的指针从而不再是野指针,只不过这个地址是无法使用的。
事实上,对于一般的指针我们在不使用它的时候我们就可以将其置空,保证不使用它的时候也不会出错。
p = NULL; //下次使⽤的时候,判断p不为NULL的时候再使⽤ //... p = &arr[0];//重新让p获得地址 if(p != NULL) //判断 { //... }
b.指针初始化
局部变量如果不初始化,变量的值是随机的;
全局变量和静态变量如果不初始化,变量的值默认为0。
如果把指针初始化,就不会导致野指针的出现,实际上就是指针的正确使用方法都需要初始化。
c.避免指针越界
⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。
d.避免返回局部变量的地址
一旦函数执行完毕,局部变量的内存空间就会被释放,而返回的指针可能会成为野指针。
9.assert断言
assert.h 头文件定义了宏 assert() ,用于在运行时确保程序符合指定条件。这个宏常常被称为“断言”。
assert断言是一种在编程中用于检查条件是否为真的语句。
在程序执行过程中,
如果assert语句的条件为假,那么程序将抛出一个AssertionError异常。
如果assert语句的条件为真,那么程序就会正常执行。
这种语句通常用于调试和测试也就是dubug阶段,以确保代码的正确性和可靠性。在release版本中,assert语句通常会被禁用或移除,因为它们可能会影响程序的性能和安全性。
在此代码中,p = NULL。
assert断言如果p != NULL,那么就正常运行。
所以在此处就会报错,因为p = NULL。
对于assert断言有几个优点:
·它能自动找出并且标识错误产生的行号
问题出现在p != NULL,那么它就会标识出来
·它还有⼀种无需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问题,不需要再做断言,就在 #include 语句的前⾯,定义⼀个宏NDEBUG即可。
重新编译程序的时候,编译器就会禁用所有的assert()语句。想要重新启用,将该宏删除即可。
需要注意的是:断言语句是代码书写者用来检查代码的问题的,本质上是属于一种多余的操作,会增加程序的运行时间。所以在release版本中会被优化掉。
9.传值调用和传址调用
(1)简析
调用针对的对象是函数。
二者顾名思义,一个是传递值来调用函数,另一个是传递地址也就是指针来调用函数。
(2)传值调用
设想:通过调用函数来打印值
- 在传值调用中,函数参数的值被复制到函数的形参中。这意味着在函数内部对形参的修改不会影响到实参的值。
- 传递给函数的是实参的值的副本也就是临时拷贝,函数内部对形参的修改不会影响到实参。
(3)传址调用
设想:通过函数来交换值
得到的结果是:
原因就是,形参和实参都有自己独立的空间,对于在函数内部形参的交换并不会影响到主函数内实参的变量的改变。对形参的影响是不会影响实参的。
如果我们需要解决这个问题,就需要用到传址调用。
结果就是:
- 在传址调用中,函数参数的地址被传递给函数的形参。这意味着在函数内部对形参的修改会影响到实参的值。
- 传递给函数的是实参的地址,函数内部对形参的修改会影响到实参。
我们其实可以将变量本身的值看成是表面,它只代表一个整型或者是浮点型等;
但是变量的地址就相当于它的本质,如果我们需要交换变量,那么肯定要改变它的本质才能进行彻底的改变。使得主函数与调用函数之间建立真正的联系。