scalascala-macrosscala-3

Scala 3 macros: "dynamically" instantiating singleton objects at compile-time


I'm trying to create a macro that makes use of some objects.

Suppose I have the following definitions:

trait Foo:
  def doStuff(): Unit

// in other files

object Bar extends Foo:
  def doStuff() = ...

object Qux extends Foo:
  def doStuff() = ...

(Foo is not sealed on purpose, see below)

I want to create a macro that has the following shape:

inline def runFoo(inline foo: Foo): Unit = ${ runFooImpl('foo) }

Such that when invoking runFoo with any singleton instance of Foo the corresponding doStuff method will be called at compile-time.

For example:

runFoo(Bar)

Will trigger Bar.doStuff() at compile-time.

In pseudo-code the macro would look something like this:

def runFooImpl(fooExpr: Expr[Foo]): Expr[Unit] = 
  val foo: Foo = fooExpr.valueOrAbort // does not compile

  foo.doStuff()

 '{ () }

Currently valueOrAbort cannot work, due to the lack of FromExpr.

My question is, is there some way to leverage the fact that Bar, Qux, etc. are compile-time constants to be able to extract them from the concrete Expr[Foo] during macro expansion?

Note that turning Foo into a sealed trait (and write a FromExpr by pattern matching on it) is not an acceptable solution, as I want Foo to be extensible by client code (with the restriction that all Foo implementations must be objects).

Thanks in advance


Solution

  • I've done that before. This approach requires several conditions:

    Once we accept these limitations, we might hack something together. I already prototyped something like that a few years ago and used the results in my OSS library to let users customize how string comparison should work.

    You start by drafting the entrypoint to the macro:

    object Macro:
    
      inline def runStuff[A <: Foo & Singleton](inline a: A): Unit =
        ${ runStuffImpl[A] }
        
      import scala.quoted.*
      def runStuffImpl[A <: Foo & Singleton: Type](using Quotes): Expr[Unit] =
       ???
    

    Then, we might implement a piece of code that translates Symbol name into Class name. I'll use a simplified version which doesn't handle nested objects:

      def summonModule[M <: Singleton: Type](using Quotes): Option[M] =
        val name: String = TypeRepr.of[M].typeSymbol.companionModule.fullName
        val fixedName = name.replace(raw"$$.", raw"$$") + "$"
        try
          Option(Class.forName(fixedName).getField("MODULE$").get(null).asInstanceOf[M])
        catch
          case _: Throwable => None
    

    with that we can actually use the implementation in macro (if it's available):

      def runStuffImpl[A <: Foo & Singleton: Type](using Quotes): Expr[Unit] = {
        import quotes.*
        summonModule[A] match
          case Some(foo) =>
            foo.doStuff()
            '{ () }
          case None =>
            reflect.report.throwError(s"${TypeRepr.of[A].show} cannot be used in macros")
      }
    

    Done.

    That said this macro typeclass pattern, as I'd call it, is pretty fragile, error-prone and unintuitive to user, so I'd suggest not using it if possible, and be pretty clear with explanation what kind of objects can go there, both in documentation as well as in error message. Even then it would be pretty much cursed feature.

    I'd also recommend against it if you cannot tell why it works from reading the code - it would be pretty hard to fix/edit/debug this if one cannot find their way around classpaths, classloaders, previewing how Scala code translates into bytecode, etc.