结构型模式-享元模式
结构型模式-享元模式
亦称: 缓存、Cache、Flyweight
意图
享元模式是一种结构型设计模式, 它摒弃了在每个对象中保存所有数据的方式, 通过共享多个对象所共有的相同状态, 让你能在有限的内存容量中载入更多对象。
问题
假如你希望在长时间工作后放松一下, 所以开发了一款简单的游戏: 玩家们在地图上移动并相互射击。 你决定实现一个真实的粒子系统, 并将其作为游戏的特色。 大量的子弹、 导弹和爆炸弹片会在整个地图上穿行, 为玩家提供紧张刺激的游戏体验。
开发完成后, 你推送提交了最新版本的程序, 并在编译游戏后将其发送给了一个朋友进行测试。 尽管该游戏在你的电脑上完美运行, 但是你的朋友却无法长时间进行游戏: 游戏总是会在他的电脑上运行几分钟后崩溃。 在研究了几个小时的调试消息记录后, 你发现导致游戏崩溃的原因是内存容量不足。 朋友的设备性能远比不上你的电脑, 因此游戏运行在他的电脑上时很快就会出现问题。
真正的问题与粒子系统有关。 每个粒子 (一颗子弹、 一枚导弹或一块弹片) 都由包含完整数据的独立对象来表示。 当玩家在游戏中鏖战进入高潮后的某一时刻, 游戏将无法在剩余内存中载入新建粒子, 于是程序就崩溃了。
解决方案
仔细观察 粒子
Particle类, 你可能会注意到颜色 (color) 和精灵图 (sprite)这两个成员变量所消耗的内存要比其他变量多得多。 更糟糕的是, 对于所有的粒子来说, 这两个成员变量所存储的数据几乎完全一样 (比如所有子弹的颜色和精灵图都一样)。
每个粒子的另一些状态 (坐标、 移动矢量和速度) 则是不同的。 因为这些成员变量的数值会不断变化。 这些数据代表粒子在存续期间不断变化的情景, 但每个粒子的颜色和精灵图则会保持不变。
对象的常量数据通常被称为内在状态, 其位于对象中, 其他对象只能读取但不能修改其数值。 而对象的其他状态常常能被其他对象 “从外部” 改变, 因此被称为外在状态。
享元模式建议不在对象中存储外在状态, 而是将其传递给依赖于它的一个特殊方法。 程序只在对象中保存内在状态, 以方便在不同情景下重用。 这些对象的区别仅在于其内在状态 (与外在状态相比, 内在状态的变体要少很多), 因此你所需的对象数量会大大削减。
让我们回到游戏中。 假如能从粒子类中抽出外在状态, 那么我们只需三个不同的对象 (子弹、 导弹和弹片) 就能表示游戏中的所有粒子。 你现在很可能已经猜到了, 我们将这样一个仅存储内在状态的对象称为享元。
外在状态存储
那么外在状态会被移动到什么地方呢? 总得有类来存储它们, 对不对? 在大部分情况中, 它们会被移动到容器对象中, 也就是我们应用享元模式前的聚合对象中。
在我们的例子中, 容器对象就是主要的 游戏
Game对象, 其会将所有粒子存储在名为 粒子
particles的成员变量中。 为了能将外在状态移动到这个类中, 你需要创建多个数组成员变量来存储每个粒子的坐标、 方向矢量和速度。 除此之外, 你还需要另一个数组来存储指向代表粒子的特定享元的引用。 这些数组必须保持同步, 这样你才能够使用同一索引来获取关于某个粒子的所有数据。
更优雅的解决方案是创建独立的情景类来存储外在状态和对享元对象的引用。 在该方法中, 容器类只需包含一个数组。
稍等! 这样的话情景对象数量不是会和不采用该模式时的对象数量一样多吗? 的确如此, 但这些对象要比之前小很多。 消耗内存最多的成员变量已经被移动到很少的几个享元对象中了。 现在, 一个享元大对象会被上千个情境小对象复用, 因此无需再重复存储数千个大对象的数据。
享元与不可变性
由于享元对象可在不同的情景中使用, 你必须确保其状态不能被修改。 享元类的状态只能由构造函数的参数进行一次性初始化, 它不能对其他对象公开其设置器或公有成员变量。
享元工厂
为了能更方便地访问各种享元, 你可以创建一个工厂方法来管理已有享元对象的缓存池。 工厂方法从客户端处接收目标享元对象的内在状态作为参数, 如果它能在缓存池中找到所需享元, 则将其返回给客户端; 如果没有找到, 它就会新建一个享元, 并将其添加到缓存池中。
你可以选择在程序的不同地方放入该函数。 最简单的选择就是将其放置在享元容器中。 除此之外, 你还可以新建一个工厂类, 或者创建一个静态的工厂方法并将其放入实际的享元类中。
享元模式结构
- 享元模式只是一种优化。 在应用该模式之前, 你要确定程序中存在与大量类似对象同时占用内存相关的内存消耗问题, 并且确保该问题无法使用其他更好的方式来解决。
- 享元 (Flyweight) 类包含原始对象中部分能在多个对象中共享的状态。 同一享元对象可在许多不同情景中使用。 享元中存储的状态被称为 “内在状态”。 传递给享元方法的状态被称为 “外在状态”。
- 情景 (Context) 类包含原始对象中各不相同的外在状态。 情景与享元对象组合在一起就能表示原始对象的全部状态。
- 通常情况下, 原始对象的行为会保留在享元类中。 因此调用享元方法必须提供部分外在状态作为参数。 但你也可将行为移动到情景类中, 然后将连入的享元作为单纯的数据对象。
- 客户端 (Client) 负责计算或存储享元的外在状态。 在客户端看来, 享元是一种可在运行时进行配置的模板对象, 具体的配置方式为向其方法中传入一些情景数据参数。
- 享元工厂 (Flyweight Factory) 会对已有享元的缓存池进行管理。 有了工厂后, 客户端就无需直接创建享元, 它们只需调用工厂并向其传递目标享元的一些内在状态即可。 工厂会根据参数在之前已创建的享元中进行查找, 如果找到满足条件的享元就将其返回; 如果没有找到就根据参数新建享元。
伪代码
在本例中, 享元模式能有效减少在画布上渲染数百万个树状对象时所需的内存。
该模式从主要的 树
Tree类中抽取内在状态, 并将其移动到享元类 树种类
TreeType之中。
最初程序需要在多个对象中存储相同数据, 而现在仅需在几个享元对象中保存数据, 然后在作为情景的 树
对象中连入享元即可。 客户端代码使用享元工厂创建树对象并封装搜索指定对象的复杂行为, 并能在需要时复用对象。
1 | // 享元类包含一个树的部分状态。这些成员变量保存的数值对于特定树而言是唯一 |
享元模式适合应用场景
仅在程序必须支持大量对象且没有足够的内存容量时使用享元模式。
应用该模式所获的收益大小取决于使用它的方式和情景。 它在下列情况中最有效:
- 程序需要生成数量巨大的相似对象
- 这将耗尽目标设备的所有内存
- 对象中包含可抽取且能在多个对象间共享的重复状态。
实现方式
- 将需要改写为享元的类成员变量拆分为两个部分:
- 内在状态: 包含不变的、 可在许多对象中重复使用的数据的成员变量。
- 外在状态: 包含每个对象各自不同的情景数据的成员变量
- 保留类中表示内在状态的成员变量, 并将其属性设置为不可修改。 这些变量仅可在构造函数中获得初始数值。
- 找到所有使用外在状态成员变量的方法, 为在方法中所用的每个成员变量新建一个参数, 并使用该参数代替成员变量。
- 你可以有选择地创建工厂类来管理享元缓存池, 它负责在新建享元时检查已有的享元。 如果选择使用工厂, 客户端就只能通过工厂来请求享元, 它们需要将享元的内在状态作为参数传递给工厂。
- 客户端必须存储和计算外在状态 (情景) 的数值, 因为只有这样才能调用享元对象的方法。 为了使用方便, 外在状态和引用享元的成员变量可以移动到单独的情景类中。
享元模式优缺点
优点
- 如果程序中有很多相似对象, 那么你将可以节省大量内存。
缺点
- 你可能需要牺牲执行速度来换取内存, 因为他人每次调用享元方法时都需要重新计算部分情景数据。
- 代码会变得更加复杂。 团队中的新成员总是会问: “为什么要像这样拆分一个实体的状态?”。
与其他模式的关系
- 你可以使用享元模式实现组合模式树的共享叶节点以节省内存。
- 享元展示了如何生成大量的小型对象, 外观模式则展示了如何用一个对象来代表整个子系统。
- 如果你能将对象的所有共享状态简化为一个享元对象, 那么享元就和单例模式类似了。 但这两个模式有两个根本性的不同。
- 只会有一个单例实体, 但是享元类可以有多个实体, 各实体的内在状态也可以不同。
- 单例对象可以是可变的。 享元对象是不可变的。
代码示例
享元是一种结构型设计模式, 它允许你在消耗少量内存的情况下支持大量对象。
模式通过共享多个对象的部分状态来实现上述功能。 换句话来说, 享元会将不同对象的相同数据进行缓存以节省内存。
在 C# 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
Program.cs: 概念示例
1 | using System; |
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
在 C++ 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.cc: 概念示例
1 | /** |
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
在 Java 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
享元模式在核心 Java 程序库中的示例:
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
渲染一片森林
本例中, 我们将渲染一片森林 (1,000,000 棵树)! 每棵树都由包含一些状态的对象来表示 (坐标和纹理等)。 尽管程序能够完成其主要工作, 但很显然它需要消耗大量内存。
原因很简单: 太多树对象包含重复数据 (名称、 纹理和颜色)。 因此我们可用享元模式来将这些数值存储在单独的享元对象中 ( TreeType
类)。 现在我们不再将相同数据存储在数千个 Tree
对象中, 而是使用一组特殊的数值来引用其中一个享元对象。
客户端代码不会知道任何事情, 因为重用享元对象的复杂机制隐藏在了享元工厂中。
trees
trees/Tree.java: 包含每棵树的独特状态
1 | package refactoring_guru.flyweight.example.trees; |
trees/TreeType.java: 包含多棵树共享的状态
1 | package refactoring_guru.flyweight.example.trees; |
trees/TreeFactory.java: 封装创建享元的复杂机制
1 | package refactoring_guru.flyweight.example.trees; |
forest
forest/Forest.java: 我们绘制的森林
1 | package refactoring_guru.flyweight.example.forest; |
Demo.java: 客户端代码
1 | package refactoring_guru.flyweight.example; |
OutputDemo.png: 屏幕截图
OutputDemo.txt: 内存使用统计
1 | 1000000 trees drawn |
在 PHP 中使用模式
复杂度: ★★★
流行度: ☆☆☆
使用示例: 由于 PHP 语言的特性, 享元模式很少在 PHP 中使用。 PHP 脚本通常仅需要应用的一部分数据, 从不会同时将所有数据载入内存。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
了解该模式的结构后, 你可以更轻松地理解下面基于真实世界的 PHP 应用案例。
index.php: 概念示例
1 |
|
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
真实世界示例
在我们开始前, 请注意享元模式在 PHP 中的真实应用情况非常罕见。 这源于 PHP 的单线程特性, 你不应该在同一线程中同时将应用中的所有对象载入内存。 尽管本例并非完全严谨, 且内存问题可以通过调整应用结构来解决, 但是它仍说明了在真实世界中使用该模式时的概念。 好了, 我已经做完了免责声明。 现在让我们开始吧。
在本例中, 享元模式被用于将猫类专科兽医诊所动物数据库中的对象内存用量最小化。 数据库中的每条记录都将由对象 猫
来表示, 其数据由两个部分组成:
- 特殊 (外在) 数据 (例如宠物名字、 年龄和主人的信息)。
- 共享 (内在) 数据 (例如品种名称、 颜色和纹理等)。
第一部分直接存储在 猫
类中作为上下文。 但第二部分则会单独存储且由好几只猫共享。 共享数据存储在 猫品种
(CatVariation) 类中。 所有包含类似特征的猫都会链接到同一个 猫品种
类, 而不是在其自身对象中存储重复的数据。
index.php: 真实世界示例
1 |
|
Output.txt: 执行结果
1 | Client: Let's see what we have in "cats.csv". |
在 Python 中使用模式
复杂度: ★★★
流行度: ☆☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.py: 概念示例
1 | import json |
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
在 Ruby 中使用模式
复杂度: ★★★
流行度: ☆☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.rb: 概念示例
1 | require 'json' |
output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
在 Swift 中使用模式
复杂度: ★★★
流行度: ☆☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
了解该模式的结构后, 你可以更轻松地理解下面基于真实世界的 Swift 应用案例。
Example.swift: 概念示例
1 | import XCTest |
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
真实世界示例
Example.swift: 真实世界示例
1 | import XCTest |
Output.txt: 执行结果
1 | Client: I created a number of objects to display |
在 TypeScript 中使用模式
复杂度: ★★★
流行度: ☆☆☆
使用示例: 享元模式只有一个目的: 将内存消耗最小化。 如果你的程序没有遇到内存容量不足的问题, 则可以暂时忽略该模式。
识别方法: 享元可以通过构建方法来识别, 它会返回缓存对象而不是创建新的对象。
概念示例
本例说明了享元设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
index.ts: 概念示例
1 | /** |
Output.txt: 执行结果
1 | FlyweightFactory: I have 5 flyweights: |
概念示例
在游戏 《反恐精英》 中, 恐怖分子和反恐精英身着不同类型的衣物。 为了简便起见, 我们就假设双方都各有一种服装类型。 服装对象嵌入在玩家对象之中, 如下所示。
下面是玩家的结构体。 我们可以看到, 服装对象是嵌入在玩家结构体之中的:
1 | type player struct { |
假设目前有 5 名恐怖分子和 5 名反恐精英, 一共是 10 名玩家。 那么关于服装, 我们就有两个选项了。
- 10 个玩家对象各自创建不同的服装对象, 并将其嵌入。 总共会创建 10 个服装对象。
- 我们创建两个服装对象:
- 单一恐怖分子服装对象: 其将在 5 名恐怖分子之间共享。
- 单一反恐精英服装对象: 其将在 5 名反恐精英之间共享。
你可以看到, 方法 1 中我们总共创建了 10 个服装对象; 方法 2 中则只有 2 个服装对象。 第二种方法, 就是我们所遵循的享元设计模式。 我们所创建的 2 个服装对象被称为是享元对象。
享元模式会从对象中提取出公共部分并创建享元对象。 这些享元对象 (服装) 随后可在多个对象 (玩家) 中分享。 这极大地减少了服装对象的数量, 更棒的是即便你创建了更多玩家, 也只需这么两个服装对象就足够了。
在享元模式中, 我们会将享元对象存储在 map 容器中。 每当创建共享享元对象的其他对象时, 都会从 map 容器中获取享元对象。
下面让我们来看看此类安排的内部状态和外部状态:
- 内部状态: 内部状态的服装可在多个恐怖分子和反恐精英对象间共享。
- 外部状态: 玩家位置和玩家所使用的武器就是外部状态, 因为其在每个对象中都是不同的。
dressFactory.go: 享元工厂
1 | package main |
dress.go: 享元接口
1 | package main |
terroristDress.go: 具体享元对象
1 | package main |
counterTerroristDress.go: 具体享元对象
1 | package main |
player.go: 背景
1 | package main |
game.go: 客户端代码
1 | package main |
main.go: 客户端代码
1 | package main |
output.txt: 执行结果
1 | DressColorType: ctDress |