跳到主要內容

合成模式 (Composite Pattern)

        延續之前反覆器模式的菜單例子,雖然已經簡化了程式碼,但是 Waitress 裡還是要呼叫 printMenu() 多次,看起來滿醜的,要是未來有新菜單加入,Waitress 程式碼勢必要修改,這算不算是「違反開放關閉守則」?是否有什麼方式可以將菜單合併,或是只傳給 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());
        }
    }
}
        看起來好像很不錯,但其實有一個大問題,假如午餐菜單裡希望有一個「甜點」的副菜單時要怎麼辦呢?依現有的設計,我們無法支援菜單中的菜單,因此我們勢必要重新設計我們的程式了。在新設計中,我們需要達到以下功能:
  1. 需要某種樹狀結構,可以容納菜單、副菜單、及菜單項目。
  2. 需要能在每個菜單的各個項目遊走,像反覆器一樣方便
  3. 能夠彈性在菜單項目間遊走,如只要在甜點菜單內遊走。
        要達成上述的要求,就要使用新的設計模式,合成模式。先介紹此模式的正式定義:

合成模式允許你將物件合成樹狀結構,呈現「部份 / 整體」的階層關係。合成能讓客戶程式碼以一致的方式處理個別物件,以及合成的物件。
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(),接下來就都看程式碼吧。
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)

留言

這個網誌中的熱門文章

訪問者模式 (Visitor Pattern)

        假設你設計一個系統,其中會有一些相似類別,類別中都有某些方法內容相似,但還是需要判斷目前要做事的是哪個類別才能呼叫對應的適當類別。通常遇到這種情情,在 Java 中最直接的做法就是使用 instanceof 關鍵字來判斷,如以下的簡單範例: public interface CarComponent { public void printMessage(); } public class Wheel implements CarComponent { @Override public void printMessage() { System.out.println("This is a wheel"); } // 這是 Wheel 跟 Engine 不同的方法 public void doWheel() { System.out.println("Checking wheel..."); } } public class Engine implements CarComponent { @Override public void printMessage() { System.out.println("This is a engine"); } // 這是 Wheel 跟 Engine 不同的方法 public void doEngine() { System.out.println("Testing this engine..."); } } public class Car { private List mComponents; public Car() { mComponents = new ArrayList<carcomponent>(); } // 有些時候我們還是需要針對不同類別去做不同的事情 public void setComponent(CarCompon...

裝飾者模式 (Decorator Pattern)

        假如你有一間飲料店, 目前只有賣幾種咖啡。因為生意很好, 因此想更換菜單…         以下是目前菜單的類別圖:         簡單說明此類別圖, cost() 是抽象的, 子類別要實作自己的 cost() 來告知飲料的價格。         買咖啡時, 也可要求要加料, 例如牛奶(Milk)、摩卡(Mocha,就是巧克力口味)。這樣的新類別要如何設計呢 ? 看起來是不能直接新增所需的子類別, 例如 EspressoWithMilk, EspressoWithMilkAndMocha, DarkRoastWithMilk, DarkRoastWithMilkAndMocha… 這樣加下去, 日後飲料跟配料越來越多時, 類別也就越多, 這實在不是個好設計。         換個方式設計呢, 在 Beverage 裡面加入所有的配料如何 ? 這樣好像也不太好, 未來要是配料有更動, Beverage 程式碼就要重寫, 而未來要是有新口味的飲料時, 有些配料就不太合理 ( 薑茶加摩卡 ? ), 更麻煩的是, 無法應付機車的客人 (例如要加 3 份牛奶)。這時候裝飾者模式就能上場啦。在介紹裝飾者模式前, 先說明其設計守則: 類別應該開放, 以便擴充 ; 應該關閉, 禁止修改。         我們的目標是允許類別容易擴充, 在不修改現有程式碼的情形就能搭配新的行為。這樣的設計具有彈性, 可以接受新功能以達到改變需求的目的。這看起來好像有點矛盾, 但是的確有一些技術可以在不直接修改程式碼的情形下進行擴充, 如裝飾者模式。         這時候應該有人會問: 那是不是以後我的專案架構設計都遵循這個守則就是好設計了 ? 答案是不太可能, 也沒這必要, 就算做得到, 也可能是浪費, 容易導致程式碼複雜且難以理解。只需小心選擇哪些部分未來會擴充, 這些部份遵循這個設計守則即可。         接下來...

狀態模式 (State Pattern)

        如果今天你要設計一台如下圖的糖果機,你會怎麼設計呢?         有上過資訊相關課程的人,應該不難從上圖聯想到 狀態圖 ,上圖中每個圓圈都是一個狀態,而每個箭頭就代表狀態的轉換。有了這個概念後,把它轉成程式就不難了: public class CandyMachine { // 以下四個值表示糖果機會用到的狀態 final static int SOLD_OUT = 0; final static int NO_COIN = 1; final static int HAS_COIN = 2; final static int SOLD = 3; // 需要有一個變數來記錄目前的狀態 // 初始設為賣完, 因為一開始機器裡沒糖果 int mState = SOLD_OUT; // 也要有另一個變數記錄目前剩多少顆糖果 int mCount = 0; public CandyMachine(int count) { mCount = count; // 機器內有糖果的話就跳到沒投錢的狀態, // 表示糖果機在等人投錢 if(mCount > 0) { mState = NO_COIN; } } // 當投錢時會執行這個方法 public void insertCoin() { if(mState == HAS_COIN) { System.out.println("You can't insert another coin"); } else if(mState == NO_COIN) { mState == HAS_COIN; System.out.println("You inserted a coin"); } ...