浅谈:设计模式六大设计原则

222 阅读10分钟

单一职责原则

定义:不要存在多于一个导致类变更的原因

说人话:一个类只负责一项职责

例如一个类负责两个功能p1和p2,在对功能p1修改的时候可能会造成p2功能的故障,所以建议一个类只负责一项功能。

其实都知道该这样做,要是把功能都写到一起了那怎么维护?但是现实开发中经常会因为职责扩散而违背这一原则,所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2因为类已经写好了,为了节约时间大多都喜欢直接就在现有的类上改,毕竟功能也不复杂。建议在职责扩散到p3、p4、p5...pn之前尽快重构。

  1. 在方法层面违反单一职责,常见的就是在method1完成p1功能在method2完成p2功能
  2. 在代码级别违反单一职责,最常见的就是各种if...else...不同的else去做不同的功能

对于上述两种情况当方法足够少、代码逻辑足够简单的时候其实违反了单一职责原则也无所谓,看个人的选择。但是一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。

单一职责原则告诉我们实现类要职责单一

里氏替换原则

定义:所有引用基类的地方必须能透明地使用其子类的对象

说人话:只要有父类出现的地方,都可以用子类来替代,并且运行的结果都是一样的

仔细研究这句话只要有父类出现的地方,都可以用子类来替代,这就相当于让子类能够完全替代父类,想想要是子类重写了父类的方法,父类明明是个减法操作到子类这里就变成加法操作了,那父类肯定就不能用子类代替了,此时就违反了里氏替换原则,所以里氏替换原则其实就是对继承在规则上进行了约束,具体的约束有以下几点:

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

第一点已经解释过了,第二点显而易见

第三点说明:当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

public class A {
    public void fun(HashMap map) {
        System.out.println("A fun");
    }
}

public class B extends A {
    public void fun(Map map){
        System.out.println("B fun");
    }
}

public static void main(String[] args) {
    HashMap<String,String>map = new HashMap<>();
    A a = new A();
    a.fun(map);
    //父类存在的地方可以用子类代替
    System.out.println("子类去替换父类");
    B b = new B();
    b.fun(map);
}

运行结果:

image.png

因为子类和父类的方法的输入参数是不同的。子类方法的参数Map比父类方法的参数HashMap的范围要大,所以当参数输入为HashMap类型时,只会执行父类的方法,不会执行子类的重载方法,这符合里氏替换原则。

注意子类B并没有重写父类A的方法,而是重载了父类的方法,重载比较官方的定义是:同一个类中定义多个方法,但每个方法的参数列表不同(参数类型或参数个数不同)。

这里之所以说是重载父类的方法我的理解是,B继承了A那么B也就继承了A的公共方法fun(HashMap map),而此时又在B中定义了一个同名的方法fun(Map map),参数类型不同了所以说重载了父类的方法,但是严格的来说这两个方法确实也不在一个类中,看怎么理解吧。。。。。。

要是把子类的形参定义的更严格呢?如下:

public class A {
    public void fun(Map map) {
        System.out.println("A fun");
    }
}

public class B extends A {
    public void fun(HashMap map){
        System.out.println("B fun");
    }
}

public static void main(String[] args) {
    HashMap<String,String>map = new HashMap<>();
    A a = new A();
    a.fun(map);
    //父类存在的地方可以用子类代替
    System.out.println("子类去替换父类");
    B b = new B();
    b.fun(map);
}

运行结果:

image.png

这里子类去替代父类就出错了,所以不满足里氏替换原则。造成这样的运行结果和重载时参数匹配规则有关,在上文中已经提到,此时B相当于是重载了父类的方法,而重载的匹配规则为:

  1. 符合基本类型的赋值规则,即低精度的值可以赋值给高精度或同精度的变量,而高精度的不可以赋值给低精度
  2. 赋值规则匹配出多条时,选精度最小的

在这两个例子中都是根据第二条匹配规则进行的。

第四点:当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格,这点自己下去试一下就知道了,如果子类返回值范围比父类抽象方法的返回值范围都大的话编译期会直接报错。

里氏替换原则告诉我们不要破坏继承体系

依赖倒置原则

定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象

说人话:类A最好是不要直接依赖类B,而是依赖接口I,类B去实现接口I

问题分析:A类fun方法中依赖了B,去实现了一些复杂的逻辑,如果此时fun又改成依赖C,那么我们势必要去修改类A中的代码,这个时候很可能会对已经实现好的功能造成影响。最好的解决办法就是:类A最好是不要直接依赖类B、C,而是依赖接口I,类B、C去实现接口IA就是高层模块,B、C就是低层模块

为什么要这样做: 相对于细节的多变性,抽象的东西要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及具体的操作,把展现细节的任务交给他们的实现类去完成。

因此依赖倒置原则的核心思想是面向接口编程

public interface IReader {
    String getContent();
}

public class Man {

    public void read(IReader reader){
        System.out.println("the content is "+reader.getContent());
    }
}

以上就遵循了依赖倒置原则,一个人去阅读,可能是书、可能是报纸、或者杂志,总不可能说每一种类型都去写一个方法吧,最好的做法当然是每一个具体的类都实现了IReader接口再去传参,这样类也满足单一职责原则。

接口隔离原则

定义:客户端不应该依赖它不需要的接口,一个类对另一个类的依赖应该建立在最小的接口上

说人话:客户端需要什么接口,就依赖什么接口,不需要的就不依赖

最小接口:一个接口I有五个方法method1、2、3、4、5,类A只需要method1、2、3,通过接口I去依赖类B,这个时候类B势必需要实现1-5所有的方法;此时接口I对于类A、B就不是最小接口,因为method4、5对于类A、B根本没用。

原理解释:客户端需要什么接口,就依赖什么接口,不需要的就不依赖。反过来如果客户端依赖了它们不需要的接口,那么这些客户端程序就面临不需要的接口变更而引起客户端变更的风险。这就跟高内聚、低耦合完全背道而驰变成了低内聚、高耦合了。

什么是高内聚低耦合?

耦合:描述模块之间的联系。内聚:描述模块内部元素的关联性,模块的单一性,现在回过头看要是类B多实现了method4、5这两个没用的方法是不是就降低了聚合性增强了耦合性。

两张图解释:

image.png

image.png

就问你哪个好维护?

解决方法:我们需要做的就是分析当前项目,看看还有没有类会依赖接口I,可能接口C它又只需要method1、4、5,这个时候就根据实际情况去拆分接口I,总之尽量细化接口,接口中的方法尽量少,我们要为各个类建立专用的接口,把一个臃肿的接口I拆分成几个专用的接口这个就是接口隔离原则。这个东西也不是说越少就越好,还是根据实际情况来看,不过不需要的接口最好还是不要依赖。

和单一职责的区别:单一职责是从业务层出发,侧重的是业务层的实现细节。接口隔离是从设计角度考虑,注重对接口依赖的隔离。

接口隔离原则告诉我们要精简接口,避免和接口耦合度太高,这一原则建立在依赖倒置原则之上

迪米特法则(最少知道原则)

定义:一个对象应该对其他对象保持最少的了解

说人话:一个类对自己依赖的类知道的越少越好(还是在说耦合度的问题)

问题由来:如果类在编写时产生了大量与陌生人说话的情况,那么这样会导致依赖的类具有隐匿性,如果陌生人发生改动,那么其他使用陌生人的类(调用方)很难精确的做出协调,需要改动的地方也很难预估,这样就加剧了类与类之间的耦合程度,如此就很难再完全断开对这个类的依赖。

朋友和陌生人:只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。如果类B是类A的成员变量的类型、方法参数类型、方法返回值类型,那么类B就是类A的朋友。除此之外如果类B仅作为类A中一个方法的局部变量那么类B就是类A的陌生人。

解决办法:当然是降低耦合度,对于被依赖的类来说无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,一段经典的描述只和朋友通信,不和陌生人说话

public class Person {
    public int id;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }
}
public class Persons {
    private int code;
    private String msg;
    private ArrayList<Person> list;

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public ArrayList<Person> getList() {
        return list;
    }

    public void setList(ArrayList<Person> list) {
        this.list = list;
    }
}

开发过程中经常遇到后台返回一个对象这里就叫Persons,对象中包含了code、msg,还包含一个数组用list接收,数组中又包含一个对象Person。这个时候在adapter中我们拿到了Persons对象,现在的需求是打印所以person对象的id,先来一个违反迪米特法则的写法

public class PersonAdapter {

    public void printAllPerson(Persons persons){
        List<Person> list = persons.getList();
        for (int i=0;i<list.size();i++){
            Person person = list.get(i);
            System.out.println(person.id);
        }
    }
}

在这段代码中persons才是类PersonAdapter的朋友,根据迪米特法则,只与直接的朋友发生通信而Person类并不是PersonAdapter的朋友。实际上这段代码完全可以封装在Persons中

public class Persons {
    //省略。。。
    public void printAllPerson(){
        List<Person> list = this.getList();
        for (int i=0;i<list.size();i++){
            Person person = list.get(i);
            System.out.println(person.id);
        }
    }

}

public class PersonAdapter {

    public void printAllPerson(Persons persons){
       persons.printAllPerson();
    }
}

如此就满足迪米特法则,只和朋友通信。

迪米特法则告诉我们要降低耦合

开闭原则

定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭

感觉前面五个原则或多或少都和开闭原则有些联系,单一职责在类层面和方法层面描述了应该注意职责的单一,里氏替换原则描述了不要破坏继承体系,依赖导致让我们面向接口编程,接口隔离告诉我们接口抽象类应该怎样设计,迪米特法则告诉我们降低耦合。从代码层面和设计角度都在描述一套规范,尽可能的遵守这套规范可以帮助我们写出更健壮更好看的代码。

引用一位大佬的解释:

开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。