Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

15_享元模式 Flyweight Pattern

1. 基本信息

中文名称: 享元模式

英文名称: Flyweight Pattern

模式类型: 结构型设计模式

Rust 中常见实现方式: RcArc、缓存池、对象池、HashMap、字符串驻留、共享不可变数据

享元模式的核心是:通过共享大量相同或相似对象中的公共部分,减少重复对象创建和内存占用。

在 Rust 中,享元模式通常通过:

Rc<T>
Arc<T>
HashMap 缓存
对象池
字符串驻留
共享不可变数据

代码示例


2. 模式核心思想

享元模式的核心思想是:

把对象中可以共享的部分抽离出来,只保存一份,然后让多个对象共同引用它。

也就是说,享元模式关注的是:

哪些数据是重复的?
哪些数据可以共享?
哪些数据必须每个对象单独保存?

例如游戏中有 10000 棵树:

树的模型
树的纹理
树的种类

这些信息可能是相同的,可以共享。

但每棵树的位置不同:

x 坐标
y 坐标
高度
旋转角度

这些信息不能共享,需要每个对象自己保存。

享元模式就是把对象拆成两部分:

内部状态:可以共享的、不随具体对象变化的部分
外部状态:不能共享的、每个对象独有的部分

3. 这个模式解决什么问题

享元模式主要解决的是:大量对象中存在重复数据时,如何减少内存浪费。

在实际开发中,如果系统中存在大量相似对象,每个对象都完整保存一份数据,就会造成明显的内存浪费。

常见情况包括:

1. 大量相同字符串
2. 大量相同图标、纹理、模型
3. 大量相同配置对象
4. 大量相同格式的节点
5. 大量共享只读元数据
6. 大量重复的词法 token

例如文本编辑器中,每个字符如果都保存字体对象、颜色对象、字号对象,那么内存会非常浪费。

更合理的做法是:

字符本身保存位置和内容;
字体、颜色、字号这些样式对象共享。

享元模式的目标是:

减少重复对象
共享公共状态
降低内存占用
提升对象创建效率

4. 不使用这个模式会怎样

如果不使用享元模式,可能会出现以下问题:

1. 大量重复对象占用过多内存。
2. 相同数据被反复创建,造成资源浪费。
3. 对象创建成本高,影响性能。
4. 缓存命中率低,系统运行效率下降。
5. 修改共享逻辑时,需要维护多个重复副本。

例如有 100 万个文本字符,每个字符都保存一份完整字体信息:

font_name
font_size
font_weight
font_color
line_height

如果这些样式大部分是重复的,那么每个字符都保存一份就没有必要。

享元模式的作用就是:把重复的部分抽出来共享,只让对象保存真正独有的数据。


5. 传统面向对象中的实现思路

在传统 OOP 中,享元模式通常包含几个角色:

1. Flyweight:享元接口,定义共享对象的行为。
2. ConcreteFlyweight:具体享元对象,保存可共享的内部状态。
3. FlyweightFactory:享元工厂,负责创建和复用享元对象。
4. Client:客户端,保存外部状态,并通过享元对象完成操作。

典型结构是:

FlyweightFactory
 └── pool: HashMap<Key, Flyweight>

Flyweight
 └── operation(external_state)

ConcreteFlyweight
 └── intrinsic_state

这里最关键的是:

享元对象保存内部状态;
客户端传入外部状态;
工厂负责复用已有享元对象。

例如树对象:

TreeType:树的种类、贴图、模型,可以共享
Tree:位置、大小、旋转角度,每棵树独有
TreeFactory:缓存 TreeType,避免重复创建

6. Rust 中的实现思路

6.1 Rust 中通常怎么实现

Rust 中享元模式通常通过 共享所有权 + 缓存池 实现。

例如游戏中有大量树,每棵树共享同一种树的模型和纹理。

use std::collections::HashMap;
use std::sync::Arc;

#[derive(Debug)]
struct TreeType {
    name: String,
    texture: String,
    model: String,
}

#[derive(Debug)]
struct Tree {
    x: f32,
    y: f32,
    height: f32,
    tree_type: Arc<TreeType>,
}

struct TreeFactory {
    types: HashMap<String, Arc<TreeType>>,
}

impl TreeFactory {
    fn new() -> Self {
        Self {
            types: HashMap::new(),
        }
    }

    fn get_tree_type(&mut self, name: &str, texture: &str, model: &str) -> Arc<TreeType> {
        if let Some(tree_type) = self.types.get(name) {
            return Arc::clone(tree_type);
        }

        let tree_type = Arc::new(TreeType {
            name: name.to_string(),
            texture: texture.to_string(),
            model: model.to_string(),
        });

        self.types.insert(name.to_string(), Arc::clone(&tree_type));
        tree_type
    }
}

fn main() {
    let mut factory = TreeFactory::new();

    let oak_type = factory.get_tree_type("Oak", "oak.png", "oak.obj");
    let pine_type = factory.get_tree_type("Pine", "pine.png", "pine.obj");

    let trees = vec![
        Tree {
            x: 10.0,
            y: 20.0,
            height: 5.0,
            tree_type: Arc::clone(&oak_type),
        },
        Tree {
            x: 15.0,
            y: 25.0,
            height: 6.0,
            tree_type: Arc::clone(&oak_type),
        },
        Tree {
            x: 30.0,
            y: 40.0,
            height: 8.0,
            tree_type: Arc::clone(&pine_type),
        },
    ];

    for tree in trees {
        println!("{:?}", tree);
    }
}

这个例子中:

TreeType 是享元对象,保存共享的内部状态。
Tree 是具体对象,保存每棵树独有的外部状态。
TreeFactory 是享元工厂,负责复用 TreeType。
Arc<TreeType> 让多个 Tree 共享同一个 TreeType。

如果是单线程场景,可以使用:

Rc<T>

如果是多线程场景,可以使用:

Arc<T>

6.2 和传统 OOP 写法相比有什么不同

和传统面向对象语言相比,Rust 的享元模式有几个明显特点。

第一,Rust 更强调共享数据的所有权关系。 传统 OOP 中可能直接共享对象引用,而 Rust 中需要明确使用 RcArc 来表达共享所有权。

第二,Rust 区分单线程共享和多线程共享。

Rc<T>:单线程共享
Arc<T>:多线程共享

这让共享对象的线程安全边界更加清楚。

第三,Rust 中享元对象通常应该设计为不可变。 如果共享对象是可变的,就需要引入 RefCellMutexRwLock,复杂度会明显增加。

第四,Rust 中的 clone 不一定表示复制底层数据。 例如:

Arc::clone(&value)

只是增加引用计数,并不会复制 TreeType 本身。

第五,Rust 可以通过 HashMap 实现显式缓存池。 享元工厂通常就是一个缓存表,负责复用已经创建过的对象。


6.3 Rust 中是否有更自然的替代写法

在 Rust 中,享元模式不一定非要写成传统的 FlyweightFactory

如果只是共享只读配置,可以直接使用:

Arc<Config>
Rc<Config>

如果是全局共享常量,可以考虑:

static
OnceLock
LazyLock

如果是字符串重复,可以考虑:

字符串驻留 string interning

如果是大量对象重复创建,可以考虑:

对象池
缓存池
arena 分配器

如果共享对象需要频繁查询,可以使用:

HashMap<Key, Rc<T>>
HashMap<Key, Arc<T>>

所以 Rust 中享元模式的本质不是“必须有一个 Flyweight 类”,而是:

识别重复数据;
把共享部分抽离出来;
用 Rc / Arc / 缓存池复用它。

7. Rust 中涉及的语言特性

享元模式在 Rust 中主要涉及以下语言特性:

1. Rc
2. Arc
3. HashMap
4. Clone
5. 所有权
6. 借用
7. static
8. OnceLock / LazyLock
9. RefCell / Mutex / RwLock
10. 生命周期

这些特性分别解决的问题是:

Rc:单线程共享享元对象
Arc:多线程共享享元对象
HashMap:根据 key 缓存和复用对象
Clone:复制共享指针,而不是复制对象本身
static:保存全局共享数据
OnceLock / LazyLock:惰性初始化全局共享对象
Mutex / RwLock:共享对象需要可变时保证线程安全
所有权和借用:明确谁拥有对象,谁只是共享使用

其中最核心的是:

Rc / Arc + HashMap

也就是:

用 HashMap 找到已有对象;
用 Rc 或 Arc 共享这个对象。

8. 性能与工程代价

享元模式的主要收益是减少内存占用和对象创建成本。

如果大量对象共享相同内部状态,享元模式可以明显降低内存使用。

例如:

10000 棵树如果共享 10 种 TreeType,
就只需要保存 10 份模型和纹理信息,
而不是每棵树都保存一份。

但是享元模式也有代价。

如果使用 RcArc

1. Rc 有引用计数开销。
2. Arc 有原子引用计数开销。
3. Arc 比 Rc 更适合多线程,但成本也更高。

如果使用 HashMap 缓存:

1. 查询享元对象需要哈希查找。
2. 缓存池需要维护。
3. key 设计不合理会导致重复缓存或错误复用。

如果共享对象需要修改:

1. RefCell 会把借用检查推迟到运行时。
2. Mutex / RwLock 会引入锁开销。
3. 共享可变状态会增加并发复杂度。

所以享元模式适合:

对象数量大
重复数据多
共享部分基本不变
内存压力明显

不适合:

对象数量少
重复数据不多
共享对象经常修改
HashMap 查询成本大于节省收益

一句话总结:

享元模式用一次共享查找和引用计数成本,换取大量重复对象的内存节省。

9. 典型应用场景

享元模式适合用于大量重复对象场景。

常见应用包括:

1. 游戏中的树、草、子弹、粒子对象
2. 文本编辑器中的字符样式共享
3. GUI 中的图标、字体、颜色资源
4. 编译器中的符号表和字符串驻留
5. 数据库连接池或对象池
6. 图形渲染中的纹理和模型共享
7. Web 服务中的共享配置对象
8. 日志系统中的格式模板共享
9. 权限系统中的角色元数据共享
10. 大规模 AST 节点中的共享字符串

在 Rust 项目中,享元模式可能出现在:

编译器和解释器
游戏开发
图形渲染
缓存系统
配置管理
大规模文本处理
对象池设计
资源管理器

例如 Rust 编译器或解析器中,大量重复的标识符字符串可以通过字符串驻留减少内存占用。


10. 和相似模式的区别

享元模式容易和单例模式、原型模式、对象池模式、缓存模式混淆。

10.1 享元模式和单例模式

二者都可能涉及共享对象,但关注点不同。

单例模式:保证某个对象在全局只有一个实例。
享元模式:共享大量相似对象中的公共部分,减少重复存储。

单例模式关注:

唯一性。

享元模式关注:

复用性和内存节省。

例如:

全局日志器只有一个:单例模式
10000 个字符共享同一种字体对象:享元模式

享元模式不要求整个系统只有一个对象,它可能会有很多个享元对象,只是每种共享状态只保存一份。


10.2 享元模式和原型模式

二者都和对象创建有关,但方向相反。

原型模式:复制已有对象,创建新对象。
享元模式:共享已有对象,避免重复创建。

原型模式强调:

通过 clone 快速生成相似对象。

享元模式强调:

不要复制重复部分,而是共享它。

例如:

复制一个默认配置生成新配置:原型模式
多个配置对象共享同一套只读元数据:享元模式

10.3 享元模式和对象池模式

对象池模式和享元模式都能减少对象创建成本,但目的不同。

对象池模式:复用可重复使用的对象实例,避免频繁创建和销毁。
享元模式:共享对象中的不变部分,减少内存重复。

对象池关注:

对象生命周期管理。

享元模式关注:

对象状态共享。

例如:

复用数据库连接:对象池模式
多个对象共享数据库连接配置:享元模式

10.4 享元模式和缓存模式

享元模式通常会用缓存实现,但缓存模式范围更广。

缓存模式:保存计算结果或对象,避免重复计算或重复访问。
享元模式:缓存并共享对象的内部状态,减少重复存储。

缓存模式关注:

提高访问速度。

享元模式关注:

共享重复状态,减少内存。

例如:

缓存一次查询结果:缓存模式
共享同一种字体对象:享元模式

11. 使用该模式的优点

享元模式的优点主要有:

1. 减少大量重复对象带来的内存占用。
2. 降低对象创建成本。
3. 提高共享资源的复用率。
4. 适合资源较大的只读对象。
5. 在 Rust 中可以用 Rc / Arc 明确表达共享所有权。
6. 可以结合 HashMap 实现统一缓存管理。

从工程角度看,享元模式的价值在于:

把重复数据变成共享数据,
让大量对象只保存自己真正独有的状态。

12. 使用该模式的代价

享元模式的代价主要包括:

1. 需要区分内部状态和外部状态。
2. 需要维护享元工厂或缓存池。
3. Rc / Arc 会有引用计数开销。
4. Arc 在多线程下有原子操作成本。
5. HashMap 查找有一定开销。
6. 共享可变对象会增加复杂度。
7. 设计不当可能造成错误共享。

尤其要注意:

享元对象最好是不可变的。

如果多个对象共享一个可变享元对象,那么修改它会影响所有使用者。 如果这种影响不是你想要的,就会产生隐蔽 bug。


13. 什么时候不应该使用

以下情况不适合使用享元模式:

1. 对象数量不多。
2. 对象之间重复数据很少。
3. 对象创建成本很低。
4. 共享对象经常需要修改。
5. 内存压力不明显。
6. 引入缓存池后反而让代码更复杂。

在 Rust 中可以遵循一个简单原则:

重复对象多、共享部分大、共享数据基本不可变,再考虑享元模式。

如果只是少量对象,直接创建更简单。


14. 一个简单例子思路

场景: 游戏地图中的树对象

需求:

地图中有大量树。
每棵树的位置、高度不同。
但相同种类的树共享模型、纹理、名称等数据。

处理流程:

1. 定义 TreeType 保存共享数据,例如 name、texture、model。
2. 定义 Tree 保存外部状态,例如 x、y、height。
3. Tree 内部持有 Rc<TreeType> 或 Arc<TreeType>。
4. TreeFactory 使用 HashMap 缓存 TreeType。
5. 创建树时,先从工厂中查找已有 TreeType。
6. 如果存在,复用;如果不存在,创建并缓存。

适合练习的 Rust 特性:

Rc
Arc
HashMap
Clone
所有权
不可变共享
结构体组合

这个例子可以体现享元模式的核心:

树的种类信息共享;
树的位置和大小独立保存。

15. 总结一句话

享元模式的本质是:

把大量对象中重复的、可共享的内部状态抽离出来,只保存一份,让多个对象共同复用。

在 Rust 中,享元模式通常通过 RcArcHashMap 缓存池实现。使用时要重点区分内部状态和外部状态,并尽量让享元对象保持不可变,以避免共享可变状态带来的复杂问题。