Макросы и квазицитаты в Scala 2.11.0
Не так давно состоялся релиз Scala 2.11.0. Одним из примечательных нововведений этой версии являются квазицитаты — удобный механизм для описания синтаксических деревьев Scala с помощью разбираемых во время компиляции строк; очевидно, что в первую очередь этот механизм предназначен для использования совместно с макросами.Удивительно, но на хабре пока тему макросов в 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. В сопровождающем статью коде уже сделаны эти приготовления, вот ссылки на результирующие файлы:
Ещё одна тонкость заключается в том, что если вы хотите, чтобы макрос работал с иерархией классов — на момент раскрытия макроса эта иерархия должна быть известна. Это вызывает некоторые проблемы, т.к. последовательность обработки файлов компилятором не всегда очевидна. Решение этого вопроса — либо располагать классы, с которыми должен работать макрос, в одном проекте с макросом (при этом раскрытие макроса по-прежнему должно быть в другом проекте), или просто разместить нужные классы в том же файле, в котором производится раскрытие макроса.При отладке макросов очень помогает параметр компилятора -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 макросы Scala являются актуальным инструментом, который может избавить нас от различной рутинной работы, которую в мире JVM ранее было принято реализовывать через рефлексию или кодогенерацию.Безусловно, макросы — это мощный инструмент, которым следует пользоваться осторожно: при неправильном использовании достаточно просто отстрелить себе ногу и упасть в пропасть неподдерживаемого кода. Однако всегда стоит стараться автоматизировать рутинную деятельность, и если макросы смогут стать для нас подспорьем в этой задаче — они будут использоваться и будут приносить пользу сообществу.
Использованные материалы Обзор макросов из документации Scala. Обзор квазицитат из документации Scala. Обзор строковой интерполяции из документации Scala. Руководство по макропроектам для SBT. Исходный код и тесты к статье.