3、面向对象设计原则(上):单一职责原则、开闭原则、里氏替换原则
设计原则这东西,说白了就是前辈们踩过的坑总结出来的经验。我刚开始写代码那会儿,觉得能跑就行,管它什么原则不原则。结果呢?项目一迭代,改一个功能要动七八个类,改完还出bug。嗯,从那以后我就老实了。
今天咱们聊三个最基础也最重要的原则:单一职责、开闭原则、里氏替换。这三个搞明白了,后面几个就好理解多了。
3.1 单一职责原则(SRP)
单一职责原则,英文叫 Single Responsibility Principle。它的核心思想就一句话:一个类只应该有一个引起它变化的原因。
你想想看,如果一个类既负责用户管理,又负责订单处理,还负责日志记录。那用户需求变了要改这个类,订单逻辑变了也要改这个类,日志格式变了还得改。改来改去,这个类就成了定时炸弹。
核心要点:每个类只负责一个明确的职责,职责边界要清晰。
我在项目中遇到过这样一个案例:有个同事写了个 ReportService,里面既有报表数据的查询逻辑,又有报表格式的渲染逻辑,还包含了报表的导出功能。结果产品经理说报表要加个水印,改渲染逻辑的时候不小心把查询条件改坏了。这就是典型的职责不单一。
正确的做法应该是拆成三个类:
// 职责分离后的设计
public class ReportDataService {
public ReportData queryReportData(QueryCondition condition) {
// 只负责数据查询
}
}
public class ReportRenderService {
public String renderHtml(ReportData data) {
// 只负责渲染
}
}
public class ReportExportService {
public File exportPdf(ReportData data) {
// 只负责导出
}
}
判断小技巧:当你写一个类的方法列表时,如果发现这些方法之间没有强关联,或者它们服务的对象不同,那就该考虑拆分了。
3.2 开闭原则(OCP)
开闭原则,Open-Closed Principle。定义是:对扩展开放,对修改关闭。
说白了就是:你写好的代码,尽量别去改它。但需求变了怎么办?通过扩展来解决,而不是直接修改已有的代码。
我记得有个老项目,里面有个计算折扣的方法,用的是 if-else 链:
// 糟糕的设计 —— 每次加新折扣都要改这个方法
public double calculateDiscount(String userType, double amount) {
if ("VIP".equals(userType)) {
return amount * 0.8;
} else if ("GOLD".equals(userType)) {
return amount * 0.85;
} else if ("SILVER".equals(userType)) {
return amount * 0.9;
}
// 又来一个新类型?继续加 else if ...
return amount;
}
这种代码,每加一种用户类型就要改这个方法。改着改着,方法越来越长,逻辑越来越乱。我曾经接手过一个项目,一个方法里有二十多个 else if,看得我头皮发麻。
遵循开闭原则的做法是这样的:
// 符合开闭原则的设计
public interface DiscountStrategy {
double calculate(double amount);
}
public class VipDiscount implements DiscountStrategy {
public double calculate(double amount) {
return amount * 0.8;
}
}
public class GoldDiscount implements DiscountStrategy {
public double calculate(double amount) {
return amount * 0.85;
}
}
// 新增类型时,只需要新增一个类,不需要修改已有代码
public class PlatinumDiscount implements DiscountStrategy {
public double calculate(double amount) {
return amount * 0.75;
}
}
注意:开闭原则不是说代码完全不能改。如果发现设计有缺陷或者有bug,该改还是要改。它强调的是:面对新的需求变化,优先考虑扩展而非修改。
3.3 里氏替换原则(LSP)
里氏替换原则,Liskov Substitution Principle。这是 Barbara Liskov 在1987年提出的。定义有点绕:子类对象必须能够替换掉所有父类对象,而程序的行为不会发生变化。
嗯,说白了就是:你写了一个父类,子类继承它。那么任何用到父类的地方,换成子类都应该能正常工作。
这个原则看起来简单,但实际中很容易踩坑。我举个例子:
// 违反里氏替换原则的经典案例
public class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // 正方形要求宽高相等
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
看起来没问题对吧?但你看这段测试代码:
public void testArea(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
// 期望面积是 20
System.out.println(rect.getArea());
}
// 传入 Rectangle 时输出 20,没问题
// 传入 Square 时输出 16,因为 setWidth 把 height 也改成了 5
// 这就违反了里氏替换原则
我曾经在一个支付系统里遇到过类似的问题。有个父类定义了 pay(Order order) 方法,子类重写后抛了个 UnsupportedOperationException。调用方拿到父类引用,以为所有支付方式都能用,结果运行时炸了。这就是典型的违反里氏替换。
里氏替换原则的约束:
- 子类不能重写父类的非抽象方法(除非你确定不会破坏行为)
- 子类的前置条件不能比父类更严格
- 子类的后置条件不能比父类更宽松
- 子类不能抛出父类没有的异常类型
3.4 三个原则的关系
这三个原则其实是环环相扣的。单一职责让你把类拆得足够小,开闭原则让你通过扩展应对变化,里氏替换保证继承体系不会出乱子。
我个人习惯在设计类的时候,先问自己三个问题:
- 这个类的职责是不是单一的?如果不是,拆。
- 如果需求变了,我能不能通过加新类来解决?如果不能,说明设计不够开放。
- 子类替换父类后,行为会不会变?如果会,说明继承关系有问题。
这三个问题问完,代码质量基本就有保障了。你想想看,是不是这个道理?
实战建议:不要为了遵循原则而过度设计。原则是指导,不是教条。小项目、原型阶段可以适当放宽,等代码稳定了再逐步重构。我见过太多人一开始就搞了七八个接口,结果需求一变全得重来。
公众号:蓝海资料掘金营,微信deep3321