Scala 3, также известная под названием Dotty, будет выпущена в мае 2021 года. Одна из его флагманских функций - принципиальное метапрограммирование. Сюда входят макросы - создание кода во время компиляции.

Макросы впервые появились в экспериментальном API со времен Scala 2.10. Несмотря на то, что API был экспериментальным, макросы стали полезным инструментом, используемым рядом библиотек. Scala - гибкий и масштабируемый язык; тем не менее, макросы позволяют еще больше сократить шаблонный код в ряде случаев использования.

Учитывая их популярность, неудивительно, что следующая крупная версия языка Scala сохраняет возможности метапрограммирования, но в улучшенной форме. Новый подход больше не является экспериментальным и основан на опыте реализации предыдущих макросов.

На уровне кода макросы Scala 3, к сожалению, сильно отличаются от предыдущей версии; однако это в основном влияет на код библиотеки, поскольку именно там преимущественно использовались макросы.

Объем метапрограммирования в Dotty / Scala 3 также отличается от того, что мы видели в Scala 2. В некоторых областях он шире, в некоторых - более ограничен. Например, Scala 3 предоставляет обширную поддержку встраивания. С другой стороны, аннотаций макросов больше нет.

Простой макрос

Давайте посмотрим, как мы можем начать разработку простого макроса для Scala 3! Я делал похожий туториал для макросов Scala 2.10 8 лет назад! Тогда мы писали макрос, улучшающий отладку println.

Оказывается, 8 лет спустя println-debugging по-прежнему остается одним из основных методов отладки, которые я использую. Довольно часто мы хотим напечатать какое-то сообщение и помеченные значения, например:

println(
  s"Funds transferred, from = $from, to = $to, amount = $amount")

Было бы неплохо, если бы нам не пришлось дублировать названия используемых значений. Наша цель - написать макрос, который будет автоматически печатать метки значений. Синтаксис, которого мы хотим достичь, выглядит следующим образом:

debug("Funds transferred", from, to, amount)

Зачем нам здесь макрос? Нам нужно получить доступ к абстрактному синтаксическому дереву (AST) нашего кода, чтобы мы могли узнать, что это за имена. Давайте посмотрим, как можно шаг за шагом реализовать макрос. Весь код доступен на GitHub.

Настройка проекта

Каждый проект начинается со сборки; То же самое и здесь, мы будем использовать sbt (версия 1.5.2), но также можно использовать любой другой инструмент, поддерживающий Scala3 / Dotty.

Единственное свойство, которое нам нужно указать в build.sbt, - это версия Scala:

scalaVersion := "3.0.0"

Как только у нас есть это, мы можем импортировать проект в IntelliJ или Metals.

Привет, мир!

Мы будем работать с двумя исходными файлами. Во-первых, Debug.scala будет там, где будет реализован макрос отладки. Во-вторых, Test.scala будет там, где мы будем тестировать написанный код. Нам нужны два отдельных файла, так как они должны компилироваться отдельно компилятором: мы не можем использовать код, генерирующий код (макрос), пока он не скомпилирован сам!

Начнем с еще более простой задачи: написать код, который будет генерировать println("Hello, world!") при вызове. Это довольно тривиально:

object Debug:
  inline def hello(): Unit = println("Hello, world!")
object Test extends App:
  import Debug._
  hello()

Мы действительно написали макрос? Не совсем. Вместо этого мы воспользовались новой функцией метапрограммирования Scala 3 / Dotty: встраивание. Обратите внимание, что метод hello имеет префикс модификатора inline. Это указывает компилятору (и это не только предложение, но и требование), что после компиляции тело метода должно быть встроено в сайт вызова.

Следовательно, когда мы скомпилируем вышеуказанное и проверим байт-код, мы не увидим hello() вызова в нашем Test приложении. Вместо этого байт-код будет содержать непосредственно println("Hello, world!").

В некотором смысле, встраивание, как описано выше, является способом выполнения статического метапрограммирования - код создается, но на основе статически доступной информации, без каких-либо вычислений. Давайте посмотрим, как мы можем сделать еще один шаг и провести динамическое метапрограммирование.

Однопараметрическая отладка

Теперь нашей целью будет написать debugSingle метод, который расширит вызов debugSingle(x) на:

println("Value of x is " + x)

Встраивания уже недостаточно. Нам нужно получить доступ к имени (или фрагменту кода), которое передается нашему методу, а не к его значению. Мы начинаем так же, как и раньше, с метода, который должен быть встроен:

inline def debugSingle(expr: Any): Unit = ???

Это метод, который будет вызывать пользователь. Однако реализация должна будет работать с представлением параметра, которое дает доступ к информации, доступной во время компиляции: текстовое представление кода для генерации метки.

Обратите внимание, что это сильно отличается от представления во время выполнения; во время компиляции мы манипулируем деревьями, соответствующими выражениям. Во время выполнения мы манипулируем значениями, по которым вычисляются выражения.

Механизм преобразования между представлениями времени компиляции и времени выполнения называется цитирование и сращивание. Когда мы цитируем значение (добавляя выражение with'), мы возвращаем абстрактное синтаксическое дерево (AST; значение типа Expr[_]), представляющее выражение:

'expr: Expr[Any]

В случае макросов Scala AST - это наш код, представленный в виде данных, которые мы можем проверить. Expr - корневой тип; каждая конструкция Scala соответствует подклассу этого типа. Поскольку они могут быть вложенными (например, выражение If имеет дочерние выражения), наш код можно представить в виде дерева.

Когда мы соединяем значение с помощью ${ }, мы возвращаемся в область выполнения:

${anotherExpr: Expr[Any]}: Any

Вы можете думать о цитировании как о функции T => Expr[T], преобразующей код в абстрактное синтаксическое дерево, которым можно управлять во время компиляции. По сути, объединение - это функция Expr[T] => T, преобразующая абстрактное синтаксическое дерево в код, который будет компилироваться и оцениваться во время выполнения, в значение данного типа.

Важнейшим свойством, обеспечиваемым компилятором, является принцип согласованности фаз. Он гарантирует, что вы можете получить доступ к AST только во время компиляции (во время выполнения эта информация больше недоступна!), И что вы не пытаетесь получить доступ к значению выражения при вызове макроса (как значения доступны только во время выполнения).

Реализация макроса debugSingle будет работать с абстрактными синтаксическими деревьями. Следовательно, его подпись:

def debugSingleImpl(expr: Expr[Any])(using Quotes): Expr[Unit]

Обратите внимание на значение using QuoteContext: оно неявно предоставляется компилятором и позволяет, например, для сообщения сообщений об ошибках, которые могут возникнуть во время вызова макроса.

Метод debugSingleImpl принимает AST, представляющий выражение, переданное в качестве параметра. Это может быть простая ссылка на значение (x) или что-нибудь более сложное (например, x+y*2). Он возвращает AST-код, представленный как данные типа Expr[Unit], который при оценке возвращает единицу (побочный эффект).

Вот реализация:

def debugSingleImpl(expr: Expr[Any])(using Quotes) = 
  '{ println("Value of " + ${Expr(expr.show)} + " is " + $expr) }

Внешняя операция - это кавычки ('{ ... }): преобразование кода, который она содержит (любого типа T), в значение типа Expr[T]; то есть абстрактное синтаксическое дерево, представляющее код в виде данных. Например, '{ println("Hello, world!") } вернет значение типа Expr[Unit] (Unit, поскольку это тип, возвращаемый println), которое представляет AST, соответствующий вызову println.

Однако внутри кода, для которого мы генерируем AST, мы хотим встроить некоторые выражения, представленные как данные: строковый литерал, соответствующий имени значения (метка), и выражение, которое вычисляет значение.

Сначала это делается с помощью expr.show. Это будет оценено во время компиляции и преобразует AST expr в String: текстовое представление кода. Мы создаем фрагмент AST - выражение, которое представляет собой постоянную строку с заданным значением, используя Expr(expr.show), и, наконец, мы соединяем (встраиваем) его в код, который мы генерируем.

Второй выполняется путем вставки (неизмененного) AST expr в сгенерированный код. Любой код, переданный в качестве параметра debugSingleImpl, останется неизменным в сгенерированном коде. Например, вызов debugSingle(x+y) во время компиляции сгенерирует следующее:

println("Value of " + "x.+(y)" + " is " + (x+y))

Остается одна последняя задача: позвонить debugSingleImpl из debugSingle. Для этого нам нужно указать expr, чтобы AST был передан в реализацию макроса (мы можем получить к нему доступ, поскольку метод встроен), и склеить результат, преобразовав AST обратно в код, который будет скомпилирован:

inline def debugSingle(expr: Any): Unit = ${debugSingleImpl('expr)}

Мы все? Не совсем. Нам нужно потребовать, чтобы компилятор встроил любое использование параметра expr вместо создания временного значения с его значением; это испортит наши этикетки! Это делается также путем добавления inline к параметру. Вот вся реализация:

inline def debugSingle(inline expr: Any): Unit = 
  ${debugSingleImpl('expr)} 
  
private def debugSingleImpl(expr: Expr[Any])(
  using Quotes): Expr[Unit] = 
  '{ println("Value of " + ${Expr(expr.show)} + " is " + $expr) }

Многопараметрическая отладка

Теперь мы можем улучшить нашу реализацию, чтобы она работала с несколькими параметрами. Кроме того, если параметр является строковым литералом, мы хотели бы просто включить его в вывод без метки.

Во-первых, давайте определим метод, ориентированный на пользователя. Мы будем использовать varargs, чтобы его можно было вызывать с несколькими параметрами:

inline def debug(inline exprs: Any*): Unit = ${debugImpl('exprs)}

Идея та же: у нас есть встроенный метод, который соединяет результат вычисления с использованием кода, представленного в виде AST.

Это следует определению из Dotty docs: макрос - это встроенная функция, которая содержит операцию соединения вне заключительной кавычки.

Varargs представлены как последовательность, следовательно, сигнатура реализации макроса:

def debugImpl(exprs: Expr[Seq[Any]])(using Quotes): Expr[Unit]

В самой реализации мы сначала должны проверить переданное дерево exprs и проверить, соответствует ли оно нескольким параметрам, переданным как varargs. Это можно сделать с помощью сопоставления с образцом с помощью экстрактора Varargs, предоставляемого стандартной библиотекой Scala 3. В результате получаем последовательность деревьев (из Expr[Seq[Any]] получаем Seq[Expr[Any]]):

val stringExps: Seq[Expr[String]] = exprs match 
  case Varargs(es) => // macro implementation called with varargs
  case e => // macro implementation called with other parameters

Если извлечение прошло успешно, мы сопоставляем каждое выражение, соответствующее последующим параметрам, снова используя сопоставление с образцом. На этот раз мы проверяем лежащее в основе дерево терминов, чтобы проверить, соответствует ли оно постоянному значению (например, константному строковому литералу).

Если это так, мы возвращаем выражение, содержащее эту константу (в виде строки). В противном случае мы преобразуем выражение в строку, содержащую метку и значение:

case Varargs(es) => 
  es.map { e =>
    e.asTerm match {
      case Literal(c: Constant) => Expr(c.value.toString)
      case _ => showWithValue(e)
    }
  }

Почему один раз мы должны сопоставлять выражения, а другой - термины? Varargs - это специальная конструкция, используемая для обработки параметров этого типа. Во всех остальных случаях, если мы хотим проверить форму кода, который был передан (абстрактное синтаксическое дерево), нам нужно будет сопоставить термин выражения, как указано выше.

И мы почти закончили; последний шаг - преобразование stringExps: Seq[Expr[String]] в Expr[String] путем генерации кода, который объединит все строки. Два строковых выражения можно объединить, соединяя оба выражения, комбинируя их, как любые другие две строки, и цитируя результат. В более общем смысле:

val concatenatedStringsExp = stringExps
  .reduceOption((e1, e2) => '{$e1 + ", " + $e2})
  .getOrElse('{""})

Итак, мы подошли к нашей окончательной реализации:

inline def debug(inline exprs: Any*): Unit = ${debugImpl('exprs)}
private def debugImpl(exprs: Expr[Seq[Any]])(using Quotes): Expr[Unit] = 
  def showWithValue(e: Expr[_]): Expr[String] = 
    '{${Expr(e.show)} + " = " + $e}
  val stringExps: Seq[Expr[String]] = exprs match 
    case Varargs(es) => 
      es.map { e =>
        e.asTerm match {
          case Literal(c: Constant) => Expr(c.value.toString)
          case _ => showWithValue(e)
        }
      }
    case e => List(showWithValue(e))
  val concatenatedStringsExp = stringExps
    .reduceOption((e1, e2) => '{$e1 + ", " + $e2})
    .getOrElse('{""})
  '{println($concatenatedStringsExp)}

Что дальше

Мы только что коснулись поверхности возможностей метапрограммирования в Scala 3 / Dotty. Во-первых, встраивание (статический вариант метапрограммирования) имеет довольно много интересных функций, описанных в Dotty docs:

  • рекурсивные встроенные методы
  • специализированные встроенные методы
  • использование условных выражений и совпадений во встроенных методах
  • выборочный призыв (условная логика в зависимости от доступных имплицитов)

У макросов есть и другие функции, которые мы не рассмотрели, такие как цитирование и сращивание типов, вызов имплицитов в макросах и более обширное сопоставление с образцом, включающим цитируемые образцы. Наконец, есть поддержка многоступенчатого программирования, которая позволяет создавать код во время выполнения.

Как упоминалось ранее, весь представленный здесь код доступен на GitHub. Удачи в изучении Scala 3 / Dotty!