2.5.1 强化指针概念
指针是C语言中最基本且很重要的概念,某种程度上甚至可以说:指针是C语言的灵魂。
不巧的是,我们公司新招聘的好多新人对C语言指针都比较陌生。和大家交流,思考背后原因,可能要拜人性中的“选择性遗忘”了(最近脑科学研发成果。人们一般会将极伤心的事忘记的干干净净,如果C语言会给我们带来痛苦,我们会第一时间忘记它)。大家因为道听途说C语言指针很难,然后就故意躲得远远的,即使尝试学了一点点,也努力忘他个干干净净。
真实产品是离不开指针的,尤其是在尝试引入各种架构设计后,指针更是漫天飞舞。因此,在入职C语言训练中,指针强化成为必不可少的课程。
C语言中对指针的基本定义是保存地址的变量,我刚开始学习C语言的时候还容易接受这个概念,但工作多年后反而容易犯糊涂了,挺像那种汉字越看越不像的感觉。
为了给新人讲解指针概念,在多年的培训实践中,我尝试了一种对比法,期望能帮助我们的小伙伴更容易理解指针。
在我眼中,指针由低级到高级,由生到死,一次迭代了三个层次:
1. 硬件层面的指针概念:
追根溯源,我们先来看最底层的汇编程序(直面硬件体系结构)是如何定义数据变量的,如下汇编代码片段:
value1:
DCD 0x11
DCD 0x22
value2:
DCB "welcome"
上述代码段定义了两个变量,value1和value2。我们习惯性认为value1对应了两个字(32位),value2对应了一个字符串。实际语言层面,value1和value2仅是一个地址标注,value1后面可以逻辑的认为是字符串,而value2后面也可以逻辑的认为是一个字,仅汇编语言本身并没有给予特别约定。
因此,在汇编语言(硬件体系)层次,所有的变量定义本质上都是地址(指针),至于地址里面存储的是什么内容,在汇编语言级别没有特别规定,可完全由我们自己自由发挥。
2. C语言中的指针
进入C语言后,为了编程的方便,我们增加了语义定义,数据开始有类型了,因此也出现了针对各种数据类型的指针定义,如int和float就有着不同又相同的含义。相同是因为都是指针,不同是因为指针指向内容存在差异。
因此,此时指针的含义,不能仅仅的理解为一个地址概念,还要关注地址指定的对象。同时,也需要记住指针变量仅仅是地址,因为这是指针作为变量本身的规则。
在C语言中,大家应该都知道,有且仅有值传递这一种方式,也就是说,传递给被调函数的参数值存放在一个临时变量中,而不是存储在原始变量中。换句话说,一个函数传递参数和返回值,或者一个赋值语句拷贝过程,都是完整字节拷贝模式,即使传递的是一个对象(结构体),也是老老实实的逐项拷贝。如果传递的是一个指针,是以指针的本质(地址)进行拷贝的。
比较下面几条代码片段:
int fun1(int a);
struct A fun2(struct A a)
{
struct A b;
……;
return b;
}
struct A* fun3(struct A* a);
fun1函数为基本的数值拷贝,比较好理解,函数内部怎样折腾变量a,都不担心对外部有影响。fun2参数和返回值的传递都是对象,逐个字节拷贝,当然这种代码效率较低。fun3参数和返回值拷贝的仅仅是指针本身,只是间接达到了对象的引用传递效果而已。
3.高级语言中的“指针”
我们经常说C语言是中级语言,为何呢,我个人的理解是:C语言仅引入了有限的语义,同时保留了大量的汇编级别语言的特性。如数组的概念,本质上依旧是指针而已,没有增加过多的语义概念。
但是随着编程理念的发展,各种新语义概念开始层出不穷。
如在C++中引入了类和对象的概念后,如果理解C++对象模型呢?要知道对象的数据结构比较复杂,甚至干脆就不在一块连续的内存上(如包含静态变量时,下图为典型的C对象内部结构图),此时的对象指针概念如何定义呢?
为此,在C++中额外增加了“引用”概念,主要就是用于对象的操作抽象,其本质相当于是一种别名。当然为了延续历史脉络,C++需要兼容设计,又需要拓展,导致其长成了一种杂合语言。大家平时会讨论指针和引用的区别,如果理解指针概念的发展史,就非常清晰了,对象尽量用引用,原有的数值和数组继续使用指针即可。
理解了这一点,就会明白很多高级语言为何会逐渐的取消指针,而进一步加强类引用的概念了,如java等。
经历了C语言指针有低级到高级,由生到死的迭代之旅后,您是否对C语言指针有更深刻的理解。我的理解:C语言指针不仅是一个地址,更需要关注它指向的内容,以及与此关联的语义。
2.5.2 指针类型及操作
上一节我们提到了C语言指针不仅是一个地址,更需要关注它指向的内容,以及与此关联的语义,这节就让我们来一起聊一聊C指针常见的语义。
首先,我们先来关注指针地址这个概念。如果有人接触过8位或16位单片机编程,就会发现指针地址本身也是存在很多差异的,有指向小范围的和大范围的,有短跳转和长跳转等。此时,各类指针变量本身的长度(sizeof)都不一样,指针变量拷贝赋值等操作都需要谨慎。
滚滚长江东逝水,目前嵌入式领域已逐渐的进入了32位系统,尤其是以arm cortex-m系列为首,此时,几乎所有的指针地址都是32位了。我们的系列文章如无特殊说明,都是以cortex-m系列芯片为主,大家可以将指针地址等价为32位整数。大家应该了解指针发展的历史脉络,这样会有更加完整的认知。
简单介绍C语言指针地址的概念后,我们来侧重关注指针指向的内容。依据我的工程实践经验,我将其提炼分解为几类:
- 0;
- void*;
- 指向常规变量的指针,如int*,float*等;
- 指向字符串的指针;
- 指向对象的指针;
- 指向函数的指针。
培训中,第一条就会让我们的一些小伙伴不淡定了, 0竟然也是指针!在c语言中,我感觉0就是个捣乱鬼,大家都知道八进制以0为起始,那么0是十进制数呢,还是八进制数呢,这个问题曾经困惑了我很多年。后来受中国文化“求同存异”的影响,我才能安慰自己,0既是十进制数,也是八进制数,破解了我多年的困惑。既然如此,0为何不能也同时是指针呢。
大家应该会经常看到这样的代码片段:
if (p != NULL)
{
……
}
该处的NULL一般被定义为0,此时,0就是以指针的身份存在的。抛开这些抽象概念,我们总结成一句话:任何指针和0进行相等或不等的比较或赋值操作都是有意义的。换句话说:指针和整数之间不能相互转换(强制转换除外),但0除外,0可以赋值给指针,也可以同指针进行比较(仅限相等和不等)。
◇◇◇
第二类指针语义是void指针。大家知道C语言一开始是没有void的吗?那为何后来有了呢,追根溯源,或许更好理解void*指针。
一开始C语言的通用指针大家都习惯使用int*,但因为指针是允许加减操作的,而且让人痛苦的是指针加减是按照执行对象大小加减的,更痛苦的是int在C语言中是一个自然变量(尽量发挥硬件特性的变量,因此各系统经常存在差异),因此,我们经常一不小心,就引入了一系列异常。
为了规避该问题,有人想出了一招,通用指针使用void*,因指向是虚无的,因此指针加加减减的操作就不被允许了,间接规避了好多无意识的错误。
既然是通用指针,因此可以装得下任何指针,但想返回去,就需要特定类型转换了,如下代码示例:
int *pint;
void *pvoid;
pvoid = pint; /* 合法 */
pint = pvoid; /* 不合法 */
pint = (int*)pvoid; /* 合法 */
因为void这个特点,如果需要传递各种指针时,我们就应该尽