Scala 3: Macros

Dean Wampler
Scala 3
Published in
3 min readJul 6, 2021
Six Vista Towers, Chicago (from Navy Pier), © 2021, Dean Wampler

Update May 22, 2022: Michel Charpentier correctly pointed out that the arguments don’t need to be by-name if they are inlined. This makes perfect sense if you think about it (which I didn’t 🤓), because we are no longer calling the invariant and fail functions; they are now gone, replaced by their bodies! I’ve updated the gist and the previous post accordingly. Thanks, Michel!

Last time I introduced one of the new metaprogramming features in Scala 3, the inline keyword and how it affects source code. The example I used also contained a macro definition, using quoting and splicing. This post introduces those concepts.

Programming Scala, Third Edition is now available. It provides a comprehensive introduction to Scala 3 changes for experienced Scala developers, as well as a complete introduction to Scala for new Scala developers.

Here is the previous example, which ensures that an invariant holds before and after a block of code is executed.

Quotation and splicing are the key components of compile-time macros in Scala 3, which is a new system replacing the experimental system in Scala 2.

Quotation is where sections of source code are converted to a tree-like data structure of type Expr[T], where T corresponds to the expression’s type. A quotation of an expression uses the syntax '{...}, with the source code to quote inside the braces. For types, '[...] is used.

Splicing goes the other direction, converting quotations back to source code. Splices are expressed using ${...}, which deliberate draws a parallel with string interpolation.

Both are used together with inline to construct code programmatically at compile time. There is also a runtime staging capability, which uses quotes and splices, but not inline, to construct code at runtime instead of compile time. I’ll explore runtime staging in a subsequent post.

Last time I explained what the InvariantEnabled.apply method is doing and how inline determines what byte code actually gets generated, depending on compile-time analysis of the ignore field, etc.

If the predicate evaluates to false, the private method fail is called, which uses a splice ${...} to construct the correct source code from the Expr[String] returned by failImpl. An Expr[T] encapsulates the “tree-like data structure” mentioned above.

When fail has finished its work, new source code will have been inserted into the compilation stream for the compiler to compile. Using inline allows quoting and splicing to happen at compile time.

Within a quote or splice, identifiers can be quoted or spliced, such as the arguments ‘predicate, ‘message, ‘block, ‘beforeAfter that are passed to failImpl. Note the types of the arguments received by failImpl. Each is an Expr[T] for some T. So, for example, writing ‘predicate converts the by-name Boolean predicate into an Expr[Boolean]. There is also a using scala.quotes.Quotes instance required by failImpl.

Notice what happens in the body of failImpl. Since we are returning an Expr[String], the body consists of ‘{...}. Inside the braces is code to construct and throw the InvariantFailure exception.

Most of work is in the construction of the message string. In ${showExpr(predicate)}, the helper method showExpr effectively returns the string representation for the input Expr[Boolean] as an Expr[String]. What this means practically is the source code for the expression is captured as a string. The returned Expr[String] is then spliced into the larger message string with the outer set of ${}. The other input parameters are evaluated similarly, except for beforeAndAfter, which doesn’t need to be passed through showExpr, because it is already an Expr[String].

Recall from the previous post that the point of all this is to create an error message string with the actual source code that failed the invariant check. We also saw in the example we used that the code had already been converted from operator notation when the string representation was generated by the macro. For example, thing1.label != “label" became thing1.label.!=(“label”) and i * 2 % 3 became i.*(2).%(3). Not perfect, but good enough for debugging failures.

For an overview of Scala 3 metaprogramming, see this Dotty documentation, which also links to more detailed information about macros.

See Programming Scala, Third Edition for more information about the new metaprogramming facilities and Scala 3, in general.

--

--

Dean Wampler
Scala 3

The person who is wrong on the Internet. ML/AI and FP enthusiast. Lurks at the AI Alliance and IBM Research. Speaker, author, pretend photographer.