cv.cm

編碼 · 設計模式

享元模式詳解與範例

閱讀約 6 分鐘

簡答

享元是一種結構型設計模式,透過共用相同的部分,讓程式能夠支援極大量相似的物件。每個物件的資料會被拆成內部狀態(共用、不可變,只在享元中儲存一次)與外部狀態(每次使用各不相同,由呼叫端傳入)。工廠會快取享元,讓相同的內部狀態永遠不會被儲存兩次。

意圖與它解決的問題

意圖:利用共用,有效率地支援大量細粒度的物件。這個模式出自《設計模式》(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 的每個物件都有額外的負擔,所以共用重複的資料可以減少記憶體用量和垃圾回收的工作。

全部編碼教學