Search code examples
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:

    • you should make sure that user would actually pass object - you can do this with e.g. inline def runFoo[A <: Foo & Singleton]: Unit

    • you CANNOT prevent user from doing something like

      class Example1 {
        def localFunction: Unit = {
          object LocalDefinition extends Foo
          runFoo[LocalDefinition]
        }
      }
      
      class Example2 {
        object LocalDefinition extends Foo
        def localFunction: Unit = {  
          runFoo[LocalDefinition]
        }
      }
      

      and this cannot be implemented as objects - even though they are singletons - contains a references to enclosing classes. So this would still require a check inside a macro, and a compilation error with the message explaining why

    • in general - if you want to access object A extends Foo, there has to be Class[A$] available on the classpath that is available to the macro, so even things like

      object LocalDefinition extends Foo
      runFoo[LocalDefinition]
      

      is off limits, since you cannot obtain an instance of something which didn't emitted a bytecode yet (and it didn't since the file is still being compiled as evidenced by the ongoing macro expansion)

    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.