Макросы и квазицитаты в Scala 2.11.0
Это слегка модифицированная версия статьи, опубликованной на хабрахабре.
Не так давно состоялся релиз Scala 2.11.0. Одним из примечательных нововведений этой версии являются квазицитаты — удобный механизм для описания синтаксических деревьев Scala с помощью разбираемых во время компиляции строк; очевидно, что в первую очередь этот механизм предназначен для использования совместно с макросами.
В данном посте будет подробно рассмотрено написание простого макроса, предназначенного для генерации кода десериализации JSON в иерархию классов.
Постановка задачи
Существует замечательная библиотека для работы с JSON для Scala — spray.json.
Обычно для того, чтобы десериализовать какой-то JSON-объект с помощью этой библиотеки, достаточно пары импортов:
// Объявление класса, который будем десериализовывать:
case class MyClass(field: String)
// Импорт объектов spray.json:
import spray.json._
import DefaultJsonProtocol._
implicit val myClassFormat = jsonFormat1(MyClass)
val json = """{ "field\": "value" }"""
val obj = json.parseJson.convertTo[MyClass] // ok
Достаточно просто, не правда ли? А если мы хотим десериализовать иерархию классов целиком? Приведу пример иерархии, которую мы будем рассматривать в дальнейшем:
abstract sealed class Message()
case class SimpleMessage() extends Message
case class FieldMessage(field: String) extends Message
case class NestedMessage(nested: Message) extends Message
case class MultiMessage(field: Int, nested: Message) extends Message
Как видно, несколько десериализуемых классов с разным количеством аргументов различных типов наследуются от абстрактного родителя. Вполне естественное желание при десериализации таких сущностей — это добавить поле type
в JSON-объект, а при десериализации диспетчеризоваться по этому полю. Идея может быть выражена следующим псевдокодом:
json.type match {
case "SimpleMessage" => SimpleMessage()
case "FieldMessage" => FieldMessage(json.field)
// ...
}
Библиотека spray.json предоставляет возможность определить конвертацию JSON в любые типы по определяемым пользователем правилам посредством расширения форматтера RootJsonFormat
. Звучит совсем как то, что нам нужно. Ядро нашего форматтера должно выглядеть следующим образом:
val typeName = ...
typeName match {
case "FieldMessage" => map.getFields("field") match {
case Seq(field) => new FieldMessage(field.convertTo[String])
}
case "NestedMessage" => map.getFields("nested") match {
case Seq(nested) => new NestedMessage(nested.convertTo[Message])
}
case "MultiMessage" => map.getFields("field", "nested") match {
case Seq(field, nested) => new MultiMessage(field.convertTo[Int], nested.convertTo[Message])
}
case "SimpleMessage" => map.getFields() match {
case Seq() => new SimpleMessage()
}
}
Выглядит этот код немного... шаблонным. Это же отличная задача для макроса! Оставшаяся часть статьи посвящена разработке макроса, который сможет сгенерировать такой код, имея в качестве отправной точки лишь тип Message
.
Организация проекта
Первое препятствие, с которым программист сталкивается при разработке макросов, заключается в том, что SBT не хочет компилировать одновременно и макрос, и использующий его код. Данная проблема рассмотрена в документации SBT и я рекомендую описанное ниже решение.
Нужно разделить код макросов и основной код приложения на два проекта, на которые следует сослаться в главном файле project/Build.sbt
. В сопровождающем статью коде уже сделаны эти приготовления, вот ссылки на результирующие файлы:
- главный сборочный файл
project/Build.sbt
; - проект для макросов:
macro/build.sbt
; - основной проект:
main/build.sbt
.
Ещё одна тонкость заключается в том, что если вы хотите, чтобы макрос работал с иерархией классов — на момент раскрытия макроса эта иерархия должна быть известна. Это вызывает некоторые проблемы, т.к. последовательность обработки файлов компилятором не всегда очевидна. Решение этого вопроса — либо располагать классы, с которыми должен работать макрос, в одном проекте с макросом (при этом раскрытие макроса по-прежнему должно быть в другом проекте), или просто разместить нужные классы в том же файле, в котором производится раскрытие макроса.
При отладке макросов очень помогает параметр компилятора -Ymacro-debug-lite
, который позволяет вывести в консоль результаты разворачивания всех макросов в проекте (эти результаты очень похожи на код Scala, и зачастую могут быть без изменений скомпилированы вручную при передаче компилятору, что может помочь в отладке нетривиальных случаев).
Макросы
Макросы в Scala работают почти так же, как reflection. Обратите внимание, Scala reflection API значительно отличается от Java reflection, поскольку не все концепции Scala известны стандартной библиотеке Java.
Механизм макросов в Scala предоставляет возможность создания участков кода во время компиляции. Это делается с помощью строго типизированного API, который генерирует синтаксические деревья, соответствующие коду, который вы хотите создать. Макросы Scala значительно отличаются от всем привычных макросов языка C, так что путать их не стоит.
В основе макросов Scala лежит класс Context
. Экземпляр этого класса всегда передаётся макросу при раскрытии. Затем можно из него импортировать внутренности объекта Universe
и использовать их точно так же, как в runtime reflection — запрашивать оттуда дескрипторы типов, методов, свойств и т.п. Этот же контекст позволяет создавать синтаксические деревья при помощи классов наподобие Literal
, Constant
, List
и др.
По сути макрос — это функция, которая принимает и возвращает синтаксические деревья. Напишем шаблон нашего макроса:
import scala.language.experimental.macros
import scala.reflect.macros.blackbox.Context
import spray.json._
object Parsers {
def impl[T: c.WeakTypeTag](c: Context)(typeName: c.Expr[String], map: c.Expr[JsObject]): c.Expr[T] = {
import c.universe._
val cls = weakTypeOf[T].typeSymbol.asClass
val tree = ??? // построение синтаксического дерева будет рассмотрено дальше
c.Expr[T](tree)
}
def parseMessage[T](typeName: String, map: JsObject): T = macro Parsers.impl[T]
}
Макрос parseMessage[T]
принимает тип T
, который является базовым для иерархии десериализуемых классов, и синтаксическое дерево для получения типа десериализуемого объекта map
, а возвращает синтаксическое дерево для получения десериализованного объекта, приведённого к базовому типу T
.
Аргумент типа T
описан специальным образом: указано, что компилятор должен приложить к нему неявно сгенерированный объект типа c.WeakTypeTag
. Вообще говоря, неявный аргумент TypeTag
используется в Scala для того, чтобы работать с типами-аргументами генериков, обычно недоступными во время выполнения из-за type erasure. Для аргументов макросов компилятор требует использовать не просто TypeTag
, а WeakTypeTag
, что, насколько я понимаю, связано с особенностями работы компилятора (у него нет "полноценного" TypeTag
для типа, который может быть ещё не полностью сгенерирован во время раскрытия макроса). Тип, ассоциированный с TypeTag
, можно получить при помощи метода typeOf[T]
объекта Universe
; соответственно, для WeakTypeTag
существует метод weakTypeOf[T]
.
Одним из недостатков макросов является неочевидность описания синтаксических деревьев. Например, фрагмент кода 2 + 2
при генерации должен выглядеть как Apply(Select(Literal(Constant(2)), TermName("$plus")), List(Literal(Constant(2))))
; ещё более серьёзные случаи начинаются, когда нам нужно представить более крупные куски кода с подстановкой шаблонов. Естественно, такая сложность нам не нравится и мы будем её преодолевать.
Квазицитаты
Вышеупомянутый недостаток макросов начиная с версии Scala 2.11.0 может быть легко решён с помощью квазицитат. Например, вышеупомянутая конструкция, описывающая выражение 2 + 2
, в виде квазицитаты будет выглядеть просто как q"2 + 2"
, что очень удобно. В целом квазицитаты в Scala — это набор строковых интерполяторов, которые расположены в объекте Universe
. После импортирования этих интерполяторов в текущей область видимости появляется возможность использовать ряд символов перед строковой константой, которые определяют её обработку компилятором. В частности, при реализации рассматриваемой задачи нам пригодятся интерполяторы pq
для паттернов, cq
для веток выражения match
, а также q
для законченных выражений языка.
Как и для других строковых интерполяторов языка Scala, из квазицитат можно ссылаться на переменные окружающей их области видимости. Например, для генерации выражения 2 + 2
можно воспользоваться следующим кодом:
val a = 2
q"$a + $a"
Для переменных разных типов интерполяция может происходить по-разному. Например, переменные строкового типа в генерируемых деревьях становятся строковыми константами. Для того, чтобы сослаться на переменную по имени, нужно создать объект TermName
.
Как видно из примера генерируемого кода, приведённого в начале статьи, нам нужно уметь генерировать следующие элементы:
match
по переменнойtypeName
с веткамиcase
, соответствующими каждому типу иерархии;- в каждой ветке — передача списка названий аргументов конструктора соответствующего класса в метод
map.getFields
; - там же — деконструкция полученной последовательности (с помощью того же выражения
match
) на переменные и передача этих переменных в конструктор типа.
В первую очередь рассмотрим генерацию общего дерева всего выражения match
. Для этого придётся использовать интерполяцию переменных в контексте квазицитаты:
val clauses: Set[Tree] = ??? // см. ниже
val tree = q"$typeName match { case ..$clauses }"
В данном участке кода используется особый вид интерполяции. Выражение case ..$clauses
внутри блока match
будет раскрыто как список ветвей case
. Как мы помним, каждая ветвь должна выглядеть следующим образом:
case "FieldMessage" => map.getFields("field") match {
case Seq(field) => new FieldMessage(field.convertTo[String])
}
В виде квазицитаты такая ветка может быть записана следующим образом:
val tpe: Type // обрабатываемый наследник
val constructorParameters: List[Symbol] // список параметров конструктора
val parameterNames = constructorParameters.map(_.name)
val parameterNameStrings = parameterNames.map(_.toString)
// Паттерны для дальнейшего матчинга создаются с помощью интерпорятора pq:
val parameterBindings = parameterNames.map(name => pq"$name")
// Это будут выражения, результаты которых передаются в конструктор:
val args = constructorParameters.map { param =>
val parameterName = TermName(param.name.toString)
val parameterType = param.typeSignature
q"$parameterName.convertTo[$parameterType]"
}
// Генерируем окончательный вид ветки case:
val typeName = tpe.typeSymbol
val typeNameString = typeName.name.toString
cq"""$typeNameString =>
$map.getFields(..$parameterNameStrings) match {
case Seq(..$parameterBindings) => new $typeName(..$args)
}"""
В данном фрагменте кода используется несколько квазицитат: выражение pq"$name"
создаёт набор паттернов, которые в дальнейшем подставляются в выражение Seq(...)
. Каждое из этих выражений имеет тип JsValue
, который нужно преобразовать к соответствующему типу перед передачей в конструктор; для этого используется квазицитата, генерирующая вызов метода convertTo
. Обращаю внимание читателя на то, что, этот метод может рекурсивно вызвать наш форматтер при необходимости (то есть можно вкладывать объекты типа Message
друг в друга).
Наконец, результирующее синтаксическое дерево, состоящее из выражения match<
со сгенерированными нами ветками case
может быть построено также с использованием интерполяции:
val tree = q"$typeName match { case ..$clauses }"
Это дерево будет встроено компилятором по месту применения макроса.
Выводы
В течение всего времени развития технологий, метапрограммирование становится всё более важным элементом языков программирования, всё чаще его применяют в повседневном коде для реализации различных концепций. Макросы Scala являются актуальным инструментом, который может избавить нас от различной рутинной работы, которую в мире JVM ранее было принято реализовывать через рефлексию или кодогенерацию.
Безусловно, макросы — это мощный инструмент, которым следует пользоваться осторожно: при неправильном использовании достаточно просто отстрелить себе ногу и упасть в пропасть неподдерживаемого кода. Однако всегда стоит стараться автоматизировать рутинную деятельность, и если макросы смогут стать для нас подспорьем в этой задаче — они будут использоваться и будут приносить пользу сообществу.