C指针详解
一,内存和地址
在了解指针前,我们应该先了解计算机中的内存和地址,
我们先举一个生活中的例子,学校的每个学生宿舍都有着自己的编号,每个学生宿舍所能住的学生数量也是固定的。如果你的朋友想去宿舍找你,那么只需要知道你在哪个宿舍就行。
在计算机中,CPU处理数据是从内存中读取的,电脑上内存是8GB/16GB/32GB等,这些内存空间又被划分为一个个内存单元,每个内存单元都有自己的编号,每个内存单元的大小为一个字节,一个字节能放8个比特位。内存单元的编号就相当于宿舍的门牌号,而8个比特位就相当于每个宿舍中住的学生,CPU在处理数据的时候只要知道了对应内存单元的编号,就可以快速找到该内存单元并读取其中的内容。在计算机中我们
把内存单元的编号也称为地址。C语⾔中给地址起了新的名字叫:指针。
也就是说,我们可以将其理解为:内存单元的编号=地址=指针
二,指针变量和地址
2.1取地址操作符’&‘
在C语言中,我们创建一个变量实际上就是向内存申请一定的空间,例如我们创建一个变量n=10;
int main()
{
int a=4;
return 0;
}
x86环境下,该代码像内存申请了四个字节用来存放a;这四个地址分别为:0x004ffc60~0x004ffc63。
要想得到a的地址,我们需要用到取地址操作符‘&’。我们使用代码演示一下:
int main()
{
int a=4;
printf("%p",&a);//%p表示打印地址
return 0;
}
我们可以看到这次运行向内存申请了0x00FBF8FC~0x00FBF8FF的空间来存储a的数据,内存显示的为16进制数,两个16进制位代表一个字节,而打印的地址是四个地址中最小的一个。因为&取地址操作符取出的就是最小的地址,只需要知道最小的地址,计算机就可以访问到四个字节的数据。
2.2指针变量和解引⽤操作符(*)
2.2.1指针变量
那该如何将a的地址存起来,我们就需要使用指针,还是用上面的代码来举例:
int main()
{
int a=4;
int*p=&a;//p为指针变量,类型为(int*)
return 0;
}
该代码中p也是一个变量,也就是一块用来存放对应数据类型的地址的变量,是指针变量。
2.2.2拆解指针类型
我们该如何分别指针变量存放的地址指向的类型?
还是用上面的代码举例:
` `
int a=4;
int*p=&a;
` `
该代码中p存放的是a的地址.。*表示p是指针变量,int代表p存放的地址指向的变量的类型是(int)类型。
2.2.3‘*’解引用操作符
我们了解了取地址操作符‘&’,可以将某个变量的地址存放到指针变量当中,那么该如何通过指针变量去访问其中存放的地址所指向的内容?这时候就需要用到解引用操作符‘*’,也被称为间接访问操作符。
还是用上面的代码来演示:
#include<stdio.h>
int main()
{
int a = 100;
int* p = &a;
*p = 0;
return 0;
}
解引用操作符实际上就是通过指针变量中存放的地址去访问对应的空间,在实际生活中相当于我们知道了某个人的家庭住址后去联系他,该代码中*p=0实际上就是通过p中存放的地址,找到指向的空间,
p其实就是a变量;所以p=,这个操作符是把a变量的值改成了0。实际效果与直接a=0的效果是一样的。但通过指针去修改变量的值可以使代码更加的灵活。
2.3指针变量的大小
指针变量的大小与环境有关,与指针变量的类型无关,
在32位平台下地址是32个bit位,指针变量⼤⼩是4个字节
在64位平台下地址是64个bit位,指针变量⼤⼩是8个字节
在相同的平台下,不同类型的指针变量的⼤⼩都是相同的。
32位平台:
64位平台:
三,指针变量类型的意义
3.1指针类型的意义
所有类型的指针变量在同一平台下的大小是一样的,不同的指针类型决定了对指针进行解引用操作时所能改变的字节。我们来看一段代码。
#include<stdio.h>
一:
int main()
{
int a = 0x11223344;//表示十六进制数
int b = 0x66778899;//表示十六进制数
int* p1 = &a;
char* p2= &b;
*p1 = 0;
*p2 = 0;
return 0;
}
这是创建的a,b变量和其中存储的内容,然后我们看下执行*p1 = 0;
*p2 = 0;两行代码后的结果
可以看到a的四个字节全部被修改为00,而b只有第一个字节被修改。
这是因为int解引用能访问四个字节,而char解引用只能访问一个字节,所以指针类型决定了进行解引用操作时能操作的字节数
3.2void*类型指针
void类型指针是一种特殊的指针类型,可以理解为⽆具体类型的指针(或者叫泛型指
针),这种类型的指针可以⽤来接受任意类型地址。但是因为这种类型的指针没有具体的类型,所以不能进行指针±整数的运算和解引用。
void接收任意类型的地址:
这里使用char类型的指针接收int类型数据的地址编译器会报一个警告,但是使用void类型可以接收float类型的数据。
void进行±整数
因为void指针没有具体类型,不知道进行加减整数时具体可操作的字节数,所以不能进行加减整数运算。
四,const修饰指针
不论是指针变量还是其他类型的变量,都是可以修改的,如果我们想让变量变得无法修改,就要使用const去修饰变量。
4.1const修饰变量
#include<stdio.h>
int main()
{
int b = 0;
const int a = 10;
b = 10;
a = 10;
return 0;
}
我们可以看到,使用const修饰过int类型变量a是无法被修改的,编译器会报错。
4.2const修饰指针变量
同样的const还能修饰指针变量,const修饰指针变量分为几种情况
一,const位于*右边
int n=5;
int a=0;
int *const p=&n;
p=&a;//?
*p=1;//?
此时const修饰的是指针变量p本身,p所储存的地址无法修改,但是p储存的地址指向的值可以通过指针进行修改
二,const位于*左边
int n=5;
int a=0;
int const *p=&n;//与const int*p=&n是等价的
p=&a;//?
*p=1;//?
此时const修饰的是指针p指向的内容,p储存的地址指向的值不可以通过指针进行修改,但是指针变量本身可以修改。也就是直接修改指针变量的指向
修改前
修改后
三,*左边和右边都有const进行修饰
int n=5;
int a=0;
int const *const p=&n;
p=&a;//?
*p=1;//?
结合上面的两种情况,我们可以很快理解这种情况下,指针变量本身和指针变量所指向的内容都是不能修改的。
五,指针运算
指针的基本运算有三种:
1,指针加减整数;
2,指针-指针;
3,指针的关系运算。
5.1指针加减整数
在前面我们分析过指针类型的意义是指针进行解引用操作时所能改变的字节。我们对一个指针进行加减整数的操作实际上就是让指针指向的地址按对应的指针类型移动对应的字节。
例如:让一个char类型的指针+1,char类型的指针每次可操作的字节数为1,+1就是让char指针指向的地址向后移动11个字节;让一个int类型的指针+1,就是让int指针指向的地址向后移动14个字节,也就是说,指针±n(n是整数)就相当于让指针指向的地址向后(前)移动nsizeof(指针指向的类型)个字节
我们来看一组代码:
#include<stdio.h>
int main()
{
int n = 4;
printf("%p\n", &n);
int* p1 = &n;
char* p2 = &n;
printf("%p\n", p1+1);
printf("%p\n", p2+1);
return 0;
}
这里我们使用两种不同的指针类型去获取n的地址,再将其+1后打印出来,结果为:
很明显,当我们让int类型的指针+1后,p1指针向后移动了四个字节,而让char类型的指针加1后只移动了一个字节。
5.2指针-指针
我们首先要知道,指针-指针实际上是两个地址的相减,所得出的结果是一个整数,实际上得出的整数的绝对值为两个地址之间的元素个数。
#include<stdio.h>
int main()
{
int arr[10] = { 0 };
printf("%d\n", &arr[9] - &arr[0]);
printf("%d\n", &arr[0] - &arr[9]);
return 0;
}
我们知道数组的地址在内存中是连续存放的,所以arr[9]和arr[0]之间有九个元素,两个地址相减的绝对值就为9。
注意:指针-指针中两个指针所指向的空间必须是同一块空间。例如一个数组中。
5.3指针的关系运算
指针的关系运算其实就是指针和指针比较大小,也就是两个地址比较大小。
举个例子,我们知道数组中的元素的地址是连续的,那么就可以比较数组中元素的地址的大小来打印数组
#include<stdio.h>
int main()
{
int arr[] = { 2,4,14,57,83,4,7 };
int sz = sizeof(arr) / sizeof(arr[0]);
int* p = arr;
while (p < arr + sz)
{
printf("%d ", *p);
p++;
}
return 0;
}
这里我们就通过比较地址的大小把数组打印出来了,while循环中的(p<arr+sz)就是指针的比较。
六,野指针
野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
6.1野指针的形成原因
1,指针未初始化
例:
#include<stdio.h>
int main()
{//TRUE
int a = 10;
int* p1 = &a;
*p1 = 15;
//ERROR
int* p2;
*p2 = 20;
return 0;
}
可以看到如果我们要使用指针,就要对指针进行相应的初始化,这里的p2是一个局部变量,而我们知道,局部变量不初始化的话值是随机的,所以是无法正常使用的,因为指向未知的地址。而p1是明确指向a的地址,所以可以正常使用。
2,指针越界访问
例:
#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]);
for (int i = 0;i <= sz;i++)
{
printf("%d ", *p);
p++;
}
return 0;
}
这里我们通过数组的地址去解引用并打印出数组的各个元素,但是,存在一个问题
数组arr[10]的下标为0~9,而条件for (int i = 0;i <= sz;i++)最终会访问到arr[10],这时候的指针指向的就是数组外的地址,也就是为进行初始化的地址,是随机值
3,指针指向的空间释放
例:
#include<stdio.h>
int* test()
{
int n = 20;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
上面的代码看上去没有问题,但是*p是野指针,因为在程序执行过程中,n是函数中创建的临时变量,函数执行完并返回值后,函数的内存空间会销毁,这时候的临时变量n也跟着被一起销毁,也就是空间已经被释放,*p就是一个野指针。
6.2如何避免野指针
6.2.1指针初始化
我们创建指针时,如果明确知道指针指向的位置,那么就将该值赋给指针,如果不知道,可以先将指针赋值为空,例:
#include <stdio.h>
int main()
{
int num = 10;
int* p1 = #//明确知道指针所指向的位置
int* p2 = NULL;//暂时不确定指针指向的位置,先置为空指针
return 0;
}
空指针的值为0,0也是地址,但该地址无法使用。
如果要使用,就要给该指针赋值。
6.2.2将不再使用的指针置为空指针,使用前检查
当我们使用完一个指针后,可以先将其置为空指针,后续如果要再次使用该指针,先检查其的有效性,也就是检查是否为空指针。例:
#include <stdio.h>
int main()
{
int num = 10;
int* p1 = #
p1 = NULL;
if (p1 != NULL)
{
;
}
return 0;
}
6.2.3⼩⼼指针越界
⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是
越界访问。也就是前面讲的指针越界访问数组的问题。
6.2.3避免返回局部变量的地址
在前面也已经说过,此处不再赘述。
七,assert断⾔
assert.h 头⽂件定义了宏 assert() ,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报
错终⽌运⾏。这个宏常常被称为“断⾔”
assert() 宏接受⼀个表达式作为参数。如果该表达式为真,则返回一个⾮零的值, assert() 不会产⽣
任何作⽤,程序继续运⾏。如果该表达式为假,则返回值为零, assert() 就会报错,显示没有通过的表达式,以及包含这个表达式的⽂件名和⾏号。
#include <stdio.h>
#include <assert.h>
int main()
{
int n= 0;
scanf("%d", &n);
assert(n > 3);
return 0;
}
当我们输入一个小于3的值时,该代码会报错,并显示错误信息
但当我们输入的值大于3,则不会有任何反应。
在实际的程序运行中,我们常常使用assert来检查空指针。例:
#include <stdio.h>
#include <assert.h>
int main()
{
int n= 10;
int* p = &n;
assert(p!=NULL);
return 0;
}
使用assert有几个好处,它不仅能⾃动标识⽂件和出问题的⾏号,还有⼀种⽆需更改代码就能开启或关闭的机制。如果已经确认程序没有问题,不需要再做断⾔,就在 #include <assert.h> 语句的前⾯,定义⼀个宏NDEBUG ,
#define NDEBUG
#include <assert.h>
这时候的assert就没有任何作用了,如果想在启用assert,则只需将NDEBUG注释掉就行了。
assert也有缺点,因为要额外检查,所以程序的运行时间会加长,
⼀般我们可以在 Debug 中使⽤,在 Release 版本中选择禁⽤ assert ,在 VS 这样的集成开
发环境中,在 Release 版本中,直接就是优化掉了。这样在debug版本写有利于程序员排查问题,
在 Release 版本不影响⽤⼾使⽤时程序的效率。