使用 Scala 宏和準引號減少樣板代碼

已發表: 2022-03-11

Scala 語言為開發人員提供了以簡潔明了的語法編寫面向對象和函數式代碼的機會(例如,與 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實體生命週期中涉及的事件的類層次結構:

來自 UserEvent 的 Scala 事件的層次結構。有三個直接後代:UserCreated(具有名稱和電子郵件,它們都是字符串)、UserChanged 和 UserDeleted。此外,UserChanged 有它自己的兩個後代:NameChanged(有一個名稱,它是一個字符串)和 EmailChanged(有一個電子郵件,它是一個字符串)。
Scala 事件類層次結構。

相應的 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 開發人員更多地了解此語言功能,因為它可以成為您工具集中的寶貴資產。