肆
数组与指针(二)
数组与指针的纠葛
以指针的形式访问数组:
下标表达式: 后缀表达式[表达式]
在C语言中,根据定义,表达式e1[e2]准确地对应于表达式*((e1)+(e2))。因此,要求表达式e1[e2]的其中一个操作数是指针,另一个操作数是整数。且这两个操作数的顺序可以颠倒。
故: a[4] 等同于 4[a] 等同于 *(a+4)
编译器把所有的e1[e2]表达式转换成*((e1)+(e2))。
所以,以下标的形式访问在本质上与以指针的形式访问没有区别,只是写法上不同罢了!
多维数组
二维数组a[i][j]
编译器总是将二维数组看成是一个一维数组,而一维数组的每个元素又都是一个数组。
多维数组定义的下标从前到后可以看做是 最宏观的维到最微观的维。例:三维数组a[i][j][k] 可理解为 共有i个大组,每个大组里有j个小组,每个小组里有k个元素。
故:
a 表示为整个三维数组,其值为&a[0][0][0],
&a+1为整个三维数组后面的第一个位置。(偏移整个三维数组的长度)
a+1 为第二个大组的首位置处(偏移一个大组的长度)【数组名a代表的是数组首元素的首地址,即:第一个大组的首地址】
a[0]表示为三维数组的i个大组中的第一个大组【可看做一个二维数组】,其值为&a[0][0][0],
&a[0]+1为第二个大组的首位置处(偏移一个大组的长度)
a[0]+1为第一个大组中第二个小组的首位置处(a[0]可看做是一个二维数组名,故其代表的是第一个小组的首地址)(偏移一个小组的长度)
a[0][0]表示为第一个大组中的第一个小组【可看做一个一维数组】其值为&a[0][0][0],
&a[0][0]+1为第一个大组中第二个小组的首位置处(偏移一个小组的长度)
a[0][0]+1为第一个大组中第一个小组的第二个元素位置处(偏移一个元素的长度)
a[0][0][0]表示为第一个大组中的第一个小组中的第一个元素。其值为&a[0][0][0],a[0][0][0]+1为首元素值加1。(因为a[0][0][0]为元素值而不是地址)
数组的数组(即:二维数组名)退化为数组的(常量)指针,而不是指针的指针。
同理,n维数组名退化为n-1维数组的(常量)指针。
【总结:指针代表的是谁的首地址 就以谁的长度为偏移单位。】
【规律:与定义比较,缺少几对方括号,就是几维数组的数组名,如上例:a缺少3对方括号,即为3维数组的数组名(代表的是2维数组的地址);a[0]缺少2对方括号,即为2维数组的数组名(代表的是1维数组的地址);a[0][0]缺少1对方括号,即为1维数组的数组名(代表的是数组元素的地址)】
【数组名与整数相加,首先要转换成数组的首元素地址与整数相加,而首元素的存储大小就是整数的单位】
对多维数组的解析:
我们可以用上面那种从前到后的解析方式来思考,a:就表示整个多维数组。a[m]:就表示第m+1大组(大组即数组最大的维),a[m][n]:就表示第m+1大组中的第n+1小组。(小组即次大的维),以此类推,即多维数组的解析是层层细化的。
◎☆指针数组与数组指针:
指针数组:首先它是一个数组。数组的元素都是指针。它是“存储指针的数组”的简称。
数组指针:首先它是一个指针。它指向一个数组。它是“指向数组的指针”的简称。
例:int * p1[10]; //它是指针数组。(因为[]的优先级比*高,p1先与[]结合,构成一个数组的定义)
int (*p2)[10] ; //它是数组指针。(括号的优先级较高,*与p2构成一个指针的定义)
它指向一个包含10个int型数据的数组。
若有:int(*p)[10][5] ; //则p指向一个int型的二维数组a[10][5]。
【规律:数组指针,把定义中括号内的指针看成是一个普通的字母,则其表示的就是 数组指针所指的对象类型】
◎☆
int a[5][5] ;
int (*p)[4] ;
p=a ;
问:&p[4][2]-&a[4][2]的值为多少?
设二维数组的首地址为0,则a[4][2]为第5组的第3个位置(以后见到多维数组要这么想,不要总想着是几排几列的模式),因为int a[5][5];即有5组,每组有5个元素。故:&a[4][2]是(4*5+2)*sizeof(int).
int (*p)[4] ; 指针指向一个含4个int型的元素的数组,故p[4]相对于p[0]向后移动了“4个int型数组”的长度,然后在此基础上再向后移动2个int型的长度(即,其步长按维度逐步递减,多维数组也可按此方式理解)。最后其值为(4*4+2)* sizeof(int)
最后切记:地址值参与的加减运算(地址不能被乘),整数的单位是地址值代表的元素的存储大小!
&p[4][2]-&a[4][2]结果为-4。若分开比较&p[4][2]和&a[4][2]则相差4* sizeof(int)个字节
【◎☆规律:数组指针的连续解引用
数组指针的定义提供了其逐次解引用时的偏移单位,例int (*p)[m][n][k],则意为:数组指针的第一次解引用的偏移单位是m*n*k个int型长度,再次解引用的偏移单位是n*k个int型长度,又一次解引用的偏移单位是k个int型长度,最后一次解引用的偏移单位是1个int型长度。它只能连续解引用4次。 故:p[2][3][4][5]与四维数组首地址相距(2*m*n*k + 3*n*k + 4*k + 5 )个int型长度】
故:数组指针指向的是哪个数组,就可以把它当做那个数组的数组名来用。
例:inta[3][10][5] ; int (*p)[10][5] ; p = a ; 则:p[1][2][3] == a[1][2][3] ; p[1][2] ==a[1][2]
即:用数组指针访问数组和用数组名访问,效果是相同的。
WHY? 以int(*p)[10][5]为例,它指向一个[10][5]的二维数组,故第一次解引用时以二维数组[10][5]的长度作为偏移单位,一次解引用后p[1]就是一个[10][5]二维数组了。(解引用就是提取出指针偏移后 指向的对象) 即为:一维数组[5]的首地址。故再次解引用就以一维数组[5]的长度作为偏移单位,二次解引用后p[1][2]就是一个[5]一维数组了,即是一维数组首元素的地址。所以三次引用后,偏移单位为1个元素。
数组参数与指针参数:
1,二维数组名做实参
int main(void)
{ int a[4][5] ;
……….
………
fun(a);
……….
}
被调函数:
①fun( inta[4][5] )
②fun( inta[ ][5] )
③fun( int(*a)[5] )
{ ……….
a[i][j]=……….
………
}
以上三种方式皆可。无论是那种方式,它们只是写法不同,但编译器的处理方式相同,都把它们看做是一维数组指针。
因为二维数组名退化为一个一维数组指针,故是以一维数组指针的形式来传递二维数组的。
2,指针数组做实参
int main(void)
{ int a[4][5] , i, *p[4] ;
for(i=0;i<4; i++)
p[i]= a[i] ;
……….
fun(p);
……….
}
被调函数:
①fun(int*q[4])
②fun(int *q[])
③fun(int **q)
{ ……….
q[i][j]=……….//取出指针数组中的第i个元素(为指针),再偏移j个单位
//也可从双重指针的角度理解:[i]为第一次解引用,偏移量是i个指针的大小(因为双重指针指向的是指针变量),[j]为第二次解引用,偏移量是j个int型变量大小(因为此时指针指向的是一个int型变量:某组的首元素)
………
}
以上三种方式皆可。无论是那种方式,写法不同,但编译器的处理方式相同,都把它们看做是二级指针。
因为指针数组名退化为数组首元素的地址,即二级指针,故是以二级指针的形式来传递指针数组的。
而多维数组名退化为次维数组的指针,即数组指针,故是以数组指针的形式来传递多维数组的。
【数组指针的连续解引用,其指针的步长对应数组的维度值 是逐渐减小的
多级指针的连续解引用,其指针的步长 前几次解引用的步长为1个指针的长度,最后一次解引用的步长为最终指向的对象长度。(操作系统常用多级指针在多张表中做查询操作)】
【C中函数实参与形参之间是传值引用的,所以你要改变这个值,就传递它的地址(无需多言)】
函数指针:
函数指针就是函数的指针。它是一个指针,指向一个函数。
(即函数在内存中的起始位置地址)
实际上,所有的函数名在表达式和初始化中,总是隐式地退化为指针。
例:int r , (*fp)( ) , func( ) ;
fp= func ; //函数名退化为指针
r= (*fp)( ) ; //等价于r=fp( ) ;
无论fp是函数名还是函数指针,都能正确工作。因为函数总是通过指针进行调用的!
例:int f(int) ; //函数声明
int (*fp)(int) = &f ;//此取地址符是可选的。编译器就把函数名当做函数的入口地址。
//在引用这个函数地址之前,f函数应先声明。
int ans ;
//以下三种方式可调用函数
ans= f(25) ; //函数名后的括号是“函数调用操作符”。
ans= (*fp)(25) ;
ans= fp(25) ;
函数名就是一个函数指针常量,函数调用操作符(即一对括号)相当于解引用
函数的执行过程:
函数名首先被转换为一个函数指针常量,该指针指定函数在内存中的位置。然后函数调用操作符调用该函数,执行开始于这个地址的代码。
再说强制类型转换:
void fun() { printf("Call fun "); }
int main(void)
{
void(*p)( ) ;
*(int*)&p = (int)fun ;
(*p)() ;
return0 ;
}
参见前面文章的强制类型转换。强制类型转换只不过是改变了编译器对位的解释方法罢了。
*(int *)&p = (int)fun ;中的fun是一个函数地址,被强制转换为int数字。左边的(int*)&p是把函数指针p转换为int型指针。*(int *)&p = (int)fun ;表示将函数的入口地址赋值给指针变量p。(*p)( ) ;表示对函数的调用。
函数指针数组:
即是存储函数指针的数组。(有时非常有用)
例:char *(*pf[3])(char *) ;
函数指针的用途:
1,转移表(转移表就是一个函数指针数组)
即可用来实现“菜单驱动系统”。系统提示用户从菜单中选择一个选项,每个选项由不同的函数提供服务。
【若每个选项包含许多操作,用switch操作,会使程序变得很长,可读性差。这时可用转移表的方式】
例:void(*f[3])(int) = {function1, function2, function3} ; //定义一个转移表
(*f[choice])( ) ; //根据用户的选择来调用相应的函数
2,回调函数(用函数指针做形参,用户根据自己的环境写个简单的函数模块,传给回调函数,这样回调函数就能在不同的环境下运行了,提高了模块的复用性)
【回调函数实现与环境无关的核心操作,而把与环境有关的简单操作留给用户完成,在实际运行时回调函数通过函数指针调用用户的函数,这样其就能适应多种用户需求】
例:C库函数中的快速排序函数
voidqsort(void *base, int nelem, size_t width, int (*fcmp)(void*, void*) );
//base为待排序的数组基址,nelem为数组中元素个数,width为元素的大小,fcmp为函数指针。
这样,由用户实现fcmp的比较功能(用户可根据需要,写整型值的比较、浮点值的比较,字符串的比较 等)这样qsort函数就能适应各种不同的类型值的排序。
使用函数指针的好处在于:
可以将实现同一功能的多个模块统一起来标识,这样一来更容易后期维护,系统结构更加清晰。或者归纳为:便于分层设计、利于系统抽象、降低耦合度以及使接口与实现分开。
函数指针数组的指针:(基本用不到)
例:char *(*(*pf)[3])(char *)
这个指针指向一个数组,这个数组里存储的都是指向函数的指针。它们指向的是一种返回值为字符指针,参数为字符指针的函数。
[对于这种复杂的声明,《C和指针》《C专家编程》中有专门的论述。我的方法就是:从核心到外层,层层分析。先找到这个声明的核心,看他的本质是什么。就像本例,最内层的括号里是一个指针,再看外层来确定它是个什么指针。外层是一个3个元素的数组,再看这个数组的元素类型是什么。是一个函数指针。 故总体来说此声明是一个函数指针数组的指针。]复杂指针的举例:
int* (*a[5])(int, char*);
void (*b[10]) (void (*)());
doube(*)() (*pa)[9];
让我们一层一层剥开它的心。
第1个、首先找到核心,即标识符a,[ ] 优先级大于“*”,a与“[5]”先结合。所以a是一个数组,这个数组有5个元素,每一个元素都是一个指针。再往外层看:指针指向“(int,char*)”,对,指向一个函数,函数参数是“int, char*”,返回值是“int*”。完毕!
第2个、首先找到核心:b是一个数组,这个数组有10个元素,每一个元素都是一个指针,指针指向一个函数,函数参数是“void(*)()”【 这个参数又是一个指针,指向一个函数,函数参数为空,返回值是“void”】 返回值是“void”。完毕!
第3个、核心pa是一个指针,指针指向一个数组,这个数组有9个元素。再往外层看:每一个元素都是“doube(*)()”【也即一个指针,指向一个函数,函数参数为空,返回值是“double”】
使用typedef简化声明:
某大牛对typedef用法做过一个总结:“建立一个类型别名的方法很简单,在传统的变量声明表达式里用类型名替代变量名,然后把关键字typedef加在该语句的开头”。
举例:
例1,void (*b[10]) (void (*)());
typedef void (*pfv)(); //先把上式的后半部分用typedef换掉
typedef void (*pf_taking_pfv)(pfv); //再把前半部分用typedef换掉
pf_taking_pfv b[10]; //整个用typedef换掉
跟void (*b[10]) (void (*)());的效果一样!
例2,doube(*)() (*pa)[9];
typedef double(*PF)(); //先替换前半部分
typedef PF (*PA)[9]; //再替换后半部分
PA pa; //跟doube(*)() (*pa)[9];的效果一样!
反思:
1,我们为什么需要指针?
因为我们要访问一个对象,我们要改变一个对象。要访问一个对象,必须先知道它在哪,也就是它在内存中的地址。地址就是指针值。
所以我们有
函数指针:某块函数代码的起始位置(地址)
指针的指针:因为我要访问(或改变)某个变量,只是这个变量是指针罢了
2,为什么要有指针类型?
因为我们访问的对象一般占据多个字节,而代表它们的地址值只是其中最低字节的地址,我们要完整的访问对象,必须知道它们总共占据了多少字节。而指针类型即向我们提供这样的信息。
注意:一个指针变量向我们提供了三种信息:
①一个首字节的地址值
②这个指针的作用范围(步长)
③对这个范围中的数位的解释规则(解码规则)
【编译器就像一个以步数测量距离的盲人。故你要告诉它从哪开始走,走多少步。】
3,强制类型转换的真相?
学过汇编的人都知道,什么尼玛指针,什么char,int,double,什么数组指针,函数指针,指针的指针,在内存中都尼玛是一串二进制数罢了。只是我们赋予了这些二进制数不同的含义,给它们设定一些不同的解释规则,让它们代表不同的事物。(比如1000 0000 0000 0001 是内存中某4个字节中的内容,如果我们认为它是int型,则按int型的规则解释它为-231+ 1;如果我们认为它是unsigned int ,则被解释为231+ 1;当然我们也可把它解释为一个地址值,数组的地址,函数的地址,指针的地址等)
如果我们使用汇编编程,我们必须根据上下文需要,用大脑记住这个值当前的代表含义,当程序中有很多这样的值时,我们必须分别记清它们当前代表的含义。这样极易导致误用,所以编译器出现了,让它来帮我们记住这些值当前表示的含义。当我们想让某个值换一种解释的方案时,就用强制类型转换的方式来告诉编译器,编译器则修改解释它的规则,而内存中的二进制数位是不变的(涉及浮点型的强制转换除外,它们是舍掉一些位,保留一些位)
4,涉及浮点型的强制转
详情参见《深入理解计算机系统》
5,难点
多维数组、数组指针、多级指针。
抓住问题的核心:指针值是谁的地址,这个地址代表的是哪个对象。
搞清楚这个问题,关于指针移动时偏移量(步长)的计算就不会出错。
指针类型只是C语言提供的一种抽象,来帮助程序员避免寻址错误。