一 创新的本质
创新的本质是无限的组合,我们以有限的基础元素尝试未曾有过的组合,得到的便是全新的产物…即使有时候它是那么糟糕。
二 以继承实现组合
关于组合一词,可能会与代码层面中的“组合”相混淆,因此在下文中,我会通过添加“逻辑”“代码”修饰的方式予以区分。
在实际开发中,我们如何实现组合(逻辑)呢?方案必然是多种多样的,这里我们问的更加具体一些,现在要求你通过组合(逻辑)基础饮料的方式来创新出搭配饮料,并实现搭配饮料价格的计算,这个代码你该怎么写?
从常规思路出发,首先很容易就能想到是基于饮料的多样性及价格的通用性,我们应该建立一个饮料父(超)类令所有的饮料都具备计算价格的能力。
import lombok.Data;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午3:37
* @Description: 饮料类
*/
@Data
public class Drink {
/**
* 计算价格
*
* @return 价格
*/
public Integer computePrice() {
return 0;
}
}
随后是建立具体的基础饮料类,并重写计算价格方法。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:12
* @Description: 可可类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Cocoa extends Drink {
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return 5;
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/15 下午8:20
* @Description: 果汁类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Juice extends Drink {
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return 10;
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/15 下午5:11
* @Description: 牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Milk extends Drink {
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return 15;
}
}
最后则是创建新的饮料类,组合(代码)基础饮料类进搭配饮料类中并重写计算价格方法。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午5:38
* @Description: 果汁牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class JuiceMilk extends Drink {
/**
* 果汁
*/
private Juice juice;
/**
* 牛奶
*/
private Milk milk;
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return this.juice.computePrice() + this.milk.computePrice();
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:23
* @Description: 可可牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class CocoaMilk extends Drink {
/**
* 可可
*/
private Cocoa cocoa;
/**
* 牛奶
*/
private Milk milk;
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return this.cocoa.computePrice() + this.milk.computePrice();
}
}
上述代码是一个很常规的写法。组合(代码)所需基础饮料类创建新的饮料类,这样无论如何组合都可实现,但现实情况是组合的可能是无限的,同一份基础饮料组合两次也是一种新的组合,并且海量的基础饮料种类也不是案例中区区的三种可以比拟的,难道每进行一种新组合(逻辑)就在搭配饮料类中进行繁复的组合(代码)吗?这是不现实的!如此行为的后果是开发者将受困于维护海量的饮料类而难以脱身(发现没有?令我们抛弃常规写法而采用/创新设计模式的动机往往就是难以招架的维护工作)。那是否有方案可令搭配饮料类轻松快捷的获取到基础饮料类呢?有的,而且很常见——继承。
我们可以建立一个搭配饮料类的父(超)类,将所有的基础饮料类(包括后期新增的)都组合(代码)为其的属性,然后使具体的搭配饮料类继承这个父(超)类,这样搭配饮料类就在一瞬间持有了所有基础饮料类,并通过具体基础饮料类的值来控制有无,具体如下。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.util.Objects;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:50
* @Description: 搭配饮料类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class MatchDrink extends Drink {
/**
* 可可
*/
private Cocoa cocoa;
/**
* 果汁
*/
private Juice juice;
/**
* 牛奶
*/
private Milk milk;
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
int price = super.computePrice();
// 判断基础饮料是否不为NULL,如果不为NULL则相加价格。
if (Objects.nonNull(this.cocoa)) {
price = price + this.cocoa.computePrice();
}
if (Objects.nonNull(this.juice)) {
price = price + this.juice.computePrice();
}
if (Objects.nonNull(this.milk)) {
price = price + this.milk.computePrice();
}
return price;
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午5:38
* @Description: 果汁牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class JuiceMilk extends MatchDrink {
public JuiceMilk() {
setJuice(new Juice());
setMilk(new Milk());
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:23
* @Description: 可可牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class CocoaMilk extends MatchDrink {
public CocoaMilk() {
setCocoa(new Cocoa());
setMilk(new Milk());
}
}
/**
* @Author: 说淑人
* @Date: 2022/5/15 下午4:49
* @Description: 主类
*/
public class Main {
/**
* 主方法
*
* @param args 参数集
*/
public static void main(String[] args) {
// 可可牛奶。
CocoaMilk cocoaMilk = new CocoaMilk();
System.out.println("可可牛奶的价格为:" + cocoaMilk.computePrice());
System.out.println();
// 果汁牛奶。
JuiceMilk juiceMilk = new JuiceMilk();
System.out.println("果汁牛奶的价格为:" + juiceMilk.computePrice());
}
}
以继承实现组合(逻辑)并不是一个好方案。首先继承虽然使搭配饮料类轻松获取到了所有基础饮料类,但在基础饮料类种类繁多的情况下调控有无依然过于繁杂;其次如果后期基础饮料类发生变动(增删改)的话我们将无法避免的对搭配饮料类进行修改(添加/删除基础饮料类的引用及价格);最后如果我们想对同一基础饮料类进行多份组合的话使用继承依然是难以完美(无法很好的做到)应对的…于是就有了解决方案“装饰者模式”的存在。
三 装饰者模式(Decorator Pattern)
回归原初,我们依旧先来探讨设计原则。装饰者模式主要(即并不限于文中列举的原则)遵循了【封装变化】【面向接口编程,而非面向实现编程】【开闭原则】等设计原则。
封装变化。
装饰者模式的实现强烈依赖于“装饰者”与“被装饰者”的父(超)类,正是因为两者的存在,才解决了封装过程中遇到的种类与数量问题。
找出程序中变化的部分并封装。在上述代码中,变化的部分在于基础饮料类的种类与数量,种类问题我们可以通过已有的饮料父(超)类来解决,但数量呢?
装饰者模式中存在两种身份——“装饰者”及“被装饰者”,其中“装饰者”是在“被装饰者”基础上的再拓展,因此所有“装饰者”都是“被装饰者”,而“被装饰者”却不一定是“装饰者”。事实上无需太在意两种身份,因为身份转变是一件很简单的事情。在装饰者模式的整个流程中,“被装饰者”固定只有一个,而“装饰者”的种类和数量则是不受限的,由此就解决了封装时的基础饮料类的数量问题。
装饰者模式致力于使用“装饰者”将“被装饰者”层层装饰。这一点通过对上图的简单思考,相信很容易理解,那么代码如何实现呢?首先我们需要选择一个具体的基础饮料类作为“被装饰者”(被装饰者的父(超)类由饮料父(超)类Drink担任),基于上述的例子,这里我们选择Milk作为“被装饰者”。其次为了让其它基础饮料类获取“装饰者”的身份,我们需要建造一个装饰者父(超)类,之前已经提及“装饰者”是在“被装饰者”基础上的再拓展,因此装饰者父(超)类必须继承被装饰者父(超)类。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午1:46
* @Description: 配料类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Burden extends Drink {
/**
* 饮料
*/
protected Drink drink;
}
装饰者父(超)类中需组合被装饰者父(超)类。组合被装饰者父(超)类的目的是为了持有被装饰者对象的引用,从而达到装饰的目的。
改写其它基础饮料类,令其继承装饰者父(超)类,从而获得“装饰者”身份。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:12
* @Description: 可可类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Cocoa extends Burden {
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
// 将装饰者的价格与被装饰者的价格相加。
return 5 + drink.computePrice();
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/15 下午8:20
* @Description: 果汁类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Juice extends Burden {
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
// 将装饰者的价格与被装饰者的价格相加。
return 10 + drink.computePrice();
}
}
完成所有的准备工作后,通过装饰者模式实现搭配饮料类的创建。
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午6:23
* @Description: 可可牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class CocoaMilk extends Burden {
public CocoaMilk() {
// 声明一个牛奶类对象作为被装饰者。
Milk milk = new Milk();
// 声明一个可可 类对象作为装饰者。
Cocoa cocoa = new Cocoa();
// 用可可类对象装饰牛奶类对象。
cocoa.drink = milk;
// 用可可牛奶类对象进行二次装饰。
drink = cocoa;
}
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return drink.computePrice();
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/22 下午5:38
* @Description: 果汁牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class JuiceMilk extends Burden {
public JuiceMilk() {
// 声明一个牛奶类对象作为被装饰者。
Milk milk = new Milk();
// 声明一个果汁类对象作为装饰者。
Juice juice = new Juice();
// 用果汁类对象装饰牛奶类对象。
juice.drink = milk;
// 用果汁牛奶类对象进行二次装饰。
drink = juice;
}
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return drink.computePrice();
}
}
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* @Author: 说淑人
* @Date: 2022/5/29 下午3:51
* @Description: 双倍果汁牛奶类
*/
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class DoubleJuiceMilk extends Burden {
public DoubleJuiceMilk() {
// 声明一个牛奶类对象作为被装饰者。
Milk milk = new Milk();
// 声明一个果汁类对象作为装饰者。
Juice juice = new Juice();
// 用果汁类对象装饰牛奶类对象。
juice.drink = milk;
// 声明一个新果汁类对象作为装饰者。
Juice newJuice = new Juice();
// 用新果汁类对象装饰旧果汁类对象(相当于组合了两份果汁)。
newJuice.drink = juice;
// 用双倍果汁牛奶类对象对新果汁类对象进行装饰。
drink = newJuice;
}
/**
* 计算价格
*
* @return 价格
*/
@Override
public Integer computePrice() {
return drink.computePrice();
}
}
最后让我们尝试一下用装饰者模式创建的搭配饮料类,计算其价格。
/**
* @Author: 说淑人
* @Date: 2022/5/15 下午4:49
* @Description: 主类
*/
public class Main {
/**
* 主方法
*
* @param args 参数集
*/
public static void main(String[] args) {
// 可可牛奶。
CocoaMilk cocoaMilk = new CocoaMilk();
System.out.println("可可牛奶的价格为:" + cocoaMilk.computePrice());
System.out.println();
// 果汁牛奶。
JuiceMilk juiceMilk = new JuiceMilk();
System.out.println("果汁牛奶的价格为:" + juiceMilk.computePrice());
System.out.println();
// 双倍果汁牛奶。
DoubleJuiceMilk doubleJuiceMilk = new DoubleJuiceMilk();
System.out.println("双倍果汁牛奶的价格为:" + doubleJuiceMilk.computePrice());
}
}
装饰者模式下方法呈嵌套式调用。原继承方案下计算价格方法的实现方式是同级性的累加,而装饰者模式则通过装饰者调用被装饰者的计算价格方法而产生的层级嵌套效果实现。
面向接口编程,而非面向实现编程。
装饰者模式的实现强烈依赖于“装饰者”与“被装饰者”的父(超)类,正是因为两者的存在,才解决了封装过程中遇到的种类与数量问题。
开闭原则。
开闭原则:对拓展开放,对修改关闭(即新增类时,不会导致原有类发生修改)。开闭原则是装饰者模式最具代表性的设计原则。这就像观察者模式为了遵循松耦合设计原则而遵循了其它设计原则一样,装饰者模式也是为了遵循开闭原则而遵循了其它原则。我们放弃继承方案的根本原因便是其没有遵循开闭原则(虽然也有难以应对多数量组合(逻辑)场景的原因),当新增基础饮料类时,会导致旧的搭配饮料料类发生修改,具体表现在搭配饮料类的父(超)类需要组合新的基础饮料类,并修改重写的计算价格方法…它虽然对拓展(新增基础/搭配饮料类)开放,但没有对修改(修改已存在的搭配饮料类)关闭。而装饰者模式则支持着一点,是想当你新增一个基础饮料类,不会有任何一个饮料类需要修改,能够做到这一点是因为其在父(超)类中不依赖任何一个具体的子类,而继承方却在搭配饮料类的父(超)类中依赖了具体的基础饮料类。
开闭原则只在拓展时对修改关闭。有些同学会对开闭原则产生误解(比如我),认为其的对修改关闭是支持所有场景的,这是错误的理解。开闭原则只在拓展时对修改关闭,而对于修改/删除等其它场景是不支持的,是想你删除一个原有的基础饮料类,那怎么可能不对相关的搭配饮料类做修改呢?除非这个基础饮料类从未被使用过,修改基础饮料类也是同理。
四 优缺点
对比文中的三个方案(常规/继承/装饰)中,各有优劣,在这里做一个统一总结。
常规方案
- 优点:遵循开闭原则;
- 缺点:难以应对多数量组合(逻辑)场景;难以维护海量的类及其组合(代码)。
继承方案
- 优点:类及其组合(代码)的维护相对容易;
- 缺点:难以应对多数量组合(逻辑)场景;不遵循开闭原则。
装饰方案
- 优点:很好应对多数量组合(逻辑)场景;遵循开闭原则;
- 缺点:难以维护海量的类及其组合(代码);难以理解诸多的搭配类。
是的,你没有看错,装饰者模式也存在难以维护的缺点。虽然装饰者模式与常规方案的组合(代码)方式并不相同(一个是嵌套组合,一个是常规组合),但在维护量上基本没有变化…例如你删除一个基础饮料类,无论你使用的是常规方案还是装饰者模式,需要维护的搭配饮料类数量基本是一样的。
难以理解诸多的搭配类也是装饰者模式一个较大的缺点。由于是嵌套组合,一旦嵌套的深度过深,就容易导致初学者看着庞大的类体系而难以入手,举个例子吧…I/O流API…典型中的典型…一堆见都没见过的输入输出流,看得人眼睛发昏脑袋发胀。
五 核心思想
就像我在设计原则中说的,装饰者模式是为了遵循开闭原则而遵循了其它原则。故而其的核心思想便是在不(少)影响原程序的前提下对程序进行拓展,而这也是每一位程序员都必须时刻牢记并实施的编程原则。