从零开始学习Java设计模式 | 结构型模式篇:装饰者模式

Posted 李阿昀

tags:

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

在本讲,我们来学习一下结构型模式里面的第三个设计模式,即装饰者模式。

概述

在学习装饰者模式之前,我们先来看一个快餐店的例子。

快餐店有炒面、炒饭这些快餐,可以额外附加鸡蛋、火腿、培根这些配菜,当然加配菜需要额外加钱,每个配菜的价钱通常不太一样,那么这样计算总价就会显得比较麻烦。比如说客户点一份炒面,他还要再加一个鸡蛋和一根火腿,那么计算总价的时候就会很麻烦。

假设我们现在要设计这么一个计算总价的系统的话,那么用传统的方式应该如何去做呢?是不是立马想到了要用继承的方式去做啊!这时,所设计出来的类图就应该是下面这个样子的。

最上面的是快餐类,它里面有两个成员变量,一个是price(即价格),一个是desc(即描述,例如炒饭的描述就是炒饭,炒面的描述就是炒面),当然还为它们提供了对应的getter和setter方法,此外,该快餐类里面还有一个cost方法,它就是用来计算快餐总价格的。

然后,快餐类又有两个子类,一个是炒饭类(即FiredRice),一个是炒面类(即FiredNoodles),它俩都有各自对应的无参构造,除此之外,它俩都重写了父类中的cost方法,以计算总价格。

由于对于炒饭和炒面来说,它们都可以加鸡蛋或者培根,所以我就又为它们设计了不同的子类,对于炒饭类来说,它有两个子类,分别是加鸡蛋的炒饭类(即EggFriedRice)和加培根的炒饭类(即BaconFriedRice);对于炒面类来说,它也有两个子类,分别是加鸡蛋的炒面类(即EggFriedNoodles)和加培根的炒面类(即BaconFriedNoodles)。

以上就是我们用传统继承的方式来实现咱们快餐店的案例。

那么使用继承方式去实现的话,会存在一个什么样的问题呢?使用继承的方式所存在的问题:

  • 扩展性不好

    如果要再加一种配料(比如火腿肠),那么我们就会发现需要给FriedRice和FriedNoodles分别定义一个子类。如果要新增一个快餐品类(比如炒河粉)的话,那么就需要定义更多的子类了。为什么这么说呢?假设我们在以上类图中新添加了一个炒河粉的子类,那么它肯定是要继承自快餐类的,此时,它下面就要有三个子类了,分别是加鸡蛋的炒河粉类、加培根的炒河粉类以及加火腿肠的炒河粉类,你会发现类爆炸的情况就出现了

  • 产生过多的子类

问题既然出现了,那么我们应该如何对上面的快餐店案例进行一个改进呢?此时,我们就可以使用装饰者模式了。那装饰者模式到底是什么呢?下面我们就来看看它的概念。

装饰者模式是指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式。

在以上快餐店案例中,对于炒饭来说,给其增加一个额外的职责,其实就是给其加一个鸡蛋或者培根或者火腿肠。因此,对于该案例而言,我们就可以使用装饰者模式了。

结构

装饰者(Decorator)模式中的角色有如下四个:

  • 抽象构件(Component)角色:定义一个抽象接口以规范准备接收附加责任的对象。注意,此处的抽象接口既可以是接口也可以是抽象类。该角色对应以上快餐店案例中的快餐类
  • 具体构件(ConcreteComponent)角色:实现抽象构件,通过装饰角色为其添加一些职责。例如,以上快餐店案例中的炒饭类、炒面类都属于具体构建角色
  • 抽象装饰(Decorator)角色:继承或实现抽象构件,并包含具体构件的实例(也就是说将其聚合进来了),可以通过其子类扩展具体构件的功能。所以,装饰者模式巧妙就巧妙在这个位置
  • 具体装饰(ConcreteDecorator)角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任

装饰者模式案例

上面我们学习了装饰者模式的概念,以及知道了它里面所具有的角色。接下来,我们就使用装饰者模式来对以上快餐店案例进行一个改进,以此体会装饰者模式的精髓。

分析

咱们先看下下面的这张类图。

首先,我们应该明确要有一个快餐类,而且它还要是一个抽象类,它里面有两个成员变量,一个是price(即价格),一个是desc(即描述),当然还为它们提供了对应的getter和setter方法,此外,该快餐类里面还有一个cost方法,它就是用来计算快餐总价格的,当然该方法是抽象的,需要由子类具体来实现。

然后,快餐类又有两个子类,一个是炒饭类(即FiredRice),一个是炒面类(即FiredNoodles),它俩都有各自对应的无参构造,除此之外,它俩都重写了父类中的cost方法,以计算总价格。

以上类图的左半边部分我们分析完了之后,再来分析一下右半边部分。

可以看到有一个Garnish类,它是一个核心类,即装饰者。作为装饰者,首先它要去继承快餐类(即FastFood),并又聚合该类的对象,这样,我们就得为其提供一个有参构造和相应的getter、setter方法了。

接着,咱们的配料类,比如Egg、Bacon,就得提供对应的有参构造给父类(即Garnish)中的FastFood对象进行赋值了,并且还得重写父类中的cost方法和getDesc方法。重写父类中的cost方法好理解,就是为了计算总价,那为啥还要重写父类中的getDesc方法呢?这是因为如果某个快餐加了鸡蛋(或者培根)的话,那么它的描述肯定是不同了,所以我们就得重写父类中的getDesc方法来获取最终的一个描述了。

经过以上分析,大家一定要清楚,Garnish是最核心的一个类,它不仅继承了FastFood类还聚合了FastFood类。

分析完了以后,接下来,我们就要开始编写代码实现以上案例了。

实现

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

然后,创建快餐类,这里我们命名为FastFood。注意,该类是一个抽象类。

package com.meimeixia.pattern.decorator;

/**
 * 快餐类(抽象构件角色)
 * @author liayun
 * @create 2021-07-31 13:02
 */
public abstract class FastFood {

    private float price; // 价格
    private String desc; // 描述

    public FastFood(float price, String desc) {
        this.price = price;
        this.desc = desc;
    }

    public FastFood() {

    }

    public float getPrice() {
        return price;
    }

    public void setPrice(float price) {
        this.price = price;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }

    /*
     * 计算总价。注意,该方法是一个抽象的方法,这是因为只有我们知道了具体的快餐之后,才能计算出来它的价格。
     * 例如,如果客户点的是炒饭,而一碗炒饭又是10块钱,那么最终返回的就是10块钱
     */
    public abstract float cost();

}

接着,创建两个快餐类的子类,一个是炒饭类,这里我们命名为FriedRice。

package com.meimeixia.pattern.decorator;

/**
 * 炒饭类(具体构件角色)
 * @author liayun
 * @create 2021-07-31 13:10
 */
public class FriedRice extends FastFood {

    /*
     * 在FriedRice类中,我们只需要给它提供一个无参的构造方法就可以了,但是我们得通过该无参构造给父类中的两个成员变量进行赋值。
     * 如果客户选择的是炒饭,而炒饭的价格又是固定的,比如10块钱,那么代码就应该向下面这样写。
     */
    public FriedRice() {
        super(10, "炒饭");
    }

    @Override
    public float cost() {
        return getPrice(); // 由于我们刚才已经定义好了炒饭的价格是10块钱,所以此处我们直接调用
                           // 父类中的getPrice方法就能获取到价格了
    }

}

现在,如果我们想要点一份炒饭,那么是不是只需要创建FriedRice类的对象就可以了啦!而且这样就会自动将炒饭的价格设置为10块钱,描述那当然就是炒饭了啊!最终,我们去计算一份炒饭的餐费的话,那就是10块钱了,这是非常合情合理的。

FriedRice类还要一个子类,就是炒面类,这里我们命名为FriedNoodles。

package com.meimeixia.pattern.decorator;

/**
 * 炒面类(具体构件角色)
 * @author liayun
 * @create 2021-07-31 13:17
 */
public class FriedNoodles extends FastFood {

    /*
     * 在FriedNoodles类中,我们只需要给它提供一个无参的构造方法就可以了,但是我们得通过该无参构造给父类中的两个成员变量进行赋值。
     * 如果客户选择的是炒面,而炒面的价格又是固定的,比如12块钱,那么代码就应该像下面这样写。
     */
    public FriedNoodles() {
        super(12, "炒面");
    }

    @Override
    public float cost() {
        return getPrice(); // 由于我们刚才已经定义好了炒面的价格是12块钱,所以此处我们直接调用
                           // 父类中的getPrice方法就能获取到价格了
    }

}

紧接着,创建最核心的类,即Garnish,它就是装饰者类。对于该装饰者类,大家一定要注意它设计的一个原则,就是它得去继承FastFood类,虽说是去继承,但是在这里我们就不重写它里面的方法了。注意了,我们应该将该装饰者类定义成抽象的。为什么呢?因为快餐具体要加哪种配料,我们是不明确的,不明确的话,那么总价就无法进行计算了,所以我们就需要把该装饰者类定义成抽象类了。

你觉得该装饰者类属于装饰者模式里面的哪种角色呢?很明显,它属于抽象装饰角色。注意了,上面我们还没说完Garnish类设计的原则呢,下面我们接着来说。

Garnish类除了要去继承FastFood类之外,还得聚合FastFood类的对象,所以我们就得在Garnish类的成员位置声明FastFood类的变量了,这是装饰者模式的一个显著特点。记住,定义完FastFood类型的成员变量之后,还得为其对应的getter和setter方法。

这样,Garnish类的代码就呼之欲出了。

package com.meimeixia.pattern.decorator;

/**
 * 装饰者类(抽象装饰者角色)
 * @author liayun
 * @create 2021-07-31 13:27
 */
public abstract class Garnish extends FastFood {

    // 声明快餐类的变量
    private FastFood fastFood;

    public FastFood getFastFood() {
        return fastFood;
    }

    public void setFastFood(FastFood fastFood) {
        this.fastFood = fastFood;
    }

    /*
     * 在Garnish类中,我们还得提供如下有参构造,价格与描述这两属性是直接调用父类中的方法来进行设置的,
     * 至于FastFood类型的属性,懂得都懂!!!
     *
     * 注意,后面的两个参数所代表的意思。
     *      float price:配料(例如鸡蛋)的价格。例如,一个炒鸡蛋是1块钱
     *      String desc:配料(例如鸡蛋)的描述。例如,鸡蛋的描述肯定就是鸡蛋了
     */
    public Garnish(FastFood fastFood, float price, String desc) {
        super(price, desc);
        this.fastFood = fastFood;
    }

}

装饰者类创建完毕之后,接下来,我们就得开始创建配料类了。第一个配料类是鸡蛋类,即Egg,注意了,它得继承Garnish装饰者类。

继承完了之后,我们得来思考一下,对于该鸡蛋类来说,我们肯定是要提供构造方法的,那么是提供有参的构造方法还是无参的构造方法呢?很明显是提供有参的构造方法,因为我们还得给父类中的快餐类成员变量进行赋值呢!你不可能只要配料而不要具体的快餐吧!比如说你就去快餐店只点两个炒鸡蛋,而不来一份炒饭,当然了,你非得干吃两个炒鸡蛋那也不是不可以,只是这种情况很少很少。

除此之外,在该鸡蛋类中,我们还得重写父类中的cost方法以便计算快餐加配料之后的总价。如何来重写父类中的cost方法呢?很简单,鸡蛋的价格加上快餐的价格就计算出来了。

重写完父类中的cost方法之后,注意了,我们还得重写父类中的getDesc方法,重写也很简单,就是鸡蛋的描述拼接上快餐的描述就行了。

这样,第一个配料类(即Egg)的代码就呼之欲出了。

package com.meimeixia.pattern.decorator;

/**
 * 鸡蛋类(具体的装饰者角色)
 * @author liayun
 * @create 2021-07-31 13:40
 */
public class Egg extends Garnish {

    public Egg(FastFood fastFood) {
        super(fastFood, 1, "鸡蛋");
    }

    @Override
    public float cost() {
        // 计算价格
        return getPrice() + getFastFood().cost();
    }

    @Override
    public String getDesc() {
        return super.getDesc() + getFastFood().getDesc();
    }
}

同理,第二个配料类(即Bacon)的代码就不难写出了。

package com.meimeixia.pattern.decorator;

/**
 * 培根类(具体的装饰者角色)
 * @author liayun
 * @create 2021-07-31 13:40
 */
public class Bacon extends Garnish {

    public Bacon(FastFood fastFood) {
        super(fastFood, 2, "培根");
    }

    @Override
    public float cost() {
        // 计算价格
        return getPrice() + getFastFood().cost();
    }

    @Override
    public String getDesc() {
        return super.getDesc() + getFastFood().getDesc();
    }
}

最后,我们就要编写一个客户端类来测试一下了。

package com.meimeixia.pattern.decorator;

/**
 * @author liayun
 * @create 2021-07-31 15:32
 */
public class Client {
    public static void main(String[] args) {
        // 点一份炒饭
        FastFood food = new FriedRice();
        // 打印炒饭的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
    }
}

此时,运行以上客户端类,如下图所示,可以看到打印的炒饭的价格确实是10块钱,因为我们之前对炒饭的价格设置就是10块钱。

如果我们此时想在上面的炒饭里面加上一个炒鸡蛋,那么应该怎么去做呢?测试代码是不是应该像下面这样啊!

package com.meimeixia.pattern.decorator;

/**
 * @author liayun
 * @create 2021-07-31 15:32
 */
public class Client {
    public static void main(String[] args) {
        // 点一份炒饭
        FastFood food = new FriedRice();
        // 打印炒饭的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 在上面的炒饭中加一个鸡蛋
        food = new Egg(food);
        // 打印炒饭加上鸡蛋之后的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
    }
}

再运行以上客户端类,如下图所示,可以看到炒饭加了一个鸡蛋之后,总价确实变成了11块钱。

现在我还能在以上鸡蛋炒饭里面再加上一个炒鸡蛋吗?显然是可以的啊!测试代码如下所示。

package com.meimeixia.pattern.decorator;

/**
 * @author liayun
 * @create 2021-07-31 15:32
 */
public class Client {
    public static void main(String[] args) {
        // 点一份炒饭
        FastFood food = new FriedRice();
        // 打印炒饭的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 在上面的炒饭中加一个鸡蛋
        food = new Egg(food);
        // 打印炒饭加上鸡蛋之后的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 再加一个鸡蛋
        food = new Egg(food);
        System.out.println(food.getDesc() + " " + food.cost() + "元");
    }
}

再运行以上客户端类,如下图所示,可以看到鸡蛋炒饭加了一个炒鸡蛋之后,总价确实变成了12块钱。

那么我们还能再给以上鸡蛋鸡蛋炒饭加一根培根吗?显然是可以的啊!测试代码如下所示。

package com.meimeixia.pattern.decorator;

/**
 * @author liayun
 * @create 2021-07-31 15:32
 */
public class Client {
    public static void main(String[] args) {
        // 点一份炒饭
        FastFood food = new FriedRice();
        // 打印炒饭的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 在上面的炒饭中加一个鸡蛋
        food = new Egg(food);
        // 打印炒饭加上鸡蛋之后的价格与描述
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 再加一个鸡蛋
        food = new Egg(food);
        System.out.println(food.getDesc() + " " + food.cost() + "元");
        System.out.println("===================");
        // 再加一个培根
        food = new Bacon(food);
        System.out.println(food.getDesc() + " " + food.cost() + "元");
    }
}

再运行以上客户端类,如下图所示,可以看到鸡蛋鸡蛋炒饭加了一根培根之后,总价确实变成了14块钱。

当然,对于炒面,你也可以参照以上代码再去进行测试,只不过这里我就略过了。

最终,你会发现使用装饰者模式设计出来的系统会特别特别灵活,如果此时我们想要再去新增一个配料的话,例如火腿肠,那么我们只需要再去定义一个火腿肠类,然后让它去继承装饰者类就可以了。

装饰者模式的好处以及使用场景

好处

装饰者模式的好处,我总结出来了如下两个。

  1. 装饰者模式可以带来比继承更加灵活性的扩展功能(这是因为装饰者模式本身就是在原有的基础上进行了一个扩展),使用更加方便,可以通过组合不同的装饰者对象来获取具有不同行为状态的多样化的结果(比如说,你可以在炒饭的基础上加鸡蛋、加培根)。装饰者模式比继承更具良好的扩展性,完美的遵循开闭原则,继承是静态的附加责任,装饰者则是动态的附加责任

  2. 装饰者类和被装饰者类可以独立发展,不会相互耦合,装饰者模式是继承的一个替代模式,装饰者模式可以动态扩展一个实现类的功能。

    上面这句话说的是啥意思啊?我用上面的快餐店案例来给大家详细解释一下。如果现在我们要再添加一种品类的快餐的话,例如炒河粉,那么是不是只需要再去定义一个炒河粉类,然后让它直接去继承FastFood类就可以了啊?而如果此时我们想要再去添加一个配料的话,例如火腿肠,那么是不是只需要再去定义一个火腿肠类,然后让它去继承装饰者类就可以了啊?这样,装饰者类和被装饰者类不就可以独立发展了嘛!而且它们还不会相互耦合

使用场景

装饰者模式的使用场景,我总结出来了如下三个。

  1. 当不能采用继承的方式对系统进行扩充或者采用继承不利于系统扩展和维护时,我们就可以使用装饰者模式了。

    不能采用继承的情况主要有两类:

    • 第一类是系统中存在大量独立的扩展(比如说配料或者快餐的品种),为支持每一种组合将产生大量的子类,使得子类数目呈爆炸性增长。而定义太多的子类会让系统变得更加的复杂
    • 第二类是因为类定义不能继承(如final类)
  2. 在不影响其他对象的情况下,以动态、透明的方式给单个对象添加职责

  3. 当对象的功能要求可以动态地添加,也可以再动态地撤销时。

    哎,动态地撤销又该怎么去理解呢?我用上面的快餐店案例再来给大家详细解释一下。快餐店里面的鸡蛋卖完了之后,我们只需要动态地把Egg这个子类移除掉就可以了;又买了一些鸡蛋之后,再将该子类添加上就行,这就是所谓的动态地添加和撤销

装饰者模式在JDK源码中的应用

接下来,我们来看下装饰者模式在JDK源码里面是如何应用的?

IO流中的包装类使用到了装饰者模式。而哪些属于包装类呢?像BufferedInputStream、BufferedOutputStream、BufferedReader以及BufferedWriter等这些都属于包装类。也就是说这些类都用到了装饰者模式。下面我们就以BufferedWriter举例来说明一下。

我们不妨先看看如何使用BufferedWriter类吧!

public class Demo {
    public static void main(String[] args) throws Exception{
        // 创建BufferedWriter对象
        // 创建FileWriter对象
        FileWriter fw = new FileWriter("C:\\\\Users\\\\liayun\\\\Desktop\\\\a.txt");
        BufferedWriter bw = new BufferedWriter(fw);

        //写数据
        bw.write("Hello Buffered");

        bw.close();
    }
}

以上代码很简单,我们是要去创建BufferedWriter对象的,但是其构造方法里面需要一个Writer的子实现类对象,所以我们得提前创建一个FileWriter对象,并把它作为参数进行一个传递。然后,我们就能使用缓冲流里面的write方法进行数据的一个写出操作了,最后就是释放资源。

当然以上代码我在这里就不给大家运行演示了,如果你要是有兴趣的话,不妨私下自己去跑一跑。

简单使用了一下BufferedWriter类之后,你会发现它使用起来感觉确实像是装饰者模式,那么到底是不是呢?我们来看一下下面这张类图。

可以看到,顶层父类是Writer,它是字符输出流的顶层父类,并且它还有几个子类,一个子类是InputStreamWriter(该类下面又有一个子类,即FileWriter),一个子类是BufferedWriter。

你注意看,BufferedWriter类同时又聚合了Writer类,也就是说BufferedWriter类不仅继承自Writer类,并且还聚合了Writer类,这很明显就是装饰者模式,装饰者模式的巧妙之处就在于这。

至此,通过以上类图,我们就分析出了BufferedWriter类确实是用到了装饰者模式。至于其他的几个类,大家有兴趣的话,可以自己去查看一下它们的源代码,看一下它们是不是也用到了装饰者模式,只是我在这里就不赘述了。

最后,我做一个小结,BufferedWriter使用装饰者模式对Writer子实现类进行了增强,即添加了缓冲区,提高了写数据的效率。

装饰者模式和静态代理的区别

接下来,我们来说说静态代理和装饰者模式的一个区别,因为它俩很像很像,不熟悉它俩之间区别的人很容易把它们搞混到一块去。

相同点

装饰者模式和静态代理的相同点:

  1. 都要实现与目标类相同的业务接口。

    这是说的啥意思呢?我们不妨往上看一下上面的快餐点案例的类图,可以看到都得去继承(或者实现)FastFood抽象类,是不是啊?

  2. 在两个类中都要声明目标对象。也就是说,都要把你继承的那个类的子类对象给聚合进来

  3. 都可以在不修改目标类的前提下增强目标方法

以上几点看下来,静态代理和装饰者模式是不是特像啊!

不同点

装饰者模式和静态代理的不同点:

  1. 目的不同。装饰者模式是为了增强目标对象,而静态代理是为了保护和隐藏目标对象。

    这话什么意思呢?回顾一下使用装饰者模式实现的快餐店案例的代码,在装饰者类(即Garnish)中,是不是声明了一个FastFood类型的成员变量啊!你注意了,在这儿我们并没有对该成员变量进行赋值,那么它应该由谁来赋值呢?谁使用,谁就对该成员变量进行赋值;而如果是静态代理的话,那么在这一块就是直接创建一个FastFood对象,并将其赋值给该成员变量了,这就相当于是保护和隐藏了目标对象

  2. 获取目标对象构建的地方不同。装饰者模式是由外界传递进来的,可以通过构造方法传递,当然我们也可以通过setter方法进行一个传递;而静态代理是在代理类内部创建的,也就是说如果代理类是Garnish这个类的话,那么在成员变量处就直接创建了FastFood对象,这样做的目的就是为了隐藏目标对象

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

从零开始学习Java设计模式 | 结构型模式篇:组合模式

从零开始学习Java设计模式 | 结构型模式篇:组合模式

从零开始学习Java设计模式 | 结构型模式篇:装饰者模式

从零开始学习Java设计模式 | 结构型模式篇:装饰者模式

从零开始学习Java设计模式 | 结构型模式篇:外观模式

从零开始学习Java设计模式 | 结构型模式篇:外观模式