C语言精华记录——肆(数组与指针②)

数组与指针(二)

 

数组与指针的纠葛

以指针的形式访问数组:

下标表达式: 后缀表达式[表达式]

在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语言提供的一种抽象,来帮助程序员避免寻址错误。

 


你可能感兴趣的:(C,系列,C之精华全记录)