软件设计原则学习

最近在学习GOF23,感觉前提一定要清楚了解设计的原则,不然无法完全体会设计模式的优势。因此也就兴冲冲的好好钻研了一波设计原则。

1. 开闭原则

开闭原则(Open-Closed Principle,OCP)是指一个软件实体(如类、模块、函数)应该对扩展开放,对修改关闭

所谓开闭,其实是对扩展和修改两个行为的一个原则,强调的是用抽象构建框架,用实现扩展细节。

实现开闭原则的核心思想就是面向抽象编程。

开闭原则可以提高软件系统的可复用性及可维护性,帮助我们建立稳定灵活的系统。

2. 依赖倒置

依赖倒置原则(Dependence Inversion Principle,DIP)是指设计代码结构时,高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。

我个人并不喜欢这种拗口的定义描述,但是不得不承认这个描述确实很准确。

注意倒置的概念,并非意味着低层模块依赖高层模块,而是将二者转化至都依赖一个抽象,如图:

传统的分层模式:

依赖倒置原则:

接着我们来理解后半段话,“抽象不应该依赖细节;细节应该依赖抽象”。

用户要上网可以用很多的设备,比如手机、平板等等,传统模式下,用户要上网这个动作是是要依赖于具体实现的,因为首先他要拥有手机或电脑或平板

传统的分层模式:

依赖倒置原则:

用户上网这个抽象动作并不依赖具体是哪种设备,反正最终他可以上网,这就是“抽象不依赖于细节”

反过来,无论是用手机还是电脑等一些方式进行上网等具体实现都依赖于设备这个抽象,这就是“细节应该依赖抽象”

除此以外,用户上网作为一个高层模块不依赖于上网这个低层模块,二者都依赖设备抽象。在代码的层面体现为,可以灵活的修改和复用低层模块。从中可以看出,依赖倒置的定义是前后一致,相辅相成的。

通过依赖倒置,可以减少类与类之间的耦合性,提高系统的稳定性,提高代码的可读性和可维护性,并能够降低修改程序所造成的风险。

3. 单一职责原则

单一职责(Simple Responsibility Pinciple,SRP)是指不要存在多于一个导致类变更的原因

假设我们一个类负责连个职责,一旦发生需求变更,修改其中一个职责的逻辑代码,有可能导致另一个职责的功能发生故障。这样就导致了这个类存在两个可能导致类变更的原因。

因此,单一职责要求一个类/接口/方法只负责一项职责。

单一职责可以降低类的复杂度,提高类的可读性,提高系统的可维护性,降低变更引起的风险。

4. 接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP)是指类间的依赖关系应该建立在最小的接口上,客户端不应该依赖它不需要的接口

接口隔离原则要求我们:

  • 建立单一接口,不要用单一接口的总接口,导致建立庞大臃肿的接口。
  • 尽量细化接口,接口中的方法应该尽可能的少,使接口更加轻便灵活。

比如我们定义一个动物接口,他有"fly"和“eat”两个抽象方法,有猪和鸟两个实现类:

public interface AnimalInterface {
    public void fly();
    public void eat();
}

public class Pig implements AnimalInterface{
    @Override
    public void fly() {
    }

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

UML图:

在上述代码中,让一个猪拥有接口中的“fly”方法是根本不可能的,因此这个接口还不是满足依赖关系的最小接口,我们可以进一步细化接口:

public interface EatInterface {
    void eat();
}
public interface FlyInterface {
    void fly();
}
public class Bird implements FlyInterface, EatInterface{
    @Override
    public void eat() {

    }

    @Override
    public void fly() {

    }
}
public class Pig implements EatInterface{

    @Override
    public void eat() {

    }
}

UML图:

这样,我们就让鸟这个类和猪这个类的依赖关系建立在最小的接口——吃接口“EatInterface”上了

接口隔离原则符合我们常说的高内聚低耦合的设计思想,从而使得类具有很好的可读性,可扩展性和可维护性。

5. 迪米特法则

迪米特法则(Law of Demeter,LoD)又称为最少知道原则(Least Knowledge Principle,LKP)一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。

套用《大话设计模式》中的一个类似的场景:

小菜作为一名职场新人刚刚步入公司,第一天最重要的就是办理入职手续,但是那天他主管不在,他装配电脑得找小张,分配公司账号得找小李等等,我们有如下类:

public class Xiaocai {
   public void handleJob() {
       Xiaoli xiaoli = new Xiaoli();
       Xiaozhang xiaozhang = new Xiaozhang();
       xiaoli.arrayAccount();
       xiaozhang.computer();
   }
}
public class Xiaoli {
    public void arrayAccount() {
        System.out.println("配账号");
    }
}
public class Xiaozhang {
    public void computer() {
        System.out.println("装电脑");
    }
}

UML图:

可以看到,小菜这个对象依赖于小李和小张两个对象。这里有个问题,小菜其实没小李和小张可能没有什么直接关系,小李和小张对于小菜是两个“陌生人”,小菜可能都不认识他们,得挨个挨个跑,挨个挨个说明情况。小菜的主管如果在的话,应该小菜直接和主管联系,主管负责协调安排。按照迪米特法则,小菜应该直接依赖主管,接着由主管再去依赖下属,因此我们有如下改造:

public class Xiaocai {
   public void handleJob() {
       ResponsiblePerson responsiblePerson = new ResponsiblePerson();
       responsiblePerson.arrayJob();
   }
}
public class ResponsiblePerson {
    void arrayJob() {
        Xiaoli xiaoli = new Xiaoli();
        Xiaozhang xiaozhang = new Xiaozhang();
        xiaoli.arrayAccount();
        xiaozhang.computer();
    }
}
public class Xiaoli {
    public void arrayAccount() {
        System.out.println("配账号");
    }
}
public class Xiaozhang {
    public void computer() {
        System.out.println("装电脑");
    }
}

UML图:

通过改在,小菜只依赖主管,主管依赖办理入职相关的同事。

迪米特法则可以减少我们对象之间的耦合。

6. 里式替换原则

里式替换原则(Liskov Substitution Principle,LSP)是指如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都替换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型

这样可能有点抽象。重新理解一下,可以理解为:一个软件实体如果适用一个父类的话,那一定适用于其子类,所有引用父类的地方必须能够透明地适用其子类的对象,子类对象能够替换父类对象,而程序逻辑不变。

引申含义:子类可以拓展父类的功能,但不能改变父类原有的功能。具体来说归纳为以下几点:

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

有个经典的里式替换原则的例子:

我们都知道正方形是一个特殊的长方形,二者又都是四边形,我们用程序来描述他们之间的关系来说明里式替换。

首先,创建一个长方形父类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 Long getHeight() {
        return super.getHeight();
    }

    @Override
    public void setHeight(Long height) {
        super.setHeight(height);
    }

    @Override
    public Long getWidth() {
        return super.getWidth();
    }

    @Override
    public void setWidth(Long width) {
        super.setWidth(width);
    }
}

现在,我们想让长方形高一直自增,直到高等于宽变成正方形,我们创建resize方法

public class Test {

    public static void main(String[] args) {
        Rectangle rectangle = new Rectangle();
        rectangle.setWidth(20L);
        rectangle.setHeight(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());
        }
    }
}

我们只需要对resize方法传入长方形作为参数便可以得到答案,但是,用长方形的正方形子类Square替换父类,程序会发生死循环,没有达到预期结果。因此,我们的程序存在一定风险,也违背了里式替换原则。

里式替换原则只存在父类和子类之间,约束继承泛滥。

下面我们做一些修改,创建一个基于长方形与正方形共同的抽象四边形Quadrangle接口:

public interface Quadrangle {
    Long getWidth();
    Long getHeight();
}

修改长方形Rectangle类:

public class Rectangle implements Quadrangle{

    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;
    }
}
public class Square implements Quadrangle{

    private Long length;

    public Long getLength() {
        return length;
    }

    public void setLength(Long length) {
        this.length = length;
    }


    public Long getWidth() {
        return null;
    }

    public Long getHeight() {
        return null;
    }
}

此时,resize方法只可以作用于Rectangle长方形,也不可以用Quadrangle接口作为参数,因为没有相关的set方法了。正方形和长方形不再是继承关系,因为他们不符合里式替换原则。此时,resize的方法参数只能是Rectangle长方形,约束了继承泛滥。

遵循里式替换原则有以下好处:

  • 约束继承泛滥,开闭原则的一种体现
  • 加强程序的健壮性,同事变更时也能有更好的兼容性,提高程序的维护性、扩展性。降低需求变更时引入的风险。

7. 合并复用

合成复用原则(Composite/Aggregate Reuse Principle,CARP)是指尽量使用对象组合(has-a)/ 聚合(contains -a),而不是继承关系达到软件复用的目的。

首先理解两个概念

  • 组合(has-a):整体与部分的关系,但是整体与部分不可以分开。
  • 聚合(contains-a):整体和部分的关系,整体与部分 可以分开。

通常类的复用分为继承复用和合成复用两种:

继承复用虽然有简单和易实现的优点,但它也存在以下缺点:

  1. 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
  2. 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
  3. 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。

采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:

  1. 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
  2. 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
  3. 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。

以数据库操作为例,创建DBConnection类:

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

在主业务中调用:

public class MainService {
    private DBConnection connection;

    public void deJob() {
        connection.getConnection();
        System.out.println("处理主业务");
    }
}

UML图:

目前的设计中,DBConnection还不是一种抽象,不利于系统拓展。后续如果要增加其他数据源,就需要修改主业务代码,违背开闭原则。

基于上述前提,我们将DBConnection修改为abstract:

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

有以下两种数据源:

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

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

UML图:

这样,在主业务(应用层)中,我们就可以自由地进行组合,可以选择SQLServer数据源,也可以选择MySQL数据源。

8. 总结

这 7 种设计原则是软件设计模式必须尽量遵循的原则,各种原则要求的侧重点不同。其中,开闭原则是总纲,它告诉我们要对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;单一职责原则告诉我们实现类要职责单一;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用,少用继承关系复用。

9. 一些形而上学的东西

9.1 SOLID原则

  • SRP,单一职责
  • OCP,开闭原则
  • LSP,里式替换原则
  • ISP,接口隔离原则
  • DIP,依赖倒置原则

参考资料