目录

定义

装饰器模式(Decorator)的 GoF 定义是:动态地给一个对象添加一些额外的职责。就增加功能而言,装饰器模式比生成子类更为灵活。

定义里有两个动作值得细看。一是”动态地”:职责是在对象运行期内一层层套上去的,不是编译期写死在类里的。二是”额外职责”:被装饰的对象本身该干什么还干什么,装饰器只是在它的行为前后补上自己的逻辑,比如记账、压缩、加密、加时间戳。

拿咖啡店计价来说:一杯 Espresso 有基础价格,你可以往里加摩卡、加奶泡、加豆浆,每加一样原价就涨一点,但归根结底,顾客手里始终是一杯”饮料”。加料的过程没改变饮料本身,只是把价格和行为一层层叠上去。

装饰器模式的实现思路其实很直接:让”被装饰的对象”和”装饰器”共用同一个接口。装饰器自己也是这个接口的实现,同时内部持有一份指向被装饰对象的引用。调用方拿到的无论是里层的原始对象还是外层的装饰器,类型始终不变,调同一个方法就行。

用来解决什么问题

设想一个文本输出的场景。你有一个基础的 Text 对象能输出纯文本,现在希望它有时带滚动条、有时带边框、有时既带滚动条又带边框。

朴素做法是给每一种组合写一个子类:TextWithScroll、TextWithBorder、TextWithScrollAndBorder……每多一种装饰、每多一种组合,子类数量就指数级膨胀。更麻烦的是,装饰之间的组合顺序和种类在写代码时往往并不固定,提前把一个一个子类都穷举出来既不现实也没必要。

装饰器模式的做法恰好相反:不写死子类,把”加职责”这件事交给运行时组合。基础对象保持原样,每个装饰器只关心自己那一份职责,需要哪些组合就在运行期把装饰器一层层包上去。原来需要 N 个子类才能覆盖的组合,用几个装饰器就能拼出来。

维度用继承扩展职责用装饰器组合
组合数量每多一种组合就多一个子类,容易膨胀几个装饰器自由拼装,线性增长
扩展时机编译期写死在类里运行期动态叠加
单一职责一个子类往往塞了多层逻辑每个装饰器只管一件事
客户端视角拿到的是各种具体子类,类型不统一始终是同一个接口类型

装饰器模式 UML 类图

flowchart BT
    subgraph CompSide [被装饰组件]
        Beverage["Beverage<br/>---<br/>&lt;&lt;interface&gt;&gt; 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/>&lt;&lt;abstract&gt;&gt; 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 表示装饰器独占内层对象,内层随装饰器一起析构,省心。如果多个装饰器要共享同一个底层对象,换 std::shared_ptr;如果装饰器只是借用、不该拥有底层对象,用裸指针或引用,但这时要自己保证底层对象的生命周期长于所有装饰器。

组件接口和装饰器基类都要有虚析构。 Beverage 是经由基类指针被持有的,没有 virtual ~Beverage() = default;,删除装饰器时内层对象不会被正确释放,造成内存泄漏。CondimentDecorator 作为中间基类同理。

只重写关心的那几个操作。 装饰器的价值之一是”不改写的方法自动透传”。如果你在每一个具体装饰器里都把 Beverage 的所有虚函数从头实现一遍,代码量会迅速膨胀,也背离了装饰器的初衷。没被装饰器关心的操作,应该让它自然透传到内层。

半透明装饰器需要向下转型,尽量避开。 前面在透明和半透明那节说过,一旦具体装饰器暴露了基类没有的方法,客户端就得 dynamic_cast 才能调用,耦合到了具体类型。真要做类型相关的分支,与其让客户端转型,不如把行为收进已有的接口方法里。