第1章 面向对象软件开发方法
1.1 概述
计算机发展早期软件开发的个体化特点,造成了许多错误的认识和做法,表现为忽视软件需求和软件分析的重要性,认为软件开发就是写程序并使之运行。随着软件规模的增大,计算机软件开发和维护过程中遇到了一系列严重问题,出现了软件危机。
在软件开发工作中,以下几个观念常常被忽略。
① 软件的生命周期(参见图1.1)大体分为规划、分析、设计、编码、维护等一系列阶段组成。
图1.1 软件生命周期
在开发过程中,每个阶段都可以直接和间接地回馈到前面的阶段。整个软件生命周期是一个迭代、渐增的开发过程,这种迭代过程不仅贯穿整个软件生命周期,并且表现在每个阶段中,特别是在分析(全局分析、局部分析)和设计(全局设计、局部设计) 阶段。
② 程序只是完整产品的一个组成部分,Boehm对软件的定义是:“软件是程序、数据和开发、使用和维护程序所需要的所有文档。”
③ 在软件开发不同阶段进行错误修改所付出的代价是很不相同的,在后期引入一个变动比在早期引入变动,代价高达2~3个数量级,所以做好软件定义时期的工作是降低软件成本,提高软件质量的关键。
④ 软件维护是极端艰巨复杂的工作,需要花费极大的代价,统计数据表明,软件维护费用占软件总费用的55%~70%,所以要采取措施提高软件的可维护性,减少维护费用。
为了解决软件危机,必须要用现代工程的概念、原理、技术和方法进行软件开发、管理和维护。
软件开发不是某种个体劳动的神秘技巧,而应该是一种组织良好、管理严密、各类人员协同配合、共同完成的工程项目。另外,在软件开发的每个阶段都有许多烦琐重复的工作需要做,应当学会使用软件辅助工具,在适当的软件工具辅助下,开发人员可以把工作做得既快又好。
1.2 软件生命周期各阶段的基本任务
软件生命周期是指一个软件项目被提出并着手实施开始,到该软件报废或停止使用为止。软件生命周期由软件定义、软件开发和运行维护(也称为软件维护) 三个时期组成,每个时期又进一步划分成若干个阶段:
● 问题定义
● 可行性研究
● 需求分析
● 总体设计
● 详细设计
● 编码和单元测试
● 综合测试
● 运行与维护
(1)问题定义
确定“要解决的问题是什么?”通过调研,写出关于问题性质、工程目标和工程规模的书面报告,并得到客户的确认。
(2)可行性研究
确定对于问题定义阶段所确定的问题是否有行得通的解决方法。研究并论证软件系统的可行性,对方案进行选择并形成可行性分析报告。
(3)需求分析
这个阶段的任务主要是确定所完成的系统必须具备哪些功能,并用正式文档准确地记录结果,这个文档通常称为系统规格说明书。
(4)总体设计(概要设计)
软件设计的一条基本原理就是,程序应该模块化,也就是说,一个程序应该由若干个规模适中的模块按合理的层次结构组织而成。因此,总体设计的主要任务就是设计程序的体系结构,也就是确定程序由哪些模块组成及模块间的关系。
(5)详细设计(模块设计)
将解法具体化,确定应该怎样具体地实现这个系统。主要工作是模块详细设计。模块详细设计包括:模块的详细功能、算法、数据结构、模块间的接口等设计,拟订模块测试方案。详细设计的工作体现在详细规格说明书。
(6)编码和单元测试
根据模块详细规格说明书,把详细设计的结果翻译成用选定的语言书写的程序。还要对模块程序进行测试,验证模块功能及接口与详细设计文档的一致性,并形成单元测试报告。
(7)综合测试
通过各种类型的测试(及相应的调试)使软件达到预定的要求。
● 集成测试:根据设计的软件结构,把经过单元测试检验的模块按某种选定的策略装配起来,在装配过程中对程序进行必要的测试。
● 验收测试:按照规格说明书的规定,由用户对目标系统进行验收。
● 现场测试或平行运行:平行运行就是同时运行新开发出来的系统和将被它取代的旧系统,以便比较新旧两个系统的处理结果。
用正式的文档资料把测试计划、详细测试方案以及实际测试结果保存下来,作为软件配置的一个组成部分。
(8)运行与维护
维护阶段的关键任务是,通过各种必要的维护活动使系统持久地满足用户的需要。
● 改正性维护:诊断和改正在使用过程中发现的软件错误。
● 适应性维护:修改软件以适应环境的变化。
● 完善性维护:根据用户的要求改进或扩充软件使它更完善。
● 预防性维护:修改软件为将来的维护活动预先做准备。
每项维护活动都应该经过提出维护要求(或报告问题),分析维护要求,提出维护方案,审批维护方案,确定维护计划,修改软件设计,修改程序,测试程序,复查验收等一系列步骤。
使用面向对象方法解决问题的过程可以大体划分为面向对象分析(Object Oriented Analysis, OOA)、面向对象设计(Object Oriented Design, OOD)、面向对象编程(Object Oriented Programming, OOP)和面向对象测试(Object Oriented Test, OOT)等步骤。
面向对象分析的主要作用是明确使用程序的用户、用户可以进行的操作,以及数据的输入、输出和储存,并且用标准化的面向对象模型规范地表述这些内容,最后形成面向对象分析模型,即OOA模型。在分析问题时,要抽取所有需要的对象实体,然后确定这些对象的状态和行为,以及它们之间的相互关系。一般来说,解决一个问题会涉及多个对象,所以这些对象之间的关系一定要明确,从而反映出整个程序的功能和状态。
面向对象设计是将在面向对象分析步骤中创建的OOA模型加以扩展并得到面向对象设计步骤中的OOD模型。面向对象设计在OOA模型的基础上引入界面管理、任务管理和数据管理三部分的内容,进一步扩充OOA模型。界面管理负责整个系统的人机对话界面的设计,任务管理负责处理整个程序资源管理功能的工作及设置客户与服务器之间的接口,数据管理负责设计程序与数据库的交换方式。面向对象设计还需要明确每个类方法的参数、返回值、功能等,以及各类之间的相容性和一致性的验证,对各个类、类内成员的访问权限的严格合理性的验证,也包括验证对象类的功能是否符合用户的需求。
面向对象编程就是具体的程序编写阶段,其主要过程是先选择一种合适的面向对象编程语言,再用选定的语言编写程序实现设计步骤中对各个对象的详尽描述,然后将编写好的各个类根据其关系集成为整个程序,最后通过各种实例测试找出程序的漏洞并改善程序,最终完成整个软件的开发。
1.3 面向对象分析
1.3.1 确定客户需要什么
人们通常认为确定客户需要什么是一件非常容易的事,只需向客户询问就能轻松获得。其实它远比我们想象的困难,原因主要有三个。
① 首先是客户说不清需求。有些客户对需求只有模糊不清的感觉,自然不能很好地用语言描述出来;有些客户心里非常清楚想要什么,但却表达不清楚。少数客户本身就懂得软件开发,能把需求说得清清楚楚,这样的需求分析将会非常轻松、愉快,但这种可能性很小。
② 其次是需求自身不断变化。有些客户对目标系统的预期一天一个想法,一拍脑袋就变一个主意,这是软件开发人员最头疼的事情,但是也只能面对现实寻找策略应对这种情况。分析人员在进行需求分析时就要注意以下两点:
● 尽可能地分析清楚哪些是稳定的需求,哪些是易变的需求。以便在进行系统设计时,将软件的核心建筑在稳定的需求上。
● 在合同中一定要说清楚“做什么”和“不做什么”。如果合同不清晰明确,日后会引起很多纠纷。
③ 最后就是需求分析人员和用户交流中产生误解。
分析人员和目标系统客户,一方是计算机专业人员,另一方是系统应用领域专家。虽然彼此都对对方的领域有所了解,但离专业人员还是存在一定的距离,这样他们在沟通交流中就不可避免地可能会产生误解。
由于需求分析存在众多困难,所以对软件需求分析人员的要求是非常高的。他们通常都是资深的计算机专家,同时具备丰富的业务领域知识和良好的沟通技能。
1.3.2 需求阶段概述
需求阶段的第一步是理解应用领域,也就是目标系统应用的特定环境,例如银行、证券公司、学校、政府等。一旦开发团队充分了解应用领域,就可以实施目标系统的系统建模,一种很主流的建模方法就是使用统一建模语言UML来描述目标系统的业务逻辑。通过模型,开发团队可以和目标系统的用户进行充分交流以确定客户的业务需求,确定客户的最终需求是一个反复迭代的过程,经过多次沟通、理解、修正才能比较客观地确定客户对系统的真实需求。
1.3.3 理解应用域
为了有效地挖掘出客户的真实需求,技术人员必须熟悉目标系统的应用领域。如果不了解系统的业务领域,很难向客户提出有意义的问题,所以不可能成功完成系统的需求分析。理解应用域的方法就是亲临目标系统将来应用的真实环境,去了解目标系统将完成哪些业务,这些业务手工完成的流程是怎样的,目前的业务流程有哪些优点和不足,等等,只有完全弄明白了这些问题,才有可能与客户进行充分且有效的交流。
1.3.4 用例建模
使用UML中的“用例图”描述拟建软件与外部环境之间的关系。一个用例表示一个外部角色Actor(例如,用户或其他外部软件环境)与拟建软件之间一个单独交互。将所有的用例集中在一起,就可以描述一个OO软件的总体功能需求。例如,一个网上拍卖系统的拍卖过程用例图(见图1.2)。
图1.2 拍卖过程用例图
用例图在软件的建模过程中是必不可少的。
1.4 面向对象设计
1.4.1 有效应用设计模式
众所周知,人是一种经验性的动物。我们常说:据我所知,据我所了解,据我的经验,等等。生活中很多事情都是依靠自己或他人的经验,软件设计也类同。建筑大师Christopher Alexander说过:“每一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。这样,你就能一次又一次地使用该方案而不必做重复劳动。”他所指的是建筑领域,软件设计模式则是描述软件设计过程中某一类常见问题的一般性的解决方案。1995年, Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides四人合著了一本经典巨作《设计模式——可复用面向对象软件的基础》,该书奠定了坚实的面向对象设计模式理论的基础,它介绍了23种基本设计模式的结构、特性和使用方法。《设计模式》一书被软件人士当作“模式的圣经”,是所有面向对象分析设计人员必读的书籍。四位作者被大家称为“四人帮”(Gang of Four, GoF)。
由于《设计模式——可复用面向对象软件的基础》一书奠定了设计模式的地位,人们通常所说的设计模式隐含地表示“面向对象设计模式”。但这并不意味“设计模式”就等于“面向对象设计模式”,也不意味着GoF 23种模式就表示了所有的“面向对象设计模式”。除了“面向对象设计模式”外,还有其他设计模式。除了GoF 23种设计模式外,还有更多的面向对象设计模式。
面向对象设计模式精髓是隐藏在背后的三条面向对象设计原则和设计理念:适应需求的变化;针对接口编程,而不要针对实现编程;优先使用聚合,而不是继承。
根据目标系统的特点和需要,选择合适的模式加以应用,应用模式后最大的优点就是系统可以适应需求的变化,从而具有更好的可复用性、可扩展性和可维护性。
1.4.2 类建模
使用“类图”描述所设计的软件中包含的类,以及这些类之间的静态关系,从而描绘了整个软件的静态组成和结构。
1.类的一般描述图
(1)类名
类名即类的名称。
(2)属性
属性描述的语法:
visibility name [N] : type = initialValue {property-string}
① visibility:属性的可见性:+ 表示public; # 表示protected; - 表示private。
② name:属性的名称。
③ [N]:属性的多值性:[2..*]表示属性能接受多值;如果属性描述中无此项,则表示该属性只允许接受一个值。
④ type:属性的实现类型(依赖于实现语言的规范)。
⑤ initialValue:属性的初始值。
⑥ property-string:表示属性的一些无法用上述语法描述的特性。例如,一个只读属性(如C++的const成员或Java的final成员), 则property-string将可以设置为{frozen}。
(3)操作
操作描述的语法:
visibility name (parameter-list) : return-type {operation-string}
① visibility:操作的可见性:+ 表示public; # 表示protected; - 表示private。
② name:操作的名称。
③ parameter-list:操作的形参列表,表中允许有多个形参,形参之间由逗号分隔。每个参数的表示语法:kind name : type = defaultValue。
● kind:表示参数的作用分类
in表示参数向操作中传入一个值。
out表示参数从操作中提取一个值。
inout表示参数既可以向操作中传入一个值又可以从操作中提取一个值。
● name:表示形参名。
● type:表示形参类型。
● defaultValue:表示形参的默认值。
④ return-type:操作的返回类型(因编译器而异)。
⑤ operation-string:表示操作的一些无法用上述语法描述的特性。例如,一个操作是抽象操作,则operation-string可以设置为{abstract}。
2.类的相互关系图
用于描述类之间的静态关系。在关系图中的类图除了类名部分不能省略外,其他两部分都可以根据需要省略。主要的静态关系有:
(1)归纳关系(Generalization)
归纳关系表示两个类之间的继承和派生关系,如右图中的类Employee是类Manager的超类(基类),而类Manager是类Employee的子类(派生类)。
(2)关联关系(Association)
关联关系表示两个类之间的关联关系,如下图中类Employee和类Corporation之间的关联关系。
图中employeeBy是Employee的属性成员,其类型为Corporation。通过该属性employeeBy使两个类关联;0..1表示关联的多样性,此关联表示一个Employee对象不会被超过一个Corporation对象所雇用。同样,employees是Corporation的属性成员,其类型为Employee。*表示一个Corporation对象允许有0至任意数量的Employee对象,即雇用任意多个雇员。上述两端关联关系也可以用两条箭头线分别表示,箭头线的方向是从关联属性所在类指向属性的类型类。
下面的关联关系表示同一类的两个以上不同对象间的关联。它的含义是该雇员可以管理1~10个其他员工。
注意,参与一个关联的两个对象常常是独立存在的,即关联关系中的一个对象的存在与否不会影响到所关联的另一个对象的存在。
(3)类之间的聚合和合成关系
表示对象之间的“整体”和“部分”之间的关系,即在整体和部分之间可能存在生命期的依赖性。合成关系表示当整体不再存在时,部分同时被销毁的紧密关系。例如,下图中的Window和Slider、TitleBar、Panel之间的关系。
聚合关系表示当整体不再存在之后,部分还会继续存在的关系。例如,类Orchestra和Performer之间的关系。当然这种关系也可以通过前面介绍过的关联关系表示。但是,聚合关系可以很形象地说明一些概念。如本例中说明:乐队是由演奏人员所组成的,但乐队的生存期与演奏人员的寿命并不存在紧密依赖的关系。
(4)类模板和类模板的实例化
类模板是具有成员类型参数的类。例如,下图中的类就是一个具有两种成员类型参数T和Y的类模板,和将该类模板的成员类型实例化后的类。
(5)类的实例——对象
对象是按照类定义创建的实例,在对象的图形描述中实例被命名,同时类的各个属性被赋予特定的值。例如,下图就是一个用Employee类创建的对象john_1。
显然,在建模的主要工作——类设计中,使用类图是十分必要的,也是十分方便的。
1.4.3 状态图建模
状态图最适合用于显示一个类对象在经历一系列相关用例的过程中,所呈现的不同状态。它们可以帮助我们更好地理解一个OO程序中单一对象的生命期行为。状态图主要由三种图示构件组成:
① 对象状态:对象的一个状态用圆角矩形表示。在该矩形中,状态名称用粗体字显示在矩形最上方;如果还有额外信息,用一条线将这些额外信息与状态名分隔开,以“do:/”为前缀的信息项目表示处于此状态下的一个活动。开始和结束是两个特殊状态。
② 转换路径:对象从一个状态转换到另一个状态用一条箭头线(箭头指向转换后的状态)表示。
③ 状态转换标签:描述引起状态转换的原因,描述语法是
Event[Guard]/Action
该语法的含义是:事件Event的出现将导致以Guard为条件的状态转换结果为TRUE。但在实际转换到新状态之前,动作Action必须先执行。由于对象从一个状态到其他状态只能进行一次转换,因此从一个给定状态可能出现的所有转换路径必须相互排斥。注意,转换描述的三个部分(事件、条件和动作)都是可选择的。
图1.3描述了一次拍卖程序中Buyer对象的一个状态图。
图1.3 Buyer对象的一个状态图
显然,使用状态图对类对象的深入研究是有效的,但并不是必不可少的。在建模的过程中是否需要使用状态图对类对象的生存过程进行状态进行,一般与软件的复杂程度有关。通常情况下,一个简单的软件建模中,不需要绘制状态图。
1.4.4 顺序图建模
顺序图按时间顺序描述了参与功能事务的一组对象在功能事务的执行过程中的交互操作。构成顺序图的要素如下(见图1.4)。
图1.4 顺序图的要素
图1.5的顺序图描述了在网上拍卖事务中,销售者对象和竞拍者对象,以及完成一次拍卖事务所需要的其他服务对象之间相互协作的顺序图。
图1.5 对象之间相互协作的顺序图
图中的各种类型的交互消息必须遵循以下6种语法规则:
① 状态消息:由一个对象将一个状态值转送给另一个对象,例如:[bidAcceptable]。
② 方法名:一个对象调用另一个对象的方法。例如:selectItem( )。
③ *[迭代依据]方法:其中“*”是迭代标记。这个命名方法在目标对象的多个实例上调用,至于具体在哪个实例上调用则由方括号中的表达式控制。例如:*[for all items in Auctionlist] getCurrentMaxBid( )。
④ flag := 方法:flag被设置为TRUE或FALSE,这取决于在这条消息接收对象上调用这个指定的方法所产生的结果。例如:minAcceptBidExceeded := check( )。
⑤ [条件]方法:只有在条件满足时才在消息接收对象上调用这个指定的方法。例如:[minAcceptBidExceeded] notifySeller( )。
⑥ 特殊符号new:表示创建消息接收对象的新实例。例如:[bidAccepttable] new。
由于顺序图既能较好地描述功能事务执行过程中各个参与对象的动作顺序,又能较好地描述对象之间的交互操作,因此,一般多用顺序图来描述类之间的动态交互关系。
1.4.5 协作图建模
协作图着眼于参与功能事务的一组对象在功能事务的执行过程中的相互协作关系,而不按照执行过程的时间顺序描述对象之间的交互操作。协作图虽然在表示对象之间的交互操作方面优于顺序图,但在其他方面都不及顺序图,因此,一般只用于顺序图所描述的类之间动态交互关系的补充。上述的网上拍卖事务的协作图如图1.6所示。
图1.6 网上拍卖事务的协作图
不难看出,使用类的交互关系图(特别是“顺序图”)对于类设计中研究类的属性和行为,最后确定类的结构和接口设计是必不可少的,也是十分方便的。
1.4.6 活动图建模
活动图描述如何通过一组相互协作的活动产生一个期望的结果。在创建活动图时需要注意的一个重要问题是:确认那些可以同时执行的子活动(通过多进程或多线程)以及那些必须线性顺序执行的子活动。活动图中的主要图示构件如下所示。
① 活动:用椭圆矩形表示。
② 触发器:用指向活动和从活动出来的箭头线表示。一个活动有流入触发器,表示这个活动可以在接收到这个触发器时执行,在绝大多数情况下前一个活动的成功执行就是触发器源(流出触发器)。对于具有多个流入触发器的活动而言,它是非连接性的。这意味着如果这个活动收到其中一条触发,目标活动将会执行。
③ 同步杠:用一条双线或一条粗黑线表示。一些活动流入它,一些活动自它流出,并且它带有附加条件[condition]。对于流入活动,同步杠是连接性(conjunctive)的,即所有流入的活动必须在控制到达同步杠之前成功执行。这些流入活动的成功执行受制于同步杠的附加条件。对于流出活动,同步杠表示这些活动允许并发执行,彼此之间相互独立。
④ 判定活动:在绝大多数情况下,判定活动是通过测试一个布尔值来实现的。这类活动是由一个菱形表示的,如下图所示。
图1.7显示了拍卖处理过程的活动图。图中那些可以并发进行的活动是从标签为“fork”的同步杠中流出的。标签为“merge”的同步杠用于表示一个条件,就是发生在该同步杠上的多个活动必须同时成功结束,控制流才能继续向前。特别要注意从P到Q的活动段。在P处与同步杠相关联的条件是
*[for each item in the group]
这里的“*”表示多个触发器,就是说它指定了从P到Q所显示的活动线程实际上由一组平行的线程所组成,每个线程都由图中所示的活动组成。因此,在P处源于同步杠的并发路径的数量等于购买者感兴趣的那组项目的数量。
图1.7 拍卖处理过程的活动图
不难看出,UML的活动图与一般的程序流程图在形状和作用上是类似的,但活动图的描述能力要远远超过一般的程序流程图。在模型中使用活动图来讨论和描述软件的部分和整个运行过程的活动也是必要的。
1.4.7 用户界面设计
1.交互设计
用户界面设计的目标就是使软件让用户能简单使用。任何软件功能的实现都是通过人和计算机的交互完成的,因此,人的因素应作为设计的核心被体现出来,界面设计的原则如下:
① 执行有较大破坏性的动作前要求确认。
② 允许用户非恶意错误,系统应保护自己不受致命错误的破坏。
③ 只显示与当前用户语境环境有关的信息。
④ 尽量减少用户输入动作的数量。
⑤ 维护信息显示和数据输入的一致性。
⑥ 允许兼用键盘和鼠标。
⑦ 使用软件最终用户的语言,而不是计算机专用术语。
⑧ 详尽的帮助信息。
⑨ 允许工作中断,例如:本次操作到一半,可以把中间状态暂存,下次启动软件可以继续完成。
⑩ 有清楚的错误提示,误操作后,软件提供有针对性地提示。
⑪提供快速反馈,给用户心理上的暗示,避免用户焦急。如时间较长的操作响应期间,可以出现进度条显示操作进度。
2.视觉设计
① 坚持图形用户界面(GUI)设计原则:界面直观、对用户透明:用户使用软件后对界面上对应的功能一目了然、不需要过多培训就可以使用软件。
② 界面一致性原则:在界面设计中应该保持界面的一致性。一致性既包括使用标准的控件,也指使用相同的信息表现方法,如在字体、标签风格、颜色、术语、显示错误信息等方面确保一致。
1.5 面向对象编程
1.5.1从设计到C++代码
为了把面向对象设计结果顺利地转变成面向对象程序,首先应该选择一种适当的程序设计语言。面向对象的程序设计语言适合用来实现面向对象设计结果。事实上,具有方便的开发环境和丰富的类库的面向对象程序设计语言,是实现面向对象设计的最佳选择。
良好的程序设计风格对于面向对象实现来说格外重要。它既包括传统的程序设计风格准则,也包括与面向对象方法的特点相适应的一些新准则。
在软件生命周期中,编码工作在整个项目中所占的比例最多不会超过1/2,通常在1/3的时间,所谓磨刀不误砍柴工,设计过程完成的好,编码效率就会极大提高。有人说,用C语言编写代码,代码量超过10 000行就会失控,用C++,这个限制可以放宽到100 000行。这说明面向对象语言要比非面向对象语言能够支持更大规模的软件。
我们知道,在一个小企业中,没有什么管理成本,管理人员也很少。但是一个大企业,一般有很多的管理人员,因为企业规模增大之后,需要付出管理成本。这些管理人员的主要职责,就是管理那些能够直接产生效益的工作人员。我们还知道,现代社会存在的基础是分工,分工明确,每个人做的事情少而精,这样整个社会就会有效率。而面向对象正体现了这两种思路。首先,一个面向对象的软件,其中会有很多看起来什么都不做的代码,只是简单地把对它的调用转给另一个对象的方法,有的只是做一些判断,或是类型处理。这些代码看起来有些浪费。但是它们就好像是企业的管理者一样,虽然消耗了一些资源(所幸的是,面向对象语言设计的高效性让这种资源消耗是可接受的),但是它们能够让其他产生效益的代码工作更加有效。其次,面向对象非常注重类的职责,每个类处理的事情集中而单一,每个方法的目标都十分专一。一件事情一般是几个类共同完成的,而不是像传统方法那样,一个函数一包到底。
1.5.2 编程举例
实验描绘:编写一个能模拟简单猜扑克牌大小游戏的应用程序。该游戏的名称为Hi-Low,其玩法和规则如下所述。
1.玩法
① 洗牌:每盘游戏开始之前,使扑克牌的排列顺序充分随机。
② 发牌:每局开始时,从未使用的扑克牌集合中顺序发给玩家5张扑克牌(明牌)。
③ 猜点:从未使用的扑克牌集合中按顺序取出一张扑克牌(即庄家的暗牌),要求玩家将手中的第一张扑克牌和这张庄家的暗牌进行比较,确定哪张牌大?
④ 积分:玩家确定回答后,翻开被猜的扑克牌(暗牌变成明牌),同时根据玩家回答的正确与否显示相应的提示,并为玩家的游戏成绩积分。然后将这两张已经比较过的牌回收到已经使用过的扑克牌集合中,玩家手中的下一张扑克牌成为新的一张扑克牌。
⑤ 对玩家手中剩余的扑克牌顺序重复第③和④步操作,直至玩家手中不再有剩余的扑克牌时,一局游戏结束。
⑤ 如果未使用的扑克牌集合中的扑克牌数目多余10张,则从第②步开始进行本盘游戏的下一局。
⑦ 询问玩家是否继续进行下一盘游戏,如果继续,则从第①步开始进行新的一盘游戏。
2.规则
① 积分规则:
● 猜中1次,积1分;猜错1次,不积分。
● 1局中连续猜中3次,除正常积分外奖励1分;连续猜错3次,罚1分。
● 1局全部猜中,除正常积分外奖励3分;1局全部猜错,罚3分。
● 玩家的最低积分为0,即不出现负分。
② 牌面大小比较规则:每张扑克牌的牌面由花色(梅花Club、方块Diamond、红心Heart和黑桃Spade)和牌点(A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K)组成。
确定两张扑克牌牌面大小的规则有两条:
● 如果两张牌面的牌点不同,则牌面大小仅与牌点有关,而与牌面的花色无关。牌点的大小顺序为:2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A。
● 如果两张牌面的牌点相同,则牌面大小仅与牌面的花色有关。花色的大小顺序为:Club < Diamond < Heart < Spade。
3.要求
① 按照面向对象的思想分析题意要求实现的需求,合理地分解对象类。
② 设计和实现各个组成类。
③ 在主函数main( )中,创建各个组成类对象,并通过这些类对象使用类功能实现游戏Hi-Low。
④ 要求编写编程文档,文档内容包括:
● 绘制各个组成类的类图与类图之间的静态关系图。
● 各个组成类的类定义描述。
● 主要功能函数的算法描述。
● main( )函数的流程图。
4.分析
分析各种扑克牌游戏,可以归纳出如下几条规律。
① 一副扑克牌,即扑克牌的全集是有54张具有不同牌面的扑克牌组成的。
② 任何一种扑克牌游戏都需要使用由n张扑克牌组成的集合,该集合可以是一副扑克牌的全集,也可以是一副扑克牌的子集,甚至可以是多副扑克牌的并集。例如Hi-Low游戏需要使用一副扑克牌中除大小王牌以外的52张牌组成的子集。
③ 任何一种扑克牌游戏都会根据自己的玩法和规则,确定一种若干步特定操作构成的游戏程式。
④ 每一种扑克牌游戏都会根据积分规则确定一个相对独立的积分计算器,依据游戏结果为玩家计算积分。
根据上述分析,可以考虑进行如下的对象类分解。
① 将扑克牌对象设计为一个类Card,用于定义标示每一张特定扑克牌的牌面(花色和牌点)和显示操作。为此该类可以用如下属性和操作描述。
属性
● 索引标志属性index作为每张扑克牌唯一标志,是确定牌面花色和牌点的依据。该属性应声明为私有,它的值域为0~53的整数值,每一个index值与一张特定牌面的花色和牌点相对应:
0~12对应梅花Club的A 2 3 4 5 6 7 8 9 10 J Q K
13~25对应方块Diamond的A 2 3 4 5 6 7 8 9 10 J Q K
26~38对应红心Heart的A 2 3 4 5 6 7 8 9 10 J Q K
39~51对应黑桃Spade的A 2 3 4 5 6 7 8 9 10 J Q K
52对应小王牌L Trump
53对应大王牌B Trump
显然,index除以13所得到的整数商可以表示花色,而index模13所得到的余数恰恰是牌点。大小王牌的index值除外。
● 牌面显示属性face。在图形用户界面的应用程序中该属性是描述牌面图形的复杂图形类;而在控制台文本界面的应用程序中该属性可以是字符数组类型, 用于存放描述牌面的字符串。例如:“C-A”表示梅花A, “D-6”表示方块6, “H-10”表示红心10,“S-Q”表示黑桃Q, “LT”表示小王牌,“BT”表示大王牌等。该属性应声明为私有。
操作
● 构造函数:无参数,便于创建对象数组。
● 初始化操作Init:依据传入参数值,为索引标志属性index赋值,并确定牌面显示属性face。该操作应向外提供服务。
● 获取标志操作Index:返回索引标志属性index的当前值。该操作应向外提供服务。
● 显示牌面操作Show:输出牌面显示属性face。该操作应向外提供服务。
② 考虑到结构的合理性,可以将计算游戏积分所需要属性和操作封装在一个类中。该类可以命名为Counter,它所包含的属性和操作如下。
属性
● 积分属性score:用于记录游戏的积分值。为了能记录足够大的积分值,该属性应声明为长整型long。
● 连续标志属性sequence:用于存放玩家当前猜点操作的连续状态:
连续猜中时,sequence > 0(连续猜中的次数)。
连续猜错时,sequence < 0(连续猜错的次数)。
即未连续猜中,也未连续猜错时,sequence = 0。
操作
● 构造函数:将积分属性score和连续标志属性sequence初始设置为0。
● 累计积分操作accumulate:根据玩家当前猜点操作的结果(布尔类型值,过实参传递该操作)修改玩家当前的连续猜中状态sequence,并根据猜点操作的结果和连续猜中状态,按积分规则玩家累计积分。该操作应向外提供服务。
● 显示积分操作Show:显示游戏积分值。该操作应向外提供服务。
● 清除连续标志操作ClearSequece:在游戏新的一盘开始前,设置Sequece为0。
③ 根据扑克牌游戏的玩法和规则,将扑克牌游戏设计成一个类。在类的定义中描述游戏所需要使用的扑克牌集合和所需要的辅助属性,以及满足游戏玩法和规则的各种操作。本题中的扑克牌游戏类可以直观地命名为HiLow,它应该包括如下属性和操作。
属性
● 扑克牌集合属性container:存放HiLow游戏所要使用的52张扑克牌(大小王牌除外)。该属性是一个Card类型的数组。
● 已用牌索引属性usedIndex:指示container中游戏已经使用过的扑克牌的索引。每盘游戏开始时,该属性应被初始化为0(container的第一个元素的下标值);以后每次从container获取一张牌(为玩家每发一张牌,取一张被猜的牌)后,属性值加1。该属性的值域为0 ≤ usedIndex≥ 51。
● 玩家牌索引属性playIndex:指示当前玩家手中正在进行猜点操作的牌在container中的索引。每局游戏开始时,该属性值 = usedIndex;以后每次猜点操作完成后,属性值加1。该属性的值域为0 ≤ playIndex ≥ 51。
● 积分计算器属性counter:该属性是一个Counter类对象,用于为游戏提供积分管理。
操作
● 构造函数:完成HiLow游戏对象的创建。在此创建操作中要完成各个属性必要的初始化,其中对container中每个元素的初始化是委托card的Init操作完成的。
● 洗牌操作Shuffle:该操作应向外提供服务,用于container中的元素(扑克牌)进行随机排列。模拟真实的洗牌操作可以通过52次将container中的两个随机下标索引的元素进行交换操作来实现。产生随机下标可以调用库函数rand来完成,52次交换操作就需要调用rand 104次。值得注意的是, 在第一次调用rand之前应该调用另一个库函数srand为随机数发生器播种,即初始随机值,否则每次洗牌所产生的104个随机值序列都是一样的。获得这个初始随机值的最好方法就是调用库函数time获取当前的时间值作为初始随机值。因此调用srand为随机数发生器播种的表达式可以写为
srand((unsigned)time(NULL));
库函数rand和srand的原型声明在系统头文件stdlib.h中;库函数time的原型声明在系统头文件time.h中。另外,库函数rand所产生随机数的值域范围是0至整型数最大值,可以通过对函数的返回值模52运算,获得值域范围为0~51的随机下标值。调用洗牌操作意味着新的一盘游戏的开始,因此,在上述随机排列操作完成后,需要将usedIndex和playIndex设置为0,还需要委托积分计算器属性counter清除积分连续标志(sequence = 0)。
● 交换操作Swap:用于container中两个指定元素的位置交换操作,这是洗牌需要频繁使用的操作。该操作只为HiLow对象提供私有服务,不必向外提供服务。索引两个进行交换操作的元素的下标是作为实参传递给该操作的。
● 发牌操作Deal:该操作应向外提供服务,用于每次为玩家发5张扑克牌。完成这一操作只需调整usedIndex和playIndex的值,而无须发生扑克牌的获取操作。
● 显示玩家手中牌的操作PlaycardShow:该操作应向外提供服务,用于显示当前玩家手中所有可以用猜牌操作的扑克牌的牌面信息。这些牌的索引范围是playIndex至playIndex+4。
● 比较操作Compare:该操作应向外提供服务,用于一次比较玩家明牌和庄家暗牌大小的操作,并返回比较结果(布尔)标志(玩家牌大为ture,否则为false)。被猜的两张牌的索引可以通过playIndex和usedIndex获得(注意访问后,需要对这两个属性值增1,以便索引下一次进行比较的两张牌)。
● 显示猜牌结果操作ResultShow:该操作应向外提供服务,用于在比较操作Compare被调用后,显示用于比较大小的玩家牌和庄家牌的牌面信息,并根据比较结果(作为实参传递给操作)显示相应的提示信息。注意,被显示的两张牌的索引是playIndex-1至usedIndex-1。
● 积分操作accumulate:该操作应向外提供服务,该操作是根据玩家的本次猜牌结果(布尔)标志(猜中为true,否则为false),委托积分计算器counter,完成玩家的游戏积分计算和管理。玩家的本次猜牌结果应通过布尔类型实参传递给操作的。
● 判断一局结束操作IsGameOver:该操作应向外提供服务,用于提供游戏的局结束(布尔)标志(结束为ture,未结束为false)。如何判断一局是否结束,可以通过分析playIndex值的变化规律发现。注意,如果一局结束,还需要委托积分计算器counter清除积分连续标志(sequence = 0)。
● 判断一盘结束操作IsSetOver:该操作应向外提供服务,用于提供游戏的盘结束(布尔)标志(结束为ture,未结束为false)。如何判断一盘是否结束,可以通过分析usedIndex值的变化规律发现。
● 显示游戏积分ScoreShow:该操作应向外提供服务,用于显示玩家的当前游戏积分。此操作是委托积分计算器属性counter提供的。
在主函数main( )中创建HiLow类游戏对象,使用该对象提供各项操作功能,实现游戏的玩法所要求的操作和控制。注意,在游戏的各步操作中应提供恰当的提示信息,以便提供友好的操作界面。
5.参考实现
(1)各个组成类的类图
① Card类
② Counter类
③ Menu类
④ HiLow类
(2)类之间的静态关系图
(3)各个组成类的类定义描述
① Card类
class Card { private: int index; // 索引标志属性,作为每张扑克牌唯一标志,是确定 // 牌面花色和牌点的依据。值域为0~53的整数值。 char* face; // 用于存放描述牌面的字符串。每张扑克牌的牌面由 // 花色(梅花Club、方块Diamond、红心Heart和 // 黑桃Spade)和牌点(A, 2, 3, 4, 5, 6, 7, 8, 9, // 10, J, Q, K)组成。 public: Card( ); // 构造函数。 ~Card( ); // 析构函数。 void Init(int inIndex); // 依据传入参数值,为索引标志属性index赋值,并确 // 定牌面显示属性face。 int Index( ) const; // 返回索引标志属性index的当前值。 void Show( ) const; // 输出牌面显示属性face。 };
② Counter类
class Counter { private: long score; // 用于记录游戏的积分值。 int sequence; // 用于存放玩家当前猜点操作的连续状态: // 连续猜中时,sequence > 0(连续猜中的次数); // 连续猜错时,sequence < 0(连续猜错的次数);
// 即未连续猜中,也未连续猜错时,sequence = 0。 public: Counter( ); // 构造函数。将积分属性score和连续标志属性 // sequence初始设置为0。 ~Counter( ); // 析构函数。 void Accumulate(bool result); // 根据玩家当前猜点操作的结果(布尔类型值,通过实参 // 传递该操作)修改玩家当前的连续猜中状态 // sequence,并根据猜点操作的结果和连续猜中状态, // 按积分规则玩家累计积分。 void Show( ) const; // 显示游戏积分值。 void ClearSequence( ); // 在游戏新的一盘开始前,设置Sequece为0。 };
③ Menu类
class Menu { public: Menu( ); // 构造函数。 ~Menu( ); // 析构函数。显示退出信息。 void PrintMainMenu( ) const // PrintMainMenu( )函数,显示主菜单。 void ShowChose( ) const // ShowChose( )函数,显示选择信息。 void ShowError( ) const // ShowError( )函数,显示错误信息。 void ShowGameOver( ) const // ShowGameOver( )函数,显示一局结束信息。 void ShowSetOver( ) const // ShowSetOver( )函数,显示一盘结束信息。 };
④ HiLow类
class HiLow { private: Card container[52]; // 存放HiLow游戏所要使用的52张扑克牌(大小王牌 //除外)。 int usedIndex; // 指示container中游戏已经使用过的扑克牌的索引。 // 每盘游戏开始时,该属性应被初始化为0(container // 的第一个元素的下标值);以后每次从container获 // 取一张牌(为玩家发牌,取一张被猜的牌)后,属性值 // 加1。该属性的值域为0≤usedIndex≥51。 int playIndex; // 指示当前玩家手中正在进行猜点操作的牌在container //中的索引。每局游戏开始时,该属性值 =usedIndex; // 以后每次猜点操作完成后,属性值加1。 // 该属性的值域为0≤playIndex≥51。 Counter counter; // 该属性是一个Counter对象,用于为游戏提供积分管理。 void Swap(int src, int des); // 用于container中两个指定元素的位置交换操作,这 // 是洗牌需要频繁使用的操作。 public: HiLow( ); // 完成HiLow游戏对象的创建。在此创建操作中要完成 // 各个属性必要的初始化,其中对container中每个元 // 素的初始化是委托card的Init操作完成的。 ~HiLow( ); // 析构函数。
void Shuffle( ); // 用于container中的元素(扑克牌)进行随机排列。 void Deal( ); // 用于每次为玩家发5张扑克牌。 void PlaycardShow( ) const; // 用于显示当前玩家手中所有可以用猜牌操作的扑克牌 // 的牌面信息。 bool Compare( ); // 用于一次比较玩家明牌和庄家暗牌大小的操作,并返回 // 比较结果(布尔)标志(玩家牌大为ture,否则为false)。 void ResultShow (bool result) const; // 用于在比较操作 Compare 被调用后,显示用于比较 // 大小的玩家牌和庄家牌的牌面信息,并根据比较结果 // (作为实参传递给操作)显示相应的提示信息。 void Accumulate (bool result); // 用于在比较操作 Compare 被调用后,显示用于比较 // 大小的玩家牌和庄家牌的牌面信息,并根据比较结果 // (作为实参传递给操作)显示相应的提示信息。 bool IsGameOver( ); // 用于提供游戏的局结束(布尔)标志(结束为ture, // 未结束为false)。 bool IsSetOver( ); // 用于提供游戏的盘结束(布尔)标志(结束为 ture, // 未结束为false)。 void ScoreShow( ) const; // 用于显示玩家的当前游戏积分。此操作是委托积分计算 // 器属性counter提供的。 };
(4)主要功能函数的算法描述
① Card类Init( )函数
void Init(int inIndex) { index ← inIndex switch(inIndex / 13) { case 1: face[0] ← ' C' , break; case 2: face[0] ← ' D' , break; case 3: face[0] ← ' H' , break; case 4: face[0] ← ' S' , break; } face[1]←' -' switch(inIndex % 13) { case 1: face[2] ← ' A' , break case 2: face[2] ← '2' , break case 3: face[2] ← '3' , break; case 4: face[2] ← '4' , break; case 5: face[2] ← '5' , break; case 6: face[2] ← '6' , break; case 7: face[2] ← '7' , break; case 8: face[2] ← '8' , break; case 9: face[2] ← '9' , break; case 10: face[2] ← '10' , break; case 11: face[2] ← ' J' , break; case 12: face[2] ← ' Q' , break; case 13: face[2] ← ' K' , break; } }
② Counter类Accumulate( )函数
void Accumulate(bool result) { if (result == true) { if (sequence < 0) sequence = 0; ++sequence; if (sequence < 3) ++score; else if (sequence >= 3 && sequence < 5) score += 2; else if (sequence == 5) score += 4; } else { if (sequence > 0) sequence = 0; --sequence; if (sequence > -3) score = score > 0 ? --score : 0; else if (sequence <= -3 && sequence > -5) score = score > 2 ? score -= 2 : 0; else if (sequence == -5) score = score > 4 ? score -= 4 : 0; } }
③ HiLow类Shuffle( )函数
void Shuffle( ) { srand((unsigned)time(NULL)); indexSrc = rand( ) % 52; indexDes = rand( ) % 52; Swap(indexSrc, indexDes); usedIndex = 0; playIndex = 0; counter.ClearSequence( ); }
(5)main( )函数的流程图
① main( )函数的流程图
② 循环中的流程图
③ 执行游戏的流程图
1.6 面向对象测试
在软件开发活动中,为保证软件的可靠性,人们研究并使用多种方法进行分析、设计及编码实现。由于软件本身是无形态、复杂的、知识高度密集的产品,不可避免地产生错误,因此软件开发总伴随着软件质量保证的活动,而软件测试是主要活动之一。软件测试代表了需求分析、设计和编码的最终复审。
1.6.1 白盒测试技术
把程序看成装在一个透明的白盒子里,也就是测试人员完全了解程序的结构和处理过程,对程序执行的逻辑路径进行测试。通过在不同的关键点检查程序的状态,确定实际状态是否和预期状态一致。因此,白盒测试又称为结构测试、逻辑测试。
白盒测试作为结构测试方法,是按照程序内部的结构测试程序,检查程序中的每条通路是否能够按照预定要求工作,因此其最主要的技术是逻辑覆盖技术。逻辑覆盖包括:语句覆盖、判定覆盖、条件覆盖、判定/条件覆盖、条件组合覆盖和路径覆盖等。
① 语句覆盖就是设计若干个测试用例,运行所测程序,使得每一个可执行的语句至少执行一次。
② 判定覆盖又称分支覆盖就是设计若干个测试用例,不仅使每个语句至少执行一次,而且程序中的每个取真分支和取假都至少执行一次。
③ 条件覆盖就是设计若干个测试用例,使判定表达式的每个条件的所有可能的取值都至少执行一次。
④ 判定/条件覆盖要求选取足够多的测试数据使每个判定表达式都取得各种可能的结果,从而测试比较复杂的路径。
⑤ 条件组合覆盖构造一组测试实例,保证使判断语句中的各逻辑条件取值的可能组合至少执行一次。
⑥ 路径覆盖是设计足够的测试用例,使得程序中所有可能路径都至少被执行一次。
1.6.2 黑盒测试技术
把程序看成一个黑盒子,完全不考虑程序的内部结构和处理过程。黑盒测试是在程序接口进行的测试,它只检查程序功能是否能按照规格说明书的规定正常使用,程序是否能适当地接收输入数据产生正确的输出信息,并且保持外部信息的完整性。黑盒测试又叫功能测试或输入/输出驱动测试。
通过黑盒测试主要发现以下错误:
● 是否有不正确或遗漏的功能。
● 在接口上,能否正确地接受输入数据,能否产生正确的输出信息。
● 访问外部信息是否正确。
● 性能上是否能满足要求。
常见的黑盒测试方法有等价类划分、边界值分析、错误猜测、基于故障的测试、因果图法等。
(1)等价类划分法
等价类划分是一种典型的黑盒测试方法,使用这一方法时,完全不考虑程序的内部结构,只依据程序的规格说明来设计测试用例。使用这一方法设计测试用例要经历划分等价类(列出等价类表)和选取测试用例两步。
所谓等价分类,就是把输入数据的可能值划分为若干等价类(等价类是指某个输入域的子集合)。在该集合中,各个输入数据对于揭露程序中的错误都是等价的)。因此,可以把全部输入数据合理地划分为若干等价类,在每一个等价类中取一个数据作为测试的输入条件,这样就可以少量的代表性测试数据,来取得较好的测试结果。
(2)边界值分析法
长期经验表明:大量的错误是发生在输入或输出范围的边界上,而不是在输入范围的内部。边界是指相当于输入等价类和输出等价类而言,稍高于其边界值及稍低于其边界值的一些特定情况。使用边界值分析方法设计测试用例,应对确定的边界,选取正好等于,刚刚大于,或刚刚小于边界的值作为测试数据,而不是选取等价类中的典型值或任意值作为测试数据。
(3)错误猜测
猜测被测程序中哪些地方容易出错,并据此设计测试实例。错误猜测法依赖于测试人员的直觉和经验。错误猜测的基本思想是某处发现了缺陷,则可能会隐藏更多的缺陷,在实际操作中,列出程序中所有可能的错误和容易发生的特殊情况,然后依据经验做出选择。
(4)基于故障的测试
基于故障的测试是证明某个规定的故障不存在于代码中。
(5)因果图法
如果在测试时必须考虑输入条件的各种组合,可使用一种适合于描述对于多种条件的组合,相应产生多个动作的形式来设计测试用例,这就需要利用因果图。因果图方法最终生成的就是判定表。它适合于检查程序输入条件的各种组合情况。
1.6.3 测试用例的编写
测试用例要根据测试大纲来写,而测试大纲要根据测试计划来编写。测试计划起到框架作用,测试的计划应该作为测试的起始步骤和重要环节。一个测试计划应包括:产品基本情况调研、测试需求说明、测试策略和记录、测试资源配置、计划表、问题跟踪报告、测试计划的评审、结果,等等。
测试大纲更多的是把握住测试项的方向,测试大纲应包括测试目的、测试环境(软件环境,硬件环境)、测试方法及测试项目。下面是一个软件测试大纲的模板。
X X系统软件V1.0
软件测试大纲
1.测试目的:通过测试验证该系统已经达到设计指标。
2.测试环境(对于C/S、 B/S结构的软件请分别说明客户端和服务器端的软、硬件环境)
● 硬件环境
● 软件环境
3.测试方法:使用以用户文档为基础构造的测试用例来测试程序和数据。
4.测试项目:
a)系统安装与卸载(对于说明书中注明由开发方提供系统安装和配置服务的软件,该部分可免测,请在测试方法中注明)
b)软件功能测试(根据软件说明书中提及的功能模块填写本部分,下表中内容仅为示范,行数可自由增删,如需提供测试用例,请附在文档后或另附文件)
c)安全可靠性(对于无安全保密性要求的软件,本项可免测,请在栏目中注明)
d)用户界面
e)用户文档
测试用例是指导怎么去执行测试。测试用例的编写要注意以下几个问题:
● 测试用例要根据测试大纲来编写。
● 测试用例也要分测试项进行归类,如业务流程测试、安装测试、功能测试、用户友好性测试、兼容性测试、性能测试、安全性测试,等等。
● 编写测试用例要考虑各种情况,精力主要集中在软件的主要业务流程和风险高的地方。最好能分出测试优先级别。
下面是一个软件测试用例的模板。