跳到内容

第 6 卷 / 第 10 章 / 第 3 课

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

集合遍历、撤销历史、语法树操作和规则解释都是行为问题,表面相似,变化方向却不同。

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

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

下面使用 Java 21,MatchSummary 是项目的不可变摘要类型;每个 public 顶层类型单独存放。

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();
    }
}

本例按传入列表顺序遍历,每次 iterator() 都从头开始;列表是构造时快照,remove() 不受支持,耗尽后 next() 抛出 NoSuchElementException。元素不可变是额外前提,List.copyOf 不会深复制摘要。

调用者可以 for-each,却不必知道内部存储细节。但改成分页查询若改变了快照、新鲜度或资源关闭语义,就不是无条件透明的替换。

设计迭代器要说明:

  • 遍历顺序是否稳定;
  • 能否重复遍历,还是一次性消费;
  • 遍历期间允许修改集合吗;
  • 是快照还是实时视图;
  • 延迟加载失败怎样报告;
  • 数据库 cursor、文件等资源由谁关闭。

Java 集合的 fail-fast 行为通常只是尽力发现并发修改,不是并发安全保证。跨 I/O 的遍历优先返回可关闭的 Stream/游标,调用方使用 try-with-resources。

Memento:保存恢复所需状态,同时保护封装 ​

Memento 让编辑器创建不透明快照,由历史管理者保存并在需要时归还。下面完整类型用私有构造器和字段隐藏快照内容,并用 owner 标记拒绝其他编辑器的快照:

java
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。恢复内容也算一次修改,所以修订号增加,不回退成旧号;否则乐观并发检查可能把“改了又撤销”误认为没改过。

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

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

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

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

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

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

java
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"))); // false

And 从左到右短路求值;区域按大小写敏感的字符串精确匹配,空区域集合永远不匹配。上下文不能为空,等级非负,区域和节点子项不能为空。构造器负责拒绝这些无效输入;复杂度和深度限额仍应在接收规则时检查。

生产 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