指针——《狂人C》观点

九路
• 阅读 1396

9.1 指针是什么 9.1.1 指针是一类数据类型的统称 对于C语言来说,计算机的内存由连续的字节(byte)构成。这些连续的字节同样被连续地编上了号码以相互区别,这个号码就是所谓的地址(Address),如图9-1所示。

指针——《狂人C》观点

图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类指针各自拥有不同的运算种类的集合。有的运算种类多些,有的少些。 9.2 指向数据对象的指针 9.2.1 什么是“数据对象” 所谓“数据对象”(Object),含义如下。 (1)是内存中一段定长的、以byte为基本单位的连续区域。 (2)这段内存区域中的内容表示具有某种类型的一个数据。 数据对象的类型不一定是简单数据类型(int、long、double等),也可以是派生类型,比如数组,甚至指针等。 而所谓的“指向”(Pointer to)的含义是指针与这块具有类型含义的整体的关联。例如,对于 int i; “i”可以表示它所占据的内存块,当说到某个指针指向“i”时,其确切的含义是指向“i”所占据内存的整体。显然这里提到的“i”是左值意义上的“i”。 函数类型不属于数据对象。 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 输出的前一项表明数组名是个指针,但是后一项“sizeof a=24”,却表明“a”同时也代表“a”数组所占据的那块内存(大小为“6*sizeof(int)”个字节),如图9-12所示。 这个说法听起来似乎有些自相矛盾,但其实不然。所有的数据类型的变量名标识符都有两种解释:变量的值以及变量所在的内存,即右值和左值。比如下面的代码。

指针——《狂人C》观点

程序代码9-16

#include <stdio.h>
#include <stdlib.h>



int main(void)
{
 int i = 3 ;

 printf (" i = %d \n sizeof i = %d\n" , i ,sizeof i ) ;

 system("Pause");
 return 0;
}

指针——《狂人C》观点

前一个结果中“i”表示“i”所在的那块内存中的内容所代表的值,而后一项结果中,“i”明显表示它自身所占据的那块内存。因此数组名一方面是个指针,而另一方面又代表数组所占据的内存,这并没有什么矛盾。 那么数组名的特殊性体现在哪里呢? 数组名的特殊性在于它的“值”(右值)并不是数组所占据的内存所代表的值。事实上,数组所占据的内存作为一个整体也没有“值”(右值)的含义(这点和结构体或联合体也不一样),数组名的“值”是指向数组起始元素的指针常量。另一方面,数组名作为内存(左值)看待时,也不像前面的“i”那样可以被赋值,因为在C语言中没有数组的整体赋值这样的运算。用术语来说就是,数组名不可以作为左值表达式被赋值。 那么什么时候该把数组名作为一个值什么时候该把数组名作为一块内存呢?这同样要根据具体的语境上下文确定。在C语言中,运算大体可分为两类,一类这里称为值运算,另一类这里称为内存运算。出现在“=”左边被赋值、“sizeof”运算、求指针运算“&”等都属于内存运算。在进行内存运算的时候得到的结果是与内存中的值是无关的。在进行关于内存的运算时,数组名和其他变量名一样是被作为一块内存参与运算的,运算的结果与内存中的内容是无关。而在值运算中,数组名和其他变量名一样是以“值”(右值)的意义参与运算的。对于简单的基本类型数据及结构体或联合体类型数据,值就是所在内存中二进制数代表的意义,而数组名的值则是指向起始元素的指针,因为数组作为一个整体其所占内存中的二进制数是没有什么意义的。 结论就是,当数组名被当做一个值(右值)参与运算时就是一个指针,而在参与其他内存运算时它不被作为指针而只是作为一块内存(左值)即数组所占据的内存。此外作为值,数组名是个指针常量,作为内存不可以被整体赋值。如表9-1所示,就是数组名的全部含义。

指针——《狂人C》观点

后缀“++”、“--”和前缀“++”、“--”是4个有些特殊的运算,这些运算中的运算对象不但要被作为值,也要作为内存参与运算。作为值,数组名可以加1,但由于作为内存时数组名没有被赋值这种运算而且是一种常量,所以对于数组名来说,“++”、“--”运算都是非法的。 总之,从前面的分析可以得出这样的结果,“int a[6];”所定义的“a”有这样的性质:有时“a”是“int *”这样一个值,有时又表示 “int [6]”这样连续存放6个“int”的内存。 9.4.8 指向数组的指针 对于数组,由于数组名也代表数组所占据的内存,所以也可以由数组名得到指向数组的指针。例如: 程序代码9-17

#include <stdio.h>
#include <stdlib.h>
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所示。

指针——《狂人C》观点

代码中的“&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 <stdio.h>
#include <stdlib.h>



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 <stdio.h>
#include <stdlib.h>

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”作为内存使用)。而前者实际上就是指针。 9.5 指针的应用(二) 指针可以方便地用来操作数组。 例题:写一程序,通过函数对一个int类型元素组成的数组按照插入法进行排序,然后输出。 插入法排序的基本思想是,把数据一个个地插入到一个有序的数组中。具体的实现可以用下面描述的方法进行。 首先数组被分为两个部分,已经排好序的部分和待插入的部分。显然只有一个元素时数组是有序的,所以一开始有序部分有一个元素,数组中其他部分都属于待插入部分。 例如,对于“int a[]={8,9,7,6,5,4,3,2,1,0};”这个数组,最初有序部分和待插入部分分别为“{8}”和“{9,7,6,5,4,3,2,1,0}”。 然后每次从待插入部分拿出第一个插入到前面已经排好序的部分,这样排好序的部分就增加了一个元素,而待插入部分则减少了一个元素。最后当待插入部分没有任何元素时(dcr_tou < dcr_wei),则排序结束。这部分的功能由crpx()函数完成。 对于前面提到的数组来说,第一次插入意味着取出“9”插入到“{8}”这个数组中适当的位置。第二次意味着把“7”插入到“{8,9}”这个数组中…… 把一个值(有序部分最后一个元素之后的元素即待插入部分的第一个元素)插入到一个有序数组中的解决过程是:首先把这个值与有序部分最后一个值进行比较,如果这个值大于等于有序部分最后一个值,则这个值的位置不动,插入结束。 以前面的数组为例,取出“9”的值与“8”比较,由于9大于8,所以“9”应该在“8”的后面不动,插入结束。有序部分变为“{8 ,9}”,待插入部分变为“{7,6,5,4,3,2,1,0}”。下一步取出“7”与前面的“9”比较。 如果这个值小于有序部分最后一个元素,则把有序部分最后一个元素向后移动一个位置,这样成了少了一个元素有序部分的同样的问题,所以可以通过递归解决。这个部分由cr()函数解决。 也就是说,由于7小于9,所以“9”移动到后面一个位置(“7”原来所在的位置)。这时,问题就变成了将“7”插入到“{8}”这个数组中合适位置的问题。显然这可以通过递归解决(“cr ( yx_tou , yx_wei - 1 , crz );”)。 程序代码9-20

#include <stdio.h>
#include <stdlib.h>

void crpx( int * , int * ) ;
void cr( int * , int * , int ) ;
void shuchu ( int * , int * );

int main( void )
{
    int a[]={8,9,7,6,5,4,3,2,1,0};

    crpx  ( a , * (&a + 1 ) ) ;
    shuchu( a , * (&a + 1 ) ) ;

    system("PAUSE");   
    return 0;
}
//输出数组
void shuchu ( int * tou , int *wei )
{

   printf("数组为:\n" ); 

   while ( tou < wei )
      {
       printf(" %d " , * tou ++ );
      }

   putchar('\n');

}
//插入法排序
//tou:数组开头;
//wei:数组结尾(指向最后元素之后下一对象)
void crpx( int * tou , int *wei )
{
  //把数组划分为两部分,排好序部分和待插入元素部分
  int *yx_tou = tou , *yx_wei = tou ,//有序部分:头,尾
       *dcr_tou= yx_wei + 1  , *dcr_wei= wei ;//待插入部分:头,尾

  //逐个把待插入元素插入有序部分
  while ( dcr_tou < dcr_wei )
     {
      cr ( yx_tou , yx_wei , * dcr_tou ) ;  //插入头一个
      yx_wei  ++ ;
      dcr_tou ++ ;
     }

}

//把待插入值插入数组
//因为待插入值在有序数组之后,
//所以总可以 *(yx_wei+1) = *(yx_wei)
void cr( int * yx_tou , int * yx_wei , int crz )
     {
       if ( crz >= * yx_wei) //不用插入
            {
             return ;
            }

      *( yx_wei + 1 ) = * yx_wei ;//把末尾元素向后移动一个位置

      if ( yx_tou == yx_wei) //有序数组只有一个元素
           {
           * yx_tou  = crz ;
           return ;
           }
        else
           {
            return cr ( yx_tou , yx_wei - 1  ,  crz );    //yx_wei - 1必须以
                                                           //yx_wei > yx_tou为前提
           }

}
输出结果如图9-15所示。

指针——《狂人C》观点

在cr()函数中需要注意的是,其中的指针的最小值只能等于“a”,如果出现了小于“a”的情况,是一种未定义行为,这一点在写代码时需要特别小心。指向数组元素的指针可以在数组所在的内存段上移动(最多到指向数组最后元素之后的第一个同类型对象),但不可以超过这个范围。可见,对于指针同样存在着“越界”的问题。指向数组元素的指针可以通过加减法指向数组内部元素,或者数组后面第一个数据对象,超过这个范围则属于越界。当然,引用数组元素依然只能引用数组内部的。

练习 用选择法对一个一维数组排序。

9.6 高维数组名 9.6.1 高维数组名是指针 本节以二维数组为例,重点讲解高维数组的数组名的含义。如下定义了一个二维数组: int a [2][3]; 作为二维数组的数组名,“a”可以进行“[]”运算(也就是可以进行一元“”运算),所以显然“a”是一个指针。问题的重点在于其类型。 如图9-16所示,由于“*a”即“a[0]”本身是由3个“int”类型变量组成的一维数组“int [3]”,所以“a”是指向一个由3个“int”类型数据构成的一维数组的指针,这种类型在C语言中写做: int ()[3]

指针——《狂人C》观点

下面代码的输出证实了这一点。 程序代码9-21

#include <stdio.h>
#include <stdlib.h>



int main( void )
{
    int a [2][3];

    printf(" &a[0][0] = %p \n" , &a[0][0] );
    printf(" a = %p , a + 1 = %p \n" , a  , a + 1 );
    printf(" sizeof ( * a  )   = %d\n" , sizeof ( *a )   );
    printf(" sizeof ( a[0] )    = %d\n" , sizeof ( a[0] )  );
    printf(" sizeof ( int [3] )  = %d\n" , sizeof ( int [3] ) );

    system("PAUSE");   
    return 0;
}

输出结果如图9-17所示。

指针——《狂人C》观点

从输出结果可以看出,“&a[0][0]”与“a”的值相同,这表明这两个指针都始于同一个起点,也就是数组开始存储的第一个byte。然而“a+1”在数值上比“a”大“0022FF64 - 0022FF58 = C ”即十进制的12,说明“a”指向一大小为12byte的数据类型。最后3条的输出表明“a”、“a[0]”及“int [3]”类型所占据的内存空间皆为12byte。这就证实了“a”这个二维数组名是一个指向“int [3]”类型一维数组的指针,即“int ()[3]”类型。 定义这种类型的指针变量的方法是: int (p_a)[3]; 其中的“()”是必需的,这是因为“[]”的优先级比“”要高,在说明“p_a”类型的时候,为了说明“p_a”首先与“”相结合是一个指针变量,必须将“p_a”用“()”括起来以表明“p_a”是与“”紧密结合。下面的定义则表示另一种含义: int a_p[3]; 这里由于标识符“a_p”的前后有“”和“[]”两个类型说明符,而“[]”的优先级别更高,因而“a_p”是一个数组名,“[]”中的“3”表示这个数组一共有3个元素,定义“int *a_p[3];”中的其他部分说明的是数组元素的类型,本例中数组“a_p”的3个元素皆为“int *”类型。 回到原来“a”的定义。现在已经分析出了“a”的类型是指向由3个“int”类型数据所构成的一维数组的指针,显然“a+1”也是同样类型的表达式,由于表达式“(a+1)”等价于“a[1]”,所以它指向“a[1]”,而“a[1]”同样是一个“int [3]”类型的一维数组。 9.6.2 高维数组名是内存 和一维数组名一样,在关于内存的运算中,二维数组名也代表这个二维数组所占据的那块内存。也就是说代表了一个数据对象(Object)。 程序代码9-22

#include <stdio.h>
#include <stdlib.h>



int main(void)
{
    int a [2][3];
    int (*p) [2][3] = & a ;

    printf (" sizeof a = %u\n" , sizeof a);
    printf (" a = %p ,&a = %p ,&a + 1   = %p\n" , a ,  &a , &a + 1 );
    printf (" p = %p ,p + 1 = %p\n" , p  , p  + 1 );  

    system("PAUSE");
    return 0;
}

程序运行结果如图9-18所示。

指针——《狂人C》观点

“sizeof a”的值为24,表明“a”也表示这个二维数组(“int [2][3]”类型)所占据的内存。进而“&a”为一个指向二维数组的指针(“int (*)[2][3]”类型),所以在数值上“&a + 1”比“&a”大18H(24D,即sizeof (int [2][3]))。如图9-19所示,程序最后的输出表明,“a”与指向二维数组的指针变量“p”具有同样的性质。 由此可见,和一维数组名一样,二维数组名同样既可以表示指向其起始元素(“a [0]”)的指针,也表示自身所占据的内存。具体的含义必须在代码的上下文中才能确定,更具体的说要视这个数组名所参与的运算才能确定。

图9-19 二维数组名的另一含义 9.6.3 “a [0]”或“a”的含义 由于“a”是指向一维数组的指针,所以“a [0]”或“a”当然是一维数组类型(“int [3]”)。 然而在C语言中除了数组名,没有什么东西可以表示或代表一个数组整体,因此“a [0]”或“a”的性质和数组名一样也就不足为怪了。 一方面“a [0]”或“a”可以表示一块内存—一维数组所占据的内存,这一点非常明显。因为根据运算符的定义就可以知道“&a [0]”或“&a”就是“a”—指向一维数组的指针。而且可以通过代码证实,“sizeof (a [0])”的值是“3sizeof(int)”。 另一方面,由于“a[0]”或“a”同样都可以进行一次“[]”或一元“”运算,这说明“a[0]”也就是“a”,同样是指针。“a[0]”(也就是“a”)进行一次“[]”或“”运算后将得到“a[0][0]”这个“int”类型的值,因而“a[0]”(也就是“a”)也是“int ”类型的指针。 再经过简单的推理,就可以轻易得出“a [0]”或“a”与“a”在数值上完全相等的结论。因为指针记录的只是一块内存单元中最前面的那个字节的编号,而这几块内存是从同一处开始的,如图9-20所示。

指针——《狂人C》观点

总结一下:二维数组名的值(右值)是指向构成这个二维数组的首个一维数组的指针,同时代表这个二维数组所占据的内存。对这个指针再进行一次“”或“[]”运算就得到了一个一维数组对象“int [3]”,代表这个一维数组所占据的内存,由于能够代表数组对象的只有数组名这样的东西,因而这个对象的值(右值)的类型是“int []”,也就是指向这个一维数组的首个基本元素的指针“int *”。对于更高维的数组,可依此类推。 9.6.4 数组与指针关系总结 数组是一类数据类型的统称,在代码中用数组名表示数组,因而在前面和后面的论述中,数组名和数组实际上是相同的概念。 数组或数组名在代码中表现出两种性质:一方面具有数组类型,另一方面具有指针类型。具体地说就是,在作为左值表达式时表现为数组类型,代表数组所占据的内存空间;在作为右值表达式时表现为指针。 当作为“sizeof”、“&”运算符的运算对象时,数组或数组名为左值表达式。此外,数组或数组名不可以作为“++”、“- -”运算符的运算对象,也不可以作为“=”运算符的左操作数。在其他运算场合,数组或数组名都是右值表达式。 数组或数组名作为右值表达式时,其值与数组所在内存块中存储的内容没有关系,数组所占据的内存空间存储的内容也不像结构体或联合体那样具有值的含义。数组或数组名的值(右值)表示的是指向构成这个数组的起始元素的指针。即,如果数组名为“a”,那么“a”就是指向“a[0]”的指针,无论对于几维数组这个结论都成立。 在对高维数组或高维数组名进行“”或“[]”运算时,运算结果可能是数组类型。这个结果同样具有数组和指针两种含义,视具体运算场合才能确定究竟是何种含义。 对于指向数组的指针变量来说,由于进行“”或“[]”运算得到的是数组类型的数据对象,因而其运算结果也同样具有数组和指针两种含义,需要视具体运算场合才能确定。 一般地说,对于n维(n>2)数组“a”,其数组名作为左值表达式时是n维数组类型,作为右值表达式时是指向n-1维数组的指针;而“a”或“a[0]”作为左值表达式时是n-1维数组类型,作为右值表达式时是指向n-2维数组的指针…… 此外请读者注意,有的书籍中认为数组始终具有数组类型,但在作为右值使用时存在着一个从数组到指针的隐式的类型转换。这与本书的叙述没有什么矛盾,只是叙述的方式不同罢了。 9.6.5 例题 例题:如图9-21所示,写一个程序,通过函数对一个5×6的二维int类型数组中从第i行第j列到第m行第n列的元素求和(假定0≤i,j≤4且0≤m,n≤5,且i×6+j≤m×6+n),m、n、i、j由键盘输入。 不难设想,程序要求可以通过下面的函数完成: int qiuhe(int (*p)[6] , int hs , int i , int j , int m , int n ); 其中“p”为指向构成二维数组的第一个一维数组{2,3,4,5,6,7}的指针,“hs”为二维数组的行数。然而这种方案会给人一种笨拙的印象,因为这个函数只适用于第二维为“6”的二维数组。 实际上问题的本质是求若干在内存中连续存放的“int”类型数据的和,只要知道指向开始的那个“int”数据的指针和指向结尾的那个“int”数据的指针就完全可解。下面代码采用的就是这种解决方案。

指针——《狂人C》观点

int qiuhe(int ,int *); 这涉及求这两个“int *”指针。 由于二维数组名“a”是指向{2,3,4,5,6,7}的指针,那么“a+i”就是指向第“i”个一维数组的指针。而“(a+i)”由于具有“int [6]”数组类型,因此作为右值是一个“int ”类型的指针,这样“(a+i)+j”就是指向开始的那个“int”数据的指针。 同理,“a[m]+n”是指向结尾的那个“int”数据的指针。 程序代码9-23

#include <stdio.h>
#include <stdlib.h>



void shuru(int *,int *, int *,int *) ;
int qiuhe(int *,int *);

int main(void)
       {
          int a[5][6]={{2,3,4,5,6,7} ,
                          {3,5,6,7,8,9} ,
                          {4,2,1,6,3,7} ,
                          {2,3,6,3,2,1} ,
                          {1,3,5,2,6,2}
                        };
          int i,j,m,n;

          shuru(&i,&j,&m,&n);
          printf("从第%d行第%d列到第%d行第%d列元素的和为%d\n",\
                   i , j , m , n , qiuhe(*(a+i)+j, a[m]+n) );

          system("Pause");
          return 0;
       }

void shuru (int *p_i,int *p_j,int *p_m,int *p_n)
       {
        printf("请输入i、j、m、n:\n");
        scanf("%d%d%d%d", p_i , p_j, p_m, p_n);
        return ;
       }

int qiuhe(int *p_beg,int *p_end)
      {
       int he=0;
       do
        {
         he+=*p_beg;
        }
       while(p_beg++<p_end);
       return he;
     }

运行结果如图9-22所示。

指针——《狂人C》观点

第1行第2列为6,第3行第4列为2,所以和为6+7+8+9+4+2+1+6+3+7+2+3+6+3+2 = 69。

练习 写一个函数判断一个二维数组是否为单位矩阵。所谓单位矩阵是指 (1)方阵。 (2)主对角线上元素为1,其余元素皆为0。 例如: 1 0 0 0 1 0 0 0 1

9.7 变量长度数组—VLA(C99) 9.7 变量长度数组——VLA(C99) 9.7.1 简述 C99增加了一种新的数组—变量长度数组(VLA,Variable Length Array),这种数组允许在程序执行时才确定数组的大小。即这种数组的尺寸不再一定是整数类型常量表达式,可以是任一值大于0的整数类型表达式。 变量长度数组这种数据类型是C89和C++中都没有的数据类型,这种类型在许多问题中都非常有用,尤其是在数值处理方面,它在许多问题解决方案的描述上特别有力而且特别灵活。这可能是为了收编FORTRAN的一些成熟的算法,由于有了这种数据类型,现在许多程序可以写得更有通用性也更流利了。 下面代码是使用“变量长度数组”的一个例子。 程序代码9-24

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int n,i;
    scanf("%d",&n);
    int a[n];   //只有支持C99标准的编译器才允许scanf();后定义变量

    for( i = 0 ; i < n ; i ++ )
        scanf("%d",&a[i]);

    for(i=0;i<n;i++)
        printf(" %d ",a[i]);

    system ("Pause");
    return 0; 

}

运行结果如图9-23所示。

指针——《狂人C》观点

特别需要说明的是,这种数组只可以是auto类别的局部变量。也就是说,只能在函数内部定义这种数组,而且不可以是static类别的。 既然变量长度数组只能是局部且必须是auto类别的,那么就一定是在某个复合语句模块中定义的。因此程序执行到这个模块的时候这个数组才获得自己的存储空间,而且,和所有auto类别的局部变量一样,程序一旦执行完它所在的复合语句模块,这个数组也就消失了—内存空间还给了操作系统。 有一种翻译把“变量长度数组”称为“可变长数组”。实际上这种数组的长度并不可变,它仅仅是用“变量”(Variable)来说明数组的长度而已。 一旦变量长度数组存在了(进行变量定义之后),就不可以再改变大小,直到最后消亡(离开作用区域)。并不会因为定义其长度的变量值的改变而改变自己的长度,但下次存在可能具有不同于上次的大小。下面代码运行的结果说明了这一点。 程序代码9-25

#include <stdio.h>
#include <stdlib.h>



int main( void )
{
    int n = 3 ;
    double d[ n + 2 ];

    printf("%u \n" , sizeof d  );
    n = 6;
    printf("%u \n" , sizeof d  );

    for ( n = 3 ; n < 5 ; n ++ )
        {
         double d2[n];
         printf("%u \n" , sizeof d2  );
       }

    system("PAUSE");   
    return 0;
}

运行结果如图9-24所示。

指针——《狂人C》观点

以往的sizeof运算都是在编译期间完成的,而且sizeof里的表达式不求值,例如: int i; sizeof (i=2) 这个运算在编译时完成,“i”也不会被赋值为“2”,因为编译时“i”可能还不存在。 但是对于C99中的VLA,sizeof需要在程序执行时进行,此时sizeof的运算对象被求值。 C99对变量长度数组有一个限制,这种数据类型不可以作为结构体或联合体的成员。 9.7.2 变量修饰类型(Variably modified type) 变量长度数组的数组名在作为右值的时候同样是一个指向其起始元素的指针。在C99中也允许指向变量长度数组的指针。 同样,在C99中也存在指向变量长度数组的指针,这样的数据类型与变量长度数组统称为变量修饰类型(Variably modified type)。比如,对于变量长度数组 int arr[m][n]; 来说,“arr”的右值的类型就是指向“int [n]”类型的指针类型“int ()[n]”,与之相对应的变量可以按照如下方式定义: int (p)[n]; 这里的“p”,和“arr”一样都属于变量修饰类型。 下面代码在语法上演示了这种指针的用法。 程序代码9-26

#include <stdio.h>
#include <stdlib.h>



int main( void )
{

    int n = 4 ;
    int d[n][n];  //一个方阵
    int (*p)[n];

    p = d ;

    int i , j ;
    for ( i = 0 ; i < n ; i ++ )    //形成一个单位方阵
          {
           for ( j = 0 ; j < n ; j ++) 
                 *(*(p+i)+j ) = (i==j) ;//主对角线上的元素都具有i==j的性质
          }

  for ( i = 0 ; i < n ; i ++ )    //输出方阵
         {
          for ( j = 0 ; j < n ; j ++) 
               printf( " %d " , *(*(p+i)+j ));
         putchar('\n');
         }

    system("PAUSE");   
    return 0;
}

程序输出如图9-25所示。

指针——《狂人C》观点

9.7.3 变量长度数组与函数参数 了解了变量长度数组值的类型,就可以写出以变量长度数组作为实参的函数。以9.7.2小节中的“int d[n][n];”为例,考虑用一个函数判断“d”是否构成一个单位矩阵。 首先,由于“d”作为右值的类型为“int ()[n]”或“int [ ][n ]”,所以在函数原型中对应参数的类型为“int ()[n]” 或“int [ ][n ]”。这表示一指向变量长度数组的指针。其中的“n”也可以写成其他标识符,如写成“int ()[k]”。问题在于“n”或“k”这个标识符在使用前必须得到说明,这是C语言的一个原则。所以在此参数类型之前必须有另外一个参数—关于“k”的类型说明。此外,数组第一维度的长度也必须作为参数。这样函数原型就应当写成: “int shi_dwz(int k, int ()[k] , int );”或“int shi_dwz(int k, int [][k] , int );” 与此相对应,函数的定义可以写成下面的形式 “int shi_dwz(int m, int (p)[m] , int n){/*……/}”或“int shi_dwz(int m, int p[][m] , int n){/……/}” 这样描述的函数定义及函数原型具有更广泛的适用范围,它可以接受任何二维数组作为参数,无论其是否为方阵,也无论其是否为变量长度数组。下面代码给出了这种函数的写法和测试。 程序代码9-27

#include <stdio.h>
#include <stdlib.h>



#define SHI 1
#define FOU 0

int shi_dwz(int k, int (*)[k] , int );

int main( void )
{

    int n = 4 ;
    int d[n][n];  //一个方阵
    int (*p)[n];

    p = d ;

    int i , j ;
    for ( i = 0 ; i < n ; i ++ )    //形成一个单位方阵
         {
          for ( j = 0 ; j < n ; j ++) 
              *(*(p+i)+j ) = (i==j) ;
         }

    printf("数组 d %s是方阵\n", shi_dwz(4, d , 4)?"":"不");

    int  a[3][3]
        =  { { 1 , 0 , 0},
              { 0 , 1 , 0},
              { 0 , 0 , 1}
           } ;

    printf("数组 a %s是方阵\n", shi_dwz(3, a , 3)?"":"不");

    int  b[2][3]
        =  { { 1 , 0 , 0},
             { 0 , 1 , 0},
           } ;

    printf("数组 b %s是方阵\n", shi_dwz(3, a , 2)?"":"不");

    system("PAUSE");   
    return 0;
}

//判断数组是否构成方阵
int shi_dwz(int m, int (*p)[m] , int n)
{
    if ( m != n )
         return FOU ;

    int i , j ;
    for ( i = 0 ; i < n ;  i++ )
        {
        for ( j = 0 ; j < m ;  j++ )
               {
              if ( *(*(p+i)+j) != ( i==j ) )
                   return FOU ;  
               }

      }

    return SHI ;

}

测试结果如图9-26所示。

指针——《狂人C》观点

按照C99标准,“int shi_dwz(int k, int ()[k] , int );”这样的函数原型也可以不写出“[k]”里的那个“k”,而代之以“*”,这样也就无需对“k”的类型进行说明,即把函数原型写成: int shi_dwz(int , int ()[*] , int ); 但是这种格式目前Dev C++尚不支持。

练习 写一个可求两矩阵相乘的函数,并自行测试

9.8 数组类型的字面量(C99) 除了结构体类型的字面量,C99也允许数组类型的字面量。 由于数组通常是由多个数据组成的,所以也需要用一种方式把这些数据“组合”在一起。C语言通过运算符“{}”把数据组织在一起。此外还要表明这种数据的类型,C语言用类型转换运算实现。如: (int []){3,4} 或(int [2] ){3,4} 这表示一个由3、4组成的一个一维数组,然而这个数组没有自己的数组名。但它能进行和数组名能进行的相同的运算,比如作为一个实参。也就是说,这个数据具有和数组名同样的类型。 本质上“(){}”是C99中的一个运算符,在C99中,“(){}”是优先级最高的运算之一。 数组字面量也属于复合字面量(Compound literals)的一种,这种复合字面量很像一种具有数组类型的“常量”。但是从根本上来说,这种量并不同于“5”、“3.14”这样的常量。复合字面量最本质的特点是没有相应的标识符而是直接写出的,这就是“literal”的含义。 由于没有相应的标识符,所以数组类型的字面量最常见的用法要么是作为函数的实参、要么是把值赋给指针。下面的代码演示了数组类型复合字面量的用法。 程序代码9-28

#include <stdio.h>
#include <stdlib.h>

int qiu_he(int *, int );

int main( void )
{
    int *p = (int [2]){3,4};

    printf("(int [2]){3,4}的和为%d\n" ,
            qiu_he ( p , sizeof (int [2]){3,4} / sizeof *(int [2]){3,4}) );

    printf("(int [3]){4,5,6}的和为%d\n" ,  qiu_he ( (int [3]){4,5,6} ,
            sizeof (int [3]){4,5,6} / sizeof *(int [3]){4,5,6}));

    system("PAUSE");   
    return 0;
}

//求int数组各元素的和
int qiu_he(int *p, int  n)
{
    int i , he ;
    for ( he = 0,i = 0 ; i < n ; i ++)
     {
           he += p[i];
     }
   return he ;
}

运行结果如图9-27所示。

指针——《狂人C》观点

复合字面量也有自己的生存期,其生存期与对应的局部变量类似。 9.9 指针与结构体 9.9.1 类型问题 结构体类型是一种数据类型,结构体数据也是一种数据对象。因此也可以构造出对应的指针类型。这种指针的运算规则遵守指向数据类型指针的运算规则。仍以前面的结构体类型为例:

struct shijian {
                  int shi ;
                  int fen ;
                  int miao;
                  } ;

这种结构体类型的名称是“struct shijian”,对应的指针的类型是“struct shijian *”。可以用这个类型名定义相应的指针变量,如: struct shijian *p_cs; 同样,如果定义了这种类型的结构体变量: struct shijian cs; 也可以通过“&”运算求得指向这个结构体变量的指针“&cs”,它的类型也是“struct shijian *”,显然这是一个指针常量。如图9-28所示,如果希望指针变量“p_cs”指向结构体变量“cs”,可以通过赋值运算实现: p_cs = &cs ;

指针——《狂人C》观点

9.9.2 通过指针读写结构体的成员 通过指向结构体类型的指针,同样可以对结构体类型量的成员进行访问。由于“* p_cs”就是“cs”,因而可以通过下面的形式访问“cs”的成员: (p_cs).shi 注意这里“()”是必需的,因为“”运算的优先级低于“.”运算的优先级。和“cs.shi”一样,这个表达式也可以作为左值。 此外,C语言还提供了另一种通过指针访问结构体成员的方法,即“->”运算,具体的方法是: p_cs -> shi 这和“(p_cs).shi”是一样的。 下面代码是指向结构体的指针用法的演示。 程序代码9-29 / 题目:21点36分23秒后再过3小时28分47秒是几点? */

#include <stdio.h>
#include <stdlib.h>

#define MSX 60      // 秒数的上限
#define FSX 60      // 分数的上限
#define SSX 24      // 时数的上限

#define SHIJIAN struct shijian

SHIJIAN {
            int shi ;
            int fen ;
            int miao;
           } ;

void jg( SHIJIAN * , SHIJIAN );

int main ( void )
{
    SHIJIAN sj = { 21 , 36 , 23 } , zl = { 3 , 28 , 47 }; //时间和时间的增量
    printf ( "%d点%d分%d秒后再过" , sj.shi , sj.fen , sj.miao );
    printf ( "%d小时%d分%d秒后是" , zl.shi , zl.fen , zl.miao ) ; 
    jg ( &sj , zl  ) ;
    printf ( "%d点%d分%d秒\n" , sj.shi , sj.fen , sj.miao ) ;
    system("PAUSE");
    return 0;
}

/* jg()函数功能:
    根据指向时间的指针和时间的增量
    改变时间的值
*/

void jg( SHIJIAN *p_sj , SHIJIAN zl )
{
   p_sj -> shi  +=  zl.shi  ;
   p_sj -> fen  +=  zl.fen  ;
   p_sj -> miao +=  zl.miao ;
   p_sj -> fen  +=  p_sj -> miao / MSX ;
   p_sj -> miao %=  MSX ;
   p_sj -> shi  +=  p_sj -> fen  / FSX ;
   p_sj -> fen  %=  FSX ;
   p_sj -> shi  %=  SSX ;

}

运行结果如下: 21点36分23秒后再过3小时28分47秒后是1点5分10秒 请按任意键继续. . .

练习 编写一个对分数约分的函数,并自己编写测试代码测试。

9.10 指针与函数 9.10.1 函数名是指针 如同数组名是指针一样,在C语言中,函数名也是指针。当然这种指针也必然是一种“常量”,因为在内存中“移动”变量尚不可能,更不必说“移动”构成函数的一群机器指令了。 作为一种指针,首先要明白它的类型。描述函数名这样的指针非常容易,只要把函数声明中的函数名换成“()”就可以了。比如某个函数的函数声明为: int qiuhe( int , int ) ; 那么,“qiuhe”这个函数名的类型是: int ()(int,int) 这种类型写法对于我们来说除了具有形式上的意义,并没有告诉我们更多的关于这种类型的含义,除非我们知道这种类型本身占据多少内存空间以及这个指针指向什么。 没有理由说这种类型和前面数据指针所需要的内存空间相同,这是必须在具体环境中才能确定的事情。但在这里我们不妨假设这种指针需要4个字节的内存空间,无论实际情况是否如此,对后面的讨论都没有什么影响。 笼统地说,这种指针指向函数也没有任何意义,因为我们并不清楚也不可能清楚函数在内存中是什么样子。毕竟函数不同于数据。数据具有统一的类型和构造规则,相同类型的数据具有相同大小的连续存储空间。而函数在内存中的存储空间我们是不可能加以考察的,甚至我们都不清楚函数占据的内存空间是否连续,可以肯定的是各个函数占据的空间原则上是不相同的。 这恰恰是函数与数据这种连续且具有确定内存长度的对象(Object)最大的区别。这个区别,在后面我们可以看到,决定了指向函数的指针与指向数据的指针之间巨大的差异。 函数与数据的相同之处是它们都占据内存空间,而它们各自所占据的空间都是从各自的某个内存单元开始的,这是可以有指向函数指针的基础,毕竟指针的值是地址。函数名的值也是函数经过编译之后在内存中的映像的起始内存单元的编号或地址。 如图9-29所示的部分内容是不真实的,只是为了帮助理解,把函数“比拟”成了一种类似数组对象的东西。后面将会说明哪些是能被C语言证实的,而哪些是虚构的。

指针——《狂人C》观点

函数占据内存空间,这是确定无疑的。但不清楚占据的是否为连续空间,也不可能清楚这块空间的大小。但在图中画成了一块连续的内存空间来表示“int qiuhe()”函数在内存中的实体,这是虚构的,但是只要我们不从这种虚构中引申出错误的结论,而只是为了帮助理解指向函数的指针这种数据类型,应该是能够获得大家的理解和宽恕的。 函数占据的内存空间有个起点,这个起点处的内存单元有一个编号,也就是所谓的“入口地址”,这是确定的。图中“qiuhe”这个函数名的实线箭头表示的是这一点。 图中,虚线箭头表示“qiuhe”这个指针指向函数所占据的这块内存的整体,这是虚拟的想象,C语言并没有承认这是事实;方向向上的“}”用来表示“qiuhe”这个函数名也代表函数所占据的内存实体,这是作者虚构的,C语言没有这样说过。这样做的目的是把函数名比拟成数组名 ,期待我们能自然地接受函数名的某些性质。 9.10.2 指向函数指针的性质 前面搭建的那个半真半假的模型的本质如下。 qiuhe这个函数名是指向qiuhe()这个函数的指针。 qiuhe这个函数名也代表qiuhe()函数所占据的内存实体。 第一点没有人会否认,只不过C语言没有明确“指向函数”的具体含义。而我虚构了一个“指向函数”的具体含义,我确信这对于编程没有什么危险,因为编程不会用到这点,只可能用到后面推导出的和C语言一致的结论。第二点则完全是我虚构的,是为了更直接地导出下面的推理和正确的结论。 由于“qiuhe”是指向函数的指针,所以“*qiuhe”就是函数的实体;而函数的实体又可以用函数名“qiuhe”表示,所以结论是

  • qiuhe == qiuhe 同理,由于“qiuhe”代表函数的内存实体,所以“&qiuhe”就是指向这个函数的指针;而指向这个函数的指针又是“qiuhe”这个函数名本身,所以可以得到另一个结论 & qiuhe == qiuhe 这样我们就用一个半真半假的模型,自然地推导出了C语言生硬且直接给出的函数名最重要的性质 函数名 == *函数名 == & 函数名

9.10.3 指向函数指针的运算 定义与函数名类型相同的指针变量 如前所述,函数名是指针常量。也可以定义这种类型的变量。仍以“int qiuhe( int , int );”这个函数原型为例,定义与函数名“qiuhe”类型相同的指针变量的方法是: int (p)( int , int ); 当然也可以构造这种类型的数组: int (a[5])(int,int); 这个定义有些复杂,这里不准备详细解读,后面将专门介绍复杂定义的解读问题。 赋值运算 由于“p”的类型与函数名“qiuhe”的类型一致,所以可以进行赋值运算: p = qiuhe ; 这时称指针“p”指向了“qiuhe()”函数。 类似的,指向函数的指针也可以作为函数的实参把值传给相同类型的形参。 函数调用运算 函数名可以进行函数调用运算是不言而喻的,与其相同类型的指针变量也可以进行这种运算。由于函数名这种指针具有函数名 == *函数名 == & 函数名 这样的性质,所以很容易地可以得到结论—下面几种函数调用方式是完全等价的: qiuhe(2,3) (qiuhe)(2,3) (&qiuhe)(2,3) p(2,3) (p) (2,3)
其中“(
qiuhe)”、“(&qiuhe)”、“(p)”的括号是必需的,因为“”、“&”的优先级低于函数调用运算的优先级。 由于函数名这种指针具有函数名 == 函数名 == & 函数名 这样的性质,甚至可以得出更惊人的推论: (*****qiuhe) (2,3)与 qiuhe(2,3)完全等价。 除了赋值、函数调用以及类型转换,其他的运算对于指向函数的指针没有意义,也是非法的。 指向函数的指针是解决某些复杂问题的一个非常巧妙的手法,它可以使代码更具有表现力、更简洁、更有美感。 9.10.4 例题 例题:编程,在键盘上输入: 1+23 这样的表达式,要求程序按照C语言的表达式的规则计算其值。 补充说明如下。 键盘输入格式为ddd…doddd…doddd…d,其中ddd…d表示连续的十进制字符序列,所得到的数值不超过int的表示范围,且表达式求值中和最后的结果也不超过“int”的表示范围。 o表示“+”、“-”、“”、“/”、“%”这5个运算符中的一个。 讨论:由所规定的输入格式,显然可以理解为"%d%c%d%c%d "并通过调用scanf()函数获取这些数据。之后需要考虑的是两个运算符的优先级问题。根据优先级关系的不同,可以借助switch语句完成运算,这种写法究竟有多烦琐可以自己试写一下。 下面的代码演示了指向函数的指针的用法,并且假定输入没有任何错误。 程序代码9-30 /
编程,在键盘上输入 1+2*3 这样的表达式,要求程序按照C语言的表达式的规则计算其值。 */

#include <stdio.h>
#include <stdlib.h>
#define SHI 1

int yxjg( char , char ) ;
int jia ( int , int ) ;
int jian ( int , int ) ;
int cheng ( int , int ) ;
int chu ( int , int ) ;
int qiuyu ( int , int ) ;
int (*qiuys(char))(int,int) ;

int main( void )
{
    int  czs1 , czs2 , czs3;    //共三个操作数
    char ysf1 , ysf2;             //两个运算符
    int  zhi;                       //表达式的值
    int  (*ys1)(int,int) , (*ys2) (int,int) ; //两个运算
    //输入
    printf("请输入一个三元算术表达式\n");
    scanf("%d%c%d%c%d",&czs1,&ysf1,&czs2,&ysf2,&czs3);
    //确定与运算符对应的函数
    ys1 = qiuys ( ysf1 ) ;  
    ys2 = qiuys ( ysf2 ) ;
    if(yxjg(ysf2,ysf1)==SHI)
        zhi = ys1(czs1,ys2(czs2,czs3)) ;
    else
        zhi = ys2(ys1(czs1,czs2),czs3) ;

    printf("%d%c%d%c%d=%d\n",czs1,ysf1,czs2,ysf2,czs3,zhi);    
    system("PAUSE");
    return 0;
}
//判断运算符ysf2是否比ysf1优先级高
int yxjg( char ysf2, char ysf1)
{
     if ( ysf2 == '*' || ysf2 == '/' || ysf2 == '%'  )
            if ( ysf1 == '+' || ysf1 == '-' )
                   return SHI ;
     return  ! SHI;       
}

int jia ( int s1 , int s2  )
{
    return s1 + s2 ;
}

int jian ( int s1 , int s2  )
{
    return s1 - s2 ;
}

int cheng ( int s1 , int s2  )
{
    return s1 * s2 ;
}

int chu ( int s1 , int s2  )
{
    if ( s2 == 0 )
          {
          printf("表达式有错误,按任意键退出\n");
          system("PAUSE");
          exit(1);  //没什么好返回的,只能退出运行程序
          }
    return s1 / s2 ;
}

int qiuyu ( int s1 , int s2  )
{
    if ( s2 == 0 )
           {
            printf("表达式有错误,按任意键退出\n");
            system("PAUSE");
            exit(1);  //退出运行程序
           }
    return s1 % s2 ;
}
//求与运算符ysf对应的函数
int (*qiuys(char ysf))(int,int)
{
    if ( ysf == '+' )
           return jia ;
    if ( ysf == '-' )
      return jian ;
    if ( ysf == '*' )
          return cheng ;
    if ( ysf == '/' )
          return chu ;
    if ( ysf == '%' )
          return qiuyu ;
}

程序运行结果如图9-30所示。 指针——《狂人C》观点

代码中的函数调用exit(1)的作用是结束程序,并返回一个值“1”给操作系统,告之程序运行的最后状态。 9.11 指向虚无的指针 C语言中有一种数据类型是“void”类型,这种类型的特点就是没有任何值。 与这种类型相对应,C语言中还有一种“void ”类型的指针,这种指针不指向任何类型的内存对象,但具有一个值,这个值当然也是地址。只有对于这种类型的指针,说“指针就是地址”才是一种恰当的说法。对于其他类型说“指针就是地址”显然是掩盖了指针更为本质、更为重要的内涵—指针所指向的数据对象或函数的类型。 作为一种只有值而没有更多含义的“void ”类型的指针,其作用仅仅在于传递、保存这个值。“void *”类型的指针可以参加赋值运算(包括作为函数的参数)和类型转换运算,除此之外,“void *”类型的指针不可以进行其他任何运算,甚至一元“”运算这种多数指针类型的基本运算也不可以。 但是“void”类型指针的最大优点在于,无论什么类型的指针赋值给“void”都不用类型转换,反之亦然。然而不少严谨的人士却并不领这个情,他们一如既往地、明白地写出这种转换,尽管他们知道这不是必须的。 在写函数定义时,可能并不清楚函数的调用者会提供什么样的指针,这时只能把对应的形参声明为“void ”类型;同样也有可能不清楚函数调用者需要什么样的指针,这时也只能把函数的返回值声明为“void”类型。 9.12 参数不确定的函数 到此为止,至少有一类函数的实现方式和工作原理我们尚未提到,这就是最常用到的printf()函数和scanf()函数。 这两个函数的特点是,它们的定义(甚至编译)都是在被调用之前完成的,但是这两个函数的作者并不清楚调用这两个函数的人究竟要用几个什么样的实参。然而这两个函数竟然被写出来了,而且编译后确实能够很好地工作。 还可以提出这样类似的问题:在不清楚数量和类型的情况下,如何写一个求几个数(可能是整数也可能是小数)的平均值的函数。 为此,首先剖析一下实现printf()函数的技术手段,研究一下它的工作原理,然后再试写一个求若干个数的平均值的函数。 9.12.1 printf()的函数原型 由于经常使用printf()函数,在源代码中几乎总要写一行编译预处理命令。 #include <stdio.h> 这是因为在文件“stdio.h”中描述了“printf”这个标识符的含义,也就是函数原型。用记事本打开这个文件会发现这个函数原型是这个样子的: int printf (const char, ...); 这里只关注这个函数原型所描述的形参的类型,我们发现第一个参数的类型是“const char*”,这很容易理解,而后面的参数的类型描述全然没有,只写了一个“…”。看来,这个“…”是解决任意个参数问题的一个要点。事实的确如此。 9.12.2 “…”是什么 从第一章中可以看到,“...”也是C语言的一个标点符号。其他的标点符号主要作为运算符或类型说明符,“{}”还可以作为很多情况下某种语言元素开始和结束的标记。但“...”这个标点符号只用于函数声明和定义(此外还用于宏),它的作用是让编译器对出现在这部分的实参与形参不做类型与个数的检查。 此外在函数声明和定义中使用“...”时有一个限制,只能指定后面的参数,且它的前面必须有确定类型的参数。比如 void f(int,…); 是合法的。但 void f(…); void f(…,int); 都不合法。至于理由,后面将会看到。 9.12.3 实现原理 首先考察一个简单的函数调用过程。 程序代码9-31

#include <stdio.h>
#include <stdlib.h>



int qh(int,int);
int main(void)
{
  int m=3,n=4;

  printf("%d\n" , qh(m,n) );

  system("Pause");
  return 0;
}

int qh(int i ,int j)
{
  return i+j;
}

在第6章中曾经提到,在进行函数调用运算时,计算机首先要求出各个实参的值,然后被调用函数的形参将把这些值作为自己的初始值。 这就是说,在程序代码9-31中,在进行qh(m,n)函数调用时,形参“i”、“j”用到的只是“m”、“n”的(右)值而不是“m”、“n”本身,这一点首先应该十分清醒。换句话说,函数调用时,“m”、“n”的值被复制到了其他地方,而这个地方恰恰就是形参占据的内存。如图9-31所示,显示了形参与实参之间的这种关系。

指针——《狂人C》观点

函数的形参一旦获得了初值就可以进行运算了。 特别要注意的是,在图9-31中的两个形参,也就是“i”、“j”,是排在一起的,这是不确定参数实现的关键。 毫无疑问,在qh()函数中通过“&”运算可以求得指向“i”的指针“&i”,而一旦两个形参排列在一起的话,那么在数值上“&i+1”和指向“j”的指针“&j”是相等的,这个值就是“(void *) (&i+1)”。如果事先知道了第二个参数“j”的类型,那么就可以求出指向第二个参数“j”的指针。现在假定qh()函数的作者知道“j”的类型为“int”,那么他就完全可以根据第一个参数的信息和“j”的类型得到指向第二个参数的指针“(int *)(&i+1)”,而一旦他知道了这个指针,也就意味着他知道了第二个参数的一切。 因此qh()函数中的“return i+j;”语句也可以这样写: return i + * (int *)(&i+1); 这个return语句只用到了第二个参数“j”的类型“int”,而没有使用“j”这个参数。 结论就是,在形参相邻及知道第二个参数类型的前提下,从第一个实参也就是第一个形参的初值可以得到第二个实参也就是第二个形参的初值,这样第二个形参就完全没有必要了。代码也可以写成:

程序代码9-32

#include <stdio.h>
#include <stdlib.h>



int qh(int,...);
int main(void)
{
  int m=3,n=4;

  printf("%d\n" , qh(m,n) );

  system("Pause");
  return 0;
}

int qh(int i ,...)
{
  return i+*(int *)(&i+1);
}

对于参数个数不确定的情形是类似的,比如编写一个求若干(>0)个“double”数据平均值的函数,可以通过函数的第一个实参传入“double” 数据的个数。代码可以写成: 程序代码9-33

#include <stdio.h>
#include <stdlib.h>

double qpj(const int,...);

int main(void) 
{

  printf("%lf\n" , qpj(1,1.2) ); //测试
  printf("%lf\n" , qpj(2,1.5,1.9) ); //测试

  system("Pause");
  return 0;
}

double qpj(const int n ,... )
{
  double he =0.0 , * p_d = NULL ;
  int i ;
  p_d = (double *)( &n + 1 );//指向第一个double量
  for ( i = 0 ; i < n ; i++  , p_d ++ )
         he += *p_d ;

  return he/n;
}

输出为: 1.200000 1.700000 请按任意键继续. . .

这就是不确定参数函数实现的基本原理,前提条件是形参在内存中的排列遵守一定的规则,且“…”所代表的各个参数的类型和个数都已知。一般情况下,“…”所代表的各个参数的类型和个数是通过前面确定参数传入的。例如: printf("%d,%c,%lf",123,65,34.0); 在“%d,%c,%f”中就包含有后面参数个数为3,类型分别为“int”,“int”,“double”的信息。 此外要说明的是,形参在内存中的次序规律在不同的环境下是不同的,所以求未定参数的方法也不同。本小节代码中的写法并不具有一般性,只是原理性的示意代码,换句话说没有可移植性。如果希望写出具备可移植性的代码,则需要采用下一小节中的方法。 9.12.4 标准形式 为了保证不确定参数函数代码的可移植性,C语言标准库提供了一套宏。尽管这套宏具有很好的可移植性,但使用起来非常笨拙且程式化,含义非常抽象难解,因此本书在每个步骤后都提供了一个不严格的非正式注解,以帮助读者理解。 这套宏的定义写在stdarg.h文件中,因此需要首先写编译预处理命令。 (1)#include <stdarg.h> (2)va_list ap;/这个“ap”用于遍历各个“…”中的参数。“va_list”是什么类型?是“…”类型。“…”是什么类型?不清楚。实际上这应该是个“void *”,但这是我猜的。/ (3)va_start(ap,最后一个确定参数的类型)/这是让“ap”获得初始值,也就是指向第一个可变参数。应该是“ap = (void)(&最后一个确定参数+1)”,这也是我猜的。/ (4)va_arg(ap,可变参数的类型) /这句的含义是求当前可变参数的值并把“ap”移至下一个可变参数。大体上应该是“((可变参数的类型 )ap)++”,然而“((可变参数的类型 *)ap)++”并不合法,所以这里很可能还需要其他编译手段,比如借助临时变量等。/ (5)va_copy(dst,src) /这是C99新增加的内容,可以复制一个“ap”的副本,在“src”被改变的情况下,一旦需要,还可以从前面重新读取参数。/ (6)va_end(ap) /这是在读完参数后对前面可能用到的临时变量等进行清理。/ 从前面几条可以看出,C语言已经把不确定参数的使用完全程式化地包装起来,并把实现细节完全留给了编译器。如果不是针对具体的环境,很难琢磨其中具体的技术实现细节。下面代码是前面小节中例题的标准化写法。 程序代码9-34

#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>

double qpj(const int,...);

int main(void) 
{

  printf("%lf\n" , qpj(1,1.2) ); //测试
  printf("%lf\n" , qpj(2,1.5,1.9) ); //测试

  system("Pause");
  return 0;
}

double qpj(const int n ,... )
{
  double he =0.0 ;
  int i ;
  va_list ap ; //这是老生常谈的写法

  va_start(ap,n);  //这句需要记一下
  for ( i = 0 ; i < n ; i++   )
         he += va_arg(ap,double) ; //这句最主要
  va_end(ap);//这也是老生常谈的写法
  return he/n;
}

输出为: 1.200000 1.700000 请按任意键继续. . .

小结 概念与术语 指针(Pointer)是C语言中的一类数据类型的统称,这类数据类型专门用来存储和表示内存单元的编号—地址。 指针数据类型是一种需要借助其他数据类型才能构造出来的数据类型。 指针也泛指具有指针数据类型的数据对象。 指针数据类型特定的类型说明符是“”。 指针总是和另外一种具体的数据类型联系在一起。 根据指针所关联的数据类型,可以把指针分为三类:数据指针、函数指针和空指针(void )。这三类指针拥有的运算种类的集合不同。 “数据对象”是指内存中一段以byte为单位的、特定长度的、连续的区域,这段内存区域中的内容具有数据类型的含义。 函数类型不属于数据对象。 数据指针的基本运算有“”和“+1”,这是理解数据指针的基础,而这两个运算都是与指针的类型息息相关的。因此理解数据指针的根本在于理解指针的类型。 “&”是求得指向运算对象的指针的运算,“&”的运算对象通常是变量,但更一般的是它的运算对象是一个左值表达式—表示一块内存的表达式。 称指针“指向某种类型数据”是指这个指针指向那种数据所占据的内存整体。 对于某个左值表达式“E”,“&E”得到的依然是“E”。 如果“p”有意义,那么“&p”一定是“p”。 所有类型的指针都可以进行赋值运算,但一般应该用相同类型的指针赋值。 “[]”运算是用“”运算定义的 :e1[e2]≡(((e1)+(e2))) 数组名作为一个值参与运算的时候是指向数组首个元素的指针,且是一个指针常量。 数组名参与“sizeof”、“&”等运算时,含义是数组所占据的内存空间。 没有内存含义的非左值表达式做“&”运算是错误的。 “”可以作为乘法运算符,可以作为指针类型说明符,也可以作为间接引用运算符。在具体的场合下才能确定。 指向具体数据类型的指针“p”可以与整数类型数据进行“+”运算,“p+i”的含义是得到指向“p”指向数据对象后面第“i”个这样类型数据对象的指针。但是“p”或“p+i”都应该指向某个有意义的数据对象或某个有意义的数据对象之后的首个“虚拟”的数据对象。 指针可以用来通过调用函数改变本地局部变量的值。 指针必须正确恰当地初始化之后才可以进行“”或“[]”运算。 两个类型相同且指向同一数组内元素或数组后面一个同类型“数据”的指针可以做减法运算,结果为“int”,含义是元素下标之差。 两个类型相同且指向同一数组内元素或数组后面一个同类型“数据”的指针可以做关系运算,结果表示两个指针的前后相对位置关系。 两个相同类型的数据指针做“==”或“!=”这两个等式运算的含义十分明显,无非是它们所指向的数据是否为同一个。 “type []”类型的形参等价于“type *”类型的形参。 “高维数组名”作为左值表示一个数组,作为右值是一个指针。 “高维数组名”或“高维数组名[0]”作为左值表示一个数组,作为右值是一个指针。 变量长度数组是指尺寸用变量描述的数组,并不是长度可变的数组(C99)。 用变量作为类型说明符的类型叫变量修饰类型(C99)。 在函数原型或函数定义中出现的标识符必须首先说明(C99)。 C99允许直接写出数组类型的字面量,这种字面量可以进行与数组名同样的运算。 在C99中,“(类型名){字面量列表}”是运算符,优先级同“[]”等运算符。 可以通过指向结构体的指针进行“->”运算访问结构体的成员。 函数名是指向函数类型的指针常量。 函数名==函数名==&函数名 指向函数的指针可以进行“=”、“”运算和“()”运算(函数调用运算)。 “void ”是纯粹的地址,一般用来传递指针的值。 C语言中的函数可分为三类,无参函数、确定参数函数及参数不确定的函数。 “…”用来描述参数不确定函数的函数原型和函数定义中参数不确定的部分。 “…”只能描述函数最后的几个参数,前面的参数必须是确定的。 为了使程序具有可移植性,一般应通过“stdarg.h”中定义的宏实现参数不确定函数。 常见错误 典型错误:“int *p=3;”,这里“”是类型说明符不是运算符。这个错误写法的真正含义是“int p;p=3;”。 类似“Suspicious pointer conversion in”的警告通常意味着错误,这个警告一般出现在指针类型与要求不一致的场合。 调用printf()函数输出指针的值应该用“%p”格式说明符,使用“%u”在一些场合会发生错误。 指向数组元素的指针经加减运算后得到的指针不指向数组元素或数组后面一个虚拟的元素。 int *f(void) {int i; return &i}:这个函数返回的是一个指向局部auto类别变量的指针。然而函数调用之后,“i”已经不存在了。 风格 每定义一个指针变量,要么初始化为0(NULL),要么使它指向恰当的位置。 牛角尖 对于下面的程序片段 n =1 ; do { int a[n][n]; static int (p)[n] = a ; /*其他/ } while ( n ++ < 10); 需要特别注意的是,每次进入循环体“a”的尺寸是变化的,但是由于“p”是static类别,“p”的值和类型却一直保持不变。这可能会带来很大的问题。 练习与自测 (1)写一个能化简分数的函数,并测试。 (2)一个旅行社要从n个旅客中选出一名旅客,为他提供免费的环球旅行服务。旅行社安排这些旅客围成一个圆圈,从帽子中取出一张纸条,用上面写的正整数m(<n)作为报数值。游戏进行时,从第一个人开始按顺时针方向自1开始顺序报数,报到m时停止报数,报m的人被淘汰出列,然后从他顺时针方向上的下一个人开始重新报数,如此下去,直到圆圈中只剩下一个人,这个最后的幸存者就是游戏的胜利者,将得到免费旅行的奖励。 编程对某个给定的n = 8与m = 3,给出被淘汰出列的旅客编号,以及最终的幸存者。 (3)围绕着山顶有10个洞,狐狸要吃兔子,兔子说:“可以,但必须找到我,我就藏身于这10个洞中,你从10号洞出发,先到1号洞找,第二次隔1个洞找,第三次隔2个洞找,以后如此类推,次数不限。”但狐狸从早到晚进进出出了1 000次,仍没有找到兔子。问兔子究竟藏在哪个洞里? (4)某人将一缸鱼分五次出售,第一次卖出全部的一半加二分之一条,第二次卖出余下的三分之一加三分之一条,第三次卖出余下的四分之一加四分之一条,第四次卖出余下的五分之一加五分之一条,最后卖出余下的11条。问原来有多少鱼。 (5)如下图所示,分别找出和为最大及和为最小的4个相邻的数。

(6)编写函数将一个一维数组中的元素颠倒顺序。 (7)编写函数求一个二维矩阵的鞍点(在行上最大、在列上最小)。 (8)编写函数将数组中下标为偶数的元素与各自下一个元素对调位置,如果数组元素个数为奇数,则最后一个元素不动。 (9)写一个函数实现将一个方阵的行列互换。 (10)按递增顺序依次列出所有分母小于等于40的最简单真分数。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这