1 指针运算
指针的基本运算有三种,分别是:
1.指针 ± 整数
2.指针 - 指针
(注意:指针只能相减,不能相加,就好比我们能通过两个日期的差推算出它们之间相差多少天,但是我们不能将两个日期相加,这是没有意义的)
3.指针的关系运算
1.1 指针 ± 整数
我们都能够用数组下标的方式来访问数组的每一个元素,只要得到了数组的首地址,我们就可以顺着得到数组后面的每一个元素,因为数组在内存中是连续存放的。
当我们学习了指针过后,我们能更加清楚了理解其中的原理,数组的本质其实就是指针,例如数组arr[0],编译器在运行时也会把它转换成指针的形式*(arr+0)来运算,我们可以试着使用指针来实现数组元素的访问。
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
int i;
int* p = &arr[0];
for (i = 0; i < sz; i++)
{
printf("%d ", *(p + i));
}
return 0;
}
数组在内存中存放实际上是这样的:
一开始我们得到数组的是地址,然后定义整形变量p指向数组的首地址。之后每加1,就是跳过1个整形元素,也就是4个字节,指向的就是arr[1]的地址,以此类推。p加上n的意思就是跳过n个整形元素,也就是n * sizeof(int)个字节,因为p是指针变量,存放的是数组的地址,我们想要打印数组元素,需要解引用,所以我们可以用 * (p+i)来实现数组的元素反问。
结果如图:
1.2指针 - 指针
前提:
两个指针指向同一块空间
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("%d",arr[9] - arr[0]);
return 0;
}
当我们想要计算数组arr[9]-arr[0]的时候,我们会有不同的想法,可能会觉得答案是9,因为下标为0和9之间有9个元素,可能认为是36,因为每个元素都是int类型,一个整形占4个字节,所以答案是36,那么答案到底是什么呢?
运行代码后,可以发现,答案其实是9
结果可以发现,指针-指针得到的是它们之间元素的个数
我们可以换种思维思考,如果我们有arr[0]的地址,想要得到arr[9]的地址,它们之间差了9个元素,所以&arr[0]+9=&arr[9,反推就得到了&arr[9]-&arr[0]=9.
有同学也许会思考,那如果输入&arr[0]-&arr[9],得到的是什么呢?
可以发现,差值变成了-9,是之间结果的相反数。
所以完整的结论应该是:
指针-指针差值的绝对值得到的是它们之间元素的个数
1.3指针的关系运算
还是上面的数组
我们可以通过p依次向后访问数组的每个元素,用while循环实现,当p指向了数组最后一个元素的后面时,访问就完成了,退出循环,此时p指向的位置为数组的首地址加上数组的长度。
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
int* p = arr; //数组名就是数组首元素的地址
while(p < arr + sz) //指针的关系运算
{
printf("%d ", *(p++)); //如果p还没访问完,就先解引用p,在把p自增1
}
return 0;
}
2 野指针
2.1野指针的三种成因
1.指针未初始化
#include <stdio.h>
int main()
{
int* p;
*p = 20;
return 0;
}
例如上面的代码,指针变量p没有进行初始化,编译器为其分配的地址是随机的,如果此时我们想要修改p所指向的值,编译器就会报错。
2.指针越界访问
#include <stdio.h>
int main()
{
int arr[10] = { 0 };
int sz = sizeof(arr) / sizeof(arr[0]);
int i;
int* p = &arr[0];
for (i = 0; i <= sz; i++)
{
*(p + i) = i;
}
return 0;
}
我们想要实现给数组10个元素用循环依次赋值为0-9,如果sz计算出数组的长度,我们知道,此时数组的下标范围为1~sz-1,如果循环的时候i判断最终是等于sz,会越界访问数组,编译器发现问题,程序最终会崩溃。
3.指针指向的空间已经被释放了
例如我们想要实现一个函数test(),定义一个变量n赋初值,并返回n的地址,用 * p接收并打印n的地址。
#include <stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d", *p);
return 0;
}
这也是一种野指针的行为,因为我们知道函数的生命周期是在{}内部的,n是在进入函数时创建分配空间的,是一个局部变量,当函数返回主函数时,为n分配的空间会销毁掉,n的地址此时已经不属于n了,但是p还是能够通过n的地址找到对应的位置,这是十分危险的。
2.2 规避野指针的方法
1.将指针初始化
代码如下:
#include <stdio.h>
int main()
{
int n = 100;
int* p1 = &n;
int* p2 = NULL;
return 0;
}
有两种初始化方法,像上面的代码,如果我们有变量的地址,就直接赋值给指针变量就行,如上面代码把n的地址给指针变量p1,如果目前我们没有变量的地址,但是我们创建了指针变量,就需要将它赋值为NULL,如上面代码的p2.
那么NULL到底是什么呢?
我们将鼠标点到NULL处右键转到定义,可以发现:
NULL在c++那里直接就是0,在c语言中,NULL是把0强制转换类型为void *的0,本质上还是0,只不过被转换成了指针类型,表示地址0。NULL其实也表示的是空指针。
那么会有人疑惑了,既然NULL本质是0,为什么不直接把指针变量赋值为0呢?
其实赋值为NULL,只是为了让我们的赋值更加明确。
如果赋值为NULL,我们一看就知道是给指针变量赋值;如果赋值为0,有可能被当成整数0,造成不必要的麻烦。
2.在写代码的时候小心指针越界,我们向内存申请了多大的空间,指针就只能在申请的空间范围内访问。
3.当我们不再使用指针时,需要将它及时置为空指针NULL,下次再用的时候再重新赋值,并且在每次使用指针之前检查指针是否有效。
例如之前写过的数组代码:
#include <stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = arr;
int sz = sizeof(arr) / sizeof(arr[0]);
int i = 0;
while(p < arr+sz)
{
printf("%d ", *p);
p++;
}
//当循环结束后,p指向数组最后一个元素的后面,此时p已经越界了,可以把p赋值为NULL,方便下次使用
p = NULL;
//之后在使用之前,可以先判断p是否为空指针
if(p!=NULL) //如果p不为空指针NULL,再执行下面的语句
{
//……
}
return 0;
}
4.在写代码用到函数并且有返回值时,要看返回的地址是否为局部变量的地址,需要尽量避免返回局部变量的地址。
3 assert断言
在野指针的解决方法中,提到了在每次使用指针前先用if语句判断是否为空指针,再往下执行,我们肯定会想,这也太麻烦了,每次使用都要写if语句,而且每次在执行代码的时候,都要多执行if语句,这样会使代码的效率降低。
这时我们就需要用到assert.h头文件中定义的宏assert(),也叫断言,该代码确保程序在运行时括号内的代码符合条件,如果不满足条件,就报错终止运行。
assert()宏接受一个表达式作为参数,如果表达式为真,assert()不会产生任何作用,程序往下执行
如果表达式为假,assert()就会报错,在标准错误流 stderr 中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
如图所示:
我们可以很清楚的观察到错误代码的文件的路径,以及第159行代码出现了问题。
例如之前的if{} else{}语句判断空指针可以直接简化写成assert(p != NULL),并引用头文件。
assert()还有一个好处就是假如我们在代码中使用了很多的assert()语句来判断空指针,但是最后确认代码没有问题,我们不想要这些assert()语句影响效率的时候,我们可以在#include assert.h的上面定义一个宏#define NDEBUG,这样编译器就会禁用文件中所有的assert()语句,从而提高程序的效率。
#define NDEBUG
#include assert.h
4 指针的使用和传址使用
例如:写一个函数,完成两个整形变量值的交换。
在我们最开始学习的时候,我们可能想的是这样写。
#include <stdio.h>
void Swap(int x, int y)
{
int tmp = 0;
tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 10, 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的值没有发生改变,这是为什么呢?
在当代码遇到问题时,我们可以多尝试使用调试来观察代码的每一步,来仔细分析到底是哪里出现了问题。
在进入函数前,a和b都成功赋值为10和20.
进入函数,可以发现,实参a和b的值成功被形参x和y接收。
当我们观察a,b和x,y的地址时,我们会发现,它们不在一块空间内,a,b和x,y是两块空间,x和y的空间是独立的。那我们也就理解了为什么函数无法实现交换的功能,我们在函数内部把x和y的值进行了交换,但是在返回主函数时,没有把它们交换后的值交给a和b,所以a和b的值没有发生变化。
结论:函数的形参是实参的一份临时拷贝,形参会单独创建一份临时空间来接受实参,对形参的修改不会影响实参。
上述方法使用的是传值调用,在调用函数的时候,我们把实参的值传递给函数的形参,再往下执行语句,显然这样的方法不适用两个数的交换。想要真正的用函数实现两个数的交换,这时候就需要使用传址交换,传址交换,顾名思义,就是把实参的地址传递给函数的形参,传递和接收地址就需要使用到指针的知识。
当我们把a和b的地址传递给函数的形参时,实参和形参就建立了一定的联系,我们可以通过实参的地址最后将形参交换后的值通过地址找到a和b,并把交换后的值返回给a和b。
代码如下:
#include <stdio.h>
void Swap(int* x, int* y)
{
int tmp = 0;
tmp = *x;
*x = *y;
*y = tmp;
}
int main()
{
int a = 10, b = 20;
printf("交换前:a= %d,b= %d\n", a, b);
Swap(&a, &b);
printf("交换前:a= %d,b= %d\n", a, b);
return 0;
}
经过修改之后,我们能很好的使用函数完成两个整数的交换。
总结:
在实现函数的参数传递时,是使用传值调用还是传址调用取决于实参和形参之间是否需要建立联系:
1.如果需要将形参的值返还给实参,就需要用到传址调用,通过地址找到实参的位置,并更改实参的值。
2.如果不需要将形参的值返还给实参,就直接用传址调用就行,
两种方法各有各的适用场景,需要我们多加练习熟练地掌握和使用。