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または準クォート?
ASTを構築するには、ASTクラスを操作するか、Scala準クォートを使用します。 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マクロを準クォートとともに使用して、このような問題を克服し、Scalaコードをクリーンで保守しやすい状態に保つことができます。
開発者がコードを生成するのを助けるためにScalaマクロを活用するMacwireのような人気のあるライブラリもあります。 この言語機能はツールセットの貴重な資産になる可能性があるため、すべてのScala開発者にこの言語機能について詳しく学ぶことを強くお勧めします。
