Réduisez le code passe-partout avec les macros Scala et les quasi-quotes
Publié: 2022-03-11Le langage Scala offre aux développeurs la possibilité d'écrire du code orienté objet et fonctionnel dans une syntaxe propre et concise (par rapport à Java, par exemple). Les classes de cas, les fonctions d'ordre supérieur et l'inférence de type sont quelques-unes des fonctionnalités que les développeurs Scala peuvent exploiter pour écrire du code plus facile à maintenir et moins sujet aux erreurs.
Malheureusement, le code Scala n'est pas à l'abri du passe-partout, et les développeurs peuvent avoir du mal à trouver un moyen de refactoriser et de réutiliser ce code. Par exemple, certaines bibliothèques obligent les développeurs à se répéter en appelant une API pour chaque sous-classe d'une classe scellée.
Mais ce n'est vrai que jusqu'à ce que les développeurs apprennent à tirer parti des macros et des quasiquotes pour générer le code répété au moment de la compilation.
Cas d'utilisation : enregistrement du même gestionnaire pour tous les sous-types d'une classe parent
Lors du développement d'un système de microservices, je voulais enregistrer un gestionnaire unique pour tous les événements dérivés d'une certaine classe. Pour éviter de nous distraire avec les spécificités du framework que j'utilisais, voici une définition simplifiée de son API pour l'enregistrement des gestionnaires d'événements :
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } Ayant un processeur d'événement pour n'importe quel type d' Event , nous pouvons enregistrer des gestionnaires pour les sous-classes d' Event avec la méthode addHandler .
En regardant la signature ci-dessus, un développeur peut s'attendre à ce qu'un gestionnaire enregistré pour un type donné soit appelé pour les événements de ses sous-types. Par exemple, considérons la hiérarchie de classes suivante d'événements impliqués dans le cycle de vie de l'entité User :
Les déclarations Scala correspondantes ressemblent à ceci :
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 Nous pouvons enregistrer un gestionnaire pour chaque classe d'événement spécifique. Mais que se passe-t-il si nous voulons enregistrer un gestionnaire pour toutes les classes d'événements ? Ma première tentative a été d'enregistrer le gestionnaire pour la classe UserEvent . Je m'attendais à ce qu'il soit invoqué pour tous les événements.
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)J'ai remarqué que le gestionnaire n'était jamais invoqué pendant les tests. J'ai creusé dans le code de Lagom, le framework que j'utilisais.
J'ai trouvé que l'implémentation du processeur d'événements stockait les gestionnaires dans une carte avec la classe enregistrée comme clé. Lorsqu'un événement est émis, il recherche sa classe dans cette carte pour que le gestionnaire l'appelle. Le processeur d'événements est implémenté de la manière suivante :
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))) } } Ci-dessus, nous avons enregistré un gestionnaire pour la classe UserEvent , mais chaque fois qu'un événement dérivé comme UserCreated était émis, le processeur ne trouvait pas sa classe dans le registre.
Ainsi commence le code standard
La solution consiste à enregistrer le même gestionnaire pour chaque classe d'événement concrète. Nous pouvons le faire comme ceci :
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)Maintenant le code fonctionne ! Mais c'est répétitif.
Il est également difficile à maintenir, car nous devrons le modifier à chaque fois que nous introduisons un nouveau type d'événement. Nous pourrions également avoir d'autres endroits dans notre base de code où nous sommes obligés de répertorier tous les types concrets. Il faudrait aussi s'assurer de modifier ces endroits.

C'est décevant, car UserEvent est une classe scellée, ce qui signifie que toutes ses sous-classes directes sont connues au moment de la compilation. Et si nous pouvions tirer parti de ces informations pour éviter le passe-partout ?
Les macros à la rescousse
Normalement, les fonctions Scala renvoient une valeur basée sur les paramètres que nous leur transmettons au moment de l'exécution. Vous pouvez considérer les macros Scala comme des fonctions spéciales qui génèrent du code au moment de la compilation pour remplacer leurs invocations.
Bien que l'interface macro puisse sembler prendre des valeurs comme paramètres, son implémentation capturera en fait l'arbre de syntaxe abstraite (AST) - la représentation interne de la structure du code source utilisée par le compilateur - de ces paramètres. Il utilise ensuite l'AST pour générer un nouvel AST. Enfin, le nouvel AST remplace l'appel de macro au moment de la compilation.
Examinons une déclaration de macro qui générera l'enregistrement du gestionnaire d'événements pour toutes les sous-classes connues d'une classe donnée :
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 } Notez que pour chaque paramètre (y compris le paramètre de type et le type de retour), la méthode d'implémentation a une expression AST correspondante en tant que paramètre. Par exemple, c.Expr[EventProcessor[Event]] correspond à EventProcessor[Event] . Le paramètre c: Context encapsule le contexte de compilation. Nous pouvons l'utiliser pour obtenir toutes les informations disponibles au moment de la compilation.
Dans notre cas, nous voulons récupérer les enfants de notre classe scellée :
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) Notez l'appel récursif à la méthode des sous- subclasses pour garantir que les sous-classes indirectes sont également traitées.
Maintenant que nous avons la liste des classes d'événements à enregistrer, nous pouvons construire l'AST pour le code que la macro Scala va générer.
Générer du code Scala : AST ou Quasiquotes ?
Pour construire notre AST, nous pouvons soit manipuler des classes AST, soit utiliser des quasiquotes Scala. L'utilisation de classes AST peut produire un code difficile à lire et à maintenir. En revanche, les quasiquotes réduisent considérablement la complexité du code en nous permettant d'utiliser une syntaxe très similaire au code généré.
Pour illustrer le gain de simplicité, prenons l'expression simple a + 2 . Générer ceci avec les classes AST ressemble à ceci :
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))Nous pouvons obtenir la même chose avec des quasiquotes avec une syntaxe plus concise et lisible :
val exp = q"a + 2"Pour garder notre macro simple, nous utiliserons des quasiquotes.
Créons l'AST et renvoyons-le comme résultat de la fonction macro :
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) Le code ci-dessus commence par l'expression du processeur reçue en tant que paramètre, et pour chaque sous-classe Event , il génère un appel à la méthode addHandler avec la sous-classe et la fonction de gestionnaire en tant que paramètres.
Nous pouvons maintenant appeler la macro sur la classe UserEvent et elle générera le code pour enregistrer le gestionnaire pour toutes les sous-classes :
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)Cela générera ce 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](handler) .addHandler[UserDeleted](handler) Le code du projet complet se compile correctement et les cas de test démontrent que le gestionnaire est bien enregistré pour chaque sous-classe de UserEvent . Nous pouvons maintenant être plus confiants dans la capacité de notre code à gérer de nouveaux types d'événements.
Code répétitif ? Obtenez des macros Scala pour l'écrire
Même si Scala a une syntaxe concise qui aide généralement à éviter le passe-partout, les développeurs peuvent toujours trouver des situations où le code devient répétitif et ne peut pas être facilement refactorisé pour être réutilisé. Les macros Scala peuvent être utilisées avec des quasiquotes pour surmonter ces problèmes, en gardant le code Scala propre et maintenable.
Il existe également des bibliothèques populaires, comme Macwire, qui exploitent les macros Scala pour aider les développeurs à générer du code. J'encourage fortement chaque développeur Scala à en savoir plus sur cette fonctionnalité de langage, car elle peut être un atout précieux dans votre ensemble d'outils.
