Reduzieren Sie Boilerplate-Code mit Scala-Makros und Quasiquotes
Veröffentlicht: 2022-03-11Die Scala-Sprache bietet Entwicklern die Möglichkeit, objektorientierten und funktionalen Code in einer sauberen und prägnanten Syntax (im Vergleich zu Java beispielsweise) zu schreiben. Fallklassen, Funktionen höherer Ordnung und Typrückschluss sind einige der Funktionen, die Scala-Entwickler nutzen können, um Code zu schreiben, der einfacher zu warten und weniger fehleranfällig ist.
Leider ist Scala-Code nicht immun gegen Boilerplate, und Entwickler haben möglicherweise Schwierigkeiten, einen Weg zu finden, solchen Code umzugestalten und wiederzuverwenden. Beispielsweise zwingen einige Bibliotheken Entwickler, sich zu wiederholen, indem sie eine API für jede Unterklasse einer versiegelten Klasse aufrufen.
Aber das gilt nur, bis die Entwickler lernen, wie man Makros und Quasiquotes nutzt, um den wiederholten Code zur Kompilierzeit zu generieren.
Anwendungsfall: Registrieren des gleichen Handlers für alle Untertypen einer übergeordneten Klasse
Während der Entwicklung eines Microservices-Systems wollte ich einen einzelnen Handler für alle Ereignisse registrieren, die von einer bestimmten Klasse abgeleitet wurden. Um uns nicht mit den Besonderheiten des von mir verwendeten Frameworks abzulenken, hier eine vereinfachte Definition seiner API zum Registrieren von Event-Handlern:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } Da wir für jeden Event -Typ einen Event-Prozessor haben, können wir Handler für Unterklassen von Event mit der addHandler Methode registrieren.
Betrachtet man die obige Signatur, könnte ein Entwickler erwarten, dass ein für einen bestimmten Typ registrierter Handler für Ereignisse seiner Untertypen aufgerufen wird. Betrachten wir beispielsweise die folgende Klassenhierarchie von Ereignissen, die am Lebenszyklus der User beteiligt sind:
Die entsprechenden Scala-Deklarationen sehen folgendermaßen aus:
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 Wir können einen Handler für jede spezifische Ereignisklasse registrieren. Aber was, wenn wir einen Handler für alle Ereignisklassen registrieren wollen? Mein erster Versuch war, den Handler für die UserEvent -Klasse zu registrieren. Ich habe erwartet, dass es für alle Ereignisse aufgerufen wird.
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)Mir ist aufgefallen, dass der Handler während der Tests nie aufgerufen wurde. Ich habe mich mit dem Code von Lagom befasst, dem Framework, das ich verwendet habe.
Ich habe festgestellt, dass die Implementierung des Ereignisprozessors die Handler in einer Zuordnung mit der registrierten Klasse als Schlüssel gespeichert hat. Wenn ein Ereignis ausgegeben wird, sucht es in dieser Zuordnung nach seiner Klasse, um den Handler zum Aufrufen zu bringen. Der Ereignisprozessor wird wie folgt implementiert:
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))) } } Oben haben wir einen Handler für die UserEvent -Klasse registriert, aber immer wenn ein abgeleitetes Ereignis wie UserCreated wurde, fand der Prozessor seine Klasse nicht in der Registrierung.
So beginnt der Standardcode
Die Lösung besteht darin, für jede konkrete Ereignisklasse denselben Handler zu registrieren. Wir können es so machen:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)Jetzt funktioniert der Code! Aber es wiederholt sich.
Es ist auch schwierig zu warten, da wir es jedes Mal ändern müssen, wenn wir einen neuen Ereignistyp einführen. Möglicherweise haben wir auch andere Stellen in unserer Codebasis, an denen wir gezwungen sind, alle konkreten Typen aufzulisten. Wir müssten auch sicherstellen, dass diese Orte geändert werden.
Dies ist enttäuschend, da UserEvent eine versiegelte Klasse ist, was bedeutet, dass alle ihre direkten Unterklassen zur Kompilierzeit bekannt sind. Was wäre, wenn wir diese Informationen nutzen könnten, um Boilerplates zu vermeiden?

Makros zur Rettung
Normalerweise geben Scala-Funktionen einen Wert basierend auf den Parametern zurück, die wir ihnen zur Laufzeit übergeben. Sie können sich Scala-Makros als spezielle Funktionen vorstellen, die zur Kompilierzeit Code generieren, durch den ihre Aufrufe ersetzt werden.
Während die macro Werte als Parameter zu akzeptieren scheint, erfasst ihre Implementierung tatsächlich den abstrakten Syntaxbaum (AST) – die interne Darstellung der Quellcodestruktur, die der Compiler verwendet – dieser Parameter. Dann verwendet es den AST, um einen neuen AST zu erzeugen. Schließlich ersetzt der neue AST den Makroaufruf zur Kompilierzeit.
Schauen wir uns eine macro an, die eine Ereignishandlerregistrierung für alle bekannten Unterklassen einer bestimmten Klasse generiert:
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 } Beachten Sie, dass die Implementierungsmethode für jeden Parameter (einschließlich Typparameter und Rückgabetyp) einen entsprechenden AST-Ausdruck als Parameter hat. Beispielsweise c.Expr[EventProcessor[Event]] EventProcessor[Event] . Der Parameter c: Context umschließt den Kompilierungskontext. Wir können es verwenden, um alle Informationen zu erhalten, die zur Kompilierzeit verfügbar sind.
In unserem Fall möchten wir die Kinder unserer versiegelten Klasse abrufen:
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) Beachten Sie den rekursiven Aufruf der subclasses -Methode, um sicherzustellen, dass auch indirekte Subklassen verarbeitet werden.
Nachdem wir nun die Liste der zu registrierenden Ereignisklassen haben, können wir den AST für den Code erstellen, den das Scala-Makro generieren wird.
Generieren von Scala-Code: ASTs oder Quasiquotes?
Um unser AST zu erstellen, können wir entweder AST-Klassen manipulieren oder Scala-Quasiquotes verwenden. Die Verwendung von AST-Klassen kann Code erzeugen, der schwer zu lesen und zu warten ist. Im Gegensatz dazu reduzieren Quasiquotes die Komplexität des Codes erheblich, indem sie es uns ermöglichen, eine Syntax zu verwenden, die dem generierten Code sehr ähnlich ist.
Um den Einfachheitsgewinn zu veranschaulichen, nehmen wir den einfachen Ausdruck a + 2 . Das Generieren mit AST-Klassen sieht so aus:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))Wir können dasselbe mit Quasiquotes mit einer prägnanteren und lesbareren Syntax erreichen:
val exp = q"a + 2"Um unser Makro unkompliziert zu halten, verwenden wir Quasiquotes.
Lassen Sie uns den AST erstellen und als Ergebnis der Makrofunktion zurückgeben:
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) Der obige Code beginnt mit dem als Parameter empfangenen Prozessorausdruck und generiert für jede Event -Unterklasse einen Aufruf der addHandler Methode mit der Unterklasse und der Handler-Funktion als Parameter.
Jetzt können wir das Makro für die UserEvent -Klasse aufrufen und es wird den Code generieren, um den Handler für alle Unterklassen zu registrieren:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)Das wird diesen Code generieren:
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) Der Code des gesamten Projekts wird korrekt kompiliert und die Testfälle zeigen, dass der Handler tatsächlich für jede Unterklasse von UserEvent registriert ist. Jetzt können wir uns mehr auf die Fähigkeit unseres Codes verlassen, neue Ereignistypen zu verarbeiten.
Sich wiederholender Code? Holen Sie sich Scala-Makros, um es zu schreiben
Obwohl Scala eine prägnante Syntax hat, die normalerweise dazu beiträgt, Boilerplates zu vermeiden, können Entwickler dennoch Situationen finden, in denen sich Code wiederholt und nicht einfach für die Wiederverwendung umgestaltet werden kann. Scala-Makros können mit Quasi-Anführungszeichen verwendet werden, um solche Probleme zu lösen und den Scala-Code sauber und wartbar zu halten.
Es gibt auch beliebte Bibliotheken wie Macwire, die Scala-Makros nutzen, um Entwicklern beim Generieren von Code zu helfen. Ich ermutige jeden Scala-Entwickler dringend, mehr über dieses Sprachfeature zu erfahren, da es ein wertvolles Gut in Ihrem Toolset sein kann.
