分类:
2011-01-04 09:21:31
原文地址:第9章 指针(第一部分) 作者:KBTiller
9.1.1 指针是一类数据类型的统称
对于C语言来说,计算机的内存由连续的字节(byte)构成。这些连续的字节同样被连续地编上了号码以相互区别,这个号码就是所谓的地址(Address),如图9-1所示。
图9-1 内存单元与地址
指针(Pointer)是C语言中的一类数据类型的统称。这种类型的数据专门用来存储和表示内存单元的编号,以实现通过地址得以完成的各种运算。
这样看来指针似乎就是地址,然而,事实上却并非如此。后面将会看到,地址只是指针内涵中的一部分,甚至只是一小部分内容而远非其全部。片面地把地址理解为指针的全部,永远学不好指针。
为了使得语言具有广泛的适用性,C语言标准允许编译器自行选择指针类型数据的长度。在不同的编译环境下,指针数据类型的长度可能不同;甚至相同的编译环境中不同的指针数据类型,也可能有不同的大小。
为了叙述的方便,本书中的指针数据类型一律假设为具有32bit的长度。这样并不影响对指针本质的描述,但涉及指针数据类型长度的代码(极少)在不同的编译环境中可能具有不同的结果,这点请读者加以注意。
C语言同样不规定地址这种内存单元的编号在内存中的存储格式,但在现实中目前这种编号多数是与二进制的unsigned int数据类型的存储格式一样,这是本章的另一个假定。这意味着程序可以访问的内存的大小最大为2的32次方(4GB)。但这绝对不意味着指针类型等同于 unsigned int 数据类型,因为它们的运算规则截然不同。
9.1.2 指针是派生数据类型
指针数据类型和数组、结构体、联合体等一样,也是一种派生数据类型(Derived Types)。也就是说,指针数据类型是一种借助其他数据类型构造出来的数据类型。对于任何类型(除了位段),都可以构造出与之相对应的指针数据类型。因此指针数据类型实际上有无穷多种。
没有纯粹的指针,正如同没有纯粹的数组一样。数组是在其他数据类型的基础上构造出来的,指针也必须与其他数据类型一道才能构成自己。
指针让人感到比较复杂的原因之一在于,各种不同类型的指针都有自己的运算规则,尽管它们都被叫做指针。这一点请特别留意,不同类型的指针有不同的运算种类和不同的运算规则。
综上所述,每一种特定的指针类型都是一种派生数据类型,其值表示某个内存单元的地址,其用途是完成与地址有关的计算。
9.1.3 指针是一类数据的泛称
当某个数据的数据类型是指针时,通常也简称这个数据是一个指针。很显然,在这里“指针”具有“名词”的含义。而指针表示“数据类型”含义时,显然具有“形容词”的意味。这种“一词多用”的现象,对于熟悉C语言特点的人来说并不值得大惊小怪,C语言本身也是这样的。比如,“[]”既可以作为类型说明符也可以作为运算符。
9.1.4 指针专用的类型说明符—“*”
数组这种构造性的数据类型有自己特定的类型说明符—“[]”,这种类型说明符用于定义数组或描述数组名的类型。
结构体和联合体数据类型特定的类型说明符分别是关键字“struct”和“union”。
指针也有自己的特定的类型说明符—“*”。
和仅靠“[]”无法完成数组的描述一样,指针也需要“*”与其他的类型说明符一道才能完成对指针类型的完整描述。由于“其他的类型说明符”有无限多种,所以指针的类型也有无限种可能。可以构造出“int *”类型的指针、“char *”类型的指针、“double *”类型的指针、“void *”类型的指针……。
指针的一个重要特点是,它总是和另外一种数据类型联系在一起的。
9.1.5 指针的分类
尽管有无穷多种指针类型,但从指针所关联的数据类型方面看,指针可以分为3类:指向数据对象的指针(Object Pointer)、指向函数的指针(Function Pointer)、指向虚无的指针(“void *”类型)。前两者都与内存中的实体(数据和一段函数的执行代码)有关,而“void *”类型的指针则仅仅是一个值,是纯粹的地址。“指针就是地址”这样的说法对于“void *”这种类型的指针是成立的。但对于与一段具体内存实体相关联的指针类型来说,这种说法是极其片面的,甚至片面到了几乎完全忽略了指针的本质而只剩下了指针的皮毛的地步。正确的说法是,指针的值(右值)是地址,这与“指针就是地址”是完全不同的概念。学习指针最重要的内容通常是关心指针的值以外的东西,而指针的值——下面将会看到,那几乎倒是无关紧要的(对于有些应用领域,如嵌入式开发等,有时需要特别关心指针的值。但对于初学者来说,这个值基本没有关心的必要。)。
从所具有的运算方面看,这3类指针各自拥有不同的运算种类的集合。有的运算种类多些,有的少些。
指向数据对象的指针
所谓“数据对象”(Object),含义如下。
(1)是内存中一段定长的、以byte为基本单位的连续区域。
(2)这段内存区域中的内容表示具有某种类型的一个数据。
数据对象的类型不一定是简单数据类型(int、long、double等),也可以是派生类型,比如数组,甚至指针等。
而所谓的“指向”(Pointer to)的含义是指针与这块具有类型含义的整体的关联。例如,对于
函数类型不属于数据对象。
9.2.2 一元“&”运算
尽管前面各章从来没有提到指针,但实际上在前面编程的过程中已经和指针打过无数次交道了。这可能令人感到吃惊,但却是事实。
比如,在调用scanf()函数输入变量值的时候,在实参中经常可以看到的“&”,实际上就是在求一个指向某个数据对象的指针。
对于下面的变量定义
double d;
表达式“&d” 就是一个指针类型的数据,类型是“double *”,这种类型的指针被称为是指向“double”类型数据的指针。
前面讲过,作为二元运算符,“&”是按位与运算。当“&”作为一个一元运算符时,要求它的运算对象是一个左值表达式(一块内存),得到的是指向这块内存(类型)的指针。而一个变量的名字的含义之一就是这个变量所占据的内存。大多数人在多数情况下关心的只是变量名的另一个含义—值,这可能是学不好指针以及C语言的一个主要原因。在此,简要地复习一下C语言的一些最基本的内容。假如有如下定义:
double d=3.0;
那么,应该如何理解表达式“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”就是一个既不可能通过代码被赋值也不可能通过代码被改变的值,因而是个常量,叫做指针常量(这种常量的含义是指不可能通过代码在程序中改变。),类型是“double *”。这样的常量不可以被赋值也不可以进行类似“++”、 “− −”之类的运算,因为改变“&d”的值就相当于改变了变量“d”的存储空间的位置,然而这是根本不可能的。
当然,在程序运行之后,具体来说是“d”的存储空间确定之后(也就是定义了变量“d”之后,因为这时“d”才开始存在),“&d”的值是确实可以知道的(其实知道了也没什么用)。如果想查看一下,可以通过调用printf()函数用“%p”格式输出(指针类型数据的输出格式是“%p”)。如下面所示。
程序代码9-1
#include
#include
int main( void )
{
return 0;
}
这段代码的程序运行结果并不能事先确定,这和程序运行的具体环境有关。在作者的计算机上,其运行结果如图9-2所示。
图9-2 一元“&”运算
这个运行结果表示的含义如图9-3所示。
图9-3 指针与地址
应该注意到“d”没有被赋值,但程序没有任何问题。这再次说明了“&d”与“d”的值没有任何关系,在表达式“&d”中的“d”表示的仅仅是变量所在的内存而不是这块内存的值。
一元“&”运算符的优先级和其他一元运算符(比如逻辑非“!”)一样,次于“()”、“[]”等运算符,结合性为从右向左。这个运算符叫做关联运算符(Referencing Operator)。其确切的含义是,运算所得到与运算对象所占据的那块内存相关联的指针,其值为那块内存单元中起始byte的地址,也可以将之称为求指针运算符。
大多数情况下,“&”的运算对象是一个变量名(或数组名、函数名)。但一般的,它的运算对象可以是一个表达式,只要这个表达式能够表示一块内存(严格的说法是,只要这个表达式是左值表达式。),比如对于数组
long a[100];
a[0]”就是一个表达式,由于这个表达式既可以表示“a[0]”的值,也可以表示“a[0]”所占据的内存,所以“&a[0]”是合法的、有意义的C语言运算,结果就是一个“long *” 类型的指针。
而另一些表达式,比如“a[0]+3”,由于只有值(右值)的含义而不代表一块内存,所以“&( a[0]+3)”是没有意义的非法的表达式。
代码中的常量,由于只有右值的含义,因而不可以进行“&”运算。比如“&5”,是没有意义的非法的表达式。对于符号常量也同样不可以做“&”运算。
9.2.3 数据指针变量的定义数据指针变量的定义,是指用完整的指针类型说明符(这里所谓的“完整”是指用*和另一种完整数据类型的名称共同的意思)来说明一个变量标识符的性质,并为这个变量标识符开辟存储空间。比如:
int *p_i;
这样就定义了一个指向“int”类型数据的指针变量“p_i”。其中“int”是另一种数据对象的类型的名称,“*”是指针类型说明符。类似地,定义:
char *p_c;
double *p_d;
分别被称为定义了一个指向“char类型”数据的指针变量“p_c”和定义了一个指向“double类型”数据的指针变量“p_d”。
至于所谓“指向‘int’类型数据”的含义,是指:如果“p_i”的值为3456H,那么“p_i”指向的是3456H、3457H、3458H、3459H这4个字节,因为“int”类型数据占据的内存空间的大小是“sizeof(int)”,即4,如图9-4所示。
图9-4 数据指针类型的含义
由此可见“指向‘int’类型数据”的确切含义是指向一块大小为“sizeof(int)”的内存空间(但是指针的值只记录最前面一个byte的地址而不是记录所指向的全部内存单元的地址),这比指针的值要重要得多,指针具体的值对掌握指针这种数据类型通常没有什么意义。
学习指针最重要的是要时刻关注指针指向一块多大的或者一块什么样的内存。因为这将决定这个指针的几乎所有运算。
对于任何一种数据类型(除了某些不完全类型),都可以用和上面相仿的方式定义相应的指针变量,指向对应类型数据所占据的内存空间的大小。
9.2.4 指针的赋值运算对于指针类型的数据,唯一一个普遍可以进行的运算是赋值运算,各种指针都可以用来赋值,指针变量都可以被赋值(除非用const关键字限制),其余的指针运算都没有普遍性。
对于下面的代码片段:
程序代码9-2(片段)
对指针变量进行赋值运算的一般原则是,应该(本章所提到的“应该”的含义指的是普遍认同的、良好的编程风格,而不是语法的必须要求)用同样类型的指针进行赋值。例如下面的赋值就是似是而非的,尽管有的编译器是能容忍的。
程序代码9-3(片段)
double d;
long *p_l;
p_l = & d;//这两个指针的类型是不同的
本质上,不同类型的指针是不可以互相赋值的。但是对于表达式“p_l = & d”,编译器会对这个不合逻辑的赋值表达式做一个隐式的类型转换。如果不是精确清醒地知道编译器会进行什么样的转化,就不要写这种连自己都不清楚确切含义的语句。如果一定要类型转换,不如显式地表达出来。比如:
p_l = ( long * ) & d;
一种不多见的对指针变量的赋值是把一个“地址常数”赋值给它,这时一般也应该把“地址常数”用“类型转换”运算转换为一个“指针常数”再进行赋值,如:
int *p_i=(int *)0XABCD;
9.2.5 不是乘法的“*”运算
“*”是指针类型说明符,同时也可以充当“乘法”运算符(作为二元运算符时),此外“*”也可以是一个一元运算符。这是C语言中典型的“一词多义”的现象(变量名也是如此),符号具体的含义需要由符号所处的语境—代码的上下文确定。这是C语言的一个特点,也是难点。
一元“*”运算是指针特有的一个运算,下面通过具体的例子讲述“*”运算的含义。
对于变量定义:
int i ;
根据前面所讲,对“int”类型变量“i” 做“&”运算可得到一个指向“int” 类型变量“i”的指针,这个指针的数据类型是“int *”。而对于“int *”类型的指针“&i”,*(&i)的含义就是“&i”所指向的那块内存或者是那块内存的值,换句话说“*(&i)”就是“i”—可以作为左值使用也可以作为右值使用。
因此,对“i”的一切操作也都可以通过指向“i”的指针与“*”来实现。例如对“i”这块内存赋值:
i = 2 ;
另一种完全等效的方式是:
*(&i) = 2 ;
如果需要取得“i”的值也是一样,比如对于表达式“i*3” (这里“i”的意义是“i”的值),完全等价的表达式是“( * ( &i ) ) * 3 ”。
这里出现的第二个“*”运算符,由于前后都有运算对象,因此是乘法运算。而“( &i )”前面的“*”则不是乘法运算。这也是在不同语境上下文中一词多义的例子。
此外由于“*”作为一个一元运算优先级与“&”相同,且一元运算符的结合性为从右向左,所以表达式“( * ( &i ) ) * 3 ”的另一种等价写法是“ * &i * 3 ”。
“*”运算符叫做“间接引用运算符”(Indirection Operator或Dereferencing Operator),其运算对象是一个指针,运算结果得到的是指针所指向的那块内存(左值)或那块内存中数据的值(右值)。
从“&”和“*”运算的含义中完全可以发现这样的事实:对于任何一个变量“v”,“*&v”就是“v”;反过来,对于任何一个指针“p”,只要“p”指向一个变量(可以进行“*”运算),那么,“&*p”就是“p”。
前面两条结论还可以适当推广。实际上,这对透彻地理解指针非常有帮助。比如第一条规律,不仅仅对变量成立,实际上对任何内存中的有完整意义的实体“st”(一段连续的内存空间,可能代表某种类型的一个数据或者是一个函数的执行代码(只有void *类型的指针不是指向一块内存。))都成立:“*&st” 就是“st”,反过来只要一个指针“p”不是“void *”类型,那么“&*p” 就是“p”。由此可见,“&”与“*”是一对逆运算(Referencing 与Dereferencing)。
在了解了指针的一些基本概念之后,自然而然会想到的一个问题就是指针究竟有什么用处。如果对于变量定义
int i ;
既然“i = 2”与“*&i = 2”是完全等价的操作,那么两个完全等价的操作中难道不是必然会有一个是多余的吗?
想到这些问题非常自然。实际上指针非常有用,指针是C语言的精华。下面将逐步介绍如何应用指针。
指针的用途之一是通过函数改变函数调用处本地局部变量的值。如果没有指针的话,改变本地局部变量的值,只能通过把函数返回值赋值给这个本地局部变量的办法。但是由于函数只能返回一个值,所以这种办法有很大的局限性。
首先看一个简单的例子。
程序代码9-6
#include
#include
void f(int);
int main(void)
{
int i=5;
f(i);
printf("i=%d\n",i);
system("PAUSE");
return 0;
}
void f(int n)
{
n++;
printf("n=%d\n" , n );
return ;
}
这段程序的输出是:
n=6
i=5
请按任意键继续. . .
可以看到在f()函数中,形参“n”的值的改变对main()函数中的i没有影响。这是因为在C语言中,实参与形参之间是“传值”的关系,形参“n”是把“i”的值(右值)而不是“i”本身作为自己的初始值。在计算实参时求出的“i”的值可能被放在运算器中,也可能被放在内存中的另一个地方,这样无论“n”如何变化都不会使得“i”发生改变。这个过程如图9-6所示。
也就是说,尽管在f()函数中,可以获得main()中当地变量“i”的值(右值),然而由于“i” 是main()中的局部变量,f()函数并不能直接使用这个变量的左值。
图9-6 n与i是两个不同作用区域的变量
如果在main()中希望通过函数调用改变本地局部变量的值,也就是说在f()函数中改变main()中的局部变量“i”的值,应该如何实现呢?答案是通过指针和间接引用运算。
程序代码9-7
#include
#include
void f(int *);
int main(void)
{
int i=5;
f( &i );
printf("i=%d\n",i);
system("Pause");
return 0;
}
void f(int *p)
{
(*p)++;
printf("*p=%d\n" ,*p );
return ;
}
这段程序的输出是:
*p=6
i=6
请按任意键继续. . .
在这段程序中,函数调用以指向“i”的指针“&i”作为实参,可以实现“p”指向变量“i”。这样在f()函数中对“*p”的操作,也就是对main()中局部变量“i”的操作,因而实现了通过对f()函数的调用改变函数调用处,即main()中的局部变量“i”的值的目的,如图9-7所示。理解了这个道理,就不难明白为什么调用scanf()时经常需要写“&”这个运算符了。
此外要注意在f()函数中(*p)++不可以写成*p++,原因在于++比*优先级高,*p++的含义是*(p++),也就是说是对指针p做“++”运算而不是对“*p”做“++”运算。当然对于上个例子来说,把“(*p)++”写成“++*p”最后的执行效果是一样的。
图9-7 f()中的(*p)++表示的是对main()中的i的运算
9.3.2 C代码中的“XXX到此一游”
“XXX到此一游”,这种不分场合胡写乱画的事情在C代码中也常常出现。
比如
int *p;
*p=10;
这是个典型的误用指针错误。这个错误在于,定义了指针变量“p”之后并没有给“p”赋值。由于“p”是个auto类别的局部变量,所以定义之后“p”的值是个“垃圾值”,说不清楚“p”指向哪块内存,这样“*p=10”就会导致把数据写在内存中一个未知的、不当的、错误的位置。这会使应用程序发生错误甚至是灾难性的后果(更坏的后果是你可能根本无法马上察觉)。这种对“*”运算的误用的后果通常会比对变量的误用严重得多。
为了尽量避免这种情况,在定义指针变量时直接将其赋值为“0”被普遍认为是一种良好的编程习惯。例如:
程序代码9-8(片段)
#include
……
int *p_i = NULL ;
其中NULL是文本文件“stdio.h” 中定义的一个符号常量,其值为“0”,指针被赋值为“0” 值时,这个“0”一般是不用进行类型转换的。“0” 这个地址的写入操作是被禁止的,这样可以很大程度地防止应用程序在内存中错误地“随处乱写”。
9.3.3 分桔子问题
例题:父亲将2 520个桔子分给6个儿子。分完后父亲说:“老大将分给你的桔子的1/8分给老二;老二拿到后连同原先的桔子分1/7给老三;老三拿到后连同原先的桔子分1/6给老四;老四拿到后连同原先的桔子分1/5给老五;老五拿到后连同原先的桔子分1/4给老六;老六拿到后连同原先的桔子分1/3给老大”。在分桔子的过程中并不存在分得分数个桔子的情形,结果大家手中的桔子正好一样多。问六兄弟原来手中各有多少桔子。
每次分桔子都有两个人的桔子数目发生改变。由于函数只能返回一个值,所以无法通过函数一次求得两个人在分之前的数目,但是利用指针可以完成这样的功能。
问题由6个相同的小问题组成,其中的任一个小问题的提法都可以描述如下。
甲把自己的桔子分给乙“1/n”之后,甲和乙各有桔子若干,求甲把自己的桔子分给乙之前两人桔子的数目。若通过函数完成这个任务,显然需要知道甲分给乙之后两人桔子的数目和“1/n”。由于要求函数改变两个数据的值,所以函数原型可以描述为:
void 求甲分给乙之前各自的数目(int * pointer_to_甲的数目,int * pointer_to_乙的数目,const int n);
由于这样的函数的前两个参数是指针,所以在函数中不但可以知道“甲的数目”和“乙的数目”(“* pointer_to_甲的数目”和“* pointer_to_乙的数目”),也可以通过这一次函数调用同时改变“甲的数目”和“乙的数目”值,即同时求出甲把自己的桔子分给乙之前两人桔子的数目。
9-9
/ ************************************************************
* 父亲将2 520个桔子分给六个儿子。 *
* 分完后父亲说:"老大将分给你的桔子的1/8分给老二; *
* 老二拿到后连同原先的桔子分1/7给老三; *
* 老三拿到后连同原先的桔子分1/6给老四; *
* 老四拿到后连同原先的桔子分1/5给老五; *
* 老五拿到后连同原先的桔子分1/4给老六; *
* 老六拿到后连同原先的桔子分1/3给老大"。 *
* 在分桔子的过程中并不存在分得分数个桔子的情形, *
* 结果大家手中的桔子正好一样多。问六兄弟原来手中各有多少桔子。 *
*************************************************************/
#include
#include
#define ZS 2520 //总数
#define ZRS 6 //总人数
//求分前数目
void qiufqsm(int * ,int * ,const int );
int main(void)
{
int l1sm,l2sm,l3sm,l4sm,l5sm,l6sm ;
//最后每个人的桔子数
l1sm = l2sm = l3sm = l4sm = l5sm =l6sm = ZS / ZRS ;
//求老六分给老大前各自数目
qiufqsm( &l6sm , &l1sm , 3 ) ;
//逐步前推
qiufqsm( &l5sm , &l6sm , 4 ) ;
qiufqsm( &l4sm , &l5sm , 5 ) ;
qiufqsm( &l3sm , &l4sm , 6 ) ;
qiufqsm( &l2sm , &l3sm , 7 ) ;
qiufqsm( &l1sm , &l2sm , 8 ) ;
printf ("最初个人桔子数为:%d,%d,%d,%d,%d,%d\n" ,
l1sm , l2sm , l3sm , l4sm , l5sm , l6sm
);
system("Pause");
return 0;
}
//求甲把自己桔子分给乙之前两人桔子的数目
void qiufqsm(int *p_jia , int * p_yi , const int n )
{
int jiazqs ;//甲之前数
jiazqs = *p_jia * n / ( n - 1 ) ; //前后的差值
* p_yi -= ( jiazqs - *p_jia ) ; //乙之前的为减去差值
*p_jia += ( jiazqs - *p_jia ) ; //甲之前的为加上差值
}
运行结果如图9-8所示。
图9-8 分桔子问题
指向数据类型的指针,可以进行加法、减法运算。但C语言对另一个运算对象有严格的限制。
数据指针可以与一个整数类型数据做加法运算。为了考察这个加法的含义,首先看一下下面代码的输出。
程序代码9-10
#include
#include
int main(void)
{
int i;
printf("%p %p",& i , &i + 1 );
system("Pause");
return 0;
}
在作者的计算机上的输出是
0023FF74 0023FF78
这个结果可能因为运行环境(编译器及计算机)的改变而有所不同。但有一点是确定的,那就是输出的“&i+1”的值在数值上比“&i”的值大“sizeof(int)”。这表明一个数据指针加1的含义是得到另一个同样类型的指针,这个指针刚好指向内存中后一个同类型的量。
对更一般的数据类型T,指向T类型的指针加1的含义是,得到指向内存中紧邻的后一个T类型量的指针,在数值上相当于加了sizeof(T)。如图9-9所示。
9 数据指针+1的含义
加1的含义清楚了之后,加上其他整数的含义不难推之,减1的含义也就是得到指向内存中紧邻的前一个同类型量的指针。然而道理上虽然可以这样理解,但实际上C语言对指针加上或减去一个整数是有严格限制的。比如对于
int i;
“& i+ 1”是有意义的运算,因为“& i + 1”恰好指向“i”后面第一个“int”类型的数据,但“& i + 2”是没有意义的,除非确信“& i + 2”确实指向了一个“int”类型数据。只有在数组内部才可能确信如此。此外,尽管“& i + 1”是有意义的运算,但是“*(& i + 1)”并没有意义。
同理,除非是在数组内部,在确认一个指针减1确实指向某个数据对象的前提下,否则指针减1的运算是没有意义的。
这里,存在着指针加减法“不对称”的现象。对于一个数据对象(如前面的“i”),“&i+1”是有意义的,而“&i-1”是没有定义的。也就是说,除非通过运算得到的指针的值为0或者指向一个确实的数据对象,或者指向紧邻某个数据对象之后的一个“虚拟”的同类型的数据对象,否则这个指针是没有意义的,其行为是未定义的。
例题:编写函数,求一个一维“int”数组中元素的最大值。
假设这个数组的数组名为“a”,共“n”个元素。那么显然“&a[0]”是指向这个数组起始元素的指针,而且“&a[0]+1”、“&a[0]+2”……显然依次指向a[1]、a[2]……。这样只要把“&a[0]”和“n” 作为实参传递给函数,函数就可以完成对数组的遍历。“&a[0]”和“n”的类型分别为“int *”和“unsigned”,求得的最大值为函数返回值,因此函数原型为
int qiuzd ( int * , unsigned ) ;
完整的代码如下。
程序代码9-11
#include
#include
int qiuzd ( int * , unsigned ) ;
int main( void )
{
int a[3]={ 5 , 9 , 7 }; //测试数据
printf("%d\n", qiuzd ( &a[0] , sizeof a / sizeof *a) ); //测试
system("PAUSE");
return 0;
}
int qiuzd ( int *p , unsigned n)
{
int i , zd = * p;
for ( i = 0 ; i < n ; i ++ )
{
if( * ( p + i ) > zd )
{
zd = * ( p + i ) ;
}
}
return zd ;
}
9.4.2 数据指针的减法
两个同类型的数据指针可以做减法(注意,不同类型的指针(甚至不一定是数据指针)在某些条件下也可以做减法。这涉及一套复杂的类型兼容性规则。本书不打算钻牛角尖介绍这套复杂的兼容性规则。如果真的确认两个不同类型的指针的减法有意义,通过类型转换应该可以绕过兼容性规则。),而且它们应该(这里说的是应该,不是必须。但如果两个指针不是指向同一数组,得到的结果如果不是没有意义,至少也是依赖编译器或依赖硬件环境,从而令人怀疑可移植性的。)是指向同一个数组的数组元素,或者是指向这个数组最后一个元素的下一个同类型的量。这个运算是指针与整数加减法的逆运算。所得到的结果是两个指针之间有几个这样类型的量,也就是它们所指向的数组元素的下标的差,结果的正负号表示两个指针的前后关系。
请说出下面程序的运行结果,然后再自己运行程序验证一下。
程序代码9-12
#include
#include
int main(void)
{
char c[10];
printf("%d %d",&c[2]-&c[9],&c[10]-&c[7]);
system("Pause");
return 0;
}
注意,这里出现了一个c[10]子表达式,但由于代码中并不涉及对c[10]的读写,只是求出指向这个char的指针,这个指针恰恰是c数组之后第一个指向char的指针,这在C代码中没有任何问题,不属于越界访问。
9.4.3 数据指针的关系运算两个指针做“<”、“<=”、“>”、“>=”这些关系运算的前提,与两个指针做减法的前提类似。最后的结果要么是0、要么是1,含义是两个指针在内存中哪个在前、哪个在后,或者是哪个不在另一个之前、哪个不在另一个之后。
两个不同类型的指针的比较及其规则或潜规则,基本上是个钻牛角尖的问题。如果有这个爱好及精力,请独立钻研C89/C99标准关于兼容性(Compatible Type)方面的阐述。事实上,在真正写代码的时候,正如记不清楚运算优先级可以加括号避开优先级问题、不同的类型之间的赋值可以通过类型转换避开转换规则一样,如果一定要在不同类型的指针之间进行关系运算,也完全可以通过类型转换避开令人烦恼的兼容性问题。毕竟,程序要解决的问题才是最重要的问题。
9.4.4 数据指针的判等运算两个相同类型的数据指针做“==”或“!=”这两个等式运算的含义十分明显,无非是它们所指向的数据是否为同一个。
两个指针可以进行“==”、“!=”运算对操作数所要求的前提条件比做关系运算对操作数所要求的前提条件更为宽泛,具体的规则在后面将详细介绍。
9.4.5 “[]”运算和多数运算符不同,下标运算(Subscripting Operator)“[]”的含义实际上是由另一个运算定义的。C语言规定下面两个表达式
表达式1[表达式2] 与 ( * ( (表达式1) +(表达式2 ) ) )
是完全等价的。
这可能多少令人出乎意料,但事实的确如此。进一步想下去的推论可能更加令人惊奇:比如,由于+具有可交换性,如果
表达式1[表达式2] 与( * ( (表达式1) +(表达式2 ) ) )完全等价,那么是否可以说“Ex1[Ex2]”与“Ex2[Ex1]”也完全等价呢?
的确如此。请看一下下面的代码。
程序代码9-13
#include
#include
int main(void)
{
int i[1]={7};
printf("i[0]=%d \n0[i]= %d\n", i [0] , 0[i] );
system("Pause");
return 0;
}
它运行的结果会输出:
i[0]=7
0[i]= 7
请按任意键继续. . .
而且没有任何语法问题,你相信吗?如果你不相信,自己运行一下程序好了。
结论是,“i[0]”与“0[i]”这两个表达式是完全等价的,它们都等价于“(*((i)+(0)))”,也就是“*(i+0)”。如果理解这一点没有什么问题,说明你对数据指针的理解已经很有深度了。
测验:以上面的代码为背景,表达式“(i+1)[-1] * (-1)[i+1]”的值是多少?请在一分钟之内给出答案并上机验证。
此外我要郑重声明,“(i+1)[-1] + (-1)[i+1]”这种显得有几分诡异的表达式,只是为了测验你对指针概念的掌握和理解,在源程序中如果没有特别正当的理由,还是写堂堂正正、平易近人的代码为好。
如果你顺利地阅读到了这里,表明你对数据指针的概念非常清晰。指针这个令很多人感到头疼的东西,对你来说只会感到轻松愉快。甚至,下一小节的内容,你可能现在已经懂了。
9.4.6 数组名是指针任意定义一个一维数组,比如:
double d[6]={2};
从C语言数组的理论中可以知道,“d[0]”是这个数组的第一个元素,而且这个元素的类型是double类型。
从上一小节中可以得知,“d[0]”这个表达式等价于“(*((d)+(0)))”,也就是等价于“*d”。而“*”作为一元运算符时,它的运算对象是指针。那么数组名“d”除了是指针还能是什么呢?
显然,“d”是一个“double *”类型的指针,而且是指向这个数组起始元素的指针。这个结论非常重要,理解了这一点,指针部分就几乎不存在什么难点了。当然,这里所谓的“理解”是要能够自然而然地根据指针的概念自己得到这个结论,而不是死记硬背。如果理解这一点很吃力,请暂时不要继续读后面的内容,重读几遍前面的内容。
既然“d”是“double *”类型的指针,那么显然可以把它的值赋给一个同类型的指针变量。假设有:
double *p;
那么显然可以:
p = d ;
而且既然“p”与“d”类型相同,值也相同,而“d[0]”或“*d”是这个数组的起始元素,那么“p[0]”或“*p”显然也是同一个数据对象。
那么“p”与“d”的区别何在呢?答案是:“d”是个常量。这从“d”的意义就可以推知。
由于“d”是指向“d”数组的起始元素的指针,而“d”数组的存储空间是编译器而不是代码编写者负责安排的,那么这意味着代码书写者也不可能通过代码确定或改变起始元素在内存中的位置。这样,对于代码书写者来说,“d”就是一个不可以改变的量,也就是“常量”。
而“p”的值是可以改变的,它可以被赋值为“d”,可以被赋值为其他的值,也可以进行“++”、“− −”等常量不可以进行的运算。
下面的代码演示了数组名与指针的这种等价性。
程序代码9-14
#include
#include
int main(void)
{
int a[2]={5,7};
int *p = a ; //这是对p初始化,不是对*p初始化。等价于“int *p ; p = a ;”
int i ;
for ( i = 0 ; i < 2 ; i ++ )
{
printf("a[%d]=%d *(a+%d)= %d"\
"\tp[%d]=%d *(p+%d)= %d\n",
i , a[i] , i , *( a + i) ,
i , p[i] , i , *( p + i) );
}
system("Pause");
return 0;
}
它运行的结果如图9-10所示。
图9-10 数组名是指针
注意代码中“int *p = a ;”的含义是对p初始化而非对*p初始化。它等价于:
int *p ;
p = a;
因为在“int *p = a ;”中定义的变量是“p”,“*”在变量定义时只是一个类型说明符,不是运算符。
9.4.7 数组名不仅仅是指针
理解数据指针,最重要的也是最不容易弄清楚的并非指针变量,而是数组名这样遮遮掩掩着的指针常量。因为这种指针常量的类型往往并不那么明显。而如果不清楚一个数据的类型,那就表明对这个数据几乎一无所知。
数组名不但具有指针的性质,同时也具有一些本身独有的性质。
下面的代码用于演示数组名的特性。
程序代码9-15
#include
#include
int main(void)
{
int a [6] ;
printf (" a = %p \n sizeof a = %d\n" , a ,sizeof a ) ;
system("Pause");
return 0;
}
这段程序的输出如图9-11所示。
图9-11 数组名不仅仅是指针
输出的前一项表明数组名是个指针,但是后一项“sizeof a=24”,却表明“a” 同时也代表“a”数组所占据的那块内存(大小为“6*sizeof(int)” 个字节),如图9-12所示。
这个说法听起来似乎有些自相矛盾,但其实不然。所有的数据类型的变量名标识符都有两种解释:变量的值以及变量所在的内存,即右值和左值。比如下面的代码。
图9-12 数组名的两种含义
程序代码9-16
#include
#include
int main(void)
{
int i = 3 ;
printf (" i = %d \n sizeof i = %d\n" , i ,sizeof i ) ;
system("Pause");
return 0;
}
输出如图9-13所示。
图9-13 变量名标识符的两种解释
前一个结果中“i”表示“i”所在的那块内存中的内容所代表的值,而后一项结果中,“i” 明显表示它自身所占据的那块内存。因此数组名一方面是个指针,而另一方面又代表数组所占据的内存,这并没有什么矛盾。
那么数组名的特殊性体现在哪里呢?
数组名的特殊性在于它的“值”(右值)并不是数组所占据的内存所代表的值。事实上,数组所占据的内存作为一个整体也没有“值”(右值)的含义(这点和结构体或联合体也不一样),数组名的“值”是指向数组起始元素的指针常量。另一方面,数组名作为内存(左值)看待时,也不像前面的“i”那样可以被赋值,因为在C语言中没有数组的整体赋值这样的运算。用术语来说就是,数组名不可以作为左值表达式被赋值。
那么什么时候该把数组名作为一个值什么时候该把数组名作为一块内存呢?这同样要根据具体的语境上下文确定。在C语言中,运算大体可分为两类,一类这里称为值运算,另一类这里称为内存运算。出现在“=”左边被赋值、“sizeof”运算、求指针运算“&” 等都属于内存运算。在进行内存运算的时候得到的结果是与内存中的值是无关的。在进行关于内存的运算时,数组名和其他变量名一样是被作为一块内存参与运算的,运算的结果与内存中的内容是无关。而在值运算中,数组名和其他变量名一样是以“值”(右值)的意义参与运算的。对于简单的基本类型数据及结构体或联合体类型数据,值就是所在内存中二进制数代表的意义,而数组名的值则是指向起始元素的指针,因为数组作为一个整体其所占内存中的二进制数是没有什么意义的。
结论就是,当数组名被当做一个值(右值)参与运算时就是一个指针,而在参与其他内存运算时它不被作为指针而只是作为一块内存(左值)即数组所占据的内存。此外作为值,数组名是个指针常量,作为内存不可以被整体赋值。如表9-1所示,就是数组名的全部含义。
表9-1 数组名运算时的含义
后缀“++”、“--”和前缀“++”、“--”是4个有些特殊的运算,这些运算中的运算对象不但要被作为值,也要作为内存参与运算。作为值,数组名可以加1,但由于作为内存时数组名没有被赋值这种运算而且是一种常量,所以对于数组名来说,“++”、“--”运算都是非法的。
总之,从前面的分析可以得出这样的结果,“int a[6];” 所定义的“a”有这样的性质:有时“a”是 “int *”这样一个值,有时又表示 “int [6]”这样连续存放6个“int”的内存。
9.4.8 指向数组的指针
对于数组,由于数组名也代表数组所占据的内存,所以也可以由数组名得到指向数组的指针。例如:
程序代码9-17
#include
#include
int main(void)
{
int a[20] ;
printf ("a =%p &a=%p\n" , a ,&a ) ;
printf ("a+1 =%p &a+1=%p\n" , a + 1 , &a + 1 ) ;
system("Pause");
return 0;
}
输出如图9-14所示。
图9-14 指向数组的指针
代码中的“&a”就是指向数组的指针,这也是一个指针常量。可以看到,在数值上它与“a”是完全相等的。这一点也不奇怪。因为一个数据指针,尽管指向的是一段内存中的所有字节,但是指针的值却只记录这段内存中第一个字节的地址。“a” 与“&a”各自所指向的内存的起始位置是一样的,它们的值自然是相同的。
但是它们的类型是不同的,因而运算规则也不同。“a+1” 与“&a+1”的值不同即表明了这种区别。
由于“a”是“int *”类型的指针,所以加1意味着在数值上加“sizeof(int)”。而“&a”是指向一个“int [20]”这样一个数组,因而加1意味着加上“sizeof(int [20])”,也就是加上十进制的80(十六进制的50)。
“&a”的类型用“int (*)[20]”描述:“*”表示这是个指针类型,“int [20]”表示这个指针指向一个由20个“int”所构成的一维数组。
特别要注意的是,“*”两边的“()”是必须的,这是因为“[]”的优先级比“*”要高,为了强调这个类型是个指针而不是数组,必须在“*” 两边加上“()”。定义与“&a” 相同类型的变量时也是如此,如果希望定义一个与“&a”类型相同的指针变量,那么应该写成:
int (*p)[20];
“*p”两边的“()” 同样是必须的,如果误写成:
int *p[20];
其含义是“p”是一个数组名,数组有20个元素,每个元素都是“int *”类型。
9.4.9 与数组名对应的形参在使用数组名做实参时,前面讲过对应的形参的类型可以用不完全类型描述,实际上这种描述就是在描述一种指针类型。例如下面的代码:
程序代码9-18
#include
#include
void jia1(int [],int);
int main(void)
{
int a[1]={3};
jia1(a,1);
printf("%d\n",a[0]);
system("PAUSE");
return 0;
}
void jia1(int b[],int gs)
{
int i;
for( i = 0 ; i < gs ; i++ )
b[i] += 1;
return ;
}
与上面代码完全等价的一种写法是:
程序代码9-19
#include
#include
void jia1(int *,int);
int main(void)
{
int a[1]={3};
jia1(a,1);
printf("%d\n",a[0]);
system("PAUSE");
return 0;
}
void jia1(int *b,int gs)
{
int i;
for( i = 0 ; i < gs ; i++ )
b[i] += 1;;
return ;
}
也就是说,类型描述形式为“int []”的形参“b” 就是一个指针,类型为“int *”。这个“b” 并不是数组名,因为数组名是常量,而形参显然是一个变量(函数调用时获得实参的值),数组名占据“元素个数*元素尺寸”大小的内存,而形参“b”只占据指针类型大小的内存。
这给我们带来了一个启示,对于数组名可以做如下理解:比如 “int a[1]”;,“a”的类型有时是不完全类型 “int []”(“a”作为值使用时),有时是“int [1]”(“a”作为内存使用)。而前者实际上就是指针。