本文的概念性內容來自深入淺出設計模式一書.
本文需結合上一篇文章(使用C# (.NET Core) 實現迭代器設計模式)一起看.
上一篇文章我們研究了多個選單一起使用的問題.
需求變更
就當我們感覺我們的設計已經足夠好的時候, 新的需求來了, 我們不僅要支援多種選單, 還要支援選單下可以擁有子選單.
例如我想在DinerMenu下新增一個甜點子選單(dessert menu). 以我們目前的設計, 貌似無法實現該需求.
目前我們無法把dessertmenu放到MenuItem的陣列裡.
我們應該怎麼做?
- 我們需要一種類似樹形的結構, 讓其可以容納/適應選單, 子選單以及選單項.
- 我們還需要維護一種可以在該結構下遍歷所有選單的方法, 要和使用遍歷器一樣簡單.
- 遍歷條目的方法需要更靈活, 例如, 我可能只遍歷DinerMenu下的甜點選單(dessert menu), 或者遍歷整個Diner Menu, 包括甜點選單.
組合模式定義
組合模式允許你把物件們組合成樹形的結構, 從而來表示整體的層次. 通過組合, 客戶可以對單個物件或物件們的組合進行一致的處理.
先看一下樹形的結構, 擁有子元素的元素叫做節點(node), 沒有子元素的元素叫做葉子(leaf).
針對我們的需求:
選單Menu就是節點, 選單項MenuItem就是葉子.
針對需求我們可以建立出一種樹形結構, 它可以把巢狀的選單或選單項在相同的結構下進行處理.
組合和單個物件是指什麼呢?
如果我們擁有一個樹形結構的選單, 子選單, 或者子選單和選單項一起, 那麼就可以說任何一個選單都是一個組合, 因為它可以包含其它選單或選單項.
而單獨的物件就是選單項, 它們不包含其它物件.
使用組合模式, 我們可以把相同的操作作用於組合或者單個物件上. 也就是說, 大多數情況下我們可以忽略物件們的組合與單個物件之間的差別.
該模式的類圖:
客戶Client, 使用Component來操作組合中的物件.
Component定義了所有物件的介面, 包括組合節點與葉子. Component介面也可能實現了一些預設的操作, 這裡就是add, remove, getChild.
葉子Leaf會繼承Component的預設操作, 但是有些操作也許並不適合葉子, 這個過會再說.
葉子Leaf沒有子節點.
組合Composite需要為擁有子節點的元件定義行為. 同樣還實現了葉子相關的操作, 其中有些操作可能不適合組合, 這種情況下異常可能會發生.
使用組合模式來設計選單
首先, 需要建立一個component介面, 它作為選單和選單項的共同介面, 這樣就可以在選單或選單項上呼叫同樣的方法了.
由於選單和選單項必須實現同一個介面, 但是畢竟它們的角色還是不同的, 所以並不是每一個介面裡(抽象類裡)的預設實現方法對它們都有意義. 針對毫無意義的預設方法, 有時最好的辦法是丟擲一個執行時異常. 例如(NotSupportedException, C#).
MenuComponent:
using System; namespace CompositePattern.Abstractions { public abstract class MenuComponent { public virtual void Add(MenuComponent menuComponent) { throw new NotSupportedException(); } public virtual void Remove(MenuComponent menuComponent) { throw new NotSupportedException(); } public virtual MenuComponent GetChild(int i) { throw new NotSupportedException(); } public virtual string Name => throw new NotSupportedException(); public virtual string Description => throw new NotSupportedException(); public virtual double Price => throw new NotSupportedException(); public virtual bool IsVegetarian => throw new NotSupportedException(); public virtual void Print() { throw new NotSupportedException(); } } }
MenuItem:
using System; using CompositePattern.Abstractions; namespace CompositePattern.Menus { public class MenuItem : MenuComponent { public MenuItem(string name, string description, double price, bool isVegetarian) { Name = name; Description = description; Price = price; IsVegetarian = isVegetarian; } public override string Name { get; } public override string Description { get; } public override double Price { get; } public override bool IsVegetarian { get; } public override void Print() { Console.Write($" {Name}"); if (IsVegetarian) { Console.Write("(v)"); } Console.WriteLine($", {Price}"); Console.WriteLine($" -- {Description}"); } } }
Menu:
using System; using System.Collections.Generic; using CompositePattern.Abstractions; namespace CompositePattern.Menus { public class Menu : MenuComponent { readonly List<MenuComponent> _menuComponents; public Menu(string name, string description) { Name = name; Description = description; _menuComponents = new List<MenuComponent>(); } public override string Name { get; } public override string Description { get; } public override void Add(MenuComponent menuComponent) { _menuComponents.Add(menuComponent); } public override void Remove(MenuComponent menuComponent) { _menuComponents.Remove(menuComponent); } public override MenuComponent GetChild(int i) { return _menuComponents[i]; } public override void Print() { Console.Write($" {Name}"); Console.WriteLine($", {Description}"); Console.WriteLine("------------------------------"); } } }
注意Menu和MenuItem的Print()方法, 它們目前只能列印自己的東西, 還無法列印出整個組合. 也就是說如果列印的是選單Menu的話, 那麼它下面掛著的選單Menu和選單項MenuItems都應該被列印出來.
那麼我們現在修復這個問題:
public override void Print() { Console.Write($" {Name}"); Console.WriteLine($", {Description}"); Console.WriteLine("------------------------------"); foreach (var menuComponent in _menuComponents) { menuComponent.Print(); } }
服務員 Waitress:
using CompositePattern.Abstractions; namespace CompositePattern.Waitresses { public class Waitress { private readonly MenuComponent _allMenus; public Waitress(MenuComponent allMenus) { _allMenus = allMenus; } public void PrintMenu() { _allMenus.Print(); } } }
按照這個設計, 選單組合在執行時將會是這個樣子:
下面我們來測試一下:
using System; using CompositePattern.Menus; using CompositePattern.Waitresses; namespace CompositePattern { class Program { static void Main(string[] args) { MenuTestDrive(); Console.ReadKey(); } static void MenuTestDrive() { var pancakeHouseMenu = new Menu("PANCAKE HOUSE MENU", "Breakfast"); var dinerMenu = new Menu("DINER MENU", "Lunch"); var cafeMenu = new Menu("CAFE MENU", "Dinner"); var dessertMenu = new Menu("DESSERT MENU", "Dessert of courrse!"); var allMenus = new Menu("ALL MENUS", "All menus combined"); allMenus.Add(pancakeHouseMenu); allMenus.Add(dinerMenu); allMenus.Add(cafeMenu); pancakeHouseMenu.Add(new MenuItem("Vegetarian BLT", "(Fakin’) Bacon with lettuce & tomato on whole wheat", true, 2.99)); 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 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("Pasta", "Spaghetti with Marinara Sauce, and a slice of sourdough bread", true, 3.89)); 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("Cheese pie", "Creamy New York cheessecake, 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", 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)); var waitress = new Waitress(allMenus); waitress.PrintMenu(); } } }
Ok.
慢著, 之前我們講過單一職責原則. 現在一個類擁有了兩個職責…
確實是這樣的, 我們可以這樣說, 組合模式用單一責任原則換取了透明性.
透明性是什麼? 就是允許元件介面(Component interface)包括了子節點管理操作和葉子操作, 客戶可以一致的對待組合節點或葉子; 所以任何一個元素到底是組合節點還是葉子, 這件事對客戶來說是透明的.
當然這麼做會損失一些安全性. 客戶可以對某種型別的節點做出毫無意義的操作, 當然了, 這也是設計的決定.
組合迭代器
服務員現在想列印所有的選單, 或者列印出所有的素食選單項.
這裡我們就需要實現組合迭代器.
要實現一個組合迭代器, 首先在抽象類MenuComponent裡新增一個CreateEnumerator()的方法.
public virtual IEnumerator<MenuComponent> CreateEnumerator() { return new NullEnumerator(); }
注意NullEnumerator:
using System.Collections; using System.Collections.Generic; using CompositePattern.Abstractions; namespace CompositePattern.Iterators { public class NullEnumerator : IEnumerator<MenuComponent> { public bool MoveNext() { return false; } public void Reset() { } public MenuComponent Current => null; object IEnumerator.Current => Current; public void Dispose() { } } }
我們可以用兩種方式來實現NullEnumerator:
- 返回null
- 當MoveNext()被呼叫的時候總返回false. (我採用的是這個)
這對MenuItem, 就沒有必要實現這個建立迭代器(遍歷器)方法了.
請仔細看下面這個組合迭代器(遍歷器)的程式碼, 一定要弄明白, 這裡面就是遞迴, 遞迴:
using System; using System.Collections; using System.Collections.Generic; using CompositePattern.Abstractions; using CompositePattern.Menus; namespace CompositePattern.Iterators { public class CompositeEnumerator : IEnumerator<MenuComponent> { private readonly Stack<IEnumerator<MenuComponent>> _stack = new Stack<IEnumerator<MenuComponent>>(); public CompositeEnumerator(IEnumerator<MenuComponent> enumerator) { _stack.Push(enumerator); } public bool MoveNext() { if (_stack.Count == 0) { return false; } var enumerator = _stack.Peek(); if (!enumerator.MoveNext()) { _stack.Pop(); return MoveNext(); } return true; } public MenuComponent Current { get { var enumerator = _stack.Peek(); var menuComponent = enumerator.Current; if (menuComponent is Menu) { _stack.Push(menuComponent.CreateEnumerator()); } return menuComponent; } } object IEnumerator.Current => Current; public void Reset() { throw new NotImplementedException(); } public void Dispose() { } } }
服務員 Waitress新增列印素食選單的方法:
public void PrintVegetarianMenu() { var enumerator = _allMenus.CreateEnumerator(); Console.WriteLine(" VEGETARIAN MENU --------"); while (enumerator.MoveNext()) { var menuComponent = enumerator.Current; try { if (menuComponent.IsVegetarian) { menuComponent.Print(); } } catch (NotSupportedException e) { } } }
注意這裡的try catch, try catch一般是用來捕獲異常的. 我們也可以不這樣做, 我們可以先判斷它的型別是否為MenuItem, 但這個過程就讓我們失去了透明性, 也就是說 我們無法一致的對待Menu和MenuItem了.
我們也可以在Menu裡面實現IsVegetarian屬性Get方法, 這可以保證透明性. 但是這樣做不一定合理, 也許其它人有更合理的原因會把Menu的IsVegetarian給實現了. 所以我們還是使用try catch吧.
測試:
Ok.
總結
設計原則: 一個類只能有一個讓它改變的原因.
迭代器模式: 迭代器模式提供了一種訪問聚合物件(例如集合)元素的方式, 而且又不暴露該物件的內部表示.
組合模式: 組合模式允許你把物件們組合成樹形的結構, 從而來表示整體的層次. 通過組合, 客戶可以對單個物件或物件們的組合進行一致的處理.
針對C#來說, 上面的程式碼肯定不是最簡單最直接的實現方式, 但是通過這些比較原始的程式碼可以對設計模式理解的更好一些.
改系列的原始碼在: https://github.com/solenovex/Head-First-Design-Patterns-in-CSharp