Riduci il codice boilerplate con macro Scala e Quasiquotes

Pubblicato: 2022-03-11

Il linguaggio Scala offre agli sviluppatori l'opportunità di scrivere codice orientato agli oggetti e funzionale in una sintassi pulita e concisa (rispetto a Java, per esempio). Classi case, funzioni di ordine superiore e inferenza di tipo sono alcune delle funzionalità che gli sviluppatori Scala possono sfruttare per scrivere codice più facile da mantenere e meno soggetto a errori.

Sfortunatamente, il codice Scala non è immune da standard e gli sviluppatori potrebbero avere difficoltà a trovare un modo per refactoring e riutilizzare tale codice. Ad esempio, alcune librerie obbligano gli sviluppatori a ripetersi chiamando un'API per ogni sottoclasse di una classe sigillata.

Ma questo è vero solo fino a quando gli sviluppatori non impareranno come sfruttare le macro e le quasivirgolette per generare il codice ripetuto in fase di compilazione.

Caso d'uso: registrazione dello stesso gestore per tutti i sottotipi di una classe padre

Durante lo sviluppo di un sistema di microservizi, volevo registrare un unico gestore per tutti gli eventi derivati ​​da una determinata classe. Per evitare di distrarci con le specifiche del framework che stavo usando, ecco una definizione semplificata della sua API per la registrazione dei gestori di eventi:

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

Avendo un elaboratore di eventi per qualsiasi tipo di Event , possiamo registrare gestori per le sottoclassi di Event con il metodo addHandler .

Osservando la firma sopra, uno sviluppatore potrebbe aspettarsi che un gestore registrato per un determinato tipo venga invocato per eventi dei suoi sottotipi. Ad esempio, consideriamo la seguente gerarchia di classi di eventi coinvolti nel ciclo di vita dell'entità User :

Gerarchia degli eventi di Scala discendenti da UserEvent. Esistono tre discendenti diretti: UserCreated (con un nome e un'e-mail, che sono entrambe stringhe), UserChanged e UserDeleted. Inoltre, UserChanged ha due propri discendenti: NameChanged (che ha un nome, che è una stringa) ed EmailChanged (che ha un'e-mail, che è una stringa).
Una gerarchia di classi di eventi Scala.

Le corrispondenti dichiarazioni di Scala si presentano così:

 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

Possiamo registrare un gestore per ogni classe di evento specifica. Ma cosa succede se vogliamo registrare un gestore per tutte le classi di eventi? Il mio primo tentativo è stato quello di registrare il gestore per la classe UserEvent . Mi aspettavo che fosse invocato per tutti gli eventi.

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

Ho notato che il gestore non è mai stato invocato durante i test. Ho scavato nel codice di Lagom, il framework che stavo usando.

Ho scoperto che l'implementazione del processore di eventi memorizzava i gestori in una mappa con la classe registrata come chiave. Quando un evento viene emesso, cerca la sua classe in quella mappa per far chiamare il gestore. L'Event Processor è implementato secondo queste linee:

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

Sopra, abbiamo registrato un gestore per la classe UserEvent , ma ogni volta che veniva emesso un evento derivato come UserCreated , il processore non trovava la sua classe nel registro.

Così inizia il codice Boilerplate

La soluzione è registrare lo stesso gestore per ogni classe di evento concreta. Possiamo farlo in questo modo:

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

Ora il codice funziona! Ma è ripetitivo.

È anche difficile da mantenere, poiché dovremo modificarlo ogni volta che introduciamo un nuovo tipo di evento. Potremmo anche avere altri posti nella nostra base di codice in cui siamo costretti a elencare tutti i tipi concreti. Dovremmo anche assicurarci di modificare quei luoghi.

Questo è deludente, poiché UserEvent è una classe sigillata, il che significa che tutte le sue sottoclassi dirette sono note in fase di compilazione. E se potessimo sfruttare queste informazioni per evitare il boilerplate?

Macro in soccorso

Normalmente, le funzioni Scala restituiscono un valore basato sui parametri che gli passiamo in fase di esecuzione. Puoi pensare alle macro di Scala come a funzioni speciali che generano del codice in fase di compilazione con cui sostituire le loro invocazioni.

Sebbene l'interfaccia della macro possa sembrare assumere valori come parametri, la sua implementazione catturerà effettivamente l'albero della sintassi astratta (AST), la rappresentazione interna della struttura del codice sorgente utilizzata dal compilatore, di tali parametri. Quindi utilizza l'AST per generare un nuovo AST. Infine, il nuovo AST sostituisce la macro call in fase di compilazione.

Diamo un'occhiata a una dichiarazione di macro che genererà la registrazione del gestore eventi per tutte le sottoclassi conosciute di una determinata 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 }

Si noti che per ogni parametro (inclusi parametro di tipo e tipo restituito), il metodo di implementazione ha un'espressione AST corrispondente come parametro. Ad esempio, c.Expr[EventProcessor[Event]] corrisponde a EventProcessor[Event] . Il parametro c: Context esegue il wrapping del contesto di compilazione. Possiamo usarlo per ottenere tutte le informazioni disponibili in fase di compilazione.

Nel nostro caso, vogliamo recuperare i bambini della nostra classe sigillata:

 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)

Notare la chiamata ricorsiva al metodo delle subclasses per garantire che vengano elaborate anche le sottoclassi indirette.

Ora che abbiamo l'elenco delle classi di eventi da registrare, possiamo costruire l'AST per il codice che genererà la macro Scala.

Generazione di codice Scala: AST o quasiquotes?

Per costruire il nostro AST, possiamo manipolare le classi AST o usare quasiquote Scala. L'uso delle classi AST può produrre codice difficile da leggere e mantenere. Al contrario, le quasivirgolette riducono drasticamente la complessità del codice consentendoci di utilizzare una sintassi molto simile al codice generato.

Per illustrare il guadagno di semplicità, prendiamo l'espressione semplice a + 2 . La generazione di questo con le classi AST è simile a questa:

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

Possiamo ottenere lo stesso con le quasivirgolette con una sintassi più concisa e leggibile:

 val exp = q"a + 2"

Per mantenere la nostra macro semplice, useremo le quasivirgolette.

Creiamo l'AST e lo restituiamo come risultato della funzione macro:

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

Il codice precedente inizia con l'espressione del processore ricevuta come parametro e, per ogni sottoclasse Event , genera una chiamata al metodo addHandler con la sottoclasse e la funzione del gestore come parametri.

Ora possiamo chiamare la macro sulla classe UserEvent e genererà il codice per registrare il gestore per tutte le sottoclassi:

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

Questo genererà questo codice:

 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)

Il codice del progetto completo viene compilato correttamente ei test case dimostrano che il gestore è effettivamente registrato per ogni sottoclasse di UserEvent . Ora possiamo essere più sicuri della capacità del nostro codice di gestire nuovi tipi di eventi.

Codice ripetitivo? Ottieni le macro di Scala per scriverlo

Anche se Scala ha una sintassi concisa che di solito aiuta a evitare il boilerplate, gli sviluppatori possono comunque trovare situazioni in cui il codice diventa ripetitivo e non può essere facilmente rifattorizzato per il riutilizzo. Le macro di Scala possono essere utilizzate con quasi virgolette per superare tali problemi, mantenendo il codice Scala pulito e manutenibile.

Ci sono anche librerie popolari, come Macwire, che sfruttano le macro Scala per aiutare gli sviluppatori a generare codice. Incoraggio vivamente ogni sviluppatore di Scala a saperne di più su questa funzionalità del linguaggio, poiché può essere una risorsa preziosa nel tuo set di strumenti.