Макроси і квазіцітати в scala

Не так давно відбувся реліз Scala 2.11.0. Одним з примітних нововведень цієї версії є квазіцітати - зручний механізм для опису синтаксичних дерев Scala за допомогою розбираємо під час компіляції рядків; очевидно, що в першу чергу цей механізм призначений для використання спільно з макросами.

Дивно, але на Хабре поки тему макросів в Scala розглядають не дуже-то активно; останній пост
з серйозним розглядом макросів був аж цілий рік назад.

У даній статті буде докладно розглянуто написання простого макросу, призначеного для генерації коду десеріалізациі JSON в ієрархію класів.

Постановка задачі

Існує чудова бібліотека для роботи з JSON для Scala - spray.json.

Зазвичай для того, щоб десеріалізовать якийсь JSON-об'єкт за допомогою цієї бібліотеки, досить пари імпорту:

Досить просто, чи не так? А якщо ми хочемо десеріалізовать ієрархію класів цілком? Наведу приклад ієрархії, яку ми будемо розглядати в подальшому:

Як видно, кілька десеріалізуемих класів з різною кількістю аргументів різних типів успадковуються від абстрактного батька. Цілком природне бажання при десеріалізациі таких сутностей - це додати поле type в JSON-об'єкт, а при десеріалізациі діспетчерізоваться по цьому полю. Ідея може бути виражена таким псевдокодом:

Бібліотека spray.json надає можливість визначити конвертацію JSON в будь-які типи по визначеним користувачем правил за допомогою розширення форматування RootJsonFormat. Звучить зовсім як то, що нам потрібно. Ядро нашої форматування має виглядати наступним чином:

Виглядає цей код трохи ... шаблонним. Це ж чудова завдання для макросу! Частина, що залишилася статті присвячена розробці макросу, який зможе згенерувати такий код, маючи в якості відправної точки лише тип 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 і ін.

По суті макрос - це функція, яка приймає і повертає синтаксичні дерева. Напишемо шаблон нашого макросу:

Макрос 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 можна скористатися наступним кодом:

Для змінних різних типів інтерполяція може відбуватися по-різному. Наприклад, змінні строкового типу в генеруються деревах стають строковими константами. Для того, щоб послатися на змінну по імені, потрібно створити об'єкт TermName.

Як видно з прикладу генерованого коду, наведеного на початку статті, нам потрібно вміти генерувати такі елементи:

  • match по змінної typeName з гілками case. відповідними кожному типу ієрархії;
  • в кожній гілці - передача невеликий перелік аргументів конструктора відповідного класу в метод map.getFields;
  • там же - деконструкція отриманої послідовності (за допомогою того ж виразу match) на змінні і передача цих змінних в конструктор типу.

В першу чергу розглянемо генерацію загального дерева всього висловлювання match. Для цього доведеться використовувати інтерполяцію змінних в контексті квазіцітати:

У даній ділянці коду використовується особливий вид інтерполяції. Вираз case. $ Clauses всередині блоку match буде розкрито як список гілок case. Як ми пам'ятаємо, кожна гілка повинна виглядати наступним чином:

У вигляді квазіцітати така гілка може бути записана наступним чином:

У цьому фрагменті коду використовується кілька квазіцітат: вираз pq "$ name" створює набір патернів, які в подальшому підставляються у вираз Seq (.). Кожне з цих виразів має тип JsValue. який потрібно перетворити до відповідного типу перед передачею в конструктор; для цього використовується квазіцітата, генеруюча виклик методу convertTo. Зверніть увагу, цей метод може рекурсивно викликати наш форматтер при необхідності (тобто можна вкладати об'єкти типу Message один в одного.

Нарешті, результуюче синтаксичне дерево, що складається з виразу match зі згенерували нами гілками case може бути побудовано також з використанням інтерполяції:

Це дерево буде вбудовано компілятором за місцем застосування макросу.

Протягом всього часу розвитку технологій, метапрограмування стає все більш важливим елементом мов програмування, все частіше його застосовують в повсякденному коді для реалізації різних концепцій. Макроси Scala макроси Scala є актуальним інструментом, який може позбавити нас від різної рутинної роботи, яку в світі JVM раніше було прийнято реалізовувати через рефлексію або кодогенерацію.

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

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