再再论指针.docx
《再再论指针.docx》由会员分享,可在线阅读,更多相关《再再论指针.docx(28页珍藏版)》请在冰豆网上搜索。
再再论指针
再再论指针
篇首语
指针是C语言规范里面一项核心内容,指针具有与生俱来的优势,利用指针可以写出许多短小精悍、效率极高的代码,它是C语言一把无可替代的利器,凭着这把利器,C语言与其它高级语言相比至少在效率方面高人一筹。
但是,由于指针的原理与使用方式跟人们通常的思维习惯有较大的差别,造成了指针比C语言其它概念难理解得多,这使得对指针认识不足成为了一种在C程序员中普遍存在的现象,这种不足必然导致程序员在指针的使用过程中不断遭受挫折,挫折多了,指针俨然变成一道无法逾越的难关,恐惧感也就油然而生了。
在恐惧感面前,某些程序员甚至产生了要避免使用指针的念头,这是非常不可取的。
指针是如此犀利,正是它才使得C语言威猛无比,如果就这样把它放弃了,那么C语言就算是白学了。
我们应当让指针成为你手中那把砍掉索伦手指上魔戒的举世无双的纳西尔圣剑,而不是成为你心中永远的魔戒。
本文的目的,是希望通过跟各位朋友一起讨论关于指针的几个关键概念及常见问题,以加深对指针的理解。
因此,本文并不是讲述形如int*p、struct{inti;floatj;}*p等这些东西是什么的文章,阅读本文的朋友最好对指针已经具有一定的使用经验,正因如此,笔者才给文章起名叫《再再论指针》。
笔者不敢奢望能够完全解开你心中的魔结,但如果通过阅读本文,能够让你在日后的指针使用过程中减少失误,那笔者就心满意足了。
本文将讨论如下十个主题,读者最好按主题的顺序一个一个地阅读,当然,如果你只对其中某个或某几个主题感兴趣,只看那几个也未尝不可。
当你阅读本文后:
如果你有不同的意见,欢迎你在评论里留下自己的见解,笔者很乐意跟你一起讨论,共同进步。
如果你觉得我说的全都是废话,那么恭喜你,你的指针已经毕业了。
如果你有太多不明白的地方,那么我介绍你先找一些关于数组与指针的读物看看,笔者推荐你阅读一本叫《C与指针》的书,看完后再回来继续思考你的问题。
第一章什么是数组名?
--一个让你吃惊的事实!
数组是指针的基础,多数人就是从数组的学习开始指针的旅程的。
下面我节选一些在各种论坛和文章里经常见到的关于数组的文字:
“一维数组是一级指针”
“二维数组是二级指针”
“数组名可以作为指针使用”
“数组名就是..........的常量指针”
“数组名就是..........的指针常量”
..................................
这些文字看起来非常熟悉吧?
类似的文字还有许多,或许你就是经常说这些话的人呢。
不过非常遗憾,这些文字都是错误的,实际上数组名永远都不会是指针!
这个结论也许会让你震惊,但它的确是事实。
数组名、指针、地址这几个概念虽然是基础中的基础,但它们恰恰是被混淆和滥用得最多的概念,把数组名说成指针,是一个概念性的错误,实质是混淆了指针与地址两个概念的本质。
俗话说得好:
浅水淹死人。
因此,在讨论数组之前,有必要先回过头来澄清一下什么是指针,什么是地址,什么是数组名。
指针是C语言具有低级语言特征的最直接的证据。
在汇编语言里面,指针的概念随处可见。
比如SP,SP寄存器又叫堆栈指针,它的值是地址,由于SP保存的是地址,并且SP的值是不断变化的,因此可以看作一个变量,而且是一个地址变量。
地址也是C语言指针的值,C语言的指针跟SP这样的寄存器虽然不完全一样,但原理却是相通的。
C语言的指针也是一种地址变量,C89明确规定,指针是一个保存对象地址的变量。
这里要注意的是,指针跟地址概念的不同,指针是一种地址变量,通常也叫指针变量,统称指针。
而地址则是地址变量的值。
看到这里,也许你会觉得,这么简单的东西还用你来说吗?
的确,对于p与&p来说,99%的人都能在0.1秒内脱口而出谁是指针,谁是地址,但是,又有多少人在使用指针的过程中能够始终如一毫不动摇地遵循这两个概念呢?
不少人使用指针的时候就会自觉或不自觉地把指针和地址两个概念混淆得一塌糊涂了,数组名的滥用就是一个活生生的例子。
这一点甚至连一些经典著作也没能避免。
不过也不能全怪你自己,笔者认为某些国内教材应该承担最大的责任。
这些教材一开始就没有给读者好好地分清指针与地址的区别,相反还在讲述的过程中有意无意地混用这两个概念。
更有甚者,甚至在书中明言指针就是地址!
说这话的家伙最应该在C语言这个地图上抹掉,呵呵。
两个月前我在购书中心随手翻开了某个作者主编的一本被冠以国家“十五”规划重点研究项目的书,书里就是这么写的。
当时笔者就感慨:
不知道又要有多少人的思想被这家伙“强奸”了。
实际上,地址这个东西,本来就是一种基本数据类型,本应该在介绍整数、浮点、字符等基本类型的时候把地址显式地放在一起讨论,这样在后面介绍指针与数组的时候就能避免许多误解。
可惜不少教材或者根本没有谈及,或者就算提起这个类型也用了指针类型这个字眼。
这就错了,指针不是类型,真正的类型是地址,指针只是存储地址这种数据类型的变量!
打个比方,对于
inti=10;
10是整数,而i是存储整数的变量,指针就好比这个i,地址就好比那个10。
指针能够进行加减法,原因并不是因为它是指针,加减法则不是属于指针这种变量的,而是地址这种数据类型的本能,正是因为地址具有加减的能力,所以才使指针作为存放地址的变量能够进行加减运算。
这跟整数变量因为整数能够进行加减乘除因而它也能进行加减乘除一个道理。
那么数组名又应该如何理解呢?
用来存放数组的区域是一块在栈中静态分配的内存(非static),而数组名是这块内存的代表,它被定义为这块内存的首地址。
这就说明了数组名是一个地址,而且,还是一个不可修改的常量,完整地说,就是一个地址常量。
数组名跟枚举常量类似,都属于符号常量。
数组名这个符号,就代表了那块内存的首地址。
注意了!
不是数组名这个符号的值是那块内存的首地址,而是数组名这个符号本身就代表了首地址这个地址值,它就是这个地址,这就是数组名属于符号常量的意义所在。
由于数组名是一种符号常量,因此它是一个右值,而指针,作为变量,却是一个左值,一个右值永远都不会是左值,那么,数组名永远都不会是指针!
不管什么话,只要说数组名是一个指针的,都是错误的!
就象把刚才inti=10例子中的10说成是整数变量一样,在最基本的立足点上就已经完错了。
总之要牢牢记住,数组名是一个地址,一个符号地址常量,不是一个变量,更不是一个作为变量的指针!
在数组名并非指针这个问题上,通常会产生两种疑问:
1。
作为形参的数组,不是会被转换为指针吗?
2。
如果形参是一个指针,数组名可以作为实参传递给那个指针,难道不是说明了数组名是一个指针吗?
首先,C语言之所以把作为形参的数组看作指针,并非因为数组名可以转换为指针,而是因为当初ANSI委员会制定标准的时候,从C程序的执行效率出发,不主张参数传递时复制整个数组,而是传递数组的首地址,由被调函数根据这个首地址处理数组中的内容。
那么谁能承担这种“转换”呢?
这个主体必须具有地址数据类型,同时应该是一个变量,满足这两个条件的,非指针莫属了。
要注意的是,这种“转换”只是一种逻辑看法上的转换,实际当中并没有发生这个过程,没有任何数组实体被转换为指针实体。
另一方面,大家不要被“转换”这个字眼给蒙蔽了,转换并不意味着相同,实际上,正是因为不相同才会有转换,相同的话还转来干吗?
这好比现在社会上有不少人“变性”,一个男人可以“转换”为一个女人,那是不是应该认为男人跟女人是相同的?
这不是笑话么。
第二,函数参数传递的过程,本质上是一种赋值过程。
C89对函数调用是这样规定的:
函数调用由一个后缀表达式(称为函数标志符,functiondesignator)后跟由圆括号括起来的赋值表达式列表组成,在调用函数之前,函数的每个实际参数将被复制,所有的实际参数严格地按值传递。
因此,形参实际上所期望得到的东西,并不是实参本身,而是实参的值或者实参所代表的值!
举个例来说,对于一个函数声明:
voidfun(inti);
我们可以用一个整数变量intn作实参来调用fun,就是fun(n);当然,也正如大家所熟悉的那样,可以用一个整数常量例如10来做实参,就是fun(10);那么,按照第二个疑问的看法,由于形参是一个整数变量,而10可以作为实参传递给i,岂不就说明10是一个整数变量吗?
这显然是谬误。
实际上,对于形参i来说,用来声明i的类型说明符int,所起的作用是用来说明需要传递给i一个整数,并非要求实参也是一个整数变量,i真正所期望的,只是一个整数,仅此而已,至于实参是什么,跟i没有任何关系,它才不管呢,只要能正确给i传递一个整数就OK了。
当形参是指针的时候,所发生的事情跟这个是相同的。
指针形参并没有要求实参也是一个指针,它需要的是一个地址,谁能给予它一个地址?
显然指针、地址常量和符号地址常量都能满足这个要求,而数组名作为符号地址常量正是指针形参所需要的地址,这个过程就跟把一个整数赋值给一个整数变量一样简单!
在后面的章节中,笔者将严格地使用地址这一概念,该是地址时就用地址,该是指针时就用指针,以免象其它教材那样给读者一个错误的暗示。
第二章再一次吃惊--数组的数组与多维数组的区别
看见这个题目,也许有些人就会嘀咕了:
难道两者不是一样的吗?
C语言的多维数组不就是数组的数组吗?
不!
两者是有区别的,而且还不小呢。
首先看看两者的共同点:
1。
内存映象一样。
2。
数组引用方式一样,都是“数组名[下标][下标]........”。
3。
数组名都是数组的首地址,都是一个符号地址常量、一个右值。
由于两者的共同点主要反映在外部表现形式上,因此,从外部看来,数组的数组跟多维数组似乎是一样的,这造成了C程序员对两者的区别长期以来模糊不清。
但实际上,c语言限于本身的语言特性,实现的并非真正的多维数组,而是数组的数组。
数组的数组与多维数组的主要区别,就在于数组的数组各维之间的内在关系是一种鲜明的层级关系。
上一维把下一维看作下一级数组,也就是数组嵌套。
数组引用时需要层层解析,直到最后一维。
举个例,对于数组:
inta[7][8][9];
如果要访问元素a[4][5][6],首先就要计算第一维元素4的地址,也就是a+4,由于是数组的数组,元素4的值代表了一个数组,因此元素4的值就是它所代表的那个数组的首地址,我们用一个符号address1代表它,也就是address1=*(a+4),接着计算第二维,显然元素5的地址是address1+5,其值也是一个数组的首地址,用address2表示它,就是address2=*(address1+5),最后一维,由于已经到达了具体的元素,因此这个元素的地址是address2+6,其值*(address2+6)是一个整数,把address1和address2分别代入相应表达式,就成了:
*(*(*(a+4)+5)+6);
这就是我们熟知的[]运算符的等价表达式。
而真正的多维数组并没有这么多“束缚”,相比之下简单得多,由于各维之间不是这种复杂的层级关系,元素a[4][5][6]的偏移量可以这样直接获得:
(4*8*9+5*9+6)*sizeof(int),再加上数组的首地址a就是元素a[4][5][6]的地址了。
但是,c语言的数组能够这样用首地址加上(4*8*9+5*9+6)*sizeof(int)的形式来访问元素吗?
显然是不行的。
归根到底就在于C语言的地址数据类型不但有类型,还具有级别。
就是这种层级关系造成了C语言只能用数组的数组当作多维数组。
如果C语言非得要实现真正的多维数组,那么地址与指针的概念就得重新改写了。
第三章数组的解剖学
这一章我们来讨论一下数组的内涵,对数组的内部构造进行一次解剖,看看里面究竟隐藏了什么秘密。
有了前面两章对数组名和C语言数组本质的澄清,再来理解这一章的内容,就容易多了。
在下面的叙述中,笔者会用到一个运算符sizeof,由于在不同的编译器和编译模式下,对一个地址进行sizeof运算的结果有可能是不同的,为了方便讨论,我都假设地址长度为4个字节。
多数教材在讲述数组的时候,都是把重点放在外部表现形式上,很少涉及数组的内部,只告诉你如何做,却忽视了为什么要这样做。
在解释的过程中,还会列出各种各样的表达式,例如:
a、a+1、a[0]、a[0][0]、&a[0]、&a[0][0]、*(a+1)等等,让人眼花缭乱。
但实际上真正能够用来描述数组内部构造的表达式只有其中的几个。
上一章讲到,C语言的数组实现并非真正的多维数组,而是数组嵌套,访问某个元素的时候,需要逐层向下解析。
仍然以上一章的例子数组inta[7][8][9]来说,第一维元素0的值a[0]是a[0]所代表的那个数组的首地址,这个表达式在C语言的数组里面具有特殊的意义,之所以特殊,不仅仅在于它所代表的东西与一般的地址不同,而且类型也并非一般的地址类型,它的类型叫做数组类型,数组类型这个名称在绝大多数教材中是从来没有出现过的,在C89标准中,也仅仅出现在介绍数组定义的那一段。
具有数组类型的地址跟一般类型地址的主要区别,在于长度不一样,对一个一般类型的地址进行sizeof运算,结果是4个字节,而a[0]由于代表了一个数组,sizeof(a[0])的结果是整个数组的长度8*9*sizeof(int),并非4个字节。
具有数组类型的地址跟数组名一样都是一个符号地址常量,因此它必定是一个右值。
数组类型在数组的定义与引用中具有非常重要的作用,它可以用来识别一个标识符或表达式是否真正的数组,一个真正数组的数组名,是一个具有数组类型的符号地址常量,它的长度,是整个数组的长度,并非一般地址的长度,如果一个标识符不具备数组类型,那它就不是一个真正的数组。
在后面的章节里,还会再次使用这个概念。
与a[0]类似的数组类型地址还有a[0][0],a[0][0]是a[0]的下一层数组,因此sizeof(a[0][0])的结果是9*sizeof(int)。
类似地,对于一个三维数组:
a[i][j][k]
a、a[x]、a[x][y](其中x、y大于等于0而小于i、j)都是具有数组类型的地址常量,而且都是一个右值。
这一点要牢牢记住。
正是由这些特殊类型的地址构成了整个数组。
以上结论对于n维数组同样适用。
接下来跟各位一起讨论一下跟数组有关的各种表达式的意义及其类型:
&a[0][0][0]:
&a[0][0][0]仅仅是一个地址,它的意义,仅仅表示元素a[0][0][0]的地址,sizeof(&a[0][0][0])的结果是4。
不少人把它说成是数组a的首地址,这是错误的,这是对数组首地址概念的滥用。
真正能代表数组a的数组首地址只有a本身,a与&a[0][0][0]的意义根本就是两回事,真正的数组首地址是具有数组类型的地址,sizeof(a)结果是i*j*k*sizeof(int),而不是4,只不过由于a[0][0][0]位置特殊,恰好是数组a的第一个元素,所以它们的地址值才相同。
而对于a[0]和a[0][0],它们是在数组a内部a[0]和a[0][0]所代表的那个数组的首地址,它们的地址值也是由于位置“特殊”,因此才跟a和&a[0][0][0]一样。
这一点一定要区分清楚了。
a+i:
可能有些人会对a+i感到迷惑,数组的首地址加上一个整数是什么呢?
它是第一维元素i的地址,sizeof(a+i)为4。
a+j:
跟上面的类似,a+j是a所代表的那个数组的元素j的地址,sizeof(a+j)的结果也为4。
&a:
对数组名取地址在C标准里面是未定义的。
这个表达式曾经引起过争论,焦点在于对一个右值取地址的合法性。
C89规定&运算符的操作数必须具有具体的内存空间,换言之就是一个左值,但数组名却是一个右值,按照&运算符的要求,这是非法行为。
因此,早期的编译器通常规定&a是非法的。
但不知道什么原因,现在的编译器都把&a人为地定义成一个比a高一级而地址值跟a一样的地址,但作为比a高一级的地址,有一个行为却非常怪诞,sizeof(&a)的结果跟sizeof(a)相同,这也是人为的痕迹。
笔者倾向于把&a定义为非法,应该维护&运算符的权威性,而不是在规定对某个右值取地址为非法的同时,又允许对另一个右值取地址,这是互相矛盾的。
&a和&a[j]:
跟&a一样,也是未定义的,同样不符合&运算符的规则。
由于a是a[j]的上一层数组,有些人可能会想当然地以为:
a=&a[j],错也,实际上,由于a[j]=*(a+j),因此&a[j]=&*(a+j),结果是a+j。
对于sizeof(&a)和sizeof(&a[j]),由于是未定义的,因此有些编译器规定其值跟sizeof(a)和sizeof(a[j])相同,有些编译器却规定为4,就是一个地址的长度。
第四章[]运算符的本质
数组是存在于人们头脑中的一个逻辑概念,而编译器其实并不知道有数组这个东西,它所知道的,只是[]运算符,当遇到[]运算符的时候,编译器只是简单地把它转换为类似*(*(a+i)+j)这样的等价表达式,之所以是这种表达式,如前几章所述,是因为C语言的数组实现本质上是数组的嵌套。
由于这种等价关系的存在,会产生一些古零精怪的表达式,例如:
10[a]
这个表达式初看上去让人摸不着头脑,它是什么呢?
如上所述,编译器会把它转换为*(10+a),把a和10调换一下,就是*(a+10)了,这个就是a[10]。
[]运算符之前还可以是一个表达式,例如:
(10+20)[a]。
严格来讲,以上两个表达式是非法的,因为C89对于数组的引用(注意不是数组定义)规定:
带下标的数组引用后缀表达式由一个后缀表达式后跟一个括在方括号中的表达式组成。
方括号前的后缀表达式的类型必须为“指向T类型的指针”,其中T为某种类型;方括号中表达式的类型必须为整型。
这个规定说明,进行数组引用的时候,[]运算符的左边并非必须为数组名,而可以是一个表达式,但这个表达式的类型必须为“指向某类型的指针”。
显然10跟(10+20)连地址都不是,因此实际上他们是非法的,编译器在这里并没有严格遵守标准的规定。
但如果是:
inta[10],*p=a;
(p+1)[2]这样就是合法的,因为p+1的结果仍然是一个指针。
要注意的是,虽然后缀表达式是一个“指向某类型的指针”,但不要被这里所说的指针一词搞混了,上面的规定不能反过来使用。
还是以上面的例子为例,我们可以p[i]这样使用p,这是符合上述规定的,但并不能因为指针p能够以p[i]这种形式使用就认为p是一个数组,这就错误了,不能反过来应用上述规则。
最后说一下编译器对&*的优化,对于数组inta[10],如果对其中一个元素取地址,例如&a[1],这条表达式等价于&*(a+1),编译器并不会先计算*再运算&,而是对&*两个运算符进行优化,把它们同时去掉,因为两者的作用是相反的,最后得到计算的是a+1表达式。
第五章指向数组的指针
讲到第五章了,数组两个字还离不开我们的左右,数组的内容也真多,另一方面也因为数组与指针的关系的确非常密切。
通常,对于inta[8][9]这个二维数组,我们可以这样定义一个指向它的指针:
int(*p)[9];
这个声明的形式跟人们所熟悉的int*p的形式大相庭径,初学者通常会感到迷惑,不理解的地方大致有四个:
1。
为什么会以这种形式声明?
2。
(*p)应该如何理解?
3。
为什么必须把第二维显式地声明?
4。
为什么忽略第一维?
下面我们就一起逐个讨论这四个问题:
1。
这种形式是C标准的声明语法规定的,由于本章不是对标准的解释,只是对标准的应用,因此笔者尽量以简洁的方式解释这个声明,详细的讨论将在第七章进行。
C标准的声明包含了两部分:
声明:
声明说明符初始化声明符表opt(opt的意思是可选)
在声明说明符里面有一项类型说明符,int就是这种类型说明符。
而初始化声明符表里面的其中一种形式,就是:
直接声明符[常量表达式opt]
(*p)[9]就是这种直接声明符加[]的形式。
2。
p左边的*在这里不是取值运算符,而是一个声明符,它指出p是一个指针。
而()括号是不能去掉的,如果去掉了,由于[]运算符优先级比*高,p就会先跟[]结合,这样p就变成了一个指针数组,而不是指向数组的指针。
题外话:
*p还有一种用法,就是当*是取值运算符的时候,*p是一个左值,表示一个变量,为什么*p是一个变量呢?
也许有人会说,因为inti,*p=&i嘛,其实这是结果不是原因。
严格来说,i只是一个变量名,不是变量,在编译器的符号表里面,变量名是一个符号地址,它所代表的地址值是它指向的那段内存单元的地址,真正叫变量的是那段内存单元,懂汇编的朋友能很容易地区分出来,在汇编里面,可以这样定义一个变量名:
VARWDW10,20
VARW就是一个变量名,它在汇编里面是一个地址,代表了10所在的内存单元这个变量。
由于p被初始化为&i,*p指向i所代表的那段内存单元,因此说*p是一个变量。
把i称为变量是一种习惯上的统称。
3。
定义一个指针的时候,首先必须定出指针的类型,由于这是一个指向数组的指针,如果数组的元素的类型定下来了,那么这个指针的类型也就定下来了。
前面说过,C语言的多维数组实质上是数组的嵌套,那么所指向数组的元素必定具有数组类型,也就是说,这个数组的元素是一个具有9个int元素的数组,因此,p定义的时候,必须指定第二维的上界,这样才能把p的类型定下来。
4。
有这种疑问的人已经犯了一个错误,没有分清楚什么是指针,什么是数组,以数组的思维模式来看待这个指针p。
定义一个数组(非static)的时候,需要在栈中静态分配一块内存,那么就需要知道这块内存的大小,因此定义数组时需要确定各维的上界。
而这里只是定义一个指针而已,对于一个指针的定义,需要知道的是它所指向对象的类型,并不需要知道对象的大小,这是多余的。
因此,所有指向数组的指针的第一维被忽略。
以上介绍了如何声明一个指向二维数组的指针,类似地,对一个指向n维数组的指针也可以用同样的方法来声明,如下:
int(*p)[x2][x3]......[xn];
同样可以忽略第一维,而其它维必须指定上界。
最后再讨论一种很常见的对多维数组的错误理解,有些人常常会以为,二维数组就是二级指针,这种错误的根源,来自于可以把一个二级指针int**p以p[j]这种形式使用。
首先把数组称为指针就是错误的,第一章笔者已经说明了数组名是地址,不能理解为指针。
第二,并非能以p[j]这种形式使用,那么p就是一个二维数组了,C标准对数组引用的规定,并没有指定数组引用时[]运算符的左边必须是数组名,而可以是一个表达式。
第三,这是一种“巧合”,归根到底是由于C语言的数组实现是数组的嵌套同时C标准把[]运算符转换为类似*(*(a+i)+j)这样的等价表达式造成的,那两个取值运算符“恰好”可以用于一个二级指针。
第四,p与p[j]并不具有数组类型,sizeof(p)和sizeof(p[j])的结果只是一个指针的大小4字节。
而对于一个真正的数组,p与p[j]都是具有数组类型的地址。
实际上,int**p只是一个指向一维指针数组的指针,而不是指向二维数组的指针。
同样地,对于n级指针,都可以看作一个指向一维指针数组的指针,这个指针数组的元素都是n-1级指针。
第六章“另类”数组
动态数组与字符串常量可算是两种“另类”数组。
VLA可变长数组并不为C89所支持,C99才开始支持VLA。
但如果想在只支持C89的编译环境中使用VLA的话,怎么办呢?
我们可以用动态数组来“模拟”,动态数组在矩阵的运算中很常见,常用来向函数传递一个大小可变的矩阵。
动态数组的原理,是利用一块或多块动态分配的内存存储各维的首地址,这样就可以p[i][j]的形式访问数组的数据了。
但是,动态数组并非真正的数组,它只是对数组的一种模拟。
由于具有数组类型的数组名是系统行为,在用户这一级没法做到,因此只能以指针的形式存放首地址,sizeof(