Design-Pattern-Decorator

装饰模式(Decorator Pattern)是一种结构型设计模式,允许在不修改现有对象结构的前提下向对象添加新的功能。

场景

假设我们正在开发一款爬虫软件,并准备将爬取到的数据写入csv文件中。按照模块化编程的思想,会把csv相关的代码保存到一个独立的类CsvWriter中。而后可能又需要把同样的数据写入txtjson、数据库中,也会诞生相应的TxtWriterJsonWriterSqlWriter。基于每个XxWriter做的事情相似,可以把通用行为抽取成接口DataWriter中的方法。例如文件初始化,或者接收数据并写入文件的方法。客户端通过接口与不同实现类交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public interface DataWriter {
void initialization();
void write(List<Data> dataList);
}

public class CsvWriter implements DataWriter {...}
public class TxtWriter implements DataWriter {...}
public class JsonWriter implements DataWriter {...}
public class SqlWriter implements DataWriter {...}

public class Crawler {
private DataWriter dataWriter;
...
public void run() {
dataWriter.initialization();
...
dataWriter.write(dataList);
}
}

但我们并不会局限于某一特定格式,而是根据场景组合多个格式,例如把数据同时写入csvtxt中。如果不改变客户端代码,那么需要提供一个可以同时写入两种格式的CsvTxtWriter。而这个类的实现代码,与CsvWriterTxtWriter的代码高度重合,通常是依次调用CsvWriterTxtWriter中的代码。因此相对于重写一个CsvTxtWriter,更多会选择扩展已有代码。

扩展已有代码有两种方式,继承或组合。由于java不支持多继承,只能选择继承其中一个类,然后把另一各类的代码重新写一遍。这怎么看都不优雅。使用组合则是在类中引入两个类的成员变量,在实现接口的方法时,把方法调用委派给对两个成员变量的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class CsvTxtWriter extends CsvWriter {
// 继承的方式扩展CsvWriter
public void initialization() {
super.initialization();
...
}

public void write(List<Data> dataList) {
super.write(dataList);
...
}
}

public class CsvTxtWriter implements DataWriter {
// 组合的方式扩展CsvWriter、TxtWriter
private CsvWriter csvWriter = new CsvWriter();
private TxtWriter txtWriter = new TxtWriter();

public void initialization() {
csvWriter.initialization();
txtWriter.initialization();
}

public void write(List<Data> dataList) {
csvWriter.write(dataList);
txtWriter.write(dataList);
}
}

但这样做的问题也很明显,如果有 \(n\) 个独立的XxWriter,一共会有 \(2^n - 1\) 种可能的组合。也就是需要定义的类数量很快会发生指数爆炸。所有组合中,只有 \(n\) 个基础的XxWriter执行了实际代码,其他类只是委派调用这些基础类的方法。

从需求出发,我们希望编程语言提供这样的机制,能在运行时根据场景(例如通过读取配置文件,或者解析命令行参数),组合不同功能,动态生成所需的多功能类。这样就可以避免定义所有的可能组合,只需定义最基础的 \(n\) 个类即可。这就是装饰模式将完成的功能。

装饰模式

在上文中,通过组合的方式扩展CsvWriterTxtWriter,在CsvTxtWriter定义了两个被封装的具体类成员变量,封装/包装/组合/装饰(本文不加区分使用这些相似概念)的对象和被封装的对象是一对多的关系。装饰模式也是通过组合定义装饰类,但区别是只定义了一个抽象接口的成员变量。由于装饰类本身也实现了接口,因此实例可以再次作为其他装饰类的被封装对象,封装的对象和被封装的对象是一对一的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CsvDecorator implements DataWriter {
private DataWriter wrappee;

public CsvDecorator(DataWriter wrappee) {
this.wrappee = wrappee;
}

public void initialization() {
// 调用被装饰类
wrappee.initialization();
// 装饰类额外新增的功能,如写入csv格式文件
...
}
}

整体上,装饰模式的多个装饰类封装后像是一个链表或者一叉树。调用装饰后对象的方法,则是通过持有链,进行先序遍历或后续遍历。换句话说就是先调用自身方法逻辑,还是先委派调用被装饰类的方法。

对于上例,基于装饰模式,则可以将 \(n\) 个基础类,重构成 \(n\) 个装饰类。这 \(n\) 个装饰类,代码仍有重复的部分,可以将这部分重复代码再进行一次抽象。将持有的被装饰类成员变量,以及委派调用被装饰对象方法的代码,都封装到抽取抽象基类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public abstract class BaseDataWriterDecorator implements DataWriter {
private DataWriter wrappee;

public BaseDataWriterDecorator(DataWriter wrappee){
this.wrappee = wrappee;
}

public void initialization() {
wrappee.initialization();
}

public void write(List<Data> dataList) {
wrappee.write(dataList);
}
}

public class CsvDecorator extends BaseDataWriterDecorator {
public CsvDecorator(DataWriter wrappee){
super(wrappee);
}

public void initialization() {
// 装饰类额外新增的功能,如写入csv格式文件,根据需求也可以在被包装类方法执行前执行
...
super.initialization();
}

public void write(List<Data> dataList) {
super.write(dataList);
// 也可以在被包装类方法执行后执行
...
}
}

对于每个装饰类来说,都需要在构造函数中传入一个被装饰类的对象。如果没有传入,则会导致在调用被装饰类的方法时,抛出NPE。这不是我们想要的。所以额外需要一种不持有被封装对象,不会进行委派调用,同样实现了接口的类。这个类可以被视作用来表示装饰链的终止,树的叶子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BaseDataWriter implements DataWriter {
// 简单定义两个空方法
public void initialization() {}
public void write(List<String> dataList) {}
}

public class client {
public static void main(){
DataWriter dataWriter = new BaseDataWriter();
// 动态向对象添加新的功能
if (config.isWriteCsv) dataWriter = new CsvDecorator(dataWriter);
if (config.isWriteTxt) dataWriter = new TxtDecorator(dataWriter);
...
}
}

至此,已经把装饰模式所有结构都提及了。如果使用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,并需要根据配置,选择是否进行加密或压缩。在这个场景中,写入负责txttxtWriter必须存在,是具体部件;而加密或压缩,需要依赖于具体部件,根据运行时动态装饰对象添加功能,是具体装饰器。

装饰模式与其他模式的异同

结构上,装饰模式也是包装/封装(Wrapper)。这与前篇介绍的多种设计模式是相同的。

适配器模式会改变被封装对象的接口,而装饰模式不会改变封装前后对象的接口。此外,装饰模式可以递归封装,而适配器模式通常只会封装一次。

组合模式也可以递归封装,且不会改变接口,和装饰模式相似。区别是组合模式中,每个容器可以拥有多个子节点,而每个装饰类只能拥有一个被装饰对象。这个结构也导致递归调用,组合模式强调每个容器对子节点进行汇总,而装饰模式强调添加额外功能。

实际在上例中,要将数据写入多种格式,可以使用组合模式。通过在容器中持有多个接口对象,然后把容器传递给客户端,也可以避免客户端调整代码,不必修改为List<DataWirter>

总结

装饰模式允许在不修改现有对象结构的前提下向对象添加新的功能。

装饰模式通常用于解决多个功能根据场景开启,需要定义所有可能的组合引起的类爆炸问题。

装饰模式由部件、具体部件、基础装饰和具体装饰类组成。

具体部件、基础装饰、具体装饰都需要实现部件接口,因此这些类的实例对象可以被装饰类再次封装,形成装饰链。

调用装饰类对象的方法, 会根据装饰链依次进行委派调用。

运行时,具体部件类通常是必须实例化的,具体装饰器可以不实例化。

组合模式和前篇提到的适配器模式、组合模式相似,均属于包装。但在结构、功能和动机上略有区别。


Design-Pattern-Decorator
http://dracoyus.github.io/2024/10/28/Design-Pattern-Decorator/
作者
DracoYu
发布于
2024年10月28日
许可协议