二、面向对象设计原则(SOLID)
说到设计原则,我得先坦白一件事。刚入行那几年,我总觉得这些原则是书本上用来唬人的。直到有一次,我接手了一个遗留系统——那个代码库,怎么说呢,就像一团打了死结的毛线球。改一个地方,崩三个地方。从那以后,我才真正明白:原则不是教条,是保命符。
SOLID 是五个原则的缩写。它们不是孤立的,而是环环相扣的。你想想看,一个系统如果每个类都职责清晰,自然就容易扩展;能扩展了,子类替换父类才不会出问题;接口设计得够细,依赖关系才能灵活。嗯,咱们一个一个来看。
核心观点:SOLID 原则的本质是管理变化。软件唯一不变的就是变化,这些原则就是帮你把变化的影响控制在最小范围。
2.1 单一职责原则(SRP)
单一职责原则,说白了就是:一个类只做一件事。如果一件事需要多个步骤,那就拆成多个类。
我见过最夸张的例子,是一个类叫 OrderManager,里面包含了订单校验、价格计算、库存扣减、发送邮件、生成报表…… 总共三千多行。你想想看,改个邮件模板,都得把整个类重新编译一遍。这合理吗?
判断标准:如果你无法用一句话说清楚这个类的职责,那它大概率违反了 SRP。比如「这个类负责管理订单」——太模糊了。应该说「这个类负责计算订单总价」。
来看个例子。违反 SRP 的写法:
class OrderProcessor {
public:
void process(const Order& order) {
// 1. 校验订单
if (!validate(order)) throw std::runtime_error("Invalid order");
// 2. 计算价格
double total = calculateTotal(order);
// 3. 保存到数据库
saveToDB(order);
// 4. 发送通知
sendEmail(order);
}
private:
bool validate(const Order& o) { /* ... */ }
double calculateTotal(const Order& o) { /* ... */ }
void saveToDB(const Order& o) { /* ... */ }
void sendEmail(const Order& o) { /* ... */ }
};
重构之后,各司其职:
class OrderValidator {
public:
bool validate(const Order& order);
};
class PriceCalculator {
public:
double calculate(const Order& order);
};
class OrderRepository {
public:
void save(const Order& order);
};
class NotificationService {
public:
void sendConfirmation(const Order& order);
};
class OrderProcessor {
public:
OrderProcessor(OrderValidator* v, PriceCalculator* c,
OrderRepository* r, NotificationService* n)
: validator(v), calculator(c), repo(r), notifier(n) {}
void process(const Order& order) {
if (!validator->validate(order)) throw ...;
double total = calculator->calculate(order);
repo->save(order);
notifier->sendConfirmation(order);
}
private:
OrderValidator* validator;
PriceCalculator* calculator;
OrderRepository* repo;
NotificationService* notifier;
};
注意:不要过度拆分。如果一个类的职责确实紧密相关(比如一个数据类同时提供 getter 和 setter),强行拆分反而增加复杂度。原则是工具,不是镣铐。
2.2 开闭原则(OCP)
开闭原则说的是:对扩展开放,对修改关闭。意思是,你想加新功能,应该通过写新代码来实现,而不是改老代码。
我曾经在一个项目中,看到同事为了加一种新的支付方式,改了五个文件。改完以后,微信支付和支付宝都崩了。为什么?因为老代码里写满了 if (type == WECHAT) ... else if (type == ALIPAY) ...。每加一种新支付,就得改这个 if-else 链。
正确的做法是用多态:
class PaymentStrategy {
public:
virtual ~PaymentStrategy() = default;
virtual bool pay(double amount) = 0;
};
class WechatPay : public PaymentStrategy {
public:
bool pay(double amount) override {
// 微信支付逻辑
return true;
}
};
class Alipay : public PaymentStrategy {
public:
bool pay(double amount) override {
// 支付宝逻辑
return true;
}
};
// 新增信用卡支付,不需要改任何已有代码
class CreditCardPay : public PaymentStrategy {
public:
bool pay(double amount) override {
// 信用卡逻辑
return true;
}
};
class PaymentProcessor {
public:
void setStrategy(PaymentStrategy* s) { strategy = s; }
void processPayment(double amount) {
strategy->pay(amount);
}
private:
PaymentStrategy* strategy;
};
你看,加一种新支付方式,只需要新增一个类,完全不用动 PaymentProcessor。这就是「对扩展开放,对修改关闭」。
核心技巧:多用抽象类或接口,少用具体类。把变化点封装起来,让系统在扩展时保持稳定。
2.3 里氏替换原则(LSP)
里氏替换原则,名字听着吓人,其实意思很简单:子类应该能替换父类,而且程序行为不变。
我见过一个经典的反例。有个 Rectangle 类,有 setWidth() 和 setHeight()。然后有人写了个 Square 继承它,重写了这两个方法,让宽高始终相等。结果呢?所有依赖 Rectangle 的代码,传入 Square 后就出 bug 了。因为调用者以为可以分别设置宽高,但正方形不允许。
class Rectangle {
public:
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
int getWidth() const { return width; }
int getHeight() const { return height; }
int area() const { return width * height; }
private:
int width = 0;
int height = 0;
};
// 违反 LSP:正方形不能独立设置宽高
class Square : public Rectangle {
public:
void setWidth(int w) override {
Rectangle::setWidth(w);
Rectangle::setHeight(w); // 副作用!
}
void setHeight(int h) override {
Rectangle::setWidth(h);
Rectangle::setHeight(h);
}
};
教训:继承不是用来「复用代码」的,而是用来表达「is-a」关系的。如果子类不能完全替代父类的行为,那就别用继承。用组合或者接口。
2.4 接口隔离原则(ISP)
接口隔离原则,说白了就是:接口要小而专,不要大而全。一个类不需要的方法,就不该出现在它的接口里。
我记得有个项目,定义了一个 Worker 接口,里面有 work()、eat()、sleep()。然后有个 Robot 类实现了它——但机器人不需要吃饭睡觉啊!于是 eat() 和 sleep() 只能写成空实现或者抛异常。这就是典型的「接口污染」。
正确的做法是拆分成多个小接口:
// 拆分成多个专用接口
class Workable {
public:
virtual void work() = 0;
};
class Eatable {
public:
virtual void eat() = 0;
};
class Sleepable {
public:
virtual void sleep() = 0;
};
// 人类需要工作、吃饭、睡觉
class Human : public Workable, public Eatable, public Sleepable {
public:
void work() override { /* 工作 */ }
void eat() override { /* 吃饭 */ }
void sleep() override { /* 睡觉 */ }
};
// 机器人只需要工作
class Robot : public Workable {
public:
void work() override { /* 工作 */ }
};
实践建议:接口的方法数量控制在 3-5 个以内。如果一个接口超过 7 个方法,大概率需要拆分。
2.5 依赖倒置原则(DIP)
依赖倒置原则,核心就两句话:
- 高层模块不应该依赖低层模块,两者都应该依赖抽象。
- 抽象不应该依赖细节,细节应该依赖抽象。
用人话说:面向接口编程,不要面向实现编程。
来看个反面例子。一个 NotificationService 直接依赖了 EmailSender:
class EmailSender {
public:
void send(const std::string& msg) {
// 发送邮件
}
};
class NotificationService {
public:
void notify(const std::string& msg) {
emailSender.send(msg); // 直接依赖具体类
}
private:
EmailSender emailSender;
};
如果哪天想改用短信通知,就得改 NotificationService。这违反了开闭原则。用依赖倒置来修复:
// 抽象接口
class MessageSender {
public:
virtual ~MessageSender() = default;
virtual void send(const std::string& msg) = 0;
};
// 具体实现
class EmailSender : public MessageSender {
public:
void send(const std::string& msg) override {
// 发送邮件
}
};
class SmsSender : public MessageSender {
public:
void send(const std::string& msg) override {
// 发送短信
}
};
// 高层模块依赖抽象
class NotificationService {
public:
explicit NotificationService(MessageSender* sender)
: sender(sender) {}
void notify(const std::string& msg) {
sender->send(msg);
}
private:
MessageSender* sender; // 依赖抽象,不依赖具体
};
依赖注入:实现 DIP 的常用手段。把依赖通过构造函数、setter 或接口参数传进来,而不是在类内部 new 出来。这样,换一种实现只需要换一个参数,不用改类本身。
2.6 五个原则的协作关系
这五个原则不是孤立的。我总结一下它们怎么配合:
| 原则 | 解决的问题 | 与其他原则的关系 |
|---|---|---|
| SRP | 类职责过多,修改影响范围大 | 职责清晰后,更容易实现 OCP |
| OCP | 修改老代码引入回归 bug | 需要 LSP 保证子类替换安全 |
| LSP | 继承滥用导致行为异常 | 正确的继承关系是 OCP 的基础 |
| ISP | 接口臃肿,实现类被迫实现无用方法 | 小接口更容易满足 SRP |
| DIP | 高层直接依赖低层,耦合紧密 | 依赖抽象后,OCP 和 ISP 自然成立 |
我的个人习惯:写代码前先画个简单的类图,看看依赖方向。如果发现箭头从高层指向低层具体类,就停下来想想——能不能加个接口把它反过来?这个习惯帮我避免了很多后期重构的麻烦。
嗯,SOLID 原则就讲到这里。这些原则不是一天就能掌握的,需要在实践中慢慢体会。你写代码的时候,多问自己一句:「如果需求变了,我改哪里?影响多大?」——答案会告诉你,该不该用这些原则。