前言
装饰模式(decorator pattern)要解决的问题是:如何在不修改原有类、也不产生大量子类的前提下,给一个对象动态地添加职责。教科书里的经典例子是咖啡加配料——基础咖啡是一个对象,加奶、加摩卡、加糖分别是「装饰」,可以任意组合、任意顺序叠加。如果改用继承,三种配料的所有组合需要 2³ 个子类,再加一种配料就要翻倍,这就是所谓的「类爆炸」。
典型误解是把装饰模式理解成「套一层包装类」。包装本身不是目的,关键是包装类与被包装对象实现同一个接口,从而可以继续被包装、也可以被当作原对象使用。另一个误解是把它与代理模式(proxy)、适配器模式(adapter)混为一谈:代理控制访问,适配器转换接口,装饰则是在保持接口不变的前提下增强行为。
在 C++ 里实现装饰模式,核心是处理好对象的所有权。装饰器需要持有被装饰对象,若用裸指针就要手动管理生命周期;用std::unique_ptr表达「独占持有」,再配合移动语义,可以让装饰链的构造既安全又直观。本文以 C++17 为基准给出完整示例。
一、结构:四个角色
装饰模式由四个角色组成:
| 角色 | 职责 |
|---|
| Component(抽象构件) | 定义统一接口,客户端只依赖它 |
| ConcreteComponent(具体构件) | 被装饰的原始对象 |
| Decorator(抽象装饰者) | 持有一个 Component,并转发请求 |
| ConcreteDecorator(具体装饰者) | 在转发前后附加行为 |
关键设计点是 Decorator 也继承自 Component。这保证了「装饰后的对象仍然是 Component」,因此可以继续被装饰,也可以传给任何接受 Component 的函数。如果 Decorator 不继承 Component,装饰链就无法嵌套,模式也就失去了意义。
转发(forwarding)是第二个关键点。抽象装饰者的每个接口默认都应该把请求原样交给内部对象,具体装饰者只覆盖需要增强的那几个。这样新增装饰者时,不必重写全部接口。
二、完整示例:咖啡与配料
下面用「饮料 + 配料」的例子实现。Beverage是抽象构件,Coffee与Tea是具体构件,CondimentDecorator是抽象装饰者,Milk与Mocha是具体装饰者。
// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Beverage {
public:
virtual ~Beverage() = default;
virtual std::string description() const = 0;
virtual double cost() const = 0;
};
class Coffee : public Beverage {
public:
std::string description() const override { return "coffee"; }
double cost() const override { return 10.0; }
};
class Tea : public Beverage {
public:
std::string description() const override { return "tea"; }
double cost() const override { return 8.0; }
};
class CondimentDecorator : public Beverage {
public:
explicit CondimentDecorator(std::unique_ptr<Beverage> inner)
: inner_(std::move(inner)) {}
protected:
std::unique_ptr<Beverage> inner_; // 独占持有被装饰对象
};
class Milk : public CondimentDecorator {
public:
using CondimentDecorator::CondimentDecorator;
std::string description() const override {
return inner_->description() + " + milk";
}
double cost() const override {
return inner_->cost() + 2.0;
}
};
class Mocha : public CondimentDecorator {
public:
using CondimentDecorator::CondimentDecorator;
std::string description() const override {
return inner_->description() + " + mocha";
}
double cost() const override {
return inner_->cost() + 3.0;
}
};
int main() {
std::unique_ptr<Beverage> drink = std::make_unique<Coffee>();
drink = std::make_unique<Milk>(std::move(drink));
drink = std::make_unique<Mocha>(std::move(drink));
std::cout << drink->description() << " : " << drink->cost() << "\n";
return 0;
}按标准语义推演:make_unique<Coffee>()得到一个unique_ptr<Beverage>,动态类型是Coffee。make_unique<Milk>(std::move(drink))把指针移进Milk的基类子对象,Milk::inner_指向原来的Coffee。再包一层Mocha,Mocha::inner_指向Milk对象。调用description()时,Mocha先调用inner_->description(),即Milk的版本,Milk再调用Coffee的版本,最终拼出coffee + milk + mocha。
using CondimentDecorator::CondimentDecorator;继承基类构造函数,省去在每个具体装饰者里重写一遍转发构造。被继承的构造函数签名是explicit CondimentDecorator(std::unique_ptr<Beverage>),因此make_unique<Milk>(...)能直接匹配。
三、顺序、嵌套与所有权
装饰的顺序会影响结果。Milk(Mocha(coffee))与Mocha(Milk(coffee))的费用相同(加法可交换),但描述字符串的拼接顺序不同;如果装饰涉及格式化、压缩、加密等操作,顺序甚至会改变语义(先压缩再加密与先加密再压缩不是一回事)。因此装饰链的构造顺序应当显式、可读,不要藏在难以追踪的循环里。
所有权方面,std::unique_ptr表达的是「装饰者独占被装饰对象」。这带来两个后果:第一,装饰者不可拷贝,因为unique_ptr不可拷贝;第二,移动装饰者会把整条链的所有权转移走。如果业务上需要共享同一个基础对象给多条装饰链,应改用std::shared_ptr,但要注意这时装饰者之间会共享状态,修改可能互相可见。
inner_声明为protected而不是private,是为了让具体装饰者能直接访问。如果希望更严格,可以改成private并提供Beverage& inner()访问函数,具体装饰者通过函数拿引用。两种写法都可以,前者短,后者封装更好。
基类析构函数必须是virtual。客户端持有的是unique_ptr<Beverage>,删除时若Beverage的析构不是虚函数,通过基类指针删除派生对象是未定义行为(undefined behavior, UB),实际表现通常是派生类的成员没有被析构,造成资源泄漏。
四、与继承、代理、适配器的区别
装饰模式经常被拿来与「继承」对比。继承是编译期确定的、静态的增强,一经定义就作用于该类的所有对象;装饰是运行期组合的、动态的增强,可以只作用于某个对象实例。继承会增加类的数量,装饰增加的是对象层次,但装饰链过深时会带来调试困难——调用栈里会嵌套多层转发。
与代理模式的区别在意图:代理通常在转发前后做访问控制(懒加载、权限校验、远程调用),被代理对象往往只有一个;装饰则可以叠加任意多个,每个都添加一点行为。结构上两者可能长得一样,判断依据是「是否允许无限嵌套、是否以增强为目标」。
与适配器模式的区别在接口:适配器改变接口,让不兼容的类能协作;装饰保持接口不变,客户端感知不到装饰的存在。如果一个类既转发又改签名,那多半是适配器而不是装饰器。
| 模式 | 接口 | 目的 | 能否嵌套 |
|---|
| 装饰 | 不变 | 动态添加职责 | 可以多层 |
| 代理 | 不变 | 控制访问 | 通常一层 |
| 适配器 | 改变 | 转换接口 | 通常一层 |
| 继承 | 不变 | 静态扩展 | 编译期固定 |
五、适用与不适用
装饰模式适合:功能可以独立组合、组合方式在运行期才确定、不希望为每种组合生成子类。典型场景包括 I/O 流(std::iostream本身就是装饰思想的体现)、HTTP 中间件、图形渲染的后处理链、日志格式化。
不适合的场景也很明确:如果组合在编译期就固定,直接用继承或模板组合更简单;如果装饰者数量很少且不会扩展,套一层包装反而增加阅读成本;如果装饰之间需要共享大量状态,unique_ptr的独占语义会逼你改用共享指针,复杂度上升,此时应重新审视设计。
常见坑点
坑点一:装饰者不继承 Component,导致无法嵌套。
// ❌ 错误:Milk 不是 Beverage,不能继续被装饰,也不能传给接受 Beverage 的函数
class Milk {
std::unique_ptr<Beverage> inner_;
};// ✅ 正确:装饰者实现同一接口
class Milk : public Beverage { /* ... */ };坑点二:基类析构函数不是虚函数。
// ❌ 错误:通过 Beverage* 删除 Milk 是 UB
class Beverage {
public:
~Beverage() = default;
};// ✅ 正确:多态基类必须有虚析构
class Beverage {
public:
virtual ~Beverage() = default;
};坑点三:构造装饰链时复制unique_ptr。
// ❌ 错误:unique_ptr 不可拷贝,编译失败
Milk m(drink);
drink = std::make_unique<Mocha>(drink);// ✅ 正确:用 std::move 转移所有权
drink = std::make_unique<Mocha>(std::move(drink));坑点四:装饰者覆盖接口时忘记调用内层。
// ❌ 错误:直接返回常量,内层的行为被完全丢弃
std::string description() const override { return "milk"; }// ✅ 正确:在内层结果上追加
std::string description() const override { return inner_->description() + " + milk"; }坑点五:按值传递装饰者,造成对象切片。
// ❌ 错误:按值传参,派生部分被切掉,只剩 Beverage 子对象
void serve(Beverage b);// ✅ 正确:传引用或指针
void serve(const Beverage& b);坑点六:以为装饰链的顺序无关紧要。
// ❌ 错误:压缩与加密的顺序会改变结果,不能随意交换
auto pipeline = std::make_unique<Encrypt>(std::make_unique<Compress>(base));// ✅ 正确:按业务语义确定顺序,并在注释中写明
auto pipeline = std::make_unique<Compress>(std::make_unique<Encrypt>(base));坑点七:在装饰者里保存内层对象的引用而非所有权。
// ❌ 错误:内层对象先销毁时,引用悬垂
class Milk : public Beverage {
Beverage& inner_;
};// ✅ 正确:明确所有权,用 unique_ptr 持有
std::unique_ptr<Beverage> inner_;总结
| 要点 | 说明 |
|---|
| 核心 | 装饰者与被装饰者实现同一接口,可无限嵌套 |
| 所有权 | unique_ptr表达独占持有,构造时用std::move |
| 转发 | 抽象装饰者默认转发,具体装饰者只覆盖需要增强的接口 |
| 顺序 | 装饰顺序影响语义,必须显式确定 |
| 析构 | 多态基类必须虚析构 |
| 边界 | 组合固定时用继承或模板,不要为模式而模式 |
装饰模式的代码量不大,难点在所有权与接口一致性:装饰者必须是 Component,必须持有内层对象,必须把不需要改变的调用转发下去。把这三点做对,装饰链就能像积木一样自由组合;做错任何一点,都会退化成「多套了一层、却没得到灵活性」。