1. 装饰器模式在C++里的独特处境
1.1 从GoF经典定义说起
装饰器模式(Decorator Pattern)是GoF二十三个经典模式里我认为“概念最简单、落地最折腾”的一个。它的原始意图就一句话:在不修改原有类的前提下,动态地给对象添加职责。Java里典型写法是定义一个接口,然后写一个抽象装饰器持有被装饰对象的引用,再通过子类一层层包上去。C++里刚接触这个模式时,很多人会照搬这套写法,结果写出来一堆虚函数、抽象基类、构造函数传引用,看着像模像样,实际用起来总觉得别扭。
别扭的根源在于C++不是一个纯面向对象语言,它有值语义、模板、RAII、移动语义这些Java里没有的东西。如果只把装饰器理解成“继承同一接口,内部再持有一个同接口对象”,那在C++里你至少会撞上三个问题:一是虚函数调用有开销,虽然大多数时候可以忽略,但高性能场景受不了;二是对象生命周期管理麻烦,装饰器持有的是引用还是指针?谁负责释放?被装饰对象提前析构了怎么办?三是一旦需要装饰的方法多了,每个装饰器子类都要转发所有接口方法,代码冗长到让人怀疑人生。
所以在C++社区里,真正被广泛使用的“装饰器”早就不是GoF原版了,而是演化出了各种变体。这些变体利用了模板、lambda、std::function、CRTP等C++特有的工具,把“动态添加职责”这个核心思想保留下来,同时绕开了经典实现里的坑。这篇文章我就把实际项目里踩过的、见过的几种装饰器变体摊开聊一聊,每种都给出能直接抄的代码,再讲清楚各自的适用场景和隐含成本。
1.2 为什么C++里经典装饰器常被“嫌弃”
先别急着否定经典装饰器,它在某些场景下依然有价值,比如你需要在运行时动态决定装饰器的组合顺序,或者你的代码库已经全面采用了虚函数多态风格。但C++开发者普遍“嫌弃”它,不是因为模式本身有问题,而是因为C++有更好的替代手段。
最大的问题是“接口污染”。假设你有一个IImageProcessor接口,里面有process()、getMetadata()、setRegion()等七八个方法。你写一个WatermarkDecorator只想在process()前后加水印,但因为这个类要实现接口,它必须把其他几个方法全部转发给被装饰对象。更烦的是,如果以后接口加了新方法,所有装饰器类都要同步改,这违反了开闭原则。Java里可以用动态代理解决,C++里没有内置动态代理,只能靠代码生成或者模板。
其次,经典装饰器是“对象级”的,它包装的是对象。但在很多场景里,我们想装饰的是“函数逻辑”,不是整个对象。比如给某个业务函数加日志、加耗时统计、加重试,用对象装饰器去包装会非常笨重——你得先定义一个函数对象接口,再写装饰器类持有被包装的函数对象,这相当于把函数调用重新塞回面向对象的壳子里。
于是C++程序员的直觉是:既然要装饰的是一段可调用逻辑,直接用std::function和lambda多好;既然要装饰的是一个具体类的能力,直接用模板在编译期把新行为混进去多好。这些思路发展出了C++特有的装饰器变体,下面逐一拆解。
2. 四种值得上手的装饰器变体
2.1 经典对象组合变体:接口包装器
先说说我实际项目中仍然会用的“改良版”经典装饰器。它没有完全抛弃GoF思路,但做了三个关键调整。
第一个调整:装饰器只实现被装饰接口的可选子集,缺失的方法用std::function或std::any兜底。严格来说这打破了接口隔离原则,但C++允许你在装饰器里持有std::function成员,通过构造函数注入“扩展逻辑”,而不是通过继承强制实现所有接口方法。比如:
class ILogger { public: virtual ~ILogger() = default; virtual void log(std::string_view msg) = 0; }; class TimestampDecorator : public ILogger { public: explicit TimestampDecorator(std::unique_ptr<ILogger> next) : next_(std::move(next)) {} void log(std::string_view msg) override { auto now = std::chrono::system_clock::now(); std::ostringstream oss; oss << "[" << std::chrono::system_clock::to_time_t(now) << "] " << msg; next_->log(oss.str()); } private: std::unique_ptr<ILogger> next_; };这里用unique_ptr管理生命周期,装饰器拥有被装饰对象的所有权,调用链结束时自动释放,避免了裸指针的悬垂问题。这是我认为经典装饰器在C++里最合理的形态:所有权明确、接口简单、销毁安全。
第二个调整:把“装饰多个方法”改为“只装饰一个核心方法”。如果一个接口确实需要装饰多个方法,我不会写一个装饰器去转发所有方法,而是把每个可装饰点抽象成独立的扩展接口,比如ILoggable、ITimeMeasurable,再用多重继承组合。这其实是一种接口隔离 + 装饰的组合思路。
第三个调整:用std::shared_ptr代替unique_ptr,当装饰器链需要在多个线程或组件间共享时。但注意,shared_ptr会引入引用计数的原子操作开销,而且循环引用一旦出现就内存泄漏。所以我默认用unique_ptr,除非确实需要共享。
这个变体的适用场景很清晰:团队中其他成员熟悉Java风格,代码库已经大量使用虚函数多态,并且装饰的目标确实是一个完整的对象行为链(类似中间件管道)。如果只是给一个函数加一层逻辑,别用它。
2.2 模板混入变体:编译期装饰
模板混入(Mixin)是C++里最具“原生气息”的装饰器变体。它不依赖虚函数,而是通过模板参数把“被装饰类”作为基类,让装饰类继承它并覆盖或扩展其方法。这种方式在编译期就完成了装饰,运行时零虚函数开销、零指针间接跳转。
一个典型例子:
template <typename Base> class LoggingMixin : public Base { public: using Base::Base; // 继承构造函数 void process() { std::cout << "before process" << std::endl; Base::process(); std::cout << "after process" << std::endl; } };使用方式:
class BasicProcessor { public: void process() { /* 核心逻辑 */ } }; using LoggedProcessor = LoggingMixin<BasicProcessor>;这里的LoggedProcessor是一个具体类型,它拥有BasicProcessor的所有能力,同时process()前后加了日志。装饰行为完全发生在编译期,运行时性能等同手写代码。
但这个变体有两个非常隐蔽的坑。第一个坑是“函数同名覆盖”问题:如果Base中有多个重载的process,而装饰器只重写了一个版本,其他重载会被隐藏,导致调用出错。解决办法是在装饰器里加using Base::process;,把基类的所有重载暴露出来。
第二个坑是模板混入只能“静态装饰”。你在运行时不能动态替换装饰层,因为类型在编译期就已经固定。如果两层装饰都是模板混入,你没法像经典装饰器那样在运行时决定“先日志后计时”还是“先计时后日志”。当然,你可以通过不同嵌套顺序声明多个类型,但它们是不同类,不能在同一个变量里互换。
模板混入最大的价值是“零成本抽象”。在性能敏感的代码里(比如游戏引擎的渲染管线、高频交易系统),用混入代替虚函数装饰器,能省掉每次虚调用的开销,同时让编译器有内联的机会。我见过一个网络库把所有编解码步骤都做成混入装饰器,层层嵌套,最终生成的代码和手写流程一样高效。
2.3 std::function + lambda 变体:运行时函数装饰
如果说模板混入是编译期装饰的代表,那std::function+ lambda就是运行时函数装饰的标杆。它的中心思想非常直白:把“要装饰的函数”当作可调用对象传给装饰函数,装饰函数内部做一些前置/后置处理,再调用原始函数。
最简洁的实现:
template <typename Func> auto with_logging(Func&& f) { return [f = std::forward<Func>(f)](auto&&... args) mutable { std::cout << "before" << std::endl; if constexpr (std::is_void_v<std::invoke_result_t<Func&, decltype(args)...>>) { f(std::forward<decltype(args)>(args)...); std::cout << "after" << std::endl; } else { auto result = f(std::forward<decltype(args)>(args)...); std::cout << "after" << std::endl; return result; } }; }这个模板接受任意可调用对象,返回一个新的lambda。新lambda转发所有参数调用原函数,并在前后打印日志。用的时候:
auto process_with_log = with_logging(process); process_with_log(42);如果要叠加多个装饰,直接嵌套即可:
auto process_with_log_and_metric = with_metric(with_logging(process));但直接嵌套有类型问题,外层装饰器返回的lambda类型和内层不一样,组合起来比较复杂。更实用的是统一装箱成std::function:
using ProcessFunc = std::function<void(int)>; ProcessFunc make_logged(ProcessFunc inner) { return [inner = std::move(inner)](int x) { std::cout << "before" << std::endl; inner(x); std::cout << "after" << std::endl; }; } ProcessFunc make_metric(ProcessFunc inner) { return [inner = std::move(inner)](int x) { auto start = std::chrono::steady_clock::now(); inner(x); auto end = std::chrono::steady_clock::now(); std::cout << "elapsed: " << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "us" << std::endl; }; } ProcessFunc f = make_metric(make_logged(process)); f(42);这里的std::function起到了“统一类型”的作用,让装饰器可以像积木一样自由组合。代价是每次调用会有一次虚函数/类型擦除的间接跳转,性能比直接调用慢一个数量级,但绝大多数业务逻辑完全不在乎这几十纳秒。它的优势是极强的灵活性:你可以在运行时动态选择装饰器组合,可以保存装饰器的栈/队列,可以把装饰后的函数作为参数传来传去。
我用这个变体写过一个小型中间件风格的请求处理器:请求进来先经过鉴权、再经过限流、再经过日志、最后到达业务函数,每个中间件就是一个std::function装饰器。新增一个中间件只需要写一个函数,改动成本极低。
2.4 CRTP静态装饰变体:零成本抽象
CRTP(Curiously Recurring Template Pattern)是C++模板独有的模式:基类模板接受派生类作为模板参数。用在装饰器上,可以实现“静态多态装饰”,既像模板混入那样零成本,又能更优雅地让装饰器访问被装饰对象的公开接口。
标准写法:
template <typename Derived> struct DecoratorBase { void process() { static_cast<Derived*>(this)->beforeProcess(); static_cast<Derived&>(*this).processImpl(); static_cast<Derived*>(this)->afterProcess(); } }; struct MyProcessor : DecoratorBase<MyProcessor> { void beforeProcess() { std::cout << "before" << std::endl; } void processImpl() { std::cout << "core" << std::endl; } void afterProcess() { std::cout << "after" << std::endl; } };这里的DecoratorBase通过static_cast调用派生类的方法,没有虚函数,没有vptr,编译器可以轻松内联。这种变体的优势在于:装饰行为被写成模板基类的一部分,派生类只需要实现切面方法(before/after),主流程由基类统一编排。相当于用继承组合了“装饰器”和“被装饰类”的角色,被装饰类本身就是装饰器的实现者。
但CRTP装饰器有个典型的困惑:它到底是在装饰谁?其实被装饰的“原始逻辑”就是派生类的processImpl,而DecoratorBase提供了“装饰框架”。如果你想在多个不同业务类上复用同一套日志装饰逻辑,你可以让它们都继承同一个LoggingDecorator<Derived>模板。这时模板参数Derived会取代业务类的过程名称,业务类只需要实现指定的接口。
CRTP适合那些“装饰逻辑固定、业务逻辑多变”的场景。比如你要给所有数据校验器加一个“校验失败重试3次再抛异常”的统一行为,用CRTP写一个RetryingDecorator<T>,让所有校验器继承它并实现validateOnce(),就能在编译期获得完整的重试逻辑,且没有虚函数开销。
3. 实操:实现一个可扩展的日志装饰器框架
3.1 需求场景与接口设计
前面讲了四种变体,纸上谈兵没意思,接下来我把一个真实需求完整走一遍。设想一个简单的缴费服务:有一个PaymentService类,提供pay(double amount) -> bool方法。现在我们需要在不修改PaymentService的情况下,给它加上三种能力:
- 日志:记录每次支付的请求金额和结果。
- 耗时统计:打印每次支付的耗时。
- 重试:失败之后最多重试3次。
而且这三个能力要能自由组合:有时候只要日志,有时候要日志+耗时,有时候三者都要,甚至顺序可变。
这个需求如果用经典GoF装饰器来做,需要定义接口、抽象装饰器、三个具体装饰器,再手动组合嵌套。代码量不少,而且重试装饰器需要处理返回值,耗时装饰器要处理异常,写起来很啰嗦。用了C++变体之后,整体会简洁得多。
我先设计统一的“可调用原语”。既然pay是一个接受double返回bool的函数,那每个装饰器本质上是一个std::function<bool(double)>的转换器。定义:
using PayFunc = std::function<bool(double)>;然后三个装饰器函数分别接收一个PayFunc,返回一个新的PayFunc。这样组合时只需要在参数上层层包裹。
3.2 基于std::function的装饰器实现
日志装饰器:
PayFunc add_logging(PayFunc inner) { return [inner = std::move(inner)](double amount) -> bool { std::cout << "[LOG] paying amount: " << amount << std::endl; bool result = inner(amount); std::cout << "[LOG] pay result: " << std::boolalpha << result << std::endl; return result; }; }耗时统计装饰器:
PayFunc add_metric(PayFunc inner) { return [inner = std::move(inner)](double amount) -> bool { auto start = std::chrono::steady_clock::now(); bool result = inner(amount); auto end = std::chrono::steady_clock::now(); auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count(); std::cout << "[METRIC] pay took " << ms << " ms" << std::endl; return result; }; }重试装饰器,这个稍微复杂点:
PayFunc add_retry(PayFunc inner, int max_retry = 3) { return [inner = std::move(inner), max_retry](double amount) -> bool { for (int attempt = 1; ; ++attempt) { if (inner(amount)) { return true; } if (attempt >= max_retry) { return false; } std::cout << "[RETRY] attempt " << attempt << " failed, retrying..." << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(100 * attempt)); } }; }组合起来:
class PaymentService { public: bool pay(double amount) { // 模拟业务逻辑,金额大于50则成功 if (amount > 50.0) { std::cout << "[PAYMENT] processing " << amount << std::endl; return true; } std::cout << "[PAYMENT] insufficient amount: " << amount << std::endl; return false; } }; int main() { PaymentService svc; PayFunc raw = [&svc](double amount) { return svc.pay(amount); }; PayFunc logged = add_logging(raw); PayFunc logged_and_metric = add_metric(logged); PayFunc full = add_retry(logged_and_metric, 3); full(30.0); full(100.0); }这段代码的优点是每个装饰器只负责一件事,想改变组合顺序只需要调整外层嵌套方式。比如想先统计耗时再记录日志,就add_logging(add_metric(raw))。想只重试不日志,就add_retry(raw)。完全契合开闭原则。
有个地方要特别注意:raw用lambda捕获了svc的引用,所以svc的生命周期必须比full长。实际项目里如果被捕获对象生命周期不确定,建议捕获shared_ptr或unique_ptr。我在第三版代码里吃过一个亏,捕获了一个临时对象,lambda悬空后崩溃,排查了好久才发现是生命周期问题。
3.3 基于模板混入的编译期装饰实现
同样的场景,如果希望零运行时开销,并且组合在编译期固定,就用模板混入。
先定义基础业务类:
class PaymentService { public: bool pay(double amount) { if (amount > 50.0) { std::cout << "[PAYMENT] processing " << amount << std::endl; return true; } std::cout << "[PAYMENT] insufficient amount: " << amount << std::endl; return false; } };注意这里没有虚函数,就是一个普通类。然后定义三个装饰器模板,每个都继承模板参数Base,并在pay前后加入自己的逻辑:
template <typename Base> class LoggingDecorator : public Base { public: using Base::Base; bool pay(double amount) { std::cout << "[LOG] paying amount: " << amount << std::endl; bool result = Base::pay(amount); std::cout << "[LOG] pay result: " << std::boolalpha << result << std::endl; return result; } }; template <typename Base> class MetricDecorator : public Base { public: using Base::Base; bool pay(double amount) { auto start = std::chrono::steady_clock::now(); bool result = Base::pay(amount); auto end = std::chrono::steady_clock::now(); auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count(); std::cout << "[METRIC] pay took " << ms << " ms" << std::endl; return result; } }; template <typename Base> class RetryDecorator : public Base { public: using Base::Base; bool pay(double amount) { for (int attempt = 1; ; ++attempt) { if (Base::pay(amount)) { return true; } if (attempt >= max_retry_) { return false; } std::cout << "[RETRY] attempt " << attempt << " failed, retrying..." << std::endl; std::this_thread::sleep_for(std::chrono::milliseconds(100 * attempt)); } } private: int max_retry_ = 3; };使用的时候通过类型别名定义组合后的类型:
using FullService = LoggingDecorator<MetricDecorator<RetryDecorator<PaymentService>>>; int main() { FullService svc; svc.pay(30.0); svc.pay(100.0); }这个变体里我要强调两个细节。第一个是using Base::Base;,它把基类的构造函数继承过来,这样FullService可以直接用PaymentService的构造函数参数构造。如果装饰器自己的构造函数需要额外参数(比如重试次数),就得自己写构造函数并在初始化列表里转发参数。第二个是pay的调用链:LoggingDecorator里的Base::pay实际调用的是MetricDecorator::pay,MetricDecorator里的Base::pay调用RetryDecorator::pay,最后到达PaymentService::pay。每一层都是直接调用,没有虚函数表,编译器可以全部内联。
编译期装饰的缺陷刚才提过,现在展示一次:你没法在运行时让FullService和LoggingDecorator<PaymentService>互相替换。它们没有继承关系。如果你需要一种“既能全功能又不想要重试”的可变组合,模板混入会让你写出N个类型别名,比如:
using S1 = LoggingDecorator<PaymentService>; using S2 = MetricDecorator<PaymentService>; using S3 = LoggingDecorator<MetricDecorator<PaymentService>>;越组合越爆炸。所以当组合需求不确定、需要运行时配置时,我会坚定选std::function版本。当组合在编译期确定、性能要求苛刻时,才选模板混入。
3.4 两种实现的对比与选择建议
这里把两种实现放在一起对比,方便项目选型。
| 维度 | std::function + lambda | 模板混入 |
|---|---|---|
| 组合时机 | 运行时动态组合 | 编译期静态组合 |
| 运行时开销 | 每次调用有间接跳转和类型擦除 | 无额外开销,可内联 |
| 代码量 | 少,每个装饰器一个函数 | 中,每个装饰器一个类模板 |
| 可读性 | 较好,组合式调用直观 | 一般,类型别名嵌套深 |
| 调试难度 | 调用栈多一层std::function,复杂场景难追 | 模板错误信息极其冗长,编译报错难懂 |
| 二进制体积 | 无显著膨胀 | 每个组合都会实例化一份代码,组合多了膨胀 |
| 依赖管理 | 依赖std::function和捕获的生命周期 | 依赖模板推导和继承构造函数 |
我的经验法则:
- 如果这是一个业务逻辑模块,性能不是第一优先级,优先用
std::function变体。它更灵活,更容易单元测试(因为装饰器是普通函数,可以直接传入mock的PayFunc)。 - 如果这是一个底层基础设施或热路径代码,比如每帧渲染调用、高频网络收发,优先用模板混入。少一次间接跳转,多一次内联机会,对整体性能有明显帮助。
- 如果团队里新手多,尽量用
std::function版本。模板混入的编译报错能把人劝退,而且继承构造函数的一些边界行为很容易踩坑。
4. 常见问题与排查技巧实录
4.1 虚函数开销与对象生命周期陷阱
经典装饰器最常见的两个问题。
第一个问题是“过度使用虚函数”。有些团队把装饰器模式奉为圭臬,一个很小的接口也要先定义纯虚基类,再写N个装饰器。结果每次调用都要两次虚函数跳转(装饰器+被装饰器),虽然单次开销可能只有几纳秒,但在高频循环里累积起来很可观。排查方法很简单:用perf top或者火焰图看热点是否落在__cxa_pure_virtual或虚调用相关符号上。如果确实遇到,考虑把装饰器改成模板混入。
第二个问题是对象生命周期。经典装饰器的构造通常需要传入被装饰对象,很多新手用裸指针传引用:
class Decorator { public: Decorator(ILogger* inner) : inner_(inner) {} private: ILogger* inner_; };然后他写:
vector<unique_ptr<ILogger>> loggers; loggers.push_back(make_unique<TimestampDecorator>(new ConsoleLogger())); // 危险更危险的是持有局部变量引用:
ILogger* logger = new ConsoleLogger(); TimestampDecorator decorator(logger); delete logger; decorator.log("hello"); // 悬垂指针我的建议是:凡是装饰器对被装饰对象拥有所有权,就使用unique_ptr;凡是只借用不拥有,必须明确文档说明“调用方保证被装饰对象在装饰器生命周期内有效”,最好用shared_ptr持有,避免手动管理。没有第三种选择,除非你能接受定时炸弹。
4.2 模板装饰器导致的代码膨胀
模板混入装饰器有一个隐蔽问题:每组合一种类型,编译器就会生成一套完整的代码。如果你有10个基础功能类,10个装饰器模板,用户随意组合,潜在的类型数量是指数级。即便只用了其中的20种组合,二进制里也会出现20份几乎一模一样的pay流程代码。
我遇到过真实案例:项目链接后二进制体积从20MB涨到60MB,排查发现就是某个通用装饰器模板被多处使用,且每处组合都不同,导致大量重复实例化。解决方案有三个:
- 把装饰器模板中逻辑相同且不依赖具体类型的部分提取成非模板基类或自由函数,减少生成的代码量。
- 使用
extern template显式实例化某些常用组合,避免在多个编译单元重复实例化。 - 如果代码膨胀不可控,干脆退回
std::function版本,让所有装饰器共享同一个函数类型,运行时再组装。
另外建议在编译时打开-flto(LTO优化),链接器会移除部分重复代码。但注意LTO会增加编译时间,大型项目需要权衡。
4.3 函数装饰器与异常安全
用std::function装饰器时,有个容易忽略的问题:装饰器内部的inner(amount)可能抛出异常。比如日志装饰器里,你已经在cout中打印了“before”,如果inner抛异常,那“after”的日志就不会打印。更糟糕的是,如果装饰器捕获了异常准备记录,但记录的代码本身也可能抛异常(比如磁盘满了),会导致原始异常被掩盖。
处理思路:
PayFunc add_logging_safe(PayFunc inner) { return [inner = std::move(inner)](double amount) -> bool { std::cout << "[LOG] before" << std::endl; try { bool result = inner(amount); std::cout << "[LOG] after success" << std::endl; return result; } catch (...) { std::cout << "[LOG] after exception" << std::endl; throw; // 重新抛出原始异常 } }; }异常安全的原则有两层:第一层是保证装饰器在异常发生时自身的一致性,不要泄漏资源、不要留下未完成状态;第二层是不要“吞掉”异常,除非装饰器的职责就是处理异常并返回失败结果。重试装饰器就属于后者——它的职责是捕获异常并重试,但要注意只捕获特定的异常类型,比如网络超时,而不是所有异常(比如逻辑错误重试也没用)。
4.4 与代理模式、责任链模式混淆
很多C++初学者会把装饰器变体和代理模式、责任链模式混为一谈。这三个模式在“包装一个对象/函数”的外在形式上确实很像,但意图完全不同。
代理模式(Proxy)强调的是“控制访问”,比如延迟加载、访问权限控制。代理和被代理对象通常不完全一致,代理可能只暴露部分接口,而且代理可能在没有真实对象的情况下存在。装饰器强调“增强功能”,它必须保持原有接口不变,且在原有逻辑上叠加新行为。
责任链模式(Chain of Responsibility)强调“请求沿着链传递,每个节点能处理或跳过”。装饰器链则必须层层调用,不能跳过。一个典型的分辨方法是:如果某个节点可以选择“不处理并直接返回”,那是责任链;如果每个节点都必须先处理后传递给下一个,那是装饰器。
基于std::function的装饰器很容易不小心写成责任链,因为每个装饰器都可以在调用inner之前判断条件并提前返回,这时候它实际上就变成了“过滤器/中间件”。不要紧,这恰恰是C++变体的灵活之处——只要你清楚自己到底在实现哪个模式,命名上保持一致,就不会误导维护者。我在业务代码里遇到过一个被称为“decorator”的类,实际逻辑是如果参数非法就直接返回错误,根本不会调用inner。这其实是校验代理。把名字改掉之后,团队理解成本立刻降了下来。
5. 我的经验心得与进一步扩展
5.1 什么时候该用装饰器变体,什么时候不该用
踩过这么多坑之后,我对装饰器变体的态度变得很务实:不要在代码里为了模式而模式。如果你的设计目标只是“在某个操作前后加点日志”,直接写两行代码就够了,没必要套装饰器。真正的装饰器需求通常有几个信号:
- 同一个基础行为需要多种可选增强,且组合方式较多。
- 这些增强需要在多处复用,而不是只服务一两个调用点。
- 你希望调用方只依赖原始接口,不感知增强的存在。
- 你想把横切关注点(日志、安全、重试、缓存)从业务逻辑中剥离出来。
反过来,如果只是单一一处需要加日志,或者你的“装饰器”只有一个固定子类,那用装饰器反而增加复杂度。C++的std::function变体尤其适合“函数级别”的横切关注点,但它有类型擦除成本;模板混入适合“类级别”的静态增强,但组合爆炸和编译期绑定是硬伤。没有全能的变体,只有当前场景下最合适的那一个。
5.2 下一步可以怎么玩:AOP式切面装饰
最后分享一个我认为很有价值的进阶方向:把装饰器变体往AOP(面向切面编程)的方向扩展。AOP的核心是把“日志、性能、安全”这类横切关注点从业务代码中抽出来,用“切面”的方式在编译期或运行期织入主流程。装饰器模式天然就是实现AOP的一种手段,尤其在C++里,我们可以用模板元编程在编译期完成“切面织入”。
比如我开发过一个简单的切面装饰器框架,核心是一个宏或模板,允许你在一个类的方法上标注多个切面:
template <typename T, template<typename> typename... Aspects> struct AspectDecorated : Aspects<T>... { using Aspects<T>::Aspects...; using Aspects<T>::operator...; // 这里只是示意 };实际实现要麻烦得多,涉及每个切面的before/after顺序、异常处理、参数修改等。一个更接地气的做法是继续用std::function,把切面实现成独立的中间件函数,通过一个decorator_chain工具把多个函数按顺序组合起来:
template <typename Ret, typename... Args> std::function<Ret(Args...)> compose_decorators( std::function<Ret(Args...)> core, std::vector<std::function<std::function<Ret(Args...)>(std::function<Ret(Args...)>)>> decorators) { for (auto it = decorators.rbegin(); it != decorators.rend(); ++it) { core = (*it)(std::move(core)); } return core; }这样可以从配置文件里读取需要启用哪些切面,运行时动态构建装饰链。我拿这套东西给内部工具写过“审计日志 + 并发限流 + 超时控制”三件套,只改一行配置就能开关某个切面,调试和上线都很方便。这算是把装饰器模式变体用到了极致。
如果你正处在C++设计模式学习的瓶颈期,我建议不要死记GoF的类图,而是多从“这个模式在C++里能映射到哪些语言特性”的角度去思考。装饰器模式就是一个绝佳的切入点:它既能映射到虚函数和继承,也能映射到模板、lambda和std::function。把每种映射都写一遍、跑一遍、比较一遍,你对C++的理解会上升一个台阶。