0. 前言
当你在编码时,如果需要对类的功能进行扩展,或者不满足于当前类提供的接口时,你要如何做呢?是直接写一个子类继承,然后基于父类进行扩展?还是说直接修改某些类或者接口?
上面提出的方案在很多情况下都非常糟糕:如果遇到问题就去写一个子类继承,那么对象之间的继承关系和代码的耦合度将会达到一个惊人的复杂程度,越往后写项目越难以维护;而第二种方案则显得更加不可取,因为如果当前类或者接口是由第三方库提供的,你没有任何修改它们的可能——你不会打算去修改源码再编译一次的。
本文将通过实际代码案例,讲解装饰器模式的概念、实现、作用和应用。
另外,本篇文章的java代码块在此网站上呈现方式有问题,所以代码块一律采用的是C++,所以可能出现高亮不准确问题。
1. 装饰器模式的技术本质
装饰器模式,在技术实现上,本质是在另一个类(即装饰器类)内,持有某个类的引用:
public class Decorator {
private Type obj;
public void setObj(Type obj) {
this.obj = obj;
}
};
然后,在装饰器类中提供所持有对象的所有方法,并在每个同签名方法中调用对象的方法:
public class Decorator {
private Type obj;
public void foo() {
// do something
obj.foo();
}
public int bar() {
return obj.bar();
}
};
并且在调用所持有对象的相应方法前后进行其他处理:
public class Decorator {
private Type obj;
public void foo() {
// do something
obj.foo();
// do something
}
public int bar() {
// do something
int result = obj.bar();
// do something
return result;
}
};
这样,就对对象的功能进行了增强。
同时,为了方便不同装饰器功能的组合,同一类对象的不同装饰器往往会实现这类对象的接口:
class Decorator1 implements TypeInterface {
TypeInterface obj;
public void setObj(TypeInterface obj) {
this.obj = obj;
}
public void foo() {
obj.foo();
System.out.println("D1");
}
}
class Decorator2 implements TypeInterface {
TypeInterface obj;
public void setObj(TypeInterface obj) {
this.obj = obj;
}
public void foo() {
obj.foo();
System.out.println("D2");
}
}
这样在使用时就可以实现功能的组合:
TypeInterface obj = new TypeA();
Decorator1 d1 = new Decorator1();
Decorator2 d2 = new Decorator2();
d1.setObj(obj);
d2.setObj(d2);
d2.foo();
// 调用d2.foo() -> 调用obj(Decorator1).foo() -> 调用obj(TypeA).foo()
如果不这么做,那么我们在不同地方组合多个功能时就需要重复创建多个装饰器,并且代码也非常冗余:
// 创建一些装饰器:d0 d1 d2
var res = d0.foobar();
var res1 = d1.bar(res);
d2.bar(res1);
// 这样的代码可能散落在程序各处
而且,当我们需要把对象作为返回值时,由于装饰器之间没有什么关联,我们不可能返回一个接收方无法处理的装饰器回去,只能返回对象本身:
var obj = getObj();
// 再次重复创建很多个装饰器,然后再使用增强功能后的obj
这时你可能会想:如果我提供一个抽象的装饰器,固定返回类型为抽象装饰器行不行呢?
public AbstractDecorator getObj() {
}
答案是不行的,因为如果只是使用抽象的装饰器,而不是让包括抽象装饰器在内的所有装饰器实现对象接口的话,会带来两个问题:
- 首先是,
setObj函数要提供两个版本,这样才既能同时扩展原始对象和被包装的对象的功能public void setObj(TypeInterface obj) { this.obj = obj; }public void setObj(AbstractDecorator otherDecorator) { // 那么问题来了,这个otherDecorator应该赋值给谁? } -
这样的话,装饰器内必须要同时持有两种类型的引用,并且在每一次使用前都要判断自己持有的到底是原始对象的应用还是装饰器的引用
public class Decorator extends AbstractorDecorator { TypeInterface obj; AbstractorDecorator decorator; public void foo() { if (decorator != null) { decorator.foo(); } else { obj.foo(); } } }而且对象只会被包装一次,装饰器会被包装多层,大部分装饰器内持有的对象是另一装饰器,只有对象外面第一层的装饰器持有的是对象的引用。因此这样的if判断十分多余,且每添加一个新的装饰器类就要写一遍,代码冗余。
当然,这不是说抽象装饰器没有用:
当所有的装饰器都实现了对象的接口,即装饰器本身也是某类对象时,就可以省去这些if判断,并且在其他方法中可以放心地返回装饰器对象:
public abstract class AbstractorDecorator implements TypeInterface {
// obj可以是某一类对象本身,也可以是装饰器对象,因为都实现了同一接口
// 同时,通过抽象类持有对象引用,避免了每个具体装饰器都要独自管理对象引用的冗余与复杂性
TypeInterface obj;
// 提供默认的方法实现,即只做调用转发
public void foo() {
obj.foo(); // 无需if判断直接调用
}
}
public class Decorator extends AbstractorDecorator {
public void foo() {
obj.foo();
// 做其他的增强处理
}
}
var d = getObjWithDecorator();
// 这样就无需重复创建装饰器,直接使用返回的对象,即功能增强后的对象
另外,装饰器类获取需要被包装的对象并不总要靠setObj函数,还可以通过构造函数获取。
2. 装饰器模式的作用
2.1 继承的替代方案
如果我们只通过继承来扩展对象功能,可能会得到一个“继承地狱”:
就比如Java的标准库中有这样几种类
FileInputStream, ByteArrayInputStream,
PipedInputStream, SequenceInputStream
和这样一些装饰器:
BufferedInputStream, DataInputStream,
GZIPInputStream, ZipInputStream等等
如果我们使用继承来实现不同装饰器的功能,就要实现这些子类:
BufferedFileInputStream, DataFileInputStream,
GZIPFileInputStream, ZipFileInputStream,
BufferedDataFileInputStream, GZIPDataFileInputStream...
而且依据装饰器包装顺序的不同,即调用相应功能先后的不同,还要做出更多其他的实现。光是一个Buffered Data GZIP FileInputStream,都可以排列组合出6种不同的子类。
如果要通过继承的方式来扩展类的功能,子类的数量将会爆炸增长。
因此,装饰器模式就可以成为继承的替代方案,只需要提供对应功能的实现,然后将装饰器自由组合即可实现目标功能。
2.2 动态扩展对象功能
使用装饰器后,可以依据实际情况来确定添加哪一种装饰器,以及装饰器间的调用顺序
InputStream in = new FileInputStream("data.txt");
if (useBuffering) {
in = new BufferedInputStream(in); // 运行时决定是否添加缓冲
}
if (useEncryption) {
in = new CipherInputStream(in, cipher); // 运行时决定是否添加加密
}
// 先加密后缓冲
InputStream in1 = new BufferedInputStream(
new CipherInputStream(
new FileInputStream("f"), cipher));
// 先缓冲后加密
InputStream in2 = new CipherInputStream(
new BufferedInputStream(
new FileInputStream("f")), cipher);
另外,功能增减也是灵活的:
// 你可能通过一段很长的处理才拿到了最终对象
BufferedDataZipInputStream bdzis = ...;
// 现在你不想要这个`Buffer`功能了,你又要重新把原始对象处理一遍
// 而如果使用装饰器,创建两个不同的装饰器即可
new BufferedInputStream(new DataInputStream(new ZipInputStream(is)));
new DataInputStream(new ZipIputStream(is));
2.3 遵守开闭原则
装饰器模式并没有修改对象类本身,也不会修改对象类的接口,而是通过增加新的装饰器来增加功能,且新增的装饰器不会对现有代码产生任何影响。
3. 装饰器模式的代码实例
装饰器模式最经典的实例,就是Java的IO流。抽象类FilterInputStream继承了InputStream并持有一个InputStream类型的引用,还提供了构造函数去获取具体对象:
public class FilterInputStream extends InputStream {
protected volatile InputStream in;
protected FilterInputStream(InputStream in) {
this.in = in;
}
// ...
}
在FilterInputStream的各子类中,都调用了super(in)来保存引用,并提供了包括InputStream类型参数的构造函数:
public InflaterInputStream(InputStream in, Inflater inf, int size) {
super(in);
// ...
}
public DataInputStream(InputStream in) {
super(in);
}
// 其他类
在不同的类中,围绕in对象的同一方法,扩展了不同的功能:
// 默认实现
public int read(byte[] b, int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
// BufferedInputStream实现
private int implRead(byte[] b, int off, int len) throws IOException {
ensureOpen();
if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int n = 0;
for (;;) {
int nread = read1(b, off + n, len - n); // 这里内部也使用了in
if (nread <= 0)
return (n == 0) ? nread : n;
n += nread;
if (n >= len)
return n;
// 使用`in`
InputStream input = in;
if (input != null && input.available() <= 0)
return n;
}
}
// CheckedInputStream实现
public int read(byte[] buf, int off, int len) throws IOException {
len = in.read(buf, off, len); // 使用default read
if (len != -1) {
cksum.update(buf, off, len);
}
return len;
}
在使用时:
byte buf[16];
// 创建一个具体的文件输入流
FileInputStream fileIn = new FileInputStream("data.txt");
// 创建装饰器:DataInputStream -> BufferedInputStream -> FileInputStream
// BufferedInputStream::in 为 fileIn
BufferedInputStream bufferedIn = new BufferedInputStream(fileIn);
// DataInputStream::in 为 bufferedIn
DataInputStream dataIn = new DataInputStream(bufferedIn);
// 调用DataInputStream::read -> 调用BufferedInputStream::read -> 调用FileInputStream::read
// 为文件输入流添加缓冲功能的基础上添加基本数据类型读取功能
int value = dataIn.read(buf);
dataIn.close();
4. 装饰器模式的缺点
这个问AI吧,在看完上面的内容后,AI的解答比我写的更准
以防你懒得去问AI,这里直接贴AI生成的版本(生成自deepseek):
- 设计复杂性增加
引入大量功能单一的装饰器类,每个类看似简单,但整体设计变得复杂。开发者需要理解多个类的职责以及它们之间的组合关系,增加了学习和维护成本。对于不熟悉模式的团队成员,可能误用或过度使用,导致系统难以理解。
- 对象标识问题
装饰器包装后的对象与原始对象不是同一个实例,因此使用 == 比较对象标识时会返回 false。依赖对象标识(如使用 IdentityHashMap、对象锁等)的代码可能失效,需要改用 equals()(需正确实现)或其他方式处理。
- 调试困难
多层装饰器嵌套会导致调用堆栈变深,异常堆栈跟踪中包含大量装饰器类,难以快速定位问题根源。例如,一个被包装多次的 InputStream 在抛出异常时,堆栈可能显示 CipherInputStream -> BufferedInputStream -> FileInputStream 的多层调用,排查问题需要仔细分析每一层。
- 性能开销
每个装饰器方法调用都会增加一层间接性,导致多次方法调用开销。对于高频调用的场景(如循环中读取字节),可能影响性能。虽然现代 JVM 可以内联部分调用,但大量嵌套仍可能带来不可忽视的性能损耗。
- 顺序依赖
装饰器的组合顺序可能影响最终行为(如先加密后压缩 vs 先压缩后加密),需要明确文档说明正确顺序。错误顺序可能导致功能异常(如先缓冲后加密可能因缓冲导致加密块边界问题),且这种错误难以在编译期发现。
- 可能产生大量相似对象
虽然避免了类爆炸,但运行时会产生许多小对象(每个装饰器一个实例)。在极端情况下(如大量并发请求使用不同组合),内存开销可能上升。例如,为每个 HTTP 请求动态构建一个多层装饰的 I/O 流,会创建大量短生命周期的装饰器对象,增加 GC 压力。
- 与依赖注入(DI)框架集成复杂
在 Spring 等 DI 容器中,动态构建装饰器链需要额外的配置或工厂方法,不如直接注入具体实现简单。自动装配时,容器难以自动判断装饰顺序,可能需要手动编码组装,降低了框架的便利性。
- 接口膨胀问题
如果被装饰的组件接口非常庞大(包含许多方法),每个装饰器都必须实现所有方法(通常转发给被包装对象),即使它只增强其中少数方法。这导致装饰器类中包含大量样板代码,且当接口变更时,所有装饰器都需要修改。
总结
装饰器模式的缺点主要集中在复杂性、调试难度、性能开销和顺序敏感上。尽管如此,在需要动态、透明地添加职责且避免继承爆炸的场景中,它仍然是极具价值的模式。使用时需权衡利弊,必要时可结合工厂模式或建造者模式来简化装饰器的创建和管理。