I'm trying to create a macro that makes use of some object
s.
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 object
s).
Thanks in advance
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 object
s - 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.