前言:今天我们来学习一下C语言中必不可少的内容----指针。在讲解指针之前,我们先来了解一下什么是内存和地址。
1 内存、地址
假设现在有一个朋友想要找你玩,但是呢,他又不知道你的位置。如果他贸然的去找你,肯定是找不到的。即使他运气好真的找到你了,必然也将浪费时间,效率低下。于是你告诉他,你在某个小区某栋单元某层楼及房牌号。他知道了这些信息就可以快速地找到你。
对比到计算机中,我们知道CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放在内存中。那我们买电脑的时候,电脑上的内存是8GB/16GB/32GB等,那这些内存如何高效的管理呢?
其实也是把内存划分为一个个内存单元,每个内存单元大小是1字节。一个内存单元就相当于一个房间,一个房间里面住8个人,就相当于8个比特位。每一个人就是一个比特位。每个内存单元都有一个编号(编号就相当于房牌号),有了这个内存单元的编号,CPU就可以快速访问内存空间。在生活中,我们把房牌号也叫做地址。在计算机中,内存单元的编号也叫做地址,C语言中,地址有一个新的名字叫做指针
。
所以我们可以理解为:
内存单元的编号=地址=指针
。
计算机中常见的单位:
一个bit可以存储一个2进制位的0或1。
bit---比特位
byte---字节
KB
MB
GB
TB
PB
1 byte = 8 bit
1 KB = 1024 byte
1 MB = 1024 KB
1 GB = 1024 MB
1 TB = 1024 GB
1 PB = 1024 TB
该如何理解编址呢?
CPU访问内存空间中的某个字节空间,就必须知道这个字节空间在内存中的位置。因为内存中有很多字节,所以就需要给内存进行编址。计算机中的编址并不是把每个字节的地址进行记录,而是通过硬件设计完成的。就像钢琴上并没有“do、re、mi、fa、sol、la、si”,但演奏者依然能够准确的进行演奏。这是因为制造商已经在硬件层面设计好了。
首先,必须知道计算机是有许多的硬件单元,而硬件单元是要协同工作的,所谓的协同至少应该是能够进行数据之间的传递。但是硬件之间是互相独立的,要如何进行数据之间的传递呢?其实是用“线“”连起来的。而CPU和内存之间有大量的数据交互,也要用线连起来。
不过今天我们关心一组线:地址总线
。我们可以简单的理解为32位机器上有32根地址总线,每一根地址总线有两种状态,0或者1(表示电脉冲的有无),那么一根线就可以表示两种含义,32根线就可以表示2^32种含义。每一种含义都可以表示一个地址。
地址信息被下达给内存,在内存上就可以找到该地址对应的数据,将数据通过数据总线传入CPU内寄存器。
2 指针变量、地址
理清了内存和地址的关系,我们来看C语言中的一段代码。
#include<stdio.h>
int main()
{
int a = 10;
return 0;
}
.
地址
在C语言中创建变量其实就是向内存申请空间
,上述代码创建了一个整型变量a,就会在内存中申请4个字节,用于存放整数10。其中每一个字节都有地址。
4个字节地址分别是:
1 0x00EFFD5C
2 0x00EFFD5D
3 0x00EFFD5E
4 0x00EFFD5F
那要如何拿到a的地址呢?
我们来学习一个新的操作符(&)
,取地址操作符。
#include<stdio.h>
int main()
{
int a = 10;
//&a取出a的地址,取出的是a所占4个字节中地址较小字节的地址
printf("%p\n", &a);
return 0;
}
.
指针变量
我们通过取地址操作符(&)取出的地址是一个数值
,比如:0x00EFFD5C
,这个数值有时候需要存储起来,方便后续使用。那我们把地址要存到哪里去呢?这个时候就轮到指针变量
出场了。
#include<stdio.h>
int main()
{
int a = 6;
int* pa = &a;
printf("%p\n", pa);//取出a的地址并存储到指针变量pa中
return 0;
}
指针变量也是一种变量,这种变量是用来存放地址的
。
我们该如何理解指针的类型呢?
int* pa
//*说明pa是指针变量
//int说明pa指向的对象是int类型
//int*是指针变量pa的类型
.
解引用操作符
我们将地址保存起来,未来我们是要使用的。那么我们该如何使用呢?解引用操作符(*)
就可以解决这个问题。
#include<stdio.h>
int main()
{
int a = 2;
int* pa = &a;
*pa = 5;
printf("%d\n", *pa);
printf("%d\n", a);
return 0;
}
*pa就是通过pa中存放的地址,找到pa指向的对象,所以*pa其实就是变量a,*pa是把a的值改成了5
。
.
指针变量的大小
我们已经知道指针是用来存放地址的,那么在32位机器上,假设有32根地址总线,每根地址线出来的电信号转换为数字信号是1或者0
,那么32根地址线产生2进制序列当做一个地址,一个地址有32个比特位,需要4个字节才能存储
。
同理,在64位机器上,有64根地址总线,也就是说一个地址是由64个二进制位组成的二进制序列,需要8个字节空间。
#include<stdio.h>
int main()
{
printf("%zd\n", sizeof(char*));
printf("%zd\n", sizeof(short*));
printf("%zd\n", sizeof(int*));
printf("%zd\n", sizeof(float*));
printf("%zd\n", sizeof(double*));
return 0;
}
结论:指针变量的大小和类型无关,与平台的大小有关
。
3 指针变量类型的意义
学到这里,可能就会有人有疑问了。既然指针变量的大小在相同的平台下是一样大的。那么为什么还要有各种各样的指针类型呢?我们可以通过调试直观地解答这个问题。
#include<stdio.h>
int main()
{
int a = 0x1122ff44;
int* pa = &a;
*pa = 0;
return 0;
}
可以直观的看到,int*类型的指针进行解引用操作的时候会访问4个字节
。
#include<stdio.h>
int main()
{
int a = 0x1122ff44;
char* pa = (char*)&a;
*pa = 0;
return 0;
}
int*类型的指针变成char*类型的指针,进行解引用操作的时候,会访问一个字节
。
现在应该清楚指针变量的意义了吧。
结论:指针的类型决定了指针在进行解引用操作的时候可以访问几个字节
。
.
指针+-整数
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* ppa = (char*)&a;
printf("&a=%p\n", &a);
printf("pa=%p\n", pa);
printf("pa+1=%p\n", pa + 1);
printf("ppa=%p\n", ppa);
printf("ppa+1=%p\n", ppa + 1);
return 0;
}
不难看出,int*类型的指针进行+1操作会跳过4个字节,char*类型的指针进行+1的操作跳过一个字节
。这就是指针类型差异带来的变化。
结论:指针的类型决定了指针加减整数的时候,跳过字节的个数
。
.
void*指针
在指针类型中有一种特殊类型是void*类型
,是一种无具体类型的指针(或者叫泛型指针)。这种类型的指针可以接受任意类型的的地址
。
注意:
.
void*类型的指针无法直接进行解引用操作
.
void*类型的指针无法直接进行加减整数的操作
#include<stdio.h>
int main()
{
int a = 10;
void* pa = &a;
return 0;
}
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
char* ppa = &a;
return 0;
}
可以看到,将int类型变量的地址赋值给一个char*类型的指针变量,会存在类型不兼容的问题。但是用void*类型的指针就不会存在这种问题
。
#include<stdio.h>
int main()
{
int a = 10;
void* pa = &a;
*pa = 5;
return 0;
}
可以很清楚的看到,void*类型的指针无法直接进行指针运算
。那么void*类型的指针到底有什么用呢?void*类型的指针一般用于函数的参数部分,用来接收任意类型数据的地址
。这样可以实现泛型编程的效果(通用性强)。
4 const修饰指针
.
const修饰变量
#include<stdio.h>
int main()
{
const int a = 10;
//int const a = 10;//这两种方式都可以,但是一般const放在前面
a = 20;
return 0;
}
到了这里,很多小伙伴应该已经迷糊了吧。为什么会这样呢?首先我们要清楚const的作用。
const具有常属性的意思。在C语言中,const修饰的变量叫做常变量,本质上还是变量,但是不允许被修改
。
在C++语言中,const修饰的变量变成了常量
。
C99之前,在C语言中,我们在指定数组大小的时候只能给一个常量值。如果使用变量来指定数组的大小,语法是不支持的。现在有了const的出现,能否打破这个规则呢?
#include<stdio.h>
int main()
{
const int n = 5;
int arr[n] = { 1,2 };
return 0;
}
看来是不行的。为什么呢?因为此时n的本质还是变量。前面提到,在C++中,const修饰的变量会变成常量,那么可不可以使用const修饰的变量来指定数组的大小呢?
#include<stdio.h>
int main()
{
const int n = 10;
int arr[n] = { 1,2,3,4,5 };
return 0;
}
看来在C++中是可以使用const修饰的变量来指定数组的大小的。因为此时const修饰的变量已经变成了常量。
前面提到,const修饰的变量是不允许被修改的。那么是否还有其他办法去修改const修饰的变量呢?
答案是有的。我们可以取出变量的地址,通过指针的方式去间接修改变量的值。
#include<stdio.h>
int main()
{
const int a = 10;
int* pa = &a;
*pa = 20;
printf("%d\n", *pa);
return 0;
}
很显然,这种方式是可以达到我们想要的效果的。但是我们为什么要用const修饰变量呢?本意是不希望变量被修改的。所以接下来我们一起来看看const修饰指针又会有什么不一样的烟火呢?
.
const修饰指针变量
一共有3种情况:
const放在*的左边
const放在*的右边
const放在*的两边
我们逐一进行分析。
#include<stdio.h>
void test1()
{
int n = 10;
int* p = &n;
//这种情况是ok的。
*p = 20;//ok?
}
void test2()
{
int n = 6;
int m = 100;
const int* p = &n;
//这种情况是不ok的。为什么呢?此时这里const放在*的
//左边,是用来修饰*p的。这也就意味着不可以通过*p来进行修
//改变量n的值。但是指针变量本身是可以进行修改的。
*p = 10;//ok?
p = &m;
}
void test3()
{
int n = 2;
int m = 4;
int* const p = &n;
//const放在*的右边,修饰的是指针变量p,说明指针p
//是不能被修改的,但是*p是可以修改的
p = &m;//ok?
*p = 8;
}
void test4()
{
int n = 12;
int m = 66;
const int* const p = &n;
//const放在*的两边,修饰的是*p和指针变量p,也就是
//说不能通过*p修改变量n的值,也不能通过指针变量p修改p的值
*p = 24;//ok?
p = &m;//ok?
}
int main()
{
//测定无const的情况
test1();
//测定const在*左边的情况
test2();
//测定const在*右边的情况
test3();
//测定const在*两边的情况
test4();
return 0;
}
结论:const修饰指针变量p的时候
.
const放在*的左边,修饰的是指针变量指向的内容,保证指针指向的内容是不能通过指针来修改的,但是指针变量本身的内容是可以修改的
。
.
const放在*的右边,修饰的是指针变量本身,保证指针变量本身的内容是不能修改的,但是指针指向的内容是可以通过指针来修改的
。
.
const放在*的两边,既修饰了指针变量本身又修饰了指针指向的内容。所以都无法进行修改
。
5 指针运算
指针的基本运算有3种,分别是:
指针+-整数
指针-指针
指针的关系运算
.
指针+-整数
数组在内存中是连续存放的,我们只需要知道第一个元素的地址,就可以顺藤摸瓜找出所有的元素。
#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* parr = arr;
int i = 0;
for (i = 0; i < sz; i++)
{
// 指针+-整数
printf("%d ", *(parr + i));
}
return 0;
}
.
指针-指针
用一个小小的练习来实践一下:求取字符串的长度
#include<stdio.h>
#include<assert.h>
size_t my_strlen(char* pch)
{
assert(pch != NULL);
char* start = pch;//存放数组首元素的地址,以防起始地址丢失
while (*pch != '\0')
{
pch++;
}
// 指针-指针
return (pch - start);
}
int main()
{
char ch[10] = "abcdefg";
size_t len = my_strlen(ch);
printf("%zd\n", len);
return 0;
}
这里需要清楚一个点,指针-指针到底是什么意思呢?指针-指针相当于地址-地址,其实求的是指针之间元素的个数
。
注意:
.
指针-指针的前提条件是指针必须指向同一块内存空间
。
.
指针-指针相当于地址-地址,地址是由低地址到高地址的,所以低地址-高地址会出现负数的情况,反之,为正数
。
.
指针的关系运算
指针的关系运算其实就是两个指针比较大小,我们可以利用这个关系来打印数组的内容。
#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]);
// 指针的关系比较
while (p < arr + sz)
{
printf("%d ", *p);
p++;
}
return 0;
}
6 野指针
概念:野指针就是指针指向的位置是不可知的(随机的,不正确的,没有明确限制的)。
野指针的成因:
.
指针未初始化
.
指针指向的空间释放
.
返回局部变量的地址
.
指针越界访问
#include<stdio.h>
int main()
{
int* p;//局部变量指针未初始化,默认是随机值
*p = 20;
return 0;
}
这里使用了未初始化的指针,意味着指针里面存放的地址是随机的。当我们通过指针里面存放的地址去修改时,就会出现非法访问,这时指针是一个野指针。
#include<stdio.h>
int* test()
{
int n = 10;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
main函数中调用了test函数,在栈区就会为test函数申请一块内存空间用来存放各种局部变量的值。进入test函数局部变量开始创建,出test函数局部变量销毁,内存回收。所以test函数返回局部变量n的地址若被使用就会出现非法访问,这时的指针是一个野指针。
#include<stdio.h>
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int i = 0;
int* p = arr;
for (i = 0; i <= 11; i++)
{
//指针指向的范围超过arr数组的范围时,p就是一个野指针
printf("%d ", *(p+i));
}
return 0;
}
在main函数中创建了一个整型数组,数组有10个元素,每一个元素的类型是int类型。使用指针访问数组时,循环了12次,此时指针已经越界访问了。这时的指针是一个野指针。
如何规避野指针?
.
指针初始化
如果知道指针指向哪里,就把地址赋值给指针,否则就赋值NULL。NULL是C语言中定义的标识符常量,值是0,0也是地址,这个地址是无法使用的,读写改地址会报错。
1 #ifdef __cplusplus
2 #define NULL 0
3 #else
4 #define NULL ((void *)0)
5 #endif
.
小心指针越界
.
指针变量不再使用时,及时置为NULL,检查指针的有效性
.
避免返回局部变量的地址
7 assert断言
assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就报错并终止程序运行。这个宏常常被称为断言。
assert宏接受一个表达式作为参数。如果该表达式为真(返回值非零)程序正常运行。反之(返回值为0),终止程序。assert()就会报错,在标准错误流stderr中写入错误信息显示没有通过的表达式以及包含这个表达式的文件名和行号。
优点:
.
自动标识文件和出问题的行号
.
无需更改代码就能开启或关闭assert的机制
。如果确认程序没有问题,不需要断言。就在#include<assert.h>
语句的前面定义一个宏NDEBUG
#define NDEBUG
#include<assert.h>
缺点:
.
引入了额外的检查,增加了程序的运行时间
。
8 传值调用和传址调用
用实战来说明指针的重要性。
写一个函数实现两个整数的交换。
.
传值调用
#include<stdio.h>
void swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
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;
}
很明显,a与b的值并没有发生交换。这是为什么呢?在函数里我们已经说到过,形参是实参的一份临时拷贝,形参与实参有着各自的独立空间
。我们可以通过调试来进行观察。
传值调用无法实现,是因为空间不同。那么如果我们能够在swap函数里操作main函数里的a和b,不就解决问题了吗。
.
传址调用
#include<stdio.h>
void swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
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;
}
很明显,传址调用是可以达到效果的。如果使用的数据非常的庞大,那么建议使用传址调用。因为传址调用不会浪费栈帧空间,但是会影响实参的结果。
传值调用的优点:
.
保护原始数据不被修改,提高了代码的稳定性与安全性
缺点:
.
浪费栈帧空间
传址调用的优点:
.
可以让函数内部与函数外边的变量建立起联系,函数内部可以直接操作函数外部
结语:今天指针的内容到此告一段落。希望各位小伙伴有所收获,帮小编点点赞吧。