面向对象设计原则(SOLID):构建可维护的软件基石
聊到设计模式,就绕不开SOLID原则。说实话,这六个原则(加上迪米特法则)是面向对象设计的基石。我面试过不少候选人,能把SOLID背得滚瓜烂熟的大有人在,但真正能在代码里用好的,确实不多。
今天我就结合自己的实战经验,把这六个原则掰开揉碎了讲清楚。你想想看,如果代码写出来像乐高积木一样,想换哪块换哪块,那该多省心?这就是SOLID要帮我们达到的境界。
核心观点:SOLID原则不是教条,而是指导我们写出高内聚、低耦合代码的经验总结。每个原则解决一类特定的设计问题。
1. 单一职责原则(SRP)
一个类只应该有一个引起它变化的原因。说白了,就是每个类只负责一件事。
我在项目中遇到过这样的代码:一个ReportGenerator类,既负责从数据库取数据,又负责格式化报表,还要负责发送邮件。结果每次需求变更,不管是改数据源、改报表格式还是改邮件模板,都得动这个类。改一次出一次bug,简直噩梦。
我的习惯:写类之前先问自己三个问题——这个类叫什么?它负责什么?如果需求变了,它需要改吗?如果答案不清晰,说明职责没分好。
// ❌ 违反SRP:一个类干三件事
class ReportGenerator {
void fetchData() { /* 从数据库取数据 */ }
void formatReport() { /* 格式化报表 */ }
void sendEmail() { /* 发送邮件 */ }
};
// ✅ 符合SRP:每个类只干一件事
class DataFetcher { void fetchData(); };
class ReportFormatter { void format(); };
class EmailSender { void send(); };
2. 开闭原则(OCP)
对扩展开放,对修改关闭。嗯,这句话听起来有点绕。我换个说法:写好的代码,尽量别去改它;要加新功能,通过扩展来实现。
我曾经接手过一个支付系统,里面用了一大堆if-else来判断支付方式。每次加一种新支付方式,就得改这个核心类。后来我重构成了策略模式,每种支付方式一个类,新增支付方式只需要加一个新类,核心代码一行都不用动。
避坑指南:我曾经以为OCP就是多用继承,结果搞出了很深的继承树,改起来更痛苦。后来发现组合+接口才是正道。
// ❌ 违反OCP:加新支付方式要改核心代码
void processPayment(string type) {
if (type == "alipay") { /* ... */ }
else if (type == "wechat") { /* ... */ }
// 加新方式?继续加else if!
}
// ✅ 符合OCP:通过接口扩展
class PaymentStrategy { virtual void pay() = 0; };
class Alipay : public PaymentStrategy { void pay() override; };
class WechatPay : public PaymentStrategy { void pay() override; };
// 加新方式?加一个新类就行!
3. 里氏替换原则(LSP)
子类对象应该能够替换父类对象,并且程序行为不变。说白了,就是子类不能破坏父类的约定。
经典的例子是正方形和长方形。如果让Square继承Rectangle,设置宽度时高度也跟着变,那用父类的地方换成子类就会出问题。你想想看,一个函数本来处理长方形,传个正方形进去结果不对了,这就是违反了LSP。
判断方法:写一个函数,参数用父类类型。如果传入任何子类都能正常工作,那就符合LSP。否则,继承关系可能有问题。
// ❌ 违反LSP:正方形不能完全替代长方形
class Rectangle {
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
int width, height;
};
class Square : public Rectangle {
void setWidth(int w) override { width = height = w; }
void setHeight(int h) override { width = height = h; }
};
// ✅ 符合LSP:让它们各自实现Shape接口
class Shape { virtual int area() = 0; };
class Rectangle : public Shape { /* ... */ };
class Square : public Shape { /* ... */ };
4. 接口隔离原则(ISP)
客户端不应该被迫依赖它不使用的接口。说白了,接口要小而专,不要搞大而全的"胖接口"。
我记得有个项目,一个Worker接口定义了work()、eat()、sleep()三个方法。结果机器人实现了这个接口,也得实现eat()和sleep(),只能抛异常。这就是典型的接口污染。
我的建议:接口尽量小,一个接口只代表一种能力。如果发现某个接口的实现类有很多方法都是空实现或抛异常,就该拆分接口了。
// ❌ 违反ISP:胖接口
class Worker {
virtual void work() = 0;
virtual void eat() = 0;
virtual void sleep() = 0;
};
class Robot : public Worker {
void work() override { /* 干活 */ }
void eat() override { throw "我不吃饭"; }
void sleep() override { throw "我不睡觉"; }
};
// ✅ 符合ISP:拆分成小接口
class Workable { virtual void work() = 0; };
class Eatable { virtual void eat() = 0; };
class Sleepable { virtual void sleep() = 0; };
class Human : public Workable, public Eatable, public Sleepable {};
class Robot : public Workable {};
5. 依赖倒置原则(DIP)
高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。
这句话有点绕,我翻译一下:写代码时,面向接口编程,不要面向具体实现编程。这样换实现的时候,高层代码不用动。
我之前做过一个日志系统,一开始直接依赖了FileLogger。后来要加数据库日志、网络日志,每次都得改业务代码。改成依赖ILogger接口后,想加什么日志就加什么,业务代码完全不用动。
// ❌ 违反DIP:高层依赖低层具体实现
class BusinessLogic {
FileLogger logger; // 直接依赖具体类
void doSomething() { logger.log("..."); }
};
// ✅ 符合DIP:高层依赖抽象接口
class BusinessLogic {
ILogger& logger; // 依赖抽象
void doSomething() { logger.log("..."); }
};
6. 迪米特法则(LoD)
一个对象应该对其他对象保持最少的了解。也叫"最少知识原则"。说白了,就是别跟陌生人说话。
举个例子:你让同事帮忙打印文件,你只需要把文件给他,不用关心他用什么打印机、怎么设置参数。如果代码里出现obj.getA().getB().doSomething()这种链式调用,就说明耦合太深了。
避坑指南:我曾经写过一个方法,返回了内部对象的内部对象。结果调用方直接修改了内部数据,导致bug很难查。后来我学会了只返回需要的值,或者返回不可变对象。
// ❌ 违反LoD:跟陌生人说话
void process(Department dept) {
dept.getManager().getAssistant().getSchedule(); // 链式调用,耦合太深
}
// ✅ 符合LoD:只跟直接朋友交互
void process(Department dept) {
dept.getSchedule(); // 让部门自己处理内部细节
}
总结一下
这六个原则,说白了就是教我们怎么把代码组织得更好。我个人的经验是:
- SRP让类更专注,好理解也好维护
- OCP让系统更容易扩展,不怕加新功能
- LSP保证继承关系正确,不会出幺蛾子
- ISP让接口更干净,实现类不被迫做无用功
- DIP让代码更灵活,换实现就像换零件
- LoD降低耦合,减少"蝴蝶效应"
记住,这些原则是指导方针,不是法律。有时候为了性能或简化设计,适当放宽也是可以的。关键是要知道自己在做什么,以及为什么这么做。