策略模式
(The
Strategy Pattern)
概述
在这一章里我们将会讨论一个电子商务方面的例子。我们将会在这个例子中使用到
Strategy
模式。
在这一章里我们将
-
继续探讨如何处理项目中的新需求和一些业务逻辑方面的变化
-
根据
GOF
的理论,寻求到最适合的解决这些新需求和变化的方案
-
引入一个新的例子
(
和电子商务有关的例子
)
-
讲解
Strategy
模式,并在例子中演示如何用它处理新业务需求
-
讲述
Strategy
模式的主要功能
处理新业务需求的方法
在实际生活中,以及在软件开发中,我们时常都需要选择一些很普遍的常用的方法来完成任务或是解决问题等。我们也都知道,为了一时的方便而走所谓的“捷径”到最后反而使问题复杂化。举个例子吧,我们必须定期给我们的汽车更换机油。没有人可以忽视这个问题。当然了,不是说每三千公里就必须更换一次,但是,总不可能汽车都跑了三万公里了,却不把更换机油的事情放在心上吧?(如果有谁真的是这么做的话,他就不用再换了,因为他的汽车已经下岗了!)再举个例子,把一张办公桌当成对方档案的档案柜。一天、两天还成,还能在桌面上找到我们需要的档案,但是时间一长,档案一多,怎么找?这些灾难性的后果不都是因为为了眼前的短期的利益而做出一些不恰当的决定而造成的么?
遗憾的是,在软件开发中,很多人也还没有意识到这一点。有很多项目都是只考虑到眼前的短期的需求,并没有考虑到日后该如何维护的问题。忽略日后维护的借口有很多:
照这样下去,就只有两个选择:
-
一直停留在分析和设计上
-
只考虑当前的业务需求,快速完成开发。然后投入到新的项目当中。
因为大多数时候我们要做的是尽早把项目交出去,至于维护的事情,好像不在考虑范围之内,所以,这样的结果并不足以为奇。但是,想一下,是不是还有其他的方案呢?是不是有什么东西促使我们看不到其他的方案呢?其实,这一切都是因为,我们一直都认为在设计过程中考虑到业务逻辑的变化比不考虑所花费的成本更高。
但是,通常,这么想是不对的。事实上,恰恰相反。在设计过程中加入对随着时间推移,业务逻辑可能出现的变化的考虑,通常这样的设计将会是个不错的设计。甚至开发周期会更短。而且,高质量的代码更加益于阅读、测试还有修改。这些好处相对于设计过程中多花的那点点时间而言完全是值得的。
下面将要讲到的例子就是一个考虑到可能存在的变化的不错的例子。要说明的是,我们并不是要预定变化,而是要揣测在什么地方将会有怎样的变化。这一切都基于
GoF
书中的一些概念:
我的一点建议是:在需要改变代码来实现新功能的时候,你应该遵循这些策略,前提是遵循这些策略并不会给新功能的实现造成太大的困难,不会使得研发费用开销过大。你可以通过这样的一点小小投入而在今后收获颇多,何乐而不为?
但是,我并不会盲目的遵循这些策略。我将会和其他不遵循这些策略的实现方案进行比较,看它们中谁更符合面向对象设计的一些概念。这种比较将还会在第十章介绍桥式的时候继续用到。在那一章,我也会衡量备用方案和当前方案谁更符合面向对象的概念。
国际电子商务系统个案:系统初始需求
在这个案例里面,我们将讨论到美国的一个国际电子商务系统当中的订单处理系统。该订单处理系统要求能够处理来自各个国家的销售订单。这一章,让我们一起来接受由系统需求的改变所带给我们的挑战。
通常这个系统将会通过一个控制对象
(TaskController)
来处理销售请求。当有订单下达的时候,该对象将把“下订单”的这个请求传递给一个
SalesOrder
类的对象来处理,就象图
9-1
所描述的那样。
图
9-1
订单处理的业务结构
SalesOrder
应该会包含有如下的一些功能:
其中的一些功能应该会在其他对象的协助下完成。例如,
SalesOrder
并不会自己完成给出订单回执的操作,相反,它仅仅保存订单的相关信息。在实际的操作中,
SalesOrder
可以调用一个
SalesTicket
的对象来完成
SalesOrder
处理完成后的订单回执的打印操作。
处理新的业务需求
我们已经把上面提到的这些功能完成了。现在,假设我们被要求改变原先销售税的计算方式。比如,现在要求该系统可以处理来自美国本土以外的其他国家的客户的订单信息。至少,我们需要添加其他国家的客户的销售税计算办法。
那么,我们将通过什么办法来实现呢?
通常,我们有哪些方法来处理需求的变化呢?
至少,我们可以很快想到下面的这些方法:
一个陈旧的处理方式。已经拥有了一些代码,而且它们的功能和现在所要求的相似,那么,复制、粘贴这些代码到新的功能模块里面,再对这些代码稍作修改,就能够达到目的。但是,很显然,这样将会使得代码很难维护,因为,在今后,不得不维护两份相似的代码。我们忽略了对象的重用。这样使得维护费用的增加。
一个趋于合理的处理方式。随着时间的推移,程序可能会出现这样那样的问题。我们来看一下,当有变量被大范围使用的时候,这对程序整体的耦合性和易测性有什么影响。例如,假设我们用变量
myNation
来标识客户的国籍。如果客户的国籍是限定在美国和加拿大以内,可能用
switch
语句就可以很好地完成。我们可以象下面的代码所演示的那样:
//
Handle Tax switch (myNation) {
case
US:
//
US Tax rules here
break;
case
Canada:
//
Canada Tax rules here
break;
}
|
//
Handle Currency switch (myNation) {
case
US:
//
US Currency rules here
break;
case
Canada:
//
Canada Currency rules here
break;
}
|
//
Handle Date Format switch (myNation) {
Case
US:
//
use mm/dd/yy format
Break;
case
Canada:
//
use dd/mm/yy format
Break;
}
|
|
但是,当有很多变量的时候怎么办呢?假设现在又需要加入对德国客户的支持。那么,上面的代码也许就会变成下面这样:
//
Handle Tax switch (myNation) {
case
US:
//
US Tax rules here
break;
case
Canada:
//
Canada Tax rules here
break;
case
Germany:
//
Germany Tax rules here
Break;
}
|
//
Handle Currency switch (myNation) {
case
US:
//
US Currency rules here
break;
case
Canada:
//
Canada Currency rules here
break;
case
Germany:
//
Germany Currency rules here
Break;
}
|
//
Handle Date Format switch (myNation) {
Case
US:
//
use mm/dd/yy format
Break;
case
Canada:
case
Germany:
//
use dd/mm/yy format
Break;
}
|
//
Handle Language switch (myNation) {
case
US:
case
Canada:
//
use English
break;
case
Germany:
//use
German
break;
}
|
这看起来好像还不是太坏,毕竟才
3
个国家而已嘛,但我们已经感觉到,这和前面的示例相比复杂了不少,并不是那么方便了,不是么?最后,我开始需要在每个
case
里面添加变量来完成任务了。这,使得整个代码变得一团糟。例如,加拿大的魁北克说的是法语。如果我要考虑到这点,代码就又变了:
//
Handle Language switch (myNation0 {
case
Canada:
if
(inQuebec) {
//
use French
break;
}
case
US:
//
use English
break;
case
Germany:
//
use Germany
break;
}
|
在
switch
内部的程序流程都变得复杂了,更不用说整个
switch
语句了,那么的难以阅读,那么的难以解释。当一个新的
case
到来的时候,程序员需要找到整个
switch
语句里每一个可能由此受到牵连的地方,并且,还要对它们作出正确的修改。
新的备用方案。人们时常会误用继承,由此还给继承带来不好的声誉。事实上错的并不是继承本身,而是程序员。是因为程序员误用继承才导致不良设计的出现。而这一切的根源可能是在于那些教授面向对象的概念的人。
当面向对象的程序设计成为主流的时候,“重用”被吹捧成面向对象的主要优点。要达到重用的效果,人们被告知:你只要继承你已有的类,并在继承类中稍作修改就可以了。
在我们计算销售税的例子中,我们可以考虑重用
SalesOrder
。我们可以把新的计税规则当成是一种新的销售订单来处理,只是这些订单的计税规则不一样而已。例如,来自加拿大的订单。我们可以从
SalesOrder
派生一个
CanadianSalesOrder
类,并覆盖其中的计税规则。
图
9-2
电子商务系统销售订单结构示例
这种方法的缺点在于,你只能用一次。例如,当处理德国订单,或者是获取其他变量(如:数据格式,语言,配货方式等。),我们正在建立的继承层次并不能够很轻易地处理好这些变化。我们一而再的特殊化,要么使得代码难以理解,要么出现冗余代码。这也是人们一直在对面向对象设计抱怨的地方:特殊化最终导致一个长长的继承结构。然而不幸的是,这些很难以理解,冗余,难以测试,多种概念交叉在一起。人们抱怨说面向对象设计其实被吹捧得太高了,特别是因为“重用”,也就不足以为奇了。
我们还有没有其他的方法呢?想想我们在刚开始时候说到的原则:考虑设计中什么是可变的,封装要变化的概念,用对象的聚合而不是继承。
要这么做,我需要做下面的两件事情:
-
找到什么是可变的并把他们用类封装起来
-
在另外一个类里包含前面创建的类
在这个例子里,我们已经知道销售税的计算方式是可变的。要完成这一点,我们需要先建立一个在概念上完成销售税计算的抽象类,并为每个变化派生出一个固定的类。也就是说,我要建立一个定义了完成该任务的接口的
CalcTax
类对象,然后就可以派生我们需要的特定版本的类的。
图
9-3
封装销售税的计算
继续,现在我们用聚合而不再是继承。这意味着,我将在
SalesOrder
对象里聚合一个
CalcTax
的对象,而不是创建不同版本的
SalesOrder
。
图
9-4
用聚合而不是继承
例
9-1
Java
代码片段:实现策略
(Strategy)
模式
public
class
TaskController {
public
void
process () {
//
this code is an emulation of a processing task controller
//
…
//
figure out which country you are in
CalcTax myTax;
myTax
=
getTaxRulesForCountry ();
SalesOrder mySO
=
new
SalesOrder ();
mySO.process ( myTax );
}
private
CalcTax getTaxRulesForCountry () {
//
In real life, get the tax rules based on country you are in.
//
You may have the logic here or you may have it in a configuration file.
//
Here, just return an USTax so this will compile.
return
new
USTax ();
}
}
public
class
SalesOrder {
public
void
process (CalcTax taxToUse) {
long
itemNumber
=
0
;
double
price
=
0
;
//
give the tax object to use
//
…
//
calculate tax
double
tax
=
taxToUse.taxAmount ( itemNumber , price);
}
}
public
abstract
class
CalcTax {
abstract
public
double
taxAmount (
long
itemSold ,
double
price );
}
public
class
CanTex
extends
CalcTax {
public
double
taxAmount (
long
itemSold ,
double
price ) {
//
in real life, figure out tax according to the rules in Canada and return it
//
Here, return 0 so this will compile
return
0.0
;
}
}
public
class
USTax
extends
CalcTax {
public
double
taxAmount (
long
itemSold ,
double
price ) {
//
in real life, figure out tax according to the rules in the US and return it
//
Here, return 0 so this will compile
return
0.0
;
}
}
我已经给
CalcTax
对象创建了一个通用的接口。可能,我将会有一个定义了什么是正在销售的
Saleable
类
(
并在该类里说明该商品如何计税
)
。
SalesOrder
对象将把
Saleable
的对象和商品的数量和价格一起同时传递给
CalcTax
对象。这些,将会是
CalcTax
对象所需要的所有信息。
本方法的一个优点是使对象间结合更紧密。销售税的计算是在
CalcTax
类里面自己完成的。另一个优点是当我得到新需求的时候,我只需要从
CalcTax
派生一个新类,并实现该类中的抽象方法就可以了。
最后,职能转换也变得非常方便。例如,在使用继承机构的方法中,我必须让
TaskController
去判断程序该调用哪一类型的
SalesOrder
。而,这个新结构中,我可以用
TaskController
或者是
SalesOrder
来完成这点。让
SalesOrder
来完成,我需要一些配置对象以使得它知道用那种类型的税务对象。
(TaskController
对象来控制的情形与此相似
)
图
9-5
SalesOrder
对象使用配置对象来感知
CalcTax
的适当类型
很多人都已经发现,这个方法也有使用到继承。确实如此。但是,这和从
SalesOrder
派生出
CanadianSalesOrder
是不同的。在完全继承的方法中,我们在
SalesOrder
类中使用继承来处理变化。而在用到设计模式的方法中,我们使用的是对象聚合方式。
(
也就是说,
SalesOrder
包含了一个能处理这些变化的对象的引用,也就是税
(tax))
。从
SalesOrder
的角度来看,我所使用的就是聚合而不是继承。至于,他所包含的对象是如何处理这个变化的和
SalesOrder
无关。
也许有人会问:你这么做,不是把难题推给另一个对象而已吗?要回答这个问题,有三个方面。第一,不错,的确是这样。但是,这么做,可以使复杂的问题简化。第二,原来的设计中在派生链中收集了太多独立的变量
(
税,数据格式……
)
,而新方法里,我们在把每个变量都固定在它自己的派生链里面了。这样,他们就可以任意的继承而不会影响到其他的变量。最后,在新方法里,系统里的其他部分可以独立地使用
SalesOrder
里的功能。总之,借用设计模式的方法使系统各部分在功能上达到平衡
(scale)
,而单纯使用继承的方法是不可能办到这点的。
本方法允许
SalesOrder
使用到的对象的业务逻辑独立发生变化。这个方法不仅仅在目前运行良好,就是在将来也没有问题。总的来说,在抽象类中封装运算法则,并适时使用这些抽象类,这就是策略模式
(Strategy
Pattern)
。
策略模式
正如
GOF
的经典著作《设计模式》所说,策略模式
(The
Strategy
Pattern)
的目的是,定义一组运算法则,并挨个进行封装,最后让它们之间可以相互作用。策略模式使客户端用到的这些运算法则可以独立变化。
策略模式基于以下几点:
-
我们需要用对象去完成某些功能
-
这些功能的不同实现方案通过多态得以表现
-
需要在一个运算法则上有多个不同的实现
在设计中把不同行为分解成独立的模块,这看起来是个不错的主意。这样,我们就可以随意改变某个类的功能而不用影响到其他类。
如何使用策略模式
当我在课堂上讲解这个电子商务的例子的时候,有同学问我:“你有没有注意到,在美国,一定年龄以上的人是不用付食品税的呢?”
我的确没有注意到这点,
CalcTax
类的接口也没有处理这个问题。但是,我至少可以通过以下的三种方式来解决这个问题:
-
把
Customer
的年龄
(age)
传递给
CalcTax
,并在
CalcTax
里调用
-
常用一点的做法是把
Customer
的引用直接传递给
SalesOrder
-
更常用的做法则是,在
SalesOrder
里把自己
(this)
传递给它自己,再让
CalcTax
来查询调用
虽然这样我必须修改
SalesOrder
和
CalcTax
来处理这个问题,但是,我很清楚我该怎么做,并不会因此而引入新的问题。
理论上说,策略模式就是用来封装算法的。然而,实际上,我发现,它可以用来封装任何的规则。通常,在我做系统分析的时候,我遇到在不同时候用不同业务规则的时候,我就会考虑用策略模式来处理这些变化。
策略模式要求被封装的算法
(
业务规则
)
要和用到这些算法的类相独立。也就是说,这些算法所需要的信息要么是客户端对象传递过来,要么是算法本身通过其他方式获取。
策略模式可以简化单元测试,因为每个算法都在自己单独的类里面,并且可以通过它自身的接口而接受测试。如果算法并不象策略模式中这样彼此隔离开来,那么会由于对象间的耦合而使测试变成一件烦人的事情。例如,在初始化某个对象以前,你可能需要一些前提条件,或者该对象需要用某种方式来操纵一个受保护的数据成员。
当有多个不同算法同时存在是,测试将会进一步得以简化。这是因为在使用策略模式以后,开发人员就不再需要去考虑对象耦合后的对象间的交互问题。即是说,可以单独对某个算法进行测试而不用去考虑所有算法间的结合
(combination)
。
在前面的订单处理的例子中,当
SalesOrder
每需要到销售税算法的时候,我就通过
TaskController
传递一个算法对象给他。细想一下,除非我在不同客户间重用
SalesOrder
,否则我将会一直在每个
SalesOrder
对象里用同一个策略对象。策略模式的一个常见变化就是,在客户对象的构造函数里使用策略对象。然后客户对象的其他方法就可以直接使用到策略对象而不用再从外界传入。然而,由于客户对象并不知道策略对象的确切类型,本模式的功能就受到了限制了。这个方法适用于那种在客户对象创建的时候就已经知道将要使用到的策略对象的确切类型的情况。
有时,有学生向我抱怨说策略模式使得他们要多写好几个类。虽然,我并不认为这有什么问题,当我可以控制到所有算法的时候,我还是在想办法尽量减少类的个数。如果用
C
++我可以在抽象算法的头文件里包含所有具体算法将会用到的头文件。我还可以在抽象算法的
cpp
文件里包含所有具体算法会用到的代码。如果是
Java
,在抽象算法里用内部类包含所有的具体算法。如果,其他程序员需要去实现他们自己的具体算法的话,我就不能这做了。
总结
策略模式就是定义一组算法,所有的这些算法都是做同一件事情,只是他们实现的方式不同。
给大家讲了一个销售税算法的例子。在一个国际性的电子商务系统里,对不同国家的客户可能会存在不同的销售税算法。策略模式使得我们可以封装这些算法到一个抽象类中,并从这个抽象类派生出一系列的具体类来完成不同国家的具体算法。
通过从抽象类派生出不同算法的方式,程序的主模块
(
如上例中的
SalesOrder)
不必考虑确切算法类型,这样就允许新变化的出现,随后在
16
章,我们继续讲如何处理这些变化。
策略模式主要功能
|
目标
|
使我们可以根据不同客户端对象采用不同算法
|
环境
|
具体算法的选择取决于具体的客户对象的类型
|
解决方式
|
把算法的选择和算法的实现分离开来,再根据客户对象进行选择
|
参与方式
|
|
结果
|
|
执行方式
|
让使用到算法的类
(Context)
包含一个包含了如何调用具体算法的抽象方法的抽象类
(Strategy)
。每个派生类根据需要实现具体算法。
|
图
9-6
策略模式通用结构
|
posted on 2006-11-05 20:57
xiaosilent 阅读(2623)
评论(3) 编辑 收藏 所属分类:
设计模式