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 三个原则的关系

这三个原则其实是环环相扣的。单一职责让你把类拆得足够小,开闭原则让你通过扩展应对变化,里氏替换保证继承体系不会出乱子。

我个人习惯在设计类的时候,先问自己三个问题:

  1. 这个类的职责是不是单一的?如果不是,拆。
  2. 如果需求变了,我能不能通过加新类来解决?如果不能,说明设计不够开放。
  3. 子类替换父类后,行为会不会变?如果会,说明继承关系有问题。

这三个问题问完,代码质量基本就有保障了。你想想看,是不是这个道理?

实战建议:不要为了遵循原则而过度设计。原则是指导,不是教条。小项目、原型阶段可以适当放宽,等代码稳定了再逐步重构。我见过太多人一开始就搞了七八个接口,结果需求一变全得重来。


公众号:蓝海资料掘金营,微信deep3321