面向对象设计原则(SOLID)

各位同学好。今天我们来聊聊 SOLID 原则。这五个原则,说白了就是面向对象设计的「基本功」。我做了十几年架构,见过太多代码因为违反这些原则而变得难以维护。嗯,咱们一个一个来看。

单一职责原则(SRP)

单一职责原则,英文是 Single Responsibility Principle。它的核心思想很简单:一个类只应该有一个引起它变化的原因

什么意思呢?我举个例子。你写了一个 OrderService 类,里面既有订单计算逻辑,又有数据库保存逻辑,还有发送邮件通知的逻辑。这时候,如果订单计算规则变了,你要改这个类;如果数据库表结构变了,你也要改这个类;如果邮件模板变了,你还要改这个类。一个类被三个不同的原因牵着走,这就是典型的违反 SRP。

核心要点:每个类只负责一件事。如果一件事变了,只改一个类。

我在项目中遇到过这样一个案例:一个 ReportGenerator 类,既负责从数据库取数据,又负责生成 HTML 报表,还负责把报表通过 FTP 发送出去。后来 FTP 服务器换了地址,改代码的时候不小心把报表格式也改坏了。这就是职责耦合带来的问题。

开闭原则(OCP)

开闭原则,Open-Closed Principle。说的是:对扩展开放,对修改关闭

说白了就是:你想加新功能,别去改已有的代码,而是通过扩展来实现。我刚开始写代码时也不理解,觉得改代码多直接啊。后来发现,改一个地方,可能引发连锁反应,测试全得重来。

举个例子:

// 违反 OCP 的设计
public class AreaCalculator {
    public double calculate(Object shape) {
        if (shape instanceof Circle) {
            Circle c = (Circle) shape;
            return Math.PI * c.radius * c.radius;
        } else if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle) shape;
            return r.width * r.height;
        }
        // 每加一种形状,就要加一个 else if
    }
}

// 符合 OCP 的设计
public interface Shape {
    double area();
}

public class Circle implements Shape {
    public double area() {
        return Math.PI * radius * radius;
    }
}

public class Rectangle implements Shape {
    public double area() {
        return width * height;
    }
}

public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.area();  // 不需要改任何代码
    }
}

你看,第二种设计,加新形状只需要新增一个类,AreaCalculator 完全不用动。这就是开闭原则的威力。

里氏替换原则(LSP)

里氏替换原则,Liskov Substitution Principle。定义有点绕:子类对象必须能够替换掉父类对象,并且程序的行为不会发生变化

我换个说法:你写代码时,如果用了父类的地方,换成子类也能正常工作,那就对了。如果不行,说明你的继承关系有问题。

我记得有个经典的反例:正方形继承矩形。

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 class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);  // 正方形宽高相等
    }
    
    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

看起来没问题?你试试这个场景:

Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
// 用户期望 width=5, height=10
// 实际得到 width=10, height=10

这就是违反 LSP 的典型例子。子类改变了父类的行为约定。我曾经在一个支付系统中见过类似问题:子类重写了父类的 pay() 方法,结果把支付流程的顺序改了,导致对账系统崩溃。嗯,教训深刻。

接口隔离原则(ISP)

接口隔离原则,Interface Segregation Principle。意思是:客户端不应该被迫依赖它不需要的接口

说白了,接口要小而专,不要大而全。你想想看,如果一个接口有 10 个方法,你的实现类只需要其中 2 个,那另外 8 个方法你就得写空实现或者抛异常。这多别扭。

我建议这样设计:

// 违反 ISP:一个臃肿的接口
public interface Worker {
    void work();
    void eat();
    void sleep();
}

// 符合 ISP:拆分成多个小接口
public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

// 机器人只需要实现 Workable
public class Robot implements Workable {
    public void work() { /* 工作逻辑 */ }
}

// 人类可以实现所有三个接口
public class Human implements Workable, Eatable, Sleepable {
    public void work() { /* 工作逻辑 */ }
    public void eat() { /* 吃饭逻辑 */ }
    public void sleep() { /* 睡觉逻辑 */ }
}

小技巧:如果你发现一个接口中有很多方法,而且不同实现类只用到其中一部分,那就该考虑拆分接口了。

依赖倒置原则(DIP)

依赖倒置原则,Dependency Inversion Principle。核心是:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象

这句话有点绕。我换个说法:你的业务逻辑代码,不要直接依赖具体的数据库、具体的文件系统、具体的第三方服务。而是依赖接口。这样换数据库、换服务的时候,业务代码不用动。

举个例子:

// 违反 DIP:高层直接依赖低层
public class UserService {
    private MySQLDatabase db = new MySQLDatabase();  // 直接依赖具体实现
    
    public void saveUser(User user) {
        db.save(user);
    }
}

// 符合 DIP:依赖抽象
public interface UserRepository {
    void save(User user);
}

public class MySQLUserRepository implements UserRepository {
    public void save(User user) { /* MySQL 实现 */ }
}

public class UserService {
    private UserRepository repo;  // 依赖抽象
    
    public UserService(UserRepository repo) {
        this.repo = repo;  // 具体实现由外部注入
    }
    
    public void saveUser(User user) {
        repo.save(user);
    }
}

我曾经接手过一个项目,所有业务代码都直接 new 了具体的数据库连接。后来要从 MySQL 迁移到 PostgreSQL,几乎每个文件都要改。那叫一个痛苦。从那以后,我写代码一定会先定义接口,再写实现。

知识体系总览

下面这张图,把 SOLID 五个原则的核心思想和它们之间的关系画出来了。你可以保存下来,写代码前看一眼。

SOLID 原则知识体系 SOLID 原则 SRP 单一职责 OCP 开闭原则 LSP 里氏替换 ISP 接口隔离 DIP 依赖倒置 一个类只做一件事 只有一个变化原因 职责分离 对扩展开放 对修改关闭 用扩展代替修改 子类可替换父类 行为不改变 继承要谨慎 接口小而专 不依赖不需要的 拆分臃肿接口 依赖抽象 不依赖具体 依赖注入 核心思想:高内聚、低耦合、易扩展、可维护 五个原则相互配合,共同构建健壮的面向对象系统

五个原则对比

原则 核心思想 违反后果 我常用的实践
SRP 一个类只做一件事 类臃肿,修改牵一发动全身 先写职责清单,再拆分
OCP 扩展开放,修改关闭 加功能要改旧代码,风险高 多用接口和抽象类
LSP 子类可替换父类 继承关系混乱,程序行为异常 慎用继承,优先组合
ISP 接口要小而专 实现类被迫实现无用方法 接口方法不超过 5 个
DIP 依赖抽象,不依赖具体 换底层实现要改高层代码 构造函数注入接口

避坑指南:我曾经在一个项目中,为了「灵活」把每个类都拆成了接口+实现,结果接口数量爆炸,反而增加了维护成本。SOLID 原则是指导方针,不是教条。要根据实际情况灵活运用,不要为了用原则而用原则。

好了,SOLID 五个原则就讲到这里。记住一句话:好的设计不是一蹴而就的,而是在不断重构中打磨出来的。写代码时多想想这几个原则,慢慢就会形成习惯。


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