单一职责原则(SRP)

单一职责原则(Single Responsibility Principle)

如何理解单一职责原则?

一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。

如何判断类的职责是否足够单一?

不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:

  • 类中的代码行数、函数或者属性过多;
  • 类依赖的其他类过多,或者依赖类的其他类过多;
  • 私有方法过多;
  • 比较难给类起一个合适的名字;
  • 类中大量的方法都是集中操作类中的某几个属性。

类的职责是否设计得越单一越好?

单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

开闭原则(OCP)

开闭原则( Open Closed Principle)是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则

开闭原则的核心思想是要尽量减少对现有代码的修改,以降低修改带来的风险和影响。在实际开发过程中,完全不修改代码是不现实的。当需求变更或者发现代码中的错误时,修改代码是正常的。然而,开闭原则鼓励我们通过设计更好的代码结构,使得在添加新功能或者扩展系统时,尽量减少对现有代码的修改,我们应该根据项目需求和预期的变化来平衡遵循开闭原则的程度。

简单来说,就是对扩展开放,对修改关闭。当我们需要添加一个新的功能时,应该在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle)就是子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变以及正确性不被破坏。

上代码举例一下

1
2
3
4
5
6
7
//父类 : 鸟类
public class Bird{
//鸟类有个可以飞行的方法
public void fly(){
System.out.print("I can fly");
}
}
1
2
3
4
5
6
7
8
9
//子类 : 企鹅类
public class Penguin extends Bird{
// 企鹅不能飞,所以覆盖了基类的fly方法,但这违反了里氏替换原则
@Override
public void fly() {
//super.fly();
throw new UnsupportedOperationException("Penguins can't fly");
}
}

正常在Penguin类里,重写fly方法是没什么问题,只要写Penguin类的业务逻辑代码就OK,但是为了遵循里氏替换原则,需要重新设计类的结构,将能飞的行为抽象到一个接口中,让需要具有飞行能力的鸟类去实现这个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 飞行行为接口
public interface Flyable {
void fly();
}

// 父类:鸟类
public class Bird {
}

// 子类:能飞的鸟类
public class FlyingBird extends Bird implements Flyable {
@Override
public void fly() {
System.out.println("I can fly");
}
}

// 子类:企鹅类,不实现Flyable接口
public class Penguin extends Bird {
}

通过这样的优化设计,既遵循了里氏替换原则,同时也保证了代码的可维护性和复用性。

再来看个基于数据库操作的案例。假设我们正在开发一个支持多种数据库的程序,包括MySQL,PostgreSQL和SQLite。我们可以使用里氏替换原则来设计合适的类结构,确保点的可维护性和扩展性。

  1. 首先,我们定一个抽象的Database父类,包含一些通用的数据库操作方法,如connect(),disconnect()和executeQuery()。这些方法的具体实现将在子类中完成。

    1
    2
    3
    4
    5
    public abstract class Database{
    public abstract void connect();
    public abstract void disconnect();
    public abstract void executeQuery(String query);
    }
  2. 然后,为每种数据库类型创建一个子类,继承自Database父类,这些子类需要实现父类中定义的抽象方法,并可以添加特定于各自数据库的方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class MySQLDatabase extends Database{
    @Override
    public void connect() {
    // 实现MySQL的连接逻辑
    }

    @Override
    public void disconnect() {
    // 实现MySQL的断开连接逻辑
    }

    @Override
    public void executeQuery(String query) {
    // 实现MySQL的查询逻辑
    }

    // 其他针对MySQL的特定方法
    }

    public class PostgreSQLDatabase extends Database {
    // 类似地,为PostgreSQL实现相应的方法
    }
    public class SQLiteDatabase extends Database {
    // 类似地,为SQLite实现相应的方法
    }

    这样设计的好处是,我们可以在不同的数据库类型之间灵活切换,而不需要修改大量代码。只要这些子类遵循里氏替换原则,我们就可以放心地使用基类的引用来操作不同类型的数据库。
    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public class DatabaseClient { 
    private Database database;
    public DatabaseClient(Database database) {
    this.database = database;
    }

    public void performDatabaseOperations() {
    database.connect();
    database.executeQuery("SELECT * FROM users");
    database.disconnect();
    }
    }

    public class Main {
    public static void main(String[] args) {
    // 使用MySQL数据库
    DatabaseClient client1 = new DatabaseClient(new MySQLDatabase());
    client1.performDatabaseOperations();
    // 切换到PostgreSQL数据库
    DatabaseClient client2 = new DatabaseClient(new PostgreSQLDatabase());
    client2.performDatabaseOperations();
    // 切换到SQLite数据库
    DatabaseClient client3 = new DatabaseClient(new SQLiteDatabase());
    client3.performDatabaseOperations();
    }
    }

    通过遵循里氏替换原则,我们确保了代码的可维护性和扩展性。如果需要支持新的数据库类型,只需创建一个新的子类,实现 Database 基类中定义的抽象方法即可。

    哪些情况下的代码违背了里氏替换原则?

    1. 子类覆盖或修改了父类的方法
    2. 子类违反了父类的约束条件
    3. 子类与父类之间缺乏“is-a”的关系

接口隔离原则(ISP)

接口隔离原则(Interface Segregation Principle,ISP)是一种面向对象编程的设计原则,它要求我们将大的、臃肿的接口拆分成更小、更专注的接口,以确保类之间的解耦。这样,客户端只需要依赖它实际使用的接口,而不需要依赖那些无关的接口。

接口隔离原则有以下几个要点:

  1. 将一个大的、通用的接口拆分成多个专用的接口。这样可以降低类之间的耦合
    度,提高代码的可维护性和可读性。
  2. 为每个接口定义一个独立的职责。这样可以确保接口的粒度适当,同时也有助于
    遵循单一职责原则。
  3. 在定义接口时,要考虑到客户端的实际需求。客户端不应该被迫实现无关的接口
    方法。

下面我们来看一个示例,说明如何应用接口隔离原则。
假设我们正在开发一个机器人程序,机器人具有多种功能,如行走、飞行和工作。我们可以为这些功能创建一个统一的接口:

1
2
3
4
5
public interface Robot{
void walk();
void fly();
void work();
}

然而,这个接口并不符合接口隔离原则,因为它将多个功能聚合在了一个接口中。对于那些只需要实现部分功能的客户端来说,这个接口会导致不必要的依赖。为了遵循接口隔离原则,我们应该将这个接口拆分成多个更小、更专注的接口:

1
2
3
4
5
6
7
8
9
10
11
public interface Walkable{
void walk();
}

public interface Flyable{
void fly();
}

public interface Workable{
void work();
}

现在,我们可以根据需要为不同类型的机器人实现不同的接口。例如,对于一个只能行走和工作的机器人,我们只需要实现 Walkable 和 Workable 接口:

1
2
3
4
5
6
7
8
9
10
11
public class WalkingWorkerRobot implements Walkable,Workable{
@Override
public void walk(){
//执行行走功能
}

@Override
public void work(){
//执行工作功能
}
}

通过遵循接口隔离原则,我们确保了代码的可维护性和可读性,同时也降低了类之间的耦合度。在实际项目中,要根据需求和场景来判断何时应用接口隔离原则。那按这么设计,是不是每个接口只能定义一个方法了?这并不是,在设计接口时,我们需要权衡接口的粒度和实际需求,过度拆分接口可能导致过多的单方法接口,这会增加代码的复杂性,降低可读性和可维护性。关键在于确保接口的职责清晰且单一,以便客户端只需依赖它们真正需要的接口。在某些情况下,一个接口包含多个方法是合理的,只要这些方法服务于一个单一的职责。例如,一个数据库操作接口可能包含 connect() 、 disconnect() 、executeQuery() 等方法,这些方法都是数据库操作的一部分,因此可以放在同一个接口中。总之,在遵循接口隔离原则时,我们需要根据实际情况进行权衡,以确保代码既简洁又易于维护。过度拆分接口并不总是一个好主意,关键是确保接口的职责单一且清晰。

依赖倒置原则(DIP)

依赖倒置原则(Dependency Inversion Principle)主要强调要依赖于抽象而不是具体实现,遵循这个原则可以使系统的设计更加灵活、可扩展和可维护。

依赖倒置原则有两个关键点:

  1. 高层模块不应该依赖于底层模块,它们都应该依赖于抽象。
  2. 抽象不应该依赖于具体实现,具体实现应该依赖于抽象。

在依赖倒置原则的背景下,我们可以从以下几个方面理解抽象:

  1. 接口

    接口是 Java 中实现抽象的一种常见方式。接口定义了一组方法签名,表示实现该接口的类应具备哪些行为。接口本身并不包含具体实现,所以它强调了行为的抽象。假设我们正在开发一个在线购物系统,其中有一个订单处理模块。订单处理模块需要与不同的支付服务提供商(如 PayPal、Stripe 等)进行交互。如果我们直接依赖于支付服务提供商的具体实现,那么在更换支付服务提供商或添加新的支付服务提供商时,我们可能需要对订单处理模块进行大量修改。为了避免这种情况,我们应该依赖于接口而不是具体实现。
    首先,我们定义一个支付服务接口:

    1
    2
    3
    public interface PaymentService{
    boolean processPayment(Order order)
    }

    然后,为每个支付服务提供商实现接口:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class PayPalPaymentService implements PaymentService{
    @Override
    public boolean processPayment(Order order){
    //实现PayPal支付逻辑
    }
    }

    public class StripePaymentService implements PaymentService{
    @Override
    public boolean processPayment(Order order){
    //实现Stripe支付逻辑
    }
    }

    现在,我们可以在订单处理模块中依赖 PaymentService 接口,而不是具体的实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class OrderProcessor{
    private PaymentService paymentService;

    //构造注入
    public OrderProcess(PaymentService paymentService){
    this.paymentService = paymentService
    }

    public void processOrder(Order order){
    //其他订单处理逻辑
    boolean paymentResult = paymentService.processPayment(order);
    // 根据 paymentResult 处理支付结果
    }
    }

    通过这种方式,当我们需要更换支付服务提供商或添加新的支付服务提供商时,只需要提供一个新的实现类,而不需要修改 OrderProcessor 类。我们可以在运行时通过构造函数注入不同的支付服务实现,使得系统更加灵活和可扩展。这个例子展示了如何依赖接口而不是实现来编写代码,从而提高系统的灵活性和可维护性.

  2. 抽象类

    抽象类是另一种实现抽象的方式。与接口类似,抽象类也可以定义抽象方法,表示子类应该具备哪些行为。不过抽象类还可以包含部分具体实现,这使得它们比接口更加灵活。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    abstract class Shape{
    abstract double getArea();

    void displayArea() {
    System.out.println("面积为: " + getArea());
    }
    }

    class Circle extends Shape {
    private final double radius;

    Circle(double radius) {
    this.radius = radius;
    }

    @Override
    double getArea() {
    return Math.PI * Math.pow(radius, 2);
    }
    }

    class Square extends Shape {
    private final double side;

    Square(double side) {
    this.side = side;
    }

    @Override
    double getArea() {
    return Math.pow(side, 2);
    }
    }

    在这个示例中,我们定义了一个抽象类 Shape ,它具有一个抽象方法 getArea ,用于计算形状的面积。同时,它还包含了一个具体方法 displayArea ,用于打印面积。 Circle 和 Square 类继承了 Shape ,分别实现了 getArea 方法。在其他类中我们可以依赖抽象Shape而非 Square和Circle。

  3. 高层模块

    在某些情况下,我们可以通过将系统分解为更小的、可复用的组件来实现抽象。这些组件可以独立地进行替换和扩展,从而使整个系统更加灵活。这种抽象方法往往在软件架构和模块化设计中有所体现。让我们来看另一个关于高层策略抽象的例子:插件化架构,案例中的插件十分简单,仅仅只有一个接口,事实上我们日常实现插件功能时往往比这个复杂的多。假设我们正在构建一个文本编辑器,它允许用户通过插件来扩展功能。我们可以将插件系统作为一个高层策略抽象,以便于在不修改核心编辑器代码的情况下,添加新功能。
    首先,我们可以定义一个插件接口:

    1
    2
    3
    4
    public interface Plugin {
    String getName();
    void execute();
    }

    然后,我们在文本编辑器的核心代码中,引入插件管理器来负责加载和执行插件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class TextEditor {
    private List<Plugin> plugins = new ArrayList<>();
    public void loadPlugin(Plugin plugin) {
    plugins.add(plugin);
    }
    public void executePlugins(String name) {
    for (Plugin plugin : plugins) {
    plugin.execute();
    }
    }
    }

    现在,如果我们想要添加一个新功能,例如支持 Markdown 格式的预览,我们可以创建一个实现 Plugin 接口的新类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class MarkdownPreviewPlugin implements Plugin {
    @Override
    public String getName() {
    return "Markdown Preview";
    }

    @Override
    public void execute() {
    // 实现 Markdown 预览功能
    }
    }

    最后,我们可以将新的插件加载到文本编辑器中:

    1
    2
    3
    4
    5
    public static void main(String[] args) {
    TextEditor editor = new TextEditor();
    editor.loadPlugin(new MarkdownPreviewPlugin());
    editor.executePlugin("Markdown Preview");
    }

    通过采用插件化架构,我们将特定功能的实现与核心编辑器代码解耦,使得整个系统更加灵活和可扩展。这是一个典型的高层策略抽象的应用示例。总之,抽象是一个广泛的概念,它有助于我们将注意力从具体实现转移到行为和概念。在依赖倒置原则的背景下,抽象可以帮助我们实现更加灵活、可扩展和可维护的系统

KISS原则

KISS 原则(Keep It Simple, Stupid):KISS 原则强调保持代码简单,易于理解和维护。在编写代码时,应避免使用复杂的逻辑、算法和技术,尽量保持代码简洁明了。这样可以提高代码的可读性、可维护性和可测试性。当然,这并不意味着要牺牲代码的性能和功能,而是要在保证性能和功能的前提下,尽量简化代码实现。KISS 原则算是一个金油类型的设计原则,可以应用在很多场景中。它不仅经常用来指导软件开发,还经常用来指导更加广泛的系统设计、产品设计等,比如,冰箱、建筑、iPhone 手机的设计等等。我们知道,代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。

遵循KISS原则的复杂代码应当具备以下特点:

  1. 模块化:将复杂的代码逻辑拆分成多个简单、独立的模块,每个模块负责一个特
    定的功能。这有助于降低代码的复杂度,提高代码的可读性和可维护性。
  2. 清晰的命名:为变量、方法、类等使用清晰、有意义的命名,以便于其他人(或
    未来的你)阅读和理解代码。
  3. 注释和文档:为复杂的代码逻辑编写清晰、详细的注释和文档,解释代码的作用
    和实现原理。这有助于其他人(或未来的你)更容易地理解和维护代码。
  4. 避免不必要的复杂度:尽量避免引入不必要的复杂性,如使用过于复杂的算法或
    数据结构。在实现功能的同时,要考虑代码的简洁性和可读性。

DRY原则

DRY 原则(Don’t Repeat Yourself):DRY原则强调避免代码重复,尽量将相似的代码和逻辑提取到共享的方法、类或模块中。

DRY原则有以下几个要点:

  1. 避免重复的代码:尽可能减少代码的冗余和重复,将重复的代码封装成可重用的
    函数、类、模块等。
  2. 提高代码的可维护性:避免重复代码可以降低代码的冗余性和复杂度,提高代码
    的可维护性和可读性。
  3. 降低代码的耦合性:通过避免重复代码,可以减少代码之间的耦合度,提高代码
    的灵活性和可扩展性。
  4. 提高代码的复用性:通过将重复的代码封装成可重用的函数、类、模块等,提高
    代码的复用性,减少代码的编写和维护成本。

迪米特法则

迪米特法则(Law of Demeter, LOD),又称最少知识原则(Least KnowledgePrinciple, LKP),是一种面向对象编程设计原则。它的核心思想是:一个对象应该尽量少地了解其他对象,降低对象之间的耦合度,从而提高代码的可维护性和可扩展性

迪米特法则的主要指导原则如下:

  1. 类和类之间尽量不直接依赖。
  2. 有依赖关系的类之间,尽量只依赖必要的接口。