1. 程式人生 > >設計模式之迭代器模式

設計模式之迭代器模式

迭代器模式提供一種方法順序訪問一個聚合物件中的各個元素,而又不暴露其內部的表示。

爆炸性新聞:物件村餐廳和物件村煎餅屋合併了!

真是個好訊息!現在我們可以在同一個地方,享用煎餅屋美味的煎餅早餐,和好吃的餐廳午餐了。但是,好像有一點小麻煩:

新的餐廳想用煎餅屋選單當作早餐的選單,使用餐廳的選單當做午餐的選單,大家都同意了這樣實現選單項。但是大家無法同意選單的實現。煎餅屋使用ArrayList記錄他的選單項,而餐廳使用的是陣列。他們兩個都不願意改變他們的實現,畢竟有太多程式碼依賴於它們了。

檢查選單項

讓我們先檢查每份選單上的專案和實現。

public class MenuItem {
    // 名稱
    String name;
    // 描述
    String description;
    // 是否為素食
    boolean vegetarian;
    // 價格
    double price;
    public MenuItem(String name,
                    String description,
                    boolean vegetarian,
                    double price) {
        this.name = name;
        this.description = description;
        this.vegetarian = vegetarian;
        this.price = price;
    }
    public String getName() {
        return name;
    }
    public String getDescription() {
        return description;
    }
    public double getPrice() {
        return price;
    }
    public boolean isVegetarian() {
        return vegetarian;
    }
}

兩個餐廳的選單實現

我們先來看看兩個餐廳的選單實現

// 這是煎餅屋的選單實現
public class PancakeHouseMenu {
    // 煎餅屋使用一個ArrayList儲存他的選單項
    ArrayList menuItems;
    public PancakeHouseMenu() {
        menuItems = new ArrayList();
        // 在選單的構造器中,每一個選單項都會被加入到ArrayList中
        // 每個選單項都有一個名稱、一個描述、是否為素食、還有價格
        addItem("K&B's Pancake Breakfast",
                "Pancakes with scrambled eggs, and toast",
                true,
                2.99);
        addItem("Regular Pancake Breakfast",
                "Pancakes with fried eggs, sausage",
                false,
                2.99);
        addItem("Blueberry Pancakes",
                "Pancakes made with fresh blueberries",
                true,
                3.49);
        addItem("Waffles",
                "Waffles, with your choice of blueberries or strawberries",
                true,
                3.59);
    }
    // 要加入一個選單項,煎餅屋的做法是建立一個新的選單項物件,
    // 傳入每一個變數,然後將它加入ArrayList中
    public void addItem(String name, String description, 
                        boolean vegetarian, double price) {
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        menuItems.add(menuItem);
    }
    // 這個方法返回選單項列表
    public ArrayList getMenuItems() {
        return menuItems;
    }
    // 這裡還有選單的其他方法,這些方法都依賴於這個ArrayList,所以煎餅屋不希望重寫全部的程式碼!
    // ...
}
// 餐廳的選單實現
public class DinnerMenu {
    // 餐廳採用使用的是陣列,所以可以控制選單的長度,
    // 並且在取出選單項時,不需要轉型
    static final int MAX_ITEMS = 6;
    int numberOfItems = 0;
    MenuItem[] menuItems;
    public DinnerMenu() {
        menuItems = new MenuItem[MAX_ITEMS];
        // 和煎餅屋一樣,餐廳使用addItem()輔助方法在構造器中建立選單項的
        addItem("Vegetarian BLT",
                "(Fakin') Bacon with lettuce & tomato on whole wheat",
                true,
                2.99);
        addItem("BLT",
                "Bacon with lettuce & tomato on whole wheat",
                false,
                2.99);
        addItem("Soup of the day",
                "Soup of the day, with a side of potato salad",
                false,
                3.29);
        addItem("Hotdog",
                "A hot dog, with saurkraut, relish, onions, topped with cheese",
                false,
                3.05);
    }
    public void addItem(String name, String description,
                        boolean vegetarian, double price) {
        // 餐廳堅持讓選單保持在一定的長度之內
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        if (numberOfItems >= MAX_ITEMS) {
            System.err.println("Sorry, menu is full! Can't add item to menu");
        } else {
            menuItems[numberOfItems] = menuItem;
            numberOfItems = numberOfItems + 1;
        }
    }
    // getMenuItems()返回一個選單項的陣列
    public MenuItem[] getMenuItems() {
        return menuItems;
    }
    // 正如煎餅屋那樣,這裡還有很多其他的選單程式碼依賴於這個陣列
    // ...
}

兩種不同的選單表現方式,會帶來什麼問題?

想了解為什麼有兩種不同的選單表現方式會讓事情變得複雜化,讓我們試著實現一個同時使用這兩個選單的客戶程式碼。假設你已經被兩個餐廳合租的新公司僱用,你的工作是建立一個Java版本的女招待,她能應對顧客的需要列印定製的選單,甚至告訴你是否某個選單項是素食的,而無需詢問廚師。跟我們來看看這份關於女招待的規格,然後看看如何實現她。

Java版本的女招待規格:


我們先從實現printMenu()方法開始:

1.列印每份選單上的所有項,必須呼叫PancakeHouseMenu和DinnerMenu的getMenuItems()方法,來取得它們各自的選單項。請注意,兩者的返回型別是不一樣的。

// getMenuItems()方法看起來是一樣的,但是呼叫所返回的結果卻是不一樣的型別。
// 早餐項是在一個ArrayList中,午餐項則是在一個數組中
PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList breakfastItems = pancakeHouseMenu.getMenuItems();
DinnerMenu dinnerMenu = new DinnerMenu();
MenuItem[] lunchItems = dinnerMenu.getMenuItems();

2.現在,想要列印PancakeHouseMenu的項,我們用迴圈將早餐ArrayList內的項一一列出來。想要列印DinnerMenu的專案,我們用迴圈將陣列內的項一一列出來。

// 現在,我們必須實現兩個不同的迴圈,個別處理這兩個不同的選單
for (int i = 0; i < breakfastItems.size(); i++) {
    MenuItem menuItem = (MenuItem) breakfastItems.get(i);
    System.out.print(menuItem.getName() + " ");
    System.out.print(menuItem.getPrice() + " ");
    System.out.println(menuItem.getDescription() + " ");
}
for (int i = 0; i < lunchItems.length; i++) {
    MenuItem menuItem = lunchItems[i];
    System.out.print(menuItem.getName() + " ");
    System.out.print(menuItem.getPrice() + " ");
    System.out.println(menuItem.getDescription() + " ");
}

3.實現女招待中的其他方法,做法也都和這裡的方法相類似。我們總是需要處理兩個選單,並且用兩個迴圈遍歷這些項。如果還有第三家餐廳以不同的實現出現,我們就需要有三個迴圈。

下一步呢?

這兩個餐廳讓我們很為難,他們都不想改變自身的實現,因為意味著要重寫許多程式碼。但是如果他們其中一人不肯退讓,我們就很難辦了,我們所寫出來的女招待程式將難以維護、難以擴充套件。

如果我們能夠找到一個方法,讓他們的選單實現一個相同的介面,該有多好!這樣一來,我們就可以最小化女招待程式碼中的具體引用,同時還有希望擺脫遍歷這兩個選單所需的多個迴圈。

聽起來很棒!但要怎麼做呢?

如果你從本書中學到了一件事情,那就是封裝變化的部分。很明顯,在這裡發生變化的是:由不同的集合(collection)型別所造成的遍歷。但是,這能夠被封裝嗎?讓我們來看看這個想法:

1.要遍歷早餐項,我們需要使用ArrayList的size()和get()方法:

for (int i = 0; i < breakfastItems.size(); i++) {
    MenuItem menuItem = (MenuItem) breakfastItems.get(i);
}

2.要遍歷午餐項,我們需要使用陣列的length欄位和中括號:

for (int i = 0; i < lunchItems.length; i++) {
    MenuItem menuItem = lunchItems[i];
}

3.現在我們建立一個物件,把它稱為迭代器(Iterator),利用它來封裝“遍歷集合內的每個物件的過程”。先讓我們在ArrayList上試試:

// 我們從breakfastMenu中取得一個選單項迭代器
Iterator iterator = breakfastMenu.createIterator();
// 當還有其他項時
while (iterator.hasNext()) {
    // 取得下一項
    MenuItem menuItem = (MenuItem) iterator.next();
}

4.將它也在陣列上試試:

// 這裡的情況也是一樣的:客戶只需要呼叫hasNext()和next()即可,
// 而迭代器會暗中使用陣列的下標
Iterator iterator = lunchMenu.createIterator();
while (iterator.hasNext()) {
    MenuItem menuItem = (MenuItem) iterator.next();
}

會見迭代器模式

看起來我們對遍歷的封裝已經奏效了;你大概也已經猜到,這正是一個設計模式,稱為迭代器模式。

關於迭代器模式,你所需要知道的第一件事情,就是它依賴於一個名為迭代器的介面。

public interface Iterator {
    // hasNext()方法返回一個布林值,讓我們知道是否還有更多的元素
    boolean hasNext();
    // next()方法返回下一個元素
    Object next();
}

現在,一旦我們有了這個介面,就可以為各種物件集合實現迭代器:陣列、列表、散列表……

讓我們繼續實現這個迭代器,並將它掛鉤到DinnerMenu中,看它是如何工作的。

用迭代器改寫餐廳選單

現在我們需要實現一個具體的迭代器,為餐廳選單服務:

public class DinnerMenuIterator implements Iterator {
    MenuItem[] items;
    // position記錄當前陣列遍歷的位置
    int position = 0;
    // 構造器需要被傳入一個選單項的陣列當做引數
    public DinnerMenuIterator(MenuItem[] items) {
        this.items = items;
    }
    // next()方法返回陣列內的下一項,並遞增其位置
    public Object next() {
        MenuItem menuItem = items[position];
        position = position + 1;
        return menuItem;
    }
    // hasNext()方法會檢查我們是否已經取得陣列內所有的元素。
    // 如果還有元素待遍歷,則返回true
    public boolean hasNext() {
        if (position >= items.length || items[position] == null) {
            return false;
        } else {
            return true;
        }
    }
}

好了,我們已經有了迭代器。現在就利用它來改寫餐廳選單:我們只需要加入一個方法建立一個DinnerMenuIterator,並將它返回給客戶:

public class DinnerMenu {
    static final int MAX_ITEMS = 6;
    int numberOfItems = 0;
    MenuItem[] menuItems;
    // ...
    // 我們不再需要getMenuItems()方法,事實上,我們根本不想要這個方法,
    // 因為它會暴露我們內部的實現。
    // 這是createIterator()方法,用來從選單項陣列建立一個DinnerMenuIterator,
    // 並將它返回給客戶
    public Iterator createIterator() {
        return new DinnerMenuIterator(menuItems);
    }
    // ...
}

現在將迭代器程式碼整合進女招待中。

public class Waitress {
    PancakeHouseMenu pancakeHouseMenu;
    DinnerMenu dinnerMenu;
    // 在構造器中,女招待照顧兩個選單
    public Waitress(PancakeHouseMenu pancakeHouseMenu, DinnerMenu dinnerMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinnerMenu = dinnerMenu;
    }
    public void printMenu() {
        // 這個printMenu()方法為每一個選單各自建立一個迭代器
        Iterator pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator dinnerIterator = dinnerMenu.createIterator();
        // 然後呼叫過載的printMenu(),將迭代器傳入
        printMenu(pancakeIterator);
        printMenu(dinnerIterator);
    }
    // 這個過載的printMenu()方法,使用迭代器來遍歷選單項並打印出來
    private void printMenu(Iterator iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = (MenuItem) iterator.next();
            System.out.println(menuItem.getName() + " " + 
                    menuItem.getPrice() + " " + menuItem.getDescription());
        }
    }
}

到目前為止,我們做了些什麼?

首先,我們讓物件村的廚師們非常快樂。他們可以保持他們自己的實現又可以擺平差別。只要我們給他們這兩個迭代器(PancakeHouseMenuIterator和DinnerMenuIterator),他們只需要加入一個createIterator()方法,一切就大功告成了。

這個過程中,我們也幫了我們自己。女招待將會更容易維護和擴充套件。讓我們來徹底檢查一下到底我們做了哪些事,以及後果如何:


做一些改良

好了,我們已經知道這兩份選單的介面完全一樣,但沒有為它們設計一個共同的介面。所以,接下來就要這麼做,讓女招待更乾淨一些。

Java有一個內建的Iterator介面,讓我們先來看看:

public interface Iterator<E> {
    /**
     * Returns true if there is at least one more element, false otherwise.
     * @see #next
     */
    public boolean hasNext();
    /**
     * Returns the next object and advances the iterator.
     *
     * @return the next object.
     * @throws NoSuchElementException
     *             if there are no more elements.
     * @see #hasNext
     */
    public E next();
    /**
     * Removes the last object returned by {@code next} from the collection.
     * This method can only be called once between each call to {@code next}.
     *
     * @throws UnsupportedOperationException
     *             if removing is not supported by the collection being
     *             iterated.
     * @throws IllegalStateException
     *             if {@code next} has not been called, or {@code remove} has
     *             already been called after the last call to {@code next}.
     */
    public void remove();
}

這個介面看起來和我們之前定義的一樣,只不過多了一個附加的方法,允許我們從聚合中刪除由next()方法返回的最後一項。

接下來讓我們用java.util.Iterator來清理程式碼。

讓我們先從煎餅屋選單開始,先把它改用java.util.Iterator,這很容易,只需要刪除煎餅屋選單迭代器,然後在煎餅屋選單的程式碼前面加上 import java.util.Iterator。再改變下面這一行程式碼就可以了:

public Iterator createIterator() {
    return menuItems.iterator();
}

這樣PancakeHouseMenu就完成了。

接著,我們處理DinnerMenu,以符合java.util.Iterator的需求。

public class DinnerMenuIterator implements Iterator {
    MenuItem[] items;
    int position = 0;
    public DinnerMenuIterator(MenuItem[] items) {
        this.items = items;
    }
    public Object next() {
        MenuItem menuItem = items[position];
        position = position + 1;
        return menuItem;
    }
    public boolean hasNext() {
        if (position >= items.length || items[position] == null) {
            return false;
        } else {
            return true;
        }
    }
    // 我們需要實現remove()方法。因為使用的是固定長度的陣列,
    // 所以在remove()方法被呼叫時,我們將後面的所有元素往前移動一個位置。
    @Override
    public void remove() {
        if (position <= 0) {
            throw new IllegalStateException("You can't remove
             an item until you've done at least one next()");
        }
        if (items[position - 1] != null) {
            for (int i = position-1; i < (items.length - 1); i++) {
                items[i] = items[i + 1];
            }
            items[items.length - 1] = null;
        }
    }
}

我們只需要給選單一個共同的介面,然後再稍微改一下女招待。這個Menu介面相當簡單:

public interface Menu {
    public Iterator createIterator();
}

現在,我們需要讓煎餅屋選單類和餐廳選單類都實現Menu介面,然後更新女招待的程式碼:

public class Waitress {
    Menu pancakeHouseMenu;
    Menu dinnerMenu;
    // 將具體選單類改成Menu介面
    public Waitress(Menu pancakeHouseMenu, Menu dinnerMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinnerMenu = dinnerMenu;
    }
    // 以下的程式碼沒有修改
    public void printMenu() {
        Iterator pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator dinnerIterator = dinnerMenu.createIterator();
        printMenu(pancakeIterator);
        printMenu(dinnerIterator);
    }
    private void printMenu(Iterator iterator) {
        while (iterator.hasNext()) {
            MenuItem menuItem = (MenuItem) iterator.next();
            System.out.println(menuItem.getName() + " " +
                    menuItem.getPrice() + " " + menuItem.getDescription());
        }
    }
}

這為我們帶來了什麼好處?煎餅屋選單和餐廳選單的類,都實現了Menu介面,女招待可以利用介面(而不是具體類)引用每一個選單物件。這樣,通過“針對介面程式設計,而不針對實現程式設計”,我們就可以減少女招待和具體類之間的依賴。

定義迭代器模式

現在我們來看看這個模式的正式定義:

迭代器模式提供一種方法順序訪問一個聚合物件中的各個元素,而又不暴露其內部的表示。

迭代器模式讓我們能遊走於聚合內的每一個元素,而又不暴露內部的表示。把遊走的任務放在迭代器上,而不是聚合上,這樣簡化了聚合的介面和實現,也讓責任各得其所。

這很有意義:這個模式給你提供了一種方法,可以順序訪問一個聚集物件中的元素,而又不用知道內部是如何表示的。你已經在前面的兩個選單實現中看到了這一點。在設計中使用迭代器的影響是明顯的:如果你有一個統一的方法訪問聚合中的每一個物件,你就可以編寫多型的程式碼和這些聚合搭配使用,如同前面的printMenu()方法一樣,只要有了迭代器這個方法,根本不管選單項究竟是由陣列還是由ArrayList(或者其他能建立迭代器的東西)來儲存的。

另一個對你的設計造成重要影響的,是迭代器模式把這些元素之間遊走的責任交給迭代器,而不是聚合物件。這不僅讓聚合的介面和實現變得更簡潔,也可以讓聚合更專注在它所應該專注的事情上面(也就是管理物件組合),而不必去理會遍歷的事情。

單一責任

如果我們允許我們的聚合實現它們內部的集合,以及相關的操作和遍歷的方法,又會如何?我們已經知道這會增加聚合中的方法個數,但又怎樣呢?為什麼這麼做不好?

想知道為什麼,首先你需要認清楚,當我們允許一個類不但要完成自己的事情(管理某種聚合),還同時要擔負更多的責任(例如遍歷)時,我們就給了這個類兩個變化的原因。兩個?沒錯,就是兩個!如果這個集合改變的話,這個類也必須改變,如果我們遍歷的方式改變的話,這個類也必須跟著改變。所以,再一次地,我們的老朋友“改變”又成了我們設計原則的中心:

設計原則:一個類應該只有一個引起變化的原因

我們知道要避免類內的改變,因為修改程式碼很容易造成許多潛在的錯誤。如果有一個類具有兩個改變的原因,那麼這會使得將來該類的變化機率上升,而當它真的改變時,你的設計中同時有兩個方面將會受到影響。

沒錯,這聽起來很容易,但其實做起來並不簡單:區分設計中的責任,是最困難的事情之一。我們的大腦很習慣看著一大群的行為,然後將它們集中在一起,儘管他們可能屬於兩個或者多個不同的責任。想要成功的唯一方法,就是努力不懈地檢查你的設計,隨著系統的成長,隨時觀察有沒有跡象顯示某個類改變的原因超出一個。