引子:項目多也別傻做#

小菜接了私營業主的小型外包:產品展示網站、博客、新聞發布等等。每來一位客戶就租一個虛擬空間、複製一份程式碼

100 家企業 = 100 個空間 + 100 套資料庫 + 100 份重複程式碼。

任何 Bug 修正或需求變更,維護量等比放大

解法:把這些網站的核心程式碼共享,透過 ID 區分不同用戶——大型博客或電商平台都是這麼做的。

享元模式#

享元模式(Flyweight Pattern):運用共享技術有效地支援大量細粒度的物件

當系統有大量相似物件時,把多個物件共用的部分抽取出來,形成共享物件,可以大幅減少實例數量、節約記憶體。

結構#

  • Flyweight:所有具體享元類的超類或介面,可接受並作用於外部狀態
  • ConcreteFlyweight:實作 Flyweight 介面,並為內部狀態增加儲存空間
  • UnsharedConcreteFlyweight:那些不需要共享的 Flyweight 子類;介面共享是可能但不強制
  • FlyweightFactory(享元工廠):建立並管理 Flyweight 物件,確保合理共享。當客戶請求一個 Flyweight 時,提供已有的實例或新建一個
classDiagram
    class FlyweightFactory {
        -Hashtable flyweights
        +GetFlyweight(key) Flyweight
    }
    class Flyweight {
        <<abstract>>
        +Operation(extrinsicstate)*
    }
    class ConcreteFlyweight
    class UnsharedConcreteFlyweight
    class Client
    Client --> FlyweightFactory
    Client ..> UnsharedConcreteFlyweight
    FlyweightFactory o--> Flyweight
    Flyweight <|-- ConcreteFlyweight
    Flyweight <|-- UnsharedConcreteFlyweight
abstract class Flyweight
{
    public abstract void Operation(int extrinsicstate);
}

class ConcreteFlyweight : Flyweight
{
    public override void Operation(int extrinsicstate)
        => Console.WriteLine("具體 Flyweight:" + extrinsicstate);
}

class FlyweightFactory
{
    private Hashtable flyweights = new Hashtable();

    public Flyweight GetFlyweight(string key)
    {
        if (!flyweights.ContainsKey(key))
            flyweights.Add(key, new ConcreteFlyweight());
        return (Flyweight)flyweights[key];
    }
}

內部狀態 vs. 外部狀態#

  • 內部狀態(Intrinsic State):在享元物件內部、不會隨環境改變的共享部分。儲存在 ConcreteFlyweight 物件中
  • 外部狀態(Extrinsic State)隨環境改變、不可共享的狀態。應由客戶端對象儲存或計算,呼叫 Flyweight 操作時將該狀態傳入

享元模式可以避免大量非常相似類的開銷——把相同部分共享不同部分外部化

範例:共享網站程式碼#

public class User
{
    public string Name { get; }
    public User(string name) { Name = name; }
}

abstract class WebSite
{
    public abstract void Use(User user);
}

class ConcreteWebSite : WebSite
{
    private string name;
    public ConcreteWebSite(string name) { this.name = name; }
    public override void Use(User user)
        => Console.WriteLine($"網站分類:{name} 用戶:{user.Name}");
}

class WebSiteFactory
{
    private Hashtable flyweights = new Hashtable();

    public WebSite GetWebSiteCategory(string key)
    {
        if (!flyweights.ContainsKey(key))
            flyweights.Add(key, new ConcreteWebSite(key));
        return (WebSite)flyweights[key];
    }

    public int GetWebSiteCount() => flyweights.Count;
}

客戶端:

WebSiteFactory f = new WebSiteFactory();

WebSite fx = f.GetWebSiteCategory("產品展示");  fx.Use(new User("小菜"));
WebSite fy = f.GetWebSiteCategory("產品展示");  fy.Use(new User("大鳥"));
WebSite fz = f.GetWebSiteCategory("產品展示");  fz.Use(new User("嬌嬌"));
WebSite fl = f.GetWebSiteCategory("博客");      fl.Use(new User("老瑞童"));
WebSite fm = f.GetWebSiteCategory("博客");      fm.Use(new User("桃谷六仙"));
WebSite fn = f.GetWebSiteCategory("博客");      fn.Use(new User("南海鱷神"));

Console.WriteLine($"網站分類總數為 {f.GetWebSiteCount()}");
// 輸出:網站分類總數為 2

6 個用戶共享 2 個網站實例。內部狀態(網站分類)共享,外部狀態(用戶)由客戶端傳入。

.NET 中的享元#

.NET 的 string 就是享元模式的應用:

string titleA = "大話設計模式";
string titleB = "大話設計模式";
Console.WriteLine(Object.ReferenceEquals(titleA, titleB));  // True

第一次建立 titleA 時 .NET 已將字串放入字串駐留池(intern pool);建立 titleB 時直接指向同一實例,不重複佔用記憶體。

棋類遊戲的享元#

圍棋、五子棋、跳棋有大量棋子物件:

  • 內部狀態:顏色(圍棋、五子棋只有黑白;跳棋顏色不多)
  • 外部狀態:方位座標

一盤圍棋理論上有 361 個位置。常規寫法每盤可能有兩三百個棋子物件,伺服器很難支撐多人對局。

用享元模式後,棋子物件可以減少到只有兩個實例,記憶體開銷大幅降低。

何時使用?#

  • 應用程式使用了大量物件,且造成很大儲存開銷
  • 物件的大多數狀態可以外部化——刪除外部狀態後,可用相對較少的共享物件取代很多組物件

缺點#

  • 需要維護一個記錄系統已有享元的列表,本身耗費資源
  • 享元模式使系統更複雜:需要將狀態外部化,會讓邏輯複雜化

應當在有足夠多的對象實例可供共享時才值得使用享元模式。

兩三個個人博客網站不必考慮享元;要做開放註冊的博客平台,享元就是非常好的選擇。