深入淺出設計模式(Design Pattern)-裝飾器模式(3-Decorator-Pattern)

Ben
生活的各種可能性
12 min readJan 24, 2024
photo by Spacejoy

看看這張照片上的裝飾品們,是不是挺美麗又有質感的啊,這篇要來介紹裝飾器模式(Decorator Pattern)。

裝飾器模式(Decorator Pattern)

首先我們先來看看書中對他的定義:裝飾器模式可以動態的為物件附加額外的職責。使用裝飾器來擴展功能比使用繼承更有彈性。

情境:我們今天有一間飲料店,我們有許多飲料品項,同時飲料也可以添加其他的調味料,但飲料品項和調味料都會在不久的將來一直增加或是調整內容,在這個情境下,你會怎麼設計呢?

首先我們先來看看一開始的想法

類別示意圖-好的開始

我們有一個抽象的Beverage類別,所有的飲料都必須繼承它,裡面也有一些屬性和方法。

接下來我們的飲料品項越來越多,因為每項飲料跟各種調味料混合後又是一個新的品項。

類別示意圖-這樣不行

於是我們又想了一個方法

類別示意圖-想到方法了

上圖看起來我們似乎有把問題解決了,但想到將來設計有可能改變,這樣的方式會有一些潛在的問題,還記得上一章提過的軟體設計唯一不變的事嗎?

在軟體設計中唯一不變的就是,改變。

讓我們來看看新的設計原則

設計原則(開放 / 封閉原則):類別應該歡迎擴展,但拒絕修改。

我們的目標是讓類別容易擴展,藉以納入新行為,但是不能修改既有的程式碼。實現這個目標有什麼好處呢?這種設計不但有因應改變的韌性,也有足夠的彈性,可以接納新功能,來滿足不斷改變的需求。

這條原則看起來有點矛盾,但有一些技術可以在不修改程式碼的情況下擴展它。

而且你必須謹慎的選擇需要擴展的部分,到處採用開放 / 封閉原則不但浪費,也沒必要,甚至可能寫出複雜的、難以理解的程式。

接著我們來看一些示意圖,看看裝飾器是怎麼運作的

運作示意圖1

首先我們會看到它的結構是層層包覆的,一開始我們會有個飲料品項DarkRoast它是繼承Beverage的飲料類型,而外層的Mocha和Whip也同樣繼承了Beverage,但它們是屬於裝飾器,最重要的是它們都是相映的。

運作示意圖2

上方這張圖則是說明了他實際計算價格的運作方式,由於每個對象都繼承了Beverage,它們都擁有了同樣的cost方法,我們將會運用相映的這個特性來完成價格的計算,接下來來看看實際的範例,會再詳細說明。

enum Size {
TALL = "TALL",
GRANDE = "GRANDE",
VENTI = "VENTI"
}

enum MochaSize {
TALL = 0.15,
GRANDE = 0.2,
VENTI = 0.25
}

enum SoySize {
TALL = 0.1,
GRANDE = 0.15,
VENTI = 0.2
}

enum WhipSize {
TALL = 0.05,
GRANDE = 0.1,
VENTI = 0.15
}

// 飲料的抽象類別
abstract class Beverage {
description: string = "Unknown Beverage";
size: Size = Size.GRANDE;

public getDescription(): string {
return this.description;
}

public setSize(size: Size): void {
this.size = size;
}

public getSize(): Size {
return this.size;
}

public abstract cost(): number;
}

// 調味料的抽象類別會繼承飲料的抽象類別
abstract class CondimentDecorator<T extends Beverage> extends Beverage {
beverage: T;

constructor(beverage: T) {
super();
this.beverage = beverage;
}

// 引用飲料類別的預設size
public getSize(): Size {
return this.beverage.getSize();
}

// 必須實作敘述功能
public abstract getDescription(): string;
}

// 實作飲料類型Espresso
class Espresso extends Beverage {
constructor() {
super();
this.description = `Espresso: ${this.cost()}`;
}

public cost(): number {
return 1.99;
}
}

// 實作飲料類型DarkRoast
class DarkRoast extends Beverage {
constructor() {
super();
this.description = `DarkRoast Coffee: ${this.cost()}`;
}

public cost(): number {
return 0.99;
}
}

// 實作飲料類型HouseBlend
class HouseBlend extends Beverage {
constructor() {
super();
this.description = `House Blend Coffee: ${this.cost()}`;
}

public cost(): number {
return 0.89;
}
}

// 實作調味料Mocha
class Mocha<T extends Beverage> extends CondimentDecorator<Beverage> {
// 建構式接受並初始化Beverage(未調味或已調味的飲料),並且引用之上一個對象的內容
constructor(beverage: T) {
super(beverage);
this.beverage = beverage;
}

public getDescription(): string {
return `${this.beverage.getDescription()} + Mocha: ${MochaSize[this.beverage.getSize()]}`;
}

public cost(): number {
return this.beverage.cost() + MochaSize[this.beverage.getSize()];
}
}

// 實作調味料Soy
class Soy<T extends Beverage> extends CondimentDecorator<Beverage> {
// 建構式接受並初始化Beverage(未調味或已調味的飲料),並且引用之上一個對象的內容
constructor(beverage: T) {
super(beverage);
this.beverage = beverage;
}

public getDescription(): string {
return `${this.beverage.getDescription()} + Soy: ${SoySize[this.beverage.getSize()]}`;
}

public cost(): number {
return this.beverage.cost() + SoySize[this.beverage.getSize()];
}
}

// 實作調味料Whip
class Whip<T extends Beverage> extends CondimentDecorator<Beverage> {
// 建構式接受並初始化Beverage(未調味或已調味的飲料),並且引用之上一個對象的內容
constructor(beverage: T) {
super(beverage);
this.beverage = beverage;
}

public getDescription(): string {
return `${this.beverage.getDescription()} + Whip: ${WhipSize[this.beverage.getSize()]}`;
}

public cost(): number {
return this.beverage.cost() + WhipSize[this.beverage.getSize()];
}
}

// 咖啡店
class StarbuzzCoffee {
espresso: Beverage = new Espresso();
darkRoast: Beverage = new DarkRoast();
houseBlend: Beverage = new HouseBlend();

constructor() {
console.log(`${this.espresso.getDescription()} = $${this.espresso.cost()}`);

// 設定尺寸
this.darkRoast.setSize(Size.VENTI);
// 添加調味料
this.darkRoast = new Mocha(this.darkRoast);
this.darkRoast = new Mocha(this.darkRoast);
this.darkRoast = new Whip(this.darkRoast);
console.log(`${this.darkRoast.getDescription()} = $${this.darkRoast.cost()}`);

// 設定尺寸
this.houseBlend.setSize(Size.TALL);
// 添加調味料
this.houseBlend = new Soy(this.houseBlend);
this.houseBlend = new Mocha(this.houseBlend);
this.houseBlend = new Whip(this.houseBlend);
console.log(`${this.houseBlend.getDescription()} = $${this.houseBlend.cost()}`);
}
}

const beverages = new StarbuzzCoffee();

例舉(Size、MochaSize、SoySize、WhipSize)

首先我們有一些關於飲料細節的enum來幫助我們更直觀的了解它們。

抽象類別(Beverage、ComdimentDecorator)

Beverage(abstract class):在Beverage抽象類別中,我們有一些方法可以幫助我們取得飲料的資訊以及設定飲料的大小,最重要的部分是有個cost方法是被宣告為abstract的,這代表著我們必須在子類別去實作這個方法。

接著我們實體化這些飲料的品項(Espresso、DarkRoast、HouseBlend),並且實作它們的cost方法,以及覆寫它們的品項的敘述。

ComdimentDecorator(abstract class):ComdimentDecorator也是個抽象類別,它會繼承Beverage,它擁有一個屬性beverage,並且會在建構式中接受一個Beverage型態的參數並初始化。

它擁有了getSize方法,用來取得我們目前飲料大小的價格。重要的它還擁有了一個抽象的方法getDescription必須在子類別中實作。待會會說明為什麼需要這個抽象的方法。

接著我們會實作每個調味品項(Mocha、Soy、Whip)的內容,在getDescription中我們會使用在建構式中初始化的屬性beverage裡的敘述再加上該類別自己的敘述並且回傳,這也是為什麼我們必須重新實作getDescription的原因。

在cost當中我們也用一樣的方法,取得beverage中的價格再加上該類別自己的價格並且回傳。

應用相映的特性,我們就能在每一層的包裝(裝飾之下,替物件加上新的職責)取得目前最新的價格以及飲料的敘述內容。

而在下方StarbuzzCoffee的建構式當中我們就應用裝飾器的方式替兩種不同類型的飲料分別客製調味內容。

重點提示

接下來我們來看看這個章節中重要的重點提示:

  • 繼承是一種擴展的形式,但不一定是實現靈活設計的最佳手段。
  • 在設計中,我們應該讓行為可以在不必修改既有程式的情況下擴展。
  • 我們可以經常使用組合與委託,在執行期加入新行為。
  • 除了繼承之外,裝飾器模式也可以用來擴展行為。
  • 裝飾器模式使用一組裝飾器類別來包裝具體組件。
  • 裝飾器類別的型態與被他們裝飾的組件型態相映(事實上,它們的型態與被它們裝飾的組件型態一樣,也許是透過繼承,也許是透過介面實作)。
  • 裝飾器改變組件的行為的做法是在呼叫組件的方法之前或之後加入新功能(甚至取代那個方法)。
  • 你可以用任意數量的裝飾器來包裝組件。
  • 裝飾器對組件的用戶端來說通常是透明的,除非用戶端依賴組件的具體型態。
  • 裝飾器可能讓設計有許多小物件,濫用它們會讓設計變複雜。
  • 裝飾器藉著包裝物件來加入新行為與職責。

結語

在了解裝飾器模式後,我真的覺得它是一個將開放/封閉原則展現的非常經典的一個設計模式之一。在上方的例子中將歡迎擴展(裝飾)而拒絕修改的這項原則實現的非常優雅且簡潔。在飲料和調味之間也是解耦合的,它們並不在乎對方實際的內容,只在乎對方是不是繼承了一樣的類別。

下一篇要介紹:工廠模式(Factory Pattern)

這篇就到這邊瞜,如果有任何問題歡迎留言給我,喜歡的話也別忘了clap起來唷。

--

--