C++从零开始(中)

上面的第一个e的有效范围是整个a.cpp文件内,而a的有效范围是main函数内,而main函数中的e的有效范围则是括着它的那对“{}”以内。即上面到最后执行完e++;后,long e = 2;定义的变量e已经不在了,也就是被释放了。而long e = 10;定义的e的值为12,a的值为11。
    也就是说“{}”可以一层层嵌套包含,没一层“{}”就产生了一个作用域,在这对“{}”中定义的变量只在这对“{}”中有效,出了这对“{}”就无效了,等同于没定义过。
    为什么要这样弄?那是为了更好的体现出语义。一层“{}”就表示一个阶段,在执行这个阶段时可能会需要到和前面的阶段具有相同语义的变量,如排序。还有某些变量只在某一阶段有用,过了这个阶段就没有意义了,下面举个例子:
    float a[10];
    // 赋值数组a
    for( unsigned i = 0; i < 10; i++ )
        for( unsigned j = 0; j < 10; j++ )
            if( a[ i ] < a[ j ] )
            {
                float temp = a[ i ];
                a[ i ] = a[ j ];
                a[ j ] = temp;
            }
    上面的temp被称作临时变量,其作用域就只在if( a[ i ] < a[ j ] )后的大括号内,因为那表示一个阶段,程序已经进入交换数组元素的阶段,而只有在交换元素时temp在有意义,用于辅助元素的交换。如果一开始就定义了temp,则表示temp在数组元素寻找期间也有效,这从语义上说是不对的,虽然一开始就定义对结果不会产生任何影响,但应不断地询问自己——这句代码能不能不要?这句代码的意义是什么?不过由于作用域的关系而可能产生性能影响,这在《C++从零开始(十)》中说明。
    下篇将举例说明如何已知算法而写出C++代码,帮助读者做到程序员的最基本的要求——给得出算法,拿得出代码。

C++从零开始(八)

——C++样例一

    前篇说明了函数的部分实现方式,但并没有说明函数这个语法的语义,即函数有什么用及为什么被使用。对于此,本篇及后续会零散提到一些,在《C++从零开始(十二)》中再较详细地说明。本文只是就程序员的基本要求——拿得出算法,给得出代码——给出一些样例,以说明如何从算法编写出C++代码,并说明多个基础且重要的编程概念(即独立于编程语言而存在的概念)。


由算法得出代码

    本系列一开头就说明了何谓程序,并说明由于CPU的世界和人们存在的客观物理世界的不兼容而导致根本不能将人编写的程序(也就是算法)翻译成CPU指令,但为了能够翻译,就必须让人觉得CPU世界中的某些东西是人以为的算法所描述的某些东西。如电脑屏幕上显示的图片,通过显示器对不同象素显示不同颜色而让人以为那是一幅图片,而电脑只知道那是一系列数字,每个数字代表了一个象素的颜色值而已。
    为了实现上面的“让人觉得是”,得到算法后要做的的第一步就是找出算法中要操作的资源。前面已经说过,任何程序都是描述如何操作资源的,而C++语言本身只能操作内存的值这一种资源,因此编程要做的第一步就是将算法中操作的东西映射成内存的值。由于内存单元的值以及内存单元地址的连续性都可以通过二进制数表示出来,因此要做的第一步就是把算法中操作的东西用数字表示出来。
    上面做的第一步就相当于数学建模——用数学语言将问题表述出来,而这里只不过是用数字把被操作的资源表述出来罢了(应注意数字和数的区别,数字在C++中是一种操作符,其有相关的类型,由于最后对它进行计算得到的还是二进制数故使用数字进行表示而不是二进制数,以增强语义)。接着第二步就是将算法中对资源的所有操作都映射成语句或函数。
    用数学语言对算法进行表述时,比如将每10分钟到车站等车的人的数量映射为一随机变量,也就前述的第一步。随后定此随机变量服从泊松分布,也就是上面的第二步。到站等车的人的数量是被操作的资源,而给出的算法是每隔10分种改变这个资源,将它的值变成按给定参数的泊松函数分布的一随机值。
    在C++中,前面已经将资源映射成了数字,接着就要将对资源的操作映射成对数字的操作。C++中能操作数字的就只有操作符,也就是将算法中对资源的所有操作都映射成表达式语句。
    当上面都完成了,则算法中剩下的就只有执行顺序了,而执行顺序在C++中就是从上朝下书写,而当需要逻辑判断的介入而改变执行顺序时,就使用前面的if和goto语句(不过后者也可以通过if后接的语句来实现,这样可以减少goto语句的使用,因为goto的语义是跳转而不是“所以就”),并可考虑是否能够使用循环语句以简化代码。即第三步为将执行流程用语句表示出来。
    而前面第二步之所以还说可映射成函数,即可能某个操作比较复杂,还带有逻辑的意味,不能直接找到对应的操作符,这时就只好利用万能的函数操作符,对这个操作重复刚才上面的三个步骤以将此操作映射成多条语句(通过if等语句将逻辑信息表现出来),而将这些语句定义为一函数,供函数操作符使用以表示那个操作。
    上面如果未明不要紧,后面有两个例子,都将分别说明各自是如何进行上述步骤的。


排序

    给出三张卡片,上面随便写了三个整数。有三个盒子,分别标号为1、2和3。将三张卡片随机放到1、2、3这三个盒子中,现在要求排序以使得1、2、3三个盒子中装的整数是由小到大的顺序。
    给出一最简单的算法:称1、2、3盒子中放的卡片上的整数分别为第一、二、三个数,则先将第一个数和第二个数比较,如果前者大则两个盒子内的卡片交换;再将第一个和第三个比较,如果前者大则交换,这样就保证第一个数是最小的。然后将第二个数和第三个数比较,如果前者大则交换,至此排序完成。
    第一步:算法中操作的资源是装在盒子中的卡片,为了将此卡片映射成数字,就注意算法中的卡片和卡片之前有什么不同。算法中区分不同卡片的唯一方法就是卡片上写的整数,因此在这里就使用一个long类型的数字来表示一个卡片。
    算法中有三张卡片,故用三个数字来表示。前面已经说过,数字是装在内存中的,不是变量中的,变量只不过是映射地址而已。在这里需要三个long类型数字,可以借用定义变量时编译器自动在栈上分配的内存来记录这些数字,故可以如此定义三个变量long a1, a2, a3;来记录三个数字,也就相当于装三张卡片的三个盒子。
    第二步:算法中的操作就是对卡片上的整数的比较和交换。前者很简单,使用逻辑操作符就可以实现(因为正好将卡片上的整数映射成变量a1、a2和a3中记录的数字)。后者是交换两个盒子中的卡片,可以先将一卡片从一盒子中取出来,放在桌子上或其他地方。然后将另一盒子中的卡片取出来放在刚才空出来的盒子。最后将先取出来的卡片放进刚空出来的盒子。前面说的“桌子上或其他地方”是用来存放取出的卡片,C++中只有内存能够存放数字,因此上面就必须再分配一临时内存来临时记录取出的数字。
    第三步:操作和资源都已经映射好了,算法中有如果的就用if替换,由什么重复多少次的就用for替换,有什么重复直到怎样的就用while或do while替换,如上照着算法映射过来就完了,如下:
void main()
{
    long a1 = 34, a2 = 23, a3 = 12;
    if( a1 > a2 )
    {
        long temp = a1;
        a1 = a2;
        a2 = temp;
    }
    if( a1 > a3 )
    {
        long temp = a1;
        a1 = a3;
        a3 = temp;
    }
    if( a2 > a3 )
    {
        long temp = a2;
        a2 = a3;
        a3 = temp;
    }
}
    上面就在每个if后面的复合语句中定义了一个临时变量temp以借助编译器的静态分配内存功能来提供临时存放卡片的内存。上面的元素交换并没有按照前面所说映射成函数,是因为在这里其只有三条语句且容易理解。如果要将交换操作定义为一函数,则应如下:
void Swap( long *p1, long *p2 )             void Swap( long &r1, long &r2 )
{                                           {
    long temp = *p1;                            long temp = r1;
    *p1 = *p2;                                  r1 = r2;
    *p2 = temp;                                 r2 = temp;
}                                           }
void main()                                 void main()
{                                           {
    long a1 = 34, a2 = 23, a3 = 12;             long a1 = 34, a2 = 23, a3 = 12;
    if( a1 > a2 )                               if( a1 > a2 )
        Swap( &a1, &a2 );                           Swap( a1, a2 );
    if( a1 > a3 )                               if( a1 > a3 )
        Swap( &a1, &a3 );                           Swap( a1, a3 );
    if( a2 > a3 )                               if( a2 > a3 )
        Swap( &a2, &a3 );                           Swap( a2, a3 );
}                                           }
    先看左侧的程序。上面定义了函数来表示给定盒子之间的交换操作,注意参数类型使用了long*,这里指针表示引用(应注意指针不仅可以表示引用,还可有其它的语义,以后会提到)。
    什么是引用?注意这里不是指C++提出的那个引用变量,引用表示一个连接关系。比如你有手机,则手机号码就是“和你通话”的引用,即只要有你的手机号码,就能够实现“和你通话”。
    再比如Windows操作系统提供的快捷方式,其就是一个“对某文件执行操作”的引用,它可以指向某个文件,通过双击此快捷方式的图标就能够对其所指的文件进行“执行”操作(可能是用某软件打开这个文件或是直接执行此文件等),但如果删除此快捷方式却并不会删除其所指向的文件,因为它只是“对某文件执行操作”的引用。
    人的名字就是对“某人进行标识”的引用,即说某人考上大学通过说那个人的名字则大家就可以知道具体是哪个人。同样,变量也是引用,它是某块内存的引用,因为其映射了地址,而内存块可以通过地址来被唯一表明其存在,不仅仅是标识。注意其和前面的名字不同,因为任何对内存块的操作,只要知道内存块的首地址就可以了,而要和某人面对面讲话或吃饭,只知道他的名字是不够的。
    应注意对某个东西的引用可以不止一个,如人就可以有多个名字,变量也都有引用变量,手机号码也可以不止一个。
    注意上面引入了函数来表示交换,进而导致了盒子也就成了资源,因此必须将盒子映射成数字。而前面又将盒子里装的卡片映射成了long类型的数字,由于“装”这个操作,因此可以想到使用能够标识装某个代表卡片的数字的内存块来作为盒子映射的数字类型,也就是内存块的首地址,也就是long*类型(注意不是地址类型,因为地址类型的数字并不返回记录它的内存的地址)。所以上面的函数参数类型为long*。
    下面看右侧的程序。参数类型变成long&,和指针一样,依旧表示引用,但注意它们的不同。后者表示它是一个别名,即它是一个映射,映射的地址是记录作为参数的数字的地址,也就是说它要求调用此函数时,给出的作为参数的数字一定是有地址的数字。所谓的“有地址的数字”表示此数字是程序员创建的,不是编译器由于临时原因而生成的临时内存的地址,如Swap( a1++, a2 );就要报错。之前已经说明,因为a1++返回的地址是编译器内部定的,就程序逻辑而言,其是不存在的,而Swap( ++a1, a2 );就是正确的。Swap( 1 + 3, 34 );依旧要报错,因为记录1 + 3返回的数字的内存是编译器内部分配的,就程序逻辑上来说,它们并没有被程序员用某块内存记录起来,也就不会有内存。
    一个很简单的判定规则就是调用时给的参数类型如果是地址类型的数字,则可以,否则不行。
    还应注意上面是long&类型,表示所修饰的变量不分配内存,也就是编译器要静态地将参数r1、r2映射的地址定下来,对于Swap( a1, a2 );就分别是a1和a2的地址,但对于Swap( a2, a3 );就变成a2和a3的地址了,这样是无法一次就将r1、r2映射的地址定下来,即r1、r2映射的地址在程序运行时是变化的,也就不能且无法编译时静态一次确定。
    为了实现上面的要求,编译器实际将会在栈上分配内存,然后将地址传递到函数,再编写代码以使得好像动态绑定了r1、r2的地址。这实际和将参数类型定为long*是一样的效果,即上面的Swap( long&, long& );和Swap( long*, long* );是一样的,只是语法书写上不同,内部是相同的,连语义都相同,均表示引用(虽然指针不仅仅只带有引用的语义)。即函数参数类型为引用类型时,依旧会分配内存以传递参数的地址,即等效于指针类型为参数。


商人过河问题

    3个商人带着3个仆人过河,过河的工具只有一艘小船,只能同时载两个人过河,包括划船的人。在河的任何一边,只要仆人的数量超过商人的数量,仆人就会联合起来将商人杀死并抢夺其财物,问应如何设计过河顺序才能让所有人都安全地过到河的另一边。
    给出最弱却万能的算法——枚举法。坐船过河及划船回来的可能方案为一个仆人、一个商人或两个商人、两个仆人及一个商人一个仆人。
    故每次从上述的五种方案中选择一个划过河去,然后检查河岸两侧的人数,看是否会发生仆人杀死商人,如果两边都不会,则再从上述的五个方案中选择一个让人把船划回来,然后再检查是否会发生仆人杀死商人,如果没有就又重新从五个方案中选一个划过河,如上重复直到所有人都过河了。
    上面在选方案时除了保证商人不被杀死,还要保证此方案运行(即过河或划回来)后,两岸的人数布局从来都没有出现过,否则就形成无限循环,且必须合理,即没有负数。如果有一次的方案选择失败,则退回去重新选另一个方案再试。如果所有方案都失败,则再退回到更上一次的方案选择。如果一直退到第一次的方案选择,并且已没有可选的方案,则说明上题无解。
    上面的算法又提出了两个基本又重要的概念——层次及容器。下面先说明容器。
    容器即装东西的东西,而C++中操作的东西只有数字,因此容器就是装数字的东西,也就是内存。容器就平常的理解是能装多个东西,即能装多个数字。这很简单,使用之前的数组的概念就行了。但如果一个盒子能装很多苹果,那它一定占很大的体积,即不管装了一个苹果还是两个苹果,那盒子都要占半立方米的体积。数组就好像盒子,不管装一个元素还是两个元素,它都是long[10]的类型而要占40个字节。
    容器是用来装东西的,那么要取出容器中装的东西,就必须有种手段标识容器中装的东西,对于数组,这个东西就是数组的下标,如long a[10]; a[3];就取出了第四个元素的值。由于有了标识,则还要有一种手段以表示哪些标识是有效的,如上面的a数组,只前面两个元素记录了数字,但是却a[3];,得到的将是错误的值,因为只有a[0]和a[1]是有意义的。
    因此上面的用数组作容器有很多的问题,但它非常简单,并能体现各元素之间的顺序关系,如元素被排序后的数组。但为了适应复杂算法,必须还要其他容器的支持,如链表、树、队列等。它们一般也被称做集合,都是用于管理多个元素用的,并各自给出了如何从众多的元素中快速找到给定标识所对应的元素,而且都能在各元素间形成一种关系,如后面将要提到的层次关系、前面数组的顺序关系等。关于那些容器的具体实现方式,请参考其他资料,在此不表。
    上面算法中提到“两岸的人数布局从来都没有出现过”,为了实现这点,就需要将其中的资源——人数布局映射为数字,并且还要将曾经出现过的所有人数布局全部记录下来,也就是用一个容器记录下来,由于还未说明结构等概念,故在此使用数组来实现这个容器。上面还提到从已有的方案中选择一个,则可选的方案也是一个容器,同上,依旧使用一数组来实现。
    层次,即关系,如希望小学的三年2班的XXX、中国的四川的成都的XXX等,都表现出一种层次关系,这种层次关系是多个元素之间的关系,因此就可以通过找一个容器,那个容器的各元素间已经是层次关系,则这个容器就代表了一种层次关系。树这种容器就是专门对此而设计的。
    上面算法中提到的“再退回到更上一次的方案选择”,也就是说第一次过河选择了一个商人一个仆人的方案,接着选择了一个商人回来的方案,此时如果选择两个仆人过河的方案将是错误的,则将重新选择过河的方案。再假设此时所有过河的方案都失败了,则只有再向后退以重新选择回来的方案,如选择一个仆人回来。对于此,由于这里只要求退回到上一次的状态,也就是人数布局及选择的方案,则可以将这些统一放在容器中,而它们各自都只依靠顺序关系,即第二次过河的方案一定在第一次过河的方案成功的前提下才可能考虑,因此使用数组这个带有顺序关系的容器即可。
    第一步:上面算法的资源有两个:坐船的方案和两岸的人数布局。坐船的方案最多五种,在此使用一个char类型的数字来映射它,即此8位二进制数的前4位用补码格式来解释得到的数字代表仆人的数量,后4位则代表商人的数量。因此一个商人和一个仆人就是( 1 << 4 ) | 1。两岸的人数布局,即两岸的商人数和仆人数,由于总共才3+3=6个人,这都可以使用char类型的数字就能映射,但只能映射一个人数,而两岸的人数实际共有4个(左右两岸的商人数和仆人数),则这里使用一个char[4]来实现(实际最好是使用结构来映射而不是char[4],下篇说明)。如char a[4];表示一人数布局,则a[0]表示河岸左侧的商人数,a[1]表示左侧的仆人数,a[2]表示河岸右侧的商人数,a[3]表示右侧的仆人数。
    注意前面说的容器,在此为了装可选的坐船方案故应有一容器,使用数组,如char sln[5];。在此还需要记录已用的坐船方案,由于数组的元素具备顺序关系,所以不用再生成一容器,直接使用一char数字记录一下标,当此数字为3时,表示sln[0]、sln[1]和sln[2]都已经用过且都失败了,当前可用的为sln[3]和sln[4]。同样,为了装已成功的坐船方案作用后的人数布局及当时所选的方案,就需要两个容器,在此使用数组(实际应该链表)char oldLayout[200][4], cur[200];。oldLayout就是记录已成功的方案的容器,其大小为200,表示假定在200次内,一定就已经得出结果了,否则就会因为超出数组上限而可能发生内存访问违规,而为什么是可能在《C++从零开始(十五)》中说明。
    前面说过数组这种容器无法确定里面的有效元素,必须依靠外界来确定,对此,使用一unsigned char curSln;来记录oldLayout和cur中的有效元素的个数。规定当curSln为3时,表示oldLayout[0][0~3]、oldLayout[1][0~3]和oldLayout[2][0~3]都有效,同样cur[0]、cur[1]和cur[2]都有效,而之后的如cur[3]等都无效。
    第二步:操作有:执行过河方案、执行回来方案、检查方案是否成功、退回到上一次方案选择、是否所有人都过河、判断人数布局是否相同。如下:
    前两个操作:将当前的左岸人数减去相应的方案定的人数,而右岸则加上人数。要表现当前左岸人数,可以用oldLayout[ curSln ][0]和oldLayout[ curSln ][1]表示,而相应方案的人数则为( sln[ cur[ curSln ] ] & 0xF0 ) >> 4和sln[ cur[ curSln ] ] & 0xF。由于这两个操作非常类似,只是一个是加则另一个就是减,故将其定义为函数,则为了在函数中能操作oldLayout、curSln等变量,就需要将这些变量定义为全局变量。
    检查是否成功:即看是否
    oldLayout[ curSln ][1] > oldLayout[ curSln ][0] && oldLayout[ curSln ][0]以及是否
    oldLayout[ curSln ][3] > oldLayout[ curSln ][2] && oldLayout[ curSln ][2]
    并且保证各自不为负数以及没有和原来的方案冲突。检查是否和原有方案相同就是枚举所有原由方案以和当前方案比较,由于比较复杂,在此将其定义为函数,通过返回bool类型来表示是否冲突。
    退回上一次方案或到下一个方案的选择,只用curSln--或curSln++即可。而是否所有人都过河,则只用oldLayout[ curSln ][0~1]都为0而oldLayout[ curSln ][2~3]都为3。而判断人数布局是否相同,则只用相应各元素是否相等即可。
    第三步:下面剩下的就没什么东西了,只需要按照算法说的顺序,将刚才的各操作拼凑起来,并注意“重复直到所有人都过河了”转成do while即可。如下:
#include
// 分别表示一个商人、一个仆人、两个商人、两个仆人、一个商人一个仆人
char sln[5] = { ( 1 << 4 ), 1, ( 2 << 4 ), 2, ( 1 << 4 ) | 1 };
unsigned char curSln = 1;
char oldLayout[200][4], cur[200];

void DoSolution( char b )
{
    unsigned long oldSln = curSln - 1;  // 临时变量,出于效率
    oldLayout[ curSln ][0] =
        oldLayout[ oldSln ][0] - b * ( ( sln[ cur[ curSln ] ] & 0xF0 ) >> 4 );
    oldLayout[ curSln ][1] =
        oldLayout[ oldSln ][1] - b * ( sln[ cur[ curSln ] ] & 0xF );
    oldLayout[ curSln ][2] =
        oldLayout[ oldSln ][2] + b * ( ( sln[ cur[ curSln ] ] & 0xF0 ) >> 4 );
    oldLayout[ curSln ][3] =
        oldLayout[ oldSln ][3] + b * ( sln[ cur[ curSln ] ] & 0xF );
}
bool BeRepeated( char b )
{
    for( unsigned long i = 0; i < curSln; i++ )
        if( oldLayout[ curSln ][0] == oldLayout[ i ][0] &&  // 这里虽然4个数字比较是否相等
            oldLayout[ curSln ][1] == oldLayout[ i ][1] &&  // 但总共才4个字节长,实际可以
            oldLayout[ curSln ][2] == oldLayout[ i ][2] &&  // 通过一次4字节长数字比较替换
            oldLayout[ curSln ][3] == oldLayout[ i ][3] &&  // 四次1字节长数字比较来优化
            ( ( i & 1 ) ? 1 : -1 ) == b )  // 保证过河后的方案之间比较,回来后的方案之间比较
                                           // i&1等效于i%2,i&7等效于i%8,i&63等效于i%64
            return true;
    return false;
}
void main()
{
    char b = 1;
    oldLayout[0][0] = oldLayout[0][1] = 3;
    cur[0] = oldLayout[0][2] = oldLayout[0][3] = 0;
    for( unsigned char i = 0; i < 200; i++ )  // 初始化每次选择方案时的初始化方案为sln[0]
        cur[ i ] = 0;                         // 由于cur是全局变量,在VC中,其已经被赋值为0
                                              // 原因涉及到数据节,在此不表
    do
    {
        DoSolution( b );
        if( ( oldLayout[ curSln ][1] > oldLayout[ curSln ][0] && oldLayout[ curSln ][0] ) ||
            ( oldLayout[ curSln ][3] > oldLayout[ curSln ][2] && oldLayout[ curSln ][2] ) ||
            oldLayout[ curSln ][0] < 0 || oldLayout[ curSln ][1] < 0 ||
            oldLayout[ curSln ][2] < 0 || oldLayout[ curSln ][3] < 0 ||
            BeRepeated( b ) )
        {
        // 重新选择本次的方案
P:
            cur[ curSln ]++;
            if( cur[ curSln ] > 4 )
            {
                b = -b;
                cur[ curSln ] = 0;
                curSln--;
                if( !curSln )
                    break;  // 此题无解
                goto P;  // 重新检查以保证cur[ curSln ]的有效性
            }
            continue;
        }
        b = -b;
        curSln++;
    }
    while( !( oldLayout[ curSln - 1 ][0] == 0 && oldLayout[ curSln - 1 ][1] == 0 &&
              oldLayout[ curSln - 1 ][2] == 3 && oldLayout[ curSln - 1 ][3] == 3 ) );

    for( i = 0; i < curSln; i++ )
        printf( "%d  %d/t %d  %d/n",
                oldLayout[ i ][0],
                oldLayout[ i ][1],
                oldLayout[ i ][2],
                oldLayout[ i ][3] );
}
    上面数组sln[5]的初始化方式下篇介绍。上面的预编译指令#include将在《C++从零开始(十)》中说明,这里可以不用管它。上面使用的函数printf的用法,请参考其它资料,这里它只是将变量的值输出在屏幕上而已。
    前面说此法是枚举法,其基本上属于万能方法,依靠CPU的计算能力来实现,一般情况下程序员第一时间就会想到这样的算法。它的缺点就是效率极其低下,大量的CPU资源都浪费在无谓的计算上,因此也是产生瓶颈的大多数原因。由于它的万能,编程时很容易将思维陷在其中,如求和1到100,一般就写成如下:
    for( unsigned long i = 1, s = 0; i <= 100; i++ ) s += i;
    但更应该注意到还可unsigned long s = ( 1 + 100 ) * 100 / 2;,不要被枚举的万能占据了头脑。
    上面的人数布局映射成一结构是最好的,映射成char[4]所表现的语义不够强,代码可读性较差。下篇说明结构,并展示类型的意义——如何解释内存的值。

发表于 2004年07月14日 2:55 PM


评论
# 回复:C++从零开始(八)——C++样例一 2004-07-23 1:10 AM ぐ落葉ζ繽紛
此两个例子看后,有诸多问题,也许是前面的基础还不够牢固。第一个排序,只有一个问题,就是我把指针类型跟地址类型弄含糊了。忘你能在此细讲一下(他们的语义和区别)。


第二个商人过河其算法和你列出的步骤都能理解,但转化能代码就有点问题。哎,连你写出来我都看不懂,有点悲哀!(主要是1由于你定义的变量比较多;2表达式的实际操作有点问题)我准备学完了再来看这个例子。 对于象我这样的情况,你有没有更好的方法和建议。菜鸟先谢过了~~~*_^

 

# 回复:C++从零开始(八)——C++样例一 2004-07-23 11:45 AM lop5712
抱歉,其实本系列一直基于这样的一个思想来写的——用尽量少的概念定义,解释尽量多的表面现象。本系列提出了以下几个概念——数字、类型、类型修饰符、映射元素、操作符、语句、语句修饰符和类型定义符。在后续文章中,由于要使用这些概念来解释C++可以写出的各种语句,我朋友对于我的解释认为过于抽象,根本不适合初学者看。
我提出地址类型的数字完全只是为了从语法上解释C++的语句,在语法上要保证其严密性。一般的理解为要标识某内存块,就应该给出它的首地址,而指针的意思就是装地址的内存块。即指针类型的变量里面装的数字应该被编译器理解为地址,是用于标识某块内存的。而编译器如何表现出它已经将某个数字理解为地址了?就通过使用取内容操作符“*”来体现,如:
long a, *pA = &a; *pA = 19;
上面的一般解释是,因为pA的内容是一个地址,因此取内容操作符就将pA给出的地址所标识的内存得到,即a对应的内存。这样的解释是有逻辑漏洞的,不过它要较我在文中通过类型匹配来解释更易理解。
指针类型表明相应变量里装的是一个地址(编译器认为是个地址),而地址能够标识内存块,所以称指针具有引用的语义(通过记录某个内存块的地址来实现引用)。因为只要给出某个指针类型的变量,就可以通过对它使用取内容操作符来得到它装的地址所标识的内存。
实际根本没有地址类型这样的东西,即无法在C++代码上表现出地址类型(指针类型就可以,使用指针类型修饰符),而我提出它就是想从语法上去掉上面语句的常规解释而带来的逻辑漏洞。因此也不用非要理解它。如果你真的要理解它,我只有建议你再看下《四》了,我觉得那里已经将地址解释得很清楚了。

正如我上面所说,我是用另外一套概念(而不是什么数组、指针、函数、结构、类等常规概念)来解释C++的,目的是要用尽量少的概念解释它的所有常规概念,即认为它的常规概念之间有共性,我在此将其抽象出来而已,也因此本系列显得较抽象。
我认为应该先看一两本C++的书以有感性认识,然后如果还有兴趣,可以看本系列。本系列后面的《十》《十一》《十二》都比较抽象,我朋友认为并不适合初学者看,在此表示抱歉。

C++从零开始(九)

——何谓结构

    前篇已经说明编程时,拿到算法后该干的第一件事就是把资源映射成数字,而前面也说过“类型就是人为制订的如何解释内存中的二进制数的协议”,也就是说一个数字对应着一块内存(可能4字节,也可能20字节),而这个数字的类型则是附加信息,以告诉编译器当发现有对那块内存的操作语句(即某种操作符)时,要如何编写机器指令以实现那个操作。比如两个char类型的数字进行加法操作符操作,编译器编译出来的机器指令就和两个long类型的数字进行加法操作的不一样,也就是所谓的“如何解释内存中的二进制数的协议”。由于解释协议的不同,导致每个类型必须有一个唯一的标识符以示区别,这正好可以提供强烈的语义。


typedef

    提供语义就是要尽可能地在代码上体现出这句或这段代码在人类世界中的意义,比如前篇定义的过河方案,使用一char类型来表示,然后定义了一数组char sln[5]以期从变量名上体现出这是方案。但很明显,看代码的人不一定就能看出sln是solution的缩写并进而了解这个变量的意义。但更重要的是这里有点本末倒置,就好像这个东西是红苹果,然后知道这个东西是苹果,但它也可能是玩具、CD或其它,即需要体现的语义是应该由类型来体现的,而不是变量名。即char无法体现需要的语义。
    对此,C++提供了很有意义的一个语句——类型定义语句。其格式为typedef <源类型名> <标识符>;。其中的<源类型名>表示已存在的类型名称,如char、unsigned long等。而<标识符>就是程序员随便起的一个名字,符合标识符规则,用以体现语义。对于上面的过河方案,则可以如下:
    typedef char Solution; Solution sln[5];
    上面其实是给类型char起了一个别名Solution,然后使用Solution来定义sln以更好地体现语义来增加代码的可读性。而前篇将两岸的人数分布映射成char[4],为了增强语义,则可以如下:
    typedef char PersonLayout[4]; PersonLayout oldLayout[200];
    注意上面是typedef char PersonLayout[4];而不是typedef char[4] PersonLayout;,因为数组修饰符“[]”是接在被定义或被声明的标识符的后面的,而指针修饰符“*”是接在前面的,所以可以typedef char *ABC[4];但不能typedef char [4]ABC*;,因为类型修饰符在定义或声明语句中是有固定位置的。
    上面就比char oldLayout[200][4];有更好的语义体现,不过由于为了体现语义而将类型名或变量名增长,是否会降低编程速度?如果编多了,将会发现编程的大量时间不是花在敲代码上,而是调试上。因此不要忌讳书写长的变量名或类型名,比如在Win32的Security SDK中,就提供了下面的一个函数名:
    BOOL ConvertSecurityDescriptorToStringSecurityDescriptor(…);
    很明显,此函数用于将安全描述符这种类型转换成文字形式以方便人们查看安全描述符中的信息。
    应注意typedef不仅仅只是给类型起了个别名,还创建了一个原类型。当书写char* a, b;时,a的类型为char*,b为char,而不是想象的char*。因为“*”在这里是类型修饰符,其是独立于声明或定义的标识符的,否则对于char a[4], b;,难道说b是char[4]?那严重不符合人们的习惯。上面的char就被称作原类型。为了让char*为原类型,则可以:typedef char *PCHAR; PCHAR a, b, *c[4];。其中的a和b都是char*,而c是char**[4],所以这样也就没有问题:char **pA = &a;。


结构

    再次考虑前篇为什么要将人数布局映射成char[4],因为一个人数可以用一个char就表示,而人数布局有四个人数,所以使用char[4]。即使用char[4]是希望只定义一个变量就代表了一个人数分布,编译器就一次性在栈上分配4个字节的空间,并且每个字节都各自代表一个人数。所以为了表现河岸左侧的商人数,就必须写a[0],而左侧的仆人数就必须a[1]。坏处很明显,从a[0]无法看出它表示的是左岸的商人数,即这个映射意义(左岸的商人数映射为内存块中第一个字节的内容以补码格式解释)无法从代码上体现出来,降低了代码的可读性。
    上面其实是对内存布局的需要,即内存块中的各字节二进制数如何解释。为此,C++提出了类型定义符“{}”。它就是一对大括号,专用在定义或声明语句中,以定义出一种类型,称作自定义类型。即C++原始缺省提供的类型不能满足要求时,可自定义内存布局。其格式为:<类型关键字> <名字> { <声明语句> …}。<类型关键字>只有三个:struct、class和union。而所谓的结构就是在<类型关键字>为struct时用类型定义符定义的原类型,它的类型名为<名字>,其表示后面大括号中写的多条声明语句,所定义的变量之间是串行关系(后面说明),如下:
    struct ABC { long a, *b; double c[2], d; } a, *b = &a;
    上面是一个变量定义语句,对于a,表示要求编译器在栈上分配一块4+4+8*2+8=32字节长的连续内存块,然后将首地址和a绑定,其类型为结构型的自定义类型(简称结构)ABC。对于b,要求编译器分配一块4字节长的内存块,将首地址和b绑定,其类型为结构ABC的指针。
    上面定义变量a和b时,在定义语句中通过书写类型定义符“{}”定义了结构ABC,则以后就可以如下使用类型名ABC来定义变量,而无需每次都那样,即:
    ABC &c = a, d[2];
    现在来具体看清上面的意思。首先,前面语句定义了6个映射元素,其中a和b分别映射着两个内存地址。而大括号中的四个变量声明也生成了四个变量,各自的名字分别为ABC::a、ABC::b、ABC::c、ABC::d;各自映射的是0、4、8和24;各自的类型分别为long ABC::、long* ABC::、double (ABC::) [2]、double ABC::,表示是偏移。其中的ABC::表示一种层次关系,表示“ABC的”,即ABC::a表示结构ABC中定义的变量a。应注意,由于C++是强类型语言,它将ABC::也定义为类型修饰符,进而导致出现long* ABC::这样的类型,表示它所修饰的标识符是自定义类型ABC的成员,称作偏移类型,而这种类型的数字不能被单独使用(后面说明)。由于这里出现的类型不是函数,故其映射的不是内存的地址,而是一偏移值(下篇说明)。与之前不同了,类型为偏移类型的(即如上的类型)数字是不能计算的,因为偏移是一相对概念,没有给出基准是无法产生任何意义的,即不能:ABC::a; ABC::c[1];。其中后者更是严重的错误,因为数组操作符“[]”要求前面接的是数组或指针类型,而这里的ABC::c是double的数组类型的结构ABC中的偏移,并不是数组类型。
    注意上面的偏移0、4、8、24正好等同于a、b、c、d顺次安放在内存中所形成的偏移,这也正是struct这个关键字的修饰作用,也就是前面所谓的各定义的变量之间是串行关系。
    为什么要给偏移制订映射?即为什么将a映射成偏移0字节,b映射成偏移4字节?因为可以给偏移添加语义。前面的“左岸的商人数映射为内存块中第一个字节的内容以补码格式解释”其实就是给定内存块的首地址偏移0字节。而现在给出一个标识符和其绑定,则可以将这个标识符起名为LeftTrader来表现其语义。
    由于上面定义的变量都是偏移类型,根本没有分配内存以和它们建立映射,它们也就很正常地不能是引用类型,即struct AB{ long a, &b; };将是错误的。还应注意上面的类型double (ABC::)[2],类型修饰符“ABC::”被用括号括起来,因为按照从左到右来解读类型操作符的规则,“ABC::”实际应该最后被解读,但其必须放在标识符的左边,就和指针修饰符“*”一样,所以必须使用括号将其括住,以表示其最后才起修饰作用。故也就有:double (*ABCD::)[2]、double (**ABCD::)[2],各如下定义:
    struct ABCD { double ( *pD )[2]; double ( **ppD )[2]; };
    但应注意,“ABCD::”并不能直接使用,即double ( *ABCD:: pD )[2];是错误的,要定义偏移类型的变量,必须通过类型定义符“{}”来自定义类型。还应注意C++也允许这样的类型double ( *ABCD::* )[2],其被称作成员指针,即类型为double ( *ABCD:: )[2]的指针,也就是可以如下:
    double ( **ABCD::*pPPD )[2] = &ABC::ppD, ( **ABCD::**ppPPD )[2] = &pPPD;
    上面很奇怪,回想什么叫指针类型。只有地址类型的数字才能有指针类型,表示不计算那个地址类型的数字,而直接返回其二进制表示,也就是地址。对于变量,地址就是它映射的数字,而指针就表示直接返回其映射的数字,因此&ABCD::ppD返回的数字其实就是偏移值,也就是4。
    为了应用上面的偏移类型,C++给出了一对操作符——成员操作符“.”和“->”。前者两边接数字,左边接自定义类型的地址类型的数字,而右边接相应自定义类型的偏移类型的数字,返回偏移类型中给出的类型的地址类型的数字,比如:a.ABC::d;。左边的a的类型是ABC,右边的ABC::d的类型是double ABC::,则a.ABC::d返回的数字是double的地址类型的数字,因此可以这样:a.ABC::d = 10.0;。假设a对应的地址是3000,则a.ABC::d返回的地址为3000+24=3024,类型为double,这也就是为什么ABC::d被叫做偏移类型。由于“.”左边接的结构类型应和右边的结构类型相同,因此上面的ABC::可以省略,即a.d = 10.0;。而对于“->”,和“.”一样,只不过左边接的数字是指针类型罢了,即b->c[1] = 10.0;。应注意b->c[1]实际是( b->c )[1],而不是b->( c[1] ),因为后者是对偏移类型运用“[]”,是错误的。
    还应注意由于右边接偏移类型的数字,所以可以如下:
    double ( ABC::*pA )[2] = &ABC::c, ( ABC::**ppA )[2] = &pA;
    ( b->**ppA )[1] = 10.0; ( a.*pA )[0] = 1.0;
    上面之所以要加括号是因为数组操作符“[]”的优先级较“*”高,但为什么不是b->( **ppA )[1]而是( b->**ppA )[1]?前者是错误的。应注意括号操作符“()”并不是改变计算优先级,而是它也作为一个操作符,其优先级被定得很高罢了,而它的计算就是计算括号内的数字。之前也说明了偏移类型是不能计算的,即ABC::c;将错误,而刚才的前者由于“()”的加入而导致要求计算偏移类型的数字,故编译器将报错。
    还应该注意,成员指针是偏移类型的指针,即装的是偏移,则可以程序运行时期得到偏移,而前面通过ABC::a这种形式得到的是编译时期,由编译器帮忙映射的偏移,只能实现静态的偏移,而利用成员指针则可以实现动态的偏移。不过其实只需将成员定义成数组或指针类型照样可以实现动态偏移,不过就和前篇没有使用结构照样映射了人数布局一样,欠缺语义而代码可读性较低。成员指针的提出,通过变量名,就可以表现出丰富的语义,以增强代码的可读性。现在,可以将最开始说的人数布局定义如下:
    struct PersonLayout{ char LeftTrader, LeftServitor, RightTrader, RightServitor; };
    PersonLayout oldLayout[200], b;
    因此,为了表示b这个人数分布中的左侧商人数,只需b.LeftTrader;,右侧的仆人数,只需b.RightServitor;。因为PersonLayout::LeftTrader记录了偏移值和偏移后应以什么样的类型来解释内存,故上面就可以实现原来的b[0]和b[3]。很明显,前者的可读性远远地高于后者,因为前者通过变量名(b和PersonLayout::LeftTrader)和成员操作符“.”表现了大量的语义——b的左边的商人数。
    注意PersonLayout::LeftTrader被称作结构PersonLayout的成员变量,而前面的ABC::d则是ABC的成员变量,这种叫法说明结构定义了一种层次关系,也才有所谓的成员操作符。既然有成员变量,那也有成员函数,这在下篇介绍。
    前篇在映射过河方案时将其映射为char,其中的前4位表示仆人数,后4位表示商人数。对于这种使用长度小于1个字节的用法,C++专门提供了一种语法以支持这种情况,如下:
    struct Solution { ServitorCount : 4; unsigned TraderCount : 4; } sln[5];
    由于是基于二进制数的位(Bit)来进行操作,只准使用两种类型来表示数字,原码解释数字或补码解释数字。对于上面,ServitorCount就是补码解释,而TraderCount就是原码解释,各自的长度都为4位,而此时Solution::ServitorCount中依旧记录的是偏移,不过不再以字节为单位,而是位为单位。并且由于其没有类型,故也就没有成员指针了。即前篇的( sln[ cur[ curSln ] ] & 0xF0 ) >> 4等效于sln[ cur[ curSln] ].TraderCount,而sln[ cur[ curSln ] ] & 0xF0等效于sln[ cur[ curSln] ].ServitorCount,较之前具有了更好的可读性。
    应该注意,由于struct AB { long a, b; };也是一条语句,并且是一条声明语句(因为不生成代码),但就其意义上来看,更通常的叫法把它称为定义语句,表示是类型定义语句,但按照不生成代码的规则来判断,其依旧是声明语句,并进而可以放在类型定义符“{}”中,即:
    struct ABC{ struct DB { long a, *b[2]; }; long c; DB a; };
    上面的结构DB就定义在结构ABC的声明语句中,则上面就定义了四个变量,类型均为偏移类型,变量名依次为:ABC::DB::a、ABC::DB::b、ABC::c、ABC::a;类型依次为long ABC::DB::、long* (ABC::DB::)[2]、long ABC::、ABC::DB;映射的数值依次为0、4、0、4。这里称结构DB嵌套在结构ABC中,其体现出一种层次关系,实际中这经常被使用以表现特定的语义。欲用结构DB定义一个变量,则ABC::DB a;。同样也就有long* ( ABC::DB::*pB )[2] = &ABC::DB::b; ABC c; c.a.a = 10; *( c.a.b[0] ) = 20;。应注意ABC::DB::表示“ABC的DB的”而不是“DB的ABC的”,因为这里是重复的类型修饰符,是从右到左进行修饰的。
    前面在定义结构时,都指明了一个类型名,如前面的ABC、ABCD等,但应该注意类型名不是必须的,即可以struct { long a; double b; } a; a.a = 10; a.b = 34.32;。这里就定义了一个变量,其类型是一结构类型,不过这个结构类型没有标识符和其关联,以至于无法对其运用类型匹配等比较,如下:
    struct { long a; double b; } a, &b = a, *c = &a; struct { long a; double b; } *d = &a;
    上面的a、b、c都没有问题,因为使用同一个类型来定义的,即使这个类型没有标识符和其映射,但d将会报错,即使后写的结构的定义式和前面的一模一样,但仍然不是同一个,只是长得像罢了。那这有什么用?后面说明。
    最后还应该注意,当在复合语句中书写前面的声明语句以定义结构时,之前所说的变量作用域也同样适用,即在某复合语句中定义的结构,出了这个复合语句,它就被删除,等于没定义。如下:
void ABC()
{
    struct AB { long a, b; };
    AB d; d.b = 10;
}
void main()
{
    {
        struct AB{ long a, b, e; };
        AB c; c.e = 23;
    }
    AB a;  // 将报错,说AB未定义,但其他没有任何问题
}


初始化

    初始化就是之前在定义变量的同时,就给在栈上分配的内存赋值,如:long a = 10;。当定义的变量的类型有表示多个元素时,如数组类型、上面的结构类型时,就需要给出多个数字。对此,C++专门给出了一种语法,使用一对大括号将欲赋的值括起来后,整体作为一个数字赋给数组或结构,如下:
    struct ABC { long a, b; float c, d[3]; };
    ABC a = { 1, 2, 43.4f, { 213.0f, 3.4f, 12.4f } };
    上面就给出了为变量a初始化的语法,大括号将各元素括起来,而各元素之间用“,”隔开。应注意ABC::d是数组类型,其对应的初始化用的数字也必须用大括号括起来,因此出现上面的嵌套大括号。现在应该了解到“{}”只是用来构造一个具有多个元素的数字而已,因此也可以有long a = { 34 };,这里“{}”就等同于没有。还应注意,C++同意给出的大括号中的数字个数少于相应自定义类型或数组的元素个数,即:ABC a = { 1, 2, 34 }, b = { 23, { 34 }, 65, { 23, 43 } }, c = { 1, 2, { 3, { 4, 5, 6 } } };
    上面的a.d[0]、a.d[1]、a.d[2]都为0,而只有b.d[2]才为0,但c将会报错,因为嵌套的第一个大括号将{ 4, 5, 6 }也括了起来,表示c.c将被一个具有两个元素的数字赋值,但c.c的类型是float,只对应一个元素,编译器将说初始化项目过多。而之前的a和b未赋值的元素都将被赋值为0,但应注意并不是数值上的0,而是简单地将未赋值的内存的值用0填充,再通过那些补码原码之类的格式解释成数值后恰好为0而已,并不是赋值0这个数字。
    应注意,C++同意这样的语法:long a[] = { 34, 34, 23 };。这里在定义a时并没有给出元素个数,而是由编译器检查赋值用的大括号包的元素个数,由其来决定数组的个数,因此上面的a的类型为long[3]。当多维数组时,如:long a[3][2] = { { 1, 2 }, { 3, 4 }, { 5, 6 } };。因为每个元素又是需要多个元素的数字,就和前面的ABC::d一样。再回想类型修饰符的修饰顺序,是从左到右,但当是重复类型修饰符时,就倒过来从右到左,因此上面就应该是三个long[2],而不是两个long[3],因此这样将错误:long a[3][2] = { { 1, 2, 3 }, { 4, 5, 6 } };。
    还应注意,C++不止提供了上面的“{}”这一种初始化方式,对于字符串,其专门提供如:char a[] = "ABC";。这里a的类型就为char[4],因为字符串"ABC"需要占4个字节的内存空间。除了这两种初始化方式外,C++还提供了一种函数式的初始化函数,下篇介绍。


类型的运用

    char a = -34; unsigned char b = ( unsigned char )a;
    上面的b等于222,将-34按照补码格式写成二进制数11011110,然后将这个二进制数用原码格式解释,得数值222。继续:
    float a = 5.6f; unsigned long b = ( unsigned long )a;
    这回b等于5。为什么?不是应该将5.6按照IEEE的real*4的格式写成二进制数0X40B33333(这里用十六进制表示),然后将这个二进制数用原码格式解释而得数值1085485875吗?因为类型转换是语义上的类型转换,而不是类型变换。
    两个类型是否能够转换,要视编译器是否定义了这两个类型之间的转换规则。如char和unsigned char,之所以前面那样转换是因为编译器把char转unsigned char定义成了那样,同样float转unsigned long被编译器定义成了取整而不是四舍五入。
    为什么要有类型转换?有什么意义?的确,像上面那样的转换,毫无意义,仅仅只是为了满足语法的严密性而已,不过由于C++定义了指针类型的转换,而且定义得非常地好,以至于有非常重要的意义。
    char a = -34; unsigned char b = *( unsigned char* )( &a );
    上面的结果和之前的一样,b为222,不过是通过将char*转成unsigned char*,然后再用unsigned char来解释对应的内存而得到222,而不是按照编译器的规定来转换的,即使结果一样。因此:
    float a = 5.6f; unsigned long b = *( unsigned long* )( &a );
    上面的b为1085485875,也就是之前以为的结果。这里将a的地址所对应的内存用unsigned long定义的规则来解释,得到的结果放在b中,这体现了类型就是如何解释内存中的内容。上面之所以能实现,是因为C++规定所有的指针类型之间的转换,数字的数值没有变化,只有类型变化(但由于类的继承关系也是可能会改变,下篇说明),因此上面才说b的值是用unsigned long来解释a对应的内存的内容所得的结果。因此,前篇在比较oldLayout[ curSln ][0~3]和oldLayout[ i ][0~3]时写了四个“==”以比较了四次char的数字,由于这四个char数字是连续存放的,因此也可如下只比较一次long数字即可,将节约多余的三次比较时间。
    *( long* )&oldLayout[ curSln ] == *( long* )&oldLayout[ i ]
    上面只是一种优化手段而已,对于语义还是没有多大意义,不过由于有了自定义类型,因此:
    struct AB { long a1; long a2; }; struct ABC { char a, b; short c; long d; };
    AB a = { 53213, 32542 }; ABC *pA = ( ABC* )&a;
    char aa = pA->a, bb = pA->b, cc = pA->c; long dd = pA->d;
    pA->a = 1; pA->b = 2; pA->c = 3; pA->d = 4;
    long aa1 = a.a1, aa2 = a.a2;
    上面执行后,aa、bb、cc、dd的值依次为-35、-49、0、32542,而aa1和aa2的值分别为197121和4。相信只要稍微想下就应该能理解为什么没有修改a.a1和a.a2,结果它们的值却变了,因为变量只不过是个映射而已,而前面就是利用指针pA以结构ABC来解释并操作a所对应的内存的内容。
    因此,利用自定义类型和指针转换,就可以实现以什么样的规则来看待某块内存的内容。有什么用?传递给某函数一块内存的引用(利用指针类型或引用类型),此函数还另有一个参数,比如是long类型。当此long类型的参数为1时,表示传过去的是一张定单;为2时,表示传过去的是一张发货单;为3时表示是一张收款单。如果再配上下面说明的枚举类型,则可以编写出语义非常完善的代码。
    应注意由于指针是可以随便转换的,也就有如下的代码,实际并没什么意义,在这只为加深对成员指针的理解:
    long AB::*p = ( long AB::* )( &ABC::b ); a.a1 = a.a2 = 0; a.*p = 0XAB1234CD;
    上面执行后,a.a1为305450240,a.a2为171,转成十六进制分别为0X1234CD00和0X000000AB。


枚举

    上面欲说明1时为定单,2时为发货单而3时为收款单,则可以利用switch或if语句来进行判断,但是语句从代码上将看见类似type == 1或type == 2之类,无法表现出语义。C++专门为此提供了枚举类型。
    枚举类型的格式和前面的自定义类型很像,但意义完全不同,如下:
    enum AB { LEFT, RIGHT = 2, UP = 4, DOWN = 3 }; AB a = LEFT;
    switch( a )
    {
        case LEFT:;  // 做与左相应的事
        case UP:;    // 做与上相应的事
    }
    枚举也要用“{}”括住一些标识符,不过这些标识符即不映射内存地址也不映射偏移,而是映射整数,而为什么是整数,那是因为没有映射浮点数的必要,后面说明。上面的RIGHT就等同于2,注意是等同于2,相当于给2起了个名字,因此可以long b = LEFT; double c = UP; char d = RIGHT;。但注意上面的变量a,它的类型为AB,即枚举类型,其解释规则等同于int,即编译成在16位操作系统上运行时,长度为2个字节,编译成在32位操作系统上运行时为4个字节,但和int是属于不同的类型,而前面的赋值操作之所以能没有问题,可以认为编译器会将枚举类型隐式转换成int类型,进而上面没有错误。但倒过来就不行了,因为变量a的类型是AB,则它的值必须是上面列出的四个标识符中的一个,而a = b;则由于b为long类型,如果为10,那么将无法映射上面的四个标识符中的一个,所以不行。
    注意上面的LEFT没有写“=”,此时将会从其前面的一个标识符的值自增一,由于它是第一个,而C++规定为0,故LEFT的值为0。还应注意上面映射的数字可以重复,即:
    enum AB { LEFT, RIGHT, UP = 5, DOWN, TOP = 5, BOTTOM };
    上面的各标识符依次映射的数值为0、1、5、6、5、6。因此,最开始说的问题就可以如下处理:
    enum OperationType { ORDER = 1, INVOICE, CHECKOUT };
    而那个参数的类型就可以为OperationType,这样所表现的语义就远远地超出原来的代码,可读性高了许多。因此,当将某些人类世界的概念映射成数字时,发现它们的区别不表现在数字上,比如吃饭、睡觉、玩表示一个人的状态,现在为了映射人这个概念为数字,也需要将人的状态这个概念映射成数字,但很明显地没有什么方便的映射规则。这时就强行说1代表吃饭,2代表睡觉,3代表玩,此时就可以通过将1、2、3定义成枚举以表现语义,这也就是为什么枚举只定义为整数,因为没有定义成浮点数的必要性。


联合

    前面说过类型定义符的前面可以接struct、class和union,当接union时就表示是联合型自定义类型(简称联合),它和struct的区别就是后者是串行分布来定义成员变量,而前者是并行分布。如下:
    union AB { long a1, a2, a3; float b1, b2, b3; }; AB a;
    变量a的长度为4个字节,而不是想象的6*4=24个字节,而联合AB中定义的6个变量映射的偏移都为0。因此a.a1 = 10;执行后,a.a1、a.a2、a.a3的值都为10,而a.b1的值为多少,就用IEEE的real*4格式来解释相应内存的内容,该多少是多少。
    也就是说,最开始的利用指针来解释不同内存的内容,现在可以利用联合就完成了,因此上面的代码搬到下面,变为:
    union AB
    {
        struct { long a1; long a2; };
        struct { char a, b; short c; long d; };
    };
    AB a = { 53213, 32542 };
    char aa = a.a, bb = a.b, cc = a.c; long dd = a.d;
    a.a = 1; a.b = 2; a.c = 3; a.d = 4;
    long aa1 = a.a1, aa2 = a.a2;
    结果不变,但代码要简单,只用定义一个自定义类型了,而且没有指针变量的运用,代码的语义变得明显多了。
    注意上面定义联合AB时在其中又定义了两个结构,但都没有赋名字,这是C++的特殊用法。当在类型定义符的中间使用类型定义符时,如果没有给类型定义符定义的类型绑定标识符,则依旧定义那些偏移类型的变量,不过这些变量就变成上层自定义类型的成员变量,因此这时“{}”等同于没有,唯一的意义就是通过前面的struct或class或union来指明变量的分布方式。因此可以如下:
    struct AB
    {
        struct { long a1, a2; };
        char a, b;
        union { float b1; double b2; struct { long b3; float b4; char b5; }; };
        short c;
    };
    上面的自定义类型AB的成员变量就有a1、a2、a、b、b1、b2、b3、b4、b5、c,各自对应的偏移值依次为0、4、8、9、10、10、10、14、18、19,类型AB的总长度为21字节。某类型的长度表示如果用这个类型定义了一个变量,则编译器应该在栈上分配多大的连续空间,C++为此专门提供了一个操作符sizeof,其右侧接数字或类型名,当接数字时,就返回那个数字的类型需要占的内存空间的大小,而接类型名时,就返回那个类型名所标识的类型需要占的内存空间的大小。
    因此long a = sizeof( AB ); AB d; long b = sizeof d;执行后,a和b的值都为40。怎么是40?不应该为21吗?而之前的各成员变量对应的偏移也依次实际为0、4、8、9、16、16、16、20、24、32。为什么?这就是所谓的数据对齐。
    CPU有某些指令,需要处理多个数据,则各数据间的间隔必须是4字节或8字节或16字节(视不同的指令而有不同的间隔),这被称作数据对齐。当各个数据间的间隔不符合要求时,CPU就必须做附加的工作以对齐数据,效率将下降。并且CPU并不直接从内存中读取东西,而要经一个高速缓冲(CPU内建的一个存取速度比内存更快的硬件)缓冲一下,而此缓冲的大小肯定是2的次方,但又比较小,因此自定义类型的大小最好能是2的次方的倍数,以便高效率的利用高速缓冲。
    在自定义类型时,一个成员变量的偏移值一定是它所属的类型的长度的倍数,即上面的a和b的偏移必须是1的倍数,而c的偏移必须是2的倍数,b1的偏移必须是4的倍数。但b2的偏移必须是8的倍数,而b1和b2由于前面的union而导致是并行布局,因此b1的偏移必须和b2及b3的相同,因此上面的b1、b2、b3的偏移变成了8的倍数16,而不是想象的10。
    而一个自定义类型的长度必须是其成员变量中长度最长的那个成员变量的长度的倍数,因此struct { long b3; float b4; char b5; };的长度是4的倍数,也就是12。而上面的无名联合的成员变量中,只有double b2;的长度最长,为8个字节,所以它的长度为16,并进而导致c的偏移为b1的偏移加16,故为32。由于结构AB中的成员变量只有b2的长度最长,为8,故AB的长度必须是8的倍数40。因此在定义结构时应尽量将成员和其长度对应起来,如下:
    struct ABC1 { char a, b; char d; long c; };
    struct ABC2 { char a, b; long c; char d; };
    ABC1的长度为8个字节,而ABC2的长度为12个字节,其中ABC1::c和ABC2::c映射的偏移都为4。
    应注意上面说的规则一般都可以通过编译选项进行一定的改变,不同的编译器将给出不同的修改方式,在此不表。
    本篇说明了如何使用类型定义符“{}”来定义自定义类型,说明了两种自定义类型,实际还有许多自定义类型的内容未说明,将在下篇介绍,即后面介绍的类及类相关的内容都可应用在联合和结构上,因为它们都是自定义类型。

发表于 2004年07月17日 2:32 PM


评论
# 回复:C++从零开始(九)——何谓结构 2004-07-23 12:53 PM ぐ落葉ζ繽紛
现在我发现自己存在一个严重的问题,就是理论上完全理解但是用到实际就存在太多的问题。特别是指针和引用的应用!
为什么*P=&a;要加&,而数组*p=a[10];就可以不加;变量名跟数组名同是映射地址或首地址。这里的&表示的是什么意思?是引用?
也许你会觉得我的问题太简单,很幼稚。但我希望你的抽空为我这种初学者讲解!(可否利用你的休息时间专门写一篇关于指针的实际应用)

 

# 回复:C++从零开始(九)——何谓结构 2004-07-23 2:52 PM lop5712
你是在哪里看到*p = &a;的??如果是下面的定义语句:
int a, *p = &a;
这里的“*”由于是在定义语句中,是指针类型修饰符,表示p的类型是int*。而如果不在定义语句中对p赋值则应该p = &a;而不是*p = &a;

对于*p = a[10];,这里是个表达式语句,即整个语句最后将返回一个数字,则这里的“*”就是操作符而不是指针类型修饰符。
后者只在说明类型时,如定义语句、类型转换、类型定义语句等,前者只在说明数字时,如表达式语句、任何接数字的地方等。
由于这里的“*”是取内容操作符,我在《五》中已经说明,它的操作很简单,它右侧只接指针类型的数字,将这个指针类型的数字换成地址类型的数字然后返回。而变量就是映射的一个地址类型的数字,比如:
int a, *p; // 假设a映射的地址是4000,p映射的地址是4004,注意不管那个变量的类型是什么,它一定要映射一个地址
注意a = 10;是一个表达式语句(因为整个语句都由操作符和数字组成,变量也算数字,因为变量名映射的是一个地址),对于赋值操作符“=”,其两边接数字,左侧接的数字是4000,类型为int类型的地址类型;右侧接的数字是10,类型为int类型,则意思就是将10放到4000所对应的内存中去。
接着看*p = a[10];,“*”取内容操作符右侧接指针类型的数字,对于此,其右侧接的数字是4004,类型是int的指针类型(也就是int*),然后“*”返回数字4004,类型是int的地址类型,接着就符合了“=”的要求,左边是地址类型的数字了,将“=”右边的数字放到4004所标识的内存中。

同样,“&”也是有两个:引用类型修饰符和取地址操作符,分别用于说明类型和说明数字。如何区别就和上面说的一样,比如:
int a, *p = &a, &ra1 = a, &ra2 = *p;
这里的“&”就是取地址操作符而不是类型修饰符,因为这里是给变量p赋初值,“=”右侧接的是数字。而ra1和ra2前面的“&”就是引用类型修饰符,因为它在定义语句中。而ra2后的“*”就由于是在“=”的右边,要求是数字,因此是取内容操作符而不是引用类型修饰符

至于《指针的运用》,我实际是干机械的,过几天就工作了,到时候就没硬件也没环境,所以也就不会写了。打算写到《十三》,如果还有时间,就写《指针的运用》,说明各种类型的指针各自有什么用.

C++从零开始(十)

——何谓类

    前篇说明了结构只不过是定义了内存布局而已,提到类型定义符前还可以书写class,即类型的自定义类型(简称类),它和结构根本没有区别(仅有一点小小的区别,下篇说明),而之所以还要提供一个class,实际是由于C++是从C扩展而成,其中的class是C++自己提出的一个很重要的概念,只是为了与C语言兼容而保留了struct这个关键字。不过通过前面括号中所说的小小区别也足以看出C++的设计者为结构和类定义的不同语义,下篇说明。
    暂时可以先认为类较结构的长足进步就是多了成员函数这个概念(虽然结构也可以有成员函数),在了解成员函数之前,先来看一种语义需求。


操作与资源

    程序主要是由操作和被操作的资源组成,操作的执行者就是CPU,这很正常,但有时候的确存在一些需要,需要表现是某个资源操作了另一个资源(暂时称作操作者),比如游戏中,经常出现的就是要映射怪物攻击了玩家。之所以需要操作者,一般是因为这个操作也需要修改操作者或利用操作者记录的一些信息来完成操作,比如怪物的攻击力来决定玩家被攻击后的状态。这种语义就表现为操作者具有某些功能。为了实现上面的语义,如原来所说进行映射,先映射怪物和玩家分别为结构,如下:
    struct Monster { float Life; float Attack; float Defend; };
    struct Player { float Life; float Attack; float Defend; };
    上面的攻击操作就可以映射为void MonsterAttackPlayer( Monster &mon, Player &pla );。注意这里期望通过函数名来表现操作者,但和前篇说的将过河方案起名为sln一样,属于一种本末倒置,因为这个语义应该由类型来表现,而不是函数名。为此,C++提供了成员函数的概念。


成员函数

    与之前一样,在类型定义符中书写函数的声明语句将定义出成员函数,如下:
    struct ABC { long a; void AB( long ); };
    上面就定义了一个映射元素——第一个变量ABC::a,类型为long ABC::;以及声明了一个映射元素——第二个函数ABC::AB,类型为void ( ABC:: )( long )。类型修饰符ABC::在此修饰了函数ABC::AB,表示其为函数类型的偏移类型,即是一相对值。但由于是函数,意义和变量不同,即其依旧映射的是内存中的地址(代码的地址),但由于是偏移类型,也就是相对的,即是不完整的,因此不能对它应用函数操作符,如:ABC::AB( 10 );。这里将错误,因为ABC::AB是相对的,其相对的东西不是如成员变量那样是个内存地址,而是一个结构指针类型的参数,参数名一定为this,这是强行定义的,后面说明。
    注意由于其名字为ABC::AB,而上面仅仅是对其进行了声明,要定义它,仍和之前的函数定义一样,如下:
    void ABC::AB( long d ) { this->a = d; }
    应注意上面函数的名字为ABC::AB,但和前篇说的成员变量一样,不能直接书写long ABC::a;,也就不能直接如上书写函数的定义语句(至少函数名为ABC::AB就不符合标识符规则),而必须要通过类型定义符“{}”先定义自定义类型,然后再书写,这会在后面说明声明时详细阐述。
    注意上面使用了this这个关键字,其类型为ABC*,由编译器自动生成,即上面的函数定义实际等同于void ABC::AB( ABC *this, long d ) { this->a = d; }。而之所以要省略this参数的声明而由编译器来代劳是为了在代码上体现出前面提到的语义(即成员的意义),这也是为什么称ABC::AB是函数类型的偏移类型,它是相对于这个this参数而言的,如何相对,如下:
    ABC a, b, c; a.ABC::AB( 10 ); b.ABC::AB( 12 ); c.AB( 14 );
    上面利用成员操作符调用ABC::AB,注意执行后,a.a、b.a和c.a的值分别为10、12和14,即三次调用ABC::AB,但通过成员操作符而导致三次的this参数的值并不相同,并进而得以修改三个ABC变量的成员变量a。注意上面书写a.ABC::AB( 10 );,和成员变量一样,由于左右类型必须对应,因此也可a.AB( 10 );。还应注意上面在定义ABC::AB时,在函数体内书写this->a = d;,同上,由于类型必须对应的关系,即this必须是相应自定义类型的指针,所以也可省略this->的书写,进而有void ABC::AB( long d ) { a = d; }。
    注意这里成员操作符的作用,其不再如成员变量时返回相应成员变量类型的数字,而是返回一函数类型的数字,但不同的就是这个函数类型是无法用语法表示出来的,即C++并没有提供任何关键字或类型修饰符来表现这个返回的类型(VC内部提供了__thiscall这个类型修饰符进行表示,不过写代码时依旧不能使用,只是编译器内部使用)。也就是说,当成员操作符右侧接的是函数类型的偏移类型的数字时,返回一个函数类型的数字(表示其可被施以函数操作符),函数的类型为偏移类型中给出的类型,但这个类型无法表现。即a.AB将返回一个数字,这个数字是函数类型,在VC内部其类型为void ( __thiscall ABC:: )( long ),但这个类型在C++中是非法的。
    C++并没有提供类似__thiscall这样的关键字以修饰类型,因为这个类型是要求编译器遇到函数操作符和成员操作符时,如a.AB( 10 );,要将成员操作符左侧的地址作为函数调用的第一个参数传进去,然后再传函数操作符中给出的其余各参数。即这个类型是针对同时出现函数操作符和成员操作符这一特定情况,给编译器提供一些信息以生成正确的代码,而不用于修饰数字(修饰数字就要求能应付所有情况)。即类型是用于修饰数字的,而这个类型不能修饰数字,因此C++并未提供类似__thiscall的关键字。
    和之前一样,由于ABC::AB映射的是一个地址,而不是一个偏移值,因此可以ABC::AB;但不能ABC::a;,因为后者是偏移值。根据类型匹配,很容易就知道也可有:
    void ( ABC::*p )( long ) = ABC::AB;或void ( ABC::*p )( long ) = &ABC::AB;
    进而就有:void ( ABC::**pP )( long ) = &p; ( c.**pP )( 10.0f );。之所以加括号是因为函数操作符的优先级较“*”高。再回想前篇说过指针类型的转换只是类型变化,数值不变(下篇说明数值变化的情况),因此可以有如下代码,这段代码毫无意义,在此仅为加深对成员函数的理解。
struct ABC { long a; void AB( long ); };
void ABC::AB( long d )
{
    this->a = d;
}
struct AB
{
    short a, b;
    void ABCD( short tem1, short tem2 );
    void ABC( long tem );
};
void AB::ABCD( short tem1, short tem2 )
{
    a = tem1; b = tem2;
}
void AB::ABC( long tem )
{
    a = short( tem / 10 );
    b = short( tem - tem / 10 );
}
void main()
{
    ABC a, b, c; AB d;
    ( c.*( void ( ABC::* )( long ) )&AB::ABC )( 43 );
    ( b.*( void ( ABC::* )( long ) )&AB::ABCD )( 0XABCDEF12 );
    ( d.*( void ( AB::* )( short, short ) )ABC::AB )( 0XABCD, 0XEF12 );
}
    上面执行后,c.a为0X00270004,b.a为0X0000EF12,d.a为0XABCD,d.b为0XFFFF。对于c的函数调用,由于AB::ABC映射的地址被直接转换类型进而直接被使用,因此程序将跳到AB::ABC处的a = short( tem / 10 );开始执行,而参数tem映射的是传递参数的内存的首地址,并进而用long类型解释而得到tem为43,然后执行。注意b = short( tem - tem / 10 );实际是this->b = short( tem - tem / 10 );,而this的值为c对应的地址,但在这里被认为是AB*类型(因为在函数AB::ABC的函数体内),所以才能this->b正常(ABC结构中没有b这个成员变量),而b的偏移为2,所以上句执行完后将结果39存放到c的地址加2所对应的内存,并且以short类型解释而得到的16位的二进制数存放。对于a = short( tem / 10 );也做同样事情,故最后得c.a的值为0X0027004(十进制39转成十六进制为0X27)。
    同样,对于b的调用,程序将跳到AB::ABCD,但生成的b的调用代码时,将参数0XABCDEF12按照参数类型为long的格式记录在传递参数的内存中,然后跳到AB::ABCD。但编译AB::ABCD时又按照参数为两个short类型来映射参数tem1和tem2对应的地址,因此容易想到tem1的值将为0XEF12,tem2的值为0XABCD,但实际并非如此。参数如何传递由之前说的函数调用规则决定,函数调用的具体实现细节在《C++从零开始(十五)》中说明,这里只需了解到成员函数映射的仍然是地址,而它的类型决定了如何使用它,后面说明。


声明的含义

    前面已经解释过声明是什么意思,在此由于成员函数的定义规则这种新的定义语法,必须重新考虑声明的意思。注意一点,前面将一个函数的定义放到main函数定义的前面就可以不用再声明那个函数了;同样如果定义了某个变量,就不用再声明那个变量了。这也就是说定义语句具有声明的功能,但上面成员函数的定义语句却不具有声明的功能,下面来了解声明的真正意思。
    声明是要求编译器产生映射元素的语句。所谓的映射元素,就是前面介绍过的变量及函数,都只有3栏(或3个字段):类型栏、名字栏和地址栏(成员变量类型的这一栏就放偏移值)。即编译器每当看到声明语句,就生成一个映射元素,并且将对应的地址栏空着,然后留下一些信息以告诉连接器——此.obj文件(编译器编译源文件后生成的文件,对于VC是.obj文件)需要一些符号,将这些符号找到后再修改并完善此.obj文件,最后连接。
    回想之前说过的符号的意思,它就是一字符串,用于编译器和连接器之间的通信。注意符号没有类型,因为连接器只是负责查找符号并完善(因为有些映射元素的地址栏还是空的)中间文件(对于VC就是.obj文件),不进行语法分析,也就没有什么类型。
    定义是要求编译器填充前面声明没有书写的地址栏。也就是说某变量对应的地址,只有在其定义时才知道。因此实际的在栈上分配内存等工作都是由变量的定义完成的,所以才有声明的变量并不分配内存。但应注意一个重点,定义是生成映射元素需要的地址,因此定义也就说明了它生成的是哪个映射元素的地址,而如果此时编译器的映射表(即之前说的编译器内部用于记录映射元素的变量表、函数表等)中没有那个映射元素,即还没有相应元素的声明出现过,那么编译器将报错。
    但前面只写一个变量或函数定义语句,它照样正常并没有报错啊?实际很简单,只需要将声明和定义看成是一种语句,只不过是向编译器提供的信息不同罢了。如:void ABC( float );和void ABC( float ){},编译器对它们相同看待。前者给出了函数的类型及类型名,因此编译器就只填写映射元素中的名字和类型两栏。由于其后只接了个“;”,没有给出此函数映射的代码,因此编译器无法填写地址栏。而后者,给出了函数名、所属类型以及映射的代码(空的复合语句),因此编译器得到了所有要填写的信息进而将三栏的信息都填上了,结果就表现出定义语句完成了声明的功能。
    对于变量,如long a;。同上,这里给出了类型和名字,因此编译器填写了类型和名字两栏。但变量对应的是栈上的某块内存的首地址,这个首地址无法从代码上表现出来(前面函数就通过在函数声明的后面写复合语句来表现相应函数对应的代码所在的地址),而必须由编译器内部通过计算获得,因此才硬性规定上面那样的书写算作变量的定义,而要变量的声明就需要在前面加extern。即上面那样将导致编译器进行内部计算进而得出相应的地址而填写了映射元素的所有信息。
    上面难免显得故弄玄虚,那都是因为自定义类型的出现。考虑成员变量的定义,如:
    struct ABC { long a, b; double c; };
    上面给出了类型——long ABC::、long ABC::和double ABC::;给出了名字——ABC::a、ABC::b和ABC::c;给出了地址(即偏移)——0、4和8,因为是结构型自定义类型,故由此语句就可以得出各成员变量的偏移。上面得出三个信息,即可以填写映射元素的所有信息,所以上面可以算作定义语句。对于成员函数,如下:
    struct ABC { void AB( float ); };
    上面给出了类型——void ( ABC:: )( float );给出了名字——ABC::AB。不过由于没有给出地址,因此无法填写映射元素的所有信息,故上面是成员函数ABC::AB的声明。按照前面说法,只要给出地址就可以了,而无需去管它是定义还是声明,因此也就可以这样:
    struct ABC { void AB( float ){} };
    上面给出类型和名字的同时,给出了地址,因此将可以完全填写映射元素的所有信息,是定义。上面的用法有其特殊性,后面说明。注意,如果这时再在后面写ABC::AB的定义语句,即如下,将错误:
    struct ABC { void AB( float ){} };
    void ABC::AB( float ) {}
    上面将报错,原因很简单,因为后者只是定义,它只提供了ABC::AB对应的地址这一个信息,但映射元素中的地址栏已经填写了,故编译器将说重复定义。再单独看成员函数的定义,它给出了类型void ( ABC:: )( float ),给出了名字ABC::AB,也给出了地址,但为什么说它只给出了地址这一信息?首先,名字ABC::AB是不符合标识符规则的,而类型修饰符ABC::必须通过类型定义符“{}”才能够加上去,这在前面已多次说明。因此上面给出的信息是:给出了一个地址,这个地址是类型为void ( ABC:: )( float ),名字为ABC::AB的映射元素的地址。结果编译器就查找这样的映射元素,如果有,则填写相应的地址栏,否则报错,即只写一个void ABC::AB( float ){}是错误的,在其前面必须先通过类型定义符“{}”声明相应的映射元素。这也就是前面说的定义仅仅填充地址栏,并不生成映射元素。


声明的作用

    定义的作用很明显了,有意义的映射(名字对地址)就是它来做,但声明有什么用?它只是生成类型对名字,为什么非得要类型对名字?它只是告诉编译器不要发出错误说变量或函数未定义?任何东西都有其存在的意义,先看下面这段代码。
    extern"C" long ABC( long a, long b );
    void main(){ long c = ABC( 10, 20 ); }
    假设上面代码在a.cpp中书写,编译生成文件a.obj,没有问题。但按照之前的说明,连接时将错误,因为找不到符号_ABC。因为名字_ABC对应的地址栏还空着。接着在VC中为a.cpp所在工程添加一个新的源文件b.cpp,如下书写代码。
    extern"C" float ABC( float a ){ return a; }
    编译并连接,现在没任何问题了,但相信你已经看出问题了——函数ABC的声明和定义的类型不匹配,却连接成功了?
    注意上面关于连接的说明,连接时没有类型,只管符号。上面用extern"C"使得a.obj要求_ABC的符号,而b.cpp提供_ABC的符号,剩余的就只是连接器将b.obj中_ABC对应的地址放到a.obj以完善a.obj,最后连接a.obj和b.obj。
    那么上面什么结果,由于需要考虑函数的实现细节,这在《C++从零开始(十五)》中再说明,而这里只要注意到一件事:编译器即使没有地址也依旧可以生成代码以实现函数操作符的功能——函数调用。之所以能这样就是因为声明时一定必须同时给出类型和名字,因为类型告诉编译器,当某个操作符涉及到某个映射元素时,如何生成代码来实现这个操作符的功能。也就是说,两个char类型的数字乘法和两个long类型的数字乘法编译生成的代码不同;对long ABC( long );的函数调用代码和void ABC( float )的不同。即,操作符作用的数字类型的不同将导致编译器生成的代码不同。
    那么上面为什么要将ABC的定义放到b.cpp中?因为各源文件之间的编译是独立的,如果放在a.cpp,编译器就会发现已经有这么个映射元素,但类型却不匹配,将报错。而放到b.cpp中,使得由连接器来完善a.obj,到时将没有类型的存在,只管符号。下面继续。
    struct ABC { long a, b; void AB( long tem1, long tem2 ); void ABCD(); };
    void main(){ ABC a; a.AB( 10, 20 ); }
    由上面的说法,这里虽然没有给出ABC::AB的定义,但仍能编译成功,没有任何问题。仍假设上面代码在a.cpp中,然后添加b.cpp,在其中书写下面的代码。
    struct ABC { float b, a; void AB( long tem1, long tem2 ); long ABCD( float ); };
    void ABC::AB( long tem1, long tem2 ){ a = tem1; b = tem2; }
    这里定义了函数ABC::AB,注意如之前所说,由于这里的函数定义仅仅只是定义,所以必须在其前面书写类型定义符“{}”以让编译器生成映射元素。但更应该注意这里将成员变量的位置换了,这样b就映射的是0而a映射的是4了,并且还将a、b的类型换成了float,更和a.cpp中的定义大相径庭。但没有任何问题,编译连接成功,a.AB( 10,20 );执行后a.a为0X41A00000,a.b为0X41200000,而*( float* )&a.a为20,*( flaot* )&a.b为10。
    为什么?因为编译器只在当前编译的那个源文件中遵循类型匹配,而编译另一个源文件时,编译其他源文件所生成的映射元素全部无效。因此声明将类型和名字绑定起来,而名字就代表了其所关联的类型的地址类型的数字,而后继代码中所有操作这个数字的操作符的编译生成都将受这个数字的类型的影响。即声明是告诉编译器如何生成代码的,其不仅仅只是个语法上说明变量或函数的语句,它是不可或缺的。
    还应注意上面两个文件中的ABC::ABCD成员函数的声明不同,而且整个工程中(即a.cpp和b.cpp中)都没有ABC::ABCD的定义,却仍能编译连接成功,因为声明并不是告诉编译器已经有什么东西了,而是如何生成代码。


头文件

    上面已经说明,如果有个自定义类型ABC,在a.cpp、b.cpp和c.cpp中都要使用它,则必须在a.cpp、b.cpp和c.cpp中,各自使用ABC之前用类型定义符“{}”重新定义一遍这个自定义类型。如果不小心如上面那样在a.cpp和b.cpp中写的定义不一样,则将产生很难查找的错误。为此,C++提供了一个预编译指令来帮忙。
    预编译指令就是在编译之前执行的指令,它由预编译器来解释执行。预编译器是另一个程序,一般情况,编译器厂商都将其合并进了C++编译器而只提供一个程序。在此说明预编译指令中的包含指令——#include,其格式为#include <文件名>。应注意预编译指令都必须单独占一行,而<文件名>就是一个用双引号或尖括号括起来的文件名,如:#include "abc.c"、#include "C:/abc.dsw"或#include </abc.exe>。它的作用很简单,就是将引号或尖括号中书写的文件名对应的文件以ANSI格式或MBCS格式(关于这两个格式可参考《C++从零开始(五)》)解释,并将内容原封不动地替换到#include所在的位置,比如下面是文件abc的内容。
    struct ABC { long a, b; void AB( long tem1, long tem2 ); };
    则前面的a.cpp可改为:
    #include "abc"
    void main() { ABC a; a.AB( 10, 20 ); }
    而b.cpp可改为:
    #include "abc"
    void ABC::AB( long tem1, long tem2 ){ a = tem1; b = tem2; }
    这时,就不会出现类似上面那样在b.cpp中将自定义类型ABC的定义写错了而导致错误的结果(a.a为0X41A00000,a.b为0X41200000),进而a.AB( 10, 20 );执行后,a.a为10,a.b为20。
    注意这里使用的是双引号来括住文件名的,它表示当括住的只是一个文件名或相对路径而没有给出全路径时,如上面的abc,则先搜索此时被编译的源文件所在的目录,然后搜索编译器自定的包含目录(如:C:/Program Files/Microsoft Visual Studio .NET 2003/Vc7/include等),里面一般都放着编译器自带的SDK的头文件(关于SDK,将在《C++从零开始(十八)》中说明),如果仍没有找到,则报错(注意,一般编译器都提供了一些选项以使得除了上述的目录外,还可以再搜索指定的目录,不同的编译器设定方式不同,在此不表)。
    如果是用尖括号括起来,则表示先搜索编译器自定的包含目录,再源文件所在目录。为什么要不同?只是为了防止自己起的文件名正好和编译器的包含目录下的文件重名而发生冲突,因为一旦找到文件,将不再搜索后继目录。
    所以,一般的C++代码中,如果要用到某个自定义类型,都将那个自定义类型的定义分别装在两个文件中,对于上面结构ABC,则应该生成两个文件,分别为ABC.h和ABC.cpp,其中的ABC.h被称作头文件,而ABC.cpp则称作源文件。头文件里放的是声明,而源文件中放的是定义,则ABC.h的内容就和前面的abc一样,而ABC.cpp的内容就和b.cpp一样。然后每当工程中某个源文件里要使用结构ABC时,就在那个源文件的开头包含ABC.h,这样就相当于将结构ABC的所有相关声明都带进了那个文件的编译,比如前面的a.cpp就通过在开头包含abc以声明了结构ABC。
    为什么还要生成一个ABC.cpp?如果将ABC::AB的定义语句也放到ABC.h中,则a.cpp要使用ABC,c.cpp也要使用ABC,所以a.cpp包含ABC.h,由于里面的ABC::AB的定义,生成一个符号
?AB@ABC@@QAEXJJ@Z(对于VC);同样c.cpp的编译也要生成这个符号,然后连接时,由于出现两个相同的符号,连接器无法确定使用哪一个,报错。因此专门定义一个ABC.cpp,将函数ABC::AB的定义放到ABC.obj中,这样将只有一个符号生成,连接时也就不再报错。
    注意上面的struct ABC { void AB( float ){} };。如果将这个放在ABC.h中,由于在类型定义符中就已经将函数ABC::AB的定义给出,则将会同上,出现两个相同的符号,然后连接失败。为了避开这个问题,C++规定如上在类型定义符中直接书写函数定义而定义的函数是inline函数,出于篇幅,下篇介绍。


成员的意义

    上面从语法的角度说明了成员函数的意思,如果很昏,不要紧,实现不能理解并不代表就不能运用,而程序员重要的是对语言的运用能力而不是语言的了解程度(虽然后者也很重要)。下面说明成员的语义。
    本文一开头提出了一种语义——某种资源具有的功能,而C++的自定义类型再加上成员操作符“.”和“->”的运用,从代码上很容易的就表现出一种语义——从属关系。如:a.b、c.d分别表示a的b和c的d。某种资源具有的功能要映射到C++中,就应该将这种资源映射成一自定义类型,而它所具有的功能就映射成此自定义类型的成员函数,如最开始提到的怪物和玩家,则如下:
    struct Player { float Life; float Attack; float Defend; };
    struct Monster { float Life; float Attack; float Defend; void AttackPlayer( Player &pla ); };
    Player player; Monster a; a.AttackPlayer( player );
    上面的语义就非常明显,代码执行的操作是怪物a攻击玩家player,而player.Life就代表玩家player的生命值。假设如下书写Monster::AttackPlayer的定义:
    void Monster::AttackPlayer( Player &pla )
    {
        pla.Life -= Attack - pla.Defend;
    }
    上面的语义非常明显:某怪物攻击玩家的方法就是将被攻击的玩家的生命值减去自己的攻击力减被攻击的玩家的防御力的值。语义非常清晰,代码的可读性好。而如原来的写法:
    void MonsterAttackPlayer( Monster &mon, Player &pla )
    {
        pla.Life -= mon.Attack - pla.Defend;
    }
    则代码表现的语义:怪物攻击玩家是个操作,此操作需要操作两个资源,分别为怪物类型和玩家类型。这个语义就没表现出我们本来打算表现的想法,而是怪物的攻击功能的另一种解释(关于这点,将在《C++从零开始(十二)》中详细阐述),其更适合表现收银工作。比如收银台实现的是收钱的工作,客户在柜台买了东西,由营业员开出单据,然后客户将单据拿到收银台交钱。这里收银台的工作就需要操作两个资源——钱和单据,这时就应该将收钱这个工作映射为如上的函数而不是成员函数,因为在这个算法中,收银台没有被映射成自定义类型的必要性,即我们对收银的工作由谁做不关心,只关心它如何做。
    至此介绍完了自定义类型的一半内容,通过这些内容已经可以编写出能体现较复杂语义的代码了,下篇将说明自定义类型的后半内容,它们的提出根本可以认为就是语义的需要,所以下篇将从剩余内容是如何体现语义的来说明,不过依旧要说明各自是如何实现的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
基础概念) C++从零开始(二)——何谓表达式(说明各操作符的用处,但不是全部,剩余的会 在其它文章提到) C++从零开始(三)——何谓变量(说明电脑的工作方式,阐述内存、地址等极其重 要的概念) C++从零开始(四)——赋值操作符(《C++从零开始(二)》的延续,并为指针的 解释打一点基础) C++从零开始(五)——何谓指针(阐述指针、数组等重要的概念) C++从零开始(六)——何谓语句(讲解C++提供的各个语句,说明各自存在的理 由) C++从零开始(七)——何谓函数(说明函数及其存在的理由) C++从零开始(八)——C++样例一(给出一两个简单算法,一步步说明如何从算法 编写出C++代码) C++从零开始(九)——何谓结构(简要说明结构、枚举等及其存在的理由) C++从零开始(十)——何谓类(说明类及其存在的理由,以及声明、定义、头文件 等概念) C++从零开始(十一)——类的相关知识(说明派生、继承、名字空间、操作符重载 等) C++从零开始(十二)——何谓面向对象编程思想(阐述何谓编程思想,重点讲述面 向对象编程思想) C++从零开始(十三)——C++样例二(说明如何设计基于面向对象编程思想的C+ +程序) C++从零开始(十四)——何谓模板(说明模板技术及其存在的理由) C++从零开始(十五)——何谓异常(说明异常技术及其存在的理由) C++从零开始(十六)——何谓预编译指令(说明预编译指令的概念及几个常用指令 的应用) C++从零开始(十七)——C++的一些生僻关键字(explicit、mutable、volatile等的说 明) C++从零开始(十八)——何谓SDK(说明为什么没有放音乐的指令却依然可以编出 放音乐的程序) C++从零开始(十九)——何谓C运行时期库(说明C运行时期库这一大多数问题的元 凶) C++从零开始(二十)——关于VC的一点点基础知识(说明VC的几个基本概念和一 些常用设置) C++从零开始(二十一)——C++样例三(使用VC编写一个通过DLL实现多态性的简 单程序)

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值