享元模式 ( Flyweight Pattern ): 使用條件最苛刻的設計模式

  1. 參考書籍: 《Design Patterns: Elements of Reusable Object-Oriented Software》
  2. Source Making: Flyweight

設計模式用前須知

  • 設計模式種一句出現頻率非常高的話是,“ 在不改動。。。。的情況下, 實現。。。。的擴展“ 。
  • 對於設計模式的學習者來說,充分思考這句話其實非常重要, 因爲這句往往只對框架/ 工具包的設計纔有真正的意義。因爲框架和工具包存在的意義,就是爲了讓其他的程序員予以利用, 進行功能的擴展,而這種功能的擴展必須以不需要改動框架和工具包中代碼爲前提
  • 對於應用程序的編寫者, 從理論上來說, 所有的應用層級代碼至少都是處於可編輯範圍內的, 如果不細加考量, 就盲目使用較爲複雜的設計模式, 反而會得不償失, 畢竟靈活性的獲得, 也是有代價的。

享元模式(Flyweight Pattern)

  • 設計意圖

    • GoF: 使用共享來高效支持大量的細粒度對象
    • 關鍵詞: “共享” 、“細粒度”、 “高效”
    • 閒言: “享元模式” 這個中文譯名十分精闢, 直觀地體現了該模式的精髓所在,即 元(細粒度對象)的共享
  • GoF舉例

    • 把對象設計到系統最底層級粒度可以提供最佳的靈活性, 但是有可能會導致性能和內存使用變得無法接受。
    • 大部分文檔編輯器通常都具有排版和編輯的功能, 這些功能在實現時往往會進行一定程度的模塊化。 在使用面向對象的設計方式時, 會很自然地想要用對象來代表文檔中內嵌的元素,例如表格和圖形。 然而, 編輯器應用通常並不會爲文檔中的每一個字符使用一個對象, 儘管這樣做可以提供如下的靈活性

      • 字符和內嵌的圖表元素在繪製和排版時可以無差別的對待。
      • 應用可以在不干擾其他功能的情況下, 擴展支持新的字符集(Character Set)
      • 應用的對象組織結構可以模擬文檔的物理結構。
        文檔結構對象化組織的初版方案
    • 之所以不採用這種設計的原因是開銷. 如果採用該設計, 即便是中等大小的文檔也會有成千上萬的字符對象,這會消耗大量的內存且有可能導致無法令人接受的運行時開銷。

  • 解決方案(享元模式)

    • 享元模式描述瞭如何通過共享對象來實現細粒度對象的使用, 且不產生過高的開銷。

這裏寫圖片描述

  • 概念說明

    • 在Flyweight 設計模式中, 使用了不太直觀的命名 (至少對我來說), 所以在這裏作集中的說明:
      • flyweight
        • 一個 flyweight 是一個被共享的, 可以在多種上下文中同時使用的對象。
        • 使用在不同上下文中的 flyweight 不會相互依賴, 是完全獨立的。也就是說, 對於 flyweight 對象在不同的上下文使用時, 和那些沒有共享的對象沒有任何區別。
        • 一個 flyweight 有兩種性質的信息: intrisic stateextrinsic state
      • 固有狀態/ 內在狀態(intrinsic state
        • 內在狀態(intrinsic state )被存儲在 flyweight 對象中, 它包含了與 flyweight 使用上下文無關的信息。 intrisic state 在不同上下文中的一致性是 flyweight 對象可以被共享的直接原因 。
      • 非固有狀態/ 外部狀態 (extrinsic state
        • 非固有狀態/ 外部狀態 (extrinsic state) 不被存儲在 flyweight 對象中, 是依賴於 flyweight 使用上下文的信息。 這部分信息是沒有辦法被不同上下文共享的信息。
        • 當 flyweight 需要 extrinsic state 時,客戶端代碼負責把 extrinsic state 傳遞給 flyweight 。
  • 概念應用:

    • 上述的概念直接理解起來過於抽象, 下面結合文檔排版的例子說明這些概念如何應用於實際編碼。
      這裏寫圖片描述
    • 從邏輯層面來看, 我們期待的對象模型如上圖所示, 文檔中的每一個字符都對應一個字符對象。
    • 但實際上, 每一個字符有一個共享的 flyweight 對象, 它會出現在文檔中的不同上下文。 每個特定的字符(例如小寫 p)都會引用到一個 flyweight 共享池中的同一個 flyweight 對象 。 具體如下圖所示
    • 這裏寫圖片描述
    • 類的結構則如下圖。
      這裏寫圖片描述
    • Glyph 是一個圖形對象的抽象類, 一部分的 Glyph 對象可能是 flyweight 對象 (在本例中 Chrarcerter 對象是 flyweight 對象)。
    • 對於flyweight 對象 (Character), 一些可能依賴於其外部狀態(extrinsic state)的操作需要將 extrinsic state 作爲參數傳遞給 Character 對象。
      • 例如: Draw( Context ) , Intersects( Context) 必須知道 glyph 對象在哪個上下文中才能進行。
        • 一個代表字符 “a” 的 flyweight 對象僅僅存儲該字符對應的字符編碼 ( intrisic state ) 。 它並不需要存儲其 位置或字體(extrinsic state)。 當一個 flyweight 對象 ( 字符“a”)需要被繪製時, 繪製操作所需要的 位置和字體 信息 (extrinsic state) 由調用代碼提供。
          • 例如: 一個 Row 對象是知道它的所有孩子節點(屬於該行的字符)應該被繪製的位置, 以使得所有字符在一行水平的排列開來。 因此在繪製屬於一個Row 中的字符時, 可以由 Row 對象給孩子節點 (Character) 傳遞位置信息。
    • 採用這種設計方式後, 由於字符種類的數量是遠遠少於文檔中字符總數的(英文中只有 26 種字符構成了無數的內容), 應用程序的對象總數將會被極大程度地減少(與爲每個字符直接創建一個對象的方式相比)。對於一個使用ASCCI 字符集的文檔, 如果文檔使用同一種字體和顏色, 需要被創建的 character 數量會在100個左右 (ASCII 字符集總大小爲 128), 這就使得爲文檔中的每一個字符創建一個 Character 對象作爲邏輯上的抽象變成了可能。

應用場景

享元模式的有效性很大程度上取決於其使用方式和使用場景。 注意, 只有在下列所有條件都成立的情況下應用享元模式:

  • 一個應用使用了大量的對象
  • 由於很大的對象數目導致內存消耗非常巨大
  • 大部分對象的狀態可以被看做是外部狀態(extrinsic state)
  • 有很多組的對象可以被少量的對象替換一旦外部狀態 ( extrinsic state) 被移除
  • 整個應用不依賴於對象的具體引用細節。 因爲 flyweight 對象是會被共享的, 所以邏輯上不同的對象如果對比它們的引用,返回值可能是 true. 這點不能稱爲應用程序的阻礙。
  • 注意: 享元模式的有效性很大程度上取決於是否能夠很容易地識別出希望被共享的對象的 extrinsic state , 以及這些 extrinsic state 是否容易從共享對象中移除。
    • 如果, 由其他對象維護的 extrinsic state 的數量和使用享元模式前需要創建的對象數量一樣多, 享元模式在這種情況下, 是不能減少內存消耗的
    • 理想情況下, flyweight 對象的 extrinsic state 是可以從其他內存消耗較小的對象中計算出來的。
      這裏寫圖片描述

這裏寫圖片描述

詳細代碼舉例

享元模式作爲一個相對較爲複雜的模式, 僅僅通過上述的說明並不能完全理解其使用方式, 故需要進一步詳細的例子。這裏依舊以文檔應用爲例, 進行實現層面的舉例說明。

這裏主要關注如何把 字體的 extrinsic state 信息 Font 從 Character 對象中剝離出來, 使得 Character 對象中之存儲字符碼, 從而減少內存消耗。

  • GOF code 舉例:

Glyph 類是一個複合對象(可以參考設計模式拾荒之組合模式: 複雜的樹形結構

class Glyph {

public:
    virtual ~Glyph();
    virtual void draw(Window*, GlyphContext&);
    virtual void detFont(Font*, GlyphContext&);
    virtual Font* getFont(GlyphContext&);
    virtual void first(GlyphContext&);
    virtual void next(GlyphContext&);
    virtual bool isDone(GlyphContext&);
    virtual Glyph* current(GlyphContext&);
    virtual void insert(Glyph*, GlyphContext&);
    virtual void remove(GlyphContext&);
protected:
    Glyph();

Character 類是 Glyhph 的子類, 僅僅存儲字符碼 _charcode

class Character : public Glyph {
    public:
        Character(char);
        virtual void draw(Window*, GlyphContext&);
    private:
        char _charcode;
};

爲了避免爲每一個 Character 對象的 font 屬性分配空間, 我們將 font 這個屬性外在地存儲在一個 GlyphContext 對象中。 GlyphContext 扮演了 extrinsic state 的一箇中央倉庫的角色。 它以一種緊湊的方式維護了不同上下文 Character 對象 和其 font 屬性間的映射關係。 任何需要在特定上下文獲得 Character 對象 font 信息才能執行的方法 , 都會有一個 GlyphContext 對象作爲參數傳遞給這個方法。 然後該方法就可以從 GlyphCotext 這個對象中獲得其上下文的 font 信息。GlyphContext 的信息取決於 Character 對象所在上下文的具體位置, 因此對於 Character ( Glyph ) 的子節點的遍歷和操作都必須更新 GlyphContext , 無論它是否被使用。

class GlyphContext {
public:
    GlyphContext();
    virtual ~GlyphContext();
    virtual void next(int step = 1);
    virtual void insert(int quantity = 1);
    virtual Font* getFont();
    virtual void setFont(Font*, int span = 1);
private:
    int _index;
    BTree* _fonts;

考慮下圖的字符組合 , 注意到單詞”expect”的索引是 102-107
這裏寫圖片描述

存儲 font 信息的 BTree 結構可能是如下形式

這裏寫圖片描述

內部節點定義了 character 索引的範圍 。 (注意到, 上圖中BTree 中的 children 節點中的數值總和即爲父節點的數值, 例如 300 = 100+6 +194) BTree 在字體被改變時 和 Character 被從Glyph 對象中添加或移除時 會被更新。 例如, 假設我們遍歷到102 號索引, 下列代碼將單詞 “expect” 中的每個字符的字體設置成了和其周圍字符的文本一樣的字體 ( 即爲 times12 , 一個 12-point 的 Times Roman 的Font 對象)。

GlyphContext gc;
Font* times12 = new Font("Times-Roman-12");
Font* timesItalic12 = new Font("Times-Italic-12");
// ...
gc.setFont(times12, 6); // 方法定義是 SetFont(Font*, int span = 1);

此時記錄 Font 信息的 BTree 會被更新爲如下形式

這裏寫圖片描述

當我們在 “expect” 前增加單詞 “don’t ”(包含尾部的一個空格) ,其字體爲 12-point Times Italic 時。 下列代碼把該事件通知給 glyphContext 對象 gc。

gc.insert(6);
gc.setFont(timesItalic12, 6);

這裏寫圖片描述

當從 GlyphContext 中查詢某個Character 對象的字體時, 就可以從Btree 的根節點開始搜索, 直到找到Character 索引所對應的位置, 然後就可以獲得該索引所對應的 Character 的 Font 信息。

由於文檔中的不同字體的數量通常較少, 且字體變動的頻率也比較低, 所以這棵樹可以保持的相對較小,查詢所需額時間也很難增長到不可接受的程度。

此外,我們還需要一個 FlyWeightFactory 來創建 glyphs 且確保他們被合適地共享。

GlyphFactory 類負責實例化 Character 對象和其他類型的 Glyph 對象

const int NCHARCODES = 128;
class GlyphFactory {
public:
GlyphFactory();
    virtual ~GlyphFactory();
    virtual Character* createCharacter(char);
    virtual Row* createRow();
    virtual Column* createColumn();
// ...
private:
    Character* _character[NCHARCODES];
};

數組 _character 包含了指向 Character 對象的指針 , 在一開始, _character數組中 值會被初始化爲 0 ;

GlyphFactory::GlyphFactory () {
    for (int i = 0; i < NCHARCODES; ++i) {
        _character[i] = 0;
    }
}

CreateCharacter 的函數在創建字符的時候會首先檢查該字符是否已經被創建過, 如果創建過,則直接返回已創建的對象指針, 沒有, 則創建該對象, 並將該對象的索引保存在數組中其ASCII碼對應的位置處。

Character* GlyphFactory::CreateCharacter (char c) {
    if (!_character[c]) {
        _character[c] = new Character(c);
    }
    return _character[c];
}

由於 Row 和 Column 對象不被共享, 則可以直接創建

Row* GlyphFactory::createRow () {
        return new Row;
}
Column* GlyphFactory::createColumn () {
        return new Column;
}
  • 上述的代碼整體看下來之後,可能還是比較讓人困惑, 原因是似乎沒有一個地方解釋了圖例中 500個 Character 的 index(1-500) 維護在哪裏。

    • 注意到, GlyphContext 對象中有一個 _index 屬性, 這個 _index 屬性記錄的是 Character 的 index , 而不是 GlyphContext 的 index 。
    • 需要注意 , Glyph 類中定義了方法 Next(GlyphContext&); 而 Character 是 Glyph 的子類,也具有該方法。
    • 上文中圖片所示的大段文字屬於多個 Row, 每個 Row 都包含多個 Character 對象作爲 Children 。 應用程序在繪製這一整個段落的時候, 必然是依次調用
    // Glyph 的 draw 方法是需要傳入GlyphContext 對象作爲參數的
    // virtual void draw(Window*, GlyphContext&);
    // next 方法的定義爲 virtual void next(GlyphContext&);
    while( ! row.isDone )
    {
        row.next(glyphContext); // 這個方法會把 glyphContext 中的 _index 更新到下一行 row 第一個 Character 所對應的 index 值
        row.draw(window, glyphContext);
    }

此時需要注意, 由於應用了組合模式(Composite), row.draw()的內部實現是:

    // 此處的for 循環採用僞代碼寫法 , 因爲row 中以什麼方式維護屬於該行的 Character 並不重要, 只要能夠遍歷即可
    for(all c in characters) // 
    {
        c.draw(window, glyphContext ) // 每一次調用都會需要對glyphContext 的 _index 屬性作遞增操作, 使其指向下一個 character 
    }

組合模式的結構圖回憶: ( ^_^ )
這裏寫圖片描述

GlyphContext 作爲一個 extrinsic state 的中央倉庫, 在繪製過程中是需要維護其狀態的。

總結

  • 享元模式粗略看來, 包含的思想雖然很簡單: 通過共享來減少內存消耗。 但實際上該模式所包含的設計技巧要遠多於此。
    • 享元模式真正的核心點在於在利用共享的基礎上, 提供邏輯上完全獨立的細粒度對象,便於整個應用模型的建立和實現。
    • 享元模式的難點在於爲了提供共享的對象, 那些不能被共享的狀態需要以一種更加緊湊且可控的方式管理起來, 在調用代碼需要這些信息的時候, 可以方便的獲得。
發佈了51 篇原創文章 · 獲贊 264 · 訪問量 25萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章