Reading 27: 小语言 II(Little Languages II)

目录 · ← l26 · l28 →

Reading 27: 小语言 II(Little Languages II)

说明:本讲 sp22 原版使用 TypeScript(FormulaVisitor<R> 接口、callFunction/accept 方法、onVariable/onNot 等命名),本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版,Reading 28: Little Languages II)原文保持一致(sp21 把访问者接口嵌套在 Formula 内,并用重载on(...) 方法表示各变体分支)。凡属为演示目的补充的内容(如 Java 8 default 方法、makeVisitor 工厂的等价写法)均明确标注为「补充说明」。

概述

本讲介绍访问者模式(Visitor pattern):它是”把函数当作一等值”的又一个例子,也是在递归数据类型上实现操作的另一种策略。课程的动机很直接:为递归类型实现操作时,我们一直使用解释器模式(Interpreter pattern)——在接口上声明实例方法、在每个变体类中实现;但它有两个缺点:一是操作的代码分散在所有变体类里(难读、难改、难找 bug),二是新增一个操作必须修改接口与每一个变体类。访问者模式通过双重分派(double dispatch)让”函数”本身成为一个可以传递、储存、复用的对象,从而把代码按操作分组。它与三大目标的关系是:Safe from bugs(不再需要 instanceof 与强制转型,静态检查重新回到我们这边)、Easy to understand(一个操作的全部代码集中在一个类中)、Ready for change(新增操作无需触碰既有类型;代价是新增变体时要改动访问者接口,这就是表达式问题)。


核心概念与设计原则详解

回顾:解释器模式(Interpreter Pattern)

  • 定义与目的:自课程引入递归数据类型以来,我们一直用这种方式实现函数:(1)在定义数据类型的接口中把操作声明为实例方法;(2)在每个具体变体类中实现该操作。它关系到 Easy to understand(每个变体的相关代码都在一起)与 Safe from bugs(编译器保证每个变体都实现了操作)。
  • 直观解释(”它是什么?”):每个变体”自己会做这件事”。你问一段音乐”你多长?”,NoteRestConcat 各自按自己的方式回答。
  • 关键规则与最佳实践
    • 用组合模式(Composite pattern)的术语说:组合变体ConcatNotAndOr递归实现操作;基元变体NoteRestVariable)实现基例
    • 例如 Formula 上的 variables 操作:Variable 返回 Set.of(name) 这一基例;And/Or 返回 setUnion(left.variables(), right.variables())Not 返回 formula.variables()
    • 所有操作在接口上可见,客户端只需 f.variables(),无需知道 f 的实际类型。
    • 这是 Reading 26 建立的默认做法;本讲要评估它的代价,并给出替代方案。

动态分派与静态检查(Dynamic Dispatch vs Static Checking)

  • 定义与目的:理解访问者模式必须先分清两件事。动态分派(dynamic dispatch):方法调用在运行时执行”对象实际类型(actual type / dynamic type)“中的那个实现。静态检查(static checking):在程序运行之前,编译器检查方法是否存在、实参类型是否兼容——依据的是变量/表达式的声明类型(declared type)。它关系到 Safe from bugs(静态检查是最便宜的 bug 防线)与 Easy to understand(读者能预期调用落到哪里)。
  • 直观解释(”它是什么?”):静态检查像”出门前的行李清单”(编译期核对);动态分派像”到达后由现场的门卫决定谁接待你”(运行期按实际身份找对应的人)。
  • 关键规则与最佳实践
    • 声明类型来自代码里的声明,编译期已知;实际类型是对象构造时使用的类,运行期才有。
    • 当声明类型是接口时,实际类型永远与声明类型不同,因为接口没有构造器,只有类才能产生对象值。
    • 实际类型必须是声明类型的子类型:这保证了”声明类型上能调用的方法,实际类型上一定存在,且规格相同或更强”。Java 用静态检查强制这一点(Formula f = "not"; 无法编译,因为 String 不是 Formula 的子类型)。
    • 动态分派正是解释器模式的动力f.variables() 具体跑哪段代码,只看 f 指向什么对象。
    • 记住这一对概念是理解双重分派的关键:访问者模式连续使用两次动态分派,从而在不做任何类型测试的前提下选到正确的实现。

解释器模式的两个缺点(Downsides of the Interpreter Pattern)

  • 定义与目的:课程明确列出两点代价,它们是访问者模式存在的理由。它关系到 Easy to understand(代码分散)与 Ready for change(加操作要动所有变体)。
  • 直观解释(”它是什么?”):想象一张表格,列是变体VariableAndOrNot),行是操作variablesevaluateconjunctiveNormalForm…)。解释器模式把代码按列收集:每个变体类里有该列的全部格子。
  • 关键规则与最佳实践
    • 缺点一:代码分散。一个复杂操作的实现散布在所有变体类中;要通读、重构或修 bug,必须跑到各个类里分别改——更难理解,递归实现里的 bug 更容易引入、更难发现。
    • 缺点二:加操作是破坏性改动。新增操作要改接口 + 每个实现类;如果变体很多、其中一些是别人写的或已发布,这个改动远比”把新操作的代码放在自己一处”困难。
    • 结论不是”解释器模式不好”,而是”它有适用场景“:当变体经常增加而操作稳定时,解释器模式更合适。

模式匹配:我们真正想写的东西(Pattern Matching)

  • 定义与目的:在 Reading 17 的练习里,我们写的是按等式定义的函数variables(Variable(x)) = setAdd(x, emptySet())variables(Not(f)) = variables(f)variables(And(f1,f2)) = setUnion(variables(f1), variables(f2))……大家自然想把它直译成 switch:按对象的”形状”分派。它关系到 Easy to understand(一处写完一个操作的所有情况)。
  • 直观解释(”它是什么?”):像填空题:每种”公式的形状”对应一行答案,写在一起一目了然。许多语言(函数式语言、Scala、C# 的模式匹配、乃至新版 Java)原生支持这种写法。
  • 关键规则与最佳实践
    • Java 的 switch 不能按对象类型分派;若硬要用 if (f instanceof ...) 加强制转型来模拟,就得到课程原文所说的”可怕的野兽(terrible beast)“:静态检查被彻底抛弃,编译器不再检查我们的工作,因此不安全
    • 这种写法的典型形态:一串 instanceof 分支 + 逐分支 cast + 最后 else throw new IllegalArgumentException("don't know what " + f + " is")
    • 原文的两个练习揭示了它的两种失败模式:去掉最后的 else 会得到”可能的运行时错误”或”静默的错误答案”(取决于控制流);新增一个变体(如 XorLiteral 时,代码既不会编译报错,也不会必然立刻失败——最坏情况是悄悄返回错误结果。这就是”不 safe from bugs”。
    • 只有子类型集合固定时(如枚举、判别联合,课程在 Worker 消息传递中用判别联合做过)才可能安全地这样写;对开放的类层次结构不安全。

把函数表示为数据(Representing the Function as Data)

  • 定义与目的:既然不能按形状 switch,就借用 Reading 26 的大思想:把函数表示为数据。我们为”作用于 Formula 的函数”专门定义一个类型,它的每个方法对应一个变体分支。它关系到 Ready for change(操作成为可传递的一等值)与 Easy to understand(每个操作集中成一处)。
  • 直观解释(”它是什么?”):把”一个操作”从”散落在各处的代码”变成”一个对象”:这个对象带来一套方法,分别回答”如果遇到 Variable 怎么办”“如果遇到 And 怎么办”。
  • 关键规则与最佳实践
    • 先试 Function<Formula, Set<String>> 这类通用函数类型是没用的:lambda 体里只知道 fFormula,不足以写出基例与递归例(这正是原文明说的”did that help? 没有”)。
    • 因此要自定义一个类型,为每个变体提供一个方法:R on(Variable)R on(Not)R on(And)R on(Or)(sp21 用重载 on;sp22 用 onVariable/onNot/onAnd/onOr;两者等价,重载版更简洁,分别命名版更利于 lambda 与文档)。
    • 用泛型参数 <R> 表示”函数的结果类型”:Visitor<Set<String>> 是求变量集合的函数,Visitor<Boolean> 是求值的函数,Visitor<Integer> 是求深度/结点数的函数。
    • 访问者对象本身就是一个函数对象(functional object):可以储存、传递、返回、放进集合(与 Reading 20 的回调思想一致)。
    • 还需要一个”入口”:客户端手里只有 Formula,怎么把访问者交给它?答案就是下一节的双重分派

双重分派(Double Dispatch)

  • 定义与目的双重分派 = 两次连续的方法调用:第一次用动态分派到达具体变体(f.accept(visitor) 落到 And.accept);变体随即发起第二次调用,同样用动态分派落在表示该函数的对象上(visitor.on(this) 落到 VariablesInFormula.on(And))。它解决了”如何在不知道具体变体类型的情况下调用正确的分支”这一核心难题。
  • 直观解释(”它是什么?”):像”双人确认”:你先找窗口(第一次分派,确定你是哪类业务),窗口再把你的材料转交给专门的处理人(第二次分派,确定谁来处理)。
  • 关键规则与最佳实践
    • 第一次调用是给客户端的入口,把函数作为参数传进去:public <R> R accept(Visitor<R> visitor);
    • 每个具体变体负责把自己的实际类型”交给”访问者Variable.acceptreturn visitor.on(this);And.accept 也写 return visitor.on(this);——由于 this 的静态类型在各类中不同,重载解析会选中正确的 on 重载;而 visitor 的实际类型由动态分派决定。
    • 递归发生在访问者一侧onAnd 里写 and.left().accept(this),把”自己”(this)继续传下去。
    • 这套机制让 accept 的实现永远只有一行,而所有”每种变体该怎么做”的知识都集中在访问者里。
    • 「补充说明」:accept 的泛型方法 <R> R accept(Visitor<R> visitor) 在 Java 中需要在接口与每个实现类上都声明类型参数@Override public <R> R accept(Visitor<R> visitor)),这是 Java 泛型的写法要求,容易漏写导致编译错误。

Visitor<R> 接口与 accept 方法(术语与最终形态)

  • 定义与目的:把上面的机制正式命名为课程使用的形态:接口叫 Visitor<R>(原文中先叫 FormulaFunction<R>),入口方法叫 accept(原文中先叫 callFunction)。它关系到 Easy to understand(名字符合社区惯例)。
  • 直观解释(”它是什么?”)accept = 接受一次访问:公式”接待”一位访问者,并让访问者按自己的类型处理自己。
  • 关键规则与最佳实践
    • 命名演变:FormulaFunction<R>Formula.Visitor<R>callFunctionacceptonVariable/onNot/onAnd/onOr → sp21 用一个重载on
    • 使用方式极简:Set<String> vars = f.accept(new VariablesInFormula());
    • 访问者接口放在数据类型内部Formula.Visitor<R>,sp21 做法)还是外部(FormulaVisitor<R>,sp22 做法)都可以;嵌套的好处是名字空间清晰、强调”这是 Formula 的配套接口”。
    • 访问者接口是数据类型契约的一部分accept 的规格要写清”visitor 非 null,返回值等于把该访问者应用于 this“。
    • 由于每个变体的 accept 只调用 visitor.on(this)变体类本身仍然只有表示 + 少量操作,不需要为每个新操作增加方法。

访问者即”类型上的 switch”,也是迭代器(Visitor as Switch and Iterator)

  • 定义与目的:课程给出访问者的三重身份:(1)它实现了对类型的 switchVariablesInFormula 读起来非常接近我们最初想写的 switch,只是每个 case 变成了自己的 on 方法;(2)它表示一个递归类型上的函数——可以创建实例、传递、按需应用;(3)它像迭代器(iterator)一样沿着树的结点逐个走一遍并处理,就像遍历列表或集合。它关系到 Easy to understand 与 Ready for change。
  • 直观解释(”它是什么?”):访问者像一位”上门的审计员”:数据树的每个结点都开门让审计员进来(accept),审计员在每类结点上执行自己的检查规则。
  • 关键规则与最佳实践
    • 函数需要额外参数时(如 evaluate : Formula × Map<String,Boolean> → boolean),把参数交给访问者的构造函数并保存在 final 字段里,供整个遍历使用——这就是”带参数的访问者”。
    • 遍历顺序由访问者自己决定:先序/后序、是否短路(&&/\|\| 的短路求值)、是否需要剪枝,都写在 on 方法里;这是访问者比”固定迭代器”更强的地方。
    • 「补充说明」:Java 8 可以用访问者工厂 + 函数对象让写法接近 switchmakeVisitor(Function<Variable,R> onVariable, Function<Not,R> onNot, Function<And,R> onAnd, Function<Or,R> onOr) 返回一个匿名 Visitor 实现,于是可以写 formula.accept(makeVisitor(var -> map.get(var.name()), not -> ..., ...))。这是 sp21 原文的做法,代价是”编译期不再强制你为每个变体提供分支”(改用匿名内部类逐方法实现时才会强制)。
    • 更现代的语言特性(判别联合、模式匹配、sealed 类型)能直接表达这种 switch;Java 中访问者模式就是用对象与双重分派实现类型安全的多分支

为什么用访问者:码表视角与表达式问题(Why Visitor? The Expression Problem)

  • 定义与目的:把 Formula 的所有操作想象成一张码表:列是变体(VariableAndOrNot),行是操作(variablesevaluateconjunctiveNormalForm、…),每格是”该操作在该变体上的实现”。解释器模式按列组织代码,访问者模式按行组织代码。它关系到 Ready for change:你更想为”加操作”还是”加变体”做好准备?
  • 直观解释(”它是什么?”):同样的内容,一种按”章节(变体)”排版,另一种按”主题(操作)”排版;哪种更好,取决于你以后是经常加章节还是经常加主题。
  • 关键规则与最佳实践
    • 解释器模式更容易加变体:不必改动既有代码,只需在新变体类里实现所有操作的方法(但要实现全部操作,一次性工作量不小)。
    • 访问者模式更容易加操作:新建一个 Visitor 实现,把所有变体的处理写在一个类里,既不改接口也不改变体
    • 因此”如果’新增操作’是你最想为之做好准备的变化,就定义访问者接口并用访问者写函数”。
    • 还有一个更硬的理由:当类型的设计者希望客户端理解其内部结构并实现自己的操作时,必须提供访问者接口。课程的经典例子是解析树/语法树:把具体语法树转换成抽象语法树(makeAST)时就出现了坏味道——一串 switch (parseTree.name()),最后又是 default: throw new AssertionError("should never get here");而且只能谈论泛型的 ParseTree 结点,要调用具体变体的有用操作还得做不安全的转型。更复杂的解析库会用访问者实现 makeAST抽象语法树更是普遍向客户端提供访问者接口,让客户端自己定义遍历操作。
    • 代价(本讲的另一半真相):访问者把”加变体”变难了——新增一个变体就要修改访问者接口,于是所有既有访问者实现都必须新增方法,否则编译失败。这个”两条轴不能同时免费”的困境就是表达式问题(expression problem)。选择模式时,应当先问:”未来更可能增加操作,还是增加变体?”
    • 「补充说明」:Java 8 之后可以给 Visitor 接口的方法写 default 实现,从而让”新增变体”不再破坏既有访问者实现的编译。代价是静态强制消失:忘记实现新变体的分支不再报错,而是静默走默认行为(可能返回错误结果或抛 UnsupportedOperationException)——这正是”便利 vs 静态检查”的经典取舍。

访问者的抽象函数、表示不变量与不可变性(AF, RI, and Immutability of Visitors)

  • 定义与目的:访问者是一个对象,因此也有表示(rep)、抽象函数(abstraction function, AF)表示不变量(representation invariant, RI)。按 Reading 11 的框架把这两者写清楚,能决定你的访问者是”可复用的函数值”还是”藏了一堆状态的脆弱对象”。
  • 直观解释(”它是什么?”):无状态访问者的 AF 是”这个对象 = 那个函数”(例如 VariablesInFormula 的实例 = 从公式取变量名集合的函数)。带参数的访问者(如 EvaluateVisitor)的 AF 还要说明”参数从哪来”:它的实例 = “用构造时给定的 map 求值”这一函数。
  • 关键规则与最佳实践
    • 优先把访问者写成不可变的:所有字段 final,额外参数由构造函数注入,遍历过程中的一切信息都通过返回值递归调用传递。这样访问者可以被复用、缓存、并发共享(线程安全),也不需要 reset
    • 若访问者必须累积状态(如收集所有变量名到集合中),要把 RI 写清(累积器非 null、只在单次遍历中使用、遍历结束后不再改动)、在构造与每次变更后调用 checkRep,并且绝不把内部可变集合直接返回(那会造成表示暴露,Reading 11)。更好的做法是让 on... 返回结果、由外层 accept 的调用者组合,从根上避免可变状态。
    • 不要复用”已用过一次”的有状态访问者:同一个对象再次 accept 会把上一次的累积结果混进来,产生难以定位的错误答案。
    • 访问者的方法规格要与数据类型的规格相容:accept 的返回值规格 = “把 visitor 应用于 this”,而 Visitor.on 的规格应当说明各变体分支的语义(例如”返回该变量的名字集合”)。
    • 变体的不可变性没有改变:访问者模式不要求变体可变;Formula 仍应是不可变类型(字段 finalaccept 是观察者),双重分派只是”读取结构”,不修改结构。

用访问者实现求值、打印与变换(Evaluate, Print, Transform)

  • 定义与目的:一个递归数据类型上最常出现的三类操作——求值(evaluate)打印/格式化(print/toString)变换(transform,如代入、化简、转范式)——都可以统一写成访问者。它关系到 Ready for change(三者都是”新操作”,正好是访问者擅长的方向)。
  • 直观解释(”它是什么?”):求值访问者”把公式算成一个值”;打印访问者”把公式渲染成字符串”;变换访问者”把一个公式变成另一个公式”(结果类型 R = Formula)。
  • 关键规则与最佳实践
    • 求值EvaluateVisitor implements Formula.Visitor<Boolean>,环境由构造函数注入;on(Variable) 查表,on(Not) 取反,on(And)/on(Or) 分别用 &&/\|\|(可利用短路求值)。
    • 打印PrintVisitor implements Formula.Visitor<String>;需要处理运算符优先级与括号时,可以把”父结点要求的优先级”作为额外参数——但在访问者模式里不方便在每个 on 上加参数,通常改用”构造函数注入初始状态 + 通过返回带优先级信息的结构”或”内部用带状态的小辅助类”来实现;简单场景直接在每个 on 里自行加括号即可(And 返回 "(" + left + " ∧ " + right + ")")。
    • 变换SubstituteVisitor implements Formula.Visitor<Formula>——把变量 x 替换为另一个公式,返回新的 Formula(因为变体不可变,变换必须构造新树)。
    • 由于访问者操作的都是接口类型FormulaVisitor<R>),客户端可以自由添加自己的访问者,而实现者不必预知这些操作——这正是”为可扩展而设计”的实质。
    • 注意结果类型的表达力:访问者 <R> 只能返回单一类型的值;若同一遍历需要同时算出多个结果(如”变量集合 + 深度”),要么定义一个小结果类作为 R,要么把这些信息放进一个(不可变的)记录里返回。

代码示例与对比分析

场景 1:instanceof + 强制转型的”模式匹配” vs 访问者与双重分派

❌ 错误代码

// 错误:用 instanceof + cast 在外部实现操作,静态检查被抛弃
public static Set<String> variables(Formula f) {
    if (f instanceof Variable) {
        return Set.of(((Variable) f).name());
    } else if (f instanceof Not) {
        return variables(((Not) f).formula());
    } else if (f instanceof And) {
        And and = (And) f;
        return setUnion(variables(and.left()), variables(and.right()));
    } else if (f instanceof Or) {
        Or or = (Or) f;
        return setUnion(variables(or.left()), variables(or.right()));
    } else {
        throw new IllegalArgumentException("don't know what " + f + " is");
    }
}

【错误代码的问题】

  1. 静态检查失效:编译器不知道我们是否覆盖了所有变体;新增 XorLiteral 之类的变体时,这段代码不会编译报错,而是可能在运行时抛异常(走到 else),或者更糟——静默返回错误答案。
  2. 强制转型是危险操作(Variable) finstanceof 检查必须严格对应,任何笔误(例如把 (Or) f 写成别的类型)都要到运行时才炸成 ClassCastException
  3. 每个新操作都要重抄一遍 if 链depthevaluatetoCNF 各写一遍同样的分派骨架,重复且易漏。
  4. 需要 getter 窥探表示:外部函数必须通过 name()/left()/formula() 取字段,倾向于把表示细节暴露出去(表示依赖)。

✅ 正确代码

// 正确:变体声明 accept,访问者接口把"每种变体怎么办"集中到一个类里
public interface Formula {
    /** 在一个 Formula 上调用一个访问者。
     *  @param <R> 结果类型
     *  @param visitor 要调用的访问者,前置条件:非 null
     *  @return 把 visitor 应用于 this 的结果 */
    public <R> R accept(Visitor<R> visitor);

    /** 代表"作用在不同种类 Formula 上的函数"。 */
    public interface Visitor<R> {
        R on(Variable var);
        R on(Not not);
        R on(And and);
        R on(Or or);
    }
}

public final class Variable implements Formula {
    private final String name;
    public Variable(String name) {
        if (name == null || name.isEmpty()) throw new IllegalArgumentException("name");
        this.name = name;
    }
    public String name() { return name; }

    // 第一次动态分派到达这里;这里再发起第二次动态分派
    @Override public <R> R accept(Visitor<R> visitor) {
        return visitor.on(this);
    }
}

public final class Not implements Formula {
    private final Formula formula;
    public Not(Formula formula) { this.formula = formula; }
    public Formula formula() { return formula; }

    @Override public <R> R accept(Visitor<R> visitor) { return visitor.on(this); }
}

public final class And implements Formula {
    private final Formula left, right;
    public And(Formula left, Formula right) { this.left = left; this.right = right; }
    public Formula left() { return left; }
    public Formula right() { return right; }

    @Override public <R> R accept(Visitor<R> visitor) { return visitor.on(this); }
}

public final class Or implements Formula {
    private final Formula left, right;
    public Or(Formula left, Formula right) { this.left = left; this.right = right; }
    public Formula left() { return left; }
    public Formula right() { return right; }

    @Override public <R> R accept(Visitor<R> visitor) { return visitor.on(this); }
}

/** 求公式中出现的所有变量名。 */
public final class VariablesInFormula implements Formula.Visitor<Set<String>> {
    @Override public Set<String> on(Variable var) {
        return Set.of(var.name());
    }
    @Override public Set<String> on(Not not) {
        return not.formula().accept(this);          // 递归:把同一个访问者继续传下去
    }
    @Override public Set<String> on(And and) {
        return setUnion(and.left().accept(this), and.right().accept(this));
    }
    @Override public Set<String> on(Or or) {
        return setUnion(or.left().accept(this), or.right().accept(this));
    }

    /** @return set1 与 set2 的并集(不可变) */
    private static <E> Set<E> setUnion(Set<E> set1, Set<E> set2) {
        Set<E> result = new HashSet<>(set1);
        result.addAll(set2);
        return Set.copyOf(result);
    }
}

// 客户端用法
Formula f = new And(new Or(new Variable("P"), new Variable("Q")),
                    new Not(new Variable("R")));
Set<String> names = f.accept(new VariablesInFormula());   // { "P", "Q", "R" }

【为什么这样更好】 编译器重新开始帮我们工作:Visitor<R> 接口声明了全部变体分支,任何实现类若漏掉一个方法都无法编译;新增变体时所有访问者实现会立刻编译失败(虽然这本身是代价,但至少不会静默出错)。操作代码集中在一处,VariablesInFormula 读起来就像最初想要的 switch。整个过程中没有任何 cast、没有任何 instanceof,因此也不会有 ClassCastException【代码对比解说】 关键机制是双重分派f.accept(v) 用第一次动态分派(依据 f 的实际类型)落到 And.acceptAnd.accept 立刻用第二次动态分派(依据 v 的实际类型)调用 v.on(this)。两次分派合起来就把”哪种变体 × 哪个操作”唯一确定下来,等价于一次”类型 switch”,却完全由类型系统与重载解析完成。注意递归调用写成 and.left().accept(this):访问者把自己继续传下去,遍历顺序完全由 on 方法决定。accept 在每个变体里只有一行,说明”分派”与”策略”被干净地分开了——这正是访问者模式的优雅之处。 【设计原则透视】 这是 Reading 12(用接口定义 ADT)Reading 17(递归数据类型)Reading 26(把函数表示为数据) 的组合:函数成为对象(Visitor<R> 的实例),类型安全由编译器保证。与 Reading 26 的 instanceof 反例对照,可以看到”用类型系统表达分支 vs 用运行时测试表达分支“的差别——前者是 Safe from bugs 的正道。


场景 2:新增一个操作——修改接口与全部变体 vs 新增一个访问者类

❌ 错误代码

// 错误(在"操作会不断增加"的场景下):把每个操作都做成实例方法
// 于是新增 depth() 操作必须触碰 5 个地方:接口 + 4 个变体类

public interface Formula {
    Set<String> variables();
    boolean evaluate(Map<String, Boolean> map);
    Formula substitute(String name, Formula replacement);
    int depth();                       // 新增:破坏性改动 1
    public <R> R accept(Visitor<R> visitor);
}

public final class Variable implements Formula {
    private final String name;
    @Override public Set<String> variables() { return Set.of(name); }
    @Override public boolean evaluate(Map<String, Boolean> map) { /* ... */ return false; }
    @Override public Formula substitute(String n, Formula r) {
        return n.equals(name) ? r : this;
    }
    @Override public int depth() { return 1; }                   // 新增:破坏性改动 2
    @Override public <R> R accept(Visitor<R> v) { return v.on(this); }
}

public final class Not implements Formula {
    private final Formula formula;
    @Override public Set<String> variables() { return formula.variables(); }
    @Override public boolean evaluate(Map<String, Boolean> map) { return !formula.evaluate(map); }
    @Override public Formula substitute(String n, Formula r) {
        return new Not(formula.substitute(n, r));
    }
    @Override public int depth() { return 1 + formula.depth(); }  // 新增:破坏性改动 3
    @Override public <R> R accept(Visitor<R> v) { return v.on(this); }
}
// And、Or 同理(破坏性改动 4、5)……

【错误代码的问题】

  1. 一次新操作 = 修改接口 + 所有实现类:5 个文件(接口 + 4 个变体)必须同时改、同时重新编译、同时重新测试;变体越多,代价越大。
  2. 改动落在别人的代码里:若某些变体由其他程序员维护甚至已经发布,这种”为了加一个操作而改遍所有类”的变更非常难推动。
  3. 操作本身被撕碎:想通读 depth 的实现,必须跳 4 个类;想重构它,要做 4 处一致的改动,任一处不一致就是 bug。
  4. 接口变得臃肿:所有操作都被塞进数据类型的接口,客户被迫依赖一个巨大的接口,而不是只依赖自己需要的操作。

✅ 正确代码

// 正确:为"新增操作"提供访问者接口;新增操作只是新增一个类
public final class DepthInFormula implements Formula.Visitor<Integer> {
    @Override public Integer on(Variable var) { return 1; }
    @Override public Integer on(Not not) { return 1 + not.formula().accept(this); }
    @Override public Integer on(And and) {
        return 1 + Math.max(and.left().accept(this), and.right().accept(this));
    }
    @Override public Integer on(Or or) {
        return 1 + Math.max(or.left().accept(this), or.right().accept(this));
    }
}

// 客户端(甚至可以是我们之外的人)自由定义自己的操作,实现者不需要预知它
Formula f = new Or(new Variable("p"), new Not(new Variable("q")));
int d = f.accept(new DepthInFormula());        // 3

// 同一个类型上再加一个操作:又是一个新类,零改动既有代码
public final class CountVisits implements Formula.Visitor<Integer> {
    private final Map<String, Integer> counts = new HashMap<>();   // 见场景 3:有状态访问者的取舍
    @Override public Integer on(Variable var) { return 1; }
    @Override public Integer on(Not not) { return 1 + not.formula().accept(this); }
    @Override public Integer on(And and) {
        return 1 + and.left().accept(this) + and.right().accept(this);
    }
    @Override public Integer on(Or or) {
        return 1 + or.left().accept(this) + or.right().accept(this);
    }
}

【为什么这样更好】 新增操作不触碰 Formula 接口与任何变体类:既有代码零修改、零重新测试,变更被限制在一个新文件里。这使类型可以”向外开放”:客户(下一个人、另一个团队)能定义自己的遍历,而实现者无需为每个未来操作预留方法。原文明说:如果”新增操作”是你最希望准备好的变化,那就定义访问者接口、把函数写成访问者。 【代码对比解说】 两种写法的差别就是”码表按列排版还是按行排版”:解释器模式把一列(一个变体的所有操作)放在一起,于是加行(操作)要动所有列;访问者模式把一行(一个操作的所有变体)放在一起,于是加行只是一处新增,但加列(变体)要动所有行。注意 DepthInFormulaCountVisits 都是独立文件:它们的存在不需要修改数据类型的源代码,也不需要数据类型作者知道它们的存在。 【设计原则透视】 这就是 表达式问题(expression problem) 的操作侧优势,对应 Ready for change;同时它也是”面向接口编程 + 依赖倒置“(Reading 12)的极端形态:数据类型的作者提供 accept 这个扩展点,把”新操作”的实现责任交给客户端。课程强调的另一用途是解析树/AST:具体语法树转抽象语法树的代码天然是”按结点类型分派”的,用访问者替代一串 switch (parseTree.name())(以及 default: throw new AssertionError(...))能重新拿回静态检查。


场景 3:可变的”累积型”访问者 vs 不可变的函数式访问者(AF / RI / 线程安全)

❌ 错误代码

// 错误:访问者把结果累积在可变字段里,且暴露内部集合、缺少 RI 与 reset
public final class CollectingVisitor implements Formula.Visitor<Void> {
    private Set<String> names;        // 可变状态:既可能为 null,也会在多次遍历间泄漏
    private int visits;

    @Override public Void on(Variable var) {
        if (names == null) names = new HashSet<>();   // 惰性初始化:忘记重置就累积旧结果
        names.add(var.name());
        visits++;
        return null;
    }
    @Override public Void on(Not not) {
        visits++;
        not.formula().accept(this);
        return null;
    }
    @Override public Void on(And and) {
        visits++;
        and.left().accept(this);
        and.right().accept(this);
        return null;
    }
    @Override public Void on(Or or) {
        visits++;
        or.left().accept(this);
        or.right().accept(this);
        return null;
    }

    public Set<String> names() { return names; }   // 表示暴露:调用者可直接改内部集合
    public int visits() { return visits; }
}

// 复用同一个访问者对象两次:
CollectingVisitor cv = new CollectingVisitor();
f1.accept(cv);
Set<String> r1 = cv.names();      // { "P", "Q" }
f2.accept(cv);
Set<String> r2 = cv.names();      // { "P", "Q", "R" } —— 被上一次的结果污染了!

【错误代码的问题】

  1. 状态跨遍历泄漏:同一个访问者对象被复用(或客户端”顺手”多访问一次)时,结果会累积,正确答案变成错误答案,且没有异常提示。
  2. names 可能为 null:若公式里没有变量(例如全是常量的公式),names() 返回 null,调用者必须判空;RI 无法写清。
  3. 表示暴露(rep exposure)names() 直接把内部 HashSet 交出去,调用者一改,访问者的”结果”就被篡改;AF 与 RI 都被破坏。
  4. 返回 Void 依赖副作用:遍历语义藏在可变字段里,阅读 on 方法看不出”这个访问者到底有没有遍历左子树”;同时该对象不可并发复用(线程不安全),难以缓存。
  5. 没有 checkRep:连”累积器在被读取时非 null”这样的基本不变量都没有检查。

✅ 正确代码

// 正确:不可变的函数式访问者;额外参数由构造函数注入,信息通过返回值传递
public final class VariablesInFormula implements Formula.Visitor<Set<String>> {
    // 无字段、无状态:所有结果都由返回值与递归调用传递

    @Override public Set<String> on(Variable var) {
        return Set.of(var.name());
    }
    @Override public Set<String> on(Not not) {
        return not.formula().accept(this);
    }
    @Override public Set<String> on(And and) {
        return setUnion(and.left().accept(this), and.right().accept(this));
    }
    @Override public Set<String> on(Or or) {
        return setUnion(or.left().accept(this), or.right().accept(this));
    }
    private static <E> Set<E> setUnion(Set<E> a, Set<E> b) {
        Set<E> result = new HashSet<>(a);
        result.addAll(b);
        return Set.copyOf(result);          // 返回不可变副本,杜绝表示暴露
    }
}

/** 带参数的访问者:环境由构造函数注入,字段 final。
 *  AF: 该对象表示函数  formula ↦ 在 map 下求 formula 的值
 *  RI: map != null,且(调用前由 evaluate 检查)包含 formula 中出现的所有变量 */
public final class EvaluateVisitor implements Formula.Visitor<Boolean> {
    private final Map<String, Boolean> map;

    public EvaluateVisitor(Map<String, Boolean> map) {
        if (map == null) throw new NullPointerException("map");
        this.map = map;
        checkRep();
    }
    private void checkRep() {
        assert map != null : "map must be non-null";
    }

    @Override public Boolean on(Variable var) {
        Boolean value = map.get(var.name());
        if (value == null) {
            throw new IllegalArgumentException("unbound variable: " + var.name());
        }
        return value;
    }
    @Override public Boolean on(Not not) { return !not.formula().accept(this); }
    @Override public Boolean on(And and) {
        return and.left().accept(this) && and.right().accept(this);   // 短路求值
    }
    @Override public Boolean on(Or or) {
        return or.left().accept(this) || or.right().accept(this);
    }
}

/** 把公式中出现的所有变量都用 map 中的真值代入求值。
 *  @param formula 待求值的公式
 *  @param map 前置条件:必须为 formula 中出现的每个变量提供取值
 *  @return formula 在 map 下的取值 */
public static boolean evaluate(Formula formula, Map<String, Boolean> map) {
    return formula.accept(new EvaluateVisitor(map));
}

// 每次调用都新建一个访问者:无共享、无泄漏、可并发
boolean r1 = evaluate(new And(new Variable("a"), new Not(new Variable("b"))),
                      Map.of("a", true, "b", false));   // true

【为什么这样更好】 访问者成为纯函数值:给定访问者与公式,结果唯一确定,不修改任何状态;同一个访问者实例可以被反复使用、可以并发共享、可以被缓存(因为不可变)。Map 是构造时注入的 final 字段,RI 可写、可查(checkRep),未绑定变量显式失败并带上变量名。返回不可变集合,彻底避免表示暴露。 【代码对比解说】 两版的差别是”把结果放在哪里“:左版放在访问者的可变字段里(需要 reset、可能为 null、会泄漏、不可并发),右版放在返回值里(由递归调用组合起来)。这与 Reading 26 中”环境作为参数而不是全局状态”是同一个道理——让依赖和数据沿调用栈流动,而不是留在对象的字段里。注意右版也保留了”有状态访问者”的正当用法:构造函数注入的只读参数不算可变状态,它是”这个函数值的一部分”。当确实需要累积(例如统计词频)时,应把累积器限制在单次遍历内、在方法返回前转成不可变结果,并在注释中写清 AF/RI 与”不得复用”的前置条件。 【设计原则透视】 直接对应 Reading 11(AF 与 RI)Reading 8(不可变性)Reading 21/23(并发):不可变对象天生线程安全,不需要锁,也不需要”谁的访问者”这类约定;而可变的共享访问者则是典型的并发 bug 温床(某个线程 reset 了另一个线程正在使用的累积器)。evaluate 的规格还把”map 必须覆盖所有变量”写成显式前置条件,符合 Reading 7 对规格的要求。


场景 4:选错模式——变体频繁增加却使用访问者 vs 按”变化轴”选择模式

❌ 错误代码

// 反例:表达式类型的变体经常增加,却把操作都做成了访问者
public interface IntegerExpression {
    public interface Visitor<R> {
        R on(Constant c);
        R on(Plus p);
        R on(Variable v);
    }
    public <R> R accept(Visitor<R> visitor);
}

// 现在要支持乘法:必须修改 Visitor 接口(破坏性改动)
public interface Visitor<R> {
    R on(Constant c);
    R on(Plus p);
    R on(Variable v);
    R on(Times t);   // 所有既有访问者实现(包括客户端写的)都必须新增这个方法,否则编译失败
}

// 结果:客户端代码成批编译失败,被迫实现一个它们不关心、甚至无法正确实现的分支

【错误代码的问题】

  1. 破坏性变更:新增变体让所有既有访问者实现编译失败;若这些实现由第三方维护(例如客户定义的十几个操作),升级代价极高。
  2. 被迫实现的”假分支”:客户端往往对新变体一无所知,只能写 throw new UnsupportedOperationException("Times not supported"),把类型安全换成了运行时错误。
  3. 无法逐步演进:想分两次发布(先加变体,后更新访问者)都做不到,必须一次性同步全部实现。
  4. 模式与需求错位:类型的设计者选错了”为哪条变化轴做准备”。

✅ 正确代码

// 正确:变体频繁增加、操作相对稳定时,用解释器模式(操作作为实例方法)
public interface IntegerExpression {
    /** @param environment 变量到值的映射;前置条件:包含本表达式出现的所有变量
     *  @return 本表达式的值 */
    int eval(Map<String, Integer> environment);
    /** @return 本表达式出现的所有变量名 */
    Set<String> variables();
}
// 新增变体:只加一个类,接口与所有既有类都不动
public final class Times implements IntegerExpression {
    private final IntegerExpression left, right;
    public Times(IntegerExpression left, IntegerExpression right) {
        this.left = left;
        this.right = right;
    }
    public IntegerExpression left() { return left; }
    public IntegerExpression right() { return right; }

    @Override public int eval(Map<String, Integer> environment) {
        return left.eval(environment) * right.eval(environment);
    }
    @Override public Set<String> variables() {
        Set<String> result = new HashSet<>(left.variables());
        result.addAll(right.variables());
        return Set.copyOf(result);
    }
    @Override public String toString() { return "(" + left + " * " + right + ")"; }
}
// (可选)混合方案:把稳定的核心操作留在接口上,同时用 accept 提供"客户端自定义操作"的扩展点
public interface IntegerExpression {
    int eval(Map<String, Integer> environment);
    Set<String> variables();
    public <R> R accept(Visitor<R> visitor);       // 扩展点:把"新操作"交给客户端

    public interface Visitor<R> {
        R on(Constant c);
        R on(Plus p);
        R on(Variable v);
        R on(Times t);
    }
}

【为什么这样更好】 选择标准是”哪条轴更可能变“:变体常增 → 解释器模式(加变体只加一个类,零破坏);操作常增 → 访问者模式(加操作只加一个类,零破坏)。当两者都要时,可以混合:把少数稳定、核心的操作放在接口上(简单直接),同时提供一个 accept 扩展点让客户端实现自己的遍历。这样既保留了日常使用的简洁,又把”未来未知的操作”开放出去。 【代码对比解说】 左版把”扩展点”押在了操作维度上,于是变体维度变得脆弱;右版(解释器模式)把扩展点押在变体维度上。注意二者并非互斥:真正成熟的数据类型往往同时提供两者(例如课程中 AST 的做法——核心操作在类型上,同时向客户端提供访问者接口)。选择前应回到”码表”:列出你预期会新增的行与列,数一数哪一维更多,再决定把代码组织成行还是列。 【设计原则透视】 这就是 表达式问题 的完整表述:在不修改既有代码的前提下,无法同时让”新增变体”与”新增操作”都变得容易。它也体现 Reading 7(设计规格)Reading 4(代码评审) 的精神:设计决策要基于”未来会怎么变”的证据,而不是跟风使用某个模式。关于”新增变体”的代价,还有一个必要的「补充说明」:Java 8 起可以给 Visitor 的方法写 default 实现,从而让新增变体不再导致编译失败——代价是失去静态强制,忘记实现的分支会静默走默认行为(可能返回错误结果),属于”便利换掉安全检查”的典型权衡。


与其他设计原则的关联

  • Reading 26(小语言 I):本讲是它的直接续篇。Reading 26 确立了”把代码表示为数据”与解释器模式;本讲指出解释器模式的两个缺点,并给出访问者这一替代实现策略。两讲共同回答”如何为一门小语言实现操作”。
  • Reading 17(递归数据类型)FormulaMusicIntegerExpression 都是递归数据类型;访问者正是”在这个类型上实现的函数”,variables 的四个等式就是访问者的四个 on 方法。
  • Reading 12(接口、泛型、枚举、函数对象)Visitor<R> 用泛型参数化结果类型;访问者实例是函数对象,可以像 Reading 20(回调)那样被传递、储存、返回;accept 是接口上的扩展点。
  • Reading 11(抽象函数与表示不变量):访问者对象也有 AF/RI——无状态访问者的 AF 是”这个对象 = 那个函数”,带参数访问者的 AF 说明参数含义与来源;RI 要求参数非 null、状态只在单次遍历中使用。
  • Reading 8(可变性与不可变性):递归类型的变体必须不可变,accept 是观察者;访问者本身优先写成不可变,才能被复用与并发共享。
  • Reading 21(并发)与 Reading 23(互斥):不可变访问者天然线程安全;可变累积型访问者在多线程下需要保证”每个线程用自己的实例”,否则会出现结果混合或数据竞争。
  • Reading 19(解析器):把具体语法树转成抽象语法树的 makeAST 是典型的”按结点类型分派”的坏味道(switch (parseTree.name()) + default: throw new AssertionError);复杂解析库用访问者实现它,AST 也常向客户端提供访问者接口。
  • Reading 6(规格说明)与 Reading 7(设计规格)accepton 都需要规格;evaluate 的前置条件”map 覆盖所有变量”是典型例子。
  • Reading 3(测试)与 Reading 15(相等性):访问者是纯函数值时最容易测试(给定公式与访问者,断言结果);变换型访问者返回新树,需要 equals 才能写出简洁的断言。
  • Reading 4(代码评审)instanceof + cast 的分派链、default: throw new AssertionError(...) 都是评审中一眼可见的坏味道;访问者是”如何把这类坏味道改造掉”的示范。

关键要点

  • 双重分派是访问者的心脏obj.accept(visitor) 用第一次动态分派选中变体,变体再用 visitor.on(this) 用第二次动态分派选中操作分支;两次分派合起来替代了”类型 switch”,且全程不需要 cast。
  • 按变化轴选模式:变体常增 → 解释器模式(加变体只加一个类);操作常增 → 访问者模式(加操作只加一个类);两者都重要时可以混合使用。
  • instanceof + cast 的”模式匹配”不安全:静态检查失效,新增变体会静默出错或运行时崩溃;这是必须消除的坏味道。
  • 访问者优先写成不可变函数值:额外参数由构造函数注入、字段 final、结果通过返回值传递;写清 AF/RI,绝不返回内部可变集合(表示暴露)。
  • 访问者是”类型上的 switch + 迭代器 + 一等函数”:它让客户端可以在不改动数据类型的前提下定义自己的操作,这也是 AST/解析树提供访问者接口的原因。

常见陷阱与注意事项

  1. accept 里做别的事(或忘记写 visitor.on(this) → 双重分派断链,访问者永远收不到正确分支;accept 应当只有一行。
  2. 新增变体时忘记更新所有访问者 → 若接口方法无 default,编译失败(还算安全);一旦为图方便加了 default 或落进 else 分支,就会静默返回错误结果(例如变量集合漏掉新变体里的变量)。
  3. instanceof + 强制转型替代访问者 → 静态检查失效、ClassCastException 风险、每个新操作重复一遍分派链,新增变体时既可能崩也可能给出错误答案。
  4. 把访问者写成有状态且被复用的对象 → 结果跨遍历泄漏、null 累积器、并发下互相污染;应改为无状态或”只在单次遍历内累积、返回不可变结果”。
  5. 在访问者里直接返回内部可变集合/列表 → 表示暴露,调用者一改就破坏访问者的 AF/RI;应返回 Set.copyOf(...)List.copyOf(...) 等不可变副本。
  6. <R> 硬塞多个结果 → 需要同时算出多个量时,若强行用副作用或 Object 装结果,会牺牲类型安全;应定义一个小结果类作为 R,或拆成两个访问者。

思考题(带答案)

问题 1:请逐步描述 f.accept(new VariablesInFormula()) 的执行过程,其中 fAnd(Or(Variable("P"), Variable("Q")), Not(Variable("R")))。说明每一步是动态分派还是静态检查在起作用,并解释为什么这个设计能在不使用任何 instanceof/cast 的情况下完成”按类型分派”。

答案:执行过程是:(1)f 的实际类型是 Andf.accept(...) 通过动态分派进入 And.accept,其方法体是 return visitor.on(this);;(2)visitor 的实际类型是 VariablesInFormula,因此 visitor.on(this) 再次通过动态分派进入 VariablesInFormula.on(And)——这是第二次分派,即双重分派;注意 this 的静态类型是 And,所以 Java 的重载解析(编译期、静态)选中 on(And) 这个重载;(3)on(And) 计算 and.left().accept(this) \|\| ... 的并集,先对左子式 Or(...) 递归:动态分派进入 Or.accept,再动态分派到 on(Or);(4)on(Or) 分别对 Variable("P")Variable("Q") 递归,二者都落到 on(Variable),返回 {"P"}{"Q"},并集成 {"P","Q"};(5)回到 on(And),对右子式 Not(Variable("R")) 递归:Not.accepton(Not)not.formula().accept(this)on(Variable) 返回 {"R"}on(Not) 原样返回 {"R"};(6)最后并集成 {"P","Q","R"}。整个过程没有一次 instanceof 或强制转型,因为”选哪个分支”完全由两次动态分派 + 一次重载解析完成:第一次分派用对象的实际类型确定所在变体,第二次分派用访问者的实际类型确定操作实现。静态检查在这一过程中负责保证:Visitor<R> 声明了每个变体的方法(漏实现无法编译)、accept 的签名与泛型一致、on 的参数类型与 this 兼容。因此,”类型分派”的职责被交给了类型系统本身,而不是运行时的类型测试。

问题 2:你的团队要为一个”报表表达式”类型实现操作:变体有 6 种(数字、变量、加、乘、求和、条件),预计每季度会新增 1–2 个变体(业务方不断提出新算子),而操作基本固定(求值、类型检查、打印)。团队中有一位同事主张用访问者模式,因为”访问者是 6.031 推荐的做法”。请给出你的判断与理由,并说明如果要同时给外部客户提供”自定义操作”的能力,你会怎么设计。

答案:应当选择解释器模式(把操作声明为接口上的实例方法,在每个变体类中实现),因为团队的变化轴是”变体频繁增加“而操作稳定。用访问者模式的话,每次新增算子都要修改 Visitor 接口,于是所有既有访问者实现——包括客户端写的——都会编译失败,被迫新增一个自己可能无法正确实现的分支(通常只能写 UnsupportedOperationException),这是典型的破坏性变更;而解释器模式下新增变体只是新增一个类,既有代码零改动。这与”访问者更容易加操作、解释器更容易加变体”的结论完全一致(表达式问题)。如果同时要给外部客户提供自定义操作的能力,可以采用混合设计:把稳定的核心操作(求值、类型检查、打印)留在接口上作为实例方法,同时提供 accept(Visitor<R>) 作为扩展点,让客户实现自己的遍历;并且要考虑两类风险的代价:(1)新增变体时会让既有访问者编译失败——可以通过「补充说明」中的 Java 8 default 方法缓解,但要接受”忘记实现会静默走默认行为”的代价,或者在 default 实现里统一抛 UnsupportedOperationException 并配以测试;(2)accept 必须作为规格的一部分被文档化并测试(每个变体都应有一行 return visitor.on(this); 的测试或等价断言)。最后,把决策理由(哪条轴在变、代价是什么)写进设计说明,这样下一个维护者不会推翻它。

问题 3:为什么课程的 EvaluateVisitor 要把 Map<String,Boolean> 通过构造函数传进去并存在字段里,而不是给每个 on 方法都加一个 map 参数?请从接口设计、递归便利性、AF/RI 与不可变性的角度分析,并说明这种做法在并发场景下的含义。

答案:(1)接口设计Visitor<R> 的每个 on 方法签名由数据类型决定(参数是变体对象,返回 R);如果给 on 加参数,访问者接口就必须为每个操作量身定制,无法用一个统一的 Visitor<R> 表示所有单参数函数,也会让 accept 的签名随之改变(accept 需要把额外参数透传下去,于是数据类型被迫知道操作的参数类型,破坏抽象边界)。(2)递归便利性:递归调用写的是 and.left().accept(this)——只传 this 一个参数;若需要额外参数,就必须写成 and.left().accept(this, map),把所有参数在整棵树上反复传递,代码冗长且容易传错。把参数存进字段后,递归调用保持极简。(3)AF/RI:这样做的代价是访问者”有状态”,因此必须写清 AF(”该对象表示函数 formula ↦ 在 map 下求 formula 的值“)与 RI(”map 非 null,且在调用前包含公式中出现的所有变量”),并在构造函数与 checkRep 中检查。(4)不可性与并发:关键是这些字段是 final 且只读的——访问者只是在”出生”时固定了参数,此后从不修改,因此它仍然是不可变对象,可以被安全地共享、复用、并发使用(多个线程各自用同一个 EvaluateVisitor 求值互不干扰);相反,如果字段可变且遍历中会被写入(如累积器),那就变成有状态可变对象,必须保证每个线程用独立实例,否则会出现结果污染与数据竞争。因此”构造函数注入参数”是”让函数携带上下文”的正确做法,而”遍历中修改字段”才是需要警惕的可变状态。