延續之前反覆器模式的菜單例子,雖然已經簡化了程式碼,但是 Waitress 裡還是要呼叫 printMenu() 多次,看起來滿醜的,要是未來有新菜單加入,Waitress 程式碼勢必要修改,這算不算是「違反開放關閉守則」?是否有什麼方式可以將菜單合併,或是只傳給 Waitress 一個反覆器,而此反覆器可以在所有菜單間遊走呢?
最簡單的改法,就是把菜單全包進一個 ArrayList,然後取得 ArrayList 的反覆器,這樣Waitress 有再多的菜單也不怕了:
最簡單的改法,就是把菜單全包進一個 ArrayList,然後取得 ArrayList 的反覆器,這樣Waitress 有再多的菜單也不怕了:
public class Waitress { private ArrayList<Menu> mMenus; public Waitress(ArrayList<Menu> menus) { this.mMenus = menus; } public void printMenu() { Iterator menuIterator = menus.iterator(); while(menuIterator.hasNext()) { Menu menu = (Menu) menuIterator.next(); printMenu(menu.createIterator()); } } public void printMenu(Iterator iterator) { while(iterator.hasNext()) { MenuItem menuItem = (MenuItem)iterator.next(); System.out.print(menuItem.getName() + ", "); System.out.println(menuItem.getPrice()); } } }看起來好像很不錯,但其實有一個大問題,假如午餐菜單裡希望有一個「甜點」的副菜單時要怎麼辦呢?依現有的設計,我們無法支援菜單中的菜單,因此我們勢必要重新設計我們的程式了。在新設計中,我們需要達到以下功能:
- 需要某種樹狀結構,可以容納菜單、副菜單、及菜單項目。
- 需要能在每個菜單的各個項目遊走,像反覆器一樣方便。
- 能夠彈性在菜單項目間遊走,如只要在甜點菜單內遊走。
合成模式允許你將物件合成樹狀結構,呈現「部份 / 整體」的階層關係。合成能讓客戶程式碼以一致的方式處理個別物件,以及合成的物件。
Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
我們可以用目前的菜單問題來思考此設計模式。此模式能夠建立一個樹狀結構,處理巢狀菜單及菜單項目。菜單及菜單項目放在相同的結構中,就建立了「部份 / 整體」的階層關係,也就是可以把整個菜單視為一個大整體。
一旦有了這個整體的大菜單,就可以採用「一致的方式處理個別的物件及合成的物件」,這個意思是我們有了這個樹狀結構的菜單 (菜單可以包含菜單或項目),任一個菜單都是一種「合成」,因為菜單可包含菜單及菜單項目。而個別物件就是菜單項目,並未持有其他物件。而因為可以用一致的方式處理個別和合成的物件,在大多數情形下,可以忽略合成及個別物件之間的差異。
從類別圖中可看到,客戶使用 Component 介面處理合成中的物件。 Component 是定義樹狀結構中的物件所要具備的一切,不管合成或各別物件的節點都有的行為。Leaf 就是個別物件,也繼承了 Component 裡的行為,有些行為對 Leaf 可能沒有意義。Composite 主要是要定義具有子節點,且和 Leaf 一樣,Component 的一些行為可能對 Composite 沒有意義。介紹完合成模式後,就來開始改寫程式吧:
// 所有元件都要實作的介面。 // 因為有些方法對個別元件有意義, // 有些對合成元件有意義, // 我們就提供預設的實作, // 次類別不想使用的方法就可以使用預設行為 public abstract class MenuComponent { void add(MenuComponent menuComponent) { throw new UnsupportedOperationException(); } void remove(MenuComponent menuComponent) { throw new UnsupportedOperationException(); } MenuComponent getChild(int i ) { throw new UnsupportedOperationException(); } String getName() { throw new UnsupportedOperationException(); } String getDescription() { throw new UnsupportedOperationException(); } double getPrice() { throw new UnsupportedOperationException(); } boolean isVegetarian() { throw new UnsupportedOperationException(); } void print() { throw new UnsupportedOperationException(); } }
// 一定要繼承 MenuComponent, 因為這是共通的介面 public class MenuItem extends MenuComponent { private String mName; private String mDescription; private boolean mIsVegetarian; private double mPrice; public MenuItem(String name, String description, boolean vegetarian, double price) { this.mName = name; this.mDescription = description; this.mIsVegetarian = vegetarian; this.mPrice = price; } // 省略一些回傳數值的方法… @Override public void print() { System.out.print(" " + getName()); if(isVegetarian()) { System.out.print("(v)"); } System.out.println(" " + getPrice()); System.out.println(" --" + getDescription()); } }
// Menu 跟 MenuItem 一樣, 都是 MenuComponent public class Menu extends MenuComponent { // Menu 可以有任意數目的子類別, // 使用 MenuComponent 就可以同時記錄菜單跟菜單項目 private ArrayList<MenuComponent> mMenuComponents; private String mName; private String mDescription; // 現在給每個菜單一個名字, // 之前是每個菜單類別就是此菜單的名字 public menu(String name, String description) { mMenuComponents = new ArrayList<MenuComponentt>(); this.mName = name; this.mDescription = description; } @Override public void add(MenuComponent menuComponent) { mMenuComponents.add(menuComponent); } @Override public void remove(MenuComponent menuComponent) { mMenuComponents.remove(menuComponent); } @Override public void getChild(int i) { return (MenuComponent) mMenuComponents.get(i); } // 省略一些方法... @Override public void print() { System.out.print("\n" + getName()); System.out.println(", " + getDescription()); System.out.println("-----------------"); // 因為菜單是一個合成物件, // 且菜單跟菜單項目都有實做 print(), // 所以這樣做可以完整列出菜單及其子菜單的所有項目。 // 在反覆期間,如果遇到另一個菜單物件, // 呼叫其 print() 會造成另一個反覆 Iterator iterator = mMenuComponents.iterator(); while(iterator.hasNext()) { MenuComponent menuComponent = (MenuComponent)iterator.next(); menuComponent.print(); } } }
public class Waitress { private MenuComponent mAllMenus; // 現在 Waitress 程式碼變得很簡單 // 我們只需將最上層的菜單傳給 Waitress 就可以 public Waitress(MenuComponent allMenus) { this.mAllMenus = allMenus; } public void printMenu() { // 現在只要呼叫一次 print() // 就可以印出所有菜單層級的所有項目 mAllMenus.print(); } }看到這邊,可能有人有疑問,一開始的說法是「一個類別,一個責任」,但現在卻有一個設計模式,可以讓類別管理階層,又要進行菜單的操作?這其實是一種取捨,合成模式以單一責任的設計守則,換取透明性 (transparency)。讓元件介面同時包含子節點 (菜單)及葉節點 (菜單項目) 的操作,這樣客戶就能將子葉節點視為同一個物件,一個元素是子節點還是葉節點,客戶不用知道。
上述的例子,MenuComponent 有兩種操作方式,失去一些安全性,因為客戶有機會做無意義的操作,我們當然也能換一個設計,把責任切開分成不同介面,雖然設計上較為安全,但客戶就需要多一些條件判斷來處理不同的節點。
最後來看一下合成模式加上反覆器的威力吧。其實前面的例子已經在 print() 裡面使用過反覆器,除此之外,也能使用反覆器走訪整個合成內部。比方說,可以走訪整個菜單項目,挑出素食項目。首先先在共同介面 MenuComponent 多加一個方法 createIterator(),接下來就都看程式碼吧。
最後來看一下合成模式加上反覆器的威力吧。其實前面的例子已經在 print() 裡面使用過反覆器,除此之外,也能使用反覆器走訪整個合成內部。比方說,可以走訪整個菜單項目,挑出素食項目。首先先在共同介面 MenuComponent 多加一個方法 createIterator(),接下來就都看程式碼吧。
public class Menu extends MenuComponent { // 其他部份程式碼不用改 @Override public Iterator createIterator() { return new CompositeIterator(mMenuComponents.iterator()); } } public class CompositeIterator implements Iterator { private Stack<Iterator> mStack; // 將我們欲走訪的最上層合成節點的反覆器, // 當成參數傳進來 public CompositeIterator(Iterator iterator) { mStack = new Stack<Iterator>(); mStack.push(iterator); } @Override public boolean hasNext() { // 空的就表示沒有任何菜單 if(mStack.empty()) { return false; } else { Iterator iterator = mStack.peek(); if(!iterator.hasNext()) { mStack.pop(); return hasNext(); } else { return true; } } } @Override public Object next() { // 先確認是否有下一個能取 if(hasNext()) { Iterator iterator = mStack.peek(); MenuComponent component = (MenuComponent)iterator.next(); // 如果目前元素是一個菜單, // 我們就是有了另一個合成節點, if(component instanceof Menu) { mStack.push(((Menu) component).createIterator()); } return component; } return null; } }
public class MenuItem extends MenuComponent { // 其他部份程式碼不用改 @Override public Iterator createIterator() { // 因為菜單項目沒什麼可以遊走, // 要是回傳 nul, 客戶就要多條件式判斷 // 因此設計一個什麼事都沒做的 Iterator return new NullIterator(); } } public class NullIterator implements Iterator { @Override public boolean hasNext() { return false; } @Override public Object next() { return null; } }
public class Waitress { private MenuComponent mAllMenus; // 現在 Waitress 程式碼變得很簡單 // 我們只需將最上層的菜單傳給 Waitress 就可以 public Waitress(MenuComponent allMenus) { this.mAllMenus = allMenus; } public void printMenu() { // 現在只要呼叫一次 print() // 就可以印出所有菜單層級的所有項目 mAllMenus.print(); } // 現在可以多一個方法來印出所有是素食的菜單 public void printVegetarianMenu() { Iterator iterator = mAllMenus.createIterator(); System.out.println("\nVEGETARIAN MENU\n------"); while(iterator.hasNext()) { MenuComponent menuComponent = (MenuComponent)iterator.next(); try { if(menuComponent.isVegetarian()) { menuComponent.print(); } } // 例外發生,就補捉這個例外,然後繼續反覆遊走 catch (UnsupportedOperationException e) {} } } }上面例子要特別說明的是, try/catch 一般是用來做錯誤處理的,而不是程式邏輯之用。但假如不這麼做,我們就只能檢查元件型態,確定是菜單項目才呼叫 print(),而這樣就失去透明性。雖然也可以改寫 isVegetarian(),讓它永遠回傳 flase,但這樣意義上有些扭曲,會變成了:菜單不是素食。
用上述例子的方式,是為了清楚表達 isVegetarian() 是菜單沒支援的方法,這意義不等同於菜單不是素食,且也允許未來有人為菜單實作一個合理的 isVegetarian()
參考資料:
深入淺出設計模式(Head First Design Patterns)
深入淺出設計模式(Head First Design Patterns)
留言
張貼留言