5. 指针运算
指针的基本运算有三种,分别是:
指针+-整数
指针-指针
指针的关系运算
5.1 指针运算
在上面,我们知道,数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素。
那么,运用这一点,我们就可以写出下面的代码:
cpp
#include <stdio.h>
int main()
{
int i;
int arr[] = {1,2,3,4,5,6,7,8,9};
int sz = sizeof(arr)/ sizeof(arr[0]);
for(i = 0;i < sz;i++)
{
printf("%d ",*(arr + i));
}
return 0;
}
我们利用指针 arr (数组名即为数组首元素的地址) + i ,访问数组中下标为 i 的元素,并打印出来。而指针与整数运算后跳过的字节数的大小是与数据的类型有关的。例如,上面代码中, arr 数组是整型数组,所以在运算时,会在 arr 的位置,跳过4 * i 个字节,访问到数组中下标为 i 的元素。
5.2 指针 - 指针
上面,我们知道指针可以和整数进行加减运算,那指针是否可以与指针 进行加减运算呢?
cpp
#include <stdio.h>
int main()
{
int arr[] = {1,2,3,4,5,6,7,8,9};
int a = (arr + 9) - (arr + 3);//正常运行
int b = (arr + 9) + (arr + 3);//编译器报错:Invalid operands to binary expression ('int *' and 'int *')
printf("%d",a);
return 0;
}
将指针加法的那一行代码删去后,我们得到了如下输出:
cpp
6
进程已结束,退出代码为 0
输出结果为6,这代表了(arr + 9)与(arr + 3)两个指针之间一共有6个元素。因此,指针的减法运算所得到的结果就是两个地址之间的元素个数。
利用这一点,我们可以自己写出类似于函数 strlen()的效果的代码:
cpp
int my_strlen(char* s)
{
char *p = s;
while(*p != '\0')
{
p++;
}
return p - s;
}
#include <stdio.h>
int main()
{
char s1[] = "asdf";
int a = my_strlen(s1);
printf("%d",a);
return 0;
}
//输出结果
4
我们发现,输出结果为4,正等于字符数组中的字符数。
5.3 指针的关系运算
我们知道,指针就是地址,而地址有高低之分,那指针是否可以比较大小呢?
cpp
#include <stdio.h>
int main()
{
int arr[] = {1,2,3,4,5,6,7,8,9};
int sz = sizeof(arr)/ sizeof(arr[0]);
int *p = &arr[0];
while(p < arr + sz)
{
printf("%d ",*p);
p++;
}
return 0;
}
//输出结果
1 2 3 4 5 6 7 8 9
我们可以发现,循环正常进行,说明表达式是合法有效的,指针可以用来进行比较大小。
6. 野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
6.1 产生野指针的原因
6.1.1. 指针未初始化
cpp
#include <stdio.h>
int main()
{
int* p ;
*p = 0;//编译器警告:Variable 'p' is uninitialized when used here
return 0;
}
指针未初始化时,默认为随机值。直接使用可能导致系统报错。
6.1.2 指针越界访问
这种错误可以类比数组访问越界:
cpp
#include <stdio.h>
int main()
{
int i;
int arr[10] = {0};
int* p = &arr[0];
for(i = 0;i < 11;i++)
{
*p = i;
p++;
}
return 0;
}
在这个代码中,当指针指向的范围超出数组范围时,p就会成为野指针,执行预期外的操作。
6.1.3 指针指向的空间释放
当指针所指向的空间已经被释放时,就会导致野指针的产生:
cpp
int* test()
{
int n = 1;
return &n;
}
#include <stdio.h>
int main()
{
int* p = test();
printf("%p",p);
return 0;
}
由于变量n是在函数test中创建,因此函数执行完毕后,变量n的内存也会被回收,空间被释放。此时,程序就会打印出一个无效地址或者程序崩溃。
6.2 如何规避野指针
6.2.1 指针初始化
在创建指针变量时,如果明确知道指针指向哪里就直接赋值地址;如果不知道指针应该指向哪里,可以给指针赋值NULL,再后面使用时再进行赋值。
NULL是C语言中定义的一个标识符常量,值是0,地址也是0,这个地址是无法使用的,读写该地址时程序会报错。
cpp
#include <stdio.h>
int main()
{
int n = 0;
int* p1 = &n;
int* p2 = NULL;
return 0;
}
6.2.2 防止指针越界
一个程序向内存申请了哪些空间,指针也就只能访问哪些空间,不能超出范围访问,否则就是越界访问。
6.2.3 指针变量不再使用时,及时赋值NULL,指针使用之前检查有效性
当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是:只要是NULL指针就不去访问,同时使用指针之前可以判断指针是否为NULL。
cpp
#include <stdio.h>
int main()
{
int i;
int arr[10] = {0};
int* p = &arr[0];
for(i = 0;i < 11;i++)
{
*p = i;
p++;
}
//此时,指针已经访问越界
p = NULL;//将p赋值为NULL,防止p成为野指针
...
if(p != NULL)//使用前,检验p是否为空指针
{
...
}
return 0;
}
6.2.4 避免返回局部变量的地址
如上面的示例,避免返回局部变量的地址,防止使用野指针。
7. assert 断言
assert.h 头文件中定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。这个宏常常被称为"断言"。
例如:
cpp
assert(p != NULL);
上面代码在程序运行到这⼀行语句时,验证变量p是否为空指针。如果表示,程序正常运行;否则,程序终止运行,并且会给出错误信息。
assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值非零),assert()宏则不会产生任何作用,程序继续运行。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误流stderr 中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
assert()的使用对程序员非常友好,使用assert()的好处在于:它不仅能自动标识文件和出问题的行号, 还有一种无需更改代码就能开启或关闭assert()的机制。 如果已经确认程序没有问题,不需要再做断言,就在 #include <assert.h> 语句前面定义 一个NDEBUG。
cpp
#define NDEBUG
#include <assert.h>
然后,重新编译程序,编译器就会禁用文件中所有的assert()语句。如果程序又出现问题,可以移除 #define NDEBUG 这条语句(或者是注释掉),再次编译,这样就重新启用了assert()语句。
而使用assert()的缺点在于:引入了额外的检查,增加了程序的运行时间。
一般我们可以在Debug中使用,在Release版本中选择禁用assert()就行。这样在debug版本写有利于程序员排查问题,在Release版本不影响用户的使用体验。
8. 指针的使用和传址调用
8.1 strlen的模拟实现
库函数strlen的功能是求字符串长度,统计的是字符串中 '\0' 前的字符数。
函数原型如下:
cpp
size_t strlen ( const char * str );
参数str接收一个字符串的起始地址,然后开始统计字符串中 '\0' 之前的字符个数,最终返回长度。
因此,我们模拟就需要从起始地址开始向后逐个检查字符,如果不为 '\0' ,计数器就+1,知道遇到 '\0' 为止。
例如:
cpp
#include <stdio.h>
#include <assert.h>
int my_strlen(const char* s)
{
assert(s);
int count = 0;
while(*s != '\0')
{
count++;
s++;
}
return count;
}
//输出结果
5
8.2 传值调用和传址调用
学习了指针的知识,现在我们来看看专门用指针来解决的问题。
例如:写一个函数,交换两个整型变量的值
思考之后,我们可能会写出这样的代码:
cpp
#include <stdio.h>
void Swap(int x,int y)
{
int temp;
temp = x;
x = y;
y = temp;
}
int main()
{
int a = 1,b = 2;
printf("交换前:a = %d,b = %d",a,b);
Swap(a,b);
printf("交换后:a = %d,b = %d",a,b);
return 0;
}
但是当我们检查打印结果时:
cpp
交换前:a = 1,b = 2
交换后:a = 1,b = 2
我们发现a,b的值并没有和我们预期中一样实现交换,这是为什么呢?
这个时候,我们就要回顾一下前面的知识:形参是实参的一份临时拷贝,也就是说,形参与实参的地址是不同的。在函数内部实现的值的交换只是交换了形参的地址中的值,而实参的地址的值并没有变化。在函数结束后,内存被释放。所以,x和y的值的交换不会影响a和b的值。
像Swap函数这样,在调用函数时传递变量本身的调用方法被称为传值调用。
结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实 参。
因此,这种写法是错误的,那我们应该怎么实现题目要求呢?
我们现在要解决的事情就是,在函数Swap内部实现main函数中变量a和b的值的交换。既然直接传递变量时,形参与实参的地址是不同的,那我们直接传递地址是否能解决这个问题呢?
于是,我们可以得到下面的代码:
cpp
#include <stdio.h>
void Swap(int* const px,int* const py)
{
int temp;
temp = * px;
* px = * py;
* py = temp;
}
int main()
{
int a = 1,b = 2;
printf("交换前:a = %d,b = %d\n",a,b);
Swap(&a,&b);
printf("交换后:a = %d,b = %d\n",a,b);
return 0;
}
此时,我们再检查打印结果:
cpp
交换前:a = 1,b = 2
交换后:a = 2,b = 1
可以发现,代码成功实现了值的交换。
而像这样在调用函数时传递变量的地址的调用方式被称为传址调用。
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。