跳到主要內容

合成模式 (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)

留言

這個網誌中的熱門文章

整理設計模式

        依據 GOF 的書,可以將經典的設計模式分為以下三類:生成、行為、結構。 生成模式 :牽涉到 將物件實體化 。這類模式都提供一個方法,將客戶從所需要實體化的物件中鬆綁出來。 獨體模式 (Singleton Pattern) 工廠方法模式 (Factory Method Pattern) 抽象工廠模式 (Abstract Factory Pattern) 建立者模式 (Builder Pattern) 原型模式 (Prototype Pattern) 結構模式 :讓你 合成類別或物件到大型的結構 。 裝飾者模式 (Decorator Pattern) 轉接器模式 (Adapter Pattern) 表象模式 (Facade Pattern) 合成模式 (Composite Pattern) 代理人模式 (Proxy Pattern) 橋接模式 (Bridge Pattern) 享元模式 (Flyweight Pattern) 行為模式 :模述 類別和物件如何互動 ,以及 各自的責任 。 策略模式 (Strategy Pattern) 觀察者模式 (Observer Pattern) 命令模式 (Command Pattern) 樣板方法模式 (Template Method Pattern) 反覆器模式 (Iterator Pattern) 狀態模式 (State Pattern) 責任鏈模式 (Chain of Responsibility Pattern) 解譯器模式 (Interpreter Pattern) 中介者模式 (Mediator Pattern) 備忘錄模式 (Memento Pattern) 訪問者模式 (Visitor Pattern)         有人可能會覺得裝飾者模式明明有替物件增加行為,為什麼不算是行為模式呢?我們可以從上面的結構模式得知, 結構模式用來描述類別或物件如何被合成,以建立新的結構或功能 。裝飾者模式允許你透過「 將某物件包裝進另一個物件的方式 」,將物件合成以提供新功能,因此焦點應該放在「 動態合成物件,以取得某功能 」,而不是物件之間的溝通。         設入淺出設計模式也有提到一些使用設計模式的

訪問者模式 (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 份牛奶)。這時候裝飾者模式就能上場啦。在介紹裝飾者模式前, 先說明其設計守則: 類別應該開放, 以便擴充 ; 應該關閉, 禁止修改。         我們的目標是允許類別容易擴充, 在不修改現有程式碼的情形就能搭配新的行為。這樣的設計具有彈性, 可以接受新功能以達到改變需求的目的。這看起來好像有點矛盾, 但是的確有一些技術可以在不直接修改程式碼的情形下進行擴充, 如裝飾者模式。         這時候應該有人會問: 那是不是以後我的專案架構設計都遵循這個守則就是好設計了 ? 答案是不太可能, 也沒這必要, 就算做得到, 也可能是浪費, 容易導致程式碼複雜且難以理解。只需小心選擇哪些部分未來會擴充, 這些部份遵循這個設計守則即可。         接下來正式介紹裝飾者模式的定義:  裝飾者模式動態地將責任加諸於物件上。若要擴充功能,裝飾者模提供了比繼承更有彈性的選擇 Attach additional responsibilities to an object dynamically. Decorators provide a flexible alt