Scala 3 Macro Tutorial

Scala 3 Macro Tutorial

  • Tutorial
  • Contribute
  • GitHub

›Tutorial

Tutorial

  • Introduction
  • Inline
  • Scala Compile-time Operations
  • Scala 3 Macros
  • Quoted Code
  • TASTy Reflection

Extra

  • FAQ
  • Best Practices
  • Other Recources
Edit

Scala 3 Macros

Inline methods provide us with a elegant technique for metaprogramming by performing some operations at compile time. However, sometimes inlining is not enough and we need more powerful ways to analyze and synthesize programs at compile time. Macros enable us to do exactly this: treat programs as data and manipulate them.

Macros Treat Programs as Values

With a macro, we can treat programs as values, which allows us to analyze and generate them at compile time. A Scala expression with type T is represented by an instance of the type scala.quoted.Expr[T].

We will dig into the details of the type Expr[T], as well as the different ways of analyzing and constructing instances, when talking about Quoted Code and Reflection. For now, it suffices to know that macros are metaprograms that manipulate expressions of type Expr[T].

The following macro implementation simply prints the expression of the provided argument:

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

After printing the argument expression, we return the original argument as a Scala expression of type Expr[Any].

As foreshadowed in the section on Inline, inline methods provide the entry point for macro definitions:

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

All macros are defined with an inline def. The implementation of this entry point always has the same shape:

  • they only contain a single splice ${ ... }
  • the splice contains a single call to the method that implements the macro (for example inspectCode).
  • the call to the macro implementation receives the quoted parameters (that is 'x instead of x) and a contextual QuoteContext.

We will dig deeper into these concepts later in this and the following sections.

Calling our inspect macro inspect(sys error "abort") prints a string representation of the argument expression at compile time:

scala.sys.error("abort")

Macros and Type Parameters

If the macro has type parameters, the implementation will also need to know about them. Just like scala.quoted.Expr[T] represents a Scala expression of type T, we use scala.quoted.Type[T] to represent the Scala type T.

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

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

Both the instance of Type[T] and the contextual QuoteContext are automatically provided by the splice in the corresponding inline method (that is, logged) and can be used by the macro implementation.

Defining and Using Macros

A key difference between inlining and macros is the way they are evaluated. Inlining works by rewriting code and performing optimisations based on rules the compiler knows. On the other hand, a macro executes user-written code that generates the code that the macro expands to.

Technically, compiling the inlined code ${ inspectCode('x) } calls the method inspectCode at compile time (through Java reflection), and the method inspectCode then executes as normal code.

To be able to execute inspectCode, we need to compile its source code first. As a technicaly consequence, we cannot define and use a macro in the same class/file. However, it is possible to have the macro definition and its call in the same project as long as the implementation of the macro can be compiled first.

Suspended Files

To allow defining and using macros in the same project, only those calls to macros are expanded, where the macro has already been compiled. For all other (unknown) macro calls, the compilation of the file is suspended. Suspended files are only compiled after all non suspended files have been successfully compiled. In some cases, you will have cyclic dependencies that will block the completion of the compilation. To get more information on which files are suspended you can use the -Xprint-suspension compiler flag.

Example: Statically Evaluating power with Macros

Let us recall our definition of power from the section on Inline that specialized the computation of xⁿ for statically known values of 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)

In the remainder of this section, we will define a macro that computes xⁿ for a statically known values x and n. While this is also possible purely with inline, implementing it with macros will illustrate a few things.

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

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

Simple Expressions

We could implement powerCode as follows:

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 QuoteContext): Expr[Double] =
  val value: Double = pow(x.unliftOrError, n.unliftOrError)
  Expr(value)

Here, the pow operation is a simple Scala function that computes the value of xⁿ. The interesting part is how we create and look into the Exprs.

Creating Expression From Values

Let's first look at Expr.apply(value). Given a value of type T, this call will return an expression containing the code representing the given value (that is, of type Expr[T]). The argument value to Expr is computed at compile-time, at runtime we only need to instantiate this value.

Creating expressions from values works for all primitive types, tuples of any arity, Class, Array, Seq, Set, List, Map, Option, Either, BigInt, BigDecimal, StringContext. Other types can also work if a Liftable is implemented for it, we will see this later.

Extracting Values from Expressions

The second method we use in the implementation of powerCode is Expr[T].unliftOrError, which has an effect opposite to Expr.apply. It attempts to extract a value of type T from an expression of type Expr[T]. This can only succeed, if the expression directly contains the code of a value, otherwise, it will throw an exception that stops the macro expansion and reports that the expression did not correspond to a value.

Instead of unliftOrError, we could also use the unlift operation, which will return an Option. This way we can report the error with a custom error message.

  ...
  (x.unlift, n.unlift) 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)

Alternatively, we can also use the Unlifted extractor

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

The operations unlift, unliftOrError, and Unlifted will work for all primitive types, tuples of any arity, Option, Seq, Set, Map, Either and StringContext. Other types can also work if an Unliftable is implemented for it, we will see this later.

Showing Expressions

In the implementation of inspectCode, we have already seen how to convert expressions to the string representation of their source code using the .show method. This can be useful to perform debugging on macro implementations:

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

Working with Varargs

Varargs in Scala are represented with Seq, hence when we write a macro with a vararg, it will be passed as an Expr[Seq[T]]. It is possible to recover each individual argument (of type Expr[T]) using the scala.quoted.Varargs extractor.

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

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

The extractor will match a call to sumNow(1, 2, 3) and extract a Seq[Expr[Int]] containing the code of each parameter. But, if we try to match the argument of the call sumNow(nums: _*), the extractor will not match.

Varargs can also be used as a constructor, Varargs(Expr(1), Expr(2), Expr(3)) will return a Expr[Seq[Int]]. We will see how this can be useful later.

Complex Expressions

So far, we have only seen how to construct and destruct expressions that correspond to simple values. In order to work with more complex expressions, Scala 3 offers different metaprogramming facilities ranging from

  • additional constructors like Expr.apply,
  • over quoted pattern matching,
  • to a full reflection API;

each increasing in complexity and potentially losing safety guarantees. It is generally recommended to prefer simple APIs over more advanced ones. In the remainder of this section, we introduce some more additional constructors and destructors, while subsequent chapters introduce the more advanced APIs.

Collections

We have seen how to convert a List[Int] into an Expr[List[Int]] using Expr.apply. How about converting a List[Expr[Int]] into Expr[List[Int]]? We mentioned that Varargs.apply can do this for sequences -- likewise for other collection types, corresponding methods are available:

  • Expr.ofList: Transform a List[Expr[T]] into Expr[List[T]]
  • Expr.ofSeq: Transform a Seq[Expr[T]] into Expr[Seq[T]] (just like Varargs)
  • Expr.ofTupleFromSeq: Transform a Seq[Expr[T]] into Expr[Tuple]
  • Expr.ofTuple: Transform a (Expr[T1], ..., Expr[Tn]) into Expr[(T1, ..., Tn)]

Simple Blocks

The constructor Expr.block provides a simple way to create a block of code { stat1; ...; statn; expr }. Its first arguments is a list with all the statements and the second argument is the expression at the end of the block.

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

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

The Expr.block constructor is useful when we want to generate code contanining several side effects. The macro call test(false, EXPRESSION) will generate { EXPRESSION; true}, while the call test(true, EXPRESSION) will result in false.

Simple Matching

The method Expr.matches can be used to check if one expression is equal to another. With this method we could implement an unlift operation for Expr[Boolean] as follows.

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

It may also be used to compare two user written expression. Note, that matches only performs a limited amount of normalization and while for instance the Scala expression 2 matches the expression { 2 }, this is not the case for the expression { val x: Int = 2; x }.

Arbitrary Expressions

Last but not least, it is possible to create an Expr[T] from arbitary Scala code by enclosing it in quotes. For example '{ ${expr}; true } will generate an Expr[Int] equivalent to Expr.block(List(expr), Expr(true)). The subsequent section on Quoted Code presents quotes in more detail.

← Scala Compile-time OperationsQuoted Code →
  • Macros Treat Programs as Values
    • Macros and Type Parameters
    • Defining and Using Macros
    • Example: Statically Evaluating power with Macros
  • Simple Expressions
    • Creating Expression From Values
    • Extracting Values from Expressions
    • Showing Expressions
    • Working with Varargs
  • Complex Expressions
    • Collections
    • Simple Blocks
    • Simple Matching
    • Arbitrary Expressions
Scala 3 Macro Tutorial
Docs
TutorialContributeFAQ
Community
Chat on GitterDiscuss on Scala Users
More
GitHub
Copyright © 2020 LAMP EPFL