Макросы

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

Макросы рассматривают программы как значения

С помощью макроса программы можно рассматривать как значения, что позволяет анализировать и генерировать их во время компиляции. Выражение Scala с типом T представлено экземпляром типа scala.quoted.Expr[T].

Более детально о типе Expr[T], а также различные способы анализа и построения экземпляров, будут раскрыты в главах Quoted Code и Reflection. Пока достаточно знать, что макросы — это метапрограммы, которые манипулируют выражениями типа Expr[T].

Следующая реализация макроса просто печатает выражение предоставленного аргумента:

def inspectCode(x: Expr[Any])(using Quotes): Expr[Any] =
  println(x.show)
  x

После печати выражения аргумента исходный аргумент возвращается как выражение Scala типа Expr[Any].

Как уже упоминалось в разделе Inline, встроенные методы предоставляют точку входа для определений макросов:

inline def inspect(inline x: Any): Any = ${ inspectCode('x) }

Все макросы определены с расширением inline def. Реализация этой точки входа всегда имеет одинаковую форму:

Вызов inspect макроса inspect(sys error "abort") выводит строковое представление выражения аргумента во время компиляции:

scala.sys.error("abort")

Макросы и параметры типа

Если у макроса есть параметры типа, реализация также должна о них знать. Точно так же, как scala.quoted.Expr[T] представляет выражение Scala типа T, scala.quoted.Type[T] используется для представления типа Scala T.

inline def logged[T](inline x: T): T = ${ loggedCode('x)  }

def loggedCode[T](x: Expr[T])(using Type[T], Quotes): Expr[T] = ...

И экземпляр Type[T], и контекст Quotes автоматически предоставляются склейкой в соответствующем встроенном методе (то есть logged) и могут использоваться реализацией макроса.

Определение и использование макросов

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

Технически компиляция встроенного кода ${ inspectCode('x) } вызывает метод inspectCode во время компиляции (через Java reflection), а затем метод inspectCode выполняется как обычный код.

Чтобы иметь возможность выполнить inspectCode, нужно сначала скомпилировать его исходный код. Как следствие, мы не можем определить и использовать макрос в одном и том же классе/файле. Однако можно иметь определение макроса и его вызов в одном и том же проекте, если реализация макроса может быть скомпилирована первой.

Приостановленные файлы

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

Пример: статическая оценка power с помощью макросов

Вспомним определение power из раздела Inline, которое специализировало вычисление xⁿ для статически известных значений n.

inline def power(x: Double, inline n: Int): Double =
  inline if n == 0 then 1.0
  else inline if n % 2 == 1 then x * power(x, n - 1)
  else power(x * x, n / 2)

В оставшейся части этого раздела будет определен макрос, который вычисляет xⁿ для статически известных значений x и n. Хотя это также возможно с помощью inline, реализация с помощью макросов проиллюстрирует несколько вещей.

inline def power(inline x: Double, inline n: Int) =
  ${ powerCode('x, 'n)  }

def powerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] = ...

Простые выражения

powerCode можно было бы реализовать следующим образом:

def pow(x: Double, n: Int): Double =
  if n == 0 then 1 else x * pow(x, n - 1)

def powerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] =
  val value: Double = pow(x.valueOrError, n.valueOrError)
  Expr(value)

Здесь операция pow представляет собой простую функцию Scala, которая вычисляет значение xⁿ. Интересная часть заключается в том, как мы создаем и изучаем Expr-ы.

Создание выражений из значений

Давайте сначала посмотрим Expr.apply(value). Учитывая значение типа T, этот вызов вернет выражение, содержащее код, представляющий данное значение (то есть типа Expr[T]). Значение аргумента Expr вычисляется во время компиляции, во время выполнения нужно только создать экземпляр этого значения.

Создание выражений из значений работает для всех примитивных типов, кортежей любой арности, Class, Array, Seq, Set, List, Map, Option, Either, BigInt, BigDecimal, StringContext. Другие типы также могут работать, если для них реализован ToExpr, как будет показано позже.

Извлечение значений из выражений

Второй метод, который используется при реализации powerCode - это Expr[T].valueOrError, имеющий эффект, противоположный Expr.apply. Он пытается извлечь значение типа T из выражения типа Expr[T]. Это может быть успешным только в том случае, если выражение непосредственно содержит код значения, в противном случае будет выдано исключение, которое остановит раскрытие макроса и сообщит, что выражение не соответствует значению.

Вместо valueOrError, можно было бы также использовать операцию value, которая вернет Option. Таким образом, можно сообщить об ошибке с помощью пользовательского сообщения.

  ...
  (x.value, n.value) match
    case (Some(base), Some(exponent)) =>
      pow(base, exponent)
    case (Some(_), _) =>
      report.error("Expected a known value for the exponent, but was " + n.show, n)
    case _ =>
      report.error("Expected a known value for the base, but was " + x.show, x)

Кроме того, также можно использовать экстрактор Expr.unapply:

  ...
  (x, n) match
    case (Expr(base), Expr(exponent)) =>
      pow(base, exponent)
    case (Expr(_), _) => ...
    case _ => ...

Операции value, valueOrError и Expr.unapply будут работать для всех примитивных типов, кортежей любой арности, Option, Seq, Set, Map, Either и StringContext. Другие типы также могут работать, если для них реализован FromExpr, как будет показано позже.

Отображение выражений

В реализации inspectCode было видно, как преобразовать выражения в строковое представление исходного кода с помощью метода .show. Это может быть полезно для отладки реализации макросов:

def debugPowerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] =
  println(
    s"""powerCode
       |  x := ${x.show}
       |  n := ${n.show}""".stripMargin)
  val code = powerCode(x, n)
  println(s"  code := ${code.show}")
  code

Работа с переменными аргументами

Переменные аргументы в Scala представлены с помощью Seq, поэтому, когда пишется макрос с переменным аргументом, он будет передан как Expr[Seq[T]]. Можно восстановить каждый отдельный аргумент (типа Expr[T]) с помощью экстрактора scala.quoted.Varargs.

import scala.quoted.Varargs

inline def sumNow(inline nums: Int*): Int =
  ${ sumCode('nums)  }

def sumCode(nums: Expr[Seq[Int]])(using Quotes): Expr[Int] =
  nums match
    case  Varargs(numberExprs) => // numberExprs: Seq[Expr[Int]]
      val numbers: Seq[Int] = numberExprs.map(_.valueOrError)
      Expr(numbers.sum)
    case _ => report.error(
      "Expected explicit argument" +
      "Notation `args: _*` is not supported.", numbersExpr)

Экстрактор сопоставит вызов sumNow(1, 2, 3) и извлечет Seq[Expr[Int]], содержащий код каждого параметра. Но если попытаться сопоставить аргумент вызова sumNow(nums: _*), экстрактор не совпадет.

Varargs также может быть использован в качестве конструктора. Varargs(Expr(1), Expr(2), Expr(3)) вернет Expr[Seq[Int]].

Сложные выражения

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

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

Коллекции

Было показано, как преобразовать List[Int] в Expr[List[Int]] используя Expr.apply. Как насчет преобразования List[Expr[Int]] в Expr[List[Int]]? Упоминалось, что Varargs.apply может сделать это для последовательностей; аналогично, для других типов коллекций доступны соответствующие методы:

Простые блоки

Конструктор Expr.block предоставляет простой способ создания блока кода { stat1; ...; statn; expr }. Его первые аргументы — это список со всеми операторами, а второй аргумент — выражение в конце блока.

inline def test(inline ignore: Boolean, computation: => Unit): Boolean =
  ${ testCode('ignore, 'computation) }

def testCode(ignore: Expr[Boolean], computation: Expr[Unit])(using Quotes) =
  if ignore.valueOrError then Expr(false)
  else Expr.block(List(computation), Expr(true))

Конструктор Expr.block полезен, когда необходимо сгенерировать код, содержащий несколько побочных эффектов. Вызов макроса test(false, EXPRESSION) будет генерировать { EXPRESSION; true}, в то время как вызов test(true, EXPRESSION) приведет к false.

Простое сопоставление

Этот метод Expr.matches можно использовать для проверки равенства одного выражения другому. С помощью этого метода можно было бы реализовать value операцию Expr[Boolean] следующим образом:

def value(boolExpr: Expr[Boolean]): Option[Boolean] =
  if boolExpr.matches(Expr(true)) then Some(true)
  else if boolExpr.matches(Expr(false)) then Some(false)
  else None

Его также можно использовать для сравнения двух написанных пользователем выражений. Обратите внимание, что matches выполняется только ограниченная нормализация, и хотя, например, Scala выражение 2 соответствует выражению { 2 }, это не относится к выражению { val x: Int = 2; x }.

Произвольные выражения

Можно создать произвольный код Scala Expr[T], заключив его в цитаты. Например, '{ ${expr}; true } сгенерирует Expr[Int] эквивалент Expr.block(List(expr), Expr(true)). В следующем разделе, посвященном Quoted Code, цитаты представлены более подробно.


Ссылки: