Head First 設計模式(3)----裝飾者模式

吉祥發表於2019-04-03

本文參照《Head First 設計模式》,轉載請註明出處 對於整個系列,我們按照這本書的設計邏輯,使用情景分析的方式來描述,並且穿插使用一些問題,總結的方式來講述。並且所有的開發原始碼,都會託管到github上。 專案地址:github.com/jixiang5200…

回顧上一篇文章講解了設計模式中常用的一種模式------觀察者模式。並結合氣象站設計進行實戰解析,並且從自己設計到JAVA自帶設計模式做了講解。想要了解的朋友可以回去回看一下。

本章我們會繼續前面的話題,有關典型的繼承濫用問題。這一章會講解如何使用物件組合的方式,如何在執行時候做裝飾類。在熟悉裝飾技巧後,我們能夠在原本不修改任何底層的程式碼,卻可以給原有物件賦予新的職能。你會說,這不就是“裝飾者模式”。沒錯,接下來就是裝飾者模式的ShowTime時間。

1.前言

歡迎來到星巴茲咖啡,該公司是世界上以擴張速度最快而聞名的咖啡連鎖店。但是最近這家著名的咖啡公司遇到一個巨大的問題,因為擴充套件速度太快了,他們準備更新訂單系統,以合乎他們的飲料供應需求。

他們本來的設計方式如下:

Beverage類結構

然後客戶購買咖啡時,可以要求在其中加入任何調料,例如:奶茶,牛奶,豆漿。星巴茲根據業務需求會計算相應的費用。這就要求訂單系統必須考慮到這些調料的部分。

然後我們就看到他們的第一個嘗試設計:

各種飲料的類關係圖

是不是有一種犯了密集恐懼症的感覺,整完全就是“類爆炸”。 那麼我們分析一下,這種設計方式違反了什麼設計原則?沒錯,違反了以下兩個原則:

第二設計原則 針對於介面程式設計,不針對實現程式設計

第三設計原則 多用組合,少用繼承

那麼我們應該怎麼修改這個設計呢?

#利用繼承對Beverage類進行改造 首先,我們考慮對基類Beverage類進行修改,我們根據前面“類爆炸”進行分析。主要飲料包含各種調料(牛奶,豆漿,摩卡,奶泡。。。。)。 所以修改後的Beverage類的結構如下:

Beverage帶調料後的實現

Beverage類具體實現如下:

public class Beverage {
    protected String description;//飲料簡介
    
    protected boolean milk=false;//是否有牛奶
    
    protected boolean soy=false;//是否有豆漿
    
    protected boolean cocha=false;//是否有摩卡
    
    protected boolean whip=false;//是否有奶泡
    
    protected double milkCost=1.01;//牛奶價格
    
    protected double soyCost=1.03;//豆漿價格
    
    protected double cochaCost=2.23;//摩卡價格
    
    protected double whipCost=0.89;//奶泡價格
    
    
    public String getDescription() {
        return description;
    }


    public void setDescription(String description) {
        this.description = description;
    }


    public boolean hasMilk() {
        return milk;
    }


    public void setMilk(boolean milk) {
        this.milk = milk;
    }


    public boolean hasSoy() {
        return soy;
    }


    public void setSoy(boolean soy) {
        this.soy = soy;
    }


    public boolean hasCocha() {
        return cocha;
    }


    public void setCocha(boolean cocha) {
        this.cocha = cocha;
    }


    public boolean hasWhip() {
        return whip;
    }


    public void setWhip(boolean whip) {
        this.whip = whip;
    }
    
    


    public double getCochaCost() {
        return cochaCost;
    }


    public void setCochaCost(double cochaCost) {
        this.cochaCost = cochaCost;
    }


    public double getWhipCost() {
        return whipCost;
    }


    public void setWhipCost(double whipCost) {
        this.whipCost = whipCost;
    }


   

    public double cost(){
        
        double condiments=0.0;
        if(hasMilk()){//是否需要牛奶
            condiments+=milkCost;
        }
        if(hasSoy()){//是否需要豆漿
            condiments+=soyCost;
        }
        if(hasCocha()){//是否需要摩卡
            condiments+=cochaCost;
        }
        if(hasWhip()){//是否需要奶泡
            condiments+=whipCost;
        }
        return condiments;
    }

}
複製程式碼

實現其中一個子類DarkRoast:

public class DarkRoast extends Beverage{

    public DarkRoast(){
        description="Most Excellent Dark Roast!";
    }
    
    public double cost(){
        return 1.99+super.cost();
    }
}
複製程式碼

看起來很完美,也能滿足現有的業務需求,但是仔細思考一下,真的這樣設計不會出錯?

回答肯定是會出錯。

  • 第一,一旦調料的價格發生變化,會導致我們隊原有程式碼進行大改。
  • 第二,一旦出現新的調料,我們就需要加上新的方法,並需要改變超類Beverage類中cost()方法。
  • 第三,如果星巴茲咖啡研發新的飲料。對於這些飲料而言,某些調料可能並不合適,但是子類仍然會繼承那些本就不合適的方法,例如我就想要一杯水,加奶泡(hasWhip)就不合適。
  • 第四,如果使用者需要雙倍的摩卡咖啡,又應該怎麼辦呢?

2.開放-關閉原則

到這裡,我們可以推出最重要的設計原則之一:

第五設計原則 類應該對擴充開放,對修改關閉。

那麼什麼是開放,什麼又是關閉?開放就是允許你使用任何行為來擴充類,如果需求更改(這是無法避免的),就可以進行擴充!關閉在於我們花費很多時間完成開發,並且已經測試釋出,針對後續更改,我們必須關閉原有程式碼防止被修改,避免造成已經測試釋出的原始碼產生新的bug。

綜合上述說法,我們的目標在於允許類擴充,並且在不修改原有程式碼的情況下,就可以搭配新的行為。如果能實現這樣的目標,帶來的好處將相當可觀。在於程式碼會具備彈性來應對需求改變,可以接受增加新的功能用來實現改變的需求。沒錯,這就是擴充開放,修改關閉。

那麼有沒有可以參照的例項可以分析呢?有,就在第二篇我們介紹觀察者模式時,我們介紹到可以通過增加新的觀察者用來擴充主題,並且無需向原主題進行修改。

我們是否需要每個模組都設計成開放--關閉原則?不用,也很難辦到(這樣的人我們稱為“不用設計模式會死病”)。因為想要完全符合開放-關閉原則,會引入大量的抽象層,增加原有程式碼的複雜度。我們應該區分設計中可能改變的部分和不改變的部分(第一設計原則),針對改變部分使用開放--關閉原則。

3.裝飾模式

這裡,就到了開放--關閉原則的運用模式-----裝飾者模式。首先我們還是從星巴茲咖啡的案例來做一個簡單的分析。 分析之前兩個版本(類爆炸和繼承大法)的實現方式,並不能適用於所有的子類。

這就需要一個新的設計思路。這裡,我們將以飲料為主,然後執行的時候以飲料來“裝飾”飲料。舉個栗子,如果影虎需要摩卡和奶泡深焙咖啡,那麼要做的是:

  • 拿一個深焙咖啡(DarkRosat)物件

  • 以摩卡(Mocha)物件裝飾它

  • 以奶泡(Whip)物件裝飾它

  • 呼叫cost()方法,並依賴委託(delegate)將調料的價錢加上去。

具體的實現我們用一張圖來展示

  • 首先我們構建DarkRoast物件

    DarkRoast物件

  • 假如顧客需要摩卡(Mocha),再建立一個Mocha物件,並用DarkRoast物件包起來。

    Mocha物件

  • 如果顧客也想要奶泡(Whip),就建立一個Whip裝飾者,並將它用Mocha物件包起來。

    Mocha物件

  • 最後運算客戶的賬單的時候,通過最外層的裝飾者Whip的cost()就可以辦得到。Whip的cost()會委託他的裝飾物件(Mocha)計算出價格,再加上奶泡(Whip)的價格。

計算使用者的賬單

通過對星巴茲咖啡的設計方案分析,我們可以發現,所有的裝飾類都具備以下幾個特點:

  • 裝飾者和被裝飾物件有相同的超型別。

  • 你可以用一個或多個裝飾者包裝一個物件。

  • 既然裝飾者和被裝飾物件有相同的超型別,所以在任何需要原始物件(被包裝的)的場合,可以用裝飾過的物件代替它。

  • 裝飾者可以在所委託被裝飾者的行為之前與/或之後,加上自己的行為,以達到特定的目的。

  • 物件可以在任何時候被裝飾,所以可以在執行時動態地、不限量地用你喜歡的裝飾者來裝飾物件

什麼是裝飾模式呢?我們首先來看看裝飾模式的定義:

裝飾者模式動態地將責任附加到物件上。 若要擴充套件功能,裝飾者提供了比繼承更有彈性 的替代方案。

定義雖然已經定義了裝飾者模式的“角色”,但是未說明怎麼在我們的實現中如何使用它們。我們繼續在星巴茲咖啡中來熟悉相關的操作。

裝飾者模式類圖

其中裝飾者層級可以無限發展下去,不是如圖中一般兩層關係。並且元件也並非只有一個,可以存在多個。

現在我們就在星巴茲咖啡裡運用裝飾者模式:

使用裝飾模式的星巴茲咖啡

到這裡,我們隊裝飾者模式已經有了一個基本的認識。那麼我們已經解決了上面提到的四個問題:

  • 第一,一旦調料的價格發生變化,會導致我們隊原有程式碼進行大改。
  • 第二,一旦出現新的調料,我們就需要加上新的方法,並需要改變超類Beverage類中cost()方法。
  • 第三,如果星巴茲咖啡研發新的飲料。對於這些飲料而言,某些調料可能並不合適,但是子類仍然會繼承那些本就不合適的方法,例如我就想要一杯水,加奶泡(hasWhip)就不合適。
  • 第四,如果使用者需要雙倍的摩卡咖啡,又應該怎麼辦呢?

那麼根據第四個問題,假如我們需要雙倍摩卡豆漿奶泡拿鐵咖啡時,該如何去運算賬單呢?首先,我們先把前面的深度烘焙摩卡咖啡的設計圖放在這裡。

深度烘焙摩卡咖啡

然後我們只需要將Mocha的裝飾者加一,即可

雙倍摩卡豆漿奶泡拿鐵咖啡

4.實現星巴茲咖啡程式碼

前面已經把設計思想都設計出來了,接下來是將其具體實現了。首先從Beverage類下手

public abstract class Beverage1 {
    String description="Unknown Beverage";
    
    public String getDescription(){
        return description;
    }
    
    public abstract double cost();
}
複製程式碼

Beverage類非常簡單,然後再實現Condiment(調料類),該類為抽象類,也為裝飾者類

public abstract class CondimentDecorator extends Beverage1{

    //所有的調料裝飾者都必須重新實現 getDescription()方法。 
    public abstract String getDescription();
}
複製程式碼

前面已經有了飲料的基類,那麼我們來實現一些具體的飲料類。首先從濃縮咖啡(Espresso))開始,這裡需要重寫cost()方法和getDescription()方法

public class Espresso extends Beverage1{
    
    public Espresso(){
        //為了要設定飲料的描述,我 們寫了一個構造器。記住, description例項變數繼承自Beverage1
        description="Espresso";
    }
    
    public double cost() {
        //最後,需要計算Espresso的價錢,現在不需要管調料的價錢,直接把Espresso的價格$1.99返回即可。
        return 1.99;
    }
}
複製程式碼

再實現一個類似的飲料HouseBlend類。

public class HouseBlend extends Beverage1{

    public HouseBlend(){
        description="HouseBlend";
    }
    
    public double cost() {
      
        return 0.89;
    }
}
複製程式碼

重新設計DarkRoast1

public class DarkRoast1 extends Beverage1{
   
   public DarkRoast1(){
       description="DarkRoast1";
   }
   
   public double cost() {
     
       return 0.99;
   }

}
複製程式碼

接下來就是調料的程式碼,我們一開始已經實現了抽象元件類(Beverage),有了具體的元件(HouseBlend),也有了已經完成抽象裝飾者(CondimentDecorator)。現在只需要實現具體的裝飾者。首先我們先完成摩卡(Mocha)

public class Mocha extends CondimentDecorator{
	/**
	 * 要讓Mocha能夠引用一個Beverage,採用以下做法
	 * 1.用一個例項記錄飲料,也就是被裝飾者
	 * 2.想辦法讓被裝飾者(飲料)被記錄在例項變數中。這裡的做法是:
	 * 把飲料當作構造器的引數,再由構造器將此飲料記錄在例項變數中
	 */
	Beverage1 beverage;
	
	public Mocha(Beverage1 beverage) {
		this.beverage=beverage;
	}
	
	public String getDescription() {
		//這裡將調料也體現在相關引數中
		return beverage.getDescription()+",Mocha";
	}
	
	/**
	 * 想要計算帶摩卡的飲料的價格,需要呼叫委託給被裝飾者,以計算價格,
	 * 然後加上Mocha的價格,得到最終的結果。
	 */
	public double cost() {
		return 0.21+beverage.cost();
	}
	
	

}
複製程式碼

還有奶泡(Whip)類

public class Whip extends CondimentDecorator{
	/**
	 * 要讓Whip能夠引用一個Beverage,採用以下做法
	 * 1.用一個例項記錄飲料,也就是被裝飾者
	 * 2.想辦法讓被裝飾者(飲料)被記錄在例項變數中。這裡的做法是:
	 * 把飲料當作構造器的引數,再由構造器將此飲料記錄在例項變數中
	 */
	Beverage1 beverage;
	
	public Whip(Beverage1 beverage) {
		this.beverage=beverage;
	}
	
	public String getDescription() {
		//這裡將調料也體現在相關引數中
		return beverage.getDescription()+",Whip";
	}
	
	/**
	 * 想要計算帶奶泡的飲料的價格,需要呼叫委託給被裝飾者,以計算價格,
	 * 然後加上Whip的價格,得到最終的結果。
	 */
	public double cost() {
		return 0.22+beverage.cost();
	}
}
複製程式碼

豆漿Soy類

public class Soy extends CondimentDecorator{
	/**
	 * 要讓Soy能夠引用一個Beverage,採用以下做法
	 * 1.用一個例項記錄飲料,也就是被裝飾者
	 * 2.想辦法讓被裝飾者(飲料)被記錄在例項變數中。這裡的做法是:
	 * 把飲料當作構造器的引數,再由構造器將此飲料記錄在例項變數中
	 */
	Beverage1 beverage;
	
	public Soy(Beverage1 beverage) {
		this.beverage=beverage;
	}
	
	public String getDescription() {
		//這裡將調料也體現在相關引數中
		return beverage.getDescription()+",Soy";
	}
	
	/**
	 * 想要計算帶豆漿的飲料的價格,需要呼叫委託給被裝飾者,以計算價格,
	 * 然後加上Soy的價格,得到最終的結果。
	 */
	public double cost() {
		return 0.21+beverage.cost();
	}
}
複製程式碼

接下來就是呼叫測試類,具體實現如下:

public class StarbuzzCoffe {
	
	public static void main(String[] args) {
		//訂購一杯Espresso,不需要調料,列印他的價格和描述
		Beverage1 beverage=new Espresso();
		System.out.println(beverage.getDescription()+"$"
				+beverage.cost());
		
		//開始裝飾雙倍摩卡+奶泡咖啡
		Beverage1 beverage2=new DarkRoast1();
		beverage2=new Mocha(beverage2);
		beverage2=new Mocha(beverage2);
		beverage2=new Whip(beverage2);
		
		System.out.println(beverage2.getDescription()+"$"
				+beverage2.cost());
		
		//
		Beverage1 beverage3=new HouseBlend();
		beverage3=new Soy(beverage3);
		beverage3=new Mocha(beverage3);
		beverage3=new Whip(beverage3);
		
		System.out.println(beverage3.getDescription()+"$"
				+beverage3.cost());
		
		
	}
}
複製程式碼

執行結果:

執行結果
到這裡,我們已經完成裝飾者模式對於星巴茲咖啡的改造。

#Java中的真實裝飾者 前面已經研究了裝飾者模式的原理和實現方式,那麼在JAVA語言本身是否有裝飾者模式的使用範例呢,答案是肯定有的,那就是I/O流。

第一次查閱I/O原始碼,都會覺得類真多,而且一環嵌一環,閱讀起來會非常麻煩。但是隻要清楚I/O是根據裝飾者模式設計,就很容易理解。我們先來看一下一個範例:

讀取檔案

分析一下,其中BufferedInputStream及LineNumberInputStream都擴充套件自 FilterInputStream,而FilterInputStream是一個抽象的裝飾類。這樣看有些抽象,我們將其中的類按照裝飾者模式進行結構化,方便理解。

java.io類

我們發現,和星巴茲的設計相比,java.io其實並沒有多大的差異。但是從java.io流我們也會發現裝飾者模式一個非常嚴重的"缺點":使用裝飾者模式,常常會造成設計中有大量的小類,數量還非常多,這對於學習API的程式設計師來說就增加了學習難度和學習成本。但是,懂得裝飾者模式以後會非常容易理解和設計相關的類。

5.設計自己的IO類

在理解裝飾者模式和java.io的設計後,我們將磨鍊下自己的熟悉程度,沒錯,就是自己設計一個Java I/O裝飾者,需求如下:

編寫一個裝飾者,把輸入流內的所有大寫字元轉成小寫。舉例:當讀 取“ ASDFGHJKLQWERTYUIOPZXCVBNM”,裝飾者會將它轉成“ asdghjklqwertyuiopzxcvbnm”。具體的辦法在於擴充套件FilterInputStream類,並覆蓋read()方法就行了。

public class LowerCaseInputStream extends FilterInputStream{
    
    public LowerCaseInputStream(InputStream inputStream){
        super(inputStream);
    }
    
    
    public int read() throws IOException {
       int c=super.read();
       //判斷相關的字元是否為大寫,並轉為小寫
       return (c==-1?c:Character.toLowerCase((char)c));
    }
    
    /**
     * 
     *針對字元陣列進行大寫轉小寫操作
     * @see java.io.FilterInputStream#read(byte[], int, int)
     */
    public int read(byte[] b, int off, int len) throws IOException {
        int result=super.read(b,off,len);
        for(int i=off;i<off+result;i++){
            b[i]=(byte) Character.toLowerCase((char)b[i]);
        }
        return result;
    }

}
複製程式碼

接下來我們構建測試類InputTest

public class InputTest {
    public static void main(String[] args) {
        int c;
        try {
            InputStream inputStream=new LowerCaseInputStream(new BufferedInputStream(new FileInputStream("test.txt")));
            while((c=inputStream.read())>=0){
                System.out.print((char)c);
            }
        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}
複製程式碼

其中test.txt的內容可以自行編輯,放在專案根目錄下我的內容原文為:

test.txt原文

執行結果為:

執行結果

6.總結

至此,我們已經掌握了裝飾者模式的相關知識點。總結一下:

第五設計原則 類應該對擴充開放,對修改關閉。

裝飾者模式動態地將責任附加到物件上。 若要擴充套件功能,裝飾者提供了比繼承更有彈性 的替代方案。

相應的資料和程式碼託管地址github.com/jixiang5200…

相關文章