[前言:]本文中的程序设计是指程序的"设计"(program design),所强调的是设计,而不是一般文章中提到的编程(program)。
本文的目的是学习如何把实际的编程问题概括为五个简单的步骤,以及在设计应用程序时如何以这五个步骤作为出发点。
内容:
· 算法
· 五个编程步骤
· 横向改进
· UML
· UML类图表
在过去的很多年里,出现过很多的正式的程序设计方法学。在方法学中有些相对复杂,也有些相当简单。但是所有的方法学的目标是相同的:帮助程序员写容易编写、调试和维护的程序。
本文从一个非常简单的设计方法学开始我们的程序设计讨论。尽管这个简单的方法学是相当基础的,但是它的目的是引起你思考程序的设计的问题。在你研究过这个简单的方法学后,我将介绍称为UML。UML是很流行的设计方法,特别适合面向对象编程。
为什么烦恼?
我在长期的教学中经常看到这样的问题,当我把要解决的问题分配给全班同学后,在几秒钟内就出现了鼠标和键盘的声音,因为学生们已经开始输入代码解决手头的问题了。但是,并不是所有的学生都是这样的,有一个学生拿出一张纸,并开始在纸上写一些东西。几分钟过去了,那个学生终于开始把代码输入计算机了。几乎没有异常,虽然该学生迟一些开始,但是他更快地完成了任务并且他的解决方案比班上的其他同学更好。为什么?
如果你看到该学生在纸上写的东西,你会发现那是程序设计的问题。有些学生的设计相当精确,花费了数张纸,然而其它的设计相当简洁。但是重要的是这些学生有了程序的设计的思考。
算法
每个程序都需要有一个设计或框图供程序员遵循。用程序员的说法是每个程序需要一个算法。简单地说,算法就是解决特定问题的系统方法的描述。对于程序员来说,算法是蓝图或处方,我们遵循它来建立程序。
宏观与微观算法的对比
算法有不同的"风味"。我们可以谈论用于编写给定的程序的算法,在这个层次,我们是在宏观层讨论算法。该算法试图捕捉程序的一般状况而不是程序每个方面的详细情况。
我们也可以在微观层讨论算法。在这个层次我们需要写下程序各个方面的信息。例如,如果数据在被使用前必须先排序,就有成打的排序算法供挑选。选择一个排序算法就确定了程序的一个方面。一旦你对数据排序后,你需要另一个微观算法来显示它。
你可能会猜到,我们可以收集所有的微观算法来建立一个宏观算法。如果我们从微观算法开始并向上形成程序的宏观算法,我们所作的事情称为从底部向上(bottom-up)的程序设计。如果我们从宏观算法开始并向下形成微观算法,所作的就称为从顶部向下(top-down)的程序设计。
那么哪种方法最好?如果你拿到了所有的关于哪种设计途径更好的文章并让它们首尾相连,你永远不能得到答案。每种方法都有优点和缺点。
不管使用哪种设计方法,你都必须有一些程序的设计。至少你要有手头上的编程问题的描述和解决问题的计划。一旦了解了问题,你就能找出如何解决它。我看到了太多的因为用户和程序员没有就要解决的问题进行沟通而造成的失败。
因为我相信对要解决的问题的可靠的了解是编程的第一步,而且是必需的一步。我可能更喜欢从顶部向下的程序设计方法。有了这种想法后,让我们使用宏观方法来观察实际的各种编程问题。
五个编程步骤
无论程序多么复杂,都可以被简化为五个基本的步骤。这些步骤是:
1. 初始化
2. 输入
3. 处理
4. 输出
5. 清除
下面我将更详细的介绍这些步骤。
初始化
设计程序时的初始化步骤是你必须思考的第一个步骤。初始化步骤包括在程序与用户交互前它必须做的所有工作。程序在与用户交互前执行一些操作似乎很奇怪,但是很多程序都是这样实现的。
例如,我们都使用过微软Word、Excel或类似的程序。在这些程序中,你知道可以点击"文件"菜单选项并且在该菜单的尾部可以看到最近使用过的文件列表。它们不是靠魔术跑到这儿的。该程序可能从磁盘数据文件读取最近使用的文件列表并且把该列表加到"文件"菜单。因为该列表在程序为用户显示信息前就被读取了,就必须在初始化步骤中处理。
初始化步骤的另一种普通的事务包括读取设置文件。这类设置文件可能包括查找某个数据库或其它磁盘文件的路径名称。根据程序的类型,设置文件可能包含显示字体、打印机名称和位置、前景色和背景色、屏幕分辨率等等。其它程序也许会读取网络连接信息、Internet访问和安全性特权、密码和其它的敏感信息。
在你自己的程序中,你必须考虑程序在工作前必须有哪一些类型的信息。如果你的设计需要任何设置信息或预备信息,初始化步骤就可能是处理它们的地方。
输入
输入步骤一般是你期待的。它是收集输入程序的任何信息以完成事务的步骤。在大多数情况下,如果你仔细思考程序要达到什么目的,那么定义输入列表就是相当简单的。例如,如果你编写一个贷款利息程序,你就知道需要询问用户贷款的总金额、利率和贷款的期限。
但是,在其它一些情形中,你必须思考应该让用户输入什么信息。例如,如果你编写一个地址薄程序,每次运行程序时,你真的要求用户每次都输入保存地址薄的磁盘文件的名称以及文件的位置吗?换句话说,有些输入步骤可以,也应该放入初始化步骤中。可以在初始化步骤中读取的信息量的种类依赖于程序的特性。但是,作为一条重要的规则,大多数用户希望重复的信息存储在设置或初始化文件中,而不是每次运行程序时重新输入。
输入来自于何处是设计决定的。设置文件很强大,在可以用到的时候应该尽量使用。很明显,其它的输入信息只有在用户输入的时候才可能知晓,就象我们常作的利息示例程序一样。在这些情形中,一般使用文本框向用户收集要使用的信息。因为用户必须与文本框交互为程序提供信息,文本框、标签、菜单和其它程序元素的放置方法定义了程序的用户界面的重要部件。
很多书本都说过如何设计有效的用户界面的。我没有办法评价其合理性。但是,它绝对不会伤害读完那些书的人。在本文中我谈到了很多用户界面设计的问题。
处理
处理步骤包含操作输入的信息以生成程序需要的结果。在贷款示例中,程序将接受输入(贷款量、利率和期限),把它们插入金融方程式中,解出方程式并输出想要的结果(每个月支付的金额)。处理过程接受输入,"压碎"它们,并产出问题的答案。
注意处理过程一般不会在屏幕上显示任何信息,它的唯一的目的是操作数据生成结果。但是也有一条值得注意的例外。如果你事先知道处理步骤将花费很长的时间,为用户提供一些信息表明程序正在运行是一条好主意。我们运行的程序一般有一个过程条表明手中的事务已经完成的百分比。另一个普通的例子是在完成前提供剩余时间的估计。大量的基于Web程序使用这种方法,在这些情况中必须有一些反馈表明程序仍然在工作。但是,在处理步骤相当快的情形中,为用户显示信息一般没有作用。
输出
从某种角度来说,这是程序的最重要的目的:为用户提供他们希望解决的任何问题的答案。我们的示例程序中的很多在文本框中显示答案。例如,如果你编写一个程序计算每个月贷款的支付金额,输出步骤的所有工作是把金额显示在文本框中。
但是,其它一些程序复杂得多。也许你运行程序从数据库中读取你的名字、地址、电话号码和其它的很多信息片断,接着把它们显示在屏幕上。这类复杂的程序可能需要成打的文本框来显示必要的输出信息。在有些情形中,在文本框中显示结果的效率不高。例如,使用grid或者list控件显示一个填充了客户数据的表可能更好。
输出步骤中要重点注意的是向用户显示结果,因此它也是用户界面的一部分。前面你看到输入步骤是用户界面中的一部分,因为它从用户那儿收集数据。在输出步骤中,用户界面与用户交互的方式更被动,但是在设计的时候也必须考虑用户的需求。用丰富的方法为用户显示信息几乎是艺术的表现。
清除
在某个程序完成了事务后,使用清除步骤"温和地"关闭它。你可以认为这个步骤与初始化步骤相对应。尽管很多简单的程序可以简单地关闭而不需要做进一步的操作,但是有一些复杂的程序可能需要一些帮助。例如,如果程序在初始化步骤中读取设置文件初始化了一些变量,清除步骤一般会使用用户最新的信息更新设置文件中的这些变量。
清除步骤一般与关闭磁盘数据文件(包括设置文件和数据库文件)相关。有些程序跟踪某个用户运行程序的时间并把这些信息写入日志文件。在日志文件中跟踪运行过某个程序的人的姓名、启动程序的日期和时间、停止使用程序的时间等并不罕见。
另一种日志文件称为错误日志文件。错误日志文件的目的是记录程序运行时所遇到的任何错误的信息。程序员可以使用错误日志文件的内容帮助自己调试程序。
清除步骤执行的实际事务依赖于程序本身。但是,有时候如果在初始化步骤中执行了一些操作,那么在清除步骤中也需要执行一些相匹配的事务。打开和关闭不同类型的磁盘文件就是这两步的普通的事务。
每个程序都需要这五个步骤吗?
所有的程序都需要这五个步骤吗?不。有很多示例程序不需要初始化和清除步骤。通常,初始化和清除步骤出现在很复杂的程序的设计中。
如果你有一定的编程经验,那么就知道哪些程序需要所有的五个步骤哪些不需要。但是,你在接近程序设计问题时应该假定所有的五个步骤都是必须的。在后面的设计中丢掉某些步骤比在设计未完成前把它们放入设计中容易得多。
横向改进
前面我表明了我喜欢把从顶部向下的宏观算法作为程序设计过程中的出发点。最基本的是在解决某个问题前你必须了解问题。但是了解用于解决问题的微观问题的时刻也会到来。随着我们的处理从程序的概貌转到细节,我们在增加视图的细节,或者说粒度。另一种说法是,我们从一般移动到了特殊。
我们能使用五个编程步骤作为设计过程的宏观视图的出发点。下一步,我们检阅每一步并提供每个步骤包含的更详细的信息。这个过程称为横向优化。有一个例子可以帮助我们了解这个过程。
假定你有一个用户,他拥有一个保存所有用户职位的数据库文件。这些职位按照授予职位的日期排序,保存在数据库中。但是该用户希望按照字母次序(基于该用户遇到的某个人的名字)查看职位。让我们看一看如何使用横向优化设计一个解决方案。
初始化步骤的横向改进
我们已经知道该用户有一个职位数据库。我们也知道他希望有一个按名字的字母次序排列的列表。这是算法的宏观视图。
既然这样,在初始化步骤中执行什么操作呢?很明显我们应该打开该职位数据库。在职位被排序后我们需要一个Visual Basic .net窗体显示结果。因为我们知道数据库位于网络上,并且当该用户启动程序时我们能知道用户的名称和密码,因此我们认为事务很简单。有了这些信息后,我们首先作的横向优化可能如图1所示。
图1:初始化步骤的横向改进
注意在图1中当我们从左边横向移到右边时细节是怎样增长的。在图1中,横向优化表现为需要完成事务的步骤的子程序或函数的列表。每个子程序完成一个特定的事务。
伪代码
我们可以使用伪代码进一步优化每个步骤。伪代码是程序描述的算法,它使用类似英语的语法。例如,我们可以看一看IsValidUser()程序并把它写为下面的伪代码:
IsValidUser() -_ If CurrentUserName Not in ValidUserList
Display Invalid User Error Message
Terminate Program
Else
Return ValidUserIDNumber
End
注意这段伪代码是如何描述程序要做什么的,而且它不需要目前使用的编程语言的正式语法。伪代码不是基于语言的语法。伪代码是描述程序做什么的算法语句。
伪代码的实际的优点是,因为它与英语类似,因此你能够把它与设计阶段中的其它非编程用户共享,不管你是否正确解决问题。这种在设计的早期阶段与用户一起检查的处理方式对于你也有很大的好处。捕捉设计错误,在这个过程中改变程序可以防止大量的难以应付的困难。从开始就把用户包含进来会形成更好的程序,花费更少的时间,修改更少。它对于程序设计来说是双赢的。
一旦你明白了伪代码描述的程序的目的,把伪代码转换为实际的编程代码就很简单。好的设计使程序开发更容易。
我鼓励你这时候停止阅读,花费一点时间对我们程序的其它四步进行横向优化。你在试图为每种优化编写伪代码时可能遇到一些麻烦。虽然我意识到你们中的很多人没有花费时间这样作,但是有少数人还是会坚持的。相信我,值得在它上面花费力气。
UML是什么?
到目前为止,我们对程序的设计有了一个不太正规的了解。上面的五个步骤是考虑程序设计的很好的出发点。五个步骤的横向改进是设计程序的第二个阶段。使用伪代码扩充改进的细节在程序设计中很有用。尽管这五个编程步骤对于程序设计是有用的,但是有些人喜欢更精确的方法。统一建模语言(Unified Modeling Language,UML)就是这样一种方法。
UML是一种定义(specifying)、分析(visualizing)、构造(constructing)和记录(documenting)软件系统的标准语言。UML表现了最好的工程学经验的集合,在大型和复杂系统的建模中被证明是正确的。简单地说,UML是软件开发的一种方法学。
UML的简短历史
面向对象编程(OOP)出现在19世纪60年代左右。尽管OOP相对年轻,但是面向对象建模的几种语言还是出现在19世纪70年代中期和19世纪80年代晚期。在19世纪90年代中期,大约开发了50种建模语言。很明显,如此多次数的尝试没有生产出一种被编程团体接受的语言意味着需要做更多的工作。
那时候领头的竞争者是Jim Rumbaugh(通用电器)、Grady Booch (Rational 软件)和Ivar Jacobson (Objectory)。多年中这三个领袖彼此抨击,在建模语言舞台上没有取得太大成果。
1994年Rumbaugh离开通用电器加入Booch所在的Rational软件,因此一起反对Jacobson。一年后Rational软件买下了Objectory,从而使那三个主要玩家(有时也称为三个朋友)齐聚同一个屋顶。在1996年6月,发布了UML 0.9。
从那一年开始,对象管理组(OMG)开始对UML进行改进。在2001年中期,OMG成员把工作集中在升级到UML 2.0。目前,UML包括可视化建模、模拟和开发环境。现在有大量的商业应用的建模工具。这些工具包括Rational 软件公司的Rational Rose 2002、Embarcadero技术公司的Describe EntERPrise、微软的Visio 2002。
UML图表
UML是一种可视化的工具,它使用了很多类型图表。每一种UML图表都设计了为让开发者和客户在不同的抽象层中从不同的角度查看某个软件系统。表1显示了由一些可视化建模工具建立的UML图表列表。
表1:普通的UML图表
用例图表 | 显示操作人员和用例之间的关系。 |
类图表 | 使用类、包、对象等设计元素的模型类结构和内容。它也显示了容器、继承、关联等关系。 |
状态图表 | 显示交互中的某个对象在生命周期内对接收到的刺激的响应的状态次序,同时有响应和行动。 |
序列图表 | 显示参与交互的对象的时间次序。它包含垂直维度(时间)和水平维度(不同的对象)。 |
协作图表 | 显示组织在对象周围的交互操作和它们之间的链接。数字用于显示消息的顺序。 |
行为图表 | 显示特殊的状态图表,在它里面大多数状态都是操作状态,大多数事务都被源状态中的操作的完成所触发。这种图表聚焦于内部处理驱动的流。 |
组件图表 | 显示代码自身的高层次的封装结构。显示组件之间的依赖关系,包括源代码组件、二进制代码组件和可执行组件。有些组件存在于编译时刻、链接时刻或两者的组合。 |
配置图表 | 显示运行时处理元素和软件组件、处理和对象的配置。软件组件实例模拟代码单元的运行时表现。 |
表1中列出的每个图表都需要一章讲解。但是我们主要关心UML怎样如类图表一起使用,以及它们帮助我们开发接受OOP基本原理的程序。
面向对象分析
面向对象分析(OOA)是一种软件开发过程分析的方法学。当使用OOA的时候,你必须把软件开发过程中的每样东西都想作是类。以医院电梯为例,每个医院个体(医生、护士、病人、来访者等等)是一个类。从类中建立的每个新的个体称为类的一个实例。OOA过程主要关心怎样导出系统需要的类。
OOA的核心问题是回答在软件开发过程中将发生"什么"类型的问题。典型的OOA问题和业务是"我的程序中有哪一些类?","我的程序要做什么?","每个类对象作些什么来帮助解决问题?"以及"程序中的每个类的职责是什么?"。在OOA舞台上重点是对象、事务和实际的软件系统的职责的分析。
面向对象设计
面向对象设计(OOD)阶段的焦点是软件系统的"如何/怎样"的问题。设计阶段的典型的问题包括"这个类如何收集数据?","这个类如何计算返回的税?"以及"这个类如何打印报表?"。
因此OOA组件用于识别实现软件目标的必要的类。这完成后,OOD聚焦于这些类的实现以及每个类的核心的标志、属性和方法。把这两种活动耦合起来并开发向这些类的连接是OOD过程的所有操作。
UNL类图表
类的标准UML图表或标记是有三个间隔的矩形。从类图表的顶部开始,第一个间隔包含类的名称,第二个包含类的属性,第三个包含类的方法。类的方法告诉我们类能做什么。
图2显示了第一个间隔是类名称Vehicle的类图表。类名称的标准名称约定是类名称的第一个字母大写。如果类的名称包含一个以上单词,类名称中的每个单词的第一个字母大写。例如,遵循名称约定的类名称包括Vehicle、PassengerCar和IncomeStatement。在类名称的单词之间没有空格。
图2:Vehicle 类的UML类图表示例
类属性
在我们的例子中,第二个间隔有6个整形属性。在更复杂的例子中,类可能有很多的属性。Visual Basic .net倾向于把这些属性作为类的"所有物"。每个属性可以设置不同的值。总的来说,属性的当前值描述了类对象的状态。例如,如果CurrentGear的值为4,CurrentSpeed的值为55,那么认为Vehicle对象每小时移动55英里是合理的。因此,对象的状态是速度为55英里/小时向前移动。我们可以打一个比方,对象的属性类似句子中的名词。
加号和减号
你可能注意到了第二个间隔的每个条目的前面都有一个减号。在第三个间隔中有些条目有加号,有些有减号。加号标识此项在该类外部是可以使用的。换句话说,加号意味着我们能使用这些项影响类对象的状态。因此加号表示类的公共(Public)元素。
如果某个条目的前缀是减号,意味着该项只能在类自己内部使用。该项在类的外部不可见,也不能访问。因此减号标识类的私有(Private)元素。
作为一条通用的规则,使类的项私有是很好的,它符合尽可能隐藏数据的目标。通过封装数据,我们最小化了类之外的其它程序的部分不经意地改变数据的机会。这使查找程序错误更简单。
因此加号和减号标识类中的每个元素的访问说明符(还有第三种访问说明符"保护的",用#标识)。你可以认为加号定义了你与类对象交互的途径,减号表明只有在类里面可以使用的部件。
类方法(操作)
第三个间隔列举了类中可用的操作。尽管UML符号把这些项称为操作,但是使用Visual Basic .NET时,倾向于把这些操作作为方法引用。方法告诉程序员他们必须怎样与类交互。如果你查看第三个间隔种的项,每种的意思都是某类型的行为。如果属性是句子中的名词,那么方法就是动词。
我们经常听到其它程序员把方法作为类的过程。尽管这样并没有真正的错误,但是你要记得过程可以存在于类的外部,但方法一般约束到特定的类对象。
带参数的类方法
有时方法需要外部的信息来执行它的事务。例如,在图1中,我们看到了下面的代码行:
+SetSpeed(DesiredSpeed:Integer):Integer
加号说明SetSpeed()是公共的方法,因此它可以用于与某个类对象连接。在括号之间我们看到有一个数据值被传递到Vehicle类的SetSpeed()方法中。这个数据值的名称为DesiredSpeed,是一个整型数据类型。
在反括号的后面有一个冒号(:),后面有单词Integer。这意味着SetSpeed()会返回一个整型值到程序希望使用SetSpeed()方法的其它步骤。尽管这个时候我们不能肯定它,但是这个返回值可能用于显示我们是否能够设置速度,并且不会出错,例如,如果Vehicle 对象没有运行的话SetSpeed()可能就不能工作。同样,如果当前的传动装置是Reverse,把速度设置为150可能也不是明智的。
现在看下面两行:
-IncreaseSpeed(DesiredSpeed:Integer):Integer
-DecreaseSpeed(DesiredSpeed:Integer):Integer
这两行看起来与它们的目的和使用相似。实际上,这种类似应当作为技巧,它可能会帮助你简化工作。我们可以使用下面的代码代替这两个方法:
-ChangeSpeed(DesiredSpeed:Integer):Integer
在知道如果DesiredSpeed是正值的时候,表示我们希望提高的速度,如果是负值表明我们希望降低的速度。例如,考虑下面的代码要做什么:
Dim MyVehicle as New Vehicle
Dim ObjectSpeed as integer
' 执行某些事务的代码
ObjectSpeed = MyVehicle.GetSpeed()
ObjectSpeed = MyVehicle.ChangeSpeed(-ObjectSpeed)
首先我们建立了一个称为MyVehicle的Vehicle对象。接着我们调用MyVehicle对象的GetSpeed()方法。注意点(称为点操作符)是如何把对象名称与方法名称分开的。所有的Visual Basic .NET对象都使用这种语法格式。
我们假定机动车正在以55英里/小时的速度运行。ObjectSpeed的值被指定为55。如果我们把当前的速度的相反值(-55)传递给ChangeSpeed()方法,我们是在让车子停下来。
我知道目前这个例子中的很多语法细节你并不了解。不要担心。现在你只需要知道UML类图表是什么以及它所传达的信息类型。
为什么有些方法用减号做了标记?很明显这意味着它们是私有方法,不允许在类的外部使用。这些方法是类自己用于完成事务的内部使用的辅助方法。例如OkToShiftGears()方法可能检查CurrentGear和CurrentSpeed值,看变换齿轮是否安全。如果CurrentSpeed为55,现在把齿轮转变为Reverse并不是好主意。因此ChangeGear()可能调用OkToShiftGears()帮助决定改变齿轮是否安全。
UML类图表的背后的想法是给你一条快速、简洁的表述类做什么工作,以及你作为程序员希望怎样与它交互。如果你认为类是"黑盒",减号就表示盒子内部的东西,类的用户不能干涉它;加号就表示你用于与类的属性和方法交互的途径。换句话说,类的公共项定义了类对象的接口,以及你如何与它交互。
编程技巧
记住是公共(Public)方法表明程序员必须如何使用该类。只要你不改变这些方法的使用方式,与这些程序的接口就不会改变。这保持了类的一致性。
另一方面,你可以把私有方法改变为想要的内容并且不需要担心因为你改变了他们工作的方式而受到其它的程序员的责备。实际上,他们永远没有办法看到或使用私有方法。通常这意味着应该保持公共方法尽可能简单并使用私有方法管理手头的事务的细节。
总结
在本文中你看到了实际上任何编程问题都可以被归纳为五个简单的步骤。你也看到了如何把这五个步骤作为设计程序的出发点。你看到了如何使用每个步骤的横向优化给程序设计添加细节。最后,我们把UML类图表作为更常规的方法学来思考程序设计。
花费几分钟思考一下类和对象。如果有时间,你能想到希望的类并使用五个步骤概括它的设计。这完成以后,把该设计转换为UML类图表。考虑你在设计中建立的接口并问问自己它是否足够完成类的事务,是否足够简单,足够容易使用。这是设计过程中你必须经常问自己的问题。
随着经验的增加类和程序设计会更容易。尽管目前它看起来象魔术,但是你很快会享受它的。