9.2 指向数据对象的指针

9.2.1 什么是“数据对象”

所谓“数据对象”(Object),含义如下。

(1)是内存中一段定长的、以byte为基本单位的连续区域。

(2)这段内存区域中的内容表示具有某种类型的一个数据。

数据对象的类型不一定是简单数据类型(int、long、double等),也可以是派生类型,比如数组,甚至指针等。

而所谓的“指向”(Pointer to)的含义是指针与这块具有类型含义的整体的关联。例如,对于

9.2 指向数据对象的指针 - 图1

“i”可以表示它所占据的内存块,当说到某个指针指向“i”时,其确切的含义是指向“i”所占据内存的整体。显然这里提到的“i”是左值意义上的“i”。

函数类型不属于数据对象。

9.2.2 一元“&”运算

尽管前面各章从来没有提到指针,但实际上在前面编程的过程中已经和指针打过无数次交道了。这可能令人感到吃惊,但却是事实。

比如,在调用scarf()函数输入变量值的时候,在实参中经常可以看到的“&”,实际上就是在求一个指向某个数据对象的指针。

对于下面的变量定义

9.2 指向数据对象的指针 - 图2

表达式“&d”就是一个指针类型的数据,类型是“double *”,这种类型的指针被称为是指向“double”类型数据的指针。

前面讲过,作为二元运算符,“&”是按位与运算。当“&”作为一个一元运算符时,要求它的运算对象是一个左值表达式(一块内存),得到的是指向这块内存(类型)的指针。而一个变量的名字的含义之一就是这个变量所占据的内存。大多数人在多数情况下关心的只是变量名的另一个含义——值,这可能是学不好指针以及C语言的一个主要原因。在此,简要地复习一下C语言的一些最基本的内容。假如有如下定义:

9.2 指向数据对象的指针 - 图3

那么,应该如何理解表达式“d=d+5.0”呢?

这是一个赋值表达式,表示的确切含义是“取出变量‘d’的值与常量‘5.0’相加,然后把结果放到变量‘d’所在的内存中去”。请特别注意在赋值号“=”的左边和右边,“d”这个标识符的含义是不同的:在赋值号“=”右边的“d”表示的是“d”的值,计算机的动作是取出这个值(本质上是在运算器中建立“d”的副本),并不关心“d”存放在内存中的什么地方;而在赋值号“=”左边的“d”表示的是“d”所在的内存空间,是把一个值放入这块内存中去,后一个动作与“d”中的值没有什么关系(只是把原来的值擦除),“d”中原来有什么值都不妨碍把一个新的值放入其中,也对新的值没有任何影响。

由此可见,同一个变量名确实有两种含义。针对两种不同的含义,计算机能进行的操作也不同。换句话说,对于某些运算,变量名的含义是其右值;而对于另一些运算,变量名的含义是其左值。编译器根据上下文来分辨变量名究竟是哪种含义。对于用C语言编程的人来说,不分辨清楚这两种含义就不可能透彻地理解C语言。

再举个例子,在“sizeof d”这个表达式中,“d”的含义也是“d”占据的内存而不是“d”的值——无论“d”的值是多少,表达式“sizeof d”的值都为8。

在表达式“&d”中,“d”的含义也是“d”所在的内存而不是“d”的值,“d”的值是多少都对“&”的运算结果没有任何影响。

有一种说法称一元“&”,运算是求地址运算,这种说法既是片面的,也是不严格的,同时对于学习指针有很大的负面作用。理由如下。

在C语言中根本没有“地址”这种数据类型,只有“指针”数据类型,而指针的值才是一个地址。用地址即指针的值的概念偷换指针的概念,显然是以偏概全。更为严重的是,这种说法使得许多人根本就不知道“&d”是个指针,也掩盖了“&d”指向一块内存的事实,因为“&d”的值仅仅是“d”所占据的那块内存单元中第一个byte的编号。

那么“&d”的值是多少呢?实际上多数情况下,尤其是对于初学者来说,根本没必要关心这个值是多少,也不可能事先知道这个值。因为为变量“d”安排存储空间是编译器的工作,编译器是根据程序运行时内存中的实际情况“随机”为变量“d”安排内存的。源程序的作者是永远不可能为变量“指定”一块特定的存储空间,同样也不可能改变“d”在内存中的存储位置。

这样,“&d”就是一个既不可能通过代码被赋值也不可能通过代码被改变的值,因而是个常量,叫做指针常量(3),类型是“double *”。这样的常量不可以被赋值也不可以进行类似“++”、“——”之类的运算,因为改变“&d”的值就相当于改变了变量“d”的存储空间的位置,然而这是根本不可能的。

当然,在程序运行之后,具体来说是“d”的存储空间确定之后(也就是定义了变量“d”之后,因为这时“d”才开始存在),“&d”的值是确实可以知道的(其实知道了也没什么用)。如果想查看一下,可以通过调用printf()函数用“%p”格式输出(指针类型数据的输出格式是“%p”)。如下面所示。

程序代码9-1

9.2 指向数据对象的指针 - 图4

这段代码的程序运行结果并不能事先确定,这和程序运行的具体环境有关。在作者的计算机上,其运行结果如图9-2所示。

9.2 指向数据对象的指针 - 图5

图9-2 一元“&”运算

这个运行结果表示的含义如图9-3所示。

9.2 指向数据对象的指针 - 图6

图9-3 指针与地址

应该注意到“d”没有被赋值,但程序没有任何问题。这再次说明了“&d”与“d”的值没有任何关系,在表达式“&d”中的“d”表示的仅仅是变量所在的内存而不是这块内存的值。

一元“&”运算符的优先级和其他一元运算符(比如逻辑非“!”)一样,次于“()”、“[]”等运算符,结合性为从右向左。这个运算符叫做关联运算符(Referencing Operator)。其确切的含义是,运算所得到与运算对象所占据的那块内存相关联的指针,其值为那块内存单元中起始byte的地址,也可以将之称为求指针运算符。

大多数情况下,“&”的运算对象是一个变量名(或数组名、函数名)。但一般的,它的运算对象可以是一个表达式,只要这个表达式能够表示一块内存(4),比如对于数组

9.2 指向数据对象的指针 - 图7

“a[0]”就是一个表达式,由于这个表达式既可以表示“a[0]”的值,也可以表示“a[0]”所占据的内存,所以“&a[0]”是合法的、有意义的C语言运算,结果就是一个“long *”类型的指针。

而另一些表达式,比如“a[0]+3”,由于只有值(右值)的含义而不代表一块内存,所以“&(a[0]+3)”是没有意义的非法的表达式。

代码中的常量,由于只有右值的含义,因而不可以进行“&”运算。比如“&5”,是没有意义的非法的表达式。对于符号常量也同样不可以做“&”运算。

练习

编写程序验证一下“&d”不可以被赋值也不可以进行类似“++”、“——”之类的运算。

9.2.3 数据指针变量的定义

数据指针变量的定义,是指用完整的指针类型说明符(这里所谓的“完整”是指用*和另一种完整数据类型的名称共同的意思)来说明一个变量标识符的性质,并为这个变量标识符开辟存储空间。比如:

9.2 指向数据对象的指针 - 图8

这样就定义了一个指向“int”类型数据的指针变量“p_i”。其中“int”是另一种数据对象的类型的名称,“*”是指针类型说明符。类似地,定义:

9.2 指向数据对象的指针 - 图9

分别被称为定义了一个指向“char类型”数据的指针变量“p_c”和定义了一个指向“double类型”数据的指针变量“p_d”。

至于所谓“指向‘int’类型数据”的含义,是指:如果“p_i”的值为3456H,那么“p_i”指向的是3456H、3457H、3458H、3459H这4个字节,因为“int”类型数据占据的内存空间的大小是“sizeof(int)”,即4,如图9-4所示。

9.2 指向数据对象的指针 - 图10

图9-4 数据指针类型的含义

由此可见“指向‘int’类型数据”的确切含义是指向一块大小为“sizeof(int)”的内存空间(但是指针的值只记录最前面一个byte的地址而不是记录所指向的全部内存单元的地址),这比指针的值要重要得多,指针具体的值对掌握指针这种数据类型通常没有什么意义。

学习指针最重要的是要时刻关注指针指向一块多大的或者一块什么样的内存。因为这将决定这个指针的几乎所有运算。

对于任何一种数据类型(除了某些不完全类型),都可以用和上面相仿的方式定义相应的指针变量,指向对应类型数据所占据的内存空间的大小。

练习

画一下“p_c”、“p_d”这两个指针变量在内存中的存储情况和指向的含义的示意图。假设“p_c”、“p_d”的定义为:

9.2 指向数据对象的指针 - 图11

9.2.4 指针的赋值运算

对于指针类型的数据,唯一一个普遍可以进行的运算是赋值运算,各种指针都可以用来赋值,指针变量都可以被赋值(除非用const关键字限制),其余的指针运算都没有普遍性。

对于下面的代码片段:

程序代码9-2(片段)

9.2 指向数据对象的指针 - 图12

在表达式“p_i=&i”中,“&i”是一个指向“int”,“p_i”是一个指向“int”类型数据的指针变量。

对指针变量进行赋值运算的一般原则是,应该(本章所提到的“应该”的含义指的是普遍认同的、良好的编程风格,而不是语法的必须要求)用同样类型的指针进行赋值。例如下面的赋值就是似是而非的,尽管有的编译器是能容忍的。

程序代码9-3(片段)

9.2 指向数据对象的指针 - 图13

本质上,不同类型的指针是不可以互相赋值的。但是对于表达式“p_l=& d”,编译器对这个不合逻辑的赋值表达式做一个隐式的类型转换。如果不是精确清醒地知道编译器会进行什么样的转化,就不要写这种连自己都不清楚确切含义的语句。如果一定要类型转换,不如显式地表达出来。比如:

9.2 指向数据对象的指针 - 图14

一种不多见的对指针变量的赋值是把一个“地址常数”赋值给它,这时一般也应该把“地址常数”用“类型转换”运算转换为一个“指针常数”再进行赋值,如:

9.2 指向数据对象的指针 - 图15

9.2.5 不是乘法的“*”运算

”是指针类型说明符,同时也可以充当“乘法”运算符(作为二元运算符时),此外“”也可以是一个一元运算符。这是C语言中典型的“一词多义”的现象(变量名也是如此),符号具体的含义需要由符号所处的语境——代码的上下文确定。这是C语言的一个特点,也是难点。

一元“”运算是指针特有的一个运算,下面通过具体的例子讲述“”运算的含义。

对于变量定义:

9.2 指向数据对象的指针 - 图16

根据前面所讲,对“int”类型变量“i”做“&”运算可得到一个指向“int”类型变量“i”的指针,这个指针的数据类型是“int ”。而对于“int ”类型的指针“&i”,(&i)的含义就是“&i”所指向的那块内存或者是那块内存的值,换句话说“(&i)”就是“i”——可以作为左值使用也可以作为右值使用。

因此,对“i”的一切操作也都可以通过指向“i”的指针与“*”来实现。例如对“i”这块内存赋值:

9.2 指向数据对象的指针 - 图17

另一种完全等效的方式是:

9.2 指向数据对象的指针 - 图18

如果需要取得“i”的值也是一样,比如对于表达式“i3”(这里“i”的意义是“i”的值),完全等价的表达式是“((&i))* 3”。

这里出现的第二个“”运算符,由于前后都有运算对象,因此是乘法运算。而“(&i)”前面的“”则不是乘法运算。这也是在不同语境上下文中一词多义的例子。

此外由于“”作为一个一元运算优先级与“&”相同,且一元运算符的结合性为从右向左,所以表达式“((&i)) 3”的另一种等价写法是“ &i * 3”。

“*”运算符叫做“间接引用运算符”(Indirection Operator或Dereferencing Operator),其运算对象是一个指针,运算结果得到的是指针所指向的那块内存(左值)或那块内存中数据的值(右值)。

从“&”和“”运算的含义中完全可以发现这样的事实:对于任何一个变量“v”,“&v”就是“v”;反过来,对于任何一个指针“p”,只要“p”指向一个变量(可以进行“”运算),那么,“&p”就是“p”。

前面两条结论还可以适当推广。实际上,这对透彻地理解指针非常有帮助。比如第一条规律,不仅仅对变量成立,实际上对任何内存中的有完整意义的实体“st”(一段连续的内存空间,可能代表某种类型的一个数据或者是一个函数的执行代码(5))都成立:“&st”就是“st”,反过来只要一个指针“p”不是“void ”类型,那么“&p”就是“p”。由此可见,“&”与“”是一对逆运算(Referencing与Dereferencing)。

练习

对于下面的变量定义:

程序代码9-4(片段)

9.2 指向数据对象的指针 - 图19

假设在内存中的存储图像如图9-5所示。试问经过程序代码9-5(片段)运算后,内存中的存储状态为何?

9.2 指向数据对象的指针 - 图20

图9-5 内存存储示意图

程序代码9-5(片段)

9.2 指向数据对象的指针 - 图21