使用 Scala 宏和准引号减少样板代码
已发表: 2022-03-11Scala 语言为开发人员提供了以简洁明了的语法编写面向对象和函数式代码的机会(例如,与 Java 相比)。 案例类、高阶函数和类型推断是 Scala 开发人员可以用来编写更易于维护且不易出错的代码的一些特性。
不幸的是,Scala 代码不能免于样板代码,开发人员可能很难找到重构和重用此类代码的方法。 例如,一些库通过为密封类的每个子类调用 API 来强制开发人员重复自己。
但这只有在开发人员学会如何利用宏和准引号在编译时生成重复代码之前才是正确的。
用例:为父类的所有子类型注册相同的处理程序
在微服务系统的开发过程中,我想为从某个类派生的所有事件注册一个处理程序。 为了避免因我使用的框架的细节而分散我们的注意力,下面是其用于注册事件处理程序的 API 的简化定义:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } 拥有任何Event类型的事件处理器,我们可以使用addHandler方法为Event的子类注册处理程序。
查看上面的签名,开发人员可能期望为其子类型的事件调用为给定类型注册的处理程序。 例如,让我们考虑以下User实体生命周期中涉及的事件的类层次结构:
相应的 Scala 声明如下所示:
sealed trait UserEvent final case class UserCreated(name: String, email: String) extends UserEvent sealed trait UserChanged extends UserEvent final case class NameChanged(name: String) extends UserChanged final case class EmailChanged(email: String) extends UserChanged case object UserDeleted extends UserEvent 我们可以为每个特定的事件类注册一个处理程序。 但是如果我们想为所有的事件类注册一个处理程序呢? 我的第一次尝试是为UserEvent类注册处理程序。 我希望它会为所有事件调用。
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)我注意到在测试期间从未调用过处理程序。 我深入研究了我正在使用的框架 Lagom 的代码。
我发现事件处理器实现将处理程序存储在一个映射中,其中注册的类作为键。 发出事件时,它会在该映射中查找其类以获取要调用的处理程序。 事件处理器是按照以下方式实现的:
type Handler[Event] = (_ <: Event) => Unit private case class EventProcessorImpl[Event]( handlers: Map[Class[_ <: Event], List[Handler[Event]]] = Map[Class[_ <: Event], List[Handler[Event]]]() ) extends EventProcessor[Event] { override def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] = { val eventClass = implicitly[ClassTag[E]].runtimeClass.asInstanceOf[Class[_ <: Event]] val eventHandlers = handler .asInstanceOf[Handler[Event]] :: handlers.getOrElse(eventClass, List()) copy(handlers + (eventClass -> eventHandlers)) } override def process(event: Event): Unit = { handlers .get(event.getClass) .foreach(_.foreach(_.asInstanceOf[Event => Unit].apply(event))) } } 上面,我们为UserEvent类注册了一个处理程序,但是每当发出像UserCreated这样的派生事件时,处理器就不会在注册表中找到它的类。
样板代码由此开始
解决方案是为每个具体事件类注册相同的处理程序。 我们可以这样做:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)现在代码可以工作了! 但它是重复的。
它也很难维护,因为我们每次引入新的事件类型时都需要对其进行修改。 我们的代码库中可能还有其他地方我们被迫列出所有具体类型。 我们还需要确保修改这些地方。

这是令人失望的,因为UserEvent是一个密封类,这意味着它的所有直接子类在编译时都是已知的。 如果我们可以利用这些信息来避免样板文件呢?
宏的救援
通常,Scala 函数会根据我们在运行时传递给它们的参数返回一个值。 您可以将 Scala 宏视为特殊函数,它在编译时生成一些代码来替换它们的调用。
虽然macro接口似乎将值作为参数,但它的实现实际上将捕获这些参数的抽象语法树 (AST)——编译器使用的源代码结构的内部表示。 然后它使用 AST 生成一个新的 AST。 最后,新的 AST 在编译时替换了宏调用。
让我们看一个macro声明,它将为给定类的所有已知子类生成事件处理程序注册:
def addHandlers[Event]( processor: EventProcessor[Event], handler: Event => Unit ): EventProcessor[Event] = macro setEventHandlers_impl[Event] def setEventHandlers_impl[Event: c.WeakTypeTag](c: Context)( processor: c.Expr[EventProcessor[Event]], handler: c.Expr[Event => Unit] ): c.Expr[EventProcessor[Event]] = { // implementation here } 注意,对于每个参数(包括类型参数和返回类型),实现方法都有一个对应的AST表达式作为参数。 例如, c.Expr[EventProcessor[Event]]匹配EventProcessor[Event] 。 参数c: Context包装了编译上下文。 我们可以使用它来获取编译时可用的所有信息。
在我们的例子中,我们想要检索密封类的孩子:
import c.universe._ val symbol = weakTypeOf[Event].typeSymbol def subclasses(symbol: Symbol): List[Symbol] = { val children = symbol.asClass.knownDirectSubclasses.toList symbol :: children.flatMap(subclasses(_)) } val children = subclasses(symbol) 请注意对子subclasses方法的递归调用,以确保也处理间接子类。
现在我们有了要注册的事件类列表,我们可以为 Scala 宏将生成的代码构建 AST。
生成 Scala 代码:AST 还是 Quasiquotes?
要构建我们的 AST,我们可以操作 AST 类或使用 Scala quasiquotes。 使用 AST 类会产生难以阅读和维护的代码。 相比之下,准引号通过允许我们使用与生成的代码非常相似的语法来显着降低代码的复杂性。
为了说明简单性增益,让我们采用简单的表达式a + 2 。 使用 AST 类生成它看起来像这样:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))我们可以用更简洁易读的语法来实现同样的效果:
val exp = q"a + 2"为了使我们的宏简单明了,我们将使用准引号。
让我们创建 AST 并将其作为宏函数的结果返回:
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) 上面的代码以作为参数接收的处理器表达式开始,并且对于每个Event子类,它都会以子类和处理函数作为参数生成对addHandler方法的调用。
现在我们可以在UserEvent类上调用宏,它会生成代码来为所有子类注册处理程序:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)这将生成以下代码:
com.example.event.processor.EventProcessor .apply[com.example.event.handler.UserEvent]() .addHandler[UserEvent](handler) .addHandler[UserCreated](handler) .addHandler[UserChanged](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted](handler) 完整项目的代码编译正确,测试用例证明处理程序确实为UserEvent的每个子类注册。 现在我们可以对我们的代码处理新事件类型的能力更有信心了。
重复代码? 获取 Scala 宏来编写它
尽管 Scala 具有通常有助于避免样板代码的简洁语法,但开发人员仍然可以发现代码变得重复且无法轻松重构以供重用的情况。 Scala 宏可以与 quasiquotes 一起使用来克服这些问题,保持 Scala 代码的清洁和可维护性。
还有一些流行的库,比如 Macwire,利用 Scala 宏来帮助开发人员生成代码。 我强烈鼓励每位 Scala 开发人员更多地了解此语言功能,因为它可以成为您工具集中的宝贵资产。
