设计模式——9、迭代器与组合模式
  • 作者:ZJWave
  • 分类: 设计模式
  • 发表:2019-06-14 11:57
  • 围观:6514
  • 评论:0

有许多方法可以把对象堆起来成为一个集合(collection)。你可以把它们放进数组、堆栈、列表或者是散列表(HashMap)中,这是你的自由。每一种都有它自己的优点和适合的使用时机,但总有一个时候,你的客户想要遍历这些对象,而当他这么做时,你打算让客户看到你的实现吗?我们当然希望最好不要!这太不专业了。没关系,不要为你的工作担心,你将在本文中学习如何能让客户遍历你的对象而又无法窥视你的存储对象的方式,也将学习如何创建一些对象超集合(super collection),能够一口气就跳过某些让人望而生畏的数据结构。你还将学到一些关于对象职责的知识。

 

1.封装遍历

爆炸性新闻:对象村餐厅和对象村煎饼屋合并了

真是个好消息!现在我们可以在同一个地方,享用煎饼屋美味的煎饼早餐,和好吃的餐厅午餐了。但是,好像有一个小麻烦……

Lou:他们想用我的煎饼屋菜单当做早餐的菜单,并用餐厅的菜单当做午餐的菜单。我们大家都同意了这样实现菜单项。

Mel:但是我们无法同意菜单的实现。那个小丑使用ArrayList记录他的菜单项,而我用的是数组。我们两个都不愿意改变我们的实现……毕竟我们有太多代码依赖于它们了。

检查菜单项

至少Lou和Mel都同意实现MenuItem。让我们检查每份菜单上的项目和实现。

public class MenuItem {
    String name;
    String description;
    boolean vegetarian;
    double price;

    /**
     * 菜单项包含了名称、叙述、是否为素食的标志,
     * 还有价格。将这些值传入构造器来初始化这个菜单项。
     * @param name
     * @param description
     * @param vegetarian
     * @param 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 boolean isVegetarian() {
        return vegetarian;
    }

    public double getPrice() {
        return price;
    }
}

让我们看看Lou和Mel在吵些什么。他们都在菜单项的存储方式上花了很多的时间和代码,也许有许多其他代码依赖这些菜单项。

/**
 * 这是Lou的煎饼屋菜单实现
 */
public class PancakeHouseMenu {

    ArrayList<MenuItem> menuItems;

    /**
     * Lou使用一个ArrayList存储他的菜单项
     */
    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 freid eggs,sausage",
                false,
                2.99);
        addItem("Blueberry Pancakes",
                "Pancakes made with fresh blueberries",
                true,
                3.49);
        addItem("Waffles",
                "Waffles, with your choice of blue berries or strawberries",
                true,
                3.59);
    }

    /**
     * 要加入一个菜单项,Lou的做法是:
     * 创建一个新的菜单项对象,加入每一个变量,然后将它加入ArrayList中
     */
    public void addItem(String name,String description,boolean vegetarian,double price){
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        menuItems.add(menuItem);
    }

    /**
     * 这个getMenuItems()方法返回菜单项列表
     * @return
     */
    public ArrayList<MenuItem> getMenuItems() {
        return menuItems;
    }

    //这里还有菜单的其他方法,依赖于这个ArrayList,所以他不希望重写全部的代码
}
/**
 * Mel的餐厅菜单是这么实现的。
 */
public class DinerMenu {
    static final int MAX_ITEMS = 6;
    int numberOfItems = 0;
    /**
     * Mel采用不同的方法。它使用的是一个数组,所以可以控制菜单的长度
     * 并且在取出菜单项的时候不需要转型。
     */
    MenuItem[] menuItems;

    public DinerMenu() {
        menuItems = new MenuItem[MAX_ITEMS];
        //就跟Lou一样,Mel是使用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){
        if(numberOfItems >= MAX_ITEMS){
            //Mel特别坚持让他的菜单保持在一定的长度之内(或许是他不希望记太多食谱)
            System.err.println("Sorry,menu is full! Can't add item to menu");
        }else {
            MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
            menuItems[numberOfItems] = menuItem;
            numberOfItems = numberOfItems + 1;
        }
    }

    public MenuItem[] getMenuItems() {
        return menuItems;
    }

    //这里还有菜单的其他方法
    //就和Lou一样,Mel还有许多其他的菜单代码依赖于这个数组。
    //他忙着煮菜,没空重写这么多代码!
}

有两种不同的菜单表现方式,这会带来什么问题?

想了解为什么有两种不同的菜单表现方式会让事情变得复杂化,让我们试着实现一个同时使用这两个菜单的客户代码。假设你已经被他们两人合组的新公司雇佣,你的工作是要创建一个Java版本的女招待(毕竟,这是对象村)。这个Java版本的女招待规格是:能应对顾客的需要打印定制的菜单,甚至告诉你是否某个菜单项是素食的,而无须询问厨师。这可是一大创新!

跟我们来看看这份关于女招待的规格,然后看看如何实现她:

我们先从实现printMenu()方法开始:

打印每份菜单上的所有项,必须调用PancakeHouseMenu和DinerMenu的getMenuItem()方法,来取得它们各自的菜单项。请注意,两者的返回类型是不一样的。

//方法看起来一样
//但是调用所返回的结果却是不一样的类型
PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
ArrayList<MenuItem> breakfastItems = pancakeHouseMenu.getMenuItems();

//早餐项是在一个ArrayList中,午餐项则是在一个数组中。

DinerMenu dinerMenu = new DinerMenu();
MenuItem[] lunchItems = dinerMenu.getMenuItems();

② 现在,想要打印PancakeHouseMenu的项,我们用循环将早餐ArrayList内的项一一列出来。想要打印DinerMenu的项目,我们用循环将数组内的项一一列出来。

//现在,我们必须实现两个不同的循环,个别处理这连个不同的菜单
for (int i = 0; i < breakfastItems.size(); i++) {
    //处理ArrayList的循环
    MenuItem menuItem = breakfastItems.get(i);
    System.out.print(menuItem.getName() + " ");
    System.out.println(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.println(menuItem.getPrice() + " ");
    System.out.println(menuItem.getDescription());
}

③ 实现女招待中的其他方法,做法也都和这一页的方法相类似。我们总是需要处理两个菜单,并且用两个循环遍历这些项。如果还有第三家餐厅以不同的实现出现,我们就需要有三个循环。

下一步呢?

Mel和Lou让我们很为难。他们都不想改变自身的实现,因为意味着要重写许多代码。但是如果他们其中一人不肯退让,我们就很难办了,我们所写出来的女招待程序将难以维护、难以扩展。

如果我们能够找出一个方法,让他们的菜单实现一个相同的接口,该有多好!(除了他们的getMenuItem()方法返回类型不同之外,这两个菜单其实非常类似)。这样一来,我们就可以最小化女招待代码中的具体引用,同时还有希望摆脱遍历这两个菜单所需的多个循环。

封装遍历吗?

很明显,在这里发生变化的是:由不同的集合(collection)类型所造成的遍历。但是,这能够被封装吗?让我们来看看这个想法……

① 要遍历早餐项,我们需要使用ArrayList的size()和get()方法:

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

② 要遍历午餐项,我们需要使用数组的length字段和中括号:

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

③ 现在我们创建一个对象,将它称为迭代器(Iterator),利用它来封装“遍历集合内的每个对象的过程”。先让我们在ArrayList上试试:

Iterator<MenuItem> iterator = breakfastMenu.createIterator();
while (iterator.hasNext()){
    MenuItem menuItem = iterator.next();
}

④ 将它也在数组上试试:

Iterator<MenuItem> iterator = lunchMenu.createIterator();
while (iterator.hasNext()){
    MenuItem menuItem = iterator.next();
}

会见迭代器模式

看起来我们对遍历的封装已经奏效了,你大概也已经菜单,这正是一个设计模式,称为迭代器模式(Iterator Pattern)。

关于迭代器模式,你所需要知道的第一件事情,就是它依赖于一个名为迭代器的接口。这是一个可能的迭代器接口:

现在,一旦我们有了这个接口,就可以为各种对象集合实现迭代器:数组、列表、散列表……如果我们想要为数组实现迭代器,以便使用在DinerMenu中,看起来就像这样:

我们继续实现这个迭代器,并将它挂钩到DinerMenu中,看是如何工作的……

在餐厅菜单中加入一个迭代器,我们需要先定义迭代器接口:

public interface Iterator<T> {

    /**
     * hasNext()方法会返回一个布尔值,
     * 让我们知道是否还有更多的元素
     * @return
     */
    boolean hasNext();

    /**
     * 而next()方法返回下一个元素。
     * @return
     */
    T next();
}

现在我们需要实现一个具体的迭代器,为餐厅菜单服务:

public class DinerMenuIterator implements Iterator<MenuItem> {

    MenuItem[] items;
    /**
     * position记录当前数组遍历的位置
     */
    int position = 0;

    /**
     * 构造器需要被传入一个菜单项的数组当做参数。
     *
     * @param items
     */
    public DinerMenuIterator(MenuItem[] items) {
        this.items = items;
    }

    /**
     * hasNext()方法会检查我们是否已经取得数组内所有的元素。
     * 如果还有元素待遍历,则返回true
     * @return
     */
    @Override
    public boolean hasNext() {
        //因为使用欧冠的是固定长度的数组,所有我们不但要检查是否超出了数组长度
        //也必须检查是否下一项是null,如果是null,就表示没有其他项了。
        if(position >= items.length || items[position] == null){
            return false;
        }else {
            return true;
        }
    }

    /**
     * next()方法返回数组内的下一项,并递增其位置。
     *
     * @return
     */
    @Override
    public MenuItem next() {
        MenuItem menuItem = items[position];
        position = position + 1;
        return menuItem;
    }
}

用迭代器改写餐厅菜单

好了,我们已经有了迭代器。现在就利用它来改写菜单;我们只需加入一个方法床架你一个DinerMenuIterator,并将它返回给客户:

public class DinerMenu {
    static final int MAX_ITEMS = 6;
    int numberOfItems = 0;
    MenuItem[] menuItems;

    //构造器在这里


    //addItem在这里

    /**
     * 我们不再需要getMenuItems()方法,
     * 而且事实上,我们根本不想要这个方法,因为它会暴露我们内部的实现。
     * 
    public MenuItem[] getMenuItems() {
        return menuItems;
    }
    */

    /**
     * 这是createIterator()方法,
     * 用来从菜单项数组创建一个DinerMenuIterator,并将它返回给客户。
     * @return
     */
    public Iterator<MenuItem> createIterator(){
        return new DinerMenuIterator(menuItems);
    }

    //这里还有菜单的其他方法
}

修正女招待的代码

我们需要将迭代器代码整合进女招待中。我们应该摆脱原本冗余的部分。整合的做法相当直接:首先创建一个printMenu()方法,传入一个迭代器当做此方法的参数,然后对每一个菜单都使用createIterator()方法来检索迭代器,并将迭代器传入新方法。

public class Waitress {
    PancakeHouseMenu pancakeHouseMenu;
    DinerMenu dinerMenu;

    public Waitress(PancakeHouseMenu pancakeHouseMenu, DinerMenu dinerMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinerMenu = dinerMenu;
    }

    public void printMenu(){
        //这个printMenu()方法为每一个菜单各自创建一个迭代器
        //然后对每个迭代器
        Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();
        System.out.println("MENU\n----\nBREAKFAST");
        printMenu(pancakeIterator);
        System.out.println("\nLUNCH");
        printMenu(dinerIterator);
    }


    private void printMenu(Iterator<MenuItem> iterator){
        //测试是否还有其他项
        while (iterator.hasNext()){
            //取得下一项
            MenuItem menuItem = iterator.next();
            //使用该项取得名称,价格和叙述并打印出来
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " -- ");
            System.out.println(menuItem.getDescription());
        }
    }
    
    //其他的方法
}

测试我们的代码

快来测试吧!让我们写一些测试程序,然后看看女招待如何工作……

public class MenuTestDrive {
    public static void main(String[] args) {
        PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
        DinerMenu dinerMenu = new DinerMenu();
        Waitress waitress = new Waitress(pancakeHouseMenu, dinerMenu);
        waitress.printMenu();
    }
}

到目前为止,我们做了些什么?

首先,我们让对象村的厨师们非常快乐。他们可以保持他们自己的实现有可以摆平差别。只要我们给他们这两个迭代器(PancakeMenuIterator和DinerMenuIterator),他们只需要加入一个createIterator()方法,一切就大功告成了。

这个过程中,我们也帮了我们自己。女招待将会更容易维护和扩展。让我们来彻底检查一下到底我们做了哪些事,以及后果如何:

到目前为止,我们有些什么……

在清理一切之前,让我们从整体上来看看目前的设计。

2.为迭代器做一些改良

好了,我们已经知道这两份菜单的接口完全一样,但没有为它们设计一个共同的接口。所以,接下来就要这么做,让女招待更干净一些。

你可能会奇怪,为什么我们不适用Java的Iterator接口呢——我们之所以这么做,是为了要让你了解如何从头创建一个迭代器。我们现在的目的已经达到了,所以就要改变做法,开始使用Java的Iterator接口了。而这也会带来更多的好处。什么好处呢?很快你就会知道了。

首先,让我们看看java.util.Iterator接口:

这一切都太简单了:我们只需将煎饼屋菜单迭代器和餐厅菜单迭代器所扩展的接口,由我们自己的迭代器接口,改成java.util的迭代器接口即可,对吧?差不多就这样……实际上,甚至更简单。其实不只java.util有迭代器接口,连ArrayList也有一个返回迭代器的iterator()方法。换句话说,我们并不需要为ArrayList实现自己的迭代器。然而,我们仍然需要为餐厅菜单实现一个迭代器,因为餐厅菜单使用的数组,而数组不支持iterator()方法(或其他创建数组迭代器的方法)。

Q&A

问:如果我不想让客户具备删除的能力,该怎么办?

答:remove()方法其实是可有可无的,不一定要提供删除的功能。但是,很明显的,你需要提供这样的方法,因为毕竟它被声明在Iterator接口中。如果你不允许remove()的话,可以抛出一个java.lang.UnsupportedOperationException运行时异常。

Iterator的API文件提到可以让remove()抛出这样的异常,而任何良好的客户端程序只要调用了remove()方法,就应该检查是否会发生这个异常。

问:在多线程的情况下,可能会有多个迭代器引用同一个对象集合。remove()会造成怎样的影响?

答:后果并没有指明,所以很难预料。当你的程序在多线程的代码中使用到迭代器时,必须特别小心。这看起来就和我们之前的定义一样。

用java.util.Iterator来清理

让我们先从煎饼屋菜单开始,先把它改用java.util.Iterator,这很容易,只需要删除煎饼屋菜单迭代器类,然后在煎饼屋菜单的代码前面加上import java.util.Iterator,在改变一下这一行代码就可以了:

public Iterator<MenuItem> createIterator() {
    //不创建自己的迭代器,而是调用
    //菜单项ArrayList的iterator()方法
    return menuItems.iterator();
}

这样PancakeHouseMenu就完成了。

接着,我们处理DinerMenu,以符合java.util.Iterator的需求。

import java.util.Iterator;
//要导入java.util.Iterator,我们需要实现这个接口

public class DinerMenuIterator implements Iterator<MenuItem> {

    MenuItem[] items;
    int position = 0;

    public DinerMenuIterator(MenuItem[] items) {
        this.items = items;
    }

    public boolean hasNext() {
        //在这里实现
    }

    public MenuItem next() {
        //在这里实现
    }

    @Override
    public void remove() {
        //我们需要实现remove()方法。因为使用的是固定长度的数组,所以在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接口相当简单:可能迟早需要在里面多加入一些方法,例如addItem(),但是目前,我们还是让厨师控制他们的菜单,不要把那些方法放在公开接口中:

public interface Menu {
    /**
     * 这是一个煎蛋接口,让客户能够取得一个菜单项迭代器
     * @return
     */
    Iterator<MenuItem> createIterator();
}

现在,我们需要让煎饼屋菜单类和餐厅菜单类都实现Menu接口,然后更新女招待的代码如下:

import java.util.Iterator;

public class Waitress {
    Menu pancakeHouseMenu;
    Menu dinerMenu;

    /**
     * 将具体菜单类改成Menu接口
     * @param pancakeHouseMenu
     * @param dinerMenu
     */
    public Waitress(Menu pancakeHouseMenu, Menu dinerMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinerMenu = dinerMenu;
    }

    public void printMenu(){
        Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();
        System.out.println("MENU\n----\nBREAKFAST");
        printMenu(pancakeIterator);
        System.out.println("\nLUNCH");
        printMenu(dinerIterator);
    }
    
    private void printMenu(Iterator<MenuItem> iterator){
        while (iterator.hasNext()){
            MenuItem menuItem = iterator.next();
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " -- ");
            System.out.println(menuItem.getDescription());
        }
    }
    //其他的方法
}

这为我们带来了什么好处?

煎饼屋菜单和餐厅菜单的类,都实现了Menu接口,女招待可以利用接口(而不是具体类)以你用每一个菜单对象。这样,通过“针对接口编程,而不针对实现编程”,我们就可以减少女招待和具体类之间的依赖。

这个心的菜单接口有一个方法,createIterator()。此方法是由煎饼屋菜单和餐厅菜单实现的。每个菜单类都必须负责提供适当的具体迭代器。

3.定义迭代器模式

你已经知道了如何用自己的迭代器来实现迭代器模式,也看到了Java是如何在某些面向聚合的类中(如ArrayList)支持迭代器的。现在我们就来看看这个模式的正式定义:

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

迭代器模式让我们能游走于聚合内的没一个元素,而又不暴露其内部的表示。

把游走的任务放在迭代器上,而不是聚合上。这样简化了聚合的接口和实现,也让责任各得其所。

这很有意义:这个模式给你提供了一种方法,可以顺序访问一个聚集对象的元素,而又不用知道内部是如何表示的。你已经在前面的两个菜单实现中看到了这一点。在设计中使用迭代器的影响是明显的:如果你有一个统一的方法访问聚合中的每一个对象,你就可以编写多态的代码和这些聚合搭配使用——如同前面的printMenu()方法一样,只要有了迭代器这个方法根本不管菜单项究竟是由数组还是由ArrayList(或者其他能创建迭代器的东西)来保存的。

另一个对你的设计造成重要影响的,是迭代器模式把在元素之间游走的责任交给迭代器,而不是聚合对象。这不仅让聚合的接口和实现变得更简洁,也可以让聚合更专注在它所应该专注的事情上面(也就是管理对象集合),而不必去理会遍历的事情。

让我们检查类图,将来龙去脉拼凑出来……

Q&A

问:我看到其他书上让迭代器提供一些方法叫做first()、next()、isDone()、和currentItem()。为什么这些方法不一样?

答:这些是“经典的”方法名称,它们随着时间的流逝渐渐改变了,而现在我们在java.uitl.Iterator中了,而现在我们在java.util.Iterator中所使用的名称有next()、hasNext()甚至remove()。

我们来看看这些景点的方法。java.util.Iterator将next()(移到下个位置)和currentItem()(取出目前的项目)合并成一个方法next();idDone()变成了hasNext();至于first()则不存在对应,这是因为在Java中,我们倾向于取得一个新的迭代器,而不是让目前的迭代器跳到一开始的位置。所以,其实这些接口没什么太大的差异。事实上,你还可以在迭代器内加入许多的方法,例如remove()方法。

问:我听说过“内部的”迭代器和“外部的”迭代器。这是什么?我们在前面例子中实现的是哪一种?

答:我们实现的是外部的迭代器,也就是说,客户通过调用next()取得下一个元素。而内部的迭代器则是由迭代器自己控制。在这种情况下,因为是由迭代器自行在元素之间游走,所以你必须告诉迭代器在游走的过程中,要做些什么事情,也就是说,你必须将操作传入给迭代器。因为客户无法控制遍历的过程,所以内部迭代器比外部迭代器更没有弹性。然而,某些人可能认为内部的迭代器比较容易使用,因为只需将操作告诉它,它就会帮你做完所有事情。

问: 迭代器可以被实现成向后移动吗,就像向前移动一样?

答:绝对可以。在这样的情况下,你可能要加上两个方法,一个方法取得前一个元素,而另一个方法告诉你是否已经到了集合的最前端。Java的集合类库提供另一种迭代器接口,称为ListIterator。这个迭代器在标准的迭代器接口上多家了一个previous()和一些其他的方法。任何实现了List接口的集合,都支持这样的做法。

问:对于散列表这样的集合,元素之间并没有明显的次序关系,我们该怎么办?

答:迭代器意味着没有次序。只是取出所有的元素,并不表示取出元素的先后就代表元素的大小次序。对于迭代器来说,数据结构可以是有次序的,或是没有次序的,甚至数据可以是重复的。除非某个集合的文件有特别说明,否则不可以对迭代器所取出的元素大小顺序做出假设。

问:你说可以用迭代器写出“多态的代码”,可以再多做一些解释吗?

答:当我们写了一个需要以迭代器当做参数的方法时,其实就是在使用多态的迭代。也就是说,我们所写出的代码,可以在不同的集合中游走,只要这个集合支持迭代器即可。我们不在乎这个集合是如何被实现的,但依然可以编程在它内部的元素之间游走。

问:如果我使用Java,我不见得总是想要利用java.util.Iterator,可能想要使用自己的迭代器实现,和这些已经使用Java标准的迭代器的类做整合,这做得到吗?

答:或许可以吧。如果你有一个通用的迭代器接口,那么让自己的集合和Java的集合(例如ArrayList,HashSet)混合使用就会比较容易。但是请记住,如果你需要在迭代器接口为你的集合新增功能,你可以随时扩展迭代器接口。

问:我看到Java有一个Enumeration(枚举)接口,它实现了迭代器模式吗?

答:我们曾经在适配器模式中提到过这个接口,还记得吗?java.util.Enumeration是一个有次序的迭代器实现,它有两个方法,hasMoreElements()类似hasNext(),而nextElement()类似next()。然而,你应该比较想使用迭代器,而不是枚举,因为大多数的Java类都支持迭代器。如果你想把这两者互相转换,请看适配器模式。

单一责任

如果我们允许我们的聚合实现它们内部的集合,以及相关的操作和遍历的方法,又会如何?我们已经知道这回增加聚合中的方法个数,但又怎样呢?为什么这么做不好?

想知道为什么,首先你需要认清楚,当我们允许一个类不但要完成自己的事情(管理某种聚合),还同时要担负更多的责任(例如遍历)时,我们就给了这个类两个变化的原因。两个?没错,就是两个:如果这个集合改变的话,这个类也必须改变,如果我们遍历的方式改变的话,这个类也必须跟着改变。所以,再一次地,我们的老朋友“改变”又成了我们设计原则的中心:

设计原则:一个类应该只有一个引起变化的原因

我们知道要避免类内的改变,因为修改代码很容易造成许多潜在的错误。如果有一个类具有两个改变的原因,那么这会使得将来该类的变化几率上升,而当它真的改变时,你的设计中同时有两个方面将会受到影响。

要如何解决呢?这个原则告诉我们将一个责任只指派给一个类。

类的每个责任都有改变的潜在区域。超过一个责任,意味着超过一个改变的区域。

这个原则告诉我们,尽量让每个类保持单一责任。

没错,这听起来很容易,但其实做起来并不简单:区分设计中的责任,是最困难的事情之一。我们的大脑很习惯看着一大群的行为,然后将它们集中在一起,尽管他们可能属于两个或多个不同的责任。想要成功的唯一方法,就是努力不懈地检查你的设计,随着系统的成长,随时观察有没有迹象显示某个类改变的原因超出一个。

内聚(cohesion)这个术语你应该听过,它用来度量一个类或模块紧密地达到单一目的或责任。

当一个模块或以各类被设计成只支持一组相关的功能时,我们说它具有高内聚;反之,当被设计成支持一组不相关的功能时,我们说它具有低内聚。

内聚是一个比单一责任原则更普遍的概念,但两者其实关系是很密切的。遵守这个原则的类容易具有很高的凝聚力,而且比背负许多责任的低内聚类更容易维护。

4.加入咖啡厅的菜单

这是咖啡厅的菜单,要把这个菜单整合进我们的框架中,似乎不是太难得事情……看看怎么做。

/**
 * 咖啡厅菜单并没有实现我们的Menu接口,但这很容易修改。
 */
public class CafeMenu {
    /**
     * 菜单项是用散列表存储的,
     * 不知道这是否支持迭代器?等一下研究看看……
     */
    Map<String,MenuItem> menuItems = new HashMap<>();

    public CafeMenu() {
        addItem("Veggie Burger and Air Fries",
                "Veggie burger on a whole wheat bun, lettuce, tomato, and fries",
                true,
                3.99);
        addItem("Soup of the day",
                "A cup of the soup of the day,with a side salad",
                false,
                3.69);
        addItem("Burrito",
                "A large burrito,with whole pinto beans, salsa, guacamole",
                true,
                4.29);
    }

    public void addItem(String name,String description,boolean vegetarian,double price){
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        //key是项目名称,值就是菜单项对象
        menuItems.put(menuItem.getName(),menuItem);
    }

    public Map<String, MenuItem> getMenuItems() {
        return menuItems;
    }
}

重做咖啡厅代码

将咖啡厅菜单整合进我们的框架是很容易的。为什么呢?因为HashMap本来就支持Java内置的迭代器。但是它和ArrayList有一些不同……

/**
 * 咖啡厅菜单实现Menu接口,所以女招待使用咖啡厅菜单的方式
 * 就和其他的两个菜单没有两样。
 */
public class CafeMenu implements Menu{

    Map<String,MenuItem> menuItems = new HashMap<>();

    public CafeMenu() {
        //构造器的代码写在这里
    }

    public void addItem(String name,String description,boolean vegetarian,double price){
        MenuItem menuItem = new MenuItem(name, description, vegetarian, price);
        menuItems.put(menuItem.getName(),menuItem);
    }

    /*
    跟以前一样,我们可以避开getMenuItems(),
    所以我们不需要对女招待暴露菜单项的实现
    public Map<String, MenuItem> getMenuItems() {
        return menuItems;
    }*/

    /**
     * 我们在这里实现了createIterator()方法。
     * 注意,我们不是取得整个HashMap的迭代器,而是取得值的部分的迭代器。
     */
    @Override
    public Iterator<MenuItem> createIterator() {
        return menuItems.values().iterator();
    }
}

让女招待认识咖啡厅菜单

如果修改女招待,让她能够支持我们的新菜单呢?现在女招待已经能够接受迭代器了,所以应该不困难。

public class Waitress {
    Menu pancakeHouseMenu;
    Menu dinerMenu;
    //这个咖啡厅菜单会和其他菜单一起被传入女招待的构造器中,
    //然后记录在一个实例变量中
    Menu cafeMenu;

    public Waitress(Menu pancakeHouseMenu, Menu dinerMenu, Menu cafeMenu) {
        this.pancakeHouseMenu = pancakeHouseMenu;
        this.dinerMenu = dinerMenu;
        this.cafeMenu = cafeMenu;
    }

    public void printMenu(){
        Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
        Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();
        Iterator<MenuItem> cafeIterator = cafeMenu.createIterator();
        System.out.println("MENU\n----\nBREAKFAST");
        printMenu(pancakeIterator);
        System.out.println("\nLUNCH");
        printMenu(dinerIterator);
        //我们使用这个咖啡厅的菜单作为晚餐的菜单。
        //想要将菜单打印出来,我们只需将它传入printMenu()就一切搞定。
        System.out.println("\nDINER");
        printMenu(cafeIterator);
    }

    private void printMenu(Iterator<MenuItem> iterator){
        while (iterator.hasNext()){
            MenuItem menuItem = iterator.next();
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " -- ");
            System.out.println(menuItem.getDescription());
        }
    }
    //其他的方法
}

早餐、午餐和晚餐

public class MenuTestDrive {
    public static void main(String[] args) {
        PancakeHouseMenu pancakeHouseMenu = new PancakeHouseMenu();
        DinerMenu dinerMenu = new DinerMenu();
        //创建一个咖啡厅菜单,然后将它传给女招待
        CafeMenu cafeMenu = new CafeMenu();
        Waitress waitress = new Waitress(pancakeHouseMenu, dinerMenu,cafeMenu);
        waitress.printMenu();
    }
}

我们做了什么?

  1. 我们想让女招待能够有一个简单的方式来遍历菜单项
  2. 我们的菜单项有两个不同的实现和两个不同的遍历接口
  3. 而且我们不希望她知道菜单项是如何实现的

我们将女招待解耦了

  1. 为了让女招待遍历各种所需对象组,我们给她一个迭代器
  2. 其中一个用来取得ArrayList内的项目,而另一个来取得数组内的项目
  3. ArrayList有内置的迭代器,但是数组没有内置迭代器,所以我们自己创建一个
  4. 现在她不再需要担心究竟我们使用哪一个实现,反正她都是使用相同的接口——也就是迭代器的接口——来遍历菜单项。我们将女招待从实现中解耦了。

我们让女招待更具有扩展性

  1. 通过赋予她一个迭代器,我们将她从菜单项的实现中解耦了,所以今后我们可以轻易地增加新的菜单。
  2. 我们很轻易地就加入了另一个菜单项的实现,而且因为我们提供了迭代器,所以女招待知道如何处理这个新菜单。
  3. 这对她比较好,因为现在她能够使用相同代码来遍历任意组内的对象。这也对我们比较好,因为我们不用再将实现的细节暴露出去了
  4. 为这个HashMap的值制作一个迭代器。做法很简单,你只要调用values.iterator(),就可以取得一个迭代器。

但还有更多

  1. Java提供了许多的“collection”类(例如ArrayList、LinkedList、HashSet和HashMap),让你能够存取一群对象。
  2. 它们具有不同的接口。但尽管如此,几乎这些类都会提供方法让我们获得迭代器。
  3. 而如果它们不支持迭代器的话,也没关系,因为现在你已经知道如何自己动手创建一个迭代器了。

5.迭代器与组合

我们所使用的这些类都属于Java集合类库的一部分。其中包括了ArrayList、LinkedList、HashSet、Stack和PriorityQueue。这些类都实现了java.util.Collection接口。这个接口包含了许多有用的方法,可以操纵一群对象。

让我们快速地浏览这个接口:

注意:HashMap对于迭代器的支持是“间接的”。当我们在实现咖啡厅菜单的时候,你可以从中取得一个迭代器,但是这个迭代器不是直接从HashMap中取出,而是由HashMap的value取出的。仔细想想,这很有道理:HashMap内部存储了两组对象:key和value。如果我们想要遍历value,当然是要先从HashMap取得value,然后再取得迭代器。

Collection和Iterator的好处在于,每个Collection都知道如何创建自己的Iterator。只要调用ArrayList上的iterator(),就可以返回一个具体的Iterator,而你根本不需要知道或关心到底使用了哪个具体类,你只要使用它的Iterator接口就可以了。

Java5的迭代器和集合

Java5包含一种新形式的for语句,称为for/in。这可以让你在一个集合或者数组中遍历,而且不需要显式创建迭代器。

想使用for/in,语法是这样的:

//在集合中的每个对象之间反复遍历
//每次到循环的最后,obj会被赋值为集合中的下一个元素
for (Object obj : collection) {
   ...
}

下面是利用for/in遍历ArrayList的例子:

List<MenuItem> items = new ArrayList<>();
items.add(new MenuItem("Pancakes","Delicious pancakes",true,1.59));
items.add(new MenuItem("Waffles","yummy waffles",true,1.99));
items.add(new MenuItem("Toast","perfect toast",true,0.59));
for (MenuItem item : items) {
    System.out.println("Breakfast item: " + item);
}

我们花了很多时间在女招待上,但还是得承认,程序中调用三次printMenu(),看起来实在有点丑。

看清现实,我们每次已有新菜单加入,就必须代开女招待实现并加入更多的代码。这算不算是“违反开放-关闭原则”?

public void printMenu(){
    //调用createIterator()三次
    Iterator<MenuItem> pancakeIterator = pancakeHouseMenu.createIterator();
    Iterator<MenuItem> dinerIterator = dinerMenu.createIterator();
    Iterator<MenuItem> cafeIterator = cafeMenu.createIterator();
    //调用printMenu()三次
    System.out.println("MENU\n----\nBREAKFAST");
    printMenu(pancakeIterator);

    System.out.println("\nLUNCH");
    printMenu(dinerIterator);

    System.out.println("\nDINER");
    printMenu(cafeIterator);
    //每次我们新增或删除一个菜单,就必须代开这份代码来修改。
}

这不是女招待的错。对于将她从菜单的实现上解耦和提取遍历动作到迭代器,我们都做得很好。但我们仍然将菜单处理成分离独立的对象——我们需要一种一起管理它们的方法。

我们试试看:

public class Waitress {
    ArrayList<Menu> menus;
    //现在我们只需要一个菜单ArrayList
    public Waitress(ArrayList<Menu> menus) {
        this.menus = menus;
    }

    //我们遍历菜单,把每个菜单的迭代器传给重载的printMenu()方法
    public void printMenu(){
        Iterator<Menu> menuIterator = menus.iterator();
        while (menuIterator.hasNext()){
            Menu menu = menuIterator.next();
            printMenu(menu.createIterator());
        }
    }
    
    //这里的代码不需要改变
    private void printMenu(Iterator<MenuItem> iterator){
        while (iterator.hasNext()){
            MenuItem menuItem = iterator.next();
            System.out.print(menuItem.getName() + ", ");
            System.out.print(menuItem.getPrice() + " -- ");
            System.out.println(menuItem.getDescription());
        }
    }
}

看起来相当不错,虽然我们失去了菜单的名字,但是可以把名字加进每个菜单中。

正当我们认为这很安全的时候,现在他们希望能够加上一份餐后甜点的“子菜单”。

现在怎么办?我们不仅仅要支持多个菜单,甚至还要支持菜单中的菜单。

如果我们能让甜点菜单变成餐厅菜单集合的一个元素,那该有多好。但是根据现在的实现,根本做不到。

煎饼屋菜单的实现是ArrayList,咖啡厅菜单的实现是HashMap,而餐厅菜单的实现是数组,我们需要让餐厅菜单持有一份子菜单,但是不能真的把它复制给菜单项数组,因为类型不同,所以不能这么做。

我们不能把甜点菜单赋值给菜单项数组。又要修改了!

我们需要什么?

该是做决策来改写厨师的实现以符合所有菜单(以及子菜单)的需求的时候了。没错,我们要告诉厨师,重新实现他们的菜单已经是不可避免的了。

事实是,我们已经到达了一个复杂级别,如果现在不重新设计,就无法容纳未来增加的菜单或子菜单等需求。

所以,在外面在新设计中,真正需要些什么呢?

  • 我们需要某种树形结构,可以容纳菜单、子菜单和菜单项。
  • 我们需要确定能够在每个菜单的各个项之间游走,而且至少要像现在用迭代器一样方便。
  • 我们也需要能够更有弹性地在菜单项之间游走。比方说,可能只需要遍历甜点菜单,或者可以遍历餐厅的整个菜单(包括甜点菜单在内)。

6.定义组合模式

没错,我们要介绍另一个模式解决这个难题。我们并没有放弃迭代器——它仍然是我们解决方案中的一部分——然而,管理菜单的问题已经到了一个迭代器无法解决的新维度。所以,我们将倒退几步,改用组合模式(Composite Pattern)来实现这一部分。

对于这个模式,我们不打算深入探讨,只在这里提出它的正是定义:

组合模式:允许你将对象组合成树形结构来表现“整体/部分”层次结构。组合能让客户以一直的方式处理个别对象以及对象组合。

让我们以菜单为例思考这一切:这个模式能够创建一个树形结构,在同一个结构中处理嵌套菜单和菜单项组。通过将菜单和项放在相同的结构中,我们创建了一个“整体/部分”层次结构,即由菜单和菜单项组成的对象树。但是可以将它视为一个整体,像是一个丰富的大菜单。

一旦有了丰富的大菜单,我们就可以使用这个模式来“统一处理个别对象和组合对象”。这意味着什么?它意味着,如果我们有了一个树形结构的菜单、子菜单和可能还带有菜单项的子菜单,那么任何一个菜单都是一种“组合”。因为它既可以包含其它菜单,也可以包含菜单项。个别对象只是菜单项——并未持有其它对象。就像你将看到的,使用一个遵照组合模式的设计,让我们能够写出简单的代码。就能够对整个结构应用相同的才做(例如打印)。

组合模式让我们能用属性方式创建对象的结构,树里面包含了组合以及个别的对象。

使用组合结构,我们能把想听的操作应用在组合和个别对象上。换句话说,在大多数情况下,我们可以忽略对象组合和个别对象之间的差别。

Q&A

问:组件、组合、树?我被搞混了。

答:组合包含组件。组件有两种:组合与叶节点元素。听起来像递归是不是?组合持有一群孩子,这些孩子可以是别的组合或者叶节点元素。当你用这种方式组织数据的时候,最终会得到树形结构(正确的说法是由上而下的树形结构),根部是一个组合,二组合的分支逐渐往下延伸,直到叶节点为止。

问:这和迭代器有什么关系?

答:别忘了,我们现在采取了一个新方法,打算用新的方案——组合模式,来重新实现菜单。所以不要认为迭代器和组合之间有什么神奇的转换。我们可以说,这两者可以合作无间,你很快就会看到我们可以在组合的实现中使用迭代器,而且做法还不只一种。

7.利用组合设计菜单

我们要如何在菜单上应用组合模式呢?一开始,我们需要创建一个组件接口来作为菜单和菜单项的接口,让我们能够用同一的做法来处理菜单和菜单项。换句话说,我们可以针对菜单或菜单项调用相同的方法。

现在,对于菜单或菜单项来说,有些方法可能不太恰当。但我们可以处理这个问题。至于现在,让我们从头来看看如何让菜单能够符合组合模式的结构。

实现菜单组件

我们要开始编写菜单组件的抽象类,请记住,菜单组件的角色是为叶节点和组合结点提供一个共同的接口。现在你可能想问:“那么菜单组件不就扮演了两个角色吗?”可能是这样的,我们稍后再讨论这一点。然而,目前我们要为这些方法提供默认的实现,这样,如果菜单项(叶节点)或者菜单(组合)不想实现某些方法的时候(例如叶节点不想实现getChild()方法),就可以不实现这些方法。(所有的组件都必须实现MenuComponent接口,然而,叶节点和组合节点的角色不同,所以有些方法可能并不适合某种节点。面对这种情况,有时候,你最好是抛出运行时异常。)

/**
 * 因为有些方法只对菜单项有意义,
 * 而有些则只对菜单有意义,
 * 默认实现是抛出UnsupportedOperationException异常。
 * 这样,如果菜单项或菜单不支持某个操作,它们就不需要做任何事情,
 * 直接继承默认实现就可以了。
 */
public abstract class MenuComponent {

    /**
     * 我们把“组合”方法组织在一起,即新增、删除和取得菜单组件
     * @param menuComponent
     */
    public void add(MenuComponent menuComponent){
        throw new UnsupportedOperationException();
    }
    public void remove(MenuComponent menuComponent){
        throw new UnsupportedOperationException();
    }
    public MenuComponent getChild(int i){
        throw new UnsupportedOperationException();
    }

    /**
     * 这些是“操作”方法;它们被菜单项使用,其中有一些也可用在菜单上。
     * @return
     */
    public String getName(){
        throw new UnsupportedOperationException();
    }
    public String getDescription(){
        throw new UnsupportedOperationException();
    }
    public double getPrice(){
        throw new UnsupportedOperationException();
    }
    public boolean isVegetarian(){
        throw new UnsupportedOperationException();
    }

    /**
     * print()是一个”操作“方法,这个方法同时被菜单和菜单项所实现。
     * 但我们还是在这里提供了默认的操作。
     */
    public void print(){
        throw new UnsupportedOperationException();
    }
}

实现菜单项

让我们来看菜单项类。别忘了,这是组合类图里的叶类,它实现组合内元素的行为。

/**
 * 首先,我们需要扩展MenuComponent
 */
public class MenuItem extends MenuComponent{
    String name;
    String description;
    boolean vegetarian;
    double price;

    /**
     * 构造器需要被传入名字、叙述等,并保持对它们的引用。
     * 这和我们旧的菜单项实现很像。
     * @param name
     * @param description
     * @param vegetarian
     * @param 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 boolean isVegetarian() {
        return vegetarian;
    }

    public double getPrice() {
        return price;
    }

    /**
     * 这和之前的实现不一样,在MenuComponent类里我们覆盖了print()方法。
     * 对菜单项来说,此方法会打印出完整的菜单项条目,包括:名字、描述、价格以及是否为素食
     */
    @Override
    public void print() {
        System.out.print(" " + getName());
        if(isVegetarian()){
            System.out.print("(v)");
        }
        System.out.println(", " + getPrice());
        System.out.println("    -- " + getDescription());
    }
}

实现组合菜单

我们已经有了菜单项,还需要组合类,就是我们叫做菜单的。别忘了,此组合类可以持有菜单项或者其他菜单。有一些方法并未在MenuComponent类中实现,比如getPrice()和isVegertarian(),因为这些方法对菜单而言并没多大意义。

/**
 * 菜单和菜单项一样,都是MenuComponent
 */
public class Menu extends MenuComponent{
    //菜单可以有任意数目的孩子
    //这些孩子都必须属于MenuComponent类型,
    //我们使用内部的ArrayList记录它们
    ArrayList<MenuComponent> menuComponents = new ArrayList<>();
    String name;
    String description;

    /**
     * 我们将给每个菜单一个名字和一个描述。
     * 以前,每个菜单的类名称就是此菜单的名字
     * @param name
     * @param description
     */
    public Menu(String name, String description) {
        this.name = name;
        this.description = description;
    }

    /**
     * 我们在这里将菜单项和其他菜单加入到菜单中。
     * 因为菜单和菜单项都是MenuComponent,
     * 所以我们只需用一个方法就可以两者兼顾
     * 同样的道理,可以删除或取得某个MenuComponent
     * @param menuComponent
     */
    @Override
    public void add(MenuComponent menuComponent) {
        menuComponents.add(menuComponent);
    }

    @Override
    public void remove(MenuComponent menuComponent) {
        menuComponents.remove(menuComponent);
    }

    @Override
    public MenuComponent getChild(int i) {
        return menuComponents.get(i);
    }

    /**
     * 这是用来取得名字和描述的getter方法。
     * 请注意,我们并未覆盖getPrice()或isVegetarian(),
     * 因为这些方法对Menu来说并没有意义(虽然你可能认为或isVegetarian())有意义。
     * 如果有人试着在Menu上调用这些方法,就会得到UnsupportedOperationException异常
     * @return
     */
    @Override
    public String getName() {
        return name;
    }

    @Override
    public String getDescription() {
        return description;
    }

    @Override
    public void print() {
        System.out.print("\n" + getName());
        System.out.println(", " + getDescription());
        System.out.println("---------------------");
    }
}

萌新:等一下,我不了解print()的实现。原本我以为应该能够在组合和叶节点上应用相同的操作。如果我用这种实现对组合结点应用print(),所得到的竟然只是一个煎蛋的菜单名字和描述,而不是完整地打印出组合内的每一个项。

说得好。因为菜单是一个组合,包含了菜单项和其他的菜单,所以它的print()应该打印出它所包含的一切。如果它不这么做,我们就必须遍历整个组合的每个节点,然后将每一项打印出来。这么一来,也就失去了实用组合结构的意义。

想要正确地实现print()其实很容易,因为我们可以让每个组件打印自己。

修正print()方法

public void print() {
    System.out.print("\n" + getName());
    System.out.println(", " + getDescription());
    System.out.println("---------------------");

    //我们用了迭代器。用它遍历所有菜单组件
    //遍历过程中,可能遇到其他菜单,或者是遇到菜单项。
    //由于菜单和菜单项都实现了print(),那我们只要调用print()即可。
    Iterator<MenuComponent> iterator = menuComponents.iterator();
    while (iterator.hasNext()){
        MenuComponent menuComponent = iterator.next();
        menuComponent.print();
    }
}

在遍历期间,如果遇到另一个菜单对象,它的print()方法会开始另一个遍历,依次类推。

测试前的准备工作

差不多可以测试了,但是在开始测试之前,我们必须更新女招待的代码——毕竟她是菜单的主要客户:

public class Waitress {

    MenuComponent allMenus;

    /**
     * 女招待的代码变得很简单。
     * 现在我们只要将最顶层的菜单组件交给她就可以了,
     * 最顶层菜单包含其他所有菜单,我们称之为allMenus
     * @param allMenus
     */
    public Waitress(MenuComponent allMenus) {
        this.allMenus = allMenus;
    }

    /**
     * 她只需要调用最顶层菜单的print(),就可以打印整个菜单层次
     * 包括所有菜单及所有菜单项
     */
    public void printMenu(){
        allMenus.print();
    }
}

好了,在开始测试前,还剩下最后一件事。让我们了解一下,在运行时菜单组合是什么样的:

编写测试程序

好了,现在要写一个测试程序。和以前的版本不同,我们这个版本要在测试程序中处理所有菜单的创建。我们可以请每位厨师交出他的新菜单,但是让我们先将这一切测试完毕。代码如下:

public class MenuTestDrive {
    public static void main(String[] args) {
        //先创建所有的的菜单对象。
        Menu pancakeHouseMenu = new Menu("PANCAKE HOUSE MENU", "Breakfast");
        Menu dinerMenu = new Menu("DINER MENU", "Lunch");
        Menu cafeMenu = new Menu("CAFE MENU", "Dinner");
        Menu dessertMenu = new Menu("DESSERT MENU", "Dessert of course!");

        //我们需要一个最顶层的菜单,将它称为allMenus
        Menu allMenus = new Menu("ALL MENUS", "All menus combined");

        //我们使用组合的add()方法,将每个菜单都加入到顶层菜单allMenus中
        allMenus.add(pancakeHouseMenu);
        allMenus.add(dinerMenu);
        allMenus.add(cafeMenu);

        // 在这里加入菜单项
        dinerMenu.add(new MenuItem("Vegetarian BLT",
                "(Fakin') Bacon with lettuce & tomato on whole wheat",
                true,
                2.99));
        dinerMenu.add(new MenuItem("BLT",
                "Bacon with lettuce & tomato on whole wheat",
                false,
                2.99));
        dinerMenu.add(new MenuItem("Soup of the day",
                "Soup of the day,with a side of potato salad",
                false,
                3.29));
        dinerMenu.add(new MenuItem("Hotdog",
                "A hot dog,with saurkraut, relish, onions,topped with cheese",
                false,
                3.05));

        dessertMenu.add(new MenuItem("Apple Pie",
                "Apple Pie with a flakey crust, topped with vanilla ice cream",
                true,
                1.59));
        dessertMenu.add(new MenuItem("Cheesecake",
                "Creamy New York cheesecake, with a chocolate graham crust",
                true,
                1.99));
        dessertMenu.add(new MenuItem("Sorbet",
                "A scoop of raspberry and a scoop of lime",
                true,
                1.89));
        

        //然后我们也在菜单中加入另一个菜单。
        //由于菜单和菜单项都是MenuComponent,所以菜单可以顺利地被加入。
        dinerMenu.add(dessertMenu);
        
        //在这里加入更多菜单项
        cafeMenu.add(new MenuItem("Veggie Burger and Air Fries",
                "Veggie burger on a whole wheat bun, lettuce, tomato, and fries",
                true,
                3.99));
        cafeMenu.add(new MenuItem("Soup of the day",
                "A cup of the soup of the day,with a side salad",
                false,
                3.69));
        cafeMenu.add(new MenuItem("Burrito",
                "A large burrito,with whole pinto beans, salsa, guacamole",
                true,
                4.29));

        pancakeHouseMenu.add(new MenuItem("K&B's Pancake Breakfast",
                "Pancakes with scrambled eggs, and toast",
                true,
                2.99));
        pancakeHouseMenu.add(new MenuItem("Regular Pancake Breakfast",
                "Pancakes with freid eggs,sausage",
                false,
                2.99));
        pancakeHouseMenu.add(new MenuItem("Blueberry Pancakes",
                "Pancakes made with fresh blueberries",
                true,
                3.49));
        pancakeHouseMenu.add(new MenuItem("Waffles",
                "Waffles, with your choice of blue berries or strawberries",
                true,
                3.59));

        //一旦我们将整个菜单层次构造完毕
        //把它真个交给女招待,你会发现,女招待要将整份菜单打印出来
        //简直就是易如反掌。
        Waitress waitress = new Waitress(allMenus);
        waitress.printMenu();
    }
}

萌新:到底怎么回事?首先你告诉我们“一个类,一个责任”,现在却给我们一个让一个类有两个责任的模式。组合模式不但要管理层次结构,而且还要执行菜单的操作。

你的观察有几分真实性。我们可以这么说,组合模式以单一责任设计原则换取透明性(transparency)。什么是透明性?通过让组件的接口同时包含一些管理子节点和叶节点的操作,客户就可以将组合和叶节点一视同仁。也就是说,一个元素究竟是组合还是叶节点,对客户是透明的。

现在,我们在MenuComponent类中同时具有两种类型的操作。因为客户有机会对一个元素做一些不恰当或是没有意义的操作(例如试图把菜单添加到菜单项),所以我们失去了一些”安全性“。这是设计上的抉择,我们当然也可以采用另一种方向的设计,将责任区分开来放在不同的接口中。这么一来,设计上就比较安全,但我们也因此失去了透明性,客户的代码将必须用条件语句和instanceof操作符处理不同类型的节点。

所以 ,回到你的问题,这是一个很典型的折衷案例。尽管我们收到设计原则的知道,但是,我们总是需要观察某原则对我们的设计所造成的影响。有时候,我们会故意做一些看似违反原则的事情。然而,在某些例子中,这是观点的问题;比方说,让管理孩子的操作(例如add()、remove()、getChild())出现在叶节点中,似乎很不恰当,但是换个视角来看,你可以把叶节点视为没有孩子的节点。

闪回到迭代器

我们答应过会告诉你怎样用组合来使用迭代器。我们其实已经在print()方法内部的实现中使用了迭代器,除此之外,如果女招待需要,我们也能让她使用迭代器遍历整个组合。比方说,女招待可能想要游走整个菜单,挑出素食项。

想要实现一个组合迭代器,让我们为每个组件都加上createIterator()方法。从抽象的MenuComponent类开始下手:

现在我们需要在菜单和菜单项类中实现这个方法:

public class Menu extends MenuComponent{
    //其他部分的代码不需要修改
    /**
     * 这里使用一个新的,被称为CompositeIterator的迭代器。
     * 这个迭代器知道如何遍历任何组合。
     * 我们将目前组合的迭代器传入它的构造器。
     * @return
     */
    @Override
    public Iterator<MenuComponent> createIterator() {
        return new CompositeIterator(menuComponents.iterator());
    }
}
public class MenuItem extends MenuComponent{
    //其他部分的代码不需要修改
    
    @Override
    public Iterator<MenuComponent> createIterator() {
        return new NullIterator();
    }
}

组合迭代器

这个CompositeIterator是一个不可小觑的迭代器。它的工作是遍历组件内的菜单项,而且确保所有的子菜单(以及子子菜单……)都被包括进来。代码是下面这样的。请注意,代码虽然不多,但是不见得容易理解。

注意:

跟着我默念“递归大法好,递归大法好”

/**
 * 跟所有的迭代器一样,我们实现了java.util.Iterator接口
 */
public class CompositeIterator implements Iterator<MenuComponent> {

    LinkedList<Iterator<MenuComponent>> stack = new LinkedList<>();

    /**
     * 将我们要遍历的顶层组合的迭代器传入。
     * 我们把它抛进一个堆栈数据结构
     * @param iterator
     */
    public CompositeIterator(Iterator<MenuComponent> iterator) {
        stack.push(iterator);
    }

    /**
     * 当客户想要取得下一个元素的时候,
     * 我们先调用hasNext()来确定是否还有下一个。
     * @return
     */
    @Override
    public MenuComponent next() {
        if(hasNext()){
            Iterator<MenuComponent> iterator = stack.peek();
            //如果还有下一个元素,我们就从堆栈中取出目前的迭代器,
            //然后取得它的下一个元素
            MenuComponent component = iterator.next();
            if(component instanceof Menu){
                //如果元素是一个菜单,我们有了另一个需要被包含
                //进遍历中的组合,所以我们将它丢进堆栈中。不管是不是菜单,我们都返回该组件
                stack.push(component.createIterator());
            }
            return component;
        }else {
            return null;
        }
    }

    @Override
    public boolean hasNext() {
        if(stack.isEmpty()){
            //想要知道是否还有下一个元素,我们检查堆栈是否被清空
            //如果已经空了,就表示没有下一个元素了。
            return false;
        }else{
            //否则,我们就从堆栈的顶层中取出迭代器,
            //看看是否还有下一个元素。
            //如果它没有元素,我们将它弹出堆栈,然后递归地调用hasNext()
            Iterator<MenuComponent> iterator = stack.peek();
            if(!iterator.hasNext()){
                stack.pop();
                return hasNext();
            }else {
                return true;
            }
        }
    }

    /**
     * 我们不支持删除,这里只有遍历
     */
    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }
}

萌新:真是不可小觑的代码……究竟为什么遍历组合好像比以前为MenuComponent类的print()写过的遍历代码复杂?

在外面写MenuComponent类的print()方法的时候,外面利用了一个迭代器来遍历组件内的每个项。如果遇到的是菜单(而不是菜单项),我们递归地调用print()方法处理它。换句话说,MenuComponent是在“内部”自行处理遍历。

但是在前面的代码中,我们实现的是一个“外部”的迭代器,所以有许多需要追踪的事情。外部迭代器必须维护它在遍历中的位置,以便外部客户可以通过调用hasNext()和next()来驱动遍历。在这个例子中,我们的代码也必须维护组合递归结构的位置。这也就是为什么当我们在组合层次结构中上上下下时,使用堆栈来维护我们的位置。

空迭代器

到底什么是空迭代器(NullIterator)呢?这么说好了:菜单项内没什么可以遍历的,对吧?那么我们要如何实现菜单项的createIterator()方法呢?有两种选择:

选择一:

返回null

我们可以让createIterator()方法返回null,但是如果这么做,我们的客户代码就需要条件语句来判断返回值是否为null。

选择二:

返回一个迭代器,而这个迭代器的hasNext()永远返回false

这似乎是一个更好的方案。我们依然可以返回一个迭代器,客户不用再担心返回值是否为null。我们等于是创建了一个迭代器,其作用是“没作用”。

当然第二个选择看起来比较好。让我们称它为空迭代器,下面是它的实现:

public class NullIterator implements Iterator<MenuComponent> {
    @Override
    public boolean hasNext() {
        return false;
    }

    @Override
    public MenuComponent next() {
        return null;
    }

    @Override
    public void remove() {
        throw new UnsupportedOperationException();
    }
}

给我素食菜单

现在,我们已经有一种方式可以遍历菜单的每个项了。让我们为女招待加上一个可以确切告诉我们哪些项目是素食的方法:

public class Waitress {

    MenuComponent allMenus;

    /**
     * 女招待的代码变得很简单。
     * 现在我们只要将最顶层的菜单组件交给她就可以了,
     * 最顶层菜单包含其他所有菜单,我们称之为allMenus
     * @param allMenus
     */
    public Waitress(MenuComponent allMenus) {
        this.allMenus = allMenus;
    }

    /**
     * 她只需要调用最顶层菜单的print(),就可以打印整个菜单层次
     * 包括所有菜单及所有菜单项
     */
    public void printMenu(){
        allMenus.print();
    }
    
    
    public void printVegetarianMenu(){
        Iterator<MenuComponent> iterator = allMenus.createIterator();
        System.out.println("\nVEGETARIAN MENU\n----");
        while (iterator.hasNext()){
            MenuComponent menuComponent = iterator.next();
            try {
                if(menuComponent.isVegetarian()){
                    menuComponent.print();
                }
            }catch (UnsupportedOperationException ignored){}
        }
    }
}

迭代器和组合凑在一起的魔力

我们可是费了好大一番功夫才走到这里的。现在我们已经有了一个总菜单结构,可以应对未来餐饮帝国的成长需求了。现在让我们坐下休息一会儿,顺便点些素食来吃吧:

萌新:我注意到在你的printVegetarianMenu()方法内,使用了try/catch来处理那些不支持isVegetarian()方法的菜单的逻辑。我老是听人家说这不是一个好的编程形式。

你说的是这个吧:

try {
   //我们调用全部MenuComponent的isVegetarian()方法,但是Menu会抛出一个异常
   //因为它们不支持这个操作
   if(menuComponent.isVegetarian()){
       menuComponent.print();
   }
}catch (UnsupportedOperationException ignored){}//如果菜单组不支持这个操作,那我们就对这个异常置之不理

一般来说,我们统一你的看法;try/catch是一种错误处理的方法,而不是程序逻辑的方法。如果不这么做,我们还有哪些选择呢?我们可以在调用isVegetarian()方法之前,用instanceof来检查菜单组件的运行时类型,已确定它是菜单项。但是这么做,我们就会因为无法同意处理菜单和菜单项而失去透明性。

我们也可以改写Menu的isVegetarian()方法,让它返回false。这提供了一个简单的解决方法,同时也保持了透明性。

我们的解决方案是为了要清楚地表示我们的想法。我们真正想要传达的是:isVegetarian()是Menu没有支持的操作(这和说isVegetarian()是false意义不等同)。这样的做法也允许后来人去为Menu实现一个合理的isVegetarian()方法,而我们不必为此再修改这里的代码了。

这是我们的说法,而且我们坚持这么做。

模式告白

本周访问:

组合模式,我们要讨论他在实现上的问题

记者:我们今晚谈话来宾是组合模式。组合,请向大家介绍一下你自己。

组合:好的……当你有数个对象的集合,它们彼此之间有“整体/部分”的关系,并且你想用一致的方式对待这些对象时,你就需要我。

记者:好了,让我们从这里深入……你所谓的“整体/部分”关系,指的是什么?

组合:就拿图形用户界面来说,你经常会看到一个顶层的组件(像是Frame或Panel)包含着其他组件(像菜单、文字面板、滚动条、按钮)所以你的GUI包含了若干部分,但是当你显示它的时候,你认为它是一个整体。你告诉顶层的组件显示,然后就放手不管,由顶层组件负责显示所有相关的部分。

我们成这种包含其他组件的组件为组合对象,而称没有包含其他组件的组件为叶节点对象。

记者:至于你所谓的“用一致的方式对待”所有的对象,又是什么意思?是不是说组合和叶节点之间具有共同的方法可以调用?

组合:没错。我可以叫组合对象显示或是叫叶节点对象显示,他们会各自做出正确的事情。组合对象会叫它所有的组件显示。

记者:这意味着每一个对象都有相同的接口。万一组合中有些对象的行为不太一样,怎么办?

组合:这个嘛,为了要保持透明性,组合内所有的对象都必须实现相同的接口,否则客户就必须操心哪个对象是用哪个接口,这就失去了组合模式的意义。很明显的,这也意味着有些对象具备一些没有意义的方法调用。

记者:那怎么办?

组合:有些方式可以处理这一点。有时候你可以让这样的方法不做事,或者返回null值或false。置于挑哪一种方式,就看哪一种在你的应用中比较合乎逻辑。

有时候,你可能想要采取更激烈一点的手法,直接抛出异常。当然,客户就要愿意多做一些事情,已确定方法调用不会做意料之外的事情。

记者:但是如果客户不知道他所处理的对象是哪一种,在不检查类型的情况下,他们又如何知道应该调用什么呢?

组合:如果你稍微有一点创意,就可以将你的方法架构起来,好让默认实现能够做一些有意义的事情。比方说,如果你的客户调用了getChild(),对组合来说,这个方法是有意义的。如果你把叶节点想象成没有孩子的对象,这个方法对也借点来说也是有意义的。

记者:噢……聪明。但是,我听说一些客户其实很担心这个问题,所以他们对不同的对象用了不同的接口,这样就不会产生没有意义的方法调用了。这还算是组合模式吗?

组合:是的,这是更安全版本的组合模式,但是这需要客户先检查每个对象的类型,然后才进行方法的调用。

记者:请告诉我们更多的关于组合和叶节点对象的结构的事吧。

组合:通常是用树形结构,也就是一种层次结构。

根就是顶层的组合,然后往下是它的孩子,最末端是叶节点。

记者:孩子会不会反向指向它的父亲?

组合:是的,组件可以有一个指向父亲的指针,以便在游走时更容易。而且,如果引用某个孩子,你想从树形结构中删除这个孩子,你会需要父亲去删除它。一旦孩子有了指向父亲的引用,这做起来就很容易。

记者:在你的实现上,还真的有很多的事情需要考虑呢。在实现组合模式的时候,还有其他的问题吗?

组合:老实说,还有……其中之一就是孩子的次序。万一你又一个需要保持特定孩子次序的组合对象,就需要使用更复杂的管理方案来进行孩子的增加和删除,而且当你在这个层次结构内游走时,应该要更加小心。

记者:很好的观点,我根本没想到过。

组合:你想到过缓存(caching)吗?

记者:缓存?

组合:是的,缓存。有时候,如果这个组合结构很复杂,或者遍历的代价太高,那么实现组合结点的缓存就很有帮助。比方说,如果你要不断地遍历一个组合,而且它的没一个子节点都需要进行某些计算,那你就应该使用缓存来临时保存结果,省去遍历的开始。

记者:组合模式真的具有相当的内含,远远超出我之前的想象。在我们结束之前,我还有最后一个问题:你认为你的最大强项是什么?

组合:我认为我让客户生活得更加简单。我的客户不再需要操心面对的是组合对象还是叶节点对象了,所以就不需要写一大堆if语句来保证他们对正确的对象调用了正确的方法。通常,他们只需要对整个结构调用一个方法并执行操作就可以了。

记者:听起来像是一个很重要的好处。毫无疑问,你是一个很有用的模式,可以帮助我们收集和管理对象。

8.总结

多了两个模式,两种很棒的方法来处理集合对象。

OO原则:

  • 封装变化
  • 多用组合,少用继承
  • 针对接口编程,不针对实现编程
  • 为交互对象之间的松耦合设计而努力
  • 类应该对扩展开放,对修改关闭。
  • 依赖抽象,不要依赖具体类。
  • 只和朋友谈
  • 别找我,我会找你
  • 类应该只有一个改变的理由

OO模式:

迭代器模式——提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露其内部的表示。

组合模式——允许你将对象组成树形结构来表现“整体/部分”的层次结构。组合能让客户以一致的方式处理个别对象和对象组合。

本文要点:

  • 迭代器允许访问聚合的元素,而不需要暴露它的内部结构。
  • 迭代器将遍历聚合的工作封装进一个对象中。
  • 当使用迭代器的时候,我们一来聚合提供遍历。
  • 迭代器提供了一个通用的接口,让我们遍历聚合的项,当我们编码使用聚合的项时,就可以使用多态机制。
  • 我们应该努力让一个类只分配一个责任。
  • 组合模式提供一个结构,可同时包容个别对象和组合对象。
  • 组合模式允许客户对个别对象以及组合对象一视同仁。
  • 组合结构内的任意对象称为组件,组件可以是组合,也可以是叶节点。
  • 在实现组合模式时,有许多设计上的折衷。你要根据需要平衡透明性和安全性。

转载请注明原文链接:ZJ-Wave

Top