组合模式:允许你将对象组合成树形结构来表现"整体/部分"层次结构。组合能让客户以一致的方式处理个别对象以及对象组合;
知识点的梳理:
- 组合模式提供一个结构,可同时包容个别对象和组合对象;
- 组合模式允许客户对个别对象以及组合对象一视同仁;
- 组合结构内的任意对象称为组件,组件可以是组合,也可以是叶节点;
-
在实现组合模式时,有许多设计上的折衷。需要根据需要平衡透明性和安全性;
-
在餐厅,煎饼屋的基础上,增加咖啡厅!
- 先来看看咖啡厅的菜单,将代码整合进案例
import java.util.Hashtable; import java.util.Iterator; //咖啡厅菜单实现Menu接口,所以女招待使用咖啡厅菜单的方法,就和其他的两个菜单没有两样 public class CafeMenu implements Menu{ //菜单项使用散列表存储 Hashtable menuItems = new Hashtable(); 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); } //创建新的菜单项,并将它加入到散列列表中 private void addItem(String name, String description, boolean vegetarian, double price) { MenuItem menuItem = new MenuItem(name,description,vegetarian,price); menuItems.put(menuItem.getName(), menuItem); } //实现createIterator方法。 @Override public Iterator 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 pancakeIterator = pancakeHouseMenu.createIterator(); Iterator dinerIterator = dinerMenu.createIterator(); //将咖啡厅加入晚餐的菜单 Iterator cafeIterator = cafeMenu.createIterator(); System.out.println("MENU\n----\nBREAKFAST"); printMenu(pancakeIterator); System.out.println("\nLUNCH"); printMenu(dinerIterator); //传入printMenu打印 System.out.println("\nDINNER"); printMenu(cafeIterator); } private void printMenu(Iterator iterator) { while(iterator.hasNext()){ MenuItem 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();//打印出菜单 } } |
效果: |
-
升级代码
- 上面的示例成功解决了在原有基础上,再次增加一个咖啡厅是完全可能的。但在程序中调用三次printMenu()貌似不太明智。每次一有新的菜单加入,就必须打开女招待实现并加入更多的代码,着违反了"开放-关闭原则"
- 如果将这些菜单全都打包进一个ArrayList中,然后取得它的迭代器,遍历每个菜单的话,是不是就解决了呢?来试试看
public class Waitress { ArrayList menus;//现在只需要一个菜单ArrayList public Waitress(ArrayList menus){ this.menus = menus; } public void printMenu(){ Iterator menuIterator = menus.iterator(); while(menuIterator.hasNext()){ //遍历菜单,把每个菜单的迭代器传给重载的printMenu()方法 Menu menu = (Menu)menuIterator.next(); printMenu(menu.createIterator()); } } //这个函数不需要改变 void printMenu(Iterator iterator) { while(iterator.hasNext()){ MenuItem menuItem = (MenuItem)iterator.next();//取得下一项 System.out.print(menuItem.getName() + ", "); System.out.print(menuItem.getPrice() + " -- "); System.out.println(menuItem.getDescription()); } } } |
这样做的话,暂时可以解决问题 |
-
新的需求
-
现在餐厅希望能够加上一份餐后甜点的"子菜单";
- 现在不仅仅要支持多个菜单,还要支持菜单中的菜单;
- 如果能让甜点菜单变成餐厅菜单集合的一个元素,就很好了。但是根据现在的实例,根本无法做到;
-
-
现在怎么办?如果不重新设计,就无法容纳未来增加的菜单或子菜单等需求。那我们真正需要什么呢?
- 我们需要某种树形结构,可以容纳菜单,子菜单和菜单项;
- 我们需要确定能够在每个菜单的各个项之间游走,而且至少要像想在用迭代器一样方便;
- 还需要能够更有弹性地在菜单项之间游走。比如,可能需要遍历贴点菜单,或者可以遍历餐厅的整个菜单(包括甜点菜单);
-
定义组合模式
-
以菜单为例来说明问题:
- 这个模式能够创建一个树形结构,在同一个结构中处理嵌套菜单和菜单项组。通过将菜单和项放在相同的结构中,我们创建了一个"整体/部分"层次结构,即由菜单和菜单项组成的对象树;但是可以将它视为一个整体,像是一个丰富的大菜单;
-
| |
- 使用组合结构,能把相同的操作应用在组合和个别对象上。换句话说,在大多数情况下,可以忽略对象组合和个别对象之间的差别;
-
类图
-
说明:组合模式和迭代器模式有什么关系
- 两者合作无间,可以在组合的实现中使用迭代器,而且做法还不只一种;
-
利用组合设计菜单
- 我们需要创建一个组件接口来作为菜单和菜单项的共同接口,这样就可以针对菜单或菜单项调用相同的方法;
-
如何让菜单符合组合模式的结构呢?
-
重新设计菜单
-
实现菜单组件
- 所有的组件都必须实现MenuComponent接口。然而,叶节点和组合节点的角色不同,所以有些方法可能并不适合某些节点。面对这种情况,有时候,最好是抛出运行时异常;
-
public abstract class 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(); }
//以下是"操作"方法,它们被菜单项使用,其中有一些也也可用在菜单上,再过几页你就会在菜单代码中看到了 public String getName(){ throw new UnsupportedOperationException(); } public String getDescription(){ throw new UnsupportedOperationException(); } public double getPrice(){ throw new UnsupportedOperationException(); } public boolean isVegetarian(){ throw new UnsupportedOperationException(); } //该方法是一个"操作"方法,这个方法同时被菜单和菜单项所实现,和但我们还是在这里提供了默认的操作 public void print(){ throw new UnsupportedOperationException(); } } |
- 实现菜单项
//扩展MenuComponent接口 public class MenuItem extends MenuComponent{ 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 boolean isVegetarian() { return vegetarian; } public double getPrice() { return price; } //在MenuComponent类里覆盖print()方法。对菜单项来说,此方法会打印出完整的菜单项条目,包括:名字,描述,价格以及是否为素食 public void print(){ System.out.print(" "+getName()); if(isVegetarian()){ System.out.print("(v)"); } System.out.println(", "+getPrice()); System.out.println(" --"+getDescription()); } } |
-
实现组合菜单
- 现在已经有了菜单项,还需要组合类,也就是菜单咯。这个组合类可以持有菜单项或者其他菜单。有一些方法并未在MenuComopnent类中实现,比如getPrice()和isVegertarian(),因为这些方法对菜单而言没有太大的意义;
//菜单和菜单项一样,都是MenuComponent public class Menu extends MenuComponent { //菜单可以有任意数目的孩子,这些孩子都必须属于MenuComponent类型,我们使用内部的ArrayList记录它们 ArrayList menuComponents = new ArrayList(); String name; String description; //这和之前的实现不一样,我们将给每个菜单一个名字和一个描述。以前,每个菜单的类名称就是此菜单的名字 public Menu(String name,String description){ this.name = name; this.description = description; } //在这里将菜单项和其他菜单加入到菜单中。因为菜单和菜单项都是MenuComponent,所以我们只需用一个方法就可以两者兼顾 //同样的道理,也可以删除或者取得某个MenuComponent public void add(MenuComponent menuComponent){ menuComponents.add(menuComponent); } public void remove(MenuComponent menuComponent){ menuComponents.remove(menuComponent); } public MenuComponent getChile(int i){ return (MenuComponent) menuComponents.get(i); } //这是用来取得名字和描述的getter方法 //这里没有覆盖getPrice()或isVegetarian(),因为这些方法对Menu来说没有意义。如果尝试调用这些方法的话,就会得到父类定义的异常 public String getName(){ return name; } public String getDescription(){ return description; } //打印菜单的名称和描述,还要打印出菜单内所有组件的内容:其他菜单和菜单项 public void print(){ System.out.print("\n"+getName()); System.out.println(", "+getDescription()); System.out.println("----------------------"); //便利过程中,可能遇到其他菜单,或老是遇到菜单项。由于菜单和菜单项都实现了print(),那我们只要调用print()即可。 Iterator iterator = menuComponents.iterator(); while(iterator.hasNext()){ MenuComponent menuComponent = (MenuComponent) iterator.next(); menuComponent.print(); } //在遍历期间,如果遇到另一个菜单对象,它的print()方法会开始另一个遍历,依次类推 } } |
-
测试前的准备工作
- 更新女招待的代码--她可是菜单的主要客户:
public class Waitress { MenuComponent allMenus; //只需要将最顶层的菜单组件交给她就可以了,最顶层菜单包含其他所有菜单,可以称为allMenus public Waitress(MenuComponent allMenus){ this.allMenus = allMenus; } public void printMenu(){ //只需要调用最顶层菜单的print(),就可以打印整个菜单层次,包括所有菜单及所有菜单项 allMenus.print(); } } |
-
运行时菜单的组合:
- 编写测试代码
public class MenuTestDrive { public static void main(String[] args) { //先创建所有的菜单对象 MenuComponent pancakeHouseMenu = new Menu("PANCAKE HOUSE MENU","Breakfase"); MenuComponent dinerMenu = new Menu("DINER MENU","Lunch"); MenuComponent cafeMenu = new Menu("CAFE MENU","Dinner"); MenuComponent dessertMenu = new Menu("DESSERT MENU","Dessert of course!"); //需要一个最顶层的菜单,将它称为allMenus MenuComponent allMenus = new Menu("ALL MENUS","All menus combined"); //使用组合的add()方法,将每个菜单都加入到顶层菜单allMenus中 allMenus.add(pancakeHouseMenu); allMenus.add(dinerMenu); allMenus.add(cafeMenu); pancakeHouseMenu.add(new MenuItem("K&B`s Pancake Breakfast" ,"Pancakes with scrambled eggs,and toast" ,true ,2.99)); pancakeHouseMenu.add(new MenuItem("Regular Pancake Breakfase" ,"Pancakes with fried 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 blueberries or strawberries" ,true ,3.59)); //在这里加入菜单项 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)); dinerMenu.add(new MenuItem("Steamed Veggies and Brown Rice" ,"Steamed Vegetabls over brown rice" ,true ,3.99)); dinerMenu.add(new MenuItem("Pasta" ,"Spagheti with Marinara Sauce,and a slice of sourdough bread" ,true ,3.89)); //在菜单中加入另一个菜单。由于菜单和菜单项都是MenuComponent,所以菜单可以顺利地被被加入 dinerMenu.add(dessertMenu); 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)); cafeMenu.add(new MenuItem("Veggie Burger and Air Fries" ,"Veggie burger on a whole wheat bun,lettuce,tomato,and fries Soup of the day" ,true ,3.99)); cafeMenu.add(new MenuItem("Soup of the day" ,"A cup of the soup of the day,with a side salad" ,true ,3.69)); cafeMenu.add(new MenuItem("Burrito" ,"A large burrito,with whole pinto beans,salsa,guacamole" ,true ,4.29)); //将整个菜单层级构造完毕,把它整个交给女招待 Waitress waitress = new Waitress(allMenus); waitress.printMenu(); } } |
效果: ALL MENUS, All menus combined ----------------------
PANCAKE HOUSE MENU, Breakfase ---------------------- K&B`s Pancake Breakfast(v), 2.99 --Pancakes with scrambled eggs,and toast Regular Pancake Breakfase, 2.99 --Pancakes with fried eggs,sausage Blueberry Pancakes(v), 3.49 --Pancakes made with fresh blueberries Waffles(v), 3.59 --Waffles,with your choice of blueberries or strawberries
DINER MENU, Lunch ---------------------- Vegetarian BLT(v), 2.99 --(Fakin') Bacon with lettuce & tomato on whole wheat BLT, 2.99 --Bacon with lettuce & tomato on whole wheat Soup of the day, 3.29 --Soup of the day,with a side of potato salad Hotdog, 3.05 --A hot dog,with saurkraut,relish,onions,topped with cheese Steamed Veggies and Brown Rice(v), 3.99 --Steamed Vegetabls over brown rice Pasta(v), 3.89 --Spagheti with Marinara Sauce,and a slice of sourdough bread
DESSERT MENU, Dessert of course! ---------------------- Apple Pie(v), 1.59 --Apple pie with a flakey crust,topped with vanilla ice cream Cheesecake(v), 1.99 --Creamy New York cheesecake,with a chocolate graham crust Sorbet(v), 1.89 --A scoop of raspberry and a scoop of lime
CAFE MENU, Dinner ---------------------- Veggie Burger and Air Fries(v), 3.99 --Veggie burger on a whole wheat bun,lettuce,tomato,and fries Soup of the day Soup of the day(v), 3.69 --A cup of the soup of the day,with a side salad Burrito(v), 4.29 --A large burrito,with whole pinto beans,salsa,guacamole |
-
问题:一个类,不是应该只有一个职责吗?现在组合模式是让一个类有两个责任的模式。它现在要管理层次结构,还要执行菜单的操作。
- 组合模式以单一责任设计原则换取透明性。通过让组件的接口同时包含一些管理子节点和叶节点的操作,客户就可以将组合的叶节点一视同仁。也就是说,一个元素究竟是组合还是叶节点,对客户是透明的。
- 在MenuComponent类中,同时具有两种类型的操作。因为客户有机会对一个元素做一些没有意义的操作(例如试图把菜单添加到菜单项),所以失去了一些"安全性";这是设计上的决策;当然也可以采用另一种方向的设计,将责任区分开来放在不同的接口中。这么一来,设计上就比较安全,但也因此失去了透明性,客户的代码将必须用条件语句和instanceof操作fu
- 综上,有时候我们会故意做一些看似违反设计原则的事情,这是一个典型的折衷做法;
-
组合模式中使用迭代器
- 在上例中print()方法中,已经出现了迭代器。
- 如果需要,可以让女招待实现迭代器遍历整个组合。比如,女招待可能想要游走整个菜单,挑出素食项;
- 想要实现一个组合迭代器,要为每个组件都加上createIterator()方法,从抽象的MenuComponent类下手:
|
public abstract class MenuComponent { //其余代码不变 public Iterator createIterator(){ throw new UnsupportedOperationException(); } } |
- 在菜单和菜单项类中实现这个方法:
public class Menu extends MenuComponent { //其余代码不变 public Iterator createIterator(){ //这里使用新的,被称为CompositeIterator的迭代器。这个迭代器知道如何遍历任何组合 //将目前组合的迭代器传入它的构造函数 return new CompositeIterator(menuComponents.iterator()); } } |
public class MenuItem extends MenuComponent{ //其余代码不变 public Iterator createIterator(){ //这里的空迭代器需要延展出来说说 return new NullIterator(); } } |
-
空迭代器:这可是空对象"设计模式"的另一个例子
-
菜单项既然没有可遍历的,那么我们要如何实现createIterator()方法呢?两种选择:
- 返回null:可以让createIterator()方法返回null,但是如果这么做,我们的客户代码就需要条件判断语句来判断返回值是否为null;
- 返回一个迭代器,而这个迭代器的hasNext()永远返回false:这样客户不需要担心返回值是null,我们等于创建一个"没有任何意义"的迭代器;
- 显然第二个选择会好一点,来看看它的代码:
-
//一个什么都不做的迭代器 public class NullIterator implements Iterator { @Override public boolean hasNext() { //最重要的,当hasNext()被调用时,永远返回false return false; } @Override public Object next() { return null; } @Override public void remove() { throw new UnsupportedOperationException(); } } |
-
OK,了解了空迭代器,来看看组合迭代器
- CompositeIterator的工作是遍历组件内的菜单项,而且确保所有的子菜单(以及子子菜单....)都被包括进来。
import java.util.Iterator; import java.util.Stack; //实现java.util.Iterator接口 public class CompositeIterator implements Iterator { Stack stack = new Stack(); //将要遍历的顶层组合的迭代器传入。放入一个堆栈数据结构中 public CompositeIterator(Iterator iterator){ stack.push(iterator); } @Override public Object next() { //当客户想要取得下一个元素的时候,我们先调用hasNext()来确定是否还有下一个 if(hasNext()){ Iterator iterator = (Iterator) stack.peek(); //如果还有下一个元素,就从堆栈中取出目前的迭代器,然后取得它的下一个元素 MenuComponent component = (MenuComponent)iterator.next(); if(component instanceof Menu){ //如果元素是一个菜单,我们有了另一个需要被包含进遍历中的组合,所以我们将它丢进堆栈中,不管是不是菜单,我们都返回该组件 stack.push(component.createIterator()); } return component; }else{ return null; } } @Override public boolean hasNext() { if(stack.empty()){ //想要知道是否还有下一个元素,我们检查堆栈是否被清空;如果已经空了,就表示没有下一个元素了 return false; }else{ //否则,我们就从堆栈的顶层中取出迭代器,看看是否还有下一个元素。如果它没有元素,我们将它弹出堆栈,然后递归地调用hasNext() Iterator iterator = (Iterator) stack.peek(); if(!iterator.hasNext()){ stack.pop(); return hasNext(); }else{ //否则,表示还有下一个元素,返回true return true; } } } @Override public void remove() { //不支持删除,这里只有遍历 throw new UnsupportedOperationException(); } } |
- 让女招待告诉我们哪些菜是素食
public class Waitress { MenuComponent allMenus; //只需要将最顶层的菜单组件交给她就可以了,最顶层菜单包含其他所有菜单,可以称为allMenus public Waitress(MenuComponent allMenus){ this.allMenus = allMenus; } public void printMenu(){ //只需要调用最顶层菜单的print(),就可以打印整个菜单层次,包括所有菜单及所有菜单项 allMenus.print(); } //该方法取得allMenus的组合并得到它的迭代器来作为我们的CompositeIterator public void printVegetarianMenu(){ Iterator iterator = allMenus.createIterator(); System.out.println("\nVEGETARIAN MENU\n----"); //遍历组合内的每个元素 while(iterator.hasNext()){ MenuComponent menuComponent = (MenuComponent)iterator.next(); try{ //调用每个元素的isVegetarian()方法,如果为true,就调用它的print()方法 if(menuComponent.isVegetarian()){ menuComponent.print(); } //在菜单上实现isVegetarian()方法,让它永远抛出异常。如果异常果真发生了,就捕捉这个异常,然后继续遍历 }catch(UnsupportedOperationException e){} } } } |
测试这个方法: Waitress waitress = new Waitress(allMenus); // waitress.printMenu(); waitress.printVegetarianMenu(); |
效果: VEGETARIAN MENU ---- K&B`s Pancake Breakfast(v), 2.99 --Pancakes with scrambled eggs,and toast Blueberry Pancakes(v), 3.49 --Pancakes made with fresh blueberries Waffles(v), 3.59 --Waffles,with your choice of blueberries or strawberries Vegetarian BLT(v), 2.99 --(Fakin') Bacon with lettuce & tomato on whole wheat Steamed Veggies and Brown Rice(v), 3.99 --Steamed Vegetabls over brown rice Pasta(v), 3.89 --Spagheti with Marinara Sauce,and a slice of sourdough bread Apple Pie(v), 1.59 --Apple pie with a flakey crust,topped with vanilla ice cream Cheesecake(v), 1.99 --Creamy New York cheesecake,with a chocolate graham crust Sorbet(v), 1.89 --A scoop of raspberry and a scoop of lime Apple Pie(v), 1.59 --Apple pie with a flakey crust,topped with vanilla ice cream Cheesecake(v), 1.99 --Creamy New York cheesecake,with a chocolate graham crust Sorbet(v), 1.89 --A scoop of raspberry and a scoop of lime Veggie Burger and Air Fries(v), 3.99 --Veggie burger on a whole wheat bun,lettuce,tomato,and fries Soup of the day Soup of the day(v), 3.69 --A cup of the soup of the day,with a side salad Burrito(v), 4.29 --A large burrito,with whole pinto beans,salsa,guacamole |
-
关于printVegetarianMenu()方法内的try/catch
- try/catch是一种错误处理的方法,而不是程序逻辑的方法。如果这样做,而是在调用isVegetarian()方法之前,用instanceof来检查菜单组件的运行时类型,来确定它是菜单项。但是这样做,就会因为无法统一处理菜单和菜单项而失去透明性;
- 也可以改写Menu的isVegetarian()方法,让它返回false。这提供了一个简单的解决方案,同时也保持了透明性;
- 之所以使用try/catch,是为了传达:isVegetarian()是Menu没有支持的操作。这样的做法也允许后来人去为Menu实现一个合理的isVegetarian()方法,而我们不必为此再修改这里的代码了;
-
模式对比