Reduza o código Boilerplate com macros Scala e quase aspas

Publicados: 2022-03-11

A linguagem Scala oferece aos desenvolvedores a oportunidade de escrever código funcional e orientado a objetos em uma sintaxe limpa e concisa (em comparação com Java, por exemplo). Classes de caso, funções de ordem superior e inferência de tipo são alguns dos recursos que os desenvolvedores Scala podem aproveitar para escrever código que seja mais fácil de manter e menos propenso a erros.

Infelizmente, o código Scala não é imune ao clichê, e os desenvolvedores podem se esforçar para encontrar uma maneira de refatorar e reutilizar esse código. Por exemplo, algumas bibliotecas forçam os desenvolvedores a se repetirem chamando uma API para cada subclasse de uma classe selada.

Mas isso só é verdade até que os desenvolvedores aprendam como aproveitar macros e quase aspas para gerar o código repetido em tempo de compilação.

Caso de uso: registrando o mesmo manipulador para todos os subtipos de uma classe pai

Durante o desenvolvimento de um sistema de microsserviços, eu queria registrar um único handler para todos os eventos derivados de uma determinada classe. Para evitar nos distrair com as especificidades do framework que eu estava usando, aqui está uma definição simplificada de sua API para registrar manipuladores de eventos:

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

Tendo um processador de eventos para qualquer tipo de Event , podemos registrar handlers para subclasses de Event com o método addHandler .

Observando a assinatura acima, um desenvolvedor pode esperar que um manipulador registrado para um determinado tipo seja invocado para eventos de seus subtipos. Por exemplo, vamos considerar a seguinte hierarquia de classes de eventos envolvidos no ciclo de vida da entidade User :

Hierarquia de eventos Scala descendentes de UserEvent. Existem três descendentes diretos: UserCreated (com nome e email, que são ambos Strings), UserChanged e UserDeleted. Além disso, UserChanged tem dois descendentes próprios: NameChanged (com um nome, que é uma string) e EmailChanged (com um email, que é uma string).
Uma hierarquia de classes de eventos Scala.

As declarações Scala correspondentes são assim:

 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 um manipulador para cada classe de evento específica. Mas e se quisermos registrar um handler para todas as classes de eventos? Minha primeira tentativa foi registrar o manipulador para a classe UserEvent . Eu esperava que fosse invocado para todos os eventos.

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

Percebi que o handler nunca foi invocado durante os testes. Eu mergulhei no código do Lagom, o framework que eu estava usando.

Descobri que a implementação do processador de eventos armazenava os manipuladores em um mapa com a classe registrada como chave. Quando um evento é emitido, ele procura sua classe nesse mapa para fazer com que o manipulador chame. O processador de eventos é implementado nestas linhas:

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

Acima, registramos um manipulador para a classe UserEvent , mas sempre que um evento derivado como UserCreated era emitido, o processador não encontrava sua classe no registro.

Assim começa o código de referência

A solução é registrar o mesmo manipulador para cada classe de evento concreta. Podemos fazer assim:

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

Agora o código funciona! Mas é repetitivo.

Também é difícil de manter, pois precisaremos modificá-lo toda vez que introduzirmos um novo tipo de evento. Também podemos ter outros lugares em nossa base de código onde somos forçados a listar todos os tipos concretos. Também precisaríamos nos certificar de modificar esses lugares.

Isso é decepcionante, pois UserEvent é uma classe selada, o que significa que todas as suas subclasses diretas são conhecidas em tempo de compilação. E se pudéssemos aproveitar essas informações para evitar o clichê?

Macros ao resgate

Normalmente, as funções Scala retornam um valor baseado nos parâmetros que passamos para elas em tempo de execução. Você pode pensar em macros Scala como funções especiais que geram algum código em tempo de compilação para substituir suas invocações.

Embora a interface de macro possa parecer receber valores como parâmetros, sua implementação na verdade capturará a árvore de sintaxe abstrata (AST) — a representação interna da estrutura do código-fonte que o compilador usa — desses parâmetros. Em seguida, ele usa o AST para gerar um novo AST. Finalmente, o novo AST substitui a chamada de macro em tempo de compilação.

Vejamos uma declaração de macro que gerará o registro do manipulador de eventos para todas as subclasses conhecidas de uma determinada classe:

 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 }

Observe que para cada parâmetro (incluindo parâmetro de tipo e tipo de retorno), o método de implementação tem uma expressão AST correspondente como parâmetro. Por exemplo, c.Expr[EventProcessor[Event]] corresponde EventProcessor[Event] . O parâmetro c: Context envolve o contexto de compilação. Podemos usá-lo para obter todas as informações disponíveis em tempo de compilação.

No nosso caso, queremos recuperar os filhos da nossa classe selada:

 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)

Observe a chamada recursiva ao método de subclasses para garantir que as subclasses indiretas também sejam processadas.

Agora que temos a lista das classes de eventos a serem registradas, podemos construir o AST para o código que a macro Scala irá gerar.

Gerando Código Scala: ASTs ou Quasiquotes?

Para construir nosso AST, podemos manipular classes AST ou usar quase aspas Scala. O uso de classes AST pode produzir código difícil de ler e manter. Em contraste, as quase aspas reduzem drasticamente a complexidade do código, permitindo-nos usar uma sintaxe muito semelhante ao código gerado.

Para ilustrar o ganho de simplicidade, vamos usar a expressão simples a + 2 . Gerar isso com classes AST fica assim:

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

Podemos conseguir o mesmo com quase aspas com uma sintaxe mais concisa e legível:

 val exp = q"a + 2"

Para manter nossa macro simples, usaremos quase aspas.

Vamos criar o AST e retorná-lo como resultado da função macro:

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

O código acima começa com a expressão do processador recebida como parâmetro e, para cada subclasse Event , gera uma chamada ao método addHandler com a subclasse e a função handler como parâmetros.

Agora podemos chamar a macro na classe UserEvent e ela irá gerar o código para registrar o handler para todas as subclasses:

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

Isso vai gerar 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)

O código do projeto completo compila corretamente e os casos de teste demonstram que o manipulador está realmente registrado para cada subclasse de UserEvent . Agora podemos ter mais confiança na capacidade do nosso código para lidar com novos tipos de eventos.

Código repetitivo? Obter macros Scala para escrevê-lo

Embora Scala tenha uma sintaxe concisa que geralmente ajuda a evitar o clichê, os desenvolvedores ainda podem encontrar situações em que o código se torna repetitivo e não pode ser facilmente refatorado para reutilização. As macros Scala podem ser usadas com quase aspas para superar esses problemas, mantendo o código Scala limpo e passível de manutenção.

Existem também bibliotecas populares, como Macwire, que utilizam macros Scala para ajudar os desenvolvedores a gerar código. Eu encorajo cada desenvolvedor Scala a aprender mais sobre esse recurso de linguagem, pois ele pode ser um recurso valioso em seu conjunto de ferramentas.