跳到内容

10.3 遍历、快照、外部操作与小型语言

议事厅最后一批行为问题分别来自集合遍历、撤销历史、语法树操作和规则解释,表面相似,变化方向却不同。

最后四种行为型模式常被语言和标准库隐藏:for-each 使用 Iterator,编辑器历史接近 Memento,编译器遍历语法树常用 Visitor,而规则 DSL 会出现 Interpreter。理解它们的边界,比手写模式样板更重要。

Iterator:暴露遍历协议,不暴露存储结构

java
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 保存并在需要时归还:

java
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 把操作从元素类移出,并通过双重分派保留具体类型信息。

java
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:为有限语法建立可控求值器

当业务需要表达规则,例如:

text
level >= 18 AND region IN ("ap", "eu")

不要把字符串交给通用 eval。一个受控解释器通常分四步:

text
字符 -> Token -> AST -> 在显式上下文中求值
java
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

行为模式总检查

选择任何行为型模式前,先回答:

  1. 变化的是算法、流程、状态、通知关系还是遍历方式?
  2. 同步/异步、顺序、错误和取消语义是什么?
  3. 对象状态与资源由谁拥有?
  4. 重试、重放或恢复是否需要幂等与版本?
  5. 语言自带函数、sealed switch、Iterable 或 Stream 是否已经足够?

参考资料

Built with VitePress | Software Systems Atlas