1. Overview
Scala 3, or Dotty, brings many improvements and changes. One of the most notable ones is the changes in the Scala macros API.
Scala macros were first introduced as an experimental feature in Scala 2.10.0. It allows us to manipulate the compiler’s Abstract Syntax Tree API to generate new code or modify existing code during compilation.
In this tutorial, we’ll explore the new Scala macros API and learn how we can use it to implement macros.
2. Inline
The new API makes implementing macros even more straightforward than before. It introduces the inline keyword as a soft keyword.
If inline precedes a method or a variable definition, it guarantees that the compiler will inline the method instead of just trying if some conditions are met and evaluating it at compile time.
The inline keyword doesn’t define macros, but acts as an entry point for metaprogramming that we’ll use later to define our macros.
Let’s explore how the inline keyword works by creating a method that evaluates if a number is odd or even and returns the result as a String:
inline def oddEvenMacroInline(number: Int): String =
number % 2 match {
case 0 => "even"
case _ => "odd"
}
The implementation of an inline method is as simple as that. Now, everywhere we call our method, the compiler replaces the call with the actual method code.
The inline methods are treated as static ones at compile time. Thus, there are a few restrictions. Inline methods are effectively final. We can still declare abstract inline methods, but we can’t call them from the abstract class/object. An inline implementation of the method must exist, and we can call this concrete, implemented method.
2.1. Inline Arguments
We’ll continue adding functionality to our code to demonstrate how we can improve the usage of our method with the inline keyword. As long we call our method with compile-time-known values of our argument, we can also inline the argument. By doing so, our code will have the following format:
inline def oddEvenMacroInline(inline number: Int): String =
number % 2 match {
case 0 => "even"
case _ => "odd"
}
The code has mostly stayed the same but will have significant changes during compilation.
If we call our method with the number 2 as an argument, for example, the compiler replaces the call with the following code:
2 % 2 match {
case 0 => "even"
case _ => "odd"
}
2.2. Inline Conditionals
By using inline arguments, we’ve improved our code’s inlining, but we’ve introduced a pretty obvious problem. There’s an unreachable case in our code.
To avoid such issues, we can use the inline keyword with conditionals. Let’s modify our code to include that as well:
inline def oddEvenMacroInlineConditional(inline number: Int): String =
inline number % 2 match {
case 0 => "even"
case 1 => "odd"
}
Using the same example, with the number 2 as an argument, the compiler reduces our code to a single line that returns the string even wherever it’s called.
2.3. Transparent Inline Methods
transparent is another soft keyword that may precede the inline keyword in a method. It allows us to refine our methods even further.
Using this keyword, we can refine the return type based on the return expression. So, if our inline method could return different types, the compiler would know what type to return.
We can go even further and define the exact values that our method may return. In our example, the possible values are the odd and even strings:
transparent inline def oddEvenMacroTransparent(inline number: Int): "even" | "odd" = {
inline number % 2 match {
case 0 =>
"even"
case _ =>
"odd"
}
3. Macros
As stated before, the inline keyword is an entry point for metaprogramming and can be used for simple cases. However, it doesn’t reflect the full capabilities of Scala macros or follow the exact principles.
Macros treat programs as values and allow us to manipulate them to generate or inspect new code.
To implement macros, we need to manipulate the AST representation of the code. We can do this using the Reflection API of Scala 3. The API manipulates values of type Expr[T], a higher-level wrapper of a typed expression of type T, where T must be statically known.
Let’s implement a new macro that expands our example and returns if a number is odd or even:
def oddEven(n: Expr[Int])(using Quotes): Expr[String] = {
val number = n.valueOrAbort
number % 2 match {
case 0 => Expr("even")
case _ => Expr("odd")
}
}
The first thing we notice is introducing a new argument of type Quote with the keyword using. The compiler provides this argument and, in turn, provides the context needed for the low-level AST manipulation API.
The using keyword states that the compiler will provide this argument. We can easily compare it with an implicit value in terms of simple Scala programming. The actual Quote parameter implementation depends on the expression, so we use a new instance in every expression.
The rest of the code, although slightly unusual, is straightforward. We pass an argument of type Expr[Int] that holds the value of the integer we want to compare. We use the valueOrAbort() method to get the holding value and then return an Expr[String] that contains a string that states if the number is odd or even.
3.1. Quoting
Quoting is the process of casting Scala code into an Expr. It lets us create simpler code that we can use for our macros.
For example, we can create a quote expression using the ‘ operator before our code block. A quoted expression won’t execute immediately but will be evaluated later.
So now, let’s create our quoted macro that returns if a number is odd or even:
def oddEvenQuotes(n: Expr[Int])(using Quotes): Expr[String] = '{
$n % 2 match {
case 0 => "even"
case _ => "odd"
}
}
We pass an argument of type Expr[Int] that represents our number and a second one of type Quotes. After that, we wrap our code with the ‘ operator to state that this is quoted code, and we proceed with the implementation.
The actual implementation is quite simple, and we use regular Scala code. The only thing that we need to understand is the $ operator that we use for splicing.
3.2. Splicing
Splicing is the opposite of quoting – we use it to cast an expression to Scala code.
In our example earlier, we had the argument n of type Expr[Int], and we wanted to use it as a simple argument. So, we used the $ operator to cast it to Scala code. What’s left now is actually to call our macro:
inline def oddEvenMacroQuote(inline number: Int): String =
${ oddEvenQuotes('number) }
All macro calls must start with the inline keyword. This is because we are again using splicing to call our macro implementation and convert the expression of the quoted code into actual Scala code. Since our argument is a simple integer that’s not wrapped in an Expr, we must quote it and pass it as a variable.
Interestingly, the number of quotes and splices must be the same, and the compiler won’t allow us to have a different number.
3.3. Difference Between Macros and Inline Methods
All macros must start with the inline keyword, but significant differences exist between a macro and an inline method.
Macros can evaluate expressions at compile time and generate new code.
An inline method instructs the compiler to expand the method call to a method definition and simplify conditional statements.
4. Generic Macros
We can easily use macros with type parameters to create generic methods. However, the macro method needs to keep track of the type explicitly since Scala uses erased-types semantics and doesn’t keep track of the type in runtime.
Fortunately, we can do this easily with the using keyword and passing an argument of type Type. Type is a wrapper that holds information for the underlying type class that the compiler can use.
Let’s create a simple generic method that accepts an argument and returns the class name as a string:
def getType[T](obj: Expr[T])(using Type[T])(using Quotes): Expr[String] = '{
val o: T = $obj
o.getClass.getSimpleName
}
The implementation is straightforward if we use quoting and splicing. We cast the value of our argument obj to a variable and then get the simple name of the class. We can, however, make some simple adjustments that’ll allow us to use more capabilities of the macros API:
def getType[T](obj: Expr[T])(using t: Type[T])(using Quotes): Expr[String] = '{
val o: t.Underlying = $obj
o.getClass.getSimpleName
}
The code above uses the variable t representing our Type[T] argument. We then use the t.Underlying value, which is the T type in the variable that holds the value of our argument obj. The result is the same – the simple name of the class of our parameter. What’s left is to call our macro from an inline method:
inline def getTypeMacro[T](obj: T): String = ${ getType('obj) }
5. Debugging Macros
Scala macros are also Scala code, so we may quickly end up having to debug it. There are various ways to do this, but the easiest is using println()**.
Let’s take as an example our previous code that checks a number if it’s odd or even. Then, we’ll change it to print some debugging information:
def oddEven(n: Expr[Int])(using Quotes): Expr[String] = {
import quotes.reflect.*
val number = n.valueOrAbort
println(n.asTerm.show(using Printer.TreeStructure))
number % 2 match {
case 0 => Expr("even")
case _ => Expr("odd")
}
}
The first change is the import we have included. This gives us access to methods that cast our value to Term. A Term is a tree representation of an expression wrapped within an Expr type.
So, we get the value of our argument n as a Term and then print it using the TreeStructure printer. The quoter.reflect library also imports the printer. This generates the following result when we call it with the argument 2:
Inlined(None, Nil, Literal(IntConstant(2)))
The result is not very readable, as we are printing an AST. But we can understand that we’ve passed an integer with the value 2 in our method. We can use the show() method without the printer to print only the value of our argument:
def oddEven(n: Expr[Int])(using Quotes): Expr[String] = {
import quotes.reflect.*
val number = n.valueOrAbort
println(n.asTerm.show)
number % 2 match {
case 0 => Expr("even")
case _ => Expr("odd")
}
}
We can also enable some flags, like the -Xcheck-macros and -Ycheck:all flags, while developing or testing macros. These allow extra runtime checks to find ill-formed trees or types as soon as they are created and check compiler invariants for tree well-formedness. If these checks aren’t satisfied, an assertion error is thrown.
6. Conclusion
In this article, we learned to implement Scala macros in various ways, such as quoting and splicing. We also discussed learned the difference between inline methods and macros.
Further, we understood how to implement generic macros and perform simple debugging in our macros.
As always, the code is available over on GitHub.