Visitor Pattern 与 Finally Tagless:解决表达式问题

by

说到设计模式,大家一定会想到世界上著名的『面向对象编程语言』(棒读)Java。以及一大群认为动态类型编程语言比静态类型编程语言更『灵活』、设计模式解决的问题在动态类型编程语言里面都不是问题的人在各大娱乐网站发表的谜样の言论。 这篇文章虽然说是讨论设计模式,但是不是讨论这种问题的(2019 年了,同学)。我们站在一个更 PLT 的层次来看其中一个(我觉得还比较巧妙的)设计模式——Visitor 模式,以及它在函数式编程中对应的函数式编程的『设计模式』。

Expression Problem

最早的时候,我以为 Visitor 只是为了在 Java 里面模拟模式匹配用的。比如,考虑这段幼儿园水平的 Haskell 代码:

data Exp
  = Lit Int
  | Add Exp Exp

eval :: Exp -> Int
eval (Lit n) = n
eval (Add a b) = eval a + eval b

在 Java 里面写起来会很痛苦,因为 Java 没有『代数数据类型』的概念,也没有 sealed class。啊先不说这么多,我们先写一个:

interface Exp { int eval(); }

class Lit implements Exp {
  int x;
  public int eval() { return x; }
}

class Add implements Exp {
  Exp l, r;
  public int eval() { return l.eval() + r.eval(); }
}

此处为了代码简洁性,略去了构造函数。可以看出两种实现在可维护性上的区别:

看起来这是一个鱼和熊掌的模型,两者互相有不同之处。

Java 为了解决这个问题,引入了 Visitor 模式:我们换一种方法『引入操作』,即提供 visit(又叫 accept)接口。 我们使用 Visitor 设计模式重写前面的 Java 代码:

interface Exp { <A> A visit(Visitor<A> vis); }

interface Visitor<A> {
  A lit(Lit a);
  A add(Add a);
}

class Lit implements Exp {
  int value;
  public <A> A visit(Visitor<A> vis) { return vis.lit(this); }
}

class Add implements Exp {
  Exp left, right;
  public <A> A visit(Visitor<A> vis) {
    return vis.add(this);
  }
}

class Eval implements Visitor<Integer> {
  public Integer lit(Lit a) { return a.value; }
  public Integer add(Add a) { return a.left.visit(vis) + a.right.visit(vis); }
}

这样的话,我们的代码变得很 Haskell 了——『操作』和 class 互相独立(而不是像 class 和方法一样紧耦合):

这就很像 Haskell 那种使用模式匹配的方式定义数据类型了。

这个双向扩展的问题,叫做Expression Problem出处(9102 了怎么还是 http))。

后来,在看了浙江大学一位极为优秀的编程大牛的文章(前面的代码基本是参考的这篇文章的。如果你知道如何使用 Visitor 进行双向扩展,那么就不需要点击这个链接了)后,我才知道 Visitor 可以同时支持可扩展地添加新的操作和数据构造器的。 这种做法似乎叫 Object Algebra。

那么,Java 就同时在保留自己的优点的情况下,借助设计模式,解决了 Haskell 的问题。是不是可以说,Java 大法好,垃圾 Haskell 了?其实不行,人家 Haskell 也有『设计模式』解决这个问题。

解决方案叫——Finally Tagless。

Finally Tagless

这是原本的 AST,它长这样。

data Exp
  = Lit Int
  | Add Exp Exp

我们把它写成 GADT 的形式,看起来更顺眼一些(方便思路转换):

data Exp a where
  Lit :: Int -> Exp Int
  Add :: Exp Int -> Exp Int -> Exp Int

我们把它变成 typeclass,这个东西的形状和前面 Java 的 Visitor 几乎一模一样(这里没有解释这个转换是怎么想出来的,下面的延伸阅读里面有)。 变化的规则是,我们把 Exp 的每个构造器表示成函数(typeclass 里面的抽象方法),然后把它们的返回类型通过一个 typeclass 的参数(下面代码中的 a)抽象出来:

class Visitor a where
  int :: Int -> a
  add :: a -> a -> a

这和基于 Object Algebra 的 Visitor 是完全一致的——后者提供的 Visitor 可以在外部被扩展,而这正是 typeclass 与生俱来的性质。我们利用 typeclass 的这一天然性质,实现了 Visitor 实现的一切。这种方法就叫 Finally Tagless。

我们可以添加 type instance,作为『操作』(这是伪代码,我们假设一下平凡的 Applicative 实现):

instance Visitor Int where
  int = id
  add = (+)

instance Visitor String where
  int = show
  add a b = "(" ++ a ++ " + " ++ b ++ ")"

然后如果要执行『操作』,就先构建 AST,然后显式指定 AST 的类型,编译器就会使用那个特定的 type instance 去真正的把这颗 AST『折叠』起来。比如:

sampleAst :: Visitor m => m
sampleAst = add
  (add (int 114) (int 514))
  (add (int 1926) (add (int 8) (int 17)))

evaluatedAst = sampleAst :: Int
dumpedAst = sampleAst :: String

下面讲讲两者的对应关系,Visitor 的代码直接抄前面引用的文章的:

Visitor

原本提供的接口:

interface Visitor<E> {
  E lit(int x);
  E add(E a, E b);
}

添加新的操作

比如,添加一个打印 AST 的操作:

@FunctionalInterface
interface Dump { String dump(); }

class DumpVisitor implements Visitor<Dump> {
  public Dump lit(final int x) { return () -> x.toString(); }

  public Dump add(final Dump a, final Dump b) {
    return () -> "(" + a.dump() + " + " + b.dump() + ")";
  }
}

和一个求值的操作:

@FunctionalInterface
interface Eval { int eval(); }

class EvalVisitor implements Visitor<Eval> {
  public Eval lit(final int x) { return () -> x; }

  public Eval add(final Eval a, final Eval b) {
    return () -> a.eval() + b.eval();
  }
}

添加新的数据类型

比如,添加一个表达减法的数据类型:

interface SubVisitor<E> extends Visitor<E> { E sub(E a, E b); }

由于前面已经扩展了 EvalDump,我们分别再不修改已有代码的情况下把前面的功能扩展出来:

class EvalSubVisitor extends EvalVisitor implements SubVisitor<Eval> {
  public Eval sub(final Eval a, final Eval b) {
    return () -> a.eval() - b.eval();
  }
}

class DumpSubVisitor extends DumpVisitor implements SubVisitor<Dump> {
  public Dump sub(final Dump a, final Dump b) {
    return () -> "(" + a.print() + " - " + b.print() + ")";
  }
}

注意到整个过程中都没有冗余代码出现,Object Algebra 成功地解决了它们之间的耦合问题!

Finally Tagless

原本提供的接口:

class Visitor a where
  lit :: Int -> a
  add :: a -> a -> a

这里好像没说怎么构建 AST,那么我构建一个:

constructAst :: Visitor a => a
constructAst = add (lit 114) (lit 514)

(好像没什么必要说,太 trivial)

添加新的操作

比如,添加一个打印 AST 的操作:

instance Visitor String where
  lit = show
  add a b = "(" ++ a ++ " + " ++ b ++ ")"

和一个求值的操作:

instance Visitor Int where
  lit = id
  add = (+)

添加新的数据类型

比如,添加一个表达减法的数据类型:

class Visitor a => SubVisitor a where
  sub :: a -> a -> a

由于前面已经扩展了 Visitor StringVisitor Int,我们分别在不修改已有代码的情况下把前面的功能扩展出来:

instance SubVisitor Int where
  sub = (-)

instance SubVisitor String where
  sub l r = "(" ++ l ++ " - " ++ r ++ ")"

注意到整个过程中都没有冗余代码出现,Finally Tagless 成功地解决了它们之间的耦合问题!

总结

Haskell 的版本很明显要比 Java 版本简洁很多,而且不需要想很多名字(Java 那边起了一堆名字,什么 Eval 什么 Dump,Haskell 那边都是平凡的 instance)。但是反过来,Java 版本拥有同时存在多种类型相同的操作的可能性,而 Haskell 就没那么方便了,因为直接拿类型对应的。

这个问题,如果使用first class module(aka dependent record)实现的 typeclass,就可以变得和 Java 一样给这些不同的 instance 起名字,同时保留 Haskell 的简洁性。

延伸阅读

本文并不是讲解 Finally Tagless 的,只是讲解它和 Visitor 这种设计模式之间的对应关系的。 对于 Finally Tagless 本身的讲解和推导,可以看下面的文章。

练习

CodeWars Kata: Finally Tagless Interpreter

我时隔多年又回到了 CodeWars 做题了……真是一种怀念的感觉。

我说完了

祝大家新年快乐,2019 心想事成。

明天就要离开中国了……










啊,东方幕华祭真好玩啊,搞得我都想搞斗金 STG 了……还有点想学作曲。

Tweet this
Top


Create an issue to apply for commentary

License

This work (Visitor Pattern 与 Finally Tagless:解决表达式问题) is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

License