第十四章:合成复用原则(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”关系 | 继承 | 比如Circle是Shape的一种 |
| 需要运行时动态改变行为 | 组合 | 继承在编译期就固定了 |
| 类之间只有功能上的协作关系 | 组合 | 用接口+委托,别硬套继承 |
| 父类可能频繁变化 | 组合 | 避免脆弱的基类问题 |
| 需要多重继承(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. 什么时候该用继承?
别误会,我不是说继承一无是处。它有自己的用武之地:
- 明确的层次结构:比如图形系统中的
Shape、Circle、Rectangle - 需要多态行为:通过基类指针调用派生类的虚函数
- 框架设计:模板方法模式中,基类定义算法骨架,子类实现具体步骤
- 代码复用且变化极少:父类非常稳定,几乎不会改动
我曾经在一个嵌入式项目中,用继承实现了设备驱动框架。基类Device定义了init()、read()、write()接口,每个具体设备继承并实现自己的逻辑。这个场景下,继承是合适的——因为设备类型确实有“is-a”关系,而且接口稳定。
6. 核心知识体系
下面这张图,是我对合成复用原则的总结:
7. 避坑指南
我曾经犯过一个典型的错误:在一个支付系统中,用继承实现了各种支付方式。WechatPay、Alipay、BankCard都继承自Payment。后来需求变了,要支持“组合支付”——微信+支付宝混合支付。继承体系直接崩了,我不得不重构整个模块。
如果当时用组合,把支付方式作为策略注入,加一个CompositePayment组合多个策略,根本不用动现有代码。
另一个常见坑是“为了复用而继承”。我见过有人让Manager继承Employee,就为了复用getName()和getSalary()。但经理和员工的行为差异很大,最后Manager类里全是重写的方法。这种设计,说白了就是偷懒,后面维护起来苦不堪言。
8. 总结
合成复用原则不是什么高深理论,它就是一个朴素的道理:别硬凑继承关系,能用组合解决的问题,就别引入继承。
组合让你的代码更灵活、更可测、更容易扩展。继承虽然在某些场景下是必要的,但它带来的耦合代价,你得想清楚能不能承受。
我个人习惯是:写代码前先问自己三个问题——
- 这个关系真的是“is-a”吗?
- 父类会频繁变化吗?
- 我需要运行时改变行为吗?
如果有一个答案是“是”,那就用组合。相信我,未来的你会感谢现在的选择。
公众号:蓝海资料掘金营,微信 deep3321