代码大全_cc11-15


第十一章 基本数据类型 154 第十一章第十一章第十一章第十一章 基本数据类型基本数据类型基本数据类型基本数据类型 目录目录目录目录 11.1 常数 11.2 整型数 11.3 浮点数 11.4 字符和字符串 11.5 逻辑变量 11.6 枚举类型 11.7 命名常量 11.8 数组 11.9 指针 11.10 小结 相关章节相关章节相关章节相关章节 生成数据: 见第8章 数据命名: 见第9章 使用变量时通常要考虑的问题: 见第10章 格式化数据说明: 见18.5节 说明数据: 见19.5节 基本数据类型是其它各种数据类型的基本构成部分。本章叙述了使用整型数、浮点数、 字符数据、逻辑变量、枚举类型、常量、数组和指针等的一些窍门。数据结构,即含有一种以上简 单类型的数据类型组合,将在下章讨论。 本章同时也涉及了与基本数据类型有关的一些问题的解决方法。如果你已经有了这方面 的知识,可以直接跳到本章末尾的检查表中,看一下需要避免的问题,然后直接进行下一章。 11.1 11.1 11.1 11.1 常常常常 数数数数 以下是一些可以减少数据使用错误的措施: 避免避免避免避免""""奇异数奇异数奇异数奇异数"(magic numbers)"(magic numbers)"(magic numbers)"(magic numbers)。“奇异数”指的是出现在程序中间的不加解释的常数。如 100或47524。如果你所用的语言支持命名常量的话,那就用命名常量来代替它。如果无法使用 命名常量的话,应考虑使用全局变量。 避免”奇异数”可以带来三个好处: • 可以更可靠地进行修改。如果使用命名常量的话,就不会漏掉要修改的几个“100”中 的 一个,或者错误地修改一个本不该改动的“100”。 • 可以使修改更容易。假设要把人口的最大值从 100 变为 200,如果你用的是奇异数的话, 第十一章 基本数据类型 155 你将不得不找出所有的“100”,并把它们改为“200”。如果在程序中你使用了100+1 或100-l 的话,那同时你还要找出所有的101或99并把它们改为201或199。但若你使用 的是命名常量的话,则只需在定义命名常量的地方把100改为200就可以了。 • 可以增强代码可读性。确实,对语句 for i=1 to 100 你可以猜测 100指的是入口的最大数,但是对语句 for i=1 to MaxEntries 你可以肯定地知道 to 后面的是入口最大数,而无须猜测,即使你可以绝对保证不改 变某个数,那么使用命名常量来代替它也可以提高可读性。 在需要时可以使用常数“在需要时可以使用常数“在需要时可以使用常数“在需要时可以使用常数“0000”或“”或“”或“”或“1111””””。"0"或"1"往往被用来增加或减少循环变量的值,也 用于数组的第一个元素下标或循环变量的初始值。如: for i=0 to CONSTANT 其中的"0"和 Total=Total+1 中的"1"都是允许的。在程序中是允许"0"或"1"作为常数出现的,但其它数值则不允许以 这种方式出现。 采取预防被采取预防被采取预防被采取预防被"0""0""0""0"除的措除的措除的措除的措施施施施。每当你在程序中使用了除号(大多数语言中都用"/"表示)时, 都要考虑除数是否有可能是零。如果存在这种可能性的话,应加入防止被“0”除的代码。 明显进行类型转换明显进行类型转换明显进行类型转换明显进行类型转换。当程序中有不同类型间的转换时,要确保这种转换是明显的。在 Pascal 中,你可以用: x := a + float(i) 在 CCCC 中,可以用: x = a +(float)i 这样作也可以保证类型转换确实是按照你的意愿进行的。不同编译程序的类型转换是不 同的,如果不这样作的话就有出错的危险。 避免混合类型比避免混合类型比避免混合类型比避免混合类型比较较较较。如果 x 是浮点数而 i 是一个整型数,那么以下检验: if (i=x) then…… 几乎可以认定是不会起作用的。在编译程序确定出比较要用的类型,把一种类型转换为另 一种、进行一连串的访问并最终确定答案之后,你的程序要是仍在运行的话,那真可说是“瞎猫 遇上死耗子了”。应该通过人工转换来使编译程序可以对同一类型的数进行转换,以便你可以 确切知道正在被比较的是什么。 注意编译程序的警告注意编译程序的警告注意编译程序的警告注意编译程序的警告。许多先进的编译程序都会对在同一表达中使用不同数值类型进行 警告。应注意这些警告!几乎每一个程序员都有过帮助别人去寻找某个麻烦的错误,而最终却 往往发现编译程序早已对此提出过警告的经历。优秀的程序员总是力争消除所有的编译程序 警告信息。让编译程序去查错毕竟比自己干容易得多。 11.2 11.2 11.2 11.2 整型数整型数整型数整型数 以下是一些在使用整型数时应该牢记的准则: 第十一章 基本数据类型 156 检查整型相除检查整型相除检查整型相除检查整型相除。当你使用的是整型数时,7/10并不等于0.7,它等于0,这也同样适于中间 结果。在客观世界中10*(7/10)=7,而在整型算法中10*(7/10)却等于0,因为(7/10)等于0,解 决这个问题最简单的办法是调整计算顺序,如上例的表达式可以改写为:(10*7)/10,使得除 法运算在最后进行。 检查整型是否溢出检查整型是否溢出检查整型是否溢出检查整型是否溢出。在进行整型加法或乘法运算时,应明确可能的最大整型数。通常不带 符号的最大整型数是65535,或 说 是 216-l。当两个整型数相加的结果超过可以的最大整型数时 就会出现问题。比如,250*300,正确的答案是75000。但由于整型溢出,你得到的答案很可能 是9464,(75000-65536=9464)。下面是一些常见类型的整型数的范围: 整型数类型 范围 有符号8位 -128到127 无符号8位 0到255 有符号16位 -32768到32767 无符号16位 0到65535 有符号32位 -2,147,483,648到2,147,483,647 无符号32位 0到4,294,967,295 防止整型溢出的最简单办法是先笔算一下表达式中每一项的值是否溢出。例如,在整型表 达式 M=J*K 中,J 的最大可能值是 200,而 K 的最大值是 25,从而 M 的最大值为 5000,小于 65535,因而这一运算是可行的,但若 K 的最大值为 2000 的话,那么此时 M 的最大值便是 200 000,大于65535,因而运算是不可行的。这时,你将不得不采用长整型或者浮点数来容纳 M 的 最大值。 同时还要考虑到将来的程序扩展,如果 M 的值永远不超过 5000 的话是最好的,但如果 M 的值在几年之内都将是逐步增长的,那应把这点考虑在内。 检查中间结果是否溢出检查中间结果是否溢出检查中间结果是否溢出检查中间结果是否溢出。公式的最后结果并不是你要考虑的唯一的数。比如用 Pascal 写 出下面的一段代码: var TermA: integer; TermB: integer; Product: integer; begin TermA :=1000; TermB :=1000; Product :=(TermA*TremB)div 1000; writeln(‘(‘,TermA,’*’,TermB,’)div 1000=‘,Product); 如果你认为 Product 的值与(1000*1000)/1000 相同,你可能会认为它的值是 1000,但是 在1000*1000 的结果最终被 1000 除之前,必须计算出 1000*1000 的值,而此时的结果为 1,000, 000,显然已经溢出。你能猜到最后的运行结果是什么吗?下面是结果: (1000*1000)div 1000=16 第十一章 基本数据类型 157 如果你所用的机器的整型上限是32767,那么1,000,000这一中间结果显然是太大了,因而 其实际结果为 16,960,它再被1000 除,最后结果就是 16 了。 可以用处理整型溢出相同的方法来处理中间结果整型溢出,即使用长整型或者将其转换 为浮点类型。 11.3 11.3 11.3 11.3 浮点数浮点数浮点数浮点数 使用浮点数时要考虑的主要问题是许多十进制的分数不能用浮点数精确地表示出来,像 1/3或1/7这样的无限小数,其浮点数形式通常只有7到15位有效数字。在许多的 Pascal 版本中, 一个占有4个字节的1/3的浮点数表示形式为0.33333334267440796,它 精确到7位。在 一 般情况 下,它是足够精确的。但是,在某些情况下,它的不精确性也足以令人迷惑。 以下是使用浮点数时需要特殊考虑的一些问题: 不要在数量级相差太大的数之间进行加减运算。不要在数量级相差太大的数之间进行加减运算。不要在数量级相差太大的数之间进行加减运算。不要在数量级相差太大的数之间进行加减运算。假设变量都是 4 个字节的浮点变量,那么 1,000,000.00+0.01 的结果很可能仍然是 1,000,000.00 而不是 1,000,000.01,因为这里的浮 点变量只有 4 个字节,因而在结果中 0.01 事实上是无效数字。同样,5,000,000.02-5,000,000. 01的结果也很可能是0.0而不是0.1。 怎么办呢?如果你不得不对像上例那样相差巨大的浮点数进行加减运算的话,可以先检查 一下所要运算的数,然后从最小的数开始运算。同样,如果你需要对无穷数列进行求和运算, 应从最小一项开始进行,事实上是从未尾向开头进行运算,这并不一定能消除上述问题,但可 以使由此带来的危害最小。许多算法书中都有关于这方面的论述。 避免相等比较避免相等比较避免相等比较避免相等比较。应该相等的浮点数事实上往往是不相等的。主要问题是:算法不同但结果 应该相同的浮点数运算的结果事实上是不同的。例如,把0.l 累加10次的结果很少是1.0。下面 的例子便表示出了两个应该相等的变量。Sum 和 Nominal,事实上是不相等的: var Norminal: single; ——变量 Nominal 是4字节实数 Sum: single; i: integer; begin Norminal:=1.0; Sum:=0; for i:=1 to 10 do Sum:=Sum+0.1; ——Sum 进行10*0.1计算,结果为1.0 if(Nominal=Sum) then ——这是错误的比较 writeln(‘Numbers are the same.’) else writeln(‘ Numbers are different.’) end; 正如你所预料的那样,这个程序的输出结果是: 第十一章 基本数据类型 158 Numbers are different. 因此,最好用另外一种方法来代替相等比较。一种方法是确定一个可以接受的精度范围, 然后用逻辑函数来确定两个数是否接近。比如,你可以编写一个Equal()函数,当两个值足够 接近时Equal( )返回的值为True,否则其返回的值为False,在Pascal 中,函数是这样的: const AcceptableDelta = 0.00001; function Equals(Terml:single; Term2:single): boolean ; begin if(abs(Terml-Term2)q->r->S.data 之类东西的表达式呢?下面是一个尤其难读的 C 语 言代码段: NewMiddleNode InsertNode StartNode CrntNode FollowingNode 第十一章 基本数据类型 171 for(Rateldx = 0; RateIdx < Num; RateIdx++) { NetRate[RateIdx]= BaseRate[RateIdx]*Rates>Discounts->Factors->Net; } 对像上例中这样复杂的指针表达式,恐怕只能“破译”而没法读。如果程序中含有复杂的指 针表达式的话,可以通过使用清楚命名的变量来使其意图更清楚些。下面是对上面程序改进后 的程序: QuantityDiscount = Rates->Discount->Factors->Net; for ( i=0; i < Num; i++) { NetRate[i] = BaseRate[i] * QuantityDiscount; } 通过上述简化,不仅改善了程序的可读性,同时可能也提高了程序的性能,因为循环内的 指针操作也被简化了。 编写跟踪指针存储单元的子程序编写跟踪指针存储单元的子程序编写跟踪指针存储单元的子程序编写跟踪指针存储单元的子程序。通常,对程序危害最大的错误便是使用 free()或 dispose ()去再释放已经被释放的指针。不幸的是,几乎没有什么语言能查找这类错误,不过,如果语言 不支持我们的话,我们可以自己支持自己。保留一张含有全部已经分配的指针表,将释放一个 指针之前检查一下指针是否在这个表上。例如,在 C 中,可以使用如下两个子程序: • safe_calloc( )。这个子程序与 C 中的 calloc()接受同样的参数。它调用 calloc()来分 配指针,把新的指针加到已分配指针表上,然后把新指针返回到调用子程序中。这样作 的另一个好处是你只需要在这一个地方检查由 calloc( )返回的是否是 NULL,从而简化 了程序其余部分的错误处理工作。 • safe_free( )。这个子程序与C中的free( )子程序接受同样的参数。它会检查传给它的 指针是否已在分配指针表中。如果在的话,safe_free( )会调用C中的free( )子程序来 释放这个指针并将它从表上去掉;如果不存在,safe_free( )会打印出诊断信息并中止 程序。 可以很容易对它们作出改动以把它们移到别的语言中。 在calloc()中分配一些冗余字节,并把这些字节作为标记字段。当使用safe_free()释放 指针时,检查一下标记字段看是否有错误。在检查之后,抹掉标记字段,以便当你错误地试图再 次释放同一指针时可以发现这一错误。例如,你要分配100字节。 1.用 Calloc( )分配104字节,4个字节是冗余的 104字节 2.把最后4个赋值成标记字段然后向已分配的内存中返回一个指针; 把指针放到这里 ↓↓↓↓ 标记 第十一章 基本数据类型 172 3.当用 safe_free( ) 释放指针时,检查一下标记字段 指针通过 safe_free ↓↓↓↓ 标记 检查这个标志 4.如果标识字段正常的话,将其赋为 0 或其它你的程序认为是正确的标记值。因为你不 会愿意在内存已经被释放后仍在其中保留一个正确的标记值。出于同样原因,应把这 段存储单元中的数据改成某特定值而不是随机值。 5.最后,释放这个指针 释放104字节 你可把这种方法与前面提过的合理性检查方法联合使用。为了检查指针所指 的是不是合理的存储单元,应检查这一指针是否在已分配指针表上,而不是检查可 能的内存范围。 画图画图画图画图。对指针的代码表示往往是令人困惑的,画图往往更有帮助。例如,图11-2中便表示出 了前面提到过的链表插入问题。可以把它与程序对照一下: 图11-2 指针再链接 按正确顺序释放链表中的指针。按正确顺序释放链表中的指针。按正确顺序释放链表中的指针。按正确顺序释放链表中的指针。 在处理动态分配链表时,一个常见的问题是:在释放了 链表中的第一个指针后,无法到达链表中的下一个指针。为避免这个问题,在释放当前指针之 前,应保证有指针指向链表中的下一个元素。 编写输出指针地址的子程序编写输出指针地址的子程序编写输出指针地址的子程序编写输出指针地址的子程序。如果你所用的是具有分段式结构的 80X86 系列处理器,那么 你所用的语言很可能不支持指针地址的格式化输出。这使得诊断信息的打印变得非常困难。一 个比较容易的办法是编写一个子程序,它把指针作为变元并返回一个字符串“03af::::bf8a”或 类似的东西,当你调用语言支持的 Print、writeln()、Printf()等标准输出子程序时,可以调用 这个输出指针地址的子程序。 在内在中划分出一段空间作为“降落伞”在内在中划分出一段空间作为“降落伞”在内在中划分出一段空间作为“降落伞”在内在中划分出一段空间作为“降落伞”。如果你的程序是用动态内存,那么当突然出现 内存溢出错误时,程序应该能够避免把用户的辛苦和数据扔在 RAM 中。解决这一问题的方案 之一便是制作一个内存”降落伞”。首先确定你的程序为完成存储数据,退出等工作所需要的 内存,然后在程序开始运行时,分配出相应的内存并把它保留起来。当内存溢出时,就可以靠这 个内存“降落伞”来拯救你的辛苦数据了。 第十一章 基本数据类型 173 使用非指针技术使用非指针技术使用非指针技术使用非指针技术。指针非常难以理解,很容易产生错误,而且往往对硬件有依赖性从而影 响可移植性。因此,如果有其它方法能胜任指针工作的话,应该尽量采用这种方法来代替指针。 11.9.3 C11.9.3 C11.9.3 C11.9.3 C 中的指针中的指针中的指针中的指针 以下是针对在 C 中使用指针的一些指导方针: 应使用显式指针类型而不是缺省类型应使用显式指针类型而不是缺省类型应使用显式指针类型而不是缺省类型应使用显式指针类型而不是缺省类型。C 中对任何类型的变量都允许使用 char 或 void 指针,只要有指针在那儿就行,程序是不会关心它指向的是什么的,而如果你使用的是显式类 型指针的话,编译程序则会对不匹配的指针类型等进行警告,但若你不用的话,编译程序是不 会警告的。因此应该尽量使用特定的指式类型。 对这一原则的推论是,当你不得不进行类型转换时,应使用显式强制类型转换。比如,在下 面的程序,一个 NODE_PTR 类型的变量正在被分配这一点就很清楚: NodePtr=(NODE_PTR)calloc(1,sizeof(NODE)); 避免强制类型转换避免强制类型转换避免强制类型转换避免强制类型转换。强制类型转换指的是把一种类型的变量强行送入另一种类型变量 的空间中。强制类型转换会使你的编译程序失去对类型不匹配进行警告的能力,从而使你所采 用的防错性编程技术产生漏洞,一个需要进行许多强制类型转换的程序往往说明其结构设 计有问题。因此如果可能的话,应重新进行设计;如果做不到这一点,那么你应该尽量避免强 制类型转换。 遵守参数传递的星号规则遵守参数传递的星号规则遵守参数传递的星号规则遵守参数传递的星号规则。在 C 中,只有当赋值语句中的变元前面带有“*”号时,才能 从子程序中把这个变元传递回来。许多 C 语言程序员在面临这个问题时都感到很难作出决 定。事实上,这一点是很容易记住的。只要在你给参数赋值时它的前面带有一星号,那么这个 值就是被传回到调用程序的。不管你在说明中堆积了多少个星号,如果你想让某一值传回的 话,那么在赋值语句中至少要有一个星号。例如,在下面的这个程序段中,赋给 parameter 的 值就不是被传回调用程序的.因为在赋值语句中一个星号也没有: void TryToPassBackAValue( int * parameter ) { parameter= SOME_VALUE; } 而在下面的这个程序段中,赋给parameter的值就是被传回来的,因为在给parameter赋值 的语句中,parameter 前面带有一个星号: void TryToPassBackAValue(int * parameter) { *parameter = SOME_VALUE; } 使用使用使用使用 sizeof()sizeof()sizeof()sizeof()来确定内存存储单元中变量的规模。来确定内存存储单元中变量的规模。来确定内存存储单元中变量的规模。来确定内存存储单元中变量的规模。使用 sizeof( )来查找变量规模要比在 手册中查找变量规模容易,而且 sizeof()可以处理自己建立的结构,而这种结构在手册中是没 有的,使用 sizeof()不会影响性能,因为它的计算是在编译过程中进行的,sizeof()也是可以 移植的——在另一种环境下编译会自动改变由 sizeof( )计算出来的值。它所需要的维护工作 也是很少的,因为你可以改变你已经定义的类型,并且分配工作将是自动进行的。 第十一章 基本数据类型 174 11.9.4 11.9.4 11.9.4 11.9.4 检查表检查表检查表检查表 基本数据基本数据基本数据基本数据 常数常数常数常数 • 代码中是否避免了”奇异数”(常数?) • 程序中是否采取了措施来防止出现被“0”除错误? • 类型转换是显式进行的吗? • 如果在同一个表达式中出现了两种不同类型的变量,是否对表达式按照你的意愿进行 求值? • 程序中是否避免了混合类型比较? • 在编译过程中是没有警告的吗? 整型数整型数整型数整型数 • 使用整型数相除表达式的结果是否与预期的一致? • 整型数表达式中是否避免了整型数溢出问题? 浮点数浮点数浮点数浮点数 • 是否避免了数量级相差过大的数之间加减运算? • 程序中是否系统地采取措施来防止舍入误差问题? • 程序中是否避免了对浮点数进行相等比较? 字符和字符串字符和字符串字符和字符串字符和字符串 • 程序中是否避免了常数型字符和字符串? • 对字符串的引用是否避免了边界错误? • 若是用 c 写成的程序,是否是把字符数组和字符串指针区别对待的? • 若程序是用 C 写成的,是否遵守了把字符串长度说明为 CONSTANT+1这一约定? • 是否用字符数组代替指针? • 在 C 语言程序中,是否把字符由初始化成了 NULL 以避免出现无限长字符串? • 在C语言程序中,是否用strncpy()代替了strcpy()?并且用了strncat()和strncmp()? 逻辑变量逻辑变量逻辑变量逻辑变量 • 程序中是否使用了附加的逻辑变量来说明条件判断? • 程序中是否使用了附加的逻辑变量来简化条件判断? 枚举类型枚举类型枚举类型枚举类型 • 程序中是否用枚举类型代替了命名常量来改善可读性、可靠性和易改动性? • 是否用了枚举类型代替逻辑变量以改进可读性和灵活性? • 在使用了枚举类型的判断中是否检查了无效值? • 枚举类型的第一个入口是否是保留为无效的? 命名常量命名常量命名常量命名常量 • 在数据说明中使用的是命名常量吗? • 是否一致地使用了命名常量,而不是一会儿使用命名常量,一会儿使用数值? 数组数组数组数组 • 是否所有的下标都在数组界限之内? 第十一章 基本数据类型 175 • 是否对数组所有的引用都没有发生越界错误? • 多维数组的下标排列顺序正确吗? • 在嵌套循环中,作为循环变量的数组下标是正确的吗?是否出现了交叉错误? 指针指针指针指针 • 是否把指针操作独立在函数中? • 指针引用是有效的吗?是否误用了悬挂指针? • 程序中在使用指针之前,是否对它进行了检查; • 在使用由指针指向的变量之前,是否对其有效性进行了检查? • 在释放指针之后,是否把它们的值赋成了 NULL 或 NIL? • 为了提高可读性,程序中是否使用了所有需要用的指针变量? • 链表中的指针是否是按正确的顺序释放的? • 程序中是否分配了备用内存空间以作为内存溢出时拯救数据和工作努力的降落伞? • 是否是在万不得已时才使用指针的? 11.10 11.10 11.10 11.10 小小小小 结结结结 使用各种特定的数据类型意味着需要记住许多种规则。因此要用上面的检查表来确认你 已考虑过了所有常见问题。 第十二章 复杂数据类型 176 第十二章第十二章第十二章第十二章 复杂数据类型复杂数据类型复杂数据类型复杂数据类型 目录目录目录目录 12.1 记录与结构 12.2 表驱动方法 12.3 抽象数据类型(ADTs) 12.4 小结 相关章节相关章节相关章节相关章节 基本数据类型:见第 11 章 信息隐蔽:见 6.2 节 本章将要讨论的是自己建立的数据类型。在第十一章所叙述过的基本数据类型是必 不可 少的。当以它们为基础来建立高层次结构时,才打开了通往有效使用数据的大门。 如果你对高级数据结构熟悉的话,你可能会对本章的某些内容已经知道了,你可以跳过 12.1 节。浏览一下“灵活信息格式化举例”和 12.3 节,在 12.3 节你将发现在其它数据结构课本中所 没有的观点。 12.1 12.1 12.1 12.1 记录与结构记录与结构记录与结构记录与结构 “结构化数据”指在其它类型基础上建立起来的数据。由于数组是其中一个特例,所以它在 第十一章讨论。本书主要讨论由用户定义的结构化数据——Pascal 和 Ada 中的“记录”和 C 中的 “结构”。以下是使用结构化数据的几点原因: 使用结使用结使用结使用结构化数据来表明数据间的关系构化数据来表明数据间的关系构化数据来表明数据间的关系构化数据来表明数据间的关系。结构化数据把同类的所有数据都集中在一起。有时 ,读懂一个程序最大的障碍是找出哪一个数据是与另外一个数据相联的。这就像到一个小镇上去 问一个人都有谁与他有关系,最终你会发现每个人都与其它人有些关系.但却又都不很确定,所 以你永远也得不出确切的答案。 如果数据是经过仔细结构化的,那么找出数据间的联系就容易得多了。下面是一个使用没有 结构化的、容易会人误会的数据的 Pascal 程序的例子: Name := InputName; Address := InputAddress; Phone := InputPhone; Title := InputTitle; Department := InputDepartment; Bonus := InputBonus; 由于数据是没有结构化的,看起来似乎所有的赋值语句都是属于一类。而事实上 Names、 Address 和 Phone 是雇员的变量,而 Title、Department、Bonus 则是与监工有关系的变量。而从上 第十二章 复杂数据类型 177 面的程序段中根本找不出使用了两种变量的暗示。在下面的程序段中,由于使用了结构化程序 ,使得数据间的关系清楚多了: Employee.Name := InputName; Empployee.Address := InputAddress; Employee.Phone := InputPhone; Supervisor.Title := InputTitle; Supervisor.Department := InputDepartment; Supervisor.Bonus := InputBonus; 由于在代码中使用了结构化数据,因此某些数据是与雇员有关,而另一些数据是与监工有 关这一点就变得十分清楚了。 使用结构化数据来简化对成块数据的操作使用结构化数据来简化对成块数据的操作使用结构化数据来简化对成块数据的操作使用结构化数据来简化对成块数据的操作。你可以把相关的数据组合进一个数据结构中, 然后对这一结构进行操作。对数据结构进行操作要比对每一个元素进行同样操作容易得多。而 且可读性更好、代码行数相对也要少些。 假设有一组相关的数据,例如人事数据库中关于雇员的数据。如果不把这些数据组织成结 构,那么即使简单地拷贝这些数据也要许多行代码,请看下面这个用 Basic 写成的例子: NewName = OldName NewAddress = OldAddress NewPhone = OldPhone NewSSN = OldSSN NewSex = OldSex NewSalary = OldSalary 每次要传送关于某一雇员的信息,都不得不使用上面整个一组语句,如果想加入一条新的 雇员信息,比如,NumWithholdings——你都将不得不找出每个使用这一级语句的地方并在其中 加一条赋值语句: NewNumWithholdings = OldNumWithholdings 你能想象出两个雇员之间的交换数据有多么可怕吗?请看下面: ' swap new and old employee data PrevOldName = OldName PrevOldAddress = OldAddress PrevOldPhone = OldPhoe PrevOldSSN = OldSSN PrevOldSex = OldSex PrevOldsalary = OldSalary OldName = NewName OldAddress = NewAddress OldPhone = NewPhone OldSSN = NewSSN 第十二章 复杂数据类型 178 OldSex = NewSex OldSalary = NewSalary NewName = PrevOldName NewAddress = PrevOldAddress NewPhone = PrevOldPhone NewSNN = PrevOldSSN NewSex = PrevOldsex NewSalary = PrevOldSalary 处理这个问题比较简单的方法是说明一个结构化变量。下面是一个用 Microsoft Quick Bas eic 写成的采用这种方法的例子。其中使用了非标准 Basic 的特性、TYPE…ENDTYPE 语句。 TYPE tEmployee Name AS STRING Address AS STRING Phone AS STRING SSN AS STRING Sex AS STRING Salary AS STRING END TYPE DIM NewEmployee AS tEmployee DIM OldEmployee AS tEmployee DIM PrevOldEmployee AS tEmployee 现在,你只要用三个语句就可以在新旧雇员记录之间进行数据交换: PtevOldEmployee = OldEmploydee OldEmployee = NewEmployee NewEmployee = PtevOldEmployee 如果你想在其中加入一个域,如 NumWithholdings,那你只要把它加人类型说明就可以了 。而不对程序其余部分作任何改动。所有的标准 Pascal、各 C 语言都有类似能力。 使用结构化数据来简化参数表使用结构化数据来简化参数表使用结构化数据来简化参数表使用结构化数据来简化参数表。可以通过使用结构化数据来简化子程序参数表。这一技术 与刚才展示的技术是基本类似的。你可以把相联系的数据组成一个数据结构,然后把整个数据 结构进行传递,从而省去了一个个地传递每一个要传递元素的麻烦,下面是用 Basic 写成的未 使用数据结构调用子程序的例子: CALL HardWayRoutine( Name, Address, Phone, SSN, Sex,Salary ) 下面是用数据结构简化了参数表的子程序调用例子; CALL EasyWayRoutine( Employee ) 如果想在第一种调用中(未使用数据结构)加入 NumWithholdings,你就不得不在整个程序中 找出并修改每一个对 HardWayRoutine()的调用,而如果在第二种调用中想在 EmoloveeRec 中加 入 NumWithholdings,则只需改动类型说明而根本不必改动 EasyWayRoutine()。 你也可以用极端的方法来使用这技术,把程序中的所有变量都放入一个巨大的、臃肿的结 第十二章 复杂数据类型 179 构中,然后把它在子程序间进行传递。细心的程序员都会避免超出逻辑需要地把数据捆在一起 。而且,当结构中只有一或两个域被用到时,往往是传递这一两个用到特定的域而不是传递整 个结构。这也是信息隐蔽的一种形式;某些信息被隐含在子程序中,而有些则要对子程序是隐 含的。信息是在需要知道的基础上进行传递的。 使用结构化数据来降低维护工作量。使用结构化数据来降低维护工作量。使用结构化数据来降低维护工作量。使用结构化数据来降低维护工作量。因为在使用数据结构类型时你把相关数据都组织在一 起,因此在改变数据结构时只需在程序中作出少数改动就可以了。对于与被改动的数据结构没 有逻辑联系的代码段来说更是这样。由于改动往往容易产生错误,因此较少的改动就意味着更 少的错误。如果你的 EmployeeRec 结构中有一个 Title 域要被删掉,那么你不必改动任何用到整 个记录的参数表或赋值语句。当然,你将不得不改动特别处理 Title 的代码,因为它们与被删掉 的 Title 域有概念上的联系。因而不能被忽略掉。 由使用结构化数据带来的优势主要体现在与 Title 域没有逻辑联系的代码段中,有时,程序 中含有指向数据集合全体而不是其中某一部分的代码段。在这种情况下,个别结构要素如 Title 之所以被引用,仅仅是因为它们是数据集合的一部分,这些代码段与 Title 域没有任何特别的逻 辑联系,因此,可以在变动 Title 时被忽略掉(当使用数据结构时),因为它是把数据结构当作整 体而不是一个个的元素来对待的。 12.212.212.212.2 表驱动方法表驱动方法表驱动方法表驱动方法 表是几乎所有数据结构课本都要讨论的非常有用的数据结构。表驱动方法出于特定的目的 来使用表,下面将对此进行讨论。 程序员们经常谈到“表驱动”方法,但是课本中却从未提到过什么是“表驱动”方法。表 驱动方法是一种使你可以在表中查找信息,而不必用逻辑语句(if 或 case)来把它们找出来的方法 。事实上,任何信息都可以通过表来挑选。在简单的情况下,逻辑语句往往更简单而且更直接 。但随着逻辑链的复杂,表就变得越来越富于吸引力了。 例如,如果你想把字符排序成字母、逗号和数字,将很可能采用如下所示的复杂逻辑链(用 P ascal 写成): if (( 'a' <= InputChar ) and ( InputChar <= 'z' )) or (( 'A' <= InputChar ) and( InputChar <= 'Z' )) then begin CharType := Letter end else if ( InputChar = '' ) or ( InputChar = ',' ) or ( InputChar = '.' ) or ( InputChar = '!' ) or ( InputChar = '(' ) or ( InputChar = ')' ) or ( InputChar = ':' ) or ( InputChar = ';' ) or ( InputChar = '?') or ( InputChar = '-' ) then begin CharType := Punctuation end else if ( '0' <= InputChar and InputChar <= '9' ) then begin CharType := Digit End; 第十二章 复杂数据类型 180 而如果你使用的是查询表的话,就可以把每个字符类型都存储在由字符类型来存储的数组 中,那么上例中复杂的逻辑链就可以简化成下面这个样子: CharType:=CharTypeTable[InputChar]; 在这个程序段中,假定 CharTypeTable 已经被建立了,把程序的内容建立在表而不是 if 判断 的基础上。如果使用是适当(如上例)的话,表方法要比复杂的逻辑简单得多,而 且也更易于改动 ,效率也更高。 12.2.112.2.112.2.112.2.1 表驱动方法的通常问题表驱动方法的通常问题表驱动方法的通常问题表驱动方法的通常问题 使用表驱动方法时,将不得不说明两个问题: 首先,你不得不说明如何寻找表中的入口,你可以使用某些数据来直接存取表。比如,你 需要按月份把数据进行排序,那么进入月份表是非常容易的,你可以使用以 1 到 12 为下标的数 组来实现它。 而若用其它数据来直接查找表的入口则显得有些力不从心了,比如需要把社会保险号码 进行排序时,就不能用社会保险号来直接进入表,除非你把 999,999,999 个入口全部存在表中,这 时你将被迫采用比较复杂的方法。下面是查找表的人口的几种方法: · 直接存取 · 变址存取 · 阶梯存取 后面将对上述每种方法都进行详细的论述。 在使用表驱动方法时需要说明的另一个问题是,你将在表中存储些什么。在某些情况下, 表查寻的结果是数据。如果是这种情况,你可以把数据存储在表中;在其它情况下,表查寻的 结果是动作。在 这 种情况下,你可以把描述这一动作的代码存储在表中。在某些语言中,也可以 把实现这一动作的子程序的调用存储在表中。但不论是哪种情况,表都已经变得很复杂了。 12.2.2 12.2.2 12.2.2 12.2.2 直接存取直接存取直接存取直接存取 与其它查寻表一样,直接存取表是用来代替比它更复杂的逻辑控制结构的,之所以称其为 “直接存取”是因为用这种方法时,你不必为了找到你想要的信息而在表中绕来绕去。正 如图 12-l 所表示的那样,你可以直接找出你想要的入口。 示例示例示例示例 1111:一个月中的天数:一个月中的天数:一个月中的天数:一个月中的天数 假设你需要一个可以返回每个月中天数的函数(为简单起见不考虑闰年),一个比较笨的 图 12-1 直接存取 第十二章 复杂数据类型 181 方法是写一个大的 if 语句: IF Month=1 THEN Days=3l ELSEIF Month=2 THEN Days=28 ELSEIF Month=3 THEN Days=31 ELSEIF Month=4 THEN Days=30 ELSEIF Month=5 THEN Days=31 ELSEIF Month=6 THEN Days=30 ELSEIF Month=7 THEN Days=31 ELSEIF Month=8 THEN Days=31 ELSEIF Month=9 THEN Days=30 ELSEIF Month=10 THEN Days=31 ELSEIF Month=11 THEN Days=30 ELSEIF Month=12 THEN Days=31 ENDIF 更简单,效率更高也更容易改动的方法是,把这些数据放在一个表中。在 Basic 中,必须首先 建立表: ′ INITIALIZE TABLE OF "Days Per Month" DATA ′ DATA 31,28,31,30,31,30,31,31,30,31,30,31 DIM DaysPerMonth(I) FOR I=1 TO 12 READ DaysPerMonth(I) NEXT I 现在,有了这个表,就可以用一个简单的数组存取来代替上面那段复杂而又臃肿的 if 语 句了。 Days=DaysPerMonth(Month) 即使现在你想把闰年也考虑进来,程序仍然是非常简单的: Days=DaysPerMonth(Month,IsLeapYear) 显然,如果再用 if 语句的程序中计算闰年的话,那么程序将不知有多么复杂。 确定一个月中的天数是一个比较简单的例子,因为你可以用变量 Month 来查寻表的入口。 一般来说,可以采用控制着一大串 if 语句的数据来直接存取一个表。 示例示例示例示例 2222:保险费用:保险费用:保险费用:保险费用 假设你要编写一个计算医疗保险费用的程序,其中保险费用是随着性别、年龄、婚姻状况 和是否吸烟而变化的。如果你用逻辑控制结构作这些工作的话,它应该是与下面这个 Pascal 程 序段类似的: if ( Sex = Female ) then begin if ( MaritalStatus = Single ) then begin if ( SmokingStatus = NonSmoking ) then begin if ( Age < 18 ) then Rate = 40.00 第十二章 复杂数据类型 182 else if ( Age = 18 ) then Rate = 42.50 else if ( Age = 19 ) then Rate = 45.00 … else if ( Age > 65) then Rate = 150.00 end else begin { SmokingStatus = Smoking} if ( Age < 18 ) then Rate = 44.00 else if ( Age = 18 ) then Rate = 47.00 else if ( Age = 19 ) then Rate = 50.00 … else if ( Age > 65 ) then Rate = 200.00 end else{ Marital Status = Married } … end;{ if Sex …} 在上例中,只考虑了是否吸烟和性别、年龄而没有考虑婚姻状况,也没有考虑 18 岁到 65 岁 之间的大部分年龄,但 是 其 复杂程度已经相当惊人了。你可以想象一下,如果把影响保险率的所 有因素都考虑进来的话,它将有多么复杂。 你或许会问:“为什么要对每一个年龄都进行判断而不把保险费用放入年龄数组呢?”问 得 好,如果把保险费用放入年龄数组的话,将极大地改进上面的程序。 不过,如果把保险费用放入所有影响因素的数组而不仅仅是年龄数组的话,将会使程序更简 单,以下是 Pascal 中是如何说明数组的: type Smoking_t = (Smoking,Nonsmoking); Sex_t = (Male,Female); Marital_t = (Single, Married); Age_t = 1..100; var RateTable=array[Smoking_t,Sex_t,Marital_t,Age_t]; 在 Pascal 中使用枚举类型的一大特点是你可以用类似 smoking_t 的参数来说明数组,而编 译程序会自动识别出有两种抽烟状态从而知道数组中应该有两个元素。 定义好数组之后,你就需要确定如何把数据放进去。你可以用赋值语句,从磁盘中的一个文 件中读入数据计算出数据或其它任何合适的方法来做到这一点。当你建立好数据之后,便做 第十二章 复杂数据类型 183 好了计算保险费用的一切工作。现在就可以用下面这个简单的语句来代替前面那个复杂的逻辑 结构了: Rate:=RateTable[SmokingStatus,Sex,MaritalStatus,Age]; 这种方法的好处是可以用表查寻来代替复杂的逻辑控制,而表查寻的方法往往具有更好的 可读性并且修改容易,同时还具有占用空间少和可读性强的优点。 示例示例示例示例 3: 3: 3: 3: 灵活信息格式灵活信息格式灵活信息格式灵活信息格式 你还可以用表来描述由于变化太快而无法用代码来描述的逻辑,通过上面的几个例子,我们 已经知道某些问题是可以用 if 语句来实现的,虽然有时这种方法显得很拙劣,但毕竟是可用的 。然而在某些情况下,有些非常复杂的数据是无法用 if 语句来描述的。 如果你认为对直接表存取方法已经很熟悉了的话,可以跳过下一个例子,因为它只是比前几 个例子稍微复杂一些。 假设你要编写一个打印存储在某一文件中信息的子程序。文件中通常含有 500 条信息,信息 共有大约 20 种。这些信息来自于一个给出自身位置和水温的浮标。 每一条信息都有几个域,其 开头都是一个表示该信息种类的识别标志。图 12-2 表示了信息 的存储方式: 由于信息格式是由用户决定的,因而是反复无常的,你也不能寄希望于用户来把格式稳定 下来。图 12-3 表示出了几条详细的信息。 如果你用的是逻辑控制方法,那么你就不得不读取每一条信息,检查它的识别标志,然后调 用相应予程序来读取,解释和打印该种信息。如果共有 20 种信息的话,那么你就需要设计 图 12-2 无特定次序存储的信息,其中每个信息有一个信息识别标志 第十二章 复杂数据类型 184 20 种子程序,同时还要有许多低层次子程序来支持它们。比如,你将不得不用 PrintBuoyTemp aratureMessage()子程序来专门打印浮标温度信息。 当任何信息格式变动时,你都不得不改动打印该种信息子程序的内部逻辑。在上图表示的信 息细节中,如果平均温度域从浮点型变成其它类型,那么你就不得不改动 PrintBuoyTemperatu reMessage()中的逻辑结构。 而如果用表驱动方法的话,就可以把每种信息的格式放在一个表中而不必把它们硬性编码 在程序逻辑结构中,这使得编程和修改都变得很容易,而且使用的代码也要少得多。要使用这种 方法,你首先必须列出信息的种类和域的类型。在 Pascal 中,可以像下面这样来定义域的类型: var FiledTypes=(FloatingPoint,Integer,CharString, TimeOfDay,SingleFlag,BitField); 这样你可以用曲指可数的几个打印基本数据类型(整型、浮点型、字符串等)的子程序来代 替专门打印某种信息的 20 个子程序。你可以在表中描述每种信息的内容(包括每个域的名称) ,然 后 根据表中的描述来解释每一条信息。用 Pascal 编写的描述某种信息表入口可能是这样的 : Message[Type1].NumFields := 5 ; Message[Type1].MessageName := ′Buoy Temperature Message′ ; Message[Type1].FieldType[1] := FloatingPoint; Message[Type1].FieldLabel[l] := ′Average Temperature′ ; Message[Type1].FieldType[2] := FloatingPoint ; 图 12-3 各种信息格式 第十二章 复杂数据类型 185 Message[Type1].FieldLable[2] := ′Temperature Range′ ; Message[Type1].FieldType[3] := Integer ; Message[Type1].FieldLabel[3] := ′Number of Samples′ ; Message[Type1].FieldType[4] := CharString ; Message[Type1].FieldLabel[4] := ′Location′ ; Message[Type1].FieldType[5] := TimeOfDay ; Message[Type1].FieldLabel[5] := ′Time of Measurement′ ; 现在,你已经用存储在数据中的信息取代了存储在程序逻辑结构中的信息,而数据要比逻辑 结构灵活得多,当一个信息格式变化时,很容易改动相应的数据来适应它。如果不得不在其中加 入一种新信息,那你只需在 Messaae 数组中添加一个元素就可以了。 这时读取信息的代码也会变得简单多了。在以逻辑为基础的方法中,信息读取子程序要用 一个循环来读取每条信息,再根据信息识别标志判别出它的种类,然后再从 20 个打印子程序中 调用相应的子程序来打印它。下面是以逻辑为基础方法的伪代码: While more message to read Read a message header Decode the message ID from the message header If the message header is type 1 then Print a type 1 message Else if the message header is type 2 then Print a type 2 message ……… Else if the message header is type 19 then Print a type 19 message Else if the message header is type 20 then Print a type 20 message 上段伪代码事实上是省略的,因为其中只表示了 20 种信息中的几种。在低于这个层次的 逻辑中,20 种信息中的每一处都要求专门的子程序来打印它们。这些子程序也可以用伪代码 来表示。下面是表示浮标温度打印子程序的伪代码: Print ′ Buoy Temperature Message′ Read a floating-point value Print ′ Average Temperature′ Print the floating-point value Read a floating-point value Print ′ Temperature Range′ Print the floating-point value 第十二章 复杂数据类型 186 Read an integer value Print ′ Number of Samples′ Print the integer value Read a character string Print ′ Location′ Print the character string Read a time of day Print ′ Time of Measurement′ Print the time of day 而表驱动方法则要比这简单得多。信息读取子程序首先利用循环来读取每条信息的开头, 再利用识别标志判定它的种类,然后在 Message 数组中查寻关于该信息的描述,然后调用同一个 (而不是 20 个中的某一个)子程序来解释和打印信息。下面是表驱动方法中的高层次伪代码: While more message to read Read a message header Decode the message ID from the message header Look up the message descript in the message-description table Read the message fields and print them based on the message description 与以逻辑为基础方法不同的是,上面的伪代码并没有省略,因为现在的逻辑关系是非常简 单的。在低于这个层次的逻辑上,你将会发现只用一个子程序就可以同时解释和打印所有信息 。这个子程序的通用性要比以逻辑为基础方法中的打印子程序通用性强得多。但却并不比后者 复杂多少。而且只用这一个子程序便可代替 20 个子程序,下面是这个子程序的伪代码。 While more fields to print Get the field type from the message description Depending on the type of the field case of floating point => read a floating-point value print the field label print the floating-point value case of integer => read a Integer value print the field label print the integer value case of character string => read a character string print the field label print the character string 前三行与以逻辑为基础的相同 第十二章 复杂数据类型 187 case of time of day => read a time of day print the field label print the time of day case of single flag => read a single flag print the field label print the single flag case of bit field => read a Integer value print the field label print the bit field 当然,这个子程序要比前面单个的浮标温度打印子程序长一些。但这却是你在打印时所需要 的唯—一个子程序,从而节省了19 个子程序。这个子程序可以处理六种域类型并可以打印和解 释所有种类信息。 不过,这个子程序采用的是表查寻中最复杂的一种方法,因为它采用了 6 个 case 语句。许多 语言支持像存储数据一样把对子程序的调用存储在表中。如果你所用的正是这种语言,那么就 不必再用 case 语句了。你可以把子程序存储在表中并根据域的类型来调用它们。 下面是一个如何在 Pascal 中建立过程表的例子,首先,你需要建立一个 Pascal 的“过程类型 ”,即一种可以在其中存放对过程调用的变量。以下是这种类型的一个示例: type HandleFieldProc= procedure ( FieldDescription: String var FileStatus: FileStatusType ); 上面这段代码说明了一个过程类型,这种过程可以带有字符串参数和 FieldType 参数。第 二步工作是说明一个存放过程调用的数组。这个数组就是查寻表,下面就是这个查询表的示例: var ReadAndPrintFieldByType: array[FieldTypes] of HandleFileProc; 最后,要建立一个把某一特定过程的名称赋给 ReadAndPrintFieldByType 数组的表。下面就 是用 Pascal 写成的这个表: ReadAndPrintFieldByType[FloatingPoint]:=ReadAndPrintFloatingPoint; ReadAndPrintFieldByType[Integer] :=ReadAndPrintInteger; ReadAndPrintFieldByType[CharString] :=ReadAndPrintCharString; ReadAndPrintFleldByType[TimeofDay] :=ReadAndPirntTimeofDay; 第十二章 复杂数据类型 188 ReadAndPrintFieldByType[SingleFlag]:=ReadAndPrintSingleFlag; ReadAndPrintFieldByType[BitField] :=ReadAndPrintBitField; 在上段代码中假定 ReadAndPrintFloatingPoint 和其它等号右边的标识符都是 HandleFieldPro c 类型的过程名称。把过程名称赋给由过程变量组成的数组中的元素,意味着你可以通过引用数 组元素来调用过程,而不必直接使用过程名称来调用它们。 当过程表建立好之后,你只需访问过程表并调用表中的某个过程便可以处理信息中一个域。具 体代码是这样的; MessageIdx = 1 While(MessageIdx<=NumFieldsInMessage) and( FileStatus=OK) do begin FieldType:=FieldDescription[MessageIdx].FieldType; FieldName:=FieldDescription[MessageIdx].FieldName; ReadAndPrintFieldByType[FieldType](FieldName,FileStatus) end; 还记得那段使用了 case 语句的有 27 行的伪代码子程序吗?如果你用一个子程序表来代替 case 语句的话,上面便是你为实现同样功能而所需要的全部代码,显然它要比使用 case 语句 的子程序短得多。在其它类似支持过程变量的语言中如 C,你也可以使用类似的方法。这种方 法很难产生错误,非常容易维护同时效率也非常高。 建立查寻标志建立查寻标志建立查寻标志建立查寻标志。在上面的三个例子中,你都可以用数据作为标识符来直接进入表。比如, 你可以用 MessageID 而不必作任何变动就可以把它作为进入表的标志。你可能总想使用直接存 取表方法,因为这种方法比较简单,而且速度比较快。可是有时数据却不允许你这样作。比如 在计算保险费用的示例 2 中,Age 就不能用来作为直接存取标志,因为对 18 岁以下的人和 65 岁以上的人都各只有一个保险费用。这便产生了如何建立查寻标志的问题。 复制信息以建立直接存取标志复制信息以建立直接存取标志复制信息以建立直接存取标志复制信息以建立直接存取标志。Age 成为直接存取标志的一个简单办法是为 0 到 17 岁和 66 岁以上的每个年龄都规定一个保险费用,当然,这两个年龄段中每个年龄段保险费用都分别 是相同的,这相当于把这两个年龄段的保险率进行了复制。这种方法的好处是表结构和表存取 方式都是直接的。当要对 0~17 岁中的某一个年龄规定一个特殊的保险费用时,你只要改动一下 表就可以了。其缺点是大量的冗余信息会浪费空间并且很容易使表中存在错误,因为表中的数 据变多了。 变换数据使它成为直接存取标志符变换数据使它成为直接存取标志符变换数据使它成为直接存取标志符变换数据使它成为直接存取标志符。把 Age 变成直接存取标志的另一个办法是用一个函 数作用于 Age。在这种情况下,这个函数将把 0 到 17 岁之间的所有年龄都变成一个标志,而 把 65 岁以上的所有年龄变成另外一个标志。用 min()和 max()函数就可以完成这项工作,比如可 以用如下的表达式: max(min(66,,,,Age),,,,17) 使用转换函数要求,你对将被用作标志的数据模式有清楚的了解,而且这种方法也不总是 如上例那样简单用 min()和 max()就可以完成的。假设上例中的保险费用是每 5 岁为一个段而不 是以每一岁为一个段的,除非你把所有的保险费用都复制 5 次,否则的话你必须找到一个可以 使年龄被 5 整除并且可以调用 min()和 max()的函数才能完成转换,而这却不是件容易的事。 第十二章 复杂数据类型 189 把标志转换独立在函数中把标志转换独立在函数中把标志转换独立在函数中把标志转换独立在函数中。当你使用变换数据的方法来建立直接存取标志时,应把对数据 的转换操作放在函数中,函数的使用消除了在不同地方使用不同转换方法的可能性,当需要对 变换作改动时也非常容易。同时,要恰当地命名转换函数以清楚地说明使用函数的目的,比如 GetkeyFromAge()就是一个很好的名称。 还要把转换函数放入含有表定义和其它与表有关子程序的模块中。当转换函数与数据表距 离比较近时,如果要改动数据表的活,也比较容易想起改动转换函数。 12.2.3 变址存取变址存取变址存取变址存取 有时依靠简单的数字变换还不足把数据转换为直接存取标志。这时可以用变址存取方法解 决某些问题。 当使用变址时,首先用基本数据在变址表中找到一个标志,然后利用这个标志查找你感兴 趣的主要数据。 假设你在考虑一个存货问题,并且其中有一张岂有 100 项的清单,每一项都含有一个在 00 00 到 9999 之间的四位数。在这种情况厂,如果你想用四位数作为标志来直接进入一个描述了 某一项某些方面的表,就要先建立一个有 10,000 个入口的变址数组。这一个数组中除了那些与 上述 100 个 4 位数相应的入口外,其余入口都是空的。如图 12-4 所表示的那样,这些入口指向 一个描述了清单中的项,但入口数要远远小于 10,000 的表。 变址存取方法主要有三个优点。首先,如果主查寻表中每一个入口占用的空间都很大的话 ,那么建立一个有许多空入口的变址表所浪费的空间要比建立有许多空入口的主查寻表浪费的 空间少得多。比如上例中如果主查寻表每个入口占用 10 个字书,而 变 址表每个入口占用 5 个字 节的话,就可以节约(10-5)* 9900=49500 个字节。 第二个优点是即使使用变址表没有节约空间,对变址表中的入口进行操作也要比对主查 图 12-4 变址存取 第十二章 复杂数据类型 190 寻表口进行操作容易得多。比如,假设有一个含有雇员姓名、雇佣日期和工资等信息的表,你 可以建立一个通过姓名来查寻主表的变址表,还可以建立一个工资查寻主表的变址表,而且你 也可以建立通过雇佣日期查寻主表的第三个变址表。 使用变址存取的第三个优点是表驱动方法所共有的良好维护性。存在表中的数据维护起来 要比淹没在代码中的数据容易得多。为了增强灵活性,可以把变址存取代码放在子程序中,当 你需要由数据得到一个直接存取标志时再调用这个子程序,当需要改动表时,对子程序中的变 址存取代码进行修改要比对散布在程序中的同样代码进行修改容易得多。 12.2.4 12.2.4 12.2.4 12.2.4 阶梯存取阶梯存取阶梯存取阶梯存取 另一种表存取方法是阶梯法。这种存取方法不如变址法直接,但要比变址法节约空间。 阶梯法的主要思想是如图 12-5 所示的阶梯结构,其核心是表中的入口对数据范围而不是 对不同数据点有效的。例如你正在编写一个评分程序,"B" 入口的取值范围可以是从 77.5%到 9 0%。下面是一些你可能遇到的评分范围: ≥90% A < 90.0% B < 77.5% C < 65.0% D < 50.0% F 这是一个很难用表来查寻的问题,因为你无法用一个简单的转换函数来得到从 A 到 F 的直 接存取标志。这对变址存取法来说也是比较困难的,因为上面的数都是浮点型的。或许你会考 虑把浮点型转化为整型,就这个问题而言,这样作是可行的。不过为了说明阶梯法,我们把解 决方案限制为只能用浮点型数。 使用阶梯法时,首先把每个范围的上界放入表中,然后用一个循环来查找超过每一个范围 上界的分数。当你找到某一分数第一次超过某一范围上界的地方时,也就知道了该分数应属于 哪一级。使用这一技术时,必须仔细而且恰当地处理每一个范围的边界。下面是一段给一组学 生的分数分级的 Basic 程序; 'set up data for grading table 图 12-5 阶梯活确定入口 第十二章 复杂数据类型 191 RangelLimit(1) = 50.0 Grade(1) = 'F' RangleLimit(2) = 65.0 Grage(2) = 'D' RansleLimit(3) = 77.5 Grage(3) = 'C' RangleLimit(4)= 90.0 Grage(4)= 'B' RangleLimit(5)= 100.0——这是范围边界 Grage(5) = 'A' MaxGradeLevel = 5 ... 'assign a grad to a student based on the student's score GradeLevel= 1 StudentGrade = ”A" while( StudentGrade = "A" and GradeLevel < MaxGradeLevel ) if( StudentScore < RangeLimit( GradeLevel ) ) then StudentGrade = Grade ( GradeLevel) end if GradeLeveli = GradeLevel + 1 end 虽然这是一个非常简单的例子,你可以很容易地把它推广到多个学生、多等级方法(例如, 不同水平的分数对应不同的级)中。 阶梯方法优于其它表驱动方法之处,在于它比较善于处理不规则的数据。上面这个评分例 子的简单之处在于虽然不同等级之间间隔是不规则的,但是数据已经经过四舍五入了,其结尾 部是 0 或 5。阶梯法对于不是以 0 或 5 结尾的数也是同样有效的。你可以用阶梯法来处理统计 学中概率分布问题,比如下面的数据: 0,458747 $0.00 0 547651 $254.32 0.627764 $514.77 0.778883 $747.82 0.893211 $l,042.65 0.957665 $5,887.55 0.976544 $12,836.98 0.987889 $27,234.12 像这样不规则的数据是很难用函数把它们转换为直接存取标志的,但是用阶梯法却很容易 解决这个问题。 同时这种方法也具有表驱动方法的共同优点。它也是非常灵活而且容易更改的。如果上例 第十二章 复杂数据类型 192 中的分级范围要进行变动的话,只要改动 RangeLimit 数组中的入口就可以了。你可以很容易地 改动程序中等级赋值部分,以使得它可以接受不同级和相应的分数范围的表。在程序中把分数 转换成了百分数,事实上你也可以直接使用自然分数而不必将其转换为百分数。 以下是在使用阶梯法时应该注意的几个问题: 注意边界注意边界注意边界注意边界。要保证已经把每一台阶范围的上界值都考虑在内了。使用阶梯搜寻法来找出所 有不在最高一级范围内的值,然后把剩下的值全部归入最高一级中。有时候需要人为地为最高 一级范围添加一个上界。 同时应小心不要错误地用“<”来代替“<=”。要保证循环在找出属于最高一级范围内的 值后恰当地绍束,同时也要保证恰当地处理了范围边界。 应使用交叉搜寻而不是顺序性搜寻应使用交叉搜寻而不是顺序性搜寻应使用交叉搜寻而不是顺序性搜寻应使用交叉搜寻而不是顺序性搜寻。在上面的评分示例中,循环顺序地按照分级界限来进 行分级工作。如果表稍微大一些的话,进行顺序搜寻的代价将是非常巨大的。如果出现了这种 情况,你可以用“准交叉”搜寻来代替顺序搜寻,之所以称其为准交叉搜寻是因为绝大多数交 叉搜寻的目的都是为了寻找某个值。而在这种情况下,你想找到的是值属于的类别而不是值本 身。在交叉搜寻算法中,必须正确地决定值的归宿,同时,也要特别注意考虑边界问题。 考虑使用变址存取方法来代替阶梯存取法考虑使用变址存取方法来代替阶梯存取法考虑使用变址存取方法来代替阶梯存取法考虑使用变址存取方法来代替阶梯存取法。有时使用变址存取法来代替阶梯存取法会更好 。阶梯法中所需要的搜寻是时间累积的,如果运算速度是你考虑的重点的话,可以考虑用变址 法来代替阶梯法,即以牺牲空间为代价来换取速度。 显然,并不是在所有情况下这种替换都是可行的。在评分的例子中或许这是可行的。如果 只有一百个待分级的分数,并且这些分数也比较规则的话,建立一个变址表并不会占用太大的 内存,因而是可能的。但是如果分数是很不规则的,则无法使用变址法。因为你无法用这种方 法进入存有 0.458747 或 0.513392 这类数的入口。 在很多情况下往往几种方法都是可行的。这时设计的要与是从几种方法中选出一种适用的 ,而不是过多地考虑从中选择出一种最好的。找到一种较好的方法,并避免灾难性的后果,往 往比竭力找出最好的方法要实用,也合适得多。 最后一点应注意的是把阶梯表放入它自己的子程序中。 12.212.212.212.2....5 5 5 5 其它的表查寻的例子其它的表查寻的例子其它的表查寻的例子其它的表查寻的例子 在本书的其它章节中出现了一些表查寻的例子。在那些地方使用它们的目的是为了强调其 它观点而不是为了强调表查寻本身,下面是它们所在的章节: · 在保险表中查寻保险费用:见 15.3 节 · 在表查寻中查找的代价:见 28.3 节 · 逻辑变量的值(A 或 B或 C):见 29.2 节 · 贷款偿还表中的计算:见 29. 4 节 12.312.312.312.3 抽象数据类型抽象数据类型抽象数据类型抽象数据类型(ADTS)(ADTS)(ADTS)(ADTS) 抽象数据类型是由数据及对数据的操作组成的。这些操作既向程序的其余部分描述了数 据,也允许程序其余部分改变它所描述的数据。在“抽象数据类型”中的数据指的是广义的数 据。抽象数据类型可能是图形窗口及影响该窗口的操作,也可能是一个文件及对文件的操作。 第十二章 复杂数据类型 193 甚至还可能是一个保险费用表及对该表的操作。 抽象数据类型通常是作为模块来实现的。模块和抽象数据类型都是数据以及对数据进行的 操作的组合。要想详细了解模块的问题,请参阅第六章。 通常关于编程的课本在讲到抽象数据类型时往往会让人感到味同嚼蜡。他们总是喜欢这样 说:“你可以把抽象数据类型当作一个有许多操作来定义的数学模型。”接着,便是一些令人厌 烦的关于如何编写存取堆栈、队列或表的子程序的内容。这些书总是使人感到自己水远也不可 能方便地使用抽象数据类型。 这些关于抽象数据类型的干巴巴的解释根本没有说到点子上。抽象数据类型是应该令人激 动的,因为你用它们来解决的是客观世界中的实体问题,而不是计算机中的实体问题,你可以 向表格中加入一个空格,可以向窗口类型表中加入一个新的类型,也可以在特快列车上加挂一 节车厢而不是向链表结构中加入一个结点。不要低估了应在问题域而不是在计算机域中工作这 一准则的威力。这并不是说你不应该用抽象数据类型来实现堆栈、队列和表。你应该这样作。 但是抽象数据在客观世界问题中的威力要比在课本中讲的要强大得多。 12.3.1 12.3.1 12.3.1 12.3.1 需要用到抽象数据类型的例子需要用到抽象数据类型的例子需要用到抽象数据类型的例子需要用到抽象数据类型的例子 下面是一些需要用到抽象数据类型的情形。我们将在首先讨论这些具体情况之后再来看一 下抽象数据类型(ADT)的具体理论。 假设你正在编写一个用一系列打印页、点阵规模和字体来控制向屏幕输出的程序,程序中 的一部分要对字体进行操作。如果你使用抽象数据类型(ADT)的话,那么就需要把一组字体 子程序和它们作用的数据——打印页名称、点阵大小和字体属性组成一个集合。这个由字体子 程序及其数据组成的集合,就是一个抽象数据类型(ADT)。 如果不用抽象数据类型(ADT)的话,你就不得不用麻烦得多的方法来操作字体。比如, 你需要把字体改为 12 点阵的,那么你很可能要用如下的代码: CurrentFont.Size = 12 如果要在程序中的几个地方改变点阵规模,那么在程序中就会有许多类似的代码散布其中。 如果你要把字体置为黑体的话,可能要使用如下的语句: CurrentFont.Attribute = currentFont.Attribute or 02h 如果你很幸运的话,实际代码行可能会比上面的简单一些,但最好也只能是下面这个样子: CurrentFont.Bold =True 采用这种方法来编程的话,那么在程序中就会在许多地方出现非常类似的代码行,而 且如果你要设置打印页名称的话,你将使用如下的代码: CurrentFont.Typeface = TIMES_ROMAN 12.3.2 12.3.2 12.3.2 12.3.2 使用抽象数据类型的好处使用抽象数据类型的好处使用抽象数据类型的好处使用抽象数据类型的好处 这并不是说上面的编程方法是不好的。但是我们可以用更好的编程方法来代替它,从而获 得很多收益,何乐而不为呢? 下面就是使用抽象数据类型的一些优点: 第十二章 复杂数据类型 194 可以隐含实现细节。可以隐含实现细节。可以隐含实现细节。可以隐含实现细节。对字体数据的信息隐蔽意味着当需要变动字体时,你只需在程序中的 某一处作出改动而不会影响程序的其它地方。比如你如果不用抽象数据类型的话,那么在需要 改动字体时,你就不得不在程序中每一个设置了字体的地方进行修改,而不是只在一处进行修 改。如果进行了信息隐蔽,那么即使你想把数据从存在内存改为存放在外部文件中,或是想用 其它语言重写全部字体子程序,也不会对程序其余部分产生什么影响。 把改动的影响限制在局部。把改动的影响限制在局部。把改动的影响限制在局部。把改动的影响限制在局部。如果你需要更加丰富的字体,并且要支持更多的操作时,只需 在一处作出改动就可以了,程序的其余部分将不受影响。 更容易改进性能。更容易改进性能。更容易改进性能。更容易改进性能。如果你需要改进字体性能的话,只要修改几个已经明确定义的子程序就 可以了,而不必在整个程序中到处进行修改。 减少修改时犯错误的可能性。减少修改时犯错误的可能性。减少修改时犯错误的可能性。减少修改时犯错误的可能性。通过使用抽象数据类型,你可以用对子程序 SetCurrentFont ToBold()简单修改,来代替对 CurrentFont.Attribute= CurrentFont.Attribute or 02h 的语 句的复杂修改,从而减少了犯错误的地方有:使用了错误的结构名称、使用了错误的域名称或者 使用了错误的逻辑操作(比如误用了“and ”来代替“or”),还有可能使用了错误的属性值( 误用了 20h 而不是 02h);而在修改了程序时,你可能犯的唯一错误便是调用了错误的子程序, 而这个错误又是很容易发现的。 使程序成为使程序成为使程序成为使程序成为自说明的。自说明的。自说明的。自说明的。在上面的设置字体的语句中,你用 SetCurrentFontToBold()干程序 来代替原来语句所获得的可读性是不可同日而语的。 WoodField 和 Dumsmore 和 Shen 曾于 1981 年进行过如下实验:他们用两个内容相同的程序 ----一个被分成 8 个按功能划分的子程序,而另一个被分成 8 个抽象数据类型子程序,对计算 机专业研究生和高年级本科生进行测试,结果接受抽象数据类型程序测试学生对程序的理解程 度要比另一组高出 30%。 避免了在程序中四处传递数据的麻烦。避免了在程序中四处传递数据的麻烦。避免了在程序中四处传递数据的麻烦。避免了在程序中四处传递数据的麻烦。上前面的设置字体的例子中,如果不使用数据的话 ,那么你将不得不直接改变 CurrentFont 的值,或者将其在每一个处理它的子程序中传递。而 通过使用抽象数据类型,你既不必把 CurrentFont 在程序中四处传递也不必使用全局数据。在 抽象数据类型中将有一个含有 CurrentFont 数据的结构,对这个结构只有在 ADT 内部的子程序 才能进行直接存取,而 ADT 之外的子程序则无需关心这个结构。 你可以直接处理客观世界中的实体而不是计算机专业的结构。你可以直接处理客观世界中的实体而不是计算机专业的结构。你可以直接处理客观世界中的实体而不是计算机专业的结构。你可以直接处理客观世界中的实体而不是计算机专业的结构。通过使用抽象数据类型,你 可以对处理字体的操作进行定义,从而可以使程序的其余部分是以字体的名义来进行操作,而不 是以数组存取、记录定义或者逻辑变量的名义来进行操作。 在这种情况下,为了定义抽象数据类型,你将首先定义几个控制字体的子程序,很可能是 如下几个子程序: SetCurrentFonSize(size) SetCurrentFontToBold() SetCurrentFontToItalic() SetCurrentFontToRegular() SetCurrentFontToTypeFace(FaceName) 在这些子程序内部的代码,可能是非常短的,而且很可能与你所见到的不使用抽象数据类 型时的代码差不多。但它们的区别在于现在你可以把对字体的操作独立在一组子程序中,从而 为程序其余与字体有关的部分提供了较高的抽象程度,而且也避免了在改动字体操作时影响 第十二章 复杂数据类型 195 程序的其余部分。把对数据的操作隐含在存取子程序后面相当于在宇宙飞船中加装的减压舱, 宇航员可以通过减压能进入太空,也可以再从太空通过减压舱回飞船。但飞船内的空气却不会 泄漏进太空。 12.3.3 12.3.3 12.3.3 12.3.3 关于抽象数据类型的其它例子关于抽象数据类型的其它例子关于抽象数据类型的其它例子关于抽象数据类型的其它例子 下面是关于抽象数据类型更多的例子: 假设你正在编写一个控制核反应堆冷却系统的软件,那么你可以通过定义下述操作而把 冷却系统处理成抽象数据类型: GetReactoTemperature() SetCirculationRate(Rate) OpenValue(ValueNumber) CloseValue(ValueNumber) 特定的环境将决定实现这些操作的具体代码,程序的其余部分将通过这些函数来处理这个 冷却系统,而不必担心数据结构实现细节、数据结构限制、改动等问题。 下面是关于抽象数据类型及对它的可能操作的例子: 表: 灯: 初始化表 开灯 在表中加入项 关灯 从表中去掉项 从表中读下一项 文件: 堆栈: 打开文件 初始化堆栈 读取文件 把项压入堆栈 写文件 从堆钱中弹出项 设置当前位置 读取栈顶 关闭文件 通过研究上述这些例子,我们可以得出几条准则: 把典型的计算机专业数据结构建为抽象数据类型把典型的计算机专业数据结构建为抽象数据类型把典型的计算机专业数据结构建为抽象数据类型把典型的计算机专业数据结构建为抽象数据类型。绝大多数关于抽象数据类型的讨论都集 中在把典型的计算机专业数据结构建为抽象数据类型。正如你从上面的例子中发现的,你可以 用抽象数据类型来代表堆栈、表等数据结构。 你需要提出疑问的是这些堆栈、表所代表的到底是什么?比如,如果堆栈所代表的是一组 雇员,那么应把抽象数据类型当作雇员而不是堆栈来处理。又比如假设表所代表的是一组帐单 ,那么应把 ADT 作为帐单而不是作为表来对待。总之,应从尽可能高的抽象水平上来处理问题 。 把常见的目标如文件等处理为抽象时据类型(ADT)。绝大多数语言中都含有一些你可能非常 熟悉的抽象数据类型,只不过你没有意识到罢了。文件操作就是一个很好的例子。当向磁盘写 文件时,操作系统会把读/写磁头放置在一个特定的物理地址上,而在你放弃一个旧文件时,又 会分配一个新的磁盘扇区并查找交叉代码。操作系统提供了一个低层次的抽象,并提供了这个 层次的抽象数据类型,高级语言将可能提供稍高一些的抽象出乎并可以提供更高层次 第十二章 复杂数据类型 196 的抽象数据类型。高级语言可以使你避免与复杂而繁琐的操作系统调用和数据缓冲区操作细节 打交道。这种能力可以使你把一段磁盘空间当作“文件”来处理。 与此类似,你 也 可 能对抽象数据类型进行分层。如果你在数据结构操作层次(如压入或弹出 堆钱)上使用了抽象数据类型(ADT),那很好。接着,你可以在比它更高的层次上使用处理客观 世界真实问题的 ADT。 即使是简单的问题也应考虑使用抽象数据类型即使是简单的问题也应考虑使用抽象数据类型即使是简单的问题也应考虑使用抽象数据类型即使是简单的问题也应考虑使用抽象数据类型(ADT)(ADT)(ADT)(ADT)。。。。不要在碰到复杂得可怕的数据结构时 才考虑使用抽象数据类型。在使用 ADT 的示例表中有一个非常简单的例子——只有开灯和关灯 两个操作的灯控制问题。你可能会认为“开”和“关”两个操作过于简单了,不值得为它们分 别编写专门的子程序,但事实上,即使是简单的问题也可以因为使用 ADT 而受益。把对灯的操 作放入 ADT 中,可以提高代码的自我说明程度并区改善其易改动性,并把改动的影响限制在 Tu rn_Light_0n()和 Turn_Light_Off()两个子程序中,同时也降低了数据传递的工作量。 可以提供一对互补的操作。可以提供一对互补的操作。可以提供一对互补的操作。可以提供一对互补的操作。绝大多数操作都需要有与其相对应的、效果相反的操作。如果 有一个打开灯的操作,那么就需要一个关灯的操作。如果有向表中添加项的操作,那么就要有 从表中去除项的操作。因此,当你设计抽象数据类型时,应检查每一个功能以确定是否有与其 互补的功能。不要根据直觉,而是根据需要来决定是否设计互补功能。 应相对应相对应相对应相对 ADTADTADTADT 所存储的介质独立地引用它。所存储的介质独立地引用它。所存储的介质独立地引用它。所存储的介质独立地引用它。假设有一张非常大的保险费用表,你不得不把它 存储在磁盘中,你可能会把它作为一个“保险费用文件”,并且用诸如 ReadRateFile()之类的 存取子程序来访问它。但是,如果把它作为文件来处理的话,你就暴露了超过实际需要的信息 。如果你对程序作出改动,把保险费用表由存放在磁盘中改为存放在内存中,那么把它作为文 件来引用的代码就会变得不正确而且是令人困惑的了。应努力使存取于程序的名字与数据存取 方式是相互独立的,而且应该引用抽象数据类型如保险费用表。你应该用 ReadRateTable()来 代替 ReadRateFile()作为存取子程序的名称。 12.3.4 12.3.4 12.3.4 12.3.4 用用用用 ADT ADT ADT ADT 来处理多事件数据来处理多事件数据来处理多事件数据来处理多事件数据 如果你正在为 ADT 定义基本操作,可能会发现需要处理一种类型的几条而不是一条数据。 例如,在处理字体时,你可能需要跟踪许多字体。用 ADT 的术语来说,你所需要跟踪的每一种 字体都是一个“事件”。一种办法是建立几个分立的 ADT 来处理你要跟踪的每一个字体事件。但 更好的办法是用一个 ADT 来处理这个多事件字体数据。这通常意味着设计建立和取消事件及其 它功能,以便用它们来处理多事件数据。 字体 ADT 原来可能会包括如下功能: SetCurrentFontsize(size) SetCurrentFontToBold() SetCurrentFontToItalic() SetCurrentFontToRegular() SetCurrentFontToTypeFace(Face_Name) 如果你想一次不止处理一个事件,那么,需要加入如下的建立和取消字体事件的几个功能 : CreateFont(FontID) DeleteFont(FontID) 第十二章 复杂数据类型 197 SetCurrentFont(FontID) 在其中引入了 FontID 这一符号,用 它 来 作为建立和取消字体事件时的跟踪标志。至于其它 操作,你可以从处理 ADT 接口的三种方法中任选一种: 隐蔽地使用字体事件。隐蔽地使用字体事件。隐蔽地使用字体事件。隐蔽地使用字体事件。设计一个新的函数来调用某一设置当前特定字体事件的函数——比 如 SetCurrentFont(FontID)。设置当前字体使其它函数在被调用时可能使用当前字体。当你用 这种方法时,可以不必把 FontID 作为其它函数的参数。 每次使用每次使用每次使用每次使用 ADTADTADTADT 函数时,显示识别事件函数时,显示识别事件函数时,显示识别事件函数时,显示识别事件。在某种情况下,你没有用来表示“当前字体”的符 号。使用 ADT 函数的子程序不必跟踪字体数据。但它们必须使用字体识别标志。这需要在每个 子程序中都加入 FontID 来作为参数。 显示提供显示提供显示提供显示提供 ADTADTADTADT 函数要用到的数据。函数要用到的数据。函数要用到的数据。函数要用到的数据。用这种方法时,需要在每一个用到 ADT 函数的子程序内 部说明 ADT 所用数据。换句话说,你要建立一个传递到每一个 ADT 功能子程序中的 Font 数据结 构。同时,要把 ADT 功能子程序设计成每当它们被调用时,它们就会使用传入其中的 Font 数据 。这时,你不在需要使用字体识别标志,因为你跟踪的就是字体数据本身(虽然这种数据可以直 接从 Font 数据结构中得到,但你还是应该通过 ADT 功能子程序来存取它,这种方法称为把记录 保持为“封闭”的。将在后面详细讨论)。 这种方法的优点是 ADT 功能子程序不必根据字体识别标志来寻找字体信息。其缺点是这种 方法会使程序其余部分面临危险。因为数据是可以被直接存取的,所以它很容易被错误改动。 在抽象数据类型(ADT)内部,有许多种处理多事件数据的方法可供选择,但 在 ADT 外部则只 有上述三种方法可供选择。 12.3.5 12.3.5 12.3.5 12.3.5 混合抽象级别混合抽象级别混合抽象级别混合抽象级别((((要避免!要避免!要避免!要避免!)))) 如果你只在 ADT 功能子程序内部才对数据结构进行存取,而且在程序其余各部分只通过 AD T 功能子程序来存取数据,那么你就保持了一致的抽象级别。如果不这样作,那么就产生了混 合抽象级别问题,这足以抵消由使用 ADT 而带来的所有好处。其直接后果是在修改程序时非常 容易犯错误。因为这时你会错误地以为修改了存取子程序便等于修改了程序中所有存取方式。 我们继续使用前面的飞船减压舱隐喻来说明问题:如果说 ADT 中的功能子程序相当于飞船 上减压船的话,那么对功能子程序的不一致使用就相当于在减压舱门上钻了个孔。这个孔可能 不至于使飞船中的空气一下了漏光,但是只要时间足够,它便足以毁掉整个飞船。在程序中如 果你使用了混合抽象级别,那么当你对程序进行修改时,程序便会变得越来越令人难以理解, 其功能会逐渐退化直至最后它成为完全无法维护的。 开放和封闭的记录开放和封闭的记录开放和封闭的记录开放和封闭的记录 随着混合抽象级别而来的是“封闭”和“开放”记录的思想。当一个子程序直接用到了在 其中的记录或结构的任一个域时,便称这个域或结构是开放的:而如果记录或结构的任一个域 都没有被直接使用时,就称它是“封闭”的。向前面所提到的那样,你可以把使用封闭记录作 为处理多事件数据的一种方法。一个数据是可直接存取的这一事实,并不意味着你必须开放它 。除非是受到 ADT 功能了程序的限制,否则就该把记录保持为封闭的。 当你在用处理多事件数据的三种方法进行选择时,应选择 FontID 法而不是 FontRecord 第十二章 复杂数据类型 198 法。 12.3.6 12.3.6 12.3.6 12.3.6 抽象数据类型与信息隐蔽、模块和目标抽象数据类型与信息隐蔽、模块和目标抽象数据类型与信息隐蔽、模块和目标抽象数据类型与信息隐蔽、模块和目标 抽象数据类型和信息隐蔽是互相联系的概念。当你使用 ADT 时,就已经隐蔽了它的实现细 节。这也正是从抽象数据类型通往信息隐蔽之路。当你使用信息隐蔽时,首先要寻找可以隐蔽 的内容。最明显的需要隐蔽的内容就是抽象数据类型(ADT)的实现细节。这也是从信息隐蔽通往 抽象数据类型的道路。 抽象数据类型这一概念与模块的思想也是相互联系的。在直接支持模块语言中,你可以在 模块中实现抽象数据类型。模块与抽象数据类型一样,都是由数据以及作用在数据上的操作组 成的。 抽象数据类型这一概念与目标的概念也是相互联系的。“目标”是一个比较广义的概念,但 它通常指的是数据及使用在数据上的操作。从这个观点来说,所有的目标都是抽象数据类型。 “目标”的思想事实上也利用了继承性和多形性的概念。把目标当作抽象数据类型的想法事实 上便是把继承性和多形性加到了一起。抽象数据类型只是目标思想的一部分而不是全部。 12.3.7 12.3.7 12.3.7 12.3.7 抽象数据类型所需要的语言支持抽象数据类型所需要的语言支持抽象数据类型所需要的语言支持抽象数据类型所需要的语言支持 有些语言对 ADT 的支持要比其它语言更强有力。Ada 语言对抽象数据类型的支持近乎完美 的。比如在字体的例子中,Ada 允许把所有的字体功能子程序都放入一个“包”中。你可以把 几个子程序声明为开放的,而其条子程序则只在包中才是可用的。你可以限制 Font 的定义,从 而禁止 ADT 功能的子程序对 Font 的内部进行操作。这些子程序可以声明 Font 数据。但是它们 不能对任一属于 Font 的域进行操作,也不知道 Font 的内部结构。 在其它语言,如 C、Fortran、Basic 和 Pascal 中,你可以把 ADT 放入单一的一个源文件中 ,开放某些子程序而把其余子程序保持为专用的 C 对 ADT 的支持也是比较有力的,因为它支持 多重源文件(模块)和专用于程序。而其它语言对 ADT 的支持能力则取决于特定的实现细节。换 句话说,在其它语言中,含有 ADT 的程序不一定是可移植的。 在任一种给定的语言中,无论你怎样有效地隐含子程序、隐含数据都是一个棘手的问题。仍以 字体程序为例:如果你在功能子程序外说明了一个 Font 变量,就可以在任意能够引用这个变量 的地方对其内部进行操作。这时它总是一个开放的记录,这意味着它很可能成为一个错误的记 录。只有通过规定,编程标准和你痛苦的失败经历才能使其保持为封闭的。 12.4 12.4 12.4 12.4 小小小小 结结结结 · 恰当地对数据进行结构化,可以使程序更简单、更容易理解也更容易维护。 · 可以用表来代替复杂的逻辑结构。当你被程序的复杂逻辑迷惑时,应考虑是否可用查 寻表来简化程序。 · 抽象数据类型是降低复杂性的有力武器。它使你可以分层编写程序,而且是从问题域 而不是程序语言细节来编写顶层的程序。 第十三章 顺序程序语句 199 第十三章第十三章第十三章第十三章 顺序程序语句顺序程序语句顺序程序语句顺序程序语句 目录目录目录目录 13.1 必须有明确顺序的程序语句 13.2 与顺序无关的程序语句 13.3 小结 相关章节相关章节相关章节相关章节 常见的几个控制方面问题:见第 17 章 有条件的代码:见第 14 章 循环代码:见第 15 章 从这章起,论点已由程序的数据衷心论转移到控制中心论方面。这章介绍一种最简单的控 制流――按顺序放置程序语句和程序块。 虽然组织顺序式程序代码(straight line Code)是件相对比较简单的事情,但是组织形式的 技巧却影响到代码的质量、正确性、可读性与可维护性。 13.1 13.1 13.1 13.1 必须有明确顺序的程序语句必须有明确顺序的程序语句必须有明确顺序的程序语句必须有明确顺序的程序语句 必须按先后顺序往下执行的语句,是最简单的一类有顺序关系的语句,举例如下: 按顺序执行的 Pascal 程序例子: RecordData ( Data ); CalculateResultFromData( Data, Results ); PrintResults (Results); 除非代码段中发生了什么不可思议的事情,否则程序将按语句的先后顺序往下执行,即要 得到计算结果必须先读取数据,而只是计算出结果后才能有结果输出。 这个例子中默认的一个概念是语句间的依赖性。第三个程序语句依赖于第二个,而第二个 则依赖于第一个。这个例子中一个语句对另一个的依赖关系,由子程序名就看得很清楚。在下 面的代码段中,这种依赖关系就不是很明确。 虽不明显但仍有依赖关系的 C 程序: ComputeMonthlyRevenues( Revenues); ComputeQuarterlyRevenues( Revenues); ComputeAnnualRevenues( Revenues); 这个例子中,计算季度收入时是假设月度收入已经算出了的。如果对会计学很了解或有一 些常识,我们应当知道在算出季度收入之后,才能算出年度收入。这里存在着依赖关系,但仅 从代码本身却不很明显。下面这个代码中,程序语句间的依赖关系更不明显,这种依赖关系从字 第十三章 顺序程序语句 200 面上是看不出来的。 依赖关系隐藏的 Basic 程序: CALL ComputeMarketingExpenses CALL ComputeMISExpenses CALL ComputeAccountingExpenses CALL PrintExpenseSummary 由于各子程序的调用没有参数,你或许能猜出各子程序是通过模块数据或全局变量等方式 获得数据的。假设是在 ComputeMarketingExpenses()于程序中来初始化全局变量,其它子程序 也就得到了各自需要的数据。在这种情况下,这个子程序必须在其它各子程序前被调用才行, 但在这个代码中你无法从通读程序得知。 当程序语句间有依赖关系时,你需要把它们按一定的先后顺序组织起来而使这种依赖性更 明显。下面提供一些这方面的指导。 组织代码使它们间的依赖关系明组织代码使它们间的依赖关系明组织代码使它们间的依赖关系明组织代码使它们间的依赖关系明显。显。显。显。在上面的 Basic 程序中,ComputeMarketingExpense() 没有必要初始化全局变量。这个子程序的名字表明它计算的是市场数据,而不是 Mix 或 Mis 会 计数据。在 ComputeMarketingExpense()中初始化全局变量是个不太好的习惯,应当改掉。为 何我们要在这个子程序中初始化全局变量而不是在其它两个子程序中呢?除非你有什么正当理 由,否则你必须另写一个子程序——InitializeExpense()去初始比全局变量。这个子程序的名 字已经很清楚地表明它应当在其它子程序前被调用。 子程序子程序子程序子程序的名字应当清轻地表明依赖关系。的名字应当清轻地表明依赖关系。的名字应当清轻地表明依赖关系。的名字应当清轻地表明依赖关系。在上面例子中,ComputeMarketingExpense()的 名字起得不好,因为它仅说明它是计算市场费用的,但同时它却初始化了全局变量。假如你不 乐意另写一个初始比数据的子程序,那么你至少应该给 ComputeMarketingExPense()起一个能 描述其作用名字。本例中,起个 ComputeMarketingExpenseAndInitGlobelData()名字是较为准 确的。 你或许要说太可怕了,名字那么长,但这个名字却实实在在地反映了其作用,因而显 得不可怕。原来的子程序名名不符实才害人呢? 使用子程序参数使依赖关系明显。使用子程序参数使依赖关系明显。使用子程序参数使依赖关系明显。使用子程序参数使依赖关系明显。在上面这个例子中,因为子程序间无参数传递,你不知 道其它各子程序是否用了同一个数据。重写上代码段,使子程序间有参数传递,这样在代码中 就留下一些表明执行先后顺序的线索,下面是重写的代码段。 用数据传递来显示程序语句间依赖关系的 Basic 例子: CALL InitializeExpenseData(ExpenseData) CALL ComputeMarketingExpenses(ExpenseData) CALL ComputeMISExpense(ExpenseData) CALL ComputeAccounting(ExpenseData) CALL PrintExpenseSummary(ExpenseData) 因为所有子程序都用了 ExpenseData 这个参数,你可能得到暗示,即所有子程序都用了同 一数据,因而语句的先后顺序是重要的。但反过来想也不算错,既然有数据传递,那么是否该 说明执行顺序并不重要呢?下面是一个例子。 有数据传递并不表明语句间依赖关系的 Basic 例子: CALL ComputeMarketingExpense(MarketingData) 第十三章 顺序程序语句 201 CALL ComputeMISExpenses(MISData) CALL ComputeAccountingExpenses(AccountingData) CALL PrintExpenseSummary(MarketingData,MISData,AccouningData) 既然前三个程序语句中没有用到任何相同的参数,这个代码表明调用各于程序的次序并不 重要,因为第四个子程序用到了前三个子程序的数据,你可能想到这个子程序必须在其它三个 子程序后执行。 注明不明确的依赖关系。注明不明确的依赖关系。注明不明确的依赖关系。注明不明确的依赖关系。首先写一个没有次序依赖关系的代码,然后再写一个有明显依赖 关系的代码。如果你还觉得依赖关系不够明显,那么就用注释注明这种依赖关系。注明不明确 的依赖关系,是说明你代码设想的一个方面,而这对编写出易维护、易修改的代码至关重要。 在 Basic 例子中,写出这些行的注释是很有帮助的。下面程序语句的次序依赖关系很隐藏, 但用注释来注明的 Basic 程序: ' Compute expense data.each of the routines acceses the ' global data structure ExpenseData.ComputeMarketingExpenses ' should be called first because it initlializes Expenses ' PrintExpenseSummary should be called last because it uses ' data calculatal by the other routines. CALL ComputeMarketingExpenses CALL ComputeMISExpenses CALL ComputeAccountingExpenees CALk PrintExpenseSummary 这个例子中,代码没有用技巧来使语句的依赖关系变得明显。最好还是用那些技巧来表明 这种关系而不要用注释。但若你想让别人无论何时都能很明白你的程序,或因为某些原因你不 能通过改进表明这种关系,那就只好用注释了。 13.2 13.2 13.2 13.2 与顺序无关的程序语句与顺序无关的程序语句与顺序无关的程序语句与顺序无关的程序语句 可能有这种情形,即代码中某些语句或程序块的先后顺序并不重要,一个语句并不依赖于 或说逻辑上从属于另一些语句,但实实在在的情况是,次序是影响可读性、性能、维护性的。 而且当语句执行顺序的依赖关系不存在时,可用下面的准则来组织这些语句或程序块的顺序。 指导原则是“接近原则”,使相关操作组织在一起。 13.2.1 13.2.1 13.2.1 13.2.1 使代码能由上读到下使代码能由上读到下使代码能由上读到下使代码能由上读到下 作为一种基本原则,应使程序能由上读到下,而不要到处转移。专家们认为"从上到下"的 次序对可读性最有帮助。简单地使控制流在运行时从上到下是不够的。如果为获得某一所需的 信息而必须去通读你的全部代码,那你就该重新去组织一下你的代码。下面举例说明: InitMarketingData(MarketingData); InitMISData(MISData); InitAccountingData(AccountingData); 第十三章 顺序程序语句 202 ComputeQuarterlyAccountingData(AccountingData); ComputeQuarterlyMISData(MISData); ComputeQuarterlyMarketingData(MarketingData); ComputeAnnualMISData(MISData); ComputeAnnualMarketingData(MarketingData); ComputeAnnua1AccountingData(AccountingData); PrintMISData(MISData); PrintAccountingData(AccountingData); PrintMarketingData(MarketingData); 假如你想知道 MarketingData 是怎样算出来的,你必须从最后一行开始,上溯搜查所有有 关 MarketingData 的语句直到第一行,其实 MarketingData 仅在另外两行出现过,但你却不能 放过每一个语句而得从头看到尾。换句话说,你得看完整个代码才能知道 MarketingData 中如 何算出的。下面是修改后的程序: InitMarketingData(MarketingData); ComPuteQuarterlyMarketingData(MarketingData); ComputeAnnualMarketingData(MarketingData); PrintMarketingData(MarketingData); InitMISData(MISData); ComputeQuarterlyMISData(MISData); ComputeAnnualMISData(MISData); PrintMISData(MISData); InitAccountingData(AccountingData); ComputeQuarterlyAccountingData(AccountingData); ComputeAnnualAccountingData(AccountingData); PrintAccountinData(AccountingData); 这个代码比上一个在几个方面要好。要查的每个变量都局部化了,用到同一变量的语句都 集中到一起。变量赋值后紧接着就用到这些数据。代码段中每个变量集中出现在一处。或许更 重要的一点是代码可能被分割成市场、MIS、会 计数据等几个子程序,但前一个代码能看出这种 可能性吗? 13.2.2 13.2.2 13.2.2 13.2.2 使同一变量局部化使同一变量局部化使同一变量局部化使同一变量局部化 有不同参数出现的代码段是一个“脆弱的窗口”。在 这 个 窗口内,很有可能无意中加上了新 的代码行,无意中改变了变量。或读着读着忘了这个变量的值是多少,因此把一个变量出现的 语句集中到一起总是一个好主意,例如上面这个例子。 把变量的再现局部化到一起的观点,其优点是不言自明的,但它却受制于常规的评价方法。 一个评价变量局部化方法是计算变量的跨度(span)。例子如下。 用来计算变量跨度的 Pascal 程序示例: a:=0; b:=0; c:=0; 第十三章 顺序程序语句 203 a:=b+c; 在这个例子中,第一次出现 a 和第二次出现 a 的语句间隔了两行,因此 a 的跨度为 2。两 次出现 b 的语句之间隔了一行,因此 b 的跨度为 1。而 C 的跨度则为 0,下面是例子。 跨度为1和0 的 Pascal 程序例子: a:=0; b:=0; c:=0; b:=a+1; b:=b/c; 在这个例子中,第一次和第二次出现 b 的语句间隔了一条语句,因此 b 的跨度为1;而第 二次与第三次之间无插入行,因此此时跨度为 0。 平均跨度取各个跨度的平均值,上例中以 b 为例,平均跨度为(l+0)/2=0.5。当你坚持把 同一变量出现的语句集中一起时,你能使读者把注意力一直都集中在一起。如果同一变量出现 得很分散,那么读者就只能在你的程序中满天飞了,因此把同一变量局部化的主要优点在于提 高程序的可读性。 13.2.3 13.2.3 13.2.3 13.2.3 使变量存活时间尽可能短使变量存活时间尽可能短使变量存活时间尽可能短使变量存活时间尽可能短 与变量跨度相关的另一概念是变量“存活时间”,即变量存活期涉及到的程序语句的总行数。 一个变量的生命由第一次出现它的程序语句开始,而终止于最后一次出现它的语句。 不像变量跨度,存活时间不受存活期间变量被用过多少次的影响,假如变量第一次出现在 第一行而最后一次出现在第 25 行,那么存活时间是 25 行,假如在这 25 行中这个变量仅被用了 两次报(即只在第 1,25 行),那么跨度是 23 条语句,假如变量从第一行到 25 行中行行都出现, 那么平均跨度为 0,但存活时间依然为 25 条语句。下面的图显示了跨度和存活时间的具体含义: “长的存活时间”意味着变量存活期间有许多条语句,“短的存活时间”意味着变量存活时 只出现过几条语句;跨度则指变量出现的密度情况。 跟变量跨度一样,在 考 虑 存活时间时要使它尽可能地小,使存活期间出现的语句条数最小。 同样地,减小存活时间也降低了变量出现窗口的脆弱性。当你想改变变量时,若存活时间短, 那么最早和最晚出现该变量的两条语句间的语句条数就小,因而减小了有意无意的修改出错的 可能性。 减小存活时间的另一大好处是在编码时你能在头脑中有一幅很清晰的画面。假如一个变量 在第 10 行赋值而在第 45 行才被用到,你可能认为在这期间也会用到这个变量,老想着这种可 能性,头脑当然很乱了。假如在第 44 行给一个变量赋值而在第 45 行马上就用到,那么之间不 可能有别的语句,你只需在这小的范围内考虑这个变量就行了。 第十三章 顺序程序语句 204 小的存活时间能减小初始化的错误,顺序式程序代码往往倾向于采用循环。若你把初始化 数据的地方放到远离你循环的地方,当你修改循环时,你很可能忘了去修改初始化值。把初始 化代码与循环代码紧放在一起,你就可能减小修改程序带来的初始化错误。 最后,小的存活时间使你编的代码可读性好,读者脑中装的代码愈少,你的代码便愈是易 懂。同样地,存活时间愈小,当你编辑或调用代码时变量出现的范围也愈小。 计算变量的存活时间计算变量的存活时间计算变量的存活时间计算变量的存活时间 我们规定计算变量的存活时间是这样的:第一次和最后一次出现该变量的语句间总共有的 语句行数(包括两个端点)。下面是例子: 这个 Pascal 程序不好,变量存活时间长。 l {Initialize all variables } 2 3 RecordIndex := 0 4 Total := 0 5 Done := False .... 27 while (RecordIndex ProjectedTotal) ——Total 的最后引用 71 Done := True; ——Done 的最后引用 这里几个变量存活时间分别为: RecordIndex (第 29 行-第 3 行+1)=27 Total (第 70 行-第 4 行+4)=67 Done (第 71 行-第 5 行+1)=67 平均存活时间 (27+67+67)/3=54 这个平均存活时间显得太长。 把上例重新写一下使出现变量的语句比较靠拢。 这个 Pascal 程序较好,因为变量存活时间短。 ... 26 RecordIndex:=0 ——RecordIndex 的初始化从第 3 行向下移 27 while (RecordIndex< RecordCount) do 28 begin 29 RecodIndex:=RecordIndex+1; 63 Total:=0 ——Total 和 Done 的初始化从第 4、5 行向下移 64 Done:=False 65 while not Done begin 70 if (Total>ProjectedTotal) 71 Done:=True; 在这个例子中,各变量存活时间计算如下: Recordlndex (第 29 行-第 26 行+1)=4 Totel (第 70 行-第 63+1)=8 Done (第 71 行-第 64 行+1)=8 平均存活时间 (4+8+8)/3=7 我们能靠直观觉得第二个例子比第一个例子好,因为初始化的语句和用到该变量的语句较 靠近。计算出两个例子的平均存活时间很有意义:54 行的平均存活时间与 7 行的平均存活时间 相比,正好从数据上说明了为什么我们的直观是正确的。 本章前面有几个例子,有一个含 InitMarketingData()的子程序经过重新合理编写后,使出 第十三章 顺序程序语句 206 现同一变量的语句较为靠近,因而同时减小了平均跨度和平均存活时间,程序得以改进,读者 可自行计算一下改进的程序。 那么仅从数字上能说明短的存活时间的代码就比长的存活时间的代码好吗?同样地,小跨 度的代码就一定比长跨度的代码好吗?研究人员还没有一个定量的答案。但有一点是肯定的, 即减小变量跨度和存活时间对设计程序来说是一个好的思想。 假如用跨度和存活时间来衡量全局变量你会发现,全局变量的跨度和存活时间都相当之大, 这是为何要避免用全局变量的重要原因之一。 13.2.4 13.2.4 13.2.4 13.2.4 相关语句组织在一起相关语句组织在一起相关语句组织在一起相关语句组织在一起 把相关的语句放在一起,这些语句之所以能放在一起是因为它们用一个数据,所起作用相 同、或语句的执行是有先后之分的,下一句的执行依赖于上一句的结果。 检查是否把相关语句很好地组织在一起的一个简单办法是,把代码的子程序列出来并把相关的 语句用框线框框起来,如果组织得很好,那么就应当如图 13-2 所示那样,各框之间无交叉。 图 13-2 如果代码组织得好.画出相关语句的框与框之间无交叉部分,这些框是一个一个叠起来的 如果组织得不好,很有可能是使用 goto 引起的,那么画出图就如图 13-3 所示,框与框之 间有交叉,这时应重新编写和组织代码,以便把有关的语句更好地组织在一起。 一旦你组织好了相关语句,你会发现他们内部之间联系非常紧密,但这一组相关语句与其 前后的语句却无太大联系。这时你可以把这组紧密联系的程序语句写成一个子程序。 13.2.5 13.2.5 13.2.5 13.2.5 检查表检查表检查表检查表 组织顺序式程序代码组织顺序式程序代码组织顺序式程序代码组织顺序式程序代码 • 把语句间的依赖关系表示得很清楚吗? • 子程序名是否把依赖关系表示得很清楚? • 子程序的参数是否把依赖关系表示得很清楚? • 若代码的依赖关系不清楚,用注释注明了吗? • 代码能从上读到下吗? • 变量的出现靠得很近吗? ——从跨度和存活时间来考虑。 第十三章 顺序程序语句 207 图 13-3 如果代码组织得不好,画出的相关语句的框与框之间有交叉,这种情形很可能是用 goto 引起的 • 是否把相关语句组织在一起? • 是否把相对独立的各相关语句写成子程序了? 13.3 13.3 13.3 13.3 小小小小 结结结结 • 组织顺序式代码最好的原则是整理出依赖关系。 • 用合适的子程序名、参数表、注释来标明依赖关系。 • 如果代码没有明显依赖关系,把相关语句组织在一起,特别是使用同一参数的那些语句。 第十四章 条件语句 208 第十四章第十四章第十四章第十四章 条件语句条件语句条件语句条件语句 目录目录目录目录 14.l if 语句 14.2 case 语句 14.3 小结 相关章节相关章节相关章节相关章节 经常遇到的有关控制语句的几个问题:见第 17 章 顺序式程序代码:见第 13 章 使用循环的代码:见第 15 章 条件语句是控制别的语句是否执行的语句,有些语句如 if,else,case,switch 能控制别 的程序语句是否执行。虽然循环控制语句如 while 和 for 通常也认为是条件语句,但习惯上一 般把它们单独讨论。在第 15 章的循环语句中,将讲到 while 和 for 的用法。 14.1 if14.1 if14.1 if14.1 if 语句语句语句语句 你所用语言可能不同,但你总会用几种 if 语句。最简单的是常用的 if 和 if-then 语句, if-then-else 稍复杂一点,长链形式的 if-then-else 更复杂。 假如你所使用的语言不支持结构形式的 if-then-else 语句,可以用第 17.6 节中用到的技巧 来模仿它,即把 goto 语句模仿结构化结构。 14.1.114.1.114.1.114.1.1 简单简单简单简单 ifififif----thenthenthenthen 语句语句语句语句 编写 if 语句时请参考以下几点: 在代码中,先按正常情况的路径往下编写,然后再写异常清况。在代码中,先按正常情况的路径往下编写,然后再写异常清况。在代码中,先按正常情况的路径往下编写,然后再写异常清况。在代码中,先按正常情况的路径往下编写,然后再写异常清况。在编写代码时,一定要使 得正常情况的路径在代码中显得很清晰。记住,异常情况一定不要使正常情况路径产生模糊。 这对程序的可读性及功能是很重要的。 在出现等号情况时,一定要弄清程序的流向。在出现等号情况时,一定要弄清程序的流向。在出现等号情况时,一定要弄清程序的流向。在出现等号情况时,一定要弄清程序的流向。当读取数据或计算循环的控制变量时,用’ <’替代’<=’或用’<’替代’<=’都同样会产生边界错误(所谓边界错误指在控制循环或重复 时,由于控制变量的某一边界值出错或超出范围而使整个程序无法继续下去的错误)。在循环时, 一定要弄清结束点,以避免产生边界错误,在条件语句中,一定要弄清等号时的情形,以避免 产生边界错误。 把正常情况放在把正常情况放在把正常情况放在把正常情况放在 ifififif 后面而不是后面而不是后面而不是后面而不是 elseelseelseelse 后面。后面。后面。后面。你当然希望正常的情况在程序中先处理,这样 按层层递推的方法,按先正常后不正常情况,按直线式往下排。下面这个例子犯了好多随意安 排正常不正常情况的毛病: OpenFile (InputFile,Status) 第十四章 条件语句 209 if Status=Error then ErrorType=FileOpenError ——错误情况 else ReadFile(InputFile,FileData,Status) ——正常情况 if Status= Success then SummarizeFileData(FileData,SummaryData,Status) ——正常情况 if Status=Error then ErrorType=DataSummaryError —一错误情况 else PrinSummary(SummaryData) SaveSummaryData(SummaryData,Status) if Status=Error then ErrorType=SummarySaveError ——错误情况 else UpdateAllAccounts ——正常情况 EraseUndoFile ErrorType=None end if end if else ErrorType=FileReadError ——错误情况 end if end if 这段代码中是很难让人苟同,因为正常情况和异常情况都混杂在一起。在代码中很难看出 整个程序是按正常情况的路径贯穿的。另外,因为异常情形有时也出现在 if 语句后而非 else 之 后,因此很难相信程序是按正常情况的线索贯穿的。下面重新写了这段代码,先集中编写了正 常情形,然后才编写异常情形。这样寻找和读起来,都很容易发现哪些是正常情况。 OpenFile(InputFile,Status) if Status<>Etror then ReadFile(InputFile,FileData,Status) ——正常情况 if Status=Success then SummaryFileData(FileData,SummaryData,Status) ——正常情况 if Status<>Error then PrintSummary(SummarData) ——正常情况 SaveSummaryData(SummaryData,Status) if Status<> Error then UpdateAllAccounts ——正常情况 EraseUndoFile ErrorType=None else ErrorType=SummarySaveError /* 错误情况 */ end if 第十四章 条件语句 210 else ErrorType = DataSummaryError /* 错误情况 */ end if else ErrorTyre = FileReadError /* 错误情况 */ end if else ErrorType=FileOpenError /* 错误情况 */ end if 在以上例子中,可以顺着主程序流的 if 语句去发现哪些是正常情况,修改后的代码使人集 中阅读主程序流而不是费力地去寻找异常情况,整个程序读起来轻松明快。把异常情况都放在 程序的后部,就是很好地处理了异常情况的代码。 ifififif 语句后跟上一个有意语句后跟上一个有意语句后跟上一个有意语句后跟上一个有意义的语句。义的语句。义的语句。义的语句。有时你可能碰到下面这个例子,if 语句后什么也没有。 If(SomeTest) ; else { /* do something*/ … } 若你是一个有经验的编程员,绝对不会这样去编程,这样显得很呆板。改进的方法是把 if 的条件逻辑取反,把 else 后的可执行语句移到 if 后,然后去掉 else 语句,改正如下: 去掉了 if 语句后空语句情况的 C 程序例子: if(!SomeTest) { /* do something */ } 考虑是否需要 else 语句。如果你真的想用一个简单 if 语句,考虑到底是否真的就需用一 个 if-then-else 语句的形式。通用动力公司(General Motors)在分析了用 PL/I 编写的 代码且发现仅 17%的 if 语句后跟 else 语句,而这用了 else 语句的情况中,也有 50~80%的 仅用一个 else 语句(Elshoff 1976)。 一种观点认为编写 else 语句(如果需要,跟上一个空语句),是 要 表 明 else 情况已经考虑 过了。为了表明考虑过了 else 情况而编一个空 else 语句,可能显得多余,但至少说明你并没 有忽略 else 情况。如果你的 if 语句后无 else,除非原因很明显,否则用注释来标明为什么 else 语句不需要。例子如下: 这是一个 Pascal 程序例子,用注释说明为什么不要 else 语句是很有帮助。 { if color is valid } 第十四章 条件语句 211 if (Min.Color<=Color and Color<=Max.Color) begin { do some thing } … end; { else color is invalid } { Screen not written to -- safely ignore command } 检查检查检查检查 elseelseelseelse 语句的正确性。语句的正确性。语句的正确性。语句的正确性。检查代码时,像 if 语句这样的主要语句是应当检查到的。但也 应当检查 else 语句,以确保其正确。 检查检查检查检查 ifififif 语句和语句和语句和语句和 elseelseelseelse 语句是否弄反了。语句是否弄反了。语句是否弄反了。语句是否弄反了。一个常见的错误是在编写 if-then 语句时,把该跟 在 if 后的代码与该跟在 else 后的代码正好弄反了,或者使 if 中的判断逻辑正好弄反了。检查 你的代码避免这种错误。 14.1.2 if14.1.2 if14.1.2 if14.1.2 if----thenthenthenthen----elseelseelseelse 语句语句语句语句 假如你所使用的语言不支持 case 语句,或者只是部分支持,那么你会发现,你得经常编写 if-the-else 语句。如下例子中,代码要把输入的字符归类,因而用到如下语句: 用 if-then-else 语句排序字符的 C 语言例子。 if(InputChar CharType := Letter; when ' ' | ',' | '.' | '!' | '(' | ')' | ':' | ';' | '?' | '-' => CharType := Punctuation; when 'o'..'9' => CharType:=Digit; when nul..us=> 第十四章 条件语句 213 CharType:=ControlChar; others=> PUT_LINE("internal Error: Unexpected type of character detected."); end case; 14.2 case14.2 case14.2 case14.2 case 语句语句语句语句 case 语句和 switch 语句因语言不同其结构也变化很大。许多版本的 Basic 语言根本就不 支持 case 语句。C 语言支持的 case 语句,其分支变量每次只能对应一个值。Pascal 中的 case 语句也只对应一定范围内的数据。Ada 支持 case 语句,并且它用表示值的范围和组合的能力很 强。 在 Apple Macintosh 和 Microsoft Windows 环境中编程,大型的 case 语句经常用到。随着 交互式事件驱动程序的日益普及,case 语句的使用频率越来越高。下面部分指出了怎样有效地 使用 case 语句。 14.2.1 14.2.1 14.2.1 14.2.1 选择最有效的方法来组织各种情况选择最有效的方法来组织各种情况选择最有效的方法来组织各种情况选择最有效的方法来组织各种情况 在 case 语句有许多方法可以用于组织各种情况。假如 case 语句很小,仅有三种选择和相 应的三条响应的语句,那么你怎样组织这个 case 语句都行。如果你的 case 语句很大,如在事 件驱动程序中用到的 case 语句,把各种情况组织成一个合理的顺序是很重要的。下面是可能的 组织方法。 把各种情况按字母或数字顺序组织。把各种情况按字母或数字顺序组织。把各种情况按字母或数字顺序组织。把各种情况按字母或数字顺序组织。如果各种情况是同等重要的,按 A-B-C 顺序安排, 以提高可读性。每个事件都可很容易从整体中挑出来。 把正常情况的事件放在最开始。把正常情况的事件放在最开始。把正常情况的事件放在最开始。把正常情况的事件放在最开始。如果是一个正常情况及几个异常情况,把正常情况放在最 先,并用注释标明哪些是正常情况哪些是异常情况。 按出现频率组织情况。按出现频率组织情况。按出现频率组织情况。按出现频率组织情况。把最经常执行的情况放在最先,而最不可能的情况放在最后。这种 方法有两大好处。首先,读者很容易找出最普遍的情况。读者为寻找某个具体的情况而浏览整 个程序,极有可能就是找最常见的那种情况。把常见的情况放在代码的最开始,读者能很快找 到它。第二,机器执行起来也快。每一种情况都在代码中有相应的执行语句,机器执行都要花 时间搜索,如果有 12 种情况而最后一个情况是要执行的。那么机器要搜索 12 条 if 语句,直到 最后发现相应的情况。若把最普遍情况放在最先,你就可以减少机器的搜索区间,这样就可提 高代码的效率。 14.2.2 14.2.2 14.2.2 14.2.2 用用用用 casecasecasecase 语句该注意的几点语句该注意的几点语句该注意的几点语句该注意的几点 下面是用 case 语句时要注意的几点: 使每种情况对应的执行语句最简单。使每种情况对应的执行语句最简单。使每种情况对应的执行语句最简单。使每种情况对应的执行语句最简单。每种情况对应执行代码应当短些。短的执行代码结构 显得清楚。如果某个情况对应的执行代码显得很复杂,应当把它写成一个子程序然后对应这种 情况调用于程序,而不是在这种情况之后直接跟上复杂的执行代码。 不要为了用不要为了用不要为了用不要为了用 casecasecasecase 语句而去定义伪交量。语句而去定义伪交量。语句而去定义伪交量。语句而去定义伪交量。一个 case 语句应当用在易被归类的简单数据上。 假如数据不简单,用 if-then-else 代替 case。伪变量显得很乱,应当避免使用。下面是几个 反例: 这个 Pascal 程序编得不好,因为它定义了一个 case 伪变量 第十四章 条件语句 214 Action:=UserCommand[1]; case Action of 'c': Copy; 'd': DeleteCharacter; 'f': Format; 'h': HelP; else PrintErrorMsg('Error: Invalid command.'); end;{case} 控制 case 语句的变量是 Action。在本例中,变量 Action 是通过取 UserCommand 字符串的 第一个字母形成的,字符串由用户自己输入。 这种勉强的编码方法非常不可取,也产生了许多问题。在 case 语句中,你自己定义了变量, 但真实的数据可能并不如你所期望那样产生预期动作。比如在上例中,读者把 copy 的第一个字 母 C 定义替代 copy,并且能正确调用 copy()子程序。但另一方面,如果用户想在 case 语句中 定义"cement overshoes","clambake" ,"Cellalite"抽出第一个字母来代替这个变量,那么 也为 C,调用的却是 Copy()子程序。case 语句中的 else 语句也可能出毛病,因为它只管错误 命令的第一个字母而不管这个命令本身。 若不用定义伪变量的方法,则可用 if-then-else 来达到检查整个命令串的目的。以下是 重新编写的代码: 这个 Pascal 程序较好,用 if-then-else 代替 case 中的伪变量。 if(UserCommand='Copy') then Copy else if(UserCommand='delete') then DeleteCharacter else if(UserCommand='format') then Format else if(UserCommand='help') then Help else PrintErrorMsg('Error: Invalid command.'); 若用缺省语句只用合法的缺省。若用缺省语句只用合法的缺省。若用缺省语句只用合法的缺省。若用缺省语句只用合法的缺省。有时还有一种情况,你就想把这种情况编写成缺省语句。 这种做法有时虽很诱人,但却不足取。要 这 样你就没有利用 case 语句的好处,并且失去利用缺 省语句检查错误的能力。 这样使用 case 语句不便于修改。如果用合法的缺省,增加一种新情况是很容易的事情—— 你只需增加一种情况并编写相应的执行代码就行了。假如用了缺省,修改起来很困难。若你要 增加一种新情况则要先编一个新的缺省情况,然后把以前用作缺省的情况改为一般情况,才能 使整个 case 语句写成合法代码。在编写程序时从一开始时就该用合法缺省。 用缺省语句检查错误。用缺省语句检查错误。用缺省语句检查错误。用缺省语句检查错误。如果缺省语句不用作处理一些操作,也不支持某些情况,就把诊断 信息放在里面。下面是这样的例子: 第十四章 条件语句 215 这个 Pascal 程序较好,用缺省情况发现错误。 case Letter of 'a': PrintArchives; 'p': {no action required,but case was considered} ; 'q': PrinrQuarterlyReport; 's': PrintSummary; else PrintErrorMsg('Internal Error 905:Call Customer assistance.'); end; {case} 缺省语句中的信息对调试和编写代码都是很有用的,用户最喜欢的信息是在系统运行失败 时给出“内部错误,请调用客户支持”之类的信息;或者最坏的情形,但结果是错误的却好像 是正确的,直到检查到了为止。如果缺省语句不仅仅用作发现错误,那么就意味着对每一种情 况的选择都应当正确,应当检查以确保进入 case 语句的值都合法。如果有进入 case 语句的值 不合法的,修改对应情况的语句,以便使缺省能真正检查错误。 在 C 语言中,每个情况对应的代码段都应当有一个结束语句。C 语言是一种通用的高级语 言,在 case 语句中执行每种情况都并不自动跳出,因而你得给它一个明确的结束命令,如 果 你 不给每种情况一个结束的语句,执行完这种情况的代码后接着转入执行下一个情况的代码。如 果真是这样,那就犯了编程中的大忌。下面这个例子正是这样。 这个 C 语言程序中 case 语句没用好 switch(Inputvar) { case('A'): if(test) { /* statement l*/ case('B'): /* sratement 2*/ /* statement 3*/ /* statement 4*/ } /* if(test) */ break; } 这个例子编得不好,因为整个控制结构混在一起了,这种嵌套结构很难理解,要修改例子 中的'A' 情况和'B' 情况比做脑部手术更难。若你真的要修改这两种情况,倒还不如推翻了重 写。你可能一次就写出正确的代码。总的说来,在用 case 语句时,不要忘了在每种情况中安排 一个结束代码。 在在在在 CCCC 语言中,语言中,语言中,语言中,casecasecasecase 语句的最后都应当准确无误地标明结语句的最后都应当准确无误地标明结语句的最后都应当准确无误地标明结语句的最后都应当准确无误地标明结束。束。束。束。假 如 你 有意地在最后不标明结 束,那么用注释说明你为什么要这样。 第十四章 条件语句 216 14.2.3 14.2.3 14.2.3 14.2.3 检查表检查表检查表检查表 条件语句条件语句条件语句条件语句 ifififif----thenthenthenthen 语句语句语句语句 • 正常情况路径在代码中流向是否很分明? • if-then 语句在出现等号时流向是否正确? • else 语句是否有必要? • else 语句正确吗? • if 语句和 else 语句正确吗?它们是否弄反了? • 正常情况是否跟在 if 后而非 else 后? • if-then-else • 复杂的条件是否封装成布尔函数调用了? • 最常见情况放在前面吗? • 全部情况都覆盖住了吗? • if-then-else 语句是最好的选择吗?——用 case 语句代替是否更好? casecasecasecase 语句语句语句语句 • 各情况的安排次序有含义吗? • 每种情况对应的操作简单吗?——如需要调用别的子程序。 • case 语句中的变量有实际意义吗?它是为了用 case 语句而单纯地定义出来的伪变量 吗? • 缺省语句的用法是否合法(规范)? • 用缺省语句检查和报告异常情况吗? • 在 C 语言中,每一情况的结尾用了 break 了吗? 14.314.314.314.3 小小小小 结结结结 • 注意 if 和 else 的顺序,特别是在处理好多异常情况时,务必使正常情况流向清晰。 • 组织好 if-then-else 和 case 语句中的几种情况,使可读性最好。 • 在 case 语句中用缺省值,在 if-then-else 中的最后一个 else 中获取意外错误。 各种控制结构并不都同样有用,在编码时选用最合适的控制结构。 第十五章 循环语句 217 第十五章第十五章第十五章第十五章 循环语句循环语句循环语句循环语句 目录目录目录目录 15.1 选择循环类型 15.2 控制循环 15.3 编写循环的简单方法——从里到外 15.4 循环与数组的关系 15.5 小结 相关章节相关章节相关章节相关章节 一般控制语句应当注意的几个问题:见第 17 章 直接式程序代码:见第 13 章 条件语句代码:见第 14 章 所谓循环指任何一种类型的重复性控制结构。这种结构让代码的某一块被重复执行。几种 常见的的循环类型有 Basic 中的 FOR NEXT 语句、Fortran 中的 DO 语句、Pascal 中的 while-do 和 for 语句、C 语言中的 while 和 for 语句以及 Ada 中的 loop 语句等,使用循环是用程序化语言 编程时最复杂的情形之一,知道如何及何时用哪种循环是编写高质量软件的决定性因素。 15151515....1111 选择循环类型选择循环类型选择循环类型选择循环类型 在大多数语言中,你总可以用上几种循环类型。 · 计数循环计数循环计数循环计数循环要执行给定的循环次数.也可能就只循环一次。 · 条件循环条件循环条件循环条件循环先并不知道要执行多少次循环,而要在每次重复之前检查是否满足循环 条件。只要满足循环条件,循环不断继续下去,除非用户退出循环或出错。 · 死循环死循环死循环死循环只要开始便无限执行下去。在心脏起搏器、微波炉、巡航控制等系统中, 把死循环置入其中。 以上几种循环的不同首先在于其灵活性——循环前检验一下是否满足退出循环的条件。循 环还在于把控制循环的语句放置在何处。你可以把控制循环的语句放在循环体的开始,中间或 结尾。这种特性告诉你循环是否至少要执行一次。如果控制循环的语句放在开始那么循环体可 能不被执行。而若放在结尾,循环体至少要执行一次。若放在中间,有一部分循环体至少要执 行一次,而另一部分(控制循环语句之后)则可能一次也不执行。 在选用循环结构时,灵活性和控制循环语句的位置是关键。表 1 5-1 列出了几种语言的循环 类型和控制循环语句的位置。 第十五章 循环语句 218 表表表表15151515----1111循环类型循环类型循环类型循环类型 语言 循环类型 特性 放置位置 Ada For 计数循 开始 While 条件循 开始 loop-with-exit 条件循环 通常在中间 Loop n/a 无(用于死循环) Basic FOR-NEXT 计数循 开始 WHILE-WEND 条件循 开始 DO LOOP 条件循环 开始或结尾 C do-while 条件循环 开始 While 条件循环 开始 Fortran Do 计数循环 开始 Pascal FOR 计数循环 开始 repeat-until 条件循 结尾 While 条件循环 开始 15151515....1111....1 1 1 1 whilewhilewhilewhile 循环循环循环循环 初学者有时认为 while 循环是要不断地作判断。当 while 的条件为假时,循环立即停止;而 不问在循环中哪些语句被执行了。虽然 while 循环不全是条件循环,但一般把它看成是条件循 环。假如事先你并不知道循环的次数,那就用 while 循环。与初学者想法正好相反,使循环中 止的语句在每次循环时只执行一次,而主要要考虑的事情是确定把控制循环的语句放在开始还 是结尾。 把控制循环的语句放在开头的循环把控制循环的语句放在开头的循环把控制循环的语句放在开头的循环把控制循环的语句放在开头的循环 把控制循环语句放在开始的循环在 C、Basic、Pascal、Ada 中是 while 循环,在 Fortran、 汇编或其它语言中,可模仿 while 循环。 若想把控制循环的语句放在开始,最好选 while 循环。有些研究人员建议除非不能用,否 则一定要作这种循环类型。若认同这种评论,那么读者在读你的代码时所需理解的循环结构种 类就减少了。如果你只用一种基本的循环类型,读者就会慢慢适应你的这种编程风格,如果你 用几种循环类型,你就使读者很烦——他们要熟悉这几种循环类型,而你自己也要精通这几种 类型。 把控制循环的语句放在末尾的循环把控制循环的语句放在末尾的循环把控制循环的语句放在末尾的循环把控制循环的语句放在末尾的循环 有时你想用条件循环,但又想使循环至少执行一次,那么用 while 循环并把控制循环的语 句放在循环体的末尾。当然你也可以在 C 中用 do-while 循环,在 Pascal 中用 repeat-while 循环、 在 Basic 中用 DO-LOOP-WHILE 循环、或在 Ada 中用 loop-with-exit 循环结构。也可在 Fortran、 汇编或其它语言中模仿这种把控制循环的语句放在结尾的循环。 C 语言的一大好处便是 while 和 do-while 这两种循环结构所用控制循环和语句相同,两者 唯一的区别在于 while 把控制语句放在开始而 do-while 把控制语句放在结尾。Pascal 的 while 第十五章 循环语句 219 循环和 repeat-until 循环有两点不同:跟 c 语言一样,while 控制循环语句放在开头,而 repeat-until 则放在末尾。而另一奇怪的不同之处在于 repeat-until 的循环条件在逻辑上与对应的 while 循环 的条件正好相反。如果 while 的条件是“当…不循环”,则 repeat-until 的条件是“当…循环”。 这种区别比控制循环语句的放置位置更让人容易糊涂,因此一般建议选用把控制循环语句放在 开头的 while 循环。 15151515....1111....2 Loop2 Loop2 Loop2 Loop----withwithwithwith----exitexitexitexit 循环循环循环循环 Loop-with-exit 循环是把终止条件放在循环体中间而不是开头或结尾的循环。Ada 支持 Loo-with-exit 循环,你也以在 C 中用 while 和 break,或在其他语言中用 goto 来模仿这种循环结 构。 正常的正常的正常的正常的 looplooplooploop----withwithwithwith----exitexitexitexit 循环循环循环循环 正常的 loop-with-exit 循环包含循环头、循环体(含结束条件)循环尾,如下面 Ada 程序例 子: 一个常见的 Ada 的 Loop-with-exit 例子程序: loop … exit when(some exit condition); … end loop; 用到 loop-with-exit 循环的情况是,在循环体退出前至少执行循环体中部分情况一次。 下面的 C 语言子程序用到了 loop-with-exit 循环。 /* Compute scores and ratings */ score=0; GetNextRating(&RatingIncrement); Rating=Rating + RatingIncrement; while(Scroe < TargetScore && RatingIncrement!=0) { GetNextScore(&ScoreIncrement); Scroe=Score + ScoreIncrement; GetNextRating(&RatingIncrement); Rating=Rating + RatingIncrement; } 上例中,循环体中的最后两行重复了循环体前的两行。在修改程序时,你可能忘了同时修 改这两处,而其它的编程人员或许根本就没有意识到这两处是一样的,而需要一起修改,这样 因为改得不彻底程序会出错。下面告诉你怎样修改这个程序。 这个 C 程序用了 loop-with-exit 循环,易于修改。 /*Compute scores and ratings. The loop uses a FOREVER macro 这些行出现在这里 这些行出现在这里 第十五章 循环语句 220 and a break statement to emulate a loop-with-exit loop. */ Score = 0; FOREVER { GetNextRating( &RatingIncrement ); Rating = Rating + RatingIncrement ); If (!( Score < TargetScore && RatingIncrement != 0 )) -- 这是循环退出条件 break; GetNextScore( &SoreIncrement); Score = Score + ScoreIncrement; } 下面用 Ada 写了一段代码; 这个 Ada 程序用到了 loop-with-exit 循 环 - Compute scores and ratings Score := 0; Loop GetNextRating( RatingIncrement); Rating := Rating + RatingIncrement; exit when ( not ( Score < TargetScore and RatingIncrement!=0 )); GetNextscore ( ScoreIncrement ) ; Score := Score + ScoreIncrement; end loop; 在用 loop-with-exit 循环时,请遵循以下几点 : · 把所有的终止循环语句放在一起。分散放置终止语句可能使一个或几个其它的终止 语 句在调试、修改、测试时被忽视。 · 用注释阐明。如果一种语言不支持 loop-with-exit 循环结构(实际上,Ads 之外的 任何语言都不支持),那么用注释标明哪些地方你用了 loop-with-exit 循环技术。 loop-with-exit 循环是单进单出的结构化控制结构,而且是 Ada 循环控制中较为常用的 一 种。它较之其他种类的循环显得简单易懂。有人把 loop-with-exit 在中间结束循环和其 它在开始或未尾结束循环的循环作比较发现,初学者中有高达 25%人用 loop-with-exit 循 环。研究人员从这个研究中得出结论认为,loop-with-exit 循环结构更接近于人对循环控制的 理解。 但是 loop-with-exit 循环用得并不普遍。问题在于对这种循环是否是一个好的方法的无 穷无尽的争论。除非最后确实证明,否则我要说 loop-with-exit 循环是一个很好的技巧,你 尽可能地使用。 第十五章 循环语句 221 不正常的 loop-with-exit 循环 为避免循环无法作用在边界情况上,用到另一种 loop-with-exit 循环。 这个 C 语言用 goto 直接进入循环体内部,显得很不好: goto Start; while(expression) { /* do something */ ... Start; /* do something else */ ... } 乍一看,上面这个程序与以前的 loop-with-exit 例子相似。这 种 编程方法用在循环体的前 半部分 do-something 第一次要跳过而不执行,但后部分/*do something else*/第一次却 需执行的情况。这也是种单进单出的结构:唯一的进口在循环体的上边的 goto 语句,唯一的出 口是 while 语句。这种方法有两个问题:用了 goto 语句并且显得很乱。 在 C 中,不用 goto 语句同样能达到效果,如下面有这个子程序。如果一种语言不支持 break 或 leave,可用 goto 去模仿。 这个 C 程序较好,没有用 goto 却达到同样效果 FOREVER { /* do something else */ ... if (!( expression )) break; /* do something */ ... } 15.1.315.1.315.1.315.1.3 forforforfor 循环循环循环循环 当你想使程序块循环给定次数时,for 循环是一个好的选择。在 C、Pascal、Basic、Ada 中用 for 循环,在 Fortran 中用 DO 语句。 用 for 循环做简单动作时,不需设内部循环控制变量。你所需做的是在循环之前设一个控 制变量就可以不管了。你不需在循环内部设任何控制。若在某些条件下必须跳出循环,选用 while 循环更好。 第十五章 循环语句 222 当然,不要强行修改控制标志值使循环终止,这时可用 while 循环替代。for 循环仅用于简 单情形,大多数复杂的循环还需要很大 while 循环。 15.215.215.215.2 控制循环(控制循环(控制循环(控制循环(Controlling The LoopControlling The LoopControlling The LoopControlling The Loop)))) 在编写循环时会出哪些错?当然包括以下错误:忽略了与循环有关的变量或累加器初始 化、不正确的嵌套、不正确的循环中断、忘记给循环变量一个增量或给错了增量、用不正确的 循环指标访问数组元素。 你可以先来看两个例子以对上述问题有一个感性认识。首先,应减少影响循环的因素,简 化、简化、再简化。第二,把循环体内部当作一个子程序看一一把控制语句尽可能地放在循环 体外,以明确循环体执行的条件。不要让读者看了循环主体本身后才弄清循环控制。可以把循 环体着作一个黑盒子:周围的程序只判断控制条件而不知里面的内容。 这个 C 程序把循环体作为一个黑盒子; while(!eof(InputFile)&& MoreDataAvailable) { } 循环到底在什么条件下终止?在这个程序中,你只知道当 eof(InPutFile)为真而 MoreDataAvailable 为假时才退出循环。 15.215.215.215.2....1111 进入循环进入循环进入循环进入循环 下面几条告诉你如何进入循环: 仅从一个入口进入循环。仅从一个入口进入循环。仅从一个入口进入循环。仅从一个入口进入循环。许多循环控制结构允许你从开头中间或末尾进入循环。你从循环 头部进入循环,大可不必多口进入。 把初始化循环的代码紧放在循环前头。把初始化循环的代码紧放在循环前头。把初始化循环的代码紧放在循环前头。把初始化循环的代码紧放在循环前头。最近原则提倡把相关语句放在一起。如果把相关语 句分散在程序的各处。修改时可能顾及不全而产生修改错误。如把相关语句放在一起。可避免 在修改时出错。 把与循环体相关的初始化循环的语把与循环体相关的初始化循环的语把与循环体相关的初始化循环的语把与循环体相关的初始化循环的语句放在一起。句放在一起。句放在一起。句放在一起。如果不这样,当你扩大循环体或修改时很 可能忘了修改初始化循环的代码。当你把一个循环体拷贝到子程序里时,你就可能因为忘了一 起拷贝初始化部分而出错。把初始化部分放在远离循环的地方(在数据定义区域)或内务处理 区就可能产生初始化循环出错的麻烦。 在在在在 C 中,用中,用中,用中,用 FOREVER 宏编写死循环或事件循环。宏编写死循环或事件循环。宏编写死循环或事件循环。宏编写死循环或事件循环。有时你想写一个无终止的循环,如埋 植在心脏起搏器中或微波炉中的循环,有时你也可能写一个循环,在响应某一事件时才终止, 这就是事件循环。你可能有许多方法编制循环,但下面的宏定义在 c 中是标准的: 这个 c 语言程序用了一个死循环: #define FOREVER for(;;) ... 第十五章 循环语句 223 FOREVER——这里是无限循环 { ... } 在在在在 Pascal 中,用中,用中,用中,用 while(真)结构编写死循环或事件循环。(真)结构编写死循环或事件循环。(真)结构编写死循环或事件循环。(真)结构编写死循环或事件循环。如果希望循环有条件终止,在 Pascal 中用 goto 或 exit 离开循环。在这种情况下,goto 或 exit 是一个保护性的结构性编程结构, 保证了循环的单口退出,保证了单入单出的结构性编程需要,下面的 Pascal 程序是标准的建立 死循环的方法; 这个 Pascal 程序编写了一个死循环: while(True) begin {infinite loop} ... end; 以上是用 Pascal 和 C 编写死循环和事件循环的标准方法。伪死循环像 for i:=-i to 999 使得循环的替换性很差。因为试图用循环次数不会超出极限以达到死循环目的——可能 9999 就是一个合法值(没超出范围)。这个伪死循环在修改时也可能出错。 在在在在 C 中,只要允许就用中,只要允许就用中,只要允许就用中,只要允许就用 for 循环:循环:循环:循环:C 的 for 循环是这种语言强有力的结构之一。它不仅灵 活性强,而且把循环控制代码封装在一起,增加了其可读性。程序员在修改软件时易犯的错误 是;修改了循环前面的初始化循环的代码,但却忘了修改其后面的有关代码。在 C 的 for 循环 中,所有相关代码集中在循环的顶部,修改起来很容易。如果在 C 中能用 for 循环替代别的类 型的循环,尽量这样做。 当当当当 while 循环更合适时,别用循环更合适时,别用循环更合适时,别用循环更合适时,别用 for 循环。循环。循环。循环。在 C 中乱用 for 循环的一例是在 for 的条件中用了 while 循环的条件,下例便是这种情形: 这个 C 中程序虽是 for 循环却用了 while 循环的条件头: /* read all the records from a file */ for(rewind(InFile).RecCount = 0; !feof(InFile); RecCount++) { fgets(InputRec[RecCount], MAX_CHARS, InFile); } C 语言的 for 循环比其它语言的 for 循环优点在于,它的初始化和结束条件很灵活,而这 种灵活性带来的固有缺点是把控制条件放在了循环头,因而对循环体就无法控制了。 把控制循环的语句放到 for 循环头如初始化循环变量、终止循环或转向终止的表达式。上 例中,fgets()语句使循环转向中止,但 RecCount 语句却没起到这个作用,它是内部语句, 没有起到控制循环的作用。把 RecCount 语句放在循环头而把 fgets()语句放在循环体中是一 个错误,它使人误解为是 RecCount 在控制循环。 在这种情形下,若想用 for 循环而不用 while 循环,那就把控制循环的语句放在循环头, 而把不起这个作用的其他语句放到循环体中。下面这个程序用了正确的循环头: 第十五章 循环语句 224 这个 c 语言的例子,循环头很不一般: RecCount = 0; for(rewind(InFile); !feof(InFile); fgets(InputRec[RecCount], MAX_CHARS, InFile)); { RecCount++; } 这个程序的循环头中的内容都与循环的控制有关,rewind()语句初始比循环,feof() 语句检查是否该终止循环,fgets()语句转向终止操作。修改 RecCount 语句并不使循环转向 终止,因而不把它放在循环头,在这种情况下用 while 循环或许是最合适的,但要用得好,仍 要用 for 循环头的形式。在这里给出了用 while 循环写的程序。 这个 C 程序较好地用了 while 循环: /* read all the records from a file */ rewind(InFile); RecCount = 0; while(!feof(InFile)) { fgets(InputRec[RecCount], MAX_CHARS, InFile); RecCount++; } 15.2.215.2.215.2.215.2.2 处理好循环体处理好循环体处理好循环体处理好循环体 以下几点对处理好循环体会有帮助: 用用用用 beginbeginbeginbegin 和和和和 endendendend 或{和}把循环体括起来。或{和}把循环体括起来。或{和}把循环体括起来。或{和}把循环体括起来。如果你容易忘记从哪到哪是循环体,就用括号 把它们括起来。括号不占任何空间和运算时间,也不破坏可读性,相反,括号能保证你不出错, 因此尽量用就是了。 尽量避免用空循环。尽量避免用空循环。尽量避免用空循环。尽量避免用空循环。在 C 中有可能出现空循环。要检查某一工作是否完成时,把检查作为 循环条件就可能出现了只有一句的空循环。如下例: 这个 c 程序是一个空循环: while((InputChar= getch())!=' \n') ; 此例中,循环为空循环,因为它只做了两件事,循环动作中——InputChar=getch()和检 查循环是否该终止——InputChar!='\n'下面是修改后的程序,循环所做的工作对读者来说相当 清楚,程序也显得明朗。 把空循环改为有操作语句的循环的 c 程序: do InputChar= getch(); 第十五章 循环语句 225 while(InputChar!=’\n’); 这个程序较好,因为在三行中完成一个操作总比在一行外加一个分号完成一个操作好。 把循环的“内务处理工作放在循环的开头或结尾。把循环的“内务处理工作放在循环的开头或结尾。把循环的“内务处理工作放在循环的开头或结尾。把循环的“内务处理工作放在循环的开头或结尾。“内务处理”工作指像 i:=i+1这样的 表达式,它的作用不是循环要做的事情,而是去控制循环。下面的例子中,内务处理工作在末 尾处完成。 这个 Pascal 程序把完成内务处理的语句放在末尾; StringIdx:=1; TtlLength:=0; while not eof(InputFile) do begin {do the work of the loop} ReadString(InputFile, StringIdx, Str); ... {prepare for next pass through the loop--housekeeping} StringIex:=StringIdx+1; TtLength:=TtLength + Length(Str) end; 一般来说,你所初始化的循环变量往往是在内务处理部分要修改的变量。 使每个循环仅执行一项功能。使每个循环仅执行一项功能。使每个循环仅执行一项功能。使每个循环仅执行一项功能。一个循环一次做两件事并不能说这两件事是同时完成的。循 环应当像子程序一样一个只完成一件事且应做好。如果两个循环的效率比用一个循环的效率低, 那先用两循环编码并注明它们能合成一个循环以提高效率,然后用标准测试程序来对这部分进 行测试,如有必要最后才合并成一个循环。 15.2.315.2.315.2.315.2.3 退出循环退出循环退出循环退出循环 以下几点对如何退出循环会有帮助: 确保循环能终止。确保循环能终止。确保循环能终止。确保循环能终止。先要在脑中模仿,确信在各种条件下循环都能终止。这一点很重要。仔 细考虑正常与异常情形下循环的终止问题。 使循环的终止条件明显。使循环的终止条件明显。使循环的终止条件明显。使循环的终止条件明显。如 果 用 for 循环且不用 goto 或 break 语句来退出循环,终止条件 应该很明显。同样,若用 while 或 repeat-until 循环,并把控制条件放在 while 或 repeat- until 句子中,循环中止条件也会很明显。关键是要把控制条件放在一处。 循环中不要强制改变控制循环变量从而达到提前终止循环的目的。循环中不要强制改变控制循环变量从而达到提前终止循环的目的。循环中不要强制改变控制循环变量从而达到提前终止循环的目的。循环中不要强制改变控制循环变量从而达到提前终止循环的目的。下面这个程序就是这样 的: 这个 Pascal 程序强行改变循环控制变量: for i =1 to 100 do 这里是内务处理语句 第十五章 循环语句 226 begin { some Code } ... if(...) then i = 101 这里做得不好 {more code} ... end; 这个程序的本意是在某些条件下,改变控制变量的值为 101,这样大于 i 的范围 1~100, 循环终止。一个优秀的程序员是避免这样做的。一旦写好了 for 循环,控制变量就在你的控制 之下。若实在想在某些条件下终止,用 While 循环就能满足对终止条件的需求。 尽量避免直接用到循环控制变量的终值。尽量避免直接用到循环控制变量的终值。尽量避免直接用到循环控制变量的终值。尽量避免直接用到循环控制变量的终值。这样用显得很不好。循环控制变量的终值随语言 和用法的不同而不同,在正常和异常情况下退出循环也会不同。即使你知道终值是什么,但别 人在读程序时却要考虑才知道。在循环体中的适当位置先把终值赋给一个变量,这种方法好些。 下面这个程序就乱用了终值。 这个 C 程序乱用了循环控制变量的终值: for (i=0; i=SAFETY_LIMIT) begin PrintErrorMsg("Internal Error : Safety-Counter Violation."); exit(Error) end; ... until(Nodeˆ.Next = Nil); 这个 Pascal 程序引进一个安全计数器,可 能会导致额外的错误。如果安全计数器不是每个 循环都用,当你修改用到安全计数器的循环代码时,可能忘了去修改安全计数器。如果你用安 全计数器相当熟练,那么它就不会比别的语句更容易出错。 15.2.415.2.415.2.415.2.4 用用用用 breakbreakbreakbreak 和和和和 continuecontinuecontinuecontinue 语句语句语句语句 break 语句是一种辅助控制结构,它能使循环提前退出,break 使循环从正常出口退出,程 序继续执行接在循环后的程序。这里说的 break 是指属同一类的语句,在 C 中是 break,在 Pascal 中是 exit 或类似的结构,包括不支持 break 的语言中有相似功能的 goto 语句。 continue 语句和 break 一样同是辅助循环控制语句,但正好相反,continue 使程序又回到 控制体的开始并执行下一个循环。continue 语句是 if-then 语句的简写形式,而后者可能使 循环的余下部分不被执行。 用用用用 breakbreakbreakbreak 和和和和 continuecontinuecontinuecontinue 一定要谨慎。一定要谨慎。一定要谨慎。一定要谨慎。用 break 语句就不可能再把循环体看作一个黑盒子, 把控制循环退出的条件都写在一个语句里,这是简化循环的有力手段。用了 break 语句就要使 人从头到尾看你的循环体内容才能理解循环控制,这样就使得循环更难读了。 有选择性地用有选择性地用有选择性地用有选择性地用 breakbreakbreakbreak 语句。语句。语句。语句。有些计算机科学家认为这种结构是合法的技巧,而另一些则认 为不然,既然你不知用 continue 和 break 好还是不好,那么你用它时应当小心可能用错了。其 实情形很简单,若非一定要用,尽可不用。 在在在在 whilewhilewhilewhile 循环中,若要用布尔量标志时就应考虑用循环中,若要用布尔量标志时就应考虑用循环中,若要用布尔量标志时就应考虑用循环中,若要用布尔量标志时就应考虑用 breakbreakbreakbreak 语句。语句。语句。语句。在 while 循环中有时要增 这里是安全计数器代码 第十五章 循环语句 228 加一个逻辑标志来模拟循环体的出口,这就使得程序难读。有时为了好看,在有几个 if 语句时, 下一个比上一个要缩进几列,这时可用 break,把 break 放在独立的语句段之后,使它靠近它 所属的代码段能减少嵌套,使程序易读。 要特别小心一个循环中许多要特别小心一个循环中许多要特别小心一个循环中许多要特别小心一个循环中许多 breakbreakbreakbreak 语句分散出现的情形。语句分散出现的情形。语句分散出现的情形。语句分散出现的情形。一个循环中包含了许多 break, 则表明对循环的结构或者对周围代码的作用还考虑得不清楚。若 break 语句出现多了,就表明 该循环若用许多小循环替代可能更好,因为一个大循环会有许多出口。循环中有多个 break 并 不说明一定有错误,但起码给出一个信号;这种用法不太好! 如果用如果用如果用如果用 gotogotogotogoto 来模拟来模拟来模拟来模拟 breakbreakbreakbreak,转向执行的语句应紧接在循环的后面。,转向执行的语句应紧接在循环的后面。,转向执行的语句应紧接在循环的后面。,转向执行的语句应紧接在循环的后面。有时你很想让循环的出 口指向的不是紧跟在循环后的第一条语句而是更远,这时你就可能离用 goto 来做的危险尝试不 远了,或者你就处于那种要用 goto 来结束循环的境地。不要因为用了 goto 而把问题弄复杂了。 把把把把 continuecontinuecontinuecontinue 放在循环的头前检查。放在循环的头前检查。放在循环的头前检查。放在循环的头前检查。continue 语句的一种很好的用法是把它放在循环的头 部检查,若满足某一条件才执行循环体。如下例程序,循环要做的是先读人记录,然后看是否 满足一定条件,若不满足,就放弃这个记录;若满足,就处理这个记录。这时把 continue 放在 循环的头部: 用 continue 比较安全的伪代码程序: while(not eof(File)) do read(Record, File) if(Record.type<>TargetType)then continue {process record of TargetType} ... end while 这种用 continue 的方法可使你避免在用 if 语句时,需把循环体往后缩几列的做法。但是若 continue 出现在循环体的中间或结尾,那最好还是用 if-then 替代。 15151515....2222....5555 检查循环边界检查循环边界检查循环边界检查循环边界 一个简单的循环通常要注意三种情况:初始情况、中间情况、结束情况。当你要编写一个 循环时,你头脑中要想清楚:在初始、中间、结尾情况下都不会出现边界错误。如果初始或结 尾情形可能出问题,仔细检查一下,如果循环包含了复杂的计算,拿出计算器核算一下。 进行这种检查是高效率与低效率程序员关键不同之处,高效率的程序员在脑中或用手算一 下,因为他们知道:这样做可以帮助发现问题。 低效率的程序员总是随意地编写,反复修改到最后发现应该是怎样做。如果循环不按设想 的那样去做,低效率的程序员总要把憭'<'改成'<='。如 果 这样还行不通,他们就要改变循环控 制变量了,或者加或者减 1。到最后,他们用这种方法幸运时,撞对了,但很可能把简单的错 误改得更隐晦。即使这种试凑方法最后把循环改对了,程序员也不知道为什么就改对了。 在脑中先想想或先算算有几个好处。如,在编程阶段减少错误,在调试时能很快发现错误, 第十五章 循环语句 229 且能更好地理解整个程序。先想想能使你知道代码是如何运行的,而试凑者则不然。 15.2.615.2.615.2.615.2.6 使用循环控制变量使用循环控制变量使用循环控制变量使用循环控制变量 以下几点对如何用循环变量有帮助; 在循环和数组中,只能用整数。在循环和数组中,只能用整数。在循环和数组中,只能用整数。在循环和数组中,只能用整数。一般说来,循环计数器应当是整数值,浮点数不好使。如, 你把 1.0 加到 42,897.0 上去得到的是 42,897.0 而非 42,898.0,若 这 种 情形发生在循环计数器 上,就发生死循环了。 要用有意义的变量名使得循环嵌套易读要用有意义的变量名使得循环嵌套易读要用有意义的变量名使得循环嵌套易读要用有意义的变量名使得循环嵌套易读。循环变量通常就用作数组下标。如果数组是一维 的,你可以用 i,j 或 k 作下标去标识它。但是若数组是二维或多维的,你就要用有意义的下标 去标明你在做什么了。有意义的数组下标名能清楚说明每一层循环的目的和要处理的数组元素。 下面这段代码没有遵循上述规则,用无意义的变量名 i、j、k: 这个 Pascal 程序用了无意义的变量名: for i:=1 to NumPayCodes do for j:=1 to 12 do for k:=1 to NumDivisions do Sum := Sum + Transaction[j,i,k]; 你明白 Transaction 下标的意义吗?i、j、k 告诉你 Transaction 的含义了吗?一旦这样 定义 Transaction,在循环中你就无法确定下标的顺序是否对了。 这个 Pascal 程序的循环变量意义明朗: for PayCodeIdx:=1 to NumPayCodes do for Month:=1 to 12 do for DivisionIdx:=1 to NumDivisions do Sum:= Sum + Transaction[Month, PayCodeIdx, DivisionIdx]; 这回你知道 Transction 数组下标的含义了吗?在本例中,答案很清楚:变量 Paycodelndex、 Month、DivisionIdx 很清楚地给出了各自代表意思,而 i、j、k 则不能。计算机在读下标时同 样简单,但人读起来却觉得第二个比第一个简单多了。记住,你的最基本读者是人而非计算机。 用有意义的变量名以避免循环变量用重复用有意义的变量名以避免循环变量用重复用有意义的变量名以避免循环变量用重复用有意义的变量名以避免循环变量用重复了。了。了。了。若习惯都用 i、j、k 作变量可能引起冲突-- 一个循环中不同地方用了同一个循环变量名,如下例子: 这个 c 程序中循环变量冲突: for(i=0;i
还剩79页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 15 金币 [ 分享pdf获得金币 ] 0 人已下载

下载pdf

pdf贡献者

ecjtuxuan

贡献于2010-11-23

下载需要 15 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf