27 мая 2014

Макросы и квазицитаты в Scala 2.11.0

Блог компании Блог компании EnterraScalaФункциональное программирование
Не так давно состоялся релиз 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 являются актуальным инструментом, который может избавить нас от различной рутинной работы, которую в мире JVM ранее было принято реализовывать через рефлексию или кодогенерацию.

Безусловно, макросы — это мощный инструмент, которым следует пользоваться осторожно: при неправильном использовании достаточно просто отстрелить себе ногу и упасть в пропасть неподдерживаемого кода. Однако всегда стоит стараться автоматизировать рутинную деятельность, и если макросы смогут стать для нас подспорьем в этой задаче — они будут использоваться и будут приносить пользу сообществу.

Использованные материалы


  1. Обзор макросов из документации Scala.
  2. Обзор квазицитат из документации Scala.
  3. Обзор строковой интерполяции из документации Scala.
  4. Руководство по макропроектам для SBT.
  5. Исходный код и тесты к статье.
Теги:scalaмакросыметапрограммированиеквазицитаты
Хабы: Блог компании Блог компании Enterra Scala Функциональное программирование
+25
11,1k 68
Комментарии 8
Лучшие публикации за сутки