享元模式中的内部状态和外部状态有什么区别?
简化版
内部状态是对象可共享、相对固定、不随使用场景变化的状态;外部状态是不可共享、随上下文变化、由客户端在调用时传入的状态。享元模式能否成立,关键就在于能否正确拆分这两类状态。
详细版
以文本编辑器中的字符为例:
- 内部状态:字符值、字体类型、基础样式等可复用信息。
- 外部状态:字符所在行列、选中状态、上下文布局等变化信息。
享元对象只保存内部状态。外部状态不能放进享元对象里,否则共享对象会被不同调用方互相污染。
判断标准:
- 多个对象是否相同?相同且稳定的部分倾向内部状态。
- 是否随位置、用户、请求变化?变化部分倾向外部状态。
- 是否可以被多个上下文同时安全使用?可以才适合内部状态。
完整版教学
一、内部状态是什么
内部状态是享元对象自身携带的、可以共享的状态。它通常在对象创建后不再变化。
例如围棋棋子:
- 颜色。
- 棋子图片。
- 棋子渲染样式。
黑棋的颜色不会因为落在不同坐标而改变,所以颜色可以作为内部状态。
享元对象最好把内部状态设计成不可变字段:
class TreeType {
private final String name;
private final String texture;
private final String color;
}
不可变能降低共享对象的线程安全风险。
二、外部状态是什么
外部状态是使用享元对象时才确定的上下文信息。
例如游戏中的树:
- x 坐标。
- y 坐标。
- 当前生命值。
- 是否被选中。
这些状态每棵树都不同,不适合放进共享的 TreeType 对象里。它们应该由客户端、上下文对象或独立数据结构维护。
三、为什么外部状态不能放进享元对象
如果把外部状态放进享元对象,会出现严重问题。
假设系统只有一个共享的黑棋对象,但它内部保存了坐标:
class BlackChessPiece {
private int x;
private int y;
}
第一次落在 (1, 2),第二次落在 (3, 4),同一个对象的坐标会被覆盖。之前棋子的位置就丢了。
所以享元对象应该代表“黑棋类型”,而不是“某一颗落在具体位置的黑棋”。
四、状态拆分的判断方法
可以用三个问题判断:
- 这个字段在很多对象之间是否重复?
- 这个字段是否不依赖具体使用位置或请求上下文?
- 共享这个字段是否不会导致数据串扰?
如果三个答案都为是,就适合作为内部状态。
如果字段会随上下文变化,就应该作为外部状态。
五、内部状态和外部状态的组合方式
享元对象在执行方法时,会把内部状态和外部状态组合起来使用。
class TreeType {
private final String name;
private final String texture;
public void draw(int x, int y) {
System.out.println("在 " + x + "," + y + " 绘制 " + name + ",纹理:" + texture);
}
}
name 和 texture 来自享元对象,x 和 y 来自外部调用。
六、常见错误划分
常见错误包括:
- 把用户相关数据放进共享对象。
- 把请求级临时状态放进共享对象。
- 把会变化的计数器放进共享对象。
- 把可变集合放进享元对象后又被多个地方修改。
这些都会让共享对象变成隐蔽的全局可变状态。
七、常见误区与追问
内部状态与外部状态的区分标准是“能否脱离一次使用上下文”。文本编辑器中字符字形、字体可作为内部状态,某个字符在第12行第8列的位置属于外部状态;同一字形可以在不同位置复用。外部状态由调用者传入,因此享元不能把它缓存成实例字段。
| 检查维度 | 判定依据 |
|---|---|
| 内部状态 | 稳定、重复、可安全共享 |
| 外部状态 | 随位置或请求变化,由上下文提供 |
render(glyph共享, row=12, col=8)
心法:内部状态回答“它是什么”,外部状态回答“这次在哪里、为谁使用”。
- 误区:只读字段都属于内部状态。 字段即使只读,若每个上下文值不同也无法有效共享。
- 追问:外部状态能参与方法计算吗? 可以,它作为参数与内部状态共同决定最终行为。
- 误区:内部状态一定是基本类型。 不可变纹理、字体描述等复杂对象也可共享。
- 追问:如何判断拆分是否值得? 统计内部状态重复率,以及外部状态拆出后的参数和存储成本。
- 追问:共享状态可以延迟加载吗? 可以,但加载结果和发布过程必须线程安全且语义稳定。
八、加强记忆
内部状态属于“对象类型”,外部状态属于“对象位置和上下文”。享元模式要共享的是类型,不是每一次具体使用;把上下文留在外面,享元对象才能安全复用。