享元模式 ( 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 的中央仓库, 在绘制过程中是需要维护其状态的。

总结

  • 享元模式粗略看来, 包含的思想虽然很简单: 通过共享来减少内存消耗。 但实际上该模式所包含的设计技巧要远多于此。
    • 享元模式真正的核心点在于在利用共享的基础上, 提供逻辑上完全独立的细粒度对象,便于整个应用模型的建立和实现。
    • 享元模式的难点在于为了提供共享的对象, 那些不能被共享的状态需要以一种更加紧凑且可控的方式管理起来, 在调用代码需要这些信息的时候, 可以方便的获得。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值