设计模式归纳
面向对象设计有几个原则(开闭、单一等),如果能够按照这些原则来开发软件,就能充分发挥面向对象的好处(低耦合,易扩展)。设计模式正式这些原则的体现,它是前人编码过程中的经验总结,针对某些问题而提出的解决方案。
最经典的设计模式出自《设计模式-可复用面向对象软件的基础》,针对不同具体问题,书中提出了23个设计模式,可大致分为三类:创建模式__、__结构模式 和 行为模式
一、创建模式
「单例、工厂、抽象工厂、原型、建造者」
开发中会用到很多对象、最佳的使用对象的方式,是遵循面向对象的一个原则“==针对接口编程==”,即在程序代码中出现的对象,都是接口类型,而不要出现具体的类,创建型模式大部分都是为了解决这个问题,即:对象创建交给专门的几个类去负责,创建完之后交给用户使用,用户得到的是一个接口类型,既不必知道这些类具体是什么(只要知道它们都是某个接口的实现类),也不必知道是如何创建它们的。
1. 单例模式(Singleton)
「创建唯一的一个对象」
- 设计:
1 | class A{ |
- 客户端:
1 | A a = A.getSingleton(); |
注意
如果是并发环境中、或者在好几个虚拟机之间创建唯一的对象,则要再用其他的方法,如并发控制、rmi等。
2. 工厂模式(Factory)
「最常见的一种模式,可在java类库中大量见到,用户只需要调用工厂方法类的相应方法即可创建对象」
- 设计:
1 | //Product 产品接口 |
- 客户端:
1 | Factory factory = new FactoryA(); |
说明
- 要修改Product对象的创建方式,客户端代码并不受影响
- 要创建另一种Product对象(如ProductB),则创建一个Facrory的子类FactoryB,然后用这个对象来创建ProductB。虽然也修改了代码,但是仅仅限于创建Factory对象的地方,其他的业务逻辑部分并没有修改(业务逻辑指用到了Product类型的地方)
- 工厂方法的一种改进,叫做静态工厂方法,如果要创建另一种Product对象,把该过程写在工厂类的静态方法里,这在jdk类库里大量使用
3. 抽象工厂(Abstract Factory)
「创建一组对象」
上面的工厂方法其实并没有体现出好处,因为每个工厂只创建一种对象,但是如果需要创建一批对象,好处就体现出来了。抽象工厂方法适用于类似下列情形:比如要更换程序界面的显示风格,如窗口、按钮、滚动条等。如果不用抽象工厂,就得一个个对象都替换掉,这显然不简单。实际上高层业务逻辑也只是针对这些窗口或者按钮的接口进行编程,抽象工厂可以很容地把这些对象立刻换成另外一批。
- 设计:
1 | //窗口对象接口 |
- 客户端
1 | Factory facory = new FactoryA(); |
4. 原型模式(Prototype)
「对象可以自身复制出新的对象」
适用场景:创建的对象很多属性都差不多,只需要在其他对象上修改一小部分。很多编程语言支持这种方式创建对象,比如java中每个对象都有clone()方法,那么在这样的语言里,不用专门写这种设计模式、利用自带方法即可。
- 设计
1 | class A{ |
- 客户端
1 | A a = new A(); |
5. 建造者模式(Builder)
对象的创建其实是一个非常复杂的过程,可能需要先创建一些零件,最后再把这些零件组装起来,建造者模式把组装这个行为分离出来,使得相同的零件,可以用不同的组装方法。
- 设计
1 | /*创建一个个零件类*/ |
- 客户端
1 | Builder builder = new Builder(); |
说明
这里的Builder和Director完全可以定义成接口,利用前面讲的工厂方法等来提供,也就是说设计模式之间是可以组合使用的,最终可以使得客户端中不存在new这种显示创建对象的方法。
小结
- 创建型模式是设计模式的基础,只有把这个问题解决了,上层模块才能完全对接口进行编程,想要哪个对象,直接调用某个创建型模式对象的方法就可以了。细心看上面的例子可以发现:尽管业务对象可以通过工厂类对象等创建,没有显示出现new关键字,但是工厂类对象本身却出现了new关键字
- 有==两种方法可以彻底消灭new==:
- 把创建这些工厂类的代码聚集到一起,使变化局限于局部;
- java中有“反射”机制,可以把创建工厂类的方式通过配置文件来提供,这样就完全避免了修改代码,完全避免了代码中出现new,这是最佳的解决方案:即工厂对象由配置文件创建,业务对象由工厂对象创建。很多框架,例如==Spring==,就是采取这种方式来提供对象,它有个时髦的名字叫做“==依赖注入==”。
二、结构模式
「适配器、桥接、组合、装饰、外观、享元、代理」
结构型模式最能体现设计的精妙,从中可以学到不少智慧。针对特定的问题,这些模式的解决思路很有借鉴启发的作用。
1. 适配器(Adapter)
「转接口」
适用于这样的情况:用户(或者高层业务逻辑)需要一种接口来调用某种功能,手头已经有能够实现这种功能的类,但是接口与用户需要的不一致,这时候就可以创建一个类似于“转接口”的类使得用户接口和已有库的接口能够对接,而不是仅仅因为接口不一致而重新开发。
- 设计
1 | //客户想要的接口 |
- 客户端
1 | A a = new Adapter(); |
2. 桥接(Bridge)
「解决类多方向发展带来的结构僵化问题」
桥接模式是相对比较晦涩的一个,它并不是明显针对某个问题,我觉得他的思想更重要。假设有这样的情形:一开始需要设计一个运行在PC上软件,能够显示文本和图像,则可以设计一个接口:interface Software { void show(); },以及两个子类:class TextSoftware implements Software{},class ImageSoftware implements Software{},这个时候结构并没有什么问题;下面又有了新需求,把这个功能移植到手机上,那么有人可能会这样设计:TextSoftware做两个子类:TextSoftware4Pc和TextSoftware4Phone,ImageSoftware也做两个子类:ImageSoftware4Pc,ImageSoftware4Phone,这样貌似也能解决问题,但是这会带来僵化问题:如果现在又有了新的需求,定制两个Software版本,一个为成人的,一个为儿童的,当然也要既有Pc的,也有手机的,那么在上面的基础上继续继承的话,可以这么做:TextSoftware4Pc下面派生两个子类TextSoftware4PcAdult和TextSoftware4PcChildren,TextSoftware4Phone派生两个子类TextSoftware4PhoneAdult和TextSoftware4PhoneChildren,ImageSoftware4Pc派生两个子类ImageSoftware4PcAdult和ImageSoftware4PcChildren,ImageSoftware4Phone派生两个子类ImageSoftware4PhoneAdult和ImageSoftware4PhoneChildren。从这里应该就可以隐约感觉到,这种基于继承的结构,已经开始存在危机了,如果再增加新的需求,这个类继承层次就会非常的复杂,给软件以后维护带来很大的难度。分析下需求的变化可以看到,需求同时朝着三个方向变化:1.文本显示功能和图像显示功能;2.Pc平台和手机平台;3.成人版本和儿童版本;这三个方向的高层逻辑变化各自是独立的,彼此之间并没有先后继承关系,只是在最后整合的时候才会关联。桥接模式就是针对这种一个类同时朝着几个方向发展的结构问题,它根据每个变化方向设计一个接口,分别演化,各方向之间通过组合的方式结合在一起,这样的结构就清晰多了。
- 设计
1 | //软件功能 |
注意
我这里是以平台为基准进行组合,也可以以使用群体为基准进行组合,或者说组合的方式是灵活的,可以很容易变化。每个变化方向之间通过“组合”的方式,类似于在各个方向之间建立了一座桥梁,故这个模式叫做桥接模式。这个模式的意义在于:它说明了==继承的劣势==,==组合的优势==,实际上,每一个类都需要在其他类的基础上进行工作,有时候需要进行功能方面的继承,有时候需要进行结构方面的继承,正确的方式应该是:继承用于功能继承,组合用于结构继承,即继承是为了使用父类中的方法和字段,省的子类再重复地写,而组合是为了使用父类的结构,好像零件的组装,把一个个零件做好了之后装在一起,然后又可以拆卸,而不是一股脑把所有零件浇筑在一起。所谓组合优于继承,就是指这个。
3. 组合(Composite)
「表示具有层次关系的一类结构」
组合模式相对来说好理解一些,数据结构中的树就是典型的组合模式,树的每一个结点都可以包含另一些结点
- 设计
1 | //结点接口 |
- 客户端
1 | Node root = new Stem(); |
4. 装饰(Decorator)
「给子类方便地添加一些功能」
适用于这种情况:一个类要添加一点点功能,这种功能可以动态地随时添加,随时删除,这时候就不能用派生子类的方法,因为一旦派生一个子类,里面的结构就是静态的,而且本身也不鼓励使用继承。比如说有了一个窗口类,现在仅仅是要给这个窗口加个边框,或者换一个边框,但不能改变窗口类的接口,也就是说,加或者没加边框,用户看起来都是同一个接口。
- 设计
1 | //窗口 |
- 客户端
1 | Window a = new Decorator(); //这里不考虑怎么给内部win对象的赋值,这个属于创建型模式需要解决的问题,下同 |
注意
从这里可以看到,a对象根本没觉察到赋给它的那个对象是原始对象还是装饰过的,反正接口是一样的。装饰模式在java库里有很多应用,比如说io包里的各个输入输出流都是包装过的,采用了装饰模式。这里的“装饰”可以有很多应用场景,比如说j2ee里的Servlet类是用来处理客户端http请求的,那么我们实际上可以设计一个Servlet的装饰类,让它做一些前期处理,或者后期处理,这个概念类似于过滤器(但过滤器自身的前置和后置处理是采用了职责链模式[以后介绍])。
5. 外观(Facade)
「把一组功能集中起来,提供一个统一的接口」
外观模式相对比较好理解,客户需要操作一组功能,需要牵涉到好几个类或者接口,那么我们可以把这些接口集中起来,通过一个类来向用户提供。这个模式的更大意义是,这个外观类可以更好地组织那些功能,以更好的方式提供给客户。
比如说需要构建一个软件,构建是一个系统工程,需要进行预处理、编译、链接、部署等等,但对于客户来说,他可以亲自动手一步步做,也可以交给一个构建程序(比如make)来做,他只要调用基本的命令(make)就行,以后的任务交给make来做,而且make做的比他本人一步步做可能效果更好
- 设计
1 | //预处理 |
- 客户端
1 | Facade f = new Facade(); |
注意
Facade类与各个功能类之间是组合关系,是松耦合的,很容易变化
6. 享元(Flyweight)
「有效解决使用大量重复的小对象的问题」
享元模式适用于这种情况:比如说设计一个文档编辑器,里面的基本对象是字符,简单的想法是每个字符为一个对象,存储字符的值以及放置的位置。这样似乎解决了问题,但是还是有可以优化的地方:一篇文档中存在大量相同的字符,相对应的这些字符对象仅仅是位置不同,这里似乎可以用“共享”的方式:如果遇到一个字符,先看这个字符对象是不是存在,不存在就创建,如果已经存在了,就使用这个对象,修改一下位置的值,然后画出这个字符。
具体在实现享元模式时,可以指定对象是否可以共享,如果不能共享,就每次都创建新对象。
- 设计
1 | //字符接口 |
- 客户端
1 | Character ch = CharacterFactory.getChar('a'); |
注意
这里客户端只是调用工厂方法来生产字符对象,但并不知道这个字符是新创建的对象,还是已经存在的对象
7. 代理模式(Proxy)
「控制对一个对象的访问」
适用于这种情况:要访问一个对象,但又要做一些限制或者控制,或者不能直接访问到这个对象,好比网络服务中的代理服务器。那么可以在访问者和被访问者之间建立一个代理类,访问者向代理类发出请求,由代理类负责来访问。
- 设计
1 | //需要访问的对象接口 |
- 客户端
1 | Subject obj = new Proxy(); |
三、行为模式
「职责链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者」
行为模式关注的是对象间的一些协作关系。
1. 职责链(Chain Of Responsibility)
「任务的转发」
适用于以下的场景:客户给出一个任务,但并不知道、也不想知道这个任务谁去执行,于是交给一个链实例,这个链实例会判断任务是否由其本身完成,如果不是,就转发给下一个实例,依次类推,直到有实例处理它或便利了所有的链实例都询问完成
网络中的数据包传递,就是这个原理,比如我要把一个消息发到某一个ip,我其实并不需要直到这个ip究竟在哪里,只要发给路由器,路由器判断这个数据包是否属于我管的这个范围,如果是就收下来,转发给下面的子网,如果不是就转发给另一个路由器;子网里面也是这个工作方式,数据包被一站一站地转发,直到最后到达目标ip的计算机。
更为贴切web开发的例子是web开发中的过滤器,FilterChain和chain.doFilter这样的特征代码就是典型的职责链模式
另一个例子是阿里的Druid数据库连接池,使用该模式对JDBC做了扩展
- 设计
1 | //操作总接口 |
- 客户端
1 | Handler start = new HandlerA(); |
注意
可以看到,客户只要简单地把任务交给第一个类,下面就可以自动地进行转发和处理,客户无需关心具体是哪个类来处理的,或者是消息是如何发到那个类的,因为接口都是一致的。
2. 命令(Command)
「暂缺,该模式用的不多」
- 设计
1 |
- 客户端
1 |
注意
3. 解释器(Interpreter)
「给定一种语言,定义一个解释器来解释」
可以用在以下场景:如果要设计一个编程语言的解释器,该语言(简单点)具有两种符号:终结符和非终结符,非终结符中包含有其他的符合序列。
- 设计
1 | // 语言的上下文 |
4. 迭代器(Iterator)
适用于以下场景:客户需要依次遍历一个数据集合中的元素,但并不清楚这个集合里元素的排列顺序,也不想知道。如果知道了这个集合中的结构,把遍历方法写死在代码里,那么反而不好。迭代器模式给出了遍历集合元素的一个接口,同时又不暴露集合内部的实现细节。
再进一步,采用直接访问集合元素的方法,即使集合结构没有变化,也会导致问题,比如说利用数组实现的线性表存储元素,一开始我们通过数组下面遍历里面的元素,后来需求变了,要求倒序访问,那么遍历的代码就要修改,这违反了开闭原则。而如果使用迭代器的话,客户代码无需变化,因为客户根本就不知道这些元素是什么顺序提供的。
- 设计
1 | // 迭代接口 |
- 客户端
1 | DataSet a = new DataSetA(); |
迭代器模式的实现相对是比较复杂的,所以还是最好参考UML类图为好,不过使用起来是很简单的,java语言内置了迭代器,比如说Collection框架中的所有数据结构类,都实现了Iterator接口,都可以直接使用jdk提供的迭代器。这23中设计模式里有一些是java语言内置提供的,从这个意义上说,这些模式应该是比较重要和常用的。
5. 中介者(Mediator)
「简化一系列对象间的通信交互」
面向对象中有一个单一职责原则,按照这个原则来编程,对象负责什么一目了然,但往往会形成很多个对象,对象间的通信会变得很复杂,因为每个对象都需要知道和它通信的是哪些对象,如果采取中介者,则对象与另一个对象的交互问题,可以交给这个中介者,对象之间可以不必知道对方是谁,在哪里。
- 设计
1 | // 中介者接口 |
有了中介者模式,对象间的交互就变得非常简单,简单地把消息交给中介者就行了。
6. 备忘录(Memento)
游戏都能存档读档,但你想过这个是如何实现的吗?备忘录模式就是来做这个的
- 设计
1 | // 备忘录,或者是存档 |
- 客户端
1 | Game game=new Game(); |
7. 观察者(Observer)
监听
这个模式比较好理解,Swing里面的控件监听机制就是采用这个实现的。一个对象A需要监听另一个对象B的状态,而对象C也在监听B的状态,怎么采取一种好的方法,来实现B的状态变化之后,会通知到A和C,让它们做相应的动作,这便是观察者模式。A和C是观察者,B是被观察者。
- 设计
1 | // 观察接口,所以观察者都要实现该接口 |
- 客户端
1 | // 建立被观察对象 |
观察者模式是一个重要的接口,java swing里面的事件监听机制就是使用这个模式的典型代表,swing里面把“观察”叫做“监听”,比如说一个按钮按下之后,一个Label需要显示一些文本,另一个Label需要显示图像,那么按钮就是一个事件源,两个Label就是监听器。可以在按钮里这么写button.addActionListener(new ActionListener()); 这里的addActionListener()方法就相当于这里例子里的add,ActionListener就是这里的Observer接口。Java里面也自带了观察者模式的接口和类,可以直接使用,但我感觉并不实用,一来这些类的接口定死了,不适用于具体情况,二来这个模式很好写,没必要用系统带的。
8. 状态(State)
考虑这样一个应用:设计一个汽车前灯的开关程序,它有三个状态变化:关->近光灯->远光灯->关,也就说每按一下,就会变成下一个状态,你会如何设计?如果不考虑面向对象的设计,一般人都会这么设计:用if语句来判断,如果当前是“关”,则按下之后,变为近光,如果是近光,按下之后变远光。。。,这种做法有两个缺点:1、会有很多if语句,2、难以适应新的变化,比如中间又加了两个新状态(朝左照射和朝右照射),那么众多if语句就要做不少修改。
如果又加了一个新需求,要让用户能够自定义转换的顺序,比如说从关到远光,那么原来的if语句的代码,基本就要废弃重写,而状态模式可以轻松解决上述问题。
- 设计
1 | // 定义状态接口 |
- 客户端
1 | //初始状态为关 |
状态模式把各个状态变成一个个局部实体,并把将来可能的变化都限制在局部范围里,把各个状态实体与客户端的代码解耦,使得状态类的修改,并不会引起客户端的变化。
注意:下面这种设计
- 设计
1 | class Switch{ |
- 客户端
1 | Switch swi=new Switch(); |
看起来如果修改了Switch类,客户端的代码并没有动,对吗?这只是从代码书写层面看没动,但是实际上修改了Switch类,Switch类当然会被重新编译,但是客户端的代码也要因此重新链接,所以实际上客户端是被影响的,这一点注意。
反过来看上面的状态模式的例子,由于客户端的是针对接口编程的,因此并不需要重新链接,Switch里的press()方法中的operation()方法是“动态绑定”或者叫“延迟绑定”的(也就是多态),当运行时才会去寻找究竟是哪个对象。所以即使修改了State对象,客户端的代码并没有受影响。这才是真正的松耦合,也是为什么多态是面向对象的核心机制。
9. 策略(Strategy)
「把算法部分独立出来」
这是个很有价值的模式,考虑这样一个软件的设计:要编写一个商场收银软件,商场的商品定价机制是很灵活的,经常会根据节假日或者什么原因临时将商品打折,打折的幅度和计算方法也会经常变,那么就给代码编写带来了难度,按照以往常规的方法,会采用很多if语句来写,这显然是一种不好的方法,一个商场有成千上万种商品,每一种商品会有很多折扣方法,难道要写成千上万个if语句吗?面向对象设计的一个原则就是:把可能变化的部分独立成接口,进行灵活的扩展,策略模式正是体现了这一点。
- 设计
1 | // 算法接口 |
- 客户端
1 | Casher cash=new Casher(); |
策略模式的结构非常简单,所以往往用来作为设计模式入门的例子,当然这里的setStrategy方法还是比较原始的,如果结合工厂方法等,就更加好了。
10. 模板方法(Template Method)
适用于这样的情况:有个复杂算法,由许多小算法组成,大致的框架已经可以确定,但是这些小算法需要到运行时才能确定调用哪个,或者说需要临时更换成另一个小算法。简而言之就是框架已经定了,细节还没定。模板方法就是用来解决这个问题,这同时也是很多类库和框架的设计思路,在传统的面向过程程序设计中,由于是上层函数调用下层函数,因此实际上,上层框架逻辑函数是依赖于底层实现的,底层函数没写好,上层就不能完成编译,底层函数修改了,上层函数也要被重新链接,这明显是不合理的。面向对象由于采取了多态机制,因此上层框架可以独立出来,不受底层的影响,要替换掉底层,也不会影响上层框架。
考虑一个做菜的程序,比如要做鱼汤,大致步骤是确定的:先放油,再煎鱼,再放水,再放调料,但是具体放多少油,放多少水,用什么鱼,这个具体再说,或者也可以先把鱼定下来,其他部分以后再说,这个看具体设计。
- 设计
1 | // 抽象类,没定下来的操作作为抽象方法 |
- 客户端
1 | DoFish o=new OneDoFish(); |
11. 访问者(Visitor)
这应该是23中模式中最复杂的一种了,但是和桥接模式一样,可以从中受到很大的启发。
总结
现有的这23种模式已经介绍完了, 我们应该注重体会各个设计模式的使用场景以及设计初衷,然后在适当的地方使用它们,做到物尽其用。
虽然这里介绍的是代码的设计模式,但其实我们应该站在宏观的角度看___设计模式___这个词。宏观来看,我们在生活和工作中___反复实践___总结出的经验都可以是设计模式,这里强调了___反复实践___,在实际应用场景中反复实践求证,这样出来的经验总结才能称作是设计模式,指导我们做好我们要做的事情。