第十四章:合成复用原则(CRP)——组合与继承的选择策略

说到面向对象设计,有个话题几乎每次代码评审都会冒出来:到底用继承还是用组合?

我早年刚带团队那会儿,有个同事特别喜欢用继承。他写了个Bird基类,然后Penguin继承它,结果企鹅不会飞,他只好在fly()方法里抛异常。嗯,这种设计,说白了就是硬套继承关系,最后把自己套进去了。

合成复用原则(Composite Reuse Principle,CRP)告诉我们:优先使用对象组合,而不是类继承。这不是说继承不能用,而是说——你得想清楚,什么时候该用哪个。

1. 继承的陷阱:我踩过的那些坑

继承最大的问题是什么?耦合太紧

子类一旦继承了父类,父类的任何改动都可能波及子类。我在一个金融项目中遇到过:基类Account加了个getBalance()方法,结果所有子类的行为都变了,测试挂了三天。

⚠ 注意:继承破坏了封装性。子类知道父类的实现细节,父类也不敢随便改。这就是所谓的“脆弱的基类问题”。

举个例子,你写了个Stack类继承Vector

// 错误示范:继承导致行为异常
class Stack : public Vector {
public:
    void push(int value) {
        add(value);  // 调用Vector的add方法
    }
    int pop() {
        return remove(size() - 1);
    }
};

// 客户端代码
Stack s;
s.push(1);
s.push(2);
s.insert(0, 100);  // 糟糕!Vector的方法暴露了
s.pop();           // 弹出的是100,不是2!

看到了吗?继承把Vector的所有方法都暴露给了Stack,客户端可以绕过push/pop直接操作内部数据。这设计,我当年第一次看到时直摇头。

2. 组合的优势:灵活、解耦、可测试

组合的核心思想很简单:用“有一个”代替“是一个”

你想想看,现实世界中,汽车有引擎,而不是汽车是引擎。用组合,你可以把Engine对象作为Car的成员,运行时还能换引擎——继承能做到吗?不行。

// 组合方式:灵活且解耦
class Engine {
public:
    virtual void start() = 0;
    virtual ~Engine() = default;
};

class GasEngine : public Engine {
public:
    void start() override {
        // 汽油引擎启动逻辑
    }
};

class ElectricEngine : public Engine {
public:
    void start() override {
        // 电动引擎启动逻辑
    }
};

class Car {
private:
    std::unique_ptr<Engine> engine_;  // 组合!
public:
    explicit Car(std::unique_ptr<Engine> engine)
        : engine_(std::move(engine)) {}
    
    void drive() {
        engine_->start();
        // 其他驾驶逻辑
    }
    
    void replaceEngine(std::unique_ptr<Engine> newEngine) {
        engine_ = std::move(newEngine);  // 运行时换引擎
    }
};
💡 关键点:组合让类之间的依赖变成接口依赖,而不是实现依赖。你可以轻松替换、扩展、测试每个组件。

3. 组合与继承的选择策略

我总结了一套选择策略,这些年一直在用,分享给你:

场景 推荐方式 理由
子类需要复用父类的实现细节 继承 但要注意,这种复用容易导致耦合
子类和父类有明确的“is-a”关系 继承 比如CircleShape的一种
需要运行时动态改变行为 组合 继承在编译期就固定了
类之间只有功能上的协作关系 组合 用接口+委托,别硬套继承
父类可能频繁变化 组合 避免脆弱的基类问题
需要多重继承(C++支持) 组合优先 多重继承容易引发菱形问题

我个人习惯是:默认用组合,除非你有充分的理由用继承。这个“充分的理由”通常只有两个:一是多态(通过基类指针调用派生类方法),二是确实有“is-a”关系且父类稳定。

4. 实战案例:一个日志系统的设计

我记得有个项目需要设计日志系统。一开始有人用继承:

// 继承方式:不够灵活
class Logger {
public:
    virtual void log(const std::string& msg) = 0;
};

class FileLogger : public Logger {
    void log(const std::string& msg) override {
        // 写入文件
    }
};

class ConsoleLogger : public Logger {
    void log(const std::string& msg) override {
        // 输出到控制台
    }
};

// 需求变了:既要写文件,又要输出到控制台
// 怎么办?再写个FileAndConsoleLogger?

用组合就简单多了:

// 组合方式:灵活组合不同策略
class LogStrategy {
public:
    virtual void write(const std::string& msg) = 0;
    virtual ~LogStrategy() = default;
};

class FileStrategy : public LogStrategy {
    void write(const std::string& msg) override {
        // 写入文件
    }
};

class ConsoleStrategy : public LogStrategy {
    void write(const std::string& msg) override {
        // 输出到控制台
    }
};

class Logger {
private:
    std::vector<std::unique_ptr<LogStrategy>> strategies_;
public:
    void addStrategy(std::unique_ptr<LogStrategy> strategy) {
        strategies_.push_back(std::move(strategy));
    }
    
    void log(const std::string& msg) {
        for (auto& strategy : strategies_) {
            strategy->write(msg);
        }
    }
};

// 使用
Logger logger;
logger.addStrategy(std::make_unique<FileStrategy>());
logger.addStrategy(std::make_unique<ConsoleStrategy>());
logger.log("Hello, CRP!");  // 同时写入文件和输出到控制台
🎯 技巧:组合配合策略模式,可以做到“零修改扩展”。加一个新策略,不需要改Logger的代码。这就是开闭原则的体现。

5. 什么时候该用继承?

别误会,我不是说继承一无是处。它有自己的用武之地:

  • 明确的层次结构:比如图形系统中的ShapeCircleRectangle
  • 需要多态行为:通过基类指针调用派生类的虚函数
  • 框架设计:模板方法模式中,基类定义算法骨架,子类实现具体步骤
  • 代码复用且变化极少:父类非常稳定,几乎不会改动

我曾经在一个嵌入式项目中,用继承实现了设备驱动框架。基类Device定义了init()read()write()接口,每个具体设备继承并实现自己的逻辑。这个场景下,继承是合适的——因为设备类型确实有“is-a”关系,而且接口稳定。

6. 核心知识体系

下面这张图,是我对合成复用原则的总结:

合成复用原则(CRP)知识体系 优先使用组合 组合(has-a) 继承(is-a) 组合的优势 • 运行时动态改变行为 • 低耦合,易于测试和替换 • 不破坏封装性 继承的适用场景 • 明确的层次结构 • 需要多态行为 • 父类稳定且变化极少 选择策略 默认用组合,有充分理由才用继承 组合 + 接口 = 最大灵活性

7. 避坑指南

我曾经犯过一个典型的错误:在一个支付系统中,用继承实现了各种支付方式。WechatPayAlipayBankCard都继承自Payment。后来需求变了,要支持“组合支付”——微信+支付宝混合支付。继承体系直接崩了,我不得不重构整个模块。

如果当时用组合,把支付方式作为策略注入,加一个CompositePayment组合多个策略,根本不用动现有代码。

⚠ 避坑:如果你发现继承层次超过3层,或者子类需要重写父类的大部分方法,这通常意味着设计有问题。赶紧停下来,考虑用组合重构。

另一个常见坑是“为了复用而继承”。我见过有人让Manager继承Employee,就为了复用getName()getSalary()。但经理和员工的行为差异很大,最后Manager类里全是重写的方法。这种设计,说白了就是偷懒,后面维护起来苦不堪言。

8. 总结

合成复用原则不是什么高深理论,它就是一个朴素的道理:别硬凑继承关系,能用组合解决的问题,就别引入继承

组合让你的代码更灵活、更可测、更容易扩展。继承虽然在某些场景下是必要的,但它带来的耦合代价,你得想清楚能不能承受。

我个人习惯是:写代码前先问自己三个问题——

  • 这个关系真的是“is-a”吗?
  • 父类会频繁变化吗?
  • 我需要运行时改变行为吗?

如果有一个答案是“是”,那就用组合。相信我,未来的你会感谢现在的选择。


公众号:蓝海资料掘金营,微信 deep3321