One of the more powerful features of Scala 2 is obviously macro annotations. Scala 3 has greatly dialed things back on the metaprogramming front, and understandably so, but expressive metaprogramming capabilities are something that I’d really like to have access to in some form. On that note, I’ve been working on language feature that I hope offers a compromise.
For me the primary use-cases I have around metaprogramming would be to add new declarations at compile-time to existing traits, classes, and objects. For example, I should at compile-time be able to write code that declares a case class, declares a type-level transformation of the shape of that case class, and applies that transformation to create a source-code-level new case class with that new shape. This would help eliminate duplicate copy/paste data models that differ in mechanically-expressible ways. In the language of Shapeless, if I can go from a case class to a Repr, why can’t I go from a Repr to a case class?
I’m sure the folks here could express another hundred different use-cases that require the same capabilities of the compiler.
I believe the Scala 3 export
keyword could be leveraged for such use in metaprogramming.
I’ve put together a fork of the dotty compiler and implemented the feature to prove it can work (see links at the end). The basic idea is to allow export
to expand out the content of a macro and splice in the generated definitions. In some sense this isn’t so different than what export
already does, and as proof of this many of the same code paths are re-used in my fork.
Here is some sample code of the feature taken from a simple unit test I have:
// In file A.scala
import scala.quoted._
object TestMacro {
def dothis(b: Boolean)(using Quotes): List[quotes.reflect.Definition] = {
import quotes.reflect.*
if (b) {
val helloSymbol = Symbol.newVal(Symbol.spliceOwner, "hello", TypeRepr.of[String], Flags.EmptyFlags, Symbol.noSymbol)
val helloVal = ValDef(helloSymbol, Some(Literal(StringConstant("Hello, World!"))))
List(helloVal)
} else {
val holaSymbol = Symbol.newVal(Symbol.spliceOwner, "hola", TypeRepr.of[String], Flags.EmptyFlags, Symbol.noSymbol)
val holaVal = ValDef(holaSymbol, Some(Literal(StringConstant("Hola, World!"))))
List(holaVal)
}
}
}
// In file B.scala
class Foo {
export ${TestMacro.dothis(false)}._
// expands to: val hola = "Hola, World!"
}
Depending on the value of the boolean argument to the macro dothis
, one of two different definitions will end up spliced in to the resulting class. And because the expansion is done as part of the typer
phase (which is where export is processed normally), these definitions are visible to other code and are part of the type signature of the surrounding class/object/trait.
One huge benefit of re-using export in this way is that user-code as written remains untouched, and in fact cannot be altered. The definitions produced by the macro have to play by the same rules as the normal ones would.
What do folks think?
Links:
- Dotty Fork: GitHub - littlenag/dotty at export-macro
** I want to stress that the fork (in branch export-macro) isn’t synced with the latest from upstream and was created a long while back. It also isn’t very clean in its implementation, and there are debug printlns galore. - Longer write up: Expressive Metaprogramming for Scala 3 · GitHub
** I’ve been thinking about this feature for a while now. Linked is a much more in-depth write up.