从零开始学习Java设计模式 | 行为型模式篇:模板方法模式

Posted 李阿昀

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始学习Java设计模式 | 行为型模式篇:模板方法模式相关的知识,希望对你有一定的参考价值。

接下来,我们来学习第四章的内容,即行为型模式。

简单聊聊行为型模式

首先,我们来看一看什么是行为型模式。

行为型模式用于描述程序在运行时复杂的流程控制(我们之前学习过很多流程控制语句,例如if elseswitchfor循环等等),即描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。

什么意思呢?也就是说如果是多个类的话,那么我们可以使用继承的关系来让其完成复杂的流程控制;如果是多个对象的话,那么我们可以通过对象的聚合或者组合来完成一个复杂的流程控制。而这里面必然会涉及到一些算法以及对象之间职责的一个分配,不同的模式,它所涉及的算法以及对象间职责的分配是不一样的。

行为型模式分为类行为模式和对象行为模式,前者采用继承机制来在类间分配行为,后者采用组合或聚合在对象间分配行为。由于组合关系或聚合关系比继承关系耦合度低,满足"合成复用原则",所以对象行为模式比类行为模式具有更大的灵活性。也就是说,我们在用的时候,对象行为模式用的是更多的。

接下来,我们就来看一下行为型模式总共包含了哪些模式。

行为型模式分为:

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

对于这些行为型模式,我会后续的学习过程中为大家一一进行讲解。注意,对于上面的这11种行为型模式而言,除了模板方法模式和解释器模式是类行为型模式,其他的全部属于对象行为型模式。

模板方法模式

接下来,我们来学习一下行为型模式里面的第一个模式,即模板方法模式。

概述

在面向对象程序设计过程中,程序员常常会遇到这种情况:设计一个系统时知道了算法所需的关键步骤,而且确定了这些步骤的执行顺序,但是某些步骤的具体实现还未知,或者说某些步骤的实现与具体的环境相关。

例如,去银行办理业务时一般要经过这4个流程:取号、排队、办理具体业务、对银行工作人员进行评分等,其中取号、排队和对银行工作人员进行评分的业务对每个客户都是一样的,可以在父类中实现(因为可以提高代码的复用性),但是办理具体业务却因人而异,它可能是存款、取款或者转账等,可以延迟到子类中实现。

延迟到子类中实现的话,就得要在父类中声明抽象方法了,而且大家也要明确一点,就是对于以上4个流程,它们调用的顺序是固定的,也就是说客户得先取号,然后再去排队,接着办理具体业务,最后对银行工作人员进行评分,因此我们也可以把调用的顺序放在父类中,这就是模板方法模式。

那到底什么是模板方法模式呢?下面我们来看下其具体的概念。

定义一个操作中的算法骨架(例如,上例中客户去银行办理业务时经过的4个流程,而且调用这4个流程时是有其固定顺序的。注意,算法骨架是需要在父类中去定义的),而将算法的一些步骤延迟到子类中(例如,上例中对于办理具体业务这一步骤而言,是因人而异的,所以我们就可以把这一步骤的实现推迟到子类中,前提是要在父类中进行抽象声明),使得子类可以在不改变该算法结构的情况下重定义该算法的某些特定步骤。

看完模板方法模式的概念,我们应明白一点,就是我们要先把调用的功能(也可以说成是算法骨架中的步骤)在父类中声明好,然后子类只需要对里面的某一个步骤或者某些步骤进行一个重新定义(或者重写)就可以了。

最后,我再多说一嘴,就是如果我们在子类中要将父类中的流程控制的方法进行重写,那么在父类中的方法可以使用final来修饰吗?很显然,肯定是不行的,如果真要是使用final来修饰的话,那么子类就不能进行重写了。

结构

模板方法(Template Method)模式包含以下主要角色:

  • 抽象类(Abstract Class):负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。
    • 模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。例如,上例中,取号、排队、办理具体业务、对银行工作人员进行评分这4个流程,它们调用的顺序是固定的,而调用这4个流程的方法,我们就将其称为模板方法,模板方法里面执行的顺序我们可以理解成就是算法的骨架,只不过在这儿基本上没有一些深入的算法。

    • 基本方法:它是实现算法各个步骤的方法,是模板方法的组成部分。

      上面我已经说过什么是模板方法了,在模板方法里面,我们会调用取号、排队、办理具体业务、对银行工作人员进行评分等等这些功能,而这些功能我们就可以理解成是基本方法了,这些基本方法是模板方法的组成部分。

      基本方法又可以分为三种:

      • 抽象方法(Abstract Method):一个抽象方法由抽象类声明,并由其具体子类实现,也即要求子类必须重写。

      • 具体方法(Concrete Method):一个具体方法由一个抽象类或具体类声明并实现,其子类可以进行覆盖也可以直接继承。

        那么现在我们来想一想,对于上例而言,哪些是抽象方法,哪些是具体方法呢?对于取号、排队和对银行工作人员进行评分这三个功能来说,它们都是属于具体方法,因为每个人的操作都是一样的,这样,我们就可以把这些功能定义在父类中了,或者就在父类中进行实现,这是不是可以提高代码的复用性啊!

        而对于办理具体业务这个功能来说,它是因人而异的(每个人办理的业务不一样),所以它就是抽象方法,应该把它推迟到子类中来实现。

      • 钩子方法(Hook Method):在抽象类中已经实现,包括用于判断的逻辑方法和需要子类重写的空方法两种。也就是说,一种是父类中已经实现了,一种是在父类中没有实现,而是要求子类必须去重写(或者实现)。对于上例来说,它是没有钩子方法的。

        对于钩子方法来说,一般它是用于判断的逻辑方法,这类方法名一般为isXxx,返回值类型一般都是boolean类型。

  • 具体子类(Concrete Class):实现抽象类中所定义的抽象方法和钩子方法,它们是一个顶级逻辑的组成步骤。

模板方法模式案例

接下来,我们便通过一个案例来让大家再去理解一下模板方法模式以及它所蕴涵的思想,这个案例就是炒菜。

分析

炒菜的步骤是固定的,分为倒油、热油、倒蔬菜、倒调料品、翻炒等步骤。现在我们要通过模板方法模式来用代码进行模拟,那又该怎么做呢?

如果要通过模板方法模式来用代码进行模拟炒菜,那么肯定是要定义抽象类以及具体子类的,而且在抽象类里面,我们还要分别去定义模板方法和基本方法。

模板方法是什么,上面我也说到了,就是定义了算法的骨架(对于该案例而言,就是炒菜的步骤,因为炒菜的步骤都是一致的)。在该案例中,模板方法就是烹饪功能,它里面要调用倒油、热油、倒蔬菜、倒调料品、翻炒等这些功能。

那抽象类里面的基本方法又都有哪些呢?很简单嘛,不就是倒油、热油、倒蔬菜、倒调料品、翻炒等这些方法嘛!问题又来了,这些基本方法里面哪些又是抽象方法呢?其实,你会发现倒油、热油和翻炒这几个方法的具体实现都是一样的,只不过对于炒不同的蔬菜而言,倒的蔬菜以及调料品是不一样的,所以我们就可以将倒蔬菜和倒调料品这俩方法定义成抽象的了,至于其他的三个方法,我们只须定义成具体方法即可,如此一来,就能提高代码的复用性了。

分析完之后,我们再来看看下面这张类图。

可以看到,顶部有一个抽象类(即父类),它下面又有两个具体的子类,一个是爆炒包菜类,一个是爆炒菜心类,它俩都得重写父类中的倒蔬菜和倒调料品这两个方法,是不是很简单啊!

以上类图分析完了之后,接下来,咱们就要编写代码来实现以上案例了。

实现

首先,打开咱们的maven工程,并在com.meimeixia.pattern包下新建一个子包,即template,也即模板方法模式的具体代码我们是放在了该包下。

然后,创建抽象类,该抽象类我们不妨就命名为AbstractClass。

package com.meimeixia.pattern.template;

/**
 * 抽象类(定义模板方法和基本方法)
 * @author liayun
 * @create 2021-08-02 18:46
 */
public abstract class AbstractClass {

    /**
     * 模板方法定义
     *
     * 注意,我们已经说过了,模板方法是定义了算法的骨架,而子类在继承父类时,是可以对该模板方法进行重写的,
     * 重写的话那就意味着子类可以去改变这个算法的骨架了,但是我们又不能让其去改变,所以我们就只好在该模板
     * 方法上加上final关键字进行修饰了。
     */
    public final void cookProcess() {
        pourOil();
        heatOil();
        pourVegetable();
        pourSauce();
        fry();
    }

    /**
     * 第一步:倒油,属于具体方法。
     *
     * 不管炒啥都是一样的,所以直接实现
     */
    public void pourOil() {
        System.out.println("倒油");
    }

    /**
     * 第二步:热油,属于具体方法。
     *
     * 不管炒啥都是一样的,所以直接实现
     */
    public void heatOil() {
        System.out.println("热油");
    }

    /**
     * 第三步:倒蔬菜,属于抽象方法。
     *
     * 注意了,炒菜时倒的蔬菜是不一样的,例如爆炒包菜时倒的是包菜,爆炒菜心时倒的是菜心
     */
    public abstract void pourVegetable();

    /**
     * 第四步:倒调味料,属于抽象方法。
     *
     * 既然炒菜时倒的蔬菜是不一样的,那么倒的调味料也必然是不一样的了
     */
    public abstract void pourSauce();

    /**
     * 第五步:翻炒,属于具体方法。
     *
     * 不管炒啥都是一样的,所以直接实现
     */
    public void fry() {
        System.out.println("炒啊炒啊炒到熟啊");
    }

}

接着,创建以上抽象类的子类,让其去重写里面的两个抽象方法。这里我会创建两个子类,一个是炒包菜类,该类我们起名为ConcreteClass_BaoCai。

package com.meimeixia.pattern.template;

/**
 * 炒包菜类
 * @author liayun
 * @create 2021-08-02 19:09
 */
public class ConcreteClass_BaoCai extends AbstractClass {
    @Override
    public void pourVegetable() {
        System.out.println("下锅的蔬菜是包菜");
    }

    @Override
    public void pourSauce() {
        System.out.println("下锅的酱料是辣椒");
    }
}

一个是炒菜心类,该类我们起名为ConcreteClass_CaiXin。

package com.meimeixia.pattern.template;

/**
 * 炒菜心类
 * @author liayun
 * @create 2021-08-02 19:09
 */
public class ConcreteClass_CaiXin extends AbstractClass {
    @Override
    public void pourVegetable() {
        System.out.println("下锅的蔬菜是菜心");
    }

    @Override
    public void pourSauce() {
        System.out.println("下锅的酱料是蒜蓉");
    }
}

最后,创建一个测试类进行测试。

package com.meimeixia.pattern.template;

/**
 * @author liayun
 * @create 2021-08-02 19:16
 */
public class Client {
    public static void main(String[] args) {
        // 现在我们来炒个包菜
        // 创建对象
        ConcreteClass_BaoCai baoCai = new ConcreteClass_BaoCai();
        // 调用炒菜的功能
        baoCai.cookProcess();
    }
}

运行以上测试类,打印结果如下,可以看到爆炒包菜确实是按照上面所描述的那几个步骤来炒的。

至此,炒菜这个案例我们就实现完毕了,以后如果我们要想炒其他的蔬菜,那么只须去定义一个子类,让它去继承(或者实现)AbstractClass类即可,而且由于AbstractClass类里面已经定义好了炒菜的步骤,所以子类就不再需要去关注它是如何进行一个翻炒的了,而是只需要去重写它里面的两个抽象方法(分别去指定倒的是什么蔬菜以及倒的是什么调料)就行。

学到这里,相信大家对模板方法模式有了一个更深入的理解。

模板方法模式的优缺点以及使用场景

接下来,我们就来看看模板方法模式的优缺点以及使用场景。

优缺点

优点

关于模板方法模式的优点,我总结出来了下面两个。

  1. 提高代码复用性。

    在上述案例中,我们是将相同部分的代码放在了抽象的父类中(这样,子类就可以直接去继承使用了,也即提高了代码的复用性),而将不同的代码放入了不同的子类中(注意了,要由父类先声明成抽象的方法,再要求子类必须去重写)。

  2. 实现了反向控制。

    什么叫实现了反转控制呢?以前我们编写代码时,可能是这样的,在子类中去调用父类的方法,而现在我们是通过一个父类调用其子类的操作,这就是反向控制。

    通过一个父类调用其子类的操作,再通过对子类的具体实现扩展不同的行为,就实现了反向控制,这也符合"开闭原则"。后期如果我们想要去添加一些其他的操作的话,那么直接去定义子类就可以了,而不再需要对父类的代码进行修改了,也不再需要对原有的那些子类进行一个修改了。

缺点

关于模板方法模式的缺点,我也总结出来了下面两个。

  1. 对每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象。

    注意了,类的数量增加也是有限的,并不会导致类的个数爆炸式增加,所以这个缺点也还能接受。

  2. 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度。

    刚刚我们说了,模板方法模式的优点是实现了反向控制,而它的缺点也是反向控制。为什么会这样说呢?因为如果实现了反向控制的话,那么就会提高代码阅读的难度了。以后,我们在去看别人写的框架的源码的时候,如果里面使用到了模板方法模式这种设计模式,那么我们在看源码时可能就会稍微有一些难以理解了。

使用场景

关于模板方法模式的使用场景,我列举出来了下面两个。

  1. 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。

    也就是说,在父类中通过模板方法的形式把算法的整体架构定义出来,至于易变的个别部分(或者个别功能),则在父类中声明成抽象方法,然后要求子类必须去重写。

  2. 需要通过子类来决定父类算法中某个步骤是否执行,实现子类对父类的反向控制。

    这里其实会涉及到钩子函数,也就是说如果有这样的一个场景的话,那么我们就需要定义钩子函数了,只不过上述案例中并没有体现出来,这是因为我们的业务需求里面并不需要用到钩子函数。后期我们在做其他业务时,就可能需要定义钩子函数了。

模板方法模式在JDK源码中的应用

接下来,我们就来看一看模板方法模式在JDK源码里面是如何来应用的。

在JDK源码里面,其实很多地方都用到了模板方法模式,而在本套系列课程中,我们只看一个类就行了,这个类就是InputStream,它就用到了模板方法模式。

在InputStream类中定义了多个read方法,如下所示,很明显它们是重载的。

public abstract class InputStream implements Closeable {
    // 抽象方法,要求子类必须重写
    public abstract int read() throws IOException;

    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }

    public int read(byte b[], int off, int len) throws IOException {
        if (b == null) {
            throw new NullPointerException();
        } else if (off < 0 || len < 0 || len > b.length - off) {
            throw new IndexOutOfBoundsException();
        } else if (len == 0) {
            return 0;
        }

        int c = read(); // 调用了无参的read方法,该方法是每次读取一个字节数据
        if (c == -1) {
            return -1;
        }
        b[off] = (byte)c;

        int i = 1;
        try {
            for (; i < len ; i++) {
                c = read(); // 调用了无参的read方法,该方法是每次读取一个字节数据
                if (c == -1) {
                    break;
                }
                b[off + i] = (byte)c;
            }
        } catch (IOException ee) {
        }
        return i;
    }
}

可以看到,InputStream类首先是一个抽象类,因为有abstract关键字来修饰它。而且,在该抽象类里面,有三个重载的read方法,第一个是无参的,第二个是带一个参数的,当然这个参数是字节数组,第三个是带三个参数的。

现在我们就来想一想,如果想要一次性去读取多个字节数据,那么我们是不是得调用带一个参数的read方法啊!而在调用该方法时,我们发现它里面又调用了另外一个重载的read方法,即带三个参数的read方法。

那我们接下来就再看看带三个参数的read方法是如何来实现的呗!你会发现在该方法中的第10行、第19行又调用了无参的抽象的read方法,既然是抽象的,那么就意味着必须要求子类去重写了,所以在这两处调用无参的抽象的read方法,其本质上调用的是子类中的read方法,这就是反向控制,而反向控制正是模板方法模式的思想。

我们都知道无参的read方法是每次读取一个字节数据,那么读取到之后,这个字节数据又是如何处理的呢?其实是将其存储到了一个数组里面。而且,在带三个参数的read方法里面,现在是要读len个字节数据的,所以在该方法里面就用到了for循环,这样就能把每次读到的字节数据全部存储在数组里面了。也就是说,我们一次性读取了多个字节数据,并将其存储在了数组里面。

分析完以上代码之后,接下来,我们就来研究研究模板方法模式里面的角色在此处是如何体现的。

InputStream类就是模板方法模式里面的抽象类角色,我们都知道抽象类里面是有基本方法和模板方法的,那模板方法究竟是哪个呢?模板方法就是InputStream类中带三个参数的read方法,它里面定义了算法的骨架。从上可以看到,在该骨架中,多次调用了无参的抽象的read方法,并且每一次调用该方法都会将获取到的字节数据存储在数组里面。

分析至此,相信大家也知道了InputStream类中的无参的抽象的read方法就是基本方法里面的抽象方法,是要求子类必须去重写的。如果子类去重写的话,那么我们在带三个参数的read方法里面调用的就是子类中重写的方法,这便是反向控制。

以上就是我们对InputStream类源码的一个分析,它里面就用到了模板方法模式。

最后,我总结一下:在InputStream父类中已经定义好了读取一个字节数组数据的方法,具体实现是每次读取一个字节,并将其存储到数组的第一个索引位置,而且得读取len个字节数据。具体如何来读取一个字节数据则是由子类来实现

以上是关于从零开始学习Java设计模式 | 行为型模式篇:模板方法模式的主要内容,如果未能解决你的问题,请参考以下文章

从零开始学习Java设计模式 | 行为型模式篇:状态模式

从零开始学习Java设计模式 | 行为型模式篇:状态模式

从零开始学习Java设计模式 | 行为型模式篇:命令模式

从零开始学习Java设计模式 | 行为型模式篇:命令模式

从零开始学习Java设计模式 | 行为型模式篇:责任链模式

从零开始学习Java设计模式 | 行为型模式篇:责任链模式