优雅的处理树状结构——组合模式总结
1、前言
本模式经 遍历“容器”的优雅方法——总结迭代器模式 引出,继续看最后的子菜单的案例
2、组合模式的概念
组合模式,也叫 Composite 模式……是构造型的设计模式之一。
组合模式允许对象组合成树形结构,来表现“整体/部分”的层次结构,使得客户端对单个对象和组合对象的使用具有一致性。
Composite Pattern
Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
有一些拗口,通俗的说:组合模式是关于怎样将对象形成树形结构来表现整体和部分的层次结构的成熟模式。
使用组合模式,可以让用户以一致的方式处理个体对象和组合对象,组合模式的关键在于无论是个体对象还是组合对象都实现了相同的接口或都是同一个抽象类的子类。
即,组合模式,它能通过递归来构造树形的对象结构,并可以通过一个对象来访问整个对象树。
即,组合模式,在大多数情况下,可以让客户端忽略对象个体和对象组合之间的差异。
2.1、组合模式的角色和类图
结合数据结构里的树,其实很好写出来。无非就是叶子和非叶子节点的的组合。
1、需要一个类为叶子节点和非叶子节点的共同抽象父类,如图里的 Component 接口(抽象类也可以),是树形结构的节点的抽象:
-
为所有的对象,包括叶子节点,定义统一的接口(公共属性,行为等的定义)
-
提供管理子节点对象的接口方法
-
[可选]提供管理父节点对象的接口方法
2、设计一个 Leaf 类代表树的叶节点,这个要单独拿出来区分,是 Component 的实现子类
3、设计一个 Composite 类作为树枝节点,即非叶节点,也是 Component 的实现子类
4、client 客户端,它使用 Component 接口操作树
2.2、组合(Composite)、组件(Component接口)、和树的关系
在该模式里熟悉一些定义,其实没必要死记硬背,定义随便起名字,只要能自洽即可。
1、组合(Composite)包含了组件(Component)
2、组件 Component 接口 = 组合Composite + 叶节点Leaf,因为组件是抽象的,叶子和枝节点(组合)是组件的具体表现,很好理解。
其实就是递归,得到的是由上而下的树形结构,根部是一个组合Composite,而组合的分支延伸展开(组合包含了组件),直至叶子节点leaf为止。
3、基于组合模式改进迭代器模式里的菜单系统
如菜单子系统的实现,就是典型的树状结构
需要一个抽象组件 Component,例子里是 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(); } }
下面编写叶子节点——菜单的菜单项类。
这是组合模式类图里的叶子角色,它只负责实现组合的内部元素的行为,因此宏观上管理整个菜单的方法,比如 add 、remove 等,它不应该复写,对她没有意义。
/** * 叶子节点,代表菜单里的一项 * 只复写对其有意义的方法,没有意义的方法,比如获得子节点等,就不理会即可 */ public class MenuItem extends MenuComponent { private String name; private String description; private boolean vegetarian; private double price; public MenuItem(String name, String description, boolean vegetarian, double price) { this.name = name; this.description = description; this.vegetarian = vegetarian; this.price = price; } @Override public String getName() { return name; } @Override public String getDescription() { return description; } @Override public double getPrice() { return price; } @Override public boolean isVegetarian() { return vegetarian; } @Override public void print() { System.out.print(" " + getName()); if (isVegetarian()) { System.out.print("(v)"); } System.out.println(", " + getPrice()); System.out.println(" -- " + getDescription()); } }
下面,编写树枝节点——菜单,也就是组合类。
之前的菜单项是的单个的组件类,而组合类才体现了递归思想,组合类聚合了组件类。一些对其没有意义的方法,同样不需要复写实现。
菜单也可以有子菜单(菜单项其实本质也可以是子菜单),所以组合了一个 Arraylist<MenuComponent>,因为菜单和菜单项都属于 MenuComponent,那么使用同样的方法,可以兼顾两者,这正应了组合模式的意义——使用组合模式,可以让用户以一致的方式处理个体对象和组合对象,组合模式的关键在于无论是个体对象还是组合对象都实现了相同的接口或都是同一个抽象类的子类。
/** * 树枝节点,也就是组合节点——代表各个菜单 */ public class Menu extends MenuComponent { private String name; private String description; /** * 依赖了菜单组件,递归的实现 */ private List<MenuComponent> menuComponents = new ArrayList<>(); public Menu(String name, String description) { this.name = name; this.description = description; } @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); } @Override public String getName() { return name; } @Override public String getDescription() { return description; } /** * 因为菜单作为树枝节点,它是一个组合,包含了菜单项和其他的子菜单,所以 print()应该打印出它包含的一切。 */ @Override public void print() { System.out.print("\n" + getName()); System.out.println(", " + getDescription()); System.out.println("---------------------"); // 使用了迭代器(迭代器模式和组合模式的有机结合),遍历菜单的菜单项 Iterator iterator = menuComponents.iterator(); while (iterator.hasNext()) { // 打印这个节点包含的一切,print 可以兼顾两类节点,这是组合模式的特点 MenuComponent menuComponent = (MenuComponent) iterator.next(); menuComponent.print(); // 递归思想的应用 } } }
因为菜单是一个组合,包含了菜单项和其他的子菜单,所以它的print()应该打印出它包含的一切,此时递归思想派上了用场。
下面编写客户端——服务员类
/** * 客户端,也就是服务员类,聚合了菜单组件接口(这里是抽象类)控制菜单,解耦合 */ public class Waitress { /** * 聚合了菜单组件——这一抽象节点,能兼顾叶子节点和树枝节点 */ private MenuComponent allMenus; public Waitress(MenuComponent allMenus) { this.allMenus = allMenus; } public void printMenu() { allMenus.print(); } }
客户端类代码很简单,只需要聚合一个顶层的组件接口即可。最顶层的菜单组件可以兼顾所有菜单或者菜单项,故客户端只需要调用一次最顶层的print方法,即可打印整个菜单系统。
整体结构如下图:
下面创建菜单
public class MenuTestDrive { public static void main(String args[]) { // 创建所有的菜单系统,它们本质上都是组合节点——MenuComponent MenuComponent pancakeHouseMenu = new Menu("PANCAKE HOUSE MENU", "Breakfast"); MenuComponent dinerMenu = new Menu("DINER MENU", "Lunch"); MenuComponent cafeMenu = new Menu("CAFE MENU", "Dinner"); MenuComponent dessertMenu = new Menu("DESSERT MENU", "Dessert of course!"); // 创建顶级root节点——allMenus,代表整个菜单系统 MenuComponent allMenus = new Menu("ALL MENUS", "All menus combined"); allMenus.add(pancakeHouseMenu); // 把每个菜单系统,组合到root节点,当做树枝节点 allMenus.add(dinerMenu); allMenus.add(cafeMenu); // 为煎饼屋的菜单系统,增加菜单项 pancakeHouseMenu.add(new MenuItem( "K&B's Pancake Breakfast", "Pancakes with scrambled eggs, and toast"));// 为餐厅的菜单系统,增加菜单项 dinerMenu.add(new MenuItem("Vegetarian BLT", "(Fakin') Bacon with lettuce & tomato on whole wheat")); dinerMenu.add(new MenuItem("BLT", "Bacon with lettuce & tomato on whole wheat")); // 为餐厅的菜单系统,增加子菜单——这个其实也是菜单项,但是,是树枝,这是一个饭后甜点子菜单 dinerMenu.add(dessertMenu); // 为饭后甜点菜单系统,增加菜单项 dessertMenu.add(new MenuItem("Apple Pie", "Apple pie with a flakey crust, topped with vanilla icecream")); dessertMenu.add(new MenuItem("Cheesecake", "Creamy New York cheesecake, with a chocolate graham crust")); // 为咖啡厅菜单系统,增加菜单项 cafeMenu.add(new MenuItem( "Veggie Burger and Air Fries", "Veggie burger on a whole wheat bun, lettuce, tomato, and fries")); // 把整个菜单传给客户端 Waitress waitress = new Waitress(allMenus); waitress.printMenu(); } }
4、单一职责和组合模式的矛盾
这是一个很典型的折中设计问题:有时候会故意违反一些设计原则,去实现一些特殊需求。还是那句话,学习设计模式不要死记硬背,最后还是要遵循具体的技术条件和服务于特定的业务场景。
回顾案例发现:组合模式不但要管理整个菜单——这个树状层次结构,还要执行菜单的一些具体操作动作。明显的,违反了单一职责原则,可以这么说:组合模式牺牲了单一职责的设计原则,换取了程序的透明性(transparency)——通过让组件的接口同时包含一些树枝子节点(组合节点)和叶子子节点的操作,客户就可以将组合节点和叶子节点一视同仁,而一个元素究竟是组合节点还是叶子节点对客户都是透明的。
如果不让组件接口同时具备多种类型节点的操作,虽然设计上安全,职责也分开,但是失去了透明性,即客户端必须显示的使用条件(一般用 instanceOf )来判断节点类型
5、迭代器模式 + 组合模式来实现分担部分责任
可让客户端使用迭代器模式去遍历整个菜单系统,比方说,女招待可能想要游走整个菜单,只打印 / 挑选素食的菜单项。
想要实现一个组合模式+迭代器模型的菜单系统,可以为每个组件都加上 createIterator() 方法。
import java.util.Iterator; /** * 先从抽象的组件节点入手,加上迭代器 */ 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(); } // 加上迭代器,这里直接使用 JDK 的迭代器 public abstract Iterator createIterator(); public void print() { throw new UnsupportedOperationException(); } }
同样的套路,编写叶子节点和树枝节点,继承这个抽象类
public class Menu extends MenuComponent { private List<MenuComponent> menuComponents = new ArrayList<>(); private String name; private String description; public Menu(String name, String description) { this.name = name; this.description = description; } @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); } @Override public String getName() { return name; } @Override public String getDescription() { return description; } @Override public Iterator createIterator() { return new CompositeIterator(menuComponents.iterator()); } @Override public void print() { Iterator iterator = menuComponents.iterator(); while (iterator.hasNext()) { MenuComponent menuComponent = (MenuComponent) iterator.next(); menuComponent.print(); } } } ////////////////////// import java.util.Iterator; public class MenuItem extends MenuComponent { private String name; private String description; private boolean vegetarian; private double price; public MenuItem(String name, String description, boolean vegetarian, double price) { this.name = name; this.description = description; this.vegetarian = vegetarian; this.price = price; } @Override public String getName() { return name; } @Override public String getDescription() { return description; } @Override public double getPrice() { return price; } @Override public boolean isVegetarian() { return vegetarian; } @Override public Iterator createIterator() { return new NullIterator(); } @Override public void print() { System.out.print(" " + getName()); if (isVegetarian()) { System.out.print("(vegetable)"); } System.out.println(", " + getPrice()); System.out.println(" -- " + getDescription()); } }
发现了两个新东西,一个是 NullIterator() 和 CompositeIterator(),尤其是后者,使用了递归思想。
回忆:在写 MenuComponent 类的 print 方法时,利用了一个迭代器遍历组件内的每个项,如果遇到的是菜单,就会递归地调度 print 方法处理它,换句话说,MenuComponent 是在“内部”自行处理遍历——内部迭代器模式。
但是在如下的 CompositeIterator 中,实现的是一个“外部”的迭代器,所以有许多需要追踪的事情。外部迭代器必须维护它在遍历中的位置,以便外部可以通过 hasNext 和 next 来驱动遍历。在 CompositeIterator 中,必须维护组合递归结构的位置,这也是为什么在组合层次结构中上上下下时,使用堆栈 JDK 的 Stack 来维护游标的位置。
import java.util.Iterator; import java.util.Stack; /** * 自定义组合模式的组合节点的专属迭代器 CompositeIterator */ public class CompositeIterator implements Iterator { private Stack<Iterator> stack = new Stack<>(); // 把要遍历的 Menu 组合的迭代器 iterator 传入,menuComponents.iterator() 被传入一个 stack 中保存位置 public CompositeIterator(Iterator iterator) { stack.push(iterator); } // 当客户端需要取得下一个元素的时候,先判断是否存在下一个元素 @Override public Object next() { if (hasNext()) { Iterator iterator = stack.peek(); // 仅查看当前的栈顶元素——迭代器,不出栈 MenuComponent component = (MenuComponent) iterator.next(); // 使用该栈顶的迭代器,取出要遍历的组合的元素 if (component instanceof Menu) { // 如果取出的元素仍然是菜单,那需要继续遍历它,故要记录它的位置,把它的迭代器取出来 // 调用 component.createIterator() 返回 CompositeIterator,这个 CompositeIterator 仍然包含一个自己的 stack,继续存入栈中 stack.push(component.createIterator()); } return component; } else { return null; } } @Override public boolean hasNext() { if (stack.empty()) { // 如果栈是空,直接返回 false return false; } else { Iterator iterator = stack.peek(); // 仅查看当前的栈顶元素——迭代器,不出栈 // 判断当前的顶层元素是否还有下一个元素,如果栈空了,就说明当前顶层元素没有下一个元素,返回 false,此处判断为 true if (!iterator.hasNext()) { stack.pop(); // 如果当前栈顶元素,没有下一个元素了,就把当前栈顶元素出栈,递归的继续判断下一个元素 return hasNext(); } else { // 否则表示还有下一个元素,直接返回 true return true; } } } @Override public void remove() { throw new UnsupportedOperationException(); } }
通过测试,来观察上述代码的执行过程:
public class TestCompositeStack { public static void main(String[] args) { MenuComponent pancakeHouseMenu = new Menu("PANCAKE HOUSE MENU", "Breakfast"); // 创建顶级root节点——allMenus,代表整个菜单系统 MenuComponent allMenus = new Menu("ALL MENUS", "All menus combined"); allMenus.add(pancakeHouseMenu); // 把菜单系统,组合到root节点,当做树枝节点 // 为煎饼小屋的菜单系统,增加菜单项 pancakeHouseMenu.add(new MenuItem( "K&B's Pancake Breakfast", "Pancakes with scrambled eggs, and toast")); pancakeHouseMenu.add(new MenuItem( "Regular Pancake Breakfast", "Pancakes with fried eggs, sausage")); testStack(allMenus); } public static void testStack(MenuComponent menuComponent) { CompositeIterator compositeIterator = new CompositeIterator(menuComponent.createIterator()); while (compositeIterator.hasNext()) { MenuComponent menuComponent1 = (MenuComponent) compositeIterator.next(); } } }
5.1、空迭代器
如果菜单项没什么可以遍历的,比如叶子节点,那么一般要给其遍历方法:
1、返回 null。可以让 createIterator() 方法返回 null,但是如果这么做,客户端的代码就需要条件语句来判断返回值是否为 null,不太好;
2、返回一个迭代器,而这个迭代器的 hasNext() 永远返回 false。这个是更好的方案,客户端不用再担心返回值是否为 null。等于创建了一个迭代器,其作用是“没作用”。
import java.util.Iterator; /** * 自定义组合模式的叶子节点的专属迭代器 */ public class NullIterator implements Iterator { @Override public Object next() { return null; } @Override public boolean hasNext() { return false; } @Override public void remove() { throw new UnsupportedOperationException(); } }
客户端代码:
import java.util.Iterator; public class Waitress { private MenuComponent allMenus; public Waitress(MenuComponent allMenus) { this.allMenus = allMenus; } public void printMenu() { allMenus.print(); } public void printVegetarianMenu() { Iterator iterator = allMenus.createIterator();while (iterator.hasNext()) { MenuComponent menuComponent = (MenuComponent) iterator.next(); try { if (menuComponent.isVegetarian()) { menuComponent.print(); } } catch (UnsupportedOperationException ignored) { } } } }
6、组合模式和缓存
有时候,如果组合的结构非常复杂,或者遍历的代价很大,那么可以为组合节点实现一个缓存,如果业务需求是需要不断的遍历一个组合结构,那么可以把遍历的节点存入缓存,省去每次都递归遍历的开支。
7、组合模式的优点
组合模式包含有个体对象和组合对象,并形成树形结构,使用户可以方便地处理个体对象和组合对象。
1、组合对象和个体对象实现了相同的接口,用户一般不需区分个体对象和组合对象。
2、当增加新的Composite节点和Leaf节点时,用户的重要代码不需要作出修改。
8、其他案例——文件系统也是典型的树状结构系统
下面使用接口来基于组合模式,实现简单的文件系统
import java.util.List;
/*
* 文件节点抽象(是文件和目录的父类)
*/
public interface IFile {
//显示文件或者文件夹的名称
public void display();
public boolean add(IFile file);
public boolean remove(IFile file);
//获得子节点
public List<IFile> getChild();
}
/////////////////////////// 文件节点
import java.util.List;
public class File implements IFile {
private String name;
public File(String name) {
this.name = name;
}
public void display() {
System.out.println(name);
}
public List<IFile> getChild() {
return null;
}
public boolean add(IFile file) {
return false;
}
public boolean remove(IFile file) {
return false;
}
}
//////////////////// 目录节点
import java.util.ArrayList;
import java.util.List;
public class Folder implements IFile{
private String name;
private List<IFile> children; // 聚合了文件抽象节点
public Folder(String name) {
this.name = name;
children = new ArrayList<IFile>();
}
public void display() {
System.out.println(name);
}
public List<IFile> getChild() {
return children;
}
public boolean add(IFile file) {
return children.add(file);
}
public boolean remove(IFile file) {
return children.remove(file);
}
}
////////////////////客户端
import java.util.List;
public class MainClass {
public static void main(String[] args) {
IFile rootFolder = new Folder("C:");
IFile dashuaiFolder = new Folder("dashuai");
IFile dashuaiFile = new File("dashuai.txt");
rootFolder.add(dashuaiFolder);
rootFolder.add(dashuaiFile);
IFile aFolder = new Folder("aFolder");
IFile aFile = new File("aFile.txt");
dashuaiFolder.add(aFolder);
dashuaiFolder.add(aFile);
displayTree(rootFolder, 0);
}
// 层序遍历树
private static void displayTree(IFile rootFolder, int deep) {
for(int i = 0; i < deep; i++) {
System.out.print("--");
}
//显示自身的名称
rootFolder.display();
//获得子树
List<IFile> children = rootFolder.getChild();
//遍历子树
for(IFile file : children) {
if(file instanceof File) {
for(int i = 0; i <= deep; i++) {
System.out.print("--");
}
file.display();
} else {
displayTree(file, deep + 1);
}
}
}
}
欢迎关注
dashuai的博客是终身学习践行者,大厂程序员,且专注于工作经验、学习笔记的分享和日常吐槽,包括但不限于互联网行业,附带分享一些PDF电子书,资料,帮忙内推,欢迎拍砖!