scala / scala3

The Scala 3 compiler, also known as Dotty.
https://dotty.epfl.ch
Apache License 2.0
5.88k stars 1.06k forks source link

Revise interaction between `inline` and implicit parameters? #6360

Closed odersky closed 4 years ago

odersky commented 5 years ago

We have to decide how inline interacts with implicit parameters (and possibly default parameters as well). Concretely, say you have

object a {
  inline def f() given C = the[C]
}
...
object b {
  inline def g() = f()
}
...
object c {
  g()
}

Is the implicit for C searched in b or in c? Currently, it's b since type checking g()'s body would infer implicit arguments for the f call. But we could also decide that implicit arguments in calls to inline methods are only inferred at the point where the method itself is expanded. This would behave as follows:

  1. typecheck the f() call in b without referring any implicit arguments.
  2. Expand the g() call in c to f()
  3. At this point, infer the implicit argument of type C in c yielding f() given the[C].

This revised system turns out to have more expressive power than the old one. In particular, this code would typecheck only in the revised system:

inline def f(x: Boolean) = 
  inline if (x) the[C] else the[D]
implied for C  // but no implicit is available for D
f(true)

It turns out that under the revised system we would not need a separate syntax for implicit match anymore. An implicit match would be expressible with what we have.

Another argument in favor of the revised system is that it treats the two forms of code generation via inline and implicits the same way: Inside an inline method we would expand neither calls to inline methods in the body nor implicit arguments. Both get expanded when the inline method is applied.

A point against the revised scheme is that we have to find a way to typecheck a call of an inline method without supplying its implicit arguments. In particular, what should happen if the result type of the method refers to some implicit parameter? We'd have to address this, probably through skolemization. That's a new case the type checker has to handle.

Another point against is that it would make inline methods more weakly typed. An inline method need not list all implicits it needs in its argument list. Instead, necessary implicits are discovered when the method is expanded. This is already true today, but it's restricted to implicits searched in implicit matches.

In fact something like the revised scheme could be expressed with whitebox macros that return an implicit function type. The example above would be expressed like this:

inline def f(x: Boolean) <: Any = 
  inline if (x) given (c: C) => c else given (d: D) => d
implied for C  // but no implicit is available for D
f(true)

But it's clunky since the macro needs to have declared return type Any and it's in general difficult to lift the needed implicits from the point where they are used to the result type.

On the other hand, this observation could lead to a compromise scheme: use late implicit expansion only for whitebox inlines, i.e. those having a <: T return type.

sjrd commented 5 years ago

This is going back on the slippery slope of the old transparent, with methods that are essentially dynamically typed, and consequently with dynamically scoped elaborations. I was strongly against then, I am still strongly against now. Anything where I cannot reason about elaboration of my code in a separate compilation context is going to have dramatic consequences on library design, evolution, versioning and ultimately usability.

anatoliykmetyuk commented 5 years ago
trait ExecutionContext { def execute(op: => Unit): Unit }

object G {
  implied for ExecutionContext = new ExecutionContext { def execute(op: => Unit) = { println(s"G context"); op } }

  object foo {
    implied for ExecutionContext = new ExecutionContext { def execute(op: => Unit) = { println(s"foo context"); op } }
    inline def sayHello(name: String) = the[ExecutionContext].execute(println(s"Hello $name"))
  }

  def main(args: Array[String]): Unit = {
    foo.sayHello("World")
  }
}

I would expect the foo's context used above, or else I would expect to have a way to force a certain context for inlined methods. I also encountered a similar pattern (when you import the implicit dependency instead of declaring it in the method's arguments) when working with Doobie, there, it was very convenient to declare a concrete implicit DB transactor, then import it in all the DAO objects that required that transactor to access the DB.

When working with concrete effect types, such as IO, I would like to be able to define my context as close to the definition site of the methods using IO as possible. That is, as soon as I know the context, I define it.

I believe typing is about verification against a context. I can see the appeal of partially deferring the context to the point of inline call and expansion. But if we take the argument to the extreme, IMO, we may ideally want to type the entire program only after all the inline expansions happen, so that all of the context is deferred (including imports, variable names etc).

This may have its own use cases (although probably hard to implement, since you need to type to detect & expand the inline methods?). I have associations with Scala 2 macros, these accepted trees as arguments and the trees were typed before the callee macros were expanded. In a nested macro scenario, f(x), it would have been nice to be able to use whatever context f provides from x.

However, this probably brings its own dangers, since the less context you have for x, the fewer guarantees you can provide on compile time.

Consider a large library of inline methods. The less context we have for inline, the fewer guarantees I can expect from the library as an end user-programmer. This won't play well with "it compiles => it works" philosophy.

anatoliykmetyuk commented 5 years ago

Continuing the argument of the library of inline methods, what if we want to provide some implicit context from the code of the library itself, instead of expecting it to be provided by the end user?

anatoliykmetyuk commented 5 years ago

But if we take the argument to the extreme, IMO, we may ideally want to type the entire program only after all the inline expansions happen, so that all of the context is deferred (including imports, variable names etc).

This may have its own use cases (although probably hard to implement, since you need to type to detect & expand the inline methods?). I have associations with Scala 2 macros, these accepted trees as arguments and the trees were typed before the callee macros were expanded. In a nested macro scenario, f(x), it would have been nice to be able to use whatever context f provides from x.

Concretely, why not:

object foo {
  implied for ExecutionContext = new ExecutionContext { def execute(op: => Unit) = { println(s"foo context"); op } }
  inline def sayHello = the[ExecutionContext].execute(println(s"Hello $name"))
}

def main(args: Array[String]): Unit = {
  val name = "World"
  foo.sayHello
}
nicolasstucki commented 4 years ago

This issue is a bit outdated.

Now we have summon which summons the implicit in place and compiletime.summonInline which summons the implicit once fully inlined. But both retain the original typing elaboration around the summon.