Zredukuj kod Boilerplate za pomocą makr i quasiquotes Scala

Opublikowany: 2022-03-11

Język Scala oferuje programistom możliwość pisania zorientowanego obiektowo i funkcjonalnego kodu w czystej i zwięzłej składni (w porównaniu na przykład z Javą). Klasy przypadku, funkcje wyższego rzędu i wnioskowanie o typie to tylko niektóre z funkcji, które programiści Scala mogą wykorzystać do pisania kodu, który jest łatwiejszy w utrzymaniu i mniej podatny na błędy.

Niestety kod Scali nie jest odporny na schematy, a programiści mogą mieć trudności ze znalezieniem sposobu na refaktoryzację i ponowne wykorzystanie takiego kodu. Na przykład niektóre biblioteki zmuszają programistów do powtarzania się, wywołując API dla każdej podklasy zapieczętowanej klasy.

Ale to prawda, dopóki programiści nie nauczą się wykorzystywać makr i quasi-cytatów do generowania powtarzającego się kodu w czasie kompilacji.

Przypadek użycia: rejestracja tej samej obsługi dla wszystkich podtypów klasy nadrzędnej

Podczas tworzenia systemu mikroserwisów chciałem zarejestrować jeden handler dla wszystkich zdarzeń pochodzących z określonej klasy. Aby nie rozpraszać nas szczegółami frameworka, którego używałem, oto uproszczona definicja jego API do rejestrowania obsługi zdarzeń:

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

Mając procesor zdarzeń dla dowolnego typu Event , możemy zarejestrować handlery dla podklas Event metodą addHandler .

Patrząc na powyższą sygnaturę, programista może oczekiwać, że procedura obsługi zarejestrowana dla danego typu będzie wywoływana dla zdarzeń jego podtypów. Rozważmy na przykład następującą hierarchię klas zdarzeń związanych z cyklem życia jednostki User :

Hierarchia zdarzeń Scali malejąco od UserEvent. Istnieją trzech bezpośrednich potomków: UserCreated (mający nazwę i adres e-mail, które są ciągami), UserChanged i UserDeleted. Ponadto UserChanged ma dwóch własnych potomków: NameChanged (posiadający nazwę, która jest ciągiem) i EmailChanged (posiadający adres e-mail, który jest ciągiem).
Hierarchia klas zdarzeń Scala.

Odpowiednie deklaracje Scali wyglądają tak:

 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

Możemy zarejestrować handler dla każdej konkretnej klasy zdarzenia. Ale co, jeśli chcemy zarejestrować handler dla wszystkich klas zdarzeń? Moją pierwszą próbą było zarejestrowanie handlera dla klasy UserEvent . Spodziewałem się, że będzie przywoływany we wszystkich wydarzeniach.

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

Zauważyłem, że przewodnik nigdy nie był przywoływany podczas testów. Dokopałem się do kodu Lagoma, frameworka, którego używałem.

Odkryłem, że implementacja procesora zdarzeń przechowuje procedury obsługi w mapie z zarejestrowaną klasą jako kluczem. Gdy zdarzenie jest emitowane, szuka swojej klasy w tej mapie, aby wywołać procedurę obsługi. Procesor zdarzeń jest zaimplementowany w następujący sposób:

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

Powyżej zarejestrowaliśmy procedurę obsługi dla klasy UserEvent , ale za każdym razem, gdy zostało wyemitowane zdarzenie pochodne, takie jak UserCreated , procesor nie znalazłby swojej klasy w rejestrze.

Tak zaczyna się kod płyty kotłowej

Rozwiązaniem jest zarejestrowanie tej samej procedury obsługi dla każdej konkretnej klasy zdarzenia. Możemy to zrobić tak:

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

Teraz kod działa! Ale to się powtarza.

Jest również trudny w utrzymaniu, ponieważ będziemy musieli go modyfikować za każdym razem, gdy wprowadzamy nowy typ zdarzenia. Możemy również mieć inne miejsca w naszej bazie kodu, w których jesteśmy zmuszeni wymienić wszystkie konkretne typy. Musielibyśmy również zadbać o modyfikację tych miejsc.

Jest to rozczarowujące, ponieważ UserEvent jest klasą zapieczętowaną, co oznacza, że ​​wszystkie jej bezpośrednie podklasy są znane w czasie kompilacji. Co by było, gdybyśmy mogli wykorzystać te informacje, aby uniknąć schematu?

Makra na ratunek

Zwykle funkcje Scali zwracają wartość na podstawie parametrów, które przekazujemy do nich w czasie wykonywania. Możesz myśleć o makrach Scala jako specjalnych funkcjach, które generują kod w czasie kompilacji , aby zastąpić ich wywołania.

Chociaż interfejs macro może wydawać się, że przyjmuje wartości jako parametry, jego implementacja w rzeczywistości przechwyci abstrakcyjne drzewo składni (AST) — wewnętrzną reprezentację struktury kodu źródłowego, której używa kompilator — tych parametrów. Następnie używa AST do wygenerowania nowego AST. Wreszcie, nowy AST zastępuje wywołanie makra w czasie kompilacji.

Spójrzmy na deklarację macro , która wygeneruje rejestrację procedury obsługi zdarzeń dla wszystkich znanych podklas danej klasy:

 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 }

Zauważ, że dla każdego parametru (w tym parametru typu i typu zwracanego) metoda implementacji ma odpowiadające wyrażenie AST jako parametr. Na przykład c.Expr[EventProcessor[Event]] pasuje EventProcessor[Event] . Parametr c: Context otacza kontekst kompilacji. Możemy go użyć, aby uzyskać wszystkie informacje dostępne w czasie kompilacji.

W naszym przypadku chcemy odzyskać dzieci naszej zapieczętowanej klasy:

 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)

Zwróć uwagę na rekurencyjne wywołanie metody subclasses , aby upewnić się, że przetwarzane są również podklasy pośrednie.

Teraz, gdy mamy już listę klas zdarzeń do zarejestrowania, możemy zbudować AST dla kodu, który wygeneruje makro Scala.

Generowanie kodu Scala: AST czy quasiquotes?

Aby zbudować nasze AST, możemy albo manipulować klasami AST, albo użyć quasicytatów Scala. Korzystanie z klas AST może generować kod, który jest trudny do odczytania i utrzymania. W przeciwieństwie do tego, quasiquotes radykalnie zmniejszają złożoność kodu, pozwalając nam na użycie składni bardzo podobnej do wygenerowanego kodu.

Aby zilustrować wzrost prostoty, weźmy proste wyrażenie a + 2 . Generowanie tego za pomocą klas AST wygląda tak:

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

To samo możemy osiągnąć za pomocą quasicytatów o bardziej zwięzłej i czytelnej składni:

 val exp = q"a + 2"

Aby nasze makro było proste, użyjemy quasicytatów.

Utwórzmy AST i zwróćmy go jako wynik funkcji makra:

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

Powyższy kod zaczyna się od wyrażenia procesora otrzymanego jako parametr, a dla każdej podklasy Event generuje wywołanie metody addHandler z podklasą i funkcją obsługi jako parametrami.

Teraz możemy wywołać makro w klasie UserEvent i wygeneruje ono kod rejestrujący procedurę obsługi dla wszystkich podklas:

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

To wygeneruje ten kod:

 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)

Kod całego projektu kompiluje się poprawnie, a przypadki testowe pokazują, że procedura obsługi jest rzeczywiście zarejestrowana dla każdej podklasy UserEvent . Teraz możemy być bardziej pewni zdolności naszego kodu do obsługi nowych typów zdarzeń.

Powtarzalny kod? Pobierz makra Scala, aby to napisać

Mimo że Scala ma zwięzłą składnię, która zwykle pomaga uniknąć schematu, programiści wciąż mogą znaleźć sytuacje, w których kod staje się powtarzalny i nie można go łatwo przerobić w celu ponownego użycia. Makra Scala mogą być używane z quasi-cytatami, aby przezwyciężyć takie problemy, utrzymując kod Scala w czystości i łatwy w utrzymaniu.

Istnieją również popularne biblioteki, takie jak Macwire, które wykorzystują makra Scala, aby pomóc programistom w generowaniu kodu. Gorąco zachęcam każdego programistę Scala, aby dowiedział się więcej o tej funkcji językowej, ponieważ może ona być cennym zasobem w twoim zestawie narzędzi.