前言
因为设计模式种类多,且重理解重回忆,所以本文尽量言简意赅,便于时时温习。
设计模式(Design Pattern)是前辈们对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。
1995年,GoF(Gang of Four,四人组/四人帮)合作出版了《设计模式:可复用面向对象软件的基础》一书,共收录了23种设计模式,从此树立了软件设计模式领域的里程碑,人称「GoF设计模式」。
这 23 种设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性,以及类的关联关系和组合关系的充分理解。
当然,软件设计模式只是一个引导,在实际的软件开发中,必须根据具体的需求来选择:
对于简单的程序,可能写一个简单的算法要比引入某种设计模式更加容易;
但是对于大型项目开发或者框架设计,用设计模式来组织代码显然更好。
我们要清楚,设计模式并不是Java的专利,它同样适用于C++、C#、JavaScript等其它面向对象的编程语言。
设计原则
开闭原则
开闭原则的含义是:当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。
里氏替换原则
里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
依赖倒置原则
依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。其核心思想是:要面向接口编程,不要面向实现编程。
依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。
单一职责原则
单一职责原则又称单一功能原则,由罗伯特·C.马丁(Robert C. Martin)于《敏捷软件开发:原则、模式和实践》一书中提出的。这里的职责是指类变化的原因,单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
该原则提出对象不应该承担太多职责,如果一个对象承担了太多的职责,至少存在以下两个缺点:
- 一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力;
- 当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。
迪米特法则
迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话。其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
模式分类
根据目的来分类
根据模式是用来完成什么工作来划分,这种方式可分为创建型模式、结构型模式和行为型模式3种。
创建型模式:用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。
GoF中提供了单例、原型、工厂方法、抽象工厂、建造者等5种创建型模式。
结构型模式:用于描述如何将类或对象按某种布局组成更大的结构。
GoF中提供了代理、适配器、桥接、装饰、外观、享元、组合等7种结构型模式。
行为型模式:用于描述类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,以及怎样分配职责。
GoF中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等11种行为型模式。
根据作用范围来分类
根据模式是主要用于类上还是主要用于对象上来分,这种方式可分为类模式和对象模式两种。
类模式:用于处理类与子类之间的关系,这些关系通过继承来建立,是静态的,在编译时刻便确定下来了。
GoF中的工厂方法、(类)适配器、模板方法、解释器属于该模式。
对象模式:用于处理对象之间的关系,这些关系可以通过组合或聚合来实现,在运行时刻是可以变化的,更具动态性。
GoF中除了以上4种,其他的都是对象模式。
创建型模式 | 结构型模式 | 行为型模式 | |
---|---|---|---|
类模式 | 工厂方法 | (类)适配器 | 模板方法、解释器 |
对象模式 | 单例、原型、抽象工厂、建造者 | 代理、(对象)适配器、桥接、装饰、外观、享元、组合 | 策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录 |
1 代理模式
【介绍】:
在代理模式(Proxy Pattern)中,一个类代表另一个类的功能。这个类叫做代理类。在有些情况下,一个客户不能或者不想直接访问另一个对象,这时需要找一个中介帮忙完成某项任务,这个中介就是代理对象。
【比喻】:
购买火车票不一定要去火车站买,可以通过12306网站或者去火车票代售点买。又比如租房子,可以通过找中介完成。
【优点】:
- 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
- 代理对象可以扩展目标对象的功能;
- 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度,增加了程序的可扩展性
【缺点】:
- 代理模式会造成系统设计中类的数量增加
- 在客户端和目标对象之间增加一个代理对象,会造成请求处理速度变慢;
- 增加了系统的复杂度;
那么如何解决以上提到的缺点呢?答案是可以使用动态代理方式
【应用】:
spring AOP中就大量使用了代理模型。
【案例】:
我们将创建一个Image接口和实现了Image接口的实体类。ProxyImage是一个代理类,减少 RealImage对象加载的内存占用。
ProxyPatternDemo类使用ProxyImage来获取要加载的Image对象,并按照需求进行显示。
1 | public interface Image { |
当被请求时,使用 ProxyImage 来获取 RealImage 类的对象。
1 | public class ProxyPatternDemo { |
2 适配器模式
【介绍】:
适配器模式(Adapter Pattern)是作为两个不兼容的接口之间的桥梁,它可以将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。
适配器模式分为类结构型模式和对象结构型模式两种,前者类之间的耦合度比后者高,且要求程序员了解现有组件库中的相关组件的内部结构,所以应用相对较少些。
适配器不是在设计时添加的,而是解决正在服役的项目的不兼容问题。
【比喻】:
在现实生活中,经常出现两个对象因接口不兼容而不能在一起工作的实例,这时需要第三者进行适配。
例如:
- 讲中文的人同讲英文的人对话时需要一个翻译
- 用直流电的笔记本电脑接交流电源时需要一个电源适配器
- 用计算机访问照相机的SD内存卡时需要一个读卡器等。
【优点】:
- 客户端通过适配器可以透明地调用目标接口。
- 复用了现存的类,程序员不需要修改原有代码而重用现有的适配者类。
- 将目标类和适配者类解耦,解决了目标类和适配者类接口不一致的问题。
- 在很多业务场景中符合开闭原则。
【缺点】:
- 适配器编写过程需要结合业务场景全面考虑,可能会增加系统的复杂性。
- 增加代码阅读难度,降低代码可读性,过多使用适配器会使系统代码变得凌乱。
【应用】:
spring AOP中的MethodInterceptor接口被用来拦截指定的方法,对方法进行增强。
【案例】:
例如一个美国人说英语,一个中国人说中文,为了跟美国人做生意,就需要一个适配器,来充当沟通两者的工作。现在,我们希望让一个能说中国话的个体(实现说中文的接口的类),开口说英文。
适配器有两种主要的实现,我们先看第一种——类适配器
类适配器:
1 | // 被适配类,已存在的、具有还有用处的特殊功能、但不符合我们既有的标准接口的类 |
对象适配器:
另外一种适配器模式是对象适配器,它不是使用多继承或继承再实现的方式,而是使用直接关联,或者称为委托的方式。
其他目标类和被适配类都一样,就是适配器类的定义方式有所不同:
1 | // 适配器类,直接关联被适配类,同时实现标准接口 |
3 桥接模式
【介绍】:
桥接(Bridge Pattern)是用于把抽象化与实现化解耦,使得二者可以独立变化。它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。
在现实生活中,某些类具有两个或多个维度的变化,如图形既可按形状分,又可按颜色分。如何设计类似于Photoshop这样的软件,能画不同形状和不同颜色的图形呢?
如果用继承方式,m种形状和n 种颜色的图形就有m×n种结果,不但对应的子类很多,而且扩展困难。
桥接模式是用组合关系代替继承关系来实现,从而降低了抽象和实现这两个可变维度的耦合度。
【比喻】:
文字类桥接了颜色和字体的接口,实现组合成了不同颜色和字体的文字。
汽车类桥接了扭矩和功率的接口,实现组合成了不同扭矩和功率的汽车。
【优点】:
- 抽象与实现分离,利用组合关系,扩展能力强
- 符合开闭原则和合成复用原则
- 内部实现细节对客户透明
【缺点】:
由于聚合关系建立在抽象层,要求开发者针对抽象化进行设计与编程,能正确地识别出系统中两个独立变化的维度,这增加了系统的理解与设计难度。
【应用】:
Java的JDBC中,Driver类就是桥接对象,它组合了Connection/DriverPropertyInfo[]/Logger等功能类
1 | public interface Driver { |
【案例】:
我们有一个作为桥接实现的DrawAPI接口和实现了DrawAPI接口的实体类RedCircle、GreenCircle。Shape是一个抽象类,将使用DrawAPI的对象。BridgePatternDemo类使用Shape类来画出不同颜色的圆。
1 | // 绘画功能的api接口,以及两种颜色功能的功能类 |
1 | // 要桥接的对象类及其父类,注意,桥接进来的功能是组合在抽象层Shape上。 |
1 | // demo |
如果除了绘画维度的变化,还有材质维度的变化,那类似的,定义相关功能的接口,然后并列地组合在抽象层
4 装饰模式
【介绍】:
装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。
通常情况下,扩展一个类的功能会使用继承方式来实现。但继承具有静态特征,耦合度高,并且随着扩展功能的增多,子类会很膨胀。
如果使用组合关系来创建一个包装对象(即装饰对象)来包裹真实对象,并在保持真实对象的类结构不变的前提下,为其提供额外的功能,这就是装饰模式的目标。
【比喻】:
在《绝地求生:刺激战场》游戏里面我们都知道。枪支装上4倍镜后可以进行4倍瞄准;装上8倍镜后可以进行4倍瞄准、8倍瞄准。
四倍,八倍瞄准就是对现有对象的功能拓展。【拥有八倍镜的枪】和【拥有四倍镜的枪】就是两个装饰类,他们都不是通过继承来实现枪的功能,而是通过持有一个枪实现枪的功能。
故而【拥有八倍镜的枪】对象更像是持有了一把枪,并在内部实现了八倍瞄准功能。
如果使用继承模式,那么98k这把枪要实现【4倍98k】和【8倍98k】这两个子类,m4这把枪同样要实现两个子类,非常不灵活。
用装饰模式就很灵活了,不管是什么枪,都可以组合进【拥有八倍镜的枪】这个类中,甚至我们可以将【拥有四倍镜的枪】组合进【拥有八倍镜的枪】中,同时得到两种能力增强。
【优点】:
- 装饰器是继承的有力补充,比继承灵活,在不改变原有对象的情况下,动态的给一个对象扩展功能,即插即用
- 通过使用不同的装饰类,原有对象可以实现不同效果。
- 装饰器模式完全遵守开闭原则
【缺点】:
- 装饰模式会增加许多子类,过度使用会增加程序得复杂性。
- 装饰模式提供了一种比继承更加灵活机动的解决方案,但同时也意味着比继承更加易于出错,排错也很困难,对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为繁琐。
【应用】:
Java sdk的io包中,inputStream类和outputStream类使用的就是装饰模式,以输入流为例,BufferedInputStream和ByteArrayInputStream等类都继承了inputStream,他们都是装饰类。
如果要给一个输入流装饰缓冲池的功能和读各种基本类型数据的功能,那么可以这么使用:
DataInputStream in=new DataInputStream(new BufferedInputStream(new FileInputStream("D:\\hello.txt")));
装饰模式和代理模式很像,这两个模式的UML图都是一样的。但这两个模式在含义上有点差别。
代理模式是原对象做不了那件事,必须让代理对象去做,主导侧重于代理对象,比如说买车。
装饰模式是说,就是让原对象直接去做这件事,只是功能上增强一点,主导在于原对象。比如说炒菜的时候撒点盐。
【案例】:
以前文的《绝地求生:刺激战场》游戏为例,我们定义一个gun的接口,一个Kar98K的具体gun,以及可以拓展Kar98K的两个装饰对象,Telescope8XGun和Telescope4XGun。
1 | public interface Gun { |
定义两个不同功能的装饰类:
1 | public abstract class AbstractTelescopeGun implements Gun { |
1 | public class Demo { |
发现没有,装饰模式可以装饰一个已经被装饰了的对象,比如new Telescope8XGun(gun)这句,此时的gun对象是Telescope4XGun对象。
5 外观模式
【介绍】:
外观模式(Facade Pattern)隐藏系统的复杂性,并向客户端提供了一个客户端可以访问系统的接口。
当一个系统的功能越来越强,子系统会越来越多,客户对系统的访问也变得越来越复杂。这时如果系统内部发生改变,客户端也要跟着改变,这违背了“开闭原则”,也违背了“迪米特法则”。
所以有必要为多个子系统提供一个统一的接口,从而降低系统的耦合度,这就是外观模式的目标。
【比喻】:
在现实生活中,常常存在办事较复杂的例子,如注册一家公司,有时要同多个部门联系,这时要是有一个统一的申请入口能解决一切手续问题就好了。
作为客户,不需要了解申请后会发生什么,可能背后涉及多个部门,但对客户来说是无感的。
【优点】:
- 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
- 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易。
- 降低了大型软件系统中的编译依赖性,简化了系统在不同平台之间的移植过程,因为编译一个子系统不会影响其他的子系统,也不会影响外观对象。
【缺点】:
感觉没啥缺点,我们在编写代码时已经有此类意识了。
【应用】:
在日常编码工作中,我们都在有意无意的大量使用外观模式。
只要是高层模块需要调度多个子系统(2个以上的类对象),我们都会自觉地创建一个新的类封装这些子系统,提供精简的接口,让高层模块可以更加容易地间接调用这些子系统的功能。
尤其是现阶段各种第三方SDK、开源类库,很大概率都会使用外观模式。
【案例】:
外观(Facade)模式的结构比较简单,主要是定义了一个高层接口。它包含了对各个子系统的引用,客户端可以通过它访问各个子系统的功能。现在来分析其基本结构和实现方法。
1 |
|
6 组合模式
【介绍】:
组合模式(Composite Pattern),又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。
组合模式一般用来描述整体与部分的关系,它将对象组织到树形结构中,顶层的节点被称为根节点,根节点下面可以包含树枝节点和叶子节点,树枝节点下面又可以包含树枝节点和叶子节点。
我们把树枝节点称为Composite(容器构件),把叶子节点称为Leaf(叶子构件),同时他们都是Component(抽象构件)。
在使用组合模式时,根据抽象构件类的定义形式,我们可将组合模式分为透明组合模式和安全组合模式两种形式。
透明组合模式
透明组合模式中,抽象构件角色中声明了所有用于管理成员对象的方法,譬如在示例中 Component 声明了 add、remove 方法,这样做的好处是确保所有的构件类都有相同的接口。透明组合模式也是组合模式的标准形式。
透明组合模式的缺点是不够安全,因为叶子对象和容器对象在本质上是有区别的,叶子对象不可能有下一个层次的对象,即不可能包含成员对象,因此为其提供add()、remove()等方法是没有意义的。
安全组合模式
- 在安全组合模式中,在抽象构件角色中没有声明任何用于管理成员对象的方法,而是在容器构件Composite类中声明并实现这些方法。
- 安全组合模式的缺点是不够透明,因为叶子构件和容器构件具有不同的方法,且容器构件中那些用于管理成员对象的方法没有在抽象构件类中定义,因此客户端不能完全针对抽象编程,必须有区别地对待叶子构件和容器构件。
一般我们常用的是安全组合模式
【比喻】:
其实就是我们常见的树状结构,用代码的方式表达出来。
【优点】:
- 组合模式使得客户端代码可以一致地处理单个对象和组合对象,无须关心自己处理的是单个对象,还是组合对象,这简化了客户端代码;
- 更容易在组合体内加入新的对象,客户端不会因为加入了新的对象而更改源代码,满足“开闭原则”;
【缺点】:
- 设计较复杂,客户端需要花更多时间理清类之间的层次关系;
- 不容易限制容器中的构件;
- 不容易用继承的方法来增加构件的新功能;
【应用】:
MyBatis的强大特性之一便是它的动态SQL,其通过if,where,foreach等标签,可组合成非常灵活的SQL语句,从而提高开发人员的效率。
Mybatis在处理动态SQL节点时,应用到了组合设计模式,Mybatis会将映射配置文件中定义的动态SQL节点、文本节点等解析成对应的SqlNode实现,并形成树形结构。
【案例】:
我们来实现一个简单的目录树,有文件夹和文件两种类型,首先需要一个抽象构件类,声明了文件夹类和文件类需要的方法
1 | public abstract class Component { |
实现一个文件夹类Folder,继承Component,定义一个 List
1 | public class Folder extends Component { |
文件类File,继承Component父类,实现getName、print、getContent等方法
1 | public class File extends Component { |
最后
1 | public class Test { |
1 | 设计模式资料 |
7 享元模式
【介绍】:
享元模式(Flyweight Pattern)尽可能的让用户复用已经有的对象,从而避免造成反复创建对象的资源浪费。主要用于减少创建对象的数量,以减少内存占用和提高性能。
在面向对象程序设计过程中,有时会面临要创建大量相同或相似对象实例的问题。创建那么多的对象将会耗费很多的系统资源,它是系统性能提高的一个瓶颈。
例如,围棋和五子棋中的黑白棋子,图像中的坐标点或颜色,局域网中的路由器、交换机和集线器,教室里的桌子和凳子等。这些对象有很多相似的地方,如果能把它们相同的部分提取出来共享,则能节省大量的系统资源,这就是享元模式的产生背景。
元模式的定义提出了两个要求,细粒度和共享对象。因为要求细粒度,所以不可避免地会使对象数量多且性质相近,此时我们就将这些对象的信息分为两个部分:内部状态和外部状态。
- 内部状态指对象共享出来的信息,存储在享元信息内部,并且不会随环境的改变而改变;
- 比如,连接池中的连接对象,保存在连接对象中的用户名、密码、连接URL等信息,在创建对象的时候就设置好了,不会随环境的改变而改变,这些为内部状态。
- 外部状态指对象得以依赖的一个标记,随环境的改变而改变,不可共享。
- 而当每个连接被占用时,我们将其标记为占用状态。要被回收利用时,我们需要将它标记为可用状态,这些为外部状态。
而我们需要共享的部分,就是内部状态的部分数据。外部状态的数据无法共享,需要从享元对象中剥离出来。
【比喻】:
无需比喻,享元模式就是我们常用的缓存的思想。
【优点】:
相同对象只要保存一份,这降低了系统中对象的数量,从而降低了系统中细粒度对象给内存带来的压力。
【缺点】:
- 为了使对象可以共享,需要将一些不能共享的状态外部化,这将增加程序的复杂性。
- 读取享元模式的外部状态会使得运行时间稍微变长。
【应用】:
- Java中的String,如果有则返回,如果没有则创建一个字符串保存在字符串缓存池里面。
- 数据库的数据池。
- Java中的线程池。
【案例】:
1 |
|
最后输出:
1 | 具体享元a被创建! |