编程的本质其实就是操控数据,数据存放在内存中。因此,如果能更好地理解内存的模型,以及 C 如何管理内存,就能对程序的工作原理洞若观火,从而使编程能力更上一层楼。大家真的别认为这是空话,我大一整年都不敢用 C 写上千行的程序也很抗拒写 C。因为一旦上千行,经常出现各种莫名其妙的内存错误,一不小心就发生了 coredump...... 而且还无从排查,分析不出原因。相比之下,那时候最喜欢 Java,在 Java 里随便怎么写都不会发生类似的异常,顶多偶尔来个 NullPointerException,也是比较好排查的。直到后来对内存和指针有了更加深刻的认识,才慢慢会用 C 写上千行的项目,也很少会再有内存问题了。「指针存储的是变量的内存地址」这句话应该任何讲 C 语言的书都会提到吧。所以,要想彻底理解指针,首先要理解 C 语言中变量的存储本质,也就是内存。
1.1 内存编址
计算机的内存是一块用于存储数据的空间,由一系列连续的存储单元组成,就像下面这样:每一个单元格都表示 1 个 Bit,一个 bit 在 EE 专业的同学看来就是高低电位,而在 CS 同学看来就是 0、1 两种状态。由于 1 个 bit 只能表示两个状态,所以大佬们规定 8个 bit 为一组,命名为 byte。并且将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址。这就相当于,我们给小区里的每个单元、每个住户都分配一个门牌号:301、302、403、404、501......在生活中,我们需要保证门牌号唯一,这样就能通过门牌号很精准的定位到一家人。同样,在计算机中,我们也要保证给每一个 byte 的编号都是唯一的,这样才能够保证每个编号都能访问到唯一确定的 byte。
上面说,我们可以通过&符号获取变量的内存地址,那获取之后如何来表示这是一个地址,而不是一个普通的值呢?也就是在 C 语言中如何表示地址这个概念呢?对,就是指针,你可以这样:
1int *pa = &a;
pa 中存储的就是变量 a 的地址,也叫做指向 a 的指针。在这里我想谈几个看起来有点无聊的话题:
为什么我们需要指针?直接用变量名不行吗?
当然可以,但是变量名是有局限的。
变量名的本质是什么?
是变量地址的符号化,变量是为了让我们编程时更加方便,对人友好,可计算机可不认识什么变量 a,它只知道地址和指令。所以当你去查看 C 语言编译后的汇编代码,就会发现变量名消失了,取而代之的是一串串抽象的地址。你可以认为,编译器会自动维护一个映射,将我们程序中的变量名转换为变量所对应的地址,然后再对这个地址去进行读写。也就是有这样一个映射表存在,将变量名自动转化为地址:
这样在func 里就能获取到 a 的地址,进行读写了。理论上这是完全没有问题的,但是问题在于:编译器该如何区分一个 int 里你存的到底是 int 类型的值,还是另外一个变量的地址(即指针)。这如果完全靠我们编程人员去人脑记忆了,会引入复杂性,并且无法通过编译器检测一些语法错误。而通过int * 去定义一个指针变量,会非常明确:这就是另外一个 int 型变量的地址。编译器也可以通过类型检查来排除一些编译错误。这就是指针存在的必要性。实际上任何语言都有这个需求,只不过很多语言为了安全性,给指针戴上了一层枷锁,将指针包装成了引用。可能大家学习的时候都是自然而然的接受指针这个东西,但是还是希望这段啰嗦的解释对你有一定启发。同时,在这里提点小问题:既然指针的本质都是变量的内存首地址,即一个 int 类型的整数。
那为什么还要有各种类型呢?
比如 int 指针,float 指针,这个类型影响了指针本身存储的信息吗?
这个类型会在什么时候发挥作用?
2.3 解引用
上面的问题,就是为了引出指针解引用的。pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?这个操作就叫做解引用,在 C 语言中通过运算符 *就可以拿到一个指针所指地址的内容了。比如*pa就能获得a的值。我们说指针存储的是变量内存的首地址,那编译器怎么知道该从首地址开始取多少个字节呢?这就是指针类型发挥作用的时候,编译器会根据指针的所指元素的类型去判断应该取多少个字节。如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。下面是指针内存示意图:pa 指针首先是一个变量,它本身也占据一块内存,这块内存里存放的就是 a 变量的首地址。当解引用的时候,就会从这个首地址连续划出 4 个 byte,然后按照 int 类型的编码方式解释。
2.4 活学活用
别看这个地方很简单,但却是深刻理解指针的关键。举两个例子来详细说明:比如:
1float f = 1.0; 2short c = *(short*)&f;
你能解释清楚上面过程,对于 f 变量,在内存层面发生了什么变化吗?或者 c 的值是多少?1 ?实际上,从内存层面来说,f 什么都没变。如图:假设这是f 在内存中的位模式,这个过程实际上就是把 f 的前两个 byte 取出来然后按照 short 的方式解释,然后赋值给 c。详细过程如下:
&f取得f 的首地址
(short*)&f
上面第二步什么都没做,这个表达式只是说 :“噢,我认为f这个地址放的是一个 short 类型的变量”最后当去解引用的时候*(short*)&f时,编译器会取出前面两个字节,并且按照 short 的编码方式去解释,并将解释出的值赋给 c 变量。这个过程 f的位模式没有发生任何改变,变的只是解释这些位的方式。当然,这里最后的值肯定不是 1,至于是什么,大家可以去真正算一下。那反过来,这样呢?
1short c = 1; 2float f = *(float*)&c;
如图:具体过程和上述一样,但上面肯定不会报错,这里却不一定。
2.5 为什么?
(float*)&c会让我们从c 的首地址开始取四个字节,然后按照 float 的编码方式去解释。但是c是 short 类型只占两个字节,那肯定会访问到相邻后面两个字节,这时候就发生了内存访问越界。当然,如果只是读,大概率是没问题的。但是,有时候需要向这个区域写入新的值,比如:
为什么?因为解引用的本质就是编译器根据指针所指的类型,然后从指针所指向的内存连续取 N 个字节,然后将这 N 个字节按照指针的类型去解释。比如 int *型指针,那么这里 N 就是 4,然后按照 int 的编码方式去解释数字。但是 void,编译器是不知道它到底指向的是 int、double、或者是一个结构体,所以编译器没法对 void 型指针解引用。
花式秀技
很多同学认为 C 就只能面向过程编程,实际上利用指针和结构体,我们一样可以在 C 中模拟出对象、继承、多态等东西。
也可以利用 void 指针实现泛型编程,也就是 Java、C++ 中的模板。
大家如果对 C 实现面向对象、模板、继承这些感兴趣的话,可以积极一点,点赞,留言~ 呼声高的话,我就再写一篇。
实际上也是很有趣的东西,当你知道了如何用 C 去实现这些东西,那你对 C++ 中的对象、Java 中的对象也会理解得更加透彻。