Skip to content

Instantly share code, notes, and snippets.

@xeno-by
Last active December 4, 2018 03:29
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save xeno-by/8b64ca6a73775147941e to your computer and use it in GitHub Desktop.
Save xeno-by/8b64ca6a73775147941e to your computer and use it in GitHub Desktop.
Towards perfect compile-time proxies in Scala
// PROBLEM STATEMENT
// Let's build Proxy[T], an object that can capture calls to methods of an underlying object.
// We want the proxy to be type-safe, i.e. we need to verify that the names of the calls
// and the arguments that are passed to those calls are well-typed wrt the type of the proxee.
object Test extends App {
trait Foo { /* ... */ }
val proxy: Proxy[Foo] = ???
proxy.bar()
proxy.bar[Int](1)
proxy.bar[Int, Int](1, 2)
}
// STEP 1: A NAIVE APPROACH THAT DOESN'T WORK
// We need a proxy, so let's use Dynamic, which was seemingly built exactly for that.
// Since we need to proxy method calls, let's use applyDynamic.
//
// Scala has term-level varargs, but it doesn't have type-level varargs.
// Therefore we need to use overloading on applyDynamic or
// otherwise we won't be able to proxy polymorphic methods.
//
// However, if we try to do something like the code below we will end up
// with a compilation error, because the overloads are going to have identical erasures.
class ProxyAttempt1[T](x: T) extends Dynamic {
def applyDynamic(name: String)(xs: Any*): Any = macro ???
def applyDynamic[T1](name: String)(xs: Any*): Any = macro ???
def applyDynamic[T1, T2](name: String)(xs: Any*): Any = macro ???
}
// STEP 2: A MORE SOPHISTICATED ATTEMPT THAT STILL DOESN'T WORK
// Here's an interesting trick that comes from the land of scala-virtualized and LMS.
// We can have different dummy implicit parameters on every overload,
// with corresponding implicit values always in scope.
// This will make erased signatures of those overloads different,
// and will also be completely invisible for the users.
//
// Unfortunately, this doesn't work because of a bug in the applyDynamic desugarer.
// When the typer sees `foo.bar[Baz](args)`, it desugars it into `foo.applyDynamic[Baz]("bar")[Baz](args)`.
// This second `[Baz]` type application really messes things up, making it impossible to use implicits.
trait OverloadHack0
object OverloadHack0 { implicit val ev: OverloadHack0 = new OverloadHack0 }
trait OverloadHack1
object OverloadHack1 { implicit val ev: OverloadHack1 = new OverloadHack1 }
trait OverloadHack2
object OverloadHack2 { implicit val ev: OverloadHack2 = new OverloadHack2 }
class ProxyAttempt2[T](x: T) extends Dynamic {
def applyDynamic(name: String)(xs: Any*)(implicit ev: OverloadHack0): Any = macro ???
def applyDynamic[T1](name: String)(xs: Any*)(implicit ev: OverloadHack1): Any = macro ???
def applyDynamic[T1, T2](name: String)(xs: Any*)(implicit ev: OverloadHack2): Any = macro ???
}
// STEP 3: WORKING AROUND THE APPLYDYNAMIC DESUGARING BUG
// We can hack around the double type application bug by splitting the `(xs: Any*)` part
// into an `apply` method on some object that's going to be returned from `applyDynamic`.
// This makes it possible to accommodate two type applications, but it still doesn't
// solve issue with erasure.
//
// Luckily, we can again use implicits to combat erasure. Now let's have the `name` parameter
// to be of some weird type that we can convert strings to. That will again ensure
// smooth user experience while providing distinct erasures.
//
// We're really close, but unfortunately there's a problem (and I considered it to be fatal for a few years).
// When you call a method with no type arguments (e.g. `proxy.bar()`), the desugaring into applyDynamic
// is going to fail typechecking, because the typer won't be able to pick the correct overload.
// Overloads with type parameters are going to match as well, because those type parameters can be inferred!
class Cont0 { def apply(xs: Any*): Any = macro ??? }
class Cont1 { def apply[T1](xs: Any*): Any = macro ??? }
class Cont2 { def apply[T1, T2](xs: Any*): Any = macro ??? }
class ProxyAttempt3[T](x: T) extends Dynamic {
def applyDynamic(name: AnotherHack0): Cont0 = ???
def applyDynamic[T1](name: AnotherHack1): Cont1 = ???
def applyDynamic[T1, T2](name: AnotherHack2): Cont2 = ???
}
trait AnotherHack0
object AnotherHack0 { implicit def hack0(s: String): AnotherHack0 = ??? }
trait AnotherHack1
object AnotherHack1 { implicit def hack1(s: String): AnotherHack1 = ??? }
trait AnotherHack2
object AnotherHack2 { implicit def hack2(s: String): AnotherHack2 = ??? }
// STEP 4: MACROS TO THE RESCUE
// Allright, so we need to customize the behavior of the typechecker. What do we do?
// Well, let's try using macros!
//
// My first attempt was turning hack0, hack1 and hack2 into macros and
// having hack1 and hack2 explode if they are called for a proxied call
// which doesn't have type arguments. Unfortunately, this didn't work, because
// overload resolution only looks up suitable implicit conversions,
// but it doesn't expand them if they are macros.
//
// When a macro doesn't get the job done, you write another macro.
// We can't force the typer to expand the implicit conversion if it's a macro,
// but we can have it typecheck the invocation of that implicit conversion
// and, thanks to the fix of SI-3346, that typecheck can involve typechecking
// implicit parameters of that implicit conversion, and those can be macros!!
//
// Inside those macros, we can check whether the call that's undergoing the applyDynamic transformation
// has type arguments or not and then we can deny harmful overloads of applyDynamic.
// See an example (very dirty) implementation of such a gatekeeper below.
// Executable code for everything can be found at https://gist.github.com/xeno-by/840329c34d82da906724.
trait FinalHack0
object FinalHack0 { implicit def hack0(s: String)(implicit hackhack0: Gatekeeper0): FinalHack0 = ??? }
trait FinalHack1
object FinalHack1 { implicit def hack1(s: String)(implicit hackhack1: Gatekeeper1): FinalHack1 = ??? }
trait FinalHack2
object FinalHack2 { implicit def hack2(s: String)(implicit hackhack2: Gatekeeper2): FinalHack2 = ??? }
class ProxyAttempt4[T](x: T) extends Dynamic {
def applyDynamic(name: FinalHack0): Cont0 = ???
def applyDynamic[T1](name: FinalHack1): Cont1 = ???
def applyDynamic[T1, T2](name: FinalHack2): Cont2 = ???
}
trait Gatekeeper0
object Gatekeeper0 { implicit def materialize: Gatekeeper0 = macro Macros.gatekeeper }
trait Gatekeeper1
object Gatekeeper1 { implicit def materialize: Gatekeeper1 = macro Macros.gatekeeper }
trait Gatekeeper2
object Gatekeeper2 { implicit def materialize: Gatekeeper2 = macro Macros.gatekeeper }
class Macros(val c: Context) {
import c.universe._
def gatekeeper: Tree = {
val powerc = c.asInstanceOf[scala.reflect.macros.contexts.Context]
val Apply(target, args) = powerc.callsiteTyper.context.tree.asInstanceOf[Tree]
val hasTargs = target match { case _: TypeApply => true; case _ => false }
if (!hasTargs && !c.macroApplication.toString.startsWith("Gatekeeper0")) c.abort(c.enclosingPosition, "denied")
q"???"
}
}
// STEP 5: FUTURE WORK
// This still doesn't handle the case of multiple argument lists,
// because `applyDynamic` only processes one argument list at a time.
// Nevertheless, I believe that this step should be more or less straightforward.
// Unfortunately, I don't have time to elaborate, so let's leave it for future work.
@xeno-by
Copy link
Author

xeno-by commented Jan 13, 2015

If you have comments to this gist, please don't reply here, because I most likely won't notice your replies. Consider letting me know at https://twitter.com/xeno_by/status/555051365693915137.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment