软件构造(06):棋类游戏的设计:一些启发式方法


Lab2的P3要求学生从0开始设计一套ADT,给同学们带来了设计上的困扰和大量的代码重构。这篇博客将介绍一些设计一个小型项目架构的启发式方法,作为今后实践中指导模块设计的一些可行的思考方向。

#1 找出现实世界中的对象

既然要设计一个棋类游戏,那么最直观的想法就是考虑现实世界中的棋类游戏是怎样的,这也是面向对象程序设计最基本的想法。具体的做法如下:

  • 辨识对象及其属性(数据和方法)
    辨识对象这一点在本次实验中不需要我们做,因为老师已经很好心地给出了需要我们设计哪些ADT。但假如老师没有给出这些ADT,我们可能最开始不会想到把Position和Action抽象出来作为一个类,更可能的是把Position和Action作为某些对象数据或方法的一部分。从现实世界来看,一局棋类游戏中只要有两个玩家、一个棋盘、一些棋子就能进行,因此在这一步骤中我们可以辨识出的对象就是玩家、棋盘和棋子。
  • 确定可以对各个对象进行的操作
    对于玩家来说,对其可能不会有什么操作,因为玩家在现实世界中是下棋的主体(当然有的同学可能会在控制玩家轮流行动的时候改变玩家的某些属性);对于棋盘来说,可能的操作就是对其上的棋子进行操作;对于对于棋子来说,可能的操作就是落子、吃子、提子等。
  • 确定各个对象能对其他对象进行的操作
    对于玩家来说,可能进行的操作是操作棋子,或者更高抽象层次上,调用棋盘上的操作,让棋盘来操作棋子;对于棋盘来说,如果棋子由它管理,它可能进行的操作就是移动上面的棋子;对于棋子来说,它可能不会操作任何其它对象。
  • 确定对象的哪些部分对其他对象可见
    这一部分在课堂上讲得很多了,这里就略过。
  • 定义每个对象的公开接口
    在编程语言的层次上为每个对象定义具有正式语法的接口。对象对其他对象暴露的数据及方法都被称为该对象的“公开接口”,而对象通过继承关系向其派生对象暴露的部分则被称为“受保护的接口”。要考虑这两种不同的接口。

经过上面的步骤,我们可以得到一个初步的面向对象的系统组织结构,进而可以使用两种方式进行迭代:

  • 在高层次的系统组织结构上进行迭代,以便更好地组织类的结构,如把上面得到的棋盘和玩家整合到Game类中,从更高层次上进行抽象并组织系统结构。
  • 在每一个已经定义好的类上进行迭代,把每个类的设计细化,如把棋子的位置单独抽取出来做成一个Posotion类等,把类的功能交给更小的组件来处理。

#2 形成一致的抽象

抽象是一种能我们你在关注某一概念的同时可以放心地忽略其中一些细节的能力——在不同的层次处理不同的细节。任何时候当我们对一个聚合对象工作时,我们就是在使用抽象了。

举个例子,现实生活中并没有“棋类游戏”这个东西,真实存在的东西只是两个玩家、一个棋盘、一些棋子和一系列下棋的动作,由这些真实存在的基类构成的东西,我们就把它成为“棋类游戏”,这实际上就是一种抽象。

在Code Complete一书中有这样一段话:

基类也是一种抽象,它使我们能集中精力关注一组派生类所具有的共同特性,并在基类的层次上忽略各个具体派生类的细节。一个好的接口也是一种抽象,它能让我们关注于接口本身而不是类的内部工作方式。一个设计良好的子程序接口也在较低的层次上提供了同样的好处,而设计良好的包和子系统的接口则在更高的层次上提供了同样的好处。

抽象的主要好处就在于它使我们能忽略无关的细节。大多数现实世界中的物体都已经是某种抽象了。正如上面所提到的,棋类游戏是两个玩家、一个棋盘、一些棋子和一系列下棋的动作以特定的组织方式所形成的抽象。这样,我们就能在子程序接口的层次上、在类接口的层次上以及包接口的层次上进一步做更高层次的抽象,如做出客户端MyChessAndGoGame类来调用Game类,而不是让客户端直接去调用棋盘、棋子等基本元素,从而更快、更稳妥地进行开发。

为了我们能够充分发挥抽象的这种好处,我们就需要形成一致的抽象,即在一个抽象层次中不能混杂其他层次的抽象,例如在Game类中包含对Board直接进行操作的方法就是不好的,对Board的操作应交给较低级的抽象类型来完成,如让Action来直接调用Baord上的方法进行棋盘操作;而Game只应该考虑“游戏”这个层次上的抽象,即如何根据用户的输入设计相关的方法来调用低一级的抽象实现用户需要的操作。“鞠躬尽瘁,死而后已”所描述的就是没有形成一致的抽象,原本是高层次的抽象,却盲目地关注低层次的细节,从而造成设计上的低内聚性,降低软件的质量,增大了开发难度。

#3 保持松散的耦合

耦合度表示类与类之间关系的紧密程度。耦合度设计的目标是创建出小的、直接的、清晰的类,使它们与其他类之间关系尽可能地灵活,这就被称作“松散耦合”。模块之间的好的耦合关系会松散到恰好能使一个模块能够很容易地被其他模块使用,即不妨碍模块之间正常业务逻辑的调用。下面是一些可能有用的方法:

增大模块之间连接的可见性

可见性可见性指的是两个模块之间的连接的显著程度。通过参数表传递数据便是一种明显的连接,这样可以最大限度保证模块的可复用性。例如,在笔者的实现中,ChessGame与Action没有对象之间的组合关系

public class ChessGame implements Game{
	
	private final Player player1, player2;
	private final Board board = new Board(8);
	private final List<String> history = new ArrayList<>();
	//other codes
}

ChessGame中的包装方法通过向Action中的静态方法传入参数来调用这些方法,就可以保证ChessGame与Action之间是松耦合的。

public boolean move(Player player, Position sourcePosition, Position targetPosition) {
		//the above codes are omitted here
		Action.moveAction(board, sourcePosition, targetPosition, movingPiece);
		checkRep();	
		return true;
	}

与此相对的,通过将某个模块的域直接传给另一个模块从而使另一模块能够使用该数据则是一种隐式的连接,这种做法就比较糟糕。
例如,有的同学可能会想:Action中每个静态方法都要传入同一个Board参数,岂不是很麻烦?干脆把board作为Action的一个域吧!于是把ChessGame改成了下面这样:

public class ChessGame {

	private final Player player1, player2;
	private final Board board = new Board(8);
	private final List<String> history = new ArrayList<>();
	private final Action action = new Action(board);
}

相应的,Action不再是一个工具类了,变成了这样:

public class Action {
	
	private final Board board;
	public void Action(Board board) {
	this.board = board;
	}
	//other codes
}

这样ChessGame控制棋盘再也不用向Action传入board参数了,因为两个类已经偷偷地串通好了它们的操作是在哪一个baord上进行的,但是这种做法却违背了我们上面提到的原则。

这种做法从安全角度考虑,如果能保证Action的方法不会被除ChessGame外的其他类调用,其实也不会损伤安全性。但这样做实质上是把ChessGame和Action给拴在一起了,如果要对ChessGame进行改变,如增加某个可能修改board域的方法,就可能会对Action的正常工作造成影响。

更何况,假如Action是作为一个独立的模块来开发的,而不是和ChessGame一起封装在一个包里供外部调用,此时安全性也是必须要考虑的因素,这么做就不可避免地会产生表示泄漏,这里大家都看得出来,不用笔者多说。

因此增大模块之间连接的可见性,不仅是出于可复用性的考虑,也是出于安全性方面的考虑。

避免模块之间的语义耦合

Code Complete中指出:

语义耦合指一个模块不仅使用了另一模块的语法元素,而且还使用了有关那个模块内部工作细节的语义知识。语义上的耦合是非常危险的,因为更改被调用的模块中的代码可能会破坏调用它的模块,破坏的方式是编译器完全无法检查的。类似这样的代码崩溃时,其方式是非常微妙的,看起来与被使用的模块中的代码更改毫无关系,因此会使得调试工作变得无比困难。

这种方法在本次实验中没有用到,仅作为一个参考。

#4 保持高内聚性

内聚性指的是类内部的方法或一个方法中的全部代码在支持一个中心目标上的紧密程度,即这个类的目标是否集中。包含一组密切相关的功能的类被称为有着高内聚性,而这种启发式方法的目标就是使内聚性尽可能地高。保持高内聚性可以降低开发者的工作复杂程度。

例如,在类的层次上,我们用Action这个ADT来表示棋类游戏中的下棋动作,认为它与用户的输入没有直接接触,仅仅是是调用棋盘上的一组方法来操作棋子而已。即它是一个较低级的抽象,类似于工厂的工人。
而在更高层的抽象,如ChessGame上,则要先进行对用户异常输入的处理,确认输入正确后才调用Action中的方法进行操作,类似于工厂的决策者。
保持这两个类的高内聚性,使二者的工作不会相互重叠,也符合上面提到的抽象一致性。

#5 构造分层结构

分层结构指的是一种分层的信息结构,其中最通用的或者最抽象的概念表示(如本实验中的客户端MyChessAndGoGame)位于层次关系的最上面,而越来越详细的具有特定意义的概念(如Game以及更基本的Player等)表示放在更低的层次中。

使用分层结构有助于我们自顶向下地进行总体的软件设计;同时当自底向上实现的时候,我们只需要当前正在关注的那一层细节。其他的细节并没有完全消失,它们只是被放到了另一层次上,这样我们就可以在需要的时候去考虑它们,而不是在所有的时间都要考虑所有的细节。

#6 分配职责

另一个启发式方法是去想诙怎样为对象分配职责。问每一个对象该对什么负责,类似于问这个对象应该执行什么样的功能,以及应该隐藏些什么信息。

例如,在本实验中,我们需要选择让Player来管理棋子还是让Board来管理棋子。
如果让Player来管理棋子,Player就需要维护一个Piece的集合,同时有责任保证对这个集合的引用不会泄露。在这种情况下,Piece就需要维护自己的位置信息,在移动棋子的时候对位置信息进行修改。此时Baord的数据不会被改变,可能就会成为一个不变类。
如果让Baord来管理棋子,Baord就需要维护一个Piece的二维数组,同样的Baord也有责任保证对这个数组的引用不会泄露。在这种情况下,Piece就不需要维护自己的位置信息,因为二维数组在表示位置上有天然的优势。此时Player的数据不会被改变,可能就会成为一个不变类。

两种方法在笔者看来并无优劣之分,但在设计时需要根据不同的职责分配谨慎考虑各个类应有的数据和方法,以及它们在维护软件质量上应当承担什么样的责任。

#7 创建中央控制点

在Code Complete中提到了这样的方法:

P.J. Plauger表示,他最关心的就是“唯一一个正确位置的原则(the Principle of One Right Place) -对于每一段有作用的代码,应该只有唯一的一个地方可以看到它,并且也只能在一个正确的位置去做可能的维护性修改”。
之所以这么做有助于降低复杂度,其原因在于:为了找到某样事物,你需要查找的地方越少,那么改起它来就会越容易、越安全。

在本实验中,如果我们已经将Game的基本下棋动作委托给了Action类,通过Action类来调用Board上的操作,那么Board类中的方法是public的,我们也不应该在Game中调用这些方法,因为它违背了上面所说的原则,这样假如Baord中出现了错误,就很容易传播到Game中,进而传播到MyChessAndGoGame中,在维护代码是就可能导致需要检查的地方过多且分散。
与此相对地,假如Game完全通过Action类来调用Board上的操作,查找某处错误时需要查找的地方就会比较少,修改起来也会更容易、更安全。

#8 为测试而设计

有一种思考过程能带来很有趣的对设计的理解,那就是问:如果为了便于测试而设计这个系统,那么系统会是什么样子?

这种思想实际上就是模块之间松散耦合的思想。由于我们推崇测试优先编程,在实现某个类之前需要先写好测试。因此我们在设计整体架构的时候就要先想好:怎样设计能够便于我们测试?如果两个类之间的耦合过于紧密,可能在测试一个类的时候就需要调用另一个类中的方法。

在本实验中,ChessGame有一个Board类型的对象,假如ChessGame与Baord的耦合设计过于紧密,那么在编写测试时,由于两个类都还没有实现,我们并不知道两个类具体是怎么连接的,在测试的时候往往就会假设二者之间有复杂的接口,从而对编写测试造成困难。

因此采用为测试而设计的思考模式,就能促使我们设法组织好每一个模块,使它与其他模块之间的依赖关系最小,从而产生更为规整的类接口,而这通常是非常有益处的。

#9 写在最后

其他的一些设计思想包括进行良好的封装和信息隐藏、严格描述规约、保持设计的模块化等,在课上已经讲过许多,这里不再重提。

值得注意的是我们在设计时可以采用一些现有的设计模式。这些设计模式精炼了众多现成的解决方案,可用于解决很多软件开发中最常见的问题。有些软件问题要求全新的解决方案,但是大多数问题都和过去遇到过的问题类似,因此可以使用类似的解决方案或者模式加以解决。利用这些设计模式可以给我们的设计带来启发性的价值,我们在第四章的课程中应该也会学习设计模式,这里就不一一列举了。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值