今天我们就来介绍一个系统性的方法去设计一个C++程序,一个简单的国际象棋程序。为了提供完整的案例,有些步骤的概念目前还没有讲到。现在学习该案例来获得一一些人设计过程的整体印象,当你学习了那些概念后也可以再回头重新阅读本篇。
1、需求
在开始程序设计之前,一定要对程序的功能与性能有一个清晰的需求。理想情况下,要有需求规格文档。国际象棋程序的需求应该包含以下几类规格,可能要更详细,类目更多:
- 程序要支持国际象棋标准规则。
- 程序要支持两个人玩。程序不提供人工智能计算机玩家。
- 程序要支持字符界面:
- 程序要给出字符界面的棋盘和棋子。
- 玩家通过输入代表棋盘上的位置的数字来进行移动。
该需求确保按照用户期待的执行逻辑进行设计程序。
2、设计步骤
要用系统性的方法来设计程序,从总体到细节。以下步骤并不适用于所有程序,但它提供了一个通用的指导。设计应该包括合适的图表。UML是做此类图表的工业标准。简单来讲,UML定义了标准图表的多维,可以用于将软件设计文档化,例如,类图,时序图等等。推荐使用UML或者至少在合适的地方使用类UML图表。然而,并不推荐完全按照UML的语法来进行,因为我们的目的是拥有一个清晰的、可以理解的图表,而这要比语法正确更重要。
3、将程序分解到子系统中
第一步就是将程序分解为通用的功能子系统,给出子系统之间的接口与交互。做这一步时,还不需要过多考虑数据结构与算法,甚至类。只是要获得程序各个部分以及交互的通用感觉。可以使用表格列出子系统,表达子系统的高阶行为与功能,子系统与其他子系统之间爆露的接口,其他子系统消费使用的接口。
国际象棋推荐的设计是在保存数据与显示数据之间通过使用MVC模式拥有一个清晰的界限。该模式将常用的应用数据用符号建模,对于数据的一个或多个视图,以及对数据的操作。在MVC中,数据被叫做模型,视图是对模型的可视化,控制器就是以事件的方式改变模型的代码。MVC的三个部分交互反馈循环如下:控制器处理动作,动作调整模型,改变视图。控制器也可以直接修改视图,例如UI的元素。如下图所示,使用该模式,不同部分被清晰地划分,允许在不影响其他部分的情况下进行修改。例如,在不影响数据模型或逻辑的情况下,你可以在使用字符界面和图形用户界面之间进行切换,或者是在运行在桌面电脑与手机的用户界面之间进行切换。
下面的表格表示了国际象棋游戏可能的子系统的划分:
|------|-----|----------------------------|-----------|--------------------------|
| 子系统名 | 实例数 | 功能 | 对外暴露接口 | 消费接口 |
| 玩游戏 | 1 | 开始游戏 控制游戏流 控制落子 声名获胜者 结束游戏 | 游戏结束 | 下棋顺序(玩家) 落子(棋盘视图) |
| 棋盘 | 1 | 保存棋子 检测平局与将死 | 获得棋子 赋值棋子 | 游戏结束(玩游戏) |
| 棋盘视图 | 1 | 画出关联的棋盘 | 画 | 画(棋子视图) |
| 棋子 | 32 | 移动 检测合法移动 | 移动 检测移动 | 获得棋子(棋盘) 赋值棋子(棋盘) |
| 棋子视图 | 32 | 画出关联的棋子 | 画 | 无 |
| 玩家 | 2 | 通过提示玩家下棋与玩家交互,获得玩家的下棋动作 | 下棋 | 获得棋子(棋盘) 下棋(棋子) 检测下棋(棋子) |
| 错误记录 | 1 | 将错误信息记录到文件中 | 记录错误 | 无 |
如表所示,国际象棋功能子系统包括:玩游戏子系统,棋盘与棋盘子系统,32个柜子与棋子视图,两个玩家,以及一个错误记录。当然了,这并不是唯一可行的方法。在软件设计中,程序设计本身,有许多不同的方法完成同样的目的,条条大路通罗马。各个解决方案也不相同;有些就比其他的好,但是,现实是有许多一样的有效的解决方案。
好的对子系统的划分是将程序分成基本的功能部分。例如,玩家是区别于棋盘、棋子、或者玩游戏的子系统。将玩家放到玩游戏子系统中是没有道理的,因为它们是逻辑上分离的子系统。其他的选择可能并不是这么明显。
在MVC设计中,棋盘与棋子子系统是模型部分。棋盘视图与棋子视图是视图部分,玩家是控制器部分。
由于表格对子系统之间的关系很难可视化,在图中用线条来显示一个子系统调用另一个子系统来展示程序子系统通常是很有帮助的。下图基于UML的通信图可视化显示了国际象棋的子系统。
4、选择线程模型
在设计阶段考虑怎么写算法中的多线程还是太早了。然而,在这一步,在程序中选择高阶线程的数量、给出交互。例如像UI线程、声音播放线程、网络通信线程等。
在多线程设计中,要尽可能避免数据共享,因为这样的话设计就会更简单、更安全。如果不能避免数据共享的话,就要给出锁需求。
如果你对多线程不熟悉或者你的平台不支持多线程,那就只能将程序设计成单线程的了。然而,如果你的程序有多个不同的任务,每个任务平行运行,那么多线程就是一个不错的选择。例如,图形用户界面应用经常使用一个线程执行主应用程序,另一个线程等待用户按键或者选择菜单执行。
国际象棋程序只需要一个线程来控制游戏流。
5、给出子系统的类层次结构
在这一步,要决定程序中的类层次结构了。国际象棋程序可以用一个类层次结构来表示棋子。如下图所示,ChessPiece类是抽象类,作为类的基础。ChessPieceView类也有类似的层次结构。
另一个用于ChessBoardView类的类层次结构,使其可以用字符界面或者图形用户界面。下图显示了允许棋盘以字符控制台、2D或者3D的图形用户界面展示的层次结构示例。对于ChessPieceView层次结构类似。
6、给出每个子系统的类、数据结构、算法与模式
在这一步,要考虑更高层次的细节,给出每个子系统的特定信息,包括要为每个子系统写的特定的类。可能会将每个子系统模型化为一个类。以下表格总结了相关信息。
|------|---------------------------------------------------------------------------------------------|------------------------------|--------------------------|-----------|
| 子系统 | 类 | 数据结构 | 算法 | 模式 |
| 玩游戏 | GamePlay class | 玩游戏对象包含一个棋盘对象和两个玩家对象 | 每个玩家顺序下棋 | 无 |
| 棋盘 | ChessBoard class | 棋盘对象存储了一个二维8*8的网格,包含多达32个棋子 | 每走一步检测是否得胜或平局 | 无 |
| 棋盘视图 | ChessBoardView抽象基础类 实体继承类 ChessBoardViewConsole, ChessBoardViewGUI2D, 等等 | 保存如何画棋盘的信息 | 画棋盘 | 观察者 |
| 棋子 | ChessPiece 抽象基础类 Rook, Bishop, Knight, King, Pawn, 以及 Queen 继承类 | 每个棋子保存其在棋盘上的位置 | 通过查询棋盘上棋子的多个位置来检测是否为合法移动 | 无 |
| 棋子视图 | ChessPieceView 抽象基础类 继承类 RookView, BishopView, 等等, 以及实体继承类 RookViewConsole, RookViewGUI2D等等 | 保存如何画一个棋子的信息 | 画一个棋子 | 观察者 |
| 玩家 | Player 抽象基础类 实体继承类 PlayerConsole, PlayerGUI2D等等 | 无 | 提示玩家下棋,检测是否为合法移动,然后移动棋子 | 中间者 |
| 错误记录 | ErrorLogger class | 要记录的信息队列 | 缓存信息并且将其写入Log文件 | 使用依赖注入的策略 |
上面的表格给出了软件设计中不同类的一些信息,但是没有描述它们之间的交互。UML时序图可用于模型化相关交互。下图是对上表的一些类的交互的可视化。
上图所示仅是一个迭代,一个从GamePlay到Player的单个TakeTurn调用;因此,只是部分时序图。当TakeTurn调用结束时,GamePlay对象应该让ChessBoardView去画出自身,然后按次序让不同的ChessPieceView去画自身。还有,应该扩展时序图可视化一个棋子怎么吃掉对方的棋子,还要包含对于王车易位的支持,王车易位是指一个涉及到一个玩家的国王和任何一个玩家的车的移动。王车易位是唯一的一个玩家同时移动两个棋子的动作。
这部分的设计文档一般都会提供每个类的真实接口,例子中省略了这个层次的细节。
设计类与选择数据结构、算法、以及模式可能会比较麻烦。要时刻记住前面讨论过的抽象与重用的原则。对于抽象,关键的是将接口与实现分开。首先,从用户的角度给出接口。决定将部件去做什么。然后通过选择数据结构与算法决定让部件怎么做。对于重用,要对标准数据结构、算法以及模式做到烂熟于心,确保牢记C++中的标准库与可用的其他代码。
7、给出每个子系统的错误处理
在这一步骤中,要勾画出每个子系统的错误处理。错误处理应该包含像网络访问错误这样的系统错误,以及像无效输入这样的用户错误。给出是否每个子系统都要使用异常。也可以将其列在表中。
|-------------------------------|-----------------------------------------------|--------------------------------------------------------------------|
| 子系统 | 处理系统错误 | 处理用户错误 |
| 玩游戏 | 使用ErrorLogger记录错误,向用户显示信息,当发生不可预知的错误时优雅地将程序关闭 | 不适用(没有直接的用户界面) |
| ChessBoard ChessPiece | 使用ErrorLogger记录错误,当发生不可预知的错误时抛出异常 | 不适用(没有直接的用户界面) |
| ChessBoardView ChessPieceView | 使用ErrorLogger记录错误,画棋盘或棋子出错时抛出异常 | 不适用(没有直接的用户界面) |
| 玩家 | 使用ErrorLogger记录错误,当发生不可预知的错误时抛出异常 | 对用户的移动进行安全检查,确保落子在棋盘内;然后提示玩家下一步。子系统在移动棋子前检查每一步移动的合法性;如果不合法,提示用户重下。 |
| ErrorLogger | 尝试记录一个错误;当未知错误发生时提示用户。 | 不适用(没有直接的用户界面) |
错误处理的通用规则是处理所有错误。要考虑所有的错误情况。如果忘记了某种可能性,就会出现一个bug!不要把所有的错误都按"不可预知"的错误来处理。预知所有的可能性:内存申请失败,无效用户输入,磁盘失败,网络失败等等。然后就像国际象棋游戏表中显示的那样,对于不同的内部错误处理好用户错误。例如,如果用户移动无效,不应该使程序终止。