10.3 遍历、快照、外部操作与小型语言
集合遍历、撤销历史、语法树操作和规则解释都是行为问题,表面相似,变化方向却不同。
最后四种行为型模式常被语言和标准库隐藏:对 Iterable 的 for-each 使用 Iterator(数组遍历采用另一套展开规则),编辑器历史接近 Memento,编译器遍历语法树常用 Visitor,而规则 DSL 会出现 Interpreter。理解它们的边界,比手写模式样板更重要。
Iterator:暴露遍历协议,不暴露存储结构
下面使用 Java 21,MatchSummary 是项目的不可变摘要类型;每个 public 顶层类型单独存放。
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();
}
}本例按传入列表顺序遍历,每次 iterator() 都从头开始;列表是构造时快照,remove() 不受支持,耗尽后 next() 抛出 NoSuchElementException。元素不可变是额外前提,List.copyOf 不会深复制摘要。
调用者可以 for-each,却不必知道内部存储细节。但改成分页查询若改变了快照、新鲜度或资源关闭语义,就不是无条件透明的替换。
设计迭代器要说明:
- 遍历顺序是否稳定;
- 能否重复遍历,还是一次性消费;
- 遍历期间允许修改集合吗;
- 是快照还是实时视图;
- 延迟加载失败怎样报告;
- 数据库 cursor、文件等资源由谁关闭。
Java 集合的 fail-fast 行为通常只是尽力发现并发修改,不是并发安全保证。跨 I/O 的遍历优先返回可关闭的 Stream/游标,调用方使用 try-with-resources。
Memento:保存恢复所需状态,同时保护封装
Memento 让编辑器创建不透明快照,由历史管理者保存并在需要时归还。下面完整类型用私有构造器和字段隐藏快照内容,并用 owner 标记拒绝其他编辑器的快照:
public final class TournamentEditor {
public record RoundDraft(String title) {
public RoundDraft {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("blank round title");
}
}
}
private record Content(String name, java.util.List<RoundDraft> rounds) {
private Content {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("blank name");
}
rounds = java.util.List.copyOf(rounds);
}
}
public static final class Snapshot {
private final Object owner;
private final Content content;
private Snapshot(Object owner, Content content) {
this.owner = owner;
this.content = content;
}
}
private final Object owner = new Object();
private Content content;
private long revision;
public TournamentEditor(String name, java.util.List<RoundDraft> rounds) {
content = new Content(name, rounds);
}
public String name() { return content.name(); }
public java.util.List<RoundDraft> rounds() { return content.rounds(); }
public long revision() { return revision; }
public void replace(String name, java.util.List<RoundDraft> rounds) {
Content next = new Content(name, rounds);
long nextRevision = Math.incrementExact(revision);
content = next;
revision = nextRevision;
}
public Snapshot snapshot() { return new Snapshot(owner, content); }
public void restore(Snapshot snapshot) {
java.util.Objects.requireNonNull(snapshot, "snapshot");
if (snapshot.owner != owner) {
throw new IllegalArgumentException("snapshot belongs to another editor");
}
long nextRevision = Math.incrementExact(revision);
content = snapshot.content;
revision = nextRevision;
}
}Content 与 RoundDraft 都不可变,快照可以安全共享旧内容;它没有把一个可写列表藏在“不可变”标签下面。更新先校验并计算下一修订号,再替换字段;恢复先检查归属,失败时旧内容保持不变。示例限定单线程和普通异常,不保证进程崩溃恢复。
把下面语句放入 main。恢复内容也算一次修改,所以修订号增加,不回退成旧号;否则乐观并发检查可能把“改了又撤销”误认为没改过。
var editor = new TournamentEditor("Open", java.util.List.of());
var saved = editor.snapshot();
editor.replace("Updated", java.util.List.of());
editor.restore(saved);
System.out.println(editor.name()); // Open
System.out.println(editor.revision()); // 2这里快照只在内存中有效,不是可跨进程导入的文件格式。完整状态可能很大,是否引入增量快照、命令历史或持久化 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(long amount) implements Reward {
public Coins {
if (amount < 0) throw new IllegalArgumentException("negative coins");
}
@Override
public <R> R accept(RewardVisitor<R> visitor) {
return visitor.visitCoins(this);
}
}
public record ItemReward(String sku, int quantity) implements Reward {
public ItemReward {
if (sku == null || sku.isBlank() || quantity < 1) {
throw new IllegalArgumentException("invalid item reward");
}
}
@Override
public <R> R accept(RewardVisitor<R> visitor) {
return visitor.visitItem(this);
}
}
public final class RewardText implements RewardVisitor<String> {
@Override
public String visitCoins(Coins coins) { return "coins:" + coins.amount(); }
@Override
public String visitItem(ItemReward item) {
return "item:" + item.sku() + ":" + item.quantity();
}
}这里 Coins 是整数游戏币,RewardText 只做说明文字。new Coins(100).accept(new RewardText()) 返回 coins:100;物品 new ItemReward("key", 2) 则返回 item:key:2。第一次分派选择具体元素的 accept,其中再调用对应 visitor 方法,因而 visitor 得到具体元素类型。新增估值、导出或审计 Visitor 不必修改元素分派代码。
代价正好相反:新增一种 Reward 必须修改所有 Visitor。若元素类型经常增加、操作很少,把行为放回元素更合适。Java sealed 类型加穷尽 pattern switch 也能实现外部操作,样板更少;选择取决于语言版本、模块边界和扩展方向。
Visitor 不应要求元素暴露全部私有字段。元素提供满足操作所需的稳定查询,仍要维护封装。
Interpreter:为有限语法建立可控求值器
当业务需要表达规则,例如:
level >= 18 AND region IN ("ap", "eu")不要把字符串交给通用 eval。一个受控解释器通常分四步:
字符 -> Token -> AST -> 在显式上下文中求值public record EligibilityContext(int level, String region) {
public EligibilityContext {
if (level < 0) throw new IllegalArgumentException("negative level");
java.util.Objects.requireNonNull(region, "region");
}
}
public sealed interface EligibilityExpr permits And, MinimumLevel, RegionIn {
boolean evaluate(EligibilityContext context);
}
public record And(EligibilityExpr left, EligibilityExpr right)
implements EligibilityExpr {
public And {
java.util.Objects.requireNonNull(left, "left");
java.util.Objects.requireNonNull(right, "right");
}
@Override
public boolean evaluate(EligibilityContext context) {
return left.evaluate(context) && right.evaluate(context);
}
}
public record MinimumLevel(int level) implements EligibilityExpr {
public MinimumLevel {
if (level < 0) throw new IllegalArgumentException("negative level");
}
@Override
public boolean evaluate(EligibilityContext context) {
return context.level() >= level;
}
}
public record RegionIn(java.util.Set<String> regions) implements EligibilityExpr {
public RegionIn { regions = java.util.Set.copyOf(regions); }
@Override
public boolean evaluate(EligibilityContext context) {
return regions.contains(context.region());
}
}这里仅实现 AST 与求值,没有字符串解析器;不能把前面的规则文本直接传给它。三个节点恰好能手动组装该规则,放入 main 运行:
EligibilityExpr rule = new And(new MinimumLevel(18),
new RegionIn(java.util.Set.of("ap", "eu")));
System.out.println(rule.evaluate(new EligibilityContext(18, "ap"))); // true
System.out.println(rule.evaluate(new EligibilityContext(17, "ap"))); // false
System.out.println(rule.evaluate(new EligibilityContext(18, "us"))); // falseAnd 从左到右短路求值;区域按大小写敏感的字符串精确匹配,空区域集合永远不匹配。上下文不能为空,等级非负,区域和节点子项不能为空。构造器负责拒绝这些无效输入;复杂度和深度限额仍应在接收规则时检查。
生产 DSL 还必须限制:
- 最大输入、AST 深度和执行时间;
- 可访问字段与函数白名单;
- 数字精度、时区和 null 语义;
- 错误位置与诊断;
- 语法/规则版本和向后兼容;
- 恶意正则、递归或资源耗尽。
语法稍复杂时,应使用成熟 parser generator 或 parsing library,而不是不断扩张 split() 和正则。Interpreter 模式描述 AST 与求值协作,不等于必须手写 lexer/parser。
四种模式选择表
| 变化问题 | 模式 |
|---|---|
| 遍历方式与集合表示分离 | Iterator |
| 保存并恢复对象内部状态 | Memento |
| 元素族稳定、外部操作常增加 | Visitor |
| 有限语法需要解析与安全求值 | Interpreter |
行为模式总检查
选择任何行为型模式前,先回答:
- 变化的是算法、流程、状态、通知关系还是遍历方式?
- 同步/异步、顺序、错误和取消语义是什么?
- 对象状态与资源由谁拥有?
- 重试、重放或恢复是否需要幂等与版本?
- 语言自带函数、sealed switch、Iterable 或 Stream 是否已经足够?