作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Alain-Michel Chomnoue Nghemning的头像

Alain-Michel Chomnoue Nghemning

作为远程办公公司的首席架构师和Java专家, Alain领导的软件开发团队影响了成千上万用户的工作.

Expertise

Years of Experience

12

Share

Scala语言为开发人员提供了以干净简洁的语法编写面向对象和功能代码的机会(as compared to Java, for example). Case classes, higher-order functions, 和类型推断是Scala开发人员可以利用的一些特性,它们可以编写更容易维护和更少出错的代码.

Unfortunately, Scala代码也不能幸免于样板文件, 开发人员可能会努力寻找重构和重用这些代码的方法. For example, 一些库强制开发人员通过为类的每个子类调用API来重复自己的工作 sealed class.

但是,在开发人员学会如何利用宏和准引号在编译时生成重复的代码之前,这是正确的.

用例:为父类的所有子类型注册相同的处理程序

在微服务系统的开发过程中, 我想为从某个类派生的所有事件注册一个处理程序. 为了避免被我使用的框架的细节分散我们的注意力, 下面是注册事件处理程序的API的简化定义:

trait EventProcessor[Event] {
  def addHandler[E <: Event: ClassTag](
      handler: E => Unit
  ): EventProcessor[Event]

  def process(event: Event)
}

拥有任何事件处理器 Event 类型的子类,我们可以注册处理程序 Event with the addHandler method.

Looking at the above signature, 开发人员可能希望为其子类型的事件调用为给定类型注册的处理程序. 事件中涉及的事件的类层次结构如下 User entity lifecycle:

从UserEvent降序的Scala事件层次结构. 有三个直接的后代:UserCreated(有名字和电子邮件), which are both Strings), UserChanged, and UserDeleted. Furthermore, UserChanged有两个自己的后代:NameChanged(有一个名称, 这是一个字符串)和EmailChanged(有电子邮件, which is a string).
A Scala event class hierarchy.

相应的Scala声明如下所示:

sealed trait UserEvent
类UserCreated(name: String, email: String)扩展UserEvent
密封特性UserChanged扩展UserEvent
最后一个case类NameChanged(name: String)扩展UserChanged
final case类EmailChanged(email: String)扩展UserChanged
case对象UserDeleted扩展UserEvent

我们可以为每个特定的事件类注册一个处理程序. 但是如果我们想为 all the event classes? 的处理程序 UserEvent class. 我期望对所有事件都调用它.

val handler = new EventHandlerImpl[UserEvent]
val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)

我注意到在测试期间从未调用处理程序. I dug into the code of Lagom, the framework I was using.

我发现事件处理程序实现将处理程序存储在映射中,并将注册的类作为键. 当发出事件时,它在映射中查找其类以获取要调用的处理程序. 事件处理器是这样实现的:

type Handler[Event] = (_ <: Event) => Unit

private case类EventProcessorImpl[Event]
    handlers: Map[Class[_ <: Event], List[Handler[Event]]] =
      Map[Class[_ <: Event], List[Handler[Event]]]()
)扩展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 ,处理器在注册表中找不到它的类.

Thus Begins the Boilerplate Code

解决方案是为每个具体事件类注册相同的处理程序. We can do it like this:

val handler = new EventHandlerImpl[UserEvent]  
val processor = EventProcessor[UserEvent]  
  .addHandler[UserCreated](handler)  
  .addHandler[NameChanged](handler)  
  .addHandler [EmailChanged](处理器)  
  .addHandler[UserDeleted.type](handler)

Now the code works! But it’s repetitive.

It’s also difficult to maintain, 因为我们需要在每次引入新事件类型时修改它. 我们还可能在代码库中有其他地方被迫列出所有具体类型. 我们还需要确保修改这些地方.

This is disappointing, as UserEvent 是不是一个密封类,这意味着它的所有直接子类在编译时都是已知的. 如果我们可以利用这些信息来避免样板文件会怎么样?

Macros to the Rescue

通常,Scala函数会根据我们在运行时传递给它们的参数返回一个值. You can think of Scala macros 作为生成一些代码的特殊函数 compile 是时候将它们的调用替换为.

While the macro 接口可能看起来接受值作为参数,但它的实现实际上将捕获 abstract syntax tree (AST)——编译器使用的源代码结构的内部表示——这些参数. 然后使用AST生成一个新的AST. 最后,新的AST在编译时替换宏调用.

Let’s look at a macro 将为给定类的所有已知子类生成事件处理程序注册的声明:

def addHandlers[Event](
      处理器:EventProcessor(事件),
      handler: Event => Unit
  ): EventProcessor[Event] =宏setEventHandlers_impl[Event]  
  
  
defseteventhandlers_impl[事件:c.WeakTypeTag](c: Context)(
      processor: c.Expr[EventProcessor[Event]],
      handler: c.Expr[Event => Unit]
  ): c.Expr[EventProcessor[Event]] = {

  // implementation here
}

注意,对于每个参数(包括类型参数和返回类型), 实现方法有一个相应的AST表达式作为参数. For example, c.Expr[EventProcessor[Event]] matches EventProcessor[Event]. The parameter c: Context wraps the compilation 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 =子类(symbol)

Note the recursive call to the subclasses 方法以确保也处理间接子类.

现在我们有了要注册的事件类列表, 我们可以为Scala宏将要生成的代码构建AST.

生成Scala代码:ast或准引号?

要构建AST,既可以操作AST类,也可以使用Scala quasiquotes. 使用AST类会产生难以阅读和维护的代码. In contrast, 准引号允许我们使用与生成的代码非常相似的语法,从而极大地降低了代码的复杂性.

为了说明简单性的好处,让我们以简单表达式为例 a + 2. 用AST类生成它是这样的:

val exp = Apply(Select(Ident(TermName("a")), TermName("$plus"))), List(Literal(Constant(2)))) . 0))

我们可以使用准引号实现同样的功能,语法更简洁易读:

val exp = q"a + 2"

为了保持宏的直接性,我们将使用准引号.

让我们创建AST并将其作为宏函数的结果返回:

val calls = children.foldLeft(q"$processor")((current, ref) =>
  q"$current.addHandler[$ref]($handler)"
)
c.Expr [EventProcessor[活动]](调用)

上面的代码以作为参数接收的处理器表达式开始,对于每个处理器表达式 Event 类,它生成对 addHandler 方法,并将子类和处理程序函数作为参数.

Now we can call the macro on the UserEvent 类,它将生成为所有子类注册处理程序的代码:

val handler = new EventHandlerImpl[UserEvent]  
val processor = EventProcessorMacro.addhandler (EventProcessor UserEvent,处理程序)

That will generate this code:

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](处理器)
.addHandler[UserDeleted](handler)

The code of the complete project 正确编译,并且测试用例证明处理程序确实为的每个子类注册了 UserEvent. 现在我们可以对代码处理新事件类型的能力更有信心了.

Repetitive Code? Get Scala Macros to Write It

Even though Scala 有一个简洁的语法,通常有助于避免样板文件, 开发人员仍然可以发现代码变得重复的情况,并且不能容易地重构以重用. Scala宏可以与准引号一起使用来克服这类问题, 保持Scala代码的整洁和可维护.

还有一些很受欢迎的图书馆,比如 Macwire,它利用Scala宏帮助开发人员生成代码. 我强烈建议每个Scala开发人员更多地了解这个语言特性,因为它可以 a valuable asset in your tool set.

Understanding the basics

  • What is Scala used for?

    Scala允许程序员用干净简洁的语法编写面向对象和函数式代码. 它的输出可以在Java虚拟机(JVM)中执行,也可以在JavaScript运行时中执行.

  • What are Scala macros?

    Scala宏是一些特殊的函数,它们用编译时生成的源代码替换自己的调用.

  • What are syntax trees?

    抽象语法树(AST)是编译器内部使用的源代码结构的表示形式.

  • What are sealed classes?

    密封类是其子类在编译时已知的类. 在Scala中,它们不能被定义它们的源文件之外的类扩展.

  • Scala中的类和对象是什么?

    In Scala, classes define common structure and behavior; objects are instances of a class. Scala也使用object关键字来定义单例(只有一个实例的类)。.

聘请Toptal这方面的专家.
Hire Now
Alain-Michel Chomnoue Nghemning的头像
Alain-Michel Chomnoue Nghemning

Located in 阿比让,拉古内斯大区,Côte科特迪瓦

Member since November 11, 2020

About the author

作为远程办公公司的首席架构师和Java专家, Alain领导的软件开发团队影响了成千上万用户的工作.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

Years of Experience

12

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.