GoF设计模式——结构型设计模式
在面向对象程序设计中,很多个对象要被组织在一起协同工作,结构型设计模式给出了一些通用的组织对象的方法。
适配器模式(Adapter)
在两种不相容的组件之间造一座桥,使得我们可以复用已有组件实现另一个组件,即使它们不相容,这就是适配器模式。
动机
你在编写一个绘图软件,Shape
是所有图形的接口,目前你已经有了一些基本的形状,但是你想实现一个TextShape
,它可以绘制可自由编辑的文本。
TextShape
的实现可能很复杂,不过你注意到由你的伙伴编写的GUI部分有一个TextView
控件,它是一种可以自由编辑的文本UI控件。可惜,由于GUI设计时并没考虑到TextView
可能被复用,所以TextView
类的接口和Shape
并不兼容,我们没法把TextView
用作一个Shape
。
此时,无需放弃并自己从头实现一个TextShape
,你可以创建一个实现了Shape
的TextShape
,并在其中持有一个TextView
的实例,把对于Shape
接口的请求转换成对TextView
的请求,在两种不相容的组件之间造一座桥,使得我们可以复用已有组件实现另一个组件,即使它们不相容,这就是适配器模式。
如上图,TextShape
将Shape
接口中的BoundingBox
转换成了TextView
中的GetExtent
的调用,而且,即使是对于TextView
中没有提供类似方法的CreateManipulator
,你也可以手动的实现一个。
适配器模式有两种实现方式:
- 对象实现方式:即在适配器类中持有被适配的组件的实例,就像上面的例子
- 类实现方式:即让适配器类既实现要适配成的接口,又继承要被适配的类,对于上面的例子,就是
TextShape
实现Shape
并继承TextView
。说白了就是组合和继承的区别。
适用性
- 你想使用一个已经存在的类,而它的接口不符合你的需求。
- 你想创建一个可以复用的类,该类可以与其他不相关的类或不可预见的类(即那些接口 可能不一定兼容的类)协同工作。
题外话,比如Android中的各种在数据和UI组件间提供适配的Adapter,因为UI组件设计时并不知道用户的数据是什么样的,如果你不想强迫这个数据必须符合某种格式,那一种实现方式就是提供Adapter,让用户来实现数据到UI组件的转换,比如ListAdapter)
- (仅适用于对象Adapter)你想使用一些已经存在的子类,但是不可能对每一个都进行子类化以匹配它们的接口。对象适配器可以适配它的父类接口。
结构
对象实现方式
类实现方式
参与者
在此部分,给出该设计模式中的关键组件,为了便于练习,我不会将这里所述的组件与上面示例中的组件一一对应,你需要自己思考并对号入座。如果不确定,再往下一点就是答案。
Target
:Adapter模式中要将不相容接口转换到的目标接口Client
:目标接口的使用者Adaptee
:要复用的不相容组件,被适配的组件Adapter
:适配之后的组件,它是目标接口的一个实现类
Target: Shape
Client: DrawingEditor
Adaptee: TextView
Adapter: TextShape
桥接模式(Bridge)
将某些实现移到单独的接口中,通过委托来建立原接口到新接口之间的桥梁,使得这些实现与原接口可以独立变化,这就是桥接模式。
动机
考虑我们在设计一个GUI工具箱,这个GUI工具箱可以移植到多种GUI库上,比如X Window System
和Presentation Manager
上。很容易想到的做法就是提供一个Window
接口,然后根据不同的GUI库去编写不同的实现,如下图左。
我们甚至还能说出上图的一些好处,比如Window
接口可以将用户与具体GUI平台的实现解耦。确实,这是接口带来的好处,但是我一直都没想过,接口的实现类与接口是紧紧耦合的,我认为这种耦合理所当然。
看上面的右图展示了这种情况下会发生的一些问题:
- 我们想提供一个具有图标的窗口类型——
IconWindow
,那么你要针对XWindow
和PMWindow
分别实现不同的版本,如果你想再支持其它类型的窗口,你都得实现两个不同版本 - 系统中和特定GUI框架相关的类一下子增多了,我们没法便捷的切换GUI实现。
- 由于实现类成倍增加,改动接口很困难。因为接口的改动会直接反映到所有实现类上,所有的实现类都得修改。
下面看看Bridge模式如何解决这一问题:
Bridge模式将之前的基于继承(或者说实现)的开发方式转换为基于组合的方式,不再把对应平台相关的Window类直接实现自Window
接口了,而是单独用一个WindowImp
接口解耦合,让之前的Window
接口持有WindowImp
的一个实例,并且使用WindowImp
接口的功能来实现Window
的功能。
将某些实现移到单独的接口中,通过委托来建立原接口到新接口之间的桥梁,使得这些实现与原接口可以独立变化,这就是桥接模式。
适用性
- 你不希望在抽象和它的实现部分之间有一个固定的绑定关系。例如这种情况可能是因为, 在程序运行时刻实现部分应可以被选择或者切换。
- 类的抽象以及它的实现都应该可以通过生成子类的方法加以扩充。这时Bridge模式使你可以对不同的抽象接口和实现部分进行组合,并分别对它们进行扩充。
有点绕嘴,就是对于
IconWindow
和那些特定于GUI实现的Window可以独立发展了。 - 对一个抽象的实现部分的修改应对客户不产生影响,即客户的代码不必重新编译。
原先的实现中,特定于GUI库的类很多,客户很难不直接使用到它们,这不利于客户切换到其它GUI平台。
- 有许多类要生成。这样一种类层次结构说明你必须将一个对象分解成两个部分。 Rumbaugh称这种类层次结构为“嵌套的普化”(nested generalizations)
- 你想在多个对象间共享实现(可能使用引用计数),但同时要求客户并不知道这一点。
在上面的例子中,各种类型的
Window
实现共享WindowImp
的一个实现
结构
参与者
在此部分,给出该设计模式中的关键组件,为了便于练习,我不会将这里所述的组件与上面示例中的组件一一对应,你需要自己思考并对号入座。如果不确定,再往下一点就是答案。
Abstraction
:用户最终使用的抽象接口,它需要定义API,维护到Implementor的一个引用RefinedAbstraction
:抽象接口的一个实现,也是由用户最终使用的实现类Implementor
:你想要提取出去的一批实现类的接口,它的API不非要和Abstraction的一致ConcreteImplementor
:被提取出去的具体实现类
Abstraction: Window
RefinedAbstraction: IconWindow, TransientWindow
Implementor: WindowImp
ConcreteImplementor: XWindowImp, PMWindowImp
组合(Composite)
使用一个宽泛的接口,使你能够忽略特定实现之间的差异,并方便的在所有实现之间组织层次结构,这就是组合模式。
动机
考虑绘图软件中,你可以绘制一个简单图形,如Text
、Line
,你也可以绘制由简单图形组合而成的一些复杂图形。如何实现一个可组合的复杂图形?组合模式是这样实现的:
任何Graphic
都具有Add
、Remove
和GetChild
方法,这意味着任何图形都被看做一个容器,而只有复杂图形类——Pitcure
实现了这些方法,其它的都没有实现(你可以抛出异常或忽略请求)。这样做的好处是开发者可以忽略复杂图形和简单图形之间的区别,这也是组合模式所鼓励的。我们很容易就可以建立出下图的结构:
使用一个宽泛的接口,使你能够忽略特定实现之间的差异,并方便的在所有实现之间组织层次结构,这就是组合模式。
适用性
- 你想表示对象的部分-整体层次结构。
- 你希望用户忽略组合对象与单个对象的不同,用户将统一地使用组合结构中的所有对象。
结构
参与者
在此部分,给出该设计模式中的关键组件,为了便于练习,我不会将这里所述的组件与上面示例中的组件一一对应,你需要自己思考并对号入座。如果不确定,再往下一点就是答案。
Component
:一个接口,提供基本功能以及在实现间建立层次结构的功能Leaf
:对象层次结构中的叶子节点Composite
:对象层次结构中的非叶子节点
Component: Graphic
Leaf: Text, Line, Rectangle
Composite: Picture
装饰器模式(Decorator)
所以装饰器就是采用组合模式动态、透明的给对象扩展功能。
动机
假设你在创建一款组件库,VisualComponent
是组件库中所有可视组件的基类,TextView
是其下一个用于显示文本的组件。
假如你想对组件添加滚动条功能,首先想到的办法是在VisualComponent
中添加,并且维护一个变量来控制滚动条是否显示。突然,你想对组件添加外边框功能,也好,你还可以扩展VisualComponent
,你不停的扩展,VisualComponent
已经大到难以维护,这时,对于手中的新需求,你开始怀疑,我是否应该在VisualComponent
中添加这个功能?
何不把滚动条和边框也看成一种组件?这种组件的功能就是对其它组件提供装饰,你把一个组件传递给滚动条,滚动条对该组件进行包装,绘制滚动条。这样逻辑都到了滚动条组件里,而且用户添加滚动条的方式更加灵活了,如下图所示:
UML图:
作为装饰器的类持有一个它所装饰的组件component
,在它的Draw
方法中,先调用component.Draw
绘制底层组件,再通过一个自己的方法绘制自己应该绘制的东西。所以装饰器就是采用组合模式动态、透明的给对象扩展功能。
适用性
- 在不影响其他对象的情况下,以动态、透明的方式给单个对象添加职责
- 处理那些可以撤消的职责。
- 当不能采用生成子类的方法进行扩充时。一种情况是,可能有大量独立的扩展,为支持每一种组合将产生大量的子类,使得子类数目呈爆炸性增长。另一种情况可能是因为类定义被隐藏,或类定义不能用于生成子类。
它的意思应该是,创建一个
ScrollableTextView
,继承自TextView
,可滚动。
结构
参与者
在此部分,给出该设计模式中的关键组件,为了便于练习,我不会将这里所述的组件与上面示例中的组件一一对应,你需要自己思考并对号入座。如果不确定,再往下一点就是答案。
Component
:所有组件的接口,包括正常组件、装饰器组件ConcreteComponent
:某种正常组件的一个实现Decorator
:装饰器接口,用于对正常组件进行装饰ConcreteDecorator
:装饰器的具体实现
Component: VisualComponent
ConcreteComponent: TextView
Decorator: Decorator
ConcreteDecorator: ScrollDecorator, BorderDecorator
外观模式(Facade)
为复杂的子系统提供一个统一的接口(界面),方便外界调用而不用了解子系统中各个组件的细节以及组件间的关系
动机
为复杂的子系统提供一个统一的接口(界面),方便外界调用而不用了解子系统中各个组件的细节以及组件间的关系,同时并不隐藏子系统中的组件,了解子系统的人可以越过统一接口直接使用这些组件。
上图左是没有统一接口情况下,外部对子系统的调用,外部需要了解子系统间的组件细节,上图右是提供了统一接口后外部对子系统的调用,所有调用都发送到统一接口,外部只需要了解这个接口即可。所以好像Facade模式也叫门面模式。
下面是一个编译子系统对外界提供的统一接口,Compiler
调度系统中的各个部分向外部提供简单的编译功能。
适用性
- 当你要为一个复杂子系统提供一个简单接口时。
- 客户程序与抽象类的实现部分之间存在着很大的依赖性。引入facade将这个子系统与客户以及其他的子系统分离,可以提高子系统的独立性和可移植性。
- 当你需要构建一个层次结构的子系统时,使用 facade模式定义子系统中每层的入口点。 如果子系统之间是相互依赖的,你可以让它们仅通过 facade进行通讯,从而简化了它们之间的依赖关系。
参与者
Facade
:整个子系统向外提供的统一接口Subsystem classes
:子系统中的类
享元模式(FLYWEIGHT)
把程序中大量使用到的对象预先建立,在程序其它位置共享它们以节省开销,这就是享元模式。
动机
假设你在开发一款ASCII文本编辑器,这个文本编辑器支持样式、嵌入对象等功能。使用面向对象开发时,我们很容易把每个嵌入对象(如图片、音频)都当作一个对象处理,很自然的,我们也会把每个字符都当作一个对象处理。
但文本编辑器中的文本可能由大量字符组成,这每个字符一个对象的开销完全是受不了的。这时我们可以考虑把ASCII字符集中的所有字符都创建出一个对象,组成一个对象池,并在文本编辑器中直接使用这些对象。
把程序中大量使用到的对象预先建立,在程序其它位置共享它们以节省开销,这就是享元模式。
Java中的Boolean
类就用到了享元模式,因为你在程序中大量使用到Boolean(true)
和Boolean(false)
这两个值,用到就创建一个对象,开销很大,所以Boolean
类预先创建好了这两个对象,你可以通过Boolean.TRUE
和Boolean.FALSE
来使用。
享元模式的对象维护一个内部状态,像我们的文本编辑器的例子,内部状态就是对象代表的ASCII字符。同时它也可以有外部状态,外部状态是运行时才能确定的,比如该字符该绘制到屏幕的哪个位置,所以这个字符对象可以维护一个操作方法,比如Draw(posX, posY)
,来接收两个外部状态并将字符绘制到屏幕上。
适用性
- 一个应用程序使用了大量的对象。
- 完全由于使用大量的对象,造成很大的存储开销。
- 对象的大多数状态都可变为外部状态。
- 如果删除对象的外部状态,那么可以用相对较少的共享对象取代很多组对象。
- 应用程序不依赖于对象标识。由于Flyweight对象可以被共享,对于概念上明显有别的对象,标识测试将返回真值。
这应该是说,比如文本编辑器中的例子,我们的文本编辑器不要求第三行第二列的字符
a
和第四行第一列的字符a
的标识不同,即即使它们通过==
判断后得到的结果是true
也不会对我们的程序造成影响
结构
参与者
FlyweightFactory
:创建享元对象的工厂Flyweight
:享元对象的接口,规定了享元对象接收外部状态的操作ConcreteFlyweight
:具体的享元对象实现UnsharedConcreteFlyweight
:不共享的享元对象。emmmm,我想这多半是为了在编码过程中屏蔽那些需要做成享元对象和正常对象的区别的吧,所以把不需要共享的对象也实现了Flyweight
接口,让它们具有一致的操作方式。比如Row
、Column
不需要被共享,但是为了和每个字符具有一致的操作方式,也实现Flyweight
接口。
代理模式(Proxy)
代理模式用来提供对一个真实对象的访问控制。
动机
举个例子,ORM框架中,表之间平坦的一对一关系一般会被映射成编程语言中对象的嵌套关系。比如:
class Student {
private Integer id;
private String name;
// ...
// 学生所在的系
private Department department;
}
这种关系在关系型数据库中往往通过两张表来存储,Student表中的外键dept_id
用来到Department
表中查找所在系:
Student表:
id | name | ... | dept_id |
---|---|---|---|
1 | 王狗蛋 | ... | CS |
2 | 李猪八 | ... | BIO |
Department表:
id | name |
---|---|
CS | Comp. Sci. |
BIO | Biology |
所以,ORM框架可能需要两条SQL语句才能完成Student
对象的构建:
# 这条语句查询出指定的Student
SELECT * FROM Student WHERE id=#{student_id};
# 根据上一条语句中的dept_id,去Department中查找对应的部门
SELECT * FROM Department WHERE id=#{dept_id}
而发送一条SQL语句是昂贵的,并且用户可能并不需要访问构造好的Student对象的department
属性,这样第二个SQL等于白发。
所以,ORM框架倾向于先发一条SQL语句,然后创建Student的一个代理并返回,当用户尝试访问Student的department属性时,再发第二条SQL语句,并初始化它的department属性。
所以,这就是我们为什么要对对象进行访问控制的原因之一——节约开销。
适用性
- 远程代理(Remote Proxy)为一个对象在不同的地址空间提供局部代表。
- 虚代理(Virtual Proxy)根据需要创建开销很大的对象。比如Student.department
- 保护代理(Protection Proxy)控制对原始对象的访问。保护代理用于对象应该有不同的访问权限的时候。
Java中的
Collections.unmodifiableList
,你提供一个普通的List,它会返回该List的一个代理对象,对于所有对List进行修改的操作都不会被允许。Kotlin中大量的使用了这种模式。 - 智能指引(Smart Reference)取代了简单的指针,它在访问对象时执行一些附加操作。 它的典型用途包括:
- 对指向实际对象的引用计数,这样当该对象没有引用时,可以自动释放它。一般用于自动内存管理系统,也就是GC。
- 当第一次引用一个持久对象时,将它装入内存。
- 在访问一个实际对象前,检查是否已经锁定了它,以确保其他对象不能改变它。
结构
参与者
Subject
:被代理对象和代理对象的共用接口,因为对于使用者来说,它并不了解它拿到的对象已经是代理对象了。RealSubject
:真实对象,也就是被代理的对象Proxy
:代理对象,在这里对真实对象进行访问控制