跳到主要内容

SOLID 面向对象设计原则

为什么需要 SOLID 原则?

代码虽然由机器来执行,但是代码的读者始终是开发人员。写出可以运行的代码并不难,但是一个软件服务通常由大量的人力,经过长时间开发才能够完成,一段代码可能由不同的开发人员接手。因此,软件行业的开发者都需要面对一个严重的问题:

经过长时间开发后,代码如何保持良好的可读性和可维护性?

为了解决这个问题,软件开发人员们总结出了一系列的开发设计原则,其中有代表性的就是 SOLID 原则与设计模式。

SOLID 简介

在程序设计领域, SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由罗伯特·C·马丁(代表作《Clean Code》)在 21 世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。

SOLID 分别指代:

  • Single Responsibility Principle(单一功能原则)
  • Open/Closed Principle(开闭原则)
  • Liskov Substitution Principle(里氏替换原则)
  • Interface Segregation Principle(接口隔离原则)
  • Dependency Inversion Principle(依赖反转原则)

SOLID 原则旨在帮助软件开发人员设计出更易于维护、扩展和重用的代码。通过设计、重构等方式,清除代码异味。SOLID 被典型的应用在测试驱动开发上,并且是敏捷开发以及自适应软件开发的基本原则的重要组成部分。

SOLID 原则

1. 单一职责原则(Single Responsibility Principle, SRP)

每个类应只有一个职责或原因导致其变更。这样可以使类更易于理解、维护和扩展,避免因为一个职责的修改而影响到其他职责的实现。

设想一个用户注册的场景,我们通常会设计一个 User 类来维护用户的信息。如果需要对 User 进行校验,我们应该将校验逻辑放在一个独立的类 UserValidator 中,而不是将校验逻辑直接放在 User 类中。

User 类中应该只包含用户的基本信息:

public class User {
private String username;
private String password;
private String email;

// getter and setter
}

`UserValidator` 类中存放用户校验的逻辑:

```java
public class UserValidator {
public boolean validate(User user) {
// validate user
}
}

2. 开闭原则(Open/Closed Principle, OCP)

软件实体(类、模块、函数等)应对扩展开放,对修改关闭。这意味着在不修改现有代码的情况下,可以通过添加新功能来扩展系统,从而降低引入错误的风险。

假设我们又一些类用来描述几何形状,比如 TriAngleRectangle 等,它们有共同的父类 Shape。如果我们需要增加一个新的形状 Circle,我们应该通过继承或接口的方式来实现,而不是直接修改现有的类。

public abstract class Shape {
public abstract void draw();
}

public class Triangle extends Shape {
@Override
public void draw() {
System.out.println("Triangle is drawing");
}
}

// 增加一个新的形状 Circle
public class Circle extends Shape {
@Override
public void draw() {
System.out.println("Circle is drawing");
}
}

3. 里氏替换原则(Liskov Substitution Principle, LSP)

子类必须能够替换其基类而不影响系统的正确性。这确保了继承关系的正确性,子类能完美适应父类的使用场景。

继续上一个几何形状的示例。如果我们有一个函数接收 Shape 类型的参数,那么我们可以传入任何继承自 Shape 的类,而不用担心会影响函数的正确性。

public void draw(Shape shape) {
shape.draw();
}

下面的代码同样都是支持的:

draw(new Triangle());
draw(new Rectangle());
draw(new Circle());

4. 接口隔离原则(Interface Segregation Principle, ISP)

客户端不应该被迫依赖它们不使用的方法。应将庞大的接口拆分成多个更小、更具体的接口,从而使实现类只需关注与其相关的接口。

对于功能的添加,我们应该通过增加接口的方式来实现。

现在让我们来描述一下神奇的动物世界。所有的动物都继承自 Animal 类,但是不同的动物有不同的行为,比如有些动物会游泳,有些动物会飞。我们可以通过接口的方式来实现:

public interface Flyable {
void fly();
}

public interface Swimmable {
void swim();
}

一只鸭子即会飞又会游泳,我们可以这样实现:

public class Duck extends Animal implements Flyable, Swimmable {
@Override
public void fly() {
System.out.println("Duck is flying");
}

@Override
public void swim() {
System.out.println("Duck is swimming");
}
}

但是鱼只会游泳,我们可以这样实现:

public class Fish extends Animal implements Swimmable {
@Override
public void swim() {
System.out.println("Fish is swimming");
}
}

通过不同的接口隔离了不同的功能。需要实现不同的功能时,只需要实现对应的接口。增加新的功能时,也只需要增加新的接口即可。

5. 依赖倒置原则(Dependency Inversion Principle, DIP)

高层模块不应依赖于低层模块,二者都应该依赖于抽象。抽象不应依赖于细节,细节应依赖于抽象。这样可以减少模块之间的耦合,提高系统的灵活性和可维护性。

在使用 Spring 框架中,我们大量应用了 DIP 原则。

考虑一个用户管理的场景,我们通常会设计一个 UserService 类来处理用户的信息。如果我们需要保存用户信息,我们应该将保存用户信息的逻辑放在一个独立的类 UserRepository 中,而不是直接在 UserService 类中实现。

UserRepository 中应该定义以下功能:

public interface UserRepository {
void save(User user);
}

对于 `UserService` 类,它不需要关注用户信息存储在 `MySQL` 还是 `MongoDB` 中,它只需要关注 `UserRepository` 提供了 `save` 方法即可,实际存储的逻辑由 `UserRepository` 实现。

```java
public class UserService {
private final UserRepository userRepository;

public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}

public void save(User user) {
userRepository.save(user);
}
}