設計模式-裝飾器模式

煮詩君發表於2020-09-03

示例

對於裝飾器模式,我想先不談概念,而是先從一個例子開始說起,看看面對這樣的需求,我們應該如何處理,並希望由此逐步引出裝飾器模式以加深理解。

需求

假設現在需要開一個奶茶店,奶茶種類繁多,如紅豆奶茶,布丁奶茶,珍珠奶茶,紅豆珍珠奶茶等。種類雖多,但實質上都是在奶茶中加了各種配料而已。為了簡化實現,繼續假設奶茶的價格根據奶茶本身加上不同配料累計計算而成。然後,根據每個客戶的要求,每種奶茶又可以加糖或者加冰,加糖加冰不額外收費。

初級方案

在學習設計模式之前,或許最容易想到的方案就是繼承了,即先定義奶茶類,然後再定義各種奶茶子類繼承自奶茶類,考慮到以後或許還會有更多的飲品,例如咖啡,因此再定義一個飲品的抽象基類,讓奶茶類繼承自飲品基類,這樣一來,最終設計可能會如下類圖所示:

部分程式碼如下:

public abstract class Drink
{
    public string Name { get; set; }

    public int Price { get; set; }

    public abstract string Desc { get; }

    public abstract int Cost { get; }
}

public class Naicha : Drink
{
    public Naicha()
    {
        Name = "奶茶";
        Price = 8;
    }
    public override string Desc => this.Name;
    public override int Cost => this.Price;
}

public class HongDouNaicha : Naicha
{
    public HongDouNaicha()
    {
        Name += "+紅豆";
        Price += 1;
    }
}

public class ZhenzhuNaicha : Naicha
{
    public ZhenzhuNaicha()
    {
        Name += "+珍珠";
        Price += 3;
    }
}

...

問題

不難想象,這種設計是一種災難,因為它至少會出現如下四個問題:

  • 類爆炸,程式碼雖然只列了部分,但通過類圖可以看出類的數量必定會達到一個恐怖的地步;
  • 如果加冰改為收費,需要多處修改價格,程式碼維護困難,嚴重違反開閉原則;
  • 如果新增配料,類的數量會急劇增加,程式碼維護困難,嚴重違反開閉原則;
  • 無法實現加多份配料,如多冰、多糖等。

改進一

由於上述問題,現實促使我們不得不對方案進行改進,不過好在對於類爆炸的問題,我們是有經驗的,我們在學習工廠方法模式的時候就出現過類爆炸,我們通過合併的方式就演化出了抽象工廠模式,這裡我們也可以依葫蘆畫瓢,對類進行合併。
合併後的類圖如下:

再看看程式碼:

public abstract class Drink
{
    public string Name { get; set; }

    public int Price { get; set; }

    public abstract string Desc { get; }

    public abstract int Cost { get; }

    public abstract void AddBuding();

    public abstract void AddHongdou();

    public abstract void AddZhenzhu();

    public abstract void AddBing();

    public abstract void AddTang();
}

public class Naicha : Drink
{
    private string _desc = string.Empty;
    private int _cost = 0;
    public Naicha()
    {
        Name = "奶茶";
        Price = 8;
    }

    public override string Desc => this.Name + _desc;
    public override int Cost => this.Price + _cost;

    public override void AddBing()
    {
        _desc += "+冰";
    }

    public override void AddBuding()
    {
        _desc += "+布丁";
        _cost += 2;
    }

    public override void AddHongdou()
    {
        _desc += "+紅豆";
        _cost += 1;
    }

    public override void AddTang()
    {
        _desc += "+糖";
    }

    public override void AddZhenzhu()
    {
        _desc += "+珍珠";
        _cost += 3;
    }
}

優點

將各種子類都直接改成抽象方法放到Drink父類中,效果簡直立杆見影,起碼解決了初級方案中的兩個問題:

  • 消除了類爆炸的問題,程式碼簡潔,一下子就只剩下兩個類了;
  • 配料可以任意搭配組合,並且也可以加入多份。

缺點

但是新的問題也隨之而來:

  • 如果修改價格或新增配料就需要新增方法,違反了開閉原則;
  • 如果新增飲品咖啡,這時也會變得麻煩,因為咖啡需要冰和糖,同時還需要咖啡伴侶,但是不需要布丁、珍珠、紅豆等。
    public abstract class Drink
    {
        ...
    
        public abstract void AddKafeibanlv();
    }
    
    public class Naicha : Drink
    {
        ...
    
        public override void AddKafeibanlv()
        {
    
        }
    }
    
    public class Kafei : Drink
    {
        ...
    
        public override void AddBuding()
        {
    
        }
    
        public override void AddHongdou()
        {
    
        }
    
        public override void AddZhenzhu()
        {
    
        }
    
        public override void AddKafeibanlv()
        {
            _desc += "+咖啡伴侶";
            _cost += 2;
        }
    }
    
    可以看到,增加了咖啡之後,父類以及每個子類的程式碼都要跟著修改,而且每個子類都必須繼承大量無用的方法。

改進二

因此,我們還需要進一步改進,這次我們改進的方向是將這些方法抽象併合並,因為我們可以看到,上面的方案之所以會有這麼多問題就是因為面向了實現程式設計,每個方法都代表了一種配料,如果我們將這些配料全部繼承自同一個抽象類,然後提供一個面向抽象的AddPeiliao(Peiliao peiliao)方法不就可以這個問題了嗎?於是我們就有了如下改進:

為了滿足這個需求,我們對飲品基類也進行了較大的改造,程式碼如下:

public abstract class Drink
{
    protected List<Peiliao> Peiliaos = new List<Peiliao>();
    public string Name { get; set; }

    public int Price { get; set; }

    public int Cost
    {
        get
        {
            int cost = this.Price;
            foreach (var peiliao in Peiliaos)
            {
                cost += peiliao.Price;
            }
            return cost;
        }
    }

    public string Desc
    {
        get
        {
            string desc = this.Name;
            foreach (var peiliao in Peiliaos)
            {
                desc += "+" + peiliao.Name;
            }
            return desc;
        }
    }

    public void AddPeiliao(Peiliao peiliao)
    {
        Peiliaos.Add(peiliao);
    }
}

public class Naicha : Drink
{
    public Naicha()
    {
        Name = "奶茶";
        Price = 8;
    }
}

由於配料全部通過一個集合組合到了基類中,因此,不需要通過抽象方法讓子類計算價格,而是直接在基類中迴圈疊加計算,同時,由於大部分的功能都在基類中實現了,子類變得乾淨簡潔了。

再看看配料:

public abstract class Peiliao
{
    public abstract string Name { get; }

    public abstract int Price { get; }
}

public class Buding : Peiliao
{
    public override string Name => "布丁";

    public override int Price => 2;
}

同樣的簡潔乾淨。

優點

這樣的改進優點是巨大的,幾乎解決了所有問題:

  • 配料可任意搭配組合,並且滿足新增飲品的需求;
  • 新增飲品和配料均只需要增加新的類即可,滿足開閉原則

缺點

感覺上好像挺不錯的,堪稱完美!難道這就是今天的主角---裝飾器模式?其實,我們忽略了兩個問題:

  • 這個方案以及上一個方案都犯了一個致命的錯誤,就是修改了飲品類,這在很多時候是不被允許的,或者說根本做不到的,就好比我們要給手機加個裝飾---貼個膜,難道我們要先改一下手機的內部結構嗎?這明顯是不合理,也是做不到的。
  • Add方法也不太合理,飲料不應該具有新增配料的能力,這好比給了手機一個膜,手機自己貼上了,總覺得哪裡怪怪的。

改進三

其實,從設計原則的角度來講,上一個方案的改進已經很接近了,因為它已經滿足了開閉原則,擴充套件性方面也非常優秀,唯一的問題就是需要修改奶茶類,這通常是不能實現的。那麼,我們思路再次轉變一下,奶茶類不能改,但是配料可以改啊,我們換個依賴方向,將奶茶聚合到配料中不就可以了嗎?於是就有了如下類圖:

再看看程式碼:

public abstract class Drink
{
    public string Name { get; set; }

    public int Price { get; set; }

    public abstract string Desc { get; }

    public abstract int Cost { get; }
}

飲品類還原到了最初狀態,沒做任何修改。

public class Peiliao:Drink
{
    protected readonly Drink Drink;

    public Peiliao(Drink drink)
    {
        Drink = drink;
    }

    public override string Desc
    {
        get
        {
            return Drink.Desc + "+" + this.Name;
        }
    }

    public override int Cost
    {
        get
        {
            return Drink.Cost + this.Price;
        }
    }
}

將飲品類聚合到了配料類中,但是這裡和前一個方案又有所不同,因為配料畢竟是配料,聚合方向換了之後,通過new就只能得到配料而得不到奶茶了,因此,為了最終能得到奶茶,我們的配料也必須繼承自飲品類,這看起來很怪,但妙也妙在這裡,通過聚合+繼承的方式改進,可使得飲品的擴充套件更靈活,同時也遵守了開閉原則。其中,聚合是為了實現功能,而繼承是為了約束型別,這就是裝飾者模式。

定義

裝飾器模式動態地給一個物件增加一些額外的職責。就增加功能而言,裝飾器模式比生成子類更為靈活。

UML類圖

優缺點

優點

  • 可動態的給一個物件增加額外的職責
  • 有很好地可擴充套件性

缺點

  • 增加了程式的複雜度,剛接觸理解起來會比較困難

跟代理模式的區別

裝飾器模式跟代理模式類圖十分相似,但是,它們之間卻有很大的區別:

  • 裝飾器模式關注於在一個物件上動態的新增方法,而代理模式關注於控制對物件的訪問。
  • 裝飾器模式通常用聚合的方式,而代理模式通常採用組合的方式。
  • 裝飾器模式通常會套用多層,而代理模式通常只有一層。

但是由於他們的結構十分相似,因此很多時候二者可以做同樣的事,比如裝飾器模式和代理模式都可用於實現AOP(面向切面程式設計)。

經典案例

在.NET類庫中,System.IO.Stream就是裝飾者模式的一個經典案例,不過在這個案例中沒有用到Decorator基類。

總結

裝飾器模式可以說是結構型設計模式的巔峰之作,其中設計思想十分精妙,但理解起來也確實有些困難,因此,可能還是需自己動手擼碼,加深體會。

原始碼連結

相關文章