Макроси та квазіцітати в 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 є актуальним інструментом, який може позбавити нас від різної рутинної роботи, яку в світі JVM раніше було прийнято реалізовувати через рефлексію або кодогенерацію.
 
Безумовно, макроси — це потужний інструмент, яким слід користуватися обережно: при неправильному використанні досить просто відстрелити собі ногу і впасти в прірву непідтримуваного коду. Однак завжди варто намагатися автоматизувати рутинну діяльність, і якщо макроси зможуть стати для нас підмогою в цьому завданні — вони будуть використовуватися і приноситимуть користь спільноті.
 
 

Використані матеріали

 
     
  1. Огляд макросів з документації Scala .
  2.  
  3. Огляд квазіцітат з документації Scala .
  4.  
  5. Огляд строковой інтерполяції з документації Scala .
  6.  
  7. Керівництво по МАКРОПРОЕКТ для SBT .
  8.  
  9. Вихідний код та тести до статті .
  10.  
    
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.