Макросы
Встроенные методы предоставляют элегантную технику метапрограммирования, выполняя некоторые операции во время компиляции. Однако иногда встраивания недостаточно, и нужны более мощные способы анализа и синтеза программ во время компиляции. Макросы позволяют делать именно это: относиться к программам как к данным и манипулировать ими.
Макросы рассматривают программы как значения
С помощью макроса программы можно рассматривать как значения,
что позволяет анализировать и генерировать их во время компиляции.
Выражение 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
.
Реализация этой точки входа всегда имеет одинаковую форму:
- они содержат только одну склейку
${ ... }
- склейка содержит единственный вызов метода, реализующего макрос (например
inspectCode
). - вызов реализации макроса получает параметры в кавычках (то есть
'x
вместоx
) и контекстноеQuotes
.
Вызов 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 предлагает различные средства метапрограммирования, начиная от
- дополнительные конструкторы, такие как
Expr.apply
, - сопоставление с образцом в цитатах,
- reflection API;
каждый из них усложняется и потенциально теряет гарантии безопасности. Обычно рекомендуется чаще использовать простые API. В оставшейся части этого раздела вводятся еще несколько дополнительных конструкторов и деструкторов, а в последующих главах представлены более продвинутые API.
Коллекции
Было показано, как преобразовать List[Int]
в Expr[List[Int]]
используя Expr.apply
.
Как насчет преобразования List[Expr[Int]]
в Expr[List[Int]]
?
Упоминалось, что Varargs.apply
может сделать это для последовательностей;
аналогично, для других типов коллекций доступны соответствующие методы:
Expr.ofList
: преобразуетList[Expr[T]]
вExpr[List[T]]
Expr.ofSeq
: преобразуетSeq[Expr[T]]
вExpr[Seq[T]]
(так же, как Varargs)Expr.ofTupleFromSeq
: преобразуетSeq[Expr[T]]
вExpr[Tuple]
Expr.ofTuple
: преобразует(Expr[T1], ..., Expr[Tn])
вExpr[(T1, ..., Tn)]
Простые блоки
Конструктор 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, цитаты представлены более подробно.
Ссылки: