編碼 · 設計模式
享元模式詳解與範例
簡答
享元是一種結構型設計模式,透過共用相同的部分,讓程式能夠支援極大量相似的物件。每個物件的資料會被拆成內部狀態(共用、不可變,只在享元中儲存一次)與外部狀態(每次使用各不相同,由呼叫端傳入)。工廠會快取享元,讓相同的內部狀態永遠不會被儲存兩次。
意圖與它解決的問題
意圖:利用共用,有效率地支援大量細粒度的物件。這個模式出自《設計模式》(Gang of Four,簡稱 GoF)一書,屬於結構型。
想像一張要畫出一百萬棵樹的地圖。每棵樹都有位置 (x, y) 和類型:樹種名稱、顏色,以及數 KB 大小的紋理圖片。如果每個樹物件都持有自己的紋理副本,記憶體用量就會隨樹的數量成長,程式可能會耗盡記憶體。然而樹種其實只有少數幾種。每一棵橡樹的紋理、名稱和顏色都是一樣的。
享元把重複的資料移到少數幾個共用的物件裡。一棵樹只需要儲存它的位置,再加上指向共用樹種類型的參照。一百萬棵樹、五個樹種,只需要五份紋理副本,而不是一百萬份。
內部狀態與外部狀態
關鍵的一步,是把每個物件的資料分成兩種:
| 狀態 | 意義 | 樹的範例 |
|---|---|---|
| 內部狀態 | 與情境無關、許多物件都相同、儲存在享元內部,而且永不改變 | 樹種名稱、顏色、紋理 |
| 外部狀態 | 取決於情境、每次使用各不相同、由用戶端保管,並傳入享元的方法中 | 位置 x 和 y |
因為享元是共用的,所以必須是不可變的。如果某個用戶端可以更改共用橡樹類型的顏色,地圖上的每一棵橡樹都會跟著改變。
結構與參與者
| 角色 | 職責 |
|---|---|
| 享元(Flyweight) | 持有內部狀態,並提供以外部狀態作為參數的操作 |
| 享元工廠 | 建立並快取享元;當再次要求相同的內部狀態時,回傳既有的享元 |
| 情境(Context) | 儲存外部狀態,以及指向享元的參照(每個真實世界的物件對應一個情境) |
| 用戶端 | 向工廠索取享元,並保管或計算外部狀態 |
TypeScript 實作
工廠使用以內部狀態的值作為鍵的 Map。TreeType 是享元,Tree 是情境,而 Forest 是用戶端。
class TreeType {
constructor(
readonly name: string,
readonly color: string,
readonly texture: Uint8Array, // large, shared data
) {}
draw(x: number, y: number): string {
return this.name + " (" + this.color + ") at " + x + "," + y;
}
}
class TreeTypeFactory {
private static cache = new Map<string, TreeType>();
static get(name: string, color: string): TreeType {
const key = name + "|" + color;
let type = this.cache.get(key);
if (!type) {
type = new TreeType(name, color, new Uint8Array(4096));
this.cache.set(key, type);
}
return type;
}
}
class Tree {
constructor(readonly x: number, readonly y: number, readonly type: TreeType) {}
draw(): string {
return this.type.draw(this.x, this.y);
}
}
const forest: Tree[] = [];
for (let i = 0; i < 100_000; i++) {
const [name, color] = i % 2 === 0 ? ["Oak", "green"] : ["Birch", "white"];
forest.push(new Tree(i, i * 2, TreeTypeFactory.get(name, color)));
}
const types = new Set(forest.map((t) => t.type));
console.log(forest.length, "trees,", types.size, "tree types");
console.log(forest[3].draw());
// 100000 trees, 2 tree types
// Birch (white) at 3,6沒有工廠的話,程式會配置 100,000 個紋理緩衝區;有了工廠,就只有兩個。Tree 物件依然存在,但每個只持有兩個數字和一個參照。
何時使用、何時不該用
當以下條件全部成立時,使用享元:
- 程式會建立極大量相似的物件。
- 記憶體用量是實際量測到的問題,而不是猜測。
- 每個物件的大部分狀態都可以轉為外部狀態或共用。
- 程式不依賴物件的身分(兩個使用同一個享元的情境,允許共用它)。
當物件數量很少、物件需要各自獨立更改共用的資料,或是傳遞外部狀態所增加的複雜度,讓程式碼變得比省下的記憶體更難閱讀時,應避免使用。在許多情況下,更簡單的做法就行得通:把資料存放在平行陣列或型別陣列中,或是用 Map 把重複的字串駐留(intern)起來。
取捨:享元是以 CPU 時間換取記憶體。每次呼叫方法時,外部狀態可能都得重新計算或查詢。
實際應用
- Java:
Integer.valueOf(int)對 -128 到 127 的值回傳快取的Integer物件,所以對小數字做裝箱時,不會每次都配置新物件。 - Java:
String.intern()會從字串池回傳該字串的標準共用副本。 - 文字編輯器和瀏覽器從共用的字型快取來渲染字形:螢幕上的每個字元只儲存它的位置,而字形的形狀在每種字型與大小下只儲存一次。
- 遊戲引擎使用實例化渲染(instanced rendering):共用一個網格與材質,每個副本只傳送位置、旋轉這類各實例專屬的資料。
相關模式
| 模式 | 與享元的差異 |
|---|---|
| 單例 | 單例保證一個類別只有一個實例;享元則是每一種不同的內部狀態保留一個實例,所以可以有很多個。 |
| 工廠方法 | 享元工廠是帶有快取的工廠;工廠方法則是讓子類別選擇要建立哪個類別。 |
| 組合 | 組合樹的葉節點常以享元實作,讓重複的葉節點共用記憶體。 |
| 原型 | 原型是複製一個物件來建立新物件;享元則是藉由共用同一個物件來避免複製。 |
常見問題
內部狀態和外部狀態有什麼差別?
內部狀態是共用的,許多物件都相同,所以放在享元內部且永不改變。外部狀態則是每次使用各不相同,所以由用戶端保管,並傳入享元的方法。
享元就是快取嗎?
它會用到快取,但目標不同。快取是為了避免重新計算結果,而享元則是讓許多使用者共用同一個物件,避免把相同的不可變資料儲存很多次。
為什麼享元必須是不可變的?
許多情境都持有同一個享元的參照。如果其中一個更改了它,這項更改就會出現在所有共用它的其他情境中。
享元在 JavaScript 和 TypeScript 中有用嗎?
有用,當你要建立數十萬個物件時,例如粒子、地圖圖磚或格線儲存格。JavaScript 的每個物件都有額外的負擔,所以共用重複的資料可以減少記憶體用量和垃圾回收的工作。