前言
本文的所有代码均在Compiler Explorer(一个交互式编译器探索网站)上实现,你需要有一点C/C++语言的基础。本文主要是面对指针掌握得不好得朋友,我会从代码逐步分析至指针的底层运行逻辑,希望能给你带来对指针不一样的理解。
一、认识指针
1、指针是一个变量
指针是一个变量,指针存储的值是变量的内存地址,而不是值本身。(这句话很关键)在C/C++语言中通过“&”取地址符可以获取变量的内存地址,在输出语句中通过“%p”占位符输出内存地址。来看下面这段代码
//example_01
#include <stdio.h>
int main()
{
int A = 10;
char B = 'b';
double C = 6.6;
//输出变量内存地址
printf("A的地址是:%p\n",&A);
printf("B的地址是:%p\n",&B);
printf("C的地址是:%p\n",&C);
return 0;
}
输出结果如下图
不难看出变量A,B、C的内存地址都是一串相似16进制的数字,那么从本质上讲内存地址就是整数( 16进制数可以转化为10进制的整数 ),尽管这不够严谨,后面会深入解读。那么内存地址究竟能干嘛?变量都具有内存地址嘛?
对于第二个问题答案是肯定的,变量都具有内存地址。对于问题一,还请继续往下阅读。
2、指针的声明
指针的声明格式如下
数据类型 * 变量名;
在数据类型后面加上 “ * ” 是表示声明的变量是个指针。你可能会有疑惑,既然指针存储的是内存地址( 就像int类型变量存储整数 ),那为什么指针还有数据类型呢?上面例子1的代码输出的三个不同类型的变量的内存地址几乎一摸一样。
这很简单,内存地址也是地址啊!地址也是有类型的,就拿生活中的例子来讲,浦东机场的地址类型就是机场类地址,而汤臣一品的地址类型就是宅基地类地址。因此指针也必须要具有对应的类型。
下面来看指针的声明和初始化。
//example_02
#include <stdio.h>
int main()
{
int A = 10;
char B = 'b';
double C = 6.6;
int* pA = &A; //初始化为A的内存地址
char* pB = &B; //初始化为B的内存地址
double* pC = &C; //初始化为C的内存地址
printf("A的地址是:%p\n",&A);
printf("B的地址是:%p\n",&B);
printf("C的地址是:%p\n\n",&C);
printf("pA的值是:%p\n",pA);
printf("pB的值是:%p\n",pB);
printf("pC的值是:%p\n",pC);
return 0;
}
输出结果如下图
由上面的例子,确实能看到指针存储了变量的内存地址。可是与例子1相比较,你发现变量的内存地址发生了改变。这是为什么呢?
这是因为在每次程序运行时操作系统分配给程序的内存空间都是随机的。这才导致每次输出变量的内存地址不一样。
二、内存地址
本节是关键中的关键,也是难点,如果你能明白内存地址这个概念,那么指针对于你来说就非常简单了。
1、何为内存地址
现在可以回答你 “ 内存地址究竟能干嘛?”这个问题了。内存地址是一种用于软件及硬件等不同层级中的数据概念,用来访问电脑主存中的数据。读完这句话的你可能有点懵逼。但你只需要知道,“ 内存地址可以用来访问电脑主存中的数据 ”就足够你了解接下来要讲的内容了。
2、通过变量内存地址访问变量值
既然内存地址可以用来访问电脑主存中的数据,那么在编程中如何实现通过变量内存地址访问变量值?这需要用到 “ * ” 解引用操作符,只需要解引用变量的内存地址就能获得该变量的值。来看下面这段程序
//example_03
#include <stdio.h>
int main()
{
int A = 10;
int *p=&A;
printf("A的地址是:%p\n",&A);
printf("p的地址是:%p\n\n",&p);
printf("A的值是:%d\n",A);
printf("p的值是:%p\n",p);
printf("p的*解引用值是:%d\n",*p);
return 0;
}
输出结果如下图
从输出结果上来看,指针p的值是变量A的内存地址,这没有任何问题。通过解引用p(p存储的是A的内存地址)获得了变量A的值(10)。这和预期一样,通过解引用变量的内存地址就能获得该变量的值。这看似简单的解引用操作,背后运行的原理果真如此吗?来看下面这副图
图 A
首先我们要达成一个共同的认知就是,程序在运行时,其数据是存储在内存空间中的,其次变量名是方便程序员阅读代码和操作变量,然而在实际运行中,电脑是通过内存地址去读写数据的,因此内存地址必不可少。
由上图,不难看出指针p的值是变量A的内存地址,因此你可以说 “ 指针p指向变量A的内存地址 ”(也就是上图黑色虚线箭头),它有一个 “ 指向 ” 的动作。红色的实线箭头就是变量A的内存地址被解引用,这样可以访问A的内存空间里的数据了。其实解引用本质就是获得内存地址对应的内存空间中的数据对象。(简单的来说就是通过某个变量的内存地址获得其值)
3、“ & ”取地址和“ * ”解引用
简单的提一下取地址和解引用。
取地址就是获取变量的内存地址,直接将 “ & ”取地址符放在变量的左边即可,&变量名。
解引用就是获取内存地址对应内存空间的值,直接将 “ * ”解引用放在指针的左边,(其实并不一定是指针,也可以是任何合法的内存地址)。也就是说,我们可以完成下面这样的操作
//example_04
#include <stdio.h>
int main()
{
int A = 10;
if(A == *&A)
{
printf("它们相等\n");
}
return 0;
}
输出结果如下图
为什么if语句里的判断条件相等是正确的呢?来看 “ == ”双等的右边的表达式。
首先通过取地址符,&A获取到了变量A的内存地址,然后再通过解引用,*&A获取变量A内存地址对应的内存空间中的数据。也就是说 if 判断的表达式实际是 “ 10 == 10 ”,那么肯定正确,于是乎输出了 “ 它们相等 ”。
那么再来看下面这段代码
//example_05
#include <stdio.h>
int main()
{
int A = 10;
int* p = &A;
if(A == *p)
{
printf("它们也相等\n");
}
return 0;
}
输出结果一定是 “ 它们也相等”,因为指针p存储的值就是变量A的内存地址。终于你发现了
*p == A这个事实,*p和A都是指的都是对应内存空间中的数据(参考上图A)。
既然可以访问,也就是说通过指针p,可以改变A的值。来看下面这段代码
//example_06
#include <stdio.h>
int main()
{
int A = 10;
int* p=&A;
*p = 20; //解引用指针p指向的内存地址,并向该内存地址对应的内存空间写入20
printf("A的值是:%d\n",A);
return 0;
}
来分析分析这句代码
*p = 20 ;
这句代码到底干了啥?为什么在没有操作变量A的情况下,改变了变量A的值,由刚才的发现的事实,你可以大胆的把 “ *p = 20 ” 看成是 “ A = 20 ”,因为它们操作的都是同一个内存空间!!!
4、内存------“ 字节数组 ”
学习指针的难点在于理解指针所存储的值是内存地址,相信阅读至此,你对内存地址有了一定的了解。那么内存地址为何可以访问数据?你可能还是有点疑惑。接下来就来回答这问题。再回答这个问题前,请再次回到上面的图A,仔细观察观察......
发现了什么?如果你对数组足够敏感,那么就能想到内存不就是数组!!!为什么这么说?因为你在图A中发现内存空间之于内存,就像数组元素之于数组;内存地址之于内存,就像数组下标之于数组。这两个 “ 之于 ” 是理解内存是“ 字节数组 "的关键。我们通常通过数组下标获取数组元素的数据,同理通过内存地址也可以获取内存空间的数据。那么从本质上来讲 “ 内存就是字节数组 ”,为何是字节数组?不是比特数组?这是因为电脑存储的最小单元是一个字节(8比特)。
你想象中的 “字节数组”(内存)可能是下面这样的
图 B
显而易见,指针就是 “ 字节数组的下标 ”(因为指针存储的是内存地址),那么通过数组下标(内存地址)就可以操作数组元素(内存空间)。一切都是这么的合理!!!
最后请一定要试着去理解 “ 字节数组 ”这个概念,因为这对后面你去理解指针的一些骚操作有一点的帮助。
三、指针运算
指针本身只支持加和减两种操作,同类型指针也仅仅支持减法操作。
你:???!!为什么???你不是说内存地址的本质是整数嘛?既然指针存储内存地址(整数),为什么指针只能加减操作,而且同类型只支持减操作而不支持加操作?
如何解决你的疑惑,且听我细细道来。
还是以生活中的例子来举例。通常建筑物都有一个地址(对此你没有异议吧),你看到的可能是xxxx街道2社0066号(可能是个文具店),我们对这个地址可以进行加操作,比如对这个地址加1,也就获得了xxxx街道2社0067号(可能是个玩具店),同理当然可以进行减操作。
你试想一下,如果你现在站在xxxx街道2社0066号,假设你前进是加法操作而退后是减法操作,那么在66号的基础上加3,就是向前移动3个建筑来到69号。那么地址能进行乘除嘛?答案是肯定不能的。你不要想着66号x3变成了189号,你问路的时候也听过,这个地方乘多少就到了,更多的是向前,向后,左转,右转。
因此尽管内存地址的本质是整数,那也是有局限的整数。
1、自身的加法操作和减法操作
为了更好理解,我们将用指针和数组来实现这样的操作,来看下面这段代码。
//example_07
#include <stdio.h>
int main()
{
int arr[5]={1,2,3,4,5};
int* p = &arr[2]; //将数组第3个元素的内存地址赋值给指针p
printf("未进行任何操作,解引用指针p,此时*p = %d\n",*p);
p++; //指针p自增 得到第4个元素
printf("指针p自增操作,解引用指针p,此时*p = %d\n",*p);
p = p - 3; //指针减3 得到第1个元素
printf("指针p减3操作,解引用指针p,此时*p = %d\n",*p);
return 0;
}
输出结果如下图
非常的神奇哈,这是为什么?原因非常简单,因为数组是连续存存储的同一类型的数据,因此数据项之间也是连续的,不可改变的是数据都要存储在内存空间中,因此数组的元素内存地址也是连续的。当拿到第三个元素的内存地址时也就意味着你可以获取到这个数组的任何元素。因为你只需要不断的自增指针或者自减指针,就能访问完数组中的元素了。
2、同类型指针的减法操作
来看下面这段代码
//example_08
#include <stdio.h>
int main()
{
int arr[5]={1,2,3,4,5};
int* p1 = &arr[0];
int* p2 = &arr[3];
printf("p1-p2=%d\n",p1-p2);
printf("p2-p1=%d\n",p2-p1);
printf("\n");
printf("*p1-*p2=%d\n",*p1-*p2);
printf("*p2-*p1=%d\n",*p2-*p1);
return 0;
}
输出结果如下图
可以看到两个指针确实能进行减法操作,可是这个结果有什么意义呢?还记得“ 字节数组 ”嘛?现在来回忆一下,指针是字节数组的下标。两个指针相减,也就意味着两个下标相减,那么它们的意义又是什么?
p1-p2=-3,这就意味着指针p1存储的内存地址(字节数组下标)要比p2存储的内存地址小。因此结果为负数。一般我们对统一类型的指针做减法操作,并不关心结果的正负,只关注数值。这个数值3意味着两个指针之间的距离为3,具体的指针的距离为3个int字节的距离,因为这两个指针存储的是int类型的内存地址。这么说你可能有点迷茫,来看一段程序,它输出两个内存地址
//example_09
#include <stdio.h>
int main()
{
int arr[5]={1,2,3,4,5};
printf("arr[0]的内存地址:%p\n",&arr[0]);
printf("arr[1]的内存地址:%p\n",&arr[1]);
printf("&arr[0]-&arr[1]=%d\n",&arr[0]-&arr[1]);
return 0;
}
输出结果如下图
观察输出结果,你发现了没有。第一个元素和第二个元素之间的实际距离就是4个字节,而指针运算出的结果是-1,这意味着第一个元素和第二个元素之间距离为1,准确的来说是4字节,这个4字节(int)就是这两个指针的 “ 步长 ”。意味着指针每走一步,实际内存地址加了4字节。
不同是不同类型指针的步长取决于指针的类型。比如char*类型的指针,它的步长就是1个字节(char)。可能下面这幅图能让你更好的理解步长。
在指针赋值时往往会将变量的首地址赋值给变量。int是4字节的整型,存储整数时需要4个字节,但通过取地址符可以获取变量的首地址,这样使得指针的赋值操作格外简单,但需要注意的char存储数据需要1字节,但内存地址和int型存储地址没有区别,所以指针也需要类型,这样才能正确的解引用指针指向内存地址里的值。
四、指针大小
指针的大小(所占内存空间大小)只取决于计算机的寻址能力,而不是和指针所指向的变量类型有关。
指针在32位的计算机所占内存空间的大小是4字节
指针在64位的计算机所占内存空间的大小是8字节
如果你想搞清楚指针的大小为什么是4字节和8字节,那么建议你去读一读《计算机组成原理》。下面简单的输出一下4个基本类型指针的大小,它们的大小一定是8字节(因为我的电脑是64位)
//example_10
#include <stdio.h>
int main()
{
printf("指向char型指针的大小是%d字节\n",sizeof(char*));
printf("指向int型指针的大小是%d字节\n",sizeof(int*));
printf("指向float型指针的大小是%d字节\n",sizeof(float*));
printf("指向double型指针的大小是%d字节\n",sizeof(double*));
return 0;
}
输出结果如下图
既然指针的大小是8个字节,也就意味着内存地址的取值范围在0x0000000000000000-0xFFFFFFFFFFFFFFFF之间。
总之指针大小取决于计算机的寻址能力。
五、空指针
空指针是一个特殊的指针值,是指没有指向(即存储变量地址,后面都会用“ 指向 ")任何一个对象的指针,用宏定义NULL表示空指针的值。
如果你试图解引用空指针,程序会被操作系统强行终止。看下例代码
//example_11
#include <stdio.h>
int main()
{
int* p=NULL;
printf("解引用p的值是:%d\n",*p);
return 0;
}
输出结果如下图
发现程序报错。事实证明不能解引用空指针,因此常常使用空指针初始化指针,这一样一旦错误的使用了无效(未初始化)的指针,就能及时发现潜在的bug。然而像Dos、UNIX这些操作系统都是允许通过空指针引用的。
六、数组和指针
先来回顾一下数组的定义,数组是指将有限个相同类型的变量排列起来的对象。通过下列程
序输出数组各个元素的值和地址
//example_12
#include <stdio.h>
int main()
{
int arr[5]={11,22,33,44,55};
//依次输出数组元素的值
for(int i=0;i<5;i++)
printf("arr[%d]:%d\t",i,arr[i]);
putchar('\n');
//依次输出数组元素的地址
for(int i=0;i<5;i++)
printf("%p\t",&arr[i]);
return 0;
}
输出结果如下图
仔细观察输出,发现各个相邻元素之间的地址值相差4字节,这正是1个int的大小,这个数组在内存中的布局如下图所示