二、面向对象设计原则(SOLID)
说到设计原则,我得先坦白一件事。刚入行那几年,我总觉得这些原则是书本上用来唬人的。直到有一次,我接手了一个遗留系统——那个代码库,怎么说呢,就像一团打了死结的毛线球。改一个地方,三个模块跟着崩。那天加班到凌晨三点,我对着屏幕发誓:以后写代码,一定把 SOLID 放在心上。
SOLID 是五个原则的缩写。它们不是银弹,但能帮你写出更容易维护、更不容易出 bug 的代码。咱们一个一个聊。
2.1 单一职责原则(SRP)
一个类,只应该有一个引起它变化的原因。说白了,就是别让一个类干太多事。
我见过最夸张的例子:一个叫 ReportManager 的类,里面既有数据查询逻辑,又有报表格式排版,还负责把报表通过邮件发出去。你想想看,哪天邮件服务器换了,要改这个类;报表格式变了,也要改这个类;数据源换了,还得改它。一个类被三个人同时改,不出冲突才怪。
核心判断标准:问自己一个问题——“这个类如果必须修改,原因可能有多少种?” 如果答案超过一个,就该拆了。
来看个例子。假设我们要做一个订单处理系统:
// ❌ 违反 SRP:一个类干了三件事
class OrderProcessor {
public:
void process(Order& order) {
// 1. 验证订单
if (order.total <= 0) throw std::runtime_error("invalid order");
// 2. 保存到数据库
db.save(order);
// 3. 发送通知邮件
email.send("order processed: " + order.id);
}
};
这个类,验证逻辑、持久化逻辑、通知逻辑全揉在一起。哪天要改成发短信通知,你猜怎么着?得改 OrderProcessor。哪天数据库从 MySQL 换成 PostgreSQL,还得改它。
拆开之后:
// ✅ 符合 SRP:每个类只有一个职责
class OrderValidator {
public:
void validate(const Order& order) {
if (order.total <= 0) throw std::runtime_error("invalid order");
}
};
class OrderRepository {
public:
void save(const Order& order) {
db.save(order);
}
};
class NotificationService {
public:
void notify(const Order& order) {
email.send("order processed: " + order.id);
}
};
嗯,这样清爽多了。每个类只关心自己那一亩三分地。
我的习惯:写类之前,先在注释里写一句话描述这个类的职责。如果这句话里出现了“和”、“以及”、“同时”这些词,我就知道该拆了。
2.2 开闭原则(OCP)
对扩展开放,对修改关闭。什么意思呢?就是你想加新功能的时候,尽量别去改已有的代码,而是通过加新代码来实现。
我曾经维护过一个支付模块,里面用了一大堆 if-else:
// ❌ 违反 OCP:加新支付方式就要改这个函数
void processPayment(PaymentType type, double amount) {
if (type == ALIPAY) {
// 支付宝逻辑
} else if (type == WECHAT) {
// 微信逻辑
} else if (type == CREDIT_CARD) {
// 信用卡逻辑
}
// 加新支付方式?继续加 else if!
}
每次加一种新支付方式,就得打开这个函数往里塞代码。改着改着,函数越来越长,最后变成了一坨没人敢动的代码。
用多态来重构:
// ✅ 符合 OCP:通过扩展来添加新功能
class PaymentStrategy {
public:
virtual void pay(double amount) = 0;
virtual ~PaymentStrategy() = default;
};
class AlipayStrategy : public PaymentStrategy {
public:
void pay(double amount) override {
// 支付宝逻辑
}
};
class WechatStrategy : public PaymentStrategy {
public:
void pay(double amount) override {
// 微信逻辑
}
};
// 客户端代码
void processPayment(PaymentStrategy& strategy, double amount) {
strategy.pay(amount);
}
现在加新支付方式?写一个新类继承 PaymentStrategy 就行。已有的代码一行都不用改。这就是“对扩展开放,对修改关闭”。
注意:开闭原则不是让你把所有东西都抽象化。过度设计也是病。我见过有人为了“开闭”,给只有两种类型的逻辑也搞了个抽象基类,结果代码量翻了三倍,维护起来更痛苦。适度就好。
2.3 里氏替换原则(LSP)
这个原则名字听着挺唬人,其实意思很简单:子类应该能替换掉父类,而且程序的行为不会出问题。
我记得有个经典的反面教材——正方形和长方形。很多人觉得正方形是长方形的子类,但实际写代码时会发现不对劲:
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; }
private:
int width, height;
};
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);
}
};
看起来没问题?那试试这个:
void resize(Rectangle& rect) {
rect.setWidth(5);
rect.setHeight(10);
assert(rect.getWidth() == 5); // 对 Rectangle 成立
assert(rect.getHeight() == 10); // 对 Square 不成立!
}
传一个 Square 进去,断言就失败了。这就是违反了 LSP——子类不能完全替代父类。
避坑指南:我曾经在项目中用继承来实现“特殊化”,结果被 LSP 坑了好几次。后来我学乖了——如果子类和父类在行为上有“不一致”的地方,优先考虑组合而不是继承。
2.4 接口隔离原则(ISP)
接口要小而专,不要大而全。一个类不应该被迫依赖它用不到的方法。
我见过一个 IMultiFunctionPrinter 接口:
// ❌ 违反 ISP:接口太胖了
class IMultiFunctionPrinter {
public:
virtual void print() = 0;
virtual void scan() = 0;
virtual void fax() = 0;
virtual void copy() = 0;
};
然后有个 SimplePrinter 类,它只能打印,不能扫描、传真、复印。但因为它实现了这个接口,就不得不把 scan()、fax()、copy() 都写成空实现或者抛异常。这代码看着就难受。
拆成小接口:
// ✅ 符合 ISP:每个接口只做一件事
class IPrinter {
public:
virtual void print() = 0;
};
class IScanner {
public:
virtual void scan() = 0;
};
class IFax {
public:
virtual void fax() = 0;
};
// SimplePrinter 只实现它需要的接口
class SimplePrinter : public IPrinter {
public:
void print() override { /* 打印逻辑 */ }
};
这样,每个类只实现它真正需要的方法。接口干净,实现也干净。
我的经验:接口的方法数量,我一般控制在 3 到 5 个。超过 5 个,我就会停下来想想——是不是该拆了?
2.5 依赖倒置原则(DIP)
高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。
说白了,就是面向接口编程,不要面向实现编程。
来看个反面教材:
// ❌ 违反 DIP:高层直接依赖低层实现
class MySQLDatabase {
public:
void save(const std::string& data) {
// MySQL 保存逻辑
}
};
class UserService {
private:
MySQLDatabase db; // 直接依赖具体实现
public:
void saveUser(const std::string& name) {
db.save(name);
}
};
哪天公司说要换成 PostgreSQL,你就得改 UserService 的代码。这耦合度,太高了。
引入抽象层:
// ✅ 符合 DIP:依赖抽象
class IDatabase {
public:
virtual void save(const std::string& data) = 0;
virtual ~IDatabase() = default;
};
class MySQLDatabase : public IDatabase {
public:
void save(const std::string& data) override {
// MySQL 保存逻辑
}
};
class PostgreSQLDatabase : public IDatabase {
public:
void save(const std::string& data) override {
// PostgreSQL 保存逻辑
}
};
class UserService {
private:
IDatabase& db; // 依赖抽象
public:
UserService(IDatabase& database) : db(database) {}
void saveUser(const std::string& name) {
db.save(name);
}
};
现在换数据库?只要传不同的实现进去就行。UserService 一行代码都不用改。
注意:依赖注入(DI)是实现 DIP 的常用手段,但不是唯一手段。工厂模式、服务定位器也可以。别把 DIP 和 DI 划等号。
知识体系总览
下面这张图,把五个原则的核心思想串起来了。你可以把它当作一个速查表:
这五个原则,说白了就是一套“如何把代码拆得更合理”的指导方针。SRP 告诉你拆的粒度,OCP 告诉你拆的方向,LSP 保证拆了之后不出问题,ISP 让接口更干净,DIP 让依赖关系更清晰。
我个人习惯是:写代码的时候,脑子里过一遍这五个原则。不用每条都严格遵守,但至少要知道——我为什么在这里违反了它,以及这个违反会不会在未来带来麻烦。
嗯,SOLID 就聊到这儿。下一章咱们聊聊设计模式中的创建型模式,看看怎么把对象的创建过程管起来。
公众号:蓝海资料掘金营,微信 deep3321