浅谈设计模式 - 装饰器模式(五)
前言:
装饰器模式是是对类进行增强的一种典型设计模式,它允许对于一个现有类进行增强的操作,对于喜欢使用继承的伙伴,这个模式非常贴切的展示的了对于继承的灵活用法。但是装饰器模式同样不是一个推崇使用的模式,因为他对于继承存在依赖性,从本文后续就可以了解到装饰类膨胀的问题,所以在设计代码结构的时候,装饰器模式并不是第一考虑。
什么是装饰器模式?
装饰器模式:对现有类不改动结构的情况下为类添加新职责和功能的模式。
动态的扩展类的职责,装饰器模式是一种是比继承更加灵活的代码扩展模式。同时装饰类之间可以进行互相的嵌套
装饰器模式的结构图:
- Component 装饰接口:装饰接口定义了装饰的顶层抽象行为,一般定义被装饰者和装饰者的公用行为
- ConrecteComponent 被装饰类:主要为被装饰类实现,和装饰类相互独立,拥有单独的功能方法
- Decorder 装饰器:定义了装饰的通用接口,包含装饰器的通用方法
- ConrecteDecorderA 装饰器A:定义了装饰器的具体设计,可以包含自己的装饰方法
- ConrecteDecorderB 装饰器B:定义了装饰器的具体设计,可以包含自己的装饰方法
装饰器模式的特点
- 装饰者和被装饰者都需要实现相同的接口(必要条件)
- 装饰者一般需要继承一个抽象类,或者需要定义抽象的方法和实现
- 装饰者可以在所委托被装饰者的行为之前或之后,加上自己的行为,以达到特定的目的。
- 任何父类出现的地方都可以用子类进行替换,在活用继承的同时可以灵活的扩展。
什么时候使用装饰器模式
- 需要大量的子类为某一个对象进行职责增强的时候,可以使用装饰器模式
- 希望使用继承对于类进行动态扩展的时候,可以考虑使用装饰器模式
实际案例:
模拟场景:
我们用一个奶茶的结构来模拟一个装饰器的设计场景,我们通常在奶茶店点奶茶的时候,对于一杯奶茶,可以添加各种配料,这时候配料就是奶茶的装饰者,而奶茶就是典型的被装饰者,我们使用配料去“装饰”奶茶,就可以得到各种口味的奶茶。同时可以计算出奶茶的价格
下面我们来看一下针对模拟场景的案例和使用:
不使用设计模式:
不使用设计模式,我们的第一考虑就是简单的使用继承去设计装饰类,我们通过各种子类组合来实现一杯杯不同口味的奶茶,从下面的结构图可以看到,将被装饰类定义为独立的类,同时不进行任何的继承而是作为独立的类使用。而调料也就是奶茶饮料的配料需要继承同一个抽象类,同时在内部实现自己的方法。
紧接着,我们在装饰者的方法中引入被装饰者,可以通过内部组合被装饰者进行 模仿行为的同时进行增强,就像IO当中的Buffer
。
我们根据上面的说明画出这一种设计的大致结构图:
看了上面的设计图稿之后,我们来说明一下具体的代码实现:
首先是奶茶实体类:在奶茶的实体类里面定义两个属性, 使用一个display()
打印信息,奶茶的实体类表示被装饰类
/**
* 奶茶实体类
*
* @AuThor zxd
* @version 1.0
* @date 2021/2/7 22:21
*/
public class MilkTea {
private String name;
private double price;
public MilkTea(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public void display() {
System.out.println("name = "+ name + " price = " +price);
}
}
下面是柠檬汁的被装饰类,这个被装饰类也是独立的:
/**
* 柠檬汁
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:53
*/
public class LeamonJuice {
private String name;
private double price;
public LeamonJuice(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public void display() {
System.out.println("name = "+ name + " price = " +price);
}
}
调料的父类:注意这是一个抽象类,定义了调料的基本方法。
/**
* 调料父类
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:23
*/
public abstract class Codiment {
/**
* 为装饰类添加附加值
* @return
*/
abstract void plusAdditionVal(MilkTea milkTea);
/**
* 详细信息
*/
protected String description(){
return "无任何配料";
}
}
调料的子类珍珠类,这里为父类进行装饰,添加父类的信息
/**
* 配料:珍珠
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:27
*/
public class Pearl extends Codiment{
@Override
void plusAdditionVal(MilkTea milkTea) {
if(milkTea == null){
throw new RuntimeException("对不起,请先添加奶茶");
}
milkTea.setPrice(milkTea.getPrice() + 2);
milkTea.setName(milkTea.getName() + "," +description());
}
/**
* 详细信息
*/
protected String description(){
return "珍珠";
}
}
调料的子类椰果类,这里同样是为了父类进行装饰的方法:
/**
* 配料:椰果
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:30
*/
public class Coconut extends Codiment{
@Override
void plusAdditionVal(MilkTea milkTea) {
if(milkTea == null){
throw new RuntimeException("对不起,请先添加奶茶");
}
milkTea.setPrice(milkTea.getPrice() + 1);
milkTea.setName(milkTea.getName() + "," +description());
}
@Override
protected String description() {
return "椰果";
}
}
最后我们使用一个单元测试:
/**
* 单元测试
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:34
*/
public class Main {
public static void main(String[] args) {
MilkTea milkTea = new MilkTea("原味奶茶", 5);
Pearl pearl = new Pearl();
Coconut coconut = new Coconut();
pearl.plusAdditionVal(milkTea);
coconut.plusAdditionVal(milkTea);
milkTea.display();
}
}/*
打印结果:name = 原味奶茶,珍珠,椰果 price = 8.0
*/
不使用设计模式的优缺点:
优点:
- 添加一个装饰者十分简单,只需要继承抽象父类接口,同时子类只需要通过方法传入被装饰者进行装饰。
缺点:
- 我们的调料父类如果增加抽象方法,所有的子类都需要改动,这是整个子类群体来说是毁灭性的,对于编写代码的程序员来说也是毁灭性的。
- 可以看到装饰者已经是一种面向实现编程的状态,如果我们换一种被装饰者,需要添加更多的装饰类进行装饰。并且这些装饰者是相互独立并且不能复用的
从结构图的设计就可以看出这种设计不符合面向接口编程的设计原则
总结不使用模式:
不使用设计模式看起来没有什么大问题,但是可以从结构可以看到抽象父类以及子类的耦合过于严重,父类完全不敢动abstract void plusAdditionVal(MilkTea milkTea)
这个抽象签名方法,并且如果需求增加一个其他的被装饰者,这些装饰奶茶的装饰者就完全“傻眼”了,因为他们完全不认识新的被装饰者,这导致程序要更多的子类来接纳新的的被装饰者,这种设计结构将导致类子类无限膨胀,没有尽头。
使用设计模式:
从不使用设计模式可以看出,不使用设计模式最大的问题是在于调料的父类抽象方法耦合过于严重,以及被装饰类和装饰者之间存在依赖磁铁。从结构图可以看出来被装饰类和装饰类并没有明显的关联,我们之前已经说明了装饰模式更多的是对于一个被装饰类的增强,既然是增强,那么被装饰类和装饰类通常需要具备相同的抽象行为,这样才比较符合装饰模式的设计结构。
下面就上面的结构图进行改进,在 被装饰类和装饰类之上,再增加一层接口,调料的父类不在管理公用接口,而是可以增加自己的方法。我们改进一下结构图,只要稍微改进一下,整个结构就可以变得十分好用:
为了方便展示代码和理解,这里只列出了奶茶类,调料父类,配料:珍珠,以及我们最重要的公用接口进行介绍:
我们从最顶层开始,最顶层在结构上定义了一个抽象公用接口,提供装饰者以及被装饰者进行实现或者定义抽象和扩展:
/**
* 饮料的抽象类,定义饮料的通用接口
*
* @author zxd
* @version 1.0
* @date 2021/2/7 23:46
*/
public interface DrinkAbstract {
/**
* 装饰接口
*/
void plusAdditionVal();
/**
* 计算售价
* @return
*/
double coat();
}
然后是奶茶类,我们的奶茶类在上一个版本基础上,实现了一个新的接口,所以需要定义实现接口后的方法:
奶茶类:
/**
* 奶茶实体类
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:21
*/
public class MilkTea implements DrinkAbstract{
private String name;
private double price;
public MilkTea(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public void display() {
System.out.println("name = "+ name + " price = " +price);
}
// 增加
@Override
public void plusAdditionVal() {
System.out.println("name = "+ name + " price = " + price);
}
// 增加
@Override
public double coat() {
return price;
}
}
下面是调料的父类,调料的父类需要改动的内容不是很多,本质上就是把自己的抽象方法提取到父接口。这个类可以是抽象类,也可以是配料接口的通用抽象:
/**
* 调料父类
* 这里需要实现饮料接口
* @author zxd
* @version 1.0
* @date 2021/2/7 22:23
*/
public class Codiment implements DrinkAbstract{
/**
* 为装饰类添加附加值
* @return
*/
public void plusAdditionVal(){
description();
}
@Override
public double coat() {
return 5.0f;
}
/**
* 详细信息
*/
private String description(){
return "无任何配料";
}
}
最后是配料的具体实现类配料-珍珠进行改动:
/**
* 配料:珍珠
*
* @author zxd
* @version 1.0
* @date 2021/2/7 22:27
*/
public class Pearl extends Codiment implements DrinkAbstract{
private DrinkAbstract drinkAbstract;
public Pearl(DrinkAbstract drinkAbstract) {
this.drinkAbstract = drinkAbstract;
}
@Override
public void plusAdditionVal() {
// 如果是奶茶
if(drinkAbstract instanceof MilkTea){
MilkTea drinkAbstract = (MilkTea) this.drinkAbstract;
drinkAbstract.setName(drinkAbstract.getName() + " -- " + "珍珠");
drinkAbstract.setPrice(drinkAbstract.getPrice() + 55);
description();
}
}
@Override
public double coat() {
return 5;
}
/**
* 详细信息
*/
private void description(){
drinkAbstract.plusAdditionVal();
}
}
最后,我们来看下单元测试的变化:
public class Main {
private static void run2(){
DrinkAbstract drinkAbstract = new MilkTea("原味奶茶", 5);
Pearl codiment = new Pearl(drinkAbstract);
codiment.plusAdditionVal();
}
public static void main(String[] args) {
run2();
}
}/*控制台结果:name = 原味奶茶 -- 珍珠 price = 60.0*/
可以看到我们使用装饰类对于被装饰类的属性进行了改变的同时并没有改变被装饰者的本身的行为,而是对于行为做了扩展。
使用装饰器设计模式的优缺点:
优点:
- 装饰类的公用类不再需要设置抽象的方法,使得装饰实现子类也不在依赖抽象父类的抽象方法
- 既然装饰者和被装饰对象有相同的超类型,所以在任何需要原始对象(被包装的)的场合,就可以用装饰过的对象代替它。
- 装饰类和被装饰类的扩展和实现都是解耦的,不需要互相关注实现细节,装饰子类可以独自实现方法
- 我们解决了增加新的被装饰类之后导致装饰类大量膨胀的问题,现在可以进行简单的应用。
缺点:
- 本质上还是继承结构,而且装饰类和被装饰类必须有相同的顶级父类接口
- 装饰类在系统越来越复杂之后会出现明显的膨胀。
JAVA IO - 典型的装饰模式:
首先说明JAVA IO
类其实本质上并不是一个十分优秀的设计(因为复杂的装饰子类和API结构),这个问题可以查看《JAVA编程思想》作者对于JAVA IO复杂难用的API以及继承结构进行过的一系列吐槽,而且JAVA IO经过后面版本的迭代改进。使得原本的方法更加复杂多变,但是不管JAVA IO设计的API如何不“便民”,这一块的设计依然是非常值得学习和思考的,也是装饰模式最典型的使用。
下面为一张《Head First设计模式的一张图》说明一下JAVA IO装饰设计的装饰器膨胀问题:
- 可以看到InputStream是一个抽象类。
- 在JDK1.5当中,他扩展自接口
java.io.Closeable
,规定需要接入装饰的类需要实现自己的流关闭方法。
- 在JDK1.7 中,在
Closeable
基础上增加了java.io.AutoClosable
来实现流的自动关闭功能。
从上面的图标也可以看到装饰器的一些缺点:
- 装饰类之间的具有复杂的继承结构
- 装饰者之间虽然可以互相嵌套,但是不一定互相兼容
JAVA IO对于JAVA初学者来说十分不友好,从其他语言可以看到吸取了这一点的教训,通常都把IO流这一块设计的越简单好用越好(尽量的让调用者不需要去思考IO流的细节问题)。而JAVA IO 显然设计的不是很亲民。
总结装饰器模式:
优点:
+ 装饰者和被装饰对象有相同的接口。
+ 可以用一个或多个装饰者包装一个被装饰对象或者被装饰对象。
+ 既然装饰者和被装饰对象有相同的超类型,所以在任何需要原始对象(被包装的)的场合,可以用装饰过的对象代替它。
+ 装饰者可以在所委托被装饰者的行为之前或之后,加上自己的行为,以达到特定的目的。
+ 装饰者可以无限的嵌套,因为他们本质上归属于同一个接口
缺点:
+ 装饰者很容易出现大量的小类,这让了解代码的人不容易清楚不同装饰的设计
+ 一个依赖其他具体类型的接口导入装饰者可能会带来灾难。所以导入装饰者要非常小心谨慎,并且仔细考虑是否真的需要装饰者模式
+ 装饰者互相嵌套可能会增加代码的复杂度,也增加扩展装饰者子类的复杂度,最终这个难题会变成调用者的难题
总结:
许多的设计模式书籍都警告过装饰器模式是一个需要谨慎考虑的设计模式,因为装饰模式很容易会造成装饰类的膨胀,同时对于特定类型接入装饰类可能会有意想不到的灾难,同时在接入装饰类的时候,需要仔细的了解公用接口和抽象类的实现,需要了解这一类装饰针对的行为,否则只是简单的继承装饰父类或者继承接口可能会有一些莫名其妙的问题。