Сокращение шаблонного кода с помощью макросов Scala и квазицитатов
Опубликовано: 2022-03-11Язык Scala предлагает разработчикам возможность писать объектно-ориентированный и функциональный код с чистым и лаконичным синтаксисом (по сравнению, например, с Java). Классы case, функции высшего порядка и вывод типов — вот некоторые из функций, которые разработчики Scala могут использовать для написания кода, который легче поддерживать и который менее подвержен ошибкам.
К сожалению, код Scala не застрахован от шаблонов, и разработчикам может быть трудно найти способ рефакторинга и повторного использования такого кода. Например, некоторые библиотеки заставляют разработчиков повторяться, вызывая API для каждого подкласса запечатанного класса.
Но это верно только до тех пор, пока разработчики не научатся использовать макросы и квазикавычки для генерации повторяющегося кода во время компиляции.
Вариант использования: регистрация одного и того же обработчика для всех подтипов родительского класса
При разработке системы микросервисов я хотел зарегистрировать единый обработчик для всех событий, происходящих от определенного класса. Чтобы не отвлекать нас особенностями фреймворка, который я использовал, вот упрощенное определение его API для регистрации обработчиков событий:
trait EventProcessor[Event] { def addHandler[E <: Event: ClassTag]( handler: E => Unit ): EventProcessor[Event] def process(event: Event) } Имея обработчик событий для любого типа Event , мы можем зарегистрировать обработчики для подклассов Event с помощью метода addHandler .
Глядя на приведенную выше сигнатуру, разработчик может ожидать, что обработчик, зарегистрированный для данного типа, будет вызываться для событий его подтипов. Например, рассмотрим следующую иерархию классов событий, связанных с жизненным циклом сущности User :
Соответствующие объявления Scala выглядят так:
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 Мы можем зарегистрировать обработчик для каждого конкретного класса событий. Но что, если мы хотим зарегистрировать обработчик для всех классов событий? Моей первой попыткой было зарегистрировать обработчик класса UserEvent . Я ожидал, что он будет вызываться для всех событий.
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent].addHandler[UserEvent](handler)Я заметил, что обработчик ни разу не вызывался во время тестов. Я копался в коде Lagom, фреймворка, который использовал.
Я обнаружил, что реализация обработчика событий хранит обработчики в карте с зарегистрированным классом в качестве ключа. Когда событие генерируется, оно ищет свой класс в этой карте, чтобы вызвать обработчик. Обработчик событий реализован следующим образом:
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))) } } Выше мы зарегистрировали обработчик для класса UserEvent , но всякий раз, когда возникало производное событие, такое как UserCreated , процессор не находил свой класс в реестре.
Так начинается шаблонный код
Решение состоит в том, чтобы зарегистрировать один и тот же обработчик для каждого конкретного класса событий. Мы можем сделать это так:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessor[UserEvent] .addHandler[UserCreated](handler) .addHandler[NameChanged](handler) .addHandler[EmailChanged](handler) .addHandler[UserDeleted.type](handler)Теперь код работает! Но это повторяется.
Его также сложно поддерживать, так как нам нужно будет изменять его каждый раз, когда мы вводим новый тип события. У нас также могут быть другие места в нашей кодовой базе, где мы вынуждены перечислять все конкретные типы. Мы также должны убедиться, что изменили эти места.
Это разочаровывает, поскольку UserEvent является закрытым классом, а это означает, что все его прямые подклассы известны во время компиляции. Что, если бы мы могли использовать эту информацию, чтобы избежать шаблонов?

Макросы в помощь
Обычно функции Scala возвращают значение на основе параметров, которые мы передаем им во время выполнения. Вы можете думать о макросах Scala как о специальных функциях, которые генерируют некоторый код во время компиляции , чтобы заменить их вызовы.
Хотя может показаться, что интерфейс macro принимает значения в качестве параметров, его реализация на самом деле захватывает абстрактное синтаксическое дерево (AST) — внутреннее представление структуры исходного кода, которое использует компилятор — этих параметров. Затем он использует AST для создания нового AST. Наконец, новый AST заменяет вызов макроса во время компиляции.
Давайте посмотрим на объявление macro , которое будет генерировать регистрацию обработчика событий для всех известных подклассов данного класса:
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 } Обратите внимание, что для каждого параметра (включая параметр типа и возвращаемый тип) метод реализации имеет соответствующее выражение AST в качестве параметра. Например, c.Expr[EventProcessor[Event]] соответствует EventProcessor[Event] . Параметр c: Context оборачивает контекст компиляции. Мы можем использовать его, чтобы получить всю информацию, доступную во время компиляции.
В нашем случае мы хотим получить дочерние элементы нашего закрытого класса:
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) Обратите внимание на рекурсивный вызов метода subclasses , чтобы убедиться, что косвенные подклассы также обрабатываются.
Теперь, когда у нас есть список классов событий для регистрации, мы можем создать AST для кода, который сгенерирует макрос Scala.
Генерация кода Scala: AST или квазицитаты?
Чтобы построить наш AST, мы можем либо манипулировать классами AST, либо использовать квазикавычки Scala. Использование классов AST может создавать код, который трудно читать и поддерживать. Напротив, квазикавычки значительно снижают сложность кода, позволяя нам использовать синтаксис, который очень похож на сгенерированный код.
Чтобы проиллюстрировать выигрыш в простоте, давайте возьмем простое выражение a + 2 . Создание этого с помощью классов AST выглядит следующим образом:
val exp = Apply(Select(Ident(TermName("a")), TermName("$plus")), List(Literal(Constant(2))))Мы можем добиться того же с помощью квазикавычек с более кратким и читаемым синтаксисом:
val exp = q"a + 2"Чтобы сделать наш макрос простым, мы будем использовать квазикавычки.
Давайте создадим AST и вернем его как результат макрофункции:
val calls = children.foldLeft(q"$processor")((current, ref) => q"$current.addHandler[$ref]($handler)" ) c.Expr[EventProcessor[Event]](calls) Приведенный выше код начинается с выражения процессора, полученного в качестве параметра, и для каждого подкласса Event генерирует вызов метода addHandler с подклассом и функцией-обработчиком в качестве параметров.
Теперь мы можем вызвать макрос для класса UserEvent , и он сгенерирует код для регистрации обработчика для всех подклассов:
val handler = new EventHandlerImpl[UserEvent] val processor = EventProcessorMacro.addHandlers(EventProcessor[UserEvent],handler)Это сгенерирует этот код:
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) Код всего проекта компилируется корректно, и тестовые примеры демонстрируют, что обработчик действительно зарегистрирован для каждого подкласса UserEvent . Теперь мы можем быть более уверены в способности нашего кода обрабатывать новые типы событий.
Повторяющийся код? Получите макросы Scala, чтобы написать это
Несмотря на то, что Scala имеет краткий синтаксис, который обычно помогает избежать шаблонного кода, разработчики все же могут столкнуться с ситуациями, когда код становится повторяющимся и его нельзя легко реорганизовать для повторного использования. Макросы Scala можно использовать с квазикавычками для преодоления таких проблем, сохраняя код Scala чистым и удобным для сопровождения.
Существуют также популярные библиотеки, такие как Macwire, которые используют макросы Scala, чтобы помочь разработчикам генерировать код. Я настоятельно рекомендую каждому разработчику Scala узнать больше об этой функции языка, поскольку она может стать ценным активом в вашем наборе инструментов.
