设计模式之七大设计原则

337 阅读13分钟

photo-1617365722049-33d2f6d72f10.jpeg 设计模式是对软件设计中普遍存在的问题,所提出的解决方案,而作为一个合格的程序员,设计模式也是必须要掌握的。在学习设计模式之前,肯定是要学习一下设计模式的基础,即设计原则

七大设计原则

首先大概了解一下有哪七大设计原则,如下:

设计原则解释
开闭原则对扩展开放,对修改关闭
依赖倒置原则通过抽象使各个类或者模块不相互影响,实现松耦合
单一职责原则一个类、接口、方法只做一件事
接口隔离原则尽量保证接口的纯洁性,客户端不应该依赖不需要的接口
迪米特法则又叫最少知道原则,一个类对其所依赖的类知道的越少越好
里氏替换原则子类可以扩展父类的功能但不能改变父类原有的功能
合成复用原则尽量使用对象组合、聚合,而不使用继承关系达到代码复用的目的

开闭原则(Open-closed Principle, OCP)

开闭原则是面向对象设计汇总最基础的设计原则,指导我们如何建立稳定灵活的系统。

开闭原则是指一个实体如类、模块和函数对扩展开放,对修改关闭。所谓的开闭,正是对扩展和修改两个行为的一个原则。强调的是用抽象构建框架,用实现扩展细节,提高软件的可复用性及可维护性

举个例子,公司实行弹性制工作时间,规定每天工作8小时。意思就是说,对于每天工作8小时的这个规定是关闭的,但是你什么时候来,什么时候走是开放的,早来早走,晚来晚走。

实现开闭原则的核心思想就是面向抽象编程,接下来看下面一段代码:

一个在线教育系统,里面有很多种课程,比如Java、大数据、Python等,现在创建一个课程接口

public interface ICourse {
    Integer getId();
    String getName();
    Double getPrice();
}

创建一个Java课程

public class JavaCourse implements ICourse{

    private Integer id;
    private String name;
    private Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    @Override
    public Integer getId() {
        return this.id;
    }

    @Override
    public String getName() {
        return this.name;
    }

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

现在要给Java课程做活动,搞优惠。如果我们去修改JavaCourse中的getPrice()方法,则会存在一定的风险,会影响到其他地方调用的结果。如何在不修改原有代码的前提下,实现价格优惠功能呢?那么我们就需要再创建一个处理优惠逻辑的类

public class JavaDiscountCourse extends JavaCourse{

    public JavaDiscountCourse(Integer id, String name, Double price) {
        super(id, name, price);
    }

    public Double getOriginPrice() {
        return super.getPrice();
    }

    public Double getPrice() {
        return super.getPrice() * 0.6;
    }
}
public static void main(String[] args) {
  ICourse  javaCourse = new JavaCourse(1, "java", 666.66);
  javaCourse = new JavaDiscountCourse(1, "Java", 666.66);
  System.out.println(javaCourse.getPrice());
}

最终,我们来看一下代码的类图

image-20210409232726360

依赖倒置原则(Dependence Inversion Principle, DIP)

  1. 高层模块(调用层)不应该依赖底层模块,二者都应该依赖其抽象
  2. 抽象不应该依赖细节,细节应该依赖抽象
  3. 依赖倒置的中心思想是面向接口编程
  4. 通过依赖倒置,可以减少类与类之间的耦合性,提高代码的稳定性,可读性以及维护性
  5. 使用接口或抽象类的目的是指定好规范,而不涉及任何具体的操作,把展现细节的任务交给它们的实现类来完成

看一个案例,还是以课程为例,先创建一个用户

public clss Jack {
  public void studyJavaCourse() {
        System.out.println("Jack在学习Java");
  }
  
  public void studyPythonCourse() {
        System.out.println("Jack在学习Python");
  }
}
public static void main(String[] args) {
  Jack jack = new Jack();
  jack.studyJavaCourse();
  jack.studyPythonCourse();
}

如果后续要学习新的课程,那么就要从底层到调用层修改代码,如此一来是非常的麻烦,而且有可能在修改代码的同时,带来其他风险,因此要优化一下代码,创建一个课程的抽象接口

public interface ICourse {
    void study();
}

创建课程类JavaCourse

public class JavaCourse implements ICourse{
    @Override
    public void study() {
        System.out.println("Jack在学习Java");
    }
}

创建课程类PythonCourse

public class PythonCourse implements ICourse{
    @Override
    public void study() {
        System.out.println("Jack在学习Python");
    }
}

修改Jack

public clss Jack {
  public void study(ICourse course) {
    course.study();
  }
}

再来调用一下

public static void main(String[] args) {

  Jack jack = new Jack();
  jack.study(new JavaCourse);
  jack.study(new PythonCourse);
}

优化后的代码,对于后续需要学习新的课程,只需要创建一个类,然后通过传参的方式告诉Jack,而不需要修改底层代码。实际上这是就是依赖注入,注入的方式有构造器方式setter方式

构造器方式

public clss Jack {
	private ICourse course;
  public Jack(ICourse course) {
    this.course = course;
  }
  
  public void study() {
  	course.study();
  }
}

根据构造器方式,在调用时,每次都要创建实例,也不是很方便,那就只能选择Setter方式来进行注入

public class Jack {

    private ICourse course;

    public void setCourse(ICourse course) {
        this.course = course;
    }

    public void study() {
        course.study();
    }
}
public static void main(String[] args) {

  Jack jack = new Jack();
  jack.setCourse(new JavaCourse());
  jack.study();

  jack.setCourse(new PythonCourse());
  jack.study();
}

依赖倒置原则的本质就是通过抽象(接口或者抽象类)使各个类或者模块的实现彼此独立,互不影响,实现模块间的松耦合。以后在项目的开发中使用这个原则要遵循一下规则:

  1. 每个类尽量都有接口或者抽象类
  2. 变量的表面类型尽量是接口或者抽象类
  3. 任何类都不应该从具体类派生
  4. 尽量不要覆写基类的方法
  5. 如果基类是一个抽象类,而这个方法已经实现,子类尽量不要覆写。类间依赖的是抽象, 覆写了抽象方法,对依赖的稳定性会有一定的影响

最终我们来看下类图:

image-20210409235100473

单一职责原则(Simple Responsibility Principle, SRP)

单一职责原则是指对象不应该承担太多功能,正如一心不能多用,唯有专注才能保证对象的高内聚;唯有唯一,才能保证对象的细粒度。比如有A和B两个类,当A需求发送改变需要修改时,不能导致B类出问题。如何解决这个问题呢?我们就要给两个类分别负责一个职责,进行解耦,后期需求变更维护互不影响

还是以课程为例,课程可以分为直播课和录播课,直播课不能快进快退,录播课可以任意的来回观看,功能职责不一样,先建一个课程类

public class Course {
    public void study(String courseName) {
        if ("直播课".equals(courseName)) {
            System.out.println("不能快进");
        }else {
            System.out.println("可以任意的来回播放");
        }
    }
}
public static void main(String[] args) {
  Course course = new Course();
  course.study("直播课");
  course.study("录播课");
}

从上面的代码来看,Course类承担了两种处理逻辑。如果后期要对这两种课程进行一个操作,并且两种课程的操作逻辑不一样,那么就必须要修改代码,那么这样就提高了代码的复杂度,并且可维护性降低了。因此,我们要对其职责进行解耦,分别创建直播课类和录播课类

public class LiveCourse {
    public void study(String courseName) {
        System.out.println(courseName + "不能快进");
    }
}
public class ReplayCourse {
    public void study(String courseName) {
        System.out.println(courseName + "可以任意的来回播放");
    }
}

代码调用一下

public static void main(String[] args) {

  LiveCourse liveCourse = new LiveCourse();
  liveCourse.study("直播课");

  ReplayCourse replayCourse = new ReplayCourse();
  replayCourse.study("录播课");
}

这样修改之后,后期维护起来就比较容易了。但是,我们在实际开发中,由于项目依赖,组合等关系,很多类其实都不符合单一职责,我们也要尽可能的让接口和方法保持单一职责,对项目后期的维护是有巨大帮助的。

最后,依然来看一下类图:

image-20210410001538642

接口隔离原则(interface Segregation Principle, ISP)

  1. 客户端不应该依赖它不需要的接口
  2. 类间的依赖关系应该建立在最小的接口上

通俗的来讲就是,不要在一个接口里面放太多的方法,这样会显得很臃肿。接口应该尽量细化,一个接口对应一个功能模块,同时接口里面的方法应该尽可能的少,是接口更加灵活轻便

下面来看一个例子

创建一个动物行为的抽象

public interface IAnimal {
    void eat();
    void fly();
    void swim();
}

Bird类实现

public class Bird implements IAnimal{
    @Override
    public void eat() {}
    @Override
    public void fly() {}
    @Override
    public void swim() {}
}

Dog类实现

public class Dog implements IAnimal{
    @Override
    public void eat() {}
    @Override
    public void fly() {}
    @Override
    public void swim() {}
}

从上面的代码可以看出,Bird的swim()方法可能会空着,Dog的fly()方法显然是不可能的。因此,我们要针对不同的动物行为来设计不同的接口,看如下代码

public interface IEatAnimal {
    void eat();
}
public interface IFlyAnimal {
    void fly();
}
public interface ISwimAnimal {
    void swim();
}

Bird类实现IEatAnimal、IFlyAnimal

public class Bird implements IEatAnimal, IFlyAnimal{
    @Override
    public void eat() {}
    @Override
    public void fly() {}
}

Dog类实现IEatAnimal、ISwimAnimal

public class Dog implements IEatAnimal, ISwimAnimal{
    @Override
    public void eat() {}

    @Override
    public void swim() {}
}

通过下面的类图,就能很清晰明了

image-20210410005509824

迪米特法则(Law of Demeter LOD)

迪米特法则是指一个对象应该对其他对象保持最少的了解,又叫最少知道原则(Least Knowledge Principle, LKP),尽量减少类与类之间的耦合,强调只与你的直接朋友交谈,不与陌生人说话。

迪米特法则中的朋友是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、方法参数等,这些对象存在关联、聚合或者组合关系,可以直接访问这些对象

现在来举一个例子,设计一个权限系统,TeamLeader需要查看目前发布到线上的课程数量。这个时候,TeamLeader要找到员工Employee进去统计,Employee再把统计结果告诉TeamLeader,接下来看代码

Course类

public class Course {
}

Employee类

public class Employee {
    public void checkNumberOfCourse(List<Course> courseList) {
        System.out.println("已发布的课程" + courseList.size());
    }
}

TeamLeader类

public class TeamLeader {
    public void commandCheckNumber(Employee employee) {
        List<Course> courseList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            courseList.add(new Course());
        }
        employee.checkNumberOfCourse(courseList);
    }
}

测试代码

public static void main(String[] args) {
  TeamLeader leader = new TeamLeader();
  Employee employee = new Employee();
  leader.commandCheckNumber(employee);
}

代码写到这里,其实功能已经完成了,但是根据迪米特原则,TeamLeader只需要知道结果就好,并不需要跟Course产生直接的交流,而Employee统计需要引用Course对象。TeamLeader和Course并不是朋友,从下面的类图就可以看出来

image-20210411220035467

下面来对代码进行改造

Employee类

public class Employee {
    public void checkNumberOfCourse() {
        List<Course> courseList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            courseList.add(new Course());
        }
        System.out.println("已发布的课程" + courseList.size());
    }
}

TeamLader类

public class TeamLeader {
    public void commandCheckNumber(Employee employee) {
        employee.checkNumberOfCourse();
    }
}

再看下面的类图,Course和TeamLeader已经没有关联了

image-20210411220621673

里氏替换原则(Liskov SubStitution Principle)

里氏替换原则是指对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得T1定义的所有程序P在所有对象o1都替换成o2时,程序p的行为没有发生变化,那么类型T2是类型T1的子类型。

定义看上去比较抽象,我们可以简单的理解为一个软件实体如果适用一个父类的话,那一定是适用于其子类,子类对象能够替换父类对象,而程序逻辑不变。根据这个理解,我们总结一下:

子类可以扩展父类的功能,但不能改变父类原有的功能

  1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象的方法
  2. 子类中可以增加自己的特有的方法
  3. 当子类的方法重载父类的方法时,方法的前置添加(即方法的输入/入参)要比父类方法的输入参数更宽松
  4. 当子类的方法重载父类的方法时(重写/重载或实现抽象方法), 方法的后置条件(即方法的输出/返回值)要比父类更严格或相等

使用里氏替换原则有以下优点

  1. 约束继承泛滥,开闭原则的一种体现
  2. 加强程序的健壮性,同时变更时也可以做到非常好的兼容性,提高程序的维护性、扩展性。

下面用一个案例来说明里氏替换原则,我们都知道,正方形是一个特殊的长方形,下面创建一个长方形父类

Rectangle类

public class Rectangle {
    private Long height;
    private Long width;

    public Long getHeight() {
        return height;
    }
    public void setHeight(Long height) {
        this.height = height;
    }
    public Long getWidth() {
        return width;
    }
    public void setWidth(Long width) {
        this.width = width;
    }
}

Square类

public class Square extends Rectangle{
    private Long length;
    public Long getLength() {
        return length;
    }
    public void setLength(Long length) {
        this.length = length;
    }
    @Override
    public void setHeight(Long height) {
        setLength(height);
    }
    @Override
    public Long getHeight() {
        return getLength();
    }
    @Override
    public void setWidth(Long width) {
        setLength(width);
    }
    @Override
    public Long getWidth() {
        return getLength();
    }
}

在Main类中创建一个resize()方法,如果宽大于或等于高,就让高一直自增

public class Main {
    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setHeight(5L);
        rectangle.setWidth(10L);
        resize(rectangle);
    }
    public static void resize(Rectangle rectangle) {
        while (rectangle.getWidth() >= rectangle.getHeight()) {
            rectangle.setHeight(rectangle.getHeight() + 1);
            System.out.println("Width:" + rectangle.getWidth() + ", Height:" + rectangle.getHeight());
        }
        System.out.println("Resize End, Width:" + rectangle.getWidth() + ", Height:" + rectangle.getHeight());
    }
}

打印结果如下

Width:10, Height:6
Width:10, Height:7
Width:10, Height:8
Width:10, Height:9
Width:10, Height:10
Width:10, Height:11
Resize End, Width:10, Height:11

现在将Rectangle替换成Square

Square square = new Square();
square.setLength(10L);
resize(square);

这时我们运行代码就出现了死循环,违背了里氏替换原则,将父类替换成子类后,程序运行的结果没有达到预期。因此,我们的代码设计是存在一定风险的,里氏替换原则只存在父类与子类之间,约束继承泛滥。

合成复用原则(Composite/Aggregate Reuse Principe, CARP)

合成复用原则是指尽量使用对象组合/聚合,而不是继承关系达到软件复用的目的,可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少

继承相当于把所有的实现细节暴露给子类,组合/聚合是对类以外的对象无法获取到实现细节

通过一个数据库的例子来讲解一下

DBConnection类

public class DBConnection {
    public String getConnection() {
        return "MSQL数据库连接";
    }
}

ProductDao类

public class ProductDao {
    private DBConnection connection;

    public void setConnection(DBConnection connection) {
        this.connection = connection;
    }

    public void  addProduct() {
        String conn = this.connection.getConnection();
        System.out.println("使用" + conn + "增加产品");
    }
}

从上面代码看,DBConnection还不是一种抽象,不便于扩展。现在只支持MySQL数据库,后续要是业务发生改变,要支持Oracle数据库。我们可以在DBConnection中增加对Oracle数据库连接的方法,但是这样就违背了开闭原则,其实我们只需要把DBConnection修改为abstract就可以了

public abstract class DBConnection {
    public abstract String getConnection();
}

将MySQL的逻辑抽离

public class MySQLConnection extends DBConnection{
    @Override
    public String getConnection() {
        return "MySQL数据库连接";
    }
}

创建Oracle支持

public class OracleConnection extends DBConnection{
    @Override
    public String getConnection() {
        return "Oracle数据库连接";
    }
}

Dao根本就不用改变,怎么选择直接交给应用层来处理

public static void main(String[] args) {
  DBConnection connection = new MySQLConnection();
  ProductDao dao = new ProductDao();
  dao.setConnection(connection);
  dao.addProduct();
}

最后来看一下类图

image-20210411232344501

总结

学习设计原则,学习设计模式的基础。在实际开发过程中,并不是一定要求所有代码都遵循设计原则,我们要考虑人力、时间、成本、质量,不是刻意追求完美,要在适当的场景遵循设计原则,体现的是一种平衡取舍,帮助我们设计出更加优雅的代码结构