跳到主要內容

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

解譯器模式 (Interpreter Pattern)

        解譯器模式簡單來說就是 把一句有特殊規則的語句,透過解釋器將它真正的意思表現出來 。相信有學過 Context-free grammar (CFG),Backus–Naur form (BNF),或是 Compiler 相關程的人會比較了解這個模式 (不是我,我早就忘光了…)。不知道上面術語的人,還是可以透過接下來的介紹來稍微了解這個模式。先來看一下這個模式的正式定義及類別圖: 定義一個語言與其文法,使用一個解譯器來表示這個語言的敘述 Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language Context 通常是指待解譯的語句 AbstractExpression 是所有規則都要實作的介面 TerminalExpression 是指無法再展開的規則,算是最小單位的規則 NonterminalExpression 是指可以再展開的規則,可以展開成 NonterninalExpression 和 TerminalExpression 的組合         從類別圖可以看到,語法可能可以一直展開,這時就可以用語法樹來表示,而語法樹以程式來表達的話,就可以使用 合成模式 。而通常比較正式的語法會用 BNF 來表示,如下: expression ::= plus | minus | variable | number plus ::= expression expression '+' minus ::= expression expression '-' variable ::= 'a' | 'b' | 'c' | ... | 'z' digit = '0' | '1' | ... | '9' number ::= digit | digit number 而 每個語法都會定義一個類別 ,如 pl...

代理人模式 (Proxy Pattern)

        代理人模式如同字面上的意思,就是做事情時(如取得某些資料),是透過代理人,而不是直接跟提供資料的物件構通。先來看看此模式的正式定義及類別圖: 代理人模式讓某個物件具有一個替身,藉以控制外界對此物件的影響。 Provide a surrogate or placeholder for another object to control access to it.         從類別圖可以看到, Subject 是共用的介面,可以讓客戶 將 Proxy 物件視為 RealSubject 物件 來處理。RealSubject 是真正做事的物件,被 Proxy 代理, Proxy 可以控制 RealSubject 的存取 。Proxy 持有 RealSubject 的參考,客戶和 RealSubject 的互動都要透過 Proxy,在某些情形下這樣的限制是必要的,如 RealSubject 是遠端物件, RealSubject 建立成本高等等。         此模式有許多種變形,都是依據上面的原則而發展出來的。以下介紹  深入淺出設計模式(Head First Design Patterns) 裡面提到的一些應用方式:         1. 遠端代理人(Remote) :算是最常使用到的應用方式。在網路中或是跨 Process 的各種程式,不可能直接存取(assess) 不同程式間的物件,因此就需要遠端代理人。 Java RMI  和 Android AIDL  都算是遠端代理人的實踐。         2. 虛擬代理人(Virtual) :代理的對象是建立很花費資源的物件。當物件建立前和建立中,由虛擬代理人扮演代理的角色。物件建立完成後,代理人就會將讓求直接轉給物件。以下為簡單範例程式: // Proxy 實作跟 RealSubject 共同的介面 Icon public class ImageProxy implements Icon { // 這是我們的 RealSubje...