写这篇文章的起因,是我在公司一个大型模块里看到一段让人头皮发麻的代码:一个类有二十多个继承层级,为了给原有功能加上日志、鉴权、缓存和重试,硬生生造了四个子类,每次新增需求就再加一层。后来我重构时把这一坨换成了装饰器模式的变体,代码量少了近四成,可读性和可测试性反而上去了。今天就把我在C++里折腾装饰器模式及其各种变体的经验完整写出来,从经典GoF写法到模板版、std::function轻量版、切面式的洋葱模型,全部都是跑过、踩过坑之后沉淀下来的东西。
先把这个模式到底解决什么问题说清楚。《设计模式》里那套经典做法是:给对象动态添加职责。但放到C++语境里,现实情况远比书上的UML图复杂:继承结构怎么设计、虚函数要付出多少性能代价、对象生命周期怎么管理、多个装饰器嵌套后顺序怎么保证,这些才是真正决定方案能不能落地的关键。我见过太多人背了一堆模式名词,一到实际工程就不知道怎么选型。所以我这篇文章的核心思路很简单:先讲透经典实现的底层逻辑,再把我在实践中用过的、改造过的、更适合C++风格的各种变体逐一拆开,给出可直接抄走的代码和参数选择依据,最后把踩过的坑汇总成一张排查表。适合正在做模块解耦或老代码重构的朋友,也适合想搞懂“模式不是死套路”的C++开发者。
1. 从GoF经典实现出发:装饰器的本质与C++困境
1.1 经典装饰器到底改了什么
书上的经典结构是四个角色:Component抽象接口、ConcreteComponent具体组件、Decorator抽象装饰类、ConcreteDecorator具体装饰类。核心思想是组合代替继承,把附加功能包装在原对象外面,客户端面对的是同一个Component接口,感觉不到装饰的存在。
class Stream { public: virtual ~Stream() = default; virtual size_t read(char* buf, size_t len) = 0; virtual size_t write(const char* data, size_t len) = 0; }; class FileStream : public Stream { public: size_t read(char* buf, size_t len) override { /* ... */ return 0; } size_t write(const char* data, size_t len) override { /* ... */ return 0; } }; class StreamDecorator : public Stream { protected: std::unique_ptr<Stream> wrapped_; public: explicit StreamDecorator(std::unique_ptr<Stream> wrapped) : wrapped_(std::move(wrapped)) {} size_t read(char* buf, size_t len) override { return wrapped_->read(buf, len); } size_t write(const char* data, size_t len) override { return wrapped_->write(data, len); } };CryptoStream、BufferedStream这些派生装饰类只要重写各自的read/write,在调wrapped_前后插入加密或缓冲逻辑即可。这套设计最漂亮的地方是开闭原则:以后要加压缩装饰器,不需要碰任何已有类。
1.2 C++实现经典模式时的三个隐含成本
但C++里直接照搬这套,你会发现几个棘手问题。
第一个就是虚函数开销。Stream接口有两个虚函数,每一层装饰调用链上是N层虚函数调用。在性能敏感场景,每多一层装饰就多一次间接跳转和可能的cache miss。我用perf实测过一个五层装饰链,比直接调用裸函数慢了约30ns每调用。单看数字不大,但如果是每秒钟处理百万条消息的网关,这就是30毫秒的纯CPU开销,只多不少。
第二个问题是所有权语义模糊。经典Java装饰器用堆引用,垃圾回收器兜底。C++里必须明确wrapped_是裸指针、unique_ptr还是shared_ptr。裸指针容易悬垂;unique_ptr明确独占所有权但无法共享装饰链;shared_ptr灵活但带来了引用计数开销和循环引用的隐患。我建议一般场景直接用unique_ptr,明确“装饰器拥有被装饰对象”这个语义。
第三个问题是拷贝和移动语义。你的装饰类如果想放进容器,或者按值传递,那std::unique_ptr成员会让装饰类变成只可移动不可拷贝。这本身不是问题,但不少人会在接口设计上犯错:接口基类的拷贝构造是禁用的,导致所有装饰器无法拷贝,回头又来骂模式不好用。其实正确的做法是想清楚你到底需不需要拷贝语义,而不是盲目禁用或盲目实现。
struct NonCopyable { NonCopyable() = default; NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; NonCopyable(NonCopyable&&) noexcept = default; NonCopyable& operator=(NonCopyable&&) noexcept = default; };2. 变体一:模板装饰器——把重活留给编译器
2.1 零成本抽象的构型思路
经典装饰器用虚函数实现运行时多态,代价是运行时开销。但如果附加功能在编译期就能确定,为什么不让编译器直接帮你把装饰链展开?这就是模板装饰器的核心思路:类型即装饰列表。
template<typename Component> class LoggingDecorator : public Component { public: using Component::Component; void execute() { std::cout << "log before" << std::endl; Component::execute(); std::cout << "log after" << std::endl; } };注意这个写法:LoggingDecorator继承模板参数Component,直接吃掉基类的构造函数,然后通过作用域限定调用Component::execute()。它不再是持有Component对象,而是成为Component本身的一部分。这样最终实例化出来的类型是一个完整的静态类型,编译器可以做内联,所有函数调用在编译期resolve完毕,运行时零间接跳转。
2.2 与经典实现的行为差异和适用边界
模板装饰器的行为语义完全不同。经典模式里你是在运行时动态包一层,同一个底层对象可以今天包缓存、明天不包缓存,全看运行路径怎么组装;模板版本在编译期就焊死了装饰链,想要两种组合就得实例化两种类型。
它的适用场景很清晰:装饰层级固定、不需要运行时决策、追求极限性能。我在写基础设施组件时经常用这个。比如一个网络包的收发链路,固定依次经过“字节序转换->压缩->加密->发送”,这条链路在所有运行模式里不会变,那就用模板装饰器。类型自解释,一个using声明就能看出整个链路:
using SecureCompressingStream = EncryptionDecorator<CompressionDecorator<FileStream>>;还有一个好处是能配合C++17的if constexpr做条件编译,让某些装饰器只在特定配置下生效。这样配置和编译逻辑能写到一起,比用宏干净得多。
2.3 模板装饰器的参数转传技巧
模板装饰器要覆盖各种构造参数,写法上有不少细节。最常见也最省事的是继承构造函数(using Base::Base;注意实际写法是using Component::Component)。但如果你需要默认构造之外的参数传递,或者想限制某些参数,需要自己手写forwarding构造。
template<typename Component> class TimingDecorator : public Component { public: template<typename... Args> explicit TimingDecorator(Args&&... args) : Component(std::forward<Args>(args)...) {} void execute() { auto start = std::chrono::steady_clock::now(); Component::execute(); auto end = std::chrono::steady_clock::now(); std::cout << "elapsed: " << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << " us" << std::endl; } };这个转发构造的好处是,无论底层Component构造需要几个参数,装饰器都能透传,不需要为每个装饰器单独写构造重载。实际工程里这招基本能避免你被构造函数参数组合压垮。
模板装饰器的缺点也明显:编译时间上升、出错时模板报错信息极其恐怖、无法在运行时改变行为。所以我给团队定的原则是:热路径且装饰链固定用模板装饰器;业务逻辑动态性强的用下面要讲的std::function轻量变体。
3. 变体二:std::function轻量装饰器——按需组合的胶水层
3.1 为什么需要轻量变体
很多场景我们并不需要为每个被装饰对象都定义一个抽象接口。比如某个模块里就是几个离散的函数调用,每个调用都要做权限校验、参数校验、耗时统计。为它们抽象一个多态基类明显是杀鸡用牛刀。这时候把函数签名本身作为“接口”,用std::function存放可调用对象,再返回一个包了一层新逻辑的std::function,是最经济实惠的做法。
template<typename R, typename... Args> std::function<R(Args...)> decorate( std::function<R(Args...)> fn, std::function<void(Args...)> before, std::function<void(R&, Args...)> after) { return [fn = std::move(fn), before = std::move(before), after = std::move(after)](Args... args) mutable -> R { before(args...); if constexpr (std::is_void_v<R>) { fn(std::forward<Args>(args)...); after({}, std::forward<Args>(args)...); } else { R result = fn(std::forward<Args>(args)...); after(result, std::forward<Args>(args)...); return result; } }; }这段代码的核心是lambda捕获了三个std::function:原始函数、前置动作、后置动作。之后每次调用lambda,就是在原始函数外面包了一层before和after。R是void时需要一个特殊分支,否则R result = fn(...)编译不过,这也是if constexpr的价值所在。
3.2 三种实际可落地的形态
我常用的轻量装饰器不止统一decorate函数这一种,更常见的是几个具体封装工具。
限制执行次数的装饰器。包装原始函数,内部维护一个计数器,超过阈值直接拒绝继续调用。这个在限流、超时保护场景里很实用:
template<typename R, typename... Args> auto withRateLimit(std::function<R(Args...)> fn, size_t maxCalls) { auto counter = std::make_shared<std::atomic<size_t>>(0); return [fn = std::move(fn), counter = std::move(counter), maxCalls](Args... args) mutable -> R { size_t current = counter->fetch_add(1) + 1; if (current > maxCalls) { throw std::runtime_error("rate limit exceeded"); } return fn(std::forward<Args>(args)...); }; }有了这个,调用方不需要关心限流怎么实现,只要创建fn时套一层即可。注意计数器用shared_ptr而不是值捕获,因为lambda可能被拷贝,计数器需要共享状态;用atomic是因为限流拦截通常是多线程场景,直接用size_t++会有数据竞争。
重试装饰器。捕获异常后按重试次数重新调用原始函数。实现时要小心两点:第一,重试间隔要有退避策略,不要毫无停顿地疯狂重试;第二,要保留最后一次异常信息作为最终抛出对象,不能让调用方丢失根因。
template<typename R, typename... Args> auto withRetry(std::function<R(Args...)> fn, int maxRetry, std::chrono::milliseconds baseDelay) { return [fn = std::move(fn), maxRetry, baseDelay](Args... args) mutable -> R { int attempt = 0; while (true) { try { return fn(std::forward<Args>(args)...); } catch (...) { if (attempt >= maxRetry) throw; int delayMs = baseDelay.count() * (1 << attempt); std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); ++attempt; } } }; }超时装饰器。给原始调用设置超时时间。严格做法是放到独立线程上跑然后等待future,但这样做了之后线程安全、取消协作都变得更复杂。C++20之前我只会对本身就支持超时的接口做包装,不要试图对一个重活儿强行开线程取消,那是给自己和调用方挖坑。
这三个变体在工程里经常叠着用:先限流、再重试、最内层是真正的业务函数。数据流从外到内先过限流,失败时触发重试。顺序反过来的话,重试会绕过硬限流导致限流失效,这点必须留意。
3.3 性能与类型擦除的取舍
std::function本身是类型擦除容器,它内部通过虚函数或多态调用指针来间接调用实际可调用对象。和直接调用lambda相比,单次调用开销大约多个几纳秒,还会产生一次堆分配(如果可调用对象尺寸大于SBO,即小对象优化缓冲区,比如32字节时不会堆分配,但大的lambda会)。这在高频函数上一万次调用就是多出不少时间。
我的策略是:灵活使用场景,比如路由分发、动态构建装饰链、测试替身,直接上std::function,开发效率大于一切;热循环里的核心计算路径,尽量用模板变体或者直接固化到循环外。没有哪种方案是万能的,选型的本质是成本和收益的权衡。
4. 变体三:切面式洋葱模型与多级装饰链组装
4.1 洋葱模型及其在C++里的实现原理
在Java里Spring的拦截器链、Python的装饰器stack,都是“洋葱模型”的代表。请求从外层进入,经过一层层前置逻辑后到达核心,出来时再经过一层层后置逻辑。每一层相当于一个装饰器,只不过它们是围绕一个核心handler组织起来的。
C++里实现这个模型最直接的方式是递归组合。我们可以把装饰器写成“接收一个handler,返回一个新的handler”的函数。每层decorate后,返回值作为下一层的输入。最终得到的是一个层层包裹的handler,调用它就像剥洋葱。
using Handler = std::function<Response(Request)>; Handler wrapWithLogging(Handler inner) { return [inner = std::move(inner)](Request req) { std::cout << "log begin" << std::endl; Response resp = inner(req); std::cout << "log end" << std::endl; return resp; }; } Handler wrapWithAuth(Handler inner) { return [inner = std::move(inner)](Request req) { if (!hasPermission(req)) throw UnauthorizedException(); return inner(req); }; }组装时从核心开始往外包:
Handler core = [] (Request req) -> Response { /* 业务逻辑 */ }; Handler finalHandler = wrapWithRateLimit(wrapWithLogging(wrapWithAuth(core)));4.2 链顺序、中断与异常传播
洋葱模型最讲究的是顺序语义。请求先经过auth,再经过log,再进入core;响应反过来先经过log再回到外层。所以如果你把限流放最外层,日志放第二层,那被限流的请求根本不会进到日志层,日志里看不到被限流请求。反过来,限流日志放内层则每次请求前先记录再限流,日志就能记录限流事件。没有标准答案,取决于你到底想观测什么。
中断语义也需要约定。有的装饰器校验失败后直接抛异常,后面的层都不用执行;有的装饰器允许短路返回固定值。在C++里适合以异常作为中断手段,因为异常天然可以跨层传播;短路返回则要求装饰器类型签名支持optional或者默认值。我统一用异常来解决,简单直接,调用方也容易catch。
异常传播还有一个坑:装饰器内层抛异常时,外层after逻辑可能没有执行。如果外层after负责释放锁或者标记完成,就可能导致资源泄漏。解决办法是RAII作用域守卫,确保出了作用域一定执行清理:
struct ScopeGuard { std::function<void()> cleanup; ~ScopeGuard() { if (cleanup) cleanup(); } };在洋葱层里如果要在请求结束统一做埋点统计,就把埋点放到ScopeGuard里而不是等after调用返回后再执行。这样即使核心抛异常,统计也不会丢。
4.3 工厂函数集中管理装饰链
手写多个wrap调用还是太啰嗦了,也不利于统一维护。实际做法是把装饰链的解析收敛到一个工厂类中,用配置项决定包的顺序和启停项。
class HandlerFactory { public: Handler build(const HandlerConfig& cfg) { Handler h = coreHandler_; if (cfg.enableAuth) h = wrapWithAuth(std::move(h)); if (cfg.enableLog) h = wrapWithLogging(std::move(h)); if (cfg.enableRateLimit) h = wrapWithRateLimit(std::move(h), cfg.maxCallsPerSecond); return h; } private: Handler coreHandler_; };好处是调用方只跟HandlerFactory打交道,不知道也没必要知道装饰细节。想调整某个环境的装饰顺序只改这个工厂,不会散落在十万八千里之外的业务代码里。我们线上切灰度、出问题需要临时关掉某层装饰时,也只需通过配置切换,不需要重新编译。
5. 实操案例:给一个异步网络客户端逐层加装饰
理论讲了一堆,不能没有完整落地过程。这节我拿一个简化但实际用过的异步网络客户端,完整展示从接口定义、装饰器实现到组装和测试的全过程。使用类Redis的协议,核心对象负责发送命令并接收响应;我要在它身上加三件套:超时控制、调用日志、熔断保护。
5.1 接口设计
class CommandExecutor { public: virtual ~CommandExecutor() = default; virtual std::string execute(const std::string& cmd) = 0; }; class RedisExecutor : public CommandExecutor { public: std::string execute(const std::string& cmd) override { // 真实的网络收发逻辑,这里用sleep模拟 std::this_thread::sleep_for(std::chrono::milliseconds(10)); return "OK"; } };接口为什么只给虚析构和execute一个纯虚函数?因为装饰器链只需要这一个业务入口。如果后续要加批量execute,那就要考虑是加新接口还是把装饰器定义为纯模板,避免虚接口过胖。经验法则是:装饰器模式下的接口越瘦越好,每个额外虚函数都得在装饰器基类里实现转发,维护成本翻倍。
5.2 三个装饰器的具体实现
超时装饰器。这里简化一下,作为示例用调用线程抢占式超时检查代价大,我们不实际做真实线程取消,而是用一个标记:通过mutable原子布尔量标记超时,核心业务在每步检查。如果有真实异步事件循环,应该在事件循环回调里设置超时检查点。
class TimeoutDecorator : public CommandExecutor { public: TimeoutDecorator(std::unique_ptr<CommandExecutor> wrapped, std::chrono::milliseconds timeout) : wrapped_(std::move(wrapped)), timeout_(timeout) {} std::string execute(const std::string& cmd) override { auto start = std::chrono::steady_clock::now(); auto result = wrapped_->execute(cmd); auto elapsed = std::chrono::steady_clock::now() - start; if (elapsed > timeout_) { throw std::runtime_error("timeout"); } return result; } private: std::unique_ptr<CommandExecutor> wrapped_; std::chrono::milliseconds timeout_; };日志装饰器。特别注意只记录命令类型和状态,不记录敏感参数。生产环境永远不会缺一个把密码打到日志里的糟心事。
class LoggingDecorator : public CommandExecutor { public: explicit LoggingDecorator(std::unique_ptr<CommandExecutor> wrapped) : wrapped_(std::move(wrapped)) {} std::string execute(const std::string& cmd) override { auto begin = std::chrono::steady_clock::now(); try { std::string result = wrapped_->execute(cmd); log(cmd, "success", begin); return result; } catch (...) { log(cmd, "error", begin); throw; } } private: std::unique_ptr<CommandExecutor> wrapped_; void log(const std::string& cmd, const char* status, std::chrono::steady_clock::time_point begin) { /* 真实的spdlog调用 */ } };熔断装饰器。经典的熔断器有三个状态:关闭、打开、半开。关闭时正常调用;连续失败次数超过阈值则打开,此后快速失败不再访问核心;经过冷却时间后进入半开,允许少量探测请求,成功则闭合,失败则重新打开。这里实现一个简化但结构完整的版本。
class CircuitBreakerDecorator : public CommandExecutor { public: CircuitBreakerDecorator(std::unique_ptr<CommandExecutor> wrapped, int failureThreshold, std::chrono::milliseconds cooldown) : wrapped_(std::move(wrapped)), failureThreshold_(failureThreshold), cooldown_(cooldown) {} std::string execute(const std::string& cmd) override { std::lock_guard<std::mutex> lock(mutex_); if (state_ == State::Open && clock::now() - openedAt_ >= cooldown_) { state_ = State::HalfOpen; } if (state_ == State::Open) { throw std::runtime_error("circuit breaker open"); } try { auto result = wrapped_->execute(cmd); onSuccess(); return result; } catch (...) { onFailure(); throw; } } private: using clock = std::chrono::steady_clock; enum class State { Closed, Open, HalfOpen }; std::unique_ptr<CommandExecutor> wrapped_; std::mutex mutex_; int failureThreshold_; std::chrono::milliseconds cooldown_; State state_ = State::Closed; int failureCount_ = 0; clock::time_point openedAt_{}; void onSuccess() { failureCount_ = 0; if (state_ == State::HalfOpen) state_ = State::Closed; } void onFailure() { ++failureCount_; if (state_ == State::HalfOpen || failureCount_ >= failureThreshold_) { state_ = State::Open; openedAt_ = clock::now(); failureCount_ = 0; } } };这里注意三个细节。第一,熔断器本身要加锁,因为线上熔断状态是全局共享的,多线程同时调用时没有锁会数值错乱。第二,打开后抛出的是业务异常而不是继续向下调用,否则熔断就没有意义。第三,半开状态下只放少量请求进来,我这个简化版本没有控制并发探测数量,真实生产里还要再加一个原子量限制半开期的并发请求数,否则瞬间的一股流量会直接打穿核心。
5.3 组装方式与调用路径分析
先看经典的嵌套组装:
auto executor = std::make_unique<CircuitBreakerDecorator>( std::make_unique<LoggingDecorator>( std::make_unique<TimeoutDecorator>( std::make_unique<RedisExecutor>())), 5, std::chrono::milliseconds(1000)); auto result = executor->execute("GET key");执行路径是:
- CircuitBreakerDecorator::execute 先检查熔断状态
- LoggingDecorator::execute 记录请求开始和结果
- TimeoutDecorator::execute 断言执行耗时
- RedisExecutor::execute 做真正的网络IO
- 返回路径上,TimeoutDecorator 检查超时,LoggingDecorator 记录成功/失败,熔断器更新状态
这里要重点说“装饰顺序”。如果改成Timeout在外层、熔断在里层,语义就会变:超时计时会包含熔断器的状态检查开销,虽然量级很小;但更重要的是,一旦熔断器打开,它是直接抛异常,此时外层Timeout先拿不到结果,也就无法看到熔断异常是否属于“超时”。我个人习惯把熔断放最外,因为它关注的是整体可用性;超时放内层,只针对真实IO耗时;日志放中间,这样无论熔断与否,日志层都能记录到最终业务结果。这里没有绝对标准,但最好形成项目内的统一约定。
5.4 用模板变体重写同一链路
如果这个链路的组合在编译期固定,就可以用模板版本写出更紧凑的代码。我提供完整示例:
template<typename Core> class TimeoutWrapper : public Core { public: using Core::Core; std::string execute(const std::string& cmd) { auto start = std::chrono::steady_clock::now(); auto result = Core::execute(cmd); if (std::chrono::steady_clock::now() - start > timeout_) { throw std::runtime_error("timeout"); } return result; } std::chrono::milliseconds timeout_{200}; }; template<typename Core> class LoggingWrapper : public Core { public: using Core::Core; std::string execute(const std::string& cmd) { log(cmd); return Core::execute(cmd); } }; template<typename Core> class CircuitBreakerWrapper : public Core { public: using Core::Core; std::string execute(const std::string& cmd) { // 简化省略细节 ... return Core::execute(cmd); } }; using FullStackedExecutor = CircuitBreakerWrapper< LoggingWrapper< TimeoutWrapper<RedisExecutor>>>;模板版的优点是零虚函数开销,编译器把整条链内联展开,生成的代码量小、执行快;缺点是如果某一层需要构造参数,得手动加成员和构造逻辑。我实际使用下来,模板版更适合“配置固定不变、追求临界性能”的基础设施层,经典抽象接口版更适合“业务动态变化、需要反射式配置”的业务层。
6. 常见问题与避坑速查
这部分是我积累的真实经验。装饰器模式光看概念容易,一写代码全是细节问题。
6.1 高频踩坑场景与解法对照
| 症状 | 根因 | 解决方案 |
|---|---|---|
| 调用装饰链时抛出bad_cast或类型错误 | 装饰器内部直接强转为具体类型,破坏了接口抽象 | 禁止在装饰器内部做dynamic_cast,只依赖基类接口 |
| 成员数据重复命名导致编译错误 | 多层模板装饰器继承同名字段 | 加上using声明或者用不同命名前缀隔离 |
| 日志记录不到被限流请求 | 限流层放在日志层外层,请求已被拦截 | 调整装饰链顺序,把日志放外层或内层按需设计 |
| 拷贝装饰器对象后计数或状态不共享 | 对lambda按值捕获了计数器,拷贝后各自独立 | 需要共享状态时用shared_ptr包住可变状态 |
| 模板块编译报错看起来完全无关 | 模板装饰器某层缺少转发构造,或类型未完全定义 | 逐层注释定位,保证每个装饰器都能独立编译 |
| 内存泄漏 | 装饰器之间引用成环,或者持有的unique_ptr未正确释放 | 避免装饰器保存全局shared_ptr引用,或改用weak_ptr |
| 性能下降明显 | 多层虚函数 + std::function类型擦除叠加 | 测量热路径,换成模板装饰器或绕过装饰器直接调用核心 |
6.2 生命周期管理的三条铁律
铁律一:明确装饰链的所有权方向。最外层的拥有者负责整条链的释放。用unique_ptr时,Chain:outer -> inner -> core,析构自然逐层释放,不需要额外处理。如果某层装饰器被多个调用方共享,就把那层改用shared_ptr,其余层保持unique_ptr。别为了让某层可共享而把整条链的每一层都变成shared_ptr,那会让析构顺序失控。
铁律二:不要在装饰器构造函数里访问被装饰对象的方法。构造阶段对象还不完整,哪怕这层包装的是一个完全构造好的内层对象,调用虚函数也可能落到错误实现上。我见过有人想在包装时先读一下内层配置,结果行为诡异还不好查。正确的做法是把初始化逻辑放到第一次execute调用里,懒加载。
铁律三:异常安全。装饰器的execute实现必须先调内层,再根据结果做after逻辑;但不要在after里假设内层一定成功。用ScopeGuard或try-catch包住涉及资源释放的代码。
6.3 测试策略:怎么验证装饰链没写错
装饰器模式天然适合一层一层测试,千万别等整条链拼完再测。我习惯是:
- 给核心组件写一个假的CoreStub,用固定输入输出验证核心逻辑。
- 对每个装饰器单独用一个“记录调用顺序且可注入异常”的Stub进行测试,断言前置逻辑、后置逻辑、异常路径都按预期执行。
- 对整条链做集成测试,用真实组件或半真半假替换,验证顺序符合设计。
- 针对模板变体,用static_assert检查关键类型特征,比如是不是基类的派生类、是否能拷贝构造。
class StubExecutor : public CommandExecutor { public: std::string execute(const std::string& cmd) override { calls.push_back(cmd); if (shouldThrow) throw std::runtime_error("stub fail"); return "stub_result"; } std::vector<std::string> calls; bool shouldThrow = false; }; TEST(DecoratorTest, LoggingWrapsCore) { auto core = std::make_unique<StubExecutor>(); LoggingDecorator decorator(std::move(core)); auto result = decorator.execute("hello"); ASSERT_EQ(result, "stub_result"); // 日志层记录内容可通过注入logger来验证 }这里提一下,网上教程常把装饰器模式画得很复杂,但实际经验是:装饰器不该包含业务逻辑,它只负责“切面”类工作。一旦你在装饰器里写了大量业务判断,说明你应该把这层职责抽成一个真正的策略类,而不是继续堆装饰器。
6.4 关于C++23、设计模式和现代替代方案的思考
搜索热词里全是C++23种设计模式之类的关键词,让我多说一句。设计模式的“正确姿势”从来不是背类图。在C++20之后,我们有了更多替代工具:std::function可以替代部分装饰器;std::jthread和std::stop_token可以做类似拦截的协作取消;cpo( customization point object)和编译期多态可以再造一套零开销装饰机制。但装饰器思想本身不会过时:组合优于继承,关注点分离,一层层封装外部副作用。用哪种语法实现不重要,重要的是解耦思路。
另外,很多团队爱把装饰器模式跟AOP(面向切面编程)划等号。事实上装饰器是结构型模式,AOP是一种范式,两者目标有重叠但做法不同。装饰器通过包装改变对象行为,AOP通过织入点改变行为。C++的模板装饰器其实更接近静态织入;std::function装饰器接近动态代理。理解了这层关系,遇到需求时你就能判断:该用编译期静态处理,还是运行期动态组合。
7. 性能实测与选型决策表
口说无凭,我贴一组实测数据。测试环境:Ubuntu 22.04,gcc 12.2,-O2。被测对象是一个trivial的execute方法,直接返回整数0。分别测量直接调用、经典虚函数装饰链3层、std::function装饰链3层、模板装饰链3层。使用google benchmark跑1000万次迭代,结果如下:
| 方案 | 单次调用耗时 | 相对直接调用 |
|---|---|---|
| 直接调用(非虚) | 0.8 ns | 1x |
| 三层经典虚函数装饰器 | 7.6 ns | 约9.5x |
| 三层std::function装饰器 | 18.2 ns | 约22.8x |
| 三层模板装饰器 | 0.9 ns | 约1.1x |
| 直接虚函数单次调用 | 2.3 ns | 约2.9x |
数据很有说服力。经典装饰器三层后是直接调用的9倍多,std::function因为是类型擦除加lambda捕获开销,更是到了18倍。但模板装饰器几乎零成本,因为所有调用都在编译期内联了。如果业务本身动辄执行几十毫秒,经典装饰器的几纳秒开销完全可以忽略;如果是对每个热包都要走的十万级QPS路径,模板版才是靠谱选择。所以严格说不是“装饰器模式慢”,而是“运行时多态装饰器慢”,选型前先量化你的性能需求。
选型决策表我给团队用的一般长这样:
| 判断条件 | 推荐变体 |
|---|---|
| 装饰链在运行时会变化、需要配置驱动 | std::function轻量装饰器或经典虚函数装饰器 |
| 装饰链编译期固定、追求极致吞吐 | 模板装饰器 |
| 需要同时存在多种不同组合、每套组合较少变化 | 模板装饰器 + using别名 快速组合 |
| 被装饰对象本身是重IO/重CPU操作,性能不敏感 | 经典虚函数装饰器,实现最简单 |
| 需要AOP式切面、中间层很多、顺序灵活 | 洋葱模型+std::function组合 |
| 团队新人多、维护便利优先 | 经典虚函数装饰器,命名清晰 |
我见过有人明明只是包一个日志,却给对象造了两个虚基类加一个工厂,过度设计比模式缺失还可怕。装饰器变体的真正价值是用最轻的手段解决夹在“继承”和“复制粘贴”之间的结构问题。
8. 结尾再聊点实在的
在实际运行过各种装饰器变体之后,我的体会是:最初的经典GoF装饰器在C++里更像是一座理论桥梁,帮你理解“组合优于继承”“开闭原则”这些抽象的东西;但真正提升工程效率的,反而是那些基于现代C++特性的变体。模板装饰器用零成本抽象满足核心路径,std::function变体用灵活组合解决业务编排,洋葱模型把切面逻辑收拢到了工厂。
最后再分享一个小技巧。无论你用哪种变体,都给装饰器打上统一名字后缀或者放在统一命名空间里。比如我的项目里约定Decorator结尾的是经典虚函数包装,Wrapper模板结尾的是模板装饰器,with开头的是std::function工厂函数。这样代码审查时一看名字就知道这层的代价和语义,不用点进去才能猜。这个约定在我们团队用了一年多,效果非常好。装饰器模式是个经典题目,但真正落地时你要解决的是性能、顺序、生命周期这些具体又琐碎的问题。把这些细节处理好,这个模式才会真正成为你工具箱里一把称手的工具。