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電子書,資料,幫忙內推,歡迎拍磚!