must return a value怎么解决_Visitor Pattern 与 Finally Tagless:解决表达式问题

5a61335136bb708b1262df63551cc60e.png

原文链接:ice1000.org

说到设计模式,大家一定会想到世界上著名的『面向对象编程语言』(棒读)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(); }
}

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

  • Haskell 版本
    • 如果想给 Exp 添加新的操作(比如输出整颗 AST),只需要新写一个函数就是了
dump :: Exp -> String
dump (Lit n) = show n
dump (Add a b) = "(" ++ dump a ++ ", " ++ dump b ++ ")"
    • 如果想给 Exp 添加新的数据构造器(比如加个 Sub 表示减),就得修改所有的函数
  • Java 版本
    • 如果想给 Exp 添加新的数据构造器(比如加个 Sub 表示减),加个 classimplements Exp 就是了
class Sub implements Exp {
  Exp l, r;
  public int eval() { return l.eval() - r.eval(); }
}
    • 如果想给 Exp 添加新的操作(比如输出整颗 AST),需要在接口和所有实例中增加新的方法

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

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(left.visit(vis), right.visit(vis));
  }
}

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

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

  • 如果想给 Exp 添加新的操作(比如输出整颗 AST),只需要新写一个 classimplements Visitor<> 就是了
class Dump implements Visitor<String> {
  public String lit(Lit a) { return String.valueOf(a.value); }
  public String add(Add a) { return "(" + a.visit(this) + ", " + b.visit(this) + ")"; }
}
  • 如果想给 Exp 添加新的数据构造器(比如加个 Sub 表示减),就得修改所有的 Visitor

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

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

后来,在看了浙江大学一位极为优秀的编程大牛的文章(前面的代码基本是参考的这篇文章的。如果你知道如何使用 Visitor 进行双向扩展,那么就不需要点击这个链接了)后(知乎id @zjuwyd ),我才知道 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 本身的讲解和推导,可以看下面的文章。

  • 一个语文极好的人写的『一不小心发明 de Bruijn Indices, SKI 组合子和 Finally Tagless』
  • 一个语文极差的人写的『解决 HOAS 无法 look into 的问题,同时一不小心发明 SKI 组合子』

练习

CodeWars Kata: Finally Tagless Interpreter

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

我说完了

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

明天就要离开中国了……

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值