深入淺出設計模式(Design Pattern)-組合模式(11-Composite Pattern)

Ben
15 min readApr 2, 2024

--

photo by Mourizal Zativa

在上一篇中介紹了迭代器模式,這篇的內容將要延續上一篇的情境之下延伸出的需求來介紹組合模式Composite Pattern。

組合模式(Composite Pattern)

定義:可讓你將物件組合成樹狀結構,用它來代表部分/整體階層結構。組合可以讓用戶端一致的方式來處理個別物件與物件組合。

我們在上一個範例(迭代器模式Iterator Pattern)的Waitress中,有一些重複的地方,而且如果我們需要新增新的菜單的時候我們就必須打開Waitress去做修改,這樣似乎違反了開放/封閉原則,我們似乎得想個辦法來處理。

  public printMenu(): void {
// 在這邊當我們需要新增新的菜單的時候就必須修改它
const pancakeIterator: U = this.pancakeHouseMenu.createIterator();
const dinnerIterator: U = this.dinnerMenu.createIterator();

console.log("*BREAKFAST MENUS*");
this.printMenuDetail(pancakeIterator);
console.log("-----------------------");

console.log("*DINNER MENUS*");
this.printMenuDetail(dinnerIterator);
console.log("-----------------------");
}

我們想到了一個方法,將原本分開的菜單做成一個陣列,並且去迭代他

class Waitress<T extends Menu<U>, U extends MenuIterator> {
menus: T[];

// 建構式改為接受一個T類型的陣列
constructor(menus: T[]) {
this.menus = menus;
}

// 遍歷menu並交由printMenuDetail列出細節
public printMenu(): void {
this.menus.forEach((menu: T) => this.printMenuDetail(menu));
}

private printMenuDetail(iterator: U): void {
while (iterator.hasNext()) {
const menuItem = iterator.next();
console.log(`${iterator.getOrder()}: ${menuItem.getName()}, ${menuItem.getPrice()} -- ${menuItem.getDescription()}`);
}
}
}

這個方法看似很安全且好像解決我們問題的時候,我們碰到了新的需求。我們不僅需要支援多個菜單之外還要支援菜單內的菜單,例如我們想要在既有的菜單內新增甜點菜單。

菜單結構示意圖

看起來是個棘手的問題,首先我們遇到了相當程度的複雜性了,這也意味著我們現有的設計方式已經沒辦法處理後續的需求了。

那我們需要什麼呢?

  1. 為了容納菜單、副菜單、菜單,我們需要某種樹狀結構。
  2. 我們必須能夠方便的去遍歷每一個菜單的項目,至少要與使用迭代器一樣方便。
  3. 我們可能再之後會需要更靈活的方式來遍歷項目。例如我們可能只需要迭代晚餐的甜點菜單或是只需要迭代整個晚餐菜單(包括甜點菜單)。
菜單樹狀結構示意圖

並且我們還可以靈活的選擇要迭代的對象

迭代路線示意圖1

或者是

迭代路線示意圖2

接下來我們用菜單來討論這個定義:這個模式可以讓我們建構一個樹狀結構,並且在同一個結構裡,處理嵌套的菜單與菜單的集合。將菜單與項目都放在同一個結構裡可以做出一個部分或整體的階層結構。

意思也就是這個菜單是由每個部分組合起來的,但是這個樹狀結構可以視為一個整體,例如我們的範例:一個巨大的菜單。

而擁有這個菜單之後,我們就能透過這個模式的特性來用一致的方式來處理個別物件與組合了。

組合模式可讓你為物件建構樹狀結構,在結構裡有物件的組合,也有作為節點的個別物件。

使用組合結構時,你可以對組合和個別的物件套用同一種操作,換句話說,在多數情況下,你可以忽略物件組合和個別物件之間的差異

接下來我們來看看是如何實作的

首先我們會有個抽象的類別MenuComponent,接下來的Menu類別和MenuItem類別都會繼承它,且選擇性的覆寫某些方法。

// Menu和MenuItem的class都會繼承該類別,並且選擇性的覆寫某些方法
abstract class MenuComponent {
public add(menuConponent: MenuComponent): void {}
public remove(menuComponent: MenuComponent): void {}
public getChild(idx: number): void {}
public getName(): void {}
public getDescription(): void {}
public getPrice(): void {}
public isVegetarian(): void {}
public createIterator(): void {}
public print(): void {}
}

MenuItem類別繼承MenuComponent之外還擁有一些自己的屬性並且覆寫了某些它會用到的內容

// MenuItem的類別
class CompositeMenuItem extends MenuComponent {
name: string;
description: string;
vegetarian: boolean;
price: number;

constructor(name: string, des: string, vegetarian: boolean, price: number) {
super();
this.name = name;
this.description = des;
this.vegetarian = vegetarian;
this.price = price;
}

public getName(): string {
return this.name;
}

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

public getPrice(): number {
return this.price;
}

public isVegetarian(): boolean {
return this.vegetarian;
}

public print() {
console.log(`- ${this.getName()}, ${this.getPrice()} -- ${this.getDescription()}`);
}

Menu也一樣繼承MenuComponent並且也擁有了一些自己的屬性也覆寫了某些它需要用到的內容

// Menu類別
class CompositeMenu extends MenuComponent {
menuComponents: MenuComponent[] = [];
name: string;
description: string;

constructor(name: string, des: string) {
super();
this.name = name;
this.description = des;
}

public add(menuComponent: MenuComponent) {
this.menuComponents.push(menuComponent);
}

public remove(menuComponent: MenuComponent) {
console.log("remove menu component");
}

public getChild(idx: number) {
console.log("get child");
}

public getName(): string {
return this.name;
}

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

public print(): void {
console.log("\n")
console.log(`${this.getName()}: ${this.getDescription()}`)
console.log("-------------------------------------------")
this.menuComponents.forEach((item: MenuComponent) => item.print())
}
}

再來我們的服務生要登場了

// Waitress建構式接受一個allMenus的參數,也只負責列出菜單這項工作
class CompositeWaitress {
allMenus: MenuComponent;

constructor(allMenus: MenuComponent) {
this.allMenus = allMenus;
}

public printMenu(): void {
this.allMenus.print();
}
}

接著我們來測試看看

class MenuTestDrive {
allMenus: CompositeMenu;
pancakeHouseMenu: CompositeMenu;
dinnerMenu: CompositeMenu;
dessertMenu: CompositeMenu;
waitress: CompositeWaitress;

constructor() {
this.allMenus = new CompositeMenu("ALL MENUS", "All menus combined");
this.pancakeHouseMenu = new CompositeMenu("PANCAKE HOUSE MENU", "Breakfast");
this.dinnerMenu = new CompositeMenu("DINNER MENU", "Lunch");
this.dessertMenu = new CompositeMenu("DESSERT MENU", "Dessert of course");

// 加入菜單內容: pancake menu
this.pancakeHouseMenu.add(new CompositeMenuItem("K&B's Pancake Breakfast", "Pancakes with scrambled eggs and toast", true, 2.99))
this.pancakeHouseMenu.add(new CompositeMenuItem("Regular Pancake Breakfast", "Pancakes with fried eggs, sausage", false, 2.99))
this.pancakeHouseMenu.add(new CompositeMenuItem("Blueberry Pancakes", "Pancakes made with fresh blueberries", true, 3.49))
this.pancakeHouseMenu.add(new CompositeMenuItem("Waffles", "Waffles with your choice of blueberries or strawberries", true, 3.49))

// 加入菜單內容: dinner menu
this.dinnerMenu.add(new CompositeMenuItem("Vegetarian BLT", "Bacon with lettuce & tomato on whole wheat", true, 2.99))
this.dinnerMenu.add(new CompositeMenuItem("BLT", "Bacon with lettuce & tomato on whole wheat", false, 2.99))
this.dinnerMenu.add(new CompositeMenuItem("Soup of the day", "Soup of the day, with a side of potato salad", false, 3.29))
this.dinnerMenu.add(new CompositeMenuItem("Hotdog", "A hot dog, with sauerkraut, relish, onions, topped with cheese", false, 3.05))

// 加入菜單內容: dessert menu
this.dessertMenu.add(new CompositeMenuItem("Apple Pie", "Apple pie with a flakey crust, topped with vanilla ice cream", true, 1.59))
this.dessertMenu.add(new CompositeMenuItem("Cheesecake", "Creamy New York cheesecake, with a chocolate graham crust", false, 1.99))
this.dessertMenu.add(new CompositeMenuItem("Sorbet", "A scoop of raspberry and a scoop of lime", true, 1.89))

// 將所有菜單加到根節點: allMenus
this.allMenus.add(this.pancakeHouseMenu);
this.allMenus.add(this.dinnerMenu);

// 在dinnerMenu中加入dessertMenu
this.dinnerMenu.add(this.dessertMenu);

this.waitress = new CompositeWaitress(this.allMenus);
this.waitress.printMenu();
}
}

const menuTestDrive = new MenuTestDrive()

首先我們在MenuTestDrive的建構式中分別創建了所有的菜單內容並且把他們一層一層的組合起來,最後將組合完的菜單交給waitress去使用。

而Menu和MenuItem藉由在上方繼承抽象的MenuComponent,巧妙了應用結構之間的關係(遞迴的方式)讓每個元件都能print出他自己。

接著來看看他的類別關係示意圖

類別關係示意圖

觀點的不同與設計上的取捨

在這個設計模式中會發現它違反了單一職責原則(一個類別,兩個職責),組合模式不僅管理一個階層,也執行與Menu有關的操作。

而這個看法在某方面是對的。在組合模式當中放棄了單一職責原則來換取透明度,而透明度是指將子元件管理與葉節點操作放入Component介面(MenuComponent),讓用戶端能以一致的方式來對待組合與葉節點,無論是組合還是葉節點對用戶端都是透明的(可以無視的)。

而因為在Component會有兩種操作類型(Menu、MenuItem),所以我們會失去一些安全性,因為用戶端可能會對元素做出一些不恰當或是無意義的事情,這是設計上的抉擇。

我們也可以採取另一種設計,將不同的責任分到不同的介面裡面,這樣就可以做出安全的設計,這些不洽當的呼叫都能在編譯期或執行期被抓到,但是也會因此失去透明度。

這是個非常典型的權衡取捨,有時候我們可能會故意做出一些看起來違反原則的事情,但是有時這只是從不同的觀點來看事情。

而這次的所有範例內容就是將上方的MenuComponent、CompositeMenuItem、CompositeMenu、CompositeWaitress、MenuTestDrive這些內容組合起來。

重點提示

  • 組合模式可讓用戶端以一致的方式對待組合與個別物件。
  • 元件是組合結構裡面的任何物件。元件可能是其他組合,或葉節點。
  • 在實作組合時,有許多設計面的權衡取捨。你必須視你的需求,在透明度與安全性之間取的平衡。

結語

在組合模式當中了解到沒有完美的模式,只有最適合當下情境的解決辦法。我們必須了解到做出一些權衡和取捨在某些情境中是必要的,端看我們的目標是什麼,而哪些是相較之下對我們幫助較大的,而這些設計的思考方式和原則是提供了我們各種的可能性以及侷限性。

下一篇要介紹:狀態模式(State Pattern)

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

--

--