从2005年初听说设计模式,到现在虽然已经8年多了,但GoF的23种模式依然盛行,当然GoF提出这些模式的年代更加久远(1995年)。
在工作的过程中,陆陆续续接触了GoF的大部分模式,我记得在2008年的时候就想总结一
下设计模式(最近想做的两件事情),最后因为各种原因也没有完成。最近这段时间正好是职业空档期,没什么事儿做,就把之前看过的设计模式翻出来整理了一
下,于是就有了上面几篇文章。
整理设计模式的过程,也是一个深刻理解面向对象设计的过程。通过对各个模式的回顾,让我更能够明白前辈们关于面向对象设计提出的各种“最佳实践”,特别是S.O.L.I.D,我觉得在这里再说一次,也不算矫情。
S:单一职责原则(Single Responsibility Principle,
SRP),一个类只能有一个原因使其发生改变,即一个类只承担一个职责。
O:开放-封闭原则(Open-Close Principle, OCP),这里指我们的设计应该针对扩展开放,针对修改关闭,即尽量以扩展的方式来维护系统。
L:里氏替换原则(Liskov Subsititution Principle,
LSP),它表示我们可以在代码中使用任意子类来替代父类并且程序不受影响,这样可以保证我们使用“继承”并没有破坏父类。
I:接口隔离原则(Interface Segregation Principle,
ISP),客户端不应该依赖于它不需要的接口,两个类之间的依赖应该建立在最小接口的基础上。这条原则的目的是为了让那些使用相同接口的类只需要实现特定必要的一组方法,而不是大量没用的方法。
D:依赖倒置原则(Dependence Inversion Principle,
DIP),高层模块不应该依赖于低层模块,两者应该都依赖于抽象;抽象不依赖于细节,而细节应该依赖于抽象。这里主要是提倡“面向接口”编程,而非“面向实现”编程。
设计模式,从本质上讲,是针对过去某种经验的总结。每种设计模式,都是为了在特定条件下去解决特定问题,离开这些前提去讨论设计模式,是没有意义的。
下面,我们快速回顾GoF的23种模式。
1.工厂方法
意图:定义一个用户创建对象的接口,让子类去决定具体使用哪个类。
适用场合:1)类不知道它所要创建的对象的类信息;2)类希望由它的子类来创建对象。
2.抽象工厂
意图:提供一个创建一系列相关或者相互依赖的对象的接口,而无须指定它的具体实现类。
适用场合:1)系统不依赖于产品是如何实现的细节;2)系统的产品族大于1,而在运行时刻只需要某一种产品族;3)属于同一个产品族的产品,必须绑在一起使用;4)所有的产品族,可以抽取公共接口
3.单例
意图:保证一个类只有一个实例,并且在系统全局范围内提供访问切入点。
适用场合:各种“工厂类”
4.构造者
意图:将复杂对象的构造与表示相分离,使得同样的构造过程可以产生不同的复杂对象。
适用场合:1)需要创建的对象有复杂的内部结构;2)对象的属性之间相互依赖,创建时前后顺序需要指定。
5.原型
意图:用原型实例指定创建对象的种类,并通过复制原型实例得到对象。
适用场合:1)系统不关心对象创建的细节;2)要实例化的对象的类型是动态加载的;3)类在运行过程中的状态是有限的。
6.适配器
意图:将一个类的接口转换成用户希望的另一个接口。
适用场合:系统需要使用现有类的功能,但接口不匹配
7.装饰
意图:动态的为对象添加额外职责
适用场合:1)需要添加对象职责;2)这些职责可以动态添加或者取消;3)添加的职责很多,从而不能用继承实现。
8.桥接器
意图:将抽象部分与实现部分分离,从而使得它们可以独立变化
适用场合:1)系统需要在组件的抽象化角色与具体化角色之间增加更多的灵活;2)角色的任何变化都不应该影响客户端;3)组件有多个抽象化角色和具体化角
9.享元
意图:运用共享技术支持大量细粒度的对象
适用场合:1)系统中有大量对象;2)这些对象占据大量内存;3)对象中的状态可以很好的区分为外部和内部;4)可以按照内部状态将对象分为不同的组;5)对系统来讲,同一个组内的对象是不可分辨的
10.门面
意图:为系统的一组接口提供一个一致的界面
适用场合:1)为一个复杂的接口提供一个简单界面;2)保持不同子系统的独立性;3)在分层设计中,定义每一层的入口
11.合成
意图:将对象组装成树状结构以表示“部分-整体”的关系
适用场合:1)系统中的对象之间是“部分-整体”的关系;2)用户不关心“部分”与“整体”之间的区别
12.代理
意图:为其他对象提供一种代理以控制对该对象的访问
适用场合:对象无法直接访问(远程代理)
13.职责链
意图:对目标对象实施一系列的操作,并且不希望调用双方和操作之间有耦合关系
适用场合:1)输入对象需要经过一系列处理;2)这些处理需要在运行时指定;3)需要向多个操作发送处理请求;4)这些处理的顺序是可变的
14.命令
意图:对一类对象公共操作的抽象
适用场合:1)调用者同时和多个执行对象交互;2)需要控制调用本身的生命周期;3)调用可以取消
15.观察者
意图:定义对象之间一种“一对多”的关系,当一个对象发生改变时,所有和它有依赖关系的对象都会得到通知
适用场合:1)抽象模型有两部分,其中一部分依赖于另一部分;2)一个对象的改变会导致其他很多对象发生改变;3)对象之间是松耦合
访问者
16.意图:对一组不同类型的元素进行处理
适用场合:1)一个类型需要依赖于多个不同接口的类型;2)需要经常为一个结构相对稳定的对象添加新操作;3)需要用一个独立的类型来组织一批不相干的操作,使用它的类型可以根据应用需要进行定制
17.模板
意图:定义一个操作步骤的方法骨架,而将其中一些细节的实现放到子类中
适用场合:1)可以抽取方法骨架;2)控制子类的行为,只需要实现特定细节
18.策略
意图:对算法族进行封装
适用场合:1)完成某项业务有多个算法;2)算法可提取公共接口
19.解释器
意图:应用或对象与用户狡猾时,采取最具实效性的方式完成
适用场合:1)针对对象的操作有规律可循;2)在执行过程中,对效率要求不高,但对灵活性要求很高
20.迭代
意图:提供一种方法, 来顺序访问集合中的所有元素
适用场合:1)访问一个聚合对象的内容,而不必暴露其内部实现;2)支持对聚合对象的多种遍历方式;3)为遍历不同的聚合对象提供一致的接口
21.中介者
意图:避免大量对象之间的紧耦合
适用场合:1)有大量对象彼此依赖(M:N);2)某个类型要依赖于很多其他类型
22.备忘录
意图:希望备份或者恢复复杂对象的部分属性
适用场合:1)对象的属性比较多,但需要备份恢复的属性比较少;2)对象的状态是支持恢复的
23.状态
意图:管理对象的多个状态
适用场合:1)对象的行为依赖于当前状态;2)业务处理过程存在多个分支,而且分支会越来越多
上面是对GoF23中模式的快速回顾,其中的理解未必很深刻很到位。对设计模式的学习是没有止境的,而且它也只是面向对象分析与设计的冰山一偶。
设计模式之创建型模式
GoF的设计模式一共23个,可以分为3大类:创建型、结构型和行为型,这篇文章主要讨论创建型。
创建型的设计模式包括:简单工厂(Simple Factory)、工厂方法(Factory
Method)、抽象工厂(Abstract Factory)、单例(Singleton)、构造者(Builder)和原型(Prototype),我们分别来讨论。
我们首先来看工厂系列的3个设计模式,它们都主要是针对软件设计中的“开放-封闭”原则,
即程序应该对扩展开放,对修改封闭。特别是当我们的程序采用XML+反射的方式来创建对象时,工厂模式的威力就完全展现出来了,这时我们可以通过维护配置
文件的方式,来控制程序的逻辑。
1)简单工厂,当我们的程序在实例化对象时,如果输入条件不一样,产生的对象也不一样,那么我们可以考虑使用简单工厂对不同的实例进行统一封装,
UML结构如下:
优点:封装了具体对象的实例化过程,Client端和具体对象解耦,同时ProductManager可以作成静态类或者Singleton对象,然后可以使用HashMap缓存具体对象(前提是对象没有时间依赖性),降低创建对象的次数。
缺点:当增添一种新类型的对象时,需要修改Productmanager的代码(如果不采用XML)
2)工厂方法,它是针对简单工厂的改进版,添加了对ProductManager的抽象,UML结构如下:
优点:结构更加灵活,对于某种类型的对象来说,会有一个特定的对象工厂指向它,这样当我们需要添加一种新类型的产品时,只需要添加两个类,一个是具体产品类,一个是新产品的工厂类。这样更加灵活。
缺点:结构开始变得复杂,而且最终还是需要Client端来确定究竟使用哪一个Factory(当然这个信息可以保存在上下文或者配置文件中)。
3)抽象工厂,这个是最复杂的工厂模式,它用来生成一个产品线上的所有产品,我们假设一个产品线上包括多个产品,不同的产品线上的产品个数是一样的,这样我们需要一个针对产品线的抽象,并且很显然不同产品线上的产品是不可能混到一起的。对应的UML结构图如下:
上图表明,一个产品线上的产品由IProduct1和IProduct2组成,客户端在获取产品时,这两个产品应该是同时返回的,因此对于IProductManager来说,它需要同时生成这两个对象。
优点:对创建产品家族的行为高度抽象,添加一个产品线的逻辑比较清晰。
缺点:当我们对产品线上的产品进行增加和删除时,对应的操作比较麻烦,所有的产品工厂都需要进行修改。
4)单例,这是比较好理解的一个模式,从字面上说,就是程序在运行的过程中,希望在任意时刻,都只保留某个对象的唯一实例。对应的UML结构图如下:
单例的实现方式一般包括几步:1)私有的指向自身的字段;2)私有构造函数;3)公开对私有字段进行实例化的方法。也有几种针对具体语言进行的改善,例如针对多线程采用double
lock机制,采用常量方式定义私有字段、使用内嵌类来实例化字段等。
我们也可以对单例进行一些适当的扩展,例如我们将对象的个数由1个变为N个,这就成了对象池。
通常工厂模式中会使用到单例模式,特别是对于简单工厂来说。
5)构造者,对于一些复杂对象来说,它可以分成多个不同的部分,在实例化时,不同部分之间实例化的顺序,有时会有严格的限制,这时我们就可以使用构造者模式了。对应的UML结构图如下:
我们定义了IBuilder接口来实例化对应的不同部分,同时有一个方法来返回对象的实例。而Constructor类的Construct方
法会按照业务逻辑依次调用实例化部分对象的方法,即BuildPartA、BuildPartB,这里的调用顺序,完全由业务逻辑来控制,最后可以调用
GetProduct方法取得完整的对象实例。
我们有时也会对上图进行修改,例如将GetProduct放到Constructor中,或者将Construct方法放入到GetProduct(取消Constructor)中。即使有这些变形,但是基本的思想是不变的。
6)原型,我们在程序运行过程中,当需要有新的实例对象时,有时并不希望是从头创建一个对象,而是希望新的实例的状态和某个已存在的实例保持一致,这就是原型模式发挥作用的地方。对应的UML结构图如下:
在.NET中,已经定义了IClonable接口来实现原型模式。需要注意在实现时,会有深拷贝和浅拷贝的区别,深拷贝会同时拷贝堆栈和堆上的内容,而浅拷贝只会拷贝堆栈上的内容。
设计模式之结构型模式
在 这部分里,我们关注GoF里面的结构型模式,它主要是用于描述如何将类组合在一起去构成更大的结构。结构型模式包括适配器(Adapter)、装饰
(Decorator)、桥接器(Bridge)、享元(FlyWeight)、门面(Facade)、合成(Composite)以及代理
(Proxy)模式。
下面我们对上面提到的模式分别进行描述。
1)适配器(Adapter)。当我们已经开发出一个模块,有一套清晰的接口,并且模块正在被某个功能使用(意味着模块接口改变的可能性不高),这是如果有另外一个功能也需要使用这个模块的功能,但是对应的是一套完全不同的接口,这时适配器就可以发挥作用了。
适配器模式分为两种,一种是对象适配器,一种是类适配器,对象适配器的UML图如下:
这里Adaptee1和Adaptee2指两套不同的子系统,它们作为Adapter的属性存在,可以使用IoC的方式指定。
类适配器的UML图如下:
同样是两个不同的子系统,但是这里我们创建了2个Adapter类来分别指向两个子系统。在这里我们可以在Client和ITarget之间,设置一个Adapter工厂,来根据业务需求创建不同的Adpater实例。
2)装饰(Decorator),假如我们已经开发了一套功能,然后根据需求,需要增加一些子功能,而且这些子功能是比较分散比较时可以增删的,这时如果直接修改接口,那么会造成接口功能复杂并且不稳定,针对这种情况,我们可以使用装饰模式。对应的UML图如下:
上图中,ConcreteComponent已经实现了Component的基本功能,对于一些附加的功能,如果放在
ConcreteComponent中不合适的话,我们可以像ConcreteDecoratorA一样,创建一个基于Decorator的类,通过
SetComponent方法将核心功能和辅助功能串在一起。
有时,为了简单,我们也可以把ConcreteDecorator直接挂在Concretecomponent下面。
3)桥接器(Bridge), 面向对象提倡的几个最佳实践包括:1)封装变化;2)面向接口编程;3)组合优于继承;4)类的职责尽量单一。桥接器完美的体现了这些,通过创建型模式,
我们可以很好地达到面向接口编程的目标,也就是说我们在程序中各变量的声明类型是接口类型或者抽象类,而具体的实现类型则由不同的设计模式使用不同方式指
定。这在接口或者抽象类基本稳定的情况下,是很好地,但当接口需要发生变化时,我们如何去处理?可以看看桥接器的UML图:
通过这个图,我们可以看出,Implementor接口的变化,对于Client来说,基本是没有影响的。Abstraction会持有Implementor的一个实例。
4)享元(FlyWeight), 当我们系统中需要使用大量的小对象,但我们又不希望将所有的小对象都创建出来时,可以考虑使用享元模式,它会抽取小对象中的公共部分,将其封装为基类,然
后针对不同条件创建小对象,同时在对象池中维护这些小对象,客户在需要使用小对象时,首先在对象池中查找,如果存在,直接返回。对于小对象中“个性”的部
分,由调用小对象的客户端进行维护。对应的UML图如下:
除了上述的简单享元,还存在一种复合享元,对应的UML图如下:
图中,CompositeConcreteComponent是不共享的,但是它里面包含很多简单的享元,这些享元是共享的,我们可以把它想象成一个特殊的“享元工厂”。
通常提到享元,最常见的例子就是文本编辑器中的26个字母,在.NET中,字符串常量也使用了享元模式。
在享元模式中,我们通常会将FlyWeightFactory设计为单例模式,否则享元就没有意义了。
5)门面(Facade),如果我们的程序需要深入调用某个模块的内部,但我们又不想和模块过紧耦合,这时可以考虑使用门面模式,来对外部封装内部子系统的实现。简单的门面可能和代理在某种程度上很相似。
门面模式没有固定的UML图,它是根据客户端的实际需求以及子系统内部的接口来确定的。
6)合成(Composite),当我们的对象结构中存在“父子”关系时,可以考虑使用合成模式。它分为两种,一种是安全型的合成模式,UML图如下:
这种类型的合成模式,对于Component的增、删、改,都在Composite中维护,Leaf根本不知道这些操作。另一种是透明型的合成模式,UML图如下:
这种类型的合成模式,自上而下所有的Component都会有增、删、改的操作,只不过对于Leaf来说,这些操作时没有意义的。
7)代理(Proxy),在编写程序时,有时我们希望使用某个对象或者模块的功能,但是因为种种原因,我们不能直接访问,这时就可以考虑使用代理,对应的UML图如下:
需要注意的是,在这里RealSubject只有一个,如果有多个,那么就是Adapter了。另外,代理也可以加入自己的一些逻辑处理,例如PreExecute和PostExecute。如果这里有多个Proxy,那么就是Decorator了。
上面就是对结构型设计模式的快速浏览,其中有很多UML图看上去很相似,但深入去思考,每个模式的出发点、所要解决的问题是不一样的。
设计模式之行为型模式
在这部分里,我们关注GoF设计模式中的行为型模式,它是用来在不同对象之间划分职责和算法的抽象,行为模式不仅涉及到类和对象,还涉及到类与对象之间如何进行关联。
行为型模式包括:职责链(Chain of Responsibility)、命令(Command)、解释器(Interperter)、迭代(Iterator)、中介者(Mediator)、备忘录(Memento)、观察者(Observer)、状态(State)、策略(Strategy)、模板(Template)和访问者(Visitor)。我们主要讨论其中的一部分模式,后续会有其他补充。
1) 职责链(Chain of Responsibility),如果完成一项业务,需要很多步相关操作,但是如果将这些操作完全封装到一个类或者方法里面,又违背了单一职责的原则。这时我们可以考虑使用职责链模式,对应的UML图如下:
我们可以创建很多个Handler的实现类,并通过设置Successor来将这些Handler“串”在一起。那么如何触发所有的Handler呢?这里和Decorator有点儿类似,我们可以通过调用
Successor.HandlerRequest来实现。这样用户只需要关心最开始的Handler,而不必关心后面都还有哪些其他的Handler。
2)命令(Command),命令模式将发出命令和执行命令很好的区分开来,当我们执行某项业务时,客户端只需要构造一个请求,而不必关心业务实现的具体细节,即构造请求和业务实现是独立的。对应的UML图如下:
从图中,我们可以看到,当Client端需要执行某项业务时,它需要构造一个Invoker对象,它负责发出请求,会生成一个Command对象。同时我们看到有一个Receiver对象,它是用来实现具体业务的,我们在ConcreteCommand中,会引用这个对象,来完成具体业务。
3)观察者(Observer),当我们的系统中,存在一个业务A,有其他多个业务都需要关注业务A,当它的状态发生变化时,其他业务都需要做出相应操作,这时我们可以使用观察者模式。观察者模式也称作订阅模式,它会定义一个“主题”(业务A),一个抽象的“订阅者”以及很多具体的“订阅者”(其他业务),在“主题”中,会保留所有“订阅者”的引用,同时可以对“订阅者”进行添加或者删除,当“主题”的状态发生变化时,它会主动“通知”所有“订阅者”,从而“订阅者”可以做出相应的操作。对应的UML图如下:
我们可以看到ConcreteSubject中保留了多个Subscriber的引用(Subscribers),在NotifySubscriber方法中,它会依次调用每个Subscriber的Update方法,从而更新“订阅者”的状态。
4)访问者(Visitor),当我们有一个对象集合,集合中的元素类型是不一样的,但类型是相对固定的,例如只有3种不同的类型,但是可能有30个元素。如果我们希望对集合中的所有元素进行某种操作,从接口的角度来看,由于类型不一致,我们很难通过一个统一的接口来遍历集合元素并对其进行操作。这时我们可以考虑使用访问者模式,它将获取某个元素和对元素进行操作进行了分离。对应的UML图如下:
这里我们假设集合中只包括了2中不同的类型,ObjectBuilder就是上面提到的集合,它包含多个不同的IElement元素,业务的核心实现是在VisitorA和VisitorB中,对于Element1的Accept
方法来说,它只是调用visitor.VisitElement1方法。
5)模板(Template),继承是面向对象的一大核心,而模板方法就是对继承的完美体现。对于某项业务来说,我们可以根据通用的流程,设计其方法骨架,针对不清晰或者不明确的地方,以抽象方法的方式来处理,然后根据不同的子业务,创建不同的子类,在子类中,实现那些抽象方法。对应的UML图如下:
可以看出,对于子类来说,它是不需要重写Operate方法的,而只需要实现父类的抽象方法。对于客户端来说,当它实例化某个子类后,可以直接调用Operate方法来完成某项业务。
6)策略(Strategy),当我们的系统中,针对某项业务有多个算法时,如何对这些算法进行管理,我们可以考虑使用策略模式,它主要是针对一组可以提取相同接口的算法进行管理。对应的UML图如下:
这里需要注意的是,Strategy类并不知道应该使用哪个具体的子类,这应该由Client指定。
7)解释器(Interperter),如果我们的系统中有些特定的问题反复出现,我们想要对这些问题进行抽象,那应该如何做?试想一下,当我们写完代码后,是如何进行编译的?无论对C#还是
Java,它们的编译器都会读取我们所写的每一行代码,并作出相应的解释。我们可以部分认为,编译器中存储了任何组合的语句,类似于C中的
typedef。解释器做的就是类似的事情,它将具有通用性的问题进行抽取,对其解决方案进行综合处理。对应的UML图如下:
一般的执行过程是这样的,Client读取Context中的信息,根据某种原则将其划分成多个部分,针对每一部分,构造相应的解释器,并将Context信息传入解释器中进行处理。这里的问题是Client必须要清楚
Context细节和具体解释器中间的关联。我们可以在Client和Interpreter之间构造一个“解释器工厂”,用来根据Context生成相应的解释器实例,同样,如果解释器的执行过程和数据无关,我们可以为“解释器工厂”上追加“单例”模式,构造一个解释器池。这些都是可以根据需求做的进一步的优化。
8)迭代(Iterator),前文提到的访问者(Visitor)模式,针对的是存储在一起的不同类型的对象集合,如何进行遍历处理,那么针对存储在一起的相同类型的对象集合,我们应该如何进行遍历呢?迭代模式可以帮我们做到,对应的UML图如下:
在C#和Java中,我们都已经在语言层级上实现了迭代,例如C#中的foreach,同时.NET来设计了两个接口来实现迭代:IEnumerator和IEnumerable。
9)中介者(Mediator),如果我们的系统中有多个对象,彼此之间都有联系,那这是一个对象之间耦合很高的系统,我们应该如何优化呢?我们可以建立一个知道所有对象的“对象”,在它内部维护其他对象之间的关联,这就是中介者模式,对应的UML图如下:
这里,Mediator是知道所有IPerson的“底细”的,Client
可以直接与Mediator联系,而不必关心具体的是PersonA还是PersonB,同样,对于PersonA和PersonB来说,它们之间也没有直接联系,当两者需要通信时,之金额使用Mediator的Send方法。
这个模式不好的地方在于:1)所有的IPerson类型都要有 Mediator引用,这样才能和其他的Person通信;2)Mediator需要维护所有Person的实例,这样它才能做出正确的判断,将消息发给对应的Person,但当Person子类过多时,Mediator就变的较难维护,这时,我们可以创建一套关于产生Person实例的“工厂”,会减轻
Mediator的负担。
10)备忘录(Memento),当我们的系统中存在这样一种对象,它的属性很多,在某些情况下,它的一部分属性是需要进行备份和恢复的,那应该如何做?谈到备份和恢复,我们立刻想到可以使用原型模式,但那是针对所有属性的,备忘录模式可以很好地解决这里的问题,对应的UML图如下:
在这里,我们希望Originator的State2、State3是可以备份和恢复的,其他属性是无关的。我们可以在希望备份Originator的地方,调用Creatememento方法,在希望恢复Originator部分属性的地方,调用RestoreMemento方法,同时MementoManager对Memento进行管理。
11)状态(State),当我们的系统中的对象,需要根据传入的不同参数,进行不同的处理,而且传入参数的种类特别多,这时在方法内部会产生大量的if语句,来确定方法的执行分支。那么如何消除这些if语句呢?状态模式可以帮我们做到,对应的UML图如下:
这里,Client只与Context关联,在Context内部,会维护不同状态之间的跳转,简单来说,就是在HandleRequest内部判断传入的state值,如果符合业务逻辑,那么直接调用state的
HandleRequest方法;如果不符合,那么修改state值,然后调用相应state的HandleRequest方法。
|