Java设计模式之开篇

1,446 阅读14分钟

一、概述

设计模式笔者之前也学习过一遍,但是惭愧工作中只用到几种常用的模式,比如单例模式,工厂模式,装饰者模式等。自己回想起来,发现大部分都差不多忘记了,所以,笔者想把设计模式重新学习一遍,也顺便用文字记录学习的过程,与大家分享。这篇是设计模式的开篇,里面会讲几个常用的设计原则,也会用代码去体现这些设计原则。

二、设计原则

2.1 单一职责

定义:单一职责的英文全称是Single Responsibility Principle,简称SPR。

英文解释是:There should never be more than one reason for a class to change.

翻译过来就是,一个类只能有且仅仅有一个原因导致类的变更。

我们用一个例子说明下:

需求场景:设计一个手机,手机包含功能为打电话,挂电话,播放音乐功能。

public interface Imobile {
    //打电话
    public void call(String number);
    //播放音乐
    public void playMusic(Object o);
    //挂断电话
    public  void hangup();
}

上面设计了一个Imobile的接口,声明了打电话,挂断,播放音乐的方法,我们初步看,觉得这么设计没什么问题,但是如果我们考虑单一职责的话,这个设计就有问题了,其实单一职责最难划分的就是职责,我们针对这个场景可以给这个电话分为两个职责,打电话和挂电话是属于协议管理的,播放音乐其实属于附属功能管理,所以这里的职责就划分了两个:1.协议管理;2.附属功能管理。那么单一职责的定义就是:一个类只能有且仅仅有一个原因导致类的变更。而上面这个接口中划分了两个职责,而且,协议的变动,附属功能的变动,都会导致接口和类改变,所以,这个接口就是不符合单一职责的。那么如何让其满足单一职责原则呢?我们需要拆分接口,因为协议管理和附属功能管理两个彼此并不互相影响,所以我们可以直接拆分为两个接口,如下:

//协议管理接口
public interface IMobileManager {
    //打电话
    public void call(String number);
    //挂断电话
    public  void hangup();
}
//附属功能接口
public interface Ifunction {
    public void playMusic(Object o);
}

这个时候很多人可能不理解,你这么做的好处是什么呢?我感觉不到这么做的好处啊。这里做一个假设,假设这个时候新增了一部高级手机,它可以保持会话,这个时候协议管理接口需要修改了,需要新增一个保持会话的功能,这个时候实现类也要跟着改变,如果采用第一种设计,那么所有的电话都要修改。如果有一个玩具手机,它并不会通话,这个时候也要修改这个实现类,这个设计就糟糕了。如果采用了单一职责,玩具手机并不会实现协议管理的接口,只会实现附属功能接口,所以协议管理的修改并不会导致玩具手机也要修改。

2.1.1 单一职责的好处

  • 类的复杂度降低了,各个职责都有清晰明确的定义
  • 提高了可读性,知道什么接口是干什么的
  • 提高了可维护性,某个接口的修改不会导致无关类受影响。

2.1.2 单一职责的补充

其实单一职责并不只要求接口,方法也是,我们写一个方法要能清晰的定义这个方法的职责,比如修改用户信息最好就要写多个方法来实现,不要就只写一个方法。类似于这样:

public interface IUserSerivice {
    void updateUserInfo(User user);
}

这种设计不清晰,我们应该针对每一个修改都有一个方法,类似于这样:

public interface IUserSerivice {
    void updateUserName(String name,String id);
    void updateUserTelPhone(String phone,String id);
    void updateUserHomeAddr(String adrr,String id);
}

这样写虽然很啰嗦,但是职责很清晰,后续代码也好维护,直接就能知道更新了什么信息。

2.2 里氏替换

定义:里氏替换原则的英文全称:Liskov Substitution Principle ,简称LSP。

英文解释:Functions that user pointer or references to base classes must be able to use objects of derived classes without knowing it.

翻译:所有引用基类的地方都必须能透明的使用其子类对象。

其实理解这句话很简单,无非就是父类执行的方法,替换成子类也可以正确执行并且达到一样的效果。我们先写一个没有按照里氏替换原则的代码。

public class Father {
    public void doSomeThing(Map map){
        System.out.println("父类执行啦!");
    }
}
public class Son extends Father{
    public void doSomeThing(HashMap map) {
        System.out.println("子类执行了!");
    }
}
public class Client1 {
    public static void main(String[] args) {
        HashMap map=new HashMap();
        Father father=new Father();
        father.doSomeThing(map);
    }
}

public class Client2 {
    public static void main(String[] args) {
        HashMap map=new HashMap();
        Son son=new Son();
        son.doSomeThing(map);
    }
}

我们执行客户端main方法,发现结果输出为:“父类执行啦!”,我们采用子类替换父类执行doSomeThing() 方法,发现输出结果是:“子类执行了!”,这和父类执行的结果不一致,不符合里氏替换原则,这里为什么没有执行父类的方法呢?这里因为是子类重载了父类的方法,客户端调用的参数是HashMap,所以匹配到了子类的方法。那么我们如何修改就能满足里氏替换原则呢?其实很简单,两种方式。

  • 第一,直接继承,不要重写父类的非抽象方法。
  • 第二,我们重载方法的参数范围必须大于等于父类的范围。

第一个好理解,那第二个怎么理解呢?我们还是用上面那个例子改动下,代码如下:

public class Father {
    public void doSomeThing(HashMap map){
        System.out.println("父类执行啦!");
    }
}
public class Son extends Father{
    public void doSomeThing(Map map) {
        System.out.println("子类执行了!");
    }
}

这里其实就只把子类的参数类型改成了Map,父类的参数类型改成了HashMap, 这样客户端声明的参数类型是HashMap,所以调用 son.doSomeThing(map)只会执行父类的方法。

这里其实可以总结一句:里氏替换原则就是要求,不要重写父类的非抽象方法,尽量不要重载父类的方法,如果要重载,需要注意方法的前置条件(形参),如果要保持子类的个性化,可以采用新增方法的方式。

2.2.1 里氏替换原则的作用

  • 其实最主要的作用就是降级继承的复杂度,增强代码的可维护性

2.3 依赖倒置

定义:依赖倒置英文全称为:Dependence Inversion Princiole,简称DIP。

英文解释:High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions。

官方翻译:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。

依赖倒置,我们用通俗的解释就是,平常我们生活中的依赖都是依赖具体细节,比如我要用手机就是具体的某个手机,用电脑就是用具体的某台电脑,这个依赖倒置就是和我们生活是反的,故称为倒置,所以依赖倒置就是依赖抽象(接口或者抽象类)。我们同样用一个例子来说明下:

我们实现一个司机开车的例子,我们可以抽象出2个接口,一个是司机接口,一个是汽车接口。

public interface ICar {
    //开汽车方法
    public void run();
}
public interface IDriver {
    //开车
    public void driver(ICar car);
}
//汽车实现类,宝马车
public class BmwCar implements ICar {
    @Override
    public void run() {
        System.out.println("宝马车开动啦");
    }
}
//司机实现类,C1驾照司机
public class COneDriver implements  IDriver {
    @Override
    public void driver(ICar car) {
        System.out.println("我是C1驾照司机");
        car.run();
    }
}
// 客户端场景类
public class Client {
    public static void main(String[] args) {
        ICar bmw=new BmwCar();
        IDriver cOneDriver=new COneDriver();
        cOneDriver.driver(bmw);
    }
}

这里实现了C1驾照司机开宝马车的场景,这就是依赖倒置原则的写法,那如果我不采用依赖倒置会发生什么情况呢?不依赖倒置也就是说要依赖细节,以上场景就会出现C1驾照车司机只能开宝马车的情况,这显然是有问题的。

2.3.1 依赖倒置的规则

根据上面的例子以及我们的分析,我们可以总结出依赖倒置的几个规则:

  • 每个类尽量都有接口或者抽象类,或者抽象类和接口两者都具备。
  • 变量的表面类型尽量是接口或者是抽象类。
  • 任何类都不应该从具体的实现类中派生
  • 尽量不要重写基类的方法

2.4 迪米特法则

定义:迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP)

迪米特法则通俗的解释就是,一个类要对其所耦合的类了解的尽量少,不管耦合的类内部多么复杂,都只管其暴露的public方法。迪米特法则另外一种说法是,只和朋友类交流。朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。我们先看一个违法迪米特法则的例子。

场景:我们吃饭要经过客户点菜,服务员下单,厨师做菜这三个流程,我们来用代码设计这个场景。

//厨师接口
public interface ICooker {
    //根据订单做菜
    public void cooke(List<Order> orders);
}
//服务员接口
public interface IWaiter {
    //下单
    public void doOrder(List<String> dishNames);
}
// 订单实体类
public class Order {
    private List<String> dishNames;

    public Order(List<String> dishNames) {
        this.dishNames = dishNames;
    }

    public List<String> getDishNames() {
        return dishNames;
    }

    public void setDishNames(List<String> dishNames) {
        this.dishNames = dishNames;
    }
}

// 服务员实现类
public class ChineseWaiter implements IWaiter {
    private ICooker cooker;

    public ChineseWaiter(ICooker cooker) {
        this.cooker = cooker;
    }

    @Override
    public void doOrder(List<String> dishNames) {
        List<Order> cookOrders=new ArrayList<>();
        cookOrders.add(new Order(dishNames));
        cooker.cooke(cookOrders);
    }
}

//厨师实现类
public class ChineseCooker implements ICooker {
    @Override
    public void cooke(List<Order> orders) {
        for (int i = 0; i < orders.size(); i++) {
           Order order=orders.get(i);
           List<String> dishNames=order.getDishNames();
            for (int j = 0; j < dishNames.size(); j++) {
                System.out.println("我是中餐厨师,我做:"+dishNames.get(j));
            }
        }

    }
}
//场景类
public class Client {
    public static void main(String[] args) {
        IWaiter waiter=new ChineseWaiter(new ChineseCooker());
        List<String> dishNames=new ArrayList<>();
        dishNames.add("红烧鱼块");
        dishNames.add("宫保鸡丁");
        waiter.doOrder(dishNames);
    }
}

我们自己思考下,其实上述代码中,违法迪米特法则地方就是服务员的实现类,我们发现,服务员实现类ChineseWaiter在实现类中,和非朋友类产生了依赖,这个依赖就是Order类,我们再回顾下朋友类的定义:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,Order类并不满足这个定义,所以它违反了迪米特法则。那么我们如何修改满足迪米特法则呢?我们只要修改服务员实现类和场景类即可,修改后的代码如下:

public interface IWaiter {
    //下单
    public void doOrder(List<Order> orders);
}


public class ChineseWaiter implements IWaiter {
    private ICooker cooker;

    public ChineseWaiter(ICooker cooker) {
        this.cooker = cooker;
    }


    @Override
    public void doOrder(List<Order> orders) {
        cooker.cooke(orders);
    }
}
public class Client {
    public static void main(String[] args) {
        IWaiter waiter=new ChineseWaiter(new ChineseCooker());
        List<String> dishNames=new ArrayList<>();
        dishNames.add("红烧鱼块");
        dishNames.add("宫保鸡丁");
        List<Order> orders =new ArrayList<>();
        orders.add(new Order(dishNames));
        waiter.doOrder(orders);
    }
}

这里把订单的封装丢给了场景类中,服务员只依赖他的朋友类厨师类就可以了。那么这个迪米特法则有什么作用呢?其实迪米特法则最主要的作用就是降低耦合,从而使得类的复用率得以提高。但是采用迪米特法则后就会导致产生了过多的中间类和跳转类,导致系统的复杂性提高,所以我们在使用该法则的时候要权衡利弊,还是那句话,没有最完美的设计,只有最合适的设计。

2.5 接口隔离

英文解释:Clients should not be forced to depend upon interfaces that they don't use.The dependency of one class to another one should depend on the smallest possible interface.

官方翻译:客户端不应该依赖它不需要的接口。类间的依赖关系应该建立在最小的接口上。

接口隔离原则,其实可以理解为接口设计的粒度要尽量小,接口中的方法要尽量少。这里其实和单一职责很相识,但是有区别,单一职责是职责的划分要求,每个接口只要表述对应的职责就可以了。但是接口隔离一般是对应于某个模块调用,可能只用到某个接口的部分方法,可以更细分。举例说明:

还是以单一职责的例子,设计手机。之前的代码是分为了一个功能接口,一个协议管理接口。代码见单一职责部分。我们看看如果是用接口隔离还可以怎么设计。我们其实还可以对功能接口可以划分更细的粒度,比如最新的iPhone手机有faceId功能,三星手机有虹膜功能。那这个时候,我还是用一个功能接口,就会导致接口非常冗余,一个接口有faceid,虹膜,但是实际上有些手机并没有这些功能,那么我们就可以对功能接口进行拆分。拆分成这样:

public interface ISamFunction {
    //虹膜功能
    public void iris();
}

public interface IAppleFnction {
    //faceId 功能
    public void faceId();
}

然后如果有手机既有虹膜又有faceId功能,直接实现两个接口就可以了。这样就满足了接口隔离原则。

2.6 开闭原则

英文解释:Software entities like classes,modules and functions should be open for extension but closed for modifications

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

开闭原则,其实是一个总的原则,前面五种原则其实都是开闭原则的具体实现,它并没有一个具体的设计思路,只是要求我们对设计的类,方法等对扩展开放,对修改关闭。掌握了前面五种设计原则,其实也就掌握了开闭原则了,这里就不举例说明了。

三、总结

1.单一职责

  • 接口,类,方法的划分要职责单一,不要写出一个万能的接口,类和方法,要按照职责,写出明确职责的接口,类和方法,这样可读性好,可维护性高。

2.里氏替换原则

  • 父类出现的地方,子类就能出现。它要求,尽量不要重写父类的非抽象方法,尽量不要重载父类的方法。

3.依赖倒置原则

  • 类之间的依赖要依赖抽象(接口或者抽象类),不要依赖具体的实现类,这样方便后续的扩展。

4.迪米特原则

  • 不要关注类的内部实现,只关注其public方法,只和自己的朋友类交流。这个原则要求,类的耦合只能是朋友类,朋友类指的是,出现在成员变量、方法的输入输出参数中的类。

5.接口隔离

  • 要保持接口尽可能的细粒度,不要依赖不相关的接口和方法。这样才能提高接口的复用率。

6.开闭原则

  • 是一个总的原则,是对前面五种原则一个总结的抽象。要求我们设计对扩展开放,对修改关闭。这个并不是要求我们不能去修改,我们要根据实际情况,竟可能的进行少的修改,尽可能的保证修改影响的范围尽可能的小。

最后,上面六个设计原则,都是一种原则,并不是要求我们生搬硬套这几种原则去写代码,这几种思想我们要理解消化,根据项目实际情况去设计,去写代码,没有最好的设计,没有万能的设计,没有一成不变的设计,只有最合适的设计。这里,分享无印良品的著名设计师原研哉的一个设计理念:

这样就好

四、参考

《设计模式之蝉》

五、推荐阅读

带你走进java集合之ConcurrentHashMap

带你走进java集合之ArrayList

带你走进java集合之HashMap

Java锁之ReentrantLock(一)

Java锁之ReentrantLock(二)

Java锁之ReentrantReadWriteLock

JAVA NIO编程入门(一)

JAVA NIO 编程入门(二)

JAVA NIO 编程入门(三)