ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

表达式问题:从Visitor到Typeclass与tagless final

表达式问题:从Visitor到Typeclass与tagless final

Expression Problem,中文一般叫「表达式问题」。我工作这些年,见过太多代码库在两种扩展方向上来回摆动:一种写法容易加新操作,另一种写法容易加新类型,两头都顺的写法却很少见。很多看起来优雅的设计模式,在真正面对频繁需求变更时,往往只解决了一半问题。

这篇东西我不会只讲理论。我会把 Visitor、Typeclass、Trait、tagless final 这些方案串起来,聊聊它们各自解决什么、留下什么尾巴,以及在跨语言环境下怎么权衡性能和扩展性。适合正在设计核心模型、做库抽象,或者因为“加个新类型要改所有函数”而头疼的开发者。

1. 先从“困局”说起:表达式问题到底是什么

1.1 一个问题,两个维度

不妨想象你有一棵表达式树。节点类型可能是常量、加法、乘法。针对这棵树,你会有求值、打印、求导这类操作。

现在业务方提出了两种变更:

  • 想新增一种节点类型,比如“减法”。这就是在类型维度上扩展。
  • 想新增一种操作,比如“求变量个数”。这就是在操作维度上扩展。

如果把类型和操作放在一个矩阵里,你会发现两个维度的扩展难度是相反的。

在面向对象语言里,你通常把操作定义成每个节点上的方法。想新增“减法”这个类型,只要新增一个类并实现既有方法就行,很轻松。但想新增一个“求变量个数”的操作,你就得给所有已有节点类都加上这个方法,改一堆文件,破坏性不小。

在函数式语言里,你通常会定义一个代数数据类型,然后写一批模式匹配函数。新增操作就是新写一个函数,极其顺畅。但新增类型就需要改动所有旧函数里的匹配分支,因为它们现在不穷尽了,编译器会报错,你还是得改一堆文件。

两个维度总有一个是容易的,另一个是痛苦的。这个问题非常基础,所以很早就有了名字,叫 Expression Problem,意思是:在表达式(数据结构)设计上,我们能不能找到一种编码方式,让类型和操作都能在不改动旧代码的前提下自由扩展。

1.2 一个具体的表达式示例

为了后面讨论不飘,先给出一段最朴素的实现。这是函数式风格的 Rust 版本:

enum Expr { Lit(i64), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>), } fn eval(e: &Expr) -> i64 { match e { Expr::Lit(n) => *n, Expr::Add(a, b) => eval(a) + eval(b), Expr::Mul(a, b) => eval(a) * eval(b), } }

这段代码干净、穷尽、编译器会帮你保证所有情况都处理到。你很快就能写出求导函数、打印函数,每个都是新的match,加操作非常舒服。

但如果你想加一个Expr::Sub,你就会发现eval必须改,之前写的show、deriv也全要改。每加一种新节点,所有既有操作都要再改一遍。如果你维护的是一个被外部 crate 依赖的库,这个改动就是一种破坏性变更。

再看面向对象风格的 Java 版本:

interface Expr { int eval(); } final class Lit implements Expr { final int value; // constructor... public int eval() { return value; } } final class Add implements Expr { final Expr left, right; // constructor... public int eval() { return left.eval() + right.eval(); } }

这个版本加新类型很容易,直接新增一个实现类就行。但加操作很难,你得改Expr接口,再让所有实现类跟上。两种风格正好卡在两个极端上。

表达问题要挑战的,就是这两个极端之间的无人区。

2. 经典解法的成与败:Visitor 与双分派的代价

2.1 Visitor 为什么能解一半

面向对象社区很早就发现了这个问题,并且给出了一个带有妥协意味的方案:Visitor 模式,中文叫访问者模式。

Visitor 核心思想是双分派。普通虚函数分派一次,动态地确定了接收者的类型。访问者模式再加一层:节点对象接受一个访问者对象,访问者对象再反过来调用节点对应的重载方法。通过两次动态分派,把“节点类型”和“操作类型”同时作为分派依据。

看一个简化但能说明问题的 C++ 例子:

struct Visitor; struct Expr { virtual ~Expr() = default; virtual void accept(Visitor& v) = 0; }; struct Lit : Expr { int value; void accept(Visitor& v) override; }; struct Add : Expr { Expr* left; Expr* right; void accept(Visitor& v) override; }; struct Visitor { virtual void visit(Lit& n) = 0; virtual void visit(Add& expr) = 0; };

每个新增操作就是继承Visitor实现一个新类,比如Evaluator实现visit(Lit&)和visit(Add&)。你看,新增操作不需要改任何既有节点类,这很划算。这也是为什么很多 AST 工具、编译器前端都喜欢用 Visitor。

但再仔细看,问题也很明显:Visitor基类里必须提前写好所有节点类型的visit方法。一旦你想加一个Sub类型,不仅Sub类要实现accept,你还要往Visitor基类里加一个visit(Sub&)纯虚函数。这会波及所有已有的 Visitor 实现类。曾经写过的求值器、打印器全都得补上新的visit方法。

所以 Visitor 的本质,是把“新增操作”的难度转移到了“新增类型”上。它只解决了表达问题的一半,而且是靠牺牲另一头的扩展性换来的。

2.2 新类型加入何以尴尬

有人可能觉得,给Visitor基类加一个默认实现不就行了?比如:

struct Visitor { virtual void visit(Lit& n) = 0; virtual void visit(Add& expr) = 0; virtual void visit(Sub& expr) {} };

这样已有 Visitor 实现类即使不改也不会编译失败。但你细想:默认空实现意味着某个操作对Sub节点没有定义,运行时只能静默跳过或者抛异常。对于求值这种操作,遇到减法却不处理,结果就是错的。默认实现只是把编译期错误延迟到了运行期。

也有人会说,在 Java 里可以用instanceof做类型判断,或者用模式匹配。但这又回到了手写类型分派的道路,既不穷尽也不安全。

这种尴尬的本质是:开闭原则指的方向,和类型系统的能力发生了冲突。开闭原则说,对扩展开放、对修改关闭;但经典虚函数机制只在“类型维度”上开放,没有给“操作维度”留下扩展点。

2.3 设计模式融合的第一层:开闭原则

很多讲设计模式的书会把 Visitor、Strategy、Command 分开讲,好像它们是互相无关的套路。但从表达问题这个视角往回看,你会发现它们其实都在解决同一件事:怎么把变化点抽象成一个可插拔的扩展面。

Strategy 模式是把“算法”抽成一个对象,替换算法时不改上下文。Visitor 是把“对数据结构的操作”抽成一个对象,增加操作时不动数据结构。两者的核心都是依赖注入,只不过注入的粒度不同。

我自己喜欢把表达问题当成设计模式的一个“统一场论”:打散成 OOP 术语,就是接口加组合;打散成函数式术语,就是 ADT 加高阶函数;打散成现代语言术语,就是类型类或 Trait。理解这条主线之后,你再看任何模式,都会自动思考一个问题:它在哪个维度上开放,又在哪个维度上封闭?这比背模式目录有用得多。

3. 现代语言的合流:Typeclass、Trait 与 tagless final

3.1 Haskell 的 Typeclass:把操作变成接口约束

如果你两种扩展都想要,一个比较扎实的方向是把“操作”定义成接口约束,而不是把操作硬塞进数据结构里。Haskell 的Typeclass,Rust 的Trait,Scala 的Typeclass本质都是这个思路。

先看 Haskell 风格。我们不把Expr定义成一个封闭的 ADT,而是把每种节点当成独立的类型,然后定义一个Eval类型类,凡是可以求值的类型都实现它:

class Eval a where eval :: a -> Int data Lit = Lit Int instance Eval Lit where eval (Lit n) = n data Add a b = Add a b instance (Eval a, Eval b) => Eval (Add a b) where eval (Add a b) = eval a + eval b data Mul a b = Mul a b instance (Eval a, Eval b) => Eval (Mul a b) where eval (Mul a b) = eval a * eval b

现在想新增一个操作,比如showExpr,你只需要再定义一个新的类型类ShowExpr,并给每种节点补一个实例:

class ShowExpr a where showExpr :: a -> String instance ShowExpr Lit where showExpr (Lit n) = show n instance (ShowExpr a, ShowExpr b) => ShowExpr (Add a b) where showExpr (Add a b) = "(" ++ showExpr a ++ " + " ++ showExpr b ++ ")"

想新增一个类型,比如Sub a b,你同时给它补Eval和ShowExpr的实例。旧代码完全不用动:

data Sub a b = Sub a b instance (Eval a, Eval b) => Eval (Sub a b) where eval (Sub a b) = eval a - eval b instance (ShowExpr a, ShowExpr b) => ShowExpr (Sub a b) where showExpr (Sub a b) = "(" ++ showExpr a ++ " - " ++ showExpr b ++ ")"

这种写法在函数式编程圈子里有个名字:tagless final,意思是“没有标签的最终编码”。传统Expr枚举里的每个构造器相当于一个显式标签,运行时需要打 tag 做模式匹配;而 tagless final 里节点类型就是结构本身,没有统一 tag,每个解释器各取所需。

3.2 Rust 的 Trait 与孤儿规则

Rust 没有严格意义的 Haskell Typeclass,但 Trait 基本给出了同样的能力。上面 Haskell 的写法可以直接翻译成 Rust:

trait Eval { fn eval(&self) -> i64; } struct Lit(i64); impl Eval for Lit { fn eval(&self) -> i64 { self.0 } } struct Add<L, R> { l: L, r: R } impl<L: Eval, R: Eval> Eval for Add<L, R> { fn eval(&self) -> i64 { self.l.eval() + self.r.eval() } }

加操作就是再定义一个 Trait:

trait Show { fn show(&self) -> String; } impl Show for Lit { fn show(&self) -> String { self.0.to_string() } } impl<L: Show, R: Show> Show for Add<L, R> { fn show(&self) -> String { format!("({} + {})", self.l.show(), self.r.show()) } }

加类型就是补实现。两种扩展都能做到不改旧代码。这就是我工作中最常用的“可扩展设计”起点。

但 Rust 有一条很关键的约束:孤儿规则(Orphan Rule)。它规定,你只能为本地类型实现外部 Trait,或者为外部类型实现本地 Trait,不能同时跨越两个 crate 的所有权。这条规则限制了你在下游代码里凭空为别人的类型补充实例。

举个例子,你想为自己的ShowTrait 实现第三方库里某个DateTime类型,这是允许的,因为Show是你的本地 Trait。但如果你想实现标准库的Display,给第三方库的DateTime补输出格式,就不行。反过来,若第三方库的类型和第三方的Serialize你都想用,就只能在本地包一个 newtype 再去实现。这个包壳成本会影响一层抽象,但从工程角度看,它换来的是实例的一致性,防止了隐式冲突。设计库边界时一定要搞清楚这条规则,否则下游用户会很难受。

3.3 跨语言方案演进对照

不同语言社区在应对表达问题时,演化出了各种风格。用一个表格来对照,可能比长篇大论更清楚:

语言传统风格扩展方向表达问题解法
Java / C++接口 / 虚函数新类型容易,新操作困难Visitor 作为补救
Haskell / OCamlADT + 模式匹配新操作容易,新类型困难Typeclass、tagless final
Scalacase class + 模式匹配偏向函数式Typeclass,Scala 3 的 given 更轻量
Rustenum + match 或 trait + 泛型两种都能写推荐 trait + 泛型,注意孤儿规则
Gointerface + type switch介于两者之间偏 OOP,动态注册机制补足
Pythonduck typing两者都灵活动态分发 + 注册表,但安全性弱

我从这个表格里得到的体会是:语言不决定你能不能解决表达问题,语言决定的是你能付出多少代价解决它。动态语言靠运行时灵活性,什么问题都能解,但问题都挪到了运行时;静态语言里,Rust 和 Haskell 这类拥有高阶类型能力和强大编译期检査的语言,更接近问题的最终答案。

4. 性能权衡:vtable、单态化与数据布局

4.1 四种编码的性能模型

扩展性和性能往往是拉扯的。不少人问过我:用 Trait 泛型写了一大堆嵌套类型,会不会比直接匹配枚举慢?这里要分清楚几种不同的运行时开销。

第一种是 Rust 里enum + match。这是最贴近“免费”的写法。枚举值就是带 tag 的内存布局,match编译出来通常是一次 tag 比较加若干分支跳转,热点情况下编译器还会自动做内联。数据就在栈上或者直接内嵌在容器里,缓存非常友好。坏处是类型集合封闭,一旦外部想要扩展新类型,这条路走不通。

第二种是泛型单态化,Rust 的impl Trait for ConcreteType在编译后会生成具体的代码。Add<Lit, Lit>、Add<Lit, Mul<Lit, Lit>>各自有独立的机器码,没有虚函数调用,绝大多数情况下也可以内联。代价是代码膨胀:如果组合很多,二进制体积会涨。指令缓存压力可能变大,有时反而比动态分派慢。所以单态化不是绝对快,它只是统计意义上通常更快。

第三种是 Trait Object,即&dyn Eval或者Box<dyn Eval>。这是一个胖指针,一份数据指针加一份虚表指针,一共 16 字节。每次调用eval()都要走一次虚表间接跳转,数据又通常被推到堆上,缓存友好度下降。好处是类型集合完全开放,而且同一个调用点可以处理所有实现了Eval的类型,代码体积小。这与 Java 接口、C++ 虚函数是同一个模型。

第四种是 Haskell 那种 Typeclass 字典传递。eval的每个实例本质上是把一组函数打包成一个字典,运行时额外传递这个字典。Haskell 编译器优化好时能把字典内联掉,但复杂情况下字典传递和间接调用依然存在。它的性能特征更接近一种“能内联但未必内联”的动态分发。

把这些放在一起,性能从好到差大致的排序是:单态化泛型、枚举匹配、Typeclass 字典、Trait Object 动态分发。但这只是“大概率排序”,具体项目里要打问号。

4.2 热点代码怎么选

我处理过的几个项目里,最容易被性能歧视的其实是 Visitor 模式。访问者要做到双重分派,相当于一次操作里发生了两次虚函数调用,再加上业务逻辑本身,开销比直接的match高出不少。如果这个操作在循环里跑了几百万次,差异立刻能观察到。

所以我的建议是,先画一条线:

  • 核心热点路径、类型集合稳定时,优先enum + match,或者 Rust 泛型 trait。不要为了所谓的“未来可扩展”给自己上性能包袱。
  • 库的公共 API、需要外部插件扩展、调用频率不高的地方,用 Trait Object、动态注册或 Typeclass 都可以。
  • 如果你写的是一个 AST,解析热路径上用enum做节点,在边界处用 Visitor 暴露给外部插件,两个方案并不矛盾。也就是说,不要对一种技术产生宗教式崇拜。

数据处理上空指针问题也要注意。Box<dyn Expr>把对象放在堆上,只留下一个指针,处理Lit这种 8 字节的小对象也要额外一次内存访问。而泛型版本里Lit可以直接嵌在Add<Lit, Lit>结构体里,连续内存碰两次,性能差很多。还有一点:dyn Trait不允许按值传递,必须通过引用或智能指针,这也会限制你可用的内存布局优化。

4.3 测量优先

说句大实话:很多性能上的直觉都是错的。现代编译器非常聪明,一个看似低效的 Typeclass 调用可能被内联成完全静态的代码;一个看似高效的泛型写法也可能因为代码膨胀导致指令缓存不命中。

我建议在选型时真的跑一下基准测试。Rust 有 criterion,Haskell 有 criterion,Java 有 JMH。先写出两种候选实现,用代表性数据跑一轮,再决定走哪条路。我在一个模拟项目里就把 tagless final 的表达式树跑过一轮,结果单调的eval热点路径上它和enum + match几乎持平,因为泛型被完全单态化了;但换成长递归的打印操作,单态化版本代码膨胀导致速度还慢了一点。测量结果和离线推演完全不同。

简单总结,性能权衡不是“扩展性成品质”的权衡,而是“实现路径的权衡”。让你难受的往往是不该抽象的地方过度抽象,而不是抽象本身。

5. 实战选型与避坑建议

5.1 一个决策清单

这几年的实践里,我慢慢形成了一套选型决策清单。每次设计一个会被多人扩展的核心模型,我都会过一遍。

  • 第一问:未来更可能加新类型,还是加新操作?如果拿不准,问需求方最近三个迭代里发生了什么。频繁加类型的业务,适合接口加实现的 OOP 路线;频繁加操作的业务,适合枚举加模式匹配的路线。
  • 第二问:这个模型是库的内部实现,还是对外暴露的公共 API?内部实现完全可以用封闭枚举,外部用户不该看到你的所有节点,也就无所谓开放扩展。公共 API 才需要认真考虑扩展点。
  • 第三问:第三方使用者会不会想为这个类型添加不属于你控制的实现?如果会,请提前设计好 Trait 边界,并把孤儿规则的后果想清楚。
  • 第四问:性能要求是否明确?如果没有压测数据支持“这里必须最快”,先写可扩展性更好的版本,让性能问题等到有真实数据再解决。

这四问看起来简单,但我见过太多项目从一开始就把方向定死在某个风格上。比如一个内部小工具,非得上完整插件化架构,结果所有节点都变成了Box<dyn Trait>,开发复杂度和运行开销都上去了,收益却约等于零。选型要有止损线。

5.2 避坑实录

第一坑:给枚举加新变体导致所有match报错,就以为是编译器找麻烦。其实这是编译期穷尽检查在保护你。遇到这个情况,先判断新类型是不是“领域内”的概念。如果是,直接改全部match分支是正确的,因为“新类型加入就修改相关函数”本来就应该是显式变更。如果不是,比如新类型来自某个插件生态,那就说明你的核心模型选错了:应该从开始就用 Trait 风格开放操作。

第二坑:为了实现完全开放,把所有节点定义成独立类型并让它们互相嵌套。这个写法会让类型签名变得很长,比如Add<Lit, Mul<Add<Lit, Lit>, Lit>>。更麻烦的是,后续加一个“统一重命名”之类的全局转换操作时,你要在 Trait 里返回Self还是别的类型,很受折磨。更实用的方式是用类型别名简化:

type SimpleExpr = Add<Lit, Mul<Lit, Lit>>;

这样日常使用里的签名不至于失控。同时,把“结构递归”和“操作递归”分开。结构递归交给数据类型完成,操作递归集中写好基例,不要让业务代码到处展开结构。

第三坑:Visitor 基类新增纯虚函数,导致所有实现类需要同步修改。如果你在一个中期项目里,被迫往基类加方法,这通常表明类型集合并不封闭,Visitor 可能一开始就是错误的选择。止损方法是给新方法提供带默认行为的实现,但你要明确这种默认行为的语义,否则运行期出错更难查。

第四坑:过度相信动态语言。Python 的singledispatch、注册表、装饰器确实能拜拜解决扩展问题:

from functools import singledispatch @singledispatch def eval_expr(expr): raise TypeError(f"unsupported: {type(expr)}") @eval_expr.register def _eval_lit(expr: Lit): return expr.value

但动态方案的约束全靠约定。别人少写一个@register函数,错误只在运行期浮现。如果你愿意付出这种代价,没问题;但如果你在做一个团队共享的 SDK,其实你会需要类似 Rust Trait 那样的编译期约定。

5.3 设计一个“两条腿走路”的库边界

最后给一个我比较常用的模板。核心场景是:我想在内部享受enum + match的穷尽性与性能,但依然让外部用户能够扩展。

做法是把内部封闭模型和外部开放模型用一层薄薄的适配层隔开。内部定义封闭枚举:

enum CoreExpr { Lit(i64), Add(Box<CoreExpr>, Box<CoreExpr>), }

对外定义开放 Trait:

trait ExprOp { fn eval_core(&self, core: &CoreExpr) -> i64; fn eval_external(&self, external: &dyn ExternalExpr) -> i64; }

内部函数走match,外部扩展通过 Trait Object 注入。这样做必然有一点性能损耗,但损耗被限制在适配边界上,核心热点依然是封闭枚举。这有点像把一个系统的内核做成微内核,把可插拔能力限制在明文的接口处。

6. 一些更深的思考:模式融合和语言演进

6.1 从“模式融合”看 Visitor 与 Typeclass 的共同点

把 Visitor 和 Typeclass 摆在一起看,会发现它们的内核是惊人的相似。Visitor 是“操作对象化”,Typeclass 是“操作约束化”,它们都在把“操作”这个维度从数据主体中抽出来。区别在于 Visitor 依赖运行期虚表,Typeclass 依赖编译期实例解析。

这就回答了一个初学者常问的问题:如果我学设计模式,还要不要学 Typeclass?我的答案是,不要把它们当成两门知识。它们是在不同类型系统强度下,对同一个表达问题的两种近似解法。理解了问题的内核,你就有了一种翻译能力。Java 里的 Visitor 思路可以平移到 Rust Trait,Rust Trait 的思路也可以反向平移到 Java 的接口加泛型约束。真正值钱的不是某个模式的类图,而是你能在不同语言之间移植那种扩展性思维。

6.2 跨语言方案演进的主线

把视线拉长一点,跨语言方案的演进其实有一条主线:从运行期多态走向编译期多态。C++ 的虚函数、Java 的接口方法是动态分派的;Haskell 的 Typeclass 把分派信息打包成字典,在编译期决议;Rust 的 Trait 更进一步,倾向于单态化和静态派发,同时保留dyn作为显式逃逸口。Rust 的特别之处在于,它把“哪种扩展模型”的选择权交给开发者,而不是由语言替你决定。enum与match是函数式路线,trait与dyn是对象式路线,两种路线可以共存于同一份代码里,这种混合能力在主流静态语言里是很少见的。

Go 的接口更像是结构类型与动态派发的结合,比 Java 轻,但比 Rust 静态;Python 的动态分派让很多扩展问题消失于无形,却把安全责任全部还给开发者。每个语言都在表达问题上交出了自己的答案,有的靠类型系统,有的靠运行时灵活性。

6.3 我个人的一点体会

如果让我总结一个最重要的实操心得,那就是:永远不要把一个封闭模型当成永久开放来设计,也不要为了未来牺牲当下的清晰度。封闭有封闭的清楚,开放有开放的复杂。enum + match的好处是穷尽、直接、可读;trait + 泛型的好处是开放、可扩展、可插拔。两者各有价格。

真正的高手并不是永远选择开放那一边,而是能看出来:未来两年内这个模型到底会不会真的长出新的分支。判错没关系,重要的是预留出“从封闭迁移到开放”的路径。比如用 Rust 时先把核心枚举和数据操作分文件放,将来真要开放时,把操作挪到 Trait 实现里,改动面会小很多。

如果说还有什么建议,那就是多去看不同语言社区对这个问题的讨论。Haskell 的 tagless final 文章、Rust 的 trait 设计讨论、Java 的 Visitor 演进历史,其实都在讲同一个故事。把这些故事串起来看,你才会明白一个数据结构设计得好不好,不在于它今天有多优雅,而在于明天需求变化时你还能不能优雅。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进