当类名出现FooWithBarAndBaz等拼贴式命名,或构造函数含5个bool参数时,说明继承已退化为功能拼贴,应改用组合:将核心职责抽象为接口(如Sender),附加行为封装为独立装饰器类,通过持有unique_ptr实现动态、可插拔、低耦合的职责叠加。

直接说结论:当继承树开始出现 FooWithBarAndBaz、BarWithCacheAndRetry 这类命名,或一个类的构造函数塞满 5 个 bool 参数时,说明继承已沦为“功能拼贴胶带”,该用组合替换了。
怎么识别该拆——看类是否同时承担核心职责和附加职责
比如你有一个 MessageSender 类,它原本只做一件事:把字符串发给远端。后来加了日志、加密、重试、压缩、限流……所有逻辑都堆在 send() 里。这时候它就不再是“发送器”,而是“发送+日志+加密+重试+压缩+限流器”。问题不在于功能多,而在于这些功能本不该由同一个类决定生命周期和调用顺序。
关键判断点:
- 如果去掉某个功能(比如日志),需要删改大量条件分支或注释掉大段代码,说明耦合过深
- 如果另一个模块(如
FileUploader)也需要同样一组附加行为,但你得复制粘贴或写新子类,说明复用路径错了 - 测试时必须 mock 所有附加行为才能验证核心逻辑,说明关注点没分离
怎么拆——用接口 + 持有对象代替父类继承
把“核心行为”抽成一个稳定接口(如 Sender),所有装饰器和原始实现都实现它;每个附加职责封装为一个独立类,内部持有一个 std::unique_ptr<sender></sender>。不是“我是一个带日志的发送器”,而是“我是一个发送器,但我包装了一层日志逻辑”。
立即学习“C++免费学习笔记(深入)”;
实操要点:
- 基接口必须有虚析构:
virtual ~Sender() = default;,否则unique_ptr释放派生对象会未定义行为 - 装饰器构造函数接收
std::unique_ptr<sender>&&</sender>,避免拷贝,明确所有权转移 - 不要让装饰器暴露被包装对象的接口(比如加个
get_inner()),否则外部可绕过装饰链,破坏封装 - 叠加顺序直接影响行为:日志装饰器包在最外层才能记录重试全过程;加密包在重试内层才能避免重复加密
怎么避免新坑——生命周期与转发陷阱
组合不是简单加个成员变量就完事。常见翻车点集中在对象生存期和方法转发上:
-
Sender接口若返回引用或指针(如const std::string& get_last_error()),装饰器必须确保被包装对象还活着——不能返回临时对象或已销毁对象的引用 - 转发调用别漏掉 const 正确性:
void send(const std::string&) const override和非 const 版本要分别实现,否则编译不过或调用错版本 - 如果装饰器需访问被包装对象的私有状态(比如重试装饰器要看上次失败时间),不要强行加 friend 或暴露 getter,应通过接口扩展(如在
Sender加last_failure_time())来解耦 - 避免循环持有:装饰器 A 持有 B,B 又持有 A —— 这种设计通常意味着职责划分本身就有问题
什么时候别硬上组合——继承仍有不可替代场景
组合不是银弹。以下情况继承仍是更自然的选择:
- 真正 is-a 关系且语义稳定:比如
Dog是Animal的一种,Circle是Shape的一种,且不会频繁新增中间抽象层 - 需要静态多态或模板特化:CRTP 模式依赖继承关系在编译期确定,组合无法替代
- 性能极端敏感且虚函数调用开销不可接受:此时可考虑模板参数注入(如
Sender<encryptor retrypolicy></encryptor>),但这已是组合的变体,而非传统继承
真正难的是判断“是不是真的 is-a”。很多所谓“继承”,只是因为“它们都有 send 方法”就拉郎配,结果基类接口越来越脏,派生类不断补 override 空实现。这种时候,组合不是退让,而是把模糊的“像”换成清晰的“用”。


















