基于元编程的可扩展访问者模式

本文探讨了在编程语言中解决表达式问题的挑战,并介绍了访问者模式作为应对策略。文章详细阐述了基于Java的EVF框架和Scala的Castor框架,这两种框架通过元编程实现了可扩展的访问者模式,以支持在不修改原有代码的情况下扩展数据结构和操作。通过对皮亚诺数的示例,展示了如何在面向对象和函数式编程中应用这些框架,以及它们如何简化代码生成和遍历模板。最后,文章对比了两种框架在实际项目中的应用和性能表现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

# 表达式问题 #

# 访问者模式#

# 第一部分:EVF#

# 一种基于 Java 的可扩展访问者模式

# 用EVF框架简化代码

# 遍历模版

# EVF的实现

# 第二部分:Castor #

# 一种基于 Scala 的可扩展访问者模式

# 用Castor框架简化代码

# GADT和模式匹配

# Castor的实现

# 案例分析#

# 结语#


本文以技术文章的方式回顾张炜昕老师在 SIG-元编程 技术沙龙上分享的议题《基于元编程的可扩展访问者模式》,感谢张老师为方便大家阅读梳理的文字版内容,此外,回顾视频已经上传 B 站,欢迎小伙伴们点开观看。

B站链接如下:SIG-元编程技术沙龙回顾|基于元编程的可扩展访问者模式


表达式问题 #

模块化和可扩展性是开发复杂软件系统的重要基础,而表达式问题(Expression Problem)则是检验编程语言对模块化和可扩展性支持程度的根本问题。表达式问题要求在不修改和重复既有代码、保障类型安全、分离编译和模块化类型检查的前提下同时扩展数据结构及其操作。传统的面向对象和函数式编程范式仅支持单一维度的扩展性。

我们将以皮亚诺(Peano)数及其扩展为例贯穿本文。一开始只支持零(Zero)和后继(Succ)来构造自然数和将皮亚诺表示转换为数字表示的求值操作(eval)。例如eval作用在 Succ(Zero)上的结果是 1。以下的 Scala 代码分别用面向对象和函数式实现了皮亚诺数:

//面向对象式
trait Tm { def eval: Int }
object Zero extends Tm {
  def eval = 0
}
class Succ(t: Tm) extends Tm {
  def eval = t.eval + 1
}

//函数式
sealed trait Tm
case object Zero extends Tm
case class Succ(t: Tm) extends Tm

def eval(t: Tm): Int = t match {
  case Zero     => 0
  case Succ(t1) => eval(t1) + 1
}

面向对象式是操作优先,先以接口描述数据结构所支持的操作,再用实现接口来定义数据结构。在面向对象式中,添加数据结构易而添加新的操作难。添加数据结构仅需定义新的子类比如前驱(Pred):

class Pred(t: Tm) extends Tm {
  def eval = t.eval - 1
}

而添加新的操作则需修改接口及其所有子类。相反,函数式是数据结构优先,先以代数数据类型来定义数据结构,再用模式匹配定义操作。在函数式中,添加操作易而添加新的数据结构难。添加操作仅需定义一个新的函数比如打印(print):

def print(t: Tm): String = t match {
  case Zero     => "Zero"
  case Succ(t1) => "(Succ " + print(t1) + ")"
}

而添加数据结构则需修改代数数据类型定义以及给已有函数增加一条新的模式匹配语句。


访问者模式#

那么如何在面向对象语言中实现操作扩展呢?这就需要借助访问者设计模式(Visitor Pattern)了。访问者模式将操作从类层次结构中分离出来从而允许添加新的操作而不修改既有的类层次结构,如下所示:

trait Tm {
  def accept[A](v: Visitor[A]): A
}
object Zero extends Tm {
  def accept[A](v: Visitor[A]) = v.zero
}
class Succ(val t: Tm) extends Tm {
  def accept[A](v: Visitor[A]) = v.succ(this)
}
//访问者接口
trait Visitor[A] {
  def zero: A
  def succ(x: Succ): A
}

访问者接口声明的访问方法(zero 和 succ)与类层次结构的每个子类(Zero 和 Succ)一一对应,用来实现类层次结构中的 accept 方法。类型参数 A 抽象了访问方法的返回类型。

操作通过实现访问者接口定义:

class Eval extends Visitor[Int] {
  def zero = 0
  def succ(x: Succ) = x.t.accept(this) + 1 //递归调用accept方法遍历子表达式
}

重复实现访问者接口即可添加新的操作,比如打印:

class Print extends Visitor[String] {
  def zero = "Zero"
  def succ(x: Succ) = "(Succ " + x.t.accept(this) + ")"
}

然而,传统的访问者模式只是转换了扩展维度并没有解决表达式问题:当我们想添加一个新的子类时,访问者接口并没有对应的访问方法来实现其 accept 方法。因此,需要修改访问者接口及已有的访问者。此外,访问者模式引入了样板代码(boilerplate code),使用起来较为繁琐。

为解决上述缺陷,本文将分别介绍基于 Java 和 Scala 两种语言的可扩展访问者模式元编程框架 EVF[1] 和 Castor[2]。


# 第一部分:EVF#

# 一种基于 Java 的可扩展访问者模式

让访问者模式可扩展的关键在于如何解耦访问者接口和类层次结构。用可扩展访问者模式定义的皮亚诺数访问者接口如下:

interface Visitor<Tm, A> {
  A Zero();
  A Succ(Tm t);
  A visitTm(Tm t);
}

通过新增类型参数 Tm 来抽象数据结构类型以及从 Tm 转换到返回值类型 A 的方法 visitTm 来解耦访问者接口和类层次结构。求值访问者的定义如下:

interface Eval<Tm> extends Visitor<Tm, Integer> {
  default Integer Zero() {
    return 0;
  }
  default Integer Succ(Tm t) {
    return visitTm(t) + 1; //递归调用visitTm方法遍历子表达式
  }
}

在定义具体的访问者时,Tm 保持抽象并通过递归调用 visitTm 方法实现对子表达式的遍历。这里运用 Java 8 引入的 default methods 使得访问者不仅可以扩展还能利用接口多重继承来组合访问者。

> 扩展

现在,前驱可以模块化地定义了:

//扩展访问者接口
interface ExtVisitor<Tm,A> extends Visitor<Tm,A> {
  A Pred(Tm t);
}
//扩展求值访问者
interface ExtEval<Tm> extends ExtVisitor<Tm,Integer>, Eval<Tm> {
  default Integer Pred(Tm t) {
    return visitTm(t) - 1;
  }
}

这些接口定义是为了复用而非使用,因此需要额外定义一些类来实体化这些接口才能创建对象:

//数据结构类型
interface CTm {
  <A> A accept(ExtVisitor<CTm, A> v);
}
interface VisitTm<A> extends ExtVisitor<CTm, A> {
  default A visitTm(CTm tm) {
    return tm.accept(this);
  }
}
//工厂模式
class Factory implements ExtVisitor<CTm, CTm>, VisitTm<CTm> {
  public CTm Zero() {
    return new CTm() {
      public <A> A accept(ExtVisitor<CTm, A> v) { 
        return v.Zero(); 
  }};}
  public CTm Succ(CTm t) {
    return new CTm() {
      public <A> A accept(ExtVisitor<CTm, A> v) { 
        return v.Succ(t); 
  }};}
  public CTm Pred(CTm t) {
    return new CTm() {
      public <A> A accept(ExtVisitor<CTm, A> v) { 
        return v.Pred(t); 
  }};}
}
class EvalImpl implements ExtEval<CTm>, VisitTm<Integer> {}

至此,我们可以用工厂创建表达式并用访问者对其进行遍历:

Factory f = new Factory();
CTm t = f.Pred(f.Succ(f.Zero()));
t.accept(new EvalImpl()); // 0

# 用EVF框架简化代码

可以看到可扩展访问者模式使用起来更加繁琐(例如为实例化所定义的类)。所幸,这些样板代码大多可以通过 EVF 框架自动生成。用户仅需在描述数据结构的接口上加上 @Visitor 注解,例如:

@Visitor interface Peano<Tm> {
  Tm Zero();
  Tm Succ(Tm t);
}

在 IDE 中(如 Eclipse)保存后即可生成包括访问者接口,数据结构类型,工厂等在内的源文件:

图片

 访问者的实现跟先前所示的手写几乎一致,唯一的区别在于使用了生成的代码:

interface Eval<Tm> extends GPeano<Tm, Integer> { /*同上*/ }

这里 GPeano 是生成的扩展访问者接口。类似地,扩展访问者的 EVF 定义如下:

@Visitor interface ExtPeano<Tm> extends Peano<Tm> {
  Tm Pred(Tm t);
}
interface ExtEval<Tm> extends GExtPeano<Tm, Integer>, Eval<Tm> { /*同上*/ }

由于 EVF 生成了包括数据结构、工厂等繁琐代码,用户只需要定义一些类来实体化访问者即可:

class EvalImpl implements ExtEval<CTm>, ExtPeanoVisitor<Integer> {}
ExtPeano<CTm> f = new ExtPeanoFactory();
CTm t = f.Pred(f.Succ(f.Zero()));
new EvalImpl().visitTm(t); // 0

遍历模版

另一大部分样板代码来源于遍历复杂的抽象语法树(AST)。很多情况下我们只关心某些特定节点,而其它大部分节点什么也不做或是只做简单地递归调用。遍历模版预定义了 AST 遍历,通过继承遍历模版,我们仅需重写(override)所关心的节点。遍历可以粗略地分为:查询(query)变换(transformation)—— 前者遍历 AST 计算一个值,后者构造一个新的 AST。EVF 生成了多种遍历模版供用户灵活选择。

> 查询

假设我们要为一个复杂的语言定义一些操作:

@visitor interface ComplexLang<Exp> {
 Exp Var(String x);
 ... // 其它许多语言结构省略
}

首先是一个查询操作,收集一个表达式中用到的所有变量名集合。其定义如下:

interface CollectVars<Exp> extends ComplexLangQuery<Exp, Set<String>> {
  default Monoid<Set<String>> m() {
    return new SetMonoid<>();
  }
  @Override default Set<String> Var(String x) {
    return Collections.singleton(x);
  }
}

通过继承生成的 ComplexLangQuery 模版,我们只需要重写 Var 访问方法以及提供一个 SetMonoid 对象作为 m 方法的实现即可。ComplexLangQuery 调用了 Monoid 接口定义了两个方法 empty 和 join 来为各访问方法提供了缺省定义。其中 empty 表示缺省值,用于实现 Var 这类不包含子表达式的访问方法而 join 是一个二元操作符用来结合子表达式的计算结果。这里集合就是幺半群(monoid)的一个实例,其中 empty 是空集而 join 是并集操作。其它的幺半群实例包括加、乘、列表等。

> 变换

接下来是一个变换的例子,将表达式中的某个变量替换成另一个表达式的操作定以如下:

interface Subst<Exp> extends ComplexLangTransform<Exp> {
  String x(); //变量名
  Exp y(); //替换成的表达式
  @Override default Exp Var(String z) {
    return z.equals(x()) ? y() : alg().Var(z);
  }
}

通过继承生成的 ComplexLangTransform 模版,我们只需要重写 Var 访问方法。 ComplexLangTransform 模版实现访问方法的方式,是对子表达式递归调用访问者并用抽象工厂 alg 重新构造遍历后的表达式。

# EVF的实现

EVF 运用 Java 注解处理器(Java Annotation Processor)在编译期生成一系列源文件。主要用到了 javax.annotation.processing 和 javax.lang.model 两个库。前者提供了包括 AbstractProcessor 在内的注解处理器基础设施,后者用于分析 Java AST。EVF 的实现约 800 行代码。


# 第二部分:Castor #

受限于 Java 注解处理器和 Java 语言本身,EVF 有如下局限性:

  • 不支持对用户代码的直接简化

  • 对模式匹配支持不佳

  • 仅支持函数式、树结构的访问者

针对这些缺陷,我们在 EVF 基础之上开发了基于 Scala 语言的 Castor 框架。作为一个同时支持函数式和面向对象范式的语言,Scala 有着更简洁的语法,原生的模式匹配支持,更强大的类型系统以及更好的元编程支持。

# 一种基于 Scala 的可扩展访问者模式

trait Peano {
  type TmV <: TmVisit
  trait Tm {
    def accept(v: TmV): v.OTm
  }
  case object Zero extends Tm {
    def accept(v: TmV) = v.zero
  }
  case class Succ(t: Tm) extends Tm {
    def accept(v: TmV) = v.succ(this)
  }
  trait TmVisit { _: TmV =>
    type OTm
    def zero: OTm
    def succ: Succ => OTm
    def apply(t: Tm) = t.accept(this)
  }
  trait Eval extends TmVisit { _: TmV =>
    type OTm = Int
    def zero = 0
    def succ = x => this(x.t) + 1
  }
}

相较于 Java 版,Scala 版可扩展访问者的编码有几大不同之处:

  1. 用嵌套的 trait 来定义,通过 mixin-composition 实现扩展和组合。

  2. 不同于 Java 版从访问者接口角度出发,Scala 版从类层次结构角度出发,将 accept 方法的参数类型声明为类型成员 TmV 来实现和特定的访问者接口的解耦。通过将 TmV 限制为 TmVisit 的子类型(subtype),我们可以调用 TmVisit 声明的访问方法来实现 Zero 和 Succ 的 accept 方法。

  3. Zero 和 Succ 前面加上了 case 关键字来获得 Scala 原生模式匹配的支持。

  4. TmVisit 中访问方法的返回值类型也声明为类型成员。这么做的好处是当访问者被继承时,返回值类型不必重复实例化。

  5. TmVisit 定义了 apply 语法糖,将 x.t.accept(this) 简化为 this(x.t)。注意到这里可以将 this 作为参数传给 accept 的原因是用 self-type annotation 声明了自身类型是 TmV

> 扩展

下面的代码同时扩展的数据结构和操作:

trait ExtPeano extends Peano {
  type TmV <: TmVisit
  case class Pred(t: Tm) extends Tm {
    def accept(v: TmV) = v.pred(this)
  }
  trait TmVisit extends super.TmVisit { _: TmV =>
    def pred: Pred => OTm
  }
  trait Eval extends super.Eval with TmVisit { _: TmV =>
    def pred = x => this(x.t) - 1
  }
}

ExtPeano中定义了一个新的 case 类 Pred。相应地,我们扩展了访问者接口,声明了 pred 访问方法。通过 covariant refinement 将 TmV 约束为扩展访问者接口的子类型我们得已调用 pred 来实现 Pred 的 accept 方法。可以看到,使用嵌套的 trait 的一个好处是不必为扩展的访问者起新名字。

类似于 Java 版本中将 interface 实体化为 class 的步骤,Scala 版本需要将 trait 实体化为 object,定义如下:

object ExtPeano extends ExtPeano {
  type TmV = TmVisit
  object eval extends Eval
}

类型成元 TmV 绑定为 TmVisit ,而 eval 实体化了访问者 Eval

导入 ExtPeano 即可构造和求值表达式,例如:

import ExtPeano._
val t = Pred(Succ(Zero))
eval(t) // 0

# 用Castor框架简化代码

用 Castor 框架简化后的代码如下:

@family trait Peano {
  @adt trait Tm {
    case object Zero
    case class Succ(t: Tm)
  } 
  @visit(Tm) trait Eval {
    type OTm = Int
    def zero = 0
    def succ = x => this(x.t) + 1
  }
}

最外层的 trait 用 @family 注解,数据结构使用 @adt trait 定义,而其 case 写在 trait 里面并且不必显示地写出 extends 语句(类似的语法在最近发布的 Scala 3 中的 enum 采纳)。Eval 是 Tm 的访问者上用注解 @visit(Tm) 表明。可以看到,访问者接口和其它高级的语言特性如带有类型约束的类型成员、self-type annotation 等均由 Castor 隐式地生成,使得 Castor 容易上手使用。

类似地,扩展的定义如下:

@family trait ExtPeano extends Peano {
  @adt trait Tm extends super.Tm {
    case class Pred(t: Tm)
  }
  @visit(Tm) trait Eval extends super.Eval {
    def pred = x => this(x.t) - 1
  }
  @visit(Tm) trait Print {
    type OTm = String
    def zero = "Zero"
    def succ = x => "(Succ " + this(x.t) + ")"
    def pred = x => "(Pred " + this(x.t) + ")"
  }
}

Castor 为带有 @family 注解的 trait 自动生成了伴生对象,可以直接导入用户代码。

# GADT和模式匹配

假设我们想进一步扩展我们的例子让它支持布尔值和 if 表达式:

case object TmTrue
case object TmFalse
case class TmIf(t1: Tm, t2: Tm, t3: Tm)

合法的 if 表达式要求 t1 为布尔类型且 t2 和 t3 类型相同。类似 TmIf(TmZero,TmTrue,TmFalse) 这样的非法表达式一般通过定义类型检查访问者来剔除。

> GADT

一种更好的做法是用 GADT(Generalized Algebraic Data Types,泛化代数数据类型)嵌入类型约束,通过宿主语言的类型系统来阻止非法表达式的构建。这一点特别适用于实现 EDSL(Embedded Domain-Speicific Languages,嵌入式领域特定语言)。用 GADT 定义的数据结构如下(注:可以模块化地定义两个子语言再合并,为节省篇幅将其定义在一起):

@adt trait Tm[A] {
  case object Zero extends Tm[Int]
  case class Succ(t: Tm[Int]) extends Tm[Int]
  case class Pred(t: Tm[Int]) extends Tm[Int]
  case object TmTrue extends Tm[Boolean]
  case object TmFalse extends Tm[Boolean]
  case class TmIf[A](t1: Tm[Boolean], t2: Tm[A], t3: Tm[A]) extends Tm[A]
}

Tm 的定义引入了类型参数 A。通过显示的 extends 语句对 A 不同的实例化来区别出表达式的类型。可以看到 TmIf 的定义约束了合法的 if 表达式的形式,即 t1 是 Tm[Boolean] 且 t2 和 t3 类型一致为 Tm[A]。现在 TmIf(Zero,TmTrue,TmFalse) 将不被类型系统接受因为 TmZero 的类型是 Tm[Int]

> 模块化的大步(big-step)语义

布尔值和 if 表达式的扩展带来的第二个问题是如何定义大步语义求值访问者。已有的 Eval 定义需要修改否则无法复用:

@visit(Tm) trait Eval {
  type OTm = Int | Boolean
  def zero = 0
  def succ = x => this(x.t) match {
    case v: Int => v + 1
    case _ => throw new RuntimeException
  }
  ...
}

访问者的返回值类型修改为联合类型(union types)Int | Boolean 来表示返回值类型既可能是 Int 也可能是 Boolean。此外,子表达式的遍历结果需要额外的处理来识别出期待的返回值类型。

这种方式定义的大步语义是不模块化的,因为每当有新的表达式类型引入时(比如浮点数)时,既有的求值访问者需要修改返回类型否则不兼容。

而 GADT 使得大步语义访问者的定义变得简洁且模块化:

@visit(Tm) trait Eval {
  type OTm[A] = A
  def zero = 0
  def succ = x => this(x.t) + 1
  def pred = x => this(x.t) - 1
  def tmTrue = true
  def tmFalse = false
  def tmIf[A] = x => if (this(x.t1)) this(x.t2) else this(x.t3)
}

返回值类型与表达式携带的类型参数一致,即类型为 Tm[Int] 的表达式将返回 Int 而 Tm[Boolean] 的表达式返回 Boolean。子表达式的遍历不需要做额外处理且既有的访问者不受新引入的表达式类型影响。

> 模块化的小步(small-step)语义

Castor 的优势进一步地反映在小步语义的定义上:

@default(Tm) trait Eval1 {
  type OTm[A] = Tm[A]
  def tm[A] = x => throw NoRuleApplies
  override def tmIf[A] = {
    case TmIf(TmTrue, t2, _) => t2
    case TmIf(TmFalse, _, t3) => t3
    case TmIf(t1, t2, t3) => TmIf(this(t1), t2, t3)
  }
  ...
}

小步语义逐步地改写表达式直到其不能再被改写,因此访问者返回类型就是表达式本身。一个表达式的小步语义可能有多条规则,比如 if 表达式,若 t1 为真则改写为 t2,若 t1 为假则改写为 t3,否则继续改写 t1。而访问方法仅揭露当前表达式最外层的形式,要识别出子表达式的形式一般需要定义额外的访问者写起来比较繁琐。所幸 Castor 使用了 case class 定义层次结构,可以用 Scala 原生的模式匹配来识别子表达式。tmIf 的定义用三条 case 语句分别对应上述三种情形。注意到 Eval1 的注解是 default,表示它使用了带有缺省实现的模版。其缺省定义抛出一个异常,表示表达式要么已经是个值了,要么是非法的。

除了 GADT 和模式匹配,Castor 还提升了一些 EVF 所欠缺的表达能力,比如图结构,命令式访问者等。受限于篇幅就不一一展开说明了。

Castor的实现

Castor 使用了 Scalameta 元编程库来分析、变换和生成 Scala 程序。不同于 Java,Scala 提供了一种更简洁、安全的操纵 Scala 语法树的方式,称为 quasiquotes。Quasiquotes 既可用于构建也可用于匹配 AST。例如, q"trait Tm" 等价于如下 AST 定义:

Defn.Trait(Nil, Type.Name("Tm"), 
           Nil, Ctor.Primary(Nil, Ctor.Ref.Name("this"), Nil),       
           Template(Nil, Nil, Term.Param(Nil, Name.Anonymous(), None, None), None))

相较于 EVF,Castor 注解处理器的代码行仅是其 1/4。


# 案例分析#

我们分别用 EVF 和 Castor 重构了《Types and Programming Languages》这一编程语言经典书籍中的解释器实现。该书逐步介绍新的语言特性,从一开始的数值、布尔慢慢扩展无类型 lambda 演算、简单类型 lambda 演算、let 表达式、record 等。其解释器的实现是将原始语言特性的实现复制黏贴到新的扩展语言实现中,因而是不模块化的。我们将语言特性分离出来,使其能够模块化的扩展和复用,如下图所示:

图片

灰色方框代表 10 个原始语言,白色方框代表抽取出来的语言特性,箭头代表依赖。可以看到这些特性在这些语言中被广泛复用。

下面的表格比较这 10 个解释器分别用 EVF、Castor 以及 Scala 的实现:

图片

可以看到代码行数显著下载,同为 Scala 语言的实现,Castor 实现的代码量不到其一半。受限于 Java 语言,EVF 实现比 Castor 多用了 400 多行,但依然比非模块化的 Scala 代码少 1000 多行。

当然模块化带来了间接性,对性能造成影响。为了评估这个影响我们比较了同为 Scala 语言的两个实现,用随机生成 10000 个表达式计算 10 次求值的平均时间得出下图:

图片

Castor 比 Scala 慢 1.35x (arith) 至 3.92x (fullsub),越复杂的语言性能下降得越多,但还在可接受的范围内。


# 结语#

表达式问题是检验模块化和可扩展性的根本问题。可扩展访问者模式给出了表达式问题的一种解决方案。可扩展访问者模式的复杂性在很大程度上可以通过元编程自动生成代码来消除。当然元编程不是万灵丹,可能会造成调试的困难并且要求用户对生成的代码有所了解。

[1] Weixin Zhang and Bruno C. d. S. Oliveira. [EVF: An Extensible and Expressive Visitor Framework for Programming Language Reuse], In 31st European Conference on Object-Oriented Programming (ECOOP), 2017: https://wxzh.github.io/papers/ecoop2017.pdf

[2] Weixin Zhang and Bruno C. d. S. Oliveira. [Castor: Programming with extensible generative visitors], In Science of Computer Programming, 2020: https://wxzh.github.io/papers/scp2020.pdf

> 了解更多

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值