常用设计模式

——及其与面向对象设计的关系

前言

我常常觉得人们低估了设计模式的作用和意义。它们不仅是简历上的金边、程序员的黑话,也不仅是常见业务的常用处理方式或经验总结。

设计模式不仅是这些,它们更是面向对象思想理论结合实践的切入点。我们前面聊过抽象、高内聚低耦合、封装继承多态、SOLID设计原则。它们更偏理论指导,离编码实践还有一段距离。而这里要聊的设计模式,不仅有扎实的理论基础,而且实实在在地俯下身子、扎根到了实践当中。

从编码实践的角度来讲设计模式,这类文章没有一万也有八千。细抠几种设计模式之间的区别,这类文章写再多也没太大意义。这里就不凑这些热闹了。

这里,我会简单聊聊几个主要设计模式的编程应用,然后把主要精力放在它们与面向对象思想的关联上。另外,在聊过几种设计模式之后,计划提供一种复合模式,作为我使用设计模式的“最佳实践”,供读者参考。

​策略模式

策略模式堪称最简单的一种设计模式。所以,让我们从它开始吧。

是什么

策略模式把属于同一类别的不同行为封装为某种“策略抽象”,而把这些行为统一为这个抽象下的某个“策略实现”。这样,我们就可以很灵活地决定在哪种场景下使用哪种“策略”了。

用类图来描述就是这样的:

常用设计模式-策略模式_面向对象

↑策略模式的类图,主要是右边的一个接口加两个实现类。↑

上图中,PaymentStrategy是所谓“策略抽象”,两种支付方式(信用卡支付、PayPal支付)是这个策略抽象下的不同行为,因而被写在了两个不同的策略实现中。

这样,客户来商店买东西时,爱用信用卡就用信用卡,爱用PayPal就用PayPal。哪怕客户非要用现金,我们也可以方便快捷地增加一个策略实现。

怎么做

上面的类图已经把策略模式的使用方式说得很清楚了。定义一个接口,为这个接口写多个不同的实现类——锵锵锵锵,策略模式达成!要不然说策略模式是最简单的一种设计模式呢。

当然,严格意义上说也没这么简单。如果实现类之间不能在运行时灵活切换,而只能在开发或者编译期固定写死,那这个“策略模式”就要打点折扣了。

例如,老年人习惯了用现金买东西,相当于代码里写死了使用CashStrategy。要让他们切换到新兴的支付方式上,可不是改几行客户端代码那么简单。

public class OldMan{
public void buy(Goods goods){
// 老人购物,还是习惯用现金
PaymentStrategy cashStrategy = new CashStrategy(this.wallet);
cashStrategy.pay(goods.price());
// 其它,略
}
}

而且,如何定义一个合适的接口,这也不是一个简单的问题。接口定义太“通用”了,就会像含义太过宽泛的词语一样,在误解和误用中,或令人忍俊不禁、或令人后悔不已。而定义得太“专用”了,又难以扩展实现类。

// 太过通用的接口,就像含义太过宽泛的词语——比如东北话的"整"——一样……
public interface 东北话{
void 整(Object target);
}
public class 吃 implements 东北话{
public void 整(Object unkownFood){
System.out.println("这要咋整啊?");
}
}
public class 打扫 implements 东北话{
public void 整(Object brokenToilet){
System.out.println("这要咋整啊!");
}
}
public class 东北老铁{
public void 看到(Object something){
东北话 say;
if(something instanceof UnkonwFood){
say = new 吃();
}else if(something instanceof BrokenToilet){
say = new 打扫();
}
say.整(something);
}
}

此外,还有实现类之间代码复用、参数的泛型扩展等问题,都会给简单的策略模式增加复杂度。

不过,这些都是另外的话题,暂且按下不表。总之,只要满足了一个接口加多个实现类,就可以说用上了策略模式了。

为什么

很多文章都会把策略模式和“重构if-else”联系在一起。的确,策略模式可以很好地对if-else代码进行重构优化。

我曾经做过这样的重构。在原先的代码中,正常还款、提前还款、逾期还款,以及其它一些方式的还款操作,都被写在一个类中。很自然的,这个类里满是if-else。它们串联起了若干个为了复用而提取出来的公共方法,以及若干个无法复用的代码片段。最终,这个类变成了这样:

常用设计模式-策略模式_设计模式_02

↑所有这些流程、分支,全在一个类里。↑

度过了快速试错的几年——也许是几个月——之后,这个类的评价从“快”变成了“错”。它成为了还款业务的性能瓶颈,一次还款就要花费将近一秒。同时,它也成为了业务扩展的堵点,很难再向其中添加新的还款方式了。

重构之后,这套业务大体上变成了这个样子:

常用设计模式-策略模式_面向对象_03

↑重构后,变成了一个接口加三个实现类。↑

重构之后,业务扩展自然不在话下,按需增加策略实现就行。性能瓶颈也得到了轻松解决:堵点清晰明了、代码互不干扰,优化工作其实就很简单了。

很明显,我们这次重构用的就是策略模式。所以,我认为这个例子可以很好的解释为什么要使用策略模式。也许,在“快速试错”的“快”阶段时,策略模式可能会拖慢进度,因而不是最佳选择。但是,如果过去的“快”已经变成了现在的“错”,那么,考虑考虑策略模式吧。它很简单,但很有用。

策略模式与面向对象思想

策略模式与抽象

当我们把不同的系统行为归纳到同一类策略中时,这里的“策略”就是这些系统行为的对外抽象。

可见,想要使用策略模式,我们首先要设计一个“抽象”。我们得先把信用卡支付、PayPal支付等方式抽象成“支付”,才能让客户在开发、编译期知道有个操作叫“支付”,并在运行期选择自己喜欢的支付方式。

常用设计模式-策略模式_策略模式_04

↑就像诸葛亮的锦囊妙计一样:首先,你得有个锦囊……↑

和其它抽象设计一样,策略模式的抽象必须是可扩展的。当需要增加现金支付、微信支付等新的支付方式时,这个抽象应当是向下兼容的。无论是客户端还是服务端,原有代码都不需要发生变更,原有功能也不应当受到影响。如果每增加一种支付方式,我们就要修改客户端代码或原有支付代码,那么,这个“支付方式”就是一个失败的抽象,这里的策略模式也是一个失败的设计模式实践。

同样,策略模式的这个抽象应当是有区分度的。虽然都是把一个人的钱交到另一个人手里,但支付跟存款不是一回事,跟打劫更挨不着边。一个好的抽象设计,对外应当让使用者一看就知道怎么使用,对内应当在具体实现中做出某些约束,使之“做且只做”自己应该做的事情。极端一点来说,所有行为都可以抽象为函数 y=f(x)。但这个抹去所有行为区分度的抽象既会让使用者不知所措,也会让实现者无从下手。

常用设计模式-策略模式_策略模式_05

↑“好的设计,应当让人一看就知道设计意图”——《设计中的设计》(如果没记错的话)↑

设计好策略抽象之后,就需要用不同的方式来实现它。这时,策略模式主要考虑的就不是抽象了。

策略模式与高内聚低耦合

高内聚低耦合本是抽象自带的光环。但是在策略模式这里,围绕抽象的高内聚低耦合做得并不够好;反而是在另一个层面上,它的成绩更加亮眼。

本来么,既然策略实现是围绕策略抽象展开的,那么很自然的,策略模式的调用方应该只知道策略抽象、而不知道策略实现。策略抽象的调用方和服务方由此实现低耦合。然而,在这道“送分题”上,策略模式却失了荆州。

常用设计模式-策略模式_面向对象_06

↑顺便缅怀一下君侯↑

的确,策略模式为调用方提供了一个策略抽象。的确,策略模式试图把策略实现“隐藏”在策略抽象之下。可是,在具体的流程中,应该使用哪个策略实现?什么时候用现金支付、什么时候用信用卡支付?对这个策略路由问题,策略模式没有给出答案,调用方必须自己决定。

调用方自己决定策略路由,隐含了这样一个问题:调用方至少要知道服务方提供了哪些策略实现。用户买单时,商家是不是会问“您是信用卡还是现金”?如果商家没问,用户是不是也要问“微信还是支付宝”?这是问题的核心所在:用户必须知道商家支持哪些支付方式,然后才能决定应该从钱包里拿出什么来:现金,信用卡,还是手机。

诚然,有些商家能够客户给什么就用什么支付——给现金找零,给银行卡就刷pos机。因而,客户不用知道商家支持什么支付方式。

从系统的角度来说,这不是单纯的策略模式能提供的能力,还需要别的设计模式的支持。因此,这里暂时按下不表,留待后话。

调用方知道服务方有哪些具体实现,这种耦合已经很重了。更严重的问题是,调用方可以决定服务方使用哪个具体实现。这是典型的控制耦合。在控制耦合下,服务方无论是对现有实现进行重构优化,还是扩展新的实现,都不可避免地会影响到调用方。而这正是低耦合所极力避免的情形。

只提供策略实现,不提供策略路由,这使得策略模式无法独立解决控制耦合的问题。它需要别的设计模式的支持。这里暂时按下不表,留待后话。

“失之东隅,收之桑榆”,虽然围绕策略抽象的高内聚低耦合做得不好,好在另一个方面上,策略模式做得还不错 。对一个正常的策略模式来说,每个策略实现内都是高内聚的,而不同的策略实现之间则是低耦合的。这是个显而易见的结论,这里就不展开了。

常用设计模式-策略模式_设计模式_07

↑我可太喜欢这张图了。↑

无论是对抽象外,还是在抽象内,要实现高内聚低耦合,必然要靠封装继承和多态。策略模式也不例外。

策略模式与封装继承多态

策略模式与封装

策略模式在高内聚低耦合方面的出现问题,有其更深层次的原因。在封装方面做得不够好就是其中一个。

设计并建立一个抽象的目的,就是要把实现细节封装在抽象内部,使之对抽象外部不可见。策略模式的“实现细节”就是与策略实现的一切:有哪些策略实现,它们需要什么数据、怎样处理这些数据、返回什么数据,以及什么情况下选择哪种策略实现,等等。

然而,前文已经说过:策略模式没有封住“在什么情况下选择哪种策略实现”这一条,因而不得不把“有哪些策略实现”也暴露出去。这样一来,它简直什么也没有封住,差不多是在裸奔了。

常用设计模式-策略模式_面向对象_08

↑就算不是裸奔,也跟这个差不多。↑

当然,说它完全是裸奔,这也有失偏颇。虽然策略模式在对外抽象这一层上封装得不太好,但它毕竟把不同的实现封装到了不同的策略实现里。我们不必担心在使用信用卡支付时错找了零钱,也不必担心在修改微信支付时影响到花呗。“外战外行、内战内行”,说的就是策略模式了吧。

毕竟是最简单的设计模式嘛!不能因为它“至简”就要求它是“大道”吧。

策略模式与继承

如果说在封装方面,策略模式是个半吊子的话,那么在继承方面,它就只是做了做样子。一般来说,如果不在策略实现之间引入继承关系的话,策略模式中只会有一种继承关系:策略实现继承策略抽象。例如,现金支付、信用卡支付……等等实现类,都继承“支付方式”这一个接口。

回顾一下类图,就能一眼看穿策略模式中的继承关系:

常用设计模式-策略模式_设计模式_09

↑当然,从Java语法层面来说是“implements”。↑

策略实现之间的继承关系往往涉及其它模式。例如,信用卡支付和借记卡支付都继承自银行卡支付,这是典型的模板模式。老规矩,按下不表。

策略模式与多态

虽然在封装和继承方面做得不咋样,但是在多态方面,策略模式简直是标准典范。

多态是什么?同一种事物的多种形态,就叫多态。策略模式做了什么?为同一个策略抽象提供多种策略实现。还有谁比这一对更天造地设的吗?

常用设计模式-策略模式_设计模式_10

↑异性就结婚,同性就结拜。↑

天造地设不止体现在总体感觉上,还体现在细节里。对策略模式来说,它的多态不止体现在整个策略抽象上,还体现在策略抽象的方法参数上。

先来考虑一个问题。在不同的支付方式中,客户都需要给收银员什么东西?现金支付的话,显然是给现金。信用卡支付给信用卡,微信支付的话则是付款码(有时是收银员给客户一个收款码)。“基于人眼人脑和人手的人工智能系统”可以轻松识别并接受林林总总的支付凭据;计算机系统可没这么强。

对弱类型语言系统来说,客户提供的类型不是那么重要。重要的是数据本身,而不是数据的“类型”。但是对强类型系统来说,客户必须按约定的类型提供数据。这就要求我们把现金、信用卡、付款码……都用同一种类型来描述,同时又必须考虑到彼此之间的封装性。这让人很头疼。

除了构建一个大而全的数据包装类之外,还有一个法子就是构建一套数据类型体系。在抽象定义中使用顶级类或泛型,通过不同的子类来封装不同的数据。在这里,多态同样发挥着作用:在抽象的方法声明上,入参是一个通用类型;但是调用方实际传进来的却是不同的实现类。

当然,这种多态与继承密切相关,甚至可说是一体两面。而且,这种多态并非策略模式所独有。只要有抽象-实现类这样的多态存在,就有抽象参数-实现类这种多态存在。

策略模式与5+1设计原则

简(còu)单(diǎn)回(zì)顾(shù):5+1设计原则是指单一职责原则、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则这五个原则(即SOLID原则),以及迪米特法则这个“编外人员”。

常用设计模式-策略模式_策略模式_11

↑也许看到这张图,你就能想起来了。↑

策略模式与单一职责原则

当我们把不同的系统行为放到不同的策略实现中去的时候,我们就既实现了策略模式,又遵循了单一职责原则。

之所以这么说,是因为使用了策略模式之后,每个实现类都只负责一种策略实现。也就是说,只有当这种策略实现发生变化时,我们才需要修改对应的类。这与单一职责原则的要求简直严丝合缝。

常用设计模式-策略模式_设计模式_12

↑还能说啥,太完美了↑

策略模式与开闭原则

正常情况下,在统一的策略抽象的掩护之下,策略模式可以很容易地满足开闭原则的要求。要增加新的还款方式?加一个策略实现类就行。要增加新的支付方式?加一个策略实现类就行。哪怕要修改现有的类,也可以通过保留原有类、增加一个新的策略实现类来实现。这样,不仅可以保证原有逻辑不变——代码都没变过嘛——还能充分复用原有代码。

通过增加新的策略实现类、而不是修改现有代码,来实现新的功能需求。这不正是开闭原则所要求的吗?

策略模式与里氏替换原则

只靠策略模式,我们可以在编译期满足里氏替换原则的要求,但很难在运行期做到这一点。

由于子类可以安全地转换为父类,而所有的策略实现类都是策略抽象的实现类,所以在编译期,它们可以安全地被替换为策略抽象。

public class OldMan{
public void buy(Goods goods){
// 老人学会了移动支付,不再用现金支付了。这里可以直接用移动支付方式替换现金支付方式
// PaymentStrategy cashStrategy = new CashStrategy(this.wallet);
PaymentStrategy cashStrategy = new MobileStrategy(this.mobilePhone);
cashStrategy.pay(goods.price());
// 其它,略
}
}


但是到了运行期,事情就没这么简单了。试想,如果客户递给你一张十元大钞,而你拿起了微信扫码枪,会发生什么事情呢?

如果是人工柜台,当然什么也不会发生。收银员最多会自嘲地笑一笑——或者叹口气,然后就放下扫码枪,打开零钱柜。但是系统可没这么智能。如果数据和策略实现类匹配不上,轻则类型异常、数据异常,重则发生一笔莫名其妙的业务,然后程序员们回退业务、修正数据……忙去吧!

诚然,策略模式并没有完全遵循里氏替换原则。事实上,也没有谁能够完完全全彻彻底底地遵循这一原则。但是,策略模式不仅在这个方向上迈出了很大的一步,而且还有很大的潜力可供发掘。例如,零钱柜固然不能无法被扫码枪替代,但总可以和零钱柜2.0无缝衔接吧!到这一步时,不就实现了运行时的里氏替换原则吗?

策略模式与接口隔离原则

策略模式和接口隔离原则没有多大关系,因此略过不表。

策略模式与依赖倒置原则

别看聊单一职责原则、开闭原则时,策略模式笑得那么欢实,到了依赖倒置原则时,它就只能苦瓜脸了。

常用设计模式-策略模式_设计模式_13

↑苦瓜说你别碰瓷啊我可不这样。↑

谁让策略模式把不少内部细节都暴露到抽象之外去了呢?有哪些策略实现、哪种情况下使用哪种策略实现,以及哪种策略实现要用哪些数据,这些本该自己处理的细节,全都交给了调用方来处理。这直接违反了依赖倒置原则的要求——高层、低层互不依赖,而共同依赖于一个中间抽象。

策略模式与迪米特法则

这么一路聊下来,策略模式与迪米特法则的关系已经很明显了。

问题还是出在策略抽象的封装上。策略抽象本该把内部实现细节封装、隐藏起来的。可是,由于该封的没封住,该藏的没藏好,所以调用方可以知道——甚至是必须知道——策略实现的很多细节。这些实现细节,显然不是调用方所需的“最小知识”。自然,策略模式并不能满足迪米特法则的要求。

常用设计模式-策略模式_面向对象_14

↑迪米特法则:使不得!策略模式:放进去!↑



这真的有点没办法。我推测,策略模式的设计初衷,就只是为了把纠缠在一起的各种流程分门别类进行处理,相当于把纠缠在一起一团电线理出清楚的头绪来。它虽然建立起了一个策略抽象,但它更多的着眼于抽象内部、着手梳理了内部流程。这些都是对服务端的设计优化。而对抽象的外部、流程的上游,也就是对调用方,策略模式并没有把它们放在心上。

但我们要考虑的、要设计的,绝不仅仅是服务方,而是从调用方到服务方的、端到端的完整流程。在这个要求下,策略模式的格局就不太够了。这大概也是设计模式被普遍低估的原因之一。

没有解决法了吗?当然有。由多种模式相互配合组成的复合模式,不仅能够取长补短,还能发挥1+1>2的作用。策略模式作为一个“基酒”,只需要加上一些其它模式,最终甚至可以“七个纵队包打天下”了。

常用设计模式-策略模式_策略模式_15

↑七个纵队七个纵队,你先给我七个纵队啊!↑

至于还需要加哪些设计模式呢?我们下回分晓。



往期索引

​《面向对象是什么》​



从具体的语言和实现中抽离出来,面向对象思想究竟是什么?



公众号:景昕的花园​​面向对象是什么​


《​​抽象​​》


抽象这个东西,说起来很抽象,其实很简单。


花园的景昕,公众号:景昕的花园​​抽象​


《​​高内聚与低耦合​​》

《​​细说几种内聚​​》

《​​细说几种耦合​​》


"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。


花园的景昕,公众号:景昕的花园​​高内聚与低耦合​


《​​封装​​》

《​​继承​​》

《​​多态》​



——“面向对象的三大特性是什么?”

——“封装、继承、多态。”




​《[5+1]单一职责原则》



单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。



花园的景昕,公众号:景昕的花园​​[5+1]单一职责原则​

《[5+1]开闭原则(一)》

《[5+1]开闭原则(二)》



什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。

什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。



花园的景昕,公众号:景昕的花园​​[5+1]开闭原则(一)​


《[5+1]里氏替换原则(一)》

《[5+1]里氏替换原则(二)》



里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则



花园的景昕,公众号:景昕的花园​​[5+1]里氏替换原则(一)​


《[5+1]接口隔离原则(一)》

《[5+1]接口隔离原则(二)》



一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更小、更具体的接口。

不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的……客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。




花园的景昕,公众号:景昕的花园​​[5+1]接口隔离原则(一)​


《[5+1]依赖倒置原则(一)》

《[5+1]依赖倒置原则(二)》


在Java世界里谈到依赖倒置原则,相信90%的人都会立即想起SpringIOC;还有9%的人会想起“面向接口编程”。最多只有1%的人能想起依赖倒置原则的真正定义。


花园的景昕,公众号:景昕的花园​​[5+1]依赖倒置原则(一)​



《​​[5+1]迪米特法则(一)​​》

《​​[5+1]迪米特法则(二)​​》


迪米特法则可以用一句话概括:Only talk to your friends。

 “只和你的朋友说话”,这是1987年的表述。2003/2004年左右,Karl Liebertherr对迪米特法则做了一次升级:由“Only talk to your friends”升级为了“Only talk to your friends who share your concerns”——“只和与你同忧同乐的朋友说话”。


花园的景昕,公众号:景昕的花园​​[5+1]迪米特法则​


常用设计模式-策略模式_设计模式_16