# 08 | 指针系列(二):记住,指针变量也是变量 你好,我是胡光,咱们又见面了,上节课中,我们介绍了结构体相关的基础知识,也介绍了指针变量,并且教给你了最重要的一句话“指针变量也是变量”。这句话的意思在于告诉你,所有你对变量的理解,都可以放到指针变量上,例如:变量有类型,变量有大小,变量里面的值支持某些操作等等。今天呢,我们就来详细地聊一下指针变量。 ## 任务回顾 在正式开始之前,我们先来回顾一下上节课的任务内容: 上节课我们说,如果给我们如下 Data 结构体类型,这个类型中有两个整型数据字段 x,y: ``` struct Data { int x, y; } a[2]; ``` 那么请用尽可能多得形式,替换下面代码中 &a\[1\].x 的部分,使得代码效果不变: ``` struct Data *p = a; printf("%p", &a[1].x); ``` 你会看到,如上代码中,就是输出 a\[1\].x 的地址值。 通过上节的学习,你现在已经掌握了关于结构体的相关知识,也初步地接触了“指针变量也是变量”的这个概念,今天就让我们再深入了解指针变量吧。 ## 必知必会,查缺补漏 #### 1\. 深入理解:指针变量的类型 还记得我们是如何定义 p 变量的么?代码语句是: ``` int *p ``` 之前我们介绍了,语句中的 \* 代表 p 变量是一个指针变量,而 int 的作用是什么呢?只是用来说明 p 是一个指向整型存储区的指针变量么?其实 int 更大的作用,就是用来解决我们上面提到的那个问题,根据 p 变量中的内容,我们可以找到一个存储区的首地址,然后再根据 p 的类型,就可以确定要取几个字节中的内容了。 下面给你举个例子: ``` int a = 0x61626364; int *p = &a; char *q = (char *)&a; printf("%x %c\n", *p, *q); ``` 这段上面代码中,p 和 q 同时指向了 a 变量的存储区。而取值 p 和取值 q 的结果,却截然不同。这是因为,取值 p 时,程序会从 p 所指向的首地址开始,取 4 个字节的内容作为数据内容进行解析,而取值 q 的时候,则是取 1 个字节的内容,作为数据内容进行解析。 你如果运行上述代码,大概率你会看到输出内容是: ``` 61626364 d ``` 小概率会看到输出内容是: ``` 61626364 a ``` 这个原因和“大端机”“小端机”有关,关于这个问题,你要是有兴趣的话,可以自行查阅相关资料。下面的图中呢,就是以“小端机”为例,说明的 p 和 q 取值的问题: ![](https://static001.geekbang.org/resource/image/2f/53/2f8c77a569286f3bc3fb8adbf0dc3553.jpg "图1:指针变量取值示意图") 如图所示,p 变量对应了 a 变量整个存储区中的内容,所以输出取值 p 和 a 原本存储内容相同。而 q 变量由于是字符型指针变量,只能从首地址取到 1 个字节的内容,取到的就是64,这里的 64 注意可是 16 进制的数字,对应到 10 进制数字就是 100,而 %c 是输出一个字符,数字 100 对应的字符就是英文小写字母 ‘d’。 实际上,我们看到的任何字符,在底层都对应了一个具体的数字。常用的有字符 ‘a’,对应的是 97,字符 ‘b’,对应的是 98,以此类推,还有数字 ‘0’ 是 48,数字 ‘1’ 是 49,后面的对应规律类似,我们管这个对应规则叫做 ASCII 编码。 指针变量的类型,除了用来确定取值时,确定覆盖存储区的大小以外,还有其他作用。想一想,整型支持加减乘除操作,而我们所谓的地址类型的值,也可以在其上面做加减的操作,你可以试着运行下面的代码: ``` int a, *p = &a; char *q = &a; printf("%p %p", p, q); printf("%p %p", p + 1, q + 1); ``` 代码中,定义了三个变量,其中一个整型变量 a,两个指针变量 p 和 q,其中 p 是整型指针变量,q 是字符型指针变量。然后分别输出 p 和 q,以及 p + 1 和 q + 1 的值以作对比。 如果你运行上面的程序,你会看到,p 和 q 的值是相同的,都是 a 变量的首地址,但是 p + 1 和 q + 1 的值却不同。如果你仔细观察会发现,p + 1 的地址值与 a 的地址之间差了 4 个字节,而 q + 1 的地址值与 a 的地址之间只差了 1 个字节。 ![](https://static001.geekbang.org/resource/image/19/f1/199260e49de2ab7bd33cf2610b4a33f1.jpg "图2:地址加法操作结果") 通过上图,你就可以更清晰的看到,由于 p 是整型指针,所以 p + 1 的计算结果,是向后跳了一个整型,相当于从第一个整型的首地址,跳到第二个整型的首地址;而由于 q 是字符型指针,所以 q + 1 的计算结果,就是向后跳了一个字符型。 这样,你就可以明白了吧?如果一个浮点型的指针变量加 1,就会向后跳一个浮点型。这就是**指针变量类型的第二个作用:在加法或者减法时,确定增加或者减少的地址长度**。 #### 2\. 指针变量与数组 理解了指针类型的作用以后,我们再回到“指针变量也是变量”这句话上,指针变量所存储的值,就是地址。在之前的学习中,还有什么是与地址相关的概念呢?你一定会想起数组这个概念。对,数组名代表了数组中第一个元素的首地址,也是整个数组的首地址,既然是地址,那就可以用指针变量来存储。 下面,我就跟你说几个之前没有告诉你,但却很有趣的事情。 假设有一个整型数组arr,如何表示第二个元素的地址呢?是不是 &arr\[1\] ?如果 arr 也代表了整个数组的首地址,同时把这个首地址存储在一个整型指针变量 p 中,那么用这个指针变量如何表示第二个元素的地址呢? 根据上面的学习,应该是 p + 1。那如何表示 arr\[n\] 元素的地址呢?稍加思索,你就应该知道就是 p + n。所以我们现在知道了,在程序中,&arr\[n\] 等价于 p + n,当然也等价于 arr + n,聪明的你别犯糊涂,一定要注意,参与运算的是值,不是变量名! 既然 p 中存储了一个地址,可以参与加法运算,那么 arr 实际上也代表了一个地址,也可以参与加法运算。地址才是参与运算的值,指针只是存储地址值的变量,只是一个容器。所以,不是指针支持加减法操作,而是地址这种类型的值,支持加减法操作。 在这里,我们回头看数组名称后面的那一对方括号,如果我告诉你这也是一个运算符,你会想到什么?请注意认真看下面这一段合理化的猜想推理: 如果那一对方括号代表了运算符,而运算符本质上是作用在值上面,也就是说,当我们写 arr\[1\] 的时候,方括号运算符前面看似放着一个数组名,实际上放了一个地址,放了一个数组的首地址,因为 arr 就是数组的首地址,还是那句话:地址才是参与运算的值。也就是说,当我们把数组的首地址,存储在一个指针变量中以后,这个指针变量配合上方括号运算符,也可以达到相同的效果! 为了让你更清楚的理解,准备了如下演示代码: ``` int arr[100] = {1, 2, 3, 4}; int *p = arr; printf("%d %d\n", arr[1], p[1]); ``` 代码中,我们定义了一个整型数组 arr,然后将数组的首地址赋值给了一个整型指针变量 p,最后分别输出 arr\[1\] 和 p\[1\] 的值,你将看到输出的是同一个值,都是数组中第二个元素的值。 最后,我用一张图给你展示了指针与数组的几个程序代码层面的等价关系,在实际编程过程中,重点是需要分析,相关的指针操作后,对应的到底是哪个元素,对应的是这个元素的首地址,还是这个元素的值。 ![](https://static001.geekbang.org/resource/image/08/af/08de66172ebcf2f13cc0ff2b8deba8af.jpg "图3:指针与数组的等价表示") 从上图的等价表示中,你可能会自己推导出另外一种等价表示\*(p + 5) 等于 arr\[5\]。我希望你重视等价表示的学习,因为所谓等价表示,就是在写程序的时候,多种等价表示,写哪一种都一样。这就造成了,不同的编码习惯,会用不同的符号来完成程序,如果你不理解这些等价的表示方法,很有可能在看别人程序的过程中,就会出现看不懂的现象。 #### 3.指针变量的大小 最后,我们再回到“指针变量也是变量”这句话上。只要是变量,就占据一定的存储空间,那一个指针变量占多少个字节的存储空间呢? 在回答这个问题之前,我先问你另一个问题,请你思考一下:是整型指针变量占用的存储空间大,还是字符型指针变量占用的存储空间大?我们想想啊,一种数据类型占用多少存储空间跟什么有关系?和存储的值有关系啊。当你想存储一个 32 位整数的时候,就必须要用 4 个字节,不能用 2 个字节,也不能用 3 个字节,这都是不够的。 究竟是哪一种类型的指针占的存储空间大呢?答案是:一样大。为什么呢?就是因为,无论是什么类型的指针,存储的值都是某个字节的地址,而在一个系统中,无论是哪个字节的地址,二进制数据长度都是一样的。所以,无论什么类型的指针,所需要存储的值的底层表示长度是一样的,那么所占用的存储空间也当然是一样的了! 有句话描述的非常形象“类型就是指针变量的职业”。什么意思呢?我们知道现实生活中,有些人做保安,有些人做工程师,还有些人当艺术家,可不管你做什么,你无法改变的是你作为人的生理结构。所以放到指针变量的概念里,那就是不管什么类型的指针,指针所改变不了的是其占用空间的存储大小,因为不管是什么类型的指针,存储的都是无差别的地址信息。 ## 任务参考答案 至此,我们终于准备完了所有的基础知识,下面就让我们回到最开始的那个任务吧。对于这个任务,如果我们要是想写的话,至少能写出 20 种以上的答案。这里,我会选出两种比较有代表性的、比较有趣的做法分享给你。 #### 1\. 间接引用 首先来看第一种: ``` struct Data *p = a; printf("%p", &((a + 1)->x)); ``` 这里用到了一个之前提到过,可是没有讲到的运算符,减号大于号(->),组合起来,我们叫做“间接引用”运算符,作用可以和“直接引用”运算符对比。 例如:a 是一个结构体变量,a 中有一个字段叫做 x,由 a 去找到 x,这个过程比较直接,我们就用 a.x 来表示。可如果 p 是一个指针,指向 a 变量,如果要是由 p 去找到 x,这个过程就是个间接的过程,所以我们就使用 p->x。简单来说,就是:是结构体变量引用字段,就直接引用,如果是指针想引用字段,就是间接引用。 在这个第一种做法中,直接用 a + 1 定位到第二个结构体元素的首地址,然后间接引用 x 字段,最后再对 x 字段取地址,那么得到的和原任务中所输出的地址是一样的。 #### 2\. 巧妙使用指针类型 再来看一下第二种: ``` struct Data *p = a; printf("%p", &(a[0].y) + 1); ``` 这个第二种做法就有点儿意思了。首先,它先定位到 a\[0\] 元素中 y 字段的首地址,然后对 y 字段取地址,这个时候,由于 y 字段是整型,所以取到的地址类型就是整型地址,之后再对这个整型地址执行 +1 操作,得到的也是 a\[1\].x 的首地址。 按照之前所学,画出内存中的存储示意图,你就会得到下面这张图的具体情况: ![](https://static001.geekbang.org/resource/image/40/bd/409bd833baaab2a1ac3b89c27688cfbd.jpg "图4:a数组内存结构示意图") 第二种方法巧妙的利用了地址类型这个知识点,通过整型地址加法操作结合对于内存存储结构的知识,综合运用以上两个知识点,最终定位 a\[1\].x 变量的地址。如果你可以独立想出这个方案,那我真的是要给你点赞的! 上面的方案中,都在用原数组 a 去定位 a\[1\].x 变量的地址,你可以使用 p 指针,完成相同的操作么?欢迎把你的答案写在留言区,让我也欣赏一下你的思维方式。记住,这个问题,至少能写出来 20 种以上的等价表示形式。 ## 课程小结 今天我们终于讲完了指针部分,这一部分的知识,再回过头来看,虽然各种各样的知识点,可我想让你记住的还是那一句话:“指针变量也是变量”。 而在今天的学习中,我希望你记住的重点,有以下三点: 1. 指针的类型,决定了指针取值时所取的字节数量。 2. 指针的类型,决定了指针加减法过程中,所跨越的字节数量。 3. 无论是什么类型的指针,大小都相等,因为地址信息是统一规格的。 好了,今天就到了这里了,我是胡光,我们下次见!