Reduceți codul standard cu macrocomenzi Scala și cvasiquote
Publicat: 2022-03-11Limbajul Scala oferă dezvoltatorilor posibilitatea de a scrie cod funcțional și orientat pe obiecte într-o sintaxă curată și concisă (în comparație cu Java, de exemplu). Clasele de caz, funcțiile de ordin superior și inferența de tip sunt câteva dintre caracteristicile pe care dezvoltatorii Scala le pot folosi pentru a scrie cod care este mai ușor de întreținut și mai puțin predispus la erori.
Din păcate, codul Scala nu este imun la boilerplate, iar dezvoltatorii s-ar putea lupta să găsească o modalitate de a refactoriza și reutiliza un astfel de cod. De exemplu, unele biblioteci obligă dezvoltatorii să se repete apelând un API pentru fiecare subclasă a unei clase sigilate.
Dar asta este valabil doar până când dezvoltatorii învață cum să folosească macrocomenzi și cvasighilimele pentru a genera codul repetat în timpul compilării.
Caz de utilizare: înregistrarea aceluiași handler pentru toate subtipurile unei clase părinte
În timpul dezvoltării unui sistem de microservicii, am vrut să înregistrez un singur handler pentru toate evenimentele derivate dintr-o anumită clasă. Pentru a evita să ne distragi atenția cu specificul cadrului pe care îl foloseam, iată o definiție simplificată a API-ului său pentru înregistrarea gestionarilor de evenimente:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } Având un procesor de evenimente pentru orice tip de Event , putem înregistra handlere pentru subclasele de Event cu metoda addHandler .
Privind semnătura de mai sus, un dezvoltator s-ar putea aștepta ca un handler înregistrat pentru un anumit tip să fie invocat pentru evenimentele subtipurilor sale. De exemplu, să luăm în considerare următoarea ierarhie de clase a evenimentelor implicate în ciclul de viață al entității User :
Declarațiile Scala corespunzătoare arată astfel:
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 Putem înregistra un handler pentru fiecare clasă de eveniment specific. Dar dacă vrem să înregistrăm un handler pentru toate clasele de evenimente? Prima mea încercare a fost să înregistrez handlerul pentru clasa UserEvent . Mă așteptam să fie invocat pentru toate evenimentele.
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)Am observat că handlerul nu a fost niciodată invocat în timpul testelor. Am săpat în codul Lagom, cadrul pe care îl foloseam.
Am descoperit că implementarea procesorului de evenimente a stocat handlerii într-o hartă cu clasa înregistrată ca cheie. Când este emis un eveniment, acesta caută clasa sa în acea hartă pentru a determina handlerul să apeleze. Procesorul de evenimente este implementat pe următoarele linii:
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))) } } Mai sus, am înregistrat un handler pentru clasa UserEvent , dar ori de câte ori a fost emis un eveniment derivat precum UserCreated , procesorul nu și-a găsit clasa în registru.
Astfel începe codul standard
Soluția este să înregistrați același handler pentru fiecare clasă de evenimente concrete. O putem face astfel:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)Acum codul funcționează! Dar este repetitiv.
De asemenea, este dificil de întreținut, deoarece va trebui să-l modificăm de fiecare dată când introducem un nou tip de eveniment. S-ar putea să avem și alte locuri în baza noastră de cod unde suntem forțați să listăm toate tipurile concrete. De asemenea, ar trebui să ne asigurăm că modificăm acele locuri.
Acest lucru este dezamăgitor, deoarece UserEvent este o clasă sigilată, ceea ce înseamnă că toate subclasele sale directe sunt cunoscute la momentul compilării. Ce se întâmplă dacă am putea folosi aceste informații pentru a evita boileplate?

Macro-uri pentru salvare
În mod normal, funcțiile Scala returnează o valoare bazată pe parametrii pe care le transmitem în timpul execuției. Vă puteți gândi la macrocomenzile Scala ca la funcții speciale care generează ceva cod în timpul compilării pentru a le înlocui invocările.
În timp ce interfața macro ar putea părea să ia valori ca parametri, implementarea sa va capta de fapt arborele de sintaxă abstractă (AST) - reprezentarea internă a structurii codului sursă pe care o folosește compilatorul - a acelor parametri. Apoi utilizează AST pentru a genera un nou AST. În cele din urmă, noul AST înlocuiește apelul macro în timpul compilării.
Să ne uităm la o declarație macro care va genera înregistrarea de gestionare a evenimentelor pentru toate subclasele cunoscute ale unei clase date:
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 } Observați că pentru fiecare parametru (inclusiv parametrul tip și tipul returnat), metoda de implementare are ca parametru o expresie AST corespunzătoare. De exemplu, c.Expr[EventProcessor[Event]] se potrivește EventProcessor[Event] . Parametrul c: Context include contextul de compilare. Îl putem folosi pentru a obține toate informațiile disponibile în momentul compilării.
În cazul nostru, dorim să recuperăm copiii din clasa noastră sigilată:
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) Observați apelul recursiv la metoda subclasses pentru a vă asigura că și subclasele indirecte sunt procesate.
Acum că avem lista claselor de evenimente de înregistrat, putem construi AST pentru codul pe care macro-ul Scala îl va genera.
Generarea codului Scala: AST sau cvasiquote?
Pentru a construi AST-ul nostru, putem fie să manipulăm clasele AST, fie să folosim cvasi-ghilimele Scala. Utilizarea claselor AST poate produce cod greu de citit și de întreținut. În schimb, cvasighilimele reduc dramatic complexitatea codului, permițându-ne să folosim o sintaxă care este foarte asemănătoare cu codul generat.
Pentru a ilustra câștigul de simplitate, să luăm expresia simplă a + 2 . Generarea acestui lucru cu clasele AST arată astfel:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))Același lucru îl putem realiza cu cvasighilimele cu o sintaxă mai concisă și mai ușor de citit:
val exp = q"a + 2"Pentru a menține macrocomanda simplă, vom folosi cvasighilimele.
Să creăm AST și să-l returnăm ca rezultat al funcției macro:
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) Codul de mai sus începe cu expresia procesorului primită ca parametru, iar pentru fiecare subclasă Event , generează un apel la metoda addHandler cu funcția de subclasă și handler ca parametri.
Acum putem apela macro-ul din clasa UserEvent și va genera codul pentru a înregistra handlerul pentru toate subclasele:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)Asta va genera acest cod:
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) Codul proiectului complet se compilează corect, iar cazurile de testare demonstrează că handlerul este într-adevăr înregistrat pentru fiecare subclasă a UserEvent . Acum putem fi mai încrezători în capacitatea codului nostru de a gestiona noi tipuri de evenimente.
Cod repetitiv? Obțineți macrocomenzi Scala pentru a le scrie
Chiar dacă Scala are o sintaxă concisă care ajută de obicei la evitarea boilerplate, dezvoltatorii pot găsi totuși situații în care codul devine repetitiv și nu poate fi ușor refactorizat pentru reutilizare. Macrocomenzile Scala pot fi folosite cu cvasighilimele pentru a depăși astfel de probleme, păstrând codul Scala curat și ușor de întreținut.
Există, de asemenea, biblioteci populare, cum ar fi Macwire, care folosesc macrocomenzi Scala pentru a ajuta dezvoltatorii să genereze cod. Încurajez cu tărie fiecare dezvoltator Scala să învețe mai multe despre această caracteristică de limbă, deoarece poate fi un atu valoros în setul dvs. de instrumente.
