昨天用 Bridge 把「報表種類」跟「匯出格式」兩個維度拆開,結構型系列也來到最後一篇
今天來聊 Flyweight 享元模式
原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software
Use sharing to support large numbers of fine-grained objects efficiently.
用中文理解可以是
透過共享技術,有效率地支援大量細粒度的物件
書中將物件狀態精確劃分為兩個維度:
Intrinsic State(內部狀態):儲存在享元物件內部、獨立於場景之外,不可變且可被多個物件完全共享的資訊
Extrinsic State(外部狀態):取決於享元物件使用的具體上下文環境,隨場景變動而不可共享,通常由呼叫端傳遞給享元物件的方法處理
Flyweight 主要是要解決什麼問題?
當系統需要建立數以萬計甚至數百萬計的微小物件時,每個物件都需要分配堆疊記憶體與物件標頭開銷,這容易引發:
記憶體耗盡(Out of Memory)
頻繁觸發垃圾回收(Garbage Collection),造成畫面卡頓或延遲
Flyweight 的解法是:抽離重複狀態並集中為單一份額,只將「會變的狀態(如座標、顏色偏移)」保留在容器或迴圈呼叫中
核心角色
舉個生活化的例子,你喜歡打遊戲嗎,我蠻喜歡的,而遊戲的畫面渲染,常常是人們喜愛這款遊戲的準則
想像一片森林場景要種一萬棵樹,每一棵樹的外觀(3D 網格、樹皮貼圖)其實都是同一個樹種、長得一模一樣
只有座標、大小、旋轉角度每棵樹各自不同
如果一萬棵樹各自都 new 一份完整的網格跟貼圖資料,建立10萬棵樹,記憶體就會馬上噴給你看
// Flyweight & ConcreteFlyweight:樹的外觀資料
public class TreeType
{
// 內部狀態欄位: 橡樹 / 3D 網格 / 樹皮貼圖
public string Name { get; }
public string MeshData { get; }
public string TextureData { get; }
public TreeType(string name, string meshData, string textureData)
{
Name = name;
MeshData = meshData;
TextureData = textureData;
}
public void Draw(int x, int y) => Console.WriteLine($"在 ({x},{y}) 畫一棵 {Name},套用共用的網格與貼圖");
}
// FlyweightFactory:共享池,同樣的樹種只建立一次
public class TreeTypeFactory
{
private readonly Dictionary<string, TreeType> _pool = new();
public TreeType GetTreeType(string name, string meshData, string textureData)
{
// 第一次: new 出一個 TreeType 物件並存進 _pool["橡樹"]
// 第二次: TryGetValue 會把字典裡存的「同一個物件的參考」回傳出去,不會執行 new
if (!_pool.TryGetValue(name, out var type))
{
Console.WriteLine($"建立新的樹種資料:{name}");
type = new TreeType(name, meshData, textureData);
_pool[name] = type;
}
return type;
}
}
// Context:每一棵樹自己的外在狀態(座標),加上共享的內在狀態(TreeType)
public class Tree
{
private readonly int _x, _y;
private readonly TreeType _type;
public Tree(int x, int y, TreeType type)
{
_x = x;
_y = y;
_type = type;
}
public void Draw() => _type.Draw(_x, _y);
}
那我們來印一萬棵橡樹吧
var factory = new TreeTypeFactory();
var t1 = factory.GetTreeType("橡樹", "oak_mesh", "oak_texture");
var t2 = factory.GetTreeType("橡樹", "oak_mesh", "oak_texture");
Console.WriteLine(ReferenceEquals(t1, t2)); // true,兩次拿到的是同一個物件
var forest = new List<Tree>();
for (int i = 0; i < 10000; i++)
{
var oakType = factory.GetTreeType("橡樹", "oak_mesh", "oak_texture");
forest.Add(new Tree(i, i * 2, oakType));
}
// 建立新的樹種資料:橡樹 ← 一萬棵樹跑完迴圈
一萬棵樹,TreeType(網格+貼圖)只在記憶體裡存在一份,每一棵 Tree 只多存了自己的座標
這就是 Flyweight 省下的記憶體
我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
朋友如果你第一次看 Mermaid 架構圖,這是小指南
方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / + 代表 private / public)
下層:對外提供的方法 (- / + 代表 private / public)
箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考
柴咖啡連鎖化之後,每天湧入的訂單量早就不是一間店的規模
阿柴一開始的寫法,是讓每一筆訂單的每一行,都各自帶著完整的飲品資料
// 一開始的版本:每一行訂單都各自持有一份完整的飲品資料
public class OrderLine
{
public string DrinkName { get; }
public int BasePrice { get; }
public string IconUrl { get; }
public string Sugar { get; }
public string Ice { get; }
public int Quantity { get; }
public OrderLine(string drinkName, int basePrice, string iconUrl, string sugar, string ice, int quantity)
{
DrinkName = drinkName;
BasePrice = basePrice;
IconUrl = iconUrl;
Sugar = sugar;
Ice = ice;
Quantity = quantity;
}
}
一天幾千筆訂單裡,「拿鐵」這個名稱、基礎價格、圖示網址,會被重複建立幾千次
這些資料每一份內容都一模一樣,卻各自佔了一份記憶體,甜度、冰量才是真正每一行訂單各自不同的部分
阿柴發現,物件數量太多,其中一大部分內容其實都長得一模一樣
阿柴把「飲品規格」拆出來當成共享的 Flyweight,只留名稱、基礎價格、圖示這些不會因為訂單而改變的「 內在狀態」
// Flyweight:飲品規格,放入「不會因為不同訂單而改變」的內在狀態
public class DrinkSpec
{
public string Name { get; }
public int BasePrice { get; }
public string IconUrl { get; }
public DrinkSpec(string name, int basePrice, string iconUrl)
{
Name = name;
BasePrice = basePrice;
IconUrl = iconUrl;
}
}
// FlyweightFactory:共享池,同樣名稱的規格只建立一次,之後直接重複使用
public class DrinkSpecFactory
{
private readonly Dictionary<string, DrinkSpec> _pool = new();
public DrinkSpec GetSpec(string name, int basePrice, string iconUrl)
{
if (!_pool.TryGetValue(name, out var spec))
{
Console.WriteLine($"建立新的 DrinkSpec:{name}");
spec = new DrinkSpec(name, basePrice, iconUrl);
_pool[name] = spec;
}
return spec;
}
}
OrderLine 改成只留這筆訂單獨有的外在狀態(甜度、冰量、數量),飲品規格改成持有共享池拿到的參考
public class OrderLine
{
public DrinkSpec Spec { get; } // 共享的內在狀態
public string Sugar { get; } // 外在狀態,每筆訂單各自不同
public string Ice { get; }
public int Quantity { get; }
public OrderLine(DrinkSpec spec, string sugar, string ice, int quantity)
{
Spec = spec;
Sugar = sugar;
Ice = ice;
Quantity = quantity;
}
}
var factory = new DrinkSpecFactory();
var line1 = new OrderLine(factory.GetSpec("拿鐵", 90, "latte.png"), "全糖", "正常冰", 1);
var line2 = new OrderLine(factory.GetSpec("拿鐵", 90, "latte.png"), "半糖", "少冰", 2);
var line3 = new OrderLine(factory.GetSpec("美式", 70, "americano.png"), "無糖", "去冰", 1);
// 建立新的 DrinkSpec:拿鐵 ← Console 只印一次
// 建立新的 DrinkSpec:美式
line1、line2 雖然來自兩筆不同訂單、甜度冰量也不一樣
但兩者的 Spec 指向同一個 DrinkSpec 物件,不管系統一天要處理幾千筆「拿鐵」,飲品規格本身在記憶體裡只會有一份
Mermaid 的類別架構圖
共享物件必須是不可變的(Immutable):DrinkSpec 一旦被多筆訂單共用,就不能再讓任何一方回頭修改它的欄位,不然改了一次,等於所有引用到它的訂單都被一起改到,這是共享狀態最容易踩到的坑
分不清楚內在跟外在狀態,共享就會出錯:如果誤把「甜度」這種該屬於外在狀態的欄位塞進 DrinkSpec,接下來只要有人調整了甜度,等於所有共用同一個 DrinkSpec 的訂單甜度都被一起改掉
不是物件數量多就要用:多數系統的物件數量還沒到會造成記憶體壓力的規模時,這時候引入一個共享池,只是用一層額外的複雜度,卻換不到實質的效益
Flyweight 要解決的問題:
系統中需要產生大量細粒度物件,其中大部分內容彼此相同,如果每個物件都各自持有一份完整拷貝,會造成不必要的記憶體浪費
優點:
缺點:
結構型 Pattern(Decorator、Adapter、Facade、Proxy、Composite、Bridge、Flyweight)到這裡告一段落
明天開始進入行為型 Pattern(Behavioral),柴咖啡展店到連鎖規模,促銷方式也越玩越多——集點、折扣、買一送一,這種「演算法本身可以抽換」的情境,換 Strategy 策略模式上場
[Design Pattern] Flyweight 輕量模式