Reduzca el código repetitivo con Scala Macros y Quasiquotes

Publicado: 2022-03-11

El lenguaje Scala ofrece a los desarrolladores la oportunidad de escribir código funcional y orientado a objetos en una sintaxis limpia y concisa (en comparación con Java, por ejemplo). Las clases de casos, las funciones de orden superior y la inferencia de tipos son algunas de las características que los desarrolladores de Scala pueden aprovechar para escribir código que sea más fácil de mantener y menos propenso a errores.

Desafortunadamente, el código de Scala no es inmune a la repetición y los desarrolladores pueden tener dificultades para encontrar una manera de refactorizar y reutilizar dicho código. Por ejemplo, algunas bibliotecas obligan a los desarrolladores a repetirse llamando a una API para cada subclase de una clase sellada.

Pero eso solo es cierto hasta que los desarrolladores aprendan a aprovechar las macros y las cuasicomillas para generar el código repetido en el momento de la compilación.

Caso de uso: registro del mismo controlador para todos los subtipos de una clase principal

Durante el desarrollo de un sistema de microservicios, quería registrar un solo controlador para todos los eventos derivados de una determinada clase. Para evitar distraernos con los detalles del marco que estaba usando, aquí hay una definición simplificada de su API para registrar controladores de eventos:

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

Teniendo un procesador de eventos para cualquier tipo de Event , podemos registrar manejadores para subclases de Event con el método addHandler .

Mirando la firma anterior, un desarrollador podría esperar que un controlador registrado para un tipo dado sea invocado para eventos de sus subtipos. Por ejemplo, consideremos la siguiente jerarquía de clases de eventos involucrados en el ciclo de vida de la entidad User :

Jerarquía de eventos de Scala que descienden de UserEvent. Hay tres descendientes directos: UserCreated (que tiene un nombre y un correo electrónico, que son cadenas), UserChanged y UserDeleted. Además, UserChanged tiene dos descendientes propios: NameChanged (que tiene un nombre, que es una cadena) y EmailChanged (que tiene un correo electrónico, que es una cadena).
Una jerarquía de clases de eventos de Scala.

Las declaraciones de Scala correspondientes se ven así:

 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

Podemos registrar un controlador para cada clase de evento específica. Pero, ¿y si queremos registrar un controlador para todas las clases de eventos? Mi primer intento fue registrar el controlador para la clase UserEvent . Esperaba que se invocara para todos los eventos.

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

Noté que el controlador nunca se invocó durante las pruebas. Busqué en el código de Lagom, el marco que estaba usando.

Descubrí que la implementación del procesador de eventos almacenaba los controladores en un mapa con la clase registrada como clave. Cuando se emite un evento, busca su clase en ese mapa para que el controlador llame. El procesador de eventos se implementa de la siguiente manera:

 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))) } }

Arriba, registramos un controlador para la clase UserEvent , pero siempre que se emitía un evento derivado como UserCreated , el procesador no encontraba su clase en el registro.

Así comienza el código repetitivo

La solución es registrar el mismo controlador para cada clase de evento concreto. Podemos hacerlo así:

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

¡Ahora el código funciona! Pero es repetitivo.

También es difícil de mantener, ya que necesitaremos modificarlo cada vez que introduzcamos un nuevo tipo de evento. También podríamos tener otros lugares en nuestra base de código donde nos veamos obligados a enumerar todos los tipos concretos. También tendríamos que asegurarnos de modificar esos lugares.

Esto es decepcionante, ya que UserEvent es una clase sellada, lo que significa que todas sus subclases directas se conocen en tiempo de compilación. ¿Qué pasaría si pudiéramos aprovechar esa información para evitar la repetición?

Macros al rescate

Normalmente, las funciones de Scala devuelven un valor basado en los parámetros que les pasamos en tiempo de ejecución. Puede pensar en las macros de Scala como funciones especiales que generan algún código en tiempo de compilación para reemplazar sus invocaciones.

Si bien la interfaz macro puede parecer que toma valores como parámetros, su implementación en realidad capturará el árbol de sintaxis abstracta (AST), la representación interna de la estructura del código fuente que usa el compilador, de esos parámetros. Luego usa el AST para generar un nuevo AST. Finalmente, el nuevo AST reemplaza la llamada de macro en tiempo de compilación.

Veamos una declaración de macro que generará el registro del controlador de eventos para todas las subclases conocidas de una clase dada:

 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 }

Tenga en cuenta que para cada parámetro (incluido el parámetro de tipo y el tipo de retorno), el método de implementación tiene una expresión AST correspondiente como parámetro. Por ejemplo, c.Expr[EventProcessor[Event]] coincide con EventProcessor[Event] . El parámetro c: Context envuelve el contexto de compilación. Podemos usarlo para obtener toda la información disponible en tiempo de compilación.

En nuestro caso, queremos recuperar los hijos de nuestra clase sellada:

 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)

Tenga en cuenta la llamada recursiva al método de subclasses para garantizar que también se procesen las subclases indirectas.

Ahora que tenemos la lista de clases de eventos para registrar, podemos construir el AST para el código que generará la macro Scala.

Generando Código Scala: ¿ASTs o Quasiquotes?

Para construir nuestro AST, podemos manipular las clases de AST o usar las cuasicomillas de Scala. El uso de clases AST puede producir código que es difícil de leer y mantener. Por el contrario, las cuasicomillas reducen drásticamente la complejidad del código al permitirnos usar una sintaxis que es muy similar al código generado.

Para ilustrar la ganancia de simplicidad, tomemos la expresión simple a + 2 . Generar esto con clases AST se ve así:

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

Podemos lograr lo mismo con cuasicomillas con una sintaxis más concisa y legible:

 val exp = q"a + 2"

Para mantener nuestra macro sencilla, usaremos cuasicomillas.

Vamos a crear el AST y devolverlo como resultado de la función macro:

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

El código anterior comienza con la expresión del procesador recibida como parámetro y, para cada subclase de Event , genera una llamada al método addHandler con la subclase y la función del controlador como parámetros.

Ahora podemos llamar a la macro en la clase UserEvent y generará el código para registrar el controlador para todas las subclases:

 val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)

Eso generará este código:

 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)

El código del proyecto completo se compila correctamente y los casos de prueba demuestran que el controlador está efectivamente registrado para cada subclase de UserEvent . Ahora podemos tener más confianza en la capacidad de nuestro código para manejar nuevos tipos de eventos.

¿Código repetitivo? Obtenga macros de Scala para escribirlo

Aunque Scala tiene una sintaxis concisa que generalmente ayuda a evitar la repetición, los desarrolladores aún pueden encontrar situaciones en las que el código se vuelve repetitivo y no se puede refactorizar fácilmente para su reutilización. Las macros de Scala se pueden usar con cuasicomillas para superar estos problemas, manteniendo el código de Scala limpio y mantenible.

También hay bibliotecas populares, como Macwire, que aprovechan las macros de Scala para ayudar a los desarrolladores a generar código. Recomiendo encarecidamente a todos los desarrolladores de Scala que aprendan más sobre esta característica del lenguaje, ya que puede ser un activo valioso en su conjunto de herramientas.