Design-Pattern-Decorator
装饰模式(Decorator Pattern)是一种结构型设计模式,允许在不修改现有对象结构的前提下向对象添加新的功能。
场景
假设我们正在开发一款爬虫软件,并准备将爬取到的数据写入csv
文件中。按照模块化编程的思想,会把csv
相关的代码保存到一个独立的类CsvWriter
中。而后可能又需要把同样的数据写入txt
、json
、数据库中,也会诞生相应的TxtWriter
、JsonWriter
、SqlWriter
。基于每个XxWriter
做的事情相似,可以把通用行为抽取成接口DataWriter
中的方法。例如文件初始化,或者接收数据并写入文件的方法。客户端通过接口与不同实现类交互。
1 |
|
但我们并不会局限于某一特定格式,而是根据场景组合多个格式,例如把数据同时写入csv
和txt
中。如果不改变客户端代码,那么需要提供一个可以同时写入两种格式的CsvTxtWriter
。而这个类的实现代码,与CsvWriter
和TxtWriter
的代码高度重合,通常是依次调用CsvWriter
和TxtWriter
中的代码。因此相对于重写一个CsvTxtWriter
,更多会选择扩展已有代码。
扩展已有代码有两种方式,继承或组合。由于java
不支持多继承,只能选择继承其中一个类,然后把另一各类的代码重新写一遍。这怎么看都不优雅。使用组合则是在类中引入两个类的成员变量,在实现接口的方法时,把方法调用委派给对两个成员变量的调用。
1 |
|
但这样做的问题也很明显,如果有 \(n\)
个独立的XxWriter
,一共会有 \(2^n
- 1\)
种可能的组合。也就是需要定义的类数量很快会发生指数爆炸。所有组合中,只有
\(n\)
个基础的XxWriter
执行了实际代码,其他类只是委派调用这些基础类的方法。
从需求出发,我们希望编程语言提供这样的机制,能在运行时根据场景(例如通过读取配置文件,或者解析命令行参数),组合不同功能,动态生成所需的多功能类。这样就可以避免定义所有的可能组合,只需定义最基础的 \(n\) 个类即可。这就是装饰模式将完成的功能。
装饰模式
在上文中,通过组合的方式扩展CsvWriter
、TxtWriter
,在CsvTxtWriter
定义了两个被封装的具体类成员变量,封装/包装/组合/装饰(本文不加区分使用这些相似概念)的对象和被封装的对象是一对多的关系。装饰模式也是通过组合定义装饰类,但区别是只定义了一个抽象接口的成员变量。由于装饰类本身也实现了接口,因此实例可以再次作为其他装饰类的被封装对象,封装的对象和被封装的对象是一对一的关系。
1 |
|
整体上,装饰模式的多个装饰类封装后像是一个链表或者一叉树。调用装饰后对象的方法,则是通过持有链,进行先序遍历或后续遍历。换句话说就是先调用自身方法逻辑,还是先委派调用被装饰类的方法。
对于上例,基于装饰模式,则可以将 \(n\) 个基础类,重构成 \(n\) 个装饰类。这 \(n\) 个装饰类,代码仍有重复的部分,可以将这部分重复代码再进行一次抽象。将持有的被装饰类成员变量,以及委派调用被装饰对象方法的代码,都封装到抽取抽象基类中。
1 |
|
对于每个装饰类来说,都需要在构造函数中传入一个被装饰类的对象。如果没有传入,则会导致在调用被装饰类的方法时,抛出NPE
。这不是我们想要的。所以额外需要一种不持有被封装对象,不会进行委派调用,同样实现了接口的类。这个类可以被视作用来表示装饰链的终止,树的叶子节点。
1 |
|
至此,已经把装饰模式所有结构都提及了。如果使用GoF
书中的命名:
接口
DataWriter
被称为部件(Component),用以声明装饰器和被装饰对象的公用接口。不封装其他接口对象,在装饰链处于末端的,部件实现类
BaseDataWriter
称为具体部件(Concrete Component),定义了基础行为, 但装饰类可以改变这些行为。抽象的装饰器基类
BaseDataWriterDecorator
称为基础装饰(Base Decorator),拥有一个被封装对象的成员变量。 该成员变量的类型应当被声明为通用部件接口, 这样它就可以引用具体的部件和装饰。 装饰基类会将所有操作委派给被封装的对象。具体的装饰器类
CsvDecorator
称为具体装饰类(Concrete Decorators),定义了可动态添加到部件的额外行为。 具体装饰类会重写装饰基类的方法, 并在调用父类方法之前或之后进行额外的行为。
classDiagram
direction TB
class Client
class Component {
<< interface >>
+methodA() ResultA
}
Client --> Component
class ConcreteComponent {
+methodA() ResultA
}
ConcreteComponent ..|> Component
class BaseDecorator {
- Component component
+BaseDecorator(Component)
+methodA() ResultA
}
BaseDecorator ..|> Component
class ConcreteDecorator {
+methodA() ResultA
}
ConcreteDecorator --|> BaseDecorator
由于装饰类都实现了部件接口,对于客户端来说,仍是通过部件接口与装饰后的对象交互,无需在意其是否是被装饰。
具体部件与具体装饰类
上例中,BaseDataWriter
只是为了作为装饰链的终点,定义了两个空方法。而各种实际功能都定义在了XxDecorator
中。这容易引起疑惑,具体部件和具体装饰类有什么区别,某个功能应该写成具体部件还是具体装饰类。
会发生这个问题,其实是因为最开头的例子并不合适。这都是笔者为了引出这个话题的良苦用心。
从结构分析,具体部件没有持有部件的成员变量,因此它一定是装饰链的末端。而且具体部件在创建对象时,不需要传入其他部件对象,而具体装饰类均需要一个已经存在的部件对象,因此具体部件通常先于具体装饰类实例化。基于这两个特征,可以认为对于特定装饰链,具体部件对象有且只有一个,而具体装饰对象可以有零个或多个,且创建具体装饰对象前需要先创建具体部件对象,具体装饰对象的存在依赖于具体部件对象。这意味着地位上,具体部件和具体装饰类不是等价的,具体部件相对更重要。
而上例中,各种XxWriter
之间都是相互独立,地位均等的。没有说某个XxWriter
一定依赖于另一个XxWriter
。换句话说,无法找出一个必须存在的具体部件。因此定义了一个没有意义的空实现具体部件。
当所有功能相互独立,且可以单独存在的情况下,解决类组合爆炸的另一种优雅的解决方案是客户端引用的不是单个DataWriter
,而是List<DataWirter>
。对于所有DataWriter
的方法调用,不是通过装饰链,而是遍历列表依次调用,调用顺序由加入List
的顺序决定。
为了使例子更适合装饰模式,需要某个具体部件必须存在的场景。因此将例子调整为,需要将数据写入txt
,并需要根据配置,选择是否进行加密或压缩。在这个场景中,写入负责txt
的txtWriter
必须存在,是具体部件;而加密或压缩,需要依赖于具体部件,根据运行时动态装饰对象添加功能,是具体装饰器。
装饰模式与其他模式的异同
结构上,装饰模式也是包装/封装(Wrapper
)。这与前篇介绍的多种设计模式是相同的。
适配器模式会改变被封装对象的接口,而装饰模式不会改变封装前后对象的接口。此外,装饰模式可以递归封装,而适配器模式通常只会封装一次。
组合模式也可以递归封装,且不会改变接口,和装饰模式相似。区别是组合模式中,每个容器可以拥有多个子节点,而每个装饰类只能拥有一个被装饰对象。这个结构也导致递归调用,组合模式强调每个容器对子节点进行汇总,而装饰模式强调添加额外功能。
实际在上例中,要将数据写入多种格式,可以使用组合模式。通过在容器中持有多个接口对象,然后把容器传递给客户端,也可以避免客户端调整代码,不必修改为List<DataWirter>
。
总结
装饰模式允许在不修改现有对象结构的前提下向对象添加新的功能。
装饰模式通常用于解决多个功能根据场景开启,需要定义所有可能的组合引起的类爆炸问题。
装饰模式由部件、具体部件、基础装饰和具体装饰类组成。
具体部件、基础装饰、具体装饰都需要实现部件接口,因此这些类的实例对象可以被装饰类再次封装,形成装饰链。
调用装饰类对象的方法, 会根据装饰链依次进行委派调用。
运行时,具体部件类通常是必须实例化的,具体装饰器可以不实例化。
组合模式和前篇提到的适配器模式、组合模式相似,均属于包装。但在结构、功能和动机上略有区别。