10.3 遍历、快照、外部操作与小型语言
议事厅最后一批行为问题分别来自集合遍历、撤销历史、语法树操作和规则解释,表面相似,变化方向却不同。
最后四种行为型模式常被语言和标准库隐藏:for-each 使用 Iterator,编辑器历史接近 Memento,编译器遍历语法树常用 Visitor,而规则 DSL 会出现 Interpreter。理解它们的边界,比手写模式样板更重要。
Iterator:暴露遍历协议,不暴露存储结构
public final class MatchHistory implements Iterable<MatchSummary> {
private final java.util.List<MatchSummary> matches;
public MatchHistory(java.util.List<MatchSummary> matches) {
this.matches = java.util.List.copyOf(matches);
}
@Override
public java.util.Iterator<MatchSummary> iterator() {
return matches.iterator();
}
}调用者可以 for-each,却不必知道内部是数组、树还是分页游标。
设计迭代器要说明:
- 遍历顺序是否稳定;
- 能否重复遍历,还是一次性消费;
- 遍历期间允许修改集合吗;
- 是快照还是实时视图;
- 延迟加载失败怎样报告;
- 数据库 cursor、文件等资源由谁关闭。
Java 集合的 fail-fast 行为通常只是尽力发现并发修改,不是并发安全保证。跨 I/O 的遍历优先返回可关闭的 Stream/游标,调用方使用 try-with-resources。
Memento:保存恢复所需状态,同时保护封装
Memento 让 originator 创建不透明快照,由 caretaker 保存并在需要时归还:
public final class TournamentEditor {
public record Snapshot(
String name,
java.util.List<RoundDraft> rounds,
long revision) {
public Snapshot {
rounds = java.util.List.copyOf(rounds);
}
}
public Snapshot snapshot() {
return new Snapshot(name, rounds, revision);
}
public void restore(Snapshot snapshot) {
this.name = snapshot.name();
this.rounds = new java.util.ArrayList<>(snapshot.rounds());
this.revision = snapshot.revision();
checkRep();
}
}快照应包含恢复不变量所需的完整状态,并切断可变别名。它可能很大,因此可使用增量快照、命令历史或持久化 checkpoint,但要先测量。
Memento 与 Event Sourcing 不同:Memento 保存某时刻状态;Event Sourcing 把领域事件作为权威事实,通过重放派生状态。后者还涉及事件版本、顺序和不可变日志,不能用 List<Snapshot> 替代。
快照可能包含个人信息、密钥和历史已删除数据,需要加密、访问控制和保留策略。
Visitor:在稳定元素族上增加新操作
当元素类型集合稳定、而新操作频繁增加时,Visitor 把操作从元素类移出,并通过双重分派保留具体类型信息。
public sealed interface Reward
permits Coins, ItemReward {
<R> R accept(RewardVisitor<R> visitor);
}
public interface RewardVisitor<R> {
R visitCoins(Coins coins);
R visitItem(ItemReward item);
}
public record Coins(Money amount) implements Reward {
@Override
public <R> R accept(RewardVisitor<R> visitor) {
return visitor.visitCoins(this);
}
}可以增加估值、导出、审计等 Visitor,而不改每种操作的分派逻辑。
代价正好相反:新增一种 Reward 必须修改所有 Visitor。若元素类型经常增加、操作很少,把行为放回元素更合适。Java sealed 类型加穷尽 pattern switch 也能实现外部操作,样板更少;选择取决于语言版本、模块边界和扩展方向。
Visitor 不应要求元素暴露全部私有字段。元素提供满足操作所需的稳定查询,仍要维护封装。
Interpreter:为有限语法建立可控求值器
当业务需要表达规则,例如:
level >= 18 AND region IN ("ap", "eu")不要把字符串交给通用 eval。一个受控解释器通常分四步:
字符 -> Token -> AST -> 在显式上下文中求值public sealed interface EligibilityExpr {
boolean evaluate(EligibilityContext context);
}
public record And(EligibilityExpr left, EligibilityExpr right)
implements EligibilityExpr {
@Override
public boolean evaluate(EligibilityContext context) {
return left.evaluate(context) && right.evaluate(context);
}
}
public record MinimumLevel(int level) implements EligibilityExpr {
@Override
public boolean evaluate(EligibilityContext context) {
return context.level() >= level;
}
}AST 节点组合表达语法,求值上下文只暴露允许的数据。
生产 DSL 还必须限制:
- 最大输入、AST 深度和执行时间;
- 可访问字段与函数白名单;
- 数字精度、时区和 null 语义;
- 错误位置与诊断;
- 语法/规则版本和向后兼容;
- 恶意正则、递归或资源耗尽。
语法稍复杂时,应使用成熟 parser generator 或 parsing library,而不是不断扩张 split() 和正则。Interpreter 模式描述 AST 与求值协作,不等于必须手写 lexer/parser。
四种模式选择表
| 变化问题 | 模式 |
|---|---|
| 遍历方式与集合表示分离 | Iterator |
| 保存并恢复对象内部状态 | Memento |
| 元素族稳定、外部操作常增加 | Visitor |
| 有限语法需要解析与安全求值 | Interpreter |
行为模式总检查
选择任何行为型模式前,先回答:
- 变化的是算法、流程、状态、通知关系还是遍历方式?
- 同步/异步、顺序、错误和取消语义是什么?
- 对象状态与资源由谁拥有?
- 重试、重放或恢复是否需要幂等与版本?
- 语言自带函数、sealed switch、Iterable 或 Stream 是否已经足够?