设计模式-1章(设计原则)


第一章 设计原则

在面向对象设计中,有五个常见的设计原则,通常被统称为SOLID原则。每个原则都有不同的焦点,但它们共同旨在帮助开发人员创建更加可维护、可扩展和健壮的软件系统。这些原则是:

  1. 单一职责原则(Single Responsibility Principle,SRP)
    单一职责原则要求一个类或模块应该有且仅有一个改变的原因,也就是说,一个类应该只有一个职责或功能。这有助于保持类的简单性和可维护性。

  2. 开闭原则(Open/Closed Principle,OCP)
    开闭原则要求软件实体(如类、模块、函数等)应该对扩展开放,对修改关闭。这意味着在添加新功能时,不应该修改现有的代码,而是应该通过扩展来实现变化。这鼓励使用抽象和接口来定义系统的扩展点。

  3. 里氏替换原则(Liskov Substitution Principle,LSP)
    里氏替换原则强调子类应该能够替代其父类,而不会引发不一致性或错误。这要求子类必须遵循其父类定义的行为,不得修改父类的预期行为。

  4. 接口隔离原则(Interface Segregation Principle,ISP)
    接口隔离原则建议将大接口拆分为多个小接口,以确保类只需要实现其所需的接口。这有助于减少类之间的耦合度,并提高系统的可扩展性。

  5. 依赖倒置原则(Dependency Inversion Principle,DIP)
    依赖倒置原则要求高层模块不应该依赖于低层模块,二者都应该依赖于抽象。抽象不应该依赖于具体细节,具体细节应该依赖于抽象。这有助于减少模块之间的直接依赖,提高系统的灵活性。

这些SOLID原则是面向对象设计的基石,它们帮助开发人员编写具有高内聚性、低耦合度、易于维护和扩展的代码。这些原则通常与设计模式结合使用,以达到更好的软件设计和架构。

1. 开闭原则

1.1 定义

开闭原则(Open/Closed Principle,OCP)是面向对象设计中的一个重要原则,它强调了软件实体(如类、模块、函数等)应该对扩展开放,对修改关闭。这意味着当需要改变系统的行为或添加新功能时,不应该修改现有的代码,而是应该通过扩展现有的代码来实现变化。用抽象构建框架,用实现拓展细节。

以下是一些遵循开闭原则的设计模式和实践:

  1. 策略模式(Strategy Pattern)
    策略模式允许你定义一系列算法,将它们封装在各自的策略类中,并使它们可以互相替换。这样,你可以在不修改上下文类的情况下,动态地选择不同的策略来改变系统行为。

  2. 观察者模式(Observer Pattern)
    观察者模式定义了一种一对多的依赖关系,当被观察对象的状态发生变化时,所有依赖它的观察者都会收到通知并自动更新。这允许你在不修改被观察对象的情况下,增加或移除观察者。

  3. 工厂模式(Factory Pattern)
    工厂模式通过定义一个创建对象的接口,但让子类决定实际创建的对象类型。这样,你可以轻松地扩展工厂类,以创建新的对象类型,而不必修改客户端代码。

  4. 装饰者模式(Decorator Pattern)
    装饰者模式允许你通过将对象包装在装饰器对象中来动态地为对象添加新的行为,而无需修改原始对象的代码。这符合开闭原则,因为你可以不断地添加新的装饰器类来扩展功能。

  5. 模板方法模式(Template Method Pattern)
    模板方法模式定义了一个算法的框架,将算法的具体步骤延迟到子类中实现。这样,你可以在不改变算法结构的情况下,通过创建不同的子类来扩展或修改算法的具体步骤。

1.2 优点

  1. 可维护性(Maintainability)
    遵循开闭原则的代码更容易维护。因为在添加新功能时不需要修改现有代码,而只需扩展现有代码,这减少了引入错误的风险,同时也降低了修改代码的复杂性。

  2. 可扩展性(Extensibility)
    开闭原则鼓励将系统的不同部分分离开来,通过接口、抽象类等方式定义清晰的扩展点。这使得在不影响现有功能的情况下,可以轻松地添加新功能或模块,从而增强了系统的可扩展性。

  3. 复用性(Reusability)
    遵循开闭原则的代码通常更具有通用性,因为它们更容易被其他部分或其他项目中的代码重用。这降低了开发新功能或项目的成本和时间。

  4. 降低风险(Risk Reduction)
    修改现有代码通常伴随着风险,可能会引入新的错误或导致现有功能失效。开闭原则通过减少对现有代码的修改来降低风险,因为新功能的变化主要发生在新的代码中。

  5. 增强可测试性(Testability)
    遵循开闭原则的代码更容易进行单元测试,因为现有的功能不会频繁变化,测试不会受到不断的修改影响,从而提高了代码的可测试性。

  6. 提高代码质量(Code Quality)
    开闭原则鼓励使用抽象和接口来定义系统的结构,这有助于更好地组织代码并降低耦合度,从而提高了代码的质量。

1.3 Coding

现在有这么一个场景,有一个课程,包含课程ID、价格

定义课程接口:

package com.nxz.designpattern.principle.openclose;

import java.math.BigDecimal;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 课程接口
 * @date 2023/9/19 12:04
 */
public interface ICourse {
    /**
     * 课程ID
     */
    Integer getId();
    /**
     * 课程价格
     */
    BigDecimal getPrice();

    /**
     * 获取课程名称
     * @return 课程名称
     */
    String getCourseName();
}

定义课程的实现,比如有Java课程:

package com.nxz.designpattern.principle.openclose;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.math.BigDecimal;

/**
 * @author 念心卓
 * @version 1.0
 * @description: Java课程
 * @date 2023/9/19 12:07
 */
@Data
@AllArgsConstructor
public class JavaCourse implements ICourse{
    /**
     * 课程ID
     */
    private Integer id;
    /**
     * 课程价格
     */
    private BigDecimal price;
    /**
     * 获取课程名称
     */
    private String courseName;
    @Override
    public Integer getId() {
        return this.id;
    }

    @Override
    public BigDecimal getPrice() {
        return this.price;
    }

    /**
     * 获取课程名称
     * @return 课程名称
     */
    @Override
    public String getCourseName() {
        return this.courseName;
    }
}

测试:

@Slf4j
public class Main {
    public static void main(String[] args) {
        ICourse course =  new JavaCourse(1,new BigDecimal("399.89"),"Java课程");
        log.debug(
                "课程ID为:{};课程名称为:{};课程价格为:{}",
                course.getId(),
                course.getCourseName(),
                course.getPrice()
        );
    }
}

执行结果:

12:16:58.194 [main] DEBUG com.nxz.designpattern.principle.openclose.Main - 课程ID为:1;课程名称为:Java课程;课程价格为:399.89

可见目前为止没有什么问题;那么我现在如果想要计算一个打折过后的价格呢?

可能有人会想到再给ICourse接口添加一个抽象方法即可,然后让其他的实现类分别去实现即可,但是如果我现在有很多课程,比如Java,前端、测试等等100+的课程呢?,那么我岂不是要写100+次,而且很容易出错,耦合度太高了,那么有没有别的办法呢?

我们可以编写一个打折的类然后来继承某个课程即可解决:

package com.nxz.designpattern.principle.openclose;

import java.math.BigDecimal;

/**
 * @author 念心卓
 * @version 1.0
 * @description: Java打折课程
 * @date 2023/9/19 12:23
 */
public class JavaDiscountCourse extends JavaCourse{

    public JavaDiscountCourse(Integer id, BigDecimal price, String courseName) {
        super(id, price, courseName);
    }

    /**
     * 打折,重写父类的方法
     * @return 返回打八折之后的价格
     */
    @Override
    public BigDecimal getPrice(){
        return super.getPrice().multiply(new BigDecimal("0.8"));
    }

    //但是我们就获取不到原价了,所以还要拓展一个自己的方法来获取原价

    /**
     * 获取原价
     * @return 返回原价
     */
    public BigDecimal getOriginPrice(){
        return super.getPrice();
    }
}

Main方法修改:

@Slf4j
public class Main {
    public static void main(String[] args) {
        ICourse course =  new JavaDiscountCourse(1,new BigDecimal("399.89"),"Java课程");
        JavaDiscountCourse javaCourse = (JavaDiscountCourse) course; //必须强转,否则父类无法获取到子类的getOriginPrice方法
        log.debug(
                "课程ID为:{};课程名称为:{};课程原价为:{};打折过后的价格为:{}",
                javaCourse.getId(),
                javaCourse.getCourseName(),
                javaCourse.getOriginPrice(),
                javaCourse.getPrice()
        );
    }
}

执行结果:

12:34:15.074 [main] DEBUG com.nxz.designpattern.principle.openclose.Main - 课程ID为:1;课程名称为:Java课程;课程原价为:399.89;打折过后的价格为:319.912

总结:

我们对于功能的增强,最好不要再底层去做,比如接口,以及接口的实现类,这样会导致牵一发而动全身,引发联动效应,我们最好是再上层来做,这样出错了也就只有我这个类出错,而不影响其他的类。

对比我上面的Coding,我就没有再接口中拓展方法,而是单独开了一个类,然后去实现实现接口的这个类,这样出错了既不会影响实现类,也不会导致接口的更改而需要更改所有的实现类。

上诉代码的类图

2. 依赖倒置原则

2.1 定义

依赖倒置原则(Dependency Inversion Principle,DIP)是面向对象设计的五个SOLID原则之一,它强调高层模块不应该依赖于低层模块二者都应该依赖于抽象。同时,抽象不应该依赖于具体细节,具体细节应该依赖于抽象。换句话说,DIP 鼓励通过接口或抽象类来定义系统的组件之间的交互,而不是直接依赖于具体的实现细节。针对接口编程、不针对实现编程

2.2 优点

  1. 高层模块不应该依赖于低层模块
    高层模块通常包括应用程序的主要逻辑,而低层模块包括实现细节,如数据库访问、文件操作等。DIP 要求高层模块不应该直接依赖于低层模块的具体实现,而是应该依赖于抽象接口或类。

  2. 依赖于抽象
    DIP 强调使用抽象来定义组件之间的约定和接口,这些抽象可以是接口、抽象类或其他形式的抽象数据类型。这样,高层模块可以与抽象交互,而不需要关心底层实现的细节。

  3. 具体细节依赖于抽象
    具体的实现应该依赖于抽象定义的接口或类,而不是反过来。这意味着低层模块应该实现高层模块所依赖的抽象,而不是高层模块适应于低层模块的具体实现。

  4. 松耦合
    DIP 有助于实现松耦合,因为高层模块不直接依赖于低层模块,而是依赖于抽象,这使得系统更容易维护、扩展和测试。当需要更改底层实现时,只需确保新的实现符合相应的抽象接口,而不必修改高层模块的代码。

  5. 可替代性
    依赖倒置原则有助于实现组件的可替代性。通过依赖于抽象,可以轻松地替换底层模块的具体实现,而不必影响高层模块的代码。这在测试、模块重用和系统升级时非常有用。

2.3 Coding

现在有这样一个场景,我要学习课程,有可能是Java课程,有可能是Python课程,还有可能是前端课程等。

现在有如下代码:

package com.nxz.designpattern.principle.dip;

/**
 * @author 念心卓
 * @version 1.0
 * @description: 念心卓同学要学习课程
 * @date 2023/9/19 16:03
 */
public class Nxz {
    public void studyJavaCourse(){
        System.out.println("学习Java课程");
    }
    public void studyPythonCourse(){
        System.out.println("学习Python课程");
    }
    public void studyFECourse(){
        System.out.println("学习FE课程");
    }
}
public class Main {
    public static void main(String[] args) {
        Nxz nxz = new Nxz();
        nxz.studyJavaCourse();
        nxz.studyPythonCourse();
        nxz.studyFECourse();
    }
}

执行结果:

学习Java课程
学习Python课程
学习FE课程

可见如上方法存在问题:如果我要学习其他很多课程呢?你是不是要去修改Nxz这个类,然后一直拓展呢?很麻烦

所以我们需要将课程的学习抽象出来。

来改进一下:

将课程的学习抽象出来做一个接口

/**
* 课程接口
*/
public interface ICourse {
    /**
     * 学习课程
     */
    void studyCourse();
}

新增具体的课程:

/**
 * @author 念心卓
 * @version 1.0
 * @description: Java课程
 * @date 2023/9/19 16:10
 */
public class JavaCourse implements ICourse {
    @Override
    public void studyCourse() {
        System.out.println("学习Java课程");
    }
}

/**
 * @author 念心卓
 * @version 1.0
 * @description: Python课程
 * @date 2023/9/19 16:11
 */
public class PythonCourse implements ICourse{
    @Override
    public void studyCourse() {
        System.out.println("学习Python课程");
    }
}

/**
 * @author 念心卓
 * @version 1.0
 * @description: 前端课程
 * @date 2023/9/19 16:11
 */
public class FECourse implements ICourse{
    @Override
    public void studyCourse() {
        System.out.println("学习FE课程");
    }
}

这样,每一个课程就都去实现一下课程接口就好了。

/**
 * @author 念心卓
 * @version 1.0
 * @description: 念心卓同学
 * @date 2023/9/19 16:03
 */
public class Nxz{
    public void study(ICourse iCourse){
        iCourse.studyCourse();
    }
}

学习者无需关心学习什么课程,给你什么课程就学习什么。

public class Main {
    public static void main(String[] args) {
        Nxz nxz = new Nxz();
        nxz.study(new JavaCourse());
        nxz.study(new PythonCourse());
        nxz.study(new FECourse());
    }
}

同样实现效果,而且有新的课程来了不必反复来修改Nxz这个类,只需要新添加一个具体课程的实现即可,也满足开闭原则。

上诉代码类图

可见改进之后,高层模块Nxz(相对于其他的类算高层)不依赖底层模块,并且所有的模块都依赖于抽象(ICourse),已经完全解耦出来了。

3. 单一职责原则

3.1 定义

单一职责原则(Single Responsibility Principle,SRP)是面向对象设计中的一个基本原则,它也在设计模式中有着重要的应用。单一职责原则要求一个类或模块应该有且仅有一个改变的原因,也就是说,一个类或模块应该只有一个职责或功能

3.2 优点

  1. 提高代码的可读性:每个类只有一个职责,这使得类的设计更加简单和清晰,代码更容易理解和阅读。开发人员可以更容易地找到特定功能的实现。

  2. 降低代码的复杂性:SRP鼓励将不同的职责分离到不同的类中,避免了一个类中包含过多的方法和属性,降低了类的复杂性。这有助于减少错误的引入和提高代码的稳定性。

  3. 提高代码的可维护性:由于每个类只有一个职责,当需要修改代码时,只需关注与修改相关的类,而不必涉及多个不相关的功能。这降低了维护代码的成本和风险。

  4. 提高代码的可测试性:单一职责原则使得单元测试更容易进行,因为每个类都有一个明确的职责,可以更容易地针对特定功能编写测试用例。

  5. 支持模块化和重用:SRP有助于将系统分解为独立的模块或组件,这些模块可以更容易地被重用在不同的上下文中。这提高了代码的模块化性和可重用性。

  6. 降低耦合度:遵循SRP有助于降低类之间的耦合度,因为每个类都只关注一个职责,不需要了解其他类的内部实现细节。这使得系统更加灵活,能够更容易地适应变化。

  7. 符合开闭原则:SRP的遵循有助于实现开闭原则,因为当需要添加新功能时,通常只需创建新的类来实现新的职责,而不必修改现有的类。

4. 接口隔离原则

4.1 定义

接口隔离原则(Interface Segregation Principle,ISP)是面向对象设计中的一个原则,它强调客户端不应该被强迫依赖于其不需要的接口。简而言之,一个类不应该强制性地实现它用不到的方法。这个原则有助于确保接口的细化和高内聚性,提高了代码的可维护性和可扩展性。

也就是有一个接口,里面有很多抽象方法,但是某一个实现类只能够使用到其中的一些方法,如果你实现这个接口的话,就会导致很多没用的方法被实现,所以我们一般把一个大接口拆分为很多小的接口。

4.2 优点

  1. 接口细化:ISP鼓励将大接口(包含多个方法)拆分成多个小接口,每个小接口包含与特定功能相关的方法。这样,实现类只需要实现其所需的小接口,而不必强制性地实现不需要的方法。

  2. 降低耦合度:通过遵循ISP,可以降低类与接口之间的耦合度。实现类只依赖于其所需的接口,而不会依赖于不需要的方法,从而减少了类之间的依赖关系。

  3. 避免冗余代码:遵循ISP可以避免实现类中出现不需要的方法,从而减少了冗余代码的产生。这使得代码更加精简和易于维护。

  4. 提高可维护性:当接口被细化为小接口时,每个接口的职责更加明确,易于理解。这提高了代码的可维护性,开发人员可以更容易地定位和修改特定功能的实现。

  5. 支持模块化和重用:小接口的创建有助于将系统分解为独立的模块或组件,这些模块可以更容易地被重用在不同的上下文中。这提高了代码的模块化性和可重用性。

  6. 遵循依赖倒置原则:ISP的遵循有助于实现依赖倒置原则,因为高层模块可以依赖于它们所需的小接口,而不需要依赖于底层模块的具体实现。

  7. 可测试性:遵循ISP可以提高代码的可测试性,因为每个小接口只包含有限的功能,更容易编写和运行测试用例。

5. 迪米特法则

5.1 定义

迪米特法则(Law of Demeter,LOD),也被称为最少知识原则(Principle of Least Knowledge),是一项面向对象编程中的设计原则。它强调一个对象应该对其他对象有最少的了解,也就是说,一个类不应该暴露太多的接口或依赖关系。这个原则的核心思想是降低对象之间的耦合度,使系统更加稳定、可维护和易于扩展。

以下是迪米特法则的关键要点和原则:

  1. 只与直接的朋友通信
    迪米特法则要求一个对象只应该与其直接的朋友通信。这里的直接朋友指的是以下几种情况:

    • 该对象本身。
    • 被当作方法参数传递给该对象的对象。
    • 该对象的成员变量。

    通过限制与直接朋友的通信,可以减少类之间的依赖关系,降低耦合度。

    出现在方法体内部的类不属于朋友。

  2. 不要暴露类的内部细节
    类不应该暴露其内部的实现细节,而是应该提供简洁的公共接口。这有助于隐藏对象的实现细节,使类的修改不会影响到其客户端。

  3. 避免链式调用
    链式调用(也称为方法链)是一种常见的违反迪米特法则的做法。链式调用会导致一个对象访问了多个不直接的朋友,增加了对象之间的耦合度。

  4. 提倡封装
    迪米特法则鼓励封装对象的行为,使得每个对象只需要关心自己的任务,而不需要了解其他对象的内部工作。这有助于代码的模块化和可维护性。

  5. 促进松耦合
    遵循迪米特法则有助于实现松耦合,使得系统更加灵活,易于维护和扩展。当对象之间的依赖关系较少时,修改一个对象的实现不会影响到其他对象。

5.2 优点

5.3 Coding

现在有这样一个场景:有3个类,Boss、TeamLeader、Course,老板现在要知道目前有多少课程,老板只需要通知TeamLeader去执行就可以了。

写出了如下代码:

public class Boss {
    public void getCourseNumber(TeamLeader teamLeader){
        List<Course> courseList = new ArrayList<Course>();
        for (int i = 0;i < 20;i++){
            courseList.add(new Course());
        }
        teamLeader.checkCourseNumber(courseList);
    }
}

public class TeamLeader {

    public void checkCourseNumber(List<Course> courseList) {
        System.out.println("课程的数量为:"+courseList.size());
    }
}

public class Course {
}

可见再Boss这个类中,teamLeader是朋友,但是里面的Course不是朋友,我们要想让Boss符合迪米特法则,将Course放到TeamLeader中即可,这样Boss就只关心TeamLeader,无需关心其他。

public class Boss {
    public void getCourseNumber(TeamLeader teamLeader){
        teamLeader.checkCourseNumber();
    }
}

public class TeamLeader {

    public void checkCourseNumber() {
        List<Course> courseList = new ArrayList<Course>();
        for (int i = 0;i < 20;i++){
            courseList.add(new Course());
        }
        System.out.println("课程的数量为:"+courseList.size());
    }
}

这样就符合迪米特法则了。

类图及依赖关系

可见这样Boss就只关系TeamLeader了。


文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录