目录
定义
装饰器模式(Decorator)的 GoF 定义是:动态地给一个对象添加一些额外的职责。就增加功能而言,装饰器模式比生成子类更为灵活。
定义里有两个动作值得细看。一是”动态地”:职责是在对象运行期内一层层套上去的,不是编译期写死在类里的。二是”额外职责”:被装饰的对象本身该干什么还干什么,装饰器只是在它的行为前后补上自己的逻辑,比如记账、压缩、加密、加时间戳。
拿咖啡店计价来说:一杯 Espresso 有基础价格,你可以往里加摩卡、加奶泡、加豆浆,每加一样原价就涨一点,但归根结底,顾客手里始终是一杯”饮料”。加料的过程没改变饮料本身,只是把价格和行为一层层叠上去。
装饰器模式的实现思路其实很直接:让”被装饰的对象”和”装饰器”共用同一个接口。装饰器自己也是这个接口的实现,同时内部持有一份指向被装饰对象的引用。调用方拿到的无论是里层的原始对象还是外层的装饰器,类型始终不变,调同一个方法就行。
用来解决什么问题
设想一个文本输出的场景。你有一个基础的 Text 对象能输出纯文本,现在希望它有时带滚动条、有时带边框、有时既带滚动条又带边框。
朴素做法是给每一种组合写一个子类:TextWithScroll、TextWithBorder、TextWithScrollAndBorder……每多一种装饰、每多一种组合,子类数量就指数级膨胀。更麻烦的是,装饰之间的组合顺序和种类在写代码时往往并不固定,提前把一个一个子类都穷举出来既不现实也没必要。
装饰器模式的做法恰好相反:不写死子类,把”加职责”这件事交给运行时组合。基础对象保持原样,每个装饰器只关心自己那一份职责,需要哪些组合就在运行期把装饰器一层层包上去。原来需要 N 个子类才能覆盖的组合,用几个装饰器就能拼出来。
| 维度 | 用继承扩展职责 | 用装饰器组合 |
|---|---|---|
| 组合数量 | 每多一种组合就多一个子类,容易膨胀 | 几个装饰器自由拼装,线性增长 |
| 扩展时机 | 编译期写死在类里 | 运行期动态叠加 |
| 单一职责 | 一个子类往往塞了多层逻辑 | 每个装饰器只管一件事 |
| 客户端视角 | 拿到的是各种具体子类,类型不统一 | 始终是同一个接口类型 |
装饰器模式 UML 类图
flowchart BT
subgraph CompSide [被装饰组件]
Beverage["Beverage<br/>---<br/><<interface>> Beverage<br/>---<br/>+cost() : double<br/>+getDescription() : string"]
Espresso["Espresso<br/>ConcreteComponent<br/>cost() = 1.99"]
HouseBlend["HouseBlend<br/>ConcreteComponent<br/>cost() = 0.89"]
end
subgraph DecoSide [装饰器]
Condiment["CondimentDecorator<br/>---<br/><<abstract>> Decorator<br/>---<br/>-beverage : Beverage*"]
Mocha["Mocha<br/>ConcreteDecorator<br/>cost += 0.20"]
Whip["Whip<br/>ConcreteDecorator<br/>cost += 0.10"]
Soy["Soy<br/>ConcreteDecorator<br/>cost += 0.15"]
end
Espresso -- 继承 --> Beverage
HouseBlend -- 继承 --> Beverage
Condiment -- 继承 --> Beverage
Mocha -- 继承 --> Condiment
Whip -- 继承 --> Condiment
Soy -- 继承 --> Condiment
Condiment -. 持有 beverage : Beverage* .-> Beverage
图里两条继承链汇到 Beverage:一边是真正干活的 Espresso / HouseBlend,另一边是 CondimentDecorator 以及它的具体子类 Mocha / Whip / Soy。关键在 CondimentDecorator 这一格:它既继承 Beverage(所以装饰出来的东西还是一杯饮料),又持有一个 Beverage*(指向下一层被包裹的对象)。新职责就是沿着这条持有的引用一层层往里委托再加料。
对象交互图:一次计价的调用链
以”一杯 Espresso 外面包了 Mocha、再外面包了 Whip”为例,看一次 cost() 是怎么穿过装饰链的。
sequenceDiagram
participant C as 客户端 Client
participant D1 as Whip(外层)
participant D2 as Mocha(内层)
participant B as Espresso(被装饰组件)
C->>D1: cost()
D1->>D2: cost() // 先委托给内层装饰器
D2->>B: cost()
B-->>D2: return 1.99
D2-->>D1: return 1.99 + 0.20
D1-->>C: return 2.19 + 0.10
客户端只调了外层装饰器的一次 cost(),后面的穿梭全是装饰链自己的事。每一层都不自己算总价,而是先把内层的结果要回来,再补上自己那份。时序图是简化示意,只画了三层,实际可以包任意多层。
伪代码:把 UML 类落到实现
以下代码为说明原理的示意性伪代码,省略头文件与 include guard 等内容。
先定义统一的组件接口:
class Beverage {
public:
virtual ~Beverage() = default;
virtual double cost() const = 0;
virtual std::string getDescription() const = 0;
};
两个具体的饮料,各自只管自己的基础价格:
class Espresso : public Beverage {
public:
double cost() const override { return 1.99; }
std::string getDescription() const override { return "Espresso"; }
};
class HouseBlend : public Beverage {
public:
double cost() const override { return 0.89; }
std::string getDescription() const override { return "House Blend"; }
};
装饰器的抽象基类,关键动作是接住那个被包裹的 Beverage:
class CondimentDecorator : public Beverage {
protected:
std::unique_ptr<Beverage> beverage; // 指向被装饰的那一杯
public:
explicit CondimentDecorator(std::unique_ptr<Beverage> b)
: beverage(std::move(b)) {}
// cost() 与 getDescription() 留给具体装饰器实现
};
具体装饰器在调用内层结果之后,再加上自己的那一份:
class Mocha : public CondimentDecorator {
public:
using CondimentDecorator::CondimentDecorator; // 继承构造函数
double cost() const override {
return beverage->cost() + 0.20;
}
std::string getDescription() const override {
return beverage->getDescription() + ", Mocha";
}
};
class Whip : public CondimentDecorator {
public:
using CondimentDecorator::CondimentDecorator;
double cost() const override {
return beverage->cost() + 0.10;
}
std::string getDescription() const override {
return beverage->getDescription() + ", Whip";
}
};
class Soy : public CondimentDecorator {
public:
using CondimentDecorator::CondimentDecorator;
double cost() const override {
return beverage->cost() + 0.15;
}
std::string getDescription() const override {
return beverage->getDescription() + ", Soy";
}
};
客户端把装饰一层层包上去:
int main() {
std::unique_ptr<Beverage> drink = std::make_unique<Espresso>();
drink = std::make_unique<Mocha>(std::move(drink)); // 加一份摩卡
drink = std::make_unique<Mocha>(std::move(drink)); // 再来一份摩卡
drink = std::make_unique<Whip>(std::move(drink)); // 加奶泡
std::cout << drink->getDescription() << "\n";
std::cout << "Cost: $" << drink->cost() << "\n";
// 输出: Espresso, Mocha, Mocha, Whip
// Cost: 1.99 + 0.20 + 0.20 + 0.10 = 2.49
}
从这段代码能看出来,职责是沿着委托链一层层叠上去的:外层的 Whip 先调内层的 cost(),内层又继续往里调,到底层的 Espresso 返回基础价,然后每一层在结果回来的路上补上自己的加价。调用方写几行 make_unique 就把一杯定制饮料拼出来了,连循环都不用自己写。
用 std::function 串起装饰链
回过头想想,装饰器真正在做的事无非是”对一份数据做一段变换再往下传”。这件事不一定要用继承。C++ 可以用 std::function 把装饰器写成一个个可调用对象,省掉整套基类和子类。
#include <functional>
#include <iostream>
#include <string>
struct Drink { std::string desc; double price; };
std::function<Drink(Drink)> withMocha = [](Drink d) {
return Drink{d.desc + ", Mocha", d.price + 0.20};
};
std::function<Drink(Drink)> withWhip = [](Drink d) {
return Drink{d.desc + ", Whip", d.price + 0.10};
};
int main() {
Drink d{"Espresso", 1.99};
d = withMocha(d);
d = withWhip(d);
std::cout << d.desc << " : $" << d.price << "\n";
// 输出: Espresso, Mocha, Whip : $2.29
}
这套写法省掉了整套继承层次:没有虚函数、没有基类,变换逻辑直接写成 lambda,上手快。装饰的顺序就是一行行赋值的顺序,读起来跟走流水线一样清楚。每个装饰器是独立的函数对象,复用和单元测试都很顺手。
代价是这版不再有统一的 Beverage 基类:被装饰的值和装饰器不是同一个类型,调用方没法把”原始饮料”和”加了料的饮料”当成同一个接口来统一传递。所以这类写法适合职责是纯数据变换、且不需要把装饰结果当多态对象到处传的场景;如果需要的是一个能到处当 Beverage 用的对象,经典继承版仍然更合适。
透明装饰器 vs 半透明装饰器
装饰器的抽象基类 CondimentDecorator 继承自 Beverage,也就是说,无论包了多少层,拿到手里的仍然是一杯 Beverage。客户端只调 cost() / getDescription(),无从知晓底下有几层装饰。这是装饰器模式推荐的做法,称为透明装饰器:接口保持一致,类型始终统一。
与之相对的是半透明装饰器。有些装饰器除了改写公共操作,还想额外暴露自己的方法,比如 Mocha 想提供一个 getMochaCount()。但 Beverage 接口里没有这个方法,客户端要调用就只能把 Beverage* 向下转型成 Mocha*。一旦走了转型,客户端就耦合到了具体装饰器类型,透明性就打破了,装饰器之间也无法再自由替换。
实际写的时候,除非必要,尽量让装饰器只改写基类已有的操作,不新增对外方法。新增方法意味着半透明,意味着客户端要认识具体类型,也意味着你失去了”装饰器可以任意嵌套”的关键好处。真有装饰器专属的状态要暴露,优先考虑把它反映到已有的 getDescription() 或某个已有接口方法里。
认清几个类似模式的边界
装饰器的 UML 很容易让新手往别的模式上联想,尤其是组合、适配器、代理。
先从组合模式说起。两者都让子类持有同类型的引用、都实现同一个接口,UML 长得也像。差别在于意图:组合模式关心的是”部分-整体”的树形结构,它一对多地管理一堆子节点,目的是让客户端把单对象和对象树一视同仁;装饰器模式是一对一地包裹单个对象,目的是在不改原对象的前提下叠加职责。一个组合节点底下挂的是一串兄弟节点,一个装饰器底下只链着它内层的那一个。更妙的是,装饰器常常可以包住一个组合节点,给整条分支统一加上某种行为。
另一个容易搞混的是适配器模式。适配器做的是接口转换,把 A 接口变成 B 接口让不兼容的两方能对接;装饰器不改变接口,被装饰对象和装饰器对外始终是同一个。
还有一个容易搞混的是代理模式。代理也持有被包装对象的引用、也实现同一接口,但代理的重心是”控制访问”,比如延迟加载、权限校验、远程调用;装饰器的重心是”增加职责”。一个负责拦,一个负责加。
| 模式 | 持有关系 | 关注点 | 典型操作 |
|---|---|---|---|
| 装饰器 | 一对一包裹单个对象 | 动态叠加职责 | 在原有行为前后插入逻辑 |
| 组合 | 一对多管理子节点 | 部分-整体层次 | 统一对待单对象与对象树 |
| 适配器 | 包装一个不兼容对象 | 接口转换 | 把 A 接口适配成 B 接口 |
| 代理 | 包装一个对象 | 控制访问 | 延迟加载、权限、远程 |
什么时候不该用装饰器
如果职责的种类和组合在编译期就固定死了,直接写几个子类或者用成员变量组合可能更直白。装饰器真正的价值在于”组合在运行期才确定”,没有这层不确定性,继承层次反而更简单。
如果装饰的顺序毫不相干、彼此之间也没有叠加关系,那要加的其实只是几个独立函数,把它们写成普通工具函数或者管道里的一步变换就够了,没必要为每个都立一个装饰器类。
如果装饰链会变得很长,调试会变得费劲:一次 cost() 要穿过十几层委托,出错时很难一眼看出是哪一层算错。这种情况下要权衡,是装饰链太长,还是该把某些装饰合并、或者用配置表来驱动。
落到工程里要注意的几点
装饰顺序会改变结果。 先压缩再加密,和先加密再压缩,出来的字节流和代价都不一样。客户端组装装饰链时的顺序,就是运行时生效的顺序,这一点要在文档或接口设计上讲清楚,别让调用方凭感觉排。
持有被装饰对象要讲清楚所有权。 示例里用 std::unique_ptr
组件接口和装饰器基类都要有虚析构。 Beverage 是经由基类指针被持有的,没有 virtual ~Beverage() = default;,删除装饰器时内层对象不会被正确释放,造成内存泄漏。CondimentDecorator 作为中间基类同理。
只重写关心的那几个操作。 装饰器的价值之一是”不改写的方法自动透传”。如果你在每一个具体装饰器里都把 Beverage 的所有虚函数从头实现一遍,代码量会迅速膨胀,也背离了装饰器的初衷。没被装饰器关心的操作,应该让它自然透传到内层。
半透明装饰器需要向下转型,尽量避开。 前面在透明和半透明那节说过,一旦具体装饰器暴露了基类没有的方法,客户端就得 dynamic_cast 才能调用,耦合到了具体类型。真要做类型相关的分支,与其让客户端转型,不如把行为收进已有的接口方法里。