设计模式总结


​ 面向对象的特点是 可维护可复用可扩展灵活性好,它真正强大的地方在于:随着业务变得越来越复杂,面向对象依然能够使得程序结构良好,而面向过程却会导致程序越来越臃肿。

​ 让面向对象保持结构良好的秘诀就是 设计模式

1、设计模式的六大原则

​ 设计模式的世界丰富多彩,比如生产一个个「产品」的工厂模式,衔接两个不相关接口的适配器模式,用不同的方式做同一件事的策略模式,构建步骤稳定、根据构建过程的不同配置构建出不同对象的建造者模式等。

​ 面向对象结合设计模式,才能真正体会到程序变得可维护、可复用、可扩展、灵活性好。设计模式对于程序员而言并不陌生,每个程序员在编程时都会或多或少地接触到设计模式。无论是在大型程序的架构中,亦或是在源码的学习中,设计模式都扮演着非常重要的角色。

设计模式基于六大原则

  • 开闭原则:一个软件实体如类、模块和函数应该对修改封闭,对扩展开放。
  • 单一职责原则:一个类只做一件事,一个类应该只有一个引起它修改的原因。
  • 里氏替换原则:子类应该可以完全替换父类。也就是说在使用继承时,只扩展新功能,而不要破坏父类原有的功能。
  • 依赖倒置原则:细节应该依赖于抽象,抽象不应依赖于细节。把抽象层放在程序设计的高层,并保持稳定,程序的细节变化由低层的实现层来完成。
  • 迪米特法则:又名「最少知道原则」,一个类不应知道自己操作的类的细节,换言之,只和朋友谈话,不和朋友的朋友谈话。
  • 接口隔离原则:客户端不应依赖它不需要的接口。如果一个接口在实现时,部分方法由于冗余被客户端空实现,则应该将接口拆分,让实现类只需依赖自己需要的接口方法。

所有的设计模式都是为了程序能更好的满足这六大原则。设计模式一共有 23 种:

1.1、构建型模式 5种

  • 工厂方法模式
  • 抽象工厂模式
  • 单例模式
  • 建造型模式
  • 原型模式

1.2、结构型模式 7 种

  • 适配器模式:用于有相关性但不兼容的接口
  • 桥接模式:用于同等级的接口互相组合
  • 组合模式:用于整体与部分的结构
  • 装饰模式
  • 外观模式
  • 享元模式
  • 代理模式

1.3、行为型模式11 种

  • 责任链模式
  • 命令模式
  • 解释器模式
  • 迭代器模式
  • 中介者模式
  • 备忘录模式
  • 观察者模式
  • 状态模式
  • 策略模式
  • 模板方法模式
  • 访问者模式

2、工厂模式

​ 在平时编程中,构建对象最常用的方式是 new 一个对象。乍一看这种做法没什么不好,而实际上这也属于一种硬编码。每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。其实构建过程可以被封装起来,工厂模式便是用于封装对象的设计模式。

​ 工厂模式在接口中定义了创建对象的方法,而将具体的创建对象的过程在子类中实现,用户只需通过接口创建需要的对象即可,不用关注对象的具体创建过程。同时,不同的子类可根据需求灵活实现创建对象的不同方法。

2.1、简单工厂模式

​ 举个例子,直接 new 对象的方式相当于当我们需要一个苹果时,我们需要知道苹果的构造方法,需要一个梨子时,需要知道梨子的构造方法。更好的实现方式是有一个水果工厂,我们告诉工厂需要什么种类的水果,水果工厂将我们需要的水果制造出来给我们就可以了。这样我们就无需知道苹果、梨子是怎么种出来的,只用和水果工厂打交道即可。

水果工厂:

public class FruitFactory {
    public Fruit create(String type){
        switch (type){
            case "苹果": return new Apple();
            case "梨子": return new Pear();
            default: throw new IllegalArgumentException("暂时没有这种水果");
        }
    }
}

调用者:

public class User {
    private void eat(){
        FruitFactory fruitFactory = new FruitFactory();
        Fruit apple = fruitFactory.create("苹果");
        Fruit pear = fruitFactory.create("梨子");
        apple.eat();
        pear.eat();
    }
}

​ 事实上,将构建过程封装的好处不仅可以降低耦合,如果某个产品构造方法相当复杂,使用工厂模式可以大大减少代码重复。比如,如果生产一个苹果需要苹果种子、阳光、水分,将工厂修改如下:

public class FruitFactory {
    public Fruit create(String type) {
        switch (type) {
            case "苹果":
                AppleSeed appleSeed = new AppleSeed();
                Sunlight sunlight = new Sunlight();
                Water water = new Water();
                return new Apple(appleSeed, sunlight, water);
            case "梨子":
                return new Pear();
            default:
                throw new IllegalArgumentException("暂时没有这种水果");
        }
    }
}

调用者的代码则完全不需要变化,而且调用者不需要在每次需要苹果时,自己去构建苹果种子、阳光、水分以获得苹果。苹果的生产过程再复杂,也只是工厂的事。这就是封装的好处,假如某天科学家发明了让苹果更香甜的肥料,要加入苹果的生产过程中的话,也只需要在工厂中修改,调用者完全不用关心。

不知不觉中,我们就写出了简单工厂模式的代码。工厂模式一共有三种:

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

注:在 GoF 所著的《设计模式》一书中,简单工厂模式被划分为工厂方法模式的一种特例,没有单独被列出来。

总而言之,简单工厂模式就是让一个工厂类承担构建所有对象的职责。调用者需要什么产品,让工厂生产出来即可。它的弊端也显而易见:

  • 一是如果需要生产的产品过多,此模式会导致工厂类过于庞大,承担过多的职责,变成超级类。当苹果生产过程需要修改时,要来修改此工厂。梨子生产过程需要修改时,也要来修改此工厂。也就是说这个类不止一个引起修改的原因。违背了单一职责原则。
  • 二是当要生产新的产品时,必须在工厂类中添加新的分支。而开闭原则告诉我们:类应该对修改封闭。我们希望在添加新功能时,只需增加新的类,而不是修改既有的类,所以这就违背了开闭原则。

2.2、工厂方法模式

​ 为了解决简单工厂模式的这两个弊端,工厂方法模式应运而生,它规定每个产品都有一个专属工厂。比如苹果有专属的苹果工厂,梨子有专属的梨子工厂,Java 代码如下:

苹果工厂:

public class AppleFactory {
    public Fruit create(){
        return new Apple();
    }
}

梨子工厂:

public class PearFactory {
    public Fruit create(){
        return new Pear();
    }
}

调用者:

public class User {
    private void eat(){
        AppleFactory appleFactory = new AppleFactory();
        Fruit apple = appleFactory.create();
        PearFactory pearFactory = new PearFactory();
        Fruit pear = pearFactory.create();
        apple.eat();
        pear.eat();
    }
}

​ 有读者可能会开喷了,这样和直接 new 出苹果和梨子有什么区别?上文说工厂是为了减少类与类之间的耦合,让调用者尽可能少的和其他类打交道。用简单工厂模式,我们只需要知道 FruitFactory,无需知道 Apple 、Pear 类,很容易看出耦合度降低了。但用工厂方法模式,调用者虽然不需要和 Apple 、Pear 类打交道了,但却需要和 AppleFactory、PearFactory 类打交道。有几种水果就需要知道几个工厂类,耦合度完全没有下降啊,甚至还增加了代码量!

​ 工厂模式的第二个优点在工厂方法模式中还是存在的。当构建过程相当复杂时,工厂将构建过程封装起来,调用者可以很方便的直接使用,同样以苹果生产为例:

public class AppleFactory {
    public Fruit create(){
        AppleSeed appleSeed = new AppleSeed();
        Sunlight sunlight = new Sunlight();
        Water water = new Water();
        return new Apple(appleSeed, sunlight, water);
    }
}

调用者无需知道苹果的生产细节,当生产过程需要修改时也无需更改调用端。同时,工厂方法模式解决了简单工厂模式的两个弊端。

  • 当生产的产品种类越来越多时,工厂类不会变成超级类。工厂类会越来越多,保持灵活。不会越来越大、变得臃肿。如果苹果的生产过程需要修改时,只需修改苹果工厂。梨子的生产过程需要修改时,只需修改梨子工厂。符合单一职责原则。
  • 当需要生产新的产品时,无需更改既有的工厂,只需要添加新的工厂即可。保持了面向对象的可扩展性,符合开闭原则。

2.3、抽象工厂模式

​ 抽象工厂模式(Abstract Factory Pattern)工厂模式在接口中定义了创建对象的方法,而将具体的创建对象的过程在子类中实现,用户只需通过接口创建需要的对象即可,不用关注对象的具体创建过程。同时,不同的子类可根据需求灵活实现创建对象的不同方法。

工厂方法模式可以进一步优化,提取出工厂接口:

public interface IFactory {
    Fruit create();
}

然后苹果工厂和梨子工厂都实现此接口:

public class AppleFactory implements IFactory {
    @Override
    public Fruit create(){
        return new Apple();
    }
}
public class PearFactory implements IFactory {
    @Override
    public Fruit create(){
        return new Pear();
    }
}

此时,调用者可以将 AppleFactory 和 PearFactory 统一作为 IFactory 对象使用,调用者 Java 代码如下:

public class User {
    private void eat(){
        IFactory appleFactory = new AppleFactory();
        Fruit apple = appleFactory.create();
        IFactory pearFactory = new PearFactory();
        Fruit pear = pearFactory.create();
        apple.eat();
        pear.eat();
    }
}

可以看到,我们在创建时指定了具体的工厂类后,在使用时就无需再关心是哪个工厂类,只需要将此工厂当作抽象的 IFactory 接口使用即可。这种经过抽象的工厂方法模式被称作抽象工厂模式。

由于客户端只和 IFactory 打交道了,调用的是接口中的方法,使用时根本不需要知道是在哪个具体工厂中实现的这些方法,这就使得替换工厂变得非常容易。

例如:

public class User {
    private void eat(){
        IFactory factory = new AppleFactory();
        Fruit fruit = factory.create();
        fruit.eat();
    }
}

如果需要替换为吃梨子,只需要更改一行代码即可:

public class User {
    private void eat(){
        IFactory factory = new PearFactory();
        Fruit fruit = factory.create();
        fruit.eat();
    }
}

​ IFactory 中只有一个抽象方法时,或许还看不出抽象工厂模式的威力。实际上抽象工厂模式主要用于替换一系列方法。例如将程序中的 SQL Server 数据库整个替换为 Access 数据库,使用抽象方法模式的话,只需在 IFactory 接口中定义好增删改查四个方法,让 SQLFactory 和 AccessFactory 实现此接口,调用时直接使用 IFactory 中的抽象方法即可,调用者无需知道使用的什么数据库,我们就可以非常方便的整个替换程序的数据库,并且让客户端毫不知情。

​ 抽象工厂模式很好的发挥了开闭原则、依赖倒置原则,但缺点是抽象工厂模式太重了,如果 IFactory 接口需要新增功能,则会影响到所有的具体工厂类。使用抽象工厂模式,替换具体工厂时只需更改一行代码,但要新增抽象方法则需要修改所有的具体工厂类。所以抽象工厂模式适用于增加同类工厂这样的横向扩展需求,不适合新增功能这样的纵向扩展。

3、单例模式

​ 单例模式是保证系统实例唯一性的重要手段。单例模式首先通过将类的实例化方法私有化来防止程序通过其他方式创建该类的实例,然后通过提供一个全局唯一获取该类实例的方法帮助用户获取类的实例,用户只需也只能通过调用该方法获取类的实例。

​ 单例模式的设计保证了一个类在整个系统中同一时刻只有一个实例存在,主要被用于一个全局类的对象在多个地方被使用并且对象的状态是全局变化的场景下。同时,单例模式为系统资源的优化提供了很好的思路,频繁创建和销毁对象都会增加系统的资源消耗,而单例模式保障了整个系统只有一个对象能被使用,很好地节约了资源。

​ 单例模式的实现很简单,每次在获取对象前都先判断系统是否已经有这个单例对象,有则返回,没有则创建。需要注意的是,单例模型的类构造函数是私有的,只能由自身创建和销毁对象,不允许除了该类的其他程序使用new关键字创建对象及破坏单例模式。

单例模式非常常见,某个对象全局只需要一个实例时,就可以使用单例模式。它的优点也显而易见:

  • 它能够避免对象重复创建,节约空间并提升效率
  • 避免由于操作不同实例导致的逻辑错误

单例模式有两种实现方式:饿汉式和懒汉式。

单例模式的常见写法有懒汉模式(线程安全)、饿汉模式、静态内部类、双重校验锁,

3.1.饿汉式

​ 饿汉模式指在类中直接定义全局的静态对象的实例并初始化,然后提供一个方法获取该实例对象。懒汉模式和饿汉模式的最大不同在于,懒汉模式在类中定义了单例但是并未实例化,实例化的过程是在获取单例对象的方法中实现的,也就是说,在第一次调用懒汉模式时,该对象一定为空,然后去实例化对象并赋值,这样下次就能直接获取对象了;而饿汉模式是在定义单例对象的同时将其实例化的,直接使用便可。也就是说,在饿汉模式下,在Class Loader完成后该类的实例便已经存在于JVM中了,

  • 饿汉式:变量在声明时便初始化。
public class Singleton {
  
    private static Singleton instance = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return instance;
    }
}

​ 可以看到,我们将构造方法定义为 private,这就保证了其他类无法实例化此类,必须通过 getInstance 方法才能获取到唯一的 instance 实例,非常直观。但饿汉式有一个弊端,那就是即使这个单例不需要使用,它也会在类加载之后立即创建出来,占用一块内存,并增加类初始化时间。就好比一个电工在修理灯泡时,先把所有工具拿出来,不管是不是所有的工具都用得上。就像一个饥不择食的饿汉,所以称之为饿汉式。

3.2.懒汉式

​ 懒汉模式很简单:定义一个私有的静态对象instance,之所以定义instance为静态,是因为静态属性或方法是属于类的,能够很好地保障单例对象的唯一性;然后定义一个加锁的静态方法获取该对象,如果该对象为null,则定义一个对象实例并将其赋值给instance,这样下次再获取该对象时便能够直接获取了。懒汉模式在获取对象实例时做了加锁操作,因此是线程安全的

  • 懒汉式:先声明一个空变量,需要用时才初始化。例如:
public class Singleton {
  
    private static Singleton instance = null;
  
    private Singleton() {
    }
    
    public static Singleton getInstance(){
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

​ 我们先声明了一个初始值为 null 的 instance 变量,当需要使用时判断此变量是否已被初始化,没有初始化的话才 new 一个实例出来。就好比电工在修理灯泡时,开始比较偷懒,什么工具都不拿,当发现需要使用螺丝刀时,才把螺丝刀拿出来。当需要用钳子时,再把钳子拿出来。就像一个不到万不得已不会行动的懒汉,所以称之为懒汉式。

​ 懒汉式解决了饿汉式的弊端,好处是按需加载,避免了内存浪费,减少了类初始化时间。

​ 上述代码的懒汉式单例乍一看没什么问题,但其实它不是线程安全的。如果有多个线程同一时间调用 getInstance 方法,instance 变量可能会被实例化多次。为了保证线程安全,我们需要给判空过程加上锁:

public class Singleton {

    private static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }
}

这样就能保证多个线程调用 getInstance 时,一次最多只有一个线程能够执行判空并 new 出实例的操作,所以 instance 只会实例化一次。但这样的写法仍然有问题,当多个线程调用 getInstance 时,每次都需要执行 synchronized 同步化方法,这样会严重影响程序的执行效率。所以更好的做法是在同步化之前,再加上一层检查:

public class Singleton {
    
    private static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

这样增加一种检查方式后,如果 instance 已经被实例化,则不会执行同步化操作,大大提升了程序效率。上面这种写法也就是我们平时较常用的双检锁方式实现的线程安全的单例模式

除了双检锁方式外,还有一种比较常见的静态内部类方式保证懒汉式单例的线程安全:

public class Singleton {
    
    private static class SingletonHolder {
        public static Singleton instance = new Singleton();
    }

    private Singleton() {
    }

    public static Singleton getInstance() {
        return SingletonHolder.instance;
    }
}

虽然我们经常使用这种静态内部类的懒加载方式,但其中的原理不一定每个人都清楚。接下来我们便来分析其原理,搞清楚两个问题:

  • 静态内部类方式是怎么实现懒加载的
  • 静态内部类方式是怎么保证线程安全的

Java 类的加载过程包括:加载、验证、准备、解析、初始化。初始化阶段即执行类的 clinit 方法(clinit = class + initialize),包括为类的静态变量赋初始值和执行静态代码块中的内容。但不会立即加载内部类,内部类会在使用时才加载。所以当此 Singleton 类加载时,SingletonHolder 并不会被立即加载,所以不会像饿汉式那样占用内存。

​ 另外,Java 虚拟机规定,当访问一个类的静态字段时,如果该类尚未初始化,则立即初始化此类。当调用Singleton 的 getInstance 方法时,由于其使用了 SingletonHolder 的静态变量 instance,所以这时才会去初始化 SingletonHolder,在 SingletonHolder 中 new 出 Singleton 对象。这就实现了懒加载。

​ 第二个问题的答案是 Java 虚拟机的设计是非常稳定的,早已经考虑到了多线程并发执行的情况。虚拟机在加载类的 clinit 方法时,会保证 clinit 在多线程中被正确的加锁、同步。即使有多个线程同时去初始化一个类,一次也只有一个线程可以执行 clinit 方法,其他线程都需要阻塞等待,从而保证了线程安全。

​ 懒加载方式在平时非常常见,比如打开我们常用的美团、饿了么、支付宝 app,应用首页会立刻刷新出来,但其他标签页在我们点击到时才会刷新。这样就减少了流量消耗,并缩短了程序启动时间。再比如游戏中的某些模块,当我们点击到时才会去下载资源,而不是事先将所有资源都先下载下来,这也属于懒加载方式,避免了内存浪费。

​ 但懒汉式的缺点就是将程序加载时间从启动时延后到了运行时,虽然启动时间缩短了,但我们浏览页面时就会看到数据的 loading 过程。如果用饿汉式将页面提前加载好,我们浏览时就会特别的顺畅,也不失为一个好的用户体验。比如我们常用的 QQ、微信 app,作为即时通讯的工具软件,它们会在启动时立即刷新所有的数据,保证用户看到最新最全的内容。著名的软件大师 Martin 在《代码整洁之道》一书中也说到:不提倡使用懒加载方式,因为程序应该将构建与使用分离,达到解耦。饿汉式在声明时直接初始化变量的方式也更直观易懂。所以在使用饿汉式还是懒汉式时,需要权衡利弊。

​ 一般的建议是:对于构建不复杂,加载完成后会立即使用的单例对象,推荐使用饿汉式。对于构建过程耗时较长,并不是所有使用此类都会用到的单例对象,推荐使用懒汉式。

4、建造型模式

​ 建造者模式(Builder Pattern)使用多个简单的对象创建一个复杂的对象,用于将一个复杂的构建与其表示分离,使得同样的构建过程可以创建不同的表示,然后通过一个Builder类(该Builder类是独立于其他对象的)创建最终的对象。

​ 建造者模式主要用于解决软件系统中复杂对象的创建问题,比如有些复杂对象的创建需要通过各部分的子对象用一定的算法构成,在需求变化时这些复杂对象将面临很大的改变,这十分不利于系统的稳定。但是,使用建造者模式能将它们各部分的算法包装起来,在需求变化后只需调整各个算法的组合方式和顺序,能极大提高系统的稳定性。建造者模式常被用于一些基本部件不会变而其组合经常变化的应用场景下。

​ 注意,建造者模式与工厂模式的最大区别是,建造者模式更关注产品的组合方式和装配顺序,而工厂模式关注产品的生产本身。

​ 建造者模式在设计时有以下几种角色。

  • Builder:创建一个复杂产品对象的抽象接口。
  • ConcreteBuilder:Builder接口的实现类,用于定义复杂产品各个部件的装配流程。
  • Director:构造一个使用Builder接口的对象。
  • Product:表示被构造的复杂对象。ConcreteBuilder定义了该复杂对象的装配流程,而Product定义了该复杂对象的结构和内部表示。

​ 建造型模式用于创建过程稳定,但配置多变的对象。在《设计模式》一书中的定义是:将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示。

​ 经典的「建造者-指挥者」模式现在已经不太常用了,现在建造者模式主要用来通过链式调用生成不同的配置。比如我们要制作一杯珍珠奶茶。它的制作过程是稳定的,除了必须要知道奶茶的种类和规格外,是否加珍珠和是否加冰是可选的。使用建造者模式表示如下:

public class MilkTea {
    private final String type;
    private final String size;
    private final boolean pearl;
    private final boolean ice;

    private MilkTea(Builder builder) {
        this.type = builder.type;
        this.size = builder.size;
        this.pearl = builder.pearl;
        this.ice = builder.ice;
    }

    public String getType() {
        return type;
    }

    public String getSize() {
        return size;
    }

    public boolean isPearl() {
        return pearl;
    }
    public boolean isIce() {
        return ice;
    }

    public static class Builder {

        private final String type;
        private String size = "中杯";
        private boolean pearl = true;
        private boolean ice = false;

        public Builder(String type) {
            this.type = type;
        }

        public Builder size(String size) {
            this.size = size;
            return this;
        }

        public Builder pearl(boolean pearl) {
            this.pearl = pearl;
            return this;
        }

        public Builder ice(boolean cold) {
            this.ice = cold;
            return this;
        }

        public MilkTea build() {
            return new MilkTea(this);
        }
    }
}

​ 可以看到,我们将 MilkTea 的构造方法设置为私有的,所以外部不能通过 new 构建出 MilkTea 实例,只能通过 Builder 构建。对于必须配置的属性,通过 Builder 的构造方法传入,可选的属性通过 Builder 的链式调用方法传入,如果不配置,将使用默认配置,也就是中杯、加珍珠、不加冰。根据不同的配置可以制作出不同的奶茶:

public class User {
    private void buyMilkTea() {
        MilkTea milkTea = new MilkTea.Builder("原味").build();
        show(milkTea);

        MilkTea chocolate =new MilkTea.Builder("巧克力味")
                .ice(false)
                .build();
        show(chocolate);
        
        MilkTea strawberry = new MilkTea.Builder("草莓味")
                .size("大杯")
                .pearl(false)
                .ice(true)
                .build();
        show(strawberry);
    }

    private void show(MilkTea milkTea) {
        String pearl;
        if (milkTea.isPearl())
            pearl = "加珍珠";
        else
            pearl = "不加珍珠";
        String ice;
        if (milkTea.isIce()) {
            ice = "加冰";
        } else {
            ice = "不加冰";
        }
        System.out.println("一份" + milkTea.getSize() + "、"
                + pearl + "、"
                + ice + "的"
                + milkTea.getType() + "奶茶");
    }
}

运行程序,输出如下:

一份中杯、加珍珠、不加冰的原味奶茶
一份中杯、加珍珠、不加冰的巧克力味奶茶
一份大杯、不加珍珠、加冰的草莓味奶茶

使用建造者模式的好处是不用担心忘了指定某个配置,保证了构建过程是稳定的。在 OkHttp、Retrofit 等著名框架的源码中都使用到了建造者模式。

5、原型模式

​ 原型模式指通过调用原型实例的Clone方法或其他手段来创建对象。原型模式属于创建型设计模式,它以当前对象为原型(蓝本)来创建另一个新的对象,而无须知道创建的细节。原型模式在Java中通常使用Clone技术实现,在JavaScript中通常使用对象的原型属性实现。原型模式的Java实现很简单,只需原型类实现Cloneable接口并覆写clone方法即可。Java中的复制分为浅复制和深复制。

  • 浅复制:Java中的浅复制是通过实现Cloneable接口并覆写其Clone方法实现的。在浅复制的过程中,对象的基本数据类型的变量值会重新被复制和创建,而引用数据类型仍指向原对象的引用。也就是说,浅复制不复制对象的引用类型数据。
  • 深复制:在深复制的过程中,不论是基本数据类型还是引用数据类型,都会被重新复制和创建。简而言之,深复制彻底复制了对象的数据(包括基本数据类型和引用数据类型),浅复制的复制却并不彻底(忽略了引用数据类型)。

原型模式:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。

Java 中,Object 的 clone() 方法就属于原型模式。

举个例子,比如有一天,周杰伦到奶茶店点了一份不加冰的原味奶茶,你说我是周杰伦的忠实粉,我也要一份跟周杰伦一样的。用程序表示如下:

奶茶类:

public class MilkTea {
    public String type;
    public boolean ice;
}

下单:

private void order(){
    MilkTea milkTeaOfJay = new MilkTea();
    milkTeaOfJay.type = "原味";
    milkTeaOfJay.ice = false;
    
    MilkTea yourMilkTea = milkTeaOfJay;
}

好像没什么问题,将周杰伦的奶茶直接赋值到你的奶茶上就行了,看起来我们并不需要 clone 方法。但是这样真的是复制了一份奶茶吗?

当然不是,Java 的赋值只是传递地址。这样赋值之后,yourMilkTea 仍然指向的周杰伦的奶茶,并不会多一份一样的奶茶。

那么我们要怎么做才能点一份一样的奶茶呢?将程序修改如下就可以了:

private void order(){
    MilkTea milkTeaOfJay = new MilkTea();
    milkTeaOfJay.type = "原味";
    milkTeaOfJay.ice = false;
    
    MilkTea yourMilkTea = new MilkTea();
    yourMilkTea.type = "原味";
    yourMilkTea.ice = false;
}

​ 只有这样,yourMilkTea 才是 new 出来的一份全新的奶茶。我们设想一下,如果有一千个粉丝都需要点和周杰伦一样的奶茶的话,按照现在的写法就需要 new 一千次,并为每一个新的对象赋值一千次,造成大量的重复。

更糟糕的是,如果周杰伦临时决定加个冰,那么粉丝们的奶茶配置也要跟着修改:

private void order(){
    MilkTea milkTeaOfJay = new MilkTea();
    milkTeaOfJay.type = "原味";
    milkTeaOfJay.ice = true;
    
    MilkTea yourMilkTea = new MilkTea();
    yourMilkTea.type = "原味";
    yourMilkTea.ice = true;
    
    // 将一千个粉丝的 ice 都修改为 true
    ...
}

大批量的修改无疑是非常丑陋的做法,这就是我们需要 clone 方法的理由!

运用原型模式,在 MilkTea 中新增 clone 方法:

public class MilkTea{
    public String type;
    public boolean ice;

    public MilkTea clone(){
        MilkTea milkTea = new MilkTea();
        milkTea.type = this.type;
        milkTea.ice = this.ice;
        return milkTea;
    }
}

下单:

private void order(){
    MilkTea milkTeaOfJay = new MilkTea();
    milkTeaOfJay.type = "原味";
    milkTeaOfJay.ice = false;
    
    MilkTea yourMilkTea = milkTeaOfJay.clone();
    
    // 一千位粉丝都调用 milkTeaOfJay 的 clone 方法即可
    ...
}

这就是原型模式,Java 中有一个语法糖,让我们并不需要手写 clone 方法。这个语法糖就是 Cloneable 接口,我们只要让需要拷贝的类实现此接口即可。

public class MilkTea implements Cloneable{
    public String type;
    public boolean ice;

    @NonNull
    @Override
    protected MilkTea clone() throws CloneNotSupportedException {
        return (MilkTea) super.clone();
    }
}

值得注意的是,Java 自带的 clone 方法是浅拷贝的。也就是说调用此对象的 clone 方法,只有基本类型的参数会被拷贝一份,非基本类型的对象不会被拷贝一份,而是继续使用传递引用的方式。如果需要实现深拷贝,必须要自己手动修改 clone 方法才行。

6、适配器模式

​ 我们常常在开发中遇到各个系统之间的对接问题,然而每个系统的数据模型或多或少均存在差别,因此可能存在修改现有对象模型的情况,这将影响到系统的稳定。若想在不修改原有代码结构(类的结构)的情况下完成友好对接,就需要用到适配器模式。适配器模式(Adapter Pattern)通过定义一个适配器类作为两个不兼容的接口之间的桥梁,将一个类的接口转换成用户期望的另一个接口,使得两个或多个原本不兼容的接口可以基于适配器类一起工作。适配器模式主要通过适配器类实现各个接口之间的兼容,该类通过依赖注入或者继承实现各个接口的功能并对外统一提供服务,可形象地使用图9-4来表示适配器模式。

​ 在适配器模式的实现中有三种角色:Source、Targetable、Adapter。Source是待适配的类,Targetable是目标接口,Adapter是适配器。我们在具体应用中通过Adapter将Source的功能扩展到Targetable,以实现接口的兼容。

​ 适配器的实现主要分为三类:类适配器模式、对象适配器模式、接口适配器模式。

  • 类适配器模式

​ 在需要不改变(或者由于项目原因无法改变)原有接口或类结构的情况下扩展类的功能以适配不同的接口时,可以使用类的适配器模式。适配器模式通过创建一个继承原有类(需要扩展的类)并实现新接口的适配器类来实现。

  • 对象适配器模式

    对象适配器模式的思路和类适配器模式基本相同,只是修改了Adapter类。Adapter不再继承Source类,而是持有Source类的实例,以解决兼容性问题。

  • 接口适配器模式

    在不希望实现一个接口中所有的方法时,可以创建一个抽象类AbstractAdapter实现所有方法,在使用时继承该抽象类按需实现方法即可。

​ 说到适配器,我们最熟悉的莫过于电源适配器了,也就是手机的充电头。它就是适配器模式的一个应用。

​ 试想一下,你有一条连接电脑和手机的 USB 数据线,连接电脑的一端从电脑接口处接收 5V 的电压,连接手机的一端向手机输出 5V 的电压,并且他们工作良好。

中国的家用电压都是 220V,所以 USB 数据线不能直接拿来给手机充电,这时候我们有两种方案:

  • 单独制作手机充电器,接收 220V 家用电压,输出 5V 电压。
  • 添加一个适配器,将 220V 家庭电压转化为类似电脑接口的 5V 电压,再连接数据线给手机充电。

如果你使用过早期的手机,就会知道以前的手机厂商采用的就是第一种方案:早期的手机充电器都是单独制作的,充电头和充电线是连在一起的。现在的手机都采用了电源适配器加数据线的方案。这是生活中应用适配器模式的一个进步。

适配器模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。

适配的意思是适应、匹配。通俗地讲,适配器模式适用于 有相关性但不兼容的结构,源接口通过一个中间件转换后才可以适用于目标接口,这个转换过程就是适配,这个中间件就称之为适配器。

​ 家用电源和 USB 数据线有相关性:家用电源输出电压,USB 数据线输入电压。但两个接口无法兼容,因为一个输出 220V,一个输入 5V,通过适配器将输出 220V 转换成输出 5V 之后才可以一起工作。

让我们用程序来模拟一下这个过程。

首先,家庭电源提供 220V 的电压:

Java 实现

class HomeBattery {
    int supply() {
        // 家用电源提供一个 220V 的输出电压
        return 220;
    }
}

USB 数据线只接收 5V 的充电电压:

class USBLine {
    void charge(int volt) {
        // 如果电压不是 5V,抛出异常
        if (volt != 5) throw new IllegalArgumentException("只能接收 5V 电压");
        // 如果电压是 5V,正常充电
        System.out.println("正常充电");
    }
}

先来看看适配之前,用户如果直接用家庭电源给手机充电:

public class User {
    @Test
    public void chargeForPhone() {
        HomeBattery homeBattery = new HomeBattery();
        int homeVolt = homeBattery.supply();
        System.out.println("家庭电源提供的电压是 " + homeVolt + "V");

        USBLine usbLine = new USBLine();
        usbLine.charge(homeVolt);
    }
}

运行程序,输出如下:

家庭电源提供的电压是 220V

java.lang.IllegalArgumentException: 只能接收 5V 电压

这时,我们加入电源适配器:

class Adapter {
    int convert(int homeVolt) {
        // 适配过程:使用电阻、电容等器件将其降低为输出 5V
        int chargeVolt = homeVolt - 215;
        return chargeVolt;
    }
}

然后,用户再使用适配器将家庭电源提供的电压转换为充电电压:

public class User {
    @Test
    public void chargeForPhone() {
        HomeBattery homeBattery = new HomeBattery();
        int homeVolt = homeBattery.supply();
        System.out.println("家庭电源提供的电压是 " + homeVolt + "V");

        Adapter adapter = new Adapter();
        int chargeVolt = adapter.convert(homeVolt);
        System.out.println("使用适配器将家庭电压转换成了 " + chargeVolt + "V");

        USBLine usbLine = new USBLine();
        usbLine.charge(chargeVolt);
    }
}

运行程序,输出如下:

家庭电源提供的电压是 220V
使用适配器将家庭电压转换成了 5V
正常充电

这就是适配器模式。在我们日常的开发中经常会使用到各种各样的 Adapter,都属于适配器模式的应用。

但适配器模式并不推荐多用。因为未雨绸缪好过亡羊补牢,如果事先能预防接口不同的问题,不匹配问题就不会发生,只有遇到源接口无法改变时,才应该考虑使用适配器。比如现代的电源插口中很多已经增加了专门的充电接口,让我们不需要再使用适配器转换接口,这又是社会的一个进步。

7、桥接模式

​ 桥接模式(Bridge Pattern)通过将抽象及其实现解耦,使二者可以根据需求独立变化。这种类型的设计模式属于结构型模式,通过定义一个抽象和实现之间的桥接者来达到解耦的目的。

​ 桥接模型主要用于解决在需求多变的情况下使用继承造成类爆炸的问题,扩展起来不够灵活。可以通过桥接模式将抽象部分与实现部分分离,使其能够独立变化而相互之间的功能不受影响。具体做法是通过定义一个桥接接口,使得实体类的功能独立于接口实现类,降低它们之间的耦合度。

​ 我们常用的JDBC和DriverManager就使用了桥接模式,JDBC在连接数据库时,在各个数据库之间进行切换而不需要修改代码,因为JDBC提供了统一的接口,每个数据库都提供了各自的实现,通过一个叫作数据库驱动的程序来桥接即可。

考虑这样一个需求:绘制矩形、圆形、三角形这三种图案。按照面向对象的理念,我们至少需要三个具体类,对应三种不同的图形。

抽象接口 IShape:

public interface IShape {
    void draw();
}

三个具体形状类:

class Rectangle implements IShape {
    @Override
    public void draw() {
        System.out.println("绘制矩形");
    }
}
class Round implements IShape {
    @Override
    public void draw() {
        System.out.println("绘制圆形");
    }
}
class Triangle implements IShape {
    @Override
    public void draw() {
        System.out.println("绘制三角形");
    }
}

接下来我们有了新的需求,每种形状都需要有四种不同的颜色:红、蓝、黄、绿。

这时我们很容易想到两种设计方案:

  • 为了复用形状类,将每种形状定义为父类,每种不同颜色的图形继承自其形状父类。此时一共有 12 个类。
  • 为了复用颜色类,将每种颜色定义为父类,每种不同颜色的图形继承自其颜色父类。此时一共有 12 个类。

乍一看没什么问题,我们使用了面向对象的继承特性,复用了父类的代码并扩展了新的功能。

但仔细想一想,如果以后要增加一种颜色,比如黑色,那么我们就需要增加三个类;如果再要增加一种形状,我们又需要增加五个类,对应 5 种颜色。

更不用说遇到增加 20 个形状,20 种颜色的需求,不同的排列组合将会使工作量变得无比的庞大。看来我们不得不重新思考设计方案。

形状和颜色,都是图形的两个属性。他们两者的关系是平等的,所以不属于继承关系。更好的的实现方式是:将形状和颜色分离,根据需要对形状和颜色进行组合,这就是桥接模式的思想。

桥接模式:将抽象部分与它的实现部分分离,使它们都可以独立地变化。它是一种对象结构型模式,又称为柄体模式或接口模式。

官方定义非常精准、简练,但却有点不易理解。通俗地说,如果一个对象有两种或者多种分类方式,并且两种分类方式都容易变化,比如本例中的形状和颜色。这时使用继承很容易造成子类越来越多,所以更好的做法是把这种分类方式分离出来,让他们独立变化,使用时将不同的分类进行组合即可。

说到这里,不得不提一个设计原则:合成 / 聚合复用原则。虽然它没有被划分到六大设计原则中,但它在面向对象的设计中也非常的重要。

合成 / 聚合复用原则:优先使用合成 / 聚合,而不是类继承。

继承虽然是面向对象的三大特性之一,但继承会导致子类与父类有非常紧密的依赖关系,它会限制子类的灵活性和子类的复用性。而使用合成 / 聚合,也就是使用接口实现的方式,就不存在依赖问题,一个类可以实现多个接口,可以很方便地拓展功能。

让我们一起来看一下本例使用桥接模式的程序实现:

新建接口类 IColor,仅包含一个获取颜色的方法:

public interface IColor {
    String getColor();
}

每种颜色都实现此接口:

public class Red implements IColor {
    @Override
    public String getColor() {
        return "红";
    }
}
public class Blue implements IColor {
    @Override
    public String getColor() {
        return "蓝";
    }
}
public class Yellow implements IColor {
    @Override
    public String getColor() {
        return "黄";
    }
}
public class Green implements IColor {
    @Override
    public String getColor() {
        return "绿";
    }
}

在每个形状类中,桥接 IColor 接口:

class Rectangle implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("绘制" + color.getColor() + "矩形");
    }
}
class Round implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("绘制" + color.getColor() + "圆形");
    }
}
class Triangle implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("绘制" + color.getColor() + "三角形");
    }
}

测试函数:

@Test
public void drawTest() {
    Rectangle rectangle = new Rectangle();
    rectangle.setColor(new Red());
    rectangle.draw();
    
    Round round = new Round();
    round.setColor(new Blue());
    round.draw();
    
    Triangle triangle = new Triangle();
    triangle.setColor(new Yellow());
    triangle.draw();
}

运行程序,输出如下:

绘制红矩形
绘制蓝圆形
绘制黄三角形

这时我们再来回顾一下官方定义:将抽象部分与它的实现部分分离,使它们都可以独立地变化。抽象部分指的是父类,对应本例中的形状类,实现部分指的是不同子类的区别之处。将子类的区别方式 —— 也就是本例中的颜色 —— 分离成接口,通过组合的方式桥接颜色和形状,这就是桥接模式,它主要用于 两个或多个同等级的接口

8、组合模式

​ 组合模式(Composite Pattern)又叫作部分整体模式,主要用于实现部分和整体操作的一致性。组合模式常根据树形结构来表示部分及整体之间的关系,使得用户对单个对象和组合对象的操作具有一致性。

​ 组合模式通过特定的数据结构简化了部分和整体之间的关系,使得客户端可以像处理单个元素一样来处理整体的数据集,而无须关心单个元素和整体数据集之间的内部复杂结构。组合模式以类似树形结构的方式实现整体和部分之间关系的组合。

​ 上文说到,桥接模式用于将同等级的接口互相组合,那么组合模式和桥接模式有什么共同点吗?

​ 事实上组合模式和桥接模式的组合完全不一样。组合模式用于 整体与部分的结构,当整体与部分有相似的结构,在操作时可以被一致对待时,就可以使用组合模式。例如:

  • 文件夹和子文件夹的关系:文件夹中可以存放文件,也可以新建文件夹,子文件夹也一样。
  • 总公司子公司的关系:总公司可以设立部门,也可以设立分公司,子公司也一样。
  • 树枝和分树枝的关系:树枝可以长出叶子,也可以长出树枝,分树枝也一样。

在这些关系中,虽然整体包含了部分,但无论整体或部分,都具有一致的行为。

组合模式:又叫部分整体模式,是用于把一组相似的对象当作一个单一的对象。组合模式依据树形结构来组合对象,用来表示部分以及整体层次。这种类型的设计模式属于结构型模式,它创建了对象组的树形结构。

考虑这样一个实际应用:设计一个公司的人员分布结构,结构如下图所示。

img

我们注意到人员结构中有两种结构,一是管理者,如老板,PM,CFO,CTO,二是职员。其中有的管理者不仅仅要管理职员,还会管理其他的管理者。这就是一个典型的整体与部分的结构。

3.1.不使用组合模式的设计方案

要描述这样的结构,我们很容易想到以下设计方案:

新建管理者类:

public class Manager {
    // 职位
    private String position;
    // 工作内容
    private String job;
    // 管理的管理者
    private List<Manager> managers = new ArrayList<>();
    // 管理的职员
    private List<Employee> employees = new ArrayList<>();

    public Manager(String position, String job) {
        this.position = position;
        this.job = job;
    }
    
    public void addManager(Manager manager) {
        managers.add(manager);
    }

    public void removeManager(Manager manager) {
        managers.remove(manager);
    }

    public void addEmployee(Employee employee) {
        employees.add(employee);
    }

    public void removeEmployee(Employee employee) {
        employees.remove(employee);
    }

    // 做自己的本职工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }
    
    // 检查下属
    public void check() {
        work();
        for (Employee employee : employees) {
            employee.work();
        }
        for (Manager manager : managers) {
            manager.check();
        }
    }
}

新建职员类:

public class Employee {
    // 职位
    private String position;
    // 工作内容
    private String job;

    public Employee(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本职工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }
}

客户端建立人员结构关系:

public class Client {
    
    @Test
    public void test() {
        Manager boss = new Manager("老板", "唱怒放的生命");
        Employee HR = new Employee("人力资源", "聊微信");
        Manager PM = new Manager("产品经理", "不知道干啥");
        Manager CFO = new Manager("财务主管", "看剧");
        Manager CTO = new Manager("技术主管", "划水");
        Employee UI = new Employee("设计师", "画画");
        Employee operator = new Employee("运营人员", "兼职客服");
        Employee webProgrammer = new Employee("程序员", "学习设计模式");
        Employee backgroundProgrammer = new Employee("后台程序员", "CRUD");
        Employee accountant = new Employee("会计", "背九九乘法表");
        Employee clerk = new Employee("文员", "给老板递麦克风");
        boss.addEmployee(HR);
        boss.addManager(PM);
        boss.addManager(CFO);
        PM.addEmployee(UI);
        PM.addManager(CTO);
        PM.addEmployee(operator);
        CTO.addEmployee(webProgrammer);
        CTO.addEmployee(backgroundProgrammer);
        CFO.addEmployee(accountant);
        CFO.addEmployee(clerk);

        boss.check();
    }
}

运行测试方法,输出如下(为方便查看,笔者添加了缩进):

我是老板,我正在唱怒放的生命
	我是人力资源,我正在聊微信
	我是产品经理,我正在不知道干啥
		我是设计师,我正在画画
		我是运营人员,我正在兼职客服
		我是技术主管,我正在划水
			我是程序员,我正在学习设计模式
			我是后台程序员,我正在CRUD
	我是财务主管,我正在看剧
		我是会计,我正在背九九乘法表
		我是文员,我正在给老板递麦克风

这样我们就设计出了公司的结构,但是这样的设计有两个弊端:

  • name 字段,job 字段,work 方法重复了。
  • 管理者对其管理的管理者和职员需要区别对待。

关于第一个弊端,虽然这里为了讲解,只有两个字段和一个方法重复,实际工作中这样的整体部分结构会有相当多的重复。比如此例中还可能有工号、年龄等字段,领取工资、上下班打卡、开各种无聊的会等方法。

大量的重复显然是很丑陋的代码,分析一下可以发现, Manager 类只比 Employee 类多一个管理人员的列表字段,多几个增加 / 移除人员的方法,其他的字段和方法全都是一样的。

有读者应该会想到:我们可以将重复的字段和方法提取到一个工具类中,让 Employee 和 Manager 都去调用此工具类,就可以消除重复了。

这样固然可行,但属于 Employee 和 Manager 类自己的东西却要通过其他类调用,并不利于程序的高内聚。

关于第二个弊端,此方案无法解决,此方案中 Employee 和 Manager 类完全是两个不同的对象,两者的相似性被忽略了。

所以我们有更好的设计方案,那就是组合模式!

3.2.使用组合模式的设计方案

组合模式最主要的功能就是让用户可以一致对待整体和部分结构,将两者都作为一个相同的组件,所以我们先新建一个抽象的组件类:

public abstract class Component {
    // 职位
    private String position;
    // 工作内容
    private String job;

    public Component(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本职工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }

    abstract void addComponent(Component component);

    abstract void removeComponent(Component component);

    abstract void check();
}

管理者继承自此抽象类:

public class Manager extends Component {
    // 管理的组件
    private List<Component> components = new ArrayList<>();

    public Manager(String position, String job) {
        super(position, job);
    }

    @Override
    public void addComponent(Component component) {
        components.add(component);
    }

    @Override
    void removeComponent(Component component) {
        components.remove(component);
    }

    // 检查下属
    @Override
    public void check() {
        work();
        for (Component component : components) {
            component.check();
        }
    }
}

职员同样继承自此抽象类:

public class Employee extends Component {

    public Employee(String position, String job) {
        super(position, job);
    }

    @Override
    void addComponent(Component component) {
        System.out.println("职员没有管理权限");
    }

    @Override
    void removeComponent(Component component) {
        System.out.println("职员没有管理权限");
    }

    @Override
    void check() {
        work();
    }
}

修改客户端如下:

public class Client {

    @Test
    public void test(){
        Component boss = new Manager("老板", "唱怒放的生命");
        Component HR = new Employee("人力资源", "聊微信");
        Component PM = new Manager("产品经理", "不知道干啥");
        Component CFO = new Manager("财务主管", "看剧");
        Component CTO = new Manager("技术主管", "划水");
        Component UI = new Employee("设计师", "画画");
        Component operator = new Employee("运营人员", "兼职客服");
        Component webProgrammer = new Employee("程序员", "学习设计模式");
        Component backgroundProgrammer = new Employee("后台程序员", "CRUD");
        Component accountant = new Employee("会计", "背九九乘法表");
        Component clerk = new Employee("文员", "给老板递麦克风");
        boss.addComponent(HR);
        boss.addComponent(PM);
        boss.addComponent(CFO);
        PM.addComponent(UI);
        PM.addComponent(CTO);
        PM.addComponent(operator);
        CTO.addComponent(webProgrammer);
        CTO.addComponent(backgroundProgrammer);
        CFO.addComponent(accountant);
        CFO.addComponent(clerk);

        boss.check();
    }
}

运行测试方法,输出结果与之前的结果一模一样。

可以看到,使用组合模式后,我们解决了之前的两个弊端。一是将共有的字段与方法移到了父类中,消除了重复,并且在客户端中,可以一致对待 Manager 和 Employee 类:

  • Manager 类和 Employee 类统一声明为 Component 对象
  • 统一调用 Component 对象的 addComponent 方法添加子对象即可。

3.3.组合模式中的安全方式与透明方式

读者可能已经注意到了,Employee 类虽然继承了父类的 addComponent 和 removeComponent 方法,但是仅仅提供了一个空实现,因为 Employee 类是不支持添加和移除组件的。这样是否违背了接口隔离原则呢?

接口隔离原则:客户端不应依赖它不需要的接口。如果一个接口在实现时,部分方法由于冗余被客户端空实现,则应该将接口拆分,让实现类只需依赖自己需要的接口方法。

答案是肯定的,这样确实违背了接口隔离原则。这种方式在组合模式中被称作透明方式.

透明方式:在 Component 中声明所有管理子对象的方法,包括 add 、remove 等,这样继承自 Component 的子类都具备了 add、remove 方法。对于外界来说叶节点和枝节点是透明的,它们具备完全一致的接口。

这种方式有它的优点:让 Manager 类和 Employee 类具备完全一致的行为接口,调用者可以一致对待它们。

但它的缺点也显而易见:Employee 类并不支持管理子对象,不仅违背了接口隔离原则,而且客户端可以用 Employee 类调用 addComponent 和 removeComponent 方法,导致程序出错,所以这种方式是不安全的。

那么我们可不可以将 addComponent 和 removeComponent 方法移到 Manager 子类中去单独实现,让 Employee 不再实现这两个方法呢?我们来尝试一下。

将抽象类修改为:

public abstract class Component {
    // 职位
    private String position;
    // 工作内容
    private String job;

    public Component(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本职工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }

    abstract void check();
}

可以看到,我们在父类中去掉了 addComponent 和 removeComponent 这两个抽象方法。

Manager 类修改为:

public class Manager extends Component {
    // 管理的组件
    private List<Component> components = new ArrayList<>();

    public Manager(String position, String job) {
        super(position, job);
    }

    public void addComponent(Component component) {
        components.add(component);
    }

    void removeComponent(Component component) {
        components.remove(component);
    }

    // 检查下属
    @Override
    public void check() {
        work();
        for (Component component : components) {
            component.check();
        }
    }
}

Manager 类单独实现了 addComponent 和 removeComponent 这两个方法,去掉了 @Override 注解。

Employee 类修改为:

public class Employee extends Component {

    public Employee(String position, String job) {
        super(position, job);
    }

    @Override
    void check() {
        work();
    }
}

客户端建立人员结构关系:

public class Client {

    @Test
    public void test(){
        Manager boss = new Manager("老板", "唱怒放的生命");
        Employee HR = new Employee("人力资源", "聊微信");
        Manager PM = new Manager("产品经理", "不知道干啥");
        Manager CFO = new Manager("财务主管", "看剧");
        Manager CTO = new Manager("技术主管", "划水");
        Employee UI = new Employee("设计师", "画画");
        Employee operator = new Employee("运营人员", "兼职客服");
        Employee webProgrammer = new Employee("程序员", "学习设计模式");
        Employee backgroundProgrammer = new Employee("后台程序员", "CRUD");
        Employee accountant = new Employee("会计", "背九九乘法表");
        Employee clerk = new Employee("文员", "给老板递麦克风");
        boss.addComponent(HR);
        boss.addComponent(PM);
        boss.addComponent(CFO);
        PM.addComponent(UI);
        PM.addComponent(CTO);
        PM.addComponent(operator);
        CTO.addComponent(webProgrammer);
        CTO.addComponent(backgroundProgrammer);
        CFO.addComponent(accountant);
        CFO.addComponent(clerk);

        boss.check();
    }
}

运行程序,输出结果与之前一模一样。

这种方式在组合模式中称之为安全方式。

安全方式:在 Component 中不声明 add 和 remove 等管理子对象的方法,这样叶节点就无需实现它,只需在枝节点中实现管理子对象的方法即可。

安全方式遵循了接口隔离原则,但由于不够透明,Manager 和 Employee 类不具有相同的接口,在客户端中,我们无法将 Manager 和 Employee 统一声明为 Component 类了,必须要区别对待,带来了使用上的不方便。

安全方式和透明方式各有好处,在使用组合模式时,需要根据实际情况决定。但大多数使用组合模式的场景都是采用的透明方式,虽然它有点不安全,但是客户端无需做任何判断来区分是叶子结点还是枝节点,用起来是真香。

9、装饰模式

​ 装饰者模式(Decorator Pattern)指在无须改变原有类及类的继承关系的情况下,动态扩展一个类的功能。它通过装饰者来包裹真实的对象,并动态地向对象添加或者撤销功能。装饰者模式包括Source和Decorator两种角色,Source是被装饰者,Decorator是装饰者。装饰者模式通过装饰者可以为被装饰者Source动态添加一些功能。

提到装饰,我们先来想一下生活中有哪些装饰:

  • 女生的首饰:戒指、耳环、项链等装饰品
  • 家居装饰品:粘钩、镜子、壁画、盆栽等

我们为什么需要这些装饰品呢?很容易想到是为了美,戒指、耳环、项链、壁画、盆栽等都是为了提高颜值或增加美观度。但粘钩、镜子不一样,它们是为了方便我们挂东西、洗漱。所以我们可以总结出装饰品共有两种功能:

  • 增强原有的特性:我们本身就是有一定颜值的,添加装饰品提高了我们的颜值。同样,房屋本身就有一定的美观度,家居装饰提高了房屋的美观度。
  • 添加新的特性:在墙上挂上粘钩,让墙壁有了挂东西的功能。在洗漱台装上镜子,让洗漱台有了照镜子的功能。

并且,我们发现装饰品并不会改变物品本身,只是起到一个锦上添花的作用。装饰模式也一样,它的主要作用就是:

  • 增强一个类原有的功能
  • 为一个类添加新的功能

并且 装饰模式也不会改变原有的类

装饰模式:动态地给一个对象增加一些额外的职责,就增加对象功能来说,装饰模式比生成子类实现更为灵活。其别名也可以称为包装器,与适配器模式的别名相同,但它们适用于不同的场合。根据翻译的不同,装饰模式也有人称之为“油漆工模式”。

9.1、用于增强功能的装饰模式

我们用程序来模拟一下戴上装饰品提高我们颜值的过程:

新建颜值接口:

public interface IBeauty {
    int getBeautyValue();
}

新建 Me 类,实现颜值接口:

public class Me implements IBeauty {

    @Override
    public int getBeautyValue() {
        return 100;
    }
}

戒指装饰类,将 Me 包装起来:

public class RingDecorator implements IBeauty {
    private final IBeauty me;

    public RingDecorator(IBeauty me) {
        this.me = me;
    }

    @Override
    public int getBeautyValue() {
        return me.getBeautyValue() + 20;
    }
}

客户端测试:

public class Client {
    @Test
    public void show() {
        IBeauty me = new Me();
        System.out.println("我原本的颜值:" + me.getBeautyValue());

        IBeauty meWithRing = new RingDecorator(me);
        System.out.println("戴上了戒指后,我的颜值:" + meWithRing.getBeautyValue());
    }
}

运行程序,输出如下:

我原本的颜值:100
戴上了戒指后,我的颜值:120

这就是最简单的增强功能的装饰模式。以后我们可以添加更多的装饰类,比如:

耳环装饰类:

public class EarringDecorator implements IBeauty {
    private final IBeauty me;

    public EarringDecorator(IBeauty me) {
        this.me = me;
    }

    @Override
    public int getBeautyValue() {
        return me.getBeautyValue() + 50;
    }
}

项链装饰类:

public class NecklaceDecorator implements IBeauty {
    private final IBeauty me;

    public NecklaceDecorator(IBeauty me) {
        this.me = me;
    }

    @Override
    public int getBeautyValue() {
        return me.getBeautyValue() + 80;
    }
}

客户端测试:

public class Client {
    @Test
    public void show() {
        IBeauty me = new Me();
        System.out.println("我原本的颜值:" + me.getBeautyValue());

        // 随意挑选装饰
        IBeauty meWithNecklace = new NecklaceDecorator(me);
        System.out.println("戴上了项链后,我的颜值:" + meWithNecklace.getBeautyValue());

        // 多次装饰
        IBeauty meWithManyDecorators = new NecklaceDecorator(new RingDecorator(new EarringDecorator(me)));
        System.out.println("戴上耳环、戒指、项链后,我的颜值:" + meWithManyDecorators.getBeautyValue());

        // 任意搭配装饰
        IBeauty meWithNecklaceAndRing = new NecklaceDecorator(new RingDecorator(me));
        System.out.println("戴上戒指、项链后,我的颜值:" + meWithNecklaceAndRing.getBeautyValue());
    }
}

运行程序,输出如下:

我原本的颜值:100
戴上了项链后,我的颜值:180
戴上耳环、戒指、项链后,我的颜值:250
戴上戒指、项链后,我的颜值:200

可以看到,装饰器也实现了 IBeauty 接口,并且没有添加新的方法,也就是说这里的装饰器仅用于增强功能并不会改变 Me 原有的功能,这种装饰模式称之为 透明装饰模式,由于没有改变接口,也没有新增方法,所以透明装饰模式可以无限装饰

装饰模式是 继承 的一种替代方案。本例如果不使用装饰模式,而是改用继承实现的话,戴着戒指的 Me 需要派生一个子类、戴着项链的 Me 需要派生一个子类、戴着耳环的 Me 需要派生一个子类、戴着戒指 + 项链的需要派生一个子类…各种各样的排列组合会造成类爆炸。而采用了装饰模式就只需要为每个装饰品生成一个装饰类即可,所以说就 增加对象功能 来说,装饰模式比生成子类实现更为灵活

9.2、用于添加功能的装饰模式

我们用程序来模拟一下房屋装饰粘钩后,新增了挂东西功能的过程:

新建房屋接口:

public interface IHouse {
    void live();
}

房屋类:

public class House implements IHouse{

    @Override
    public void live() {
        System.out.println("房屋原有的功能:居住功能");
    }
}

新建粘钩装饰器接口,继承自房屋接口:

public interface IStickyHookHouse extends IHouse{
    void hangThings();
}

粘钩装饰类:

public class StickyHookDecorator implements IStickyHookHouse {
    private final IHouse house;

    public StickyHookDecorator(IHouse house) {
        this.house = house;
    }

    @Override
    public void live() {
        house.live();
    }

    @Override
    public void hangThings() {
        System.out.println("有了粘钩后,新增了挂东西功能");
    }
}

客户端测试:

public class Client {
    @Test
    public void show() {
        IHouse house = new House();
        house.live();

        IStickyHookHouse stickyHookHouse = new StickyHookDecorator(house);
        stickyHookHouse.live();
        stickyHookHouse.hangThings();
    }
}

运行程序,显示如下:

房屋原有的功能:居住功能
房屋原有的功能:居住功能
有了粘钩后,新增了挂东西功能

这就是用于 新增功能 的装饰模式。我们在接口中新增了方法:hangThings,然后在装饰器中将 House 类包装起来,之前 House 中的方法仍然调用 house 去执行,也就是说我们并没有修改原有的功能,只是扩展了新的功能,这种模式在装饰模式中称之为 半透明装饰模式

为什么叫半透明呢?由于新的接口 IStickyHookHouse 拥有之前 IHouse 不具有的方法,所以我们如果要使用装饰器中添加的功能,就不得不区别对待 装饰前的对象和装饰后的对象。也就是说客户端要使用新方法,必须知道具体的装饰类 StickyHookDecorator,所以这个装饰类对客户端来说是可见的、不透明的。而被装饰者不一定要是 House,它可以是实现了 IHouse 接口的任意对象,所以被装饰者对客户端是不可见的、透明的。由于一半透明,一半不透明,所以称之为半透明装饰模式。

我们可以添加更多的装饰器:

新建镜子装饰器的接口,继承自房屋接口:

public interface IMirrorHouse extends IHouse {
    void lookMirror();
}

镜子装饰类:

public class MirrorDecorator implements IMirrorHouse{
    private final IHouse house;

    public MirrorDecorator(IHouse house) {
        this.house = house;
    }

    @Override
    public void live() {
        house.live();
    }

    @Override
    public void lookMirror() {
        System.out.println("有了镜子后,新增了照镜子功能");
    }
}

客户端测试:

public class Client {
    @Test
    public void show() {
        IHouse house = new House();
        house.live();

        IMirrorHouse mirrorHouse = new MirrorDecorator(house);
        mirrorHouse.live();
        mirrorHouse.lookMirror();
    }
}

运行程序,输出如下:

房屋原有的功能:居住功能
房屋原有的功能:居住功能
有了镜子后,新增了照镜子功能

现在我们仿照 透明装饰模式 的写法,同时添加粘钩和镜子装饰试一试:

public class Client {
    @Test
    public void show() {
        IHouse house = new House();
        house.live();

        IStickyHookHouse stickyHookHouse = new StickyHookDecorator(house);
        IMirrorHouse houseWithStickyHookMirror = new MirrorDecorator(stickyHookHouse);
        houseWithStickyHookMirror.live();
        houseWithStickyHookMirror.hangThings(); // 这里会报错,找不到 hangThings 方法
        houseWithStickyHookMirror.lookMirror();
    }
}

我们会发现,第二次装饰时,无法获得上一次装饰添加的方法。原因很明显,当我们用 IMirrorHouse 装饰器后,接口变为了 IMirrorHouse,这个接口中并没有 hangThings 方法。

那么我们能否让 IMirrorHouse 继承自 IStickyHookHouse,以实现新增两个功能呢?

可以,但那样做的话两个装饰类之间有了依赖关系,那就不是装饰模式了。装饰类不应该存在依赖关系,而应该在原本的类上进行装饰。这就意味着,半透明装饰模式中我们无法多次装饰

有的同学会问了,既增强了功能,又添加了新功能的装饰模式叫什么呢?

—— 举一反三,肯定是叫全不透明装饰模式!

—— 并不是!只要添加了新功能的装饰模式都称之为 半透明装饰模式,他们都具有不可以多次装饰的特点。仔细理解上文半透明名称的由来就知道了,“透明”指的是我们无需知道被装饰者具体的类,既增强了功能,又添加了新功能的装饰模式仍然具有半透明特性。

看了这两个简单的例子,是不是发现装饰模式很简单呢?恭喜你学会了 1 + 1 = 2,现在你已经掌握了算数的基本思想,接下来我们来做一道微积分题练习一下。

9.3、I/O 中的装饰模式

I/O 指的是 Input/Output,即输入、输出。我们以 Input 为例。先在 src 文件夹下新建一个文件 readme.text,随便写点文字:

禁止套娃
禁止禁止套娃
禁止禁止禁止套娃

然后用 Java 的 InputStream 读取,代码一般长这样:

public void io() throws IOException {
    InputStream in = new BufferedInputStream(new FileInputStream("src/readme.txt"));
    byte[] buffer = new byte[1024];
    while (in.read(buffer) != -1) {
        System.out.println(new String(buffer));
    }
    in.close();
}

这样写有一个问题,如果读取过程中出现了 IO 异常,InputStream 就不能正确关闭,所以我们要用try...finally来保证 InputStream 正确关闭:

public void io() throws IOException {
    InputStream in = null;
    try {
        in = new BufferedInputStream(new FileInputStream("src/readme.txt"));
        byte[] buffer = new byte[1024];
        while (in.read(buffer) != -1) {
            System.out.println(new String(buffer));
        }
    } finally {
        if (in != null) {
            in.close();
        }
    }
}

这种写法实在是太丑了,而 IO 操作又必须这么写,显然 Java 也意识到了这个问题,所以 Java 7 中引入了try(resource)语法糖,IO 的代码就可以简化如下:

public void io() throws IOException {
    try (InputStream in = new BufferedInputStream(new FileInputStream("src/readme.txt"))) {
        byte[] buffer = new byte[1024];
        while (in.read(buffer) != -1) {
            System.out.println(new String(buffer));
        }
    }
}

这种写法和上一种逻辑是一样的,运行程序,显示如下:

禁止套娃
禁止禁止套娃
禁止禁止禁止套娃

观察获取 InputStream 这句代码:

InputStream in = new BufferedInputStream(new FileInputStream("src/readme.txt"));

是不是和我们之前多次装饰的代码非常相似:

// 多次装饰
IBeauty meWithManyDecorators = new NecklaceDecorator(new RingDecorator(new EarringDecorator(me)));

事实上,查看 I/O 的源码可知,Java I/O 的设计框架便是使用的 装饰者模式,InputStream 的继承关系如下:

img

其中,InputStream 是一个抽象类,对应上文例子中的 IHouse,其中最重要的方法是 read 方法,这是一个抽象方法:

public abstract class InputStream implements Closeable {
    
    public abstract int read() throws IOException;
    
    // ...
}

这个方法会读取输入流的下一个字节,并返回字节表示的 int 值(0~255),返回 -1 表示已读到末尾。由于它是抽象方法,所以具体的逻辑交由子类实现。

上图中,左边的三个类 FileInputStream、ByteArrayInputStream、ServletInputStream 是 InputStream 的三个子类,对应上文例子中实现了 IHouse 接口的 House。

右下角的三个类 BufferedInputStream、DataInputStream、CheckedInputStream 是三个具体的装饰者类,他们都为 InputStream 增强了原有功能或添加了新功能。

FilterInputStream 是所有装饰类的父类,它没有实现具体的功能,仅用来包装了一下 InputStream:

public class FilterInputStream extends InputStream {
    protected volatile InputStream in;
    
    protected FilterInputStream(InputStream in) {
        this.in = in;
    }

    public int read() throws IOException {
        return in.read();
    }
    
    //...
}

我们以 BufferedInputStream 为例。原有的 InputStream 读取文件时,是一个字节一个字节读取的,这种方式的执行效率并不高,所以我们可以设立一个缓冲区,先将内容读取到缓冲区中,缓冲区读满后,将内容从缓冲区中取出来,这样就变成了一段一段读取,用内存换取效率。BufferedInputStream 就是用来做这个的。它继承自 FilterInputStream:

public class BufferedInputStream extends FilterInputStream {
    private static final int DEFAULT_BUFFER_SIZE = 8192;
    protected volatile byte buf[];

    public BufferedInputStream(InputStream in) {
        this(in, DEFAULT_BUFFER_SIZE);
    }

    public BufferedInputStream(InputStream in, int size) {
        super(in);
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }
    
    //...
}

我们先来看它的构造方法,在构造方法中,新建了一个 byte[] 作为缓冲区,从源码中我们看到,Java 默认设置的缓冲区大小为 8192 byte,也就是 8 KB。

然后我们来查看 read 方法:

public class BufferedInputStream extends FilterInputStream {
    //...

    public synchronized int read() throws IOException {
        if (pos >= count) {
            fill();
            if (pos >= count)
                return -1;
        }
        return getBufIfOpen()[pos++] & 0xff;
    }

    private void fill() throws IOException {
        // 往缓冲区内填充读取内容的过程
        //...
    }
}

在 read 方法中,调用了 fill 方法,fill 方法的作用就是往缓冲区中填充读取的内容。这样就实现了增强原有的功能。

在源码中我们发现,BufferedInputStream 没有添加 InputStream 中没有的方法,所以 BufferedInputStream 使用的是 透明的装饰模式

DataInputStream 用于更加方便地读取 int、double 等内容,观察 DataInputStream 的源码可以发现,DataInputStream 中新增了 readInt、readLong 等方法,所以 DataInputStream 使用的是 半透明装饰模式

理解了 InputStream 后,再看一下 OutputStream 的继承关系,相信大家一眼就能看出各个类的作用了:

img

这就是装饰模式,注意不要和适配器模式混淆了。两者在使用时都是包装一个类,但两者的区别其实也很明显:

  • 纯粹的适配器模式 仅用于改变接口,不改变其功能,部分情况下我们需要改变一点功能以适配新接口。但使用适配器模式时,接口一定会有一个 回炉重造 的过程。
  • 装饰模式 不改变原有的接口,仅用于增强原有功能或添加新功能,强调的是 锦上添花

掌握了装饰者模式之后,理解 Java I/O 的框架设计就非常容易了。但对于不理解装饰模式的人来说,各种各样相似的 InputStream 非常容易让开发者感到困惑。这一点正是装饰模式的缺点:容易造成程序中有大量相似的类。虽然这更像是开发者的缺点,我们应该做的是提高自己的技术,掌握了这个设计模式之后它就是我们的一把利器。现在我们再看到 I/O 不同的 InputStream 装饰类,只需要关注它增强了什么功能或添加了什么功能即可。

10、外观模式

观模式(Facade Pattern)也叫作门面模式,通过一个门面(Facade)向客户端提供一个访问系统的统一接口,客户端无须关心和知晓系统内部各子模块(系统)之间的复杂关系,其主要目的是降低访问拥有多个子系统的复杂系统的难度,简化客户端与其之间的接口。外观模式将子系统中的功能抽象成一个统一的接口,客户端通过这个接口访问系统,使得系统使用起来更加容易。

外观模式非常简单,体现的就是 Java 中封装的思想。将多个子系统封装起来,提供一个更简洁的接口供外部调用。

img

外观模式:外部与一个子系统的通信必须通过一个统一的外观对象进行,为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。外观模式又称为门面模式。

举个例子,比如我们每天打开电脑时,都需要做三件事:

  • 打开浏览器
  • 打开 IDE
  • 打开微信

每天下班时,关机前需要做三件事:

  • 关闭浏览器
  • 关闭 IDE
  • 关闭微信

用程序模拟如下:

新建浏览器类:

public class Browser {
    public static void open() {
        System.out.println("打开浏览器");
    }

    public static void close() {
        System.out.println("关闭浏览器");
    }
}

新建 IDE 类:

public class IDE {
    public static void open() {
        System.out.println("打开 IDE");
    }

    public static void close() {
        System.out.println("关闭 IDE");
    }
}

新建微信类:

public class Wechat {
    public static void open() {
        System.out.println("打开微信");
    }

    public static void close() {
        System.out.println("关闭微信");
    }
}

客户端调用:

public class Client {
    @Test
    public void test() {
        System.out.println("上班:");
        Browser.open();
        IDE.open();
        Wechat.open();

        System.out.println("下班:");
        Browser.close();
        IDE.close();
        Wechat.close();
    }
}

运行程序,输出如下:

上班:
打开浏览器
打开 IDE
打开微信
下班:
关闭浏览器
关闭 IDE
关闭微信

由于我们每天都要做这几件事,所以我们可以使用 外观模式,将这几个子系统封装起来,提供更简洁的接口:

public class Facade {
    public void open() {
        Browser.open();
        IDE.open();
        Wechat.open();
    }

    public void close() {
        Browser.close();
        IDE.close();
        Wechat.close();
    }
}

客户端就可以简化代码,只和这个外观类打交道:

public class Client {
    @Test
    public void test() {
        Facade facade = new Facade();
        System.out.println("上班:");
        facade.open();

        System.out.println("下班:");
        facade.close();
    }
}

运行程序,输出与之前一样。

外观模式就是这么简单,它使得两种不同的类不用直接交互,而是通过一个中间件——也就是外观类——间接交互。外观类中只需要暴露简洁的接口,隐藏内部的细节,所以说白了就是封装的思想。

外观模式非常常用,(当然了!写代码哪有不封装的!)尤其是在第三方库的设计中,我们应该提供尽量简洁的接口供别人调用。另外,在 MVC 架构中,C 层(Controller)就可以看作是外观类,Model 和 View 层通过 Controller 交互,减少了耦合。

11、享元模式

​ 享元模式(Flyweight Pattern)主要通过对象的复用来减少对象创建的次数和数量,以减少系统内存的使用和降低系统的负载。享元模式属于结构型模式,在系统需要一个对象时享元模式首先在系统中查找并尝试重用现有的对象,如果未找到匹配的对象,则创建新对象并将其缓存在系统中以便下次使用。享元模式主要用于避免在有大量对象时频繁创建和销毁对象造成系统资源的浪费,把其中共同的部分抽象出来,如果有相同的业务请求,则直接返回内存中已有的对象,避免重新创建。

​ 享元模式体现的是 程序可复用 的特点,为了节约宝贵的内存,程序应该尽可能地复用,就像《极限编程》作者 Kent 在书里说到的那样:Don’t repeat yourself. 简单来说 享元模式就是共享对象,提高复用性,官方的定义倒是显得文绉绉的:

享元模式:运用共享技术有效地支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于享元模式要求能够共享的对象必须是细粒度对象,因此它又称为轻量级模式。

有个细节值得注意:有些对象本身不一样,但通过一点点变化后就可以复用,我们编程时可能稍不注意就会忘记复用这些对象。比如说伟大的《超级玛丽》,谁能想到草和云更改一下颜色就可以实现复用呢?

还有里面的三种乌龟,换一个颜色、加一个装饰就变成了不同的怪:

img

在《超级玛丽》中,这样的细节还有很多,正是这些精湛的复用使得这一款红遍全球的游戏仅有 40KB 大小。正是印证了那句名言:神在细节之中。

12、代理模式

​ 代理模式指为对象提供一种通过代理的方式来访问并控制该对象行为的方法。在客户端不适合或者不能够直接引用一个对象时,可以通过该对象的代理对象来实现对该对象的访问,可以将该代理对象理解为客户端和目标对象之间的中介者。

​ 在现实生活也能看到代理模式的身影,比如企业会把五险一金业务交给第三方人力资源公司去做,因为人力资源公司对这方面的业务更加熟悉,等等。

​ 在代理模式下有两种角色,一种是被代理者,一种是代理(Proxy),在被代理者需要做一项工作时,不用自己做,而是交给代理做。比如企业在招人时,不用自己去市场上找,可以通过代理(猎头公司)去找,代理有候选人池,可根据企业的需求筛选出合适的候选人返回给企业。

现在我们有一个 类,他整天就只负责吃饭、睡觉:

类的接口

public interface IPerson {
    void eat();
    void sleep();
}

类:

public class Person implements IPerson{

    @Override
    public void eat() {
        System.out.println("我在吃饭");
    }

    @Override
    public void sleep() {
        System.out.println("我在睡觉");
    }
}

客户端测试:

public class Client {
    @Test
    public void test() {
        Person person = new Person();
        person.eat();
        person.sleep();
    }
}

运行程序,输出如下:

我在吃饭
我在睡觉

我们可以把这个类包装到另一个类中,实现完全一样的行为:

public class PersonProxy implements IPerson {

    private final Person person;

    public PersonProxy(Person person) {
        this.person = person;
    }

    @Override
    public void eat() {
        person.eat();
    }

    @Override
    public void sleep() {
        person.sleep();
    }
}

将客户端修改为调用这个新的类:

public class Client {
    @Test
    public void test() {
        Person person = new Person();
        PersonProxy proxy = new PersonProxy(person);
        proxy.eat();
        proxy.sleep();
    }
}

运行程序,输出如下:

我在吃饭
我在睡觉

这就是代理模式。

笔者尽量用最简洁的代码讲解此模式,只要理解了上述这个简单的例子,你就知道代理模式是怎么一回事了。我们在客户端和 Person 类之间新增了一个中间件 PersonProxy,这个类就叫做代理类,他实现了和 Person 类一模一样的行为。

代理模式:给某一个对象提供一个代理,并由代理对象控制对原对象的引用。

现在这个代理类还看不出任何意义,我们来模拟一下工作中的需求。在实际工作中,我们可能会遇到这样的需求:在网络请求前后,分别打印将要发送的数据和接收到数据作为日志信息。此时我们就可以新建一个网络请求的代理类,让它代为处理网络请求,并在代理类中打印这些日志信息。

新建网络请求接口:

public interface IHttp {
    void request(String sendData);

    void onSuccess(String receivedData);
}

新建 Http 请求工具类:

public class HttpUtil implements IHttp {
    @Override
    public void request(String sendData) {
        System.out.println("网络请求中...");
    }

    @Override
    public void onSuccess(String receivedData) {
        System.out.println("网络请求完成。");
    }
}

新建 Http 代理类:

public class HttpProxy implements IHttp {
    private final HttpUtil httpUtil;

    public HttpProxy(HttpUtil httpUtil) {
        this.httpUtil = httpUtil;
    }

    @Override
    public void request(String sendData) {
        httpUtil.request(sendData);
    }

    @Override
    public void onSuccess(String receivedData) {
        httpUtil.onSuccess(receivedData);
    }
}

到这里,和我们上述吃饭睡觉的代码是一模一样的,现在我们在 HttpProxy 中新增打印日志信息:

public class HttpProxy implements IHttp {
    private final HttpUtil httpUtil;

    public HttpProxy(HttpUtil httpUtil) {
        this.httpUtil = httpUtil;
    }

    @Override
    public void request(String sendData) {
        System.out.println("发送数据:" + sendData);
        httpUtil.request(sendData);
    }

    @Override
    public void onSuccess(String receivedData) {
        System.out.println("收到数据:" + receivedData);
        httpUtil.onSuccess(receivedData);
    }
}

客户端验证:

public class Client {
    @Test
    public void test() {
        HttpUtil httpUtil = new HttpUtil();
        HttpProxy proxy = new HttpProxy(httpUtil);
        proxy.request("request data");
        proxy.onSuccess("received result");
    }
}

运行程序,输出如下:

发送数据:request data
网络请求中...
收到数据:received result
网络请求完成。

这就是代理模式的一个应用,除了 打印日志,它还可以用来做权限管理。读者看到这里可能已经发现了,这个代理类看起来和装饰模式的 FilterInputStream 一模一样,但两者的目的不同,装饰模式是为了 增强功能或添加功能,代理模式主要是为了加以控制

12.1、动态代理

上例中的代理被称之为静态代理,动态代理与静态代理的原理一模一样,只是换了一种写法。使用动态代理,需要把一个类传入,然后根据它正在调用的方法名判断是否需要加以控制。用伪代码表示如下:

public class HttpProxy {
    private final HttpUtil httpUtil;

    public HttpProxy(HttpUtil httpUtil) {
        this.httpUtil = httpUtil;
    }

    // 假设调用 httpUtil 的任意方法时,都要通过这个方法间接调用, methodName 表示方法名,args 表示方法中传入的参数
    public visit(String methodName, Object[] args) {
        if (methodName.equals("request")) {
            // 如果方法名是 request,打印日志,并调用 request 方法,args 的第一个值就是传入的参数
            System.out.println("发送数据:" + args[0]);
            httpUtil.request(args[0].toString());
        } else if (methodName.equals("onSuccess")) {
            // 如果方法名是 onSuccess,打印日志,并调用 onSuccess 方法,args 的第一个值就是传入的参数
            System.out.println("收到数据:" + args[0]);
            httpUtil.onSuccess(args[0].toString());
        }
    }
}

伪代码看起来还是很简单的,实现起来唯一的难点就是 怎么让 httpUtil 调用任意方法时都通过一个方法间接调用。这里需要用到反射技术,不了解反射技术也没有关系,不妨把它记做固定的写法。实际的动态代理类代码如下:

public class HttpProxy implements InvocationHandler {
    private HttpUtil httpUtil;

    public IHttp getInstance(HttpUtil httpUtil) {
        this.httpUtil = httpUtil;
        return (IHttp) Proxy.newProxyInstance(httpUtil.getClass().getClassLoader(), httpUtil.getClass().getInterfaces(), this);
    }

    // 调用 httpUtil 的任意方法时,都要通过这个方法调用
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Object result = null;
        if (method.getName().equals("request")) {
            // 如果方法名是 request,打印日志,并调用 request 方法
            System.out.println("发送数据:" + args[0]);
            result = method.invoke(httpUtil, args);
        } else if (method.getName().equals("onSuccess")) {
            // 如果方法名是 onSuccess,打印日志,并调用 onSuccess 方法
            System.out.println("收到数据:" + args[0]);
            result = method.invoke(httpUtil, args);
        }
        return result;
    }
}

先看 getInstance 方法,Proxy.newProxyInstance 方法是 Java 系统提供的方法,专门用于动态代理。其中传入的第一个参数是被代理的类的 ClassLoader,第二个参数是被代理类的 Interfaces,这两个参数都是 Object 中的,每个类都有,这里就是固定写法。我们只要知道系统需要这两个参数才能让我们实现我们的目的:调用被代理类的任意方法时都通过一个方法间接调用。现在我们给系统提供了这两个参数,系统就会在第三个参数中帮我们实现这个目的。

第三个参数是 InvocationHandler 接口,这个接口中只有一个方法:

public Object invoke(Object proxy, Method method, Object[] args) throws Throwable;

那么不用猜就知道,现在我们调用被代理类 httpUtil 的任意方法时,都会通过这个 invoke 方法调用了。invoke 方法中,第一个参数我们暂时用不上,第二个参数 method 就是调用的方法,使用 method.getName() 可以获取到方法名,第三个参数是调用 method 方法需要传入的参数。本例中无论 request 还是 onSuccess 都只有一个 String 类型的参数,对应到这里就是 args[0]。返回的 Object 是 method 方法的返回值,本例中都是无返回值的。

我们在 invoke 方法中判断了当前调用方法的方法名,如果现在调用的方法是 request,那么打印请求参数,并使用这一行代码继续执行当前方法:

result = method.invoke(httpUtil, args);

这就是 反射调用函数 的写法,如果不了解可以记做固定写法,想要了解的同学可以看之前的这篇文章:详解面试中常考的 Java 反射机制。虽然这个函数没有返回值,但我们还是将 result 返回,这是标准做法。

如果现在调用的方法是 onSuccess,那么打印接收到的数据,并反射继续执行当前方法。

修改客户端验证一下:

public class Client {
    @Test
    public void test() {
        HttpUtil httpUtil = new HttpUtil();
        IHttp proxy = new HttpProxy().getInstance(httpUtil);
        proxy.request("request data");
        proxy.onSuccess("received result");
    }
}

运行程序,输出与之前一样:

发送数据:request data
网络请求中...
收到数据:received result
网络请求完成。

动态代理本质上与静态代理没有区别,它的好处是 节省代码量。比如被代理类有 20 个方法,而我们只需要控制其中的两个方法,就可以用动态代理通过方法名对被代理类进行动态的控制,而如果用静态方法,我们就需要将另外的 18 个方法也写出来,非常繁琐。这就是动态代理的优势所在。

13、责任链模式

​ 责任链(Chain of Responsibility)模式也叫作职责链模式,用于避免请求发送者与多个请求处理者耦合在一起,让所有请求的处理者持有下一个对象的引用,从而将请求串联成一条链,在有请求发生时,可将请求沿着这条链传递,直到遇到该对象的处理器。

在责任链模式下,用户只需将请求发送到责任链上即可,无须关心请求的处理细节和传递过程,所以责任链模式优雅地将请求的发送和处理进行了解耦。

责任链模式在Web请求中很常见,比如我们要为客户端提供一个REST服务,服务端要针对客户端的请求实现用户鉴权、业务调用、结果反馈流程,就可以使用责任链模式实现。责任链模式包含以下三种角色。

  • Handler接口:用于规定在责任链上具体要执行的方法。

  • AbstractHandler抽象类:持有Handler实例并通过setHandler()和getHandler()将各个具体的业务Handler串联成一个责任链,客户端上的请求在责任链上执行。

  • 业务Handler:用户根据具体的业务需求实现的业务逻辑。

我们每个人在工作中都承担着一定的责任,比如程序员承担着开发新功能、修改 bug 的责任,运营人员承担着宣传的责任、HR 承担着招聘新人的责任。我们每个人的责任与这个责任链有什么关系吗?

——答案是并没有太大关系。

咳咳,也不是完全没有关系,主要是因为每个人在不同岗位上的责任是分散的,分散的责任组合在一起更像是一张网,无法组成一条链。

同一个岗位上的责任,就可以组成一条链。举个切身的例子,比如:普通的程序员可以解决中等难度的 bug,优秀程序员可以解决困难的 bug,而菜鸟程序员只能解决简单的 bug。为了将其量化,我们用一个数字来表示 bug 的难度,(0, 20] 表示简单,(20,50] 表示中等, (50,100] 表示困难,我们来模拟一个 bug 解决的流程。

“解决 bug” 程序 1.0

新建一个 bug 类:

public class Bug {
    // bug 的难度值
    int value;

    public Bug(int value) {
        this.value = value;
    }
}

新建一个程序员类:

public class Programmer {
    // 程序员类型:菜鸟、普通、优秀
    public String type;

    public Programmer(String type) {
        this.type = type;
    }

    public void solve(Bug bug) {
        System.out.println(type + "程序员解决了一个难度为 " + bug.value + " 的 bug");
    }
}

客户端:

import org.junit.Test;

public class Client {
    @Test
    public void test() {
        Programmer newbie = new Programmer("菜鸟");
        Programmer normal = new Programmer("普通");
        Programmer good = new Programmer("优秀");

        Bug easy = new Bug(20);
        Bug middle = new Bug(50);
        Bug hard = new Bug(100);

        // 依次尝试解决 bug
        handleBug(newbie, easy);
        handleBug(normal, easy);
        handleBug(good, easy);

        handleBug(newbie, middle);
        handleBug(normal, middle);
        handleBug(good, middle);

        handleBug(newbie, hard);
        handleBug(normal, hard);
        handleBug(good, hard);
    }

    public void handleBug(Programmer programmer, Bug bug) {
        if (programmer.type.equals("菜鸟") && bug.value > 0 && bug.value <= 20) {
            programmer.solve(bug);
        } else if (programmer.type.equals("普通") && bug.value > 20 && bug.value <= 50) {
            programmer.solve(bug);
        } else if (programmer.type.equals("优秀") && bug.value > 50 && bug.value <= 100) {
            programmer.solve(bug);
        }
    }
}

运行程序,输出如下:

菜鸟程序员解决了一个难度为 20 的 bug
普通程序员解决了一个难度为 50 的 bug
优秀程序员解决了一个难度为 100 的 bug

功能完美实现了,但在这个程序中,我们让每个程序员都尝试处理了每一个 bug,相当于大家围着讨论每个 bug 该由谁解决,这无疑是非常低效的做法。那么我们要怎么才能优化呢?

“解决 bug” 程序 2.0

实际上,许多公司会选择让项目经理来分派任务,项目经理会根据 bug 的难度指派给不同的人解决。

引入 ProjectManager 类:

public class ProjectManager {
    Programmer newbie = new Programmer("菜鸟");
    Programmer normal = new Programmer("普通");
    Programmer good = new Programmer("优秀");

    public void assignBug(Bug bug) {
        if (bug.value > 0 && bug.value <= 20) {
            System.out.println("项目经理将这个简单的 bug 分配给了菜鸟程序员");
            newbie.solve(bug);
        } else if (bug.value > 20 && bug.value <= 50) {
            System.out.println("项目经理将这个中等的 bug 分配给了普通程序员");
            normal.solve(bug);
        } else if (bug.value > 50 && bug.value <= 100) {
            System.out.println("项目经理将这个困难的 bug 分配给了优秀程序员");
            good.solve(bug);
        }
    }
}

我们让项目经理管理所有的程序员,并且根据 bug 的难度指派任务。这样一来,所有的 bug 只需传给项目经理分配即可,修改客户端如下:

import org.junit.Test;

public class Client2 {
    @Test
    public void test() {
        ProjectManager manager = new ProjectManager();

        Bug easy = new Bug(20);
        Bug middle = new Bug(50);
        Bug hard = new Bug(100);

        manager.assignBug(easy);
        manager.assignBug(middle);
        manager.assignBug(hard);
    }
}

运行程序,输出如下:

项目经理将这个简单的 bug 分配给了菜鸟程序员
菜鸟程序员解决了一个难度为 20 的 bug
项目经理将这个中等的 bug 分配给了普通程序员
普通程序员解决了一个难度为 50 的 bug
项目经理将这个困难的 bug 分配给了优秀程序员
优秀程序员解决了一个难度为 100 的 bug

看起来很美好,除了项目经理在骂骂咧咧地反驳这个方案。

在这个经过修改的程序中,项目经理一个人承担了分配所有 bug 这个体力活。程序没有变得简洁,只是把复杂的逻辑从客户端转移到了项目经理类中。

而且项目经理类承担了过多的职责,如果以后新增一类程序员,必须改动项目经理类,将其处理 bug 的职责插入分支判断语句中。

“解决 bug” 程序 3.0

责任链模式:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。

在本例的场景中,每个程序员的责任都是“解决这个 bug”,当测试提出一个 bug 时,可以走这样一条责任链:

  • 先交由菜鸟程序员之手,如果是简单的 bug,菜鸟程序员自己处理掉。如果这个 bug 对于菜鸟程序员来说太难了,交给普通程序员
  • 如果是中等难度的 bug,普通程序员处理掉。如果他也解决不了,交给优秀程序员
  • 优秀程序员处理掉困难的 bug

有的读者会提出疑问,如果优秀程序员也无法处理这个 bug 呢?

——那当然是处理掉这个假冒优秀程序员。

修改客户端如下:

import org.junit.Test;

public class Client3 {
    @Test
    public void test() throws Exception {
        Programmer newbie = new Programmer("菜鸟");
        Programmer normal = new Programmer("普通");
        Programmer good = new Programmer("优秀");

        Bug easy = new Bug(20);
        Bug middle = new Bug(50);
        Bug hard = new Bug(100);

        // 链式传递责任
        if (!handleBug(newbie, easy)) {
            if (!handleBug(normal, easy)) {
                if (!handleBug(good, easy)) {
                    throw new Exception("Kill the fake good programmer!");
                }
            }
        }

        if (!handleBug(newbie, middle)) {
            if (!handleBug(normal, middle)) {
                if (!handleBug(good, middle)) {
                    throw new Exception("Kill the fake good programmer!");
                }
            }
        }

        if (!handleBug(newbie, hard)) {
            if (!handleBug(normal, hard)) {
                if (!handleBug(good, hard)) {
                    throw new Exception("Kill the fake good programmer!");
                }
            }
        }
    }

    public boolean handleBug(Programmer programmer, Bug bug) {
        if (programmer.type.equals("菜鸟") && bug.value > 0 && bug.value <= 20) {
            programmer.solve(bug);
            return true;
        } else if (programmer.type.equals("普通") && bug.value > 20 && bug.value <= 50) {
            programmer.solve(bug);
            return true;
        } else if (programmer.type.equals("优秀") && bug.value > 50 && bug.value <= 100) {
            programmer.solve(bug);
            return true;
        }
        return false;
    }
}

首先我们将 handleBug 方法的签名改为了返回一个 boolean 值,如果此 bug 被处理了,返回 true;否则返回 false,使得责任沿着菜鸟-> 普通 -> 优秀这条链继续传递。

运行程序,输出如下:

菜鸟程序员解决了一个难度为 20 的 bug
普通程序员解决了一个难度为 50 的 bug
优秀程序员解决了一个难度为 100 的 bug

熟悉责任链模式的同学应该可以看出,这个责任链模式和我们平时使用的不太一样。事实上,这段代码已经很好地体现了责任链模式的基本思想。我们平时使用的责任链模式只是在面向对象的基础上,将这段代码封装了一下,如 4.0 所示。

“解决 bug” 程序 4.0

新建一个程序员抽象类:

public abstract class Programmer {
    protected Programmer next;

    public void setNext(Programmer next) {
        this.next = next;
    }

    abstract void handle(Bug bug);
}

在这个抽象类中:

  • next 对象表示如果自己解决不了,需要将责任传递给的下一个人;
  • handle 方法表示自己处理此 bug 的逻辑,在这里判断是自己解决或者继续传递。

新建菜鸟程序员类:

public class NewbieProgrammer extends Programmer {

    @Override
    public void handle(Bug bug) {
        if (bug.value > 0 && bug.value <= 20) {
            solve(bug);
        } else if (next != null) {
            next.handle(bug);
        }
    }

    private void solve(Bug bug) {
        System.out.println("菜鸟程序员解决了一个难度为 " + bug.value + " 的 bug");
    }
}

新建普通程序员类:

public class NormalProgrammer extends Programmer {

    @Override
    public void handle(Bug bug) {
        if (bug.value > 20 && bug.value <= 50) {
            solve(bug);
        } else if (next != null) {
            next.handle(bug);
        }
    }

    private void solve(Bug bug) {
        System.out.println("普通程序员解决了一个难度为 " + bug.value + " 的 bug");
    }
}

新建优秀程序员类:

public class GoodProgrammer extends Programmer {

    @Override
    public void handle(Bug bug) {
        if (bug.value > 50 && bug.value <= 100) {
            solve(bug);
        } else if (next != null) {
            next.handle(bug);
        }
    }

    private void solve(Bug bug) {
        System.out.println("优秀程序员解决了一个难度为 " + bug.value + " 的 bug");
    }
}

客户端测试:

import org.junit.Test;

public class Client4 {
    @Test
    public void test() {
        NewbieProgrammer newbie = new NewbieProgrammer();
        NormalProgrammer normal = new NormalProgrammer();
        GoodProgrammer good = new GoodProgrammer();

        Bug easy = new Bug(20);
        Bug middle = new Bug(50);
        Bug hard = new Bug(100);

        // 组成责任链
        newbie.setNext(normal);
        normal.setNext(good);

        // 从菜鸟程序员开始,沿着责任链传递
        newbie.handle(easy);
        newbie.handle(middle);
        newbie.handle(hard);
    }
}

在客户端中,我们通过 setNext() 方法将三个程序员组成了一条责任链,由菜鸟程序员接收所有的 bug,发现自己不能处理的 bug,就传递给普通程序员,普通程序员收到 bug 后,如果发现自己不能解决,则传递给优秀程序员。

责任链思想在生活中有很多应用,比如假期审批、加薪申请等,在员工提出申请后,从经理开始,由你的经理决定自己处理或是交由更上一层的经理处理。

再比如处理客户投诉时,从基层的客服人员开始,决定自己回应或是上报给领导,领导再判断是否继续上报。

理清了责任链模式,笔者突然回想起,公司的测试组每次提出 bug 后,总是先指派给我!一瞬间仿佛明白了什么了不得的道理,不禁流下了没技术的眼泪。

13.5、小结

通过这个例子,我们已经了解到,责任链主要用于处理职责相同,程度不同的类

其主要优点有:

  • 降低了对象之间的耦合度。在责任链模式中,客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,所以责任链将请求的发送者和请求的处理者解耦了。
  • 扩展性强,满足开闭原则。可以根据需要增加新的请求处理类。
  • 灵活性强。可以动态地改变链内的成员或者改变链的次序来适应流程的变化。
  • 简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的条件判断语句。
  • 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。不再需要 “项目经理” 来处理所有的责任分配任务。

但我们在使用中也发现了它的一个明显缺点,如果这个 bug 没人处理,可能导致 “程序员祭天” 异常。其主要缺点有:

  • 不能保证每个请求一定被处理,该请求可能一直传到链的末端都得不到处理。
  • 如果责任链过长,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
  • 责任链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于责任链拼接次序错误而导致系统出错,比如可能出现循环调用。

14、命令模式

​ 命令(Command)模式指将请求封装为命令基于事件驱动异步地执行,以实现命令的发送者和命令的执行者之间的解耦,提高命令发送、执行的效率和灵活度。

​ 命令模式将命令调用者与命令执行者解耦,有效降低系统的耦合度。同时,由于命令调用者和命令执行者进行了解耦,所以增加和删除(回滚)命令变得非常方便。

命令模式包含以下主要角色。

  • 抽象命令类(Command):执行命令的接口,定义执行命令的抽象方法execute()。

  • 具体命令类(Concrete Command):抽象命令类的实现类,持有接收者对象,并在接收到命令后调用命令执行者的方法action()实现命令的调用和执行。- 命令执行者(Receiver):命令的具体执行者,定义了命令执行的具体方法action()。

  • 命令调用者(Invoker):接收客户端的命令并异步执行

​ 近年来,智能家居越来越流行。躺在家中,只需要打开对应的 app,就可以随手控制家电开关。但随之而来一个问题,手机里的 app 实在是太多了,每一个家具公司都想要提供一个 app 给用户,以求增加用户粘性,推广他们的其他产品等。

站在用户的角度来看,有时我们只想打开一下电灯,却要先看到恼人的 “新式电灯上新” 的弹窗通知,让人烦不胜烦。如果能有一个万能遥控器将所有的智能家居开关综合起来,统一控制,一定会方便许多。

说干就干,笔者立马打开 PS,设计了一张草图:

img

“咳咳,我对这个 app 的设计理念呢,是基于 “简洁就是美” 的原则。一个好的设计,首先,最重要的一点就是 ‘接地气’。当然,我也可以用一些华丽的素材拼接出一个花里胡哨的设计,但,那是一个最低级的设计师才会做的事情…”

我们先来看下四个智能家居类的结构,大门类:

public class Door {
    public void openDoor() {
        System.out.println("门打开了");
    }

    public void closeDoor() {
        System.out.println("门关闭了");
    }
}

电灯类:

public class Light {
    public void lightOn() {
        System.out.println("打开了电灯");
    }

    public void lightOff() {
        System.out.println("关闭了电灯");
    }
}

电视类:

public class Tv {
    public void TurnOnTv() {
        System.out.println("电视打开了");
    }

    public void TurnOffTv() {
        System.out.println("电视关闭了");
    }
}

音乐类:

public class Music {
    public void play() {
        System.out.println("开始播放音乐");
    }

    public void stop() {
        System.out.println("停止播放音乐");
    }
}

由于是不同公司的产品,所以接口有所不同,接下来就一起来实现我们的万能遥控器!

万能遥控器 1.0

不一会儿,我们就写出了下面的代码:

// 初始化开关
Switch switchDoor = 省略绑定UI代码;
Switch switchLight = 省略绑定UI代码;
Switch switchTv = 省略绑定UI代码;
Switch switchMusic = 省略绑定UI代码;

// 初始化智能家居
Door door = new Door();
Light light = new Light();
Tv tv = new Tv();
Music music = new Music();

// 大门开关遥控
switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        door.openDoor();
    } else {
        door.closeDoor();
    }
});
// 电灯开关遥控
switchLight.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        light.lightOn();
    } else {
        light.lightOff();
    }
});
// 电视开关遥控
switchTv.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        tv.TurnOnTv();
    } else {
        tv.TurnOffTv();
    }
});
// 音乐开关遥控
switchMusic.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        music.play();
    } else {
        music.stop();
    }
});

这份代码很直观,在每个开关状态改变时,调用对应家居的 API 实现打开或关闭。

只有这样的功能实在是太单一了,接下来我们再为它添加一个有趣的功能。

万能遥控器 2.0

一般来说,电视遥控器上都有一个回退按钮,用来回到上一个频道。相当于文本编辑器中的 “撤销” 功能,既然别的小朋友都有,那我们也要!

设计狮本狮马不停蹄地设计了 UI 2.0:

img

UI 设计倒是简单,底部添加一个按钮即可。代码设计就比较复杂了,我们需要保存上一步操作,并且将其回退。

初步的想法是设计一个枚举类 Operation,代表每一步的操作:

public enum Operation {
    DOOR_OPEN,
    DOOR_CLOSE,
    LIGHT_ON,
    LIGHT_OFF,
    TV_TURN_ON,
    TV_TURN_OFF,
    MUSIC_PLAY,
    MUSIC_STOP
}

然后在客户端定义一个 Operation 变量 lastOperation,在每一步操作后,更新此变量。然后在撤销按钮的点击事件中,根据上一步的操作实现回退:

public class Client {

    // 上一步的操作
    Operation lastOperation;
    
    @Test
    protected void test() {
        
        // 初始化开关和撤销按钮
        Switch switchDoor = 省略绑定UI代码;
        Switch switchLight = 省略绑定UI代码;
        Switch switchTv = 省略绑定UI代码;
        Switch switchMusic = 省略绑定UI代码;
        Button btnUndo = 省略绑定UI代码;

        // 初始化智能家居
        Door door = new Door();
        Light light = new Light();
        Tv tv = new Tv();
        Music music = new Music();

        // 大门开关遥控
        switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
            if (isChecked) {
                lastOperation = Operation.DOOR_OPEN;
                door.openDoor();
            } else {
                lastOperation = Operation.DOOR_CLOSE;
                door.closeDoor();
            }
        });

        // 电灯开关遥控
        switchLight.setOnCheckedChangeListener((view, isChecked) -> {
            if (isChecked) {
                lastOperation = Operation.LIGHT_ON;
                light.lightOn();
            } else {
                lastOperation = Operation.LIGHT_OFF;
                light.lightOff();
            }
        });

        ... 电视、音乐类似

        btnUndo.setOnClickListener(view -> {
            if (lastOperation == null) return;
            // 撤销上一步
            switch (lastOperation) {
                case DOOR_OPEN:
                    door.closeDoor();
                    break;
                case DOOR_CLOSE:
                    door.openDoor();
                    break;
                case LIGHT_ON:
                    light.lightOff();
                    break;
                case LIGHT_OFF:
                    light.lightOn();
                    break;
                ... 电视、音乐类似
            }
        });
    }
}

大功告成,不过这份代码只实现了撤销一步,如果我们需要实现撤销多步怎么做呢?

思考一下,每次回退时,都是先将最后一步 Operation 撤销。对于这种后进先出的结构,我们自然就会想到栈结构,代码如下:

public class Client {

    // 所有的操作
    Stack<Operation> operations = new Stack<>();

    @Test
    protected void test() {

        // 初始化开关和撤销按钮
        Switch switchDoor = 省略绑定UI代码;
        Switch switchLight = 省略绑定UI代码;
        Switch switchTv = 省略绑定UI代码;
        Switch switchMusic = 省略绑定UI代码;
        Button btnUndo = 省略绑定UI代码;

        // 初始化智能家居
        Door door = new Door();
        Light light = new Light();
        Tv tv = new Tv();
        Music music = new Music();

        // 大门开关遥控
        switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
            if (isChecked) {
                operations.push(Operation.DOOR_OPEN);
                door.openDoor();
            } else {
                operations.push(Operation.DOOR_CLOSE);
                door.closeDoor();
            }
        });

        // 电灯开关遥控
        switchLight.setOnCheckedChangeListener((view, isChecked) -> {
            if (isChecked) {
                operations.push(Operation.LIGHT_ON);
                light.lightOn();
            } else {
                operations.push(Operation.LIGHT_OFF);
                light.lightOff();
            }
        });

        ...电视、音乐类似

        // 撤销按钮
        btnUndo.setOnClickListener(view -> {
            if (operations.isEmpty()) return;
            // 弹出栈顶的上一步操作
            Operation lastOperation = operations.pop();
            // 撤销上一步
            switch (lastOperation) {
                case DOOR_OPEN:
                    door.closeDoor();
                    break;
                case DOOR_CLOSE:
                    door.openDoor();
                    break;
                case LIGHT_ON:
                    light.lightOff();
                    break;
                case LIGHT_OFF:
                    light.lightOn();
                    break;
                ...电视、音乐类似
            }
        });
    }
}

我们将每一步 Operation 记录到栈中,每次撤销时,弹出栈顶的 Operation,再使用 switch 语句判断,将其恢复。

虽然实现了功能,但代码明显已经变得越来越臃肿了。遥控器知道了太多的细节,它必须要知道每个家居的调用方式。以后有开关加入时,不仅要修改 Status 类,增加新的 Operation,还要修改客户端,增加新的分支判断,导致这个类变成一个庞大的类。不仅违背了单一权责原则,还违背了开闭原则。

万能遥控器 3.0

我们期待能有一种设计,让遥控器不需要知道家居的接口。遥控器只需要负责监听用户按下开关,再根据开关状态发出正确的命令,对应的家居在收到命令后做出响应。就可以达到将 “行为请求者” 和 ”行为实现者“ 解耦的目的。

先定义一个命令接口:

public interface ICommand {
    void execute();
}

接口中只有一个 execute 方法,表示 “执行” 命令。

定义开门命令,实现此接口:

public class DoorOpenCommand implements ICommand {
    private Door door;

    public void setDoor(Door door) {
        this.door = door;
    }

    @Override
    public void execute() {
        door.openDoor();
    }
}

关门命令:

public class DoorCloseCommand implements ICommand {
    private Door door;

    public void setDoor(Door door) {
        this.door = door;
    }


    @Override
    public void execute() {
        door.closeDoor();
    }
}

开灯命令:

public class LightOnCommand implements ICommand {

    Light light;

    public void setLight(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.lightOn();
    }
}

关灯命令:

public class LightOffCommand implements ICommand {

    Light light;

    public void setLight(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.lightOff();
    }
}

电视、音乐的命令类似。

可以看到,我们将家居控制的代码转移到了命令类中,当命令执行时,调用对应家具的 API 实现开启或关闭。

客户端代码:

// 初始化命令
DoorOpenCommand doorOpenCommand = new DoorOpenCommand();
DoorCloseCommand doorCloseCommand = new DoorCloseCommand();
doorOpenCommand.setDoor(door);
doorCloseCommand.setDoor(door);
LightOnCommand lightOnCommand = new LightOnCommand();
LightOffCommand lightOffCommand = new LightOffCommand();
lightOnCommand.setLight(light);
lightOffCommand.setLight(light);
...电视、音乐类似

// 大门开关遥控
switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        doorOpenCommand.execute();
    } else {
        doorCloseCommand.execute();
    }
});
// 电灯开关遥控
switchLight.setOnCheckedChangeListener((view, isChecked) -> {
    if (isChecked) {
        lightOnCommand.execute();
    } else {
        lightOffCommand.execute();
    }
});
...电视、音乐类似

现在,遥控器只知道用户控制开关后,需要执行对应的命令,遥控器并不知道这个命令会执行什么内容,达到了隐藏技术细节的目的。

与此同时,我们还获得了一个附带的好处。由于每个命令都被抽象成了同一个接口,我们可以将开关代码统一起来。客户端优化如下:

public class Client {

    @Test
    protected void test() {
        ...初始化

        // 大门开关遥控
        switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, doorOpenCommand, doorCloseCommand);
        });
        // 电灯开关遥控
        switchLight.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, lightOnCommand, lightOffCommand);
        });
        // 电视开关遥控
        switchTv.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, turnOnTvCommand, turnOffTvCommand);
        });
        // 音乐开关遥控
        switchMusic.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, musicPlayCommand, musicStopCommand);
        });
    }

    private void handleCommand(boolean isChecked, ICommand openCommand, ICommand closeCommand) { 
        if (isChecked) {
            openCommand.execute();
        } else {
            closeCommand.execute();
        }
    }
}

不知不觉中,我们就写出了命令模式的代码。来看下命令模式的定义:

命令模式:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。

使用命令模式后,要实现撤销功能非常容易。

首先,在命令接口中,新增 undo 方法:

public interface ICommand {
    
    void execute();

    void undo();
}

开门命令中新增 undo:

public class DoorOpenCommand implements ICommand {
    private Door door;

    public void setDoor(Door door) {
        this.door = door;
    }

    @Override
    public void execute() {
        door.openDoor();
    }

    @Override
    public void undo() {
        door.closeDoor();
    }
}

关门命令中新增 undo:

public class DoorCloseCommand implements ICommand {
    private Door door;

    public void setDoor(Door door) {
        this.door = door;
    }

    @Override
    public void execute() {
        door.closeDoor();
    }

    @Override
    public void undo() {
        door.openDoor();
    }
}

开灯命令中新增 undo:

public class LightOnCommand implements ICommand {

    Light light;

    public void setLight(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.lightOn();
    }

    @Override
    public void undo() {
        light.lightOff();
    }
}

关灯命令中新增 undo:

public class LightOffCommand implements ICommand {

    Light light;

    public void setLight(Light light) {
        this.light = light;
    }

    @Override
    public void execute() {
        light.lightOff();
    }

    @Override
    public void undo() {
        light.lightOn();
    }
}

电视、音乐命令类似。

客户端:

public class Client {

    // 所有的命令
    Stack<ICommand> commands = new Stack<>();

    @Test
    protected void test() {
        ...初始化

        // 大门开关遥控
        switchDoor.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, doorOpenCommand, doorCloseCommand);
        });
        // 电灯开关遥控
        switchLight.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, lightOnCommand, lightOffCommand);
        });
        // 电视开关遥控
        switchTv.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, turnOnTvCommand, turnOffTvCommand);
        });
        // 音乐开关遥控
        switchMusic.setOnCheckedChangeListener((view, isChecked) -> {
            handleCommand(isChecked, musicPlayCommand, musicStopCommand);
        });

        // 撤销按钮
        btnUndo.setOnClickListener(view -> {
            if (commands.isEmpty()) return;
            // 撤销上一个命令
            ICommand lastCommand = commands.pop();
            lastCommand.undo();
        });
    }

    private void handleCommand(boolean isChecked, ICommand openCommand, ICommand closeCommand) {
        if (isChecked) {
            commands.push(openCommand);
            openCommand.execute();
        } else {
            commands.push(closeCommand);
            closeCommand.execute();
        }
    }
}

我们同样使用了一个栈结构,用于存储所有的命令,在每次执行命令前,将命令压入栈中。撤销时,弹出栈顶的命令,执行其 undo 方法即可。

命令模式使得客户端的职责更加简洁、清晰了,命令执行、撤销的代码都被隐藏到了命令类中。唯一的缺点是多了很多的命令类,因为我们必须针对每一个命令都设计一个命令类,容易导致类爆炸。

宏命令

在我们学习宏命令前,先来了解一下宏。在使用 word 时,有时会弹出一个提示:是否启用宏?

img

在笔者小的时候(当然现在也没有很老),小小的眼睛里有大大的疑惑:这个 “宏” 是什么意思呢?简简单单一个字,却看起来如此的高大上,一定是一个很难的东西吧。

其实宏一点也不难,宏(英语:Macro)的意思是 “批量处理”,能够帮我们实现合并多个操作。

比如,在 word 中,我们需要设置一个文字加粗、斜体和字号 36。通常来说,我们需要三个步骤:

  • 选中文字,设置加粗
  • 选中文字,设置斜体
  • 选中文字,设置字号 36

如果有一个设置,能一键实现这三个步骤,这个设置就称为一个宏。

如果我们有大量的文字需要这三个设置,定义一个宏就可以省下许多重复操作。

听起来是不是很像格式刷,不过宏远比格式刷要强大。比如宏可以实现将一段文字一键加上 【】,在 Excel 中的宏还可以一键实现 居中 + 排序 等操作。

比如笔者写的一个宏,效果是运行时给两个汉字自动加上中括号:

img

这个宏对应的 vba 代码长这样:

Sub Macro1()
'
' Macro1 Macro
'
'
    Selection.TypeText Text:=ChrW(12304)
    Selection.MoveRight Unit:=wdCharacter, Count:=2
    Selection.TypeText Text:=ChrW(12305)
End Sub

当然 vba 代码只是秀一秀,不是重点。重点是了解了宏,就不难理解宏命令了。宏命令就是将多个命令合并起来组成的命令

接下来我们给遥控器添加一个 “睡眠” 按钮,按下时可以一键关闭大门,关闭电灯,关闭电视、打开音乐(听着音乐睡觉,就是这么优雅)。UI…就不看了吧,这时就可以使用宏命令:

public class MacroCommand implements ICommand {
    // 定义一组命令
    List<ICommand> commands;

    public MacroCommand(List<ICommand> commands) {
        this.commands = commands;
    }

    @Override
    public void execute() {
        // 宏命令执行时,每个命令依次执行
        for (int i = 0; i < commands.size(); i++) {
            commands.get(i).execute();
        }
    }

    @Override
    public void undo() {
        // 宏命令撤销时,每个命令依次撤销
        for (int i = 0; i < commands.size(); i++) {
            commands.get(i).undo();
        }
    }
}

客户端代码如下:

// 定义睡眠宏命令
MacroCommand sleepCommand = new MacroCommand(Arrays.asList(doorCloseCommand, lightOffCommand, turnOffTvCommand, musicPlayCommand));
// 睡眠按钮
btnSleep.setOnClickListener(view -> {
    // 将执行的命令保存到栈中,以便撤销
    commands.push(sleepCommand);
    // 执行睡眠命令
    sleepCommand.execute();
});

有了宏命令,我们就可以任意组合多个命令,并且完全不会增加程序结构的复杂度。因为宏命令使用起来和普通的命令一模一样。

小结

前文的定义中讲到,命令模式还可以用于请求排队。要实现请求排队功能,只需创建一个命令队列,将每个需要执行的命令依次传入队列中,然后工作线程不断地从命令队列取出队列头的命令执行即可。

事实上,安卓 app 的界面就是这么实现的。源码中使用了一个阻塞式死循环 Looper,不断地从 MessageQueue 中取出消息,交给 Handler 处理,用户的每一个操作也会通过 Handler 传递到 MessageQueue 中排队执行。

命令模式可以说将封装发挥得淋漓尽致。在我们平时的程序设计中,最常用的封装是将拥有一类职责的对象封装成类,而命令对象的唯一职责就是通过 execute 去调用一个方法,也就是说它将 “方法调用” 这个步骤封装起来了,使得我们可以对 “方法调用” 进行排队、撤销等处理。

命令模式的主要优点如下:

  • 降低系统的耦合度。将 “行为请求者” 和 ”行为实现者“ 解耦。
  • 扩展性强。增加或删除命令非常方便,并且不会影响其他类。
  • 封装 “方法调用”,方便实现 Undo 和 Redo 操作。
  • 灵活性强,可以实现宏命令。

它的主要缺点是:

  • 会产生大量命令类。增加了系统的复杂性。

15、解释器模式

​ 解释器(Interpreter)模式给定一种语言,并定义该语言的语法表示,然后设计一个解析器来解释语言中的语法,这种模式常被用于SQL解析、符号处理引擎等。

​ 解释器模式包含以下主要角色。

  • 抽象表达式(Abstract Expression):定义解释器的接口,约定解释器所包含的操作,比如interpret()方法。

  • 终结符表达式(Terminal Expression):抽象表达式的子类,用来定义语法中和终结符有关的操作,语法中的每一个终结符都应有一个与之对应的终结表达式。

  • 非终结符表达式(NonterminalExpression):抽象表达式的子类,用来定义语法中和非终结符有关的操作,语法中的每条规则都有一个非终结符表达式与之对应。- 环境(Context):定义各个解释器需要的共享数据或者公共的功能。解释器模式主要用于和语法及表达式有关的应用场景,例如正则表达式解释器等。

在设计模式中,解释器模式就是用来自定义语法的,它的定义如下。

解释器模式(Interpreter Pattern):给定一门语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。

解释器模式较为晦涩难懂,但本文我们仍然深入浅出,通过一个简单的例子来学习解释器模式:使用中文编写出十以内的加减法公式。比如:

  • 输入“一加一”,输出结果 2
  • 输入“一加一加一”,输出结果 3
  • 输入“二加五减三”,输出结果 4
  • 输入“七减五加四减一”,输出结果 5
  • 输入“九减五加三减一”,输出结果 6

看到这个需求,我们很容易想到一种写法:将输入的字符串分割成单个字符,把数字字符通过switch-case转换为数字,再通过计算符判断是加法还是减法,对应做加、减计算,最后返回结果即可。

计划的确可行,但这实在太面向过程了,众所周知面向过程编程会有耦合度高,不易扩展等缺点。接下来我们尝试按照面向对象的写法来实现这个功能。

按照面向对象的编程思想,我们应该为公式中不同种类的元素建立一个对应的对象。那么我们先分析一下公式中的成员:

  • 数字:零到九 对应 0 ~ 9
  • 计算符:加、减 对应 +、-

公式中仅有这两种元素,其中对于数字的处理比较简单,只需要通过switch-case将中文名翻译成阿拉伯数字即可。

计算符怎么处理呢?计算符左右两边可能是单个数字,也可能是另一个计算公式。但无论是数字还是公式,两者都有一个共同点,那就是他们都会返回一个整数:数字返回其本身,公式返回其计算结果。

所以我们可以根据这个共同点提取出一个返回整数的接口,数字和计算符都作为该接口的实现类。在计算时,使用栈结构存储数据,将数字和计算符统一作为此接口的实现类压入栈中计算。

talk is cheap, show me the code.

数字和计算符公共的接口:

interface Expression {
    int intercept();
}

上文已经说到,数字和计算符都属于表达式的一部分,他们的共同点是都会返回一个整数。从表达式计算出整数的过程,我们称之为解释(intercept)。

对数字类的解释实现起来相对比较简单:

public class Number implements Expression {
    int number;

    public Number(char word) {
        switch (word) {
            case '零':
                number = 0;
                break;
            case '一':
                number = 1;
                break;
            case '二':
                number = 2;
                break;
            case '三':
                number = 3;
                break;
            case '四':
                number = 4;
                break;
            case '五':
                number = 5;
                break;
            case '六':
                number = 6;
                break;
            case '七':
                number = 7;
                break;
            case '八':
                number = 8;
                break;
            case '九':
                number = 9;
                break;
            default:
                break;
        }
    }

    @Override
    public int intercept() {
        return number;
    }
}

在 Number 类的构造函数中,先将传入的字符转换为对应的数字。在解释时将转换后的数字返回即可。

无论是加法还是减法,他们都是对左右两个表达式进行操作,所以我们可以将计算符提取出共同的抽象父类:

abstract class Operator implements Expression {
    Expression left;
    Expression right;

    Operator(Expression left, Expression right) {
        this.left = left;
        this.right = right;
    }
}

在此抽象父类中,我们存入了两个变量,表达计算符左右两边的表达式。

加法类实现如下:

class Add extends Operator {

    Add(Expression left, Expression right) {
        super(left, right);
    }

    @Override
    public int intercept() {
        return left.intercept() + right.intercept();
    }
}

减法类:

class Sub extends Operator {

    Sub(Expression left, Expression right) {
        super(left, right);
    }

    @Override
    public int intercept() {
        return left.intercept() - right.intercept();
    }
}

加法类和减法类都继承自 Operator 类,在对他们进行解释时,将左右两边表达式解释出的值相加或相减即可。

数字类和计算符内都定义好了,这时我们只需要再编写一个计算类将他们综合起来,统一计算即可。

计算类:

class Calculator {
    int calculate(String expression) {
        Stack<Expression> stack = new Stack<>();
        for (int i = 0; i < expression.length(); i++) {
            char word = expression.charAt(i);
            switch (word) {
                case '加':
                    stack.push(new Add(stack.pop(), new Number(expression.charAt(++i))));
                    break;
                case '减':
                    stack.push(new Sub(stack.pop(), new Number(expression.charAt(++i))));
                    break;
                default:
                    stack.push(new Number(word));
                    break;
            }
        }
        return stack.pop().intercept();
    }
}

在计算类中,我们使用栈结构保存每一步操作。遍历 expression 公式:

  • 遇到数字则将其压入栈中;
  • 遇到计算符时,先将栈顶元素 pop 出来,再和下一个数字一起传入计算符的构造函数中,组成一个计算符公式压入栈中。

需要注意的是,入栈出栈过程并不会执行真正的计算,栈操作只是将表达式组装成一个嵌套的类对象而已。比如:

  • “一加一”表达式,经过入栈出栈操作后,生成的对象是 new Add(new Number('一'), new Number('一'))
  • “二加五减三”表达式,经过入栈出栈操作后,生成的对象是 `new Sub(new Add(new Number(‘二’), new Number(‘五’)), new Number(‘三’))`

最后一步 stack.pop().intercept(),将栈顶的元素弹出,执行 intercept() ,这时才会执行真正的计算。计算时会将中文的数字和运算符分别解释成计算机能理解的指令。

测试类:

public class Client {
    @Test
    public void test() {
        Calculator calculator = new Calculator();
        String expression1 = "一加一";
        String expression2 = "一加一加一";
        String expression3 = "二加五减三";
        String expression4 = "七减五加四减一";
        String expression5 = "九减五加三减一";
        // 输出: 一加一 等于 2
        System.out.println(expression1 + " 等于 " + calculator.calculate(expression1));
        // 输出: 一加一加一 等于 3
        System.out.println(expression2 + " 等于 " + calculator.calculate(expression2));
        // 输出: 二加五减三 等于 4
        System.out.println(expression3 + " 等于 " + calculator.calculate(expression3));
        // 输出: 七减五加四减一 等于 5
        System.out.println(expression4 + " 等于 " + calculator.calculate(expression4));
        // 输出: 九减五加三减一 等于 6
        System.out.println(expression5 + " 等于 " + calculator.calculate(expression5));
    }
}

这就是解释器模式,我们将一句中文的公式解释给计算机,然后计算机为我们运算出了正确的结果。

分析本例中公式的组成,我们可以发现几条显而易见的性质:

  • 数字类不可被拆分,属于计算中的最小单元;
  • 加法类、减法类可以被拆分成两个数字(或两个公式)加一个计算符,他们不是计算的最小单元。

在解释器模式中,我们将不可拆分的最小单元称之为终结表达式,可以被拆分的表达式称之为非终结表达式。

解释器模式具有一定的拓展性,当需要添加其他计算符时,我们可以通过添加 Operator 的子类来完成。但添加后需要按照运算优先级修改计算规则。可见一个完整的解释器模式是非常复杂的,实际开发中几乎没有需要自定义解释器的情况。

解释器模式有一个常见的应用,在我们平时匹配字符串时,用到的正则表达式就是一个解释器。正则表达式中,表示一个字符的表达式属于终结表达式,除终结表达式外的所有表达式都属于非终结表达式。

16、迭代器模式

​ 迭代器(Iterator)模式提供了顺序访问集合对象中的各种元素,而不暴露该对象内部结构的方法。

​ Java中的集合就是典型的迭代器模式,比如HashMap,在我们需要遍历HashMap时,通过迭代器不停地获取Next元素就可以循环遍历集合中的所有元素。迭代器模式将遍历集合中所有元素的操作封装成迭代器类,其目的是在不暴露集合对象内部结构的情况下,对外提供统一访问集合的内部数据的方法。

​ 迭代器的实现一般包括一个迭代器,用于执行具体的遍历操作;以及一个Collection,用于存储具体的数据。我们以Collection集合的迭代器设计为例介绍迭代器模式的设计思路。

设想一个场景:我们有一个类中存在一个列表。这个列表需要提供给外部类访问,但我们不希望外部类修改其中的数据。

public class MyList {
    private List<String> data = Arrays.asList("a", "b", "c");
}

通常来说,将成员变量提供给外部类访问有两种方式:

  • 将此列表设置为 public 变量;
  • 添加 getData() 方法,返回此列表。

但这两种方式都有一个致命的缺点,它们无法保证外部类不修改其中的数据。外部类拿到 data 对象后,可以随意修改列表内部的元素,这会造成极大的安全隐患。

那么有什么更好的方式吗?使得外部类只能读取此列表中的数据,无法修改其中的任何数据,保证其安全性。

分析可知,我们可以通过提供两个方法实现此效果:

  • 提供一个 String next() 方法,使得外部类可以按照次序,一条一条的读取数据;
  • 提供一个 boolean hasNext() 方法,告知外部类是否还有下一条数据。

代码实现如下:

public class MyList {
    private List<String> data = Arrays.asList("a", "b", "c");
    private int index = 0;

    public String next() {
        // 返回数据后,将 index 加 1,使得下次访问时返回下一条数据
        return data.get(index++);
    }

    public boolean hasNext() {
        return index < data.size();
    }
}

客户端就可以使用一个 while 循环来访问此列表了:

public class Client {
    @Test
    public void test() {
        MyList list = new MyList();
        // 输出:abc
        while (list.hasNext()) {
            System.out.print(list.next());
        }
    }
}

由于没有给外部类暴露 data 成员变量,所以我们可以保证数据是安全的。

但这样的实现还有一个问题:当遍历完成后,hasNext() 方法就会一直返回 false,无法再一次遍历了,所以我们必须在一个合适的地方把 index 重置成 0。

在哪里重置比较合适呢?实际上,使用 next() 方法和 hasNext() 方法来遍历列表是一个完全通用的方法,我们可以为其创建一个接口,取名为 Iterator,Iterator 的意思是迭代器,迭代的意思是重复反馈,这里是指我们依次遍历列表中的元素。

public interface Iterator {

    boolean hasNext();

    String next();
}

然后在 MyList 类中,每次遍历时生成一个迭代器,将 index 变量放到迭代器中。由于每个迭代器都是新生成的,所以每次遍历时的 index 自然也就被重置成 0 了。代码如下:

public class MyList {
    private List<String> data = Arrays.asList("a", "b", "c");

    // 每次生成一个新的迭代器,用于遍历列表
    public Iterator iterator() {
        return new Itr();
    }

    private class Itr implements Iterator {
        private int index = 0;

        @Override
        public boolean hasNext() {
            return index < data.size();
        }

        @Override
        public String next() {
            return data.get(index++);
        }
    }
}

客户端访问此列表的代码修改如下:

public class Client {
    @Test
    public void test() {
        MyList list = new MyList();
        // 获取迭代器,用于遍历列表
        Iterator iterator = list.iterator();
        // 输出:abc
        while (iterator.hasNext()) {
            System.out.print(iterator.next());
        }
    }
}

这就是迭代器模式,《设计模式》一书中将其定义如下:

迭代器模式(Iterator Pattern):提供一种方法访问一个容器对象中各个元素,而又不需暴露该对象的内部细节。

迭代器模式的核心就在于定义出 next() 方法和 hasNext() 方法,让外部类使用这两个方法来遍历列表,以达到隐藏列表内部细节的目的。

事实上,Java 已经为我们内置了 Iterator 接口,源码中使用了泛型使得此接口更加的通用:

public interface Iterator<E> {
    boolean hasNext();
    E next();
}

并且,本例中使用的迭代器模式是仿照 ArrayList 的源码实现的,ArrayList 源码中使用迭代器模式的部分代码如下:

public class ArrayList<E> {
    ...
    
    public Iterator<E> iterator() {
        return new Itr();
    }
    
    private class Itr implements Iterator<E> {
        protected int limit = ArrayList.this.size;
        int cursor;
        
        public boolean hasNext() {
            return cursor < limit;
        }

        public E next() {
            ...
        }
    }
}

我们平时常用的 for-each 循环,也是迭代器模式的一种应用。在 Java 中,只要实现了 Iterable 接口的类,都被视为可迭代访问的。Iterable 中的核心方法只有一个,也就是刚才我们在 MyList 类中实现过的用于获取迭代器的 iterator() 方法:

public interface Iterable<T> {
    Iterator<T> iterator();
}

只要我们将 MyList 类修改为继承此接口,便可以使用 for-each 来迭代访问其中的数据了:

public class MyList implements Iterable<String> {
    private List<String> data = Arrays.asList("a", "b", "c");

    @NonNull
    @Override
    public Iterator<String> iterator() {
        // 每次生成一个新的迭代器,用于遍历列表
        return new Itr();
    }

    private class Itr implements Iterator<String> {
        private int index = 0;

        @Override
        public boolean hasNext() {
            return index < data.size();
        }

        @Override
        public String next() {
            return data.get(index++);
        }
    }
}

客户端使用 for-each 访问:

public class Client {
    @Test
    public void test() {
        MyList list = new MyList();
        // 输出:abc
        for (String item : list) {
            System.out.print(item);
        }
    }
}

这就是迭代器模式。基本上每种语言都会在源码层面为所有列表提供迭代器,我们只需要直接拿来用即可,这是一个比较简单又很常用的设计模式。

17、中介者模式

​ 中介者(Mediator)模式指对象和对象之间不直接交互,而是通过一个名为中介者的角色来实现对象之间的交互,使原有对象之间的关系变得松散,且可以通过定义不同的中介者来改变它们之间的交互。中介者模式又叫作调停模式,是迪米特法则的典型应用。

​ 中介者模式属于对象行为型模式,其主要特点是将对象与对象之间的关系变为对象和中介者之间的关系,降低了对象之间的耦合性,提高了对象功能的复用性和系统的灵活性,使得系统易于维护和扩展。

​ 中介者模式包含以下主要角色。

  • 抽象中介者(Mediator):中介者接口,定义了注册同事对象方法和转发同事对象信息的方法。

  • 具体中介者(Concrete Mediator):中介者接口的实现类,定义了一个List来保存同事对象,协调各个同事角色之间的交互关系。

  • 抽象同事类(Colleague):定义同事类的接口,持有中介者对象,并定义同事对象交互的抽象方法,同时实现同事类的公共方法和功能。

  • 具体同事类(Concrete Colleague):抽象同事类的实现者,在需要与其他同事对象交互时,通过中介者对象来完成。

​ 顾名思义,中介这个名字对我们来说实在太熟悉了。平时走在上班路上就会经常见到各种房产中介。他们的工作就是使得买家与卖家不需要直接打交道,只需要分别与中介打交道,就可以完成交易,用计算机术语来说就是减少了耦合度。

​ 当类与类之间的关系呈现网状时,引入一个中介者,可以使类与类之间的关系变成星形。将每个类与多个类的耦合关系简化为每个类与中介者的耦合关系。

举个例子,在我们打麻将时,每两个人之间都可能存在输赢关系。如果每笔交易都由输家直接发给赢家,就会出现一种网状耦合关系。

img

我们用程序来模拟一下这个过程。

玩家类:

class Player {
    // 初始资金 100 元
    public int money = 100;

    public void win(Player player, int money) {
        // 输钱的人扣减相应的钱
        player.money -= money;
        // 自己的余额增加
        this.money += money;
    }
}

此类中有一个 money 变量,表示自己的余额。当自己赢了某位玩家的钱时,调用 win 方法修改输钱的人和自己的余额。

需要注意的是,我们不需要输钱的方法,因为在 win 方法中,已经将输钱的人对应余额扣除了。

客户端代码:

public class Client {
    @Test
    public void test() {
        Player player1 = new Player();
        Player player2 = new Player();
        Player player3 = new Player();
        Player player4 = new Player();
        // player1 赢了 player3 5 元
        player1.win(player3, 5);
        // player2 赢了 player1 10 元
        player2.win(player1, 10);
        // player2 赢了 player4 10 元
        player2.win(player4, 10);
        // player4 赢了 player3 7 元
        player4.win(player3, 7);

        // 输出:四人剩余的钱:105,120,88,97
        System.out.println("四人剩余的钱:" + player1.money + "," + player2.money + "," + player3.money + "," + player4.money);
    }
}

在客户端中,每两位玩家需要进行交易时,都会增加程序耦合度,相当于每位玩家都需要和其他所有玩家打交道,这是一种不好的做法。

此时,我们可以引入一个中介类——微信群,只要输家将自己输的钱发到微信群里,赢家从微信群中领取对应金额即可。网状的耦合结构就变成了星形结构:

img

此时,微信群就充当了一个中介者的角色,由它来负责与所有人进行交易,每个玩家只需要与微信群打交道即可。

微信群类:

class Group {
    public int money;
}

此类中只有一个 money 变量表示群内的余额。

玩家类修改如下:

class Player {
    public int money = 100;
    public Group group;

    public Player(Group group) {
        this.group = group;
    }

    public void change(int money) {
        // 输了钱将钱发到群里 或 在群里领取自己赢的钱
        group.money += money;
        // 自己的余额改变
        this.money += money;
    }
}

玩家类中新增了一个构造方法,在构造方法中将中介者传进来。每当自己有输赢时,只需要将钱发到群里或者在群里领取自己赢的钱,然后修改自己的余额即可。

客户端代码对应修改如下:

public class Client {
    @Test
    public void test(){
        Group group = new Group();
        Player player1 = new Player(group);
        Player player2 = new Player(group);
        Player player3 = new Player(group);
        Player player4 = new Player(group);
        // player1 赢了 5 元
        player1.change(5);
        // player2 赢了 20 元
        player2.change(20);
        // player3 输了 12 元
        player3.change(-12);
        // player4 输了 3 元
        player4.change(-3);

        // 输出:四人剩余的钱:105,120,88,97
        System.out.println("四人剩余的钱:" + player1.money + "," + player2.money + "," + player3.money + "," + player4.money);
    }
}

可以看到,通过引入中介者,客户端的代码变得更加清晰了。大家不需要再互相打交道,所有交易通过中介者完成即可。

事实上,这段代码还存在一点不足。因为我们忽略了一个前提:微信群里的钱不可以为负数。也就是说,输家必须先将钱发到微信群内,赢家才能去微信群里领钱

总而言之,中介者模式就是用于将类与类之间的 多对多关系 简化成 多对一、一对多关系 的设计模式,它的定义如下:

中介者模式(Mediator Pattern):定义一个中介对象来封装一系列对象之间的交互,使原有对象之间的耦合松散,且可以独立地改变它们之间的交互。

中介者模式的缺点也很明显:由于它将所有的职责都移到了中介者类中,也就是说中介类需要处理所有类之间的协调工作,这可能会使中介者演变成一个超级类。所以使用中介者模式时需要权衡利弊。

18、备忘录模式

​ 备忘录(Memento)模式又叫作快照模式,该模式将当前对象的内部状态保存到备忘录中,以便在需要时能将该对象的状态恢复到原先保存的状态。备忘录模式提供了一种保存和恢复状态的机制,常用于快照的记录和状态的存储,在系统发生故障或数据发生不一致时能够方便地将数据恢复到某个历史状态。

​ 备忘录模式最常见的实现莫过于游戏中的存档、读档功能了,通过存档、读档,使得我们可以随时恢复到之前的状态。

当我们在玩游戏时,打大 Boss 之前,通常会将自己的游戏进度存档保存,以防打不过 Boss 的话,还能重新读档恢复状态。

玩家类:

class Player {
    // 生命值
    private int life = 100;
    // 魔法值
    private int magic = 100;

    public void fightBoss() {
        life -= 100;
        magic -= 100;
        if (life <= 0) {
            System.out.println("壮烈牺牲");
        }
    }

    public int getLife() {
        return life;
    }

    public void setLife(int life) {
        this.life = life;
    }

    public int getMagic() {
        return magic;
    }

    public void setMagic(int magic) {
        this.magic = magic;
    }
}

我们为玩家定义了两个属性:生命值和魔法值。其中有一个 fightBoss() 方法,每次打 Boss 都会扣减 100 点体力。如果生命值小于等于 0,则提示用户已“壮烈牺牲”。

客户端实现如下:

public class Client {
    @Test
    public void test() {
        Player player = new Player();
        // 存档
        int savedLife = player.getLife();
        int savedMagic = player.getMagic();

        // 打 Boss,打不过,壮烈牺牲
        player.fightBoss();

        // 读档,恢复到打 Boss 之前的状态
        player.setLife(savedLife);
        player.setMagic(savedMagic);
    }
}

客户端中,我们在 fightBoss() 之前,先去存档,把自己当前的生命值和魔法值保存起来。打完 Boss 发现自己牺牲之后,再回去读档,将自己恢复到打 Boss 之前的状态。

这就是备忘录模式…吗?不完全是,事情并没有这么简单。

还记得我们在原型模式中,买的那杯和周杰伦一模一样的奶茶吗?开始时,为了克隆一杯奶茶,我们将奶茶的各个属性分别赋值成和周杰伦买的那杯奶茶一样。但这样存在一个弊端:我们不可能为一千个粉丝写一千份挨个赋值操作。所以最终我们在奶茶类内部实现了 Cloneable 接口,定义了 clone() 方法,来实现一行代码拷贝所有属性。

备忘录模式也应该采取类似的做法。我们不应该采用将单个属性挨个存取的方式来进行读档、存档。更好的做法是将存档、读档交给需要存档的类内部去实现。

新建备忘录类:

class Memento {
    int life;
    int magic;

    Memento(int life, int magic) {
        this.life = life;
        this.magic = magic;
    }
}

在此类中,管理需要存档的数据。

玩家类中,通过备忘录类实现存档、读档:

class Player {
    ...

    // 存档
    public Memento saveState() {
        return new Memento(life, magic);
    }

    // 读档
    public void restoreState(Memento memento) {
        this.life = memento.life;
        this.magic = memento.magic;
    }
}

客户端类对应修改如下:

public class Client {
    @Test
    public void test() {
        Player player = new Player();
        // 存档
        Memento memento = player.saveState();

        // 打 Boss,打不过,壮烈牺牲
        player.fightBoss();

        // 读档
        player.restoreState(memento);
    }
}

这才是完整的备忘录模式。这个设计模式的定义如下:

备忘录模式:在不破坏封装的条件下,通过备忘录对象存储另外一个对象内部状态的快照,在将来合适的时候把这个对象还原到存储起来的状态。

备忘录模式的优点是:

  • 给用户提供了一种可以恢复状态的机制,可以使用户能够比较方便的回到某个历史的状态
  • 实现了信息的封装,使得用户不需要关心状态的保存细节

缺点是:

  • 消耗资源,如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存。

总体而言,备忘录模式是利大于弊的,所以许多程序都为用户提供了备份方案。比如 IDE 中,用户可以将自己的设置导出成 zip,当需要恢复设置时,再将导出的 zip 文件导入即可。这个功能内部的原理就是备忘录模式。

19、观察者模式

观察者模式非常常见,近年来逐渐流行的响应式编程就是观察者模式的应用之一。观察者模式的思想就是一个对象发生一个事件后,逐一通知监听着这个对象的监听者,监听者可以对这个事件马上做出响应。生活中有很多观察者模式的例子,比如我们平时的开关灯。当我们打开灯的开关时,灯马上亮了;当我们关闭灯的开关时,灯马上熄了。这个过程中,灯就对我们控制开关的事件做出了响应,这就是一个最简单的一对一观察者模式。当力扣公众号发表一篇文章,所有关注了公众号的读者立即收到了文章,这个过程中所有关注了公众号的微信客户端就对公众号发表文章的事件做出了响应,这就是一个典型的一对多观察者模式。再举个例子,比如警察一直观察着张三的一举一动,只要张三有什么违法行为,警察马上行动,抓捕张三。这个过程中:

  • 警察称之为观察者(Observer)
  • 张三称之为被观察者(Observable,可观察的)
  • 警察观察张三的这个行为称之为订阅(subscribe),或者注册(register)
  • 张三违法后,警察抓捕张三的行动称之为响应(update)

众所周知,张三坏事做尽,是一个老法外狂徒了,所以不止一个警察会盯着张三,也就是说一个被观察者可以有多个观察者。当被观察者有事件发生时,所有观察者都能收到通知并响应。观察者模式主要处理的是一种一对多的依赖关系。它的定义如下:

观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

我们使用程序来模拟一下这个过程。

观察者的接口:

public interface Observer {
    void update(String event);
}

接口中只有一个 update 方法,用于对被观察者发出的事件做出响应。

被观察者的父类:

public class Observable {

    private List<Observer> observers = new ArrayList<>();

    public void addObserver(Observer observer) {
        observers.add(observer);
    }

    public void removeObserver(Observer observer) {
        observers.remove(observer);
    }

    public void notifyObservers(String event) {
        for (Observer observer : observers) {
            observer.update(event);
        }
    }
}

被观察者中维护了一个观察者列表,提供了三个方法:

  • addObserver:将 observer 对象添加到观察者列表中
  • removeObserver:将 observer 对象从观察者列表中移除
  • notifyObservers:通知所有观察者有事件发生,具体实现是调用所有观察者的 update 方法

有了这两个基类,我们就可以定义出具体的罪犯与警察类。

警察属于观察者:

public class PoliceObserver implements Observer {
    @Override
    public void update(String event) {
        System.out.println("警察收到消息,罪犯在" + event);
    }
}

警察实现了观察者接口,当警察收到事件后,做出响应,这里的响应就是简单的打印了一条日志。

罪犯属于被观察者:

public class CriminalObservable extends Observable {
    public void crime(String event) {
        System.out.println("罪犯正在" + event);
        notifyObservers(event);
    }
}

罪犯继承自被观察者类,当罪犯有犯罪行为时,所有的观察者都会收到通知。

客户端测试:

public class Client {
    @Test
    public void test() {
        CriminalObservable zhangSan = new CriminalObservable();
        PoliceObserver police1 = new PoliceObserver();
        PoliceObserver police2 = new PoliceObserver();
        PoliceObserver police3 = new PoliceObserver();
        zhangSan.addObserver(police1);
        zhangSan.addObserver(police2);
        zhangSan.addObserver(police3);
        zhangSan.crime("放狗咬人");
    }
}

在客户端中,我们 new 了一个张三,为其添加了三个观察者:police1,police2,police3。

运行程序,输出如下:

罪犯正在放狗咬人
警察收到消息,罪犯在放狗咬人
警察收到消息,罪犯在放狗咬人
警察收到消息,罪犯在放狗咬人

可以看到,所有的观察者都被通知到了。当某个观察者不需要继续观察时,调用 removeObserver 即可。

这就是观察者模式,它并不复杂,由于生活中一对多的关系非常常见,所以观察者模式应用广泛。

Java 源码中的观察者模式

实际上,Java 已经为我们提供了的 Observable 类和 Observer 类,我们在用到观察者模式时,无需自己创建这两个基类,我们来看一下 Java 中提供的源码:

java.util.Observer 类:

public interface Observer {
    void update(Observable o, Object arg);
}

Observer 类和我们上例中的定义基本一致,都是只有一个 update 方法用于响应 Observable 的事件。区别有两点:

  • update 方法将 Observable 对象也提供给了 Observer
  • update 方法中的参数类型变成了 Object

这两点区别都是为了保证此 Observer 的适用范围更广。

java.util.Observable 类:

public class Observable {
    private boolean changed = false;
    private Vector<Observer> obs;

    public Observable() {
        obs = new Vector<>();
    }

    public synchronized void addObserver(java.util.Observer o) {
        if (o == null)
            throw new NullPointerException();
        if (!obs.contains(o)) {
            obs.addElement(o);
        }
    }

    public synchronized void deleteObserver(java.util.Observer o) {
        obs.removeElement(o);
    }

    public void notifyObservers() {
        notifyObservers(null);
    }

    public void notifyObservers(Object arg) {
        Object[] arrLocal;
        synchronized (this) {
            if (!hasChanged())
                return;
            arrLocal = obs.toArray();
            clearChanged();
        }
        for (int i = arrLocal.length - 1; i >= 0; i--)
            ((Observer) arrLocal[i]).update(this, arg);
    }

    public synchronized void deleteObservers() {
        obs.removeAllElements();
    }

    protected synchronized void setChanged() {
        changed = true;
    }

    protected synchronized void clearChanged() {
        changed = false;
    }

    public synchronized boolean hasChanged() {
        return changed;
    }

    public synchronized int countObservers() {
        return obs.size();
    }
}

Observable 类和我们上例中的定义也是类似的,区别在于:

  • 用于保存观察者列表的容器不是 ArrayList,而是 Vector
  • 添加了一个 changed 字段,以及 setChanged 和 clearChanged 方法。分析可知,当 changed 字段为 true 时,才会通知所有观察者,否则不通知观察者。所以当我们使用此类时,想要触发 notifyObservers 方法,必须先调用 setChanged 方法。这个字段相当于在被观察者和观察者之间添加了一个可控制的阀门。
  • 提供了 countObservers 方法,用于计算观察者数量
  • 添加了一些 synchronized 关键字保证线程安全

这些区别仍然是为了让 Observable 的适用范围更广,核心思想与本文介绍的都是一致的。

20、状态模式

​ 状态模式指给对象定义不同的状态,并为不同的状态定义不同的行为,在对象的状态发生变换时自动切换状态的行为。状态模式是一种对象行为型模式,它将对象的不同行为封装到不同的状态中,遵循了“单一职责”原则。同时,状态模式基于对象的状态将对象行为进行了明确的界定,减少了对象行为之间的相互依赖,方便系统的扩展和维护。状态模式在生活中很常见,比如日常生活有工作状态、休假状态;钉钉有出差、会议、工作中等状态。每种状态都对应不同的操作,比如工作状态对应的行为有开会、写PPT、写代码、做设计等,休假状态对应的行为有旅游、休息、陪孩子等。

状态模式把受环境改变的对象行为包装在不同的状态对象里,用于让一个对象在其内部状态改变时,行为也随之改变。具体的角色如下。

  • 环境(Context):也叫作上下文,用于维护对象当前的状态,并在对象状态发生变化时触发对象行为的变化。

  • 抽象状态(AbstractState):定义了一个接口,用于定义对象中不同状态所对应的行为。

  • 具体状态(Concrete State):实现抽象状态所定义的行为。

状态模式(State Pattern):当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。

通俗地说,状态模式就是一个关于多态的设计模式。

如果一个对象有多种状态,并且每种状态下的行为不同,一般的做法是在这个对象的各个行为中添加 if-else 或者 switch-case 语句。但更好的做法是为每种状态创建一个状态对象,使用状态对象替换掉这些条件判断语句,使得状态控制更加灵活,扩展性也更好。

举个例子,力扣的用户有两种状态:普通用户和 PLUS 会员。PLUS 会员有非常多的专享功能,其中“模拟面试”功能非常有特色,我们便以此为例。

  • 当普通用户点击模拟面试功能时,提示用户:模拟面试是 Plus 会员专享功能;
  • 当 PLUS 会员点击模拟面试功能时,开始一场模拟面试。

先来看看不使用状态模式的写法,看出它的缺点后,我们再用状态模式来重构代码。

首先定义一个用户状态枚举类:

public enum State {
    NORMAL, PLUS
}

NORMAL 代表普通用户状态,PLUS 代表 PLUS 会员状态。

用户的功能接口:

public interface IUser {
    void mockInterview();
}

本例中我们只定义了一个模拟面试的方法,实际开发中这里可能会有许许多多的方法。

用户状态切换接口:

public interface ISwitchState {
    void purchasePlus();

    void expire();
}

此接口中定义了两个方法:purchasePlus 方法表示购买 Plus 会员,用户状态变为 PLUS 会员状态,expire 方法表示会员过期,用户状态变为普通用户状态。

力扣用户类:

public class User implements IUser, ISwitchState {
    private State state = State.NORMAL;

    @Override
    public void mockInterview() {
        if (state == State.PLUS) {
            System.out.println("开始模拟面试");
        } else {
            System.out.println("模拟面试是 Plus 会员专享功能");
        }
    }

    @Override
    public void purchasePlus() {
        state = State.PLUS;
    }

    @Override
    public void expire() {
        state = State.NORMAL;
    }
}

用户类实现了 IUser 接口,IUser 接口中的每个功能都需要判断用户是否为 Plus 会员,也就是说每个方法中都有 if (state == State.PLUS) {} else {} 语句,如果状态不止两种,还需要用上 switch-case 语句来判断状态,这就是不使用状态模式的弊端:

  • 判断用户状态会产生大量的分支判断语句,导致代码冗长;
  • 当状态有增加或减少时,需要改动多个地方,违反开闭原则。

在《代码整洁之道》、《重构》两本书中都提到:应使用多态取代条件表达式。接下来我们就利用多态特性重构这份代码。为每个状态新建一个状态类,普通用户:

class Normal implements IUser {

    @Override
    public void mockInterview() {
        System.out.println("模拟面试是 Plus 会员专享功能");
    }
}

PLUS 会员:

class Plus implements IUser {

    @Override
    public void mockInterview() {
        System.out.println("开始模拟面试");
    }
}

每个状态类都实现了 IUser 接口,在接口方法中实现自己特定的行为。

用户类:

class User implements IUser, ISwitchState {

    IUser state = new Normal();

    @Override
    public void mockInterview() {
        state.mockInterview();
    }

    @Override
    public void purchasePlus() {
        state = new Plus();
    }

    @Override
    public void expire() {
        state = new Normal();
    }
}

可以看到,丑陋的状态判断语句消失了,无论 IUser 接口中有多少方法,User 类都只需要调用状态类的对应方法即可。

客户端测试:

public class Client {

    @Test
    public void test() {
        // 用户初始状态为普通用户
        User user = new User();
        // 输出:模拟面试是 Plus 会员专享功能
        user.mockInterview();

        // 用户购买 Plus 会员,状态改变
        user.purchasePlus();
        // 输出:开始模拟面试
        user.mockInterview();

        // Plus 会员过期,变成普通用户,状态改变
        user.expire();
        // 输出:模拟面试是 Plus 会员专享功能
        user.mockInterview();
    }
}

可以看到,用户状态改变后,行为也随着改变了,这就是状态模式定义的由来,它的优点是:将与特定状态相关的行为封装到一个状态对象中,使用多态代替 if-else 或者 switch-case 状态判断。缺点是必然导致类增加,这也是使用多态不可避免的缺点。

21、策略模式

​ 策略模式(Strategy Pattern)为同一个行为定义了不同的策略,并为每种策略都实现了不同的方法。在用户使用的时候,系统根据不同的策略自动切换不同的方法来实现策略的改变。同一个策略下的不同方法是对同一功能的不同实现,因此在使用时可以相互替换而不影响用户的使用。

​ 策略模式的实现是在接口中定义不同的策略,在实现类中完成了对不同策略下具体行为的实现,并将用户的策略状态存储在上下文(Context)中来完成策略的存储和状态的改变。

​ 我们在现实生活中常常碰到实现目标有多种可选策略的情况,比如下班后可以通过开车、坐公交、坐地铁、骑自行回家,在旅行时可以选择火车、飞机、汽车等交通工具,在淘宝上购买指定商品时可以选择直接减免部分钱、送赠品、送积分等方式。对于上述情况,使用多重if …else条件转移语句也可实现,但属于硬编码方式,这样做不但会使代码复杂、难懂,而且在增加、删除、更换算法时都需要修改源代码,不易维护,违背了开闭原则。通过策略模式就能优雅地解决这些问题。

​ 策略模式用一个成语就可以概括 —— 殊途同归。当我们做同一件事有多种方法时,就可以将每种方法封装起来,在不同的场景选择不同的策略,调用不同的方法。

策略模式(Strategy Pattern):定义了一系列算法,并将每一个算法封装起来,而且使它们还可以相互替换。策略模式让算法独立于使用它的客户而独立变化。

我们以排序算法为例。排序算法有许多种,如冒泡排序、选择排序、插入排序,算法不同但目的相同,我们可以将其定义为不同的策略,让用户自由选择采用哪种策略完成排序。

首先定义排序算法接口:

interface ISort {
    void sort(int[] arr);
}

接口中只有一个 sort 方法,传入一个整型数组进行排序,所有的排序算法都实现此接口。

冒泡排序:

class BubbleSort implements ISort{
    @Override
    public void sort(int[] arr) {
        for (int i = 0; i < arr.length - 1; i++) {
            for (int j = 0; j < arr.length - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    // 如果左边的数大于右边的数,则交换,保证右边的数字最大
                    arr[j + 1] = arr[j + 1] + arr[j];
                    arr[j] = arr[j + 1] - arr[j];
                    arr[j + 1] = arr[j + 1] - arr[j];
                }
            }
        }
    }
}

选择排序:

class SelectionSort implements ISort {
    @Override
    public void sort(int[] arr) {
        int minIndex;
        for (int i = 0; i < arr.length - 1; i++) {
            minIndex = i;
            for (int j = i + 1; j < arr.length; j++) {
                if (arr[minIndex] > arr[j]) {
                    // 记录最小值的下标
                    minIndex = j;
                }
            }
            // 将最小元素交换至首位
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
    }
}

插入排序:

class InsertSort implements ISort {
    @Override
    public void sort(int[] arr) {
        // 从第二个数开始,往前插入数字
        for (int i = 1; i < arr.length; i++) {
            int currentNumber = arr[i];
            int j = i - 1;
            // 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
            while (j >= 0 && currentNumber < arr[j]) {
                arr[j + 1] = arr[j];
                j--;
            }
            // 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
            // 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
            arr[j + 1] = currentNumber;
        }
    }
}

这三种都是基本的排序算法,就不再详细介绍了。接下来我们需要创建一个环境类,将每种算法都作为一种策略封装起来,客户端将通过此环境类选择不同的算法完成排序。

class Sort implements ISort {

    private ISort sort;

    Sort(ISort sort) {
        this.sort = sort;
    }

    @Override
    public void sort(int[] arr) {
        sort.sort(arr);
    }

    // 客户端通过此方法设置不同的策略
    public void setSort(ISort sort) {
        this.sort = sort;
    }
}

在此类中,我们保存了一个 ISort 接口的实现对象,在构造方法中,将其初始值传递进来,排序时调用此对象的 sort 方法即可完成排序。

我们也可以为 ISort 对象设定一个默认值,客户端如果没有特殊需求,直接使用默认的排序策略即可。

setSort 方法就是用来选择不同的排序策略的,客户端调用如下:

public class Client {
    @Test
    public void test() {
        int[] arr = new int[]{6, 1, 2, 3, 5, 4};
        Sort sort = new Sort(new BubbleSort());
        // 这里可以选择不同的策略完成排序
        // sort.setSort(new InsertSort());
        // sort.setSort(new SelectionSort());
        sort.sort(arr);
        // 输出 [1, 2, 3, 4, 5, 6]
        System.out.println(Arrays.toString(arr));
    }
}

这就是基本的策略模式,通过策略模式我们可以为同一个需求选择不同的算法,以应付不同的场景。比如我们知道冒泡排序和插入排序是稳定的,而选择排序是不稳定的,当我们需要保证排序的稳定性就可以采用冒泡排序和插入排序,不需要保证排序的稳定性时可以采用选择排序。

策略模式还可以应用在图片缓存中,当我们开发一个图片缓存框架时,可以通过提供不同的策略类,让用户根据需要选择缓存解码后的图片、缓存未经解码的数据或者不缓存任何内容。在一些开源的图片加载框架中,就采用了这种设计。

策略模式扩展性和灵活性都相当不错。当有新的策略时,只需要增加一个策略类;要修改某个策略时,只需要更改具体的策略类,其他地方的代码都无需做任何调整。

但现在这样的策略模式还有一个弊端,如本系列第一篇文章中的工厂模式所言:每 new 一个对象,相当于调用者多知道了一个类,增加了类与类之间的联系,不利于程序的松耦合。

所以使用策略模式时,更好的做法是与工厂模式结合,将不同的策略对象封装到工厂类中,用户只需要传递不同的策略类型,然后从工厂中拿到对应的策略对象即可。接下来我们就来一起实现这种工厂模式与策略模式结合的混合模式。

创建排序策略枚举类:

enum SortStrategy {
    BUBBLE_SORT,
    SELECTION_SORT,
    INSERT_SORT
}

在 Sort 类中使用简单工厂模式:

class Sort implements ISort {

    private ISort sort;

    Sort(SortStrategy strategy) {
        setStrategy(strategy);
    }

    @Override
    public void sort(int[] arr) {
        sort.sort(arr);
    }

    // 客户端通过此方法设置不同的策略
    public void setStrategy(SortStrategy strategy) {
        switch (strategy) {
            case BUBBLE_SORT:
                sort = new BubbleSort();
                break;
            case SELECTION_SORT:
                sort = new SelectionSort();
                break;
            case INSERT_SORT:
                sort = new InsertSort();
                break;
            default:
                throw new IllegalArgumentException("There's no such strategy yet.");
        }
    }
}

利用简单工厂模式,我们将创建策略类的职责移到了 Sort 类中。如此一来,客户端只需要和 Sort 类打交道,通过 SortStrategy 选择不同的排序策略即可。

客户端:

public class Client {
    @Test
    public void test() {
        int[] arr = new int[]{6, 1, 2, 3, 5, 4};
        Sort sort = new Sort(SortStrategy.BUBBLE_SORT);
        // 可以通过选择不同的策略完成排序
        // sort.setStrategy(SortStrategy.SELECTION_SORT);
        // sort.setStrategy(SortStrategy.INSERT_SORT);
        sort.sort(arr);
        // 输出 [1, 2, 3, 4, 5, 6]
        System.out.println(Arrays.toString(arr));
    }
}

通过简单工厂模式与策略模式的结合,我们最大化地减轻了客户端的压力。这是我们第一次用到混合模式,但实际开发中会遇到非常多的混合模式,学习设计模式的过程只能帮助我们各个击破,真正融会贯通还需要在实际开发中多加操练。

需要注意的是,策略模式与状态模式非常类似,甚至他们的 UML 类图都是一模一样的。两者都是采用一个变量来控制程序的行为。策略模式通过不同的策略执行不同的行为,状态模式通过不同的状态值执行不同的行为。两者的代码很类似,他们的区别主要在于程序的目的不同。

  • 使用策略模式时,程序只需选择一种策略就可以完成某件事。也就是说每个策略类都是完整的,都能独立完成这件事情,如上文所言,强调的是殊途同归
  • 使用状态模式时,程序需要在不同的状态下不断切换才能完成某件事,每个状态类只能完成这件事的一部分,需要所有的状态类组合起来才能完整的完成这件事,强调的是随势而动

22、模板方法模式

​ 模板方法(Template Method)模式定义了一个算法框架,并通过继承的方式将算法的实现延迟到子类中,使得子类可以在不改变算法框架及其流程的前提下重新定义该算法在某些特定环节的实现,是一种类行为型模式。该模式在抽象类中定义了算法的结构并实现了公共部分算法,在子类中实现可变的部分并根据不同的业务需求实现不同的扩展。模板方法模式的优点在于其在父类(抽象类)中定义了算法的框架以保障算法的稳定性,同时在父类中实现了算法公共部分的方法来保障代码的复用;将部分算法部分延迟到子类中实现,因此子类可以通过继承的方式来扩展或重新定义算法的功能而不影响算法的稳定性,符合开闭原则。

模板方法模式需要注意抽象类与具体子类之间的协作,在具体使用时包含以下主要角色。

  • 抽象类(Abstract Class):定义了算法的框架,由基本方法和模板方法组成。基本方法定义了算法有哪些环节,模板方法定义了算法各个环节执行的流程。

  • 具体子类(Concrete Class):对在抽象类中定义的算法根据需求进行不同的实现。

​ 通俗地说,模板方法模式就是一个关于继承的设计模式。

​ 每一个被继承的父类都可以认为是一个模板,它的某些步骤是稳定的,某些步骤被延迟到子类中实现。

这和我们平时生活中使用的模板也是一样的。比如我们请假时,通常会给我们一份请假条模板,内容是已经写好的,只需要填写自己的姓名和日期即可。

本人 ___ 因 ___ 需请假 ___ 天,望批准!

这个模板用代码表示如下:

abstract class LeaveRequest {
    
    void request() {
        System.out.print("本人");
        System.out.print(name());
        System.out.print("因");
        System.out.print(reason());
        System.out.print("需请假");
        System.out.print(duration());
        System.out.print("天,望批准");
    }

    abstract String name();

    abstract String reason();

    abstract String duration();
}

在这份模板中,所有的其他步骤(固定字符串)都是稳定的,只有姓名、请假原因、请假时长是抽象的,需要延迟到子类去实现。

继承此模板,实现具体步骤的子类:

class MyLeaveRequest extends LeaveRequest {
    @Override
    String name() {
        return "阿笠";
    }

    @Override
    String reason() {
        return "参加力扣周赛";
    }

    @Override
    String duration() {
        return "0.5";
    }
}

测试:

// 输出:本人阿笠因参加力扣周赛需请假0.5天,望批准
new MyLeaveRequest().request();

在使用模板方法模式时,我们可以为不同的模板方法设置不同的控制权限:

  • 如果不希望子类覆写模板中的某个方法,使用 final 修饰此方法;
  • 如果要求子类必须覆写模板中的某个方法,使用 abstract 修饰此方法;
  • 如果没有特殊要求,可使用 protected 或 public 修饰此方法,子类可根据实际情况考虑是否覆写。

23、访问者模式

​ 访问者(Visitor)模式指将数据结构和对数据的操作分离开来,使其在不改变数据结构的前提下动态添加作用于这些元素上的操作。它将数据结构的定义和数据操作的定义分离开来,符合“单一职责”原则。访问者模式通过定义不同的访问者实现对数据的不同操作,因此在需要给数据添加新的操作时只需为其定义一个新的访问者即可。访问者模式是一种对象行为型模式,主要特点是将数据结构和作用于结构上的操作解耦,使得集合的操作可自由地演化而不影响其数据结构。它适用于数据结构稳定但是数据操作方式多变的系统中。

访问者模式实现的关键是将作用于元素的操作分离出来封装成独立的类,包含以下主要角色。

  • 抽象访问者(Visitor):定义了一个访问元素的接口,为每类元素都定义了一个访问操作visit(),该操作中的参数类型对应被访问元素的数据类型。

  • 具体访问者(ConcreteVisitor):抽象访问者的实现类,实现了不同访问者访问到元素后具体的操作行为。

  • 抽象元素(Element):元素的抽象表示,定义了访问该元素的入口的accept()方法,不同的访问者类型代表不同的访问者。

  • 具体元素(Concrete Element):实现抽象元素定义的accept()操作,并根据访问者的不同类型实现不同的业务逻辑。

​ 以我们去吃自助餐为例,每个人喜欢的食物是不一样的,比如 Aurora 喜欢吃龙虾和西瓜,Kevin 喜欢吃牛排和香蕉,餐厅不可能单独为某一位顾客专门准备食物。所以餐厅的做法是将所有的食物都准备好,顾客按照需求自由取用。此时,顾客和餐厅之间就形成了一种访问者与被访问者的关系。

准备好各种食物的餐厅:

class Restaurant {
    private String lobster = "lobster";
    private String watermelon = "watermelon";
    private String steak = "steak";
    private String banana = "banana";
}

在餐厅类中,我们提供了四种食物:龙虾、西瓜、牛排、香蕉。

为顾客提供的接口:

public interface IVisitor {
    void chooseLobster(String lobster);

    void chooseWatermelon(String watermelon);

    void chooseSteak(String steak);

    void chooseBanana(String banana);
}

接口中提供了四个方法, 让顾客依次选择每种食物。

在餐厅中提供接收访问者的方法:

class Restaurant {
    ...

    public void welcome(IVisitor visitor) {
        visitor.chooseLobster(lobster);
        visitor.chooseWatermelon(watermelon);
        visitor.chooseSteak(steak);
        visitor.chooseBanana(banana);
    }
}

在 welcome 方法中,我们将食物依次传递给访问者对应的访问方法。这时候,顾客如果想要访问餐厅选择自己喜欢的食物,只需要实现 IVisitor 接口即可。

比如顾客 Aurora 类:

public class Aurora implements IVisitor {
    @Override
    public void chooseLobster(String lobster) {
        System.out.println("Aurora gets a " + lobster);
    }

    @Override
    public void chooseWatermelon(String watermelon) {
        System.out.println("Aurora gets a " + watermelon);
    }

    @Override
    public void chooseSteak(String steak) {
        System.out.println("Aurora doesn't like " + steak);
    }

    @Override
    public void chooseBanana(String banana) {
        System.out.println("Aurora doesn't like " + banana);
    }
}

在此类中,顾客根据自己的喜好依次选择每种食物。

客户端测试:

public class Client {
    @Test
    public void test() {
        Restaurant restaurant = new Restaurant();
        IVisitor Aurora = new Aurora();
        restaurant.welcome(Aurora);
    }
}

运行程序,输出如下:

Aurora gets a lobster
Aurora gets a watermelon
Aurora doesn't like steak
Aurora doesn't like banana

可以看到,Aurora 对每一种食物做出了自己的选择,这就是一个最简单的访问者模式,它已经体现出了访问者模式的核心思想:将数据的结构对数据的操作分离。

访问者模式(Visitor Pattern):表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

本例中,顾客需要选择餐厅的食物,由于每个顾客对食物的选择是不一样的,如果在餐厅类中处理每位顾客的需求,必然导致餐厅类职责过多。所以我们并没有在餐厅类中处理顾客的需求,而是将所有的食物通过接口暴露出去,欢迎每位顾客来访问。顾客只要实现访问者接口就能访问到所有的食物,然后在接口方法中做出自己的选择。

相信这个例子还是非常简单直观的,看起来访问者模式也不是那么难理解。那么为什么很多书中说访问者模式是最复杂的设计模式呢?原因就在于《设计模式》一书中给访问者模式设计了一个“双重分派”的机制,而 Java 只支持单分派,用单分派语言强行模拟出双重分派才导致了访问者模式看起来比较复杂。要理解这一点,我们先来了解一下何谓单分派、何谓双重分派。

23.1、单分派与双重分派

先看一段代码:

Food 类:

public class Food {
    public String name() {
        return "food";
    }
}

Watermelon 类,继承自 Food 类:

public class Watermelon extends Food {
    @Override
    public String name() {
        return "watermelon";
    }
}

在 Watermelon 类中,我们重写了name()方法。

客户端:

public class Client {
    @Test
    public void test() {
        Food food = new Watermelon();
        System.out.println(food.name());
    }
}

思考一下,在客户端中,我们 new 出了一个 Watermelon 对象,但他的声明类型是 Food,当我们调用此对象的 name 方法时,会输出 “food” 还是 “watermelon” 呢?

了解过 Java 多态特性的同学都知道,这里肯定是输出 “watermelon” ,因为 Java 调用重写方法时,会根据运行时的具体类型来确定调用哪个方法。

再来看一段测试代码:

public class Client {
    @Test
    public void test() {
        Food food = new Watermelon();
        eat(food);
    }

    public void eat(Food food) {
        System.out.println("eat food");
    }

    public void eat(Watermelon watermelon) {
        System.out.println("eat watermelon");
    }
}

在这段代码中,我们仍然 new 出了一个 Watermelon 对象,他的声明类型是 Food,在客户端中有eat(Food food)eat(Watermelon watermelon)两个重载方法,这段代码会调用哪一个方法呢?

我们运行这段代码会发现输出的是:

eat food

这是由于 Java 在调用重载方法时,只会根据方法签名中声明的参数类型来判断调用哪个方法,不会去判断参数运行时的具体类型是什么。

从这两个例子中,我们可以看出 Java 对重写方法和重载方法的调用方式是不同的。

  • 调用重写方法时,与对象的运行时类型有关;
  • 调用重载方法时,只与方法签名中声明的参数类型有关,与对象运行时的具体类型无关。

了解了重写方法和重载方法调用方式的区别之后,我们将其综合起来就能理解何谓双重分派了。

测试代码:

public class Client {
    @Test
    public void test() {
        Food food = new Watermelon();
        eat(food);
    }

    public void eat(Food food) {
        System.out.println("eat food: " + food.name());
    }

    public void eat(Watermelon watermelon) {
        System.out.println("eat watermelon" + watermelon.name());
    }
}

在这段测试代码中,仍然是 new 出了一个 Watermelon 对象,它的声明类型为 Food。运行test()函数,输出如下:

eat food: watermelon

在面向对象的编程语言中,我们将方法调用称之为分派,这段测试代码运行时,经过了两次分派:

  • 调用重载方法:选择调用eat(Food food)还是eat(Watermelon watermelon)。虽然这里传入的这个参数实际类型是Watermelon,但这里会调用eat(Food food),这是由于调用哪个重载方法是在编译期就确定了的,也称之为静态分派
  • 调用重写方法:选择调用Foodname方法还是Watermelonname方法。这里会根据参数运行时的实际类型,调用Watermelonname方法,称之为动态分派

单分派、双重分派的定义如下:

方法的接收者和方法的参数统称为方法的宗量。 根据分派基于多少个宗量,可以将分派分为单分派和多分派。单分派是指根据一个宗量就可以知道应该调用哪个方法,多分派是指需要根据多个宗量才能确定调用目标。

这段定义可能不太好理解,通俗地讲,单分派和双重分派的区别就是:程序在选择重载方法和重写方法时,如果两种情况都是动态分派的,则称之为双重分派;如果其中一种情况是动态分派,另一种是静态分派,则称之为单分派。

说了这么多,这和我们的访问者模式有什么关系呢?首先我们要知道,架构的演进往往都是由复杂的业务驱动的,当程序需要更好的扩展性,更灵活的架构便诞生出来。

上例中的程序非常简单,但它无法处理某种食物有多个的情形。接下来我们就来修改一下程序,来应对每种食物有多个的场景。

23.2、自助餐程序 2.0 版

在上面的例子中,为了突出访问者模式的特点,我们将每种食物都简化为了 String 类型,实际开发中,每种食物都应该是一个单独的对象,统一继承自父类 Food:

public abstract class Food {
    public abstract String name();
}

继承自 Food 的四种食物:

龙虾:

public class Lobster extends Food {
    @Override
    public String name() {
        return "lobster";
    }
}

西瓜:

public class Watermelon extends Food {
    @Override
    public String name() {
        return "watermelon";
    }
}

牛排:

public class Steak extends Food {
    @Override
    public String name() {
        return "steak";
    }
}

香蕉:

public class Banana extends Food {
    @Override
    public String name() {
        return "banana";
    }
}

四个子类中分别重写了 name 方法,返回自己的食物名。

IVisitor 接口对应修改为:

public interface IVisitor {
    void chooseFood(Lobster lobster);

    void chooseFood(Watermelon watermelon);

    void chooseFood(Steak steak);

    void chooseFood(Banana banana);
}

每种食物都继承自 Food,所以我们将接口中的方法名都修改为了 chooseFood。

餐厅类修改如下:

class Restaurant {

    // 准备当天的食物
    private List<Food> prepareFoods() {
        List<Food> foods = new ArrayList<>();
        // 简单模拟,每种食物添加 10 份
        for (int i = 0; i < 10; i++) {
            foods.add(new Lobster());
            foods.add(new Watermelon());
            foods.add(new Steak());
            foods.add(new Banana());
        }
        return foods;
    }

    // 欢迎顾客来访
    public void welcome(IVisitor visitor) {
        // 获取当天的食物
        List<Food> foods = prepareFoods();
        // 将食物依次提供给顾客选择
        for (Food food : foods) {
            // 由于单分派机制,此处无法编译通过
            visitor.chooseFood(food);
        }
    }
}

餐厅类中新增了prepareFoods方法,在这个方法中,我们简单模拟了准备多个食物的过程,将每种食物添加了 10 份。在接收访问者的welcome方法中,遍历所有食物,分别提供给顾客。

看起来很美好,实际上,visitor.chooseFood(food)这一行是无法编译通过的,原因就在于上一节中提到的单分派机制。虽然每种食物都继承自 Food 类,但由于接口中没有chooseFood(Food food)这个重载方法,所以这一行会报错"Cannot resolve method chooseFood"。

试想,如果 Java 在调用重载方法时也采用动态分派,也就是根据参数的运行时类型选择对应的重载方法,这里遇到的问题就迎刃而解了,我们的访问者模式讲到这里也就可以结束了。

但由于 Java 是单分派语言,所以我们不得不想办法解决这个 bug,目的就是使用单分派的 Java 语言模拟出双分派的效果,能够根据运行时的具体类型调用对应的重载方法

我们很容易想到一种解决方式,采用 instanceOf 判断对象的具体子类型,再将父类强制转换为具体子类型,调用对应的接口方法:

// 通过 instanceOf 判断具体子类型,再强制向下转型
if (food instanceof Lobster) visitor.chooseFood((Lobster) food);
else if (food instanceof Watermelon) visitor.chooseFood((Watermelon) food);
else if (food instanceof Steak) visitor.chooseFood((Steak) food);
else if (food instanceof Banana) visitor.chooseFood((Banana) food);
else throw new IllegalArgumentException("Unsupported type of food.");

的确可行,在某些开源代码中便是这么做的,但这种强制转型的方式既冗长又不符合开闭原则,所以《设计模式》一书中给我们推荐了另一种做法。

首先在 Food 类中添加 accept(Visitor visitor) 抽象方法:

public abstract class Food {
    public abstract String name();

    // Food 中添加 accept 方法,接收访问者
    public abstract void accept(IVisitor visitor);
}

在具体子类中,实现此方法:

public class Lobster extends Food {
    @Override
    public String name() {
        return "lobster";
    }

    @Override
    public void accept(IVisitor visitor) {
        visitor.chooseFood(this);
    }
}

经过这两步修改,餐厅类就可以将接收访问者的方法修改如下:

class Restaurant {

    // 准备当天的食物
    private List<Food> prepareFoods() {
        List<Food> foods = new ArrayList<>();
        // 简单模拟,每种食物添加 10 份
        for (int i = 0; i < 10; i++) {
            foods.add(new Lobster());
            foods.add(new Watermelon());
            foods.add(new Steak());
            foods.add(new Banana());
        }
        return foods;
    }

    // 欢迎顾客来访
    public void welcome(IVisitor visitor) {
        // 获取当天的食物
        List<Food> foods = prepareFoods();
        // 将食物依次提供给顾客选择
        for (Food food : foods) {
            // 由于重写方法是动态分派的,所以这里会调用具体子类的 accept 方法,
            food.accept(visitor);
        }
    }
}

经过这三步修改,我们将访问者来访的代码由:

visitor.chooseFood(food);

改成了

food.accept(visitor);

这样我们就将重载方法模拟成了动态分派。这里的实现非常巧妙,由于 Java 调用重写方法时是动态分派的,所以food.accept(visitor)会调用具体子类的 accept 方法,在具体子类的 accept 方法中,调用visitor.chooseFood(this),由于这个 accept 方法是属于具体子类的,所以这里的 this 一定是指具体的子类型,不会产生歧义。

再深入分析一下:之前的代码中,调用visitor.chooseFood(food)这行代码时,由于重载方法不知道 Food 的具体子类型导致了编译失败,但实际上这时我们是可以拿到 Food 的具体子类型的。利用重写方法会动态分派的特性,我们在子类的重写方法中去调用这些重载的方法,使得重载方法使用起来也像是动态分派的一样。

顾客 Aurora 类:

public class Aurora implements IVisitor {

    @Override
    public void chooseFood(Lobster lobster) {
        System.out.println("Aurora gets a " + lobster.name());
    }

    @Override
    public void chooseFood(Watermelon watermelon) {
        System.out.println("Aurora gets a " + watermelon.name());
    }

    @Override
    public void chooseFood(Steak steak) {
        System.out.println("Aurora doesn't like " + steak.name());
    }

    @Override
    public void chooseFood(Banana banana) {
        System.out.println("Aurora doesn't like " + banana.name());
    }
}

顾客 Kevin 类:

public class Kevin implements IVisitor {

    @Override
    public void chooseFood(Lobster lobster) {
        System.out.println("Kevin doesn't like " + lobster.name());
    }

    @Override
    public void chooseFood(Watermelon watermelon) {
        System.out.println("Kevin doesn't like " + watermelon.name());
    }

    @Override
    public void chooseFood(Steak steak) {
        System.out.println("Kevin gets a " + steak.name());
    }

    @Override
    public void chooseFood(Banana banana) {
        System.out.println("Kevin gets a " + banana.name());
    }
}

客户端测试:

public class Client {
    @Test
    public void test() {
        Restaurant restaurant = new Restaurant();
        IVisitor Aurora = new Aurora();
        IVisitor Kevin = new Kevin();
        restaurant.welcome(Aurora);
        restaurant.welcome(Kevin);
    }
}

运行程序,输出如下:

Aurora gets a lobster
Aurora gets a watermelon
Aurora doesn't like steak
Aurora doesn't like banana
... 输出 10 遍
Kevin doesn't like lobster
Kevin doesn't like watermelon
Kevin gets a steak
Kevin gets a banana
... 输出 10 遍

这就是访问者模式,它的核心思想其实非常简单,就是第一小节中体现的将数据的结构对数据的操作分离。之所以说它复杂,主要在于大多数语言都是单分派语言,所以不得不模拟出一个双重分派,也就是用重写方法的动态分派特性将重载方法也模拟成动态分派

但模拟双重分派只是手段,不是目的。有的文章中说模拟双重分派是访问者模式的核心,还有的文章中说双分派语言不需要访问者模式,笔者认为这些说法都有点舍本逐末了。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值