Code Complete 代码大全


计算机实用软件技术系列丛书 软件开发人员必备工具书 Code Complete 代码大全 Steve McConnell 著 天奥 译 熊可宜 校 学苑出版社 1993 eVersion 1.0 缺陷报告: codecomplete@163.net 本书网站: www.delphidevelopers.com (京)新登字 151 号 内容摘要 本书从软件质量和编程思想等方面论述了软件构造问题,并详细论述了紧跟潮流的新技术、 高屋建瓴的观点、通用的概念,还含有丰富而典型的程序示例。本书中所论述的技术不仅填补 了初级与高级编程技术之间的空白,而且也为程序员们提供了一个有关编程技巧的信息来源。 本书适合经验丰富、自学成才的程序员阅读,也适合于那些几乎不懂什么编程技巧的新程 序员阅读. 欲购本书的用户,请直接与北大 8721 信箱联系。邮编:100080,电话:2562329。 版权声明 本书中文版版权由 Microsoft 授予北京希望电脑公司和学苑出版社独家出版、发行。未经 出版者书面许可,本书的任何部分不得以任何形式或任何手段复制或传播。 Microsoft,MS, Ms Dos 和 Windows 是微软公司注册商标。 计算实用软件技术系列丛书 软件开发人员必备工具书 代码大全 著者:Steve McConnell 翻译:天奥 审校:熊可宜 责任编辑:徐建军 出版发行:学苑出版社 邮政编码:100032 社址:北京市西城区成方街 33 号 印刷:兰空印刷厂 开本:787X1092 l/16 印张:33, 625 字数:777 千字 印数:1~10000 册 版次:1993 年 11 月第 1 版第 1 次 ISBN7—5077—0876—4/TP· 25 本册定价;66.00 一—————————————————————————————————— 学苑版图书 印、装错误可随时退换 前言 1 前 言 近年来,关于软件开发的研究,进展是非常迅速的,但是开发实践却并非如此。许多程序 仍然是错误百出,充斥着过时的技术,从而无法满足用户需要。软件工业界和学术界的研究者 们,基本上已经解决了七十年代和八十年代在编程中遇到的问题,并发展了相应的技术。但是 直到现在,这些技术中的大部分仍然没有在软件编程中广泛采用,其主要原因是这些研究成果 主要发表在高度专业性的学术刊物中,普通的程序员们无心顾及。Sridhar Raghavan 和 Donald Chand(1989)的研究表明,一项新技术从诞生到被工业界广泛采用大约需要 5 到 15 年的时间。 本书的目的就是希望能够缩短新技术推广周期,使广大的程序员们可以迅速地获得软件开发的 最新方法与手段。 本书所面向的对象 本书中所收集的研究和编程经验,将有助于你编写出高质量的软件,并且使得开发周期缩 短。通过阅读本书,你将会对自己过去所犯过的错误有更深刻的理解,并懂得今后如何避免它 们。同时,书中所收集的丰富的编程经验也将使你在控制大规模项目和按要求对软件进行修改 和维护时感到得心应手。下面是适合阅读本书的几类人: 经验丰富的程序员 本书适合于想要得到一本全面易用的软件设计指南的那些资深程序员们阅读。由于本书的 中心内容是广大程序员们所熟知的实现过程,因此,无论是受过正规训练而已经验丰富的程序 员,还是完全靠自学成长起来的程序员,都能容易读懂本书所论述的先进技术和方法。 自学成才的程序员 本书尤其适合于很少受过正式专业训练的程序员阅读。1988 年有 100,000 人加入了程序 员大军,但其中只有 40,000 人是从计算机专业毕业的本科生,其余则几乎全是靠自学成才的。 同时,还有不计其数的其他各行各业的人员需要在工作中自己动手编一些程序。无论你所受到 的正规计算机专业训练多或少,本书都将使你对有效的编程方法和技巧有更深刻的理解。 学生 本书不仅适于实践经验丰富但理论基础薄弱的自学者阅读,同时也适于那些理论基础较好 但几乎不懂得什么编程诀窍的毕业生们阅读。新程序员们的某些实践经验来自于经验丰富的同 事.但主要还是靠自己──吃一堑,长一智──获得的,这往往是一个艰苦而缓慢的过程。通 过本书,可以使你在短时期内获得大量的经验和技巧,从而脱颖而出,所以,不妨一试。 本书的主要特点 完备的软件创建参考 本书从质量和编程思想等方面论述了软件构造问题。几乎囊括 了生成子程序、数据的输入输出与控制结构、调试、代码调整策略与技术等各方面的细节。在 使用本书时不必逐页阅读每一个细节,只要在需要时查阅你所感兴趣的章节即可。请把本书作 前言 2 为手册而不是作为教科书来使用。 方便而实用的检查表 书中附有用于检查软件的结构设计、设计方法、模块和子程序等质 量的检查表,以供评估软件质量之用。同时,关于变量名、控制结构、方案布置、测试用例等 等检查表也将使你获益匪浅。 紧跟潮流的新技术 书中论述了许多目前最先进的技术,其中许多还只是刚刚投入应 用。由于本书取材于实践经验和最新研究成果两个方面,因此书中所提供的技术在相当长的时 间内都不会过时。 高屋建瓴的观点 阅读本书将使你跳出日常琐碎工作的圈子,对软件开发有一个总体上 的把握与认识。繁杂的日常工作往往使程序员们穷于应付而无暇阅读浩如烟海的书籍与资料, 本书丰富而翔实的第一手资料将弥补这一缺憾,使你对软件开发的策略作出正确决策而不致陷 入旷日持久的消耗战中。 通用的概念 无论你用的是 Pascal、C、C++、Ada、Basic、Fotran 还是 COBOL,都可 以从本书所论述的概念、方法和技巧中获得教益。 丰富而典型性的程序示例 书中含有大约 500 多个正反两方面的程序示例。之所以引入这 么多的示例,是因为笔者就是从各种例程中吸取了大部分的知识、经验与诀窍,因此笔者认为 最好的学习方法是多看例程。例程是用多种语言写成的,因为对于程序员来说,掌握多种语言 是其必不可少的基本素质之一。而且,只有掌握了不受语法规则限制的编程准则,才能真正有 效地提高你的编程效率和质量。 为了减轻由于使用多种语言所带来的额外负担,在例程中除非 确有必要,尽量避开了各个语言过于独特的部分。事实上,如果你真正注意每个例程所要说明 的问题的话,那么不必详细理解每个程序段,你也可以清楚地懂得程序的意义。同时,为了进 一步减轻读者的负担,对程序中有意义的部分作了标记。 本书的独特内容 本书关于创建活动的内容是从多个渠道获得的。有关创建活动的资料不仅分布得非常分 散,而且往往没有成文资料,事实上,卓有成效的优秀程序员们所使用的技术并不神秘,但由 于日常事务的繁重和工作任务的重压,程序员们很少有互相交流切磋的时间,因而,他们往往 缺乏有关编程技巧的有效信息来源。 本书中所论述的技术不仅填补了初级与高级编程课本之间的空白,而且也为程序员们提 供了一个有关编程技巧的信息来源。比如当你读过 C 语言初级教程之后,你可以再读 C 语言高 级教程,然后再去读 C 语言高级的高级教程,但读完这些书后,你还能再读什么书呢?你可以 再去读关于 PC、Macintosh 或 UNIX 等硬件或操作系统的书或者其它有关编程细节的书——因 为你如果不了解实现环境详情的话是无法充分有效地使用语言和程序的。但这只是讨论了编程 的一个方面,最有效的编程技术是那些不受实现环境及语言限制的技术。其它书往往忽略了这 一点,但这恰恰是本书的重点。 写作本书的目的 需要一本关于软件开发有效技术的书,是软件工程界所公认的。由计算机科学技术委员会 所发表的一份报告认为,提高程序质量和生产效率的最有效途径是出版一本关于软件开发有效 前言 3 技术的书,而且这本书应该以手册的形式来组织。 同时,计算机编程技术的发展史也证明急需一本这方面的书,本书正是出于这个目的 才出版的。 创建活动未受到应有的重视 在一段时期内,软件开发与编码被当作是一回事,但随着软件开发周期中的其它活动被认 识,这一领域内的主要努力全部集中到了项目管理、需求分析、设计和测试等方面,创建活动 成了被遗忘的角落。 与这种现象相对应的思想是认为创建活动是软件开发中无关紧要的部分。于是,刚入门的 程序员被派去进行创建工作,为那些已经由上一阶段设计好的子程序编码。工作几年之后,他 可能会被提升至需求分析或项目管理部门。于是,这位程序员也会自豪地感到他不必再去编码 了。 创建活动是非常重要的 创建活动被忽视的另一个原因是:研究者和程序员们错误地认为与其它开发活动相比,创 建活动是一相对来说比较机械的活动,没有什么值得改进的。没有什么比这种想法离事实更远 了。 在小规模项目中,创建活动约占工作量的 80%,在中型项目中也要占 50%的工作量,而发 生在创建活动中的错误则占总错误的 50%到 75%。一项会产生 50%到 75%错误的工作是有许 多待改进之处的。 一些人认为,虽然创建时的错误占到总错误的 50%到 75%,但修改它们的费用与分析、设 计错误相比要少得多。的确,创建时的错误修改费用与前期工作错误修改费用相比是要少一些, 但是绝对数并不少。 Gerald Weinbers 曾在 1983 年报道过三个错误,每个错误的修改费用都高 达数百万美元,而每个错误都是一行编码层次上的代码错误。因此,绝不能以修改费用相对少 为理由来忽视创建活动。 具有讽刺意味的是,被忽视的创建活动事实上是唯一任何规模项目都必不可少的活动。 需求可以进行猜想而不必分析;结构可以被省略而不必设计。系统测试也可以不进行。但是, 如果你想有一个程序的话,你就不得不进行创建活动。 本书的独特性 如果创建活动的重要性是非常明显的话,那么本书恐怕就没有出版的必要了。但事实上几 乎没有什么书详细论述了这一主题。只有 15 年前出版过一本类似内容的书,讲述的是 ALGOL、 PL/I、Ratfor 等早已过时的语言中的具体问题。其它偶尔也有几本这方面的书,但却是教授们 针对教学用的演示性项目而写的,没有涉及到真正的工程问题。有些则偏激地推崇新技术而不 恰当地贬低了一些非常实用的成熟技术。总之,就内容的新颖、翔实、丰富和实用来看,目前 似乎还没有与本书相匹敌的关于创建活动的书。 编 者 目 录 第一章 欢迎进入软件创建世界⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯1 1.1 什么是软件创建.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯1 1.2 软件创建的重要性.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯3 1.3 小结.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯4 第二章 利用隐喻对编程进行更深刻的理解⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯5 2.1 隐喻的重要性.. ⋯.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯5 2.2 如何使用软件隐喻.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯6 2.3 通常的软件隐喻.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯7 2.4 小结 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯11 第三章 软件创建的先决条件⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯12 3.1 先决条件重要性.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯12 3.2 问题定义先决条件 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯16 3.3 需求分析先决条件 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯16 3.4 结构设计先决条件 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯20 3.5 选择编程语言先决条件.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯26 3.6 编程约定.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯29 3.7 应花在先决条件上的时间.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯29 3.8 改变先决条件以适应你的项目.⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯30 3.9 小结. ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯30 第四章 建立子程序步骤 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯31 4.1 建立程序步骤概述. ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯31 4.2 程序设计语言(PDL) ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯31 4.3 设计子程序 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯33 4.4 子程序编码 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯37 4.5 检查子程序 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯42 4.6 小结 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯44 第五章 高质量子程序特点 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯45 5.1 生成子程序的原因 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯47 5.2 子程序名称恰当 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯51 5.3 强内聚性 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯52 5.4 松散耦合性 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯56 5.5 子程序长度 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯60 5.6 防错性编程 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯61 5.7 子程序参数 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯67 5.8 使用函数⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯71 5.9 宏子程序⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯72 5.10 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯74 第六章 模块化设计 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯75 6.1 模块化:内聚性与耦合性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯75 6.2 信息隐蔽⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯77 6.3 建立模块的理由⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯84 6.4 任何语言中实现模块⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯85 6.5 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯90 第七章 高级结构设计 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯92 7.1 软件设计引论⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯92 7.2 结构化设计⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯95 7.3 面向对象⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯98 7.4 对目前流行设计方法的评论⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯102 7.5 往返设计⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯105 7.6 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯109 第八章 生成数据 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯111 8.1 数据识别⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯111 8.2 自建数据类型的原因⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯113 8.3 自建类型的准则⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯115 8.4 使变量说明更容易⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯115 8.5 初始化数据的准则⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯120 8.6 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯120 第九章 数据名称 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯121 9.1 选择名称⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯121 9.2 特定数据类型的命名⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯124 9.3 命名约定⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯128 9.4 非正式命名约定⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯129 9.5 匈牙利命名约定⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯132 9.6 短名称⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯136 9.7 要避免的名称⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯137 9.8 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯139 第十章 变量 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯141 10.1 作用域⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯141 10.2 持久性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯143 10.3 赋值时间⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯144 10.4 数据结构与控制结构的关系⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯145 10.5 变量功能单一性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯146 10.6 全局变量⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯148 10.7 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯153 第十一章 基本数据类型 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯154 11.1 常数⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯154 11.2 整型数⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯155 11.3 浮点数⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯157 11.4 字符和字符串⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯159 11.5 逻辑变量⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯161 11.6 枚举类型⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯162 11.7 命名常量⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯164 11.8 数组⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯166 11.9 指针⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯167 11.10 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯175 第十二章 复杂数据类型 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯176 12.1 记录与结构⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯176 12.2 表驱动方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯179 12.3 抽象数据类型(ADTs)⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯192 12.4 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯198 第十三章 顺序程序语句 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯199 13.1 必须有明确顺序的程序语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯199 13.2 与顺序无关的程序语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯201 13.3 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯207 第十四章 条件语句 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯208 14.1 if 语句 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯208 14.2 case 语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯213 14.3 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯216 第十五章 循环语句 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯217 15.1 选择循环类型⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯217 15.2 控制循环(Controlling The Loop)⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯222 15.3 编写循环的简单方法——从里到外⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯230 15.4 循环与数组的关系⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯232 15.5 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯233 第十六章 少见的控制结构 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯234 16.1 goto 语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯234 16.2 return 语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯243 16.3 递归调用⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯244 16.4 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯248 第十七章 常见的控制问题 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯249 17.1 布尔表达式⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯249 17.2 复合语句(块)⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯257 17.3 空语句⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯257 17.4 防止危险的深层嵌套⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯258 17.5 结构化编程的作用⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯264 17.6 用 goto 模拟结构化结构⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯267 17.7 控制结构和复杂性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯269 17.8 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯271 第十八章 布局和风格 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯272 18.1 基本原则⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯272 18.2 布局技巧⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯279 18.3 布局风格⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯280 18.4 控制结构布局⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯285 18.5 单条语句布局⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯292 18.6 注释布局⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯301 18.7 子程序布局⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯303 18.8 文件、模块和程序布局⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯306 18.9 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯311 第十九章 文档 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯313 19.1 外部文档⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯313 19.2 编程风格作文档⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯314 19.3 注释还是不注释⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯316 19.4 有效注释的关键⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯318 19.5 注释方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯322 19.6 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯337 第二十章 编程工具 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯338 20.1 设计工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯338 20.2 源代码工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯339 20.3 执行代码工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯343 20.4 面向工具的环境⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯345 20.5 建立自己的编程工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯346 20.6 理想编程环境⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯347 20.7 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯350 第二十一章 项目大小如何影响创建 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯351 21.1 项目大小⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯351 21.2 项目大小时开发活动的影响⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯352 21.3 项目大小对错误的影响⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯356 21.4 项目大小对生产效率的影响⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯357 21.5 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯358 第二十二章 创建管理 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯359 22. l 使用好的代码⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯359 22.2 配置管理⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯361 22.3 评估创建计划⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯364 22.4 度量⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯369 22.5 将程序员视为普通人⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯370 22.6 如何对待上司⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯374 22.7 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯374 第二十三章 软件质量概述 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯375 23.1 软件质量特点⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯375 23.2 提高软件质量的方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯377 23.3 各种方法的效果⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯379 23.4 何时应作质量保证⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯381 23.5 软件质量的一般原则⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯381 23.6 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯382 第二十四章 评审 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯384 24.1 评审在软件质量保证中的地位⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯384 24.2 检查⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯386 24.3 其它评审方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯389 24.4 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯391 第二十五章 单元测试 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯393 25.1 单元测试在软件质量中的作用⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯393 25.2 单元测试的一般方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯395 25.3 测试技巧⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯396 25.4 典型错误⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯404 25.5 测试支持工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯408 25.6 提高测试质量⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯411 25.7 测试记录 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯412 25.8 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯412 第二十六章 调试 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯414 26.1 概述⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯414 26 2 找错⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯417 26.3 修改错误⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯423 26.4 调协心理因素⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯425 26.5 调试工具⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯427 26.6 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯430 第二十七章 系统集成 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯431 27.1 集成方法重要性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯431 27.2 分段与递增集成⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯432 27.3 递增集成法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯434 27.4 改进的公布法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯439 27.5 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯445 第二十八章 代码调整策略 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯446 28.1 功能综述⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯446 28.2 代码调整介绍⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯448 28.3 低效率情况⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯454 28.4 代码调整方法⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯457 28.5 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯457 第二十九章 代码调试技术 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯459 29.1 循环⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯459 29.2 逻辑⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯460 29.3 数据转换⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯469 29.4 表达式⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯474 29.5 子程序⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯478 29.6 汇编语言再编码⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯484 29.7 调试技术快速参考⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯485 29.8 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯486 第三十章 软件优化 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯487 30.1 软件优化种类⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯487 30.2 软件优化指南⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯488 30.3 编写新程序⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯489 30.4 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯499 第三十一章 个人性格 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯501 31.1 个人性格是否和本书的主题无关⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯501 31.2 聪明和谦虚⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯502 31.3 好奇⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯503 31.4 诚实⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯504 31.5 交流和合作⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯506 31.6 创造力和合作⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯507 31.7 懒惰⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯507 31.8 不是你想象中那样起作用的性格⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯508 31.9 习惯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯508 31.10 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯510 第三十二章 软件开发方法的有关问题 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯511 32.1 克服复杂性⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯511 32.2 精选开发过程⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯513 32.3 首先为人编写程序,其次才是计算机⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯514 32.4 注重约定使用⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯515 32.5 根据问题范围编程⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯516 32.6 当心飞来之祸⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯517 32.7 重复⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯519 32.8 不要固执己见⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯520 32.9 小结⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯521 第三十三章 从何处获取更多的信息 ⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯522 33. l 软件领域的资料库⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯522 33.2 软件创建信息⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯523 33.3 创建之外的主题⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯523 33.4 期刊⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯524 33.5 参加专业组织⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯⋯525 第一章 欢迎进入软件创建世界 1 第一章 欢迎进入软件创建世界 目录 1.1 什么是软件创建(Software Construction) 1.2 软件创建的重要性 1.3 小结 相关章节 本书适合什么人阅读:见前言 阅读本书的益处:见前言 为什么要写这本书:见前言 大家都知道“Construction”这个词在一般情况下的意思是“建筑”。建筑工人盖房子、建 学校、造摩天大楼等时所进行的工作都是建筑。当你小的时候,你用积木进行“建筑工作”。因 此“Construction”指的是建造某个东西的过程。这个过程可能包括:计划、设计、检验等方面的 某些工作,但是,它主要是指在这其中的创造性工作。 1.1 什么是软件创建 开发计算机软件是一项非常复杂的工作,在过去的十五年中,研究者们指出了这项工作所 包括的主要方面,包括: · 问题定义 · 需求分析 · 实现计划 · 总体设计 · 详细设计 · 创建即实现 · 系统集成 · 单元测试 · 系统测试 · 校正性的维护 · 功能强化 如果你以前从事过一些不太正规的研制工作,你可能以为列出的这个表有些太详细了。而 如果你从事过一些正式的项目,你就会认为这个表非常准确。在正规性与随意性之间达到平衡 是非常困难的.这会在以后章节中讨论。 如果你是自学编程员或是主要从事非正规研制工作,你很可能还没有意识到这些在生产软 件中所需要的工作步骤。在潜意识中,你把这些工作统统称为编程。在非正式项目中,当你在 考虑设计软件时,你所想到的主要活动可能就是研究者们所指的“创建”工作。 关于“创建”的直觉概念是非常准确的,但它往往缺乏正确观点。把创建活动放到与其相 第一章 欢迎进入软件创建世界 2 关活动的背景中,有助于我们在适当重视其它非创建工作的同时,把主要精力放在正确的任务 上。图 1-1 中给出了创建活动在典型软件生存周期循环中的地位和包括的范围。 正如图中所指出的,创建活动主要指编码和调试过程,但也包括详细设计和测试中的某些 工作。假如这是本关于软件开发所有方面的书,它应该涉及到开发过程所有方面并给予同等重 视。但因为这是一本关于创建技术的手册,所以我们只重点论述创建活动及相关主题。如果把 这本书比喻成一只狗,那么它将用鼻子轻擦创建活动,尾巴扫过设计与测试,而同时向其它开 发活动汪汪叫。 创建活动有时被称作“实现”,它有时被叫作“编码和调试”,有时也称之为“编程”。“编 码”实在不是一个很好的叫法,因为它隐含着把已经设计好的程序机械地翻译成机器语言的过 程;创建则无此含义,它指的是在上述过程中的创造性和决策性活动,在本书中,将交替使用 “实现”、“编程”和“创建”。 图 l-l 软件生存周期中软件开发过程的平面图 在图 1-1 中,给出了软件开发过程的平面图示,而在图 1-2 中,则给了它的立体图示。 图 1-1 和图 l-2 是创建活动的总体图示,但是,什么是它的细节呢?下面是创建活动 中所包含的一些特定任务。 · 验证基础工作已经完成,可以进行创建工作 第一章 欢迎进入软件创建世界 3 · 设计和编写子程序与模块 · 创立数据类型并命名变量 · 选择控制结构并组织语句块 · 找出并修正错误 · 评审其它小组的细节设计和代码,同时接受其它小组评审 · 通过仔细地格式化和征集意见改进编码 · 对分别完成的软件单元进行综合 · 调整编码使其更小、更快 图 1-2 本书主要详细论述详细设计、编码、调试和单元测试(所占比例如图示) 要想更 些是创建活动呢?”。一般认 为, 1.2 软件创建的重要性 正如我们所知,改进软件质量、提高软件生产率是非常重要的。当今世界许多激动人心的 工程 同,创建活动在整个开发活动中所占 时间 件开发中处于枢纽地位。分析和设计是创建活动的基础工作,对系统进行测 试以 ikson 和 详尽地了解创建活动,请参阅目录中每一章的标题。 创建活动包括如此众多的工作,人们可能会禁不住要问:“哪 非创建活动包括:管理活动、需求分析、软件总体设计、用户交互界面设计、系统测试、 维护工作等。这其中每项工作都与创建工作一样,会直接影响到项目的最终成败(那些需要两 个人以上合作至少一星期项目的成败)。关于这其中每一项活动都有很不错的论著,在本书每一 章后都列出这些书的书名。 计划中,软件都被广泛地应用:太空飞行、航空、医学与生命保障科学、电影特技、金融 信息的快速处理、科学研究等,这仅是其中的几个例子。如果读者您也认为软件开发是重要的, 那么您就会问,为什么创建活动是重要的?原因如下: 创建活动是开发软件的重要组成部分。随项目规模不 为 30%~80%之间,在任何计划中占有如此大时间比例的活动必然会影响计划的成败,这 是不言而喻的。 创建活动在软 证实创建活动是正确的则是其后续工作,因而创建活动是软件开发的核心工作。 把主要精力集中于创建活动,可以极大地提高程序员的生产效率。由 Sackman、Er 第一章 欢迎进入软件创建世界 4 Grant 在 1968 年进行的实验表明,每个程序员的效率系数的变化范围为 10~20,这一结果随后 又被其它几个实验所证实。最优秀程序员与普通程序员的巨大差异表明,普通程序员提高效率 的潜力是非常大的。 创建活动的产品,源代码,往往是软件的唯一精确描述。在许多项目中,程序员可得到的 唯一文件便是代码本身。需求说明和设计文档可能会过时,但源代码却总是最新的。因此,源 代码必须具有最好的质量。一个软件成功与否的关键,就在于是否不断运用技术来改进源代码。 而这些技术恰恰是在创建阶段,才能得以最有效的应用。 创建活动是唯一一项必不可少的工作。理论上一个软件项目要经过精心的需求分析和总体 设计,然后再进行创建,接着对其进行彻底的、严格的系统测试。然而,实际工作中的软件项 目,往往越过前两个阶段而直接进行创建活动,最后,由于有太多的错误要修改,系统测试又 被弃之路旁。但是,不管一个项目的计划多么疏漏而又如何匆忙,创建活动都是必不可少的。 无论怎样精简,改进创建活动都是改进软件开发工作的方法。 l.3 小 结 · 创建活动是总体设计和系统测试之间承上启下的工作。 · 创建活动主要包括:详细设计、编码、调试和单元测试。 · 关于创建活动的其它称谓有:实现、编程等。 · 创建活动质量对软件质量有潜在影响。 · 在最后的分析中,对创建活动理解的好坏,决定了一个程序员素质的高低,这将在 本书其余部分论述。 第二章 利用隐喻对编程进行更深刻的理解 5 第二章 利用隐喻对编程进行更深刻的理解 目录 2.1 隐喻的重要性 2.2 如何使用软件隐喻(Software MetaPhors) 2.3 通常的软件隐喻 2.4 小结 相关章节 设计中的启发:“设计是一个启发过程”见 7.5 节 计算机科学的语言可能是所有科学领域中最丰富的。想象一下。你走进一间干净整洁、温 度严格控制在 68℉的房间,在这里,你将会找到病毒、蠕虫、臭虫、炸弹、崩溃、火焰、扭曲 的变形者、特洛伊木马和致命错误,在其它领域中,你会遇到这种情况吗? 这些形象的隐喻描述了特定的软件现象。同样形象的隐喻描述了更为广泛的现象,你可以 利用它们来加深你对软件开发的理解。 本书其余部分与本章关于隐喻的论述无关,如果你想了解实质问题可以跳过这一章。但你 要想对软件开发有更清楚的理解,请阅读这一章。 2.1 隐喻的重要性 重大发现往往是从类比中产生的。通过把一个你所陌生的事物与你所熟知的事物比较,你 会对它有进一步的认识,从而形成你对它的独到的深刻理解,这种隐喻方法被称之为“模型化”。 在科学发展史上,充满了利用类比而产生的发现。化学家 Kekle 梦见一条蛇咬住了自己的尾巴, 醒来后,他由此联想到苯的结构,提出了苯是环形分子的假说,这一假说在 1966 年被 Barbour 用实验所证实。 分子运动论是在“保龄球”模型上建立起来的。在这里,分子被假想为具有质量并且与保 龄球一样相互之间进行完全弹性碰撞的小球,并且在此基础上,又产生了许多有用的模型。 光的波动理论是在与声音类比的基础上产生的。光与声都具有振幅(亮度与音量),频率(颜 色与音调)和其它类似性质。这种类比是如此有效,以致于科学家们花费了大量时间来寻找像 空气传播声音一样传播光的物质——“以太”,但他们从来也没能找到。有时如此有效的类比这 次却导出了错误结果。 通常,模型的力量在于它能提供生动形象的概念而易被人整个接受。并提供特性、联系和 附加的疑问,有时模型会提出令人困惑的问题,这时往往是由于模型被误解了,那些建筑“以 太”的科学家们,就是因为误解了模型。 正如你所预料的,有些模型比其它的要好。好的模型要简单、与其它模型关联密切、能解释 第二章 利用隐喻对编程进行更深刻的理解 6 大部分实验事实和观测现象。 比如一个悬在铁链上来回晃动的大石头。在 Galileo 之前,Aristotelian 看到它时想到的是重 物必然要从高处落下来停在低处,他认为石头是在克服阻力下落,而当 Galileo 看到同一现象时, 他认为自己看到了一个单摆,他认为石头是在不断地重复同一运动。 这两个模型所提供的信息是截然不同的。Aristotelian 认为石头是在下落,因而他关心的是 石头的重量、升起的高度及停下所需的时间。而 Galileo 从单摆模型出发,他关心的是石头的重 量、铁链的半径、石头的角位移及石头每摆一次所需要的时间。Galileo 之所以能发现单摆定律, 就是因为他的模型与 Aristotelian 不同,从而导致他们提出了不同的问题。 隐喻对加深软件理解所做出的贡献,与它对其它领域所做出的贡献一样大。1973 年,在图 灵奖颁奖演说中,Charles Bachman 叙述了从地心说向日心说转移的过程。Ptolemy 的地心说统 治了近 1400 年。直到 1543 年,Copernicus 提出了日心说,这一思想模型的转变导致了一系列 新星的发现,把月亮定义为卫星而不是行星,也改变了人类对自身在宇宙中地位的理解。 Bachman把天文学中从地心说向日心说的转变,与 70 年代前期在计算机编程中的变化作了 个对比。在当时,数据处理正从以计算机为中心向以数据库为中心进行转变。Bachman 指出, 在旧的处理模式中,数据被当成是一个连续流过计算机的卡片流(以计算机为中心);而在新的 模式中,数据好比是一个水池,而计算机则偶尔涉足其中(以数据库为中心)。 今天,很难想象谁会认为太阳绕着地球转;也同样难以想象谁会把数据当成流过计算机的 卡片流。在这两个例子中,旧的理论一旦被抛弃,很难想象有谁会再把它捡起来。具有讽刺意 味的是,旧理论的相信者认为新理论荒唐可笑,就像我们今天看旧理论一样。 当日心说出现之后,地心说便成了那些相信它的天文学家的阻碍。同样,计算机中心模式 也已经成了那些相信它的计算机科学家的阻碍,因为我们现在已经有了数据库中心模式。 如果一旦看了新的模型,我们便说:“哦,当然正确的模型更有用,其余的都是错误的”, 那只会降低模型的作用。因为这太偏激了。科学史并不是由一系列从“错误”模型到“正确” 模型开关组成的,而是逐渐由“坏的”模型变为“较好”的模型,从包含面较窄到包含面较宽, 从覆盖领域较少到覆盖领域较多。 事实上,很多被较好模型替代的旧模型仍然在发挥作用。例如,工程师们仍然在用牛顿力 学进行工程计算,虽然它已经被相对论力学所取代。 软件科学是一门比其它学科年轻得多的学科,还很不成熟,远未形成一套标准的模型。所 以,现在拥有的是大量相互矛盾的模型。这其中有些很好,有些则很差。因此,对这些模型理 解得好坏,便决定了你对软件开发理解的好坏。 2.2 如何使用软件隐喻 软件隐喻更像是一束搜索灯光,而不是一张地图,它并不会告诉你到哪里去寻找答案;它 只给你以启发,教你如何寻找答案,而不是像数学算法一样硬性规定出到哪里找出答案。 一个公式是一套完整建立的、进行某一些任务的规则。它的结果是可以预测的、确定的, 并不取决于运气。公式会告诉你直接从 A 点走到 B 点,中间不准绕路,不准随意顺便访问 C、 D、E 或 F 点,也不准停下来闻一下玫瑰花香或者喝杯咖啡什么的,一切必须按规定来。 启发是一种帮助你寻求答案的技术。它的结果往往和运气有关,因为它只告诉你如何去 第二章 利用隐喻对编程进行更深刻的理解 7 找,而并未告诉你应该找到些什么。它不会告诉你怎样直接从点 A 到点 B.甚至很可能它根本 就不知道点 A 和点 B 在哪里。事实上,可以认为启发是一个穿着小丑儿外套的公式。它往往不 可预测,更富有趣味,不会保证一定会发生或不会发生什么。 比如,开车去某人家的公式是这样的:沿 167 号公路向南到 Sumner,从 Bonney 湖出口向 山上开 2.4 英里,借助加油站的灯光向左拐,在第一个右转弯处向右转,再拐入通向褐色房子 的公路,寻找的门牌号是北大街 714 号。 以下则是一个如何找到我们房屋的启发:找到我们寄给你的最后一封信,开车到回信地址 所说的小镇,到了镇上后随便问哪个人我们住哪儿,别担心,镇上的人都认识我们。如果你谁 也遇不到的话,就打电话找我们。 公式和启发之间的区别是微妙的,这两个例子或许会说明一些问题。从本书的角度来看, 它们之间的主要区别是:它们与答案之间的直接程度。公式给予直接指令;而启发则告诉你该 怎样找到这些指令,或者至少告诉你到哪里寻找它们。 如果有一套指令告诉你该如何解决程序中的问题,这当然会使编程变得很容易,而且结果 也可以预测了。但是编程科学目前还没有那样发达,也许永远也不会。编程中最富于挑战性的 问题便是将问题概念化,编程中许多错误往往都是概念性错误,因为每个程序在概念上都是独 特的,所以创立一套可以指导每一个问题的规则是非常困难,甚至是不可能的。这样,从总体 上知道该如何解决问题,便几乎和知道某一特定问题的答案一样重要了。 你是怎样使用软件隐喻的呢?应该用它来帮助你获得关于编程过程的内在理解,利用它们 来帮助你考虑编程活动,想象解决问题的更好办法。你不要一看到某一行代码就说这与这一章 所使用的某个隐喻相矛盾。随着时间推移,在编程过程当中使用隐喻的程序员肯定比不使用这 一方法的人编写代码更快更好。 2.3 通常的软件隐喻 随着软件的发展,隐喻越来越多,已经到了使人迷惑的地步,Fred Brooks 说写软件就像耕 种、猎狼或者在一个沥青矿坑中淹死一只恐龙。Paul Heekel 说这就像电影《白雪公主与七个小 矮人》。David Gries 说这是科学,Donald Knuth 则说这是门艺术,Watts Hamphrey 则说这是一个 过程,Peter Freeman 说这是个系统,Harlan Mills 认为这就像解数学题、做外科手术、或者是宰 一条狗,Mark Spinrad 和 Curt Abraham 说这更像是开发西部、在冰水中洗澡或者围着营火吃豆 子。 2.3.l 软件书写:写代码(Writing Code) 开发软件最原始的隐喻出自“写代码”一词。这个写的隐喻说明开发一个程序就像随便写 封信,你准备好纸、笔和墨水,坐下从头写到尾就算完成了。这不需要任何正式计划,你只是 把你要说的都写出来。 许多想法都源于写隐喻。Jon Beitle 说,你应该准备好一杯白兰地,一支上等雪茄,与你喜 欢的猎狗一同坐在火边,像一个优秀小说家一样享受一次“自由编程”。Brian 和 Kernighan 把 写隐喻风格的书称为《风格要素》(《The Elements of Style》)之后,把他们编程风格的书称作《编 程风格要素》(《The Elements of Programming Style》),程序员们则经常谈论程序的“可读性”。 第二章 利用隐喻对编程进行更深刻的理解 8 在一些小问题中,写代码隐喻可以充分描述它们。但是对于其余的问题,它就力不从心了, 它不可能全面彻底地描述软件开发过程。写往往是一种个人活动,而软件开发往往需要许多人 分担各种不同的责任。当你写完一封信时,你把它装进信封并把它寄出去后,你就再也不能改 变它的内容了,无论从哪个角度说,这项工作都已经完成了。软件的内容是很容易改变的却很 难彻底完成。几乎有 50%的软件开发工作量是在软件最初发行之后才进行的(Lientz和 Swanson, 1980)。编写软件,主要工作量集中在初始阶段。在软件创建中,把精力集中于初始阶段往往不 如在初始工作完成后,再集中精力进行代码的重新调整工作。简而言之,写隐喻往往把软件工 作表示成是一项过于简单而刻板的工作。 不幸的是,写隐喻已经通过我们这个星球上最流行的软件书——Fred Brooks 的《The Mythical Man Month》而变得永存了。Brooks 说,“扔掉一个计划,又有什么呢?”这使得我们 联想到一大堆被扔进废纸篓的手稿。当你写封家常信问候你叔叔时,准备扔掉一封信是可能的, 这也可能是 Brooks1975 年写那本书时,当时软件工程的水平。 但是,到了九十年代,再把写隐喻解释为准备扔掉一封信时,恐怕是不合时宜的。现在, 开发一个主要系统的投资已经相当于建一幢十层办公楼或造一艘远洋客轮的费用了。我们应该 在第一次调试时就完成它,或者在它们成本最低时试几次运气,其它几个隐喻较好地解决了说 明达到这一目的的方法问题。 2.3.2 软件播种:生成系统(Growing a System) 与刻板的写隐喻相反,一些软件开发者认为你应该把创建软件当作播种或培植庄稼。你设 计一小部分,编码一小部分,测试一小部分,然后在某个时候把它加到系统上,通过小步走, 你减小了每次可能遇到的错误。 有时,一项先进的技术可能是通过拙劣的隐喻来表达的。在这种情况下,应努力保留这项 技术并换一个隐喻来表达它。在这里增量技术是先进的,但是种庄稼的比喻则是十分拙劣的。 一次干一点儿的想法可能和植物生长有某种类似之处,但是耕种类比实在太牵强,而且也 令人感到陌生,因而也就很快被后面的隐喻所取代了。很难把耕种隐喻推广到每次做一点儿这 一简单想法之外。如果你来用耕种隐喻,你就会发现自己在谈论给系统计划施肥,减少详细设 计,通过有效地田间管理提高编码产量,最后收获编码。你也会谈论进行轮作,用种小麦代替 大麦,让土地休息一年以提高土壤中的养分。 软件种植隐喻的弱点是你对于软件开发失去了直接控制。你在春天播种代码,最后在秋天 收获一大堆代码。 2.3.3 软件珍珠培植法:系统积累(System Accretion) 有时候,人们在谈论种植软件而事实上他们指的是软件积累。这两个隐喻是密切联系的, 但是软件积累更深刻一些。“积累”这个词,含有通过外加或吸收,缓慢生长的意思,就像河蚌 逐渐分泌酸钙形成珍珠一样。在地质学上,水中悬浮物逐渐沉积形成陆地的过程也与此相似。 这并不是说你要从水中悬浮物里沉积出代码来;这只意味着你应该学会每次向你的系统中 加一点儿东西。另外一个与积累密切相联的词是增量。增量设计、构造、测试是软件开发的最 强有力工具之一。“增量”一词在设计者心目中还远未达到“结构化”或“面向对象设计”等的 地位,所以迄今为止也没有一本关于这方面的论述,这实在是令人遗憾的,因为这种书中所收 集的技术将具有极大的潜力。 第二章 利用隐喻对编程进行更深刻的理解 9 在增量开发中,你首先设计系统可以运行的最简单版本。它甚至可以不接受实际数据输入, 或者对数据进行处理。它也可以不产生输出,只需要成为一个坚实的骨架结构,以便能承受将 要在它之上发展的真实系统。它可以调用任何一个实现预定功能而设立的伪子程序。就像河蚌 刚开始产生珍珠的核——一粒沙子。 当你搭好骨架后,逐渐地往上添加肌肉和皮肤。你把每一个伪子程序变成真正的子程序。 此时你不必再假设产生结果了,你可以随意访问一个代码来产生结果。也不必使其假设接收输 入,你可以用同样的方法让它接收输入。你每次加入一点儿代码直到你最终完成它。 这种方法的发展是令人印象非常深刻的。Fred Brooks,在 1975 年时还认为:“应做好建造 一个扔掉一个的准备”,在 1987 年时,却说在过去的岁月里,还没有一样东西像增量概念这样 如此深刻地改变了他自己的实践或效率。 增量隐喻的力量在于:作为一个隐喻,它并没有过分作出许诺,它不像耕种隐喻那样容易 被错误延伸。河蚌育珍珠的联想对理解增量发展法或积累法有很大帮助。 2.3.4 软件创建:建造软件(building software) “建造”一词的想象比“写”或者“种植’软件的想象更为贴切,它与“增量”软件的想 法是基本一致的。建造隐喻暗示了许多诸如计划、准备、执行等工作阶段。如果你仔细研究这 个隐喻,你还会发现它还暗示着其它许多东西。 建造一个四英尺高的塔需要一双稳健的手、一个平台和十个完好的啤酒罐。而建造一个四 百英尺高的塔却决不仅仅是需要一千个啤酒罐就够了,它还需要一种完全不同的计划和创建方 法。 如果你想建一个简单的建筑物,比如说一个狗舍,你买来了木板和钉子,到下午的时候, 你已经给你的爱犬造好了一幢新房子,假设你忘了修一个门,不过这没关系,你可以补救一下 或推倒一节重新开始。你所浪费的不过是一个下午的时间罢了。这与小型软件的发展失败非常 类似。如果你有 25 行代码设计错了。那你重新再来一遍好了,你不会因此浪费许多的。 然而如果你是在造一幢房子,那修建的过程就要复杂些了,而拙劣设计的后果也严重得多。 首先,你必须决定造一幢什么样的房子,这就像软件开发中的问题定义。然后,你与建筑师必 须搞出一个你们都同意的总体方案,这和软件的总体设计是一样的。接着,你又画出细节蓝图 并找来一位承包商,这相当于软件中的详细设计。下面的工作是选好房址、打地基、建造起房 屋的框架、建好墙壁并加上屋顶、用千斤锤检查墙壁是否垂直,这同软件创建基本差不多。当 房屋的绝大部分工作已经完成时,你请来园艺师和装修师,以便使你的房间和空地得到最好的 利用,这可以与软件优化相类似。在整个过程中,会有各种监督人员来检查房址、地基、框架、 供电系统和其它东西,这也可以与软件开发中的评审和鉴定相类似。 较大的规模和复杂性往往意味着可以产生较大的成果。在修房子的时候,材料可能比较贵, 但更大的花费是劳动力。拆掉一面墙并把它移到六英尺之外是很昂贵的,但并不是因为你浪费 了许多钉子,而是因为你需要付出劳动。你应该尽可能精心设计,以避免那些本可避免的错误, 以降低成本。在开发软件过程中,材料更便宜,然而劳动力成本却更高。改变一个报告的格式, 可能与移走一幢房子里的墙壁一样昂贵,因为二者成本的主要部分都是劳动力。 第二章 利用隐喻对编程进行更深刻的理解 10 这两个活动之间还有什么类似之处呢?在建房子中,你不会去建造那些你可以现成买来的 东西,比如洗衣机、烘干机,电冰箱、吸尘器等,除非你是个机械迷。同时,你也会去购买已 经做好的地毯、门、窗和浴室用品,而不是自己动手建。如果你正在建造一个软件,你也会这 样做。你会推广使用高级语言的特点,而不是去编写操作系统一级的代码。你也会利用已经存 在的显示控制和数据库处理系统,利用已经通过的子程序。如果样样都自己动手是很不明智的。 如果你想修建一幢陈设一流的别墅,情况就不同了,你可能定做全套家具,因为希望洗碗 机、冰箱等与你的家具协调一致,同时你还会定做别具风格的门和窗户。这种定制化的方式与 一流软件开发也是非常类似的。为了这一目的,你可能创建精度更高、速度更快的科学公式。 你也会设计自己的显示控制、数据库处理系统和自己的子程序,以使整个软件给人以一气呵成, 天衣无缝的感觉。 当然这两种建造方法也要付出代价,工作的每一步都要依据事先制定好的计划进行。如果 软件开发工作的顺序有误,那么这个软件将是难以编码、难以测试和难以调试的。这可能会使 整个计划延误甚至失败,因为每个人从事的工作都非常复杂,把它们综合到一起后会使人无所 适从。 如果你在盖办公楼时工作做得不好,那么在楼内办公的人便可能面临危险。同样,如果你 在创建医药、航空电子、空中交通管制、加工控制等软件时工作做得不好,后果也可能是灾难 性的。危及别人生命是劣质软件的最可怕后果,但并不是它的唯一危害。如果公司的股东们因 为你编写了错误软件而赔钱,那也是令人遗憾的。无论如何,无辜的人们没有义务为你的工作 失误而付出代价。 对于软件作修改与建造建筑物也有类似之处。如果你要移走的那面墙壁还要支撑其它东西 而不仅仅是隔开两个房间,那么你要付出的成本将会更高。同样,对软件做结构性的修改也将 比增加或减少外设特征付出更高昂的代价。 最后,建筑类比对于超大型软件也是同样适用的。一幢超大型建筑物存在错误的后果将是 灾难性的,整个工程可能不得不返工。建筑师们在制定和审查计划时是非常仔细的,他们往往 留出安全裕度,多用 10%的材料来加强结构总比一幢大楼坍塌要好得多,同时还必须仔细注意 工时计划,在修建帝国大厦时,每辆卡车的每次卸货时间都留出了十五分钟的裕度。因为如果 有一辆卡车不能在指定时间到达指定的位置,整个计划就有可能被延误。 同样,对于超大型软件来说,计划工作需要比一般的大型软件在更高的层次上进行。1977 年,Capers Jones 估计说,对于一个拥有 750,000 行代码的系统来说,可能需要多达 600 页的 功能定义文件。对于一个人来说,不要说理解这种规模全部的设计,就是读完它也是非常困难 的。安全系数对于这种项目是必须的,制定该系统的工时计划尤为重要。当我们在建造与帝国 大厦同等经济规模的软件时,我们也需要同等严密的计划。而我们现在才刚刚开始考虑这种规 模项目的计划技术。 这两者之间的相似还可以推广到其它方面,这就是为什么建筑物创建隐喻是如此强有力的 原因。许多常用的软件词汇来源于建筑学,如:软件体系结构、搭结构架、构造、分割代码、 插入子程序等等。 2.3.5 实用软件技术:智能工具箱(The Intellectual Toolbox) 在过去的十几年中,优秀的软件开发人员们积累了几十条关于开发软件的技术和技巧,有 第二章 利用隐喻对编程进行更深刻的理解 11 些像咒语般灵验,这些技术不是规则,它们是分析工具。一个优秀的工匠知道用什么样的工 具干哪一样工作,而且知道该如何使用它们。程序员也是如此,关于编程你理解得越深入, 你的工具箱里的工具也就越多,何时何地该如何运用它们的知识也就越多。 把方法和技巧当作工具是很有益处的,因为这样可以使我们对其有一个正确的态度。不 要把最新的“面向对象设计技术”当作上帝赐予的法宝,它不过是一件在某些场合下有用, 而在某些场合下又无用的技术。如果你拥有的唯一工具就是一把锤子,那么你就会把整个世 界都当作一个钉子。好在没有人会花 500 美元一天的费用来雇佣一个仅告诉你去买一把可以 解决一切问题的锤子的研究小组,也没有人建议你丢掉你的改锥、手钻和电烙铁。 在软件开发中,常常会有人告诉你用一种方法来代替另外一种方法。这实在不幸,如果 你仅仅采用一种方法,那你就会把整个世界都当成那个工具的作用对象。你会失去用更适合 的方法解决问题的机会。工具箱隐喻有助于我们保留一切方法、技巧、技术等,并在适当的 时候使用它们。 2.3.6 复合隐喻(Combing Metaphors) 因为隐喻更像是一种启发,而不是公式,所以,它们并不是互相排斥的。你可以同时使 用增量隐喻和建筑隐喻。如果你愿意的话,你也可以采用“写”隐喻,或者把写隐喻与耕种 隐喻一起使用。只要能激发你的思想,你尽可以采用一切你认为合适的隐喻。 使用隐喻是一项模糊的事情。你不得不把它们外推到可以从中受到启发的外延中。如果 你把它过分外推或者推广到了错误方向,它很可能使你误入歧途。就像是再好的工具也有可 能被误用一样,你也可能错误使用隐喻。但是,它们的作用将无可置疑地使其成为你的智能 工具箱中的一件有力工具。 2.4 小 结 隐喻仅仅是启发,而不是公式,因此,它们更倾向于比较随便,无拘无束。 · 隐喻通过把软件开发与你所熟知的事情联系在一起,从而使你对其有更深刻的理解。 · 一些隐喻要好于其它隐喻。 · 把软件创建与建造建筑物类比,表明开发软件前要精心准备,并表明了大规模项目与小 规模项目之间的差别。 · 认为软件开发实践是智能工具箱中的工具进一步表明,每个程序员都有许多自己的工 具,没有任何一种工具是万能的。为每件工作选择合适的工具,是成为一个优秀程序员 的首要素质之一。 第三章 软件创建的先决条件 12 第三章 软件创建的先决条件 目录 3.1 先决条件重要性 3.2 问题定义先决条件 3.3 需求分析先决条件 3.4 结构设计先决条件 3.5 选择编程语言先决条件 3.6 编程约定 3.7 应花在先决条件上的时间 3.8 改变先决条件以适应你的项目 3.9 小结 相关章节 · 不同规模程序的不同条件:见第 21 章 · 管理创建:见第 22 章 · 设计:见第 7 章 在开始修造一幢房屋之前,建筑工人会评审蓝图,确认所有用料已经备齐,并检查房子的 地基。建筑工人为修建摩天大楼和修建狗舍所做的准备工作是截然不同的。但不管是什么样的 项目,准备工作总是和需要相适应的,并且应在工程正式开始前做完。 本章主要论述在软件创建之前所要做的准备工作,对于建筑业来说,项目的成败往往在开 工前就已经决定了。如果基础打得不好,或者项目计划进行得不充分,你所能做的最多也就是 防止计划失败,根本谈不上做好。如果你想做一件精美的首饰,那么就得用钻石作原料。如果 你用的是砖头,那你所能得到的最好结果不过是块漂亮的砖头而已。 虽然本章讲的是软件创建基础工作,但并没有直接论述创建工作。如果你觉得不耐烦,或 是你对软件工程生存期循环已经很熟悉了,那么请跳过本章而直接进入下一章。 3.1 先决条件重要性 优秀程序员的一个突出特点是他们采用高质量的过程来创建软件。这种过程在计划的开始、 中间和末尾都强调高质量。 如果你只在一个计划即将结束时强调质量,那你注重的只是测试。当某些人一谈起软件质 量时,他们首先想到的便是测试。然而,事实上测试只是全部质量控制策略的一部分。而且并 不是最重要的部分。测试既不能消除在正确方向上的错误工作,也不能消除在错误方向上的正 确工作的错误,这种错误必须在测试开始之前就清除掉,甚至在创建工作开始之前就要努力清 除掉它们。 第三章 软件创建的先决条件 13 如果你在一个计划的中间强调质量,那么你强调的是创建活动,这一活动是本书论述的中 心。 如果在一个计划的开始强调质量,这意味着你计划并要求设计一种高质量的产品。假设你 在过程开始时要求设计的是一种菲亚特汽车,你尽可以用你所喜欢的各种手段测试它,但是无 论你怎样测试,它也决不会变成一辆罗尔斯——罗伊斯牌汽车。或许你所得到的是一辆最好的 菲亚特汽车,但如果你想要的是罗尔斯——罗伊斯车,你就不得不从计划开始时就提出要求。 在软件开发中,当你进行诸如问题定义、规定解决办法等等计划工作时,你所进行的就是这样 的工作。 由于创建工作处在一个计划的中间,所以,当你开始创建工作时,早期的工作已经奠定了 项目成败的基础。在创建工作中,至少你应该知道自己的处境如何,当你发现失败的乌云从地 平线上升起时,赶快返回第一阶段。本章其余部分主要讲述准备工作已经作好了。 3.1.l 造成准备不足的原因 你也许会认为所有的职业程序员都懂得准备工作的重要性,并且在开始正式工作之前确认 所有的先决条件都已得到满足。不幸的是,事实并非如此。 一些程序员并不作准备工作,因为他们抵制不了立刻开始进行编码工作的渴望。如果你就 是这种程序员,那我对你有两条忠告。第一,阅读一下下一部分工作的内容提示,或许你会从 中发现一些你没想到的问题。第二,要注意自己的问题。只要创建过几个大的程序,你就会明 白强调准备工作的必要性。不要忘记自己的经验教训。 程序员不重视准备工作的另一个原因是管理人员往往不理解那些在创建先决条件上花费时 间的程序员。 Ed Yourdon 和 Tom DeMarco 等人强调准备工作已经有十五年了。在这期间,他 们不时地敲响警钟,或许有一天,管理人员们最终会明白软件开发不仅仅是编写代码。 八十年代后期,我曾经在一项军用项目的某一部门中工作。当项目进行到需求分析阶段时, 负责这个计划的一位将军前来视察。我们告诉了他目前所处的阶段,并主要谈论了文件编写工 作,而这位将军却坚持要看一下代码,我们告诉他目前还没有代码,而他却走进一间正有一百 多人工作的房间,转了一圈,企图找到谁在编码。由于未能如愿以偿,他变得有些气急败坏, 这位身材高大的将军指着自己身边的工程师喊道:“他在干什么?他一定是在写代码。”事实上, 这位软件工程师正在进行文档格式编排的工作,由于这位将军想得到代码,认为那看起来像代 码并且想让工程师编码,所以我们不得不骗他说这位工程师写的确实是代码。 这可以称为 WISCA 或 WIMP 现象,即:为什么 Sam 没有正在写代码?或 Mary 为什么没 正在编程? 如果你正在从事的项目经理像那个将军一样,命令你立刻开始编码,说声“是,长官”是 很容易的。但这是一个坏的反应,你应该还有几个替代办法。 第一,你应该平静地拒绝按照错误顺序工作。如果你与老板的关系很正常的话,那么这太 好了。 第二,你可以假装正在编码而事实上没有。把一个旧的程序清单放到桌角上,然后埋头从 事你的需求和构想文件编写工作,不管你的老板同不同意。这样你可以把工作做得更快更好。 从你老板的观点来看,这个忽视是一个福音。 第三,你可以用技术项目的开发方式来教育一下老板。这是一个好办法因为这可以增加这 第三章 软件创建的先决条件 14 个世界上开明老板的数量。在下一部分,我们将给出更多在创建活动前做好准备工作的理由。 最后,你可以另找一份工作。优秀的程序员是非常短缺的。可以找到更好的工作,干吗非 要呆在一个很不开明的程序店里徒损生命呢? 3.1.2 在进行创建工作之前必须做准备工作的论据 假设你已经登上了问题定义的山峰,与负责需求分析的人并肩走了一英里,在结构设计之 泉中,洗净了你沾满灰尘的衣服,并且沐浴在已经作好准备的纯洁之水中。那么你就会知道在 实现一个系统之前,你应该清楚需要一个系统干什么和需要怎样去干。 作为一个工程技术人员,教育你周围的人,让他们懂得技术项目的开发过程,也是你工作 的一部分。本书的这一部分可以帮你对付那些还不懂得技术项目开发过程的老板和管理人员。 它是关于进行构造设计和问题定义设计权利的延伸论据。在你进行编码、测试和调试之前,学 会这些论据,并且和你的老板推心置腹地谈谈技术项目的开发过程。 求助于逻辑推理 进行有效程序设计的关键之一就是认识到准备工作是非常重要的。在进行一项大的项目 之前,事先做好计划是明智的。项目越大,需要的计划工作量也越大,从管理人员的角度来看, 计划是指确定一个项目所需要的时间、人力、物力和财力。从技术人员的观点来看,计划是指 弄清楚你想要干什么,以免做出错误的工作而徒耗精力与钱财。有时候你自己并不十分清楚自 己想要的到底是什么?起码刚开始是这样。这时,就会比清楚知道用户需求的人要付出更多努 力,但是,这总比做出一件错误的东西,然后把它扔掉,再从头开始的成本要低得多。 建造一个系统之前,弄清楚怎样开始和如何建造它也是非常重要的,你当然不希望在完全 没有必要的情况下,浪费时间与钱财去钻死胡同而白白增加成本。 求助于类比 创建一个软件系统与其它需要耗费人力与财力的工程是一样的。如果你要造一幢房子,在 开始砌第一块砖之前,你必须事先画好建筑图与蓝图。在你开始浇铸水泥之前,你必须让人评 审你的蓝图并获得通过,在软件开发中事先做计划也与此类似。 在你把圣诞树立起来后,你才会开始装饰它,在没有修好烟囱之前你也不会点燃炉火的, 同样,也没有人会打算在油箱空空的情况下踏上旅程,在软件开发中,你也必须按照正确的顺 序来进行。 程序员处于软件开发食物链的最后一环。结构设计吃掉需求分析;详细设计者以结构设计 者为食,而他自己又成为编码者的食物。 比较软件食物链和真正的食物链,我们会发现如下事实,在一个正常的生态系统中,海鸥 以沙丁鱼为食,沙丁鱼吃鲜鱼,鲜鱼吃水虱,其结果会形成一个正常的食物链。在编程工作中, 如果软件食物链的每一级都可以吃到健康的食物,其结果是由一群快乐的程序员写出的正确代 码。 在一个被污染了的环境中,水虱在受到核沾染的水中游泳,鲫鱼体内积聚了滴滴涕,而沙 丁鱼生活的水域又遭受了石油污染,那么,不幸的海鸥由于处在食物链的最后一环,因此,它 吃的不仅仅是沙丁鱼体内的石油,还有鲜鱼体内的滴滴涕和水虱体内的核废料。在程序设计中, 第三章 软件创建的先决条件 15 如果需求定义遭受了污染,那么这又会影响结构设计,而这将最终影响创建活动。这将导 致程序员们脾气暴躁而营养不良,同时生产出遭受严重污染而充满缺陷的软件。 求助于数据 过去十五年的研究证明,一次完成是最好的选择,不必要的修改是非常昂贵的。 TKW的数据表明,在项目的初期阶段进行设计更改,比如在需求定义和结构设计阶段进行 更改,与在项目的后期,即创建和维护阶段进行更改相比较,其成本要低 50 到 100 倍(Boehm 和 Pappecio,1988)。 对 IBM 的研究也表明了同样结果。在设计开始阶段,如详细设计、编码或单元测试阶段就 消除错误,其成本要比在后期即系统测试和功能强化阶段低 10 到 100 倍(Fagan,1976)。 通常的准则是,一旦引入错误,就尽早发现和消除它。错误在软件食物链中存留的时间越 长,它的危害也就传播得越远。因为需求分析是我们做的第一项工作,因此这时引入的错误在 系统中存留时间最长,危害最大。在软件开发初期引入的错误往往比后来引入的错误传播的面 更广,这也使得早期错误会极大地提高成本。 由 Robert Dunn 总结的表 3-1,给出了由于错误引入和发现时间不同,而产生修复它们所 要耗费的相对成本差异。 表 3-1 错误引入时间 错误发现时间 需求分析 细节设计 编码 需求分析 1 — — 细节设计 2 1 — 波动测试 5 2 1 结构测试 15 5 2 功能测试 25 10 5 表 3-1 的数据表明,在需求分析阶段引入的错误,如果马上发现并消除所耗费的成本是 1000 美元的话,那么如果到了功能测试阶段才发现和消除,耗费的成本则会高达 25000 美元。这说 明我们应该尽早地发现并消除错误。 如果你的老板不相信这些数据,那你可以告诉他,立刻开始编码的程序员往往要比那些先 作计划、而后才编码的程序员花费更长的时间,由 NASA 计算机科学公司和马里兰大学联合建 立的软件工程实验室的研究表明,过分地使用计算机(进行编辑、编译、链接、测试等)往往 与低生产率紧密相联。而在计算机旁花费较少时间的程序员,往往更快地完成工作。这是由于 频繁使用计算机的程序员在进行编码和测试之前,花在计划和设计上的时间较少。 老板的意愿测试 当你认为老板已经理解了在开始创建工作之前进行准备工作的重要性,那么请进行下面 的测验以证实这一点。 下面这些说法哪些是正确的? · 我们最好马上就开始编码因为我们将会有许多测试工作要做。 第三章 软件创建的先决条件 16 · 我们没有安排许多时间进行测试,因为我们不会发现很多错误。 · 我们已经在计划和设计上花费了这么多精力,我想我们的编码和测试时不会有什么 大问题了。 以上这些都是正确的。 在本章的其余部分我们将论述如何确定先决条件是否已经得到满足。 3.2 问题定义先决条件 在进行创建工作之前你要满足的第一个先决条件,便是必须弄清楚你想要解决的问题是 什么。由于本书的中心内容是创建活动,因此我们不打算在这里论述如何进行问题定义。我们 只想告诉读者如何确认问题定义是否完成,这个定义的质量如何,是否足以作为创建活动的基 础。 问题定义只描述要解决的问题是什么,根本不涉及解决方法。它应该是一个简短的说明, 听起来像一个问题。比如“我们无法跟上指令系统”听起来像一个问题,也是一个好的问题定 义。而“我们需要优化数据入口系统以便跟上指令系统”则是一个糟糕的问题定义,它听起来 不像是个问题而更像是个解决方案。 问题定义的工作是在需求分析之前进行,后者是对问题的更为详尽的分析。 问题定义应该从用户的观点出发,使用用户的语言进行定义。一般来说,它不应该使用计 算机技术术语进行定义。因为最好的解决办法可能并不是一个计算机程序。比如说,你需要一 份关于年度利润的报告,而你已经拥有了一套能产生季度利润的计算机报表系统,如果你的思 路仅仅局限于计算机,那你可能会让人再写一个产生年度利润报告的程序加到这个系统中。为 达到这个目的,你不得不雇用一个程序员编写并调试出一段相应的程序。可是,要是你的思路 开阔一些的话,让你的秘书用计算器把四个季度的利润加到一起,问题不就解决了吗? 当然,如果问题是关于计算机本身时,就是个例外了。比如,计算机的编译速度太慢或者 编程工具的问题太多,那我们只能用技术术语来说明问题了。问题定义错误的后果是你可能浪 费许多时间精力去解决了一个错误问题。这种惩罚往往是双重的,因为真正的问题并没有解决。 3.3 需求分析先决条件 需求详细描述了一个软件系统需要解决的问题,这是找到问题答案的第一步。这项活动也 被称作“需求分析”、“需求定义”等。 3.3.1 为什么要有正式的需求 明确的需求是很重要的,因为: 明确的需求可以保证是由用户而不是程序员决定系统的功能。如果需求是很清楚的,那么 用户可以对其进行评定,并确认自己是否同意。如果需求不很清楚,那么程序员在编程过程中 就不得不自己决定系统功能,明确的需求防止对用户需求进行猜测。 明确的需求也可以避免引起争议。在开始编程之前,系统的范围已经明确确定了。如果在 编程过程中,两个程序员对系统干什么有争议,那么只要查阅一下写好的需求分析,问题就解 第三章 软件创建的先决条件 17 决了。 注意需求定义,也可以使得在开发工作开始之后,对系统作的改动最小、如果你在编码时 发现某几行有误,那么改掉这几行就是了。而如果在编码阶段发现需求有误,那么你很可能不 得不改变所有的代码以适应新的需求。 一些设计不得不被丢掉,是因为按它们的要求写好的代码不具备兼容性。新设计可能要花 费很长的时间,被一同扔掉的还有受到需求变更影响的代码和测试用例,即使未受影响的代码 部分也不得不进行重新测试,以确认其他地方的变动没有引入新的错误。 IBM、GTE、TRW 的数据表明.修正在总体结构阶段发现的需求错误,将比当时就发现并 修正的成本要高出 5 倍,如果是在编码阶段,要高出 10 倍,在单元或系统测试阶段,高 20 倍, 在验收测试阶段,高 50 倍,而在维护阶段,竟要比原来高出多达 100 倍!在较小规模的计划中, 在维护阶段修正错误的放大因子可能是 20 而不是 100,因为这时管理费用较低。但无论如何没 有人愿意从自己的收益中拿出这笔钱来。 充分进行需求分析是一个项目成功的关键,很可能比使用有效的创建技术还重要。关于如 何进行需求分析有许多好的论著。因此,我们不打算在随后的几部分中探讨如何进行需求分析。 我们只想告诉你如何确定需求分析已经完成,如何最充分地利用需求分析。 3.3.2 稳定需求的神话 稳定的需求可以说是软件开发的法宝。有了稳定的需求,软件开发工作可能从结构设计到 详细设计到编码,都平稳、顺利的进行。这简直是造就了软件开发的天堂。你可以预测开支, 不必担心最终会冒出一个让你多花 100 倍钱的错误来。 用户一旦接受了写好的需求文件,便再也不会提出更改需求,这简直是太好了。然而事实 上,在实际项目中,用户在代码写出来之前,往往并不能确切可靠地描述出他想要的到底是什 么,这倒并不是说用户是一种低级生物。正如随着工作的进行,你对其理解越来越深刻一样, 用户对自己想要的东西,也是随着项目的进行而越来越清楚的,这也是需求变动的主要原因。 -个从不变更需求的计划,事实上是一个对用户的需求不予理睬的计划。 典型的变动有多少呢?根据 IBM 的调查,对于一个典型的有一百万字的需求分析,大约 25%的内容在开发过程中要进行变动。 或许你认为卡迪拉克小汽车是空前绝后的,帝国大厦将万古永存,如果真是这样的话,那 你就相信你的项目需求永远不会更好了。如果不是这样,那么或许我们可以采取一些措施,使 得由于需求变更所造成的冲击最小。 3.3.3 在创建阶段如何对付需求变化 以下是在创建阶段,为应付需求变化而应该采取的措施。 用本部分后面的检查表来评估你的需求分析质量 如果你的需求分析不是很好,那么,停止继续工作,重新返回到需求分析阶段。当然,这 样会使人觉得你已经落后了。但是,如果你在开车从芝加哥到洛杉矶的途中,发现自己到了纽 约市郊,那么停下车来看一下地图是浪费时间吗?当然不是。因此,如果你发现方向不对,赶 紧停下来检查你的方向。 第三章 软件创建的先决条件 18 让每个人都知道由于变化需求所付出的代价 雇员们往往由于自己有了新的设计想法而激动不已。在这种兴奋驱使之下,他们往往会热 血沸腾,得意忘形。什么讨论需求的会议,什么签约仪式、什么需求文件,统统都会被他们扔 在一边。对付这种人最简单办法就是对他说:“喂,先生,你的想法不错,但是由于它不在需求 文件之中,我想先做一个变动后的进度和成本估计,然后我们再决定是立刻就采用这个想法还 是以后再说”。“时间进度”和“成本”这两个词往往比咖啡和泼冷水更管用,这样说,往往会 把许多“立刻采用”变成“最好采用”。 如果你的组织机构还没有认识到需求分析的重要性,那么就请引述本章前面“进行创建活 动前满足先决条件的安全和必要论据”一节的内容,告诉他们,在需求阶段变更设计是成本最 低的办法。 建立一套更改控制过程 如果雇员们坚持更改的热情高涨,则可以考虑建立一个审查这种更改建议的正式委员会。 用户改变主意,意识到他们的软件需要更强的功能是非常正常的。但如果他们频繁地改变主意 以至于你无法跟上他们的速度,那就不正常了。这时如果拥有一套控制更改的正式过程,那将 使大家都会感到宽慰。你感到宽慰是因为现在你只在特定的时候处理变动问题。顾客也感到宽 慰是因为有专门机构处理他们的意见,会使他们感到自己倍受重视。 用开发的方法来容纳变动 一些开发方法可以极大地扩展你应付变更需求的能力。原型化开发的方法可能帮助你在 全力以赴投入工作以前,首先了解系统的需求。渐进开发的方法是指按阶段公布系统。每次你 只做一点儿,从用户那里得到一些反馈后,你再做一些调整的改动,然后再增加一些内容。这 种方法的关键是使用短周期开发方法,以便你对顾客的需求变更迅速作出反应。 放弃项目 如果需求特别稀奇古怪或者反复无常,上面那些办法全都不起作用,那就放弃这个项目。 即使你并不能真正地砍掉这个项目,你也可以考虑一下这样做会怎么样。考虑在你砍掉这个项 目之前,事情会发展到什么地步。假如在某一情况下,的确可以把这个项目扔进垃圾箱,那么 还可以考虑一下有或没有这个项目会造成什么区别。 3.3.4 检查表 需求 这个需求检查表包含一系列关于你的项目需求的自测题。本书并没有论及如何提出一份 好的需求文件,这个检查表也同样没有。但用这个检查表,你可以检验一下在创建工作时,你 的工作基础是否牢固可靠。 并不是表中所列出的每一个问题都适用于你的项目。如果你正在从事一个非正式项目,你 会发现根本不需要考虑这个问题,你也会在其中发现一些需要考虑但并不需要回答的问题。但 如果你正在从事一个大型的正式项目,我们建议你最好还是仔细考虑每一个问题。 第三章 软件创建的先决条件 19 需求内容 · 系统的所有输入都定义了吗?包括它们的来源、精度、取值范围和频率? · 系统所有的输出都定义了吗?包括它们的目标、精度、取值范围、频率和格式? · 所有的报告格式都定义了吗? · 所有的硬件与软件接口都定义了吗? · 所有的通信界面都定义了吗?包括握手、错误检查以及通信约定? · 是否从用户的观点出发,定义了所有必要操作的反应时间? · 是否定义了时间问题,如处理时间、数据传输率以及系统吞吐能力? · 是否对用户所要求完成的任务都作出了规定? · 每项任务所需用到和产生的数据都规定了吗? · 规定保密级别了吗? · 规定可靠性了吗?包括软件出错的后果、在出错时要保护的至关重要的信息、以及错误 测试和恢复策略。 · 规定所需最大内存了吗? · 所需最大存储容量规定了吗? · 对系统的维护性是否作出了规定?包括系统对运行环境、精度、性能以其与其它软件的 接口等方面变化的适应能力规定了吗? · 是否规定了相互冲突的设计之间的折衷原则,例如,在坚固性与准确性之间如何进行折 衷? · 是否制定了系统成败的标准? 关于需求的完善性 · 在开发开始前暂时得不到的信息是什么?是否规定了不够完善的区域? · 需求定义是否已经完善到了可以成为软件标准的地步? · 需求中是否有哪一部分令你感到不安?有没有根本不可能实现,而仅仅为了取悦老板和 用户才加进来的内容? 关于需求的质量 · 需求是否是用用户的语言制定的?用户也这样认为吗? · 需求中是否每一条之间都尽量避免冲突? · 需求中是否注意了避免规定设计工作? · 需求在详细程度方面是否保持了一致性;有没有应该更详细些的需求?有没有应该更 简略些的? · 需求是否明确得可以分为一些独立的可执行部分,而每一部分又都很明了? · 是否每一条都与问题和答案相关?是否每一条都可以追溯到产生它的环境中? · 是否每一条需求都可以作为测试依据?是否可以针对每一条进行独立测试以确定是否满 足需求? · 是否对可能的改动作出了规定?包括每一改动的可能性? 关于需求定义的进一步阅读 以下是一些给出了如何进行需求定义的书: DeMarco, Tom 《 Structured Analysis and Systems Specification:Tools and Techniques 》 第三章 软件创建的先决条件 20 Englewood Cliffs,N.J:Prentice Hall,1979,这是关于需求定义的经典著作。 Yourdon,Edward 《Modern Structured Analysis》 New York:Yourdon Press,1989,这本 新书论述了许多进行需求定义的文字和图表工具。 Hatley,Derek J 和 Imtiz A. Pirbhai 《Strategies for Real-Time system Specification》Newyork : Dorset house,1988。这是一本替代 DeMarco 或 Yourdon 书的最佳选择。它重点论述了实时系统, 并把 DeDarco 和 Yourdon 提出的图表法扩展到了实时系统中。 Shlaer,sally 和 Stephen Mellor《Object Oritented System Analysis-Modeling the World in Data》Englen wood Cliffs,N.J: Prentice Hall,1988。本书讨论了面向对象设计中的需求分析。 IEEE Std 830-1984(Guide for Software Requirements Specifications) in IEEE 1991。这份 文献是 IEEE 为编制软件开发需求定义制订的指导性论述。它描述了需求定义中应该包括的内 容并给出了几个例子。 Gibson,Elizabeth《objects-Born and Bred》Byte,1990 10:245-54。这篇文章是关于面向 对象需求分析的入门书。 3.4 结构设计先决条件 软件结构设计是较高级意义上的软件设计,它是支持详细设计的框架。结构也被称为“系 统结构”、“设计”、“高水平设计”或者“顶层设计”。一般说来,结构体系往往在一个被称为“结 构定义”或者“顶层设计”的单一文件中进行描述。 由于本书是关于创建活动的,因此这部分也没有讲述如何开发软件结构。本部分的中心是 如何确定一个现存结构质量。因为结构设计比需求定义更接近于创建活动,因此对于结构设计 的描述要比需求定义详尽得多。 为什么要把结构设计当成先决条件呢?因为结构设计的质量决定了系统概念上的完整性, 而这又会决定系统的最终质量。好的结构设计可能使创建工作变得很容易,而坏的结构设计则 使创建活动几乎无法进行。 在创建活动中,对结构设计进行变动也是很昂贵的。一般来说,在创建阶段修复结构设计 错误要比修复需求错误耗时少,但比修正编码错误耗时多得多。从这个意义上来说,结构变动 与变动需求差不多,所以,无论是出于修正错误还是提高性能的动机,如果要进行结构变动的 话,那么越早越好。 3.4.1 典型的结构要素 有许多要素是一个好的系统结构所共有的。如果你是一个人在独自开发一个系统,那么你 的结构设计工作,或者说顶层设计工作,将与你的详细设计工作重叠。在这种倩况下,至少你 应该考虑每一个结构要素。如果你正在从事一项由别人进行结构设计的系统工作,你应该不费 什么劲儿就能找到其中的重要部分。下面是一些在两种情况下都需要考虑的要素。 程序的组织形式 一个系统结构首先需要一个总体上的概括性描述。如果没有的话,从成千个细节与几十个 独立模块中勾画出一幅完整的图画将是一件十分困难的事情。如果这个程序仅仅是一个由十二 第三章 软件创建的先决条件 21 块积木组成的小房子,那么或许连你那两岁的儿子也会认为这很容易。然而,对于一个由十二 个模块组成的软件系统,事情恐怕就困难得多了。因为你很难把它们组合到一起,而如果不能 把它们组合到一起,你就不会理解自己所开发的这一个模块对系统有什么贡献。 在结构设计中,你应该能找出最终组织形式的几种方案,并且应该知道为什么选中了现在 这种组织形式。如果开发模块在系统中不被重视,会使人产生挫折感。通过描述这些组织形式 的替代方案,我们就可以从结构设计中找出选择目前方案的原因,并已知道每一个模块的功能 都仔细考虑过了。回顾设计实践发现,设计理由对于维护性来说,与设计本身是同样重要的 (Rombach 1990) 在结构设计中,应该在程序中定义主要模块。在这里,“模块”并不是指子程序。在结构 设计中通常不考虑建立模块一级的子程序。一个模块是一个能完成某一高级功能的子程序的组 合,例如,对输出结果进行格式化,解释命令,从文件中读取数据等。在需求定义中列出的每 一项功能,都应该有至少一个模块覆盖这项功能。如果一项功能由两个或更多的模块覆盖,那 么它们之间应该是互补的而不是相互冲突。 每一个模块作什么应该明确定义。一个模块应该只完成一项任务而且圆满完成。对于与它 相作用的其它模块情况,你知道得越少越好。通过尽可能地降低模块之间的了解程度,就可能 把设计信息都集中在一个模块中。 每个模块之间的交界面也应该明确定义。结构设计应该规定可以直接调用哪些模块,哪些 模块它不能调用。同时,结构设计也应该定义模块传送和从其它模块接收的数据。 变动策略 创建一个软件系统,对于程序员和用户来说,都是一个逐渐学习的过程,因此在这个过程 中作出变动是不可避免的。变动产生的原因可能是由于反复无常的数据结构,也可能是由于文 件格式和系统功能改变,新的性能等而引起的。这些变动有时是为了增加新的能力以便强化功 能,也有时是版本增加而引起的。所以结构设计所面临的主要挑战便是增强系统的灵活性,以 便容纳这类变动。 结构设计应该清晰地描述系统应付变动的策略。结构设计应该表明:设计中已经考虑到了 可能的功能增强变动,而且,应该使最可能的变动同时也是最容易实现的变动。比如,假设最 可能的变动是输入或者输出格式、用户界面的方式或者处理需求,那么结构设计就应表明已经 预先考虑到了这些变动,而且,其中每一个单一的变动,只会涉及到数量有限的几个模块。在 结构设计中应付变动的手段可能是非常简单的,比如在数据文件中加入版本号,保留一部分区 域以备将来使用,或是设计一些可以添加内容的文件。 结构设计中应该说明用于延缓变动的策略。比如,结构设计中可能规定应使用表驱动技术 而不是手工编码技术。它还可能规定表所使用的文件应该保存在一个外部文件中,而不是编码 在程序中,这样,可以不必重新编译就可以对程序作出调整。 购买而不是建造的决定 创建一个软件的最彻底的办法并不是创建——而是去购买一个软件,你可以购买数据库管 理系统、屏幕生成程序、报告生成程序和图形环境。在苹果公司 Macintosh 或者微软公司 Windows 环境下编程的一个主要优点是你可以自动获得许多功能;图形程序,对话框管理程序, 第三章 软件创建的先决条件 22 键盘输入与处理程序,可以自动与任何打印机或者显示器工作的代码,等等。 如果计划中要求使用已有的程序,那它就该指出如何使这些重新被使用的软件适应新的需 求,而且它应该证明这个软件可以通过改动来满足新的需求。 Barry Boehm 在 1984 年指出:从长远观点来看,重新使用旧软件是提高生产率的首要因素。 购买代码可以降低计划、详细设计、测试和调试的工作量。 Caper Jones 在 1986 年报告如果购 买的代码从 0 上升到 50%,那么生产率可以提高一倍。 主要的数据结构 结构设计应该给出使用的主要文件、表和数据结构。同时,还应给出考虑的替代方案并评 审作出的选择。在《Software Maintenance Guidebook》一书中,Glass 和 Noiseux 认为数据结构 对系统维护有举足轻重的影响,因而,它应该在经过全盘考虑之后,才能选定(1981 年)。如 果某一应用需要维护一个用户识别表,而结构设计又选中了顺序存取表来实现,那它就该解释 为什么顺序存取表要好于随机存取表、堆栈和哈希表。在创建阶段,这些信息可以使你对结构 设计有一个比较深刻的理解。在维护阶段,这些信息也是非常宝贵的。如果没有它们,你就会 有一种看一部不带字幕的外国电影的感觉。 不应该允许一个以上的模块访问数据结构,除非是通过访问子程序,以使得这种访问是抽 象的而且是可控的。这将在 6.2 “信息隐蔽”部分中详细论述。 如果一个程序使用了数据库,那么结构中应该规定这个数据库的组织形式和内容。 最后,应该遵循数据守恒定律:每一个进入的数据都应该出去,或者与其它数据一道出去, 如果它不出去,那它就没有必要进来。 关键算法 如果结构设计依赖于某一特定算法,那它应该描述或指出这一算法。同主要数据结构一样, 结构设计中也应该指出考虑过的算法方案,并指出选中最终方案的原因。比如,如果系统的主 要部分是排序,而结构设计中又指定了排序方式是堆排序,那它就要说明为什么采用堆排序的 方法,以及未采用快速排序或插入排序的理由。如果是在对数据作出某种假定的基础上才选中 堆排序的,那就该给出这个假定。 主要对象 在面向对象的系统中,结构中应指出要实现的主要对象,它应该规定每一个对象的责任并 指出每个对象之间是如何相互作用的。其中应包括对于排序层次、状态转换和对象一致性的描 述。 结构中还应该指出考虑的其它对象,以及选择这种组织形式的原因。 通用功能 除了特定程序的特定功能,绝大多数程序中都需要几种在软件结构中占有一席之地的通用 功能。 用户界面。有时用户界面在需求定义阶段便已经规定了。如果没有的话,那就应该在结构 设计中作出规定。结构中应该定义命令结构,输入格式和菜单。用户界面的精心结构设计,往 第三章 软件创建的先决条件 23 往是一个深受欢迎的软件与被人弃之不用的软件间的主要不同之处。 这部分结构应该是模块化的,这样,当用新的界面代替旧的时,就不致影响到处理和输出 部分。比如,这部分结构应该使得用批处理接口替代交互式界面的工作非常容易。这种能力是 很有用的,特别是在单元测试和子系统测试阶段。 用户界面设计本身就值得写一部专著,但本书并未涉及这一内容。 输入/输出。输入/输出是结构中另一个应引起重视的部分。结构中应规定采用向前看、 向后看还是当前规则的查询方式。同时,还应该指出在哪个层次上检查输入/输出错误,是在 区域层次、记录层次还是在文件层次上。 内存管理。内存管理是结构设计中应该处理的另一个重要部分,结构中应该对正常和极端 情况下所需要的内存作出估计。例如,如果你正在写数据表,那么结构就应估计其中每一个单 元所需的内存。它还应估计正常表格和最大表格所需要的内存。在简单情形下,这种估计应表 明内存在某项功能的实现环境中是正常的。在复杂情况下,可能不得不建立自己的内存管理系 统,如果是这样,那么内存管理程序的设计应和系统其它部分一样,需要认真对待。 字符串存储。在交互式系统中,字符串存储也应在结构设计阶段予以重视。在这种系统中, 往往包含了大量的提示、帮助信息和状态显示。应该估计被字符串所占用的内存。如果程序是 商用的,那么,结构中应该考虑到典型的字符串问题,包括字符串的压缩,不必修改代码即可 保持字符串,以及保证在译成外文时对代码的影响将是最小的。结构设计可以决定字符串的使 用方法,是编码在程序中,还是把它保存在数据结构中。是需要时通过存取子程序调用,还是 把它存在一个源文件中,结构设计应该指明采用哪种方法及其原因。 错误处理 错误处理已成为当代计算机科学中最棘手的问题,没有谁能担负起频繁应付它的负担。有 人估计,程序中有 90%的代码是为了应付例外的错误处理或者内务处理而编写的,就是说仅有 10%的代码才是处理正常情况的。既然有如此多的代码是用于错误处理,那么在结构中阐明处 理错误的策略就是十分必要的了。以下是些需要考虑的问题: · 错误处理是纠正还是仅仅测试错误?如果是纠正错误,程序可以尝试从错误状态下恢 复。如果仅仅是测试,那么程序可以继续运行,就像什么也没有发生一样,或者直接 退出运行。但无论在哪种情况下,都应该提醒用户发现了错误。 · 错误测试是主动的还是被动的?系统可以积极地预防错误,如通过检验用户的输入是 否合法,当然也可以消极地在无法回避它们时才做出反应。例如,用户的一系列输入 产生了溢出,你可以清除,也可以滤除信息。同样,无论哪种方案,都要提醒用户。 · 程序是怎样对付错误的?一旦测试出错误,程序可以立刻抛弃产生错误的数据,也可 以把它当作错误而进入错误处理状态,还可以等到全部处理完毕后再通知用户数据有 误。 · 处理错误信息的约定是什么呢?如果结构设计中没有规定某种策略。那么用户界面在 程序的不同部分就会像迷宫中的通道一样忽东忽西,让人摸不着头脑。为避免出现这 类问题,结构设计中应建立一套处理错误信息的约定。 · 在程序中,应该在哪一个层次上处理错误呢?你可以在发现的地方立即处理,也可以 把它交给一个错误处理子程序去处理,或者交给更高层次的子程序处理。 第三章 软件创建的先决条件 24 · 每一个模块检验输入数据合法性的责任级别有多高?是每一模块仅检验它自己的数据, 还是由一级模块来检验整个系统的数据?是否每个层次上的模块都可以假定输入其中 的数据是合法的? 坚固性 (Robustness) 坚固性是指在发现错误后,一个系统继续运行的能力。在结构设计中需要从几个方面表述 坚固性。 裕度设计(over-engineering)。在结构设计中应该明确表述所要求的系统裕度有多大。 结构设计中规定的裕度往往比需求定义中规定的要大。一个原因是由于系统是由许多部分组成 的,这会降低其总体坚固性。在软件链条中,其强度不是由最薄弱的一环决定的,而是由所有 薄弱环节的乘积决定的。 清楚地表述所要求的裕度级是非常重要的,这是因为程序员出于职业素养,会不自觉地在 程序中留出裕度。通过清楚地规定裕度级,可以避免某一部分裕度过大,而另一部分又过小的 现象发生。 断言(assertions)。结构中还应该规定断言的使用程度。断言是指一段放在代码中,当代 码运行时可以使其自检的可执行语句。如果断言显示出正确信息,那么表明一切都正常运行。 如果显示出错误信息,那么表明它在程序中发现了错误。比如,系统假定用户信息文件永远不 会超过 5000 记录行,那么程序中可能会包含一段说明这个假定的断言。只要这个文件不超过 5000,那么断言就保持沉默,而一旦断言发现此文件超过了 5000 个记录行,那它就会声称已发 现了一个错误。 为了在程序中加入断言,你必须知道在设计系统时所做的假设,这也是在结构设计中应阐 明采用假设的原因之一。 容错性(fault tolerance)。结构设计应指明所期望的容错性类型,容错性是指通过测试 错误、修正错误或在不能修复时容错等一系列方法,来提高系统可靠性的技术。 例如,一个可以采用如下办法来容忍求算术平方根时的错误。 · 系统可以返回并重新开始。如果发现结构有误,系统可以返回到正常的部分并重新开 始。 · 当发现错误时,系统可以用辅助代码来代替基本代码。如果第一个结果看起来是错的, 系统将使用另一个备用求平方根子程序重新计算一遍。 · 系统可以采取投票算法。可以用三种不同的方法算平方根,每一个子程序求一个平方 根,由系统作出比较。根据系统所采用的容错种类,最终结果可能是三者的平均,其 中的中间值就是占优势的那一个值。 · 系统可以用一个假想值来代替错误的结果,以避免对程序其余部分的不良影响。 其它的容错方式包括:在测试出错误后,只让系统部分运行或者系统功能降级,关闭自己 或者自动重新开始等,这些例子是非常简单的。容错性是一个非常诱人而又复杂的学科,但它 也不在本书讨论之列。 性能 如果考虑到性能,那么在性能需求中应该考虑性能目标。性能目标包括速度和内存使用。 第三章 软件创建的先决条件 25 结构设计要对这些目标作出估计,并解释为什么这些目标是可以达到的。如果某个域可能 有达不到目标的危险,或者,如果某个域要求使用特定的算法或者数据结构来达到某一目标, 在结构设计中也应指出这点。结构设计还应该规定每一个模块或目标的时间和存储空间预算。 通用的结构设计质量准则 一个好的结构设计特征包括;对于系统中模块的讨论,每个模块中隐含的信息,选用和不 选用某方案的原因。 这个结构应该是一个近乎完美的整体概念。关于软件工程的最权威的著作<>,其中心思想便是认为概念完整性是最重要的(Brooks,1975)。一个好的结构 设计应满足这一条,当看到这个结构设计时,应该为其解决方案的自然和简单而折服。而不会 有把问题和答案生拼硬凑到一起的感觉。 你或许知道在开发过程中变动结构设计的途径。每一次变动都应与总体和设计概念相符。 不能使最终完成的结构设计看起来像是一个穿着由各种碎布拼凑起来的百家衣的乞丐。 结构的目标应该清楚地说明。一个以可变性为首要目标的结构设计可能与一个以性能为首 要目标的结构设计差之千里,虽然二者的功能可能是完全一样的。 结构中作出每一个决定的动机都要阐明清楚。要当心“我们过去一直是这么干的”的理由。 有这样一个故事,会给我们启迪。Beth 想按照她丈夫的家传方法做一道红烧牛肉。她的丈夫 Abdul 告诉她,要先把牛肉放在盐和调料中腌一下,再剁掉肉的两边,把中间部分放进锅里, 盖上盖儿焖一下就可以了。Beth 问:“为什么要剁掉肉的两边?”Abdul 说:“我不知道,我总 是这样做的,我们问一下妈妈吧”。便打电话问妈妈、Abdul 的妈妈则说是他的外祖母告诉她的。 于是电话打到了 Abdul 的外祖母那儿,她的外祖母奇怪地说:“我也不知道你们为什么那样做, 我那样做不过是因为肉块太大,放不进锅里”。 好的软件结构往往是机器和语言相互独立。当然,我们不能忽略系统的实现环境。然而, 通过尽量减少对实现环境的依赖性,你可以避免过分地结构化系统,并且使你可以在创建阶段 把工作做得更好。但如果程序专门是为某一机型或语言设计的,那么本条不适合。 结构设计应该恰好在过分定义和定义不足的分界线上。结构中不应该有任何部分受到了它 不应受的重视。设计者不能以牺牲某一部分为代价来重视另一部分。 最后,结构中不应该有任何部分让你感到不舒服。它不应该含有任何仅仅为取悦老板而加 上去的部分。你是最终实现它的人,如果你根本读不懂它,又谈何实现呢? 3.4.2 检查表 结构设计 一个好的结构设计应该阐明所有问题。这个表并不是用于指导结构设计的,而只是想提供 一种方法,通过它,你可以估计处于软件食物链顶层的程序员可以从食物中获得多少营养。它 可以作为建立自己的检查表的起点。同需求定义检查表的使用一样,如果你正在从事一个非正 式的项目,那么其中有些条款是不必考虑的。但如果你正在开发一个较大的系统,那绝大部分 内容都是非常有用的。 · 软件的总体组织形式是否清晰明了?包括对于结构设计的总体评论与描述。 · 模块定义是否清楚?包括它们的功能及其与其它模块的接口。 第三章 软件创建的先决条件 26 · 需求定义中所提出的所有功能,是否有恰当数量的模块覆盖? · 结构设计是否考虑了可能的更改? · 是否包括了必要的购买? · 是否阐明了如何改进重新启用的代码来满足现在的结构设计需求? · 是否描述并验证了所有主要的数据结构? · 主要数据结构是否隐含在存取子程序中? · 规定数据库组织形式和其它内容了吗? · 是否说明并验证所有关键算法? · 是否说明验证所有主要目标? · 说明处理用户输入的策略了吗? · 说明并验证处理输入/输出的策略了吗? · 是否定义了用户界面的关键方面? · 用户界面是否进行了模块化,以使对它所作的改动不会影响程序其它部分? · 是否描述并验证了内存使用估算和内存管理? · 是否对每一模块给出了存储空间和速度限制? · 是否说明了字符串处理策略?是否提供了对字符串占用空间的估计? · 所提供的错误处理策略是不是一致的? · 是否对错误信息进行了成套化管理以提供一个整洁的用户界面? · 是否指定了坚固性级别? · 有没有哪一部分结构设计被过分定义或缺少定义了?它是否明确说明了? · 是否明确提出了系统目标? · 整个结构在概念上是否是一致的? · 机器和使用实现的语言是否顶层设计依赖? · 给出做出每个重要决定的动机了吗? · 你作为系统实现者的程序员,对结构设计满意吗? 3.5 选择编程语言先决条件 实现系统的语言对你来说是有重大意义的,因为从创建工作开始到结束你都要沉浸其中。 研究表明,程序语言选择可以通过几方面影响生产率和编码质量。 当程序员使用自己所熟悉的语言时,其工作效率要比使用陌生的语言高得多。TRW 公司的 数据表明,两个水平和经验相当的程序员如果一个用一种他已用了三年的语言编程,而另一个 则用一种他所陌生的语言编程,那么前者的效率要比后者高 30%。IBM 的调查表明,一个在某 种语言上经验丰富的程序员,其效率要比在这种语言上没什么经验的程序员高三倍(Walston 和 Felix 1977)。 使用高级语言编程,其效率和质量要比使用低级语言高得多。Pascal 和 Ada 语言的效率、 可靠性、简单性和可懂性是低级语言,如汇编和机器语言的 5 倍(Brooks 1987)。由于不必每 次都为机器正确地执行了指令而欢呼,你当然可以节省许多时间。同时,高级语言的表达能力 第三章 软件创建的先决条件 27 比低级语言要高,这样,它的每一行代码就可以表达更多的内容。表 3-2 给出了在代码量相同 的情况下,高级语言所表达的原指令与低级语言的比值(以汇编语言为代表)。 表 3-2 高级语言指令与低级语言指令比 语言 比值 汇编语言 1:l Ada l:4.5 Quick/Turbo Basic l:5 C 1:2.5 Fotran 1:3 Pascal l:3.5 IBM公司的数据从另一个方面指出了语言特性是如何影响效率的,用解释语言工作的程序 员往往比用编译语言工作的程序员的效率更高(Jones 1986)。许多种语言都有解释和编译两种 形式(如多种版本的 c 语言),你可以用高效率的解释形式,然后再把它们转换成更容易执行的 编译形式。 一些语言比其它语言更擅长解释编程思想。你可以把自然语言(如英语)和程序语言(如 Pascal 和汇编语言)进行对比。在自然语言中,语言学家 Sapir 和 Whorf 提出的假想指出,在一 种语言的表达能力和其所能思考的问题之间存在着联系,你思考某一问题的能力取决于你所懂 得的关于这一问题的词汇。如果你不懂那些词汇,那你也就不能表达那些思想,你甚至根本无 法形成那些思想。 程序员也可能同样受到他所懂得的程序语言限制。在某种程序语言方面你所懂得的词汇, 当然会决定你如何表达你的编程想法,还很可能决定你将表达什么样的思想。 程序语言影响程序员的思想方法。一个典型的故事是这样说的:“我们正用 Pascal 语言开发 一个新的系统,而我们的程序员们却并不熟悉 Pascal 语言,他们都是搞 Fortran 语言出身的。结 果他们写出的是用 Pascal 编译的代码,但是他们真正使用的却是变形的 Fotran 语言。他们用 Fortran 的不好的特性(goto 语句和全局数据)歪曲了 Pascal 语言,而同时又把 Pascal 丰富的控 制和数据结构弃之不用”。这种现象在整个软件业都有报道(Hanson 1984,Yourdon 1986)。 3.5.1 语言描述 某些语言的发展史同其通用功能一样令人感兴趣。以下是关于一些在本书中所举的例程中所 出现的语言的描述。 Ada 语言 是一种在 Pascal 语言基础上发展的通用高级语言,它是在国防部的要求和资助下发展起来 的,特别适用于实时和嵌入式系统。Ada 强调数据抽象和信息隐蔽,迫使你区分模块的公共和 局部部分。 把这种语言命名为“Ada”是为了纪念数学家 Ada lovelace,她被公认为世界上的第一个程 序员,从 1986 年起,北约组织和国防部的所有关键任务嵌入式系统都采用 Ada 语言。 第三章 软件创建的先决条件 28 汇编语言 汇编语言,是一种低级语言,每一条语句都与一条机器指令相对应。由于语句使用特定的 机器指令,所以汇编语言是针对特定处理器的,比如 Intel 80x86 或者 Motorala 680x0。汇编是 第二代计算机语言,除非是执行速度或代码空间的需要,绝大多数程序员都避免使用它。 Basic 语言 Basic 是由 Dartmouth 学院的 John Kemeny 和 Thormas Kurtz 开发的一种高级语言。由字首 组成的 BASIC 的意思是初学者的全功能符号指令代码(Beginner’ s All-Purpos Symbolic Instruction Code),Basic 主要用于教学生们编程。由于 IBM-PC 机包含了它而使其在微机中风 行一时,Basic 原来是一种解释性语言,现在则解释性和编译性两种形式都有。 C 语言 C是一种中级通用语言,本来是和 UNIX 操作系统相关的。C 有某些高级语言的特点,例 如,结构化数据、结构化控制流、对于机器的独立性、丰富的操作指令等。它也被称作“可移 植的汇编语言”,因为它广泛地使用了指针和地址,具有某些低级组成部分,如位操作,而且是 弱类型的。 C 是在七十年代由贝尔实验室 Dennis Ritchie 开发的。C 本来是为 DEC PDP-11 设计的, 它的操作系统、C 编译器和 UNIX 应用程序都是用 C 编写的。1988 年,ANSI 公布了 C 的编码 标准,这成了微机和工作站编程的通用标准。 C++语言 C++,是一种面向对象的语言,与 C 相似,由贝尔实验室的 Bjarne Stroustrup 于 1980 年开 发,除了与 C 兼容之外,C++提供了多态性和函数重载功能,同时,它还提供了比 C 更坚固的 类型检查功能。 Fortran 语言 Fortran 是一种高级语言,引入变量和高级循环的概念。Fortran 代表 Formula Translation, 即公式翻译的意思。 Fortran 最初是在五十年代由 Jim Bckus 开发,并且做过几次重大修订.包 括 1977 所发表的 Fotran-77,其中增加了块结构化的 IF-THEN-ELSE 语句和字符串操作。 Fortran-90 增加由用户定义的数据类型、指针、模块和丰富的数组操作。在写本书的时候(1992 年末)。Fortran 标准是如此引发争议,以致绝大多数语言商都没能最终完成它。本书中所引用 的是 Fortran-77 标准。Fortran 语言主要在科学和工程计算中使用。 Pascal 语言 Pascal 是为了教学目的而开发的高级语言。其主要特征是严格的类型、结构化控制创建和 结构化数据类型。它是在六十年代末由 Niklaus Wirth 开发,到了 1984 年,由于 Borland 国际 公司引入了微机使用的低成本编译程序,Pascal 就流行起来了。 第三章 软件创建的先决条件 29 3.5.2 语言选择快速参考表 表 3-3 给出了关于不同语言适用范围的简略参考。它也可以帮你选择应该进一步了解的语 言。但是,不要试图用它来代替对你某一特定计划进行语言选择时的详细评估。以下的排序是 很粗略的,因此阅读时应仔细辨别,因为很可能会有许多例外。 表 3-3 适于不同种类程序的最差和最好语言 程序类型 最好语言 最差语言 结构化数据 Ada、 C++、 Pascal 汇编、Basic 快速而杂乱的项目 Basic Pascal、Ada、 汇编 快速执行 汇编、C 解释性语言如 Basic 数学计算 Fortran Pascal 易于维护的程序 Pascal 、Ada C 、Fortran 动态内存使用 Pascal、C Basic 在有限内存环境下运行 Basic、汇编、C Fortran 实时程序 Ada、汇编、C Basic 、Fortran 串操作 Basic 、Pascal C 3.6 编程约定 在高质量软件中,你可以发现结构设计的概念完整性与较低层次实现之间的密切联系。这 种联系必须与指导它的结构设计保持一致,而且,这种一致应该是内在的。这就是实现时在给 变量和子程序命名、进行格式约定和注释约定时的指导方针。 在复杂的软件中,结构设计指导方针对程序进行结构性平衡,而实现指导方式则在较低层 次上实现程序的和谐统一,使得每一个子程序都成为总体设计的一个可以信赖的组成部分。任 何一个大的软件系统都需要结构控制,以便把编程语言的细节统一到一起。大型系统的完美之 处便是它的每一个细节都体现了它的结构设计风格。如果没有一个统一约束,那么你的软件只 能是一个由各种风格不同的子程序拼凑到一起的拼盘而已。 即使你有一个关于一幅画的美妙总体构思,但如果其中一部分是用古典手法的,另一部分 是印象派的,其余则是超现实主义风格的,那么,再美妙的构思又有什么用呢?不论其中每一 部分是如何密切联系主题的,这幅画的概念完整性都将荡然无存。同样,程序也需要较低层次 上的完整性。 在创建工作开始之前,一定要写明你将要采用的编程约定、约定说明一定要写得非常详尽, 使得在编程过程中无法对其进行改动。本书提供了许多非常详细的约定。 3.7 应花在先决条件上的时间 用于问题定义、需求分析和软件结构设计的时间,随项目需要的不同而不同。一般来说, 一个运行良好的项目通常把 20~30%的时间用于先决条件。这 20~30%的时间中不包括进行详 细设计的时间,因为它是创建活动的一部分。 第三章 软件创建的先决条件 30 如果你正从事一个正式项目,而需求又是不稳定的,那么,你将不得不与需求分析员一道 解决需求定义问题,拿出你的一部分时间与需求分析员讨论,并给需求分析员一定时间以便让 他重新征求用户意见,可以使需求定义更适合项目需要。 如果你从事的是一个非正式项目,而需求是不稳定的,应该给需求分析留出足够的时间, 以免反复无常的需求定义影响你的创建工作。 如果需求对于任何项目——不管是正式还是非正式的,都是不稳定的,那你就该亲自从事 需求分析工作。当完成需求分析后,再估计从事项目其余部分所需要的时间。这是一个很明智 的办法,因为在你知道自己将作些什么之前,你是不可能知道需要多长时间来完成它的。打个 比方,假设你是一个建筑承包商,你的顾客问:“这项工程要花多少钱?”你则问他要干些什么, 而他却接着说:“我不能告诉你,我只想知道工程要花多少钱?”这时你最好对他说声谢谢,然 后吹着口哨回家吧。 在建筑中,在知道要建什么之前,就进行工程预算显然是荒谬的。在设计师完成草图之前, 老板是不会问要用多少水泥、钉子和木材的。但人们对于软件开发的理解往往不是如此清楚的, 所以你的老板可能一时还弄不明白为什么要把需求分析当作一个单独的项目,这时你就需要作 出解释。 3.8 改变先决条件以适应你的项目 先决条件随项目规模和正式性不同而变化。本章指出了大规模和小型项目之间先决条件的 判别,可以根据项目的特点对先决条件作出合适的调整。要想详细了解大项目与小项目之间的 不同,请参看第 21 章“程序规模是如何影响创建活动的”。 3.9 小 结 · 如果想开发一个高质量的软件,必须自始至终重视质量问题。在开始阶段强调质量往 往比在最后强调质量更为有效。 · 程序员的份内工作之一便是向老板和同事宣传软件的开发过程,包括在编程开始前从 事先决条件准备工作的重要性。 · 如果问题定义工作做得不好,那么在创建阶段,所解决的问题可能并不是用户真正要 解决的问题。 · 如果需求分析工作做得不好,很可能因此而漏掉要解决问题中的重要细节。在创建工 作后更改需求,要比在需求分析阶段进行更改的成本高 20 到 100 倍。所以,在开始编 程前一定要确认需求定义工作一切正常。 · 在编程前规定好约定,在创建工作结束后再改变代码来满足约定几乎是不可能的。 · 在创建活动开始之前如果无法完成准备工作,可以尝试在不太稳固的基础上进行创建 活动。 第四章 建立子程序的步骤 31 第四章 建立子程序的步骤 目录 4.1 建立程序步骤概述 4.2 程序设计语言(PDL) 4.3 设计子程序 4.4 子程序编码 4.5 检查子程序 4.6 小结 相关章节 高质量程序的特点:见第 5 章 高层次设计;见第 7 章 注释方式:见第 19 章 创建工作先决条件:见第 3 章 本章详细讲述了在建立一个子程序时的典型步骤。虽然从广义上讲,你可以把本书所有的 描述都当作是在讲如何建立程序,但本章把这些步骤放在同一背景下讲述。本章的中心内容是 如何编写小规模的程序,以及编写对各种规模项目都十分关键的程序的特定步骤。本章也描述 了从程序设计语言(PDL)到编码的转换过程,几乎没有哪些程序员充分利用了这一过程所带来 的方便,这部分论述会给大家以启迪。 4.1 建立程序步骤概述 在建立程序过程中引入的许多低层次细节问题,并不需要按某一特点顺序来进行,但是一 些主要活动——设计程序、检查程序、子程序编码、检查代码,则应该按图 4-1 的顺序来进行。 4.2 程序设计语言(PDL) PDL(程序设计语言)是由 Came,Father 和 Gordon 共同开发的,在 1975 年发表之后.曾 作过重大修改。因为 PDL 是在模仿英语,所以认为任何像是英语的 PDL,都可以正确表达思想 是很自然的。但是,事实上 PDL 之间的好坏是有判别的。下面是有效使用 PDL 的一些方针: · 用模拟英语的语句来精确描述每一个特定操作。 · 避免使用最终程序语言的语句。PDL 使你比在代码稍高级的层次上进行设计工作。 当使用程序语言进行创建时,就又回到了低层次上,从而得不到由于在高层次上 进行设计的好处,而且会受到不必要的程序语言语法规则的限制。 · 在设计意向这一层次上写 PDL。说明方法的意义,而不是描述如何用目标语言实现。 第四章 建立子程序的步骤 32 图 4-1 创建子程序过程中主要活动顺序示意图 · 在足够低的层次上写出 PDL,它几乎可以自动生成代码。如果 PDL 写得太简略,可能 会在编码过程中忽略问题细节。应该精确地使用 PDL 以方便编码。 当 PDL 写好之后,就可以根据它来编码,而 PDL 则成为程序语言的注释。这可以省去大量 的注释工作。如果 PDL 遵循了这一指导方针,那么注释将是非常完备而且富有意义的。 以下则是一个几乎违背了上述所有原则的错误使用 PDL 的例子: Increment resource number by l allocate a dlg struct using malloc if malloc() returns NULL then return l invoke OSrsrc _init to initialize a resource for the operation system * hRstcPtr=resource number return 0 这个 PDL 的意图是什么?由于它写得很糟糕,因此很难说清楚。之所以称之为一个错误使 用 PDL 的典型,是为它使用了像*hRstcPtr 这种特定的 c 语言指针标志和 malloc( )这个特定的语 言函数,即它采用了代码语句。这段 PDL 的中心是如何写代码,而不是说明设计意义。不管子 程序返回 1 还是返回 0,这段 PDL 都引入了代码细节。如果从是否变为一个好的注释的观点来 看这段 PDL,你就会发现它毫无意义。 以下是对同一个操作的设计,使用的是大大改进了的 PDL: Keep track of current number of resource in use If another resource is available Allocate a dialog box structure If a dialog box structure could be allocated Note that one more resource is in use Initialize the resource Store the resource number at the location provided by the caller Endif Endif 第四章 建立子程序的步骤 33 Reture TRUE if a new resource was created; else return FALSE 这段 PDL 要好于前一个。因为它完全是用自然语言写成的,没有使用任何目标程序语言语 句。在第一段 PDL 中,它只能用 C 语言来实现,而第二段却并没有限制所使用的语言。同时, 第二段 PDL 也是在意图层次上写成的。第二段 PDL 的意图是什么?其意图理解起来比前一个要 容易多了。 尽管第二段 PDL 是完全用自然语言写成的,但它却是非常详细和精确的,很容易作为用程 序语言编码的基础。如果把这段 PDL 转为注释段,那它则可以非常明了地解释代码的意图。 以下是你使用这种风格的 PDL 可以获得的益处: · PDL 可以使评审工作变得更容易。不必检查源代码就可以评审详细设计。它可以使详 细评审变得很容易,并且减少了评审代码本身的工作。 · PDL 可以帮助实现逐步细化的思想。 从结构设计工作开始,再把结构设计细化为 PDL, 最后把 PDL 细化为源代码。这种逐步细化的方法,可以在每次细化之前都检查设计, 从而可以在每个层次上都可以发现当前层次的错误,从而避免影响下一层次的工作。 · PDL 使得变动工作变得很容易。几行 PDL 改起来要比一整页代码容易得多。你是愿意 在蓝图上改一条线还是在房屋中拆掉一堵墙?在软件开发中差异可能不是这样明显, 但是,在产品最容易改动的阶段进行修改,这条原则是相同的。项目成功的关键就是 在投资最少时找出错误,以降低改错成本。而在 PDL 阶段的投资就比进行完编码、测 试、调试的阶段要低得多,所以尽早发现错误是很明智的。 · PDL 极大地减少了注释工作量。在典型的编码流程中,先写好代码,然后再加注释。 而在 PDL 到代码的编码流程中,PDL 本身就是注释,而我们知道,从代码到注释的花 费要比从注释到代码高得多。 · PDL 比其它形式的设计文件容易维护。如果使用其它方式,设计与编码是分隔的,假 如其中一个有变化,那么两者就毫不相关了。在从 PDL 到代码的流程中,PDL 语句则 是代码的注释,只要直接维护注释,那么关于设计的 PDL 文件就是精确的。 作为一种详细设计工具,PDL 是无可比拟的。程序员们往往愿意用 PDL 而不愿使用缺陷表。 事实上程序员们愿意使用缺陷表以外的任何工具,调查表明,程序员们愿意使用 PDL,是因为 它很容易用程序语言实现,而且 PDL 可以帮助发现详细设计中的缺陷,并且 PDL 也很容易写 成文件,改动也很方便,PDL 并不是详细设计的唯一工具,但是 PDL 和 PDL 到代码流程的确 是有用的工具。不妨试一下。在随后的几部分中,我们将告诉你如何使用它们。 4.3 设计子程序 创建一个子程序的第一步是设计。假设想设计一个根据错误代码输出错误信息的子程序, 并且把这个子程序称为 RecordErrorMessge(),以下是关于 RecordErrorMessage()的需求定义: RecordErrorMessage()的输入变量是非法代码,输出是与这个非法代码相对应的错误信息, 它负责处理非法代码。如果程序运算方式是交互式,那么这个错误信息就打印给用户。如果运 行方式是批处理式的,那么这个信息就送入一个信息文件。在输出信息后,这个子程序应该能 返回到一种状态,指出程序是否成功。 第四章 建立子程序的步骤 34 在本章的其余部分,用这个子程序作为一个实际例子。这一部分的其余内容将论述如何设 计这个子程序,设计这个子程序所需要进行的活动见图 4-2。 检查先决条件。在进行与子程序有关的任何工作之前,首先检查是否定义了这个子程序的 工作任务,这项任务是否和整个结构设计融为一体?通过检查确定是否这个子程序被调用了? 至少,在项目的需求定义中就涉及到它。 定义这个子程序将要解决的问题。应该足够详尽地规定它需要解决的问题,以便于创建。 如果结构设计是非常详尽的,那么这项工作可能已经完成了,结构设计应该至少指出以下这些 问题: · 这个子程序将要隐含的信息。 · 这个子程序的输入。 · 这个子程序的输出,包括受到影响的全局变量。 · 这个子程序将如何处理错误? PDL PDL 图 4-2 设计程序中的所有实现步骤 下面是在 RecordErrorMessage()这个子程序中,上述考虑是如何得以阐明的。这个子程序隐 含了如下两个事实;错误信息与现存的处理方式(交互式或者批处理),子程序的输入是非法代 码,要求两种输出方式:第一是错误信息;第二是 RecordErrorMessass()子程序返回到调用它的 程序时的状态。 问题说明之后,并没有直接给出解决方案。假设以这个例子来说,程序约定是在发现错误 时立即报告。在这种情况下,这个子程序必须报告它所发现的每一个错误,假定其它错误都已 经报告过了。根据要求,这时子程序应把状态变量设置为失败。 给子程序命名。给子程序命名似乎是小事一桩,但好的子程序名字往往是一个高质量软件 的标志之一,而且,命名并不是件容易的事情。一般来说,子程序应该有一清楚的、不容易引 起异义的名字。如果在给程序找一个好名字时感到困难,这往往意味着对程序的功能还不十分 清楚。一个模棱两可的名字就像是一个在进行竞选辩论的政治家,似乎他在说着什么,可是当 第四章 建立子程序的步骤 35 你仔细听时,又分辨不出他的话到底有什么意义、应尽量将名字起得清楚。如果产生一个模棱 两可名字的原因是模棱两可的结构设计,那么就应注意这个危险信号,这时应返回去改进结构 设计。 在这个例子中,RecordErrorMessage()的含义是很清楚的,因此是个好名字。 决定如何测试子程序。在编写子程序时,最好能同时考虑如何测试。这对进行单元测试工 作是很有益处的。 在这个例子中,输入很简单,就是错误代码。因此,可以计划用全部有效错误代码和一系 列无效代码来进行测试。 考虑效率。根据所处的情形,你可以用一种或两种方式来说明效率。 在第一种情形下,程序的绝大部分,性能并不是主要的,在这种情况下,应该把子程序作 成高度模块化而且具有很强的可读性,以便在今后需要时很容易对其作出改进。如果其模块化 程度很高,就可以在需要时,用更好的算法或者汇编语言编写的子程序来代替速度较慢的程序 而不致影响程序其它部分。 在第二种情形下,在大部分程序中,性能都是很重要的,这时,结构设计应该对子程序的 运行速度和允许使用的内存作出规定,只要按照速度和空间指标设计子程序就可以了。如果速 度和空间只有一方面是主要的,则可以牺牲一方面来满足另一方面的要求。在初始创建阶段, 对子程序作出足够调整以使它满足速度和空间要求是合理的。 除了以上指明的情况以外,不必浪费精力去考虑个别子程序的执行效率。优化的收益主要 来自高层次设计,而不是个别子程序、只有在高层次设计某方面有缺陷时,才需要进行微观优 化,而这点只在程序全部完成时才会知道。除非必要,不要浪费时间进行增量改进。 研究算法和数据结构。同时提高编码质量和效率的最有效办法是重新使用好的代码。在 学术文章中,已经有许多种算法被发明、讨论、检验和改进过。因此,与其花费时间去发明一 种别人已经为之写过博士学位论文的东西,倒不如花几分钟测览一个算法论著,看有多少种算 法可供选择。如果想使用某种已有的算法,切记要对其做出改进以适应你的程序语言。 编写 PDL。在做完上述工作之后,编写的时间可能已经不多了。本步骤的主要目的是,建 立一种可以在实际编写子程序时提供思想指导的文件。 在主要工作步骤完成之后,可以在高层次 PDL 水平上编写子程序。可以使用编辑程序或者 整体环境来编写 PDL,很快,这些 PDL 就将成为用程序语言编码的基础。 编写工作应该从抽象到具体。一个子程序最抽象的部分便是最开始的注释部分,这部分将 说明要求子程序作什么;因此,首先应该写一个关于编写子程序目的的精确说明。编写这个说 明也将帮你更清楚地理解这个子程序。如果在编写这部分说明时感到困难,那说明需要对这个 子程序在整个软件中的地位和作用作出更深刻的理解。总之,如果感到编写抽象说明时有困难, 应该想到可能是某一环节出了问题。以下是一个精确说明子程序作用的例子: This routine outputs an error message based on an error code supplied by the calling routine. The way it outputs the message depends on the current processing state,which it retrieves on its own.It returns a variable indicating success or failure. 写完抽象说明后,再编写关于这个子程序的高层次 PDL。下面就是一个关于前述例子的高 层次 PDL: 第四章 建立子程序的步骤 36 This routine outputs an error message based on an error code supplied by the calling routine.The way it outputs the message depends on the current processing state, which it retrieves on its own. It returns a variable indicating success or failure. set the default status look up the message based on the error code if the error code is valid determine the processing method if doing interactive processing print the error message interactively and declare success else doing batch processing if the batch message file opens properly log the error message to the batch file, close the file,and declare success else the message code is not valid notify the user that an interal error has been detected 应该注意的是这个 PDL 是在一个相当高的层次上写成的。它使用的不是程序语言,而是用 自然语言来精确表达设计思想的。 考虑数据。可以在编写过程中的几个不同地方设计数据。在这个例子中,数据非常简单, 因而数据操作并不是程序的主要部分。如果数据操作是程序的主要部分,那么在考虑程序的逻 辑结构之前,考虑主要数据是必要的。如果在进行子程序的逻辑设计时,已经有了关键数据结 构的定义,那将是大有裨益的。 检查 PDL。写 好 PDL 并设计完数据之后,应该花一点时间来检查一下 PDL。返回来看着所写 的 PDL,考虑一下应该怎样向别人说明。 请别人帮助看一下或听一下你的说明。也许你认为请别人看一个只有 11 行的 PDL 是很 愚蠢的,但你会对这样作的结果感到惊奇。PDL 使假设和高层次错误比程序语言代码容易被发 现。人们往往更愿意检查一个只有几行的 PDL,而不愿去检查一个有 35 行的 C 或 Pascal 子程 序。 要确认对子程序做什么和将怎样做已经有了清楚透彻的了解。如果在 PDL 这一层次上对这 点还没有概念上的了解,那么在编码阶段了解它的机会还有多少呢?如果连你都理解不了它的 话,又有谁会理解呢? 逐步细化。在开始编码之前,要尽可能多使用 PDL 尝试一些想法。一旦开始编码,就会对 所写的代码产生爱惜之情,这时,要再想把它扔掉重新开始是非常困难的。 通常的思想是逐步细化用 PDL 写成的子程序,直到可以在每行 PDL 语句下面添加一行代码 而成为子程序为止,并把原来的 PDL 当作注释文件,或许最初的 PDL 的某些部分过于简略,需 要进一步说明,那么切记一定要对其作出进一步说明。如果不能确认如何对某一部分编码,那 么就继续细化 PDL,直到可以确认为止。要不断地细化 PDL 并对其作出进一步说明,直到你看 到这样作是在浪费时间时,再开始实际的编码工作。 第四章 建立子程序的步骤 37 4.4 子程序编码 设计好子程序之后,就要开始实现。可以按照标准步骤实现,也可以根据需要作出改动。 图 4-3 给出了实现一个子程序时的步骤。 图 4-3 实现子程序的步骤 书写子程序说明。 编写子程序的接口语句——编写过程或函数说明,应采用所需要的语言, 无论 Pascal、C 或 Fortran 都可以,只要符合需要。把原来的抽象说明用程序语言实现,把它放 在原来写好的 PDL 位置之上。以下是前述子程序的接口语句和抽象说明,它是用 Pascal 写成的: procedure RecordErrorMessage ( ErrorCode:ErrorCode_t ; var Status:Status_t ); { This routine outputs an error message based on an error code Supplied by the calling routine. The way it outputs the message depends on the current processing state, which it retrieves on its own. It returns a variable indicating success or failure. } 这些是接口语句 已转化成 Pascal 风格 注释的标题注释 set the default staus look up the message based on the error code 第四章 建立子程序的步骤 38 if the error code is valid determine the processing method if doing interactive processing print the error message interactively and declare success else doing batch processing if the batch message file opens properly log the error message to the batch file, close the file, and declare success else the message code is not valid notify the user that an internal error has been detected 这时是指出接口假设的好时机。在本例中,接口变量 ErrorCode 和 Status 是简明的,并且 根据其特定用途排序,不含任何隐蔽信息。 把 PDL 转变成高层次注释。利用 Pascal 中的 Begin 和 End,或者 C 中的“{”和“}”,可 以把 PDL 变成注释,以下是把前述的 PDL 变成了 Pascal 语言: procedure RecordErrorMessage ( Errorcode:ErrorCode_t; var Status:Status_t ); { This routine outputs an error message based on an error code Supplied by the calling routine. The way it outputs the message depends on the current processing state, which it retrieves on its own. It returns a variable indicating success or failure.} begin { set the default status } { look up the message based on the error code } { if the error code is valid} { determine the processing method } { if doing interactive processing} { print the error message interactively and declare success} { else doing batch processing } { if the batch message file opens properly} {log the error message to the batch file, close the file. and declare success} {else the message code is not valid} { notify the user that an internal error has been detected} end; { RecordErrorMessage() } 这时,子程序的特点已经非常明显了,设计工作已经结束了,没看见任何代码,但已经知 道子程序如何工作了。把 PDL 转换成程序语言代码是一件机械、自然、容易的工作。如果你不 第四章 建立子程序的步骤 39 觉得是这样,那么还需要进一步细化 PDL,直到有这种感觉为止。 在每一行注释下面填上代码。在每一行 PDL 注释语句下面填上代码。这有点像给报纸排版。 首先画好轮廓线,然后再把每一篇文章填到空格中,每一个 PDL 注释行都相当于给代码画的轮 廓线,而代码相当于文章。同文学文章的长度一样,代码的长度也是由它所要表达的内容多少 决定的。程序的质量则取决于它的设计思想的侧重点和巧妙程度。 在本例中,前两行 PDL 注释产生了两行代码: Procedure RecordErrorMessage ( ErrorCode:ErrorCode_t; var status:Status_t ) ; { This routine outputs an error message based on an error code Supplied by the calling routine The way it outputs the message depends on the current processing state,which it retrieves on its own. It returns a variable indicating success or failure.} begin 这里是填充的代码 { Set the default status } Status:=Failure; { look up the message based on the error code} LookupErrorMessage(ErrorCode,ErrorMessage); 这里是新变量 ErrorMessage { if the error code is valid } { determine the processing method } { if doing interactive processing } { Print the error message interactively and declare success } { else doing batch processing } { if the batch message file opens properly } { log the error message to the batch file, close the file,and declare success } { else the message code is not valid } { notify the user that an internal error has been detected } end; { RecordErrorMessage() } 这是一个编码的开始,使用了变量 ErrorMessage,所以需要说明它。如果是在事后进行注 释,那么,用两个注释行来注释两行代码就不必要了。但是,采用目前这种方法,是注释的字 面内容而不是它注释了多少行代码。现在,注释行已经存在了,所以还是将其保留。 代码需要变量说明,而且在每一注释行下面都要加入代码,以下是完整的子程序: procedure RecordErrorMessage ( 第四章 建立子程序的步骤 40 ErrorCode:ErrorCode_t; var Status:Status_t ); {This routine outputs an error message based on an error code Supplied by the calling routine. The way it outputs the message depends on the current processing state, whict it retrieves on its own. It returns a variable indicating success or failure. } var 这里是变量声明 ProcessingMehod: ProcessingMethod_t; ErrorMessage: Message_t; FileStatus: Status_t; begin {set the default status} Status:=Failure; {look up the message based on the error code } LookupErrorMessage(ErrorCode,ErrorMessage); {if the error code is valid} if (ErrorMessage.ValidCode) then begin {determine the processing method} ProcessingMethod := CurrentProcessingMehod; {if doing interaction processing} if (ProcessingMethod = Interactive) then begin {print the error message interactively and declare success } PrintInteractiveMessage(ErrorMessage.Text); Status := Success end {else doing batch processing} else if (ProcessingMethod = Batch) then begin {if the batch message file opens properly} FileStatus := OpenMessageFile; If (FileStatus = Success) then begin {log the error message to the batch file,close the file, and declare success} 第四章 建立子程序的步骤 41 LogBatchMessage ( ErrorMessage.Text ) ; CloseMessageFile; Status := Success end { if } end { else } end { else the message code is not valid } else begin { notify the user that an interanl error has been detected } PrintlnteractiveMessage ( 'Internal Error; Invalid error code' , 'in RecordErrorMessage()' } end end; { RecordErrorMessage () } 每一个注释都产生了一行或一行以上的代码,每一块都在注释的基础上形成了一个完整的 思想。保留了注释以便提供一个关于代码的高层次解释,在子程序的开始,对使用的所有变量 都作了说明。 现在,让我们再回过头来看一下前面的关于这个例子的需求定义和最初的 PDL,从最初的 5 行需求定义到 12 行的初始 PDL,接着这段 PDL 又扩大成为一个较大的子程序。即使需求定 义是很详尽的。子程序的创建还是需要在 PDL 和编码阶段进行潜在的设计工作。这种低层次的 设计工作正是为什么编码不是一件琐碎事的原因,同时,也说明本书的内容是很重要的。 非正式地检查代码。在注释下面填上代码之后,可以对每一块代码作一简略检查。尽力想 一下什么因素可能破坏目前的块,然后证明这种情况不会发生。 一旦完成了对某一子程序的实现,停下来检查一下是否有误。在 PDL 阶段,就已经对其作 了检查。但是,在某些情况下,某些重大问题在子程序实现之前是不会出现的。 使得问题直到编码阶段才出现的原因是多方面的。在 PDL 阶段引入错误可能到了详尽的实 现阶段才会变得明显。一个在 PDL 阶段看起来完美无缺的设计,在用程序语言实现时可能会变 得一塌糊涂。在详尽的实现阶段,可能会发现在结构设计或功能分析阶段引入的错误,最后, 代码可能存在一种司空见惯的错误——混用语言,毕竟大家都不是尽善尽美的嘛。由于上述原 因,在继续工作之前,要检查一下代码。 进行收尾工作。检查完代码是否存在问题后,再检查一下它是否满足本书所提到的通用质 量标准。可以采取几个步骤来确认子程序的质量是否满足要求。 · 检查子程序的接口。确认所有的输入和输出数据都已作出了解释,并且使用了所有 参数。关于细节问题,见 5.7 节“怎样使用子程序参数”。 · 检查通用设计质量。确认子程序只完成一项任务而且完成得很好,与其它子程序交 叉是控制不严的表现。并且,应该采用了预防错误的设计。关于细节问题,见第五 章“高质量程序的特点”。 · 检查子程序的数据。查找出不精确的变量名、没有使用的数据、没有说明的数据等 第四章 建立子程序的步骤 42 等。要了解详情,见关于数据使用的第八到第十二章。 · 检查子程序的控制结构。查找无限循环、不适当的嵌套等错误。详见关于使用控制结 构的第 13 到 17 章。 · 检查子程序设计。确认已说明了子程序的表达式、参数表和逻辑结构。详见第 18 章 “设 计与风格”。 · 检查子程序的文档。确认被翻译成注释的 PDL 仍然是精确的。检查算法描述,查找接 口假设和非显式依赖的文件资料,查找不清楚的编码等等。详见第 19 章“自我证明的 代码”。 按需要重复步骤。如果程序的质量很差,请返回 PDL 阶段。高质量程序是一个逐步的过程, 所以在重新进行设计和实现活动时,不要犹豫不决。 4.5 检查子程序 在设计并实现了子程序之后,创建活动的第三个主要步骤是进行检查,以确认所实现的软 件是正确的。你或许会问,对代码进行的非正式检查和收尾工作难道不足以保证其正确性吗? 的确,这两项工作会从某种程度上保证代码正确性,但不能完全保证,在这一阶段工作中漏掉 的错误,只有在后面的测试工作中才会被发现。而到那时纠正它们的成本将变得很高,因此, 最好现在就进行查错工作。 在心里对子程序进行查错处理。在前面提到过的非正式检查和清扫工作就是两种内心检查 方法。另一方法是在心中执行每一个路径。在心中执行一个子程序是比较困难的,这个困难也 是造成很困难保持子程序小规模的原因之一。要保证检查到每一个规定路径和中止点,同时还 要检查所有的例外情况。可以自己进行这项工作,此时叫作“桌面检查”。也可以与同事们一道 检查,这叫作“同事评审”、“过一遍”或者“视察”,这要取决于如何进行这项工作。 业余爱好者与职业程序员之间的最大区别就是迷信还是理解。在这里,“迷信”这个词指并 不是指在月圆之夜产生各种错误或使你毛骨悚然的一段程序。它指的是你对代码的感觉代替对 代码的理解。如果你总是认为编译程序或者硬件系统有故障,那说明你还处在迷信阶段。只有 5% 的错误是由编译程序、硬件或者是操作系统引起的(Brown and sampson, 1973, Ostrand and Weyuher, 1984)。进入理解境界的程序员总是怀疑自己的工作,因为他们知道 95% 的错误出自 这里。要理解每一行编码的意义,并且要明白为什么需要它。没有仅仅因为有效便是正确的东 西。如果你不知道为什么它是有效的,那么往往它是无效的,只不过你没有发现罢了。 最后,要指出的是有了一个有效的子程序并非就完事大吉了。如果你不知道为什么它是有 效的,那就研究并讨论它,或者用替代方案重新实现一次,直到你弄明白为止。 编译子程序。如果检查完了子程序,那就开始编译它。直到现在才开始编译工作,似乎是 我们的工作效率太低了,因为早在几页之前程序就完成了。的确,如果早一些开始编译,让计 算机去检查没有声明的变量,命名冲突等是可能会节约一些时间。 但是,如果晚一些开始编译,将会获得许多收益。其中的一个主要原因是,一旦开始编译, 那么你脑袋里的秒表便开始嘀嗒作响了,在第一次编译之后,你就开始不停地想:下次编译一 定让它全对。结果,在这种“就只再编译一次”的压力下,作了许多匆忙的、更易产生错误的 修改,反而浪费了更多的时间。所以,在确信子程序是正确的之前,不要急于开始编译。 第四章 建立子程序的步骤 43 本书的重点之一,就是想告诉读者如何避免陷入把各种代码拼凑到一起,通过试运行检验 它是否有效的怪圈。而在确信程序是正确的之前,就匆忙开始编译,恰恰是陷入了这种怪圈。 如果你还没有进入这个怪圈,那最好还是当确信程序正确之后再开始编译。 以下是在编译时,尽可能地检查出全部错误的指导方针: · 尽可能把编译程序的警告级别调到最高。只要允许,编译程序应尽量测试,将发现许 多难以察觉的错误。 · 消除所有编译程序指出的错误和提出警告的原因。注意编译程序关于你的代码说了些 什么。大量的警告往往意味着代码质量不高,所以应该尽量理解所得到的每个警 告。 在实际中,反复出现的警告可能产生以下影响:你忽略掉它们,而事实上它们掩盖了 更严重的错误。或者它们会变得使人痛苦,就像日本式的灌水酷刑。因此,比较安全 或痛苦较小的办法,是消灭这些隐蔽的问题以消除警告。 使用计算机来检查子程序错误。子程序编译之后,将其放入调试程序,逐步运行每一行代 码,要保证每一行都是按预期的运行。用这种简单的办法,你可以发现许多错误。 在调试程序中逐步运行程序之后,用在开发子程序时设计的测试用例对其进行测试。或许 你将不得不搭些支架来支撑你的子程序——即那些仅在测试阶段用于支持子程序而最终不包括 在产品中的代码。这些代码可能是调用子程序的程序,也可能是被子程序所调用的子程序。 消除子程序中的错误。一旦发现有错误,就要消除它。如果开发的子程序此时问题较多, 那 它将在这一阶段耗费较长的时间。如果发现程序中的错误异乎寻常的多,那就重新开发一个, 不要试图修补它。修补往往意味着不充分的理解,而且肯定会在现在和将来产生更多的错误, 而进行一个全新的设计将防止这一点。恐怕没有比重新写一个完美无缺的子程序来代替一个漏 洞百出的子程序更能让人满意的事了。 4.5.l 检查表 创建子程序 · 是否检查过先决条件已经满足了? · 定义子程序将要解决的问题了吗? · 结构设计是否足够清楚,使得你可以给子程序起个好名字? · 考虑过如何测试子程序了吗? · 是否从模块化水平或者满足时间和内存需求角度考虑过效率问题? · 是否查阅过参考书;以寻找有帮助的算法? · 是否用详尽的 PDL 设计子程序? · 在必要时,是否在逻辑设计步骤前考虑了数据? · 是否检查过 PDL,它很容易理解吗? · 是否注意到了足以使你返回到结构设计阶段的警告(使用了全局数据,更适合其它子 程序的操作,等等)。 · 是否使用了 PDL 到代码流程,是否把 PDL 作为编码基础并把原有的 PDL 转为注释? · 是否精确地把 PDL 翻译成了代码? · 在作出假设时,验证它们了吗? · 是从几个设计方案中选择了最好的,还是随意选择了一个方案? 第四章 建立子程序的步骤 44 · 是否彻底理解你的代码?它容易理解吗? 4.6 小 结 · 要想写好 PDL,首先要用易懂的自然语言,避免拘泥于某种程序语言,其次要在意向 层次上写 PDL,描述设计作什么而不是如何作。 · PDL 到代码流程方法是详细设计的有力工具,而且使得编码非常容易。可以把 PDL 直 接翻译成注释,但要注意保证注释是精确而有用的。 · 应该在工作的每一步中都检查子程序,并鼓励同事们检查。这样,可以在投入的资金 和工作努力最少时便发现错误,从而极大降低改错成本。 第五章 高质量子程序的特点 45 第五章 高质量子程序特点 目录 5.1 生成子程序的原因 5.2 子程序名称恰当 5.3 强内聚性 5.4 松散耦合性 5.5 子程序长度 5.6 防错性编程 5.7 子程序参数 5.8 使用函数 5.9 宏子程序 5.10 小结 相关章节 生成子程序的步骤:见第 4 章 高质量模块的特点:见第 6 章 通用设计技术:见 7.5 节 软件结构设计:见 3.4 节 第四章讲述了生成子程序时应该采取的步骤,其重点是创建过程。本章的重点则是子程序 本身,即区分好的子程序和低劣子程序的特征。 如果在进入现实而又困难的子程序细节问题之前,想阅读关于高层次设计的问题,那么请 首先阅读第七章,然后再阅读本章。由于模块也要比子程序抽象,因此,也可在读完第六章后 再阅读本章。 在讨论高质量子程序的细节问题之前,我们首先来考虑两个基本名词。什么叫“子程序”? 子程序是具有单一功能的可调用的函数或过程。比如 C 中的函数,Pascal 或 Ada 中的函数或过 程,Basic 中的子程序或 Fortran 中的子程序。有时,C 中的宏指令或者 Basic 中用 GOSUB 调用 的代码块也可认为是子程序。在生成上述函数或过程中,都可以使用创建高质量子程序所使用 的技术。什么是“高质量的子程序”?这是一个比较难以回答的问题。反过来最简单回答方式 是指出高质量子程序不是什么。下面是一个典型的劣质子程序(用 Pascal 写成): Procedure HandleStuff ( Var InputRec:CORP_DATA,CrntQtr:integer, EmpRec:Emp_DATA, Var EstimRevenue:Real, YTDRevenue:Real, ScreenX:integer,ScreenY:integer,Var NewColor:Color_TYPE, Var PrevColor:COLOR_TYPE,Var Status:STATUS_TYPE, ExpenseType:integer); 第五章 高质量子程序的特点 46 begin for i := 1 to 100 do begin InputRec.revenue[1]:= 0; InputRec.expense[i]:=CorpExpensse[CrntQtr,i] end; UpdateCorpDatabase(EmpRec); EstimRevenue:=YTDRevenue * 4.0 /real(CrntQtr) NewColor:=PrevColor; status:=Success; if ExpenseType=1 then begin for i:= 1 to 12 do Profit[i]:= Revenue[i]-Expense.Type[i] end else If ExpneseType= 2 then begin Peofit[i]:=Revenue[i] - Expense.Type2[i] end else if ExpenseType= 3 then begin Profit[i]:=Revenue[i] - Expense.Type3[i] end end 这个子程序有什么问题?给你一个提示:你应该至少从中发现 10 个问题。当你列出所发现 的问题后,再看一下下面所列出的问题; · 程序的名字让人困惑。HandleStuff()能告诉我们程序是干什么的吗? · 程序没有被说明(关于说明的问题已经超出了个别子程序的范围,详见第 19 章“自我 说明的子程序”)。 · 子程序的布局不好。代码的物理组织形式几乎没有给出其逻辑组织形式的任何信息。 布局的使用过于随心所欲,程序每一部分的布局都是不一样的。关于这一点。只要比 较一下 ExpenseType=2 和 ExpenseType=3 两个地方的风格就清楚了(关于布局问题, 详见第十八章“布局与风格”)。 · 子程序的输入变量值 InputRec 被改变过。如果它作为输入变量,那它的值就不该变 化。如果要变化它的值,就不该称之为输入变量 InputRec。 · 子程序进行了全局变量的读写操作。它从 CorpExpense 中读入变量并写给 Profit。它应 该与存取子程序通信,而不应直接读写全局变量。 · 这个子程序的功用不是单一的。它初始化了某些变量。对一个数据库进行写操作,又 进行了某些计算工作,而它们又看不出任何联系。一个子程序的功用应该是单一,明 了的。 · 子程序中没有采取预防非法数据的措施。如果 CrntQtr 的值为“0”,那么,表达式 YTDRevenue*4.0/real(CrntQtr)就会出现被零除的错误。 · 程序中使用了几个常数:100, 4.0, 12, 2 和 3。关于“神秘”(magic)数的问题见 11.1 节“常数” 第五章 高质量子程序的特点 47 · 在程序中仅使用了域的 CORP_DATA 型参数的两个域。如果仅仅使用两个域,那就该 仅仅传入特定的域而不是整个结构化变量。 · 子程序中的一些参数没有使用过。ScreenX 和 ScreenY 在程序中没有涉及。 · 程序中的一个参数被错误标定了。PrevColor 被标定为变量型参数,然而在程序中又没 有对其赋值。 · 程序中的参数太多。程序中参数个数的合理上限应该是七个左右。而这个程序中则多 达 11 个。程序中的参数多得怕人,恐怕没谁会仔细检查它们,甚至连数一下都不愿意。 除了计算机本身之外,子程序可以说是计算机科学最重大的发明。子程序使得程序非常好 读而且也非常容易理解,编程语言中的任何特性都不能和这一点相比。像上例中那样使用子程 序,简直就是对子程序的践踏,甚至可以说是一种犯罪。 子程序也是节省空间和提高性能的最好手段。想象一下,如果用代码段去代替程序中对子 程序的每一次调用,那么程序将会有多么庞大。如果不是把多次重复使用的代码段存放在子程 序中,而是直接把它放在程序中,那么对其进行性能改进将是一件很困难的事。是子程序使现 代编程成为可能。 现在,你可能有些不耐烦。“是好,子程序的确很了不起,我一直都在使用它”。你说,“你 的讨论似乎像是在纠正什么,你到底想让我做什么呢?” 我想说的是:有许多合理的原因使得我们去生成子程序。但是生成方法有好有坏。作为一 个计算机专业的本科生,可以认为生成子程序的主要原因是避免代码段的重复。我所用的入门 课本告诉我说,之所以使用子程序,是因为它可以避免代码段的重复,从而使得一个程序的开 发、调试、注释和维护工作都变得非常容易。除了一些关于如何使用参数和局部变量的语法细 节之外,这就是那本课本关于子程序理论与实践内容的全部。这实在不是一个完全而合理的解 释。下面这节将详细描述为什么和怎样生成子程序。 5.1 生成子程序的原因 以下是关于为什么要生成子程序的一些合理原因,其中有些原因之间可能有互相重叠的地 方。 降低复杂性。使用子程序的最首要原因是为了降低程序的复杂性,可以使用子程序来隐含 信息,从而使你不必再考虑这些信息。当然,在编写子程序时,你还需要考虑这些信息。但是, 一旦写好子程序,就可能不必再考虑它的内部工作细节,只要调用它就可以了。创建子程序的 另外一个原因是尽量减小代码段的篇幅,改进可维护性和正确性。这也是一个不错的解释,但 若没有子程序的抽象功能,将不可能对复杂程序进行明智的管理。 一个子程序需要从另一个子程序中脱离出来的原因之一是,过多重数的内部循环和条件判 断。这时,可以把这部分循环和判断从子程序中脱离出来,使其成为一个独立的子程序,以降 低原有子程序的复杂性。 避免代码段重复。无可置疑,生成子程序最普遍的原因是为了避免代码段重复。事实上, 如果在两个不同子程序中的代码很相似,这往往意味着分解工作有误。这时,应该把两个子程 序中重复的代码都取出来,把公共代码放入一个新的通用子程序中,然后再让这两个子程序调 第五章 高质量子程序的特点 48 用新的通用子程序。通过使公共代码只出现一次,可以节约许多空间。这时改动也很方便,因 为只要在一个地方改动代码就可以了。这时代码也更可靠了,因为只需在一个地方检查代码。 而且,这也使得改动更加可靠,因为,不必进行不断地、非常类似地改动,而这种改动往往又 是认为自己编写了相同的代码这一错误假设下进行的。 限制了改动带来的影响。由于在独立区域进行改动,因此,由此带来的影响也只限于一个 或最多几个区域中。要把最可能改动的区域设计成最容易改动的区域。最可能被改动的区域包 括:硬件依赖部分、输入输出部分、复杂的数据结构和商务规则。 隐含顺序。把处理事件的非特定顺序隐含起来是一个很好的想法。比如,如果程序通常先 从用户那里读取数据,然后再从一个文件中读取辅助数据,那么,无论是读取用户数据的子程 序,还是读取文件中数据的子程序,都不应该对另一个子程序是否读取数据有所依赖。如果利 用两行代码来读取堆栈顶的数据,并减少一个 Stacktop 变量,应把它们放入一个 PopStack()子程 序中,在设计系统时,使哪一个都可以首先执行,然后编写一个子程序,隐含哪一个首先执行 的信息。 改进性能。通过使用子程序,可以只在一个地方,而不是同时几个地方优化代码段。把相 同代码段放在子程序中,可以通过优化这一个子程序而使得其余调用这个子程序的子程序全部 受益。把代码段放入子程序也使得用更快的算法或执行更快的语言(如汇编)来改进这段代码 的工作变得容易些。 进行集中控制。在一个地方对所有任务进行控制是一个很好的想法。控制可能有许多形式。 知道一个表格中的入口数目便是其中一种形式,对硬件系统的控制,如对磁盘、磁带、打印机、 绘图机的控制则是其中另外一种形式。使用子程序从一个文件中进行读操作,而使用另一个子 程序对文件进行写操作便是一种形式的集中控制。当需要把这个文件转化成一个驻留内存的数 据结构时,这一点是非常有用的,因为这一变动仅改变了存取子程序。专门化的子程序去读取 和改变内部数据内容,也是一种集中的控制形式。集中控制的思想与信息隐含是类似的,但是 它有独特的启发能力,因此,值得把它放进你的工具箱中。 隐含数据结构。可以把数据结构的实现细节隐含起来,这样,绝大部分程序都不必担心这 种杂乱的计算机科学结构,而可以从问题域中数据是如何使用的角度来处理数据。隐含实现细 节的子程序可以提供相当高的抽象价值,从而降低程序的复杂程度。这些子程序把数据结构、 操作集中在一个地方,降低了在处理数据结构时出错的可能性。同时,它们也使得在不改变绝 大多数程序的条件下,改变数据结构成为可能。 隐含全局变量。如果需要使用全局变量,也可以像前述那样把它隐含起来、通过存取子程 序来使用全局变量有如下优点:不必改变程序就改变数据结构;监视对数据的访问;使用存取 子程序的约束还可以鼓励你考虑一下这个数据是不是全局的;很可能会把它处理成针对在一个 模块中某几个子程序的局部数据,或处理成某一个抽象数据的一部分。 隐含指针操作。指针操作可读性很差,而且很容易引发错误。通过把它们独立在子程序中, 可以把注意力集中到操作意图而不是机械的指针操作本身。而且,如果操作只在一处进行,也 更容易确保代码是正确的。如果找到了比指针更好的数据结构,可以不影响本应使用指针的子 程序就对程序作改动。 重新使用代码段。放进模块化子程序中的代码段重新使用,要比在一个大型号程序中的代 码段重新使用起来容易得多。 第五章 高质量子程序的特点 49 计划开发一个程序族。如果想改进一个程序,最好把将要改动的那部分放进子程序中,将 其独立。这样,就可以改动这个子程序而不致影响程序的其余部分,或者干脆用一个全新的子 程序代替它。几年前,我曾经负责一个替保险推销员编写系列软件的小组,我们不得不根据每 一个推销员的保险率、报价单格式等等来完成一个特定的程序。但这些程序的绝大部分又都是 相同的:输入潜在客户的子程序,客户数据库中存储的信息、查看、计算价格等等。这个小组 对程序进行了模块化,这样,随推销员而变化的部分都放在自己的模块中。最初的程序可能要 用三个月的时间来开发,但是,在此之后,每来一个推销员,我们只改写其中屈指可数的几个 模块就可以了。两三天就可能写完一个要求的程序,这简直是一种享受! 提高部分代码的可读性。把一段代码放入一个精心命名的子程序,是说明其功能的最好办 法。这样就不必阅读这样一段语句: if ( Node <> nil ) while ( Node.Next <> nil ) do Node = Node.Next LeafName = Node.Name else LeafName = " " 代替它的是: LeafName = GetLeafName(Node) 这个程序是如此简短,它所需要的注释仅仅是一个恰当的名字而已。用一个函数调用来代 替一个有六行的代码段,使得含有这段代码的子程序复杂性大为降低,并且其功能也自动得到 了注释。 提高可移植性。可以使用子程序来把不可移植部分、明确性分析和将来的移植性工作分隔 开来,不可移植的部分包括:非标准语言特性、硬件的依赖性和操作系统的依赖性等。 分隔复杂操作。复杂操作包括:繁杂的算法、通信协议、棘手的布尔测试、对复杂数据的 操作等等。这些操作都很容易引发错误。如果真的有错误,那么如果这个错误是在某个子程序 中,而不是隐藏在整个程序中的话,查找起来要容易得多。这个错误不会影响到其它子程序, 因为为了修正错误只要改动一个子程序就可以了。如果发现了一个更为简单迅速的算法,那么 用它来代替一个被独立在子程序中的算法是非常容易的。在开发阶段,尝试几种方案并选择其 中一个最好的是非常容易的。 独立非标准语言函数的使用。绝大多数实现语言都含有一些非标准的但却方便的扩展。使 用这种扩展的影响是两面性的,因为在另外一个环境下它可能无法使用。这个运行环境的差异 可能是由于硬件不同、语言的生产商不同、或者虽然生产商相同、但版本不同而产生的。如果 使用了某种扩展,可以建立一个作为进入这种扩展大门的子程序。然后,在需要时,可以用订 做的扩展来代替这一非标准扩展。 简化复杂的布尔测试。很少有必要为理解程序流程而去理解复杂的布尔测试。把这种测试 放入函数中可以提高代码的可读性,因为: (1) 测试的细节已经被隐含了。 (2) 清楚的函数名称已经概括了测试目的。 赋予这种测试一个函数,该函数强调了它的意义,而且这也鼓励了在函数内部增强其可读 第五章 高质量子程序的特点 50 性的努力。结果是主程序流和测试本身都显得更加清楚了。 是出于模块化的考虑吗?绝不是。有了这么些代码放入子程序的理由,这个理由是不必要 的。事实上,有些工作更适合放在一个大的子程序中完成(关于程序最佳长度的讨论见 5.5 节“子 程序长度”)。 5.1.1 简单而没有写入子程序的操作 编写子程序的最大心理障碍是不情愿为了一个简单的目的而去编写一个简单的子程序。写 一个只有两或三行代码的子程序看起来是完全没有必要的。但经验表明,小的子程序也同样是 很有帮助的。 小型子程序有许多优点,其中之一是改进了可读性。我曾在程序中采用过如下这样一个仅 有一行的代码段,它在程序中出现了十几次: Points = DeviceUnits * ( POINTS_PER_INCH / DeviceUnitsPerInch ( ) ) 这决不是你所读过的最复杂的一行代码。很多人都明白它是用来转换的。他们也会明白程 序中的每行这个代码都在作同一件事,但是,它还可以变得更清楚些,所以,我创建了一个恰 当命名的子程序来作这些工作。 DeviceUnitsToPoints(DeviceUnits Integer): Integer; begin DeviceUnitsToPoints = DeviceUnits * ( POINTS_PER_INCH / DeviceUnitsPerInch ( ) ) end 在用这段子程序来代替那些十几次重复出现的代码行后,这些代码行基本上都成了如下的 样子: Points = DeviceUnitsToPoints ( DeviceUnits ) 这显然更具可读性,甚至已经达到了自我说明的地步。 这个例子还暗示了把简单操作放入函数的另外一个原因:简单操作往往倾向于变成复杂操 作。在写这个子程序时我还不知道这一点,但在某种情况下,当某个设备打开时, DeviceUnitPerInch()会返回零,这意味着我不得不考虑到被“0”除的情况,这样,又需要另外 的三行代码; DeviceUnitsToPoints( DeviceUnit:integer):integer; begin if( DeviceUnitsPerInch ( ) <> 0 ) then DeviceUnitsPoints = DeviceUnits * ( POINTS_PER_INCH / DeviceUnitsPerInch ( ) ) else DeviceUnitsToPoints= 0 end 如果原来的代码行仍然在程序中出现十几次,那么这一测试也要重复十几次,需要新增加 36 行代码。而一个简单子程序轻而易举地便把 36 变成了 3。 第五章 高质量子程序的特点 51 5.1.2 创建子程序的理由总结 以下是创建子程序理由概述: · 降低复杂性 · 避免重复代码段 · 限制改动带来的影响 · 隐含顺序 · 改进性能 · 进行集中控制 · 隐含数据结构 · 隐含指针操作 · 隐含全局变量 · 促进重新使用代码段 · 计划开发一个软件族 · 改善某一代码段可读性 · 改善可移植性 · 分隔复杂操作 · 独立非标准语言函数的使用 · 简化复杂的布尔测试 5.2 子程序名称恰当 一个恰当的子程序名称应该清晰地描述出子程序所作的每一件事。以下是给子程序有效 命名的指导方针: 对于过程的名字,可以用一个较强的动词带目标的形式。一个带有函数的过程往往是对某 一目标进行操作。名字应该反映出这个过程是干什么的,而对某一目标进行操作则意味着我们 应该使用动宾词组。比如,PrintReport),Checkotderlnfo()等,都是关于过程的比较恰当的名 字。 在面向对象的语言中,不必加上对象名,因为对象本身在被调用时就已经出现了。这时可 求助于诸如 Report.Print(),OrderInfo.Check()和 MonthlyRevenue.Cafe()等名字。而像 RePort. PrintReport 这类名字则是冗余的。 对于函数名字,可以使用返回值的描述。一个函数返回到一个值,函数应该用它所返回的 值命名,例如 Cos(),PrinterReady(),CurrentPenColor()等等都是不错的函数名字,因为它精确 地描述了函数将返回什么。 避免无意义或者模棱两可的动词。有些动词很灵活,可以有任何意义,比如 HandleCalculation(),ProcessInput()等子程序名词并没有告诉你它是作什么的。这些名字至多 告诉你,它们正在进行一些与计算或输入等有关的处理。当然,有特定技术情形下使用“handle” 等词是个例外。 有时,子程序的唯一问题就是它的名字太模糊了,而子程序本身的设计可能是很好的。如 第五章 高质量子程序的特点 52 果用 FormatAndPrintOutput() 来代替 HandleOutPut() ,这是一个很不错的名字。 在有些情况下,所用的动词意义模糊是由于子程序本身要做的工作太模糊。子程序存在着 功能不清的缺陷,其名字模糊只不过是个标志而已。如果是这种情况,最好的解决办法是重新 构造这个子程序,弄清它们的功能,从而使它们有一个清楚的、精确描述其功能的名字。 描述子程序所做的一切。在子程序名字中,应描述所有输出结果及其附加结果。如果一个 子程序用于计算报告总数,并设置一个全局变量来表示所有的数据都已准备好了,正等待打印, 那么,ComputeReportTotal()就不是一个充分的名字了。而 ComputeReport TotalAndSetPrintingReadyVar()又是一个太长而且太愚蠢的命名。如果子程序带有附加结果, 那必然会产生许多又长又臭的名字。解决的办法不应该是使用描述不足名字,而是采用直接实 现每件事的原则来编程,从而避免程序带有附加结果。 名字的长度要符合需要。研究表明,变量名称的最佳长度是 9 到 15 个字母,子程序往往比 变量要复杂,因而其名字也要长些。南安普敦大学的 MichaelRees 认为恰当的长度是 20 到 35 个字母。但是,一般来说 15 到 20 个字母可能更现实一些,不过有些名称可能有时要比它长。 建立用于通用操作的约定。在某些系统中,区分各种不同的操作是非常重要的。而命名约 定可能是区分这些操作最简单也是最可靠的方法。比如,在开发 0S/2 显示管理程序时,如果子 程序是关于直接输入的,就在其名称前面加一个“Get”前缀,如果是非直接输入的则加“Query” 前缀,这样,返回当前输入字符的 GetlnputChar()将清除输入缓冲区.而同样是返回当前输入 字符的 QuerylnPutChar()则不清除缓冲区。 5.3 强内聚性 内聚性指的是在一个子程序中,各种操作之间互相联系的紧密程度。有些程序员喜欢用“强 度”一词来代替内聚性,在一个子程序中各种操作之间的联系程度有多强?一个诸如 Sin()之类 的函数内聚性是很强的,因为整个子程序所从事的工作都是围绕一个函数的。而像 SinAndTan() 的内聚程度就要低得多了,因为子程序中所进行的是一项以上的工作。强调强相关性的目的是, 每一个子程序只需作好一项工作,而不必过分考虑其它任务。 这样作的好处是可以提高可靠性。通过对 450 个 Fortran 子程序的调查表明,50%的强内聚 性子程序是没有错误的,而只有 18%的弱内聚性子程序才是无错的(Card,Carch 和 Agresti 1986)。 另一项对另外 450 个子程序的调查则表明,弱内聚性子程序的出错机会要比强内聚性出错机会 高 6 倍,而修正成本则要高 19 倍(Selby 和 Basili 1991)。 关于内聚性的讨论一般是指几个层次。理解概念要比单纯记住名词重要得多。可以利用这 些概念来生成内聚性尽可能强的子程序。 5.3.1 可取的内聚性 内聚性的想法是由 Wayne Stevens,Glenford Myers 和 Larry Constantine 等人在 1974 年发表 的一篇论文中提出来的,从那以后,这个想法的某些部分又逐渐得到了完善。以下是一些通常 认为是可以接受的一些内聚类型: 功能内聚性。功能内聚性是最强也是最好的一种内聚,当程序执行一项并且仅仅是一项工 作时,就是这种内聚性,这种内聚性的例子有: sin(), GetCustomerName(), EraseFile(), 第五章 高质量子程序的特点 53 CaldoanPayment()和 GetIconlocation()等等。当然,这个评价只有在子程序的名称与其实际内容 相符时才成立。如果它们同时还作其它工作,那么它们的内聚性就要低得多而且命名也不恰当。 顺序内聚性。顺序内聚性是指在子程序内包含需要按特定顺序进行的、逐步分享数据而又 不形成一个完整功能的操作,假设一个子程序包括五个操作:打开文件、读文件、进行两个计 算、输出结果、关闭文件。如果这些操作是由两个子程序完成的,DoStep1()打开文件、读文件 和计算操作,而 DoStep2()则进行输出结果和关闭文件操作。这两个子程序都具有顺序内聚性。 因为用这种方式把操作分隔开来,并没有产生出独立的功能。 但是,如果用一个叫作 GetFileData()的子程序进行打开文件和读文件的操作,那么这个子 程序将具有功能内聚性。当操作来完成一项功能时,它们就可以形成一个具有功能内聚性的子 程序。实际上,如果能用一个很典型的动宾词组来命名一个子程序,那么它往往是功能内聚性, 而不是顺序内聚性。给一个顺序内聚性的子程序命名是非常困难的,于是便产生了像 Dostep1() 这种模棱两可的名字。这往往意味着你需要重新组织和设计子程序,以使它是功能内聚性的。 通讯内聚性。通讯内聚性是在一个子程序中,两个操作只是使用相同数据,而不存在其它 任何联系时产生的。比如,在 GetNameAndChangePhoneNumber()这个子程序中,如果 Name 和 PhoneNumber 是放在同一个用户记录中的,那么这个子程序就是通讯内聚性。这个子程序从事 的是两项而不是一项工作,因此,它不具备功能内聚性。Name 和 PhoneNamber 都存储在用户 记录中,不必按照某一特定顺序来读取它们,所以,它也不具备顺序内聚性。 这个意义上的内聚性还是可以接受的。在实际中,一个系统可能需要在读取一个名字的同 时变更电话号码。一个含有这类子程序的系统可能有些显得别扭,但仍然很清楚且维护性也不 算差,当然从美学角度来说,它与那些只作一项工作的子程序还有一定差距。 临时内聚性。因为同时执行的原因才被放入同一个子程序里,这时产生临时内聚性。典型 的例子有;Startup(),CompleteNewEmployee(),Shutdown()等等,有些程序员认为临时内聚性是 不可接受的,因为它们有时与拙劣的编程联系在一切,比如,在像 Startup()这类子程序中往往含 有东拼西凑的杂烩般的代码。 要避免这个问题,可以把临时内聚性子程序设计成一系列工作的组织者。前述的 Startup ()子程序进行的操作可能包括:读取一个配置文件、初始化一个临时文件、建立内存管理、显示 初始化屏幕。要想使它最有效地完成这些任务,可以让这个子程序去调用其它的专门功能的子 程序,而不是由它自己直接来完成这些任务。 5.3.2 不可取的内聚性 其余类型的内聚性,一般来说都是不可取的。其后果往往是产生一些组织混乱而又难以调 试和改进的代码。如果一个子程序具有不良的内聚性,那最好重新创建一个较好的子程序,而 不要去试图修补它。知道应该避免什么是非常重要的,以下就是一些不可取的内聚性: 过程内聚性。当子程序中的操作是按某一特定顺序进行的,就是过程内聚性。与顺序内聚 性不同,过程内聚性中的顺序操作使用的并不是相同数据。比如,如果用户想按一定的顺序打 印报告,而所拥有的子程序是用于打印销售收入、开支、雇员电话表的。那给这个子程序命名 是非常困难的,而模棱两可的名字往往代表着某种警告。 第五章 高质量子程序的特点 54 逻辑内聚性。当一个子程序中同时含有几个操作,而其中一个操作又被传进来的控制标志 所选择时,就产生了逻辑内聚性。之所以称之为逻辑内聚性,是因为这些操作仅仅是因为控制 流,或者说“逻辑”的原因才联系到一起的,它们都被包括在一个很大的 if 或者 case 语句中, 它们之间并没有任何其它逻辑上的联系。 举例来说,一个叫作 InputAll()的子程序,程序的输入内容可能是用户名字、雇员时间卡信 息或者库存数据,至于到底是其中的哪一个,则由传入子程序的控制标志决定。其余类似的子 程序还有 ComputeAll(),EditAll(),PrintAll()等等。这类子程序的主要问题是一定要通过传入一 个控制标志来决定子程序处理的内容。解决的办法是编写三个不同的子程序,每个子程序只进 行其中一个操作。如果这三个子程序中含有公共代码段,那么还应把这段代码放入一个较低层 次的子程序中,以供三个子程序调用。并且,把这三个子程序放入一个模块中。 但是,如果一个逻辑内聚性的子程序代码都是一系列 if 和 case 语句,并且调用其它子程序, 那么这是允许的。在这种情况下,如果程序的唯一功能是调度命令,而它本身并不进行任何处 理,那么这可以说是一个不错的设计。对这种子程序的专业叫法是“事物处理中心”,事物处理 中心往往被用作基础环境下的事件处理,比如,Apple Macintosh 和 Microsoft Windows。 偶然内聚性。当同一个子程序中的操作之间无任何联系时,为偶然内聚性。也叫作“无内 聚性”。本章开始时所举的 Pascal 例程,就是偶然内聚性。 以上这些名称并不重要,要学会其中的思想而不是这些名词。写出功能内聚性的子程序几 乎总是可能的,因此,只要重视功能内聚性以获取最大的好处就可以了。 5.3.3 内聚性举例 以下是几个内聚性的例子,其中既有好的,也有坏的: 功能内聚性例子。比如计算雇员年龄并给出生日的子程序就是功能内聚性的,因为它只完 成一项工作,而且完成得很好。 顺序内聚性的例子。假设有一个按给出的生日计算雇员年龄、退休时间的子程序,如果它 是利用所计算的年龄来确定雇员将要退休的时间,那么它就具有顺序内聚性。而如果它是分别 计算年龄和退休时间的,但使用相同生日数据,那它就只具有通讯内聚性。 确定程序存在哪种不良内聚性,还不如确定如何把它设计得更好重要。怎样使这个子程序 成为功能内聚性呢?可以分别建立两个子程序,一个根据生日计算年龄,另外一个根据生日确 定退休时间,确定退休时间子程序将调用计算年龄的程序,这样,它们就都是功能内聚性的, 而且,其它子程序也可以调用其中任一个子程序,或这两个部调用。 通讯内聚性的例子。比如有一个打印总结报告,并在完成后重新初始化传进来的总结数据 的子程序,这个子程序具有通信内聚性,因为这两个操作仅仅是由于它们使用了相同的数据才 联系在一起。 同前例一样,我们考虑的重点还是如何把它变成是功能内聚性,总结数据应该在产生它的 地方附近被重新初始化,而不应该在打印子程序中重新初始化。把这个子程序分为两个独立的 子程序.第一个打印报告,第二个则在产生或者改动数据的代码附近重新初始化数据。然后, 利用一个较高层次的子程序来代替原来具有通讯相关的子程序,这个子程序将调用前面两个分 出来的子程序。 逻辑内聚性的例子。一个子程序将打印季度开支报告、月份开支报告和日开支报告.具体 第五章 高质量子程序的特点 55 打印哪一个,将由传入的控制标志决定,这个子程序具有逻辑内聚性,因为它的内部逻辑是由 输进去的外部控制标志决定的。显然,这个子程序不是按只完成一项工作并把它作好的原则。 怎样使这个子程序变为功能内聚性呢?建立三个子程序:一个打印季度报告,一个打印月 报告、一个打印日报告,改进原来的子程序,让它根据传送去控制标志来调用这三个子程序之 一。调用子程序将只有调用代码而没有自己的计算代码,因而具有功能内聚性。而三个被调用 的子程序也显然是功能内聚性的。非常巧合,这个只负责调用其它子程序的子程序也是一个事 务处理中心。最好用如 DispatchReportPrinting()之类带有“调度”或“控制”等字眼的词来给事 务处理中心命名,以表示它只负责命令的调度,而本身并不做任何工作。 逻辑内聚性的另一个例子。考虑一个负责打印开支报表、输入新雇员名字并备份数据库的 子程序,其具体执行内容将由传入的控制标志控制。这个子程序只具有逻辑内聚性,虽然这个 关联看起来是不合逻辑的。 要想使它成为功能内聚性,只要按功能把它分成几部分就可以了。不过,这些操作有些过 于凌乱。因此,最好重新建立一个调用各子程序的代码。当拥有几个需要调用的子程序时,重 新组织调用代码是比较容易的。 过程内聚性的例子。假设有一个子程序,它产生读取雇员的名字,然后是地址,最后是它 的电话号码。这种顺序之所以重要,仅仅是因为它符合用户的需求,用户希望按这种顺序进行 屏幕输入。另外一个子程序将读取关于雇员的其它信息。这个子程序是过程内聚性,因为是由 一个特定顺序而不是其它任何原因,把这些操作组合在一起的。 与以前一样,如何把它变为功能内聚性的答案仍然是把它分为几个部分,并把这几部分分 别放入程序中。要保证调用子程序的功能是单一、完善的。调用子程序应该是诸如 GetEmployeeData()这样的子程序,而不该是像 GetFirstPartofEmployeeData()这类的子程序。可能 还要改动其余读取数据的子程序。为得到功能内聚性,改动几个子程序是很正常的。 同时具有功能和临时内聚性的程序。考虑一个具有完成一项事物处理所有过程的子程序, 从用户那里读取确认信息,向数据存入一个记录,清除数据域,并给计数器加 1。这个程序是 功能内聚性的,因为它只从事一项工作,进行事物处理,但是,更确切地说,这个子程序同时 也是临时内聚性的,不过当一个子程序具有两种以上内聚性时,一般只考虑最强的内聚性。 这个例子提出了如何用一个名字恰如其分地抽象描述出程序内容的问题。比如可以称这 个子程序为 ConfirmEntryAndAdjustData(),表示这个干程序仅具有偶然内聚性。而如果称它为 CompleteTransaction(),那么就可能清楚地表示出这个子程序仅具有一个功能,而已具有功能内 聚性。 过程性、临时或者可能的逻辑内聚性。比如一个进行某种复杂计算前 5 个操作,并把中间 结果返回到调用子程序。由于 5项操作可能要用好几个小时,因此当系统瘫痪时,这个子程序 将把中间结果存入一个文件中,然后,这个子程序检查磁盘,以确定其是否有足够空间来存储 最后计算结果,并把磁盘状态和中间结果返回到调用程序。 这个子程序很可能是过程内聚性的,但你也可能认为它具有临时内聚性,甚至具有逻辑内 聚性。不过,不要忘了问题的关键不是争论它具有哪种不好的内聚性,而是如何改善其内聚性。 原来的子程序是由一系列令人莫名其妙的操作组成的,与功能内聚性相距甚远,首先,调 用子程序不应该调用一个,而应该调用几个独立的子程序:l)进行前 5 步计算的子程序;2)把 中间结果存入一个文件;3)确定可用的磁盘存储空间。如果调用子程序被称作 第五章 高质量子程序的特点 56 ComputeExtravagantNumber(),那么它不应该把中间结果写入一个文件,也决不该为后来的操作 检查磁盘剩余空间,它所作的就仅限于计算一些数而已。对这个子程序的精心重新设计,将至 少影响到一至两个层次上的子程序,对于这顶任务的较好设计,见图 5-l。 图中画阴影的部分是由原 中它们位于不同的层次上, 5.4 松散耦合性 所谓耦合性指的是两个子程序之间联系的紧密程度。耦合性与内聚性是不同的。内聚性 是 是它们之间的耦合是非常松散的,任一个子程序都能很容 它们像商业上的伙伴 一样 5.4.1 耦合标准 以下是几条估计子程序间耦合程度的标准: 联系的数量多少。对于耦合来说,联系的数 图 5-1 进行任务分解以获得功能内聚性举例 来的子程序从事的工作,在新组织结构 这就是为什么为了把这些工作放入恰当的子程序中,要进行这么多重新组织工作的原因。用几 个功能内聚性的子程序来代替一个具有不良内聚性的子程序是很平常的。 指一个子程序的内部各部分之间的联系程度,而耦合指的是子程序之间的联系程度。研究它 们的目的是建立具有内部整体性(强内聚性),而同时与其它子程序之间的联系的直接、可见、 松散和灵活的子程序(松散耦合)。 子程序之间具有良好耦合的特点 易地被其它子程序调用。火车车箱之间的联接是非常容易的,只要把两节车箱推撞到一起,挂 钩就会自动挂上,想象一下,用螺栓把它们固定到一起,或者只有特定的车厢之间才能联接到 一起,那么事情将会有多么麻烦。火车车厢之间的联接之所以非常容易,是因为它们的联接装 置非常简单。同样,在软件中,也应该使子程序之间的耦合尽量简单。 在建立一个子程序时,应尽量避免它对其它子程序有依赖性,应该使 相互分离,而不要使它们像连体婴儿一样密不可分。类似 Sin()的函数是松散耦合的,因为 它所需要的只是一个传递进去的角度值。而类似 InitVars(varl,varZ,var3,⋯⋯,varN)的函 数则是紧密耦合的,因为调用程序事实上知道函数内部做什么。依靠使用同一全局变量联系在 一起的子程序之间,其耦合程度则更高。 耦合规模。所谓耦合规模是指两个子程序之间 第五章 高质量子程序的特点 57 也越少。一个只有一个参数的子程序与调用它的程序间的耦合程序,要比有 6 个参数的子程序 与调用它的程序间的耦合程度松散得多。一个使用整型参数的子程序与其调用程序之间的耦合 程度,也要比一个使用有 10 个元素数组或者结构化数据的子程序与其调用程序之间的耦合程度 松散得多。同样,使用一个全局变量的子程序与使用十二个全局变量的子程序相比,其耦合程 度也要松散得多。 密切性。密切性指的是两个子程序之间联系的直接程度。联系越直接越好,两个子程序之 间联系最密切的是参数表中的参数。当两个程序直接通讯时,这个参数就像接吻时的嘴唇。联 系密切程度稍低的是作用于同一全局数据的两个子程序。它们之间交流的直接性稍低。全局变 量就像是两个子程序之间的爱情,它可能消失在信中,也可能到你想要它到的地方。联系程度 最低的是作用于同一数据库记录或文件的两个子程序,它们都需要这个数据但却又羞于通知对 方,这个被分享的数据就像是在课堂上传阅着的一张写有“你喜欢我吗?请回答‘是’还是‘不 是’”的纸条。 可见性。可见性是指两个子程序之间联系的显著程度。编程不像是在中央情报局中工作, 不会因为行动隐蔽而受到表彰,它更像是作广告,干得越是大张旗鼓,受到的表彰也就越多。 在参数表中传递数据是明显的,因而也是好的。而通过改动全局数据以便让别的子程序来使用 它,则是一个隐蔽的联系因而也是不好的。对全局数据联系进行注释以使其更明显,可能稍好 些。 灵活性。灵活性是指改变两个子程序之间联系的容易程度。形象地说,你更喜欢电话上的 快速插口装置,而不会喜欢用电烙铁把铜线焊到一起,灵活性可能有一部分是由其它耦合特性 决定的,但也有一些区别。比如,有一个按照给定的被雇用日期和被雇用部门,寻找雇员的第 一个监工的子程序,并命名它为 LookUpFirstSupervisor()。同时,还有一个对变量 EmpRec 进行 结构化的子程序,变量 EmpRec 包括雇用日期、雇用部门等信息,第二个子程序把这个变量传 给第一个子程序。 从其它观点来看,两个子程序之间的耦合是非常松散的。因为处于第一个和第二个子程序 之间的变量 EmpRec 是公共的,所以它们之间只有一个联系。现在,假设需要用第三个子程序 来调用子程序 LookUpFirstSupervisor(),但这个子程序中不含 EmpRec,却含有雇用部门和雇用 日期信息。这时 LookUpFirstSupervisor()就不是那么友好了,它不情愿与第三个子程序进行合作。 对于调用 LookUpFirstSupervisor()的子程序来说,它必须知道 EmpRec 的数据结构。它可以 使用一个仅有两个域的变量 EmpRec,但这又需要知道 LookUpFirstSupervisor()内部结构,即那 些仅供它使用的域,这是一个愚蠢的解决方法。第二个方案是改动 LookUpFirstSupervisor,使 它 自带雇用部门和雇用日期信息,而不是使用 EmpRec。不管采用哪种方案,这个子程序都不像最 初看起来那么灵活了。 如果愿意的话,一个不友好的子程序也是可以变为友好的。这种情况可以通过让它明确带 有雇用部门和日期信息,而不再使用 EmpRec 来达到这一目的。 简而言之,如果一个子程序越容易被其它子程序调用,那么它的耦合程度也就越低。这样 的好处是可以增强灵活性和维护性。在建立系统结构时,应该沿着相互耦合程度的最低线将其 分开。如果把程序看成一块木头的话,就是要沿着它的纹理把它劈开。 量越少越好,因为一个子程序的接口越少,那么在把它与其它子程序相连接时,所要做的工作 第五章 高质量子程序的特点 58 5.4.2 耦合层次 “正常耦合”,这也是一种最好的耦合。 序了解被调子程序的内容与结构。 基础之上的,因此其灵活性也是很差的。许多结构化语言中,都有明确禁 以下是上述各种耦合的例子,其中有好的,也有坏的: 简单数据耦合的例子。比如,一个程序 子程序传递含有角度值的变量,那它们之 间就是简单数据耦合的。 数据耦合的另一个例子。两个程序向另一个子程序传递姓名、住址、电话号码、生日 和身份证号码等五个变量。 接受的数据结构耦合的例子。一个程序向另一个子程序传递变量 EmpRec,EmpRec 是一 个结构化的变量,包括姓名、住址、生日等等五个方面的数据,而被调用的子程序则全部使用 这五个域。 不可取的数据结构耦合举例。一个程序向另一个子程序传递同样的变量 EmpRec,但是,如 果被调用的子程序只使用其中两个域,比如电话号码和生日。这虽然还是数据结构耦合,但却 不是个很好的应用,如果把生日和电话号码作为简单变量来传递的话,将使联系更加灵活, 传统上,把耦合层次称为非直觉性(unintuitive)。所以,在以下叙述中,将交替使用这两 个名字。在以下叙述中,既有好的耦合,也有不好的耦合。 简单数据耦合。如果两个子程序之间传递的数据是非结构化的,并且全部都是通过参数表 进行的,这通常称作 数据结构耦合。如果在两个程序之间传递的数据是结构化的,并且是通过参数表实现传递 的,它们之间就是数据结构耦合的。这种耦合有时也称之为“邮票耦合”(stamp coupling)(不 过我总觉得这种叫法非常奇怪)。如果使用恰当的话,这种耦合也不错,它与简单耦合的主要区 别是它所采用的数据是结构化的。 控制耦合。如果一个子程序通过传入另一个子程序的数据通知它该作什么,那么这两个子 程序就是控制耦合的。控制耦合是令人不快的,因为它往往与逻辑内聚性联在一起,并且,通 常都要求调用程 全局数据耦合。如果两个子程序使用同一个全局数据,那它就是全局数据耦合的。这也就 是通常所说的“公共耦合”或“全局耦合”。如果所使用的数据是只读的,那么这种耦合还是可 以忍受的,但是,总的来说,全局耦合是不受欢迎的,因为这时子程序之间的联系既不密切, 又不可见。这种联系容易被疏漏掉,甚至可以认为它是一种由信息隐含带来的错误——信息丢 失。 不合理耦合(pathological)。如果一个子程序使用了另外一个子程序中代码,或者它改变了 其中的局部变量,那么它们就是不合理耦合的。这种耦合也称之为“内容耦合”。这一类耦合是 不能接受的,因为它不满足关于耦合规模、密切性、可见性和灵活性中的任何一条标准。虽然 这是一个很紧的联系,但是这种联系却是不密切的。改动另一个子程序中的数据无异于在其后 背桶上一刀,而且,这背后一刀从表面上又是看不出来的。由于它是建立在一个子程序完全了 解另一个程序内容的 止不合理耦合的语法规则。但是,在 Basic 或汇编语言中,它却是允许的。因此,在用这种语言 编程时,一定要小心! 以上所有类型的耦合,如图 5-2 所示。 5.4.3 耦合举例 向 Tan() 简单 可 第五章 高质量子程序的特点 59 可以进一步对 OfficeRec 进行结构化,以使得在被调用程序中用得到的 简洁的解决办法。 简单数据耦合或可能数据结构耦合的例子。一个程序调用 EraseFile()子程序,通过一个 字符串确定将要删去的文件,这很可能是一个简单数据耦合。但也可以说这 我们的结论是半斤与八两的关系, 叫法都是同样正确的,对其作严格区分 必要的。 ,一个程序向另一个子程序传递控制标志,通知它到底是打印月报 不可取的全局数据耦合的例子。一个程序改动一个表的入口作为全局变量,这个表是以雇 递给它,而这个被调用的子程序则用雇员识别卡去读全局数据表,这是一个典型的全局数据耦 传递雇员识别卡形成的是简单数据耦合,但是第一个程序对表入口的改动,已经 可取的全局数据耦合的例子。一个程序把雇员识别卡传递给另一个子程序,两个程序都利 使用的整个结构 好的数据耦合 由被调用的子程序 局数据 使用的少数结构 可取的 全局数据耦 可取的 Global Data 一个子程序写全局数 一个子程序使用另一 个子程序的内容数据 一个子程序写全局 不可取的 全局数据耦合 不可取的 全局数据耦合 内容耦合 简单的数据耦合 好的数据耦合 坏的数据耦合 合 全局数据耦合 由被调用的子程序 使用的大多数结构 由被调用的子程序 两个子程序 都读取全局数据 数据另一个读 另一个程序的内部数据 由被调用的子程序 使用的简单变量 两个子程序都读取全 据,另一个读 而且会使它们之间的两个特定域真正联系的可见性更好。 有问题的数据结构耦合的例子。一个程序向另一个子程序传递变量 OfficeRec。OfficeRec 有 27 个域,而被调用的子程序使用其中 16 个,这也是数据结构耦合,但是,它是一个好的数 据结构耦合吗?决不是。传递 OfficeRec 使得联系是大规模的,这个事实非常明显,而传递 16 个单独参数,则又再次非常拙劣地表明了这一点,如果被调用子程序仅使用其中的 6 到 7 个域, 那么单个地传递它们是个好主意。 在现在这种情形下, 图 5-2 某些耦合类型 16 个域包含在一个或两个亚结构中,这将是最 含有待删文件名的 是一个数据结构耦合,因为字符串是一个数据结构。我觉得, 这两种 是没 控制耦合的例子。比如 表、季度报表还是年度报表。 员的识别卡作为索引的。然后,这个程序又调用另一个子程序并把雇员识别卡作为一个参数传 合(虽然仅仅 决定了这是一种最坏的耦合——全局数据耦合)。 用这个识别卡从一个全局表中读取雇员的名字,两个子程序都没有改变全局数据。 第五章 高质量子程序的特点 60 常也称为“全局数据耦合”,但事实上它更像“简单数据耦合” “可取的 数据耦合”。与前述一个子程序改变另一个程序使用数据的例子不同,这两个程序并不是由 局数据联系在一起的。比较两个例子,这种对相同全局数据的只读 这两个从 一个全局表读取数值的程序,与上述 用全局数据来掩盖它们之间联系的程序是 全不同的。 内容耦合的例子。在汇编语言中,一个子程序可以知道另一个子 部变量的 的地址。它可以命名用这个地址直接 ,而地址在两个子程序间并没有当作参数 递。 内容耦合另一个例子 sic 程序利用 GO 来执行另一个子程序中的一段代码。 好的耦合关键是 可 的抽象层 好它 为它是独立的。 降低了整个程序的复杂性,并且使你每次只致力于一件事情。如果在使用子程序时要求同时 虑几件事情——知道它的内部内容、 改动、不确定的功能等,就会使其丧失抽 能力,那么使用子程序还有什么用呢?子程序本来是用于降低复杂性的工具,如果使用它没 使 明没有用好它。 5.5 长度 理论上,常把一个子程序的最佳长度定为一两页,即 66 到 132 行。按照这种原则,在七十 IBM 制在 50 行以下,而 TRW 公司则把这个标准定为 132 行 (McCabe 1976)。几乎没有什么证据证明这一限制是正确的。相反,倒是证明较长子程序更有 利的证据更有说服力,请参照以下几点: Basili 和 Perricone 1984 年的研究表明,子程序的长度与错误数量是成反比的。随着子 程序长度的增加(长至 代码),每一行的错误数量开始下降。 · 另一个由 shen et al 1985 年进行的研究表明,程序长度与错误数是无关的,但错误数 量会随着结构复杂性和数据数量的增加而增加。 · 一项由 Card、Church 和 Agresti 在 1986 年,以及 Card 和 Glass 在 1990 年进行的调查 表明,小型子程序(32 行代码或更少)并不意味着低成本和低错误率,证据表明大型 子程序(65 行或更多)的每行成本要低于小型子程序 · 对 450 个子程序的一项调查发现小型子程序(包括注释行,少于 143 行源语句)的每 行错误率要比大型子程序高 23%(Selby 和 Basili 1991)。 · 对计算机专业高年级学生进行的测验表明,学生们对一个被过度模块化的、由许多有 10 行左右代码子程序组成的软件,与同样内容但不含任何子程序的软件的理解程度 是相同的。但若把整个程序分解成中等规模的子程序(每个有 25 行代码),学生们的 理解程度会上升为 65%。 · 最近研究发现,当子程序长度是 100 到 150 行时,错误率最低(Lind 和 Variavan 1989)。 研究子程序长度有什么好处呢?如果你是一个经理,不要限制程序员们编写长于一页的子 程序,刚才引用的资料和程序员们自己的经验都可以证明你这样作是正确的。如果想编写一个 这通 ,我们也可称它为 全局 全 使用是良性的。 同 那两个通过使 完 程序中说明为局 表 去改动这个表 传 。一个 Ba 以提供一个附加 SUB 语句 次——一旦写 它 ,就可以认 它 考 对全局数据的 象 有 工作更简单,那说 子程序 年代, 公司曾把子程序的长度限 · 200 行的 。 第五章 高质量子程序的特点 61 限。在对 IBM 为 OS/360 操作系统及其它系统而开发代码研究中发现,最易出 修复长于 143 行的子程序中错误成本低 2.4 倍(Sely 和 Basin 1991)。 刻认为其它驾驶员的行为都是不可预测的。这样, 就可以在他们做出某些危险举动时,确保自己不会因此受伤。在防错性编程中,其中心思想是, 即使一个子程序被传入了坏数据,它也不会被伤害,哪怕这个数据是由其它子程序错误而产生 的。更一般地说,其思想核心是 要被改动,一个聪明的程序员就 以这点为依据开发软件。 5.6.l 使用断言 出的假设 设为真时的布尔表达式和 A 这个 r<>0 是一个布尔表达式,其 即 ,使用断言还是非常方便 段,可以 断言 Pr seertionn: boolean; 长度是 100 行,150 行或 200 行的子程序,那就按照你想的去作吧。目前的证据表明,这种长度 的子程序并不更易引发错误,而其开发更为容易。 如果要开发长于 200 行的子程序,就要小心了(这里的长度不包括注释行和空行)。目前还 没有任何证据表明长于 200 行的子程序会带来更低的成本或更少的错误。而这样做却会使你达 到可理解性的上 错的子程序是那些大于 500 行的子程序。在超过 500 行之后,错误数量会与子程序的长度成正 比。而且,对一个有 148,000 行代码软件的研究发现,修改少于 143 行子程序错误所耗费的成 本,要比 5.6 防错性编程 防错性编程并不意味着要对自己的程序提高警惕,这一想法是在防错性驾驶的基础上产 生的,在这种驾驶方法中,必须在心中时 承认程序中都会产生问题,都 作为本书介绍的提高软件质量技术之一,防错性编程是非常有用的。最有效的防错性编码 途径是一开始就不要引入错误。可以采用逐步设计方法、在编码前先写好 PDL、进行低层次设 计、审查等都可以防止错误引入。因此,应优先考虑它们,而不是防错性编程。不过,你可以 把防错性编程与这些技术组合起来使用。 断言是一个在假设不正确时会大声抗议的函数或宏指令。可以使用断言来验证在程序中作 并排除意外情况。一个断言函数往往大致带有两个内容:假 一个为假时要打印出来的信息。以下是一个假定变量 Denominator 不为零时一个 Pascal 断言: ssert ( Denominator<>0,'Denominator is unexpected equal to 0.' ) ; 断言假定 Denominator 不等于”0”,第一部分 Denominato 结果为 True 或 False。第二部分是当第一部分的结果为 False 时,将要打印出来的信息。 使不愿让用户在最终软件中看到断言信息,在开发和维护阶段 的。在开发阶段,断言可以消除相互矛盾的假设,消除传入子程序的不良数值等等。在维护阶 表明改动是否影响到了程序其它部分。 过程是非常容易写的,下面就是一个用 Pascal 写成的例子: ocedure Assert ( A Message : string ); 第五章 高质量子程序的特点 62 一旦写好了这样一个过程,就可以用像第一个例子那样的语句来调用它。 下面是使用断言的一些指导 如果有预处理程序的话,使用预处理程序宏指令。如果在开发阶段使用预处理程序处理断 言,那么在最终代码中把断言去掉是非常容易的。 从来不会输出乱七八糟像垃圾似的东西,不管它被输入的是什么。一个好程 序的特点是“输入垃圾,什么也不产生”,或“输入垃圾,输出错误信息”,也可以是“不允许 float AdjacentLength; rt( AdjancentLength != 0,”AdjanceLength deteced to be 0." ); begin if( not Assertion) begin writeln(Messase); writeln('Stopping the program.'); halt(FATAL ERROR) end end; 方针: 在断言中应避免使用可执行代码,把可执行代码放入断言,在关闭断言时,编译程序有可 能把断言捎去。请看以下断言; Assert(FileOpen(InputFile<>NULL,'Couldn't Open input file'); 这行代码产生的问题是,如果不对断言进行编译,也编译不了打开文件的代码,应把可执 行语句放在自己的位置上,把结果赋给一个状态变量,然后再测试状态。以下是一个安全使用 断言的例子: FileStatus : FileOpen (InputFile); Assert(FileStatus<> NULL,'Couldn't Open input file'); 5.6.2 输入垃圾不一定输出垃圾 一个好的程序 垃圾进入”。从现在的观点来看“输入垃圾,输出垃圾”,往往是劣质程序。 检查所有外部程序输入的数值。当从用户文件中读取数据时,要确保读入的数据值在允许 范围之内。要保证数值是可以取的,并且字符串要足够短,以便处理。要在代码中注释出输入 数据的允许范围。 检查全部子程序输入参数值。检查子程序输入参数值,事实上与检查外部程序数据是一样 的,只不过此时由子程序代替了文件或用户。 以下是一个检查其输入参数的子程序的例子,用 c 语言编写: float tan ( float OppositeLength; ) { /*计算角度正切*/ Asee 第五章 高质量子程序的特点 63 turn (OppsiteLenght/AdjancetLength); 法参数。一旦发现了一个非法参数,该如何处理呢?根据不同情况,可以 中间值、用下一个合法数据来代替它并按计划继续执行、与 法值、调用一个处理错误的子程序、从一个子程 关闭程序。由于有这样多的方案可供选择,所以当在程 理非法参数时,一定要仔细,确定处理非法参数的通用办法,是由结构设计 在结构设计层次上予以说明。 以修复的,例如,在某种情况下使用了一 品阶段,应该利用异常情况处理做一些更完美的工作,比如 就可以从开发阶段进 动 免 旧的软件新版本,就需要对原有 代码作出许多改动,不过,即使是在开发一个软件的第一版时,也不得不由于加入某些没有预 发软件时,应该努力作到使它很容易地进行改动。而且,越 是可能的改动,越是要容易进行,把你在其中预想到的改动域隐含起来,是减少由于改动而对 调试帮助措施包括:断言、内存检查报告、打印语句等及其它一些为方便调试而编写的代 码。如果所开发的软件是供自己使用的,那么把它们保留在程序中并无大碍。但是,如果是一 软件,那么这些措施留在程序中,则会影响其速度和占用空间等性能指标。在这种情况 下,应事先作好计划,避免调试信息混在程序中,下面是几种方法。 版本控制工具可以从同一源文件中开发出不同 本的软件。在开发阶段, 试辅助手段的版本控制工具,这样,到了产品阶段,就可以很容易地去掉 希望出现的这些辅助手段。 中带有预处理程序,如 C 语言,那么仅用一下编译 。可以直接使用预处理程序,也可以通过编写宏指 序定义。下面是一个用 c 语言写成的,直接使用预处理程序的例子: ine DEBUG re } 决定如何处理非 希望返回一个错误代码、返回一个 上次一样返回一个正确答案、使用最接近的合 序中调用错误信息并打印出来或者干脆 序的任一个部分处 决定的,应该 5.6.3 异常情况处理 应该预先设计好异常处理措施来注意意想不到的情况。异常处理措施应该能使意外情况的 出现在开发阶段变得非常明显,而在运行阶段又是可 个 case 语句,其中只预计到了五种情况,在开发阶段,应该能利用异常情况处理产生一个警告, 提示出现了另外一种情况。而在产 向一个错误记录文件中写入信息等。总之,应该设计出不必费多大周折, 入产品阶段的程序。 5.6.4 预计改 改动几乎是每个程序都不可避 的现象。比如,开发一个 计到的功能而对其进行改动。在开 程序带来影响的最有力武器之一。 5.6.5 计划去掉调试帮助 个商用 使用版本控制。 版 可以设置包含全部调 在商用版本中所不 使用内部预处理程序。如果在编程环境 程序开关,就可以加入或去掉这些辅助手段 令来进行预处理程 #def … 第五章 高质量子程序的特点 64 #ifdefined(DEBUG) 代码*/ 门用途的,这时可以用#if DEBUG—一 PRINTER ERROR 这样的语句把这段代码围起来,在其它地方,还可能想设置调试层次,可用 如下语句: ugCode(code fragment){code_fragment} #else bugCode(code fragment) #endif DebugCode ( statement 1; statement 2; … ) 约定,并编写 远观点来看,这样做可以节约许多时 间, 根据 DEBUG 是否定义,包含或不包含这段代码 /*调试 … #endif 这种思想可能有几种表现形式。不仅仅是定义 DEBUG,还可以赋给它一个值,然后再测试 它的值,而不是测试它是否被定义了。用这种方法可以区分调试代码的不同层次。也可能希望 某些调试用代码长期驻存在程序中,这时可以使用诸如#if DEBUG>0 之类的语句,把这段代 码围起来,其它一些调试代码可能是有某些专 #if DEBUEG>LEVEL_A 要是不喜欢程序内部充斥着#if defined()这样的语句的话,可以用一个预处理程序宏指 令来完成同一任务。以下是一个例子: #define DEBUG #if defined(DEBUG) #define Deb #define De statment n; 与使用预处理程序的第一个例子一样,这种技术也有多种形式,可以使它更复杂一些,从 而不是简单地全部包括或全部排除调试代码。 编写自己的预处理程序。如果编程语言中没有预处理程序,可以自己编写一个加入或去掉 调试代码的预处理程序,这项工作是非常容易的。还要建立一个标识调试代码的 自己的预编译程序来遵循这一约定。比如,在 Pascal 中,可以编写一个对如下关键字作出反应 的预编译程序:/#BEGIN DEBUG/和/#END DEBUG/。并写一个批处理文件来调用这个 预处理程序,然后再编译这段已经预处理过的代码。从长 因为你不会误编译没有预处理过的代码。 保留使用调试程序。在许多情况下,可以调用一个调试子程序来进行调试工作。开发阶段 第五章 高质量子程序的特点 65 在控制 程序可能进行几项操作。在最终软件中,可以用一个子程序 序,这个子程序将立即返回控制,或者在进行两个快速操作之后, 返回控制 用这种方法,对性能带来的影响很小,与编写自己的预处理程序来相比,其速度 ,有必要保留这个子程序在开发阶段和最终产品阶段的两个版本,以供在将 . r); 这行调用检查指针的子程序 ,CheckPointer()子程序将对指针作全面检查,这项工作可能很费时,但却非 heckPointer( Pointer:POINTER_TYPE) perform check 1 --maybe check that it's not nil} 2 --maybe check that its dogtag is legitimate} -maybe c { ck n ...} 所有的内务操作都与指针检查联系到一起,这 案,但从提供一些在你的环境下能有效工作 5.6.6 尽早引入调试辅助工具 越早引入调试辅助工具,它们所起的作用也就会越大。一般说来,只有在被某一问题困扰 返回调用程序之前,这个子 来代替那个复杂的调试子程 。使 也要快得多。所以 来的开发和产品调试中使用。 比如,可以用一个检查传入其中指针的子程序作为开始: Procedure DoSomething ( Pointer:PONITER_TYPE; .. ); begin { check parameters passed in } CheckPointer(Pointe ... end. 在开发阶段 常有效,它很可能是以下这个样子的: 这个程序检查传入的每个指针, 它可以在开发期间使用,完成 指针检查 Procdure C ; begin { {perform check {perform check 3 - heck that what is point to isn't corrupted} … perform che end; 当程序进入最终产品阶段时,可能并不希望 寸,可以用下面这个子程序来代替刚才那个子程序: Proccure CheckPointer (Pointer: POlNTER_TYPE ); 本程序仅是即返回其调用程序 begin {no code;just return to caller} end; 以上这些并不是去掉调试辅助工具的所有方 方案这个思路的角度来说,这些已经是足够的了。 第五章 高质量子程序的特点 66 5.6.7 使用“防火墙”包容错误带来的危害 危害策略,在建筑物中,防火墙的作用是防止火灾蔓延,把火 果船撞上了冰山,那么只有被 于是与它分隔开来的,所以不会受到它的影响,这样就避免 而带来的全船进水的灾难性后果。 墙。对另一个子程序的内容知道得越少,对它如何操 少,而假设越少,其中一个假设出错的可能性就会越小。 也是在程序内部修建防火墙的手段之一。两个子程序之间的耦合越松散,那么 错误影响到另外一个子程序的机会也越少。相反,如果两个子程序联系得 会影响另外一个子程序。 区边界。对穿越安全区边界的 如果是非法的数据,就要作出合理的反应。基于这种想法的另一种技术 被允许进入手术室之前,要对其进行消毒处理,手术室中的一切都认为 些要被放在它外面?应该把门放在哪儿?应该把哪些 子程序 最简 消毒,但是,数据往往需要在几个层次上进行消毒,因 5.6.8 一个函数,并且可以忽略函数退回值(例如,在 C 语言中,甚至不需要知道函 这个返回值。要对这个值进行检查。如果不想让它出错的话, 一定要对其进行检查。防错性设计的核心就是防止常错误。 对于系统函数来说,这个原则也是适用的,除非在结构设计中制定了不检查系统调用的返 回 。 件中保留多少防错性编程 发过程中,你希望出现错误时越引人注意越好,惹人讨厌 总比冒险忽视它好得多。但在最终产品中,你却希望它越不显眼越好,程序运行不管成功与否, 都要看起来十分优雅。以下是帮助你决定在最终软件中应该保留哪些防错性编程的一些原则: 保留查找重要错误的代码。首先要确定哪些域可以承受未测试到的错误,而哪些域不能。 ,程序中的屏幕更新区就可以承受未测试到的错误,因为发生 这种情况的后果,不过是一个混乱的屏幕而已,而在计算部分,就不能发生这种情况。因为这 。 去掉那些无关紧要错误的代码。如果一个错误的后果是无关紧要的,那就去掉检查它的代 码。在上例中,你很可能会去掉检查屏幕更新区错误的子程序。“去掉”并不是指从物理上把这 几次之后,你才会舍得花功夫去编写调试辅助工具,但如果你在第一次遇到问题时就这样做, 或者引用一个以前遗留下的调试辅助工具,那么它将在整个项目中都会对你有很大帮助。 “防火墙”技术是一种包容 隔离在一个地方。在轮船中使用分隔式水密舱也是同样道理,如 撞的水密舱才会破损,而其它舱由 了由一个洞 信息隐蔽可以帮助在程序中建立防火 作的假设也就越 松散的耦合 其中一个子程序中的 非常紧密,那么一个子程序错误很可能 在程序中建防火墙的最好办法是把某些接口标识成“安全” 数据进行合法性检查, 是手术室技术,在数据 是无毒安全的。当然,这个手术室是指一段代码,这样,在设计中,要作出的一个关键决定, 就是在这个“手术室”中进入些什么?哪 放在安全区内,哪些放在外面?用什么对数据进行消毒? 单的办法是在外部数据进 此,有时需要进行多重消入时对其进行 毒。 检查函数返回值 如果调用了 数是否返回一个值),千万不要忽略 码,而是在每次调用后检查错误代码。如果发现错误,C 语言中的 Perror(),或其它语言中 等效的部分,能同时也查出错误个数和这个错误的说明 5.6.9 在最终软 防错性编程带来的矛盾是,在开 例如,你正在编写一个表格程序 会在表格中产生难以察觉的错误。绝大多数用户都宁愿忍受一个混乱的屏幕而不是错误的表格 第五章 高质量子程序的特点 67 去掉那些引起程序终止的代码。在开发阶段,程序发现了一个错误时,你会希望这个错误 到这一目的最好办法是让一个程序在发现错误时 果程序中含有会使千百万数据丢失的 留。如果程序中含 。从理论上说,程序不应该 对防错性编程提高警惕。过多的防错性编程会带来它自身的问题,如果你对每一种可以 察觉的参数传递,在每一个可以察觉的地方都进行检查,那么程序将变得臃肿而笨拙。更糟的 程的代码本身并非是完善无缺的,同其它代码一样,你也会在其中发 子程序间的接口往往是一个程序中最容易出错的部分,由 Basili 和 Perricone 进行的一项研 误,即子程序间的通信错误。以下是尽量减少这类 错误的一些准则: 段代码删掉,它指的是版本控制预编译开关,或其它不编译那段特定代码的技术。如果不存在 空间限制问题,你也可以保留这段查错代码,并让它向一个错误记录文件隐蔽地传送信息。 越引人注意越好,以便你能修复它,通常,达 打印出错误信息然后终止。即使对于微小错误来说,这样做也是很有用的。 而在最终软件中,在程序终止前,用户总是希望有机会将其工作存盘。他们往往愿为达到 这一目的而忍受一些反常现象,用户们不会感激那些使其工作付诸东流的东西,不管这些东西 在调试阶段多么有用,哪怕最终极大提高了软件质量。如 调试代码,那么在最终产品中应将其去除掉。 保留那些可以使程序延缓终止的代码。同时,那些相反的代码也应该保 有测试潜在致命错误的信息,那么用户会为能在它们最终发展起来之前将自己的工作存盘而感 到高兴。我所使用的文字处理机在溢出内存之前会亮起“SAVE”提示灯进行警告,当发现这一 情况后就立即存盘并退出。当重新启动程序时,一切又变得正常了 溢出内存,而且,在用同一台机器重新启动程序运行同一文件时,它也不应该用更多内存。产 生了内存溢出问题说明程序有缺欠,但是,程序员想得很周到,在程序中保留了内存检查代码, 我也宁愿得到一个警告信息,而不愿失去我前面所做的工作。 保证留在程序中的错误提示信息是友好的。如果在程序中保留了内部错误提示信息,要确 保它是用友好的语言表达。在我早期编程工作中,我曾经收到一个用户的电话,说她在屏幕上 看到了这样的信息“你的指针地址有错,笨蛋!”幸亏她还有一些幽默感,这对我来说是很幸运 的。通常的办法是通知用户存在“内部错误”,并告诉用户一个她可以投诉的电话号码。 要 是,附加的用于防错性编 现错误,而且,如果你是随意写它的,那么错误也会更多。考虑好需要在哪里预防错误,然后 再使用防错性编程。 5.7 子程序参数 究表明,程序中 39%的错误都是内部接口错 确保实际参数与形式参数匹配。形式参数,即哑参数,是在子程序定义中说明的变量,实 际参数是在调用程序中使用的变量和参数。 常见的错误是在子程序调用时变量类型有误,比如,在需要使用实型数时使用了整型数 (这种情况只在像 C 这种弱类型的语言中,才会遇到。例如,在汇编语言或在 C 语言中未使用 全部编译程序的全部警告时就可能产生这种问题。而在 Pascal 中,当变量是单纯输入的时候, 几乎不会产生这个问题)。通常编译程序在把实参传入子程序之前,会把它转为形参的类型。如 果产生这个问题,编译程序通常会产生警告。但是在某些情况下,特别是当变量既用于输入也 用于输出时,你可能由于传递了错误的变量类型而受到惩罚。 第五章 高质量子程序的特点 68 位这样来排参数。 这种排列约定与 C 语言中把被修改的参数放在首位的规定是冲突的。不过我仍然认为上述 对参数排序,这也是可以 帮助提高程序可读性的。 如果几个子程序今使用了相似的参数,应按照不变的顺序排到这些参数。子程序中参数的 )与函数 puts()相比,也只是前者多了一个文件作为最后变量。这实在是一个 数,函数 strncpy()是按照目标字符率、源字符 要养成检查参数表中参数变量类型和注意编译程序关于变量类型不匹配警告的习惯。在 C 语言中,使用 ANSI 函数的原型,以便编译程序会自动检查变量类型,并在发现类型错误时发 出警告。 按照输入一修改一输出的顺序排列参数。不要随机地或者按照字母表的顺序排列参数,应 该将输入参数放在第一位,既输出又输入的参数第二位,仅供输出的参数第三 这种排列方法显示了子程序中操作进行的顺序——输入数据、修改数据、输出一个结果、下面 是一个 Ada 语言中的参数排列顺序。 procedure InvertMatrix ( OringinalMatrix : in MATRIX; ResultMatrix : out MATRIX; ); procedure ChangeStringCase ( DesiredCase : in STRING_CASE; MixedCaseString: in out USER_STRING ); … procedur PringPageNumber ( PageNumber : in INTEGER; Status: out STATUS_TYPE ); 排列顺序至少对我来说是十分明智的。但如果你一直接某种特定方式 排列顺序可以成为一种助记符,而不停变动的排列,会使得这些参数非常难记。比如,在 C 语 言中,fprintf()函数与 printf()函数相比,除了多了一个文件作为第一变量之外,两者其余都是一 样的。而函数 fputs( 糟糕的区别,因为它使得这些函数的参数的难记程度比实际要高多了。 我们来看一个例子,同样是 C 语言中的函 率和字节的最大数目来排列变量的,而函数 memcpy()是按同样的顺序来排列变量的,这种相 似性使得两个函数中的参数都非常好记了。 使用所有的参数。如果向某个子程序中传入了一个参数,那就要在其中使用;如果不用它 的话,就把它从子程序接口中去掉。因为出错率是随着未用参数个数的增加而升高的,一项调 查表明,在没有未用参数的子程序中,有 46%是完全无错的。而在含有未用参数的子程序中, 第五章 高质量子程序的特点 69 仅 变量过程,那可能会有一些子程序拥有完全相同的参数表,而在这其中又可能有几 把状态和“错误”变量放在最后。根据约定,状态变量和表示程序中有错误的变量应该放 于子程序来说是不很重要的。同时又是仅供输出的变量,因此 最后是非常明智的。 量。把传入子程序中的参数用作工作变量是非常危险 。 在下面这个 Pascal 程序段中,不恰当地使用了 InputVal 存放中间运算结果。 proceoure Sample VAR InputVal : Integer; r; gin InputVal := InputVal * CurrentMultiplier( InputVal); rrentAdder( InputVal ); … 程序段中,对 Inputval 的使用是错误的,因为在程序到达最后一行时,InnutVal 不再 上述原因而带来的问 有 17%到 29%是完全正确的(Card,Church,Agresti 1986) 不过,这个去掉未用参数的规则有两个特例。首先,如果你使用了 c 语言中的指针函数或 Pascal 中的 个子程序没有完全用到这些参数,这是允许的。其次,当你按照某种条件对程序进行部分编译, 可能会使用某些参数编译部分程序。但如果你去掉这部分是正确有效的,那这也是允许的。一 般来说,如果你有充分的理由不使用某一参数的话,那就按照你想的大胆去干吧。但如果理由 不是很充分的话,就要保留这个参数。 在参数表的最后。这两种变量对 把它们放在 不要把子程序中的参教当作工作变 的 应该使用局部变量来代替它。比如, 这个变量来 ( OutputVal: Intege ); be HputVal := InputVal + Cu OutputVal := InputVal; end 在这个 保持它输入时的值。这时它的值是程序中计算结果的值,因此,它的名字被起错了。如果你以 后在更改程序,需要用到 InPutVal 的输入值时,那很可能会在 InputVal 的值已经改变后还错误 地认为它保留原有值。 该如何解决这个问题呢?给 InputVal 重新命名吗?恐怕不行。因为假如你将其命名为 WorkingVal 的话,那么这个名称是无法表示出它的值是来自于程序以外这个事实的。你还可以 给它起一个诸如 InputvalThatBeComesAWorkinsVal 之类荒唐的名字或者干脆称之为 X 或者 Val, 但无论哪一种办法,看起来都不是很好。 一个更好的办法是通过显式使用工作变量来避免将来或现在可能由于 题。下面的这个程序段表明了这项技术: procedure Sample ( VAR InputVal: Inteqer; OutputVal: Inteqer; ); 第五章 高质量子程序的特点 70 var 不允许在函数中改变这个参数的值。不过,不要利用这个理由来解 文学性地命名为 WorkingVal,因为这是一个过于模棱两可的名字,我们之所 一些。 的习惯。如果在调用子程序参数表中的变量 ,那么它在调用程序中的值也将被改变。在任何语言中,把输入值赋给工 强调了它的来源。它避免了从参数表中来的变量被偶然改变的可能性。 变量计算一个新值,那应该在 设。如果假定被传入子程序的数据具有某种特性,那么需要对这个假设 序的地方都需要说明这一假设,这绝不是浪费时间。不要等 再回过头来说明这些假设,因为那时很可能你已经忘记这些假设了。如果能在 (英尺、码、还是米等)。 7 个左右。7 对于人的理解能力来说是一个富于魔 数个数超过 7 个,人们就很难记住,这样会更安全 把一个子程序中的参数个数限制在多少,取决于你所用的程序语言是如何处理 化数据的先进语言,你可以传递一个含有 13 个 息。如果你使用的是一种比较原始落后的语言, 这个复合数据结构分解成 13 个单独参数分别传送。 如果你发现自己总是在传递比较多的变量,则说明程序之间的耦合就有些过于紧密了。这 WorkingVal: Integer; begin WorkingVal := InputVal; WorkingVal := WorkingVal * CurrentMultipier (WorkingVal ); WorkingVal := WorkingVal + CurrentAdder( WorkingVal ); ... 如果需要在这里或其他地方使用输入原始值,它还存在 ... OutputVal := WorkingVal; end; 通过引入新变量 WokingVal,即保留了 InputVal 的作用,还消除了在错误的时间使用 InputVal 中值的可能性。在 Ada 语言中,这项原则是通过编译程序进行强化的。如果你给某个 参数的变量名前缀是 in,则 释把一个变量很具 以这样使用它,仅仅是为了使它在这里的作用清楚 在 Fortran 语言中,使用工作变量是一个非常好 被调用子程序改动了 作变量的同时都 同样的技术也被用于保持全局变量的值。如果你需要为全局 计算的最后把最终值赋给全局变量,而不要把中间值赋给它。 说明参数的接口假 作出说明。在子程序中和在调用程 到写完子程序后 代码中放入断言的话,那么其效果要好于说明这些假设。 · 关于参数接口的哪些假设需要作出说明呢? · 参数是仅供输入的,修改的还是仅供输出的? · 数值参数的单位 · 如果没有使用枚举型参数的话,应指出状态参数和错误变量值的意义。 · 预期的取值范围。 · 永远不该出现的某些特定值。 应该把一个子程序中的参数个数限制在 力的数字。心理学研究表明人类很难一次记住超过 7 个方面的信息,这个发现被应用到不计其 数的领域中,因此,如果一个子程序中的参 一些。 在实践中, 复杂数据结构的。如果你所用的是一种支持结构 域的数据结构,而把它只看成是一个独立的信 那你就不得不把 第五章 高质量子程序的特点 71 计子程序或子程序群,来降低耦合的紧密性。如果你把同一数据传给不同的子程序, 模块,并把那些经常使用的数据当做模块数据。 个关于输入、修改和输出参数的命名约定。如果发现区分输入,修改和输出参数 个关于命名的约定,以便区分它们,比如可以用 i_,m_,o_作前 T 来作前缀。 耦合中讨论过的那样:如果子 用结构化变量中绝大部分的话,那么就只传递它所用得到的那一部分。如果你精确 规定 以降低子程序间的耦合程 高子程序的使用灵活性。 型(ADT)时,这一精确接口规则使不适用了。这种数据类 构化变量,但这时你最好不要过分注意结构内部,在这种情况下,应把抽象 数据 T 子 移植性变坏。参数一般是 5.8 使用函数 回一个值。这意味着函数应接受唯一的输 ,cos(), 于输入、修改、输出参数的个数则没有限制。 来使用,并返回一个状态变量。从逻辑上说,它是一 个例子中,从它输出参数的角度来看,是一个过程。但是从纯技术角度来说,因为程 时应重新设 应当把这些子程序组织成一个 考虑建一 是非常重要的,则你可以建立一 缀。要是你不觉得冗长的话,可以用 INPUT,MODIFY 和 OUTPU 仅传递子程序需要的那部分结构化变量。如同在 5.4 节关于 程序不是使 了接口,在别的地方再调用这个子程序会容易些。精确的接口可 度,从而提 不过,当我们使用抽象数据类 型要求我们跟踪结 类型子程序设计成将整个记录作为一个参数来接收的,这可以使你把这个记录当成 AD 程序之外的一个目标,并把这个记录的抽象水平保持在与 ADT 子程序的相同高度上,如果你通 过利用其中的每一个域来打开结构,那你就丧失了由 ADT 所带来的抽象性。 不要对参数传递作出任何设想。有些程序员总是担心与参数传递有关的内部操作,并绕过 高级语言的参数传递机制,这样做是非常危险的,而且使得程序的可 通过系统堆栈传输的,但这决不是系统传递参数的唯一方式。即使是以堆栈为基础的传递机制, 这些参数的传递顺序也是不同的,而且每一个参数的字长都会有不同程度的改变。如果你直接 与参数打交道,事实上就已经注定了你的程序不可能在另一个机器上运行。 像 C、Pascal 和 Ada 等先进的语言,都同时支持函数和过程,函数是返回一个值的子程序, 而过程则是不返回值的子程序。 5.8.1 什么时侯使用函数,什么时侯使用过程 激进者认为函数应该像数学中的函数一样,只返 入数据并返回一个唯一的值。这种函数总是以它所返回的值来命名的,比如 sin() CustomerID()等等,而过程对 公用编程法是指把一个函数当作过程 个过程,但由于它只返回一个值,因此从名义上说,它又是函数。你可能在语句中使用过如下 一个称为 Fotmatoutput()的过程: if( Formatoutput( Input,Formatting,Output ) = Success ) then ... 在这 序返回一个值,它又是一个函数。这是使用函数的合法方式吗?从保护这个方法的角度出发, 你可以认为这个函数返回一个值与这个子程序的主要目的——格式化输出无关。从这个观点来 看,虽然它名义上是一个函数,但它运行起来更像是过程。如果一贯使用这种技术的话,那么 用返回值来表示这个过程的成功与否并不会使人感到困惑。 一个替换的方案是建立一个用状态变量作为显式参数的子程序,从而产生了如下所示的 第五章 高质量子程序的特点 72 代码段: FormatOutput( Input, Formatting, Output, Status ) if( Status = Success ) then … 成一行 我更赞成使用第二种方法,这倒并不是因为我是个坚持严格区分函数与过程的教条主义者, 而是因为它明确区分了调用和测试状态变量值的子程序。把调用和测试状态值的语句写 增加了语句的代码密度,也增加了其复杂性。以下这种函数用法也是很好的: Status := FormatOutput( Input, Formatting, Output ) if( Status = Success ) then … 5.9 宏子程序 你的要求: 把宏指令表达式括在括号中。由于宏指令及其变量被扩展到了代码中,应保证它们是按照 你想要的方式被扩展的。在下面这个宏指令中包含了一种最常见的错误: #define product(a,b)a*b 这个宏指令的问题是,如果你向其中传了一个非基本数据(无论对 a 还是 b),它都无法正 输出结果并不是你想要的结果;一个好一些但并非完美的同 样功能的宏指令如下: #define product ( a,b) (a)*(b) ry (Key,Index )\ 5.8.2 由函数带来的独特危险 使用函数产生了可能不恰当值的危险,这常常是函数有几条可能的路径,而其中一条路径 又没有返回一个值时产生的。在建立一个函数时,应该在心中执行每一条路径,以确认函数在 所有情况下都可以返回一个值。 特殊情况下,用预处理程序宏调用生成子程序。下面的规则和例子仅限于在 C 中使用预处 理程序的情况。如果你使用的是其它语言或处理程序,应调整这些规则以适应 确地进行乘法运算。如果你使用这个表达式来算(x+1,x+2),它会把它扩展到 x+1*y+2,由于 乘法运算对加法具有优先佳,因此 这一次的情况要稍好些,但还没有完全正确,如果你在 preduct()中使用比乘法具有优先 权的因子。这个乘运算还是要被分割开,为防止这一点,可以把整个表达式放人括号: #define preduct ( a,b) ((a)*(b)) 用斜线将多重语句宏指令包围起来。一个宏指令可能具有多重语句,如果你把它当作多重 语句来对待的话就会产生问题,以下是一个会产生麻烦的宏指令例子: #define LookupEnt Index=(key-10)/5;\ Index=min(Index,MAX_INDEX);\ Index=max( Index,MIN_INDEX); … for ( Entrycount=0; Entrycountnull ) then node = node.text 如果在某种情形下,为了更谨慎一些,你可以使用如下代码: if ( node<>null ) then if ( node.next<>null ) then 第六章 模块化设计 80 node = node.text 如果你的程序中充斥着 node=node.next 这样的语句,你可能要在其中某些地方进行测试而 跳过其余部分。但是,如果这个操作是独立在一个子程序调用中: node = NearestNeighbor(node) 那么,你只要在子程序中一个地方进行测试,那么这一测试就会在整个程序中都起作用。 如果有时你想在整个程序中都对使用 node 的地方进行测试,也很容易漏掉其中某些地方。然而, 如果你把这一操作独立在一个子程序中,那么是不可能有遗漏的,因为此时这项工作完全是自 动进行的。 隐含数据结构细节的另一个好处是容易调试,比如,你发现 node 值在某处变得有问题了, 但却不知道是在哪里。如果存取 node 的代码充斥了整个程序的话,那么找到出问题的地方不亚 于大海捞针。但如果它是被孤立在一个子程序中的话,那么你可以在其中加入一段检查 node 的调试码,从而在每次存取它时都进行测试,这样事情就解决了。 应用存取子程序最后一个优点是,可以使所有对数据的存取所遵循的是一种平行的组织形 式;或者通过存取子程序、或者直接对数据进行存取,不会两者兼而有之。当然,在负责数据 的模块内部,对数据的存取都是直接的,在这种情况下失去平行性是不可避免的,这样做的目 的是不在公共场合吹脏肥皂泡。这常常伴随着对在存取程序中进行直接数据操作这一拙劣设计 的隐含。 6.2.3 常见需要隐含的信息 在你所从事的项目中,你可能与不计其数需要隐含的信息打交道,但是,其中只有几种是 你要反复遇到的: · 容易被改动的区域 · 复杂的数据 · 复杂的逻辑 · 在编程语言层次上的操作 以上每一项都将在下面的部分中给予详细论述。 容易被改动的区域 容易改动是好的程序设计中一项最富于挑战性的工作。目的是将不稳定的区域孤立起来, 以便使改动带来的影响仅限于一个模块中。以下是你在为应付改动的工作中要遵循的步骤。 1. 识别出那些可能被改动的地方。如果分析工作做得很好的话,其中应该附有可能改动 的地方和改动内容的明细表。在这种情况下,找出可能的改动是非常容易的。如果需 求分析中没有进行这项工作,可以参阅下面关于在任何项目中都可能被改动的区域的 讨论。 2. 把可能被改动的地方分离出来。把第一步中发现的每一个可能改动的地方分隔到自己 的模块中,或者将其与其它可能一起被改动的要素一起,独立到一个模块中。 3. 独立可能被改动的地方。应把模块间的接口设计成对可能变动不敏感,同时,接口应 该把变动限制在模块内部,外部不会受到内部变动影响。而其它调用这个被改动过模 块的模块,不应感受到这个模块被修改过。模块的接口应该能保护模块的隐私权。 第六章 模块化设计 81 以下是一些可能变动的区域: 对硬件有依赖的地方。对于监视器、打印机、绘图机等,要清楚在尺寸、颜色、控制代码、 图形能力及内存等方面可能的变化。其余对硬件有依赖性的方面包括与磁盘、磁带、通讯口、 声音器件等接口的变化等等。 输入和输出。在比原始的硬件接口稍高一些的设计层次上,输入/输出是另外一个反复无常 的区域。如果某一应用产生它自己的数据文件,那么当这一应用变得复杂起来时,文件的格式 可能也要变化。用户层次上的输入和输出格式也有可能变化,比如,在打印纸上边界的位置、 每页上边界的数量、域的排列顺序等等。总之,检查所有的外部接口以寻找可能的变化是个好 主意。 非标准语言特性。如果在程序中使用了非标准扩展,应该把这些扩展隐含在一个模块中, 以便当运行环境变化时你可以很容易地替换它。同样,如果你使用了不是在所有环境下都存在 的库子程序,应该把实际的库子程序放在另一个环境下也可以使用的接口后面。 难于设计和实现的域。最好把难于设计和实现的域隐含起来,因为此处的工作可能作得很 糟,你可能不得不返工。把它们分隔起来,以便使由于拙劣设计或实现对系统所带来的危害最 小。 状态变量。状态变量指示程序的状态,往往比其它数据更容易被改动。在典型的情形下, 你可能最初把某一错误状态变量定义成逻辑变量。但后来又发现如果把它赋成具有 NoError, WarningError 和 FatalError 三个值的枚举型变量来实现会更好。 你至少可以在使用状态变量时,加上两个层次的灵活性和可读性。 · 不要使用逻辑型变量作为状态变量,应使用枚举型变量。赋予状态变量一种新状态是 非常常见的,给枚举型变量赋一个新的类型只需要重新编译一次,而对于逻辑型变量 则需要重新编写每行检查状态变量的代码,谁难谁易是很明显的。 · 使用存取子程序检查变量,而不要对其直接检查,通过检查存取子程序而不是状态变 量,可以进行更复杂的状态测试。例如,如果想检查一个错误状态变量和一个当前函 数状态变量,那么把测试隐含在子程序中来进行,要比用充斥着程序的复杂代码进行 测试容易得多。 数据规模限制。如果你说明一个数组中含有 15 个元素,那么你就把系统不需要的信息暴 露给了它。应该保护其隐私权,信息隐蔽并不总是意味着把一系列功能装入模块这类复杂的工 作,有时,它简单到就是用一个像 MAX_EMPLOYEES 之类的常量来代替 15,以便隐含它。 商业规则。商业规则指法律、政策、规定、惯例等编入一个计算机系统中的东西。如果你 在编写一个工资发放系统,你可能把 IRS 关于允许的扣留数和估计税率等规则编入程序。其余 附加的规则是由工会规定的关于加班率、节假日付酬等方面的规定。如果你正在编写一个引用 保险率的软件,其规定来源于州关于信誉、实际保险率等的管理规定。 这些规定往往是数据处理系统中频繁变动的部分。因为国会可能修改法律,保险公司会调 整保险率。如果你遵从信息隐蔽原则,那么当规则变动时,建立在这些规则上的逻辑关系不会 完全垮掉。这些逻辑关系会隐藏在系统中唯一一个阴暗角落里,直到需对其作出改动为止。 预防到改动。当考虑一个系统中潜在的改动时,应该按照使得改动范围或大小与其改动可 能性成反比的原则来设计系统。如果改动很可能发生,要确保系统可以容易地容纳这一特征。 只有极其不可能发生的变动,才应该被允许在变动时,会影响到系统中一个以上的模块。 第六章 模块化设计 82 一个寻找可能发生变动域的技术是,首先分析程序中可能会被用户用到的最小的子单元, 这些子单元组成了程序的核心,而且很可能被改变。其次,规定对系统的最小增值。它们可以 小到看起来完全是琐碎的程度。这些潜在改进域组成了对系统的潜在改进。应使用信息隐蔽原 则对这些域进行设计。首先分析核心,可以发现哪些要素事实上是后加上的,从而从那里推测 并隐含改进。 复杂的数据 所有的复杂数据都很可能被改动;如果它很复杂而对它使用得又很多,那么在实现层次 上与其打过交道后,可能会发现实现它的更好方式。如果应用信息隐蔽来隐含数据实现,就 可以付出较少的努力而获得更好的实现方法。如果不是这样,那么你每次与这些数据打交道 时,你可能都会在后悔,如果当初进行了信息隐蔽,改动实现将会是多么容易啊! 对复杂数据的使用程度,主要取决于程序。如果是一个只有几百行代码的小程序,你想 在其中对变量进行直接操作,那就这样干吧,这样可能影响程序,但也可能不会。在担心由 于对数据直接操作而带来的维护问题之前,应首先考虑这个小程序的特点。如果你正在编写 一个大一些的程序或使用了全局数据,那么就该考虑使用存取子程序。 复杂的逻辑 隐含复杂的逻辑可以改善程序的可读性。复杂的逻辑并不总是程序的最主要方面,把它 隐含起来可以使得子程序的活动更清楚。与复杂数据一样,复杂逻辑也是很可能变动的部分。 所以,把程序的其它部分从这种变动里隔离出去是非常有益的。在某些情况下,你可以将所 使用的逻辑种类隐含起来,例如,你可以通过一个大的 if 语句、case 语句或查表方式来进行 测试。除了这些进行测试的代码外,其余的代码不需要知道这些细节。如果程序中的其余代 码只需要知道结果,那么你就应该仅仅告诉它们结果。 在程序语言层次上的操作 你的程序越是像一个实际问题的解决方案,它就越是不像程序语言结构的组合,那么, 其质量也就越好,应该把过于专业化的信息隐含起来,比如,下面的语句: EmployeeID = EmployeeID+1 CurrentEmployee = EmployeeList [ EmployeeID ] 这是一段很不错的程序,但是它是用过于专业化的语言来表达的,应该用较高程度抽象 的语言来进行这个操作: CurrentEmployee = NextAvailableEmployee() 或者用: CurrentEmployee = NextAvailableEmployee( EmployeeList, EmployeeID ) 通过加入一个隐含了用专业化语言解释正在发生什么的子程序,使得在一个更高的抽象 层次上处理这个问题。这使得你的意图更清楚,而且使得代码更容易理解和改动了。 如果用图表来实现一个排序问题。函数 HighestPriorityEvent(),LowestPriorityrEvent()和 NextEvent()是抽象函数,隐含了实现细节;而 FrontOfQueue(),BackOfQueue()和 NextInQueue() 第六章 模块化设计 83 并没有隐含多少细节,因为它们提到了实现,暴露了它们该隐藏的秘密。 一般来说,在设计一组在程序语言语句层次上操作数据的子程序时,应该把对数据操作 隐含在子程序组中,这样程序的其余部分就可能在比较抽象的层次上处理问题了。 6.2.4 信息隐蔽的障碍 绝大多数信息隐蔽障碍都是心理上的,它主要来自于在使用其它技术时形成的习惯。但 在某些情况下,信息隐蔽也的确是不可能的,而一些看起来像是隐蔽障碍的东西,但仅仅是 借口而已。 信息过度分散 信息隐蔽的一个常见障碍是系统中信息过于分散。比如在一个系统中到处分布着常数 100。把 100 当作一个常数,降低了引用它的集中程度。如果把信息隐蔽在一个地方会更好, 因为这样它的值将只在一个地方改变。 另一个信息过于分散的例子是程序中分布着与用户交互的接口。如果需要改变交互方式, 比如,从命令行方式改为格式驱动方式,那么所有的代码事实上都要被改动。因此,最好把用 户交互接口放入一个单独的模块中,这样,你不必影响到整个系统就可以对交互方式进行改动。 而还有一个例子则是全局数据结构,比如,一个在整个系统中四处被存取的拥有多达 1000 个元素的雇员数据数组。如果程序直接使用这个全局数据,那么这个数据结构的实现信息—— 它是一个数组且拥有最多 1000 个元素——将充斥着整个程序。如果这个程序只通过存取子程 序来使用这个数据结构,那么就只有这个存取子程序才知道这些细节。 交叉依赖 一个不易察觉的信息隐蔽障碍是交叉依赖。比如模块 A 中的某一部分调用了模块 B 中的 一个子程序,而模块 B 中又有一部分调用了模块 A 中的子程序。应避免这种交叉依赖现象。 因为只有在两者都已准备好的情况下,你才能测试其中的一个。当程序被覆盖时,必须使 A 和 B 同时驻留在内存中,才能避免系统失败。通过寻找两个模块中被其它模块使用的部分, 把这些部分放入新的模块 A’和 B’中,用模块 A 和 B 中的其余部分来调用 A’和 B’,基本上可 以消除这一问题。 误把模块数据当成全局数据 如果你是个谨慎的程序员,那么信息隐蔽的障碍之一便是误把模块数据当作全局数据而 避免使用它,因为要避免由于使用全局数据而带来的麻烦。但是,如同在 6.1 节“模块化: 内聚性与耦合性”中所说的那样,这两种数据是不同的。由于只有在模块中的子程序才可以 对其进行存取,因而由模块数据带来的麻烦要比全局数据小得多。 如果不使用模块数据,就不会知道了解由模块所带来的巨大收益。如果一个子程序向模 块传递了只有它才能处理的数据的话,那么就不该由模块来承担拥有数据集合并对其进行操 作的罪责。比如,在前面列举的建议利用如下语句来提高抽象程度的例子中: CurrentEmployee = NextAvaliableEmployee() 或使用: 第六章 模块化设计 84 CurrentEmployee=NextAvailableEmployee(EmployeeList, EmployeeID) 这两个赋值语句间的区别是:在第一种情形下,NextAvailableEmployee()拥有关于雇员表 和目前表中的入口是哪一个入口的信息,而在第二种情况下,NextAvailableEmployee (EmployeeList, EmployeeID)只是从向它传递数据的子程序中借用这些信息。当你使用 NextAvailableEmployee()时,为了提供全部的抽象能力,不必担心它所需要的数据,只要记住 它负责自己的问题就可以了。 全局数据主要会产生两个问题:(1)一个子程序在对其进行操作时并不知道其它子程序也 在对它进行操作;(2)这个子程序知道其它子程序也在对其进行操作,但不知道它们对它干了 什么。而模块数据则不会产生这些问题,因为只有被放在一个单独模块中的有限几个子程序 才被允许对模块数据进行直接存取操作,当一个子程序进行这种操作时,它知道别的子程序 也在进行同样操作,并确切知道这些是哪几个子程序。如果你还不相信的话,试一下,结果 会令你满意的。 误认为会损失性能 信息隐蔽的最后一个障碍是在结构设计和编码两个层次上,都试图避免性能损失。事实 上,在两个层次上你都不必担心这一点。在结构设计层次上,这种担心之所以不必要是因为, 以信息隐蔽为目标进行结构设计,与以性能为目标进行结构设计是不矛盾的,只要你同时考 虑到这两点,那么就可以同时达到这两个目标。更常见的担心是在编码层次上,这种担心主 要是认为间接而不是直接地存取数据结构会带来运行时间上的损失,因为这样做附加了调用 层次。当测试了系统的性能并在瓶颈问题上有所突破时,这种担心是不成熟的。为提高软件 性能做准备的最好手段之一就是模块化设计,这样,在发现了更好的方案之后,不必改变系 统其余部分,就可以对个别子程序进行优化。 6.3 建立模块的理由 即使不经常使用模块,凭直觉也很可能会对可以放入模块的数据和子程序种类有所了解。 从某种意义来说,模块并不是人们的目标,它只是数据及对数据所进行的操作的集合,并且支 持面向对象的概念——抽象和封装。模块不支持继承性,因而它也并不完全支持面向对象编程, 描述它的这种有限的面向对象特性的词汇是 Booch 1991 年提出来的“基于对象”编程。 以下是一些适合使用模块的域: 用户接口。可以建立一个模块来把用户接口要素独立起来。这样,不会影响程序其它部分, 你就可以进行改进。在许多情况下,用户接口模块中往往包含有几个模块来进行诸如菜单操作、 窗口管理、系统帮助等。 对硬件有依赖的区域。把对硬件有依赖的区域放入一个或几个模块中。这些区域常见的有: 与屏幕、打印机、绘图机、磁盘驱动器、鼠标等的接口。把这些对硬件有依赖的区域独立起来 可能帮助把程序移植到新环境下运行。设计一个硬件经常变动的程序时,这也是很有帮助的, 可以编写软件表模拟与特定硬件的交互作用,硬件不存在或不稳定时,让接口子程序与这些模 拟软件打交道。然后在硬件稳定时,再让接口子程序与硬件打交道。 输入与输出。把输入/输出封装起来,可以使程序其余部分免受经常变动的文件和报告 格式的影响。把输入/输出放入模块,也使得程序很容易适应输入/输出设备的变动。 操作系统依赖部分。把对操作系统有依赖的部分放入模块的原因与把对硬件有依赖部分放 入模块的原因是相同的。如果你正在编写一个在 Microsoft Windows 下运行的软件,为什么要把 它局限于 Windows 环境下呢?你完全可以把对 Windows 的调用放在一个 Windows 接口模块中。 如果以后想把程序移植到 Macintosh 或者 OS/2 环境下,你所要做的只是改动一下接口模块而已。 第六章 模块化设计 85 数据管理。应把数据管理部分放入模块中,让其中的子程序去与那些杂乱的实现细节打交 道。而让模块外的子程序用抽象的方式与数据打交道,这种方式应该尽可能避免实际处理问题, 如果你认为将数据管理模块化是将其放入一个单独模块中,那你就错了。通常,每一种主要的 抽象数据类型,都需要一个单独的模块来管理。 真实目标与抽象数据类型。在程序中,需要为每个真实目标创立一个模块。把这一目标所 需要的数据放入模块中,然后再在其中建立对目标进行模块化的子程序。这就是所谓抽象数据 类型。 可再使用的代码。应把计划在其它程序中再用的程序部分进行模块化。建立模块的一个优 点是,重新使用模块要比重新使用面向功能的程序实用得多。在面向对象设计和面向功能设计 方法中,刚开始的项目都不能从以前的项目中借用许多代码,因为以前项目还不够多,无法提 供足够的代码基础。使用面向功能设计方法开发的程序,大约可以从以前的项目中借用 35%的 代码:而在使用面向对象设计方法开发的项目中,则大约可以从以前的项目中借用 70%的代码。 如果可以通过深思远虑而在以后的项目中避免重写 70%的代码,那为什么不这样做呢? 可能发生变动的相互联系的操作。应该在那些可能发生变动的操作周围修建一道隔墙。这 事实上是容错原则的一种,因为这样可以避免局部的变动影响到程序的其余部分。在 6.2 节中, 给出了一些经常发生变动的区域。 互相联系的操作。最后,应把互相联系的操作放到一起。在绝大多数情况下,都可以发现 把看起来互相联系的子程序和数据放在一起的更强的组织原则。在无法隐蔽信息的情况下,比 如共享数据或计划增强灵活性时,仍然可以把成组操作放在一起,比如,三角函数、统计函数、 字符串操作子程序、图像子程序等。通过精心地成组放置相关操作,还可以在下一个项目中重 新使用它。 6.4 任何语言中实现模块 有些语言直接支持模块化,但有些语言则需要补充一些编程标准才可以。 6.4.1 模块化所需的语言支持 模块包括数据、数据类型、数据操作以及公共和局部操作的区分等。为了支持模块化,一 种语言必须支持多种模块。如果没有多模块,其它任何要求都是空谈。 数据需要在三个层次上可以被存取和隐含,在局部,在模块中及在全局中,绝大多数语言 都支持局部数据和全局数据。如果想要使某些数据仅对模块中的子程序才是可以存取的,那么 就要求语言支持模块数据,即只有某些而不是全部子程序都可以存取的数据。 第六章 模块化设计 86 对于数据类型的可存取性和可隐含性的要求,与对数据的要求是类似的。某些类型应该隐 含在某一特定模块中,而另一些类型应该是对其它模块开放的。模块需要能够对那些可以知道 其它类型的模块进行控制。 对模块层次上的子程序的要求也与上述相类似。有些子程序应该只有在模块内部才能调 用,而且模块应该对某一子程序是专用的还是公用的可以进行控制。在模块之外,不应该有其 它模块或子程序知道这个模块中存在专用子程序。如果模块设计得很好,那么其它模块或子程 序不应该有任何理由来关心专用子程序的存在。 6.4.2 语言支持慨述 在下表中,对几种语言支持信息隐蔽的必要结构进行了总结: 通用 Basic 和通用 Pascal 不支持多模块,所以被排在支持模块化的前列,Fortran 和 QuickBasic 不能控制支持模块化的数据类型。只有 Ada、C++和 C 以及 Turbo Pascal 才允许模块 限制对某一子程序的调用,从而使这个子程序真正是专用的。 数据 数据类型 源程序 语言 多模块 局部 模块 全局 局部 模块 全局 专用 模块/全局 Ada · · · · · · · · · C · · · · · + · · · C++ · · · · · · · · · Fortran77 + · + · - - - - · 通用 Basic - - - · - - - - · 通用 Pascal · · · · · · · · · Turbo Pascal - · - · · - · - · QuickBasic · · · - · - - - · 简而言之,除非使用 Ada、C、C++或 Turbo Pascal,否则,就不得不通过命名或其它约 定来扩充所使用语言的能力,以便模拟使用模块。以下部分简单论述了直接支持模块化的语 言,并且将告诉你在其它语言中怎样模拟使用模块。 Ada 与 Modula-2 支持 Ada 通过“包”的概念来支持模块化。如果用 Ada 编过程序,那么就已经知道如何建立包 了。Modula-2 通过模块的概念来支持模块化。虽然 Modula-2 并不是本书的特性,它对模块化 的支持仍然是非常直接的,以下给出了一个用 Modula-2 进行排序的例子: definition module Events; export EVENT, EventAvailable, 这些是公共的 HighestPriorityEvent, LowestPriorityEvent; 第六章 模块化设计 87 type EVENT = integer; var EventAvailable:boolean; { true if an event is available } function HighestPriorityEvent:Event; function LowestPriorityEvent:Event; end Events; 面向对象的语言支持 面向对象的语言,如 C++,对模块化的支持是直接的,模块化是面向对象编程的核心。以 下是一个 C++来实现排序的模块的例子: class Buffer { public; typedef int EVENT; BOOL Eventavailable; /*true if an event is available */ EVENT HighestPriorityEvent(void); EVENT LowestPriorityEvent(void); Private; ... }; Pascal 的支持 某些版本的 Pascal,即 4.0 版和随后的 Turbo Pascal,利用单元的概念来支持模块化。“单 元”是一个可以包括数据、数据类型和子程序的数据文件。单元中有一个说明了可供模块外部 使用的子程序和数据接口。数据也可以被说明为在这个文件内部的函数和过程使用,而且是仅 在其内部使用。这为 Turbo Pascal 提供了可以在局部、模块和全局层次上的数据可存取性。以 下是在 Turbo Pascal 5.0 版中的排序模块形式: unit Events; INTERFACE type EVENT=integer; var EventAvailable:boolean;{ true if an event is available } 第六章 模块化设计 88 function HighestPriorityEvent: Event; function LowestPriorityEvnet:Event; IMPLEMENTATION ... ——文件中这部分的子程序数据,如果没有在上面 INTERFACE 中说明 的话,则对其它文件来说是隐蔽的 end.{unit Events} 属于 Generic Pascal 标准的 Pascal 实现并不直接支持模块化,不过你可以通过扩展它们来达 到模块化,这将在后面讨论。 C 的支持 虽然用 C 语言编程的程序员们并不习惯在 C 中使用模块,但事实上 C 也直接支持模块化。 每一个 C 源文件都可以同时含有数据和函数,可以把这些数据和函数说明为 Static,这将使它 们只在源文件内部才能使用。也可以不把它们说明为 Static,此时它们在源文件外也可以使用。 当每一个源文件都被当作模块时,C 就完全支持模块化了。 由于源文件和模块并不完全相同,你需要为每一个源文件创建两个头文件——一个作为公 用、模块头文件,另一个作为专用的、源文件头文件。在源文件的公用头文件中,只放入公用 数据和函数说明,下面是一个例子: /* File:Event.h 本文件仅包含公共的可用类型、数据和函数说明 Contains public declarations for the "Event" module. */ typedef int EVENT; extern BOOL EventAvailable; /* true if an event is available */ EVENT HighestPriorityEvent(void); EVENT LowestPriorityEvent(void); 而在源文件专用头文件,只放入供内部使用的数据和函数说明。用#include 命令把头文件 只放入组成模块的源文件中,不要允许其它文件含有它。以下是一个例子: /* File:_Event.h Contains private declarations for the "Event" module. */ /* private declarations */ ——这里是专用类型、数据和函数 ... C 源文件中,要使用#include 来把两个头文件都包含在其中。在其它使用了 Event 模块中 的公用子程序模块中,用#include 命令来只把公用的模块标题包含进去。 如果在单独一个模块中,需要使用一个以上源文件,可能要为每一个源文件都加一个专用 头文件;但对组成模块的子程序组,应该只加一个公共的模块头文件。对于模块中的源 第六章 模块化设计 89 文件来说,利用#include 来包含同一个模块中其它源文件的头文件是可以的。 Fortran 的支持 Fortran90 为模块提供了全部支持。而 Fortran77 如果在规定范围内使用,则只对模块提供 了有限的支持。它拥有创建一组对数据具有独占存取权的机制,这个机制就是多重入口点, 对这种机制的随便使用,往往会产生问题。而如果小心使用的话,它则提供了一种不必使数 据成为公用的,就可以使一组子程序对同数据进行存取的途径。 可以说明数据为局部的、全局的或者是 COMMON 的。这个特性又提供了使子程序对数 据进行受限制存取的方法,定义一组组成一个模块的子程序。Fortran 编译程序无论如何也不 会认为它们组成了一个模块,但是从逻辑上来说,它们的确组成了一个模块。对模块中每一 个允许对数据进行直接操作的子程序,都应该用 COMMON 作为其前缀来命名。不要让模块 以外的子程序使用 COMMON 作为前缀。利用编程标准来弥补程序语言不足这一办法,将在 后面讨论。 6.4.3 伪模块化 在像通用 Basic,通 用 Pascal 和其它既不直接也不间接支持模块化的语言中,该如何进行模 块化呢?答案在前面已经提到过了,利用编程标准来代替语言的直接支持。即使你的编译程序 并不强制你采用好的编程应用,还可以采用能够达到这一目的的编码标准。以下的讨论涉及了 模块化所要求的每一方面。 把数据和子程序装入模块 这个过程的难易程度取决于编程语言,即它是允许使用各种源文件的还是要求所有的代码 必须是在一个源文件中的?如果你需要用 10 个模块,那就创建 10 个源文件。如果环境要求所 有代码都要在一个源文件中,那就按模块把代码划分为几部分,同时用注释来区分每一个模块 的开头和结尾。 保证模块的内部子程序是专用的 如果所采用的程序语言不支持限制内部子程序的可见性,使得所有的子程序对其它子程序 来说都是公用的,可以利用编码规定来限定只有标明公用的子程序才能被模块外的子程序使用。 不管是什么样的编译程序,下面都是你可以做的: 通过在说明的地方加注释,来明确区分公用和专用子程序。明确区分与每个子程序相关的 模块。如果别人不得不通过查询子程序说明来使用子程序,要确保在说明中注释了它是公用的 还是专用的。 不允许子程序调用其它模块的内部子程序。使用注释把属于同一个模块的子程序联系到一 起。 采用命名约定来表明一个子程序是内部的还是外部的。可以让所有的内部子程序名称前面 都带有下划线(_)。虽然这种方法无法区分不同模块的内部子程序,但是绝大多数人还是可以 区分一个子程序是否是属于它们自己的模块。如果它不是自己模块中的子程序,那么显然它是 其余模块中的子程序。 第六章 模块化设计 90 采用表明它是内部的还是外部的命名规定。所采用约定的细节,往往取决于编程语言所赋 予你对子程序命名的灵活性。例如,一个 DataRetrieval 模块的内部子程序名称可能会以 dr_作 为前缀。而 UserInterface 模块内部子程序的名称前缀则可以是 ui_。而属于同一模块的外部子程 序则分别会以 DR_和 UI_作为前缀,如果对名称的字节长度是有限制的(如,ANSI FORTRAN77 中的 6 个字母),那么命名的约定就会用掉其中相当一部分,这时,在制定命名约定时就要注意 这一长度限制。 保证子程序的内部数据是专用的。 保证模块层次上的内部数据的专用性与保证模块层次上子程序的专用性是相似的。通常, 要采用清楚表明只有特定数据才能在文件外部使用的编码标准。不管编译程序允许你做的是什 么,以下是一些可以采用的步骤: 首先,利用注释来说明数据是公用的还是专用的。明确区分模块所涉及到的每一个数据的 可存取性。 其次,不允许任何子程序使用其它模块中的专用数据,即使编译程序把此数据作为全局变 量也不可以。 第三,采用可以使你注意一个文件是专用还是公用的命名约定。为了保持连贯性,应该使 这一命名原则与子程序的命名原则类似。 第四,采用表明数据属于哪一个模块,即它是外部还是内部的命名约定。 6.4.4 检查表 模块的质量 · 模块是否有一个中心目的? · 模块是否是围绕着一组公用数据进行组织的? · 模块是否提供了一套相互联系的功能? · 模块功能是否足够完备,从而使得其它模块不必干预其内部数据? · 一个模块相对其它模块是否是独立的?它们之间是松散耦合的吗? · 一个模块的实现细节,对其它模块来说,是隐含的吗? · 模块的接口是否抽象到了不必关心其功能实现方式的地步?它是作为一个黑盒子来设 计的吗? · 是否考虑过把模块再划分为单元模块?是否对其进行了充分的再划分工作? · 如果用不完全支持模块的语言编程,你是否制定了编程约定以使这种语言支持模块? 6.5 小 结 · 不管调用哪一个,子程序与模块的不同是很重要的,要认真考虑子程序与模块的设计。 · 从模块数据是被几个子程序使用的这一角度来说,它与全局数据是相同的,但从可以 使用它的子程序是有限的,而且清楚地知道是哪些子程序可以使用它这一角度来说, 模块数据与全局数据又是不同的。因此,可以使用模块数据而没有全局数据的危险。 第六章 模块化设计 91 · 信息隐蔽总是有益的。其结果是可以产生可靠的易于改进的系统,它也是目前流行的 设计方法的核心。 · 创建模块的原因有许多是与创建子程序相同的。但模块概念的意义要比子程序深远得 多,因为它可以提供一整套而不是单独一个功能,因此,它是比子程序更高层次的设 计工具。 · 可以在任何语言中进行模块设计。如果所采用的语言不直接支持模块,可以用编程约 定对其加以扩展,以达到某种程度的模块化。 第七章 高级结构设计 92 第七章 高级结构设计 目录 7.1 软件设计引论 7.2 结构化设计 7.3 面向对象 7.4 对目前流行设计方法的评论 7.5 往返设计 7.6 小结 相关章节 高质量子程序的特点:见第 5 章 高质量模块的特点:见第 6 章 软件结构设计:见 3.4 节 有些人可能认为高层次设计不是真正的实现活动,但是在小规模项目中,许多活动都被认 为是实现活动,其中包括设计。在一些较大的项目中,一个正式的结构设计可能只是分解系统, 而其余大量的设计工作往往留在创建过程中进行。在其它的大型项目中,设计工作可能是非常 详细的,以致于编码不过是一项机械的工作而已,但是设计很少有这样详尽的。编码人员常常 要进行一些设计工作。 高层次设计是一个很大的主题,同时也由于它只部分地与本书主题有关系,因此,我们将 只论述其中的几个方面。模块设计和子程序设计的好坏在很大程度上取决于系统的结构设计好 不好,因此,要确保结构设计先决条件(如 3.4 节所述)已经被满足了。也有许多设计工作是 在单个子程序和模块层次上进行的,这已在四、五、六章中论述过了。 如果已经对结构化设计和面向用户设计非常熟悉了,你可能想阅读下一部分的介绍,再跳 到 7.4 节关于两种技术的比较,最后阅读 7.5 节。 7.1 软件设计引论 “软件设计”一词的意思是指,把一个计算机程序的定义转变成可以运行程序的设计方法。 设计是联系要求定义和编码与调试的活动的桥梁。 它是一个启发的过程而不是一个确定的过 程,需要创造性和深刻的理解力。设计活动的绝大部分活动都是针对当前某特定项目进行的。 7.1.1 大型和小型项目设计 在大型的、正规的项目中,设计通常是与需求分析、编码等活动分立的,它们甚至可能是 由不同人分别完成的。一个大型项目可能有几个级别的设计工作——软件结构设计、高层次模 块设计和实现细节设计。结构设计具有指导的意义,在小规模编码阶段往往也是很有帮助的。如 第七章 高级结构设计 93 果进行了通用或高层次设计,那么其指导方针则在大规模编码阶段是非常有意义的。不管是出 于什么原因,即使名义上设计工作已经结束了,但事实上它远没有停止。 在小型的、非正式的项目中,大量的设计工作都是由程序员们坐在键盘前面完成的。“设计” 可能只是用程序语言编程,用 PDL 来编写子程序。也可能是在编写子程序之前画一下流程图。 不管是怎样进行的,小型项目与大型项目一样,都会从良好的设计工作中受到大量好处。而使 得设计活动明显化,则会极大地扩展这一好处。 7.1.2 设计的层次 软件系统中,设计是在几个不同层次的细节上进行的。有些技术适用于所有的层次,而另 外一些,则往往只会适合其中的一或两个层次,下面是这些层次: 层次 1:划分成子系统 在这一层次上,设计的主要成果是划分系统的主要子系统。这些子系统可能是很大的—— 数据库接口、用户接口、命令解释程序、报告格式程序等等。这一层次的主要设计活动是如何 将系统划分成几个主要要素,并且定义这些要素间的接口。在这一层次上的划分工作主要是针 对那些耗时在几天以上的项目。在图 7-1 中,设计是由三个主要元素和它们的交互作用组成的。 其中程序划分为系统(l),子系统更进一步划分为模块(2),一些模块划分为程序(3), (4)为每个子程序内部设计。 “子程序”和“模块”的特定意义已经在前几章中引入了。“Subprogram”一词将在本章中 使用,我们可以称它为“亚程序”,它是指小于整个程序的任何程序,其中也包括子程序和模块。 层次 2:划分成模块 这一层次的设计包括识别系统中的所有模块。在大型系统中,在程序分区层次上划分出来 第七章 高级结构设计 94 的子系统往往太大,难以直接翻译成代码。例如,一个数据库接口子系统可能非常复杂,需要 有十几个子程序来实现。如果出现这种情况,那么还需要将这个子系统再划分为模块:如数据 存储、数据恢复、问题解释等模块。如果分解出来的模块还是太复杂,那么将对它再次划分。 在许多程序的设计中,在层次 1 中分解出来的子系统将直接转变成层次 2 中的模块,而不再区 分这两个阶段。 在定义模块时,同时也要定义程序中每个模块之间的相互作用方式。例如,将定义属于某 一模块的数据存取函数。总之,这一层次的主要设计活动是确保所有的子系统都被分解成为可 以用单个模块来实现它的每一部分。 与把一个系统分解成子系统一样,把子系统分解成模块也是主要针对耗时在几天以上的项 目的。如果项目很大,这一层次的分解活动是与上一层次严格区分的。如果项目较小,层次 1 与层次 2 可能是同时进行的。在图 7-1 中,分解为模块的活动已经包含于每个元素中了。正如 图中所示的那样,对于系统不同部分的设计方法是也不同的。对某些模块之间关系的设计可能 是以网络思想为基础的,也可能是以面向对象思想为基础的。如同图中左面两个子系统那样。 而其它模块的设计则可能是分级的。如图中右侧的子系统那样。 层次 3:划分成子程序 这个层次的设计包括把每个模块划分成各种功能,一旦一个子程序被识别出来,那么就同 时规定它的功能。由于模块与系统其它部分是相互作用的,而这一作用又是通过功能子程序进 行的,所以,模块与系统其余部分的作用细节是在这一部分规定的。例如,将严格定义如何调 用解释程序。 这一层次的分解和设计工作对于任何耗时超过几个小时的项目都是需要的,它并不一定需 要被正式地进行,但起码是要在心中进行。在图 7-1 中,在左上角的一组中的一个模块中,给 出了划分成子程序的工作活动。当你揭开黑盒子的盖子时,如同图 7-l 中标有 3 的模块,你可 以发现由模块提供的功能是由层次组织的子程序组成的。这并不是意味着每个黑盒子中都含有 层次结构,事实上只有某些黑盒子中才有。其余组织形式的子程序可能没有或很少有层次结构。 层次 4:子程序内部的设计 在子程序层次上的设计,包括设计单个子程序中的详细功能等。子程序内部设计往往是由 程序员进行的。这一设计包括编写 PDL,在参考书中寻找算法,在子程序中组织代码段落,编 写编程语言代码等活动。这种层次的工作在任何一个项目中都是要进行的,无论是有意识的还 是无意识的,是作得好还是作得坏。如果缺少了这一层次的工作,任何程序都不可能产生。在 图 7-1 中,在标有(4)的一组中,表现了这个层次的工作。 7.1.3 创建中的设计工作 对于设计层次的讨论,是论述本章其余部分内容的前提,当人们在提到“设计”一词时, 他们事实指的可能是许多不同的活动。这是一个非常重大的主题,也是非常重要的。以下是关 于这一活动,但这次是按照从细节到总体的顺序进行的。 第七章 高级结构设计 95 内部子程序设计 在第四章中间明确讨论过内部子程序设计问题,在第五章“高质量子程序的特点”中,对 这一问题又作了进一步的讨论。在第五章关于数据和数据控制部分中,从个别程序语句和子程 序中模块的层次,对这一问题进行了讨论。在本章中,这一问题的讨论主要分布在各个部分中。 划分成子程序 在结构化设计方法中,“设计”往往是指设计程序的结构,而不指单个子程序内部的设计。 关于程序结构问题,完全可以写一本专著,本章中论述的只是构造简单程序集合的技术总结, 这些子程序集合将是你在设计中经常要实现的。 划分成模块 在面向对象设计中,“设计”指的是设计一个系统中的不同模块。模块定义也是一个很大的 足以写成一部书的主题,在第六章中已经论述过了。 划分成子系统 对于中小型程序来说(一般是 10000 条语句左右),定义模块和子程序的技术往往隐含在整 个程序的设计中,在更大一些程序中,往往需要特殊的设计方法,这对于本书来说(本书重点 是实现)是难以详尽论述的。在许多情况下,特别是在一些小项目中,设计工作是键盘上完成 的,因此事实上是一种实现活动,虽然它应该在早期就完成了。本书之所以涉及了模块和子程 序设计,是因为它们处在实现的边缘上。而关于程序划分的其它讨论,则不在本书之列。 7.2 结构化设计 结构化设计这一概念是1974年在《IBM 系统日报》(IBM System Journal)一篇论文中出现 的。在后来由 Ed Yourdon 和 Larry Constantine 写进《Structured Design:Fundamentals of a Discipline of Computer Program and Systems Design》书 中( 1971),对其作了全面补充与扩展。 Constantine 是最初那篇论文的作者之一,而“自顶向下设计”一词则是指一种非正式的结构化设计,类似 的词还有“逐步求精”和“分解”等,指的基本都是同一意思。结构化设计是与其它结构化设 计方法一道使用的。 结构化设计是由以下部分组成的: · 系统组织,系统将被设计成几个黑盒子,明确定义的子程序和模块、接口的实现细节对 其它子程序来说都是隐含的。 · 开发设计的策略。 · 评估设计准则。 · 关于问题的明确说明,这是解决问题的指导原则。 · 表达设计的图形和语言工具,包括 PDL 和结构图。 在下面的内容中,将对这些内容作比较详细的论述。 第七章 高级结构设计 96 7.2.1 选择需进行模块化的要素 在前面几章论述了程序和模块相关好坏的标准,并提供了确定子程序和模块质量的检查表, 但并没有给出识别子程序和模块的方法,在本节中,将论述这一问题的指导原则。 自顶向下分解 把程序分解为子程序的一种流行方法是自顶向下分解,也称为自顶向下设计或逐步求精。 其特点是从关于程序功能的粗略说明出发,逐步推进到程序要做的每一项特定的工作。从粗略 的层次出发往往是指从程序中的“主要”子程序出发,通常,把这个子程序画在结构图的顶部。 以下是几项在进行自顶向下分解时要牢记的原则: · 设计高层次。 · 避免特定语言细节。从设计中,不应该看出打算在程序中使用什么语言,或者说当在设 计中更换要用的语言时,不会产生任何麻烦。 · 暂时不指出下一层次的设计细节(与信息隐含类似)。 · 正规化每个层次。 · 检验每个层次。 · 转移到下一个层次,进行新的求精工作。 自顶向下设计指导原则的依据是:人脑一次只能考虑有限数量的细节。如果你从一个较简 略的子程序开始,逐步把它分解成更加详细的子程序,就不必每次考虑过多的细节。这种方法 也常称之为“分而治之”战术。它对于分层结构往往是最有效的。如图 7-2 所示。 从两个方面来看,这种分而治之战术是一个逐次迭代逼近的过程,首先,这是由于往往在 一次分解之后你并不会马上停止,还要进行下几个层次的分解。其次,是由于分解并不是一蹴 而就的,采用某种方法分解一个程序,再看一下效果,然后,又用另外一种方法来分解这个程 序,看效果是否会好些,在几次尝试之后,就有了一个很好的办法,同时也知道为什么这样做。 需要把一个程序分解到什么程度呢?要持续不断地分解,直到看起来下一步进行编码要比 再分解要容易为止,或者到你认为设计已经非常明了详细,对再分解已经感到不耐烦为止,到 这时,可以认为分解已经完成了。由于你比任何人都熟悉这个问题,而且也比任何人都清楚, 因此,你要确保其解决方案是很容易理解的,如果连你都对解决方案有些困惑的话,那么,试 想一下,又有谁会理解它呢? 自底向上合成 有时候,自顶向下方法过于抽象以至于让人不知从何下手。如果想要进行一些具体的工作, 那么可以试一下自底向上的设计方法,如图 7-2 所示。你可以问自己,“这个系统需要做什么?” 毫无疑问,你能够回答这个问题。你可以识别出系统需要具备的较低层次的功能,例如,你可 能知道这个系统需要进行报告格式化、计算报告总数、用不同颜色在屏幕上显示字母等等。当 你识别出某些低层次功能后,再从事较高层设计可能会有把握些。 以下是一些在进行自底向上合成时要牢记的原则: · 问自己,关于系统要做什么你都知道哪些? · 利用这一问题识别出某些低层次功能。 第七章 高级结构设计 97 · 识别出这些低层次功能共同的方面,将其组合到一起。 · 向上一个层次,进行同样的工作,或回到顶端开始自顶向下。 其中自顶向下是从一般到特珠,自底向上是从特殊到一般。 自顶向下与自底向上 自底向上与自顶向下策略的首要区别是前者是合成,而后者则是分解。一个是从可控制的 零散问题出发,把它们合成到一起从而获得一个总体解决方案,而另一个则从总体问题出发, 将其分解成可控制的零散问题。两种方法各有优缺点。在实际使用时要详加比较。 自顶向下设计方法的特点是比较简单,人们往往擅长把大问题分解成小问题,而程序员们 则更是擅长这点。当一个问题是用层次结构模型化的时候,自上而下的分解方法恰好与其相符。 自顶向下设计的另一个优点是你可以避开实现细节。由于系统常常受到实现细节变动的干 扰(比如文件结构或报告格式的变化),因此把这些细节隐含在层级结构的底部,而不是让它在 顶部起支配作用,是非常有益的。 这种设计方法也有它的缺点。其中之一是系统的总体功能可能是很难识别的。关于系统所 作的最重要决定之一就是如何进行第一步分解工作,而在自上向下设计中,刚开始接触系统, 对其了解还很少时,便不得不做出这一决定,这是很危险的。它的另一个缺点是:由于许多系 统本身并不是层级结构的,因此是很难清晰地分解。或许这种设计方法的最大缺点就是它要求 系统在顶层要有一个单一而又清楚的功能,而对于现代事件驱动系统来说,这是很难想象的。 自底向上设计方法的优点是它在早期就可以识别出有用的功能子程序,结果是坚实可靠的 设计。如果已经开发了相似的系统,那么可以参阅一下旧系统,看看有什么可以借用的。 这种方法的弱点是很难单独地使用它。因为大多数人都不善于从小的概念出发形成综合 的设想。这就像一个自己组装的玩具,我想我已经组装完了,怎么盒子中还有零件呢?好在, 你不必单独使用它。 它的另一个弱点是,有时从你识别出的细节出发,无法建造出整个程序,就像你无法用砖头 造出一架飞机一样。而且当你知道在底部需要什么功能时,你可能已经不得不进行顶层设计了。 第七章 高级结构设计 98 不过,这两种方法并不是互相矛盾的。设计是一个启发的过程,就是说没有一种百试不爽 的设计方法,它总是一个尝试的过程。因此,在找到好方法之前,尽可以大胆尝试,可以用自 顶向下工作一会儿,再用自底向上工作一会儿。 设计也是一个逐次迭代逼近的过程。因此,你在第 n 次用自底向上方法学到的东西,将在 第 n+l 次用自顶向下方法设计时起到很大帮助作用。 7.3 面向对象 面向对象设计方法的特点是通过对实际问题的分析,从中抽象出对象,然后再用程序语言 来表现它,其过程主要是:识别对象中的子对象并识别出对于子对象的操作,然后再根据子对 象的操作开发出一个系统。面向对象设计是在程序中设计对象或模块的一种方法。在较低的程 度上说,它也是设计单个子程序的一种方法。 虽然有些鼓吹者把计算机历史划分为面向对象设计出现前阶段和面向对象设计出现后阶 段,但事实上面向对象设计与其它设计方法并不冲突。特别地,面向对象设计与结构化编程所 提供的低层次结构化并不是不兼容的,但它与高层次结构化的确不兼容。在更高的层次上,面 向对象设计方法在简单的功能性层次结构上,添加了类、群和非层次结构等新的概念。对这些 高层次的组合思想进行研究和标准化工作,将会使编程技术再向前产生一次飞跃。 在本书,对于面向对象设计的讨论是非常浅显的。与结构化设计方法相比,面向对象设计 的抽象化程度更高。本节着重论述的只是在较低层次上起作用的抽象方法,其中主要是在个别 语句、子程序和有限数量的子程序这个层次上的。这种设计方法相对来说也是一种新的设计理 论。它还没有完全成熟,关于这方面积累的设计经验也还不够丰富,但是,它是很有前途的。 7.3.1 关键思想 面向对象设计是建立在如下主张之上的,即:一个程序模型越是真实地反映了实际问题, 那么,由此产生出的程序质量越好,在多数情况下,关于项目的数据定义要比功能稳定得多, 因此应象面向对象设计一样,根据数据来进行设计,这可以使设计更稳定。对于现代编程来说, 面向对象设计中有许多思想是很重要的。 抽象 抽象所带来的主要好处是可以忽略掉无关紧要的细枝末节问题,而专注于重要的特性。绝 大多数现实世界中的对象都是抽象的,房屋是木材、钉子、玻璃、砖和水泥等的抽象,是把它 们组织起来的一种特殊形式。而木材本身,则又是纤维、细胞及某些矿物质的抽象,而细胞呢, 则又是各种各样的物质分子的抽象。 在建造房屋时,如果你从分子层次上与木材、钉子等打交道,是永远不可能建成房屋的。 同样,在建造一个软件系统时,如果总是在相当于分子的层次上工作,那是不可能建成软件系 统的。在面向对象设计中,你尽力创造出与解决真实问题某一部分抽象程度相同的编程抽象, 以便解决编程问题中的类似部分,而不是用编程语言实体来解决问题。 面向对象设计擅长使用抽象,但因为它所使用的“智力砖块”,要比结构化设计中功能方 法所使用的“智力砖块”大得多。在结构化设计中,抽象的单位是函数;而在面向对象设计中, 第七章 高级结构设计 99 抽象的单位是对象。由于对象包括函数及受函数影响的数据,从而使得在比函数更高层次上对 问题进行处理成为可能。这种抽象能力使你可以在更高层次上对问题进行考虑,而且,不必把 神经绷得太紧,就可以一次考虑很多问题。 封装 封装是对抽象不存在地方的补充。如果抽象对你说“你应该在较高层次上看一个对象”,而 封装则会说“你只能在这个层次上看一个对象”。这事实上就是 6.2 节所述的信息隐蔽的重复。 你对于一个模块所知道的只是它让你知道的那些,别的什么也没有。 我们继续用房屋比拟来说明问题:封装是一个使你可以看到房屋的外表但不能走进去的办 法,当然,或许你可以透过窗户看到一小部分内部情况。在较为过时的语言中,信息隐蔽完全 是自愿的行为,因为大门上没有“禁止入内”的标志,房门没有上锁,窗户也是敞开的,而对 于 Ada 语言来说,信息隐蔽则是强制性的:门被牢牢地锁上了,窗户紧闭,而且警报系统也在 工作,你所看到的就是你所得到的,而且是你得到的一切。 模块化 面向对象设计中的模块与结构化设计中模块的含义是一致的。相联系的数据和功能被放 入模块,在理想情况下,模块是高度内聚而又松散耦合的。同信息隐蔽一样,当模块内部发生 变化时,其接口保持不变。 层次结构和继承性(inheritance) 在设计软件系统时,你经常可以发现两个之间非常相似,其差别非常小的对象。例如,在 一个会计系统中,你可能既要考虑正式雇员,也要考虑兼职雇员,与两种雇员相联系的绝大多 数数据都是相同的,但也有一小部分是不同的。在面向对象编程中,你可以定义一种广义雇员, 把正式雇员当作一种广义雇员,但二者之间稍有差别。把兼职雇员也看作一种与种类无关的广 义雇员,那么这种操作就按广义雇员模式进行。如果某种操作是与雇员种类有关的,那么就按 雇员种类不同,分别进行操作。 定义这种对象间的共同和不同点称为“继承性”,因为兼职和正式雇员都从广义雇员那里继 承了许多特点。 继承策略的好处是它与抽象的概念是一致的,抽象在不同层次的细节上与对象打交道。在 某一层次上调用某种分子;在下一个层次上调用纤维与植物细胞的组合,在最高层次上调用一 片木头。木头有某种特性(如你可以用锯子锯它或用斧头劈它),不管是松木还是加州红杉木, 它们有许多共同的特性,如同它们有不同特性一样。 在面向对象编程中,继承性简化了编程,因为你只要写一个通用子程序来处理对象间的共 同特性,再编写几个专用子程序去处理它们间的不同特性就可以了。有些操作,例如 Getsize(), 可能在任何抽象层次上都适用。程序语言支持 Getsize()这种直到运行时才需要知道操作对象的 子程序特性称为“多形性”。像 C++等面向对象的语言,自动支持继承性和多形性。而以对象为 基础的语言,如 Ada 和过程性语言如 C 和 Pascal 则不支持这种特性。 第七章 高级结构设计 100 对象与类 在面向对象设计中,最后一个关键概念是对象与类。对象是程序在运行时其中的任何一个 实体,而类则是当你看程序清单时存在的一个静态实体。对象是在程序运行时具有特定值和属 性的动态实体。例如,你可以定义一个具有名字、年龄、性别等属性的人,而在运行时则会遇 到 Nancy,Tom 等人,也就是说,对象是类的特例,如果你对数据库术语很熟悉的话,它们的 区别与其中“模式”与“实例”的区别是类似的,在本节其余部分将不严格区分这些词,常会 把两种实体都称为“对象”。 7.3.2 面向对象设计的步骤 面向对象设计的步骤是: · 识别对象及其属性,它往往是数据。 · 确定对每个对象可以做些什么。 · 确定每一个对象可以对其它对象做些什么。 · 确定每个对象对其它对象来说是可见的部分——哪一部分是开放的,哪一部分是专用 的。 · 确定每个对象的公共接口。 这些步骤下一定非要按某一特定顺序来进行,但是却需要重复。逐次迭代逼近对面向对象 设计是与其它设计方法同样重要的,下面将分别论述这些步骤。 识别对象及其属性。计算机程序通常是以客观世界实体为基础的,例如,你可以用客观世 界中的雇员,时间卡及帐单为基础来建造一个时间——记帐系统。图 7-3 中表示了从面向对象 观点来看,这一系统的表现形式。 在图形系统中的对象将包括:窗口、对话框、按钮、字体、绘图工具等。对问题域进行进 一步研究,可能会发现比这种一对一方式更好的软件对象识别方法,但是,从客观世界中的真 实对象出发总是一个比较好的方法。 识别对象属性并不比识别对象困难。每一个对象都有与计算机程序相关联的特性。例如, 在时间——记账系统中,雇员对象将具有姓名、标题和记账率;顾客对象将有姓名、支票地址、 收支状况及存款余额等;账单对象具有欠账数量、顾客姓名、日期等等。 图中对象的图形符号与第六章讲述的模块符号类似。 确定可以对一个对象做些什么。对每一个对象都可以进行一系列操作,在时间——记账系 统中,雇员的记账率等可以变动,可以要求提供雇员的奖励情况等,顾客对象可以更改其存款 额或地址等。 确定对象之间可以互相做些什么。这一步骤与其字面意义是相似的。在时间——记账系统 中,雇员可以对其它对象做些什么呢?做不了什么。雇员所能做的一切便是输入时间卡信息。 而账单则可以接受时间卡信息,在更复杂的系统中,其它相互作用更为明显。 确定每一个对象中对其它对象来说是可见的部分。在设计中的一个关键问题就是决定对象 的哪些部分应该是可见的,哪些部分应该是隐蔽的。对于数据和功能来说,都要做出这种确定。 在表示时间——记账系统的图 7-3 中只表示出了可见的部分,隐蔽的部分则被藏在黑盒子 第七章 高级结构设计 101 图 7-3 四个主要对象组成的记帐系统 中。顾客与雇员对象看起来是非常复杂的,因为它们每一个都具有七八个可见的特性。这种复 杂的表现形式是图示法的一个弱点,这种情况会随着可见特性的增加而恶化。而一个精心设计 好的对象往往有许多附加的可见特性。 定义每一个对象的接口。在设计对象中的最后一个步骤是为每个对象定义一个正式的、语 法的、在程序层次上的接口。这包括由对象提供的功能和对象与类之间的继承关系。特别地, 这一步将包括函数和子程序说明。例如:时间卡的接口(用 C++编写)可能是这样的: class TimeCard { public: int Enter ( EMPLOYEE_ID Employee, DATE Date, CLIENT_ID Client, PROJECT ProjectCode, int Hours ); 第七章 高级结构设计 102 int Retrieve ( int& Hours, DATE& Date, CLIENT_ID& Client, PROJECT ProjectCode, EMPLOYEE_ID Employee ); protected: … }; 当你进行完这一步的工作,到达高层次的面向对象系统组织时,可以用两种方法进行迭代, 以便得到更好的对象——类组织。你也可以对定义的每一个对象进行迭代,把设计推向更详细 的层次。 7.3.3 典型面向对象设计的评论 一个面向对象系统通常有至少四类对象。如果你不知道这其中每类对象的一些情况,你很 可能会漏掉某类对象。 · 问题域要素。这个要素直接指向问题域,在前述的记账系统中,问题域包括客观世界中 的对象,如:雇员、顾客,时间卡和账单等。 · 用户接口要素。这个要素指的是系统中负责人机交互的部分。它包括数据入口类型、窗 口、对话框、按扭等等。正如 6.2 节中提到的那样,最好把系统的用户接口部分隐蔽起 来以适应修改。 · 任务管理要素。这类要素指的是计算机本身的对象。包括实时任务管理程序、硬件接口、 通讯协议等。 · 数据管理要素。这部分要素包括保持一致的数据。它包括数据库以及其相联系的所有存 储、维护和检索等功能。 7.4 对目前流行设计方法的评论 如果你仔细观察日前流行的设计方法——包括结构化设计和面向对象设计——你会发现每 种方法都包括两个主要部分: · 把一个系统分解成子系统的准则 · 解释分解的图形式语言符号 · 有些方法还包括第三个要素 · 防止你使用其它方法的规定 把“设计”的意义限制在前两个要素上说明设计的核心是把系统分解成亚程序 ( Subprogram ) ,同时也说明亚程序的设计并不具备足够的挑战性,不值得讨论。 第七章 高级结构设计 103 一个好的系统分解的确是很有价值的,但并不是说一旦确立了好的结构,设计就可以停止 了。在确认出子程序的模块之后,还有许多设计工作要做。 伴随着某些设计方法的第三个要素,即强调应该只使用一种方法的思想,是非常有害的。 没有一种方法囊括了设计系统所需的全部创造力和洞察力。强调使用一种方法将破坏设计中的 思维过程。 但是,设计方法的选择往往会成为一种宗教似的问题——你去参加了一个宗教复兴会议, 听一些结构化设计的先知讲道,然后你回到自己的圣地在石碑上写下一些神圣的程序,从此以 后,你不再允许自己进入非基督教的领域。你应该知道,软件工程不是宗教,不应该引入宗教 的狂想性。 如果你是个建筑工人,你不会用同样的方法建造每一幢建筑物。在周一你在浇注水泥,而 到了周末你则在修建一幢木屋。你不会用水泥来修木屋,也不会在一幢新建好的摩天大楼门口 挂上“成人禁止入内”的牌子。你会根据不同的建筑物而采取不同的施工方法,从建造房子中, 你应该得到关于编程方法选择的启示,应该选择与问题相适应的方法,这种世俗方法的合理性 已经被许多例子和研究所证明。每种方法都有其优点,但同时也有其弱点,应分析使用(Peter 和 Tripp 1977,Mannino 1987,Kelly 1987,Loy 1990)。 但是,有些方法的障碍是由它们自己的复杂的术语产生的。比如,你想学习结构化设计, 你必须懂得如下词汇:输入流与模块、输出流与模块、正规、控制、公用、全局和内容耦合、 功能的、顺序的、通讯的、过程的、临时的、逻辑的和偶然性内聚,输入、输出、事务处理中 心、事物处理分析和事物处理模块,甚至 Benuzerfreundlichkeit(多可怕!)一词也出现了。字 典也无法给出这些词的解释。 结构化设计,以隐蔽信息为目标的设计和面向对象设计等方法提供了看问题的不同角度。 图 7-4 给出了使用它们的典型方法。 从事结构化设计的人与从事面向对象设计的人会发现他们进行交流非常困难,原因是他们 没有意识到是在不同层次上讨论设计的,因此主题也是不同的。从事结构化设计的 Tom 说:“我 想这个系统应该分解成 50 个子程序。”面向对象设计的 Joh 则说:“我认为系统应划分成 7 个对 象”。如果你仔细观察,可能会发现这 7 个对象中可能共含有 50 个子程序,而 Tom 的子程序或 许可以分成 7 组。 7.4.1 何时使用结构化设计 结构化设计主要是一种把程序分解成子程序的方法。它强调功能但不强调数据。一个面向 功能的问题域的例子是一个以批处理方式读入数据,按照可以预计的顺序对数据进行可以预计 的处理并且写数据。 结构化设计并没有把子程序组成一个协同工作子程序组的概念,也没有子程序内部设计的 概念,除非这个子程序的内部会影响到整个系统。因此,结构化设计非常适用于具有许多互不 作用的独立功能的系统。同时,它也适于那些只有几百行代码的小型程序,因为这些程序过于 简单,没有建立类、对象和属性的必要。 结构化设计的最先提出者 Larry Constantine,曾经发表过一篇“对象、函数和程序可扩展性” 的文章,论述了把结构化设计和面向对象设计组合到一起的设计方法。如果数据变动可能性很 大,那么采用面向对象设计比较合适,因为它将变动可能性较大的数据独立在对象(模块)中。 第七章 高级结构设计 104 图 7-4 对于一个问题的不同设计(不同设计导致不同的解决方法,每一种都是正确的) 如果功能变动的可能性较大,采用面向对象设计就不太适合了,因为功能散布在许多对象(模 块)中。如果功能变化的可能性比数据要大,那最好采用分解功能的结构化设计。 7.4.2 何时采用信息隐蔽 无论什么问题领域,都应该尽量采用信息隐蔽。使用它没有任何危险。到目前为止,联邦 卫生委员会还没有发现它会发生危险,不论是设计子程序、模块,还是对象程序,它都是很有 效的,因此你尽可以放心使用它。 7.4.3 何时采用面向对象设计 面向对象设计与结构化设计的主要区别是:面向对象设计在较高抽象层次上要比结构化设 第七章 高级结构设计 105 计有效。这是具有重大历史意义的。因为结构化最初开发起来时程序员们正在建立定义大而复 杂的系统,到现在已经远不及如今的大规模系统那样复杂了。 面向对象设计主要是设计模块数据和对数据操作的集合。它非常适于从最顶层分解系统。 而当你识别出对象的接口并开始编码时,你往往会转向结构化设计。如果你用面向对象的语言 编程,那么很难认为你没有在面向对象设计,因为你用面向对象方法、信息或其它进行结构设 计工作。如果你是在用比较传统的过程性语言进行设计,则很容易认为你是在用较旧的结构化 设计方法,在这时,使用这一方法是很合适的。 面向对象设计适合于任何客观世界中的对象。这类系统的例子包括高度交互化的窗口、对 话框、按钮等程序;面向对象的数据库;需要对随机事件做出反应的事件驱动系统等等。 关于面向对象设计技术的研究工作主要是针对代码在 105~1005 行以上的系统的。对于这 种规模的系统,结构化设计基本上是无能为力的,而面向对象设计则往往比较有效。然而,除 了这些超大型项目之外,稍显陈旧的结构化设计还是比较有效的,而面向对象设计对于较小型 项目的有效性,却还有等待证明。 7.5 往返设计 通过组合使用主要设计方法来扬长避短是完全可能的。每种设计方法都只是程序员工具箱 中的一件工具,不同的工具适合不同的工作,你将从研究所有方法的启发中获益无穷。 下面一小节结论述了软件设计为什么困难的某些原因,并指出了如何组合使用结构化设计, 面向对象设计和其它设计方法。 7.5.1 什么是往返 你可能会有这样的体验:当你编写程序快结束时,你非常希望能有机会再重新编写一次, 因为在编写过程中你对问题又有了更深的理解。这对设计也是同样适用的,只不过在设计中这 个循环的周期更短,带来的好处也更大,因此,你完全可以在设计过程中进行几次往返。 “往返设计”一词抓住了设计是个迭代过程这一特点:通常你不会只从 A 点走到 B 点,往 往还需要再返回 A 点。 在用不同的设计方法对各种设计方案进行尝试的进程中,将从高层次的总体上和低层次的 细节上对问题进行观察。在从事高层次问题时获得的总体印象将会对你在低层次细节中的工作 有很大帮助;同时,在从事低层次问题时所获得的细节将为你对高层次的总体理解和作出总体 设计决定奠定下良好的基础。这种在高层次和低层次之间往返思维过程是非常有益的,由此而 产生的结构,将比单纯自顶向下或自底向上产生的结构要稳定得多。 许多程序员,都会在这一往返过程中遇到麻烦。从对系统的一个观察点转到另一个观察点 上,的确是很困难的,但这是进行有效设计所必需的,你可以阅读一下 Adams 于 1980 年写的 一本叫“Conceptual Blockbusting”的书,来提高自己思维的灵活性。 7.5.2 设计是一个复杂的过程 J.P Morgon 曾经说过人们在做事情时常常有两个原因:表面上冠冕堂皇的原因和真正的 原因。在设计中,最终结果往往看起来是井井有条的,似乎设计者从未犯过任何设计错误,事 第七章 高级结构设计 106 实上,设计过程很少有像最终结果那样井井有条。 设计是一个复杂的过程。因为你很难把正确答案与错误答案区分开来。如果你让三个人分 别设计同一个程序,他们带回来的往往是三个大相径庭的方案,而且其中每一个看起来都非常 适用。它是一个复杂的过程还因为你在设计过程中曾钻过许多死胡同、犯过许多错误。说它是 一个复杂的过程也是因为你不知道什么时候设计方案已经足够完善了。什么时候算完成呢?对 这个问题的通常答案是“当你没有时间时”。 7.5.3 设计是一个“险恶”的过程 Horst Rittel 和 Melvin Webber 把“烦人”的问题,定义成只有通过解决它或者部分解决它, 才能给出明确定义的问题。这个似是而非的定义事实上暗示着你不得不首先“解决”这个问题, 对其有一个清楚的定义和理解,然后再重新解决一遍,以获得正确的解决办法。这一过程对软 件开发就像母爱和面包对你我一样必不可少。 在现实中,关于险恶问题的一个富于戏剧性的例子便是托卡马大桥的设计。在修建大桥时, 主要考虑的便是它应该能承受设计载荷并能抗 12 级大风。然而,没人想到风在吹过桥时会产生 “卡门旋涡”——一种特殊的空气动力学现象,从而使桥产生横向简谐振动。结果,在 1940 年 的一天,只在 7 级风的作用下,桥便因振动而坍塌了。 说它是险恶问题的典型例子是因为直到桥坍塌时,也没有一个设计师想到应该在这个设计 中考虑空气动力学问题。只有在建成大桥(解决问题)之后,才使得他们意识到了要考虑的这 一“额外”问题,经过重新设计,新桥至今依然屹立在河上。 你在学校中设计的程序和在实际工作中设计的程序最重要的不同是:在学校中遇到的程序 设计问题,几乎没有哪个是险恶的,教师留给你的程序作业都是预先想好让你一次即可完成的。 如果哪位教师留给你们一个程序作业,当你们完成后他又突然改变了作业题目,接着,当你即 将完成那个程序时,他又改变了主意,会怎么样呢?我想,如果有谁胆敢这样的话,你们肯定 会把它绞死。但在实际工作中,几乎总是这样。 7.5.4 设计是一个启发的过程 进行有效设计的关键是要认识到它是个启发的过程。设计中,总是吃一堑,长一智的。往 返设计的概念事实上解释了设计是个启发过程这一事实,因为你要把任何设计方法都只当成一 种工具。一种工具只对一种工作或者一种工作的某一部分才有效,其余的工具适合其它的工作, 没有一种工具是万能的。因此,你往往要同时使用几种工具。 一种很有效的启发工具就是硬算。不要低估它。一个有效的硬算解决方案总比优雅却不能 解决问题的方案要好。以搜索算法的开发为例,虽然在 1946 年就产生了对分算法的概念,但直 到 16 年后,才有人找到了可以正确搜索各种规模的表的算法。 图示法是另一种有力的启发工具。一幅图抵得上一千个单词。你往往不愿用那一千个单词 而宁愿用一幅图,因为图形提供了比文字更高的抽象水平,有时或许你想在细节上处理某一问 题,但是,更常见的是在总体上处理问题。 往返设计的一个附加的启发能力是你在设计的头几次循环中,可以暂时对没有解决的细节 问题弃之不管,你不必一次就对一切都做出决定,应记住还有一个问题有待做出决定,但同时 要意识到,你目前还没有充分的信息来解决这个问题。为什么在设计工作的最后 10%的部分苦 第七章 高级结构设计 107 苦挣扎呢?往往在下一循环中它们会自然获得解决。为什么非要在经验和信息都不足的情况下 草率决定呢?你完全可以在以后等经验和信息丰富时做出正确决定。有些人对一次设计没能彻 底解决问题会感到很不舒服,但与其很不成熟地勉强解决问题,不如把问题暂放一个,待到信 息足够丰富时,再解决它。 最重要的设计原则之一是不要死抱着一种方法不放。如果编写 PDL 无效的话,那么就作图, 或用自然语言写出来,要么就写一小段验证程序,或者使用一种完全不同的方法,比如硬算解 决法,坚持用铅笔不停地写和画,大脑或许会跟上。如果这一切都无效,暂时放开这个问题。 出去自由自在地散散步,或者想一下别的,然后再回到这个问题上。如果你已经尽了全力但还 是一无所获,那么暂时不考虑这个问题往往会比坚持冥思苦想更快得到答案。最后,可以借鉴 其它领域中的方法来解决软件设计中的问题。关于问题解决中的启发方法的最初的一本专著是 G. Polya 的《How To Solve In》一书(1957),Polya 的书推广了数学中解决问题的方法,表 7-1 就是对其所用方法的总结,本表摘自 Polya 的书中的类似的总结表: 表 7-1 怎样解决问题 l.理解问题,你必须理解要解决的问题 问题是什么?条件是什么?数据是什么?有可能满足条件吗?已知条件足以确定未知 吗?已知条件是否不够充分?是否矛盾 7 是否冗余? 画一个图,引入恰当的符号,把条件的不同部分分解开。 2.设计一个方案。找到已知数据和未知之间的联系。如果不能找出直接联系的话,你可能不得 不考虑一些辅助问题,但最后,你应该找到一个解决方案。 以前你是否遇到过这个问题?或者是见过与它稍有不同的问题?是否知道与其相关的问 题?是否知道在这个问题中有用的定理? 看着未知!努力回忆起一个有着相同或类似未知的问题。这里有一个与此相关的你以前 解决过的问题,你能利用它吗?是能利用它的结论还是能用它的方法?是否该引入辅助 要素以使这个问题可以再用? 能否重新表述一下问题?能用另外一种方式表述它吗?返回到定义。 如果你无法解决这个问题,可以先试着解决一些别的问题,是否能想象出一个容易解决的 相关问题;一个广义些的问题或是一个更特殊的问题?一个相似的问题呢?能否解决问 题的一部分呢?仅保留一部分条件,忽略其余条件;未知可以被决定到什么程度?会发生 什么变化?能否从数据中推导出一些有用的东西?能否找出适于确定未知的其余数据? 能否改变数据或未知?同时改变两者呢?这样做能否使新的未知和新的数据更接近些? 是否使用了全部的数据?使用全部条件了吗?是否考虑了这个问题的全部必要条件? 3.执行你的计划。 执行你解决问题的计划,同时检查每一步工作。你是否可以认定每一步都是正确的?你 能证明这点吗? 4.回顾,检查一下答案。 你能检查一下答案吗?能检查一个论证吗?能否用另外一种方法推导出答案?能否一眼 就看出答案? 能否在其它问题中再利用本题的答案或者结论? 7.5.5 受迎的设计特点 高质量的设计往往有一些共同的特点。如果你能达到这些对象,那么可以认为你的设计也 是非常成功的。有些对象是互相矛盾的。但是这是设计的挑战所在,在相互矛盾的对象之间做 第七章 高级结构设计 108 出合理的折衷。某些高质量设计的特点同时也是高质量程序的特点——可靠性。其余的则是设 计所独有的。 以下是设计所独有的一些特点: 智力上的可管理性。对于任何系统来说,智力上的可管理性都是其重要目标之一。它对于 整个系统的完整性是非常重要的,并且会影响程序员们开发和维护系统的难易程度。 低复杂性。低复杂性实际上是智力上的可管理性一部分,由于上述同样的原因,这点也很 重要。 维护的方便性。维护的方便性意味着设计时要为负责维护的程序员着想。在设计中,要不 停地想象维护程序中将会对你的设计提出的问题。应该把维护程序员当作你的听众,同时把系 统设计成明白易懂的。 最小的联系性。最小的联系性指的是按照保持子程序之间的联系最少的原则来设计,应该 利用强内聚,松散耦合和信息隐蔽等作为指导原则来设计系统,使其内部的联系性尽可能少。 最小的联系性可以极大地减小综合、测试和维护阶段的工作量。 可扩充性。可扩充性指的是不必影响系统的内部结构,就可以对系统的功能进行强化,你 可以改变系统的某一部分而不影响其余部分,使得最大可能性变动对系统带来的影响最小。 可重复使用性。 可重复使用性指的是把系统设计成其中许多部分是可以被其它系统借用 的。 高扇入。高扇入指的是对于一个给定的子程序来说,应该有尽可能多的子程序调用它。高 扇入表明一个系统在低层次上充分利用了功能子程序。 低或中等程度扇出。低或中等扇出指的是对一个确定的子程序来说,它所调用的子程序应 该尽可能地少。高扇出(大约 7 个以上)说明一个子程序控制了许多其它子程序,因此可能是 很难理解的。而中等扇出则表明一个子程序只把任务交给了数量较少的其它子程序,因此是比 较容易理解的。低扇出(少于 4 个)看起来像是一个子程序没有把足够的任务交给其余的子程 序去做,但经验表明并不是这样。一项研究表明有 42%只调用一个子程序的子程序是没有错误 的,有 32%的调用 2~7 个子程序是没有错误的,而在调用 7 个以上子程序的情况中,只有 12% 是没有错误的(Card, Church 和 Agresi,1986)。由此,Card 认为 0~2 个扇出是最优的。 可移植性。可移植性指的是把系统设计成很容易转到另外的环境下运行。 简练性。简练性指的是把系统设计得没有任何多余部分。Voltaire 曾说过,当一本书不能删 掉,而不是不能添补任何内容时,才可以认为它已完成了。在软件中,这也是非常正确的,因 为当你对系统进行改进时,你不得不对冗余的代码进行开发、评审、测试和维护等等工作,而 且在开发软件的新版本时,新版本也不得不与这些冗余的代码兼容。最有害的观点是“多加入 些又不会有害,怕什么呢?” 成层设计。成层设计指的是尽量分解的层次是成层的,这样你可以在每一个单独的层次上 观察系统,同时也可以使观察的层次是连续的。也就是说当你在某一层次上观察系统时,不会 看到在其它层次上看到的东西。你会经常遇到某些子程序和软件在几个层次上起作用。这样会 使系统很混乱,应尽力避免。 如果在编写一个先进系统时,不得不借用许多旧的、设计得不好的代码,那么你可以在新 系统中建立一个层(layer),与那些旧代码相联接。精心设计这个层使它把旧代码的缺点隐含起 来,从而使新层表现了一整套连续的功能。然后,让程序的其余部分调用些子程序而不是直接 第七章 高级结构设计 109 调用旧代码。成层设计的好处是:(l)它可以使你避免与拙劣的旧代码直接打交道;(2)一旦 你想废弃那些旧代码中的子程序的话,只要修改一下接口层就可以了。 标准化技求。标准化技术是深受欢迎的。一个系统使用的奇特的、非标准的技术越多,当 别人第一次读它时就会越感到可怕,也越难理解。应该通过采用常用的、标准化的技术使得人 们在阅读它时是一种熟悉的感觉。 7.5.6 检查表 高层次设计 本表给出了在评估设计质量时,通常要考虑一些问题。本表是 3.4 节中结构设计检查表的 补充,这个表所考虑的主要是设计质量。3.4 节中的检查表则侧重于结构设计和设计内容。这 个表中的某些内容是相互重合的。 · 是否使用了往返设计方法,应从几个方案中选择最好的,而不是首次尝试就确定方案。 · 每个子程序的设计是否都和与其相关的子程序设计一致? · 设计中是否对在结构设计层次上发现但并未解决的问题作了充分说明? · 是否对把程序分解成对象或模块的方法感到满意? · 是否对把模块分解成子程序的方法感到满意? · 是否明确定义了子程序的边界? · 是否是按照相互作用最小的原则定义子程序的? · 设计是否充分利用了自顶向下和自底向上法? · 设计是否对问题域要素、用户接口要素、任务管理要素和数据管理要素进行了区分? · 设计是智力上可管理的吗? · 设计是低复杂性吗? · 程序是很容易维护的吗? · 设计是否将子程序之间的联系保持在最低限度? · 设计是否为将来程序可能扩展作了准备? · 子程序是否是设计成可以在其它系统中再使用的? · 低层次子程序是高扇入的吗? · 是否绝大多数子程序都是低或中等程度扇出的? · 设计易于移植到其它环境吗? · 设计是简练的吗?是不是所有部分都是必要的? · 设计是成层的吗? · 设计中是否尽量采用了标准化技术以避免奇特的、难以理解的要素? 7.6 小 结 · 设计是一个启发的过程。 固执地坚持某一种方法只会抑制创造力,从而产生低质量的 程序。坚持设计方法上有一些不屈不挠的精神是有益的,因为这可以迫使你对这种方法 进行充分理解。但是,一定要确信你是在不屈不挠而不是顽固不化。 第七章 高级结构设计 110 · 好的设计是通过迭代逼近得到的:你尝试过的设计方案越多,你最终所确定的设计方案 也越好。 · 结构化设计比较适合于小规模的子程序组合,同时,它对于功能变化可能性比数据大的 问题也是较适用的。 · 面向对象设计更适于子程序与数据的组合,通常在比结构化设计抽象程度更高些的层次 上适用。它尤其适合于数据变动可能性大于功能变动可能性的问题。 · 设计方法仅是一种工具,你对工具运用得好坏决定了你所设计的程序的质量。利用不好 的设计方法,也可能设计出高质量的程序。而即使是好的方法,如果运用不当的话, 也 只能设计出拙劣的程序。但不管怎样,选择正确的工具更容易设计出高质量的软件。 · 许多关于设计的丰富而有用的信息都是在本书之外的。在这里所论述的,不过是冰山的 一角而已。 第八章 生成数据 111 第八章 生成数据 目录 8.l 数据识别 8.2 自建数据类型的原因 8.3 自建数据类型的准则 8.4 使变量说明更容易 8.5 初始化数据的准则 8.6 小结 相关章节 变量命名:见第 9 章 使用变量时的一些考虑:见第 10 章 使用基本数据类型:见第 11 章 使用复杂数据类型:见第 12 章 在结构设计阶段定义数据:见 3.4 节 说明数据:见 19.5 节 数据结构布置:见 18.5 节 本章的内容既包括高层次的子程序、模块和程序设计中要考虑的问题,也包括对数据实现 问题基本要素的讨论。 数据结构在创建阶段能带来的收益大小,在某种程度上是由它对创建前的高层次工作影 响大小决定的。好的数据结构所带来的收益往往是在需求分析和结构设计阶段体现出来的。为 了尽可能地利用好的数据结构带来的收益,应在需求分析和结构设计阶段就定义主要数据结构。 数据结构的影响同时也是由创建活动所决定的。由创建活动来填平需求分析与结构设计 之间的壕沟是很正常也是很受欢迎的。在这种微观问题上,仅靠画几张蓝图来消除一致性的缺 陷是远远不够的。本章的其余部分将论述填平这一壕沟的第一步——生成进行此项工作的数据。 如果你是一位专家级的程序员,本章的某些内容对你来说可能已是司空见惯了。你可以浏 览一下标题和例子,寻找你不熟悉的内容看就可以了。 8.1 数据识别 有效生成数据的第一步是应该知道该生成什么样的数据结构。一张良好的数据结构清单是 程序员工具箱中的一件重要工具。对数据结构基本知识的介绍不在本书范围之内。但你可以利 用下面的“数据结构识别测试题”来看一下自己对其知道多少。 第八章 生成数据 112 8.1.1 数据结构识别测验题 每遇到一个你所熟悉的词可以计 1 分。如果你认为你知道某个词但并不知道其确切内容, 计 0.5 分。当作完题后,把你的得分加到一起,然后再根据测验题后面的说明来解释你的得分。 ____抽象数据类型 ____文字型 ____数组 ____局部变量 ____B 树 ____查找表 ____位图 ____指针 ____逻辑变量(布尔变量)____队 ____字符变量 ____记录 ____命名常量 ____回溯 ____双精度 ____集合 ____枚举流 ____堆栈 ____浮点 ____字符串 ____散列表 ____结构化变量 ____堆 ____树 ____索引 ____联合 ____整型 ____数值链 ____链表 ____变体记录 ____最后得分 以下是得分解释办法(可随便些): 0~14 分 你是个初级程序员,可能是计算机专业一年级的学生,或者是一个正在自学 第一种语言的自学者。如果读一下后面列出的书的话,你将会学到很多。本 章的许多论述都是针对高级程序员的,如果你读完那些书再阅读本书将更 有益。 15~19 分 你是个中级程序员或是个健忘的富有经验的程序员。虽然你对表中许多概念 都已经很熟了,但最好还是先阅读一下后面列出的书。 20~24 分 你是个专家级的程序员,你的书架上很可能已经插上了后面所列出的书。 25~29 分 关于数据结构,你知道的比我还多!可以考虑写一本你自己的专著(请别忘 了送我一本)。 30~32 分 你是个自大的骗子。“枚举流”、“回溯”和“数值链”并不是指数据结构 的,是我故意把它们加进去以增加难度的,在阅读本章其余部分之前,请先 阅读引言中关于智力诚实性的内容。 以下是关于数据结构的一些不错的书: Aho, Alfred V., John E. Hopcroft 《Data Structure and Algorithms, Reading,Mass》 Addison-Wesley,1983。 Reingold, Edward M 和 Wilfred J.Hansen《Data Structures》, Boston: Little, Brown, 1983。 Wirth, Niklaus,《Algorithms and Data Structures》, Englewood Cliffs,N.J.;Prentice Hall, 1986。 第八章 生成数据 113 8.2 自建数据类型的原因 程序语言所赋予你的阐明自己对程序理解的最强有力的工具之一便是程序员定义的变量 类型。它们可以使程序更容易阅读。如果使用 C、Pascal 或其它允许用户定义类型的语言,那 么一定要利用这一点。如果使用的是 Fortran,Generic Basic 等不允许用户定义变量类型的语言, 那接着看下去,或许读完本节后你就想换一种语言了。 为了说明自定义类型的效力,让我们来看一个例子。假设你正编写一个把 x、y、z 坐标转 换为高度、经度、纬度坐标的程序,你认为可能会用到双精度浮点数,但是在十分确定之前, 你只想用单精度浮点数。这时,可用 C 中的 typedef 语句或 Pascal 中的 type 说明来为坐标专门 定义一种变量(当然也可用其它相当的语言)。以下是在 C 中是如何建立这种变量的: typedef float Coordinate_t; /* for coordinate variables */ 这个类型定义说明了一种新的类型,Coordinate_t,在功能上它与 float 是一样的,为了使 用这种新类型,应用它来说明变量,就像用 float 来预先定义一种类型一样。以下是一个用 C 写 成的例子: Routine1(...) { Coordinate_t latitude; /* latitude in degrees */ Coordinate_t longitude; /* longitude in degrees */ Coordinate_t elevation; /* elevation in meters from earth center */ ... } ... Routine2(...) { Coordinate_t x; /* x coordinate in meters */ Coordinate_t y; /* y coordinate in meters */ Coordinate_t z; /* z coordinate in meters */ ... } 在这段代码中,变量 x,y,z 和变量 latitude,longitude,elevation 都是 Coordinate_t 类型的。 现在,假设程序发生了变化,发现最终还是得用双精度变量。由于你为坐标专门定义了一 种类型,因此所要做的就是改变一下类型定义而已。而且只需在一个地方进行改动;在 typedef 语句中。下面是改变后的类型定义: typedef double Coordinate_t; /* for coordinate variables */ ——原浮点已改为双精度类型 下面来看第二个例子,它是用 Pascal 写成的。假设你正在生成一个工资发放系统,其中雇 员的名字至多有 30 个字母。你的用户告诉你从来没有任何人的名字长度超过 30 个字母。你是 否该在程序中把数字 30 作为文字型变量来使用呢?如果你这样作的话,那你就过于轻信你的用 第八章 生成数据 114 户了。更好的办法是为雇员名字定义一种类型: Type EmployeeName_t = arrayy[1..3] of char; 当引入数组或字符串时,最好定义一个命名常量来表示数组或字符串的长度,然后 在类型定义中使用这个命名常量,在程序中,你会许多次用到了这个常量——以下 是你将使用它的第一个地方,它看起来是这样的: Const Namelenght_c = 30;——这里是命名常量的说明 ⋯ Type EmployeeName_t = array[1..NameLength_c] of Char;——这里是命名常量使用的地方 一个更有说服力的例子是将自定义类型与信息隐蔽的思想结合在一起。在某些情况 下,你想隐蔽的信息是关于数据类型的。 在坐标例子中用 C 写成的程序已经在走向信息隐蔽的途中了。如果你总是用 Coordinate_t 而不是用 float 或 double,事实上已经隐蔽了数据的类型。在 C 或 Pascal 中,这些便是语言本身 能为信息隐蔽所做的一切,其余部分便是你或后来的使用者必须遵守这个规则,不查看 Coordinate_t 的定义。C 和 Pascal 所赋予的都是广义的而不是狭义的信息隐蔽能力。 其它像 Ada 和 C++等语言则更进一步支持狭义的信息隐蔽。下面是在 Ada 语言中,用包来 定义 Coordinate_t 的代码: package Transformation is type Coordinate_t is private; ——这个语句说明 coordinate_t 作为包的专用说明 ⋯ 以下是在另一个包中使用 Coordinate_t 的代码: with Transformation: … procedure Routine1(⋯)⋯ latitude: Coordinate_t; longitude: Coordinate_t; begin -- statements using latitude and longitude … end Routine1; 注意 Coordinate_t 在包定义中是说明为专用的,这意味着只有 Transformation 包中的专用部 分才知道 Coordinate_t 的定义,而程序其余部分则都不知道。在有一群程序员的开发环境中, 只有包的定义部分才是开放的。对于从事另一个包的程序员来说,他很难查寻到 Coordinate_t 的类型。在这里,信息是狭义隐蔽的。 这些例子已经阐明了建立自己的类型的几条理由: · 使得改动更加容易。建立一种新类型工作量极小,但这却可以带来极大的使用灵活性。 第八章 生成数据 115 · 避免过度分散的信息分布。硬性类型会使程序中充斥着数据类型细节,而不是使其集 中在一个地方。这正是 6.2 节中所讨论的集中化原则。 · 为了增加可靠性。在 Ada 和 Pascal 中,可以定义类似 Age_t = 1~99 的类型。然后,编 译程序会产生运行检查信息,以保证 Age_t 类型总是在 1~99 的范围内。 · 为了补偿语言的弱点。如果语言中不具备某种定义好的类型,可以自己定义它。例如, C 中不含逻辑类型,通过建立你自己的类型,很容易弥补这个缺点: typedef int Boolean_t; 8.3 自建数据类型的准则 以下是几条生成自己的类型时应牢记的准则: 建立具有面向功能名称的类型。应避免用暗指计算机本身数据类型的名称。要使用代表实 际问题某一部分的名称。在上例中,为坐标建立的新类型命名就很恰当,因为它代表了客观世 界中的实体。同样,你也可以为现金、工资发放代号、年龄等客观世界中的实体建立新变量。 要避免使用含有已定义变量类型的名称。比如像 BigInteger 和 LongString 等指的是计算机 数据而不是客观世界中实体的名称就应避免使用。建立自己的类型其最大优点是,可以在程序 及其实现语言之间建立一个绝缘层,而指向程序语言类型的名称则会破坏这个绝缘层,使用已 定义的类型不会带来任何优点。面向问题的名称可以增加易改进性,并且使数据说明成为自注 释的。 避免使用已定义类型。如果类型存在变动的可能性,那么除了在 typedef 和 type 定义之外, 不要再使用已定义的类型。建立面向功能的类型是非常容易的,而改变程序中该类型的数据是 非常困难的。而且,当使用自建的面向功能类型说明变量时,也同时对变量进行了说明。 Coordinate_x 所告诉你的关于 x 的信息要比 float x 多得多。因此应尽可能使用自建类型。 不要对已定义类型重新定义。改变标准类型的定义往往令人困惑。例如,语言中已经有了 Integer 类型,而你又自建了叫作 Integer 的类型。这样,程序的阅读者往往会记住你所定义的 Integer 的含义,而仍把它当作语言中的标准 Integer 类型。 定义替换类型以增强移植性。与避免重新定义标准类型的建议相反,你可能想为标准类型 定义一种替换类型,从而使得在不同的硬件环境下变量所代表的都是同一实体。例如,你可以 定义 INT 类型来代替标准的 int 类型,它们之间的唯一区别便是字母的大小写。但当你把程序 移到新的硬件环境下时,你只要重新定义一个 INT 类型,就可以在新的硬件环境下使得数据类 型与旧环境下相容。 如果你所用的语言是不区分大小写的,你将不得不使用其它办法来对标准类型和替换类型 加以区别。 使用其它类型来建立新类型。你可以在已经建立的简单类型的基础上建立复杂类型。这种 变量类型可以进一步推广你用原来类型所达到的灵活性。 8.4 使变量说明更容易 本节论述的是怎样才能更顺利地进行变量说明。显然,这是件很容易的事情,你甚至会认 第八章 生成数据 116 为在书中专门用一节来论述这个问题是小题大做。但不管怎样,你毕竟在建立变量上花费了许 多时间,因此,养成这方面的良好习惯以避免不必要的失败和徒劳的努力是非常必要的。 8.4.1 使用模板(template)进行变量说明 在一个文件中存储一个变量说明模板。需要说明新的变量时,你可以把这个模板调入程序, 对其进行编辑以说明新变量。下面是用 C 写成的一个模板: extern * *; /* */ static * *; /* */ * *; /* */ 这个模板有几个优点。首先,很容易从中选择出与你要求最接近的行,然后删掉其余各行; 第二,每行中“*”的作用是占有位置,这使得进入每行的编辑位置都非常容易;第三,如果你 忘记了更改“*”,那么一定会产生语法错误,从而起到了提醒的作用;第四,使用模板可以保 持说明形式的一致性;最后,预留的注释空格将提醒你在说明变量时进行注释,这简化了以后 的程序注释工作。 不过,不要以为你必须采用与上例完全相同的模板,尽管按自己的想法去建立自己的模板, 只要它能降低变量说明工作量、增强代码可读性并且使调试更容易就可以了。我的一个朋友给 自己找出了一个注释变量的理由。她名叫 Chris,她的模板是这样的: * * { Chris is a jerk! } 8.4.2 隐式说明 有些语言具有隐含变量说明功能。例如,如果在 Basic 或 Fortran 中你没有说明就使用了某 些变量,那么编译程序将自动说明。 隐式说明是所有语言特性中的最具危害性的特性之一。 如果你用 Fortran 或 Basic 编过程序,那你一定知道要找出 ACCTNO 的值不正确是多么的 困难。而你最后却发现这是由于 ACCTNUM 被重新初始化为零的原因,如果你所用的语言不要 求对变量做出说明,那么,这种错误是很常见的。 而如果你所用的程序语言要求对变量作出说明,那么在出现上例中的错误之前,你必须首 先先犯两个另外的错误才行。首先,你要错误地同时把 ACCTNUM 和 ACCTNO 放入了子程序 中;其次,你要错误地在子程序的说明段中同时说明了这两个变量。第二个错误是很难出现的, 因此,要再犯上例中的错误几乎是不可能的。要求你对变量进行显式说明的语言其主要优点之 一,便是可以使你在使用变量时更加谨慎。但如果你用的是具备隐式说明功能的语言,那你该 怎么办呢?以下给出了一些指导原则: 关闭隐式说明功能。有些编译程序允许关闭这一功能。比如,在 Fortran 中,可以使用一个 叫 IMPLICIT NONE 的语句,这并不是 ANSI FORTRAN77 中的标准语句,但许多 Fortran 中都 有这一扩展。 说明所有变量。即使编译程序不要求,但你每次使用新变量时,都要坚持对它做出说明。 这样并不能避免所有的错误,但是至少可以避免某些错误。 使用命名约定。建立一个关于常用后缀(如 NUM 和 NO)的命名约定,这样,当你想要 第八章 生成数据 117 用一个变量时就不会误了两个。 检查变量名。利用你的编译程序或其它工具软件产生的参考表。许多编译程序都会列出你 在子程序中使用的所有变量,从而使你发现 ACCTNUM 和 ACCTNO 中的错误。同时,它也会 指出说明但并未使用的变量。 8.5 初始化数据的准则 在编程中,最大的错误原因之一便是对数据不恰当的初始化。开发有效地避免初始化错误 的技术,可以节约大量的调试时间。 不恰当初始化产生的问题,是由某一变量带有你没有预料的初值引起的。这可能是由下述 原因中的任何一种引起的: · 这个变量未被赋过任何值。其值是在程序开始运行时,由在它的内存中的位置偶然的 值决定的。 · 变量的值已经过时了。变量在某点中被赋过值,但是这个值已不再有效了。 · 变量的一部分被赋了值,而另一个部分没有被赋值。如果你使用的是指针变量,那么 常见的错误是用指针分配了内存,却忘记了对指针所指的变量进行初始化。这与对变 量根本不赋值的效果是一样的。 这种情况往往有几种表现形式。你可能初始化了结构的某一部分而没有初始化另一部分; 也可能会分配内存,然后初始化变量,但指向变量的指针却没有被初始化,这意味着你是随意 地拿一段内存并赋予它们一些值,这段内存可能是存储数据的,也可能是存储代码的,也可能 是被操作系统所占用的。这种错误的表现形式是多种多样的,而且它们之间往往每次都有着天 壤之别。这使得调试指针错误要比调试其它错误困难得多。 下面是如何避免初始化错误的一些准则: 检查输入参数的有效性。初始化的另一种有价值的形式是检查输入参数的有效性。在赋给 输入参数任何值之前,要首先确认它是合理的。 在使用变量的位置附近对其进行初始化。有些程序员喜欢在子程序开始的一个地方对所有 变量进行初始化,以下是用 Basic 语言写成的例子: ' initialize all variables Idx = 0 Total = 0 Done = False ... 'lots of code using Idx and Total ... 'code using Done while not Done ... 第八章 生成数据 118 另外一些程序员则尽可能在每一次用到变量的位置附近对其进行初始化,下面也是用 Basic 写成的例子: Idx = 0 'code using Idx ... Total = 0 ── Total 在使用位置附近初始化 'code using Total ... Done = False ── Done 在使用位置附近初始化 'code using Done while not Done ... 第二个例子要好于第一个,其中有几个原因。在第一个例子中,当运行到使用 Done 的代 码时,Done 很有可能已经被改变过了。即使在初次写程序时不会出现这种情况的话,那么以后 你对其进行修改时,很可能会出现这种情况。第一种方法的另一个问题是,把所有变量集中进 行初始化,会给人以这些参数将在程序中随处都被用到的印象,而事实上 Done 只在程序结束 前才被用到;最后,当对程序进行改动时(这是很可能的,起码在调试时要修改),可能会把 Done 包含在循环中,从而需要对 Done 重新进行初始化,在这种情况下,第二个例子中的代码 不会有什么太大的变化。而第一个例子中的代码则可能会产生讨厌的初始化错误。 这也是邻近原则的一个例子:把相关的操作放在一起。这一原则也适用于把对代码的注释 放在相应的代码附近,把循环设定代码放在循环附近,把注释放在非循环代码中。 要特别注意计数器和累加器。变量 i、j、k 和 Sum、Total 等往往代表计数器和累加器。常 见的错误是在下次用到它们时,忘记了对其进行清零操作。 查找需要重新进行初始化的地方。首先问自己一下有哪些地方需要重新进行初始化。重新 初始化的原因可能是由于变量在循环中被用过多次,也可能是由于变量在对子程序的调用中间 要保持原值且需清零。如果需要重新初始化的话,要确保初始化语句是在被重复的代码段中。 对命名常量只初始化一次,用可执行代码初始化变量。如果要用变量来模仿命名常量,那 么在程序的开始对它们进行一次初始化是可以的,在 Fortran 中可以用 Data 语句来做到这点。 在其它语言中,可以在 Startup()子程序中初始化它们。 应在使用变量的位置附近的可执行代码对其进行初始化。最常见的改动之一是改动某一子 程序,变一次调用它为多次调用它。在 DATA 语句或 Startup()子程序中被初始化的变量,在程 序中不能被重新初始化。 按照所说明的对每个变量进行初始化。虽然其地位无法与在变量使用位置附近对其初始化 相比,但是按照所说明的初始化变量,仍然是防错性编程中的一件有力工具。如果你养成习惯, 那就可以有效地防止初始化错误。下例就保证了 student_name 在每次调用含有它的子程序时, 都将被重新初始化。 第八章 生成数据 119 char student_name[NAME_LENGTH+1] = {'\0'}; /* full name of student */ 利用编译程序的警告信息。许多编译程序都会对使用未初始化的变量进行警告。 设置编译程序使其自动初始化所有变量。如果你的编译程序支持这种选择项,那么让它对 所有变量进行初始化是非常简单的。但是,由于对编译程序的依赖性。当把程序移到另一台编 译程序不同的机器上时,则有可能产生问题。这时要确保你对所用的编译程序进行了注释,否 则是很难发现程序对编译程序有依赖性。 使用内存存取检查程序来查找无效的指针。在某些操作系统中,操作系统代码会自动查找 无效指针,在其它情况下,就只有依靠自己了。不过,你也不一定非得靠自己。可以买一个内 存存取检查程序来检查程序中的指针操作。 在程序开始初始化工作内存。把工作内存初始化到某一个值可以帮助发现初始化错误。可 以采取以下任何一种方法: · 可以用程序内存填充程序来赋予内存某一已知值,这个值用 0 比较好,因为它保证未 初始化指针指向低内存,比较容易发现它们,在 80X86 处理器中,16 进制 0CCH 比 较好,因为它是机器的断点中断码。如果是在调试中运行数据而不是代码,你就会进 入断点。使用值 0CCH 的另一个优点是在内存转储中它很容易辨认。 · 如果你使用的是内存填充程序,可以每次改变一个填充值,用这种方法来检查一下运 行环境下隐藏的错误。 · 可以用程序在软件运行时初始化工作内存。虽然使用内存填充程序的目的是把错误暴 露出来,但这种技术的目的则是隐藏它们。通过每次把工作内存由相同的值充满,可 以保证在每次运行时程序不会被启动时的随机因素干扰。 8.5.l 检查表 数据生成 生成类型 · 程序中是否对每种可能变动的数据都使用了不同的类型? · 类型名称是面向客观世界中的实体类型而不是面向程序语言中的类型吗? · 类型名称是否有足够的表现力来帮助说明数据? · 避免重新定义已定义的类型了吗? 说明数据 · 是否使用了模板为简化数据说明工作?并用其来统一说明形式? · 如果所用的语言支持隐式说明,是否采取了补救措施? 初始化 · 是否每个子程序都对输入参数进行了检查以保证其有效性? · 是否在使用变量的位置附近对其进行初始化的? · 是否恰当地对计数器和指针进行了初始化?是否在必要时对其进行了重新初始化? · 在反复执行的代码段中,是否对变量进行了恰当地重新初始化? · 用编译程序编译代码时是否是无警告的? 第八章 生成数据 120 8.6 小 结 · 在你的工具箱中需要一张全部数据结构的清单,以便用最合适的方法处理每一种问题。 · 建立自己的数据类型,以增加程序的可变动性,并使其成为自说明的。 · 数据初始化很容易产生错误,因此应采用本章推荐的技术来避免由意外初始值所产生的错 误。 第九章 数据名称 121 第九章 数据名称 目录 9.1 选择名称 9.2 特定数据类型命名 9.3 命名约定 9.4 非正式命名约定 9.5 匈牙利命名约定 9.6 短名称 9.7 要避免的名称 9.8 小结 相关章节 生成数据:见第 8 章 使用变量时要考虑的问题:见第 10 章 说明数据:见 19.5 节 格式化数据说明:见 18.5 节 本章论述的是如何对数据进行恰当命名。这个主题对有效编程是非常重要的,但是在进行 恰当命名时要考虑的几十个问题中,我却只读过对其中两到三个的讨论。大多数编程课本只用 几段来论述名称缩写的选择问题,讲的也都是些关于这方面的陈词滥调,而完全寄希望于你自 己去解决问题。对于另一个极端,使用过多关于命名的信息将你淹没,我认为简直就是犯罪。 9.1 选择名称 在给变量命名时,你不能像给小狗起名时那样仅仅挑有趣或好听的名字。但除了实体不同 之外,变量与变量名和狗与狗名实际是同一回事。 因此,一个变量的好坏在很大程度上是由其名字决定的。所以在选择变量名时一定要谨慎。 以下是一段使用不恰当变量名的例子(用 C 写成); X = X - XX; XXX = Aretha + SalesTax( Aretha ); X = X + LateFee(X1,X)+ XXX; X = X + Interest(X1,X); 这段代码是干什么的?X1,XX 和 XXX 代表的是什么呢?Aretha 的意思是什么?如果某 人告诉你,这段程序是根据收支平衡和新的购买情况来计算顾客和全部账单的,那么你将使用 什么变量来代表那些新购买活动呢? 第九章 数据名称 122 以下是一个同样内容的新程序,这里,上述问题就很容易回答了。 Balance = Balance - LastPayment; MonthlyTotal = NewPurchases + SalesTax( NewPurchases ); Balance = Balance + LateFee( CustomerID, Balance ) + MonthlyTotal; Balance = Balance + Interest( CustomerID,Balance ); 通过比较两段代码,我们发现好的变量名是易读、易记而且是恰当的。可以使用几条通用 原则来达到这些目的。 9.1.1 命名时要考虑的最重要问题 在给变量命名时,考虑的问题是变量名称是否完全而又准确地描述了变量所代表的实体。 一个有效的方法是用自然语言(如英语)将变量所代表的实体描述出来。往往这一描述本身便 是最好的名称,因其不含缩写它很容易读懂,又由于它是对实体的全面描述。因此不会与其它 实体相混淆,而且因为它与概念相近,所以也很容易记。 比 如 要 用 一 个 变 量 来 代 表 美 国 奥 林 匹 克 队 的人数,你可以对其命名为 NumberofPeople0nTheUSOlympicTeam。对代表自 1896 年以来国家队在奥林匹克运动会上最多 得分的变量可以用 MaximumNumberofPointsSince1896 作为其名称。而用 InterestRate 或 Rate 作 为代表目前利率的变量名要比用 r 或 x 好得多。 你应该可以发现这些名字的两个特点。首先,它们很容易解释。事实上,你根本不需要解 释,它们的意思是一目了然的;第二条则是其中有些名字很长,长得根本不实用。稍后我们将 讨论这一问题。 下面是一些变量名的例子,同时列出了好的和坏的。 变量代表的实体 恰当的名称 不恰当的名称 火车速度 Velocity、TrainVelocity、VolocityInMPH VELT,V,TV,X,X1 今天日期 CurrentDate、CrntDate CD,Current,C,X 每页行数 LinesPerPage LPP , Lines , X , X1 CurrentDate 和 CrntDate 是恰当的名字,因为它们全面准确地描述了“今天日期”这一含义。 而且,它们用的是明显的单词。程序员们往往忽视使用平常的词,而事实上这是最简单的解决 办法;CD 和 C 太简短了,说明不了任何问题;Current 并没有说明现在的什么?是总统还是赌 马的结果?Date 像是一个不错的名字,但究竟是什么时候的 Date?是基督出生那天吗?X,X1 在任何情况下几乎都是不好的名字,因为它们通常都是代表未知量的,如果你要它代表其它实 体时,往往会引起误会。 9.1.2 面向问题 一个好记的名字通常是面向问题而不是解决问题的。一个恰当的名字往往说明是“什么” 而不是“怎样”。通常,如果一个名称指向计算的一方面而不是指向问题,那么可以认为之是个 “怎样”而不是“什么”的名称。要避免使用这种名称而要使用面向问题的名称。 雇员数据的记录可称为 InputRec 或 EmployeeData,InputRec 是一个计算机术语,指的是输 入和记录;EmployeeData 指的是问题域而不是计算方面。同样,对一个表示打印机状态的变量 第九章 数据名称 123 来说,BitFlag 要比 PrinterReady 专业化得多。在计帐系统中,CalcVal 要比 Sum 更加专业化。 9.1.3 最佳名称长度 名称的最佳长度应介于 MaximumNumberOfPointsSincel896 和 x 之间。太短的名字往往表示 不出完整的意思,而用 x1 和 x2 来表示的问题,即使你能找出 x1 代表的是什么,也很难发现 x1 和 x2 之间有什么联系。太长的名字难以输入,而且会对软件的可视结构产生破坏作用。 Gorla 和 Benander 在 1990 年发现当变量名长度在 10 到 16 个字母时,COBOL 程序最容易 调试。而变量名称长度在 8 到 20 个字母时,程序调试的难易度几乎是一样的。这一准则并不是 说你必须把所有变量名长度都限制在 9 到 15 或 10 到 16 个字母之间。但这确实要求你检查一下, 程序中变量名长于这个标准的那些变量,确保清楚程度符合需求。 以下是对一些变量名的评价,或许会给你一些启迪: 名称太长的: NumberofPeopleOnTheUS0lympicTeam NumberOfSeatsInTheSaddleDome MaximumNumberOfPointsSince1896 名称太短的: N,NP,NTM N,NS,NSISD M,MP,Max,Points 合适的: NumTeamNumbers,TeamMbrCount,cTeamMbrs NumSeatslnDome,SeatCount,cSeat MaxTeamPoints,Record Points,cPoints 9.1.4 变量名的作用域 短的变量名总是不好的吗?当然不总是。当你用 i 作为一个变量名时,这个较短的长度就 可以说明某些问题,比如,这个变量是一个临时变量,只在有限的操作范围内才是有效的。 当程序员读到这样一个变量时,他应该能猜到这个变量只在几行内使用。如果你把某个变 量称为“i”,你就等于在说“这个变量仅作为循环计数器或数组索引数使用,在这几行代码外 没有任何意义”。 由 W.J Hansen 进行的一项调查表明,较长的名字适于较常使用的变量或全局变量。而较 短的名字则适于局部变量或循环变量(1980)。但短名字会产生许多问题,有些程序员把避免使 用它们作为防错性编程的准则。 9.1.5 变量名中的计算值限定词 许多程序中含有带有计算值的变量:totals,averages,maximums 等等。如果你用限定词诸 如(Ttl,Sum,Avg 等)来改动变量,那要把它们放在最后。 这种方法有几个优点。第一,变量名中最有意义的部分——表达变量名大部分意义的部分, 第九章 数据名称 124 被放在最前面;第二,通过建立这种约定,在同一程序中同时使用 TtlRevenue 和 RevenueTtl 会避免因此而引起的混淆;第三,像 RevenueTtl,ExpenseTtl,RevenueAvg 和 ExpenseAvg 这 样的一系列名字有一种令人愉快的相似感。而像 TtlRevenue,ExpenseTtl,RevenueAvg 和 AvgExpense 则不具备这种相似性。最后,一致性可以改善可读性并简化了维护。 这个准则的例外是 Num 放在变量名前面时表示全部,如 NumSales 表示的全部商品的数额; 当 Num 放在变量名后面时,它表示下标,SaleNum 表示现在标出的商品是第几个。Numsales 末尾的 S 也是表示两者意义区别的一个标志。但是,由于过分频繁地使用 Num 会产生混淆,所 以往往用 Count 来表示总数,而用 Index 来表示下标。这样,SalesCount 表示的是卖出的总数, 而 Saleslndex 则指的是卖出的某一种特定商品。 9.1.6 变量名中的反义词 应恰当使用反义词。使用关于反义词的命名约定可以帮助保持连续性,同时也可以提高可 读性。像 first/last 这样一组反义词是很容易理解的,但 first/end 就有些让人摸不着头脑了,以下 是一些比较容易理解的反义词。 add/remove begin/end create/destroy insert/delete first/last get/release increment/decrement put/get up/down lock/unlock min/max next/previous old/new open/close show/hide source/destination source/target start/stop 9.2 特定数据类型命名 除了对数据命名通常需要考虑的一些问题之外,对特殊数据类型必须给予特殊的考虑。本 书将论述循环变量、状态变量、临时变量、逻辑变量、枚举变量和命名常量的命名问题。 9.2.l 循环变量命名 由于几乎每个程序中都含有循环,因此,对循环变量的命名问题加以专门考虑是十分必要 的。 简单循环中的循环控制变量的名字往往也是十分简单的,常用的是 i、j、k 等。下面是一个 Pascal 循环的例子: for i:=FirstItem to LastItem do Data[i] := 0; 如果这个变量还要在循环外使用,那么应该用比 i、j、k 更能说明问题的名称。比如,你正 从文件中读取记录,并且要知道已经读取了多少个记录,那么用 RecordCount 作为其名称似乎 更合适些,请看下面的这个 Pascal 程序: RecordCount:= 0 while not eof(InputFile) do 第九章 数据名称 125 begin RecordCount := RecordCount + 1; ReadLn ( Score [ Recordcount ] ) end; { lines using RecoudCount } … 如果循环体长度较长的话,那就很容易使人忘记它代表的是什么,因此最好给循环控制变 量一个富有意义的名字。由于经常进行更改,扩展和拷贝等代码到另一个程序中,因此,大多 数有经验的程序员都避免用 i、j、k 这类的名字。 使循环体变长的一个常见原因是嵌套。因此,对于一个有多重嵌套的循环,最好给循环控 制变量以较长的名字以便改善其可读性: for TeamIndex := i to TeamCount to begin for EventIndex := 1 to EventCount[ TeamIndex ] do Score[ TeamIndex, EventIndex ] := 0 end; 通过精心对循环控制变量进行命名,可以避免它们的交叉:当你想用 i 时误用了 j,或者 想用 j 时却又误用了 i。这样做也可以使对数组的存取操作更为明了。Score [ TeamIndex, EvenIndex ]显然要 Score[i,j]更能说明问题。总之,应尽量避免使用 i、j、k 来命名。如果不得 不使用它们的话,那除了把它们用作循环控制变量之外,最好不再用作别的变量名。这一约定 是众所周知的,如果不遵守它只会引起别人的困惑。 9.2.2 状态变量命名 状态变量描述的是程序所处的状态。下面论述了它们的命名原则。 用比 flag 更好的名称来命名变量,最好不用 flag 作为状态变量的名字。之所以要避免使用 flag 作为标志名称,是因为它不能告诉你关于这个标志的任何信息。为了清楚起见,应该给标 志赋值,并且用枚举类型、命名常量或当作命名常量使用的全局变量对其进行测试。下面是一 个在 C 语言中不恰当命名标志的例子。 if( Flag )... if( StatusFlag & 0x0F )... if( PrintFlag == 16 )... if( ComputerFlag == 0 )... Flag = 0x1; StatusFlag = 0x80; PrintFlag = 16; ComputerFlag = 0; 如果这个程序不是你写的,而且也没有注释告诉你的话,你是无法知道类似 statusFlag = 0x80 之类的语句到底是要干什么的,而且你也无法知道 Statusflag 和 0x80 到底是什么意思。以 第九章 数据名称 126 下是功能与内容相同但清楚得多的程序: if ( DataReady )... if ( CharacterType & PRINTABLE_CHAR )... if ( ReportType == AnnualRpt )... if ( RecalcNeeded == TRUE )... DataReady = TRUE; CharacterType = CONTROL_CHARACTER; ReportType = AnnualRpt; RecalcNeeded = FALSE; 显然,第二个例子中 CharacterType = CONTROL_CHARACTER 的意义要比第一个中 StatusFlag = 0x80 的意义要清楚得多。同样,第二个例子中的条件语句 if ( ReportType == AnnualRpt ) 也显然要比第一个中的 if ( PrintFlag == 16 )清楚得多。第二个例子表明,你可借 助预先命名的常量或枚举类型来使用这种方法。以下是如何利用枚举类型和命名常量来设置上 例中用到的值,仍用 C 来实现: /* values for DataReady and RecalcNeeded */ #define TRUE 1 #define FALSE 0 /* values for CharacterType */ #define LETTER 0x01 #define DIGIT 0x02 #define PUNCTUATION 0x04 #define LINE_DRAW 0x08 #define PRINTABLE_CHAR ( LETTER | DIGIT | PUNCTUATION | LINE_DRAW ) #define CONTROL_CHARACTER 0x80 /* values for ReportType */ Typedef enum { DailyRpt,MonthlyRpt,,QuarterlyRpt, AnnualRpt,AllRpts}REPORT_TYPE_T; 当你发现自己“侦破”了一段代码时,应该考虑对变量重新命名。侦破一桩凶杀案是可以 的,但你不应该去“侦破”一段代码。代码应该是具有良好可读性的。 9.2.3 临时变量命名 临时变量用来保存中间运算结果,如用作暂时保留某个位置或保留内务操作值。通常把它 们叫做 TEMP 或 X 等没有什么意义的名字。临时变量的使用往往标志着程序员还没有完全理解 第九章 数据名称 127 程序。而且,由于名义上给了它一个“临时”的状态,因而程序员们在处理它们时往往会采取 漫不经心的态度,从而增大了出错机会。 要警惕“临时”变量。通常,暂时保留某些值是完全必要的。但如果在你的程序中出现很 多临时变量的话,则说明你对它们在程序中作用和使用它们的目的还不清楚。先让我们看一下 下面用 C 语言写成一个例子: /* Compute root of a quadratic equation. This assumes that ( b^2 - 4 * a * c ) is positive. */ Temp = sqrt ( b^ 2 - 4 * a * c ); root[0] = ( -b + Temp )/( 2 * a ); root[1] = ( -b – Temp )/( 2 * a ); 把由公式 sqrt ( b^2 - 4 * a * c ) 计算出来的值储存起来是个不错的想法,尤其是在后面还有 两处用到了它的情况下。但是用 TEMP 作为它的名称不能告诉你关于这个变量意义的任何信息。 一个较好的作法是下面这个例子: /* Compute roots of a quadratic equation. This assumes that ( b^2 – 4 * a * c )is positive */ Discriminant = sqrt ( b^2 - 4 * a *c ); root[0] = ( -b + Discriminant )/( 2 * a ); root[1] = ( -b - Discriminant )/( 2 * a ); 事实上两段代码是完全一样的,但是通过采用更准确也更能说明问题的变量名,大大改善 了其可读性。 9.2.4 逻辑变量命名 以下命名逻辑变量的几条准则: 记住一些典型的逻辑变量名。以下是些非常有用的逻辑变量名: Done。用 Done 来表示某项工作已经完成了。这个变量可以表示子程序或循环是否已经完 成。当某项工作没有完成时,把 Done 的值赋为 False;当某项工作已经完成时,把 Done 的值 赋为 True。 Error。用 Error 来表示发生了错误。当没有错误时将 Error 的值赋为 False;当有错误时将 其 赋为 True。 Found。用 Found 来表示是否找取了某个值。当搜寻数组来查找某一值或搜寻某一文件表 查找某一雇员的识别卡时,没有找到某一值时将其值赋为 False,而一旦找到这个值。则把 Found 值赋为 True。 Success。用 Success 来表示某一操作是否已成功地完成。当某一程序无法完成时,将 Success 的值置为 False;而当某一操作已经完成时,将 Success 的值置为 True。如果可能的话,可以用 比 Success 更准确更具有表达力的名称来代替它,用这个新名称应可以精确地表达出到底是什 么已 成功地完成了。比如当某一程序完成处理后就可以认为是成功时,就可以用 Processingcomplete 来代替 Success。如果当找到某一值就可以为程序是成功的时,可以用 Found 来代替它。 第九章 数据名称 128 用暗含非真即假的名字来给逻辑变量命名。像 Done 或 Success 等之所以是恰当地逻辑变量 名,是因为它们的状态是非真(True) 即假 (False)的。某项工作或者完成了或者没完成;或者 是成功的或者不成功,不会有第三种状态。而类似 Status 或 SourceFile 等则是不恰当的名字, 因为看不出它们的状态是非真即假的。如果 Status 的值是 True,那它是否意味着某种物质有状 态呢?任何物质都有状态。或者说当其值是 True 时,意味着某种物质的状态很好,而为 False 时则意味着状态不好?仅从 Status 这个名称是无法回答这些问题的。 为清楚起见,最好用 Error 或 Status_OK 等来代替 Status;用 SourceFileAvailable 或 SourceFileFound 来代替 Source。 有些程序员喜欢用 Is 用为逻辑变量名的前缀,于是变量名就成了一个问句:IsDone? IsError?IsSuccess?当用 True 或 False 来回答上述问题时,就等于给变量赋了值。这样做的好 处是可以避免那些不恰当的名称。如 IsStatus 显然没有任何意义。 使用肯定的逻辑变量名。否定式的变量名如 NotFound、NotDone 和 Notsuccessful 等在“非” 运算中是很难读懂的,如: if not NotFound 这类名字应该用 Found,Done,Successful 等来代替,以方便“非”运算。 9.2.5 枚举类型命名 当使用枚举型变量时,可以通过使用相同的前缀或后缀表示某一类型的元素都是属于同一 组的,如下面的这段 Ada 程序所示: type COLOR is ( COLOR_RED, COLOR_GREEN,COLOR_BLUE ); type PLANET is ( PLANET_ERATH, PLANET_MARS, PLANET_VENUS ); 9.2.6 常量命名 对常量来说,应该用它所代表的抽象实体而不是数值来命名。FIVE 是一个很不恰当的常量 名称(不管它代表的数值是否是 5.0 ); CYCLES_NEEDED 则是个恰当的名称, CYCLES_NEEDED 可以等于 5.0 也可以等于 6.0,而 Five = 6.0 则是个荒唐的语句。由于同样的 原因,BAKERS_DOZEN 也是很不恰当的变量名,而 MAX_DONUTS 则要恰当得多。 9.3 命名约定 有些程序员往往坚持标准和约定,这是有其原因的。然而,某些原则和约定过于刻板而且 往往是无效的,这只会破坏你的创造力和程序的质量,这实在是个不幸,因为有效的标准和约 定是你的工具箱中最为有效的工具之一。这一节将讨论为什么及什么时候和怎样建立你自己的 命名标准。 9.3.1 为什么要建立约定 约定可以带来如下好处: · 它们可以使更多的东西成为独立的。通过做出一个总体决定而不是许多局部决定,你 第九章 数据名称 129 可以把精力放到更重要的程序特性上。 · 它们可以帮助借鉴其它项目的经验并移植自己的经验。相似的名字可以使你更容易并且更 自信地猜测陌生变量的功用。 · 它们可以使你更快地熟悉新项目的代码。与一套连贯的而不是各式各样、东拼西凑的代码 打交道显然要容易得多。 · 防止一变量多名。如果没有命名约定,你很可能给一个变量取两个或更多的名字。例如, 你可以把所有点的个数称作 PointTl 又称作 Ttl_Points。这对于你来说可能没什么,因为 你是程序的作者。但对以后要读这个程序的程序员来说,这很可能会使人困惑。 · 弥补语言的缺陷。你可以利用命名约定来仿效命名常量或枚举类型,这一约定可以区分局 部、模块和全局变量,也可以并入编译程序所不支持类型的信息。 · 命名约定还可以强化相关项之间的联系。如果你使用的是结构化数据,那么编译程序会自 动考虑到这一点;如果所用的语言并不支持结构化数据,可以通过命名约定来补充它。像 Addr,Phone 和 Name 等名称并不表示变量是相联系的。但如果你决定所有的雇员数据变量 名都要用 Emp 作为前缀,那么毫无疑问,EmpAddr、EmpPhone 和 EmpName 就是联系的变量 了。通过建立伪结构化数据,弥补语言的缺陷。 关键是有约定总比没有约定好,哪怕约定是随意的也罢,约定的效力并不是由某一确定的 约定,而是由约定存在决定的,它可以增加代码的结构并减少你的担心。 9.3.2 什么时候使用命名约定 关于这个问题并没有一成不变的答案。但在以下几种情况下,使用命名约定我认为还是值 得的。 · 当同时有几个程序员从事一个项目时。 · 计划把程序交给另一个程序员进行修改和维护时(这时命名约定是必不可少的)。 · 当你的程序将由其它程序员来评审时。 · 当程序规模过大,需要按部分来考虑它时。 · 当一个项目中要频繁使用某些不常见的词汇,而又想开始编码时。 命名约定总是有益的,上述准则可以帮助你确定在某一项目中命名约定使用的广泛程度。 9.3.3 正式程度 不同约定的正式程度是不同的。一个简单的约定可能只有一句话,“使用有意义的名称”。 略微正式的约定将在下节讨论。更正式些的约定将 9.5 节中论述。一般来说,约定正式程度是 由从事一个程序的程序员人数、程序的规模和程序的预测生存期决定的,在小型程序中,严格 的约定往往是不必要的。如果是需要几个人协作(可能是在开始,也可能是在程序生存期内的 某个时间)的程序,那么可读性往往要依赖正式的命名约定来保证。 9.4 非正式命名约定 绝大多数项目都采用如下所述的非正式命名约定。 第九章 数据名称 130 9.4.1 与语言无关的约定准则 以下是一些与语言无关的约定准则: 标识全局变量。常见的编程问题之一是误用全局变量。可以在所有的全局变量前面都加 上 g_作为前缀来解决。比如看到 g_Running Total 时,程序员就会知道这是一个全局变量,从而 把它作为全局变量对待。 标识模块变量。模块变量是在模块内部供几个子程序使用的变量。要能清楚地表明它既不 是全局变量也不是局部变量。这可以用在变量前加 m_作为前缀来解决。在 C 中,你可以通过在 任何子程序外说明 Static 变量来建立模块层次的数据。这样的变量对文件中子程序来说都是可 用的,但文件外的子程序则无法使用。 标识类型定义。类型的命名约定需要有两个功能:它们要明确地指出某一名称是类型名称, 同时要可以避免类型名称与变量名称相冲突。为了达到这两个要求,使用前缀或后缀不失为一 个好办法。在 Pascal 中,可以使用小写的_t 来表示类型名称,例如 Color_t 或 Menu_t。在 C 中用这种办法稍有些困难,常见的办法是用大写字母组合如 COLOR 或 MENU 来表示类型名,但这 可能与命名预处理程序常量相混淆。而“_t”这一约定已经被标准类型 size_t 所采用了,因此可 以用“_T"表示类型名称,如 COLOR_T 和 MENU_T。 标识命名常量。需要对命名常量加以标识,以便可以使你知道是在用一个变量(其值可能 变动)还是在用一个命名常量给某个变量赋值。在 Pascal 或 C 中,你还可能是在用函数给某一 变量赋值。这些语言,并不要求函数使用括号。而在 C 中,即使是不带参数的函数也必须使用 括号。 给命名常量命名的一种办法是“_C”作为其名称的后缀。在 Pascal 中,用这种方法可以产 生出像 MaxRecs_C 或 MaxlinesPerPage_ C 之类的名字。在 C 中,你可以用“_C”作为其后缀。 标识枚举类型。与命名常量同样的原因,枚举类型也需要被标识出来。即将其与变量、命 名常量和函数加以区别。常用的方式是用“_e”或“_E”作为后缀。 标识输入参数。有时输入参数会被错误改动。在像 C 或 Pascal 这样的语言中,当一个被改 变过的值返回调用子程序时,必须予以明确地说明。在 C 中,这是用“*”说明的,在 Pascal 中用的是 VAR,Ada 语言中使用的是 out 限定词。在其它语言如 Fortran 中,如果你改变了一个 输入值,那么不管你是否愿意,它都将被返回,但假如你建立了命名约定,在约定中规定只用 于输入的参数前面要采用 IP 作为前缀,那么当发现在等号左边出现了带有 IP 前缀的变量时, 你就可以知道发生了错误。比如在程序中看到 IPMAX = IPMAX+l 语句,就可以立刻认定它是 错误的,因为前缀 IP 表明 IPMAX 的值是不允许变动的。 对名字作格式化以增强可读性。增强可读性常见的两项技术是用分隔字符或大写字母将单 词分隔开来。例如,GymnasticsPointTotal 或 Gymnastic_Point_Total 的可读性显然是要强于 GYMNASTICSPOINTTOTAL。C、PASCAL、Ada 和其它语言都允许大小写混用的方式。C、 Pascal 和其它语言都允许使用的下划线“_”作为分隔符。 尽量不要混合使用这两项技术,那样会降低可读性。如果坚持采用其中任何一种,那么你的 代码的可读性将大为改观,关于变量名第一个字母是否应该大写的问题,激烈的争论已经持续 了很长时间(是 TotalPoints 好还是 totalPoints 好?)。但是只要保持一致性,我认为二者区 别实际上并不大。 第九章 数据名称 131 9.4.2 与语言有关的命名约定 应当遵守所使用语言的标准命名约定。 你可以发现许多书中都讲述了语言的形式准则。关于 C、Pascal 和 Fortran 的准则将在下 面予以论述。 C 约定 有些命名的约定是只适用于 C 的,可以在 C 中使用这些约定,也可以改变它以使其适应其 它语言。 · c 和 ch 是字符变量 · i 和 j 整型下标 · n 是数量 · p 是指针 · s 是字符串 · 预处理程序宏指令是以全部大写来表示的,这通常扩展到包含 typedef · 变量和子程序名称都是小写的 · 下划线“_”用做分隔符 这些是在 UNIX 操作系统下用 C 语言编程的一些通用约定。在不同的环境下,这些约定是 有区别的。在 Microsoft Windows 环境下,C 程序员们往往愿意使用匈牙利命名约定,并且对变 量名称是大小写混用的。在 Macintosh 环境下,C 程序员们往往乐于使用类 Pascal 的命名约定 给子程序命名。因为 Macintosh 工具箱和操作系统子程序都是为 Pascal 接口设计的。 Pascal 约定 Pascal 中只有几条特殊的约定,你可以在 Pascal 中使用它们,也可以改进它们以适应其它 语言需要。 · i、j 和 k 是整型下标 · 变量和子程序名称是以大小写混用的形式出现的 Fortran 约定 Fortran 有一些语言本身固有的命名约定,在 Fortran 中可以使用它们。但我想如果不把它们 扩展到其它语言的话,全世界都会因此而感激你的。 · 以字母 I 到 N 开头的变量名是整型的 · I、J 和 K 只能作为循环控制变量 · X、Y 和 Z 是浮点数 9.4.3 命名约定举例 当命名约定准则长达几页时,它们看起来是非常复杂的。事实上,命名约定是完全不必要 复杂到可怕程度的,你可以改进它们以适应你的需要。变量名应包括三个方面的信息: · 变量内容(它代表的是什么) 第九章 数据名称 132 · 变量数据类型(整型、浮点等) · 变量在程序结构中的位置——例如,定义变量的模块名称或者一个表示变量是全局的 前缀。 以下是 C 和 Pascal 中采用前面讲过的命名原则进行命名的一些例子。我并不是想把这些约 定推荐给你,而是想给你一些变量名应包括哪些信息的想法。 Pascal 命名约定示例 约定类别 约定内容 LocalVariable 局部变量名是大小写混用的。这个名称应与潜在的数据类型无关,且应 指出这个变量代表的到底是什么 RoutineName() 子程序也是大小混用的(具体讨论见 5.2 节) m_ModuleVariable 仅供某一模块子程序使用的变量以 m_作为前缀 g_GlobalVariable 全局变是用 g_为前缀 Constant_C 命名常量以_C 作为后缀 Type_t 类型以_t 作为后缀 Base_EnumeratedType 枚举类型是以其基本类型的助记符作为前缀的,如 Color_Red, Color_Blue C 命名约定示例 约定类别 约定内容 GlobalRoutineName() 公用程序名称是大小写混合使用的 _FileRoutineName() 供某一个模块专用的子程序以下划线作为前缀 LocalVariable 局部变量名是大小写混用的。其名称应与数据类型无关且应指明变 量代表的究竟是什么 _FileStaticVariable 模块(文件)变量以一个下划线作前缀 GLOBAL_GlobalVariable 全局变量是以定义它的模块(文件)名称的全大写形式助记符作为 前缀的,如 SCR_Dimension LOCAL_CONSTANT 仅供某一子程序或模块 (文件) 专用的命名常量是全大写的 GLOBAL_CONSTATNT 全局命名常量名字是全大写的,且以它的模块(文件)名称 助记符的大写形式来作前缀的,如 SCR_MAXROWS TYPE 类型定义是全大写的 LOCAL_MACRO() 仅供某一子程序或模块(文件)专用的宏定义是大写的 GLOBAL_MACRO() 全局宏定义是大写的且以它的模块(文件)名称助记符大写形式为 前缀 9.5 匈牙利命名约定 匈牙利命名约定是一整套对子程序和变量进行命名的详细约定。这一约定广泛应用在 C 语 言中,特别是在 Microsoft Windows 环境下用 C 编程时,之所以称其为“匈牙利”是因为遵循这 一约定的命名看起来像是外文。而且其发明者 Charles Simonyi 原来是匈牙利人。 匈牙利命名主要包括三个部分:基本类型、一个或更多的前缀、一个限定词。在下面的例 子是在 C 中采用匈牙利命名约定写成的,你很容易就可以对其加以改进以扩展到其它语言中。 第九章 数据名称 133 9.5.1 基本类型 基本类型是指待命名变量的数据类型。基本类型名称通常不指向程序语言中任何一种已定 义的标准数据类型。基本类型是一种更抽象的数据类型,基本类型名称指向的可能是窗口、屏 幕区和字体等实体。在匈牙利名称中只能使用一种基本类型。 基本类型是用你为某一特定程序建立的简写代码来表示的,然后对其作标准化以便在这一 程序中继续使用。以下是在一个文字处理程序中你可能用到的基本类型: 基本类型 含义 wn 窗口 scr 屏幕区 fon 字体 ch 字符(不是 C 意义上的字符,而是这个文字处理程序 将用来在文件中代表字符数据结构意义上的) pa 段落 使用匈牙利基本类型时,同时也定义了与基本类型使用同样缩写的数据类型。因此,如果 使用了如上表列出的基本类型,就会看到像这样的数据说明: WN wnMain; SCR scrUserWorkspace; CH chCursorPosition; 9.5.2 前缀 前缀放在基本类型前面并描述了变量的使用。与基本类型不同的是前缀在某种程度上是标 准化的。表 9-1 中示出了一级标准匈牙利前缀: 表 9-1 匈牙利变量名称前缀 前缀 含义 a 数组 c 数目(如记录、字符等等的个数等) e 数组的元素 g 全局变量 h 处理 i 数组下标 m 模块层次上的变量 p(lp,np) 指针(长指针、近指针——对于 Intel 机器) 前缀是小写的,并且放在变量名中基本类型的前面。如果需要的话,可以把它与基本类型 或与其自身组合使用。例如,一个表示窗口的数组,可以用“a”来表示它是个数组,用“wn” 表示窗口,因此可以用“awn”来作这个数组的名称;而对窗口的操作可称为 hwn;cwn 是窗口 的数目;cfon 是字体的种类等等。 9.5.3 限定记号 匈牙利名称的最后一个要素是限定词。限定词是名称的描述部分。没有使用匈牙利约定 第九章 数据名称 134 时也可利用这部分来补偿。在上前面给出的例子——wnMain 、 scrUserWorkspace 和 chCursorPosition 中,Main、UserWorkspace 和 CursorPosition 都是限定词。在本章前面给出的关 于变量命名的准则也适用于限定词。 除了自己生成的限定词以外,匈牙利约定中还对容易在处理时产生混淆的概念给出了标准 化限定词。在表 9-2 中,表示了些标准限定词。 表 9-2 匈牙利约定中的标准限定词 限定词 含义 Min 数组或其它表中绝对的第一个元素 First 数组中需要进行处理的第一个元素。First 含义与 Min 相近,但 First 是相 对操作的第一个,而 Min 则是指数组本身的第一个元素 Last 在一个数组中需要最后进行处理的元素。Last 是 First 的反义词 Lim 在一个数组中需要处理的元素上界。与 Last 一样,Lim 也是 First 的反义 词,但与 Last 不同的是,Lim 代表的是数组的不完全上界;而 Last 代表 的则是最后一个元素。通常,Lim 等于 Last 加 1 Max Max 指的是数组或表列中的绝对最后一个元素而不是相对操作的最后一 个元素 限定词能够而且应该与基本类型和前缀联合使用。例如,paReformat 指的是要重新格式化 的段落;apaReformat 指的是要重新格式化的段落数组。而 ipaReformat 则指的是需要重新格式 化的段落的数量。一个 for 循环来重新格式化段落的 C 程序如下: for ( ipaReformat = ipaFirstReformat; ipaReformat <= ipaLastReformat; ipaReformat++; )… 可以用 PaLimReformat 来代替上例中的变量名 IpaLastReformat 重写相同的循环,这时,由 于 Lim 和 Last 的区别,在确定循环是否结束的检查中,将用“<”来代替“<=”。即用: ipaReformat < ipaLastReformat 来代替 ipaReformat <= ipaLastReformat 9.5.4 匈牙利名称举例 以下是利用匈牙利约定产生的变量名。其中变量名的基本类型部分采用的是前面文字处理 程序示例中的基本类型。 变量名 意义 ch 字符变量(并不是 C 语言意义上字符,而是指文字处理程序中用来 在文件中代替字符的数据结构) achDelete 要删去的一个字符数组 ich 字符数组的下标 ichMin 字符数组中绝对第一个元素的下标 第九章 数据名称 135 ichFirst 字符数组中第一个需要进行某种操作的元素的下标 echDelete 字符数组中产生的某一元素,如 ecbDelete = acbDelete [icbFirst]的结果 pachInsert 要插入字符数组中的指针 ppach 指向某一字符数组指针的指针 cchInser 要插入字符的数量 cscrMenu 用作菜单屏幕区的数量 hscrMenu 对用作菜单屏幕区的操作 mhserUserInput 用户输入屏幕区的模块层次上的操作(所有在这一模块中的子程序 都可对这一变量进行存取操作) ghscrMessages 为获得信息而对屏幕区进行的全局操作 注:上表中某些变量名不含限定词。虽然省略限定词是很常见的,但我们并不提倡这样做, 应尽可能使用限定同。 9.5.5 匈牙利约定优点 匈牙利约定与其它命名约定一样,拥有由命名约定所带来的一切共同优点。由于有这样多 的标准名称,因此在任何一个单个子程序或程序中要特殊记忆的名字是非常少的。匈牙利约定 完全可以在不同项目中采用。 匈牙利约定可以使得在命名中容易产生定义的区域变得准确清楚。特别是约定中对 First, Min,Last,Max 和 Lim 的准确区分在实际中是尤其有帮助的。匈牙利约定可以使人对编译程序 无法检查的抽象数据类型进行检查:cpaReformat[i]很可能是错误的,因为 cpaReformat 不是数 组,而 apaReformat[i]则可能是正确的,因为 apaReformat[i]是数组。 匈牙利约定可以在类型不严格的语言或环境中对类型进行说明。例如,在 Windows 环境下 编程时,需要你放弃许多类型,这极大地限制了编译程序进行严格类型检查的能力。而建立约 定则可以对环境的这一弱点作出补偿,匈牙利约定还可以使名称更简洁,可以用 CMedals 而不 用 TotalMedals 来代表奖牌的数量,使用 pNewScore,而不是用 NewScorePtr 命名一个新分数指 针。 9.5.6 匈牙利约定缺点 一些版本的匈牙利约定事实上忽视了用抽象数据类型作为基本类型。它们以程序语言中整 型、长整型、浮点数和字符串为基础来建立基本类型。匈牙利约定基本类型事实上是没有什么 价值的,因为它使得程序员陷入对类型进行人工检查的困扰之中,而不是让编译程序对类型进 行更加快速而又准确的检查。 这种形式匈牙利约定的另一个问题是它把数据的意义与其表现联系在一起。比如,说明某 一变量是整型的,把它改为长整型的时,不得不改动这一变量的名称。 匈牙利约定的最后一个问题是它鼓励了懒惰、不含什么信息的变量名的出现。当程序员用 hwnd 来命名对窗口的操作时,往往忽视了他所指的到底是哪种窗口、对话框、菜单还是帮助区 的屏幕?显然用 hwndmenu 要比 hwnd 清楚得多。以变量的意义为代价来获得对其类型的精确 描述显然是愚蠢的。不过好在可以用加限定词的办法来同时获得完整的意义和精确的类型。 第九章 数据名称 136 9.6 短名称 从某种程度上来说,使用短名称的意向是早期语言的遗留物。较老的语言如汇编、Basic 和 Fortran 语言把变量名长度限制在七到八个字母之间,从而迫使程序员们不得不使用短名称。在 现代语言如 C、Pascal 和 Ada 中,事实上可以使用任意长度的名称,因此,此时已没有任何必 要再使用短名称。 如果确实不得不使用短名称的话,要注意某些使用短名称的方法要好于其它的。可以通过 去掉不必要的单词、使用短符号或者使用其它缩写技术来建立恰当的变量短名称。可以使用任 意一种缩写技术。但最好多熟悉几种缩写技术因为没有任何一种方法是万能的。 9.6.1 缩写使用的总体准则 以下是使用缩写的几项准则,其中有某些准则是与其它准则相密的,因此不要试图一次使 用其中所有的技术。 · 使用标准的缩写(常用缩写,如列在字典缩写表中的)。 · 去掉所有的非大写元音字母(如 Computer 写成 Cmptr,Screen 写成 Scrn,Integer 写 成 Inter 等)。 · 使用每个单词的头一个或头几个字母。 · 截掉每个单词头一至三个字母后面的其余字母。 · 使用变量名中每一个有典型意义的单词,最多可用三个单词。 · 每个单词的第一个和最后一个字母。 · 去掉无用的后缀——ing,ed 等等。 · 保留每个音节中最易引起注意的发音。 · 反复交替地使用上述技术,直到变量名长度缩短至 8 到 20 个字母为止,或者到你所 用语言规定的长度为止。 9.6.2 语音缩写 有些人喜欢根据单词的发音而不是拼写来进行缩写。如把 skating 写成 sk8ting,brightlight 写成 bilite,before 写成 b4 等等。这更像是在让人们破译密码,因此我不主张采用这种方法,但 作为一种技术,你可以尝试一下“破译”出如下语音缩写的含义: ILV2SK8 XMEQWK S2DTM8O NXTCd TRMN8R 9.6.3 关于缩写的建议 进行缩写时很容易陷入误区。以下是避免出现这种情况的几条准则: 不要通过拿掉单词中一个字母进行缩写。多敲一个字母费不了多少精力,而由此损失的可 读性却往往是巨大的。如把 June 写成“Jun”或“July”、“Jul”等都是不值得的。而且如果总是 使用这种只省略一个字母的缩写,很容易使人忘记你是否省略掉了一个字母。因此,或者多省 略几个字母,或者使用全称。 缩写应保持一致性。应坚持使用同一缩写。例如,是使用 Num 或者 No,但不要两者混用。 第九章 数据名称 137 同样,也不要时而缩写某一名称,又时而不缩写。比如,假设已经用了 Num 就不要再同时使用 Number 了。 使用容易发音的缩写。如应使用 xPos 而不是 Xpsn,用 CurTotal 而不是 ntTtl。可以用能否 在电话中让对方明白的方法来检验缩写名称,如果不能的话,最好换一个比较容易说的缩写。 避免会引起错误发音的组合。如为表示 B 的结束,应使用 ENDB 而不要使用 BEND。如果 使用了良好的分隔技术,则不必理会这条准则。如 B_END,BEnd 或 b_end 都不会导致错误发 音。 近义词来避免命名冲突。在使用短名称常碰到的一个问题是命名冲突——对不同的名称使 用了同一缩写。如 fired 和 fullrevenuedisbursal,假设对缩写的要求是限定在三个字母的话,那 么很可能两者都会被缩写成 fri,从而产生冲突。 避免这个问题的方法之一是使用近义词。如可以用 dismissed 来代替 fired,用 complete revenuedisturb 来代替 fullrevenuedistural 便可以解决上面的问题。 用注解表来说明短名称。在只允许使用简短名称语言中,可以通过加入一个注解表来对变 量名称的缩写加以说明,可以把注解表作为代码开始的注释块,以下是一个 Fortran 中使用注解 表的例子: C*************************************************************** C Translation Table C C Variable Meaning C -------- ------- C XPOS X-Coordinate Position ( in meters ) C YPOS Y-Coordinate Position ( in meters ) C NDSCMP Needs Computing ( =0 if no computation is needed; C =1 if computation is needed ) C PTGTTL Point Grand Total C PTVLMX Point Value Maximum C PSCRMX Possible Score Maximum C*************************************************************** 多从读程序者而不是写程序者的角度去考虑变量名称。你可以通过隔一段时间再阅读一下 程序的方法来检查一下是否需要费很大精力才能弄清变量的含义。如果是这样的话,应改进命 名技术来解决这一问题。 9.7 要避免的名称 以下是应该避免的几种名称: 避免容易产生误会的名称或缩写。要确认变量名称是清楚的。FALSE 通常是指 TRUE 的反 义词,如果用它当“Fig and Almond Season”的缩写显然是不合适的。 避免含义相同或相近的名字。如果可以交换两个变量的名称而不致影响程序,那说明这两 个变量都应重新命名。如 Input 和 InVal,RecNum 和 NumRecs 等每两个的意义都很相近,如果 第九章 数据名称 138 在同一程序中同时使用它们来命名两个变量,就非常容易引入某些难以察觉的错误。 避免使用含义不同但拼写相似的名称。如果发现两个变量名称拼写相似但含义不同,那应 对其中一个重新命名或改变缩写技术,比如 ClientsRecs 和 ClientsReps 这样的名称就应避免。因 为它们之间只有一个字母不同,而且这一字母很难辨别,两个名称之间至少应有两个字母不同, 或者把不同的字母放在词首或词尾。如用 ClientRecords 和 ClientsReports 来分别代替上述两个 名称显然要好得多。 避免使用发音相同或相近的名称。如 Wrap 和 rap。因为这将使你在与同事讨论问题时遇到 很多麻烦。 避免在名称中使用数字。如果变量名中的数字的确很有意义的话,应使用数组而不应使用 单个变量。如果使用数组不合适的话,那么使用含有数字的变量名更不合适。比如,应避免使 用 FILE1、FILE2 和 Total1、Total2 这类名字。可以用很多办法来区分两个变量,但不要采用在 变量名末尾加数字的方法。我不敢说应绝对禁止在变量名中使用数字,但起码你应尽全力避免 这种用法。 避免在名称中改写字母。记住单词的拼写是一件困难的事情,而记住改了字母的单词则更 困难。例如,通过改写字母把 highlight 写成 hilite 以节省三个字母,将使得读者很难记住这个 单词被改写成什么样了,是 Hilite,还是 Hai-a-lai-t?谁知道呢? 避免常见的容易拼写错的单词。Absense、acummulate、acsend、calender、conceive、defferred、 definate、independance、occassionally、prefered、reciept、superseed 等是英语中经常容易拼写错 误的,绝大多数英语词典中都列有常见的容易拼写错的单词。应避免在变量名中使用这些单词, 以避免因拼写错造成程序中的错误。 不要单纯通过大写来区分变量名。如果使用的是可以区分大小的语言,可能会试图用 Frd 来代替 fired,用 FRD 来代替 final review duty,用 frd 来代替 full revenue disbursal,应放弃这种 做法。尽管每个名字都是唯一的,但其中每个名称所代替的意义则是任意且容易混淆的。谁能 知道 Frd,FRD 和 frd 分别对应的是 fired,final review duty 和 full revenue disbursal 而不是按其 它顺序来对应的呢? 避免使用标准子程序名和已定义的变量名。所有的语言都要求保留其标准子程序名和已 定义变量名,应注意避免使用这些子程序和变量。比如,下面这段代码在 PL/I 中是合法的,但 若你真的这样的话,那你一定是一个十足的傻瓜: if if = then then then = else; else else = if; 不要使用与变量所代表的实体没有任何联系的名字。像 Margaret 或 Coolie 之类的变量名事 实上保证了除你之外没有其它任何人能理解它的。不要用你的女朋友、妻子或朋友的名字作为 变量名,除非这个程序是关于你的男朋友、妻子或朋友的。即使真的是这样的话,你也应该意 识到他们是有可能变化的,因此用通用些的名字如:BoyFriend、wife 或 FavoriteBeer 会更好。 避免使用含有难以辨认字符的变量名称。要知道有些字符是非常相象的,很难把它们区分 开来。如果两个变量名的唯一区分便是一个或两个这种字符,那么你区分这些变量时就会感到 第九章 数据名称 139 十分困难。例如,请尝试一下把下表每一组中与其它两个变量名不同的一个找出来。 变量名表 EyeChart1 EyeChartI EyeChart1 TTLCONFUSION TTLC0NFUSION TTLCONFUSION Hard2Read HardzRead Hard2Read GRANDTOTAL GRANDTOTAL 6RANDTOTAL Ttl5 TtlS TtlS 如上表所示,难以区分的字符有"l"和"1"、"1"和"I"、"."和","、"0"和"o";"S"和"5" 、"G" 和"6"等。 9.8 小 结 · 恰当的变量名是可读性好的必要条件之一。特殊的变量如循环变量和状态变量要予以 特殊考虑。 · 命名约定可以区分局部、模块和全局变量。同时它还可以区分类型名称,比如可以对 命名常量、枚举类型和变量加以区分。 · 不管你从事的是哪种项目,都应该采用命名约定。所采用的命名约定取决于程序的规 模和从事这一程序的程序员的人数。 · 匈牙利约定是一种非常有效的命名约定,比较适于大规模项目和程序。 · 在现代编程语言中几乎不需要采用缩写技术。 9.8.1 检查表 通用命名约定 · 变量名称是否完全准确地描述了变量代表的是什么? · 变量名是否指向是客观世界中的问题,而不是关于这问题的用程序语言表达解决方案? · 变量名称是否是够长,使得你不必破译它? · 变量名中如果含有计算限定词的话,是否将其放在最后? · 是否在名称中用 Count 或 Index 来代替了 Num? 对特殊类型数据的命名 · 循环变量的名称是有意义的吗?(如果循环体较长是嵌套循环的话,应用有含义的名 称来代替 i、j、k 之类的名称) · 是否用更富于含义的名称来代替了被叫作"tempotarg"的临时变量? · 当逻辑变量的值是"True"时,它的名称是否充分表达了其含义? · 是否用前缀或后缀来表明了某些枚举类型是一类的?如用 Color 来作 ColorRed, ColorGreen,ColorBlue 等枚举类型的前缀。 · 命名常量的名称是否是指向它们代表的实体而不是它们所代表的数值的? 命名约定 · 命名约定是否区分了局部、模块和全局数据? 第九章 数据名称 140 · 命名约定是否对类型名称、命名常量、枚举类型和变量进行了区分? · 在不支持强化仅供子程序输入参数的语言中,命名约定是否对这类参数进行了标识? · 命名约定是不是与程序语言的标准约定尽可能地相容? · 对于语言中没有强制的子程序中仅做输入的参数,是否约定将它标识了? · 是否对名称进行了格式化以增强程序的可读性? 短名称 · 代码是否使用了长名称?(除非有必要使用短名称) · 是否避免了只省略一个字母的缩写? · 所有单词保持缩写的连续性了吗? · 所有的名称都是容易发音的吗? · 是否避免了会引起错误发音的名称? · 是否在注释表中对短变量名进行了注释? 避免如下这些常见的命名错误了吗 · 易引起误会的名称 · 含义相似的名称 · 仅有一或两个字母不同的名称 · 发音相似的名称 · 使用数字的名称 · 对单词作改写以使其比较短的名称 · 英语中常拼写错的名称 · 标准库子程序或已定义的变量名又定义了 · 完全是随意的名称 · 含有难以辨识字母的名称 第十章 变 量 141 第十章 变 量 目录 10.1 作用域 10.2 持久性 10.3 赋值时间 10.4 数据结构与控制结构的关系 10.5 变量功能单一性 10.6 全局变量 10.7 小结 相关章节 生成数据:见第 8 章 数据命名:见第 9 章 使用基本数据类型:见第 11 章 使用复杂数据类型:见第 12 章 格式化数据说明:见 18.5 节 说明数据:见 19.5 节 由于前面一章叙述的都是数据名称问题,你可能会认为恰当地命名变量之后便完事大吉了。 绝非如此!命名仅仅是开始,你使用变量的方法也是非常重要的。 如果你是个富有经验的程序员的话,那么本章的内容对你尤其有用。在你完全清楚替代方 案之前,很容易在开始时使用有害的技术。然后,即使在你知道如何避免它们时,出于习惯仍 会继续使用它们。有经验的程序员们将会发现 10.5 节“变量功能单一性”和 10.6 节“全局变 量”的讨论对他们来说是非常有趣的。 10.1 作用域 作用域指的是变量名称的影响范围,也可称之为可见性,即在程序中某一变量被知道和提 及的范围。作用域有限或很小的变量只在程序的一小部分中被知道,如:一个只有在一个小循 环中用到的循环变量。作用域大的变量则在程序中的许多地方被知道,如:在一个程序中被到 处使用的雇员信息表。 不同的语言处理作用域的方式是不同的。在 Basic 某些实现中,所有变量都是全局的。因 此在这种情况下你无法对变量作用域进行任何控制,这也是 Basic 的主要缺点之一。在 C 中, 变量可以是对块(用大括号括起来的部分)可见的,也可以是分别对子程序、源文件或整个程 序可见的。在 Ada 中,变量可以分别是对块、亚程序、包、任务、单元或整个程序中可见的。 以下是一些关于作用域(可见性)的常用准则: 第十章 变 量 142 尽可能减小作用域。你所采取的方法往往取决于你“方便性”和“可管理性”这两个问题 的看法。许多程序员喜欢用全局变量。因为全局变量存取很方便而且程序员们不必围着参数表 和模块命名规则转。事实上,这种存取方便性是与由全局变量引入的危险共存的。 另一些程序员则尽可能使用局部变量,因为局部变量可以提高可管理性。你所隐含的信息 越多,那么需要记在心中的东西就越少,而需要记住的东西越少,那么犯错误的机会也就越少, 因为许多细节都不需要再进行记忆了。 “方便性”和“可管理性”之间的区别可以理解为是强调写程序还是强调读程序。扩大变 量作用域事实上的确可以方便写程序,但是一个任意子程序都可以在任意时刻访问任一个变量 的程序,往往要比使用模块化子程序的程序难懂得多。在这种程序中,你无法单纯理解一个子 程序,你必须同时也理解与这个子程序分享全局变量的其它子程序。这样的程序不仅难读,而 且也很难调试和修改。 因此,你必须尽可能地减小变量的作用域。如果能将变量的作用域限制在一个子程序之内 的话,那是再好不过的了,如果你无法把它限制在一个模块中的话,那就利用存取子程序来使 几个模块分享这一数据。总之,应尽量避免使用全局变量以减小作用域。 把对某一变量的引用集中放置。某些研究人员认为把对某一变量的访问放得越近,那么对 程序阅读者的精神压力也就越小。这一想法有很大的直觉吸引力——你每次只需注意比较少的 变量。以下是由这一想法产生的几项准则: 应恰好在某一循环前初始化循环中用到的变量,而不是在含有这个循环的子程序开头对其 中用到的变量进行初始化。这样作可以使你在修改循环时,同时想起对相应的变量的初始化进 行修改;或者在这一循环外再嵌套一个循环时,此时外部循环每执行一次时,都会对内部循环 用到的变量进行初始化,而不会出现只初始化一次的错误。 要在用到某一变量时才对它进行赋值。你可能有过费尽心机地寻找某一变量到底是在哪被 赋值的体验。因此,越清楚地表示出变量赋值的地方越好。 下例指出了在一个计算日收入的子程序中,是怎样把对同一变量的引用集中放置,以便方 便地寻找它们的。第一个例子是违反这一原则的一个C语言子程序: void SummarizeData(...) { ... ... GetOldData(OldData, &NumOldData); GetNewData(NewData, &NumNewData); TtlOldData = Sum(OldData,NumOldData); TtlNewData = Sum(NewData,NumNewData); 语句使用两组变量 PrintOldDataSummary(OldData,TtlOldData,NumO1dData); PirntNewDataSummary(NewData,TtlOldData,NumNewData); SaveOldDataSummary(TtlOldData,NumOldData); SaveNewDataSummary(TtlNewData,NumNewData); ... ... } 在上例中,你不得不同时注意 OldData、NewData、NumOldData、NumNewData、TtlOldData 第十章 变 量 143 和 TtlNewData 六个变量,而且又是在这样短的一段程序中。下面的例子指出了如何把这一数量 减少到只有三个: void SummariseDaily( ... ) { GetOldData(OldData, &NumOldData); TtlOldData = Sum(OldData, NumOldData); PrintOldDataSummary(OldData,TtlOldData,NumOldData); 使用 OldData 的语句 SaveOldDataSummary (TtlOldDataNumOldData); ... GetNewData( NewData, &NumNewData); TtlNewData = Sum( NewData, NumNewData); PrintNewDataSummary( NewData,TtlNewData,NumNewData); 使用 NewData 的语 SaveNewDataSummary( TtlNewData, NumNewData ); ... } 如果像上例这样把程序分用两块,那么每一块都要比原来的块要短,而且其中的变量也要 少得多。这两个块都很容易理解,而且如果需要把这段程序分成几个子程序的话,两个具有较 少变量的块本身就是定义得很好的子程序。 10.2 持久性 “持久性”指的是某一数据的使用寿命。持久性有几种表现形式。如下所示: · 与某一个块或子程序的生存期一样长。C中的auto变量或Pascal中的局部变量就属 于这种情况。 · 其生存期取决于你的意愿。Pascal中用New()产生的变量直到Dispose()它们时才失效。 在C中用malloc()产生的变量也将持续到free()它们时才失效。 · 与程序的生存期一样长。绝大多数语言中的全局变量都属于这种情况。比如C中的static 变量和 Turbo Pascal中“类型化的常量”(类型化常量是对Pascal的非标准推广)。 · 永远有效。这些变量可能包括你在程序再次执行之间存储在数据库中的变量。例如, 在一个交互式的程序中用户可以定义屏幕的颜色,可以把这些颜色存在一个文件中, 在每次程序加载时再将其调出,现在有少数几种语言支持这种持久性。 假定的某个变量的持久性要长于其实际持久性时,就会出现问题。变量就像放在冰箱中的 牛奶,你认为它可以保存一星期,但有时可以保存一个月,有时则五天就坏了。变量的持久性 也是同样不可测的。当变量的有效生存期已经结束时,还试图重新使用它,它还会保持原值吗? 有些情况下变量中的值已经被改变了。这可以使你意识到自己的错误,而在另一些情况下,计 算机还让变量保持原值,从而让你认为自己正确地使用了变量。 以下是可以使你避免这种错误的几个步骤; · 在程序中加入调试代码来检查变量的值是否合理。如果不合理的话。产生一个警告 第十章 变 量 144 信息来提示检查不恰当的变量初始化。 · 在写代码时假定变量已经失效。比如,退出子程序时某一变量等于某个值,当再次进 入子程序时不要假定这个变量仍保持原值。当然,当你用语言的特定功能来使变量保 持原值时这一原则不适用,比如用 C 中的 static 如来实现这一功能。 · 养成在恰好使用某一变量之前对其进行初始化的习惯。如果发现使用的变量附近没有 对它的初始化,那么你就要小心了。 10.3 赋值时间 一个对程序的维护性和可读性有深远影响的主题是“赋值时间”——把变量的值和变量联 系在一起的时间。是在写程序时把它们联系在一起?还是在编译、加载或者程序运行时把它们 联系在一起? 应该尽可能地晚一些将它们联系在一起。通常,越是晚一些给变量赋值,代码的灵活性便 越大。下面是在可能的最早时间——程序写成时对变量进行赋值例子(用C写成): TestID=47; 由于47是一个程序的常数值,因此在代码写好时47便与TestID联系在一起了。像上例那样 编码赋值的想法是很有害的,因为如果当这里的47改变时,程序其余用到47且必须与TestID的 值相同的地方很可能会出现语法错误。 以下是一个稍晚些赋值的例子,即在编译时进行赋值: #define MAX_ID 47 ... TestID = MAX_ID; MAX_ID是一个宏或是命名常量,当编译时编译程序会用一个值来代替它。如果所用的语 言支持这种用法的话,应尽量这样用,因为这种方法要好于前面的用47来硬性赋值。它使得改 变MAX_ID值变得很容易,因为你只需要在一个地方作出改动就可以了,而且不会影响程序性 能。 下面是在最后时刻赋值的例子,即在运行时赋值。 TestID = MaxID; 程序中MaxID是一个在程序运行时被赋值的变量。这样做的灵活性和可读性也要好于前面 的硬性编译赋值。下面是另一个在运行时赋值的例子: TestID = ReadFileForMaxID(); 上例中的ReadFileForMaxID()是一个在程序运行时从一个文件中读取数值的子程序。这一 例子假定在程序开始运行之前要用到的值已经被放在文件中了。显然这样作的灵活性和可读性 也要好于前述的硬性编码赋值的例子。不必通过改动程序来改变TestID的值,而只要改动一下 存储该值的文件就可以了。这种方法常用于用户可以定义应用环境的交互式系统中,用户的定 义被存储在一个文件中,当程序运行时从文件中读取定义。 下面是在程序运行时进行赋值的最后一种形式: TestID = GetMaxIDFromUser(); 上例中 GetMaxIDFromUser()是一个采用交互方式从用户那里读取数值的子程序。这种方 第十章 变 量 145 法的可读性和灵活性要远远好于硬性编码赋值。为改变TestID值根本不需作出任何改动,只要 在程序运行时由用户输入另外一个值就可以了。从上述在运行时赋值的例子可以看出,即使同 样是在运行时赋值的方式,变量与其值联系到一起的具体时间也是不同的。最后一个例子中的 赋值可以在程序运行中任一时刻进行,它取决于用户被要求输入TestID值的时间。 10.4 数据结构与控制结构的关系 许多研究者都曾试图努力找出数据结构与控制结构之间的通用关系,这其中最成功的是英 国计算机学家 Michael Jackson。Jackson的技术,主要是通过一种系统的方法把数据结构变换为 控制结构。他的方法在欧洲得到了充分发展并被广泛应用。本书无法详细论述Jackson的理论, 但可以对这种理论所基于的数据和控制流之间的调节关系作一概述。 从可用的数据和输出该是什么样子的想法开始 然后对程序进行定义,使其把输入转化为输出 Jackson勾画出了三种类型数据与相应的控制结构之间的关系。 程序中顺序性数据可以转化为顺序性语句。数列是由一组按某一特定顺序使用的数据组成 的。如果用排成一列的五条语句来处理五个不同的数值,那么它们就是顺序性语句,如果需要 从某一文件只读取雇员的名字、社会保险号码、地址、电话号码和年龄等五个数据,那你将在 程序中使用顺序性语句来从文件中读取这些顺序性数据。 程序中的选择性数据可以转换为if和case语句。通常,选择性数据指的是在任一特定时刻, 几个数据中的某一个会出现——将选定其中的某一个数据。相应的程序必须用If-Then-Else语句 或Case语句进行选择操作。比如在一个工资发放系统中,你可能需要按某一雇员是按小时计酬 第十章 变 量 146 顺序性数据是指按某一指定顺序进行处理的数据 还是按固定薪水计酬的对其进行不同的处理。程序中的模式就与数据中的模式相容。 对选择性数据你可以使用两个中的任何一个,但不能同时 程序中的重复性数据可以转化为for、repeat和while循环结构。重复性数据指的是同一类型 要重复几次的数据。通常,这类数据是作为记录存储在文件或数组中的,比如从文件中读取的 一列社会保险号码。重复性数据将与读取它们的循环相容。 重复性数据的操作需要重复 真实的数据可能是上述几种类型数据的组合,这时可以用上述几种操作的组合处理复杂的 数据结构。 10.5 变量功能单一性 可以通过几种“巧妙”的办法使变量具有一个以上的功能,但最好不要使用这种所谓的巧 妙办法。 应使每一个变量只具有一个功能。有时人们会试图在两个不同的地方使用同一变量来进行 两个不同的活动。通常,变量名对其中的一个活动来说是不恰当的,或者在两种情况下都充当 了“临时变量”的角色(且是用无意义的X或Temp来命名的)。下面给出了一个用C语言写成的 一个临时变量具有两个功用的例子: /* Compute roots of a quadratic equation. 第十章 变 量 147 This code assumes that ( b * b - 4 * a * c ) is positive. */ Temp = sqrt(b * b - 4 * a * c); root[0] = (-b + Temp)/(2 * a); root[1] = (-b - Temp)/(2 * a); ... /* swap the roots */ Temp = root[0]; root[0] = root[1]; root[1] = Temp; 这段程序的问题是:头几行代码中的Temp与后几行代码中的Temp是什么关系呢?事实上它 们之间没有任何关系,可是,这样使用它会使人们误以为它们之间存在着某种联系,从而产生 困惑,以下是对上面程序的改进: /* Compute roots of a quadratic equation. This code assumes that (b^2 - 4 * a * c) is positive. */ Discriminant = sqrt(b * b - 4 * a * c); root[0] = (-b + Discriminant)/(2 * a); root[1] = (-b - Discriminant)/(2 * a); ... /* swap the roots */ Oldroot = root[0]; root[0] = root[1]; root[1] = Oldroot; 避免使用具有隐含意义的变量。一个变量具有一个以上功用的另一种情况是同一变量取值 不同时,其代表的意义也不同。如变量PageCount代表是已经打印的页数,但当它等于-l时则表 示发生了错误;变量CustomerID代表的是顾客号码,但当它的值超过500,000时,你要把 CustomerId的值减掉500,00O以得到一个过期未付款的帐号;而BytesWritten一般表示的是某一输 出文件的字节数,但当其值为负时,则表示的是用于输出磁盘驱动器数目。 应避免使用上述具有隐含意义的变量。技术上把这种滥用称之为“混合关联”。使用同一变 量来承担两项任务意味着对其中一项任务来说变量类型是错误的。比如上例中的PageCount,在 正常情况下代表页数时是整型的,而当它等于-1表示发生了错误时,则是把整型变量当作逻辑 型来用了。 即使你清楚这种用法,别人也往往很难理解。如果用两个变量来分别承担两项工作的话, 其意义的清晰性将会使你感到满意,并且,也不会在存储上有额外的麻烦。 保证所有说明的变量。与用一个变量来承担两项工作相反的另一个极端是根本不用它,而 研究表明未被使用的变量往往是与出错率高相联系的。因此,要养成确实用到每一个说明变量 的习惯。某些编译程序会对说明但未用到的变量给出警告。 第十章 变 量 148 10.6 全局变量 全局变量在程序的任何地方都可以进行存取。有时它也被非正式地用来指可存取范围大于 局部变量的变量,如在某个单一文件中可以随处存取的模块变量。但是,在单独一个文件中随 处可存取,本身并不能表示某一变量是全局的。 绝大多数有经验的程序员都认定使用全局变量要比局部变量危险,同时他们也认为利用几 个子程序来存取数据是非常有益的。尽管对使用全局数据的危险性有许多附和的意见,但研究 发现全局变量的使用与错误率上升之间并无联系。 即使使用全局变量是没有危险的,使用它也决非最好的编程方法。本书其余部分将讨论由 此而引入的问题。 10.6.1 伴随全局变量的常见问题 如果不加选择地使用全局变量,或者不使用它们就感到很不方便,那么你很可能还没有充 分意识到信息隐蔽和模块化的好处。模块化和信息隐蔽可能并不是最终真理,但它们对程序的 可读性和维护性的贡献是令其它技术望尘莫及的,一旦你懂得了这一点,你就会使用与全局变 量关系尽可能少的子程序和模块。 对全局数据的疏忽改变。你可以会在某处改变全局变量的值,而在别处又会错误地以为它 仍保持着原值,这就是所谓的副作用,例如在下面的Pascal程序段中,TheAnswer就是个全局变 量: TheAnswer := GetTheAnswer; ——TheAnswer是一个全局变量 OtherAnswer := GetOldAnswer; ——GetOtherAnswer改变了TheAnswer AverageAnswer := (TheAnswer + OtherAnswer )/2; ——AverageAnswer是错误的 你可能以为对GetOtherAnswer的调用并没有改变TheAnswer的值,如果真的是这样的话,那 么第三行中的平均就是错误的。事实上,对GetOtherAnswer的调用,的确改变了TheAnswer的值, 因此程序中有一个需要改正的错误。 伴随全局变量的奇怪的别名问题。“别名”指的是用两个或更多的名称来叫某一变量。当 全局变量被传入子程序,然后又被子程序既用作全局变量又用作参数时,就会产生这种问题。 以下是一个用到全局变量的Pascal子程序: Procedure WriteGlobal (VAR InputVar:Integer); begin Globa1Var := InputVar + 1; Writeln( 'InputVariable:', InputVar); writeln( 'GlobalVariable:', Globe1Var); end; 下面是一个把全局变量当作变元来调用上面子程序的程序: WriteGlobel(GlobalVar); 由于WriteGlobal把InputVar加1后得到了GlobalVar,你会以为GlobalVar要比InputVar大l,但下面 第十章 变 量 149 却是足以令你大吃一惊的运行结果: Input Variable: 2 G1obal Variable: 2 这里令人难以置信的是:事实上GlobalVar和InputVar是同一个变量,由于GlobalVar是通过 调用子程序被传入WriteGlobal()的,因此它被用两个不同的名字提及了,或者说它是被“别名” 了,从而Writeln()的结果与你想要的便是大相径庭的了。虽然你用两个不同的名字来区分全局 变量,但它们还是将同一变量打印了两次。 有全局数据的代码重入问题。通过不止一个控制进入代码,已经变得越来越普遍了。在 Microsoft Windows、Apple Macintosh和OS/2 Presentation Manager及递归子程序中都用到了这种 代码。代码重入使得全局变量不仅可能被子程序们分享,而且可能被同一程序的不同拷贝所分 享。在这种环境下,你必须保证即使在某一程序的多个拷贝都在运行的情况下,全局数据仍保 持着它原来的意义。这是一个很有意义的问题,你可以根据后面推荐的技术来避免这一问题。 全局数据妨碍重新使用的代码。为了从另一个程序中借用某段代码,首先你要从这个程序 中把这段代码取出来,然后把它插入要借用它的程序中。理想的情况是你只需把要用的模块或 单个子程序拿出来放入另一个程序中就可以了。 但全局变量的引入则使这一过程变得复杂化了。如果你要借用的模块或子程序使用了全局 变量,你就不能简单地把它拿出来再放入另一个新程序了。这时你必须对新程序或旧的代码进 行修改以使得它们是相容的。如果想走捷径的话,那最好对旧代码进行修改,使其不再使用全 局数据。这样作的好处是下次再要借用这段代码时就非常方便了。如果不这样作的话,那你就 需要改动新程序,在其中建立旧有的模块或子程序要用到的全局数据。这时全局变量就像病毒 一样,不仅感染了旧程序,而且随着被借用的旧程序中的模块或子程序传播到了新程序中。 全局变量会损害模块性和可管理性。开发大于几百行规模软件的一个主要问题便是管理的 复杂性,使其成这可管理的唯一办法便是将程序成为几个部分,以便每次只考虑其中的一个部 分。模块化便是将程序分为几部分的最有力工具之一。 但是全局数据却降低了你进行模块化的能力。如果使用了全局数据,不能做到每次只集中 精力考虑一个子程序吗?当然不能,这时你将不得不同时考虑与它使用了相同全局数据的其余所 有子程序,尽管全局数据并没有摧毁程序的模块性,使它减弱了模块性,仅凭这一点就该避免 使用它。 10.6.2 使用全局数据的理由 在某些情况下全局数据也是很有用的: 保存全局数值,有时候需要在整个程序中都要用到某些数据。这些数据可能是反映程序状 态的——在交互式状态时还是批处理状态?是正常状态还是错误修正状态?它们也可能是在整 个程序中都要用到的信息,如程序中每一个子程序都要用到的一个数据表。 代替命名常量。尽管C、Pascal等绝大多数现代语言都支持命名常量,但仍有一些语言不支 持,这时,可以用全局变量来代替命名常量。例如,你可以通过分别给TRUE和FALSE赋值”1” 和”0”采用它们代替常量型值1和0,或者通过LinesPerPage = 66这一语句把每页行数(66行) 赋给LinesPerPage,从而用LinesPerPage来取代66。通过使用这种方法,可以改善代码的可读性和 第十章 变 量 150 易改动性。 方便常用数据的使用。有时候需要非常频繁地使用某一个变量,以至于它几乎出现在每一 个子程序的参数表中,与其在每个子程序的参数表中都将这个变量写一次,倒不如使它成为全 局变量方便。在这种情况下,这一变量几乎是到处都被存取的,不过,很少有这种情况,更多 的情况是它是被一组有限的子程序存取的,这时你可以将这些子程序及数据装入一个模块,这 将在稍后详细讨论。 消除“穿梭”数据。有时候把某个数据传入一个子程序中仅仅是为了使它可以把这一数据 传入另一个子程序中,当这个传递链中的某个子程序并没有用到这个数据时,这个数据就被叫 做“穿梭数据”。使用全局变量可以消除这一现象。 10.6.3 怎样降低使用全局数据的危险 你可能会认为下面这些准则让人感到很不自由也很不舒服,或许的确是这样,但我想你一 定知道“良药苦口利于病”这句话吧? 先使所有变量都成为局部的,然后再根据需要把其中某一些改为全局变量。首先使所有变 量针对单个子程序来说都是局部的。如果发现还需要在别的地方使用它,那么在使它成为全局 变量之前应先使它成为模块变量。如果最后发现必须使它成为全局变量,你可以这样作,但必 须确认这是迫不得已的。如果开始就使某一变量成为全局的,那么你决不会再把它变成局部的, 而很可能如果开始把它用成局部的话,你永远也不会再把它变为全局的。 区分全局和模块变量。如果某些变量是在整个程序中存取的,那么它们就是真正的全局变 量。而某些变量只在一组子程序中存取,事实上是模块变量。在指定的那组子程序中,对模块 进行任何存取操作都是可以的,如果其它子程序要使用它的话,应通过存取子程序来进行。即 使是程序语言允许,也不要像对待全局变量那样对模块变量进行存取操作。要在自己的耳边不 停地说“模块化!模块化!模块化!”。 建立使你一眼即可识别出全局变量的命名约定。如果使用全局变量的操作是十分明显的, 可以避免许多错误。如果不只是出于一个目的使用全局变量(如既作为变量又用替换命名常量), 那一定要保证命名约定可以区分这些不同目的。 建立一个清楚标出所有全局变量的注释表。建立标识全局变量的命名约定,对表明变量的 功用是非常有帮助的,而一个标有所有全局变量的注释表则是读你程序的人最强有力的辅助具 之一(Glass 1982)。 如果你用的是 Fortran 语言,那么仅使用标号 COMMON 语句,不要使用空白 COMMON。 空白 COMMON 可以使任意一个子程序存取任意一个变量。使用命名的 COMMON 来详细规定 可以存取特定 COMMON 数据的特定子程序,这是一种在 Fortran 中模拟使用模块数据的方法。 研究表明这种方法是很有效的。 使用加锁技术来控制对全局变量的存取。与多用户数据库环境下,当前值的控制方式类似, 在全局变量被使用或更新之前锁定要求,这个变量必须被“登记借出”,在变量被使用过之后, 再被“登记归还”,在它不能被使用期间(已经被登记借出),如果程序其它部分企图要求将它 登记借出,那么加锁/开锁子程序将打印出错误信息。 加锁技术在开发阶段是有用的。当程序最终成为产品时,程序应该被改动来做比打印更有 意义的工作,使用存取子程序来存取全局变量的好处之一,就是使你可以方便地实现加锁/开 第十章 变 量 151 锁技术,而如果不加限制地存取全局变量的话,就很难实现这一技术。 不要通过把数据放人庞大的变量,同时又到处传递它来掩盖你使用了全局变量的事实。把 什么都放入一个巨大的结构,可能从字面上满足了避免使用全局变量这准则,但这只是表面文 章,事实上这样做,得不到任何真正模块化的好处,如果你使用了全局变量的话,那就分开使 用它,不要企图用臃肿的数据结构来掩盖它。 10.6.4 用存取子程序来代替全局数据 用全局数据能作的一切,都可以通过使用存取子程序来做得更好,存取子程序是建立在抽 象数据类型和信息隐蔽的基础上的。即使不愿使用纯粹的抽象数据类型,仍然可以通过使用存 取子程序来对数据进行集中控制并减少因改动对程序的影响。 存取子程序的优点 以下是使用存取子程序的优点: · 可以对数据进行集中控制。如果你以后又找到了更合适的数据结构,那么不必在每一处涉 及到数据的地方都进行修改,而只修改存取子程序就可以了,修改的影响可以被限制在存 取子程序内部。 · 可以把所有对数据的引用分隔开来,从而防止因其错误造成的影响蔓延。使用类似 Stack.array[stack.top]new_element 的语句来把元素压入堆栈时,很容易忘记检查堆栈是否溢 出从而造成错误。如果使用存取子程序,例如 push stack( new_element ),就可以把检查堆 栈是否溢出的代码写入 push_stack()子程序,这样每次调用子程序都可以对堆栈自动进行检 查,而你则可以不必再考虑堆栈溢出问题。 · 你可以自动获得信息隐蔽带来的好处。即使你不是为了信息隐蔽才使用存取子程序的,它 也是信息隐蔽的范例性技术之一。你可以改变存取子程序的内部而不必改动程序的其余部 分。打个比方,存取子程序使你可以改变房屋的内部陈设而不会变动房屋的外观,这样你 仍然可以很容易便找着你的家。 · 存取子程序很容易转换为抽象数据类型。存取子程序的一个优点是:它可以得到很高的抽 象级,而直接存取全局变量却难以做到。例如,你可以用存取子程序 ifPageFull()来代替 语句 if lineCount > Maxlines,虽然这是个很小的收益,但是大量的这类差别便积聚成了高 质量软件与东拼西凑到一起的软件之间的不同之处。 怎样使用存取子程序 以下是关于存取子程序理论与应用简短论述:将数据隐含在模块中,编写可以使你访问并 修改数据的子程序,数据所在模块之外的子程序要求存取数据时,应让它通过存取子程序而不 直接地存取模块内的数据。比如,假设有一个状态变量你可以通过两个存取子程序 Getstatus() 和 SetStatus()来对其进行存取操作。 以下是关于存取子程序使用的几条较为详细的准则: 要求所有子程序来对数据进行存取操作。通过存取子程序将数据结构隐含起来。通常需要 两个子程序,一个读取数据的值,而另一个用于赋给它新值。除去几个可以直接存取数据的服 务性子程序,其它子程序都应通过存取子程序接口来对数据进行存取。 第十章 变 量 152 不要把所有的全局数据都放入同一个模块中。如果你把所有的全局数据都归成一个大堆, 并编写对其存取的子程序,的确可以消除由全局数据带来的问题,但同时也抛掉了信息隐蔽和 抽象数据类型所带来的优点。编写存取子程序之前,应先考虑一下每一全局数据应属于哪一个 模块,然后把这个全局数据、相应的存取子程序和其关联的子程序放入那个模块中。 在存取子程序中建立某种程度的抽象。在数据所代表的意义层次上而不是计算机本身的实 现细节层次上建立存取子程序,可以使你更容易应付可能的变动。 请比较下列两组语句: 直接使用复杂数据 通过存取子程序使用复杂数据 Node = node.next Node = nearestNeighbor(node) Node = node.next Node = nextEmployee(node) Node = node.next Node = nextRatele(node) Event = EventQueue[QueueFront] Event = HighestprioirtyEvent( ) Event = EventQueue[QueueFront] Event = LowesPriotityEvent( ) 表中前三个语句对中,抽象的存取子程序告诉你的信息要比数据结构所告诉你的多得多。 如果直接使用数据结构,那么一次需要做工作就太多了。你必须在表示出数据结构本身正在做 什么(移到链表中的下一个链)的同时,表示出正在对数据结构所代表的实体做什么(调用一 个邻居、下一个雇员或税率),这对于简单数据结构来说是很重的负担。把信息隐蔽在存取子程 序后面,可以使代码自己指出这些,并且可以使得其它人从问题域而不是实现细节的层次上来 阅读程序。 把对数据的所有存取保持在同一抽象水平上。如果你用了某一存取子程序对某一数据进行 了一项操作,那么对这一数据的其它操作也应通过存取子程序来实现。比如若是通过存取子程 序来从数据结构中读取数值的,那么对该数据结构的写操作也应通过存取子程序来实现。又比 如假设你通过调用 initstack()子程序将元素压入堆栈,但你接着又用 value=array(strack.top)来 获得堆栈的一个入口,那么此时对数据的观察点便不连续了。这种不连续性使别人很难理解你 的程序。因此,要保持对数据所有存取操作抽象水平的一致性。 在上表中的后两个语句对中,两个事件排队的操作是平行进行的。在队列中插入一个事件 将比表中其它操作都更复杂,因为你不得不改变队列的前后顺序,调整现存事件以便为它腾出 空间,再写几行代码以便找到插入它的地方等等,从一个序列中移出一个事件几乎是同样麻烦 的。因此,在编码时如果把复杂操作放入子程序,而其余操作直接对数据进行,将产生对数据 结构拙劣的、非平等的使用。请比较下面的两组语句: 对复杂数据的非平行使用 对复杂数据的平行使用 Event = EventQueue[QueueFront] Event = HightestPriorityEvent( ) Event = EventQueue[QueueBack] Event = LowestPriorityEvent( ) AddEvent(Event) AddEvent(Event) EventCount = EventCount-1 RemoveEvent(Event) 应注意这些准则适用许多模块和子程序构成的大型程序。在小一些的程序中,存取子程序 第十章 变 量 153 的地位也会相应降低。但不管怎样,实践已经证明,存取子程序是增强程序灵活性并避免由全 局变量带来问题的有效手段之一。 10.6.5 检查表 使用数据时通常要考虑的一些问题 一般数据 · 是否已经使变量的作用域尽可能地小? · 是否把对变量的使用集中到了一起? · 控制结构与数据结构是相对应的吗? · 每个变量是否有且仅有一个功能? · 每个变量的含义都是明确的吗?是否保证了每个变量都没有隐含的意义? · 每一个说明过的变量都被用到了吗? 全局变量 · 是否是在迫不得已的情况下,才使某些变量成为全局的? · 命名约定是否对局部、模块和全局变量进行了区分? · 是否说明了所有全局变量? · 程序中是否不含有伪全局变量——传往各个子程序的庞大而臃肿的数据结构? · 是否用存取子程序来代替了全局数据? · 是把存取子程序和数据组织成模块而不是随意归成一堆的吗? · 存取子程序的抽象层次是否超过了程序语言实现细节? · 所有相互有联系的存取子程序,其抽象程度都是一致的吗? 10.7 小 结 · 尽量减小变量的作用域。把对变量引用集中到一起,应尽量使变量成为局部或模块的, 避免使用全局变量。 · 使每个变量有且仅有一个功能。 · 并不是因为全局数据危险才避免使用它们,之所以避免用它是因为可以用更好的技术 来代替它。 · 如果全局数据确实不可避免的话,应通过存取子程序来对其进行存取操作。存取子程 序不仅具备全局变量和全部功能,而且可以提供更多的功能。 第十一章 基本数据类型 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 常 数 以下是一些可以减少数据使用错误的措施: 避免"奇异数"(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 后面的是入口最大数,而无须猜测,即使你可以绝对保证不改 变某个数,那么使用命名常量来代替它也可以提高可读性。 在需要时可以使用常数“0”或“1”。"0"或"1"往往被用来增加或减少循环变量的值,也 用于数组的第一个元素下标或循环变量的初始值。如: for i=0 to CONSTANT 其中的"0"和 Total = Total + 1 中的"1"都是允许的。在程序中是允许"0"或"1"作为常数出现的,但其它数值则不允许以 这种方式出现。 采取预防被"0"除的措施。每当你在程序中使用了除号(大多数语言中都用"/"表示)时, 都要考虑除数是否有可能是零。如果存在这种可能性的话,应加入防止被“0”除的代码。 明显进行类型转换。当程序中有不同类型间的转换时,要确保这种转换是明显的。在 Pascal 中,你可以用: x := a + float(i) 在 C 中,可以用: x = a + (float)i 这样作也可以保证类型转换确实是按照你的意愿进行的。不同编译程序的类型转换是不 同的,如果不这样作的话就有出错的危险。 避免混合类型比较。如果 x 是浮点数而 i 是一个整型数,那么以下检验: if ( i=x ) then ⋯⋯ 几乎可以认定是不会起作用的。在编译程序确定出比较要用的类型,把一种类型转换为另 一种、进行一连串的访问并最终确定答案之后,你的程序要是仍在运行的话,那真可说是“瞎猫 遇上死耗子了”。应该通过人工转换来使编译程序可以对同一类型的数进行转换,以便你可以 确切知道正在被比较的是什么。 注意编译程序的警告。许多先进的编译程序都会对在同一表达中使用不同数值类型进行 警告。应注意这些警告!几乎每一个程序员都有过帮助别人去寻找某个麻烦的错误,而最终却 往往发现编译程序早已对此提出过警告的经历。优秀的程序员总是力争消除所有的编译程序 警告信息。让编译程序去查错毕竟比自己干容易得多。 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 浮点数 使用浮点数时要考虑的主要问题是许多十进制的分数不能用浮点数精确地表示出来,像 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)Next = CrntNode->Next; InsertNode->Previous = CrntNode; if ( CrntNode->Next != NULL ) { 第十一章 基本数据类型 170 CrntNod->Next->Previous = InsertNode; ──这行是难以理解的 } CrntNode->Next = InsertNode; } 这是一段向链表中插入结点的传统程序,事实上它完全不必这样难以理解。插入一个新结 点需要引入三个对象:当前结点、当前结点的下一个结点、等待被插入的结点。而上面的程序 只是清楚地表示出了两个结点——InsertNode 和 CrntNode(插入结点与当前结点)。这迫使你 要看出并且记住 CrntNode->Next 结点也包括在这一插入过程中,如果不用当前结点之后的结 点而要表示出正在发生了什么的话,那么你作出的图应该是这样的: CrntNode InsertNode 而更清楚的应该是像下图这样同时表示出三个结点的: StartNode NewMiddleNode FollowingNode 下面是明确表示出引入了三个对象的 C 语言程序: void insert_link ( NODE * StartNode, NODE * NewMiddleNode ) { NODE * FollowingNode; /* insert "NewMiddleNode" between "StartNode" and "FollowingNode */ FollowingNode = StartNode->Next; NewMiddleNode->Next = FollowingNode; NewMiddleNode->Previous = StartNode; if ( FollowingNode != NULL ) { FollowingNode->Previous = NewMiddleNode; } StartNode->Next = NewMiddleNode; } 这一段程序加了一行代码,但是没有了前一段例程中的 CrntNote->Next->Previous,从而 变得容易理解了。 简化复杂的指针表达式。复杂的指针表达式是非常难读的。试想一下,有几个人会读懂含 有像 P.q.r.s.^.data 或 p->q->r->S.data 之类东西的表达式呢?下面是一个尤其难读的 C 语 言代码段: 第十一章 基本数据类型 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 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( )计算出来的值。它所需要的维护工作 也是很少的,因为你可以改变你已经定义的类型,并且分配工作将是自动进行的。 第十一章 基本数据类型 174 11.9.4 检查表 基本数据 常数 • 代码中是否避免了”奇异数”(常数?) • 程序中是否采取了措施来防止出现被“0”除错误? • 类型转换是显式进行的吗? • 如果在同一个表达式中出现了两种不同类型的变量,是否对表达式按照你的意愿进行 求值? • 程序中是否避免了混合类型比较? • 在编译过程中是没有警告的吗? 整型数 • 使用整型数相除表达式的结果是否与预期的一致? • 整型数表达式中是否避免了整型数溢出问题? 浮点数 • 是否避免了数量级相差过大的数之间加减运算? • 程序中是否系统地采取措施来防止舍入误差问题? • 程序中是否避免了对浮点数进行相等比较? 字符和字符串 • 程序中是否避免了常数型字符和字符串? • 对字符串的引用是否避免了边界错误? • 若是用 C 写成的程序,是否是把字符数组和字符串指针区别对待的? • 若程序是用 C 写成的,是否遵守了把字符串长度说明为 CONSTANT+1这一约定? • 是否用字符数组代替指针? • 在 C 语言程序中,是否把字符由初始化成了 NULL 以避免出现无限长字符串? • 在C语言程序中,是否用strncpy()代替了strcpy()?并且用了strncat()和strncmp()? 逻辑变量 • 程序中是否使用了附加的逻辑变量来说明条件判断? • 程序中是否使用了附加的逻辑变量来简化条件判断? 枚举类型 • 程序中是否用枚举类型代替了命名常量来改善可读性、可靠性和易改动性? • 是否用了枚举类型代替逻辑变量以改进可读性和灵活性? • 在使用了枚举类型的判断中是否检查了无效值? • 枚举类型的第一个入口是否是保留为无效的? 命名常量 • 在数据说明中使用的是命名常量吗? • 是否一致地使用了命名常量,而不是一会儿使用命名常量,一会儿使用数值? 数组 • 是否所有的下标都在数组界限之内? 第十一章 基本数据类型 175 • 是否对数组所有的引用都没有发生越界错误? • 多维数组的下标排列顺序正确吗? • 在嵌套循环中,作为循环变量的数组下标是正确的吗?是否出现了交叉错误? 指针 • 是否把指针操作独立在函数中? • 指针引用是有效的吗?是否误用了悬挂指针? • 程序中在使用指针之前,是否对它进行了检查? • 在使用由指针指向的变量之前,是否对其有效性进行了检查? • 在释放指针之后,是否把它们的值赋成了 NULL 或 NIL? • 为了提高可读性,程序中是否使用了所有需要用的指针变量? • 链表中的指针是否是按正确的顺序释放的? • 程序中是否分配了备用内存空间以作为内存溢出时拯救数据和工作努力的降落伞? • 是否是在万不得已时才使用指针的? 11.10 小 结 使用各种特定的数据类型意味着需要记住许多种规则。因此要用上面的检查表来确认你 已考虑过了所有常见问题。 第十二章 复杂数据类型 176 第十二章 复杂数据类型 目录 12.1 记录与结构 12.2 表驱动方法 12.3 抽象数据类型(ADTs) 12.4 小结 相关章节 基本数据类型:见第 11 章 信息隐蔽:见 6.2 节 本章将要讨论的是自己建立的数据类型。在第十一章所叙述过的基本数据类型是必 不可 少的。当以它们为基础来建立高层次结构时,才打开了通往有效使用数据的大门。 如果你对高级数据结构熟悉的话,你可能会对本章的某些内容已经知道了,你可以跳过 12.1 节。浏览一下“灵活信息格式化举例”和 12.3 节,在 12.3 节你将发现在其它数据结构课本中所 没有的观点。 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 OldEmploye = NewEmployee NewEmployee = PtevOldEmployee 如果你想在其中加入一个域,如 NumWithholdings,那你只要把它加入类型说明就可以了, 而不用对程序其余部分作任何改动。所有的标准 Pascal、C 语言都有类似能力。 使用结构化数据来简化参数表。可以通过使用结构化数据来简化子程序参数表。这一技术 与刚才展示的技术是基本类似的。你可以把相联系的数据组成一个数据结构,然后把整个数据 结构进行传递,从而省去了一个个地传递每一个要传递元素的麻烦,下面是用 Basic 写成的未 使用数据结构调用子程序的例子: CALL HardWayRoutine( Name, Address, Phone, SSN, Sex,Salary ) 下面是用数据结构简化了参数表的子程序调用例子; CALL EasyWayRoutine( Employee ) 如果想在第一种调用中(未使用数据结构)加入 NumWithholdings,你就不得不在整个程序中 找出并修改每一个对 HardWayRoutine()的调用,而如果在第二种调用中想在 Employee 中加入 N umWithholdings,则只需改动类型说明而根本不必改动 EasyWayRoutine()。 你也可以用极端的方法来使用这技术,把程序中的所有变量都放入一个巨大的、臃肿的结 第十二章 复杂数据类型 179 构中,然后把它在子程序间进行传递。细心的程序员都会避免超出逻辑需要地把数据捆在一起 。而且,当结构中只有一或两个域被用到时,往往是传递这一两个用到特定的域而不是传递整 个结构。这也是信息隐蔽的一种形式;某些信息被隐含在子程序中,而有些则要对子程序是隐 含的。信息是在需要知道的基础上进行传递的。 使用结构化数据来降低维护工作量。因为在使用数据结构类型时你把相关数据都组织在一 起,因此在改变数据结构时只需在程序中作出少数改动就可以了。对于与被改动的数据结构没 有逻辑联系的代码段来说更是这样。由于改动往往容易产生错误,因此较少的改动就意味着更 少的错误。如果你的 EmployeeRec 结构中有一个 Title 域要被删掉,那么你不必改动任何用到整 个记录的参数表或赋值语句。当然,你将不得不改动特别处理 Title 的代码,因为它们与被删掉 的 Title 域有概念上的联系。因而不能被忽略掉。 由使用结构化数据带来的优势主要体现在与 Title 域没有逻辑联系的代码段中,有时,程序 中含有指向数据集合全体而不是其中某一部分的代码段。在这种情况下,个别结构要素如 Title 之所以被引用,仅仅是因为它们是数据集合的一部分,这些代码段与 Title 域没有任何特别的逻 辑联系,因此,可以在变动 Title 时被忽略掉(当使用数据结构时),因为它是把数据结构当作整 体而不是一个个的元素来对待的。 12.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.1 表驱动方法的通常问题 使用表驱动方法时,将不得不说明两个问题: 首先,你不得不说明如何寻找表中的入口,你可以使用某些数据来直接存取表。比如,你 需要按月份把数据进行排序,那么进入月份表是非常容易的,你可以使用以 1 到 12 为下标的数 组来实现它。 而若用其它数据来直接查找表的入口则显得有些力不从心了,比如需要把社会保险号码 进行排序时,就不能用社会保险号来直接进入表,除非你把 999,999,999 个入口全部存在表中,这 时你将被迫采用比较复杂的方法。下面是查找表的入口的几种方法: · 直接存取 · 变址存取 · 阶梯存取 后面将对上述每种方法都进行详细的论述。 在使用表驱动方法时需要说明的另一个问题是,你将在表中存储些什么。在某些情况下, 表查寻的结果是数据。如果是这种情况,你可以把数据存储在表中;在其它情况下,表查寻的 结果是动作。在这种情况下,你可以把描述这一动作的代码存储在表中。在某些语言中,也可以 把实现这一动作的子程序的调用存储在表中。但不论是哪种情况,表都已经变得很复杂了。 12.2.2 直接存取 与其它查寻表一样,直接存取表是用来代替比它更复杂的逻辑控制结构的,之所以称其为 “直接存取”是因为用这种方法时,你不必为了找到你想要的信息而在表中绕来绕去。 正如图 12-l 所表示的那样,你可以直接找出你想要的入口。 图 12-1 直接存取 示例 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 语句的数据来直接存取一个表。 示例 2:保险费用 假设你要编写一个计算医疗保险费用的程序,其中保险费用是随着性别、年龄、婚姻状况 和是否吸烟而变化的。如果你用逻辑控制结构做这些工作的话,它应该是与下面这个 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: 灵活信息格式 你还可以用表来描述由于变化太快而无法用代码来描述的逻辑,通过上面的几个例子,我们 已经知道某些问题是可以用 if 语句来实现的,虽然有时这种方法显得很拙劣,但毕竟是可用的。 然而在某些情况下,有些非常复杂的数据是无法用 if 语句来描述的。 如果你认为对直接表存取方法已经很熟悉了的话,可以跳过下一个例子,因为它只是比前几 个例子稍微复杂一些。 假设你要编写一个打印存储在某一文件中信息的子程序。文件中通常含有 500 条信息,信息 共有大约 20 种。这些信息来自于一个给出自身位置和水温的浮标。 每一条信息都有几个域,其开头都是一个表示该信息种类的识别标志。图 12-2 表示了信息 的存储方式: 图 12-2 无特定次序存储的信息,其中每个信息有一个信息识别标志 由于信息格式是由用户决定的,因而是反复无常的,你也不能寄希望于用户来把格式稳定 下来。图 12-3 表示出了几条详细的信息。 如果你用的是逻辑控制方法,那么你就不得不读取每一条信息,检查它的识别标志, 然后 调用相应子程序来读取,解释和打印该种信息。 如果共有 20 种信息的话, 那么你就需要设计 第十二章 复杂数据类型 184 20 种子程序,同时还要有许多低层次子程序来支持它们。比如,你将不得不用 PrintBuoyTemparatureMessage()子程序来专门打印浮标温度信息。 图 12-3 各种信息格式 当任何信息格式变动时,你都不得不改动打印该种信息子程序的内部逻辑。在上图表示的信 息细节中,如果平均温度域从浮点型变成其它类型,那么你就不得不改动 PrintBuoyTemperatureMessage()中的逻辑结构。 而如果用表驱动方法的话,就可以把每种信息的格式放在一个表中而不必把它们硬性编码 在程序逻辑结构中,这使得编程和修改都变得很容易,而且使用的代码也要少得多。要使用这种 方法,你首先必须列出信息的种类和域的类型。在 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 ; 第十二章 复杂数据类型 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 和其它等号右边的标识符都是 HandleFieldProc 类型的过程名称。把过程名称赋给由过程变量组成的数组中的元素,意味着你可 以通过引用数组元素来调用过程,而不必直接使用过程名称来调用它们。 当过程表建立好之后,你只需访问过程表并调用表中的某个过程便可以处理信息中一个 域。具体代码是这样的: 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 岁之间的所有年龄都变成一个标志,而把 6 5 岁以上的所有年龄变成另外一个标志。用 min()和 max()函数就可以完成这项工作,比如可以 用如下的表达式: max( min(66,Age), 17) 使用转换函数要求,你对将被用作标志的数据模式有清楚的了解,而且这种方法也不总是 如上例那样简单用 min()和 max()就可以完成的。假设上例中的保险费用是每 5 岁为一个段而不 是以每一岁为一个段的,除非你把所有的保险费用都复制 5 次,否则的话你必须找到一个可以 使年龄被 5 整除并且可以调用 min()和 max()的函数才能完成转换,而这却不是件容易的事。 第十二章 复杂数据类型 189 把标志转换独立在函数中。当你使用变换数据的方法来建立直接存取标志时,应把对数据 的转换操作放在函数中,函数的使用消除了在不同地方使用不同转换方法的可能性,当需要对 变换作改动时也非常容易。同时,要恰当地命名转换函数以清楚地说明使用函数的目的,比如 GetKeyFromAge()就是一个很好的名称。 还要把转换函数放入含有表定义和其它与表有关子程序的模块中。当转换函数与数据表距 离比较近时,如果要改动数据表的活,也比较容易想起改动转换函数。 12.2.3 变址存取 有时依靠简单的数字变换还不足把数据转换为直接存取标志。这时可以用变址存取方法解 决某些问题。 当使用变址时,首先用基本数据在变址表中找到一个标志,然后利用这个标志查找你感兴 趣的主要数据。 假设你在考虑一个存货问题, 并且其中有一张含有 100 项的清单, 每一项都含有一个在 0000 到 9999 之间的四位数。在这种情况下,如果你想用四位数作为标志来直接进入一个描述 了某一项某些方面的表,就要先建立一个有 10,000 个入口的变址数组。这一个数组中除了那些 与上述 100 个 4 位数相应的入口外,其余入口都是空的。如图 12-4 所表示的那样,这些入口指 向一个描述了清单中的项,但入口数要远远小于 10,000 的表。 图 12-4 变址存取 变址存取方法主要有三个优点。首先,如果主查寻表中每一个入口占用的空间都很大的话, 那么建立一个有许多空入口的变址表所浪费的空间要比建立有许多空入口的主查寻表浪费的空 间少得多。比如上例中如果主查寻表每个入口占用 10 个字书,而变址表每个入口占用 5 个字节 的话,就可以节约 (10-5)*9900=49500 个字节。 第二个优点是即使使用变址表没有节约空间, 对变址表中的入口进行操作也要比对主查 第十二章 复杂数据类型 190 寻表口进行操作容易得多。比如,假设有一个含有雇员姓名、雇佣日期和工资等信息的表,你 可以建立一个通过姓名来查寻主表的变址表,还可以建立一个工资查寻主表的变址表,而且你 也可以建立通过雇佣日期查寻主表的第三个变址表。 使用变址存取的第三个优点是表驱动方法所共有的良好维护性。存在表中的数据维护起来 要比淹没在代码中的数据容易得多。为了增强灵活性,可以把变址存取代码放在子程序中,当 你需要由数据得到一个直接存取标志时再调用这个子程序,当需要改动表时,对子程序中的变 址存取代码进行修改要比对散布在程序中的同样代码进行修改容易得多。 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 的直 接存取标志。这对变址存取法来说也是比较困难的,因为上面的数都是浮点型的。或许你会考 虑把浮点型转化为整型,就这个问题而言,这样作是可行的。不过为了说明阶梯法,我们把解 决方案限制为只能用浮点型数。 图 12-5 阶梯活确定入口 使用阶梯法时,首先把每个范围的上界放入表中,然后用一个循环来查找超过每一个范围 上界的分数。当你找到某一分数第一次超过某一范围上界的地方时,也就知道了该分数应属于 哪一级。使用这一技术时,必须仔细而且恰当地处理每一个范围的边界。下面是一段给一组学 生的分数分级的 Basic 程序; 'set up data for grading table 第十二章 复杂数据类型 191 RangeLimit(1) = 50.0 Grade(1) = 'F' RangeLimit(2) = 65.0 Grade(2) = 'D' RangeLimit(3) = 77.5 Grade(3) = 'C' RangeLimit(4)= 90.0 Grade(4)= 'B' RangeLimit(5)= 100.0——这是范围边界 Grade(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 GradeLevel = 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.2.5 其它的表查寻的例子 在本书的其它章节中出现了一些表查寻的例子。在那些地方使用它们的目的是为了强调其 它观点而不是为了强调表查寻本身,下面是它们所在的章节: · 在保险表中查寻保险费用:见 15.3 节 · 在表查寻中查找的代价:见 28.3 节 · 逻辑变量的值(A 或 B或 C):见 29.2 节 · 贷款偿还表中的计算:见 29. 4 节 12.3 抽象数据类型(ADTS) 抽象数据类型是由数据及对数据的操作组成的。这些操作既向程序的其余部分描述了数 据,也允许程序其余部分改变它所描述的数据。在“抽象数据类型”中的数据指的是广义的数 据。抽象数据类型可能是图形窗口及影响该窗口的操作,也可能是一个文件及对文件的操作。 第十二章 复杂数据类型 193 甚至还可能是一个保险费用表及对该表的操作。 抽象数据类型通常是作为模块来实现的。模块和抽象数据类型都是数据以及对数据进行的 操作的组合。要想详细了解模块的问题,请参阅第六章。 通常关于编程的课本在讲到抽象数据类型时往往会让人感到味同嚼蜡。他们总是喜欢这样 说:“你可以把抽象数据类型当作一个有许多操作来定义的数学模型。”接着,便是一些令人厌 烦的关于如何编写存取堆栈、队列或表的子程序的内容。这些书总是使人感到自己永远也不可 能方便地使用抽象数据类型。 这些关于抽象数据类型的干巴巴的解释根本没有说到点子上。抽象数据类型是应该令人激 动的,因为你用它们来解决的是客观世界中的实体问题,而不是计算机中的实体问题,你可以 向表格中加入一个空格,可以向窗口类型表中加入一个新的类型,也可以在特快列车上加挂一 节车厢而不是向链表结构中加入一个结点。不要低估了应在问题域而不是在计算机域中工作这 一准则的威力。这并不是说你不应该用抽象数据类型来实现堆栈、队列和表。你应该这样做。 但是抽象数据在客观世界问题中的威力要比在课本中讲的要强大得多。 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 使用抽象数据类型的好处 这并不是说上面的编程方法是不好的。但是我们可以用更好的编程方法来代替它,从而获 得很多收益,何乐而不为呢? 下面就是使用抽象数据类型的一些优点: 第十二章 复杂数据类型 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 关于抽象数据类型的其它例子 下面是关于抽象数据类型更多的例子: 假设你正在编写一个控制核反应堆冷却系统的软件,那么你可以通过定义下述操作而把 冷却系统处理成抽象数据类型: GetReactoTemperature() SetCirculationRate(Rate) OpenValue(ValueNumber) CloseValue(ValueNumber) 特定的环境将决定实现这些操作的具体代码,程序的其余部分将通过这些函数来处理这个 冷却系统,而不必担心数据结构实现细节、数据结构限制、改动等问题。 下面是关于抽象数据类型及对它的可能操作的例子: 表: 灯: 初始化表 开灯 在表中加入项 关灯 从表中去掉项 从表中读下一项 文件: 堆栈: 打开文件 初始化堆栈 读取文件 把项压入堆栈 写文件 从堆钱中弹出项 设置当前位置 读取栈顶 关闭文件 通过研究上述这些例子,我们可以得出几条准则: 把典型的计算机专业数据结构建为抽象数据类型。绝大多数关于抽象数据类型的讨论都集 中在把典型的计算机专业数据结构建为抽象数据类型。正如你从上面的例子中发现的,你可以 用抽象数据类型来代表堆栈、表等数据结构。 你需要提出疑问的是这些堆栈、表所代表的到底是什么?比如,如果堆栈所代表的是一组 雇员,那么应把抽象数据类型当作雇员而不是堆栈来处理。又比如假设表所代表的是一组帐单, 那么应把 ADT 作为帐单而不是作为表来对待。总之,应从尽可能高的抽象水平上来处理问题。 把常见的目标如文件等处理为抽象数据类型(ADT)。 绝大多数语言中都含有一些你可能 非常熟悉的抽象数据类型,只不过你没有意识到罢了。 文件操作就是一个很好的例子。当向磁 盘写文件时,操作系统会把读/写磁头放置在一个特定的物理地址上, 而在你放弃一个旧文件 时,又会分配一个新的磁盘扇区并查找交叉代码。 操作系统提供了一个低层次的抽象, 并提 供了这个层次的抽象数据类型, 高级语言将可能提供稍高一些的抽象出乎并可以提供更高层次 第十二章 复杂数据类型 196 的抽象数据类型。高级语言可以使你避免与复杂而繁琐的操作系统调用和数据缓冲区操作细节 打交道。这种能力可以使你把一段磁盘空间当作“文件”来处理。 与此类似,你也可能对抽象数据类型进行分层。如果你在数据结构操作层次(如压入或弹出 堆钱)上使用了抽象数据类型(ADT),那很好。接着,你可以在比它更高的层次上使用处理客观 世界真实问题的 ADT。 即使是简单的问题也应考虑使用抽象数据类型(ADT)。不要在碰到复杂得可怕的数据结构时 才考虑使用抽象数据类型。在使用 ADT 的示例表中有一个非常简单的例子——只有开灯和关灯 两个操作的灯控制问题。你可能会认为“开”和“关”两个操作过于简单了,不值得为它们分 别编写专门的子程序,但事实上,即使是简单的问题也可以因为使用 ADT 而受益。把对灯的操 作放入 ADT 中,可以提高代码的自我说明程度并区改善其易改动性,并把改动的影响限制在 Tu rn_Light_0n()和 Turn_Light_Off()两个子程序中,同时也降低了数据传递的工作量。 可以提供一对互补的操作。绝大多数操作都需要有与其相对应的、效果相反的操作。如果 有一个打开灯的操作,那么就需要一个关灯的操作。如果有向表中添加项的操作,那么就要有 从表中去除项的操作。因此,当你设计抽象数据类型时,应检查每一个功能以确定是否有与其 互补的功能。不要根据直觉,而是根据需要来决定是否设计互补功能。 应相对 ADT 所存储的介质独立地引用它。假设有一张非常大的保险费用表,你不得不把它 存储在磁盘中,你可能会把它作为一个“保险费用文件”,并且用诸如 ReadRateFile()之类的 存取子程序来访问它。但是,如果把它作为文件来处理的话,你就暴露了超过实际需要的信息。 如果你对程序作出改动,把保险费用表由存放在磁盘中改为存放在内存中,那么把它作为文件 来引用的代码就会变得不正确而且是令人困惑的了。应努力使存取子程序的名字与数据存取方 式是相互独立的,而且应该引用抽象数据类型如保险费用表。你应该用 ReadRateTable()来代 替 ReadRateFile()作为存取子程序的名称。 12.3.4 用 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 作为其它函数的参数。 每次使用 ADT 函数时,显示识别事件。在某种情况下,你没有用来表示“当前字体”的符 号。使用 ADT 函数的子程序不必跟踪字体数据。但它们必须使用字体识别标志。这需要在每个 子程序中都加入 FontID 来作为参数。 显示提供 ADT 函数要用到的数据。用这种方法时,需要在每一个用到 ADT 函数的子程序内 部说明 ADT 所用数据。换句话说,你要建立一个传递到每一个 ADT 功能子程序中的 Font 数据结 构。同时,要把 ADT 功能子程序设计成每当它们被调用时,它们就会使用传入其中的 Font 数据。 这时,你不在需要使用字体识别标志,因为你跟踪的就是字体数据本身(虽然这种数据可以直接 从 Font 数据结构中得到,但你还是应该通过 ADT 功能子程序来存取它,这种方法称为把记录保 持为“封闭”的。将在后面详细讨论)。 这种方法的优点是 ADT 功能子程序不必根据字体识别标志来寻找字体信息。其缺点是这种 方法会使程序其余部分面临危险。因为数据是可以被直接存取的,所以它很容易被错误改动。 在抽象数据类型(ADT)内部,有许多种处理多事件数据的方法可供选择,但在 ADT 外部则只 有上述三种方法可供选择。 12.3.5 混合抽象级别(要避免!) 如果你只在 ADT 功能子程序内部才对数据结构进行存取,而且在程序其余各部分只通过 ADT 功能子程序来存取数据,那么你就保持了一致的抽象级别。如果不这样作,那么就产生了 混合抽象级别问题,这足以抵消由使用 ADT 而带来的所有好处。其直接后果是在修改程序时非 常容易犯错误。因为这时你会错误地以为修改了存取子程序便等于修改了程序中所有存取方式。 我们继续使用前面的飞船减压舱隐喻来说明问题:如果说 ADT 中的功能子程序相当于飞船 上减压船的话,那么对功能子程序的不一致使用就相当于在减压舱门上钻了个孔。这个孔可能 不至于使飞船中的空气一下了漏光,但是只要时间足够,它便足以毁掉整个飞船。在程序中如 果你使用了混合抽象级别,那么当你对程序进行修改时,程序便会变得越来越令人难以理解, 其功能会逐渐退化直至最后它成为完全无法维护的。 开放和封闭的记录 随着混合抽象级别而来的是“封闭”和“开放”记录的思想。当一个子程序直接用到了在 其中的记录或结构的任一个域时,便称这个域或结构是开放的:而如果记录或结构的任一个域 都没有被直接使用时,就称它是“封闭”的。向前面所提到的那样,你可以把使用封闭记录作 为处理多事件数据的一种方法。一个数据是可直接存取的这一事实,并不意味着你必须开放它。 除非是受到 ADT 功能了程序的限制,否则就该把记录保持为封闭的。 当你在用处理多事件数据的三种方法进行选择时,应选择 FontID 法而不是 FontRecord 第十二章 复杂数据类型 198 法。 12.3.6 抽象数据类型与信息隐蔽、模块和目标 抽象数据类型和信息隐蔽是互相联系的概念。当你使用 ADT 时,就已经隐蔽了它的实现细 节。这也正是从抽象数据类型通往信息隐蔽之路。当你使用信息隐蔽时,首先要寻找可以隐蔽 的内容。最明显的需要隐蔽的内容就是抽象数据类型(ADT)的实现细节。这也是从信息隐蔽通往 抽象数据类型的道路。 抽象数据类型这一概念与模块的思想也是相互联系的。在直接支持模块语言中,你可以在 模块中实现抽象数据类型。模块与抽象数据类型一样,都是由数据以及作用在数据上的操作组 成的。 抽象数据类型这一概念与目标的概念也是相互联系的。“目标”是一个比较广义的概念,但 它通常指的是数据及使用在数据上的操作。从这个观点来说,所有的目标都是抽象数据类型。 “目标”的思想事实上也利用了继承性和多形性的概念。把目标当作抽象数据类型的想法事实 上便是把继承性和多形性加到了一起。抽象数据类型只是目标思想的一部分而不是全部。 12.3.7 抽象数据类型所需要的语言支持 有些语言对 ADT 的支持要比其它语言更强有力。Ada 语言对抽象数据类型的支持近乎完美 的。比如在字体的例子中,Ada 允许把所有的字体功能子程序都放入一个“包”中。你可以把 几个子程序声明为开放的,而其条子程序则只在包中才是可用的。你可以限制 Font 的定义,从 而禁止 ADT 功能的子程序对 Font 的内部进行操作。这些子程序可以声明 Font 数据。但是它们 不能对任一属于 Font 的域进行操作,也不知道 Font 的内部结构。 在其它语言,如 C、Fortran、Basic 和 Pascal 中,你可以把 ADT 放入单一的一个源文件中, 开放某些子程序而把其余子程序保持为专用的 C 对 ADT 的支持也是比较有力的,因为它支持多 重源文件(模块)和专用子程序。而其它语言对 ADT 的支持能力则取决于特定的实现细节。换句 话说,在其它语言中,含有 ADT 的程序不一定是可移植的。 在任一种给定的语言中,无论你怎样有效地隐含子程序,隐含数据都是一个棘手的问题。 仍以字体程序为例:如果你在功能子程序外说明了一个 Font 变量,就可以在任意能够引用这个 变量的地方对其内部进行操作。这时它总是一个开放的记录,这意味着它很可能成为一个错误 的记录。只有通过规定,编程标准和你痛苦的失败经历才能使其保持为封闭的。 12.4 小 结 · 恰当地对数据进行结构化,可以使程序更简单、更容易理解也更容易维护。 · 可以用表来代替复杂的逻辑结构。当你被程序的复杂逻辑迷惑时,应考虑是否可用查 寻表来简化程序。 · 抽象数据类型是降低复杂性的有力武器。它使你可以分层编写程序,而且是从问题域 而不是程序语言细节来编写顶层的程序。 第十三章 顺序程序语句 199 第十三章 顺序程序语句 目录 13.1 必须有明确顺序的程序语句 13.2 与顺序无关的程序语句 13.3 小结 相关章节 常见的几个控制方面问题:见第 17 章 有条件的代码:见第 14 章 循环代码:见第 15 章 从这章起,论点已由程序的数据中心论转移到控制中心论方面。这章介绍一种最简单的控 制流——按顺序放置程序语句和程序块。 虽然组织顺序式程序代码(Straight Line Code)是件相对比较简单的事情,但是组织形式 的技巧却影响到代码的质量、正确性、可读性与可维护性。 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.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 使同一变量局部化 有不同参数出现的代码段是一个“脆弱的窗口”。在这个窗口内,很有可能无意中加上了新 的代码行,无意中改变了变量,或读着读着忘了这个变量的值是多少。因此把一个变量出现的 语句集中到一起总是一个好主意,例如上面这个例子。 把变量的再现局部化到一起的观点,其优点是不言自明的,但它却受制于常规的评价方法。 一个评价变量局部化方法是计算变量的跨度(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 使变量存活时间尽可能短 与变量跨度相关的另一概念是变量“存活时间”,即变量存活期涉及到的程序语句的总行数。 一个变量的生命由第一次出现它的程序语句开始,而终止于最后一次出现它的语句。 不像变量跨度,存活时间不受存活期间变量被用过多少次的影响,假如变量第一次出现在 第一行而最后一次出现在第 25 行,那么存活时间是 25 行,假如在这 25 行中这个变量仅被用了 两次(即只在第 1,25 行),那么跨度是 23 条语句,假如变量从第一行到 25 行中行行都出现,那 么平均跨度为 0,但存活时间依然为 25 条语句。下面的图显示了跨度和存活时间的具体含义: “长的存活时间”意味着变量存活期间有许多条语句,“短的存活时间”意味着变量存活时 只出现过几条语句;跨度则指变量出现的密度情况。 跟变量跨度一样,在考虑存活时间时要使它尽可能地小,使存活期间出现的语句条数最小。 同样地,减小存活时间也降低了变量出现窗口的脆弱性。当你想改变变量时,若存活时间短, 那么最早和最晚出现该变量的两条语句间的语句条数就小,因而减小了有意无意的修改出错的 可能性。 减小存活时间的另一大好处是在编码时你能在头脑中有一幅很清晰的画面。假如一个变量 在第 10 行赋值而在第 45 行才被用到,你可能认为在这期间也会用到这个变量,老想着这种可 能性,头脑当然很乱了。假如在第 44 行给一个变量赋值而在第 45 行马上就用到,那么之间不 可能有别的语句,你只需在这小的范围内考虑这个变量就行了。 第十三章 顺序程序语句 204 图 13-1 存活时间与跨度图 小的存活时间能减小初始化的错误,顺序式程序代码往往倾向于采用循环。若你把初始化 数据的地方放到远离你循环的地方,当你修改循环时,你很可能忘了去修改初始化值。把初始 化代码与循环代码紧放在一起,你就可能减小修改程序带来的初始化错误。 最后,小的存活时间使你编的代码可读性好,读者脑中装的代码愈少,你的代码便愈是易 懂。同样地,存活时间愈小,当你编辑或调用代码时变量出现的范围也愈小。 计算变量的存活时间 我们规定计算变量的存活时间是这样的:第一次和最后一次出现该变量的语句间总共有的 语句行数(包括两个端点)。下面是例子: 这个 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 所示那样,各框之间无交叉。 图 13-2 如果代码组织得好.画出相关语句的框与框之间无交叉部分,这些框是一个一个叠起来的 如果组织得不好,很有可能是使用 goto 引起的,那么画出图就如图 13-3 所示,框与框之 间有交叉,这时应重新编写和组织代码,以便把有关的语句更好地组织在一起。 一旦你组织好了相关语句,你会发现他们内部之间联系非常紧密,但这一组相关语句与其 前后的语句却无太大联系。这时你可以把这组紧密联系的程序语句写成一个子程序。 13.2.5 检查表 组织顺序式程序代码 · 把语句间的依赖关系表示得很清楚吗? · 子程序名是否把依赖关系表示得很清楚? · 子程序的参数是否把依赖关系表示得很清楚? · 若代码的依赖关系不清楚,用注释注明了吗? · 代码能从上读到下吗? · 变量的出现靠得很近吗? ——从跨度和存活时间来考虑。 第十三章 顺序程序语句 207 图 13-3 如果代码组织得不好,画出的相关语句的框与框之间有交叉,这种情形很可能是用 goto 引起的 · 是否把相关语句组织在一起? · 是否把相对独立的各相关语句写成子程序了? 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 if 语句 你所用语言可能不同,但你总会用几种 if 语句。最简单的是常用的 if 和 if-then 语句, if-then-else 稍复杂一点,长链形式的 if-then-else 更复杂。 假如你所使用的语言不支持结构形式的 if-then-else 语句,可以用第 17.6 节中用到的 技巧来模仿它,即用 goto 语句模仿结构化结构。 14.1.1 简单 if-then 语句 编写 if 语句时请参考以下几点: 在代码中,先按正常情况的路径往下编写,然后再写异常清况。在编写代码时,一定要使 得正常情况的路径在代码中显得很清晰。记住,异常情况一定不要使正常情况路径产生模糊。 这对程序的可读性及功能是很重要的。 在出现等号情况时,一定要弄清程序的流向。当读取数据或计算循环的控制变量时,用’ <’替代’<=’或用’<’替代’<=’都同样会产生边界错误(所谓边界错误指在控制循环或重复 时,由于控制变量的某一边界值出错或超出范围而使整个程序无法继续下去的错误)。在循环时, 一定要弄清结束点,以避免产生边界错误,在条件语句中,一定要弄清等号时的情形,以避免 产生边界错误。 把正常情况放在 if 后面而不是 else 后面。你当然希望正常的情况在程序中先处理,这样 按层层递推的方法,按先正常后不正常情况,按直线式往下排。下面这个例子犯了好多随意安 排正常不正常情况的毛病: 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 语句去发现哪些是正常情况,修改后的代码使人集 中阅读主程序流而不是费力地去寻找异常情况,整个程序读起来轻松明快。把异常情况都放在 程序的后部,就是很好地处理了异常情况的代码。 if 语句后跟上一个有意义的语句。有时你可能碰到下面这个例子,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 } 检查 else 语句的正确性。检查代码时,像 if 语句这样的主要语句是应当检查到的。但也 应当检查 else 语句,以确保其正确。 检查 if 语句和 else 语句是否弄反了。一个常见的错误是在编写 if-then 语句时,把该跟 在 if 后的代码与该跟在 else 后的代码正好弄反了,或者使 if 中的判断逻辑正好弄反了。检查 你的代码避免这种错误。 14.1.2 if-then-else 语句 假如你所使用的语言不支持 case 语句,或者只是部分支持,那么你会发现,你得经常编写 if-then-else 语句。如下例子中,代码要把输入的字符归类,因而用到如下语句: 用 if-then-else 语句排序字符的 C 语言例子。 if( InputChar < SPACE ) CharType=ControlChar; else if( InputChar==' ' || InputChar==',' || InputChar=='.' || InputChar=='!' || InputChar=='(' || InputChar==')' || InPutChar==':' || InputChar==';' || InputChar=='?' || InPutChar=='-' ) CharType=Punctuation; else if( '0' <= InputChar && InputChar <= '9' ) CharType=Digit; else if( 'a' <= InputChar && InputChar <= 'z' ) || ( 'A' <= InputChar && InputChar <= 'Z' ) CharType=Letter; 以下几条是在编写 if-then-else 时要遵循的: 用布尔函数调用(boolean function 亦称逻辑函数)简化程序。上面代码不好读的一个原 因是把字符进行排序的条件过于复杂。为了提高可读性,可以用布尔函数来代替这些判断条件。 用布尔函数调用的 if-then-else 的 C 语言程序例子。 if(IsControl(InPutChar)) CharType=ControlChar; else if(IsPunctuation(InPutChar)) CharType=Punctuation; else if(IsDigit(InputChar)) CharType=Digit; else if(IsLetter(InputChar)) 第十四章 条件语句 212 CharType=Letter; 把最常见的情形放在最开始。把最常见的情形放在最开始,你就可以少读许多处理异常情 况的代码,而直接读常见情况的代码。这样就提高了寻找常见情况的效率。在上述例子中,字 母该是最常见的情况,但检查是否为标点代码却放在最先。把检查字母的代码放在最开始修改 如下: 这个 C 语言的例子中,把处理最常见情况的代码放在最先: if(IsLetter(InputChar)) ——这个判断最常见,放在第一位 CharType=Letter; else if(IsPunctuation(InputChar)) CharType=Punctuation; else if(IsDigit(InPutChar)) CharType=Digit; else if(IsControl(InputChar)) ——这个判断最少见,放在最后 CharType=ControlChar; 保证覆盖全部情况。最后用一个 else 语句处理那些你未曾想到的错误信息。这个错误信息 很有可能是由你而非用户引起的,所以一定要正确处理这种情况,下面例子中,增加了处理其 它情况的代码。 这个 C 语言程序例子,用缺省情况应付可能出现的其它情况。 if(IsLetter(InputChar)) CharType=Letter; else if(IsPunctuation(InputChar)) CharType=Punctuation; else if(IsDigit(InputChar)) CharType=Digit; else if(IsControl(InputChar)) CharType=ControlChar; else PrintMsg("Internal Error: Unexpectecd type of character detected."); 假如你所使用的语言支持别的结构能代替 If-then-else,替换掉 if-then-else 形式。少 数几种语言,如 Ada 支持 case 语句,能很方便地替换大多数的 if-then-else,那就用 case 语句,case 语句易写易读,较 if-then-else 结构为好!下面用 Ada 的 Case 语句来归类输入字 符: 这个 Ada 语言程序用 Case 语句代替了 if-then-else。 case InputChar is when 'a'..'z' | 'A'..'Z' => 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 case 语句 case 语句和 switch 语句因语言不同其结构也变化很大。许多版本的 Basic 语言根本就不 支持 case 语句。C 语言支持的 case 语句,其分支变量每次只能对应一个值。Pascal 中的 case 语句也只对应一定范围内的数据。Ada 支持 case 语句,并且它用表示值的范围和组合的能力很 强。 在 Apple Macintosh 和 Microsoft Windows 环境中编程,大型的 case 语句经常用到。随着 交互式事件驱动程序的日益普及,case 语句的使用频率越来越高。下面部分指出了怎样有效地 使用 case 语句。 14.2.1 选择最有效的方法来组织各种情况 在 case 语句有许多方法可以用于组织各种情况。假如 case 语句很小,仅有三种选择和相 应的三条响应的语句,那么你怎样组织这个 case 语句都行。如果你的 case 语句很大,如在事 件驱动程序中用到的 case 语句,把各种情况组织成一个合理的顺序是很重要的。下面是可能的 组织方法。 把各种情况按字母或数字顺序组织。如果各种情况是同等重要的,按 A-B-C 顺序安排, 以提高可读性。每个事件都可很容易从整体中挑出来。 把正常情况的事件放在最开始。如果是一个正常情况及几个异常情况,把正常情况放在最 先,并用注释标明哪些是正常情况哪些是异常情况。 按出现频率组织情况。把最经常执行的情况放在最先,而最不可能的情况放在最后。这种 方法有两大好处。首先,读者很容易找出最普遍的情况。读者为寻找某个具体的情况而浏览整 个程序,极有可能就是找最常见的那种情况。把常见的情况放在代码的最开始,读者能很快找 到它。第二,机器执行起来也快。每一种情况都在代码中有相应的执行语句,机器执行都要花 时间搜索,如果有 12 种情况而最后一个情况是要执行的。那么机器要搜索 12 条 if 语句,直到 最后发现相应的情况。若把最普遍情况放在最先,你就可以减少机器的搜索区间,这样就可提 高代码的效率。 14.2.2 用 case 语句该注意的几点 下面是用 case 语句时要注意的几点: 使每种情况对应的执行语句最简单。每种情况对应执行代码应当短些。短的执行代码结构 显得清楚。如果某个情况对应的执行代码显得很复杂,应当把它写成一个子程序然后对应这种 情况调用子程序,而不是在这种情况之后直接跟上复杂的执行代码。 不要为了用 case 语句而去定义伪交量。一个 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 语句时,不要忘了在每种情况中安排 一个结束代码。 在 C 语言中,case 语句的最后都应当准确无误地标明结束。假如你有意地在最后不标明结 束,那么用注释说明你为什么要这样。 第十四章 条件语句 216 14.2.3 检查表 条件语句 if-then 语句 • 正常情况路径在代码中流向是否很分明? • if-then 语句在出现等号时流向是否正确? • else 语句是否有必要? • else 语句正确吗? • if 语句和 else 语句正确吗?它们是否弄反了? • 正常情况是否跟在 if 后而非 else 后? if-then-else 语句 • 复杂的条件是否封装成布尔函数调用了? • 最常见情况放在前面吗? • 全部情况都覆盖住了吗? • if-then-else 语句是最好的选择吗?——用 case 语句代替是否更好? case 语句 • 各情况的安排次序有含义吗? • 每种情况对应的操作简单吗?——如需要调用别的子程序。 • case 语句中的变量有实际意义吗?它是为了用 case 语句而单纯地定义出来的伪变量 吗? • 缺省语句的用法是否合法(规范)? • 用缺省语句检查和报告异常情况吗? • 在 C 语言中,每一情况的结尾用了 break 了吗? 14.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 语句等,使用循环是用程序化语言 编程时最复杂的情形之一,知道如何及何时用哪种循环是编写高质量软件的决定性因素。 15.1 选择循环类型 在大多数语言中,你总可以用上几种循环类型。 · 计数循环要执行给定的循环次数.也可能就只循环一次。 · 条件循环事先并不知道要执行多少次循环,而要在每次重复之前检查是否满足循环条 件。只要满足循环条件,循环不断继续下去,除非用户退出循环或出错。 · 死循环只要开始便无限执行下去。在心脏起搏器、微波炉、巡航控制等系统中,把死 循环置入其中。 以上几种循环的不同首先在于其灵活性——循环前检验一下是否满足退出循环的条件。循 环还在于把控制循环的语句放置在何处。你可以把控制循环的语句放在循环体的开始,中间或 结尾。这种特性告诉你循环是否至少要执行一次。如果控制循环的语句放在开始那么循环体可 能不被执行。而若放在结尾,循环体至少要执行一次。若放在中间,有一部分循环体至少要执 行一次,而另一部分(控制循环语句之后)则可能一次也不执行。 在选用循环结构时,灵活性和控制循环语句的位置是关键。表 15-1 列出了几种语言的循环 类型和控制循环语句的位置。 第十五章 循环语句 218 表 15-1 循环类型 语言 循环类型 特性 放置位置 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 条件循环 开始 15.1.1 while 循环 初学者有时认为 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 循环。 15.1.2 Loop-with-exit 循环 Loop-with-exit 循环是把终止条件放在循环体中间而不是开头或结尾的循环。Ada 支持 Loo-with-exit 循环,你也以在 C 中用 while 和 break,或在其他语言中用 goto 来模仿这种循环结 构。 正常的 loop-with-exit 循环 正常的 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 循环结构(实际上,Ada 之外的 任何语言都不支持),那么用注释标明哪些地方你用了 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.3 for 循环 当你想使程序块循环给定次数时,for 循环是一个好的选择。在 C、Pascal、Basic、Ada 中用 for 循环,在 Fortran 中用 DO 语句。 用 for 循环做简单动作时,不需设内部循环控制变量。你所需做的是在循环之前设一个控 制变量就可以不管了。你不需在循环内部设任何控制。若在某些条件下必须跳出循环,选用 while 循环更好。 第十五章 循环语句 222 当然,不要强行修改控制标志值使循环终止,这时可用 while 循环替代。for 循环仅用于 简单情形,大多数复杂的循环还是需要 while 循环。 15.2 控制循环(Controlling The Loop) 在编写循环时会出哪些错?当然包括以下错误:忽略了与循环有关的变量或累加器初始 化、不正确的嵌套、不正确的循环中断、忘记给循环变量一个增量或给错了增量、用不正确的 循环指标访问数组元素。 你可以先来看两个例子以对上述问题有一个感性认识。首先,应减少影响循环的因素,简 化、简化、再简化。第二,把循环体内部当作一个子程序看————把控制语句尽可能地放在循环 体外,以明确循环体执行的条件。不要让读者看了循环主体本身后才弄清循环控制。可以把循 环体看作一个黑盒子:周围的程序只判断控制条件而不知里面的内容。 这个 C 程序把循环体作为一个黑盒子; while(!eof(InputFile)&& MoreDataAvailable) { } 循环到底在什么条件下终止?在这个程序中,你只知道当 eof(InPutFile)为真而 MoreDataAvailable 为假时才退出循环。 15.2.1 进入循环 下面几条告诉你如何进入循环: 仅从一个入口进入循环。许多循环控制结构允许你从开头中间或末尾进入循环。你从循环 头部进入循环,大可不必多口进入。 把初始化循环的代码紧放在循环前头。最近原则提倡把相关语句放在一起。如果把相关语 句分散在程序的各处。修改时可能顾及不全而产生修改错误。如把相关语句放在一起。可避免 在修改时出错。 把与循环体相关的初始化循环的语句放在一起。如果不这样,当你扩大循环体或修改时很 可能忘了修改初始化循环的代码。当你把一个循环体拷贝到子程序里时,你就可能因为忘了一 起拷贝初始化部分而出错。把初始化部分放在远离循环的地方(在数据定义区域)或内务处理 区就可能产生初始化循环出错的麻烦。 在 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-1], 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.2 处理好循环体 以下几点对处理好循环体会有帮助: 用 begin 和 end 或{和}把循环体括起来。如果你容易忘记从哪到哪是循环体,就用括号 把它们括起来。括号不占任何空间和运算时间,也不破坏可读性,相反,括号能保证你不出错, 因此尽量用就是了。 尽量避免用空循环。在 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.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.4 用 break 和 continue 语句 break 语句是一种辅助控制结构,它能使循环提前退出,break 使循环从正常出口退出,程 序继续执行接在循环后的程序。这里说的 break 是指属同一类的语句,在 C 中是 break,在 Pascal 中是 exit 或类似的结构,包括不支持 break 的语言中有相似功能的 goto 语句。 continue 语句和 break 一样同是辅助循环控制语句,但正好相反,continue 使程序又回到 控制体的开始并执行下一个循环。continue 语句是 if-then 语句的简写形式,而后者可能使 循环的余下部分不被执行。 用 break 和 continue 一定要谨慎。用 break 语句就不可能再把循环体看作一个黑盒子, 把控制循环退出的条件都写在一个语句里,这是简化循环的有力手段。用了 break 语句就要使 人从头到尾看你的循环体内容才能理解循环控制,这样就使得循环更难读了。 有选择性地用 break 语句。有些计算机科学家认为这种结构是合法的技巧,而另一些则认 为不然。既然你不知用 continue 和 break 好还是不好,那么你用它时应当小心可能用错了。其 实情形很简单,若非一定要用,尽可能不用。 在 while 循环中,若要用布尔量标志时就应考虑用 break 语句。在 while 循环中有时要增 第十五章 循环语句 228 加一个逻辑标志来模拟循环体的出口,这就使得程序难读。有时为了好看,在有几个 if 语句时, 下一个比上一个要缩进几列,这时可用 break,把 break 放在独立的语句段之后,使它靠近它 所属的代码段能减少嵌套,使程序易读。 要特别小心一个循环中许多 break 语句分散出现的情形。一个循环中包含了许多 break, 则表明对循环的结构或者对周围代码的作用还考虑得不清楚。若 break 语句出现多了,就表明 该循环若用许多小循环替代可能更好,因为一个大循环会有许多出口。循环中有多个 break 并 不说明一定有错误,但起码给出一个信号;这种用法不太好! 如果用 goto 来模拟 break,转向执行的语句应紧接在循环的后面。有时你很想让循环的出 口指向的不是紧跟在循环后的第一条语句而是更远,这时你就可能离用 goto 来做的危险尝试不 远了,或者你就处于那种要用 goto 来结束循环的境地。不要因为用了 goto 而把问题弄复杂了。 把 continue 放在循环的头前检查。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 替代。 15.2.5 检查循环边界 一个简单的循环通常要注意三种情况:初始情况、中间情况、结束情况。当你要编写一个 循环时,你头脑中要想清楚:在初始、中间、结尾情况下都不会出现边界错误。如果初始或结 尾情形可能出问题,仔细检查一下,如果循环包含了复杂的计算,拿出计算器核算一下。 进行这种检查是高效率与低效率程序员关键不同之处,高效率的程序员在脑中或用手算一 下,因为他们知道:这样做可以帮助发现问题。 低效率的程序员总是随意地编写,反复修改到最后发现应该是怎样做。如果循环不按设想 的那样去做,低效率的程序员总要把'<'改成'<='。如果这样还行不通,他们就要改变循环控制 变量了,或者加或者减 1。到最后,他们用这种方法幸运时,撞对了,但很可能把简单的错误 改得更隐晦。即使这种试凑方法最后把循环改对了,程序员也不知道为什么就改对了。 在脑中先想想或先算算有几个好处。如,在编程阶段减少错误,在调试时能很快发现错误, 第十五章 循环语句 229 且能更好地理解整个程序。先想想能使你知道代码是如何运行的,而试凑者则不然。 15.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 -1 ) do Begin DoSomething( Data ); GetData (InputFile , Data ); End; 这个所谓的一般例子包含了一个错误。当 Data 等于-1时,条件部分检查到-1就退出 循环,这时,DoSomething()不可能执行。而用 goto 的程序在检查到-1前已经执行过了 DoSomething()。写有这个程序的书,本想用它来说明用结构化写程序显得多么容易。但没 想到转换却带来了错误。但该书作者也不用感到害臊,因为别的书也有类似的错误。 下面这个程序没用 goto 但是正确。 这个 Pascal 程序与有 goto 的程序是同一目的,但这里无 goto 且程序正确。 Repeat GetData (InputFile , Data ); If ( not eof( InputFile ) ) then DoSomething ( Data ); Until (Data = -1 or eof( InputFile ) ); 即使程序的转换正确,但这个例子还是显得很虚假,因为它们把 goto 的用法看得太繁 琐了。上述情况并不是那种思想的程序员愿用 goto 的情形。下面这种情形是常见的,即使 极不愿用 goto 的程序员有时为了增强程序的可读性与可维护性,却选用了 goto。 下面几节给出了一些情况,在这种情形下,是否用 goto,在有经验的程序员那是有争议 的,讨论用和不用 goto 的一些代码,最后得到一个折衷答案。 16.1.4 出错处理和 goto 语句 写交互式程序代码要做的几件事是,要特别注意出错处理和出错时要清除资源。下面这 个代码要清除一组文件。程序首先读入要清除的文件组,然后找到每一个文件,覆盖掉并清 除它,程序每一步都要检错。 这个 Pascal 程序用 goto 来处理出错和清除资源: procedure PurgeFiles( var ErrorState : ERROR_CODE ); {This routine purges a group of files.} var FileIndex : Integer; FileHandle : FILEHANDLE_T; FileList : FILELIST_T; 第 16 章 少见的控制结构 237 NumFileToPurge : Integer; Label END_PROC; Begin MakePurgeFileList ( FileList ,NumFilesToPurge ); ErrorState := Success; FileIndex := 0; While ( FileIndex < NumFileToPurge ) do Begin FileIndex := FileIndex + 1 ; If not FindFile ( FileList[ FileIndex ], FileHandle ) then Begin ErrorState := FileFindError; Goto END_PROC ——这里是一个 goto End; If not OpenFile(FileHandle) then Begin ErrorState:=FileOpenError; Goto END_PROC ——这里是一个 goto End; If not OverWriteFile(FileHandle) then Begin ErrorState:=FileOverWriteError; Goto END_PROC ——这里是一个 goto End; If Erase( FileHandle ) then Begin ErrorState := FileEraseError; Goto END_PROC ——这里是一个 goto End End;{while} END_PROC; ——这里是 goto 的标号 DeletePurgeFileList( FileList ,NumFilesToPurge ) 第 16 章 少见的控制结构 238 End; {PurgeFiles} 这个程序是那种有经验程序员肯定要用 goto 的情形。同样的,当程序要分配和清除资 源时(像内存、或处理字形、窗口、打印机),也要用 goto。这种情形下用 goto 通常是为了 复制代码或清除资源。若遇到这种情况,程序员就要掂量是 goto 的缺点令人讨厌呢?还是 复制代码那令人头痛的维护更讨厌呢?最后还是认为 goto 的缺点更可忍受。 可以用许多方法重新编写上述程序而不用 goto,把两种比较一下。下面是不用 goto 的 方法: 用 if 嵌套重新写程序。用嵌套的 if 语句重新写程序时,嵌套 if 语句是为了在上一个条 件满足后才进到这一层嵌套的。这是不用 goto 的标准的、书本式的结构化编程方法。下面 用标准的方法重新编程。 这 Pascal 代码用 if 嵌套来避免用 goto: procedure PurgeFiles( var ErrorState : ERROR_CODE ); {This routine pruges a group of files.} var FileIndex : Ingeter; FileHandle : FILEHANDLE_T; FileList : FILELIST_T; NumFilesToPurges: Integer; begin MakePurgeFileList( FileList , NumFilesToPurge ); ErrorState := Success; FileIndex := 0; While ( FileIndex < NumFilesToPurge and ErrorState = Success ) do—— 这个 While 已经改为增 加测试错误 状态 begin FileIndex := FileIndex + 1 ; If FindFile ( FileList[ FileIndex ] , FileHandle ) then Begin If OpenFile ( FileHandle ) then begin If OverWriteFile ( FileHandle ) then Begin If not Erase ( FileHandle ) then Begin ErrorState := FileEraseError End 第 16 章 少见的控制结构 239 end else {couldn’t overwritefile} begin ErrorState := FileOverWriteError end end else {couldn’t open file} begin ErrorState := FileOpenError end end else {couldn’t find file} begin ErrorState := FileFindError ——这行与调用它的语句距离有 23 行 end end; {While} DeletePurgeFileList( FileList ,NumFilesToPurge ) end; {PurgeFiles} 习惯于编程不用 goto 的人对这段代码可能看得很清楚。如果你把程序写成了上面例子 这样,那么在读时你就不必担心由 goto 带来很大跳跃了。 用 if 嵌套的主要弊病是嵌套层次太多、太深。为了读懂代码,你得同时把所有嵌套的 if 都记在脑中。而且处理出错的代码距引起它的代码距离太远:如在上例中,ErrorState 到 FileFindError 的距离是23行。 用 goto 的程序,发现错误的语句离处理出错的语句都不超过四行,而且你无需同时把 整个结构都放在脑中,你尽可把不成立的条件置之不理而集中精力于下一步操作。由此看来, 在这种情况下 goto 倒是更可读与易维护了。 重新编程时可调一个状态变量。定义一个状态变量以指示程序是否处于错误状态。上例 中已经用到了 ErrorState 这个状态变量,因而下面还用它: 这个 Pascal 代码设置状态变量以免用 goto: procedure PurgeFiles( var ErrorState : Error_CODE ); {This routine pruge a group of files.} var FileIndex : Integer; FileHandle : FILEHANDLE_T; FileList : FILELIST_T; NumFilesToPurge : Integer; 第 16 章 少见的控制结构 240 begin MakePurgeFileList ( FileList , NumFilesToPurge ); ErrorState := Success ; 这个 While 测 试已经增 加了一个 ErrorState FileIndex := 0; While ( FileIndex < NumFilesToPurge ) and ( ErrorState = Success ) do begin FileIndex := FileIndex + 1; if not FindFile( FileList[ FileIndex ] , FileHandle ) then begin ErrorState := FileFindError; end; if ( ErrorState = Success ) then —— 测试这个状态变量 begin if not OpenFile( FileHandle ) then begin ErrorState := FileOpenError end end; if ( ErrorState = Success ) then —— 测试这个状态变量 begin if not OverwriteFile( FileHandle ) then begin ErrorState := FileOverwriteError end end; if ( ErrorState = Success ) then —— 测试这个状态变量 begin if not Erase( FileHandle ) then begin ErrorState := FileEraseError end end end; {while} DeletePurgeFileList( FileList , NumFilesToPurge ) end; {PurgeFiles} 第 16 章 少见的控制结构 241 设置状态变量的好处是避免 if-then-else 结构的嵌套层次太深,且易于理解。这种方 法也使跟在 if-then-else 条件后的实际操作语句离测试条件更近,且完全不用 else 语句。 为理解嵌套 if 语句是需动一番脑筋的,而设置状态变量的方法则易于理解,因为它接 近于人的思维方式。你要先找到文件,如果无错,就打开文件;如果还不出错,覆盖文件; 如果还不出错…。 这种方法的不足之处在于状态变量并不如所想象的那么好,使用状态变量时你要表示清 楚,否则读者不能理解变量是什么意思。在上例中,用了有含义的整数类型的状态变量相当 有帮助。 各方法比较。三种方法各有优点。goto 方法避免嵌套太深和不必要的条件测试,但同时 也有 goto 固有的弊病;嵌套的 if 方法避免用 goto 但嵌套深且增大了程序的复杂性;设置 状态变量法避免用 goto 且不会使嵌套太深,但增加了额外的条件判断。 状态变量法相对前两个好些。因为它使程序易读且使问题简化,但它不可能在所有情况 下都好用。从整体上来讲,这三种方法在编程时都能用得很好,这时就需要全盘考虑,权衡 利弊,选择最好的方法。 16.1.5 goto 和 else 语句中的共用代码 一种挑战性的情况是,若程序有两个条件测试语句和一个 else 语句,而你又只想执行 一个条件语句中的代码和 else 中的部分代码,这时就得用 goto 语句,下面这个例子是迫使 你用 goto 的情况; 这个 C 语言程序用 goto 转向执行 else 中的共用代码: if ( StatusOK ) { if ( DataAvail ) { ImportantVar = x; Goto MID_LOOP; } } else { ImportantVar = GetVal( ); MID_LOOP ; /* lots of code */ …… } 这个程序的高明之处在于它用了一个逻辑的迂回——它可不像你所见的那么好读,但若 不用 goto 你很难再写出另外的样子出来。如果你认为你能很容易地不用 goto 就写出新程序 来。那么让别人检查看看。好些有经验的程序员都写错了。 第 16 章 少见的控制结构 242 改写的方法有几种,你可以复制代码、把共用的代码放在一个子程序里面,且从两个地 方调用它、或重新写条件测试语句。在多数语言中,改写并不比写原程序快,虽然应当是一 样快的。除非循环在程序中多次使用,否则编写时不用考虑效率。 改写最好的方法莫过于把/*lots of code*/部分放在一个子程序里。你可以在代码原来 出现的地方和 goto 语句转向的地方调用它而保留原结构的条件语句。下面是程序: 这个 C 程序把 else 中的共同程序部分写成一个子程序 if ( StatusOK ) { if ( DataAvail ) { ImportantVar = x; DoLotOfCode( ImportantVar ); } } else { ImportantVar = GetVal( ); DoLotsOfCode( ImportantVar ); } 通常,写一个新子程序(C 中可用宏定义)是最好的方法。但有时把代码放进一个子程 序中实际是不可能的。这时只能修改原结构中的条件部分而无需把共用部分放到一个子程序 中。 这个 C 程序用 else 中的共用代码替换 goto: if ( ( StatusOK && DataAvai ) || !StatusOK ) { if ( StatusOK && DataAvail ) ImportantVar = x; Else ImportantVar = GetVal( ); /* lots of code */ …… } 这种转换方法不会出错但很机械。程序意义虽然一样,但却多出来的两个地方测试 StatusOK 和一个地方测试 DataAvail。使得条件测试显得很麻烦。注意:第一个 if 条件中 StatusOK 值不必测试两次,你也可以把第二个 if 的条件中对 DataAvail 的测试减少。 16.1.6 使用 goto 方法的总结 用 goto 是一个个人爱好的问题。我的意见是,十个 goto 中有九个可以用相应的结构化结 构来替换。在那些简单情形下,你可以完全替换 goto,在复杂情况下,十个中也有九个可以不 用:你可以把部分代码写成一个小的子程序调用;用嵌套 if 语句;用状态变量代替;或者重新 第 16 章 少见的控制结构 243 设计控制条件的结构。消除 goto 是很难的,但它却是很好的脑力活动,前面讨论过的方法会对 你有所帮助。 如果 100 个用 goto 的情形中有一个靠 goto 很好地解决问题的方法,这时你要把它用得好 些。只要问题能解决,我们是不约束用不用 goto 的,但应当注意,最好还是少用或不用 goto 编程,因为有些问题你可能还没弄清楚。 下面对用 goto 的方法作一总结: · 若语言不支持结构化语句,那就用 goto 去模仿结构化控制结构,但一定要写得正确。 不要把 goto 灵活性用得出了格。 · 能用结构化结构的地方尽量不用 goto. · 评价 goto 的性能的方法是看其是否提高效率,在多数情况下,你可以把 goto 替换而 增加可读性且不失效率。在少数例外情况下,用 goto 确实有效且不能用别的方法来 代替。 · 每个程序至多用一个 goto 语句,除非你是用它来模仿结构化的结构。 · 使 goto 语句转向循环前面而不要往后转移,除非是在模仿结构化结构中。 · 保证 goto 转向的语句标号都要用上,若有没有用到的,则表明掉了部分代码,或该 转向那部分代码没有标上标号。所有给出的标号都要用上,若没用上,就去掉。 · 应保证 goto 语句不会产生执行不到的代码。 · 用不用 goto 应全面考虑,程序员对用不用 goto 考虑后,选择了 goto,那么这个 goto 可能是好的选择。 16.2 Return 语句 return 和 exit 语句都属控制结构语句,它们使程序从一个子程序中退出。它们使子程序 从正常出口退出到调用它的程序中去。这里 return 泛指有类似作用的一类词:return、exit 和相似的结构,下面说明如何使用 return 语句。 减少每个程序中的 return 语句。如果你在看一个子程序的后部分,而又不清楚在前面是 否有 return,那么就很难读懂这个程序。 用 return 增强可读性。有些子程序中,一旦你知道了答案,就想退回到调用它的程序中 去,如果子程序不需要清除,那么不立即退出意味着要执行别的代码。 下面这段程序是个较好的例子。它从子程序中多个地方退出,满足多种情况。 这个 C 程序是较好地从子程序中多个地方退出的例子: int Compare ( int Value1, int Value2 ) { if ( Value1 < Value2 ) return( LessThan ); 第 16 章 少见的控制结构 244 else if ( Value1 > Value2 ); return( GreaterThan ); else return( Equal ); } 16.3 递归调用 在一个递归调用中,子程序本身只解决很小一部分问题,而把问题分成许多小片段,然后 调用自己,来解决比本身更小的片段。递归调用一般在这种情形下,即解决问题的一小部分很 容易,但整个问题很复杂。 递归调用不常用,但若用好了,它能解决其他方法不好解决的大问题。下面是递归解决排 序算法的例子。 这个 Pascal 程序用递归解决排序算法: Procedure QuickSort ( FirstIdx : integer; LastIdx : integer; Names : NAME_ARRAY ); var MidPoint : integer; begin if ( LastIdx > FirstIdx ) then begin Partition( FirstIdx , LastIdx , Names , MidPoint ); QuickSort( FirstIdx , MidPoint – 1 , Names ); QuickSort( MidPoint + 1 , LastIdx , Names ); 这里是递归调用 end end; 这个程序中,排序算法把一个数组分成两部分,然后调用自己再去排序那两个半个的数组, 如此下去,直到不能再分为止。 一般说来,递归调用代码较短,执行速度慢,占用堆栈空间大。若问题较小,递归调用可 得到简单、巧妙的解;对稍大的问题,它们产生简单、巧妙、难于理解的解。对大多数问题, 递归调用得到很大的复杂解——在这种情形下,简单的循环或许更好理解。用递归要有选择。 16.3.1 递归调用的例子 假设你用一个数据结构代表一个迷宫。一个迷宫就是一个网格,在网格的每一点上你可以 向左转,也可向右转,可以向上,亦可向下。在每一点上,你有不止一种选择。 那么你如何写一个程序去解决这个问题即通过迷宫呢?若用递归调用,问题相当直观。从 第 16 章 少见的控制结构 245 起始点开始,试走各种可能的路径,直到最后走出迷宫。第一次你走到一个点,试着往左:若 不能往左,就试着向上或向下;若都不行,那就往右,你不用怕迷路,因为每过一点,你做下 标记,因此你不会从同一个地方走过两次。 下面是用递归写的代码: 用递归写过迷宫的 C 程序 BOOLEAN function FindPathThroughMaze ( MAZE_T MAZE , POINT Position ) { /* if the position has already been tried,don’t try it again */ if (alreadyTried(Maze,Position)) return(FALSE); /* if this position is the exit,declare success */ if (ThisIsTheExit(maze,Position)) return(TRUE); /* remember that this position has been tried */ RememberPosition(Maze,Position); /* check the paths to the left,up,down,and to the right; if any path is successful,stop looking */ if ( MoveLeft( Maze , Position , &NewPosition ) ) if ( FindPathThroughMaze ( Maze , NewPosition ) ) return( TRUE ); if ( MoveUp( Maze , Position , &NewPosition ) ) 第 16 章 少见的控制结构 246 if ( FindPathThroughMaze ( Maze , NewPosition ) ) return (TRUE); if ( MoveDown ( Maze , Position , &NewPosition ) ) if ( FindPathThroughMaze ( Maze , NewPosition ) ) return (TRUE ); if ( MoveRight ( Maze , Position , &NewPosition ) ) if ( FindPathThroughMaze ( Maze , NewPosition ) ) return ( TRUE); return( FALSE ); } 代码的第一行检查是否已经走过那点。写递归程序要防止无限递归调用。在上例中,若不 检查是否走过,那可能产生无限递归。 程序第二段检查这一点是否是通向迷宫出口的。如果 ThisIsTheExit()返回 TRUE(真),那 么子程序也返回真。 第三句记录下这一点已经走过。这样防止走入循环路径而导致无限递归调用。 下面各行找出各左、上、下、右的路径。在找出通过迷宫的路径以后,程序返回 TRUE。 这个程序的思想很直观。许多人觉得递归调用自己而感到不舒服,但在本例中,其它方法 肯定都复杂而递归调用是很好的。 16.3.2 怎样用递归 下面是用递归的几点建议: 要保证递归能终止。检查程序确保含有一个不再用递归调用的路径,这通常表明程序已经 满足条件不再需要递归了。在迷宫例子中,条件测试 AlreadyTried()和 ThisIsTheExit()保证 递归调用停止。 设置安全计数器防止无限递归。如果不许用简单的条件测试,如上例所示,要防止无限递 归,那就应设安全计数器。安全计数器应当是每次递归时不能改动的变量,可用一个全局变量 或把安全计数器作为程序的参数。 下面是例子: 用安全变量防止无限递归的 Pascal 程序: Procedure RecursiveProc( var SafetyCounter : integer ); /*这个递归程序必须可以改变 SafetyCounter 的值,它在其中是一个 Var 参数*/ begin if ( SafetyCounter > SAFETY_LIMIT ) then exit; SafetyCounter : = SafetyCounter + 1; …… end; 第 16 章 少见的控制结构 247 在上例中,如果超出安全计数器的范围,则停止递归调用。 把递归调用限制在一个子程序里。循环递归调用(A 调用 B,B 调用 C,C 调用 A)是很危险 的,因为程序很难检查。在一个程序内的递归调用就够麻烦的,要理解程序间的递归调用太难。 如果你的程序用了循环递归,那就修改代码,使递归在一个子程序中。如果你不能修改且认为 这种递归是最好的方法,那就一定要设置安全计数器。 要注意堆栈。写递归调用无法计算程序要用多少堆栈空间,而且也无法事先预测程序是如 何运行的。你可采取几个步骤来控制程序运行时的行为。 首先,若用了安全计数器,在设置安全计数器的最大值时,要考虑到可能分配给你多大的 堆栈空间。安全计数器的最大值要限制在不使堆栈溢出的范围内。 其次,你可估算一下,在运行递归程序时要用多少内存。在运行之前,用可辨识的值充满 相应内存;0 和 0xCC 是一个很好的值,把程序编译一下,然后用 DEBUG 去看对应的内存,看看 程序占用了多少堆栈空间。查查 0 和 0xCC 没有被改变的点。然后用这点来估计你程序占用的堆 栈。 不要用递归去计算阶乘或菲波那契(fibounacci)数。计算机教科书上经常出现的一个问题 是给出了许多不太好的递归调用例子。典型例子是计算阶乘或计算 fibounacci 数列。递归调用 是一个强有力的工具,但用在这里都显得很笨。假如我请人给我编一个计算阶乘的程序,而他 用递归调用算法,那我宁愿换个人。下面是用递归调用计算阶乘的程序: 这个 Pascal 程序用递归调用算阶乘不太好: Function Factorial(Number:integer):integer; begin If ( Number = 1 ) then Factorial := 1 Else Factorial := Number * Factorial ( Number – 1 ); end; 除了速度慢,计算机用到的内存难以估计外,递归调用在这个程序中要比用循环难懂多了。 下面用循环编程序: 这个 Pascal 程序用循环来计算阶乘比较好: Function Factorial( Number : integer ) : integer; var IntermediateResult : integer; Factor : integer; begin IntermediateResult := -1; For Factor := 2 to Number do IntermediateResult := InterMediateResult * Factor ; Factorial := IntermediateResult end; 你可以从这件事得到三点教训。第一,计算机教科书的这些所谓的例子对于说明递归的作 第 16 章 少见的控制结构 248 用没有一点好处;第二点也就是最重要的一点,递归调用的功能要比用来计算阶乘和 fibonacci 数列强大得多。有时你可用堆栈加循环做递归调用能做的同样的事情。有时用这种方法好,有 时另一种更合适,你得仔细权衡选用一种。 16.3.3 检查表 少见的控制结构 goto · goto 是最后的选择吗?用 goto 使程序更好读更好维护吗? · 用 goto 是为效率的目的吗?用 goto 达到此目的了吗? · 一个程序是否只用一个 goto 呢? · goto 只转向前面的程序段而不是转向其后面的程序段吗?(后面指已执行过程序) · goto 所转向的标号都有了吗? return · 每个子程序的 return 数目是否最少? · return 增强了可读性了吗? 递归调用 · 用递归调用的代码含使递归结束的语句吗? · 程序设置了安全计数器来保证递归调用终止了吗? · 是否只在一个程序中用递归调用? · 递归调用的深度是否限制在程序堆栈容量可满足的条件下? · 递归调用是实现程序的最优途径吗?它比循环更简单吗? 16.4 小 结 · 有些情况下,goto 是编出易读易维护程序的最好方法。 · 多重 return 有时增强了程序的可读性与可维护性,并且防止多重嵌套逻辑,但没必要 只想到怎样用好 return。 · 在问题较简单时,递归调用能把问题很巧妙解决。要慎用递归调用。 第十七章 常见的控制问题 249 第十七章 常见的控制问题 目录 17.1 布尔表达式 17.2 复合语句(块) 17.3 空语句 17.4 防止危险的深层嵌套 17.5 结构化编程的作用 17.6 用 goto 模拟结构化结构 17.7 控制结构和复杂性 17.8 小结 相关章节 条件代码;见第 14 章 循环的代码:见第 5 章 少见的控制结构:见第 16 章 不讨论在编写控制结构时碰到的几个问题,那么关于控制的任何讨论都是不完全的。这一 章所讲的东西是细节性的、实用性很强的。若你想看有关控制结构的理论,那就请集中精力看 17.5 节关于“结构化编程的作用”和 17.7 节中关于“对控制结构和复杂性之间关系”的研究好 了。 17.1 布尔表达式 除了那些按顺序往下计算的最简单控制结构,几乎所有的结构都依赖于对布尔表达式的计 算。 用 True 和 False 作为布尔变量 用 True 和 False(真和假)作为布尔表达式结果的标识符,而不要用 0 和 1。像 Pascal 之类 的语言有布尔型变量来支持定义 True 和 False 作为标识符。要弄清楚,对布尔型变量,你还只 能用 True 和 False 来给它赋值而不能用其它的,对那些没有布尔型变量的语言,你需用一些规 则来使布尔表达式易读。如下例子; 这个 Basic 程序任意定义布尔变量的值: 1200 IF PRINTERROR = 0 GOSUB 2000 ' initialize printer 1210 IF PRINTERROR = 1 GOSUB 3000 ' notify user of error 1230 ' 1240 IF REPORTSELECTED = 1 GOSUB 4000 ' print report 第十七章 常见的控制问题 250 1250 IF SUMMARYSELEEED = I GOSUB 5000 'print summary 1260 ' 1270 lF PRINTERROR = 0 G0SUB 6000 'clean up successful printing 如果类似 0 和 1 这样的标志很普遍的话,那会有什么错呢?问题是当测试条件为真还是假 的时候程序应当执行 GOSUB 吗?不清楚。在程序段中没有什么规则来说明是“1”代表真,“0” 代表假。正好相反,甚至“l”和“0”是否表示“真”和“假”的意思都弄不清楚。比如在 IF REPORTSELECTED = l 这一行,“1”很容易代表第一个记录,“2”代表第二个,“3”代表第三个, 在这个代码中,并没有什么标准说明”1”表示的是真还是假,同样,对“0”也是如此。 用 True 和 False 之类的词来表示布尔表达式的结果,如果所用语言不支持这种类型,用预 定义宏或全局变量的方法来创造一个。下面的例子用全局变量 True,False 来重新编写: 这个 Basic 程序用全局变量(True 和 False)来表示布尔变量的值: 110 TRUE = 1 110 FALSE =0 ⋯⋯ 1200 IF PRINTERROR = FALSE GOSUB 2000 ' initialize printer 1210 IF PRINTERROR = TRUE GOSUB 3000 ' notify user of error 1230 ' 1240 IF REPORTSELECTED = FALSE GOSUB 4000 ' print report 1250 IF SUMMARYSELECTED = TURE GOSUB 5000 ' print Sllttifi13Yy 1260 ' 1270 IF PRINTERROR = FALSE GOSUB 6000 ' clean up successful printing 用 True 和 False 作变量名使得用意很清楚。你不用记“1”和“0”表示什么意思,也不用 偶尔回头检查。而且在重新编写了以后发现,原程序中的那些“1”和“0”并不作为布尔量的 标志。IF REPORTSELECTED = 1 这一行根本不是一个布尔测试表达式,它只是检查第一个记录被 选中了没有。 这种方法可告诉读者这一行是用作布尔测试目的。你不太可能用 TRUE 来表示 FALSE(假) 的意思,但把“l”当作“0”的意思却有可能,而且用 True 和 False 还可避免那令人眼花缭乱 的“0”和“1”在程序中到处出现的。下面几点意见对在布尔测试条件定义 True 和 False 很有 帮助。 在 C 中用 1 == 1 的形式定义 TRUE 和 FALSE。在 C 中,有时很难记住是否 TRUE 等于 1 和 FALSE 等于 0 或者正好相反,你得记住测试 FALSE 和测试空终止符或其它零值一样。否则用下面定义 TRUE 和 FALSE 的方法来避免出现这个问题。 这个 C 程序用易于记住的方法定义布尔量: # define TRUE (1 ==1) # define FALSE(!TRUE) 隐含地把布尔变量与 False 比较。如果所用语言支持布尔变量,把测试表达式当作布尔表 达式来看待显得清楚。比如: while( not Done)⋯ while( a = b)⋯ 用上面的表达式比用下面的清楚: 第十七章 常见的控制问题 251 while( Done = False )⋯ while ( (a = b) = True ) ⋯ 用隐含的比较方式可减少测试条件语句中词的个数,读程序时可少记好多东西,因此表达 式读起来就简单些。 如果使用的语言不支持布尔变量,你就得去模仿。你也可能有时不能用这种技巧,因为在 有些语句像 while(not Done)之类语句,不能模仿 True 和 False 用来作检测。 使复杂的表达式简单些 采用以下几步来简化表达式: 把复杂的测试条件用中间的布尔变量变成几个部分。宁愿定义几个奇怪的中间变量并给它 们赋值,这样可编写简单的测试条件。 把复杂的表达式写成一个布尔型函数。如果测试条件要经常重复用到或很分散,把这部分 代码写成函数。如下例,测试条件很复杂。 这个 Pascal 程序的测试条件很复杂: if ( ( eof ( InputFile ) and ( Not InputError ) ) and ( ( MIN_ACCEPTABLE_ELEMENTS_C<CountElementsRead ) and (CountElementsRead<= MAX_ELEMENTS_ C= = or ( Not ErrorProcessing) )then { do something or other } ⋯ 如果你对测试条件部分不感兴趣的话,那要你读这个程序真是一件可怕的事情。把这部分 写成一个函数,就能把条件部分独立起来,除非读者感兴趣,否则可以忽略这部分。下面这个 程序给出了怎样把 if 的条件部分写成一个函数: 这个 Pascal 程序把测试条件部分写成一个布尔型函数: Function FileReadError ( var FILE InputFile; Boolean InputError; Integer CountElementsRead ): Boolean; begin if ( ( eof ( InputFile ) and ( Not InputError ) ) and ( ( MIN_ACCEPTABLE_ELEMENTS_C<CountElementsRead = and ( CountElementsRead< = MAX_ ELEMENTS _C= = or ( Not ErrorPocessing ) )then FileReadError := False 第十七章 常见的控制问题 252 else FileReadError:=True; end; 上例中把 Error_Processing 定义为一个标志现在过程状态的布尔型函数。现在,当你读整 个主程序时,可不必去管复杂的测试条件部分: 这个 Pascal 的主程序没有复杂的测试条件: if( not FileReadError( InPutFile, InPutError, CountElementsRead) then { do something of other } ⋯ 如果测试条件仅用一次,你可能认为没有把这部分写成一个子程序的必要。但把这部分编 成一个子程序并给出一个合适的名字,那么不仅能增大可读性,而且让你一看就明白这部分代 码的作用,因而很有必要这样做。 用决策表代替复杂的条件。有时复杂的测试条件涉及几个变量。若用决策表去编这个测试 条件部分则显得比用 if 或 case 好。一个决策表使得开始编程时很容易,因为它仅需几行代码 而且不需什么复杂的控制结构。这种减小复杂性的方法会降低出错的机会。若要修改数据,仅 需修改决策表而无需修改程序本身,仅需更改数据结构的内容。 17.1.3 编写肯定形式的布尔型表达式 不少人对较长的否定形式的表达式理解起来很困难。也就是说大多数人对否定太多的句子 来困难。为避免出现复杂的否定形式布尔型表达式,你可依从以下几点: 在 if 语句中,把条件从否定形式转化为肯定形式,再把 if 和 else 语句后跟着的代码对换。 如下例所示: 这个 Pascal 程序乱用否定形式的测试条件 if( not StatusOK ) begin {do something} ⋯ end else begin {do something else} ⋯ end; 你可以把程序改为肯定形式表达式: 这个 Pascal 程序用肯定形式的布尔型测试条件显得很直观: if( StatusOK ) begin ——这个测试条件转换为肯定形式 {do somthing else} ——这个模块中的代码巳经转换 ⋯ end; else begin 第十七章 常见的控制问题 253 {do something} ⋯ end; 这一段代码与前一段代码实际上是一回事,但比前一个好读,因为把否定形式的表达式转 换成了肯定形式的表达式。 当然,你可以选用不同的变量名,但其中一个要与真值的意思相反。在上例中,可用 ErrorDetected 替换 StatusOK,它在 StatusOK 错误时表示正确的意思。 用 DeMorgen 定律去简化否定形式的布尔型测试条件。DeMorgen 定律揭示了在取反时一 个表达式与另一个表达式之间的关系。比如下面这个代码段; 否定形式测试条件的 Pascal 程序: if( not DisplayOK or not PrinterOK ) then ⋯ 在逻辑上它与下面这段代码相等,这个 Pascal 程序应用了 DeMorgen 定律简化: if(( not DisplayOK and PrinterOK )) then⋯ 这里你无需对调 if 和 else 语句后的可执行代码。上述两个表达式在逻辑上是一致的。把 DeMorgen 定律应用于逻辑运算 and 或 or 或其它运算时,你把每个运算取反,对换 and 和 or, 然后整个表达式取反,表 17-l 归纳了 DeMorgen 定律各种可能的转换。 表 17-1 应用 DeMorgen 定律转换逻辑表达式 初始表达式 相应表达式 not A and not B not(A or B) not A and B not(A or not B) A and not B not(not A or B) A and B not (not A and not B) Not A or not B not(A and B) Not A or B * not(A and not B) A or not B not (not A and B) A or B not (not A and not B) *例子中已用到 17.1.4 用括号使布尔型表达式清晰 如果布尔型表达式较复杂,用括号使表达式意思更明晰,而不能仅依靠语言运算的顺序。 读者并不了解语言是怎样去运算布尔型表达式的,因此用括号可解决这个问题,读者无需知道 内部细节。如果你是一个聪明的程序员,你不必依赖自己和读者来记运算优先级,特别是几种 语言混用时,用括号不像打电报,你无需为多写的字符付出代价。 下面这个例子没有适当多用括号,这个 C 程序表达式中少许多括号: if ( a< b = = c = =d ) ⋯ 这个程序一开始就被那个条件表达式弄得昏头转向,但更令人难以捉摸的是不清楚条件表 达式的意思是 ( a< b )= =( c = =d ) 呢还是( ( a 0 ) do ⋯ 假如算了整个式子,在最后一次循环时就会出错,当变量 i 等于 MaxElements+1 时,表达 式 item 目等于 item[MaxElements+1],这个数组下标有错误(超出维数)。或许你要说你仅看 数组的值,不改变它,也就无所谓。这种说法在 Macintosh 和 MS-DOS 操作系统中是正确的, 但若操作系统是一个保护模式,像 Microsoft Windows 或 OS/2,你可能就改变了保护性。在 Pascal、basic 中也是这样。 可重写测试条件,避免出错。 这个 Pascal 程序测试条件正确: while( i<= MaxElements) do if( item[i] <> 0) then … 这个程序正确是因为除非 i 小于或等于 MaxElements,否则不计算 item[i]。 第十七章 常见的控制问题 255 许多高级的语言提供在前部分防止这类错误的规则。比如,C 和 Pascal 用短路法计算,如 果第一个运算 and 是假,那么第二个运算不再执行,因为整个式就已经是假了。也就是说,在 C 和 Pascal 中。 if something False and somecondition 被计算的部分是 if Something False。当 Something 被证明为假时,整个计算停止。 短路计算类似于 or 操作。在 C 和 Pascal 中, if something True or somecondition 假如 if somethingTrue 是真,那么就仅执行这一部分,整个计算停止。正是考虑到这种方 法,下面的语句较好而合法的。 用短路法计算测试条件的 Pascal 例子: if ((Denominator <>0) and (Item/Denominator>MinVal))do⋯ 当 Demominator 等于 0 时,整个表达式计算会出现被 0 除错误。但既然当第一部分为真(不 等 0)时第二部分才被计算,因而不会出现 Denominator 被零除的情形,也就不会出被零除的错 误。 另外,既然 and 操作是从左到右运算的,下面的语句可能出错; 这个 Pascal 程序不能用短路法避免错误; if((Item/Denominator0 ) do ⋯ 在这个例子中,Item/Denominator 在判断 Denominator<> 0 之前运算,所以程序会出现被 零除错误。 不同的语言采用不同的运算方法,而语言设计者又倾向于擅自采用所爱好的表达算法。因 此要查你所用语言是何种算法,就得查阅相应版本的手册。但既然读者对这方面理解得不如你 那么深,那就用括号标明你的意图,而不仅仅依赖于运算顺序和短路运算法。 17.1.6 按数轴上的顺序编写数字算式 按数轴的顺序组织数字运算。一般说来,仔细组织程序的数字测试条件,以便于比较,如 下例: MinElmts <= i and i<= MaxElmts i < MinElmts or MaxElmts < i 这种思想是按从小到大的顺序从左到右安排各部分。上例中,第一行 MinElmts 和 MaxElmts 是两个端点,因此把它放在这句的两端。变量 i 假设处于这两者之间,因此放在句子中间。第 二句的目的是检查 i 是否超出范围,因此 i 放在句子的两端而 MinElmts 和 MaxElmts 放在里面。 这种方法一下子就勾画出你作比较的意图。 MinElnts <= i and i<= MaxElmts MinElmts MaxElmts Valid values for I 第十七章 常见的控制问题 256 i < MinElm or MaxElmts < i MinElmts MinElmts Valid values for i 如果仅把 i 与 MinElmts 比较,i 的位置随条件的不同而变化,如果 i 被认为是小些,你得 这样写比较语句: while(iMinElmts and I 10) then if( Qty > 100) then if( Qty > 1000) then Discount :=0.10 else Discount := 0.05 else Discount := 0.025 else Discount := 0.0; 这个程序的测试条件太乱太散,这有几个原因。一个原因是测试条件显得多余。当你测试 Qty 是否比 1000 大时,你就无需测试它是否比 100 大了,更不用说 10,考虑到这一点,重新写 代码:这个 Pascal 程序把 if 嵌套转化为 if-then-else 的形式: if( Qty > 1000) then Discount := 0.10 else if( Qty > 100) then Discount >= 0. 05 else if( Qty >10) then Discount := 0.025 else Discount := 0; 这个程序就比上一个好多了,因为测试条件中比较的数值是速增的。如果数值不是那么有 规律地增长,你可替换嵌套的 if 语句如下: 这个 Pascal 程序把 if 嵌套转化为 if-then-else,在这里数值是无规律的: if(Qty > 1000) then Discount := 0.10 else if( Qty > 100 and Qty <= 1000) then Discount := 0.05 else if( Qty > 10 and Qty <= 100) then Discount := 0.025 else if( Qty <= 10) Discount := 0 这个程序与前一程序的主要区别在于 else-if 的测试表达式不依赖于前一测试结果,这 第十七章 常见的控制问题 261 段代码也可不要 else 语句,且测试条件可按任意顺序放置。代码可包含 4 个 if 语句而无 else。 用 else 语句的原因是可避免不必要的重复测试。 把 if 嵌套改成 case 语句。你可以把某些类型的 if 语句测试条件(恃别是用到整数)用 case 语句来代,而不用 if-else 来代替。但这种技巧并非所有语言都能用。若能用,这种技巧 的效果很好。下面这个 Pascal 程序用了这种技巧: 这个 Pascal 程序把 if 嵌套转换成 case 语句: case Qty of 0..10: Discount := 0. 0; 11..100: Discount := 0. 025; 101..1000: Discount := 0. 05; else Discount := 0. 10; end;{case} 这个例子读起来很轻松,把它与前几页的多次缩排程序例子比较,显得相当清楚。 把深层嵌套的代码写成一个子程序。如果深层嵌套出现在一个循环中,你可把循环的内部 写成一个子程序。这种技巧当嵌套是条件控制和重复时显得特别有效。把 if-then-else 分支留 在主程序里以显示清楚决策分支,而把各分支里的代码写成一个子程序。下面这个程序就需用 上述方法改造: 这个 C 程序的嵌套代码需分别写成子程序: while( ! feof( TransFile )) { /* read transaction record */ ReadTransRec(TransFile,TransRec); /* process transaction depending on type of transaction */ if( TransRec.TransType = = Deposit ) { /* process a deposit */ if(TransRec.AccountType = = Checking) { if( TransRec.AcctSubType == Business) MakeBusinessCheckDep( TransRec.AcctNum , TransRec.Amount ); else If(TransRec.AcctSubType = = personal ) MakePersonalCheckDep( TransRec.AccNum,TransRec.Amount ); else if(TransRec. AcctSubType == School ) MakeSchoolCheckDep( TransRec.AcctNum,TransRec.Amount ); } else if (TransRec.AccountType = = Savings ) 第十七章 常见的控制问题 262 MakeSavingsDep(TransRec.AcctNum,TransRec.Amount ); else if(TransRec.AccountType = = DebitCard ) MakeDebitCardDep(TransRec.AcctNum , TransRec.Amount ); else if(TransRec.AccountType==MoneyMarket) MakeMoneyMarketDep(TransRec.AcctNum,TransRec.Amount ); Else if(TransRec.AccountType==CD) MakeCDDep(TransRec.AcctNum ,TransRec.Amount); } else if (TransRec.TransType==Withdrawal) { /* process a withdrawal */ if( TransRec.AccountType == Checking) MakeCheckingWithdrawal( TransRec.AcctNum ,TransRec.Amount ); else if(TransRec.AccountType = = Savings ) MakeSavingsWithdrawal(TransRec.AcctNum , TransRec.Amount ); Else if (TransRec.AccountType = = DebitCard ) MakeDebitCardWithdrawal( TransRec.AccyNum, TransRec.Amount); } else if( TransRec.TransType = = Transfer) { MakeFundsTransfer(TransRec.SrcAcctType,TransRec.TgAcctType, TransRec.AcctNum,TransReC.Amount); } else { /* process unknown kind of transaction */ LogTransError(”Unknown Transaction Type”,TransRec); } } 虽然很复杂,但这个程序还不是你见到的最次的一个。它的嵌套仅有四层,并且写得很清 楚,有编排形式,功能分解得也很完整,特别是对 TransRec。的处理。尽管有这些好处,但还 是应该把内层 if 的内容写成子程序。 这个 C 程序较好,把嵌套内的内容写成子程序: While(!feof( TransFile) ) { /* read transaction record */ ReadTransRec(TransFile,TransRec); /* process transaction depending on type of transaction */ 第十七章 常见的控制问题 263 If( TransRec.TransType== Deposit) { ProcessDeposit(TransRec.AccountType, TransRec.AcctsubType); TransRec.AcctNum,TransRec.Amount); } else if ( TransRec.TransType==Withdrawal) { ProcessWithdrawal( TransRec.AccountType,TransRec.AcctNum, TransRec.Amount ); } else if( TransRec TransType== Transfer) { MakeFundsTransfer( TransRec.SrcAcctType, TransRec.TgtAcctType, TransRec.AcctNum,TransRec.Amount); } else ( /* process unknown transaction type */ LogTransError(”Unknown Transaction Type”,TransRec); } 新子程序部分的代码只是简单地从原程序中提取出来并形成子程序(这里没有写出来)。新 的程序有几个优点。第一,仅有两层嵌套,使得结构显得简单、易懂;第二,你可在显示器的 一屏上阅读、修改、调试这个较短的 while 循环,不会因为需几幕显示,几页打印而限制你的 眼界;第三,把 processDeposit()和 processWithdrawal()功能写成子程序具有了易于修 改的一切优点;第四,现在可容易看出代码能写成 switch-case 语句形式,那样就显得更易读 了。如下例子:这个 C 程序较好,嵌套内的内容写成子程序且用了 switch-case 语句: while(!feof( TransFile )) { /* read transaction record */ ReadTransRec( TransFile ,TransRec ); /* process transaction depending on type of transaction */ switch(TransRec.TransType) { case( Deposit ); ProcessDeposit(TransRec.AccountType,TransRec AcctSubType, TransRec.AcctNum , TransRec.Amount ); 第十七章 常见的控制问题 264 break; case(Withdrawal ); ProcessWithdrawal( TransRec.AccountType, TransRec.AcctNum, TransRec.Amount); break; case( Transfer ); MakeFundsTransfer(TransRec SrcAcctType,TransRec.TgtAcctType, TransRec. AcctNum,TransRec.Amount); break; default: /* process unknown transaction type */ LogTransError(”Unknown Transaction Type”,TransRec); break; } } 重新设计深层嵌套代码。一般说来,复杂代码表明还没有完全理解你的程序,应使其更简 单些。深层嵌套提示需把某些部分写成一个子程序或需重新设计复杂的那部分程序。这虽然并 不意味着就需要去修改程序,但若无充分理由,还是要修改。 17.5 结构化编程的作用 结构化编程是什么意思?结构化编程的核心基于这样一种简单思想,即程序总是单入单出 的结构,即程序只能从一个地方开始且也只能从一个地方退出的代码块,没有其它的进口与出 口。 17.5.l 结构化编程的好处 为何本书还要在整整一章去讨论一个 20 年的老话题——结构化编程呢?每个人都用它吗? 否。并非每个人都用结构化编程,且许多人认为他们不用。Gerald Weingerg 调查了大约 100 家软件公司并访问了数千名程序员。他报告说,大约仅有 5%的代码是完全结构化的;20% 的程序结构化程度很高(这比 1986 年是一个提高);50%的程序显示了用结构化编程的努力, 但并不成功,而还有 25%的程序则显示丝毫没有被过去 20 年的结构化思想影响。 有些程序员不相信结构化编程的作用。他们错误地认为结构化编程是多此一举与浪费时间, 且编程效率低,打击编程员的积极性和降低其编程效率,有些报告证实了有这种对结构化编程 抵制的行为。 尽管有这些否定意见,结构化编程的有效性是证据确凿的,观察实验数据发现结构化编码 使总编程效率增加,再考虑到限制条件和复杂性,产量增量显得更大,从 200%~600%不等。 第十七章 常见的控制问题 265 通用汽车公司(General Motors)一项研究表明结构化技巧可节省时间和培训近六个月(Elsoff 1977)。 除了提高产量,结构化编程还可提高可读性。在一项试验中,36 个专业程序员要理解结构 化和非结构化的 Fortran 程序,结果显示,对结构化程序的理解得分为 56 分(100 分制),而非 结构化程序的则仅有 42 分。这表明更多结构化编程比非结构化编程要提高 33 个百分点 (Sheppardetal 1978)。 结构化编程的执行进程是一个有序的、有规律的过程,而不是不可预测地到处转移。程序 可以从上往下读下来,执行大抵也按这个顺序。源程序无规律性会在机器中产生一些无意义的 不好读的程序执行路径。而低可读性则意味着不好理解,程序质量差。 17.5.2 结构化编程设计的三个组成部分 “结构化”这个词是这许多年来使用频率极高的调,被应用到软件开发的各个领域,包括 结构化分析、结构化设计、结构化实现。不同的结构化方法并没有统一起来,它们都是同时出 现的,结构化是它们好处的标志。 许多人错误地理解结构化编程,把这个词在几个方面被乱用。首先,结构化编程不是缩排 编写的方法,这种方法对程序的结构毫无益处;第二,结构化编程不是自上而下的设计,这只 是编程的一些细节问题;第三,结构化编程不是一种节约时间的技巧。但以上几点在面向对象 程序设计时是正确的。 下面几节讨论构成结构化编程的三个方面。 顺序编程 一个顺序程序指一组按顺序执行的语句。典型的顺序语句包括赋值和子程序调用。如下两 例: 这个 Pascal 程序包含顺序代码: {a sequence of assignment statements} a := 1; b := 2; c := 3; {a sequence of calls to routines} Writeln(a); Writeln(b); Writeln(c); 选择 选择是一种控制结构。这种结构使语句有选择地被执行。If-then-else 就是一个普通的 例子。或者 If-then 语句,或者 else 语句被执行,但不会两者都不执行,总有一个被选择执 行。 case 语句是另一种选择控制的例子。Pascal 和 Ada 中的 case 语句及 C 中的 switch 语句 第十七章 常见的控制问题 266 都属 case 类的例子。在每一种语句中,都有几种情况被选择去执行。一般说来 case 语句和 if 语句在概念上是相似的。如果一种语言不支持 case 语句,可用 if 去模仿它。下有两例: 有选择句子的 Pascal 程序: { selection in an if statement } if( a = 0) then { do something } else {do something else} {selection in a case statement} case( Shape ) of Square: DrawSquare; Circle: DrawCircle; Pentagon: DrawPentagon; DoubleHelix: DrawDoubleHelix; end; 重复 重复也是一种控制结构,它使一组语句被多次执行。重复常指“loop”(循环),常见的几 种重复有 basic 中的 FOR-NEXT、Fortran 中的 Do、Pascal 和 C 中的 while 和 for 等语句。如 果不用 goto,那么重复将是控制结构的技巧。下面是用 Ada 编的重复例子: Ada 编的重复的例子: 一 example of iteration using a for loop for INDEX in FIRST..LAST loop DO_SOMETHING(INDEX); end loop --example of iteration using a while loop INDEX := FIRST; while( INDEX <= LAST ) loop DO_SOMETHING( INDEX ); INDEX := INDEX + l; End loop; 一 example of iteration using a loop-with exit loop INDEX:=FIRST; 第十七章 常见的控制问题 267 loop exit when INDEX > LAST; DO_SOMETHING ( INDEX ); INDEX := INDEX + 1; end loop; 17.6 用 goto 模拟结构化结构 汇编、Fortran、一般 Basic 或其它有些语言不支持结构化控制结构,那你可能要问:我怎 么写结构化程序呢?正如 goto 的批评者所指出,你可以用三种结构化结构中的一种或几种来取 代每一控制结构,也可用一个或几个 goto 来取代三种结构化结构。如果你的语言没有提供其它 控制程序流的方法,那么避免用 goto 的结论就没什么意义了。这种情况下,你可用 goto 去模 拟三种结构化编程结构,只是 goto 要尽量少用。 17.6.1 模拟 if-then-else 语句 如果你想用 goto 来模拟 if-then-else 语句,首先用注释行的形式写出测试条件和 if- then-else 语句,如下例所示,这里用 Fortran 写,但你也可很容易地采用汇编、Ihac 或其它 语言写: 用注释行写出 if-then-e1se 的测试条件的程序; C if (A < B ) then C else C endif 其次,在注释行间写代码。作为一般的方法,为了转向分支,你对注释行中的条件取反, 对应于重写的条件是真——即原条件为假时转向分支。为了对原条件取反,把条件两边用括号 括上,并在其前面加上.NOT.运算,用一个 goto 语句从 if 部分转到 else 部分去,下面是程 序: 用 Fortran 填充了 if-then-else 条件的程序: C if( A< B= then IF(.NOT.(A. LT. B))GOTO 1100 CODE TO PERFORM IF THE ORIGINAL CONDITION IN COMMENTS IS TRUE … GOTO 1200 C else 1100 CONTINUE CODE TO PERFORM IF THE ORIGINAL CONDITION IN COMMENTS IS FALSE … 1200 CONTINUE C endif 注意到注释行中的 if、else、endif 语句往后退了几格与正式程序对齐,以免代码的逻辑 结构受到注释行的影响。 下面是相应的 Basic 程序段: 第十七章 常见的控制问题 268 用 goto 模拟 if-then-else。测试条件的 Basic 程序: 1000 ' if ( A0.5) do 1000 IF (.NOT. ( I .LT. MAXI .AND.X .GT. 0.5 ) ) GOTO 1010 C DO SOMETHING C … GOTO 1000 1010 CONTINUE C end while 这里要注意的地方跟上面讨论 case 语句一样,可以参考前述,注意的是循环初始化是明显 给出的,作为提醒,和前面的 for 的条件一样,这里 While 的条件在被取代后要取反。 17.6.4 模拟控制结构概述 用 goto 来模拟结构化控制结构可帮你用某种语言编程。正如,DavidGriex 指出,选择何 种语言并不重要,你要确定的是你该怎样编程。理解把程序编成某种程序语言的程序与用某种 语言编程之间的区别是本书的关键。许多编程原则并不依赖于某种语言,而仅给你提供了一种 方法。 如果所用语言不支持结构化结构或易于产生其它问题,努力弥补这种缺陷,形成一套你自 已的编程风格、标准、库子程序或其它观点。 通过模拟结构化编程的结构,那些汇编、一般 Basic、Fortran 等语言对结构化控制结构的 限制也就不复存在了。最后你能把程序从一种语言转化成另一种你碰巧要用的语言。 17.7 控制结构和复杂性 注意控制结构的一个原因是,它们对于克服程序的复杂性有很大贡献。不用控制结构,会 增加程序的复杂性。用得好则能降低复杂性。 标量一个程序复杂性的方法是,若要理解程序你得一次连续在脑中记住多少目标程序语句, 结构的处理是编程中最困难的方面,也是为什么需要特别注意结构性的原因,这也是程序在处 理“快速中断”(quick interruption)时手忙脚乱的原因,这里快速中断相当于要求一个杂技 员手上拿着东西的同时要不断向空中抛三个球做杂耍的情形差不多。 程序的复杂性很大程度上决定了要理解一个程序要花多大努力。TomMcCabe 在一本书中认 为一个程序的复杂性就决定于它的控制流。别的研究人员在此之外还确认了别的影响复杂性的 因素,但都承认,控制流若不是最大的,起码也是影响复杂性的最大原因。 17.7.1 程序复杂性的重要性 计算机科学起码在二十年前就注意到了程序复杂性的重要了。二十年前,Edager Dijkstra 就意识到复杂性的危险,他说:“一个聪明的程序员总是清楚地知道自己的脑力容量有限,因此 第十七章 常见的控制问题 270 他得十分小心谨慎地完成编程任务”(1972)。这并不意味着为了处理很复杂的问题你得增大你 的脑力,而是说你得想办法尽可能降低复杂性。 控制流的复杂性很重要,因为它和低的可读性和频繁的出错紧密联系在一起(McCabe 1976, Shen at al 1985).William T.ward 在 Hewlett-Packard 公司用 McCabe 的复杂性度量标准来 研究软件的可读性问题时,得到一个很有意义的结果(1989)。把 McCabe 的复杂性度量原理用 于确定一个 77,000 行程序的出错范围。这个程序的最后错误率为每一千行 0.31 个错,而另一 个 125,000 行程序最后出错率为每一千行 0.02 个错。ward 发现,由于这两个程序的复杂性较 低,因此这两个程序的出错数比 Hewlett-Packard 的其它程序都低。 17.7.2 减少复杂性的常用方法 下面两种方法可以帮助降低程序复杂性。首先,你可以做一些动脑筋练习来提高在脑中打 底稿的能力。但大多数程序都很大,而人同时考虑的问题一般都不能超过 5~9 个,因此靠提高 脑子的容量来帮助降低复杂性能力有限,第二,要降低程序的复杂性就要求你彻底理解你所要 解决的问题。 怎样度量复杂性 你可能要问怎样从直觉上判断哪些因素使程序变得复杂了还是简单了呢?研究人员已经把 他们的直觉归纳出来并形成了几条度量复杂性的方法,或许最有影响的数字技巧是 Tom McCabe 的方法,这种方法通过计算程序的“决定点”(decision point)的数目来度量复杂性,见表 17 -2。 表 17-2 计算程序决定点的技巧 --------------------------------------------------------------------------------- 1.从 1 开始一直往下通过程序。 2.遇到下列关键词或其同类的词加 1。 if while repeat for and or 3.case 语句中每一种情况都加 1,如果 case 语句没有缺省情况再加 1 如下例: if (( ( Status = Success ) and Done ) or (not Done and ( NumLines >= MaxLines)) ) then… 在这个程序中,从 1 算 起 ,遇 到“ if”得 2,遇 到“ and”得 3,遇 到“ or”得 4,又遇到一个“and” 得 5,这样程序总共含有 5 个决定点。 有了复杂性度量数日该怎样判断程序的复杂性 当你已经算出决定点的数目时,你可用算得的数目分析程序的复杂性。如果数目是: 0~5 程序可能很好 6~10 得想办法简化程序 10 以上 得把部分代码写成子程序并在原程序调用 把部分程序写成子程序并不能减少整个程序的复杂性,它仅仅是把决定点转移到别的地方, 但它却降低了一次涉及到的复杂性。既然你的目的是降低一次要在你脑中考虑的问题数目,因 此编成子程序降低复杂性的方法是有帮助的。 第十七章 常见的控制问题 271 决定点的最大数目为 10 并不是一个绝对的极限,而仅用这个数目作为一种提醒标志,来告 诉你程序需重新设计一下,不要死套这个规则,比如一个 case 语句有许多种情况,因而决定点 数目会比 10 大得多,但你却不能把它分解成子程序。这种 case 语句在事件驱动程序中用得很 多,如 Microsoft Windows 和 Apple Macintosh 中的许多程序。在这些程序中一个长的 Case 语句可能是降低程序复杂性的最好方法。 17.7.3 度量程序复杂性的其它方法 McCabe 的度量程序复杂性的方法并不是唯一的方法,但它却是用得最多的方法,特别当考 虑控制流问题时。其它的方法包括用到数据的次数、控制结构中嵌套层次、代码的行数、变量 连续出现的程序行行数及输入输出点的数目。另有一些研究人员已经开发了以上各方法的综合 方法。 17.7.4 检查表 控制结构方面 · 表达式用 True 和 False 而非 1 和 0? · 布尔型表达式的值是否隐含地与 False 比较? · 是否通过定义中间布尔型变量和布尔型函数及用决策表的方法来简化表达式? · 布尔型表达式是用肯定形式写出来的吗? · 在 C 中,数值、字符,指针是显式与 0 比较的吗? · begin 和 end 能保持平衡吗? · 为了使程序看起清楚,需要的地方用 begin 和 end 对标明了吗? · 空语句看起来清楚吗? · 通过诸如重新组合测试条件、转化为 if-then-else 或 case 语句或把嵌套内代码写成子 程序的方法来简化嵌套语句了吗? · 如果程序的决定点数超过 10,有什么正常理由不重新设计它吗? 17.8 小 结 · 使布尔型表达式简单可读性高对代码的质量很有好处。 · 深层嵌套使程序难懂,不过可用相对简单方法避免这样做。 · 结构化编程是一个简化程序的思想,用顺序编程、选择或循环中的一种或几种方法的组 合可编出任何程序。 · 作这种简化程序的思想可提高程序的产量和质量。 · 如果所用语言不支持结构化结构,你能模仿它们。你应该把程序编成某种语言的程序而 不是用某种语言编程的。 · 降低复杂性是编写高质量的代码的关键。 第十八章 布局和风格 272 第十八章 布局和风格 目录 18.1 基本原则 18.2 布局技巧 18.3 布局风格 18.4 控制结构布局 18.5 单条语句布局 18.6 注释布局 18.7 子程序布局 18.8 文件、模块和程序布局 18.9 小结 相关章节 文档代码:见第 19 章 从这一章开始转向计算机编程的美学问题——源程序代码的规划布局问题,组织得很好的 代码无论从直观上还是从内心里都产生一种愉悦的感觉,这恐怕是非程序员很少能达到的境界。 使那些对自己工作很有自豪感的程序员能从他们代码的优美结构得到极大的艺术满足。 这一章所讨论的技巧并不影响速度、内存的使用及其它程序外观方面的问题,所影响的仅 仅是怎样很容易地理解代码、检查代码、日后很容易地修改代码等。它也影响到别人如何很轻 松地去读、去理解、当你不在时去修改代码。 这一章尽是些人们在谈到要注意细节时涉及到的小细节问题。在整个编程过程中,注意这 些细节问题对程序最后质量和最后维护性都产生很大影响。对编码过程来说这些细节是必不可 少,以致到最后无法改变。如果你是和一个小组一起工作,在编程前要和全组的人一起看看本 章的内容并形成一个全组统一的风格。 你可能不太同意这一章的所有问题,但与其说是要你同意本章的观点还不如说是要让你考 虑涉及到格式化风格的许多问题,如果你有高血压,请翻过本章,这里的观点都是要引起争论 的。 18.1 基本原则 本节介绍布局原理,其它各节介绍实例。 18.1.1 布局的几个例子 考虑表 18-1 列出的程序。 第十八章 布局和风格 273 表 18-1 Pascal 布局的例子 procedure InsertionSort ( Var Data : SortArray_t ; FirstElmt : Integer LastElmt : Integer ) ; { use the insertion sort technique to sort the “Data” array in ascending order. This routine assumes that Data [ FirstElmt ] is not the FirstElmt element in Data and that Data [ FirstElmt - 1 ] can be accessed. }Const SortMin = ’’ ; Var SortBoundary : Integer ; { upper end of sorted range } InsertPos: Integer ; {position to insert element } InsertVal : SortElmt_t ; {value to insert} LowerBoundary : SortElmt_t ; {first value below range to sort} begin { Replace element at lower boundary with an element guaranteed to be first in a sorted list } LowerBoundary : = Data[ FirstElmt_t ] ; Data [FirstElmt-1] : = SortMin ; {The elements in positions FirstElmt through SortBoundary-1 are always sorted. In each pass through the loop, SortBoundary is increased, and the element at the position of new SortBoundary Probably isn’t in its sorted place in the array,so it’s Inserted into the proper place somewhere between FirstElmt and SortBoundary.}for SortBoundary : = FirstElmt + 1 to stElmt do begin InsertVal : = Data[SortBoundary] ; InsertPos : = SortBoundary ;while InsertVal <= Data[ InsertPos – 1 ] do begin Data[InsertPos] : = Data[ InsertPos – 1 ] ; InsertPos : = InsertPos – 1 ; end; Data [ InsertPos ] : = InsertVal ;end;{Replace orginal lower-boundary element }Data[ FirstElmt – 1 ] : = LowBoundary ; End; { InsertionSort} 这个程序从语法上来说是正确的。它用注释说明得很清楚,而且变量名也有含义、逻辑思路 也很清楚。如果不信,读完这段程序看看哪出错了。这段程序所缺省的是一个好的布局,这是 一个极端的例子。若用好坏标准数轴来表示的话,这个例子的布局是要处于负无穷大方向的。 表 18-2 的例子稍好些: 表 18-2 Pascal 程序布局的例子 procedure InsertionSort( Var Data : SortArray_t ; FirstElmt:Integer ; LastElmt : Integer ) ; { use the insertion sort technique to sort the “Data” array in ascending order. This routine assumes that Data[ FirstElmt ] is not the first element in Data and that Data[ FirstElmt – 1 ] can be accessed. } Const SortMin = ’’ ; Var SortBoundary : Integer ; { upper end of sorted range } InsertPos : Integer ; {position to insert element } InsertVal : SortElmt_t ; {value to insert} LowerBoundary :SortElmt_t ; {first value below range to sort} begin { Replace element at lower boundary with an element guaranteed to be first in a sorted list } 第十八章 布局和风格 274 LowerBoundary : = Data[ FirstElmt_t ] ; Data[ FirstElmt –1 ] : = SortMin ; {The elements in positions FirstElmt through SortBoundary-1 are always sorted. In each pass through the loop, SortBoundary is increased, and the element at the position of the new SortBoundary Probably isn’t in its sorted place in the array, so it’s inserted into the proper place somewhere between FirstElmt and SortBoundary.} for SortBoundary : = FirstElmt +1 to LastElmt to do begin InsertVal : = Data[ SortBoundary ]; InsertPos : = SortBoundary; while InsertVal < Data[ InsertPos - 1 ] do begin Data[ InsertPos ] := Data[ InsertPos-1 ] ; InsertPos := InsertPos –1 ; end; Data[ InsertPos ] := InsertVal ; end; { Replace orginal lower-boundary element } Data[ FirstElmt-1 ] : = LowBoundary ; end; { InsertionSort } 这段程序与表 18-1 的一样。虽然大多数人要说这段代码的布局要比前一段的要好得多,但 它还是显得不太好读。这段代码的布局还是显得拥挤而且无法看出程序的逻辑结构。它处于好 坏标准数轴的 0 位置。第一个例子是一个分行的过程,而第二个则什么也没有。我见过一些程 序有上千行那么长,其结构布局跟这个例子一样差劲,既无文件说明,也无好的的变量名,读 起来跟这个例子一样难受。第二个例子是为计算机格式化的,而无丝毫迹象表明,编程者想让 人读这一段程序。表 18-3 则是一个改进: 表 18-3 Pascal 程序布局例子 procedure InsertionSort ( Var Data : SortArray_t; FirstElmt: Integer; LastElmt : Integer ); { use the insertion sort technique to sort the “Data” array in ascending order. This routine assumes that Data[FirstElmt] is not the FirstElmt element in Data and that Data[ FirstElmt-1 ] can be accessed. } Const SortMin = ’ ’; Var 第十八章 布局和风格 275 SortBoundary : Integer; { upper end of sorted range } InsertPos : Integer; {positionto insert element } InsertVal : SortElmt_t; {value to insert} LowerBoundary : SortElmt_t; {first value below range to sort} begin { Replace element at lower boundary with an element guaranteed to be first in a sorted list } LowerBoundary := Data[FirstElmt_t]; Data[FirstElmt-1] := SortMin; { The elements in positions FirstElmt through SortBoundary-1 are always sorted. In each pass through the loop, SortBoundary is increased, and the element at the position of the new SortBoundary Probably isn’t in its sorted place in the array, so it’s Inserted into the proper place somewhere between FirstElmt and SortBoundary. } for SortBoundary := FirstElmt + 1 to LastElmt do begin InsertVal := Data[ SortBoundary ]; InsertPos := SortBoundary; while InsertVal< Data[InsertPos-1] do begin Data[InsertPos] := Data[ InsertPos-1 ] ; InsertPos := InsertPos-1; end; Data[ InsertPos ] := InsertVal; end; { Replace orginal lower-boundaryelement } Data[ FirstElmt-1 ] := LowBoundary; end; { InsertionSort } 这个程序的布局在好坏标准数轴的绝对正方向上。这个程序的布局完全按本章讲的原则来 设计。这个程序易读得多,而注释和好的变量名都显而易见,变量名与第一个程序一样好,但 第一个程序的布局太差,显示不出这种好处来。 这段程序与前两个的唯一差别只在有空格——其实代码和注释都一模一样。加空格只是有 利于人的阅读,计算机可认为这三段程序是一样的。你和计算机对程序的感觉不一样是当然的 事情。 另一种格式化的例子见图 18-l。这种方法是基于源代码格式的,由 Ronald M.Baecker 和 Aaron Marcus 创建。这种方法中,除了用空格外,这种方法还用到了阴影、不同字体及别的排版 技巧,Baecker 和 Marcus 创造了一种能按类似图 18-1 所示的方法打印出通常源代码的工具。 虽然这种工具还没有商业化,但由于它支持源代码的布局设计,因而不出几年就会普及。 第十八章 布局和风格 276 用排序技巧把“Data”数组按递增的顺序排序。程序中的 data 数组的第一个元素并不是 Data[FirstElmt]而是 Data[FirstElmt-1]。 图 18-l 用排版技巧来格式源代码 18.1.2 格式化的基本原理 格式化的基本原理是用直观的布局显示程序的逻辑结构。 第十八章 布局和风格 277 使程序看起来显得漂亮,目的是为显示程序的结构。如果一种技巧使结构看起来更好而另 一种技巧也是这样,那就选两种技巧中最好的一个。本章提供了许多格式形式很好但扰乱了逻 辑结构的例子。实际上优化考虑结构并不会使程序难看——除非代码的逻辑结构本身就很别扭。 那种使好代码显得更好、坏代码显得更差的技巧,要比使所有代码都显得好看的技巧更有用。 18.1.3 人和计算机对程序的解释 对程序的结构来说,布局是一个有用的线索,虽然计算机并不理睬 begin 和 end 之类的词, 但人却易于从这些看得见的标志中得到线索,考虑表 18-4 中的代码段,其中用缩排的方法使人 看起觉得每次循环时三条语句都被执行了一次。 如果代码不用括号,那么编译程序执行第一条语句 MAX_ELMTS 次,而第二条、第三条语句 仅执行一次。这种缩排的方式使你我都很清楚地觉得编程员是希望这三条语句一起执行,而应 该放在一个大括号中的,但编译程序却并不这么看。 表 18-4 这个 C 语言程序的布局对人和计算机来说理解是不一样的。 表 18-4 /* swap left and right elements for while array */ for (i=0; I statement1 ; statement2 ; … when GreenColor => statement1 ; statsment2 ; … when Others => statement1 ; statement2 ; … end case; Ada 语言中一个控制结构总有一个起始语句——if-then,while-loop,case-or 等,并且 都有一个对应的结束语句。控制结构中间的缩排却是公认的,但选用别的关键词却有些限制。 表 18-9 抽象地表示了这种格式化形式: 表 18-9 纯块结构布局形式的抽象例子 A ██████████ B ██████ C ███████ D ███ 上例中,A 语句是控制结构的开始,而 D 语句则是结束。这两语句间的其它语句对齐、缩 排。有关格式化控制结构的争论,部分起源于有些语言根本不需块结构。有时 if-then 语句之 后仅限一条语句而不是通常所说的语句块,这时你却得加 begin-end 对或开括号“{”和闭括 号“}”来制造出一个完整块而不是直接在控制结构后跟一条语句了事。程序中的 begin 和 end 不成双,这就产生了在哪去放置 begin 和 end 的问题。通常缩排的问题仅仅是因为你得补偿语 言在设计结构上的不足。下面讨论不同的补偿方法。 18.3.2 行尾布局 如果你所用的不是 Ada 语言,你就不能用纯块结构布局。你得真正用 begin 和 end 关键词 来实现块结构。其中一种方法是“行尾布局”,指一大组语句几乎要退格到行尾的布局方法。这 种行尾缩排方法使下面的一组语句与开始这个控制结构的关键词对齐,而下面的参数行与第一 个参数行对齐排下来。表 18-10 是一个抽象的例子: 第十八章 布局和风格 282 表 18-10 行尾布局形式的抽象例子 A ██████████ B ████ C ██████ D ██ 这个例子中,A 语句是控制结构的开始,D 则是结束,B、C、D 语句与 A 语句的块结构关键 词对齐。B、C、D 统一缩排说明它们是一组的,表 18-11 是一个用这种格式的 Pascal 例子。 表 18-11 行尾布局 Pascal 例子 while PixelColor = RedColor do begin statement1 ; statement2 ; … end ; 这个例子中,begin 放在第一行末尾但不作为相应的关键词,有些人愿意把 begin 作为关 键词,但在这两点间选一个作关键词仅仅是这种风格的一个细节问题。行尾布局风格有几种可 接受的变样,表 18-12 是一个例子: 表 18-12 这种形式的 Pascal 程序虽少见,但这种行尾布局形式也是可行的 if( SoldCount > 1000 )then begin Markdown := 0.10 ; Profit := 0.05 ; end else Markdown := 0.05 ; 这个例子中程序块与 then 后的 begin 对齐,但在最后一行 else 与 then 关键词对齐。这使 得逻辑结构很清楚。 如果你认为前面的 case 语句不怎么样,你可能想要打破这种形式。如果条件表达式变得复 杂了,那么这种布局形式对于理解逻辑结构是无用的或说是有误的。表 18-13 的例子给出了用 复杂控制表达式打破上面例子的布局形式: 表 18-13 这个 Pascal 程序更典型,其中的行尾布局已被破坏 if ( SoldCount > 10 and PrevMonthSales > 10 ) then if ( SoldCount > 100 and PrevMonthSales > 10 ) then if ( SoldCount > 1000 ) then begin Markdown := 0.10; Profit := 0.05 end else Markdown := 0.05 else Markdown := 0.025 else Markdown := 0.0 ; 第十八章 布局和风格 283 这个例子末尾的 else 语句怎样?它们也都与其相应的关键词对齐了,但却不能说这种编 排形式使逻辑结构更清楚了。如修改第一行使其长度变化,那么按行尾布局的要求所有相应语 句的缩排格数都要跟着变,这就在修改程序时产生别的布局形式不会产生的问题。 简言之,不用行尾布局是因为其不精确。它很难有连续性和维修性。本章中你到处可能见 到行尾布局所产生的问题。 18.3.3 模拟纯块结构 若使用的语言不支持纯块结构,那么替代行尾布局的一个较好的选择是,使用语言去模仿 Ada 的纯块结构,表 18-14 是你要模仿的纯块结构抽象表示: 表 18-14 纯块结构布局形式的抽象例子: A ██████████ B ████ C ██████ D ███ 在这种布局形式中,A 句开始块结构,而 D 语句则结束块结构,这表明 begin 需在 A 语句 的结尾而 end 在 D 语句中,要模仿纯块结构,其抽象过程如表 18-15 所示: 表 18-15 模仿纯块结构布局的抽象例子 A ██████████ B █████████ C ██████████ D ████ 表 18-16、18-17、18-18 是以上类型的具体的 Pascal 例子: 表 18-16 模仿纯 if 块结构的 Pascal 例子 If PixelColor = RedColor then begin statement1 ; statement2 ; … end; 表 18-17 模仿纯 While 块结构的 Pascal 例子 while PixelColor = RedColor do begin statement1 ; statement2 ; … end; 表 18-18 模仿纯 case 块结构的 Pascal 例子 case PixelColor of RedCelor : begin statement1; statement2; … end; GreenColor : begin 第十八章 布局和风格 284 statement1; statement2; … end else begin statement1; statement2; … end end; 这种控制语句内句子对齐的形式显得很好,你能连续地应用这种结构形式,它的维护性也 很好。这种形式符合格式化的基本原理且能显示代码的逻辑结构。因此这种形式充分可行。要 注意的是这种形式在 Pascal 中不常用,但在 C 中用得很多。 18.3.4 用 begin 和 end 作块的边界 取代纯块结构的一个替换方法是使用 begin 和 end 作为块的边界而成对出现。用这种方法 时,begin 和 end 作为控制语句下的一个语句而非作为控制语句的一个部分。 模仿纯块结构的抽象结构如表 18-19。 表 18-19 模仿纯块结构布局形式的抽象例子 A ████████████ B ███████ C ████████ D ████ 在把 begin-end 作块边界的方法中,我们是把 begin 和 end 用为块结构本身的一部分而不 是作为控制语句的一部分。你得把 begin 置于块的开始(而非放在控制语句的末尾),而把 end 放在块的结尾(而非作为控制结构的结束符)。作为一种抽象的表示方法,可把这种结构如 18-20 表示出来: 表 18-20 用 begin 和 end 作为块边界的抽象例子 A ████████████ ██████████ B ██████████ C ██████████ █████ 表 18-21、18-22、18-23 分别给出这种形式的具体 Pascal 例子。 表 18-21 在 if 块中用 begin 和 end 作块边界的 Pascal 例子 if PixelColor = RedColor then begin statement1 ; statement2 ; … end; 第十八章 布局和风格 285 表 18-22 在 while 块中用 begin 和 end 作块边界的 Pascal 例子 while PixelColor= RedColor do begin statement1; statement2; … end; 表 18-23 在 case 块中用 begin 和 end 作块边界的 Pascal 例子 case PixelColor of RedColor, begin statement1; statement2; end; GreenColor: begin statement1; statement2; end else begin statement1; statement2; end end; 这种对齐块中语句的方法显得很好,它满足了格式化的基本原理又充分体现了逻辑结构。 在所有情形下都能连续使用且维护性很好。 18.3.5 哪种形式是好 很容易回答哪种形式最次。行尾布局是最次的,这种形式无连续性且难维修。 如果用 Ada 编程,用纯块结构的缩排方法。 如果用 C 或 Pascal 编程,选择使用模仿纯块方法或 begin-end 作块边界的方法。这两种方 法基本上没有什么区别,选择你所喜欢的。 以上各种形式都不是绝对的,都需要考虑各种偶然的因素,并采取综合兼顾的方法。你可能觉 得这种或那种更美观。本书都用到了多种程序方法,通过看例子你可领略这些风格的异同。一 旦选定某种形式,你应连续地应用去发现好布局的优点。 18.4 控制结构布局 对于一些程序来讲,布局基本上是一个美学问题,但是控制结构的布局却影响到程序的可读性 第十八章 布局和风格 286 和理解性,因而是个需要考虑的问题。 18.4.1 关于格式化控制结构块的几点好意见 涉及到控制结构块的布局时需注意到几点细节,以下提供一些指导。 begin-end 对应当退格。在表 18-24 中 begin-end 对与控制结构语句对齐了,但 begin-end 对内的语句相对 begin 退了两格。 表 18-24 begin-end 对内语句缩排的 Pascal 例子 for i := 1 to Maxlines do begin 与 for 对齐 begin Readline(i); 这两个语句缩进两格 PrecessLine(i); end —— end 与 for 对齐 这种方法看起来很好,但它却违反了格式化基本原理。它并没有显示出代码的逻辑结构。 这种方法中,begin 和 end 好像既不是控制结构的一部分,也不属于它们之间语句组的一部分。 表 18-25 是这种方法的示意图。 表 18-25 引起错误导向的缩排方法的抽象例子 A ████████████ B ██████ C ██████ D ████████ E ███ 这个例子中,语句 B 属于 A 吗?它看起来不像是 A 语句的一部分。如果你已用了这种方 法,把它改成前面讲过的两种布局形式,这样你的格式化会更清楚些。 用了 begin-end 对的代码不要进行两次缩排。反对 begin-end 对无缩排的对立面,反对 begin-end 对中再次缩排,这种方法如 18-26 所示,begin 和 end 相对控制语句退后几格,而它 们之间包含的语句又退后了几格。 表 18-26 用 begin-end 对缩排两次的 Pascal 例子 for i := 1 to MaxLines do begin ReadLine(i); ProcessLine(i); end 这是另外一种看起来很好但却违反了格式化基本原理的另一布局形式。研究表明,一次缩 排与两次缩排在理解上是差不多的,但却不能正确反映代码的逻辑结构。ReadLine()和 ProcessLine()看起来好像从属于 begin-end 对,但事实上却不是。 这种方法增加了程序逻辑结构的复杂性,表 18-27 和表 18-28 中哪一个看起来更复杂? 表 18-27 抽象结构 1 █████████████ ████ ███████ ████████ ███ 第十八章 布局和风格 287 表 18-28 抽象结构 2 █████████████ ████ ███████ ████████ ███ 上述两个抽象结构都代表了 for 循环的结构。虽然两者形式表示同一代码,但抽象结构 1 却显得比抽象结构 2 更复杂。如果你的嵌套有两至三层,那么这种两次缩排的形式会使你的程 序产生四到六次退格。这种布局会使代码显得比原来的更复杂。避免的方法是用纯块结构模仿 法,或用 begin 和 end 作块边界并与其内部语句对齐的方法。 18.4.2 其它方法 虽然块的缩排是格式化控制结构的主要方法,但也有另外几种可供选用的方法。以下提供 几点参考。 段之间用空行。有些代码的块不能用 begin-end 对来划分。一个逻辑块——同一类的一组 语句——应当像写英语文章一样一起形成一段。把这样的逻辑块(段)之间用空行隔开。表 18-29 的例子便是用空格隔开的例子。 表 18-29 应当组织成块并隔开的 C 语言例子 Cursor.Start = StartingScanLine; Cursor.Ed = EndingScanLine; Window.Title = EditWindow.Title; Window.Dimensions = EditWindow.Dimensions; Window.ForegroundColor = UserPreferences.ForegroundColor; Window.BlinkRate = UserPreference.ForegroundColor; Windows.BackgroundColor = UserPreferences.BackgroundColor; SaveCursor(Cursor); SetCursor(Cursor); 这段代码是正确的,但有两条理由应当用空行。第一,当一组语句与执行顺序无关时,你 可以如上例随意混在一起,无需为计算机作进一步的排序。但是作为读者都希望从你的特定程 序顺序中获得某些线索,以判定哪些句子该属于同一组。在一段程序中加上空行使你很清楚地 知道哪些语句是属于一起的。修改程序如表 18-30 所示。 修改后的代码显示出程序要做两件事,但: 表 18-30 这个 C 程序把语句适当分组后分开 Windows.Dimensions = EditWindow.Dimensions; Window.Title = EditWindow.Title; Window.BackgroundColor = UserPreferences.BackgroundColor; Windows.ForgroundColor = UserPreferences.ForegroundColor; Cursor.Start = StartingScanLine; Cursor.End = EndingScanLine; 第十八章 布局和风格 288 Cursor.BlinkRate = EditMode.BlinkRate; SaveCursor(cursor); SetCursor(Cursor); 上一个例子不分组也无空行,使人觉得好像这些语句都有联系似的。 第二个加空行分段后,若想给每块写上注释,那这里相当于自然地留下了空间。本例中若 要加上注释则更提高了布局的透明性。 单条语句的程序块也要坚持格式化。单语句程序块是一个控制结构仅跟一条语句,比如 if 测试条件后仅跟一条语句。这种情形中,begin 和 end 对于保证正确的编译是不必要的了。这 种情况有如表 18-31 所示的三种可选格式。 表 18-31 这个 Pascal 例子提供三种 for 的单语句块的选择模式 if( expression ) then ——格式 1 one-statement; if( expression ) then begin ——格式 2a one-statement; end; if( expression ) then ——格式 2b begin one-statement; end; if( expression ) then one-statement; ——格式 3 这三种方法各有千秋。格式 1 是单条语句退格作为一个块方式,它显得很协调。格式 2(2a 或 2b)也很协调,它在 if 测试条件后增加了语句,完了再加上 begin 和 end 对,因而减少了 出错的机会。这种错误常很隐含,因为你所增加的句子都是退格写的,不注意是看不出来的; 但是计算机却不理会这种退格。格式 3 是这样一种形式,当把它拷回到另的地方去时,作为一 个整体拷贝出错的机会很少。这种格式的一个不好之处便是在一个面向行调试器中,调试器把 这整行当作一行看待且不显示在 if 测试条件后的行是否被执行。 我曾用过格式 1,并且常成为修改时出错的受害者。我也不喜欢缩排方式的变异情况格式, 因而总是避免用它,我比较喜欢用格式 2 的两种情况,因为它们看起来协调且易修改。不管你 用哪种格式,你要一直都用这种格式。 对于复杂的表达式,把单独的条件列成单独的一行。把复杂条件的每一部分写成自己单独 的一行。表 18-32 显示了一个没注意可读性的例子。 这个例子是为计算机格式化的而不是为读者。 表 18-32 这个 Pascal 例子的表达式可读性极差 if ( ( '0' <= InChar and InChar <= '9' ) or ( 'a' <= InChar and InChar <= 'z' ) or ( 'A' <= InChar and InChar <= 'Z' )) then 第十八章 布局和风格 289 … 把条件分成几行,如表 18-3 所示,能增强可读性。 表 18-33 这个 Pascal 例子条件虽然复杂却可读 if ( ( '0' <= InChar and InChar <= '9') or ( 'a' <= InChar and InChar <= 'z' ) or ( 'A' <= InChar and InChar <= 'Z' )) then 这个程序段中用了几个格式化的技巧——对齐、退格,使各行明显独立——使得条件表式 可读性好。而更重要的是这个测试条件的用意很明显。如果表达式包含了一个小错误,如把“Z” 写成了“z”。那么上面程序中能很清楚地表现出来。 避免用 goto 语句。避免用 goto 语句的最基本原因是,它使得程序很难被证明是正确的。 这点被所有希望自己程序是正确的人所接受。而更急迫的问题用了 goto 则很难把程序格式化。 goto 语句及它要转向的标号之间的语句都要往后退格吗?假如有几个 goto 语句转向同一个标 号你又该怎么退格?下一个 goto 相对前一个 goto 语句退格吗?以下几点对格式化 goto 会有帮 助。 · 避免用 goto,这就不存在再把程序格式化的问题了。 · 给 goto 所要转向的标号起个名字,这使得标号明显。 · 把含 goto 的语句单独列成一行,这使得 goto 更突出。 · 把 goto 所要转向的标号单独列成一行,且前后都加上空行。这使得标号明显。把含标 号的行与周围的行退同样的格数,以使得程序的逻辑结构看起来紧凑。 表 18-34 显示了 goto 语句的例子。 表 18-34 这个 Pascal 例子在用 goto 的情况下使程序显得很好 PROCEDURE PurgeFiles ( var ErrorCode : ERROR_CODE ); var FileldX: Integer; FlleHandle: FILEHANDLE_T; FileList: FILLIST_T; NumFllesToPurge: Integer; label END_PROC; begin GrabScreen; GrabHelp ( PURGE FILES ) ; MakePurgeFileList ( FileList, NumFilesToPurge ); ErrorCode := Success; FileldX := 0; while ( Fileldx < NumFilesToPurge ) do begin Fileldx := Fileldx + 1 ; 第十八章 布局和风格 290 if Not FindFile( FileList [FileIdx], FileHandle ) then begin ErrorCode := FileFindError; goto END_PROC; ——这里有一个 goto end; if not OpenFile ( FileHandle ) then begin ErrorCode := FileOpenError ; goto END_PROC; ——这里有一个 goto end; if not OverwriteFile ( FileHandle ) then begin ErrorCode := FileOverwriteError; goto END_PROC; ——这里有一个 goto end; if not Erase ( FileHandle ) then begin ElrorCode := FileEraseError; goto END_PROC; ——这里有一个 goto end; end ; { while } END_PROC ——这里是 goto 的标号 DeletePurgeFileList ( FileList , NumFileToPurge ); ReleaseHelp; ReleaseScreen; end; { PurgeFiles } 这个 Pascal 例子较长,在这种情况下一个专业编程员可能认为 goto 就是最好的选择。这 种情况下,表 18-34 是你所能做到格式化的最好地步。 不要用行尾布局结构特别是 case 语句。用行尾布局方法时 case 语句在修改时会出现严重 问题,一个常用的格式化 case 语句的方法是把每一种情况的执行语句退格到这种情况的描述语 句之右(后),如表 18-35 所示。但在修改时这种形式却产生很大问题。 表 18-35 把行尾结构用于 case 语句很难修改的 Paseal 例子 case BullColor of Blue : Rollout( Ball ); Orange : SpinOnFinger( Ball ); FluorescentGreen : Spike( Ball ); White : KnockCoverOff( Ball ); WhiteAndBlue : begin if( MainColor := White ) then begin 第十八章 布局和风格 291 KnockCoverOff( Ball ); end else if( MainColor := Blue ) then begin Rollout( Ball ); end end else FatalError( "Unrecognized kind of ball.”) end; { case } 如果你增加一种情况而它的描述名字比所有已存在的名字长,你就要把所有情况后的执行 代码整个右移,而开始时很多的退格已经使得逻辑结构不能再往右移,如上例中 WhiteAndBlue 情况,解决的办法是,把你在每一种情况下的退格数确定下来而把执行语句移到描述语句的下 一行。如果在循环中描述语句退三格,则在每一种情况下,执行语句在下一句都退后三格,如 表 18-36 所示: 表 18-36 按标准数退格的 Pascal 例子 case BallColor of Blue: Rollout( Ball ); Orange: SpinOnFinger( Ball ); FluorescentGreen: Spike( Ball ); White: KnockCoverOff( Ball ); WhiteAndBlue: begin if( MainColor = White ) then begin KnockCoverOff( Ball ); end else if( MainColor = Blue ) then begin Rollout( Ball ); end end else FatalError("Unrecognized Kind of Ball.") end;{case} 本例子是大多数人愿意看到的样子。它轻易地使程序在有长句子时易修改、具有连贯性、 可维护性等。 如果你的 case 语句的各情况基本平行且执行语句很短,你可考虑把情况的描述语句与执 第十八章 布局和风格 292 行语句放在同一行,然而在多数情况下你不要作这种指望。因为格式化过程是一个变动很大的 修改过程,且很难保证长的执行语句与短执行语句平行。 18.5 单条语句布局 本节给出了在程序中如何安排好单条语句的方法。 18.5.1 语句长度 一个常规是限制一条语句不超过 80 个字符,以下是原因: · 超过 80 个字符的语句很难读。 · 80 个字符的限制也防止了深层嵌套。 · 超过 80 个字符的行不能在 8.5" X 11"的打印纸上打印。 · 超过 8.5" X 11"的打印纸不好用。 现在已有了宽显示器、字体窄打印机(一行打印超过 80 字符)、激光打印机、前景模式显 示器,那么对 80 个字符的限制已不如以前那么有效了。把一句写成 90 字符一行单句远比为了 避免超出 80 字符而分成两句写为好。现在的水平允许偶尔让一行超过 80 字符。 18.5.2 用空格使语句显得清楚 用空格加在语句中有时可增强可读性。 用空格使逻辑表达式可读。表达式 while(pathName[startpath+pos ] <> ';' )and (( startpath + pos ) <= length ( Pathname )) do 就显得很难懂。 作为一种规定,你可用空格把标识符分开。用这种规定,上述 while 语句如下所示: while(pathName[ startpath+pos ] <> ':' )and ((startpath + pos ) <= length ( Pathname )) do 有些软件专家可能在上述表达式加更多的空格以强调其逻辑结构。如下: while( PathName [ startpath 十 pos ] < ':' ) and ( ( startpath + pos ) < = length( Pathname ) ) do 这就显得很好,空格有效地增强了可读性。多加的空格不造成什么资源浪费,所以尽可用 上。 用空格使数组下标更好读。表达式: GrossRate [ Census[ GroupID ].Sex, Census[GroupID].AgeGroup] 这个程序跟前面的挤在一起的 while 表达式一样难读。在数组下标前后加上空格使其更好 读。如果用上述规则,表达式如下: GrossRate [ Census [ GroupID ].Sex,Census [ GroupsID ].AgeGroup ] 用空格使子程序参数更好读。下面子程序有四个参数,但各是什么呢?看不太清。 ReadEmployeeData(MaxEmps,EmpData,InputFile,EmpCount,InputError); 经加空格后能看清楚吗? 第十八章 布局和风格 293 GetCensus ( InPutFile, EmpCount, EmpData, MaxEmps, InputError); 上面两个哪个更清楚?这是一个现实的有意义的问题,因为所有的语言都是涉及到子程序 参数,它的位置也很重要。通常是在上半屏幕定义子程序的参数表,而下半屏幕就是调用它的 地方,两者参数正好一一对应地作比较。 18.5.3 把相关的赋值语句对齐 若几个赋值语句是相关的,则应把等号对齐。表 18-37 却是没有对齐的例子: 表 18-37 这个 Basic 的赋值语句等号未对齐 EmployeeName = InputName EmployeeSalary = InputSalary EmployeeBirthdate = InputBirthdate 表 18-38 则做得较好。 表 18-38 Basic 的等号对齐,较好看 EmployeeName = InputName EmployeeSalary = InputSalary EmployeeBirthdate = InputBirthdate 第二个程序段除了看起来整齐外,其格式化也较第一个好,但如何看待等号前的空格呢? (多占存储空间)。又如何看待为对齐等号而多做的工作呢? 这种做法主要的考虑是因那些语句是同类的面对齐等号正好从直观上反映了这一点。如果 这几句不是相关的,最好别这样做。表 18-39 是一个引起误解的对齐: 表 10-39 引起误解的对齐的 Basic 例子 EmployeeName = InputName EmployeeAddress = InputAddress EmployeePhone = InputPhone BossTitle = Title BossDept = Department 以上例子使人误以为是同类操作,但实际上却做了两件事:一个是有关雇员的数据,另一 是老板的数据。格式化这段程序的目的是要区分这两件事,而改的方法就是分别把各自的等号 对齐并在中间加一空行,如表 18-40 所示。 表 18-40 是正确的对齐等号的 Basic 例子 EmployeeName = InputName EmployeeAddress = InputAddress EmployeePhone = InputPhone BossTitle = Title BossDept = Department 18.5.4 格式化续行 程序布局一个伤脑筋的事情是如何安排好续行。对于一行写不下,而在下一行继续的语句 第十八章 布局和风格 294 行,你能按标准格数退后吗?要把它与关键字对齐吗?对赋值语句又怎样续行? 下面的方法是一个有用的、协调的方法,特别是对 Pascal,C,C++,Ada 及其它支持写长 变量名的语言更有用。 使续行明显。有时必须要把一个语句拆成两句写,原因可能是一个语句太长而一个标准行 内无法装下,或把什么都放在一行里显得很不合理。这种情况下,放在第一行中的那部分要清 清楚楚地表明它仅是一个语句的一部分。断句最好的方法是若第一行部分独立出来则它有明显 的语法错误。表 18-41 是一些例子: 表 18-41 这些 Pascal 例子不完全部分很明显 while( PathName[StartPath + Pos ] <> ';' ) and ——and 表示这个语句不完整 ( ( StartPath + Pos ) <= length (PathName ))do … TotalBill := TotalBill + CustomerPurchases [ CustomerID ]+ ——表示这个语句不完整 SalesTax( CustomerPurchases[ CustomerID ] ) ; … DrawLine(Window.North , Window.South , Window.East , Window.west, ——表示这个语句不完整 CurrentWidth,CurrentAttribute ) ; … 除了能告诉读者这个第一行部分不是一个完整的句子外,这种断句的方法也可避免在修 改时出错,如果你把续行部分去掉了,那么第一行看起来不仅仅是一个忘了括号或分号的问 题,它是缺成份。 把紧密关联的元素放在一起。当你断开一个句子时,把相关的事物放在一起,如数组下标, 子程序参量等。表 18-42 是一个不太好的例子: 表 18-42 断句不大好的例子 CustomerBill := PrevBalance( PayMentHistry[ CustomerID ] )+ LateCharge( PaymentHistory[ CustomerID ] ) ; 不可否认,上例中的断句法确实使分开的两个部分明显不能独立,但却无谓地增加了不可 读性。你可能发现在有些情况下这种断法可以,但本例却没必要这么断。把数组下标与数组名 放在一起是必要的。表 18-43 是较好的断句法: 表 18-43 这个 Pascal 程序断句较好 CustomerBill := PrevBalance( PaymentHistory[ CustomerID ] ) + LateCharge( PaymentHistory[ CustomerID ] ) ; 子程序调用的续行可退后标准格数。假如你的循环或条件语句缩排三个空格,那么子程序 调用语句的续行也后退三个空格。表 18-44 是这个例子: 表 18-44 这个 Pascal 例子的子程序调用的续行用了标准退格 DrawLine ( Window.North, Window.South, Window.East, Window.West, CurrentWidth, CurrentAttribute ); SetFontAttributes( Font.FaceName, Font.Size, Font.Bold, Font.Italic, 第十八章 布局和风格 295 Font.SyntheticAttribute[ FontID ].Underline, Fout.SyntheticAttribute[ FontID ].Strikeout) 另一可选用的办法是把续行起始处放在上行的第一个参数处,如表 18-45 所示: 表 18-45 这个 Pascal 程序把续行放在第一个参数下以示强调子程序名 DrawLine( Window.North , Window.South , Window.East , Window.West , CurrentWidth , CurrentAttribute ) ; SetFontAttributes( Font.FaceName , Font.Size , Font.Bold , Font.Italic , Font.SyntheticAttribute[ FontID ].Underline, Font.SyntheticAttribute[ FontID ].Strikeout ) ; 从美学观点来看,这个程序看起来有点参差不齐,但它却突出了子程序名,因而你选用它 是一个个人爱好问题。 使续行的结尾易于发现。上面几例有一个问题便是不容易找到每行的结尾,一种可选择方 法是把每个参数放在自己单独的一行,最后用一个闭括号括住以示结束,表 18-46 是这种例子。 表 18-46 这个 Pascal 例子,格式化子程序调用续行时把一个参数名放一行 DrawLine ( Window.North , Window.South , Window.East , Window.West , CurrentWidth , CurrentAttribute ) ; SetFontAttributes ( Font.FaceName , Font.Size , Font.Bold , Font.Italic , Font.SyntheticAttribute[ FontID ].Underline , Font.SyntheticAttribute[ FontID ].Strikeout ); 最后使每个调用句子的结束显得很清楚。实际上,仅有很少的子程序调用句子需分成几行来 写。以上提供的三种处理子程序调用续行的方法都较好,但你得用得一致。 控制语句的续行应编排标准格数。假如你一行写不下 for、while 循环语句或 if 语句,那 么其续行退后的格数与循环或 if 语句后的语句退后格数一样。表 18-47 是这样的两个例子。 表 18-47 这个 Pascal 例子处理好了控制语句续行的编排 while( PathName[ StartPath + Pos ] <> ’;’) and ( ( StartPath + Pos) <= length ( PathName ) ) do ——这个续行缩进标准格数 第十八章 布局和风格 296 begin … end; for RecNum := ( Employee.Rec.Start + Employee.Rec.Offset ) to ( Employee.Rec.Start + Employee.Rec.Offset + Employee.NumRecs ) do begin … end; 因为 C 的格式化有点不一样,那么在 C 中类似的写法如表 18-48 所示。 表 18-48 C 中处理控制语句续行的例子 while( PathName[ StartPath 十 Pos ] != ';') && ( ( StartPath + Pos )<= length ( PathName ) ) { … } for( RecNum = Employee.Rec.Start + Employee.Rec.Offset; RecNum <= Employee.Rec.Start + Employee.Rec.Offset + Employee.NumRecs; RecNum ++ ) { … } 这种写法正好满足了本章早些时候提出的原则,语句的续行部分处理得很合乎道理——总 是在它所对应的句子下退格。这种缩排可连续做下去,只不过到最后比最开始的句子多退几格 罢了,这种方法跟别的好方法一样可读易修改。有时你可能通过加空格或空行的方法来增强可 读性,但要记住,当你想着怎样提高可读性时不要忘了维护性。 赋值语句的统行要写在赋值号以后。受上面处理续行方法的影响,你可能也要在赋值语句 行时想到缩排标准空格,但千万别这样做。这时若后退标准空格会严重扰乱了赋位语句组的直 观。如表 18-49 所示: 表 18-49 这个 Pascal 例子是个不好的实例,因在赋值语句的续行时也后退标准空格 CustomerPurchases := CustomerPurchases + CustomerSales(CustomerID ); CustomerBill := CustomerBill + CustomerPurchases ; TotalCustomerBill := CustomerBill + PreviousBalance( CustomerID )+ LateCharge ( CustomerID ) ; CustomerRating := Rating ( CustomerID, TotalCustomerBill ) ; 本程序的目的是想通过对齐赋值号,来表明这是一组相关例子。但续行 LateCharge (customerID)因为仅后退标准格数而影响了这种直观性,这种情况下后退标准格数却没有获 在别的地方所应有的可读性。这时用行尾布局法却很好。表 18-50 表明了这种情况该怎样格式 化代码: 第十八章 布局和风格 297 表 18-50 这个 Pascal 例子显示了如何用行尾布局来格式化赋值语句续行 CustomerPurchases := CustomerPurchases + CustomerSales ( CustomerID ); CustomerBill := CustomerBill + CustomerPurchases; TotalCustomerBill := CustomerBill + PreviousBalance ( CustomerID ) + LateCharge ( CustomerID ); CustomerRating := Rating ( CustomerID, TotalCustomerBill ); 18.5.5 每行仅写一条语句 较高级的几种语言如 Pascal,C,Ada 允许一行写多条语句。Fortran 要求注释行由第一列 开始写起,而实际语句则由第 7 列或以后开始写起,但自由格式化对这一要求是一个很大的提 高。自由格式有多种好处,但却有把多条语句放在一行的不好之处。 i=0,j=0,k=0;DestroyBadLoopNames(i,j,k); 这一行包含了几条语句,而这几条语句实际可以各自放一行的。 把几条语句放在一行的支持意见认为,这样所占屏幕空间或打印纸空间小,因而能同时看 到更多的代码,而且这也是把相关语句组织在一起的方法,有些程序员甚至称这是给编译程序 提供线索的方法。 这些原因都很好,但要求你一行写一条语句的理由更充分: · 一行写一句准确地反映了程序的复杂性。它不因为把几句写成一行而隐藏了程序的 复杂性因而显得琐碎。语句是复杂就是复杂,语句是简单就该如所见的那么简单。 · 一行写几条语句也不会给现代的编译程序提供什么优化线索,现在的组合编译程序并 不依赖格式化的线索去作出它们的选择。这一点后面详述。 · 一行写一句使程序能从上往下读,而不是从上往下之中又有从左至右。当你要搜索某 一代码行时,你的眼睛只需停留在代码的左边缘而不用担心因为一行含两句而要往右 看。 · 一行写一句容易寻找语法错误,因为编译程序仅提供出错的行号。如果你的一行里有 多句,这个行号能告诉你到底是哪句出错了吗? · 一行写一句容易用面向行调试程序来设计代码。如果一行写几句,调试程序一次调试 这几句,你就不得不切换成单条语句的汇编方法。 · 一行写一句容易编辑单条语句——去掉一行或把一行转变成注释。如果一行写多句, 你就还得编辑其它语句。 C 中应避免产生副作用。副作用是一条语句的附加作用,而不是主要作用。在 C 中,++运 算符在一行含有别的运算时就是一种有副作用的运算,同样,在条件语句中用左侧赋值就是一 种副作用。 副作用使代码难读,比如在表 18-51 中,如果 n 等 4,那么输出结果是什么呢? 表 18-5 1 一个意义不明的有副作用的 C 程序 Printf(”%d.%d\n”,++n, n+2); 那么是 4 和 6 呢?还是或 5 和 7 呢?还是 5 和 6 呢?以上都不对。第一项++n 结果为 5。但是 C 语言却并没有定义运算的次序,因而编译程序既可计算第二项 n+2 在第一项之前,也可在第 一项之后,结果可以是 6 或 7,这得看不同的编译程序。表 18-52 是如何修改以看得更清楚: 第十八章 布局和风格 298 表 18-52 避免产生意义不清副作用的 C 语言 ++n; printf(“%d%d\n”,n,n+2); 如果你还没有想清为何把产生副作用的运算单独放在一行的话,那请指出表 18-53 中程序 的都做了什么。 表 18-53 这个 C 程序一行中运算太多 strcpy(char * t, char * s) { while (* ++ t = * ++ s ) } 许多有经验的程序员可能不觉得有何复杂,因为它很熟悉,一看可能会说它是 strcpy()函 数。但这个程序却不是 strcpy(),它有错误。这就是那种你一看就认识但却没有仔细读它因而 忽略了错误的情形。表 18-54 修改后显得可读: 表 18-54 这个 C 程序把各操作放在各自的行中增强可读性 strcpy( char * t,char * s ) { do { ++ t; ++ s; * t = * s; } while ( * t != ‘\n’ ) } 这个程序中的错误非常明显。很明显,t 和 s 在把*S 赋给 *t 之前已各自加 1,因而漏过了 第一个字符的复制。 提高程序的性能不能由把多个运算放在同一行来判定。因为以上两个 strcpy()程序逻辑上 是等价的,那你可能认为编译程序应当生成相同的代码。但当你用两个程序复制 50000 个字符 的字符率时,你会发现第一个程序用 3.83 秒而第二个只用 3.34 秒。 即使你读有副作用语句时非常轻松,不要指望别人也跟你一样,大多数程序员读这种程序 时要读两次才能理解。宁可花些脑筋去理解你程序中可能出现的许多问题,也不要把一些语法 问题接合在一个特殊的语句中。 18.5.6 数据类型定义布局 注意数据类型定义的对齐。对齐数据类型定义的主要原因和对齐赋值语句是不同的。你已 知道所有的数据类型定义都是有联系的,因此这种对齐的好处是显得整齐且能在右列很快浏览 下来。定义表的右侧的内容各不相同,有些语言的右列含的是变量类型,而另一些则合的是变 量名。如果所用语言为 Pascal,那么你必须如表 18-55 一样把数据类型写在表的右侧: 第十八章 布局和风格 299 表 18-55 怎样对齐数类型定义的 Pascal 例子 SortBoundary: Integer; InsertPos: Imeger; InsertVal: SORT_STRING; LowerBoundary: SORT_STRING; 这个例中,你或许要说着定义表的右侧部分根本看不出什么,除非你要经常看看 Integer(整型)的而非 SORT_STRING 型的。 但在 C 语言中,数据名是放在右边的,如表 18-56 所示: 表 18-56 C 语言中对齐数据类型定义的例子 int SortBoundary; int InsertPos; SORT_STRING InsertVal; SORT_STRING LowerBoundary; 这个例子中,浏览列表的右侧部分是有用的,因为这部分是变量名。 每行只定义一个数据。如以上两例所示,你一行只能定义一个数据。若一行仅含一个数据 定义,那在其后加注释是轻而易举的事情,同时修改起来也很方便;要找出某个变量名很容易, 不需从左到右读完一行;在出错时容易发现和修改语法错误。 相反地,在表 18-57 的数据定义中,你能说出 CurrentBottom 是哪一类型的变量吗? 表 18-57 C 程序把几个交量定义都放在一行了 int Rowldx,ColIdX;COLOR PreviousScreen,CurrentScreen,NextScreen; POINT PrevlousTop, PreviousBottom,CurrentTop,CurrentBottom,NextTop, NextBottom; FONT PreviousFace, CurrentFace, NextFace; COLOR Choices [ NUM_COLORS ]; 这种定义变量的方法并不常见,但关键是因为所有的定义都挤在一起,你很难找出某个指 定的变量。变量类型也很难一下子找出。上例就是那种可读性差的例子。 表 18-58 几个变量定义挤在一行的例子 RowIdx,ColIdx: Integer; CurrentScreen, NextScreen, PreviousScreen: COLOR CurrentBottom,CurrentTop, NextBottom,NextTop, PreviousBottom, PreviousTop: POINT; CurrentFace, NextFace, PreviousFace: FONT; 第十八章 布局和风格 300 Choices : array[ 1..NUM_COLORS ] of COLOR; 这也是种常用的形式,它试图把相应的项对齐,但是每一个仅用一种类型名、一行中放了 几个变量名,无论从美学观点还是从可读性来看,这种形式并不比上一例好多少。 那么表 18-59 中的 NextScreen 的类型是什么呢?这种形式中一行仅定义一个变量,每行都 有一个变量类型,相当于一个完整的定义,这种对齐看起来相当美观。 表 18-59 一行说明一个变量的例子 RowIdx: Integer; ColIdx: Integer; CurrentBottom: POINT; CurrnetRop: PONIT; NextBottom: POINT; NextTop: POINT PreviousBottom: POINT; PreviousTOP: POINT; CurrentScreen: COLOR; NextScreen: COLOR; PreviousScreen: COLOR; Choices: array[ 1..NUM_COLORS ] of COLOR ; CurrentFace: FONT; NextFace: FONT; PreviousFace: FONT; 当然,这种类型也多用了一大堆空格,并不能说明形式增强了可理解性。但是如果 Sally Programmer, Jr 要我去检查她的代码数据定义如第一种形式,我会说:“太不好了,简直无法 读。”如果是第二种形式,我会说:“嗯,可能我还不如去看第一种。”若是第三种,我会说:“当 然,好极了。” 有意识地安排定义顺序。在第三种形式中,各定义按类型组织在一起。按类型把定义组织 在一起比较合理,因为同一类型的变量用在相关运算中的可能性较大。另一种情况是,你可能 会按变量名开头字母的先后顺序来安排定义表。虽然按字母顺序排的原因很多,但我的感觉是 没必要这样做,如果你的变量表很长,这时按字母排会对你有帮助的话,那么这时程序也肯定 相当长,那我还是建议你把它分成几个小的子程序,每一个程序也就仅含几个变量了。 C 语言中,在定义指针变量时,把星号(* )放在紧靠类型名之后。在指针变量的定义中, 常见的是把星号(* )靠近变量名而非类型名,如表 18-60 所示: 表 18-60 这个 C 程序中,指针走义容易引起误解 EMP_LIST *Employees; FILE *InputFile; 虽然这种写法相当普遍,但却容易引起误解。事实上,若一个变量的指针变量,那么星号属于 类型的一部分,若星号靠近变量,它显得好像星号是变量名的一部分,引起误解的原因是这个 第十八章 布局和风格 301 变量使用中可以用也可以不用星号。把星号放在靠近类型名的后面则不会有这种误解。如表 18-61 所示: 表 18-61 这个 C 语官的指针变量定义写法正确 EMP_LIST * Employees; FILE * InputFile 这种写法的一个问题是若在一行定义多个变量,星号到底属于谁呢?实际上星号仅属于第 一个变量。此时若几个变量都是指针类型,那么先定义一个指针类型名,然后用这个类型名来 定义变量,表 18-62 就是这样的例子: 表 18-62 这个 C 程序用指针类型名来定义指针变量,很好 EMP_LIST_PTR Employees; File_PTR InputFile; 18.6 注释布局 一个好的注释能极大提高程序的可读性。若注释不成功,则会帮倒忙,而能否安排好注释 对于是增强还是损坏可读性关系甚大。 注释行与相应的代码同样缩排。一个好的缩排方式能有助于理解程序的逻辑结构,好的注 释行不应当破坏这种美观的缩排方式。表 18-63 中你能看出其逻辑结构吗? 表 18-63 这个 Basic 程序注释行的缩排不正确 for TransactionID = 1 TO MaxRecords ' get transaction data read TransactionType read TransactionAmount 'procass transaction based on transaction type if TransactionType = CustomerSale then AcceptCoustomerSale(TransactionAmount) elseif TransactionType= CustomerReturn then 'either process return automatcally or get manager approval, 'if required if TransactionAmount >= MgrApprovalRequired then ' try to got manager approval and then accept or reject return ' based on whether approval is granted GetMgrApproval(APProval) if Approval = True then AcceptCustomerReturn(TransatiopnAmount) 第十八章 布局和风格 302 else RejectCustomerReturn(TransactionAmount) end if else 'manager approval not required, so accept return AcceptCustomerReturn(TransactionAmount) end if end if next TransactionlD 从这个例子中你得不到多少程序逻辑结构的线索,因为注释行不正确的退格完全掩盖了代 码的可观性。你可能很难相信还有人如此糊涂地用这样一种缩排方式,但事实上我确实见过, 甚至在教料书上。 表 18-64 程序与 18-63 一模一样,所不同者是注释行的退格方式。 表 18-64 这个 Basic 程序正确地缩排了注释行 for TransactionID = 1 To MaxRecords 'read Transaction data read TramsactionType read TransacionAmount 'process transaction based on transaction type if TransactionType = Customersalc then AcceptCustomerSale(TransactionAmount) elseif TransactionType = CustomerReturn then ' either process return automatically or get manager approval, 'if required if TransactionAmoun t>= MgrApprovalRequired then 'try toget manager approval and then accept or reject the return 'basedo on wherther approval is granted GetMgrApproval(Approval) if Approval = True then AcceptCustomerReturn(TransactionAmount) else RejectCustomerReturn(TransactionAmount) end if else 'manager approval not required, so accept return 第十八章 布局和风格 303 AcceptCustomerRetun(TransactionAmount) end if end if next TransactionID 表 18-64 中程序的逻辑结构更明显、研究表明,注释对程序的可读性并不都有帮助,因为 注释行安排不当常破坏了程序直观性。从以上这些例子你不是已有所感触了吗? 把注释行至少用一个空行隔开。如果想很快地理解一下程序,那么最有效的方法是只读注 释不读代码。把注释行用空行隔开有利于读者浏览代码。表 18-65 是这样的例子。 表 18-65 这个 Pascal 例子用空将把注释行分开 { comment zero } CodeeStatermentZero ; CodeStatementOn ; { comment one } CodeStatementTWO ; CodeStatementThree ; 当然也有人在注释行前后都加空行。两行空行可能要占用较多屏幕空间,但有人可能主张 这样代码更好读,如表 18-66 所示: 表 18-86 用两行隔开注释行的 Pascal 例子 { comment zero } CodeStatementZero; CodeStatmentOne; { comment one } CodeStatmentTWO; CodeStatmentThree; 除非屏幕空间是个要优先考虑的因素,否则这种写法相当美观。记住一种规定的存在比其 细节更重要。 18.7 子程序布局 子程序由单条的语句、数据、控制结构、注释组成,即包含本章所讨论到的所有部分。本 节提供一些如何安排好子程序的指导。 用空行把子程序各部分分开。在子程序的头、数据和常量名定义及程序体之间加空行。 对子程序的参数用标准缩排。跟别的情况一样,子程序头的安排可选用方法是:任意布局、 用行尾布局或标准缩排。大多数情况下,标准缩排更准确、连贯、可读、易维护。 表 18-67 是两个没注意子程序头布局的例子。 第十八章 布局和风格 304 表 18-67 这 C 子程序没注意子程序头的布局 BOOLEAN ReadEmployeeDate( int MaxEmployees, EMP_LIST * Employees, FILE * InputFile , int * EmployeeCount , BOOLEAN * IsInputError ) …… void InsertSort( SORT _ARRAY Data, int FirstElmt, int LastElmt ) 这种子程序头纯属实用主义的东西。计算机肯定能读,但对人呢?没注意到这一点使读者 吃了苦头,还有比这更糟糕的吗? 第二种可选用方法是用行尾布局,这种方法一般都显得较好。 表 18-68 这个 C 程序用行尾布局法格式化程序头 BOOLEAN ReadEmployeeData( int Max Emlpoyees, EMP_LIST * Employees, FILE * InputFile, int * EmployeeCount, BOOLEAN * IsInputError ) … void InsertSort( SORT_ARRAY Data, int FirstElmt, int LastElmt ) 行尾布局法显得整齐而美观。但主要问题是修改时要花好多功夫,即维修性不好。比如函 数名由 ReadEmployeeData()改成 ReadNewEmployeeData(),它就使第一行往后了而不能与下面 四行对齐,因而需要重新格式化其余四行,即加空格。 表 18-69 的例子是用标准缩排方式格式化子程序头,一样地美观,但维护性好。 这种形式在修改时也能保持美观性。 表 18-69 这个 C 程序用标准缩排方式格式化程序头,可读,易维护 B00LEAN ReadEmployeeData ( int MaxEmployees; EMP_LIST * Employess; FILE * InputFile; Int * EmployeeCount; BOOEAN * IslnputError ) … void InsertionSort ( SORT_ARRAY Data, int FirstElmt, int LastElmt ) 第十八章 布局和风格 305 要改子程序名,这种改动也不会影响参数的对齐。如果要增删一个参数,仅需修改一行(外 加一个分号),而其直观外形依然存在。这种布局形式中,你能很快得到你所需要的信息,而不 必到处搜寻。 在 Pascal 中这种形式可直接应用上去,但却不能用到 Fortran 上去,因为 Fortran 的参数 定义是在于程序定义以后才进行。 在 C 中用新的子程序定义方式。ANSI C 标准中的子程序头定义方式与原始 C 定义方式不 一样,但大多数编译程序和少数的程序员仍支持旧的方式。不幸的是,旧方式已不太好用了。 表 18-70 是新旧两种方法的比较: 表 18-70 C 程序的新旧两种子程序头 void InsertionSort( Data, FirstElmt, LastElmt) ——旧的方式 SORT_ARRAY Data, int FirstElmt, int LastElmt, { … } void InsertionSort ——新的方式 ( SORT_ARRAY Data, int FirstElmt, int LastElmt ) { … } 旧方式定义子程序头时,先到了一次变量,然后列表再定义了一次各自的类型,这样在一 开始就要再次提到同一变量名,如果要修改它,你还得记住要在两处修改。在第二版的《The C Programming Language》(《C 程序语言》)中,Kernigham 和 Ritchie 强烈要求用新的定义方法。 在 Fortran 中需单独定义参数,这时最好按程序参数表中的顺序依次定义。Fortran 的一 大不幸是你得先在子程序头中有一参数列表,然后再定义一次,正好与旧形式的 C 一样,Fortran 也通常把参数与局部变量放在一起定义.形成一个很庞杂的列表。表 18-71 即是此例; 表 18-71 这个 Fortan 的例子参数顺序混乱 SUBROUTINE READEE( MAXEE, INFILE, INERR ) INTEGER I, J LOGICAL INERR INTEGER MAXEE CHARACTER INFILE*8 INTEGER EMPID( MAXEE ) 这个程序段初看很整齐,因为几个相应项各自上下对齐了。但整齐并不能解决主要问题。 第十八章 布局和风格 306 当我还小的时候,我不允许我盘中的食物接触放在我盘里的他人的食物;但我老了时,却喜欢 把几样食品混杂起来吃。但我还是不太喜欢把参数与局部变量混杂起来定义。表 18-72 显示了 该怎样在 Fortran 子程序中定义变量或参数: 表 18-72 这个 Fortran 子程序注意到了参数顺序 SUBROUTINE READEE(MAXEE, INFILE, INERR) ——参数以参数表的顺序先义 SINTEGER SMAXEE SCHARACTER INFILE*8 SLOGICAL INERR * local variables ——再定义局部变量,与参数定义分开 INTEGER I INTEGER J INTEGER EMPID( MAXEE ) 这个程序遵从了本章的几个观点,即把子程序变量放在子程序下定义且缩排几格、每行只 定义一个变量、对齐变量的各相应、把注释与相应代码对齐、用空行把注释行与其它代码分开 等。 18.8 文件、模块和程序布局 格式化技巧除了应用于子程序外还有更大应用的空间,如怎样组织一个文件内的各子程 序?一个文件中哪个子程序该放在第一? 把一个板块放在一个文件里。一个文件不仅仅只放了一大堆代码。如果你所用语言允许, 一个文件里应该放且只放那些支持某一目的的子程序集合。一个文件应该是把一些相关的子程 序包装成一个模块。 一个文件中的所有子程序构成一个模块。一个模块才是程序中体现你设计的那一部分(或 许仅仅是一个逻辑分支)。模块是一个语义学上的概念,文件则是一个物理操作系统上的概念。 两者之间的关系是并存关系。未来的环境可能倾向于强调模块而不强调文件,但现在两者是有 联系的,所以应当等同相待。 把一个文件内的子程序区分清楚。要把一个子程序与别的子程序区分开来至少要用两个空 行。空行的作用与星号行或虚线行意义差不多,但空行更易维修,用两或三空行来区别表明这 些空行是用来分隔子程序的而不是子程序内部的。一个例子如表 18-73: 表 18-73 这个 Basic 例子用多个空行分隔字程序 Function Max!( Arg! , Arg2! ) ——这是一个空行,子程序中空行的典型使用 'find the arithmetic maxinmum of Argl and Arg2 if( Arg1! > Arg2! ) then Max! = Arg1! else Max! = Arg2! 第十八章 布局和风格 307 endif end Function Function Min!(Arg1!, Arg2!) 至少两个空行分开子程序 ‘find the arithmetic minimum of Arg1 and arg2 if ( Arg1!< Arg2!) then Max! = Arg1! else Max! = Arg2! endif end Function 空行比其它任何分隔符都好输入,但效果却是一样好。用三个空行来区分使得程序内部的 空行与用来分隔子程序的空行的差别更明显。 如果一个文件里有多个模块,要把这些模块区分得清清楚楚。相关的子程序组织成一个模 块。当读者测览你的代码时,他应当很容易地知道到哪算一个模块。模块之间应用更多的空行 来区分。一个模块犹如你书中的一章。在一本书中,每一章都是从一个新页开始,开始的章节 名用大写字。可用同样方法来突出每一个模块,表 18-74 是分隔模块的例子。 表 18-74 注意这个 Basic 程序如何分隔模块 Function ConvertBlanks$( Mixedstring$ ) 这是一个模块中的 最后一个子程序 'Create a String identical to NixedSttring$ except that the 'blanks are replaced with underscores. dim i% dim StringLength% dim workString$ WorkString $ = "" StringLength% = Len(MixedString$) for i% = 1 to StringLength% if (mid$(MixedString$,i%,1)="" then Workstring$ = WorkString$ + "_" else Workstring$ = WorkString$ + mid$(MixedString$,i%,1) endif next i% ConvertBlanks$ = WorkString$ end Function 第十八章 布局和风格 308 ’------------------------------------------------------ ’ MATHEMATICAL FUNCTIONS ’ ’ This module contains the program’s mathematical functions 新模块开始用几个空行和 模块名标记 ’------------------------------------------------------ Function Max!(Arg1!,Arg2!) 这是一个新模块的第一个 子程序 ’find the arithemetic maximum of Arg1 and Arg2 if (Arg1! > Arg2!) then Max! = Arg1! else Max! = Arg2! end if end Function 这个子程序仅用空行与前 面的子程序分开 Function Min(Arg1!, Arg2!) ’ find the arithemetic minimum of Arg1 and Arg2 if (Arg1! < Arg2!) then Max! = Arg1! else Max! = Arg2! end if end Function 避免过分突出模块内部的注释,如果你模块内部的注释或程序间的间隔用星号替代空行, 那么你实际上已经很难用别的符号或方法来强调突出模块间的间隔了。表 18-75 是这样的例 子。 表 18-75 这个 Basic 程序过分突出模块了 '********************************************************** '********************************************************** ’ MATHEMATICAL FUNCTIONS ’ This module contains the program’s mathematical functions '********************************************************** '********************************************************** '********************************************************** Function Max!(Arg1!,Arg2!) 第十八章 布局和风格 309 '********************************************************** ' find the arithmetic maximum of Argl and arg2 '************************************************************** if ( Arg1! > Arg2! ) then Max! = Arg1! else Max! = Arg2! endif end Function '************************************************************** Function Min!(Arg1!, Arg2!) '************************************************************** ' find the arithmetic maximum of Arg1 and Arg2 '************************************************************** if( Arg1!Arg2!) then Max!=Arg1! else Max!=Arg2! endif end Function 以上的建议仅适用于这种情形:即你的语言限制在一个程序中所含的文件个数,这时你不 得不在一个文件中放几个模块。如果你用 C 或某一版本的 Pascal、Fortran、Basic 等,它们支 持多个源文件,这时你最好是一个文件放一个模块。即使是在一个模块里,你也要用上述方法 来仔细分隔各子程序。 把各子程序按字母顺序排列。在一个文件中把相关子程序组织起来的一个方法是按子程序 名开头字母的先后顺序.如果不能把一个程序分解成为模块或编译程序寻找子程序不是很快, 那么按字母顺序能减小搜寻时间。 在 C 中,细心地组织源文件。以下是 C 语言中所含源文件的标准顺序: · 关于文件描述的注释 · 包含文件 · 常量定义 · 宏函数定义 · 类型定义 · 全局变量及输入函数 · 全局变量及输出菌教 · 文件内部变量及函数 18.8.1 检查表 布局 简述 · 格式化的本意是要显示代码的逻辑结构吗? · 格式化的形式能始终一致吗? · 格式化后使代码易于维护吗? · 格式化后改进了可读性吗? 第十八章 布局和风格 311 控制结构 · begin-end 对中代码避免再次缩排了吗? · 一系列的块结构用空行相互分隔了吗? · 复杂的表达式格式化后可读性增强了吗? · 单条语句块始终一致地格式化了吗? · Case 语句的格式化与其它控制结构格式化相协调吗? · goto 语句格式化后自己显得更清楚了吗? 单条语句 · 把单条语句分成几行,每行都明显地不能作独立行看待了吗? · 续行有意识地退格了吗? · 相关语句组对齐了吗? · 不相关语句组不应对齐,你是这样的吗? · 每行至多含一条语句吗? · 每个语句避免副作用了吗? · 数据定义时相应项对齐了吗? · 每行至多定义一个数据,是吗? 注释 · 注释行与它所对应的代码退同样格数了吗? · 注释行的形式易修改吗? 子程序 · 子程序的参量格式化后各参数易读、易修改、易加注释吗? · 在 C 中是否用新子程序定义方法呢? · Fortran 中,参数定义是否和局部变量定义分开? 文件、模块和程序 · 若语言允许有多个源文件,每个源文件仅含一个模块是吗? · 一个文件内的各子程序是否用空行清楚隔开? · 如果一个文件含几个模块,那么每个模块中的子程序是否被组织到被清楚隔开? · 各子程序是否按字母顺序排列? 18.9 小 结 · 布局首先考虑的是去显示程序的逻辑结构。评价这种考虑是否达到目的标准有:准确 性、连续性、可读性、易维护性。好看是第二条标准——比较弱的标准。如果以上几条 标准都达到了而且程序也较好看,那么布局一般就成功了。 · 在 C、Pascal、Basic 中用纯块结构模仿及 begin-end 作块边界,这两种布局形式行 之有效。在 Ada 中用纯块结构。 · 结构化代码是有其自身目的的,你最好还是用一些约定俗成的布局形式而少来创新, 以保持与别人协调一致。若你的布局形式与约定的不一样,那么很有可能影响你程序 第十八章 布局和风格 312 的可读性。 · 有关部局的好多观点纯属一个信仰或者说个人喜欢问题,努力把客观需要和主观喜好 分开。遵从一些明显地规划,以选择你所喜欢的布局形式。 第十九章 文 档 313 第十九章 文 档 目录 19.l 外部文档 19.2 编程风格作文档 19.3 注释还是不注释 19.4 有效注释的关键 19.5 注释方法 19.6 小结 相关章节 布局: 见第 18 章 PDL——代码流程:见第 4 章 高质量子程序:见第 5 章 在交流中编程:见 31.5 节和 32.3 节 假如程序标准合理的话,大多数编程人都喜欢写文档。就像布局那样,好的文档是编程人 员投身编程中自豪的标志。软件文档可以采取很多形式,在描述了文档后,本章将介绍文档的 “补充”即“注释”。 19.1 外部文档 软件工程中的文档既包含源码表的内部信息,又包含源码表的外部信息。通常的形式有单 独的文件或者综合资料。大体上,正式软件工程中大多数文档都位于源码外部。实际上,大约 一项大工程全部力量的三分之二放在了创建文档上,而不是源代码上。这和输出相似。外部结 构文档要在高层上同编码相联系,在低层上和编制前的阶段文件相联系。 综合资料。一个综合资料,或者软件开发资料是一个非正式文档,它包含着供开发者在编 程中使用的记录。“综合”广义地讲,通常指是常规的或是特殊的资料。综合资料的主要目的是 提供一套其它地方没有描述的设计规则。很多软件工程中,有指定最少资料的标准。例如:相 关需求的备份、开发标准的备份等。当前编码和综合资料的设计规则,通常都仅用于内部。 详细设计文档。详细设计文档是低层设计的文档。它描述模块层或程序层的决定,所考虑 的选择对象、所选用办法的原因。有时这些信息包含在一个正式文档中。这种情况下,详细设 计和结构是不同的,有时它主要包含收集在“资料”中开发者的记录。有时——常常——它仅存在 于编码本身当中。 第十九章 文 档 314 19.2 编程风格作文档 和外部文档相比,内部文档可见于程序内部。它是最详细的一种文档。由于内部文档和编 码联系最密切,故也是当编码被修正后最可能保持正确的那种文档。 对编码层文档的主要贡献不是注释,而是好的程序风格。风格包含好的程序结构,直接使 用和易于理解的方法、好的变量名、好的子程序名、命名常量、清晰的布局和最小的控制流及 灵活的数据结构。 这里有一风格不好的代码段: 不好的编程风格导致差的文档的例子: for i=1 to Num do MeetsCriteria[i]:=True; for i:=2 to Num/2 do j:=j+1; while(j<=Num) do begin meetsCriteria[i]:=False; j:=j+I; end; for i=1 to Num do if Meetcriteria[i] then writeln(i,"meetsCriteria."); 你知道这段子程序做什么吗?它并非必须保密。它是较差的文档,不是因为它的语言描述 而是因为它缺乏好的编程风格。变量名是非正规的,布局是粗略的。下面是相同的,但改进了 编程风格就使得它的意思很清晰了。 好的编程风格文档例子: for PrjmeCandidate := 1 to Num do IsPrime[ PimeCandidate ] := True; for Factor := 2 to Num / 2 do FactorableNumber := Factor + Factor; while( FactorableNumber <= Num ) do begin IsPrimes[ FactorableNumber ] := False; FactorableNumber := FactorableNumber + Factor; end; for PrimeCandidate := 1 to Num do if IsPrime[ PrimeCandidate ] then writeln ( PrimeCandidate , ’is prime.’ ); 不像第一段代码那样,这段第一眼就会让你知道它和基本数字有关。第二眼便可反映基本 数字在 1 和 Num 之间。对于第一段代码,至少读两遍才能找出循环结束的位置。 第十九章 文 档 315 两段代码的区别和注释无关。然而第二段更具可读性,达到了清晰明了的境地:这种编码 依靠好的程序风格来承担大部分的文档任务。在好的编码中,注释可称得上是“锦上添花”。 19.2.l 检查表 子程序 · 每一个子程序名都确切地描述了要做什么事吗? · 每一个子程序详细定义任务吗? · 程序会从它们的子程序中获益吗? · 每个子程序的接口处明确吗? 数据名称 · 类型名的描述足以帮助文件数据说明吗? · 变量名好吗? · 变量仅用于命名这个目的吗? · 循环计算变量能给出更多的信息吗? · 用枚举类型变量来代替标记或逻辑变量了吗? · 命名常量没有用来代替数字或字串吗? · 类型名、枚举类型名、命名常量、局部变量、模块变量和全局变量中的命名规则不同 吗? 数据组织 · 附加变量在需要时要清零吗? · 变量的引用彼此间很接近吗? · 数据结构简化会导致降低其灵活性吗? · 复杂的数据存取是通过子程序来完成的吗? 控制 · 正常编码路径清晰吗? · 相关语句分成一组了吗? · 相对独立的语句都组成子程序了吗? · 正常情况跟在 IF 后,而不是 ELSE 后吗? · 控制结构简化会降低灵活性吗? · 像一个定义完好的子程序那样,每个循环执行一个且仅一个功能吗? · 嵌套层次是最少吗? · 逻辑表达式用附加的逻辑变量、逻辑函数和功能表简化了吗? 布局 · 程序布局显示出它的逻辑结构吗? 设计 · 代码直观吗?它的编写巧妙吗? · 实现细节可能隐去了吗? · 程序编写是立足于问题域而不是计算机科学或语言结构域吗? 第十九章 文 档 316 19.3 注释还是不注释 注释写得差要比好容易些,注释有帮助但更有破坏性。关于注释的热烈讨论常常听起来像 道德观点的哲学争论,这使我想到,假如 Socrates 是一个计算机编程者,他和他的学生可能会 有下面的讨论。 注释讨论 人物: THRASYMACHUS:固执,相信他读的每件事。 CALLICLES:从古老学校中经实践磨练出来的老手——一个真正编程人员。 GLAUCON:一个年轻的,自信的,热情洋溢的计算机迷。 ISMENE:一个年长的编程员,讨厌说大话,热衷于一些实际工作。 SOCRATES: 聪明的老编程员 地点:每周小组讨论会上 “我想建议一个我们工程的注释标准,”Thrasymachus 说,“我们的一些编程员仅仅注释他 们的代码,每个人都知道没有注释的代码不可读。” Callicles 说道:“你一定是学校中的新潮流,并且超过我所想象的。注释是学术上的补救 方法,但做任何编程工作的人员都知道,注释使得代码更难读而不是容易。英语不比 C 或 Pascal 语言准确,这就造成了许多累赘。编程语言说明简练且符合要点。假如你不能使代码清楚,你 怎么会使注释清晰呢?此外,注释也跟不上代码的变化。若你相信过时的注释,你将会失败!” “我同意这个观点,”Glaucon 插话道,“注释的代码更难读,是因为这意味着更多的东西 要读。我已经是不得不读这些代码,为何我还要必须去读这些注释呢?” “等一会儿”,Ismene 把她的咖啡杯放入两块糖后说:“我知道注释能被乱用,但好的注释 是很可贵的,我不得不保留有注释的代码和无注释的代码,但我更喜欢保留有注释的代码。我 认为我们不应有一个标准指出应该每多少行代码有一行注释,但我们应鼓励每一个人去注释。” Socrates 问道:“假如注释浪费时间,Callicles 你回答我,为何每个人都使用他们?” “或因为他们被要求那样做,或因为他们读到那里时注释是有用的。但没有人认为注释总 是有用的。” “Ismene 认为注释有用,她在这里已三年了,保留你的无注释代码和那些有注释的代码, 并且她更喜欢有注释的代码。你是怎么实现的?” “因为注释是用更罗嗦的方式重复代码,故此他们没有用处。” “这儿等一下,Thrasymachus 打断道,“好的注释不是重复代码或解释它,而是使代码更 清楚。注释在高于代码的抽象水平上解释代码要做什么事。” “对”,Ismene 说道,“我浏览注释会发现,在这一部分我应改变什么或应集中精力干什么, 你说注释重复代码根本没有帮助也对,因为代码已把每件事都说了,当我们需要注释时,我是 想让它像读书中的题目或目录表一样。注释能帮助我们发现正确的部分,然后再读代码。在一 段程序语言中读一句英文要比分析二十行代码快得多了。”Ismene 给自己又倒了一杯咖啡。 “我认为人们拒绝使用注释是因为:①它们的代码已相当清楚;②认为其它编程人员对他 们的代码极感兴趣;③认为其它程序员比自己更聪明;④懒惰;⑤怕其它人推算出他们的代码 第十九章 文 档 317 是如何工作的。” “注释对代码检查有很大帮助”,Ismene 接着说道,“假如某人声称他们不需要写注释,那 么检查中一定会出问题———几个有疑问的人开始说:“这段代码中你们试图要做什么?接着他们 开始增加注释。假如他们自己不那样做,最终他们的老板也会强迫他们做的。” “我不责怪你懒惰或担心别人会发现你的代码的工作原理,Callicles,我已研究过你的代 码相信你是公司中最好的编程员之一,但要当心啊!若你使用注释,你的代码对我的研究来说会 更容易些。” “但是,注释是资源的浪费,"Callicles 反驳道:“一名好程序员的代码应是自我解释的, 你想知道的每件事都在代码中。” Thrasymachus 从椅子中站起来说道:“不可能!编译程序知道任何事都在代码中!你可能 也会争论你想知道的每件事在二进制可执行文件中!假如你去读懂它若不在代码中这意味着发 生什么呢?” Thrasymachus 意识到自己站着便坐下了。“Socrates 是可笑的、为何你对注释有无价值进 行争论呢?我读到的每件事说明他们是有价值的,并且应当合理地使用,我们在浪费时间。” “冷静些,Thrasymachus 问一下 Callides 他从事编程多久了!” “多久了,Callicles? " “好吧,大约十五年前我开始 Acropolis IV 的编写,我猜想我已经看了大约一打的主要 系统了。两个这样的系统就超过了五百行代码,因此我知道我在谈论什么。注释是极其没用的。” Socrates 看着这个年轻的程序员,“就像 Callicles 所说;注释有一些实际的问题,你没 意识到那并不需要更多的经验。假如注释错了,将会更加糟糕。” “即便是没有错,也是没用的,”Callicles 说道,“注释并不比程序语言更准确,我更希望 根本就不要注释。” “Callides 和 lsmene 的观点是说降低的精确性是注释的优点——这意味着你可以用少量 的话表达更多的含义。你写注释是因为相同的原因。你要使用高层语言。他们给你一种高层的 抽象,我们都知道抽象水平并非是程序员最有力的工具之一。” “我不赞同这个观点。不应集中在注释上而应集中在使代码更可读。好的程序员可以从代 码中读出代码的意图,当你知道某人的代码有错误时,读他的意图你会知道他做的如何吗?” Glaucon 对自己的观点很满意。Callicles 点点头。 “你的话听起来好像你从未被迫修改其它人的代码,”Ismene 说道,Callicles 突然间好像 对天花板上铅笔标记很感兴趣。“为何你不试着读你自己半年或一年前写的代码?你能够提高你 的读代码能力,并且提高注释水平。你并非不得不选这个。我是必须用注释,通过注释读几百 行代码便发现要改变两行。” “好了!能创览代码会是很方便的,”Glaucon 说道。他已经看过一些 Ismene 的程序并从 中受以启发。“但 Callicles 的其它观点怎么样呢?我已编程几年了,但据我所知没有人修正过 他们的注释。” Ismene 说:“好了,是和非难以定论,假如你把注释看作是神圣的,而代码看作是可疑的, 你就会麻烦的。实际上,在注释和代码间找一个分歧就意味着两者都错。有些注释不好并不意 味着所有注释都不好。我去厨房另取一瓶咖啡。”Ismene 离开了房间。 Callicles 说;“我对注释的反对意见是认为它是浪费资源。” 第十九章 文 档 318 Socrates 问道:“谁能想办法把写注释花费的时间减到最少?” “设计 PDL 的子程序,然后在程序间转化 PDL 到注释和填充代码。”Glaucon 说道。 Callicles 说:“好了,只要注释不重复代码就可以。” “写注释使得想你的代码在做什么变得更难了,“Ismene 从厨房中回来后说道。“假如难于 注释,或者代码较差或者你并不十分地理解。任何一种情况,你都要在代码花费更多的时间, 所以花在注释上的时间不是浪费。” “好了,”Socrates 说道,“我不能考虑任何更多的问题,我想 Ismene 得到了今天你谈话 的精华。我们鼓励加注释,我们对它不能是无知的。我们要对代码检查以便每个人对这种有帮 助的注释有一个好的看法。若我们有困难不能理解别人的代码,就让他们知道如何去改进它。” 19.4 有效注释的关键 下面的子程序做什么呢? {write out sums 1..n for all n form 1 to Num} Crnt := 1; Prev := 0; Sum := 1; for i := 1 to Num do begin writeln( Sum); Sum := Crnt + Prev; Prev := Crnt; Crnt := Sum; end; 你最好猜想一下! 这个子程序计算第一个黄金分割数字 Num。它的编码风格稍优于本章开始的子程序的风 格,但注释是错误的,如果你盲目地注释,你就会走错了方向。 下面这段代码如何呢? {set Product to "Base"} Product := Base; {loop from 2 to "Num"} fot i := 2 to Num do begin { Multiply "Base" by "Product" } Product := Product * Base; end; 你的猜测是什么? 这段子程序把一个整数 Base 提高到 Num 整数幂次。这段子程序的注释是准确的,但缺乏更 多的信息。它们仅是代码本身的更罗嗦的方式。 第十九章 文 档 319 最后这里有一个子程序: {compute the square root of Num unsing the Newton-Raphson approximation} r := Num / 2; while ( abs( r - ( Num / r ) ) < Tolerance ) do r := 0.5 * ( r + ( Num / r ) ); 程序的目的是什么? 这段程序计算 Num 的平方根,程序代码并不大,但注释是精确的。 哪个子程序对你正确计算更容易呢?由于没有一个子程序是完美的——由于变量名取的较 差,然而简单地说,这些子程序说明了这些内部注释的优点和弱点。子程序 1 有一个不正确的 注释。子程序 2 的注释仅重复了代码,故此是没用的,只有子程序 3 的注释起了作用。差的注 释不如没有注释。注释 1 和 2 没有注释都比有这差的注释要好。 下面的部分描述了写有效注释的关键点。 注释的种类 注释可以分成五类: 代码的重复 重复的注释,用不同的词重申了代码的内容。它没有给读者提供代码的附加信息。 代码的解释 解释性注释,典型地用于解释复杂的,有效的和灵敏的代码段。这种情况下,他们是有用 的,但常常是由于代码是易混淆的。假如代码复杂到需要解释,那么改进代码总比增加注释更 好些。使代码本身清晰,然后使用总结或注释。 代码中的标记 标记注释并非是故意留在代码中的注释。它是给开发者的记录,表示工作还未做。一些开 发者的标记注释为语法错误的标记(例如******),因而编译程序标记它并提醒他们要 做更多的工作。其它开发者把一套特殊字符放人注释中,因而他们可以发现它们,但编译程序 不能识别它们。 代码的总结 总结代码的注释做法是;它简化一些代码行成一或两句话。这样的注释比起仅重复代码而 使读者比读代码更快的那种注释更有价值了。总结注释是相当有用的,特别是当其它人但不是 代码的编者试图修改代码时。 代码意图的描述 意图这一层上的注释,解释了代码的目的。意图注释在问题一级上,而不是在答案一级操 作。例如: {get current employee information}获取当前雇员的信息 是一句意图注释,而 {update Employee structure }修改雇员记录结构 是一句利用答案的总结描述。在 IBM 六个月的学习,发现编程员最常说“理解最初的编程 第十九章 文 档 320 意图是最难的问题”(Fjelstad 和 Hamlen 1919)。意图注释和总结注释的区别不总是清楚的, 但常常这不是重要的。意图注释的例子本章始终都给出了。 为全部代码接受的注释仅是意图和总结注释。 有效注释不是时间的浪费。太多注释和没有注释一样糟糕。你可采取一个合理的中间数量。 由于两种共同的原因,注释要花费许多时间去写。第一,注释可能会浪费时间或令人厌烦 ——脖子疼痛。假如这样,重写一个新的注释。需要许多繁重工作的注释是很令人头疼的。假 如注释难于改变,他们就不必改变了;不准确和错误注释比根本没有注释更糟糕了。 第二,用语言描述程序做什么并不见得容易,所以注释会更难些。这常是你并不了解程序做什 么的标志。你花在注释上的时间,应更好理解程序的真正时间,那是不管你是否注释都要花费 的时间。 使用风格不应打断或妨碍修改 任何太具想象力的风格都会妨碍维护。例如,选取下面的注释部分将不便维护: 难以保存的注释类型的 Fortran 例子: C 变量 含义 C …… …… C XPOS..... X 坐标位置(以米为单位) C YPOS..... Y 坐标位置(以十为单位) C NPCMP..... 计算标志(=0 若不需要计算, C =1若需要计算) C PTGTTL.... 合计 C PTVLMX.... 最大值 C PSCCRMX.... 最大可能的值 假如你说这些起头的园点(⋯⋯)将难以维护,那么你就对了!他们看起来很好,但没有 他们会更好些,他们将对修改注释的工作增加负担,你宁愿有准确的注释而不是好看的注释, 假如有这种选择的话——常常是这样的。 下面是另一个难以维护普通风格的例子: C 语言的难以维护的注释风格的例子: /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 模型:GIGATROM.C * * 作者:Dwight K.coder * * 时间:2014 年 7 月 4 日 * * * * 控制二十一世纪程序的代 * * 码开发工具。这些程序的 * * 入口点在这个文件的底部的 * * 行, 程序名为 Evaluatecode() * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 这是一个好看的注释块。很清楚,整个块自成一体,并且块的开头和结尾都很明确。对这 第十九章 文 档 321 个块还不清楚的是它变化起来是否容易。假如你必须在注释底部增加文件名,那么你对右边漂 亮的星号栏的就得重新编辑。假如你想要改变这段注释,那么你就要去掉左边和右边的星号。 实际上这意味着这个块不便维护,因为要做比较多的工作。假如你按一个键便可得到几列整齐 的星号,那就太好了。不要使用它,他们难以维护的问题不仅是在星号上面。下面的注释看起 来并不好,但它肯定便于维护: 便于维护的 C 语言的注释风格例子: /* * * * * * * * * * * * * * * * * * * * * * * * * * * * 模型:GIGATRON.C 作者:Dwight K.Coder 日期:2014 年 7 月 4 日 控制二十一世纪程序的代码 开发工具。这段程序的人口 在文件底部,程序名为 EvaluateCode() * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 下面有一个极难维护的风格: 难于维护的 Basic 语言的注释风格例子: · 设置颜色枚举类型 +——————————————————+ ... · 设置菜单枚举变量 +——————————————————+ ... 很难知道注释里破折号行起始和结尾的加号其值是多少?但容易猜出每次注释的变化,下 划线不得不调整以使得结尾的加号处于正确的位置。当一个注释行被分成两行时你将如何办 呢?你将怎么安放加号?去掉注释里的文字以使得它仅占据一行吗?使两行具有相同的长度? 当你试图不断应用它时,这种办法的问题会很多。 关键点是应注意如何去分配你的时间。假如你花费了大量时间增加和删除破折号以使得加 号对齐,你就无法编程了,而且是在浪费时间。找一个更有效的方式。在用加号下划线的情况 中,可以进行选择,使得注释没有任何下划线。假如你需要使用下划线来强调,就找别的方法, 而不用加号来对注释进行强调。一种办法就是用一个标准的下划线,不管注释的长度它都一样 长。这样的线不需要维护,你可在开始位置用一个文字编辑宏来定义它。 使用 PDL 编码过程来减少注释时间 假如写代码前你勾划了注释中的代码,便可通过几种方法实现。当你完成了代码,注释也 就完了。在你填写低层的程序语言代码前,你可以获得高层 PDL。 在你进行过程中注释 写代码时,可以选择注释,直到工程结束再停止注释,这样做有很多的优点。在它自己的 权限内,这变成了一项任务,使得看起来比每一次做一点时更有效率。以后再注释会花费更多 的时间,因为你必须记住或算出代码在做什么而不是在书写你已想好的东西。因为你可能已忘 记了设计中的假设和细节,所以这样也不会准确。 第十九章 文 档 322 反对在进行编程时注释的观点认为“当你集中精力写代码时,不应当分散精力去写注释”。 正确的答案是,假如你极其用心地写代码,注释会打断你的思路,你需要先设计 PDL,然后把 PDL 转化成注释。需要集中精力编代码是一个警告信号,假如你的代码很难,在你对代码和注 释担忧前应简化它。若你使用 PDL 分类你的想法,编码是直接的而注释是自动的。 最佳数量的注释 工程有时采用一个标准,比如“程序必须至少每五行便有一行注释”。这个标准说明了程序 员未写清晰代码的特征,且未指出原因。 若你有效地使用 PDL 编码过程,最后你会得出结论:第几行代码就要有一行注释。然而,注释 的数量对过程本身来说是副作用。不能集中在注释的数量上,而是要集中是否每条注释都有效。 假如明白了为什么写注释以及清楚了本章中涉及的其它法则,你就会有足够的注释了。 19.5 注释方法 注释可依照它所提供的层次:程序、文件、子程序或单独行而采取几种不同的技巧。 注释单独的行 好的代码中,注释单独代码行的需要是很少的,这里是一行代码需要一条注释的两种可能 原因: · 单独行复杂,需要一条解释 · 单独行曾有一个错误,你需要这个错误的记录 这是一些注释一行代码的准则: 避免本身无关的注释 很多年前,我听说一个故事,一个维护程序员被从床上叫起来,检查一个不正常的程序。 程序的作者已离开了公司,不能来到。维护程序员开始对程序无能为力,但仔细检查了程序后, 他发现只有一条注释。注释如下所示: MOV AX,723h ; R.I.P.L.V.B 对程序研究了一整夜后,对注释感到困惑,程序员做了一个成功的修改,然后回家睡觉。 几个月后,他遇到程序作者发现注释代表的意思是:“Rest in peace. Ludwig Van Beethoven.” Beethove 死于 1827 年(十进制),也就是 723h(十六进制)。那个地方需要 723h 的事实与注释 无关。 结束行注释及其问题 结束行注释是在代码行末尾出现的注释。这里有一个例子: 结束行注释的 Basic 语言例子: for EmpID = l TO MaxRererds GetBonus( EmpID, EmpType, BonusAmt ) if EmpType = Manager then PayMgrBonus( EmpID, BonusAmt ) 'Pay intended, full amount elseif EmpType = Programmer then 第十九章 文 档 323 if BonusAmt >= MgrApprovalRequired then PayProgrBonus( EmpID, StdAmt() ) ‘pay company std. amount else PayProgrBonus( EmpID, BonusAmt ) ‘pay 1ntended, full amount end if next EmpID 一些情况下虽然结束行注释有用,但它还存在几个问题。注释必须位于代码的右边以便不 影响代码的外形结构。假如你没有整齐地设置它们,它们会使你的表看起来好像它已经通过了 洗衣机似的。 结束行注释倾向于难以安排。假如你使用很多,需要浪费时间去整理。这样的时间没有花 费在学习更多的代码上,仅仅花费在按空格键或 TAB 键这样乏味的工作上。 结束行注释倾向于是隐蔽的。行的右边通常没有足够的空间,要求在一行内保留注释意味 着注释要短。工作也就转到使行尽可能地短而不是尽可能的清楚。注释通常尽可能隐蔽地结束。 使用 132 列的监视器和打印机,你能清除这个问题。 避免结束行注释在单独行上 除了实际问题外,结束行注释还有其它几个概念性问题。这有一套结束行注释的例子: MemToInit := MemoryAvailable(); { get amount of memory available } Pointer := GetMem( MemToInit ); { get a ptr to the available memory } Zeromem( Pointer, MemToInit ); { at memory to 0 } … FreeMem( Pointer ); { tree memory allocated } 结束行注释的系统问题是难以为一行代码写一个有意义的注释。很多结束行注释只是重复 代码行,其害处超过益处。 避免给多行代码结束行注释 假如结束行代码是超过一行的代码,这种格式并未显示注释是为哪些行的。这里有一个例 子: for RateIdx L := l to RateCount do begin { Compute discounted rates} begin LookupRegularRate( RateIdx, ReguinrRate ); Rate[ RateIdx ] := RegualarRate * Discount[ RateIdx ]; end; 虽然这个特殊注释的内容是好的,它的布局却不好,你必须读整个注释和代码才能知道是 否注释适于特定的说明或整个循环。 何时使用结束行注释 这里是三条反对使用结束行注释的异议。 使用结束行注释注解数据说明 结束行注释对注解数据说明是很有用的,因为他们就像代码上的结束行注释一样没有系统 问题,条件是你有足够的宽度。对于 132 列的,你通常能在数据说明旁边写一个有含义的注释。 这里有一例子。 第十九章 文 档 324 Boundary: Integer; { upper index of sorted part or array } InsertVal: String; { data elmt to insert in sorted part of array } InsertPos: Integer; { position to insert elmt in sorted Part Of array } 为保存记录而使用结束行注释 结束行注释在初始开发后,对代码的修改很有用。这种注释典型地包含了一个数据和程序 员的初始情况,或者一个错误报告数。这里有一个例子: for i := 1 to MaxElmts – 1 { fixed error #A423 10/1/92 (Scm) } 这样的注释可由控制版本软件很好地处理,但是如果你没有这种版本控制支持的工具,你 需要维护工具去注解一个单行,这是一种解决办法。 使用结束行注解标记块的结束 一个结束行注释对于标记一长段代码的结束是很有用的,例如while循环或if说明的结束, 本章后面有更详细的描述。 除了两个特殊情况,结束行注释有概念性问题,而且可以使代码很复杂.它们也很难编排 和维护。总的来说,最好别用他们。 代码注释段 在程序中的大多数好的注释是一或二句描述代码段的注释。这里是个例子: /* swap the roots */ OldRoot = root[ 0 ]; root[ 0 ] = root[ 1 ]; root[ 1 ] = Oldroot; 这段注释并没有重复代码。它描述了代码的意图。这样的注释是相对地易于维护的。假如 方法中出现了错误,注释并不需要改变。不是在意图层次上写的注释是难以维护的。 在代码的意图层次上书写注释 描述代码块的目的后面跟随注释。这里有一个效率低的注释例子,因为它没有在意图层次上操 作: { check each character in 'InputStr" until a dollar sign is found or all character have been checked } Done := False; MaxLen := Length( InputStr ); i := 1; while ( ( not Done ) and ( i <= MaxLen ) ) begin if ( InputStr[ i ] = ‘$’ ) then Done := True else i := i + 1 end; 第十九章 文 档 325 你可以通过读代码寻找一个$并退出循环。这段注释部分有用,它概述了问题、缺点在于 它仅仅重复了代码,在代码要做什么方面并未给你任何启发,下段注释会更好一些: { find ‘$’in InputPtr } 这段注释之所以好是因为它表明了循环的目的是要找一个$,但在循环为什么需要找一个 $方面并未给你启发——换句话说,就是循环的深层意图。下面的注释更好些: { find the command -- word terminator } 这段注释实际上包含了代码列中没有的信息,也就是$结束了一个命令字。仅仅读代码段 你是没办法推论出的,所以注释是很有帮助的。 在意图层次上面考虑注释另一种办法是考虑如何命名一个子程序,这个例子所做的工作和 你要注释代码相同。假如你正书写几段代码,每段都有一个目的,这并不难。上面代码的注释 就是一个很好的例子。FindCommandWordTerminator()会是接下来的子程序名。其它的选择, Find$InInputString() 和 CheckEachcharacterInInputStrUntilADollarsignIsFoundOrAllc haractersHaveBeenChecked(),显然是不好的名字,对一个子程序名,你尽可能地不要用缩写 进行描述。这种描述很可能就是意图层次上的注释。 如果代码是另一个例程的一部分,采取步骤把代码放入自己的例程中。假如它执行一完整 的功能,并且你很好地命名了这个子程序,你就增加了程序的可读性和可维护性。 代码本身一直是你应检查的首要记录描述。上面的情况下,文字必须用一个命名常量替换, 并且变量应提供更多的有关要做什么的线索。假如你想扩展可读性的边界,就增加一个包含研 究结果的变量。在循环过程中更要做得清晰。下面是用很好的注释和很好风格书写的代码: { find the command-word terminator } FoundTheEnd := False; MaxCammandlength := Length( InputStr ); Idx := 1; whi1e ( ( not FoundTheEnd ) and ( Idx <= MaxCommandLength ) ) begin if ( InputStr[Idx] = COMMAND_WORD_TERMINATOR ) then begin FoundTheEnd := True; EndOfCommand := Idx end else Idx := Idx + 1 end; 假如代码足够好,它在接近意图层次上的注释。在这点上,注释和代码可能变得有点冗余, 但很少有程序出现这种问题。 把注释段集中在为什么而不是如何上 解释某某如何做的注释,通常是在程序语言水平上而不是问题水平上的。对一个注释用如 何执行一个操作去解释操作的意图几乎是不可能的,而且如何做的注释也常是多余的。下面的 第十九章 文 档 326 注释告诉你代码做什么了吗? /* if allocation flag is zero */ if ( AllocFlag == 0 ) ... 注释告诉你的比代码本身做的并不多。下面的注释怎么样呢? / * if allocating new member * / if ( AllocFlag == 0 ) ... 这段注释好些,因为它告诉你一些不能从代码本身中推论出来的事情。这段代码通过使用 含义丰富的命名常量而不是 0,但仍可得到提高。这里是这种注释和代码的最好版本。 / * if allocaing new member * / if ( AllocFlag == NEW_MEMBER )... 用注释告诉读者准备下面要做什么 好的注释告诉人们下面的代码将干什么。读者可以只浏览注释,对代码做什么并查找哪里 有特殊的动作,注释应当在代码前说明。这种概念不仅是在编程的课堂里教,它应是商业实践 中的标准。 对每条注释都计数 过多地注释没有优点。太多的注释会使代码表达的意思变模糊。不要写更多的注释,把这 些额外的精力放在使代码本身更可读上面。 标记有疑问的地方 假如你发现代码中有不明确的地方,应把它放到一个注释中。假如你使用一个巧妙的手法而不 是直接的办法来提高代码效率,应使用注释来指出直接的办法会是什么样的,并且指出通过使 用巧妙的方法提高效率的。这儿有一个例子: for ( i = 0; i < ElmtCount; i++ ) { /* Use right shift to divide by two. Substituting the right-shift operation cuts the loop time by 75%. */ Elmt[ i ] = Em1t[ i ]>> 1; } 例于中右移的选择是有目的的。有经验的程序员中,对整数右移功能上相当于除以二是普 通的知识。 假如是普通的知识,为何要描述它?因为操作的目的不是执行一次右移,它是要完成除 2 的功能。代码并未直接使用技巧。另外,大多数编译程序选整数除 2 的最佳方法任何时都是右 移,这意味着可以提高精确度。这种特殊情况下,编辑程序并未采用除 2 的方法,并且节省了 时间。有了这些描述记录,读者读代码时就会发现使用这些技术的意图。如果没有这些注释, 就会感到迟疑,认为代码没有效率上有意义的提高,也不一定“聪明”。通常这种迟疑是合理的, 因此记录这些异常情况还是很重要的。 第十九章 文 档 327 避免缩写 注释应当是明确的,不用进行缩写就应当可读,避免注释中出现缩写,除最普通的缩写外。 除非你在使用结束行注释,否则,不要使用缩写,那是一种过了时的技术。 区分开主要和次要的注释 在一些情况下,你想区分不同层次的注释,表明一个详尽的注释是前面的大注释的一部 分。你可用两种方法来解决。 你可以试着在主要的注释下面划线,次要的注释下面不划线来实现,就像下面这样。 /* copy the strng portion Of the tab1e,along the way omitting strings that are to be deleted */ /* -------------------------------------------------------------- */ /* determine number of strings in the table */ ⋯ /* mark the strings to be deleted */ ⋯ 这种办法的缺点是你被迫在多于你真正想划线的注释下面划线。假如在一条注释下面划了 线,那就是假定后面未画线的注释比它次要。因而,当你写第一条注释,而它并不比划线的次 要,那么它也必须划线,这样一直持续下去。结果是太多的下划线,或者不断地在一些位置划 线而其它的不划线。 这个题目有几种变化但都有一个共同的问题,假如你把主要注释用大写表示,而次要注释 用小写表示,你用太多大写注释的问题代替太多划线注释的问题。一些程序在主要注释上用大 写字母起始,而次要注释上没有大写字母起始,那么次要注释则易被忽略。 一个更好的办法是在次要注释前面使用省略号。这里有一个例子; /* copy the string portion of the table, along the way omitting strings that are to be deleted */ /* ... determine number of strings in the table */ … /* ... mark the string to be deleted */ … 另一个方法也常常是最好的方法是把主要注释放进自身的例程中。逻辑上,例程应是“平 行的”,在大约相同的逻辑层次上有自己的动作。假如你的代码存在一个例程中区分成主要的和 次要的动作,例程就不平行了。把复杂的操作分开放进自己的例程中,使其成为两个逻辑上平 行的例程,而不是一个逻辑上起伏的例程。 第十九章 文 档 328 这种主要和次要注释的讨论不适合于循环和条件环境中的交错代码。这样的情况下,交错 提供了对注释进行逻辑确认的线索。这种讨论仅适用于顺序代码段中,其中几段构成一个完整 操作并且一些段从属于其它段。 错误或语言环境独特点都要加注释 假如有一错误,它可能没有记录。即使它在某处已经记录过,它也会在你的代码中再次记 录。若它是个未记录的,它应在你的代码中被注释。 假定你发现库函数 WriteData(Data,NumItems,Blocksize),除去当 Blocksize 等于 500 外,都可以正常执行。对 499 和 501 及其它你曾试过的值也很好,但你发现仅当 Blocksize 等 于 500 时例程有一个缺陷。在使用 WriteData()的代码中,记录中 Blocksize 等于 500 时为何 你有一个特殊的情况?下面是它的答案: BolckSize := OptimalBlockSize( NumItems, SizeItem ); { The following code is necessary to work around an error in WriteData() that appears only when the third parameter equals 500. ‘500’ has been replaced with a naemd constant for clarity. } if ( BlockSize = WRITEDATA_BROKEN_SIZE ) BlockSize := WRITEDATA_WORKAROUND_SIZE; WriteData( File, Data, BolockSize ); 违反好的编程凤格的原因 假如你必须违反好的编程风格要解释为什么这样做。那样会阻止一个出于良好目的程序员 把代码改成好的风格,那也许会打乱你的代码。这种解释会让你自己清楚在做什么,而不是由 于粗心大意——给自己以信心,信心就是原因。 不要注释需要技巧的代码 一个最流行和有点冒险的编程说法就是,注释应当用在描述特别需要技巧的代码部分和 比较敏感的部分。原因就是,人们应当了解他们在那些地方工作时需要小心。 这是个可怕的想法 注释需要技巧实现的代码是错误的办法。注释不应当挽救困难的代码、就像 Kernighan 和 Plauger 所强调的那样:“不要注释坏的代码编写——重新编写它”(1978)。 一项研究表明:源代码中有大量的注释也有很多的缺点,并且会耗费很多的开发精力(Lind 和 Vairavan 1989)。作者倾向于注释难代码。 当某人说,“这是真的需要技巧的代码”,而我听另一些人说,“这代码真的不好”。若某事 似乎对你需要技巧,对别的人它也许不可理解。甚至某事对你似乎不需太多技巧而可能对以前 来看过这种技巧的人来说是不可能解决的。假如你问自己:“这需技巧吗?”是的。你总会发现, 再写时便不用技巧了,因而重写代码。使你的代码完善到不再需要注释,注释将会使它更加完 善。 这种建议主要适于你首次写代码时。假如你维护一个程序,不想重写坏的代码,那么注释 那些需技巧的部分是个好的练习。 第十九章 文 档 329 注释数据说明 变量说明的注释描述变量中由变量名无法表达的部分。仔细地记录数据是很重要的;至少 一家公司认为:标注数据要比标注数据使用过程更重要些。这里是一些注释数据的指导原则: 注释数字数据的单位 假如一个数字代表长度,要表明长度是否表示为英寸、英尺、米或者干米。假如是时间, 要注明它是表示从 1980 年 1 月 1 日起经历的时间,还是从程序运行花费的毫秒,等等。假如它 是坐标值,要注明是代表经度、纬度及高度,还是代表弧度或度。是否代表一个原点在地球中 心的 xyz 坐标系统等等。不要假设单位是显然的,对一个新的编程员,他们若不知道,那么对 在系统另外部分工作的人来说,他们也不知道。即使程序已经作了实质性的修改后,他们也不 知道单位是什么。 注释允许数值范围 假如一个变量有一个期望值范围,注明期望的范围。假如语言支持限制范围——像 Pascal 和 Ada 那样——限制范围。假如没有限制,便使用注释标明这期望值范围。例如,若一个变量 代表美元的钱数,注明你期望它在一到一百美元之间。假如一个变量表示一个电压值,表明它 应处于 105 伏到 125 伏之间。 注释代码含义 假如你的语言支持数字类型,像 Pascal 上和 Ada 那样,用它们表达代码含义。若没有的话, 便用注释表明每个值代表什么,使用一个命名常量而不是每个值一个文字。假如变量代表不同 种类的电流,注释可采用 1 代表交流电流,2 代表直流电流,3 代表不确定的。 下面是一个记录变量说明的例子,它说明这三种建议的过程: DIM CursorX% ‘horizonta1 cursor position : ranges from l..MaxCOls DIM CursorY% ‘vertical cursor position: ranges from 1..MaxRows DIM AnternaLength ! ‘length of antenna in meters; ranges is >= 2 DIM SignalStrength% ‘strength of signal in kilowatts; ranges is >= 1 DIM CharCode% ‘ASCII character code; ranges from 0..255 DIM CharAttrib% ‘0=Pinin; 1=Italic; 2=Bold; 3=BoldItalic DIM CharSize% ‘size of character in Points; ranges from 4..l27 注释中给出了所有的范围信息。在支持多变量类型的语言中,你可以说明这些变量的范 围。这儿有一个例子: var CursorX: l.. MaxCols; { horizontal screen position of cursor } CursorY: l.. MaXRows; { vertical position of cursor on screen } AntennaLength : Real; { length Of antenna in meters; >= 2 } SignalStrength : Integer; { strength of signa1 in kilowatts; >= l } 第十九章 文 档 330 CharCode: 0..255; { ASCII character code } CharAttrib: Integet; { 0=Plain; 1=Italic; 2=Bold; 3=BoldItalic } CharSize: 4..127; { size of character in points } 注释输入数据 输入数据可能来自于一个输入参数、一个文件或直接用户输入。上述应用指南也适于输入 数据。要保证期望值和非期望值都被记录,在不接收某些数据的子各程序中,注释是记录唯一 的方式。说明是另一种方式,假如你使用的话,代码会变得更具有自检能力。 位层次上的注记标记 假如一个变量用作一个位域,记录每一位的含义,就像下面的例子。 Var { The meanings of the bits in StatusFlage are as follows: } MSB 0 error detected: 1 = yes, 0 = no 1-2 kind of error: 0 = syntax, 1 = warning, 2 = servere, 3 = fatal 3 reserved ( should be 0 ) 4 printer status : 1 = ready, 0 = not ready … 14 not used ( should be u ) LSB 51 not used( should be 0 )} StatusFlags : integer; 假如这个例子用 C 编写,它将需要位域的语法以使得位域含义能够自我记录。 表明与有变量名的变量相关的注释 假如你有参照某一特定变量的注释,确信无论何时变量修正了,注释也会修正。一种办法 可提高修正的准确性,即包含有变量名的注释。可以通过字符串搜索变量名,像寻找变量那样 找到注释。 注释全局数据 若使用了全局数据,在它进行说明的地方标注好每一条数据。标注应表明数据的意图及为 何它需要是全局的。命名规则应当是强调一个变量的全局状态的首要选择。假如命名规则未使 用,注释应当填补这些。 注释控制结构 控制结构前面的空间,通常是放置注释的自然而然的地方。假如有一个 if 或一个 case 说明,你 可以提供决定和结果的原因。这里有两个例子: /* Copy Input field up to comma */ while ( * InputStr != ',' && *InputStr != END_OF_STRING ) { *Filed = *InputStr; Field + +; InPutStr + +; 第十九章 文 档 331 } /* while -- copy input field */ ——循环结束 *Filed = END_OF_STRING; /* if at end of string, all actions are complete */ ——条件的目的 if (* InputStr != END_OF_STRING) { /* read past comma and subdsequent bkanks to get to the ——循环目的 next input field */ *InputStr++; while (*Inputstr == '' && *InputStr != END_OF_STRING ) InputStr + +; } /* if -- at the of string */ 这个例子给出了一些指导原则。 在每块 if、case 或循环前面加一条注释 这样的位置是注释的自然地方,这些结构常需要注释。使用注释来阐明控制结构的目的。 注释每个控制结构的结尾 使用注释说明结尾是干什么用的。例如: end : { for Clientldx —— process record for eath client } 在长的或嵌套的循环结尾处,注释是尤其有帮助的。在支持命名循环的语言中(例如,Ada) 命名循环。在其它的语言中,使用注释来阐明循环嵌套。这是 Pascal 语言写的使用注释说明循 环结构结尾的例子: for Tableldx:= 1 to TableCount begin while( Recordldx < RecordCount) begin if ( not IllegalRecNum( Recordldx )) begin ... end; { if } end; { while} —— 这些注释表明结束控制结构 end; { for } 这种注释技巧增补了由代码缺陷引起的关于逻辑结构的可见性线索。你不需要使用这种技 术缩短没有嵌套的循环。然而,当嵌套很深或循环很长时这种技术才会有收效。 尽管它是有意义的,但加入注释并维护他们将是乏味的,避免这些乏味工作的最好办法是 经常重写那些需要乏味记录的复杂代码。 注释子程序 例程层次上的注释是在典型计算机科学手册中的最坏建议。很多手册要求你在每个例程的 第十九章 文 档 332 顶部有一堆信息,而不管它的大小或复杂性。这儿是个例子: ' * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ' ' Name: CopyString ' Purpose: This routine copes a string from the source ' siring (Source$) to the target string (Target$ ). 'Algorithm: It gets the length of Source $ and then copies each ' character, one at a time, into Target$. It uses ' the loop index as an array Index into both Source$ ' and Target$ and increments the loop/array index ' after each character is Copied. ' Inputs: Input $ the string to be copied ' Outputs: Output $ The string to receive the copy of Inupt$ ' Interface Assumptions : None ' Modification History : None ' Author: Dwight K. Coder ' Data Created: 10/1/92 ' Phone: (222) 555-2255 ' SSN: 111-22-3333 ' Eye Color: Green ' Maiden Name: None ' BloodType: AB- ' Mother's Maiden Name: None ' * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 这是可笑的。拷贝字符串大概是一个很小的例程——大概少于五行代码。注释完全不符合 例程的比例。关于例程目的和算法那一部分是受限制的,因为它很难把某事描述的像拷贝字符 串例程那样简单。注释不都有用,他们仅仅是占用了列表中的空间。每个子程序和需要所有这 些部分是不精确注释和维护失败的方法。它是许多的从没有收效的工作。这里有一些注释例程 的指导原则; 保持注释接近于它们描述的代码 例程的序言部分不应含有大量注释的一个原因是这样的,注释会偏离它们描述的例程部分。 维护过程中,偏离代码的注释不会和代码一样得到维护,注释和代码便开始产生分歧,并且突 然间注释会变得没有用处。 相反,要遵循最接近原则,让注释尽可能地接近它们描述的代码。它们更可能得到维护, 第十九章 文 档 333 也会一直都有价值。 下面描述了例程序言的几个部分,需要时应尽量包含。为了方便,创立一个常用的序言记 录。不要认为每种情况都必须包括所有信息。把适用的部分用上而其它的去掉。 例程的顶部用一两句注释来描述 假如你不能用一句或两句短句来描述例程,你可能需要认真考虑。例程要做什么?创建一 条简短描述是一个信号,标志着这种设计是否达到了最佳,否则重新回到画图设计桌上重新再 来一次。简短的概括说明在所有实际例程中都应有。 在输入和输出变量说明时描述 假如你未使用全局变量,标记输入和输出变量最简单的办法就是紧跟着参量说明进行注释。 这儿是个例子: procedure InsertionSort ( Var Data: tSortArray; { sort array elements FirstElmt.. LastElmt } FirstElmt: Integer; { index of first element to sort } LastElmt: Interger; { index of element of sort } ); 这段程序是不使用结束行注释的一个很好特例,它在注释输入和输出变量时尤其有用。这 种情况的注释也很好地说明了,使用标准缩写的值而不是例程参数表的结束行缩写,假如你使 用结束行缩写,将没有空间给含义丰富的注释。例子中的注释,甚至是连标准缩写都受到了空 间的限制,虽然本例中的代码多于八十列,但这不会是个问题。这个例子也说明了注释不是记 录的唯一形式。假如你的变量名足够好,你就能够不去注释他们。最后,标记输入和输出变量, 是避免用全局数据的很好的原因。你在什么地方标记呢?假定你在这庞大的序言中记录这些全 局数据,那将会造成更多的工作,并且不幸地是实际上通常意味着这些全局数据并未得到记录, 那真是太糟了,因为全局数据应该像其它事物那样得到注明。 输入和输出数据间的差别 知道哪个数据用于输入哪个用于输出是很有用的。Pascal 中相对容易地可看出,因为输出 数据通过关键词 Var 进行,而输入数据不是。假如你的语言不能自动支持这些差别,就要增加 注释。这里有一个 C 语言例子: void StrinyCopy ( char * Target, /* out; string to copy to */ char * Source /* in; string to Copy from */ ); … C 语言例程说明有些技巧,因为有些时候星号(*)表明变量是一个输出变量,更多时候它仅 意味着变量作为指针类型比作为基本类型更易处理。你常不用明确区分输入和输出变量。 假如你的例程足够短,在输入和输出数据间保持了清晰的界限,注记数据的输入或输出状 态可能没必要。假如例程较长,它对于帮助别人阅读例程是有帮助的。 注释界面假设 第十九章 文 档 334 注释界面假设可能会被看作是其它注释原则中的一部分。假如你做了变量状态的假设—— 合理和不合理的值、顺序分类过的数组等等——在例程序言中或在数据说明的地方注释它们。 这种注释应出现在任何实际的例程中。 要保证使用的全局变量被注释,因为它有时是例程的接口,并且有时它看起来不像变量, 所以它还是很危险的。 当你在写一个例程,并意识到自己在作一个接口的假设时,立即把它记下来。 记录程序变化的次数 记录一个例程自它初创建后所作的变化。变化是经常发生的,因为有错误并且错误集中在 几个困难的例程上。例程中的许多错误意味着相同的例程可能有更多的错误。记录下例程测试 到的错误过程,就意味着假如错误的数量达到某一点,你就知道应该重新设计和重新编写这些 例程。 当你执行每个例程操作时不想看到每一个程序有错误。记录程序的变化过程可导致对例程 顶部错误描述的混乱,但这种方法是可以自行排错的。假如在例程项部你得到了太多的错误, 你自然的倾向将会去除错误并重写例程。很多程序员喜欢有借口来重写代码,采取他们知道的 任何办法,使程序更好,那样做是很合理的。 注释例程极限 假如例程提供了数字的结果,要表明结果的准确性。假如计算在一些条件下没有定义。就 记录这些条件。假如例程出现错误时有一个缺省的结果,就记录这些结果。假如例程期望仅在 数组或某个大小的表上工作,就注明这些。假如你知道程序的修正,但会打断例程,就把他们 记录下来。假如你在例程开发过程中陷入困境,也记录下来。 注释例程的全局效果 假如例程修正了全局数据,确切地描述全局数据做了些什么。就像在 5.4 节提到的,修正 全局数据至少比仅仅阅读它会更危险些,因此修正时应谨慎行事,还要清楚地记录下来。像通 常一样,假如注释变得太繁重了,重写代码会减少全局数据的使用。 注释使用的算法的来源 假如你使用了一个从一本书中或杂志中得来的算法,记录下它出处的卷号和页数。假如是 你自己提出的算法,要表明在何处读者可以找到你所说的算法的介绍。 注释程序的各部分 一些程序员使用注释来标记他们程序的各部分,以便他们可以容易找到它们。C 语言中一 个这样的技巧就是用下面这样的注释注记在每个例程的顶部: / * * * This allows you to jump from routine to routine by doing a string serach for * * */ 一个类似的技巧是标记不同种类的注释,要清楚他们描述的是什么。例如,Pascal 中你会 用到{-X-,这里 X 是你用来表明注释种类的代码。注释{-R 一能够表明例程中描述的注释, {-I 一输入和输出数据,{-L 一表示描述本地数据等等。这种技巧允许你使用工具从你的源文 件中抽取不同种类的信息。例如,你可以找{ -R- 来修正所有例程的描述。 第十九章 文 档 335 注释文件、模块和程序 文件、模块和程序都由它们包含多个例程这个事实来表征。一个文件或模块应包含功能中 所有例程。注释工作提供给文件、模块或程序的内容是相同的,所以仅参照注释“文件”,也可 以假定这些指导原则也适于模块和程序。 注释的通用准则 在文件的开头,应该用一个注释块来描述文件的内容。以下是关于使用注释块的几条准则; 描述出文件的功能 如果程序中的所有子程序都在一个文件中,那么文件的功能使十分明显了。但如果你所从 事的项目中用到了多个模块并且这些模块被放在多个文件中,那么就应当把某些子程序放入某 一特定模块作出解释。同时说明每个文件的功能。由于在这种情况下,一个文件就是一个模块, 因此有这个说明便足够了。 如果是出于模块化以外的考虑而把程序分成几个源文件的,那么对每个文件功用的清楚解 释将对以后修改程序的程序员有非常大的帮助。假设某人想寻找某个执行任务的子程序,那么 他能仅通过查阅文件的注释块便找出这个子程序。 把你的名字和电话号码放入注释块 在注释块中加入作者是非常重要的。它可以为继续从事这个项目的程序员提供关于程序风 格的重要线索,同时,当别人需要帮助时也可以方便地找到你。 在注释块中加入版权信息 有些公司喜欢在程序中加入版权信息。如果你的公司也是这样的话,你可以在程序中加入 下面的语句: /*(c)Copyright 1993 Steve McConnell,Inc.All Rights Reserved. */ … 注释程序的注释变例 绝大多数资深的程序员都认为本书前面所论述的注释技术是非常有价值的。然而关于这方 面科学的、确凿的证据则很少。但是当组合使用这些技术时,支持这一想法的证据则是非常确 凿的。 在 1990 年,Paul Oman 和 Curtis Cook 发表了一组关于注释技术“Book Paradigm”的研究 成果。他们想要寻找一种支持不同阅读风格的编程风格。一个目标是同时支持由下而上、自上 而下和中心搜索。另一个目标是把程序分成比一长串相似代码可读性更好的大的程序块。Oman 和 Cook 想使这种风格同时提供低层次和高层次的程序组织线索。 他们发现如果把程序当作一种特殊的书并据此来组织格式就可以达到上述目标。在这本书 中,代码及其注释是模拟书籍中的格式安排以便获得对程序的总体把握。 “前言”由一组在文件开头常见的注释组成。它与真正书籍中前言的作用是一样的,主要 是为程序阅读者提供程序的总体信息。 “目录”中表示了文件、模块和子程序(就像书中的章)。其表示形式可能是表(如真正的 书一样),也可能是图或结构字符。 “节”是子程序内部的名子部分——子程序声明、数据说明及可执行语句等等。 第十九章 文 档 336 “参考资料”则是代码的参阅图,其中包括行数。 Oman和Cook 利用书籍和程序代码之间的相似性而创造的技术与第十八章和本章所论述的 技术是类似的。 当 Oman 和 Cook 把用这种方法组织的程序交给一组职业程序员来维护时,发现维护这种 用新方法组织的程序的时间要比维护同样内容用传统方法组织的程序的时间少 25%,而维护质 量则要高 20%。同时,在 Toronto 大学(1990 )进行的类似研究也证实了这一结果。 这种技术强调了同时对程序组织进行低层次和高层次说明的重要性。 检查表 有效的注释技术 通用部分 · 代码中是否包含了关于程序的大部分信息? · 是否可以做到随意拿出一段代码便可以立刻理解它的意思? · 注释是否注释了程序的意图或总结了程序的功用而不是简单地重复代码? · 是否使用了 PDL——代码流程以减少注释时间? · 对使人困惑的代码是否进行了重写而不是注释? · 注释是否已经过时了? · 注释是清楚正确的吗? · 注释风格是否使得注释很容易修改? 语句和段落 · 是否避免了结束行注释? · 注释的重点是“为什么”而不是“是什么”吗? · 注释是否提示了后续代码? · 每个注释都是合理的吗?是否删掉或改进了冗余、自相矛盾的注释? · 是否注释了令人惊异的代码? · 是否避免了缩写? · 主要和次要注释间的区别明显吗? · 用于错误处理或未说明功能的代码注释了吗? 数据说明 · 数据说明单元注释了吗? · 数值数据的取值范围注释了吗? · 是否注释了代码的含义? · 对输入数据的限制注释了吗? · 是否在位层次上对标志进行了注释? · 是否在说明全局数据的地方对其进行了注释? · 常数值是否被注释了?或者被用命名常量代替了吗? 控制结构 · 每一个控制语句都进行注释了吗? · 冗长或复杂的控制结构进行注释了吗? 第十九章 文 档 337 子程序 · 对每个子程序的功用都作出注释了吗? · 在需要时,是否对关于子程序的其它信息进行了注释?包括输入/输出数据、接口假 定、错误修正、算法来源、全局效果等? 文件、模块和程序 · 程序中是否有关于程序总体组织方式的简短注释? · 对每个文件的功用都进行描述了吗? · 注释块中有作者姓名和电话号码吗? 19.6 小 结 · 是否注释就像是立法。注释得好,是非常值得的,注释得不好,则是浪费时间而且有害。 · 源代码中应含有关于程序的绝大部分重要信息。只要程序还在运行,那么代码中的注释便 不会丢失或被丢弃。把重要信息加入代码是非常重要的。 · 好的注释是在意愿层次上进行的,它们解释的是“为什么”而不是“是什么”。 · 注释应表达出代码本身表达不了的意思。好的代码应是自说明的。当你对代码进行注释时, 应问一下自己“如何改进代码以使得对其注释是多余的?”,改进代码再加注释以使它更清 楚。 第二十章 编程工具 338 第二十章 编程工具 目录 20.1 设计工具 20.2 源代码工具 20.3 执行代码工具 20.4 面向工具的环境 20.5 建立自己的编程工具 20.6 理想编程环境 20.7 小结 相关章节 版本控制工具:见 22.2 节 调试工具:见 26.5 节 测试支持工具规 25.5 节 现代编程工具减少了编程所需时间,用最先进的编程工具能提高产量达 50%以上,编程工 具也能减少在编程时所需做的许多乏味的细节工作(Jones 1986,Boehm 1981)。 狗可能是人最好的朋友,但很少有哪种工具是程序员的最好朋友,正如 Barry Boehm 指出, 2O%工具起到了 80%工具的用途(1981)。如果错过了一个很有用途的工具,就可能失去了好 好利用其许多用途的机会。本章主要介绍一下你能获得和买到的工具。 本章重点放在两个方面。首先概述结构性工具。需求排序、管理、端到端(end-to-end) 进一步阅读开发工具是本章主要讲述的范围,本章结尾部分的能指导你获得软件开发方面更多 的信息;第二,本章力求覆盖到各种工具而不仅仅只涉及几种特殊的分支,有几种工具应用非 常普遍,我们仅提及其名字讨论一下,因为其版本、产品换代升级非常快。恐怕我们这里涉及 的大部分信息已经落后了。所以你得求助于当地的代理商查问你感兴趣的工具。 如果你是一个工具专家,那么本章的内容对你没什么帮助,你可测览一个前面目录及后面 20.6 书的有关理想编程环境即可。 20.1 设计工具 现在的设计工具主要包含图形工具,这些工具主要用来绘图。设计工具有时包含在 CASE 工具中作为其一项主要功能;有些代理商干脆就认为设计工具就是 CASE 工具。 图形设计工具通常都允许你用普通的图形来表示出一种设计,如分层图,分层输入输出图、 组织关系图、结构设计图、模块图等。有些图形设计工具仅支持一种图形符号,另一些则支持 多种符号,大多数支持 PDL。 从某种意义上来讲,这些设计工具仅仅是想象和模拟那些画图器件。用一些简单的绘图工 第二十章 编程工具 339 具或笔和纸,你就可以做出任何绘图设计工具所能做的工作。但设计工具有提供准确数据的能 力,而一般画图器件则不行。假如你画一个气泡图然后消去一个气泡,包括与该气泡连接的箭 头和低层的气泡。当你增加一个气泡时,设计工具也会在内部重新安排组织。设计工具能让你 在不同层次上抽象地移动,且检查你设计的连续性,有些甚至能直接从你的设计中产生代码。 20.2 源代码工具 有关帮助生成源代码的工具比设计的工具要丰富而成熟。 编辑 这种工具与编辑源代码有关 编辑程序 有些程序员估计他们在编辑源代码上的时间占全部时间的 40%,若真是这种情形,还不如 多花几美元去买一种更好的编辑程序。 除了基本的文字处理功能,好的程序编辑程序常有以下特征: · 编辑程序提供编辑和错误测试功能。 · 简述程序概貌(给出于程序名或逻辑结构而无具体内容)。 · 对所编辑语言提供交互式帮助。 · 括号或 begin-end 协调使用。 · 提供常用的语言结构(编辑程序在写入 for 以后自动完成 for 循环结构)。 · 灵活的退格方式(包括当逻辑改变时方便地改变语句的退格)。 · 相似语言间宏的可编程性。 · 搜索字符串的内存,使得公用字符串根本不需再输入。 · 规则表达式的搜索和替换。 · 在文件组中能搜索和替换。 · 同时编辑多个文件。 · 多层编辑。 考虑到原先多数人所用的原始编辑程序,你可能惊讶地发现,很少有哪种编辑程序包括以 上所有这些功能。 多文件字符串转换程序 通常若能在多个文件中同时修改一字符串是很有用。比如,如果你想给一个子程序、常量、 全局变量起一个更好的名字,你可能要在几个文件中同时修改。允许在几个文件中更改字符串 的功能,使这项工作很容易完成,从而能很快就把一个子程序名、常量名或全局变量名修改过 来,很方便。 AWK 编程语言是能方便地在多文件中搜索和替换的工具,AWK 的这种功用有时很难排序,它 有时被说成是使用一“相关数组”。它的主要优点在干,擅长于处理字符串和多组文件。通常它 在多组文件中修改字符串是很方便的。其它工具在处理多文件字符串修改时总有一些限制。 文件比较程序 第二十章 编程工具 340 程序常要对两个文件作比较。如果你为修改一个错误而作了几种修改尝式,最后需要把不 成功的尝试消去,那么你就要把原文件与修改过的文件作比较,列出那些被修改的行。如果你 和几个人合伙编一个程序,而想看看别人在你之后对代码作了何修改,可用比较程序把新版本 的程序与你当时的程序作比较,找出不同之处。如果你发现了一处你不记得是不是在旧程序出 现的不足,不用着急,可用比较程序比较新旧文件,看看到底修改过没有,找出问题之源。 源代码美化程序(source-code beautifiers) 源代码美化器修整你的源代码以使其看起来协调。它们标准化你的编排形式、对齐变量定 义及子程序头、格式化注释使看起来协调及其它类似功能。在打印时,有些美化程序甚至使每 个程序抬头从新的一页开始或进行其它格式代工作,许多美化程序让你的代码显得更漂亮。 除了使代码显得好看,优化程序在其它几个方面也很有用。如果一个程序由几个程序员编 写而有几种不同的风格,那么代码美化程序能把这些风格转化成一种标准的形式。几种不同的 风格转化成一种标准的形式,而无需某个人来完成此项工作。优化程序产生的缩排使人误解的 可能性要比人小得多。如果用 for 循环忘了在循环体两端加上 begin-end,那么读者也许会认 为你这部分都属于 for 循环,但对计算机则不这么认为。优化程序就不会有这种错误发生。当 它重新格式代码中,它不会像人那样容易产生使人误解的错误。 模板(template) 如果你要开发一个简单的键盘任务,而这项任务又得经常做且要连续使用,那么模板会对 你有所帮助,假设要在程序的开头写一个标准的注释性序言。那你得先写一个语法和位置都正 确的、包含所有要用的序言模板,这个骨架是你要存储在文件或键盘宏中的“模板”,当你产生 一个新文件时,可很方便地把这个模板插入你的源文件中,你可用这种样板技巧来建立大型的 组织框架,比如模块和文件等,或程序框架,如循环。 如果你同时在完成几个工程,样板是你协调编码和组织程序风格的好方法,在整个工程开 始前,整个小组应当研究出一个供全组使用的模板,这样整个小组就可能很方便地完成这项工 程,而且显得一致。 浏览 这组工具可使你方便地查看源代码,编辑程序也能让你查看源代码,但浏览程序 (browsers)却特别有功效。 浏览程序 有些工具专为浏览而特别编制,浏览的意思是,若你要买什么你喜欢的东西,你可以轻松的 方式通过“商店窗口”来查看。而在编程中,浏览的意思是你知道要找什么而希望马上就找到。 浏览程序能使你连续作出修改——比如要修改全部碰到的子程序名或变量名,一个好的浏览程 序能在一组文件中搜索与替换。 第二十章 编程工具 341 一个浏览程序能在所有文件中找出要搜查的变量和调用子程序。它能作用于一个工程中的 所有文件及所有有特定名字的文件。它能寻找变量和子程序名或寻找简单字符串。 多文件字符串搜寻 某些特别的浏览程序能在多文件中寻找你所指定的字符串,你可用它去搜寻一个全局变 量所有出现的地方,或一个子程序会所有出现的地方。你可用它去查找某个程序员所编的所有 文件,找出在文件中出现的程序员名字。这样,若你在一个文件中发现一个错误,你可用它去 查找其它由同程序员编的文件中的类似错误。 你可用它去搜索特定的字符、相似的字符(不管大小写)或标准表达式。标准表达式特别 有 用,因为你可用它去寻找复杂字符串.如果你要寻找所有含 O~9 数字下标的数组,你可这样查 找:先找“┌”后跟几个或不跟空格,是数字 O~9,再后是几个或无空格,再后就是“┐”。 一个用得很广的搜寻工具是 grep。一个 grep 查询数字的形式如下 grep "\[*[0-9]**\]" *.c 你可使表达式更复杂以调整所要搜索的目标。 互相参照工具 一个互相参照工具在一张很大表中列出所有变量、子程序名及所出现的地方。这些面向批 处理的工具已被大多数的交互式工具所取代,这些交互式工具根据需要产生必要的变量、子程 序的信息。 调用结构生成程序 一个调用结构生成程序能产生所有有关子程序调用的信息。这在调试时有时是有用的。但 更常用到的情况是在分析程序结构、或把程序包装成模块、或重复共用程序段时,有些生成程 序生成所有调用情况表。其它的生成程序则生成由顶层调用开始的树状图。还有一些是向前 或向后追踪程序调用情况的,即列出所有被一个程序调用的子程序(直接与间接)或所有调用 一个子程序的程序(直接和间接)。 分析代码质量 这种工具检查静态源代码以测试其质量。 语法和语义检查程序 语法和语义检查程序提供比编译程序更多的检查代码的功能,一般的编译程序仅能提供检 查基本的语法错误功能,而一个挑剔的语法检查程序则可利用语言间的细微差别检查出许多隐 含的错误——那种编译程序不会指出你可能不愿那样编写的错误,例如,c 语言中: while(i=0)... 是一句完全合法的语言,但它实际的意思是: while(i= =0).... 第一句是有语法错误的,把 '= '和 '== '弄混是一常见的错误。Lint 是一个你在许多 C 环境 中都能看到仔细的语法和语义检查员。Lint 提醒你,哪些变量未赋初值、哪些变量定义但完全 第二十章 编程工具 342 没用到、哪些变量赋值了但从来没用到、哪些通过子程序传递的参数没有赋值、可疑的指针 运算、可疑的逻辑比较(像上面例子所示)、无法用到的代码及许多别的问题。 质量报告程序 有些工具分析你的代码并且报告你程序的质量。比如你可买那些能报告你每个程序复杂性的 工具,这样你能集中精力对那最复杂子程序进行检查、测试、重新设计。有些工具计算代码的 行数、数据的定义、注释、整个程序和单个子程序中的空行数。它们跟踪错误及与这些错误有 关的语句,以使程序员能修改它们;提供可能的修改选择供程序员选择,它们还能计算软件的 修改次数及指出经常修改的子程序。 重新组织源代码 有些工具能把源代码从一种格式转化成另一种格式。 重构程序 重构程序能把那些含 goto 的程序转换成无 goto 的结构化代码,这种情况下,重构程序就 得做许多工作。如果原代码的逻辑结构令人感到可怕,那转换后的逻辑结构同样可怕,当然若 你要靠手工来作这种转换,你可先用重构程序来做一般情况的转换,而用手工来做那些比较难 的情况转换。当然你也可用重构程序来做全部的转换,用以启发你自己做手工转换。 代码翻译程序 有些工具能把代码从一种语言翻译成另一种语言。若你要把一个很大的代码从一种语言环境 移植到另一种语言环境,翻译程序是很有作用的。跟重构程序带来的灾难性后果一样,若你的 代码质量很差,翻译程序就照直把这种环的代码译成另一种语言。 版本控制 利用版本控制工具能帮助你应付软件版本的迅速升级,如; · 源代码控制 · 风格控制 数据词典 数据词典包含程序变量名及描述它们的数据库。在一个很大的的工程中,数据词典对于跟 踪成千上万个变量的定义是很有用的。在数据库工程中,数据词典对描述存在数据库中的数据 是很有用的。在小组合作编程时,数据词典可避免起名字的不一致。这种不一致(或冲突)有 时是直接的、语法上的冲突,即同一名字有两种意思;或是间接的、隐含的冲突,即不同名字 是同一意思。 数据词典包含每个变量的名字、类型、属性,也包含如何使用的注释。在多人合作编程环 境中,这个词典应当对每个程序员随时可查。就像程序员支持工具变得越来越强有力一样,数 据词典也变得越来越重要;数据随时可能用到,但必须有个工具为它们服务。 第二十章 编程工具 343 20.3 执行代码工具 执行代码工具踉源代码工具一样丰富。 代码生成 本书叙述的工具能帮助生成代码。 链接程序 一个标准的链接程序,能链接一个或几个由源代码文件生成的目标文件,以生成一个可执 行程序,许多功能强大的链接程序能链接用几种语言写成的模块。允许你选择最合适的语言而 不管那些集成的细节问题。有些利用共用存储区的链接程序,能帮助你节省内存空间。这种链 接程序生成的执行代码文件能一次只向内存装载部代码,而把其余部分保留在磁盘中。 代码库 在短时间内写成高质量代码的方法是,一次不全部都写出来,其中部分可以借用已有程 序。至少下面这些部分你是可买得到的高质量的代码库: · 键盘和鼠标输入 · 用户界面窗口的生成 · 屏幕和打印机输出 · 复杂的图形函数 · 多媒体应用生成 · 数据文件操作(包括常用数据库操作) · 通讯 · 网络 · 文本编辑和字处理 · 数学运算 · 排序 · 数据压缩 · 构造编译程序 · 依赖平台的图形工具集 只要在 Microsoft Windows.OS/2 Presentation Manager,Apple Macintosh 和 X Window System 中把你写的代码重写编译一次就可运行。 代码生成程序 如果你买不到你所要的代码,让别人去写怎么样?你无需到处找人,你可买些工具回来, 让它帮你写所需要的代码,代码生成工具着意于数据库应用,它包含了许多用途。普通的代码 生成程序写些数据库、用户界面、编译程序方面的代码。这些代码当然不如人写的那样好,但 许多应用场合用不着人工来编码。对许多用户来说,能得 10 个应用代码总比只能得到一个好代 码强.。 代码生成程序也能生成代码原型,利用代码生成程序你可在短时间内描绘出一个用户界面 的原型,你也可尝试用几种不同的设计方法。要做同样的工作靠手工可能要花上几个星期,你 第二十章 编程工具 344 又为何不用最便宜的方法呢? 宏预处理程序 如果你用 C 来编程而用到了宏预处理程序,你可能觉得没有预处理程序来编程是很困难的。 宏允许你几乎不花什么时间就能产生一个简单的有名字的常量,比如用 MAX_EMPS 替代 5000, 那么预处理程序就会在代码编译时用 5000 来替代 MAX_EMPS。 宏预处理程序也允许你生成一些复杂的函数,以便在程序中简单使用,它仅在编译时被替换 回来而不花什么时间。这种方法使你的程序可读而易维护。因为你在宏中给出了一个好名字, 所以你的程序更好读;又因为你把所有的名字放在一个地方,因而修改起来极其方便。 预处理程序功能对调试也很有好处。因为它很容易在改进程序时进行移植。在改进一个程 序时,如果你想在每个子程序开头检查一下各内存段,那么你可以在每个子程序开头用一个宏。 在修改以后,你可能不想把这些检查留在最后的代码中,这时你可重新定义这些宏使它不产生 任何代码。同样的原因若你要面向不同的编译环境。如 MS-DOS 和 UNIX,宏预处理程序是很好 的选择。 如果所用语言控制结构不好,比如 Fortran 和汇编,你可以写一个控制流预处理程序用来 模仿 if-then-else 和 while 循环的结构化结构。 如果所用语言不支持预处理程序,你自已可写一个。这可参考(《Software Tools 》 (Kernighan 和 Plauger 1976)中的第八章或《softwareTools in Pascal》(Kernlghan 和 Plauger 1981)《SoftwareTools》 中也有如何在 Fortran 中编写控制流的方法,也可用到汇 编中去。 调试 这种工具在调试中有如下作用: · 编译程序警告信息 · 给出程序框架 · 文件比较程序(比较源代码文件的不同版本) · 执行显示程序 · 交互调试程序,软件和硬件的 下面讨论的测试工具与调试工具有关。 测试 用下面这些性能和工具能有效地帮助你测试: · 给出程序框架 · 结果比较(比较数据文件、监视输出及屏幕图像) · 自动测试生成程序 · 记录测试条件及重复功能 · 区域监视器(逻辑分析及执行显示器) · 符号调试程序 · 系统扰乱程序(内存填充、存储器扰乱、有选择地让存储器出错,存储器存取性检查) · 缺陷数据库 代码调整 这种工具帮助调整代码。 第二十章 编程工具 345 执行显示程序 执行显示程序在程序执行时显示代码运行情况,并且告诉你每条程序语句执行了多少次或 花了多少时间。在程序执行时,显示代码犹如一个医生把听筒放在你胸前而让你咳嗽一样。它 让你清楚地知道程序执行时的内部情况,告诉你哪是关键,哪个地方是你要重点调整的目标。 汇编列表和反汇编 有时你想看看由高级语言产生的汇编语言。有些高级语言编译程序能生成汇编列表;另一 些则不能,所以你得用反汇编从机器码生成汇编语言。看着编译程序生成的汇编语言,它表明 你的编译程序把高级语言转化成机器码的效率。它也告诉你为何高级语言看起来应当很快而实 际上却运行很慢。在第二十九章的代码调整技巧中,几个标准检查程序的结果是不直观的。当 用标准检查程序检查代码时,用汇编列表能更好地理解结构而高级语言却不能这样。 如果你觉得汇编语言很不舒服需要介绍,那最好的方法是把你用高级语言写的语句与编译 程序产生的相应的汇编指令作个比较。可能你第一眼看到汇编时会感到不知所措,编译程序生 成的代码你可能再也不喜欢看这类东西了。 20.4 面向工具的环境 有些环境非常适合于面向工具的编程。以下介绍三种: UNIX UNIX 和小而强有力的编程工具是不可分的。UNIX 的有名之处在于它收集了许多小工具,有 着明确意义的名字,像什么以 grep,diff,sort,make,crypt,tar,lint,ctags,sed,awk,vi 及其 它。和 UNIX 有密切联系的 C 语言也有着同样的基本原则,标准的 C 函数库就是一大堆小函数组 成的,可由这些小函数汇合成大程序。 有些程序员用 UNIX 编程效率非常高,以至于他们随时把它带在身边,它们甚至把 UNIX 中 的许多习惯用到 MS-DOS 和其它环境中去。UNIX 成功的原因之一是它把 UNIX 上的许多工具引 放到 MS-DOS 机上。 CASE CASE 是 Computer-Aided Software Engineering 的词头缩写,这种工具的功能是对软件的 改进工作提供端到端的支持,如需求分析、结构组织、具体设计、编码、调试、单元测试、系 统测试及维护等。若一个 CASE 工具真的把以上这些工作合成到一起的话,是相当强有力的,但 不幸的是大多数 CASE 工具总有这方面或那方面不足。 · 它们仅支持部分改进功能。 · 它们支待改进所有功能,但有些方面功能太差而实际不能用。 · 它们改进各个单独部分但却没有合成到一起。 · 它们需要太多的额外开销。它们想支持任何功能,但却显得十分庞大而效率不高。 · 它们把着重点放在方法论上而不考虑其它的方面,因而把 CASE 转变成了 CASD ( Computer-Aided Software Dogma ) 。 在 1992 年的软件质量讨论中,Don Reifer 报告 CASE 编程的效率概述。 第二十章 编程工具 346 APSE APSE 是 Ada progamming support Environment 的词头缩写。APSE 的目的是提供一套支持 Ada 编程的集合环境,它把重点放在软件内部应用上。美国国防部规定了 APSE 应有能力的要 求如下: · 代码生成工具(一个 Ada 程序编辑程序,一个好的打印机,一个编译程序)。 · 代码分析工具(静态和动态代码分析程序、性能测试方法)。 · 代码维护工具(文件管理程序、配置管理程序)。 · 项目支持工具(一个文件编制系统、一个方案控制系统、一个配置控制系统、一个出 错报告 系统、需求工具、设计工具)。 虽然有些代理商能提供一个 APSE,但却没有一个环境是出色的,不过 APSE 是开发工具 的一个方向。 20.5 建立自己的编程工具 假如给你 5 个小时去做一项工作,而让你按以下两种方法去做: 1.很舒服地在 5 个小时内做完这项工作。 2.花 4 小时 45 分钟去建造一个工具,然后用 15 分钟利用这个工具去完成这项工作。 大多数编程员肯定会选择第一种做法。建造工具在编程中必不可少。几乎所有的大编程项 目都有内部工具。许多工程有专门的分析和设计工具,这些工具甚至比市场上的工具要高级。 除了少数几个外:你完全可以编写出本章所提及的大多数工具。做这些事情可能不太值 得,但实现这些工作在技术上是没有什么困难的。 各项目独有的工具 许多中型或大型项目需要一些自己独有的工具来支持编程。例如你需要一种工具去生成特 殊的测试数据、检查数据文件的质量、模仿硬件等。以下是几个例子: · 一个航空小组负责编制一套飞行软件,来控制和分析它所得到的数据。为了检查这个 软件的性能,需用一些飞行数据来测试这个软件。工程师们写了一个传统的数据分析 工具,用以分析飞行系统软件的性能,每次飞行以后,他们都用这个传统的工具去分 析原始系统。 · Microsoft 计划发行一个窗口图形环境,其中包括一种新的字体技术。既然这套软件 和字体数据文件都是新东西,错误很有可能从这两者中产生。Microsoft 的工程师编 写几个传统的工具去检查数据文件中的错误,用以提高鉴别字体数据出错和软件出错 的能力。 · 某保险公司开发了一套很大的系统用以计算保险费用的增加。因为这个系统相当复杂 且精确性要求相当高,成百个算得的费用需仔细核查,但用手算一个费费需几分钟。 公司编制了一套独立的软件一次就计算出这些费用。利用这套工具,公司计算每一个 费用只需几秒钟时间,因而检查所用时间由原来占主要部分成为很小的一部分。 一个项目的计划中必须包含一些必要的工具,且要安排一些时间去做这项工作。 第二十章 编程工具 347 批处理 批处理是一种能自动做重复操作的工具。有些系统中批处理就叫批处理文件或宏。批处理 可简单可复杂,它的好处是易写好用。比如你要写日记但又想保密,那你就加上密,除非你输 入正确的口令,才可用。为了保证每次都能加密和解密,你得写一个批处理显示怎样给你的日 记解密,怎样处理输入的词,这个批处理如下: crypto C:\word\journal. * %1 /d /Es /s word C:\word journal.doc crypto C:\word\journal. * %1 /Es /s 这里%1 是输入密码的地方。因而是不能在批处理中明显写出来的。批处理可以使你不用每 次都输入所有的参数,而能保证所有的操作每次都按正确顺序执行。 如果你在一个时间内要经常输入超过 5 个字符的字符串,那最好把它写成批处理文件。用 到批处理文件的例子有编译、连接指令序列、备份命令及带许多参数的命令。 批处理文件增强程序 批处理文件是一个 ASCll 文本文件,它包含一系列操作系统命令,其中每个批处理语言支 持的命令,并不都是一样的,有些功能比另一些强。少数批处理语言如 JCL,竟和高级语言一 样复杂;有些则实用功能不强。如果所用批处理语言功能太弱,那就找一个批处理文件增强程 序,它会提供比标准批处理语言更强的能力。 20.6 理想编程环境 本书所讨论的理想编程环境仅仅是一种设想。这种软件还没有进入实用,本书仅就结构方 面的问题预测一下这种结构工具的趋向,本书所讨论的那些性能是现有技术水平很容易达到的。 而这些讲义对那些有志于在编程环境方面更上一层楼的人会是一种启发。在讨论中我们把想象 中的环境叫“Cobbler”,它起源于传说中补鞋匠的典故,而对于今天的程序员和编程工具来说 正是这种状态。 集成环境 Cobbler 环境包括编程中涉及到的具体设计,编码及调试活动。当涉及到源代码控制问题 时,Cobbler 也包含源代码版本控制。 语言主持 在代码构造过程中,CObbler 提供了有关编程语言交互参考的帮助信息。帮助信息详细列 举所使用语言功能的许多信息,帮助信息也列举了一般问题可能产生的错误信息。 环境也提供了语言结构的标准框架,这样你就无需知道 case 语句、for 循环或一个常用结 构的精确语法了。帮助信息也给出了环境所提供的子程序的列表,你可参考这个列表选择子程 序加入到你的代码中去。 具体的交叉参考 Cobbler 能让你得到一张变量出现地方及何时被赋值的表。你能从交互参考中得到变量定 义的信息。同时还能得到关于变量说明等注释性的信息。 你可同样方便地得到检索有关于程序的信息。你可以从一个指定子程序名的地方,上溯或 下查调用子程序的表链,你也可方便地检查一个子程序调用时各变量的类型。 第二十章 编程工具 348 用查询方式观察程序组织结构 Cobbler 从根本上改变了人们观察程序源文件的方法。编译程序读源文件是从上到下的, 传统的编程环境也迫使你由上往下观察程序。这种顺序式的读程方法已不能满足人们从各个角 度观察程序的需要 在 Cobbler 中,你可观察一大堆的子程序名、图形显示及具体观察一个子程序,也可选择 组织许多程序的模式如分层、网络、模块、目标或按字母顺序列表等。从一个较高层次观察一 个子程序可能不太清楚,要仔细看的话,可具体放大这部分(指具体调出这部分观察)。 当你深入观察一个子程序时,你能从几个层次具体地观察它,你可以仅观察子程序定义或 注释序言部分;你可以只看具体的代码或只看注释;你也可只观察程序的控制结构而不显示控 制块内的内容。在支持宏命令的语言中,你可观察含宏扩展和宏缩小的程序。若是宏缩小,你 可方便地扩大每一个宏。 本书中用一个词:外形观察(outline View)。没有外形观察和没有研究外形的能力及没有 放大具体的内容的能力,那么就不能很好地把握各章的结构。读每一章时平平淡淡地看,仅有 的一点结构概念仅来自于从上而下地翻一下书。若在高层次与低层次间放大与缩小不断观察角 度,那我们就能获得各章拓扑结构的概念,这对组织写作是至关重要的。 组织源代码跟组织写作一样难。当你有了多年组织写作的经验后,很难说你缺乏观察程序 拓外结构的能力。 程序各组成成份也要改进。源文件的语义有时也很模糊,用模块或目标等观点来替代现有 的观念。源文件的观念是独立的,你应当仔细考虑程序的语义,而不要考虑它存储时的物理结 构。 交互式格式化 Cobbler 提供比现有环境更灵活的格式辅助模式。比如它的用户界面使得程序的外括号比 内括号大。 环境根据用户规定的参数,而不依靠单独巧妙的打印机程序来格式化代码。一旦环境读入 了程序,那就知道了程序的逻辑结构及在哪儿定义变量,在 Cobbler 中,你不需依靠一个单独 的程序去格式化你的代码。现有的有些环境在你进入控制结构时提供自动的退格形式,但还不 十分成熟。当你在修改程序时,若有 45 行需退后六列时,你只能靠你自己来做这项工作了,而 在理想编程环境中,环境格式化代码是根据逻辑结构来做的,如果你修改了逻辑结构,环境相 应地对代码的格式作调整。 说明 Cobbler 支持传统的注释方法并对其做格式化工作,并且支持声音注释方法。这种方法不 是通过输人注释文字而是通过对鼠标讲几个词来存储你的思路。 性能调整 Cobbler 自动收集调整程序性能所需的信息。程序的哪一块花了全部执行时间的 90%?哪 一块程序根本末被执行。它收集具体的信息为改进设计作准备:哪一块程序逻辑结构最复杂或 有许多具体的数据;或哪些部分联系最紧?它收集修改的信息,哪些程序部分相对稳定?哪些 部分是通用的?环境能做以上各项工作而无需你做什么。 第二十章 编程工具 349 环境特性 环境状态总是在几个窗口间切换,你可在这些窗口间上溯或下拉。同样你也可这样处理编 辑程序:编辑程序有多层编辑、搜寻字符串、在单个子程模块或整个程序中搜寻和替换常规表 达式等功能,而这些功能是有具体环境的。 和现有最好的调试程序相比,Cobbler 提供最完整的调试环境。现在的许多调试程序仅在 某些方面令人满意,它们有的提供稳定逻辑结构、数据结构、数据的更改及目标的检查能力; 有些提供跟踪执行和测试某个子程序的能力,甚至测试在一个大环境中有上下文的子程序。但 在 Cobbler 中所有这些编辑、编译、调试诸功能都集成在一个环境中,在调试时你根本无需变 换“模式”。 在改进程序的过程中,你总要最大限度地利用环境的编译能力去编译你的程序,你可以执 行程序、修改部分程序,然后在不改变环境状态情形下继续执行程序。Cobbler 指出修改部分 与哪些部分有依赖关系而需重新编译。 当然所有这些操作都是瞬时的,因为 Cobbler 利用了效率很高的后台处理。 最后 Cobbler 支持开发模式。它与已有模式间有着根本性的区别,现有模式强调小而快的 功能;但开发模式着眼点不在如何减小机器周期,而着眼于最大限度地利用时间。强调尽快无 痛苦地发现和改正错误。在开发过程中,Cobbler 环境发现并警告你出现一切常见的问题。如 可疑指针、未赋初值、错误数组下标及数值的上溢等。编译程序很容易在程序中插入特殊代码 去检查这些问题。 Cobbler 的主要优点 实现理想开发环境需要程序员的努力,下面是现有环境和理想环境之间主要区别: · Cobbler 完全是交互式的。变量和子程序交互参考、子程序列表、语言参考信息等都 可立即显示在屏幕。这些功能在现有的环境中都可能有,只是没有集成罢了。 · Cobble。是一个预知的环境。它预先就知道你的要求,而不需等你提出问题。环境在 某些情况就知道你会对程序进行编译和链接。它为何要等到你去要求呢?假如有空闲 时间,它为何不在后台进行编译呢? · Cobbler 应用一切计算机时间,而节约人的时间。计算机时间变得越来越便宜,因而 可以用它来节省人的时间,在随时编译策略指导下,计算机可能编译那些你还未完成 的程序,这时有些编译是被浪费了,但在另外一些情形下呢?在这些情况下你就无需 等待编译结果了。 · Cobbler 抛弃了纯源程序的观点。虽然你想清楚知道你编了什么代码,但你却不必像 编译程序那样去看代码、数据定义、注释等。你也可以不去管那些具体的管理细节, 如子程序在哪个源文件中等;你可以只管定义目标和模块及限制它们之间的相互作用。 以上所有这些功能都是现在技术水平可以达到的,许多已在 smalltalk 或 APL 环境中实 现,其它的是很有名的超文本思想(hypertextiden)。所有这些功能并没有要求改变基本的编 程语言如 C、Pascal、Basic、Fortran 及 Ada,也没有要求放松代码间的直接联系。 第二十章 编程工具 350 20.7 小 结 · 好的工具使编程更容易。 · 你可以编写大多数你需要的工具。 · 用今天的技术水平能编出高水平的编程工具。 第二十一章 项目大小如何影响创建 351 第二十一章 项目大小如何影响创建 目录 21.1 项目大小 21.2 项目大小对开发活动的影响 21.3 项目大小对错误的影响 21.4 项目大小对生产效率的影响 21.5 小结 相关章节 创建前提:见第 3 章 创建管理:见第 22 章 在软件开发中常出现小的项目随着开发的进展,项目规模不断变大的现象,假定你计划用 6 个月的工作量开发——5000 行的软件包,并在进行测试时发现 25 个错误。软件开发成功了, 软件也可运行了,但可能导致程序的长度大大增加,甚至可达 50,000 行之多。 虽然最终软件的大小是所预期的 10 倍,并不意味着也需花费 10 倍的工作量,而可能是 20 倍的工作量。而且,20 倍的工作量并不意味着 20 倍的创建,也可能是 12 倍的创建和 40 倍的 系统综合和测试。你也将可能获得不仅是 10 倍的错误,而是 15 倍的错误。 如果你习惯于开发小项目,你的第一个中等项目将是非常困难的,而且不是你预期中的令 人愉快的成功。本章将讨论此问题并给出了具体应付方法。如果你已习惯开发中等项目,你可 以利用你的经验进行一次小项目开发。本章也将告诉你如何对项目进行控制。 21.1 项目大小 你所在项目的大小是典型的吗?项目大小的范围很宽,意味着项目不是典型的。估计项目 大小的一个方法是看项目开发组人员的多少。以下是二者间的粗略关系: 项目组人数 项目所占百分比 1~3 50% 4~8 33% 8~12 6% 12~20 5% 20~50 3% 50+ 2% 并不那么直观的数据,却反映了不同项目的大小和程序员人数之间的差别,大的项目往往 使用更多的程序员,以下是对程序员和项目大小的粗略估计: 第二十一章 项目大小如何影响创建 352 程序长度 程序员所占百分比 (以代码长度计算) 2k 5~10% 2k~16k 5~10% 16k~64k 10~20% 64k~512k 30~40% 512k+ 30~40% 21.2 项目大小对开发活动的影响 如果只有你一人开发项目,对项目成功或失败影响最大的正是你自己。如果你所在项目开 发组有 25 人,你也可能仍发挥最大作用,但更可能是大家各有一份功劳,整个集体对项目的成 功或失败有更大的影响。 交流和大小 如果你是某项目的唯一开发者,你唯一交流的途径是你和用户所进行的交流,其作用就好 似将你的左、右大脑联系起来。在项目组人数增多时,交流途径也相应增加了,但是二者的关 系并不是线性的。交流途径是和人的平方数成线性关系的。以下是图示: 1 3 6 两个程序员之间的交流路径 三个程序员之间的交流路径 四个程序员之间的交流路径 10 45 五个程序员之间的交流路径 十个程序员之间的交流路径 你可看到,二位程序员之间仅有一条交流途径,五位程序员间有 10 条交流途径,而 10 位 程序员间有 45 条交流途径——假定每一位程序员都可和其他程序员交谈。其中 2%的有 50 或 更多程序员的项目,其交流途径至少有 1200 条可挖掘途径。交流途径越多,你放在交流上的时 间越多,也就越容易出现错误。大的项目要求采用一定的方法以简化交流方法,以对其作一定 程度的限制。 第二十一章 项目大小如何影响创建 353 简化交流的典型方法是规范化交流方法。不是让 50 个人按各种可能方式相互间进行交流, 而是让 50 个人都阅读和编写文档,有些是文本的,而有些则是图形的,有些是打印在纸上的, 或是表格形式的。 活动比例和大小 正如项目越大,所要求的信息交流也相应增加,项目所需各种活动的种类也急剧地变化。 以下是关于不同项目的大小和各种开发活动的比例的关系: 项目大小和代码行数 对于小项目,创建是最为突出的活动,占了整个开发活动时间的 80%。对于中项目,创建 仍然是主导性的活动,但是所占比例下降了 50%,对很大的项目,结构、综合、系统测试和创 建所占时间比例大致相等。总之,随着项目的增大,创建所占整个工作时间的比例将减小。从 上图可看出随着项目的增大,创建最终将消失。 创建之所以随着项目的增大而变弱,是因为创建的各种活动——详细设计、调试、编码单 元测试——是线性增长,而其它各种活动则是按乘幂的关系增长的。 以下是图示: 例如,某一项目是另一项目大小的二倍,则二者的创建工作量之比可能为 2 : l ,而综合 和系统测试工作量之比为 4 : l 。当最终开发所得的第一个项目的大小是另一个的 10 倍时,它 可能需要 12 倍的创建工作量,100 倍的计划工作量和 40 倍的综合测试量。以下是随项目的增 大而工作量增大的其它活动: 第二十一章 项目大小如何影响创建 354 · 计划 · 管理 · 交流 · 需求开发 · 系统功能设计 · 接口设计和描述 · 总体结构 · 综合 · 错误消除 · 系统测试 · 文档生成 不论项目的大小如何,以下方法总是有价值的:结构化编码。其它程序员对逻辑和代码的 检查、联机开发系统和高级语言的使用,对于本项目,这些方法可能并不充分,但是对小项目 却是适用的。 方法和规模 方法被广泛用于各种大小的项目中。对小系统,方法往往是偶然的和本能的。对大项目, 方法往往是精确的和仔细计划的。 其中一些方法是可自由使用的,以致于程序员甚至不知道自己在使用它们。而部分程序员 甚至说这些方法太教条化了,而不采用它们。程序员有时可能会不自觉地采用某一种方法,程 序编制的任何一种途径都包含着某种方法的使用,不管这种方法是多么不自觉或原始的。就连 早上起床后去上班,虽然不是一种创造性的方法,但也是一种基本的活动。坚持不使用方法的 程序员,实际上只是不明确地选用了方法——没有人不使用某一种方法。 正规的方法并不令人高兴。如果误用了,往往会引起麻烦。大的项目较为复杂,需加强对 方法的自觉使用。正如建造摩天大楼需应用各种方法一样,相同大小的项目也需各种方法。不 管你是否认为“军事才能”是一个矛盾的词,你应认识到军方是计算机的最大用户,并且也是 最大编程研究的赞助者之一。它提倡的对问题的正规方法包括从 12 个方面对一个程序项目进行 评分。在表 21 一 1 中给出了项目正规性明细表。 表 21-1 项目正规性明细表 评 分 因素 1 2 3 4 5 总分 1.初始要求 没有,对各种 最小,有较多 有限制;对环 相当多的熟练 非常广泛地提 不同的设备重 的苛刻要求 境应用新的界 应用现存状态 高现存状态 __ 新编程 面 2.通用性 高限制;单目 有限制;规定 限制灵活性; 多目的;格性 非常灵活;在 的 一定范围的能 允许格式修改 灵活对象范围 不同的设备上 力 广 不同,可适应 对象范围很广 __ 第二十一章 项目大小如何影响创建 355 评分 因素 1 2 3 4 5 总分 3. 操作跨度 局部的或工具 部分命令 单一命令 多命令 广泛地适用于 的 各军事部门 4. 范围和对象 无 不经常 偶然 经常 连续 的修改 __ 5. 设备复杂性 单机进程处理 单机子进程处 多计算机标准 多计算机高级 主控制系统, 理,扩展外围 外围系统 编程,复杂外 多计算机,自 系统 围系统 动输入--输出 和显示设备 6. 人事安排 1-2 3-5 5-10 10-18 18 以上 7. 开发代价 3-15k 15-70k 70-200k 200-500k 大于 500k 8. 关键 数据处理 子程序操作 人事安全 单元残存 国家防务 9. 对程序修改 >=2 周 1-2 周 3-7 天 1-3 天 1-24 小时 的平均反应时 间 10. 对数据输 >=2 周 1-2 周 1-7 天 1-24 小时 9-60 分钟(交 入的平均反应 互式) 时间 11. 编程语言 高级语言 高级语言和限 高级语言和扩 汇编语言 机器语言 量制汇编语言 充汇编语言 12. 软件开发 无 有限 中等 广泛的 深入的 中的合作性 总计 使用项目正规明细表,一程序的得分可在 12-60 之间。得 12 分意味着对项目的要 求是轻微的,且对其正规性要求很少。得 60 分意味着项目要求很高,且对结构的规定也 很多。在社交场合,越是正式,你的服饰就越让人不安(高跟鞋,领带等)。在软件开发 中,项目越正规,你就越需付出更多的工作量。根据项目正规明细表,表 21-2 给出了文 件建议。 表 21-2 文件建议 正规评分 建议文件 12-15 用户指南和小程序文件 15-26 低级文件以及操作使用手册,维修手册,测试计划、管理计划、结构配置划 24-38 低级文件加功能描述、质量确保计划 36-50 低级文件以及系统和子系统描述.测试分析报表 48-60 低级文件以及程序描述 对于特定的项目、数据要求文件、数据库描述和执行程序也可能在建议之列。 你不必因某种原因而创建这种文件。你编写配置管理计划并不是为了运行有关程序, 而只是强迫自己能向其它人解释。当你计划或创建一软件系统时,文件对你的实际工作 的副作用是可见的。如果你自己感到要编写一般的文件,这就有点不对头了。 第二十一章 项目大小如何影响创建 356 程序、产品、系统、系统产品 并不只是代码行数和参加人数对项目的大小有影响。一个更微妙的影响是软件的质量和复 杂性。最初的 Gigatron 和 Gigatron Jr. 可能只需一个月的时间就能完成编写和调试工作。它只 是单一的程序,有一个测试。如果 2500 行的 Gigatron Jr. 软件花一月时间,为什么 5000 行的 Gigatron 需 6 个月呢? 一类最简单的软件是由用户自行开发的单一程序。更为复杂的一种程序类型称为——软件 “产品”——它是为了让其它人而不仅是用户自己使用。软件使用环境和生成环境是不同的。 在其交付之前,软件接受过深入的调试,并被文档化,有可能被其它人所维护,软件产生的代 价是程序开发代价的 3 倍。 另一种复杂类型是要求一群程序员相互协作进行开发。这样的系统称之为软件“系统”。开 发一个系统要比开发一个程序更为复杂,因为系统需开发各不同部分之间的接口以便能将其有 机地综合起来。一般开发一个系统所费代价是单一程序的 3 倍。 在开发“系统产品”时,它将加装产品外壳(用户界面),并将系统各部分综合起来。系统 产品所费代价是单一程序的 9 倍。 程序、产品、系统和系统产品的复杂性,是导致估计错误最为常见的原因,程序员在利用 过去的编程经验对创建一系统产品进行评估时,可能会低估近 10 倍。如果你用自己编写 2K 行代码的经验对开发一个 2K 行的程序进行评估,你所估时间仅为实际所需时间的 80%。编写 一个 2K 行代码的程序并不相同。如果你没有考虑各种非创建活动所费时间,你实际所花费时 间将比你所估计的多花 25%。 如果你所在项目变大,创建所占工作量将会减少。如果你的估计仅基于创建经验,发生的 估计错误的机会增大了。如果你用自己 2K 的创建经验评估 32K 程序所花时间,你所估计的时 间仅为实际需要的 50%。 估计错误主要是由于你对项目大小和开发较大程序的关系理解不全。如果你不理解产品其 它工作的重要性,估计错误数可增加 3 倍或更多。 21.3 项目大小对错误的影响 质量和错误都要受到项目大小的影响。你可能认为这种类型的错误将不受影响,但是随着 项目的变大,相当大的一部分错误通常要归于分析和设计错误。以下是一个图示: 对一些小项目,创建错误可占整个错误的 75%,方法对代码质量影响不大。对程序质量影 响最大的是个人编写程序的技巧。 对大项目程序,创建错误可占整个错误的 50%。分析和结构错误所占比例是不同的。事实 上较大项目需要更多的分析和设计,所以此类活动中发生错误的机会相应也大一些。对一些很 大的项目,创建错误比是较高的。对一些 500,000 行代码的程序,75%以上的错误是创建错误。 错误的类型随项目大小而变化,所发生的错误数也会发生变化。你可能会自然想到,某项 目大小为另一项目的二倍时,其所发生的错误总数应为小项目的二倍。但是,每行代码出现的 错误概率和错误数也会增大。当某一产品大小是另一产品的二倍时,它所出现的错误是另一产 品的二倍多。表 21-3 给出了项目大小和错误数关系: 第二十一章 项目大小如何影响创建 357 项目代码行数 表 21-3 项目大小和错误数 项目大小(代码行数) 每 1000 行代码所发生错误数 小于 2k 0-25 2k-16k 0-40 16k-64k 0.5-50 64k-512k 2-70 52k或更多 4-100 本表原载《Program Quality and Programmer Productivity》 (Jones l977)。 以上数据只是来自一些特定项目,你实际所出现的错误数可能和以上数据略有出入。然而 本数据仍是有一定启发意义的。它表明随着项目的变大,其所出现错误数会急剧增加。大项目 出现错误数是小项目的 4 倍多。此数据也表明对超过一定大小的项目,不能编写出没有错误的 代码,错误总可以潜入,而不管采用什么方法预防错误。 21.4 项目大小对生产效率的影响 当和项目大小联系在一起时,生产效率和软件质量有许多共同之处。对于小项目(200 行 或更小),对生产效率影响最大的是单个程序员的技巧。随着项目的增大,开发组人数和组织对 生产效率影响迅速增大。 项目应为多大时,开发组人数才对生产效率有影响:Boehm 和 Gray 曾说过,较少人数的 开发组的效率比人数较多的开发数高 39%。 表 21-4 给出了项目大小和生产效率的一般关系: 生产效率实际上是由以下因素所决定:人员的素质、编程语言、方法、产品复杂性、编程 环境、工具支持和其它许多因素所决定的,所以可能表 21-4 的数据并不能直接用于你的编程 环境,你可视具体情况而定。 第二十一章 项目大小如何影响创建 358 表 21-4 项目大小与生产效率的关系 项目大小(代码行数) 每月代码行数 小于 2k 333-2000 2k –16k 200-1250 16k-64k 125-1000 64k-512k 67-500 512k 或更多 36-250 本表原载《Program Quality and Programmer Productivity》(Jones1977)。 但是,本数据也是有所启发的,最小项目的生产率是最大项目的 10 倍。即使从某一中等项 目转移到另一中等项目——如从千行代码的项目转到 9 千行代码的项目——生产效率也可降低 一半。 21.5 小 结 · 在一些小项目中的活动并不能想当然地用于大项目中,你应仔细计划它们。随着项目 增大,创建作用减弱。 · 随着项目的增大,交流方法应简化。各种方法的使用应因时而异。 · 在相同条件下,大项目的生产率比小项目低一些。 · 在相同条件下,大项目的每行错误数比小项目的每行错误数多。 第二十二章 创建管理 359 第二十二章 创建管理 目录 22.1 使用好的代码 22.2 配置管理 22.3 评估创建计划 22.4 度量 22.5 将程序员视为普通人 22.6 如何对待上司 22.7 小结 相关章节 创建前提:见第 3 章 程序长度:见第 21 章 软件质量:见第 23 章 系统集成:见第 27 章 设计较好软件的要旨:见第 32 章 软件开发管理已成为二十世纪晚期人们所不可轻视的一个挑战。对软件计划管理进行一般 的讨论,已超出本书的范围,但是本文将讨论和创建有关的一些管理主题。由于本文讨论了许 多主题内容,在另外几个部分你也可以找到更多有关的内容。 本章主要讨论和创建有关的软件管理主题。 22.1 使用好的代码 由于代码是创建的主要输出,所以创建管理的关键问题是养成使用好代码的习惯。一般说 来,从开始就用标准并不是一个好的方法。程序员倾向于将管理者视为技术层次中的水平较低 者,甚至认为其差距犹如单细胞生物和冰川世纪灭绝的猛犸像一样大。如果有编程标准的话, 程序员可以买一个。 标准设置的考虑 如果你避免使用标准,标准对你也就不那么重要了,可考虑使用标准的替代:灵活的原则, 或者采用一些建议而不用原则,或者一些包含最好实际情况的例子。 方法 以下是取得较好代码效果的几个方法。这些方法不应被视为教条的代码标准。 将二人分在计划的每一部分。如果二人不得不各自编码,你应确保至少这二人认为代码能 第二十二章 创建管理 360 正常运行同时可读。协调二人的开发方法将涉及到指导者和受训者的配合。 如果要了解注释的详细内容,参看第二十四章“注释”。 注释每一行代码。一条代码注释常包括程序员和至少两个注释者,这意味着至少有三人将 阅读代码的每一行。同级注释的另一个名字是“同级压力”。除了防止程序员离开计划体系外, 让程序员知道另外一些人还将阅读本代码,将会提高代码质量。即使你所在部门没有明确的代 码标准,注释也将会提供一个较好的途径,使代码向一个代码标准的方向发展,这些标准是群 体在注释过程中所得到的一些结论,而且,随着时间的推移,群体将派生出自己的标准。 采用代码许可。在一些领域中,技术草图将由管理工程师批准和鉴署。鉴名就意味着对管 理工程师来说,本草图在技术上是可行的,而且是没有错误的,一些公司也采用这种方法。在 代码将完成之前,高级技术主管将签署代码表。 用好的注释作示例。管理的一个重要方面,就是向你的人员清楚地表达你的意图。其中的 一条途径就是让你的程序员见识一些好的代码,或将其在公共场合张贴出来。这样作将会提供 清楚的代码示例,而这正是你所期望的。同样,一本代码标准手册将包括一些“最好的代码表”, 指定一些表作为最好的代码以便其它人能效仿。这样一本手册将比一本英语标准手册要容易改 进,而且,无须费多大劲就能展示代码风格的精细,如果靠平铺直叙一点一点描述是困难的。 强调代码表是公用财产。程序员有时会觉得代码是“他们的代码”,好像这是其私人财产一 样。虽然这是程序员自己心血的结晶,但是代码是整个计划的一部分,计划中需要它的人应有 权得到它。代码在注释和维护阶段应当被其它人所看到。 曾经报道过的一个最为成功的计划是,一个用 11 人年的工作量开发的 83,000 行的代码。 在其先期 13 个月的运行中仅发现一个系统错误。当你知道这计划是在 60 年代晚期没有联机编 译和交互式调试条件下完成的时候,你会感到更为惊讶的。要知道,在 60 年代晚期——7500 行代码所花费的人年工作量,二倍于今天 3750 行代码所耗费的人年工作量。本计划的一个主程 序员说,计划成功的关键之一是明确确定所有的计算机运算(错误或其它)是公共的而不是个 人的财产。 奖赏好的代码。用奖励办法促进好的代码开发工作。当你开发你的系统时,请牢记以下几 点: · 此奖励应是程序员所需要的(许多程序员发现“哄小孩”似的奖励是令人生厌的,尤 其是这些来自那些非技术管理人员的时候)。 · 所奖励的代码应是非常好的。如果你奖励一个大家都知道其工作干得不好的程序员, 你将收到相反的效果。至于程序员是否态度好或是否按时上班则不是重要的。如果你 的奖励和相应技术价值不一致,你将失去信誉。如果你的技术不够高,不能判断哪一 个是好的代码,那么,你就根本无须奖励,或者让你的合作者们选择。 一个简单的标准。如果你管理一个编程计划,同时你也有编程经验的话,做好工作的一个 有效的方法是说:“我必须能够阅读和理解本计划所编的所有代码”。管理者不是技术尖子,但 防止“聪明”或恶作剧式编码也是有好处的。 本书的角色 本书的大部分是讨论良好的编程习惯,并不打算纠正教条的标准,甚至也不打算被用作为 教条的标准。将本书作这样的用场将和本书的一些重要主题相矛盾.将本书视为讨论的基础, 第二十二章 创建管理 361 作为一本有良好编程风格的参考资料,同时也可检查你所在环境中的编程习惯是否有益。 22.2 配置管理 软件计划是变化:代码在变化、设计在变化、需求在变化,而且需求的变化引起设计上更 多的变化,设计上的变化又会引起代码和测试情况的更多变化。 何调配置管理 配置管理是全面地处理各种变化,以便系统随着时间的推移能保证其完整性。其另一个名 字是“修改控制”。它包括如下各种方法,评估各种建议处理各种修改、在不同的时间保留系统 的各种备份。 如果你不控制设计上的修改,你最终将会发现,设计中某些部分的代码和最初的大相径庭, 你所写的代码也和新设计的部分不兼容。直到系统集成时你才知道它带来了许多不兼容性的问 题,而这时恰恰是最关键的时刻,因为谁也不知道这之间是怎样一回事。 如果你不控制代码的修改,你在改变子程序而同时也有其它人正在改变它。成功地将你的 修改和他人的修改协调一致将会避免这些问题。无节制的代码改变将会付出更多的测试量。已 经测试过的版本也可能是老的、未曾变更的版本;变更的版本也可能没有接受过测试。没有一 个良好的修改控制,在改变一个程序的同时,如果发现了新的错误,也不能回到原来的可能正 常工作的程序状态。 问题也会长期存在。如果不能有条理地处理各种修改,你将好比在雾中胡乱前进而不是朝 着一个明确的目标径直向前。没有好的修改控制,与其说是开发代码,倒不如说是在浪费你的 时间翻来复去。配置管理可帮助你有效地使用时间。 尽管结构配置有其明显的必要性,许多程序员过去并不使用它。一项调查发现超过三分之 一的程序员对结构配置并不熟悉。 结构配置并不是由程序员所最先提出。但是由于程序设计是多变的,所以结构配置对程序 员异常有用。对于软件计划来说,结构配置通常称为软件配置管理,或 SCM(通常也叫“scum”)。 SCM 着重于程序的源代码文档和测试数据。 SCM 的问题在于过程。防止汽车事故最可信的方法是禁止行车,而防止软件开发中问题的 途径是停止所有的软件开发。虽然这是一条控制修改的方法,但这是软件开发中的一条可怕的 途径。你将不得不仔细地计划,这样 SCM 才是一笔财富而不是你的包袱。 有关计划尺度对创建的影响,参看第二十一章。 在一个人的小计划中,不采用 SCM 而仅需考虑一下一般的定期备份,你可能照样干得较 好。在一个 50 人的大计划体制中,你可能需要一个完整的 SCM 计划,它包括备份档案正规的 过程,要求对设计进行修改控制及文档、源代码和测试的控制。 如果你的计划不大也不小,你不得不在以上两种极端方法中采用一种折衷的方法。以下几 个部分描述了完成一个 SCM 计划的一些可选择项。 软件设计修改 在开发过程中,你一定会被怎样改进系统的性能所困扰。如果你只是在每个修改发生时才 第二十二章 创建管理 362 去考虑它,你会发现自己进入了软件开发的歧路——整个系统在变化,离目标的完成也是遥遥 无期。以下是控制设计修改的几条准则: 遵循正规修改控制流程。正如第三章所指出的那样,正规修改控制流程是你处理许多修改 要求的有力手段。建立正规流程,会对如何在整个计划的范围内考虑修改有一个清晰的了解。 建立修改控制委员会。修改控制委员会的工作是在有修改请求时,从“米糠”中将“小麦” 区分开来。任何想修改的人,将变化请求提交给变化控制委员会。“修改请求”这个词指的是任 何将改变软件的请示:一个有新特点的主题、一个有特点的修改,一个报告真实错误的“错误 报告”。委员会定期对所提修改请示进行检查。它可能同意或不同意或不予理睬这些修改请求。 集成考虑修改请求。本规则倾向于完成容易的修改。这种方式处理修改所带来的问题是, 好的修改机会可能会失去。当你按照预定计划已经完成了计划的 25%时,你想作一下简单的修 改,当然可以这样做。而如果你滞后于原定计划进度且已完成了计划的 90%时,你不可能再这 样作简单的修改。当你在计划的收尾阶段已经超出了预定时间期限时,至于比第一次修改要好 10 倍是无关紧要的——你没有必要去作一些无谓的修改。你也可能失去一些最好的机会,因为 你想到要作修改已经太迟了。 一个非正式的解决这个问题的方法是写下所有的主意和建议,而不管完成它的难易程度如 何,同时也应及时保留这些建议和主意直到你有时间去处理它们。最终,将其作为一个整体看 待,选择其中最有实效的一个。 估计改变所花的代价。不管何时你的客户、你的老板、或者你自己打算改变系统时,先估 计修改花费的时间,其中包括修改所引起的代码注释和再测试整个系统,同时也应包括处理由 于用户程序的变化所引起的设计、编码、测试需求等的修改所需时间。让有关各方知道软件是 异常复杂交错的,即使乍看起来修改很小,但是时间预估仍然是必要的。不管改变刚提出时你 是多么乐观,你应避免作出草率的估计,匆忙的估计错误率可能会达到 10 或 100 倍。 从另外一个角度看待处理各种修改,参看 3.3 节“创建过程中对各种改变请求的处理”这 一节。 谨慎对待主要修改。如果顾客建议进行重大修改,这是对你的工作没有满足要求的警告。 如果在高级设计时提出这些建议,你应停止设计工作并回到初始设计要求状态。在进行设计之 前你应耐心等待,直到修改要求有所缓和为止。 如果在代码开发过程中,你的顾客坚持要作重大修改,你应坐下来和你的顾客谈一谈,并 指出它对已经进行的工作所产生的影响。其中一个明显的影响就是早期的一些工作将废弃。 软件代码改变 另外一个配置管理主题是控制源代码。如果当你改变了代码并发现其中产生的一个新的错 误,表面上看来和你的修改无关,你可能会想到将其和老版本的代码相比较,以便找出各种错 误源。如果这仍然一无所收获,你可能会去查阅更老一级的版本。如果你有版本控制工具且保 留源代码的多个版本的话,这种类型的版本回溯过程是很容易的。 版本控制软件。一些版本控制软件工作起来是如此地得心应手以致于你很少注意到你正在 使用它。在使用开发计划中版本控制软件更是非常有帮助。典型地,当你需要对一特定文件的 源代码进行操作时,在版本控制软件之下你查询本文件,如果别人已经注册登记了它,你将被 告知你无法调出此文件。当你能够调出文件时,你可以在无版本控制下随意对其进行操作直到 第二十二章 创建管理 363 你将其登录。当你登录文件时,版本控制软件询问你为何修改它,你对此回答你的理由。 由于你对此付出了最大努力,你将会得到如下好处: · 别人正在操作某一文件时你不致于和他发生冲突。 · 你能轻易地将所有文件副本升级为当前的版本,通常你仅需使用一条命令即可。 · 你能够回溯任何由版本控制登录的文件版本。 · 你能得到任何文件的任何版本修改表。 · 你无需担心文件备份,因为版本控制拷贝备份是自动的。 版本控制对合作开发计划是不缺少的,它是如此的有效,以致干微软公司的应用部,发现 源代码版本控制是具有重要竞争力的方便措施。 制作。一种特定类型的版本控制工具是和 UNIX 以及 C 语言相联系的实用程序。制作的 目的是将生成目标文件所需要的时间减少为最少。对工作文件中每一个目标文件及目标文件所 依赖的文件都可以应用本控制工具及怎样去制作它。 假定你有一个名叫 user.obj 的目标文件,在制作文件中,你说明你想制作 userface.obj 文件, 你必须编译 userface.c。你同时指明 userface.c 将依靠 userface.h,stdilb.h 和 project.h。“依靠” 这个概念仅意味着如果 userface.h,stdib.h 或者 project.h 改变了 userface.c 也需要再编译。 当你创建你的程序时,制作检查所有的依赖,同时确定需再编译的文件。如果你的 25 个源 文件中的 5 个依赖于 userface.h 中所定义的数据的,并且 userface.h 已经改变了,制作自动再编 译 5个依赖于它的文件。制作不会再编译另外 20 个不依赖于 userface. h 的文件。使用制作可 避免再编译所有 25 个文件或单独编译每一个文件。漏掉其中某一个文件,将会得到一些奇怪 的错误。总的来讲,制作实际上比一般的编译、链接、运行循环将更节省时间和提高可靠性。 备份计划 备份计划并不是令人兴奋的概念。备份计划即定期备份你的工作。如果你正在编写一本书, 你不会将其放到门廊上。如果你这样作,他们可能将被雨淋湿或被风吹走。你最好将其放在某 个安全的地方。软件不像纸这样真实可触摸,所以你更易忘记你已在机器上留下了一些错误的 东西。 计算机里的数据也会产生许多问题,磁盘可能失效,你或别人也许在无意中删掉一些重要 文件。一位愤怒的雇员能破坏你的机器,你也可能由于盗窃、洪水、火灾等不幸事件而失去你 的机器。 你应采用一些措施以便确保你的工作成果。你的备份计划应当包括定期进行备份,定期将 备份转移至安全处,除了源代码以外,还应包括项目所有的重要资料、文档、图形、注释。 人们在设计备份计划时,常忽视的方面就是对备份程序的测试,不时测试一下你保存下来 的东西以确保备份含有你所需要的一切,恢复文件时也能正常工作。 当你成功地完成项目后,你应保存下再开发所需要的东西——源代码、编译程序、工具、 需求、设计、文档等,并且要将它们保存在安全的地方。 第二十二章 创建管理 364 检查表 配置管理 一般 · 你的软件配置管理计划是否用于帮助程序员,并能将额外开销减至最少? · 你使用 SCM 手段能否避免对项目失控? · 你所在组是否有修改请求?请示控制可以是非正式方式或正式的方式。 · 你是否能比较正确地估计每次修改的影响? · 你是否将重要修改视为需求分析不充分的警告? 工具 · 你是否使用版本控制软件以便配置管理? · 你是否使用版本控制软件以减少开发时的协调问题? · 你是否使用制作或其它控制依赖软件使编程更为有效和可靠? 备份 · 你是否将所有项目材料定期备份? · 你是否定期将所有项目备份移到安全地点存放? · 包括源代码、文档、图形和重要注释在内的所有材料都备份了吗? · 你是否对备份程序进行了测试? 因为本书主要是关于创建的,本节主要是从创建的观点讨论修改控制。但是修改从各级水 平上影响项目,需要有一集成修改控制策略才行。 22.3 评估创建计划 软件工程管理是二十世纪晚期对人类来说不可轻视的挑战之一。评估工程的大小和完成时 间是软件工程管理最为棘手的问题之一。一般的大软件工程完成需一年多时间,同时所需费用 也可能会超出预算的 100%(Jones 1986)。这主要是由于对工程的大小和所需时间估计有误和 开发过程不努力有关。本节讨论如何评估软件工程,并阐明如何获得更多的信息。 评估方法 你能从以下几个方面评估软件的大小和所需时间: 如果想获得评估方法的更多知识,参看《软件工程质度和模块》或《软件工程经济》等书。 · 使用进度软件 · 使用诸如 COCOMO 的算法,Barry Boehm 的估计模型 · 使用外面其它的专家评估 · 举行预评估会议 · 评估项目的每一部分,然后再集成这些评估 · 让人们评估自己的那部分,再进行集成评估 · 评估完成整个项目所需时间,然后将时间细分到项目的每一部分 · 参考以前项目的评估 第二十二章 创建管理 365 · 保存以前的项目估计看看它们的准确程度。用它们调整你的评估 这种方法摘自《软件工程经济》(伯亨利,1981)。 评估对象。你在评估什么?你为何要评估?你的对象需多大的评估精度?评估需多大确定 性?乐观和悲观的评估到底能有何差别? 使评估有充足时间并计划评估。匆忙评估是不精确的。如果你要评估一个大的软件工程, 你应将评估视为一个小的项目,并花费时间去评估时间。 要获得软件工程的要求的更多信息,参看 3.3 节“需求前提”,以便你能作好评估。 不要草率估计软件工程的各种开销。在某事物还没有被定义前,别人让你去估计产生它需 要多少工作量是不明智的。在作出估计之前应定义要求,或对初始探索阶段进行计划。 对你所需判别的对象从层次细节上进行评估。从对项目活动的各细节上进行评估,一般说 来,你的检查越详细,你的评估就越难确,大多数定理指出总和的错误比错误的总和要大,换 句话说,某较大部分 10%的错误,在 50 个小部分中,10%的错误往往会抵消掉。 在软件开发中随处可见重复利用技术,这是重复利用的一个场合,如需了解重复技术的摘 要,参见“重复、重复、重复”32.7 这一节。 使用不同的评估方法并比较其结果。在本节的开头给出的评估方法表,说明有几种方法。 他们将不会产生同样的结果,所以将这几种方法都试验一下,比较一下结果的不同。 小孩较早就知道如果他们向每一位家长要 1/3 碗的冰琪淋,比仅向一位家长要有多一些 获准同意的机会。有时家长们聪明地给予相同的回答,而有时他们则不。你应从不同的评估方 法中,看一看究竟能得出何种不同的答案。并不是每一种方法都是万能的,而且它们之间的差 别应是明显的。 定期再评估。软件工程的各种因素在评估后都会有所变化,所以你应对定期再评估有所计 划。在项目接近尾声时,你能评估得更好,同时你也可掂量一下你的初始评估。使用你的评估 结果精化以后的评估。在项目收尾之时,你的评估精度也会有所提高。 Milestones 软件开发进度表 评估创建工作量 有关不同大小项目的编码工作是细节,参看“工作量分配和大小”这一节(21.2)。 创建对项目进度的影响取决于项目的各项分配——一般为详细设计、编码、调试和单元测 试。正如下图所示,各项分配随不同大小的项目而有所不同。 第二十二章 创建管理 366 Unit Testing Coding and Debuging Constructing Detailed Design 项目代码长度 除非你的公司有自己的项目设计数据,上图所给出项目的各项时间分配对开始评估你的工 程是颇有益处的。 对完成一个项目需要多少创建的最佳回答随项目和组织的不同各项分配有所不同。你在开 发项目过程中,应当利用你过去的工作经验来估计项目所需花费的时间。 项目进度的影响因素 程序长度对软件效率和质量的影响并不总是凭直觉就能轻易体会得到的,要知道程序大小 对创建的影响,参着第二十一章“程序长度怎样影响创建”。 对软件开发进度影响最大的是程序的长度,但是也有许多其它因素影响逻辑开发进度。在 表 22-1 中给出了对商品程序产生影响的一些因素。为了利用这张表,你首先要对你项目的大 小作一个基本估计,然后用所给表中的每一行的因子去乘基本估计,调整所作估计。 以下为影响逻辑开发进度的一些不能轻易作结论的因素。这们都出自于《Software Engineering Economics》(1981,Barr Boehm)和《A Method of Programming Measurement and Estimation》(C.E.Wdston 和 C.P.Felis)。 · 协作动机 · 管理质量 · 代码再利用量 · 人事变动 · 语言水平 · 需求变更 · 和客户的关系如何 · 用户对要求的参与程度 · 用户的应用经验 · 什么范围内的程序员参与需求分析 · 对计算机、程序、数据的安全确保体系 · 文档量大小 · 项目对象(进度表、质量、可用性和其它可能对象) 第二十二章 创建管理 367 虽然以上因素不能轻易下结论,但它们是重要的,所以应将其同表 22-l 各种因素一并考 虑。 表 22-1 比 率 第二十二章 创建管理 368 评估和控制 重要问题是:你是想要预测,还是想要控制? 评估是使软件工程按时完成计划的重要组成部分。一旦你确定了交货日期和产品性能,剩 下的主要问题是怎样控制人力和技术资源的开销,以便能按时交付产品。从此种意义上来讲, 成功地利用各种资源以满足计划要求,比初始估计的准确性更为重要。 如果你落后了怎么办 很多软件开发进度落后于预定要求,对一些实际软件开发的调查结果表明,评估所需时间 要比实际所用时间少 20~30%。 当你落后时,增加时间并不是一个明智选择,如果你别无办法,可那样做。否则你可试一 试一个或几个方法: 努力赶上。当项目进度落后时,通常的反应是对此抱有乐观态度。较为理智的想法是这样: “所用时间比我们所预期的要长一点,但如今这是不可变更的,我们以后能挽回这点时间”,但 第二十二章 创建管理 369 是实际上人们很少这样作,对超过 300 个软件工程的调查指出越接近收尾阶段,延误和超进度 情况越来越严重。所以人们在以后不仅不能挽回已失去的时间,而是越来越落后。 扩大队伍人数。对一个已经落后于预定时间的软件工程,增加人数只会使其更加落后。这 好比向火上添油。这种解释是颇有说服力的,在新手能富有成效地工作之前,他们需要有一段 时间熟悉工作。对他们进行培训浪费了已受训人员的时间。而且增加人数也增加了软件开发的 复杂性和相互间的通信量。布鲁克指出一个妇女能在 9 个月中生下一个孩子,并不意味着 9 个 妇女能在一个月中生下 9 个孩子。无疑对布鲁克的告诫是应该注意的。它企图使人们全身心地 投入开发并按时交付。管理者应该知道开发软件不像柳接薄金属板一样;更多的工人并不就意 味着完成更多的工作量。 增加程序员到一个落后于预定时间的项目,会使其更加落后之说,也掩盖了如下一个事实: 在某些情况下增加人数有可能加快开发进度。正如布鲁克所指出的那样,对于一个任务不能再 分割和不能独自工作的软件开发来说,增加人手无济于事。但是如果项目任务还可细分,并可 将其分派给不同的人,甚至是刚进入本项目的人员。另外一些研究人员也证明了你可以在软件 开发落后时增加人员不会使其更落后。 减少项目范围,减少项目的有力方法常被人们所忽略。如果你去掉某一特点,也就去掉了 相应的设计、编码、调试、测试和文档工作,同时你也就无须再设计这点和其它点的接口。 当你最初计划你的开发时,将开发产品的能力划分为“必要有”、“有更好”、“可选项”几 部分。如果你落在这里,先考虑“有更好”和“可选项”并将某些剔除掉。 将某特征从总体上减少一些,能得到一个具有同样功能但是更便宜的一个版本。你也可能 准时提交版本,但是它没有经过实际性能调试。也可能提供一个最不重要的功能勉强得到实现 的版本。你可能对时间要求放松一些,因为提供一个时间不急的版本是很容易的,你还可能放 松空间需求,因为提供扩展存储版本是很容易的。 对重要的功能开发时间,应再评估。在 2 小时、2 天或者 2 星期之内你能提供什么功能? 制作一个二星期后的版本,比二天与二个小时的版本有什么更多功能? 22.4 度 量 “度量”这个词指的是和软件开发有关的衡量。代码行数、错误数、每千行代码错误、每 个模块的错误、全局变量的数目、完成项目所需的时间等都是度量。 对开发过程进行度量比根本不度量要好,虽然度量并不很准确;也可能难以制定一个度量 标准;它需要随着时间能推移而不断精化。但是它能使你能对软件开发过程进行控制,而没有 度量则不行。 如果数据在科学实验中被用到,则它应先量化。为了评估软件开发方式,你应首先度量它 们,仅仅作出“这种新方式看上去更有成效”的断定是不充分的。 有时度量不必知道项目究竟是怎么一回事。当度量项目的某一方面时,并不知道项目的究 竟是变大了,是变小了,还是没有变。本度量仅是项目中这一方面的窗口。在你逐步精化你的 度量之前,你的这窗口也可能是较小和模糊不清的,但是这比没有窗口要好。 你能够度量软件开发过程的各个方面,表 22-2 给出了一些被证明为行之有效的度量。 第二十二章 创建管理 370 表 22-2 有用的度量 长度 总质量 代码总行数 总的错误数量 注释总行数 每个子程序的错误数目 数据说明语句总数 每千行代码平均错误总数 空行总数 失效的平均时间 编译错误 生产率 总的项目时间 维护性 每个程序所花时间 每个子程序所用参数数目 每个程序修改次数 每个子程序所用局部变量数目 项目费用 每个子程序被其它子程序调用次数 每行源代码费用 每个子程序的断点数目 每个缺陷所耗费用 每个子程序的控制流向复杂性 每个子程序的代码行数 错误跟踪 每个子程序的注释行数 每个子程序的数据描述数 每个错误的严重程度 每个子程序的空白行数 错误的位置 每个子程序的 goto 语句数 纠正每个错误的方法 每个子程序的输入/输出语句总数 对每个错误所负责的人 纠正每个错误所影响的代码行数 纠正每一个错误所用时间 发现一个错误所用平均时间 修正一个错误所用时间 纠正每一个错误的尝试次数 由于纠正错误所引起的新的错误数目 使用当前可用的软件工具,你能得到大多数度量。本书的讨论指出每种度量都是有用的。 当前,大多数度量并不能明确区分程序、模块和子程序(Shepperd 和 Ince l989)。度量主要用来 鉴别“外来”子程序。一个子程序的反常度量是说明子程序质量低需再检查它。不要对所有的 可能度量都想收集其数据——你将会置身于数据的汪洋大海之中而不知所然。你应当从诸如错 误总数、工作耗费时间、总费用、总的代码行数等简单的度量开始。 在项目开始过程中,标准各种度量,逐步精化它们,以作为衡量进展情况的标准。你应确 保你是抱着某种目的而收集数据,确立目标,从而确定实现目标还有何问题,最终度量这些问 题。美国宇航局软件工程实验室的一份数据收集总结指出,在过去的 15 年中所得出的最重要的 教训就是,度量之前你应定义度量目标。 22.5 将程序员视为普通人 编程活动的抽象性要求程序员有适应办公室环境的天性,并且和使用者有密切的交往。高 技术公司常向其雇员提供公园式的合作园区,有系统的组织结构、舒适的办公环境以及其它“高 第二十二章 创建管理 371 级”环境以平衡精深的有时也是异常智力型的工作。最成功的公司其成功的原因在于高技术和 高接触的有机统一(Naisbitt 1982)。本节指出了程序员并不仅是其自我的反映。 程序员们怎样花费自己的时间 程序员不仅要花费其时间编程,也要花费时间开会、接受培训、阅读邮寄材料和思考。1964 年对贝尔实验室的调查发现程序员从以下几个方面花费他们的时间: 活动 源代码 事务 私事 开会 培训 邮寄 技术手册 程序运行 程序测试 合计 听说 4% 17% 7% 3% 1% 32% 和管理人员 谈话 1% 1% 打电话 2% 1% 3% 阅读 14% 2% 2% 18% 写/记录 13% 1% 14% 外出 4% 1% 4% 6% 15% 散步 2% 2% 1% 1% 6% 其它 2% 3% 3% 1% 1% 1% 11% 合计 35% 29% 13% 7% 6% 5% 2% 2% 1% 100% 以上数据是建立在对 70 位程序员的工作时间和动机时间研究的基础之上,它是过时了的, 而且不同活动中的时间分配随不同的程序员而异,但是此结果还是有所启发的。一个程序员大 约有 30%的时间花在和项目没有直接联系的活动之中,散步、私事等等。在以上调查中,程序 员花费了 6%的行路时间。这意味着他们一年有 125 个小时,一周有 2.5 个小时在路上消耗掉了。 你也许认为这并没有什么,但是当你看到程序员行路时间和他们花费在培训上的时间相当,并 且是三倍于他们阅读技术手册的时间,六倍于他们和管理人员谈话的时间之后,你会有所触动 的。 能力差别 不同程序员的才能和努力的区别是巨大的,就像其他领域一样。一项对写作、足球、发明 创造、治安和飞行等不同职业的研究表明,其中 20%的精英人物却取得了 50%的成就。以上 研究结果是建立在对各项得分、专利、已解决案例等数据之上的。由于不少没有实际可见的贡 献(如足球运动员没有得分、发明者没有自己的专利,侦探没有破获案子等),本数据也可能低 估了实际的效率差异。 对编程来说,许多研究表明对不同的程序员,其在程序书写质量、程序长度和效率方面存 在较大差别。 个人差别 在 60 年代,Sackman,Erikson 和 Grant 的研究初步表明,程序员在编程效率方面存在重大 差别。他们研究了平均工作经验为 7 年的专业程序员的编程过程,发现最好和最差的程序员的 初始编码时间比为 20:1,调试时间比 25:1,程序长度比为 5:l,程序执行速度比是 10:l, 他们也发现,程序员的工作经验和编码质量或效率并不存在必然联系。 第二十二章 创建管理 372 虽然像 25:l 这样的比例并不特别有意义,但是一般的常识如“在程序员之间存在重大差 别”是有其意义的,并且已经被对许多专业程序员的研究所证实(Curtis1981,Mills 1983, DeMarco 和 Lister 1985,Curtisal 1986,Card 1987,Boehm 和 Papaccio 1988,ValettMcGarry 1989)。 组间差别 不同的编程组之间也有软件质量和生产效率的差别。好的程序员往往趋于聚集在一起,正 如差的程序员一样。这个结论已经被对 18 个组织的 166 个专业程序员的研究所证实(DeMacro 和 Lister 1985)。 对七个相同的项目研究表明,其所耗工作量的范围之比为 3:4:l,同时程序长度之比可达 3:1。尽管存在种种差别,所调查的程序员并不是完全不同的组别。他们都是有几年工作经验 的专业程序员,并且都是计算机专业的毕业生。由此看来如果各级之间稍不同将会产生更大的 差别。 一早期研究表明,不同的编程小组完成同一项的程序,长度比可达到 5:1,并且所需时间 可能为 2.6:l。在研究了有关编程数据之后,柏雷·玻利亨认为,一个组间程序员能力差别为 15%的编程小组所需开发时间 4 倍于组间差别为 90%的编程小组。玻利亨和其它研究者还发现 80%的贡献来自于 20%的研究人员(1987)。 补充雇用人员是经常的。如果你为了得到高水平的程序员而比得到低水平的程序员要多付 出 2 倍的报酬的话,请你抓住这个机会。你将因雇用的程序员而在质量和效率方面得到回报, 而且这也会对你所组织的其它程序员的质量和生产率产生影响,因为好的程序员倾向于聚在一 起。 个人风格问题 编程计划管理人员往往不太清楚个人心理等对编程有较大影响。如果你试图要求遵守一定 的编程习惯,你可能会激怒你的程序员。以下为一些个人心理因素: · goto 语句的使用 · 编程语言 · 缩进风格 · 大括弧和 begin-end 关键词的使用 · 文本编辑的选择 · 注释风格 · 效率和可靠性的互换 · 方法的选用——例如,面向对象设计或创建设计 · 实用程序 · 变量命名习惯 · 局部变量的使用 · 度量,尤其是生产效率量度(如每天代码行数) 以上讨论的共同特征是每个程序员的个人风格的反映。如果你想以上述各项来要求你的程 序员,请考虑以下各点。 清楚地知道你是在处理一个敏感的领域。在你开始你的行动之前弄清醒每位程序员的思想。 第二十二章 创建管理 373 使用“建议”要同时避免使用教条的“规则”或“标准”。 规避使用明确的控制。为了控制缩进风格或大括弧使用,在源代码宣告完成之前要先经过 整齐打印格式化程序运行。让整齐打印机作格式化工作。为了控制注释风格,要求所有的代码 要经过检查,不清楚的代码要作修改。 你的程序员是否开发出了自己的标准。正如在其它地方所指出的那样,特定标准经常不如 一些已存在的标准重要。你不必为你的程序员设置标准。但是你应坚持要求你的程序员标准化 你认为是重要的地方。 为什么个人心理因素最重要以至于能保证不入歧途?对任何次要问题的限制可能不足以弥 补士气所产生的影响。如果你发现无区分地使用 goto 语句、全局变量、不可读风格或其它影响 整个项目的行为,你得为了提高代码质量而容忍一些摩擦。如果你的程序员是一个认真负责的 人,这倒并不是一个问题。最大的困难莫过于代码风格的细微差别,如果对整个项目无甚损失 你可不必理睬这些。 物理环境 以下是一个实验,走到乡间去,找到一个农场,再见到农场主。问他对平均每个工人来讲 其设备价值多少?农场主会看看他的仓库,看一看拖拉机、运输车、玉米、豌豆然后告诉你对 每个雇员来讲价值 100,000 美元。 接着再到城里,寻找到一个软件开发部,见到经理,问你对每个雇员来说,其设备价值多 少?经理看看办公室,扫视一下桌子、一把椅子、一些书籍、一台计算机并告诉你每雇员为 25, 000 美元。 物理环境对生产率有重大影响。DeMacro 和 Lister 询问了 35 个组织的 166 位程序员关于他 们物理环境的质量。许多雇员对自己的物理环境并不满意。在后来的开发竞争中,前 25%的高 水平程序员有着自己的更大、更安静的办公室,并且被来访者和各种电话打断机会要少。以下 为最好和最差程序员办公室环境差异摘要: 环境因素 最好的 25% 最差的 25% 未用房间空间大小 78平方英尺 46平方英尺 可接受工作环境 57%是 29%是 可接受个人环境 62%是 19%是 不受电话打扰情况 52%是 10%是 可不受来客打扰情况 76%是 19%是 频繁的不必要打扰 38%是 76%是 对工作空间的满意程度 57%是 29%是 以上数据表明了生产率和工作空间质量的紧密关系。最好的 25%的程序员的效率是最差的 25%程序员的 2.6 倍。DeMacro 和 Lister 首先认为好的程序员由于得到提升,他们的办公条件 可能一开始就好些,但是进一步调查证明,事实并不是这样。来自相同组织的程序员虽有相同 的条件,但是他们各自的表现并不一样。大的软件开发组织有相同的经验。Xerox,TRW,IBM 和 Bell 实验室表明平均每人 10,000 到 30,000 美元的投资对大大提高生产率是必要的(Boehm 1987),而这笔数目还能从提高的生产率中获得回报。有了较好的办公条件,估计可得到 39~ 第二十二章 创建管理 374 47%的生产率增长(Boch 等,1984)。概括地讲,将你的工作环境从最差的 25%的水平提高到最 好的 25%的水平可能会导致最少为 100%的生产率增长。 22.6 如何对待上司 在软件开发过程中,非技术人员往往是管理者。最为例外的情况是管理者有工作经验,但 是已是十年未再干过了。会技术的管理者是少见的。如果你为某一人工作,应尽力保住你的饭 碗。这不是一件容易的事情。作为一个阶层,每一位雇员都想升到他并不能胜任的层次。 如果你的上司并不是一个特别的人,你将不得不学会如何面对你的上司。“控制你的上司” 意味着你应告诉你的上司怎样去做,而不是用其他方法。其要决在于用这样一种方法使你的上 司相信你仍是受他管理的一员。以下是一些对付你上司的方法: · 拒绝按照你上司的吩咐去做,坚持按正确方法继续你的工作。 · 假装按照你上司的吩咐去做,暗地里按照正确的方法去做。 · 先对自己如何做有一个全盘计划,等着上司对你的见解做评论并让人如何去做。 · 告诉你上司正确的方法,这是一个中途应变方法,因为你的上司经常提升、调动或被 解雇。 · 寻找另一份工作。 最好的解决方法是努力说服你的上司。这并不是一件容易的事情,但是你可阅读《How to Win Friends and Influence People》(《怎样赢得朋友和影响他人》)一书以学会如何准备这件事。 22.7 小 结 · 配置管理,适当应用时,可使程序的工作变得更容易进行。 · 你能找到某种方法度量项目的某一方面,这样比根本不度量好。准确的度量是完善的 计划、质量控制和提高开发速度的关键。 · 程序员和管理者都是普通的人,当他们受到礼待时往往干得更好。 · 合适的软件工程评估是软件开发管理最富挑战性的方面,你不妨尝试几种方法,看看 评估的差别,以加深你对项目的认识。 第二十三章 软件质量概述 375 第二十三章 软件质量概述 目录 23.l 软件质量特点 23.2 提高软件质量的方法 23.3 各种方法的效果 23.4 何时应作质量保证 23.5 软件质量的一般原则 23.6 小结 相关章节 评审:见第 24 章 单元测试:见第 25 章 调试:见第 26 章 创建前提:见第 3 章 本章研究软件质量技术。虽然本书是讨论软件质量的提高问题,但是本章主要是对每种活 动的质量和质量保证进行讨论。它主要给出一个大概的轮廓而不是对具体细节的讨论。如果你 想了解对评审、调试、测试的有关细节,请看下三章。 23.l 软件质量特点 软件既有外部也有内部质量特征。软件的外部特征是用户应了解的软件产品属性,它包括: · 正确性。整个系统受说明、设计和实现的错误影响程度。 · 可用性。用户学会和使用系统的难易程度。 · 效率。对系统资源的最小利用,包括存储和执行时间。 · 可靠性。系统在一定条件下执行特定功能的能力——在每次失效之间有一个较长的平 均时间。 · 完整性。防止非法或不适当地访问。完整性思想包括:限制非法用户访问,同时确保 证数据恰当访问;并行数据表进行并行修改;数据段仅含有有效数据等等。 · 适应性。系统在应用