Kotlin/KEEP

Context parameters

serras opened this issue Β· 376 comments

serras commented

This is an issue to discuss context parameters. The full text of the proposal can be found here.

The use of summon as an API name seems weird to me. The name to me implies some work is occurring, but it's really only resolving an unnamed local, right?

First thing that comes to mind for me would be like a materialize, although it's not perfect. Maybe identify?

@JakeWharton Not that is probably matters but summon is what Scala 3 Type Class summoning uses.

With the introduction of named context parameters, it feels like summon function can be named something like contextParam or just dropped entirely

serras commented

@JakeWharton Not that is probably matters but summon is what Scala 3 Type Class summoning uses.

Indeed. Also it's uncommon enough to not be taken by any other framework in the ecosystem (contextParams reads like a web framework if you squint your eyes, materialize like a reactive or event-stream operator...)

alllex commented

I can suggest receiver<A>() as an alternative for summon.
It highlights the purpose of the function, which is to resolve unnamed receivers. The function will almost never be used for the named context parameters because, well, they can be directly addressed by name.

Another alternative could be to overload the new context() function. All versions that take arguments would be used to introduce context, as in context(foo) { ... }. While the parameterless overload would be used to extract the context as in val t = context<A>(). It plays well in a sense, because the function does not take any lambda argument, and therefore the only thing it can produce/return is the context parameter itself.

I read the document and have several points to mention:

  1. As mentioned by previous authors, summon seems weird to me; it would be much more obvious to call it context as you get context parameter. Maybe it would be better even to make it somehow a property as it has property semantics. However, this would be different from the regular properties which cannot have type arguments.
  2. Redness of type parameters usage. Code is usually written from left to right, so when you write context(A<B>) fun <B> ..., you first type context(A<B>) which is red because <B> is unresolved. There are two proper solutions as I see:
    • Do not mark <B> as red in the IDE if the function is not fully defined, but mark it as gray instead. It can be even used by an IDE to suggest name and bounds for the type parameters when they are typed.
    • Support defining type parameters on context keyword as well, making them visible in both context part and the rest. . But this would lead to multiple ways to do the only thing which is not great. However, we already have where which leads to multiple ways to define type parameter bounds.
  3. context keyword is used both to specify required contexts and to specify that this function itself must be called when the receiver is contextual. This is unobvious then if the class A is required to be passed as a context receiver or not in the following snippet. And have to do the second option then?
    class A {
        context(Int) fun f() = Unit
    }
    fun main() {
        context(3, A()) {
            f() // is it ok?
            // if not, then how to make it ok? `context context(Int) fun f()`?
            // if it is, how to escape such an exposure
        }
    }
  4. As for a DI, it might be a good idea to try to mimic the existing DI functionality and find if the current design is ok for the task. DIs have many things such as joining several configurations, extending ones, etc.; so it is worth trying to express them with the context receivers design.

Based on the example as far as i understand, summon will give you access to a resource that normally you dont have access to. Sounds like borrowing to me

borrow<Logger>().thing() which to me reads like you borrow the identity of Logger for an operation and then go back to normal.

One more note is about the DI. I can't understand how would this help with DI. DI is more like "here are the tools you need to do your job" while context parameters read (to me) like "i have the tools, you can borrow them to do what you need to "

Nevertheless, i like the progress.

You aren't borrowing them from anywhere. You already own the references. They were explicitly supplied to the function, just contextually rather than nominally (like a normal parameter). A borrow also deeply implies a lifetime to the reference which doesn't really apply (at least no more than one would think of a named parameter as a borrowed reference).

xxfast commented

Some of the DI use cases are already doable with just primary receiver without using any context parameter/receivers,

For example,

class ApplicationScope(
  private val store: Store,
  private val httpClient: HttpClient
)

class Logger {
  fun ApplicationScope.log(message: String) {
    this@ApplicationScope.store.add(message)
  }
}

Here the scope can be provided with with

with(application){
  logger.log(message)
}

logger.log(message) // will fail to compile 

I guess context parameters makes this ApplicationScope anonymous, personally I prefer it being more explicit

Has there been any discussion of simply disallowing unnamed context parameters? Unless I'm mistaken, this would eliminate...

  1. The awkward summon function
  2. The need for a new empty context visibility modifier.

I'm guessing everyone commenting on this issue is going to be an experienced Kotlin user -- who else is reading a technically complex KEEP and then feeling like they can express their opinion about it -- but I am trying to think about this feature from someone who is brand new to Kotlin.

Attempting to wear the newbie hat, I think if I ran across the following code, I'd find it very unsightly and confusing:

interface Logger {
    context fun log(message: String) 
    fun thing()
}

To explain this code to that new user, you'd have to explain a lot to them at once -- what context parameters are, why some are unnamed, and how method visibility / propagation works for these unnamed context parameters (which is unique and different from the rest of the language). "Well you see, yeah, here "thing" is kind of public, but then "log" is really public. I mean, for "thing", you need to summon it..."

The implication of this small code snippet worries me too:

value class ApplicationContext(
  val app: Application
) {
  context fun get() = app.get()
  // copy of every function, but with context on front
}

How much boilerplate are codebases going to need to introduce if they want to support unnamed context parameters?


Maybe someone can clue me into a use-case where unnamed context parameters are really important. Are they really worth propagating so much noise through the rest of the language for them? Will this be a lot of burden on library developers, who now have to think about every class they write in the context of being used as an unnamed context parameter?

Or maybe I'm being totally obtuse, and you'd still need to support this syntax even if every context parameter were named?

Even though I see the value and flexibility in the contextual visibility section, its complexity is really over the rest of the features of the language for me.

With a little knowledge of programming (basic level) and English, you can understand Kotlin code more or less easily, but the contextual visibility will drop this "readability" a lot.

I prefer the simpler approach it had before in which if you are in a specific context, you have access to all of its public properties/functions, not the only ones marked as context. If this feature grows in popularity, I am afraid almost everyone will just add context to all public declarations.

serras commented

Some answers and clarifications:

  • You never have to write context context(A) fun ... (in fact you can't), since context(A) is enough to mark the function as contextual.

  • Some uses of context receivers are indeed handled nowadays with extension receivers (like ApplicationScope above). The new design simply unlocks more possibilities (what if you need more than one Scope?, for example).

  • The document describes one pattern when you really want to expose all public properties/functions.

    context(a: A) fun f() = with(a) { /* every member available here */ }

    One thing to consider about being more restricted as the default, is that you can_not_ remove things from the scope (the closes you can do is shadowing).

serras commented

Maybe this was not entirely clear from the document, but the contextual visibility only affects to context receivers, not named context parameters, which work like a regular parameter in terms of accessibility. We think that the default mode of use of this feature should actually be named context parameters, which are must simpler to explain, pose no problem for visibility, and do not require using summon.

It's even debatable whether Logger is a good example of context receiver, and shouldn't just be a named context parameter. In that case, the regular declaration suffices, no need for context.

interface Logger {
    fun log(message: String) 
}

// how to use it
context(logger: Logger) fun User.doSomething() = ...

Our intention is in fact to make the library author think what is the intended mode of use of the type being defined. Not every class is suitable to be thrown as context receiver directly, since it (potentially) pollutes the scope with lots of implicitly-available functions.

Why have context receivers, then? There are several (interrelated) reasons, which in many cases boil down to the same distinction between value parameters and extension receivers we already have in Kotlin.

  • Receivers are essential to define DSLs (such as type-safe builders), and in fact many use both dispatch and extension receivers. Context receivers remove many of the restrictions from the current language: you can now extend the DSL with an extension function, or provide functions only to some generic instantiations of the DSL.
  • The ecosystem already uses extension receivers to describe "contexts", like CoroutineScope or Ktor's Application. For those it makes sense to expose them as contextual, since they allow new extensibility modes.

If this feature grows in popularity, I am afraid almost everyone will just add context to all public declarations.

We took this concern quite seriously during the design. Our point of view, however, is that this problem is not that big.

  1. As mentioned above, the goal is for named context parameters to serve the main needs, and those need no context.

  2. If you "extend" a type using a context,

    context(a: A) blah(): String = ...

    this function is also available when A is used as context receiver, since it declares a context.

Having processed my thoughts a bit more, and talking with some friends, I can see the value in an unnamed context parameter, so I partially retract my previous comment. (For example, a class can be used as a control scope; that can be pretty neat.)

I am even growing to support the consume method (although I prefer the suggested receiver rename proposed above).

I still think context functions are a misstep. It feels like a bunch of complexity which opens up a lot of confusing design decisions for code authors and can produce code that just looks confusing to read and wrap your head around (a class with a mix of context and non-context methods makes it harder to understand IMO). I'm still not sure what the benefit of that complexity buys the language.

At this point, I'd rather that unnamed context parameters did not populate the current method scope at all. If you want to use a method on an unnamed context parameter, then just summon it:

context(Session, Users, logger: Logger)
fun User.delete() {
   val users = summon<Users>()
   users.remove(this)
   logger.log("User removed: ${user.id}")

   // Or use `with(summon<Users>) { ... }` if you really want to populate the scope with its methods
}

// Or maybe just name the `Users` parameter  instead?

Wouldn't this approach prevent the need for context funs? I'm worried I'm missing something obvious, so please feel free to correct me.

serras commented

This is unobvious then if the class A is required to be passed as a context receiver or not in the following snippet. And have to do the second option then?

class A {
    context(Int) fun f() = Unit
}
fun main() {
    context(3, A()) {
        f() // is it ok?
        // if not, then how to make it ok? `context context(Int) fun f()`?
        // if it is, how to escape such an exposure
    }
}

Here f is a member of A which requires Int in the context to be called. The "member" part means that we must call it using the usual call syntax, either explicit with dot, or implicit if A is the defining class or an extension receiver.

fun main() {
  val a = A()
  context(3) { a.f() }
}

fun A.g() = context(1) { f() }

First of all, I really appreciate the introduction of named context parameters. I am still trying to realize the other changes.

As I understand, the context modifier now serves two quite different purposes:

a) it marks a callable as accessible from an unnamed context receiver
b) if non-empty, it specifies that the callable requires a context when called

To me, these two purposes look completely unrelated and it feels like they should be separated.
As is, there is no way to specify that a callable requires a context without making it accessible by receiver.
A fix, though more verbose, would be to use context for (a) and context(..) for (b):

class Users {
    context(Logger) 
    fun connect() {}                // requires context but should not be accessible from context
    
    context(Transaction) 
    context fun setUser() {}        // requires context and should be accessible from context
    
    context fun getUser() {}        // callable from context but does not require a context
}

Why holding to the design with separate context(A) argument list when using named context parameters? Wouldn't it be clearer to use normal parameter list just with some keyword preceding context parameters. e.g.

// here with is a keyword (a hommage to the typeclasses KEEP)
fun myAwesomeContextFunction(x: Int, with logger: Logger) {
  logger.log(x)
}

with(Logger()) {
  myAwesomeContextFunction(1) // log: 1
  myAwesomeContextFunction(2, OtherLogger()) // other_log: 2
}
myAwesomeContextFunction(1) // err: no context
myAwesomeContextFunction(2, OtherLogger()) // other_log: 2

It's even debatable whether Logger is a good example of context receiver, and shouldn't just be a named context parameter. In that case, the regular declaration suffices, no need for context.

interface Logger {
    fun log(message: String) 
}

This is a good example of the problems that library users will face when accessibility by receiver requires marking by the library author.
The author may believe that it is better to use as a context parameter, but the library user may prefer an unnamed receiver (for whatever reason).
Wrapping every callable with with (logger) {...} is not a very attractive alternative.

I wonder how confusing, or complicated, would be to overload the with keyword.
In terms of semantics it would be quite nice πŸ™‚

with<NetworkContext>().fetchUser()

Can someone shed some light on Β§E.2 for me.

// do not do this
context(ConsoleLogger(), ConnectionPool(2)) {
  context(DbUserService()) {
    ...
  }
}

// better be explicit about object creation
val logger = ConsoleLogger()
val pool = ConnectionPool(2)
val userService = DbUserService(logger, pool)
// and then inject everything you need in one go
context(logger, userService) {
   ...
}

It looks like context is used as a replacement for with here.

serras commented

Some more answers to the comments below. But before, a general comment: there are definitely cases which are difficult or impossible to express in this design. In the process leading to it we've tried to ponder the likeliness of every scenario; things which we don't think are going to be done, or we think should be done in other way, get less priority.

As I understand, the context modifier now serves two quite different purposes [...] To me, these two purposes look completely unrelated and it feels like they should be separated.

You're actually correct, and for some time we discussed a similar design with two different keywords. However, at some point we realized that adding context parameters to a function is already a marker for "opting in to the feature", so context context(A, B) wouldn't be necessary. In other words, the scenarios where you would have context parameters but not mark the function as context were really slim. From there we tried to uniformize the treatment of context(A, B) and context, leading to the current design of "context is a shorthand for context()".

Why holding to the design with separate context(A) argument list when using named context parameters?

One important design decision was that we did not want to interleave context and value parameters. That complicates resolution too much; since value parameters have additional powers like default values or varargs that wouldn't fit in context parameters.

This gave us two options: put all the parameters at the front or at the end. From those, the front seemed the best place because some parameters are receivers, and receivers are conventially written at the beginning in Kotlin.

Finally, the requirement to write the context before the fun keyword context(A, B) fun is needed for backwards compatibility. If not, it wouldn't be possible to know if fun context(A, B) is the beginning of a declaration of function context, or a function with a context.

Other potential syntactic options are explored in the previous iteration of context receivers.

This is a good example of the problems that library users will face when accessibility by receiver requires marking by the library author.
The author may believe that it is better to use as a context parameter, but the library user may prefer an unnamed receiver (for whatever reason).

That is the crux of the issue. After consideration, we as a team considered that the author of the API has primacy in this case over the user, since it designs the API as a whole, but also any documentation or educational material which explains the intended mode of use. We found no compelling reasons, in most cases, to override the library author design; hence the "escape hatches" are not particularly simple.

At this point, I'd rather that unnamed context parameters did not populate the current method scope at all. If you want to use a method on an unnamed context parameter, then just summon it.

I honestly think the two options here are either have receivers which populate in some capacity the implicit scope, or just get rid of them completely and have only named context parameters (this is what Scala does, for example).

serras commented

Can someone shed some light on Β§E.2 for me. [..] It looks like context is used as a replacement for with here.

In some sense, context is a "version" of with, except that the arguments enter the implicit scope as context receivers, so the methods available in the block are only the contextually visible. Another way to see the difference is with the types:

fun <A, R> with(x: A, block: A.() -> R): R
fun <A, R> context(x: A, block: context(A) () -> R): R

I wonder how confusing, or complicated, would be to overload the with keyword.

The problem is that with<(A) -> B> { ... } would become confusing (or give a resolution error). Note also that with is a regular function right now, not a built-in keyword.

serras commented

"Well you see, yeah, here "thing" is kind of public, but then "log" is really public. I mean, for "thing", you need to summon it..."

This is not a bad thing! Keeping with the example of a Logger, there might be some functions to configure the logger which you don't want to make available when using it as context.

class ConsoleLogger {
  context fun log(message: String)
  var outputStream: OutputStream
}

fun main() {
  // when we create the logger, we use "explicit" syntax
  val logger = ConsoleLogger()
  logger.outputStream = STDERR
  // and now we use this built logger contextually
  context(logger) {
    // outputStream is not accessible
    log("Hello")
  }
}

summon is terrible. Otherwise, I see this proposal as a direct continuation of the previous one. My main concern is the handling of lambda functions with contexts. In general, it will look like this:

context(MyScope) fun doSomethingInScope(block: context(MyScope) suspend Container.()->Unit){}

It looks cumbersome. I guess it is not that bad since functions like this will exist only in libraries.

One of the primary problems with previous prototype was ambiguity caused by mixing context and dispatch receivers in lambdas. It seems like introduction of context modifier will improve this situation a bit. You can't use this to refer to context receiver and it is the only way you can refer to the dispatch receiver. But it still does not make a lot of sense.

While dispatch receiver could be (or could not) important for inheritance, it does not make sense for lambdas. I can't see difference between dispatch and context receivers for lambdas and function types.

It is not that bad as it is right now, but I think it would be better to revisit one of the initial suggestions: [MyScope, Container].()->Unit for function types only. Obviously, it is not compatible with regular dispatch receiver notation because it would be not clear, which of receivers is designated by this. But both notations could exist simultaneously.

kyay10 commented

An alternative name for summon that I've used for a while is given, but that might be even more confusing. I think something like context<Foo>() is likely sufficient. I like receiver too. Otherwise, great proposal!

One unaddressed thing is perhaps some way to bring in contexts without nesting. It would need language level support, but it would allow for more complicated features later on like bringing in a context inside a class, or bringing in a context perhaps in a file, or even for a whole module eventually (similar to how typeclasses work).
It would also mean that explicit config for contexts isn't needed, and instead one can do something like:

context(DbConfig(), Logger()):
context(App()):
appContextualFunction()

But perhaps this should be a wider proposal for kotlin to support some syntax to define a lambda to the end of the current scope.

Maybe someone can clue me into a use-case where unnamed context parameters are really important. Are they really worth propagating so much noise through the rest of the language for them? Will this be a lot of burden on library developers, who now have to think about every class they write in the context of being used as an unnamed context parameter?

DSLs (Β§C) make heavy use of context receivers and if you want to extend an existing DSL you need more than one receiver.
Type classes (Β§D) need more than one receiver if combined. They more or less have to be unnamed.

Do you plan a grace period, where we can use unmarked callables from a context, perhaps with some opt-in?
Libraries will not add context modifiers until this feature is stable and todays experimental users would have to change their existing code until they do.

serras commented

One unaddressed thing is perhaps some way to bring in contexts without nesting. [...] But perhaps this should be a wider proposal for kotlin to support some syntax to define a lambda to the end of the current scope.

Indeed! There are many possibilities, and from those we think with properties are the best one. Alas, this would require an additional proposal, since the impact in the language would be bigger. In addition, during our interviews with users it seemed that providing a context function with several arguments would be enough in most of the cases.

Do you plan a grace period, where we can use unmarked callables from a context, perhaps with some opt-in?

No, the idea is to enforce this rule since the beginning. Any feature we provide without restriction is difficult to restrict after the fact, since we would break some code.

However, we'll work closely with the rest of the ecosystem to provide these annotations as soon as possible. We have already investigated which are the libraries more apt for this conversion, and talked informally to some of the maintainers.

Thanks for the update, it's great to see how this is moving.

First, as multiple people have mentioned before, I'm not a fan of the summon name, and would be happier with something more "down-to-earth" like receiver.

After thinking about it for some time, I think the contextual visibility is a good idea. One of the worries with context receivers would be that they would be abused in application code (in the same way that regular receivers are sometimes abused, but worse since there are fewer restrictions). However, since this visibility does not apply to regular receivers, I'm worried this may make the language harder to learn.

However, I'm not convinced by marking functions with this visibility. As the KEEP explains (emphasis mine):

Β§A: (implicit use case) […] A Repository class for a particular entity or a Logger are good examples of this mode of use.

Β§B (scope use case): In this case we use the context parameter as a marker of being inside a particular scope, which unlocks additional abilities. A prime example is CoroutineScope, which adds launch, async, and so on.

Β§C (DSLs use case): In this case, contexts are used to provide new members available in a domain-specific language.

Also, as @serras said:

Our intention is in fact to make the library author think what is the intended mode of use of the type being defined. Not every class is suitable to be thrown as context receiver directly, since it (potentially) pollutes the scope with lots of implicitly-available functions.

The ecosystem already uses extension receivers to describe "contexts", like CoroutineScope or Ktor's Application. For those it makes sense to expose them as contextual, since they allow new extensibility modes.

In all of these sentences, "it makes sense to use something in a contextual manner" is said about a type, not about an operation. I believe the ability to use something as an unnamed context receiver is a property of the type itself.

context interface HtmlScope {
    fun body(block: context(HtmlScope) () -> Unit)
}

In this interpretation, the parameter-less version of context is used to annotate types, not methods. I believe this is much more intuitive, which makes it easier to learn:

  • "You can use any type as a named context parameter with context(foo: Foo)"
  • "However, you can only use the unnamed form context(Foo) if Foo is marked as context, meaning the author explicitly wanted it to be used like this"

This is also a much simpler rule to explain to users, when inevitably someone will add a type that is not meant for it as a context receiver, and will not understand why IDEA autocompletes some methods but not others.

As a library author, I'm having a hard time finding a use case in which some methods of a class would be allowed in contexts but not othersβ€”either the class is meant as a DSL, and all its methods are written with this use case in mind, or it's not, in which case none are.

I believe having an explicit way to definitely mark which types were written with scope pollution in mind, and which were not, would be a great feature.

(This would also potentially allow context typealias to enable contextual usage for external types, e.g. those coming from Java, but I don't know if this is a good idea).

(If there is a chance that contextual classes are introduced later, I would prefer contextual class Bar so context(Foo) class Bar is still available; I believe context(Foo) contextual class Bar is clear that this is a class that can be used as context which itself requires a context to be instantiated).

serras commented

In all of these sentences, "it makes sense to use something in a contextual manner" is said about a type, not about an operation. I believe the ability to use something as an unnamed context receiver is a property of the type itself.

We've also toyed with this idea, but it actually doesn't solve the problem of scope pollution. How do you ensure that functions like toString or equals are not in scope? You need a more fine-grained control at the level of members.

Nevertheless, if it turns out that marking every function with context is required, one option from the top of my head is writing a plug-in similar to all-open that just marks every member with it.

As a library author, I'm having a hard time finding a use case in which some methods of a class would be allowed in contexts but not others.

As mentioned above, think about members which are inherited from Any, or functions like let or apply. You don't really want those to result in ambiguity just because you added a context receiver.

In some sense, this feature is tricky because we tend to think on interfaces for this feature, which have a very clear set of operations. However, we cannot just say "context visibility is only for interfaces", because many of the useful types in the Kotlin ecosystem are nowadays defined as abstract classes.

This would also potentially allow context typealias to enable contextual usage for external types, e.g. those coming from Java, but I don't know if this is a good idea

Adding modifiers to typealias which somehow change the semantics of the replacement break the expectation of "typelias are intechangeable with their expansion". There's already some problems with this in actual typealias, my colleague @nikitabobko has written a great summary of those in this YouTrack issue.

One obstacle on harnessing context receivers for dependency injection in general is the ability to partially provide context in some generic way

                                   subtyping is not allowed
    fun <X, Y> provide(lambda: context(X, Y) () -> Unit, a: Y): context(X) () -> Unit {
        // some code using X
        val newLambda: context(X) () -> Unit = {
            val x = this // ??? how to reference X here?
           // new proposal: val x = summon<X>() or use named context parameter
            with(a) {
                lambda(x, a)
            }
        }
        return newLambda
    }

    class A
    class B
    
    context(A, B) fun someFun() {
        // some code using A and B
    }
   val someFunWithA = provide(::someFun, A())

As far as I understand the proposal, we could lift one obstacle with referencing the context parameters in lambdas. But what with the generic named parameters?

Looking over the proposal, one thing that isn't clear to me is how the resolution works within the body of a class - particularly ones which aren't originally designed to work with context parameters (or may even be written in Java, JS, etc).

The original KT-10468 issue provides this example as a motivation for the original context receivers design:

context(View)
val Float.dp get() = this * resources.displayMetrics.density

context(View)
val Int.dp get() = this.toFloat().dp

With this, one would be able to have something like:

class MyView : View {
    val height = 8.dp
}
  1. Does MyView's (or any View's) class body qualify as a context parameter?
  2. In this example, resources is a member of View, which is both not context, nor is it even written in Kotlin. Is it even accessible in this way?

It's not clear to me whether the use-case described in the original issue is solved by the new proposal. If it still is, then great! If not, what solution (if any) is there to achieve the desired effect?

@serras

I honestly think the two options here are either have receivers which populate in some capacity the implicit scope, or just get rid of them completely and have only named context parameters (this is what Scala does, for example).

I think this is a really important decision. Can the feature designers consider highlighting it somewhere in the original feature proposal?

My current understanding: One way (unnamed with implicit scope) adds complexity to the language for some scoping safety, while the other way avoids this complexity at the cost of requiring a bit more typing in the (rare?) case that someone wants to use methods directly from an unnamed context parameter.

@serras

This is not a bad thing! Keeping with the example of a Logger, there might be some functions to configure the logger which you don't want to make available when using it as context.

We face this all the time already when writing Kotlin -- I have a class (or interface) which exposes more functionality to the user than I want. At that point, it's normal to add a new interface or wrapping class and give them that instead. It's easy enough to do, the mental model for understanding each class is easy, and this is already supported by Kotlin today without any changes.

@CLOVIS-AI

I believe having an explicit way to definitely mark which types were written with scope pollution in mind, and which were not, would be a great feature.

I'm OK with this suggestion. I can tell the feature designers want users to carefully consider what they use for unnamed context parameters, and they don't want them to just slap anything in there. In that case, marking the type with context and saying "Look, I wrote this explicitly with context parameters in mind" seems like a reasonable decision with minimal language impact.

I might even wonder if it's worth thinking through only allowing this context modifier to only work on interfaces at first ("context interfaces"?), to avoid concerns with populating the scope with ambiguity for inherited methods like toString (since this keeps getting brought up).

@quickstep24

DSLs (Β§C) make heavy use of context receivers and if you want to extend an existing DSL you need more than one receiver. Type classes (Β§D) need more than one receiver if combined. They more or less have to be unnamed.

I actually made a comment later that I'm OK with unnamed context parameters, but I still don't follow "They [...] have to be unnamed." Maybe you can share a code snippet example?

@serras

You're actually correct, and for some time we discussed a similar design with two different keywords.

I think this is a great example of the invisible technical complexity being added by this feature that will additionally make it really hard for new learners of Kotlin to understand it. Here, two concepts are subtly different but mostly overlap so it sounds like we've compromised by wrapping them behind a single keyword.

I'd be more comfortable with the compromise if it couldn't be helped, if this was the only way forward to provide some functionality. But we can also sidestep this for now and not support implicit scoping and still accomplish everything the original feature design set out to do, right?


However the context parameter feature gets added to the language is a forever decision. If we do it one way now and think it's a mistake later or notice our assumptions are wrong (e.g. is " In other words, the scenarios where you would have context parameters but not mark the function as context were really slim." really true?), it can't be undone.

The single responsibility principle pushes us to design classes that are focused and simple, with clear purpose. context funs push developers to design classes that behave two different ways depending on the context in which they are used. I'm worried this is a misstep.

I think limiting context parameters to never add to the implicit scope is the simplest way forward that still lets everyone do what the original proposal sets out to accomplish. Given the complexity of the language is going to increase quite a bit otherwise, I would really really love for someone to show me a concrete usecase where unnamed context parameters + implicit scope is significantly better than just using summon.

serras commented

The single responsibility principle pushes us to design classes that are focused and simple, with clear purpose. context funs push developers to design classes that behave two different ways depending on the context in which they are used.

I don't think this is the case. Each type still has a (preferred) mode of use. You can see it even in current Kotlin, a type like CoroutineScope is thought to be used implicitly (as extension receiver, since there are no contexts yet), and only in a handful of cases like scope in ViewModel this is done differently. But we don't say that CoroutineScope has too modes of use.

Given the complexity of the language is going to increase quite a bit otherwise, I would really really love for someone to show me a concrete usecase where unnamed context parameters + implicit scope is significantly better than just using summon.

I am a bit confused by this. If you don't want the implicit scope, you can just use named context parameters. summon is really a last resource, which we need to provide as language designers (we need to provide access to every value introduced by the user, unless explicitly ignored by using something like _), but shouldn't be used in most cases in code.

Does MyView's (or any View's) class body qualify as a context parameter?

There are two answers to this:

  • It is not a context parameter properly speaking,
  • But it's part of the implicit scope, so it is available during context resolution (it can "fill in" a context parameter).
    So in the case above you can use the .dp without further complication.

@bitspittle

Given the complexity of the language is going to increase quite a bit otherwise, I would really really love for someone to show me a concrete usecase where unnamed context parameters + implicit scope is significantly better than just using summon.

If I understand you correctly, then this wouldn't be possible and would be really unfortunate to use:

interface ViewBuilder {
    context fun label(title: String)

    context fun vstack(block: context(ViewBuilder) () -> Unit)
}

context(ViewBuilder)
fun buildSomeView() {
    vstack {
        label("Hello")
        label("World")
    }
}

Instead it'd require:

context(ViewBuilder)
fun buildSomeView() {
    summon<ViewBuilder>().vstack {
        summon<ViewBuilder>().label("Hello")
        summon<ViewBuilder>().label("World")
    }
}

or

context(ViewBuilder)
fun buildSomeView() = with(summon<ViewBuilder>()) {
    vstack {
        with(summon<ViewBuilder>()) {
            label("Hello")
            label("World")
        }
    }
}

But I may be misunderstanding what you mean, so please let me know :)

Thank you for this update and for the opportunity to comment on it. I fully support and welcome most of these changes. To keep this short, I will only comment below on some of the changes that I have issues with

Removal of this@Type syntax, introduction of summon()

I fully support the removal of this@Type syntax.

However, as many others have commented, I strongly dislike the choice of the word summon. I much prefer the suggestion by @alllex above:

Another alternative could be to overload the new context() function. All versions that take arguments would be used to introduce context, as in context(foo) { ... }. While the parameterless overload would be used to extract the context as in val t = context().

If the overload of context is not possible, my second choice would be the other choice by @alllex of receiver.

I like how @CLOVIS-AI put it: we need something more "down-to-earth."

I want an even more principled approach to deciding which word to use. Here is my suggestion for principle number 1: Do not use a verb. Instead, use a noun.

Suggestions such as summon, materialize, borrow or identify are all verbs. But using a verb here is wrong because this function is not doing anything. Instead, it is purely syntax. It is a part of the language for referring to an object, not an action we perform. That is why I strongly prefer context or receiver (most preferably context<A>). context is very legible, as it reads as a noun: reference to context A .

Context Visibility

I fully support context visibility restrictions.

I understand the idea from @CLOVIS-AI to eventually allow context classes which will automatically make all of their members context-visible, or context typealias. However, I am currently leaning against these ideas in favor of being restricted to more fine-grained control. After all, as the new proposal mentions, when all class members are context-visible, you get ambiguity with shared members such as toString().

No subtyping check between different context parameters

I fully support this, but only if the compiler throws ambiguity errors.

Disallowing of contexts in constructors

I am satisfied with the reasons provided in the updated proposal, namely:

  • Constructors in Kotlin are already restricted from using suspend
  • A constructor with contexts can be faked using the invoke operator in a companion object

Most importantly, as the update said, this feature can be added in the future.

Dropping of Context-in-classes

Eventually, a feature that solves this problem will be needed. But I welcome introducing context receiver features in phases and understand that this, or something like this, is likely best added in a later phase.

First of all, thanks for this, looks like an overall improvement (expect for the summon, that'll hopefully change to something like receiver<> or even this<>).

I do wish the constructor and class contexts were included. But since that functionality is currently buggy anyway, it's better to be reintroduced in the future. Hopefully not a far too distant one class contexts are perfect for dependency injection and class factories.

The context visibility is nice, but I have to "vote" for having an explicit way for a user-site override. One thing I've seen over the years is that us library authors never foresee new ways to use our API. On the user site it's often frustrating having to write boilerplate like the with(summon<Something>()) { ... }.

My main worry would be utility DSL, which is essentially written in the user code, but uses contexts like fun doSomething(block: context(JavaType, OtherJavaType) () -> Unit) or fun doSomething(block: context(3rdPartyType, Other3rdPartyType) where we don't have control over the type and it might not have been thought of as a scope even (any interface mostly).

@bitspittle

Maybe you can share a code snippet example?

See example from Β§D

context(comparatorT: Comparator<T>) fun <T> max(x: T, y: T) =
  with (comparatorT) { if (x.compareTo(y) > 0) x else y }

You need with or run scope for every call, which is very far from the original concept of type classes.

@TadeasKriz Thank you for your comment.

For your suggestion, I would do one of these to avoid context fun:

// 1, normal Kotlin + summon

interface VStackScope {
   fun label(title: String)
}

class ViewBuilder {
    fun vstack(block: VStackScope.() -> Unit) {
      ViewStackScope().block() 
   }
}

context(ViewBuilder)
fun buildSomeView() = with(summon<ViewBuilder>()) {
   vstack {
      label("Hello")
      label("World")
   }
}

// 2, context interfaces as suggested by CLOVIS

context interface VStackScope {
   fun label(title: String)
}

context interface ViewBuilder {
   context(VStackScope) fun vstack(block: () -> Unit)
}

context(ViewBuilder)
fun buildSomeView() {
   vstack {
       label("Hello")
       label("World")
   }
}

Let me know if I missed some nuance though.

@bitspittle In my example, I wanted to keep it simple, so I just used one scope, so you were able to partially work around it. However, it breaks down if you have more than one context, which for DSLs is not uncommon. The issue with context interface is that it doesn't allow you to have something that you don't want implicitly visible and opens the question about what to do with declarations inherited from Any.

Also, the need to do with(summon<ViewBuilder>()) at all is an unnecessary boilerplate when using DSLs.

@TadeasKriz

context interface would imply you designed an interface where everything should be intentionally public to the context.

By restricting the keyword to interfaces and not classes, you shouldn't have to worry about inherited methods. If the context interface developer inherits from another interface, then they are opting into surfacing those methods to the context.

One thing I may have missed in the proposal is how the inheritance sorts out something like this:

interface ScopeA {
  fun hello()
}

interface ScopeB { 
  fun world()
}

class ABImpl: ScopeA, ScopeB {
  override fun hello() {
    println("Hello")
  }

  override fun world() {
    println("World")
  }
}

context(a: ScopeA, b: ScopeB) fun helloworld() {
  a.hello()
  b.world()
}

Do I call it like this:

context(ABImpl()) {
  helloworld()
}

or would I have to do something like:

val impl = ABImpl()
context(a = impl, b = impl) {
  helloworld()
}

And if so, how would it work if both were unnamed?

@bitspittle

context interface would imply you designed an interface where everything should be intentionally public to the context.

By restricting the keyword to interfaces and not classes, you shouldn't have to worry about inherited methods. If the context interface developer inherits from another interface, then they are opting into surfacing those methods to the context.

AFAIK all interfaces inherit implicitly from Any and have toString() don't they? Or do you mean that when you inherit from an interface that isn't context interface then those wouldn't be visible?

And it unfortunately still doesn't solve the problem I mentioned and also creates a couple new ones. Like:

context interface ThisIsScope {
  fun directlyCallable()
}

interface ExtendsScope: ThisIsScope {
  fun notDirectlyCallable()
}

In this case, what happens when I add ExtendsScope as an unnamed context? Can I call directlyCallable implicitly, or only through summon? What about notDirectlyCallable?

And then there's the use case where you want something to configure the scope. There was the example with logger, where you could have something that configures it that's only available through summon (damn I hope that name's gonna change, it sounds like it's dynamically getting it from somewhere).

@serras

But it's part of the implicit scope, so it is available during context resolution (it can "fill in" a context parameter).
So in the case above you can use the .dp without further complication.

Is there an exception made for resources not being marked with context in this case?

@TadeasKriz You would not be able to use anything but context interfaces as unnamed context parameters.

And yes, the implication would be only the methods explicitly inherited are exposed to the context. No Any stuff.

context interface ThisIsScope {
  fun directlyCallable()
}

interface ExtendsScope: ThisIsScope {
  fun notDirectlyCallable()
}

context(ThisIsScope)
fun fine()

context(ExtendsScope)
fun compileError()
context(ViewBuilder)
fun buildSomeView() = with(summon<ViewBuilder>()) {
   vstack {
      label("Hello")
      label("World")
   }
}

@bitspittle That's not the same. label call is using the outer context from buildSomeView, not the inner one from vstack.

@quickstep24 For the user, it's the same experience. Meanwhile, this avoids the need for context fun, and the class design in my approach is more tightly aligned with the single responsibility principle. Actually reviewing the original example I see what you're saying. Thinking it through.

I'm still thinking through the multi-scope case, because it's clear that label will want to be called from more contexts than just vstack. But I'm still guessing there's a way to design the code in a way that works without needing context fun.

If I have an ambiguity, I would like to be able to exclude some of the contexts. How is it possible?

@serras

I don't think this is the case. Each type still has a (preferred) mode of use.

I'm thinking of something like this in the wild:

class LongClassThatExtendsAcrossPages {
   fun a()
   fun b()
   context fun c()
   fun d()
   fun e()
   context fun f()   
   fun g()
   fun h()
   fun i()
   context fun j()
}

This class is "a-j" normally or "c, f, j" if used in a context.

Imagine instead:

context interface ContextRelevant {
   fun c()
   fun f()
   fun j()
}

class LongClassThatExtendsAcrossPages : ContextRelevant {
   fun a()
   fun b()
   override fun c()
   fun d()
   fun e()
   override fun f()   
   fun g()
   fun h()
   fun i()
   override fun j()
}

ContextRelevant here is now embracing SRP - it's a thing that exists for a clear purpose, which is to be used as an unnamed context parameter. You can add header docs to it if needed.

I hope this shows how the first class is really the second example interface+class combined into one (so, not SRP anymore).

I am with @bitspittle here. If a class is intended to be used as a context receiver but would polute the scope, the author should provide a reasonable interface. However, I see no need to mark this as a context interface. It should be obvious from the name (LoggerScope) or from the documentation. This leaves the choice to the user (at her own risk) and would not introduce compatibility issues with Java/JS/...

I have been using context receivers for some time now and I have not faced any problems with scope polution so far.
toString et al. from Any will not polute your scope because the local receiver has higher priority.
Do the interviews with other customers indicate different insights?
If it is really required, maybe there could be an annotation to hide a callable from receiver scope?

In short, I would be very happy if named context parameters were added but unname context receivers kept as they are in the prototype today.

@TadeasKriz @quickstep24 Apologies as I'm struggling to come up with a realistic multiple-scope example. Maybe someone can help me here? I wrote and deleted three examples so far.

I find this behavior to be unintuitive, especially compared to the previous KEEP:

interface Logger {
    context fun log(message: String)  // context() fun log(..)
    fun thing()
}

fun Logger.shout(message: String) = log(message.uppercase())

context fun Logger.whisper(message: String) = log(message.lowercase())
context(Logger) fun logWithName(name: String, message: String) =  log("$name: $message")

context(Logger) fun doSomething() {
    log("Hello")  // ok
    thing()  // unresolved reference 'thing'
    summon<Logger>().thing()  // ok
    shout("Hello")  // unresolved reference 'shout'
    whisper("Hello")  // ok
    logWithName("Alex", "Hello")  // ok
}

There are 2 issues I take with this:

  1. If I want to bring an instance of some interface into a scope/context, I probably want the whole thing. I can't imagine what scenarios I would want to have a "what-color-is-your-function" split of methods that are/aren't contextually available. If I bring a Foo into scope with context(), I want to use it as if I had passed it as an explicit parameter -- only implicitly.
  2. It doesn't make sense to me that Logger.shout() is not resolvable. I get that previously, you'd need to tell Kotlin that you want specifically this@Logger.shout(), but there ought to be some way to invoke it with a context parameter.

Do these issues go away if you provide a named reference?
For instance, can I write:

context(logger: Logger)
fun doSomething() {
    logger.log("Hello")
    logger.thing()
    logger.shout("Hello")
    logger.whisper("Hello")
    logWithName("Alex", "Hello")
}

The above is roughly how I would expect contextual values to behave.


EDIT: Scala 3's given/using is along the lines of what I'd expect, if that's any help

@serras

You're actually correct, and for some time we discussed a similar design with two different keywords. However, at some point we realized that adding context parameters to a function is already a marker for "opting in to the feature", so context context(A, B) wouldn't be necessary. In other words, the scenarios where you would have context parameters but not mark the function as context were really slim. From there we tried to uniformize the treatment of context(A, B) and context, leading to the current design of "context is a shorthand for context()".

Consider the following case:

  1. You have a function that you don't want to be used as contextual.
  2. It uses comparison for some types.
  3. You want to use convenient < operator instead of verbose compareTo(other) < 0.
  4. You add context(Comparator<T>) to use it.
  5. You finish with a noncontextual function that is contextual now.

I also mentioned comparison operators which can now be overridden by implementing Comparable or Comparator.

Btw, you haven't answer to dome issues mentioned in my first comment in the discussion.

@bitspittle You requested an example: I create templates for web components with functions of the form

    templateFun: context(MT, FlowContent, Binder<MT>) () -> Unit

FlowContent is the html DSL, which is used to create html elements.
T is the domain model (e.g. User) and is used to provide access to domain properties Field<T> (e.g. login, name). The template shall only use fields from a single domain, injecting the domain model makes this easier to follow.
Binding<T> is a small interface with a few methods for binding local html element properties to model fields (e.g. bind(login, textContent) will bind User.login to the textContent property of the current html element). Behind the scenes it delegates to a builder which intercepts html creation events, so it knows the current position within the html fragment.
A simple example would be:

context(User, FlowContent, Binder<User>)
fun userTemplate() {
    div { bind(login, textContent) }
    div { bind(name, textContent) }
}

In the example

  • div is provided by FlowContent
  • login and name are provided by User
  • bind(Field<User>, ...) and textContent are provided by Binder<User>
serras commented

Note that we do not consider 100% satisfactory an approach which distinguishes interfaces from (abstract) classes, as it is not forward-source-compatible. If the library author changes from interface to abstract class, then your code using it as context receiver stops compiling if we only allow contextual interfaces.

@TadeasKriz Note that the boilerplate is not with(summon<Something>()) { ... }, but actually with(service) { ... } if you use a named context parameter (which is the recommended option). Let me stress again that you can always decide to include a context as a named one, regardless of the "contextual status".

@damianw .dp is an extension function over Float which requires a context, it doesn't resolve as part of View, but as part of Float. Only then we check whether we can resolve the View context.

@zhelenskiy you cannot "remove" context, but you can re-introduce them with a higher priority in the resolution chain using functions like with and the proposed context

@GavinRay97 yes, your example with a named context parameter logger is correct. For the context receiver, now instead of this@Logger.shout() you would use summon<Logger>().shout() (same idea, no special syntax).

@quickstep24 yes, in several interviews it was reported that at the moment that you have more than one context receiver, scope pollution starts being a problem. In some cases the problem is not per se in compilation, but the reduced understandability of the code.

serras commented

Another problem with marking types instead of functions: how does it work with extension functions? Are they also accesible? And if so, why are generic extension functions like let or apply not accesible (or why they are)?

Thank you @quickstep24 for the multi-scope example.

Note that the feature I'm uncomfortable with is context fun (with the empty context keyword). I'm totally fine with a type signature like templateFun: context(MT, FlowContent, Binder<MT>) () -> Unit.

@bitspittle

Thank you @quickstep24 for the multi-scope example.

Note that the feature I'm uncomfortable with is context fun (with the empty context keyword). I'm totally fine with a type signature like templateFun: context(MT, FlowContent, Binder<MT>) () -> Unit.

But the issue is that you wouldn't be able to call div and bind if those aren't marked as context fun and if we drop the unnamed contexts implicitness altogether then all that DSL won't be possible to write (at least without a ton of boilerplate).

@serras

Note that the boilerplate is not with(summon()) { ... }, but actually with(service) { ... } if you use a named context parameter (which is the recommended option). Let me stress again that you can always decide to include a context as a named one, regardless of the "contextual status".

Yeah, that kinda makes sense, but adds more stuff, where now there's a named thing service in scope, which you might not want, especially for things like multiple scopes where instead of context(HtmlScope, BindScope, ViewScope) you'd have to do context(htmlScope: HtmlScope, bindScope: BindScope, viewScope: ViewScope) and then with for those to use them.

Thinking more about the context fun, I like that there's a way to explicitly mark what's implicit, but I'd still like it to be separate from context(..) and have the ability to mark a whole structure as implicit. Something like @ContextAccess(implicit) on top of functions, classes, interfaces to enable implicit calling. Or @ContextAccess(explicit). These could then also be applied on user's side with context(@ContextAccess(implicit) SomeScope, OtherScope). I know there's probably a tons of issues I'm glossing over with this and the proposed "syntax" is more of a spitballing.

@serras

(BTW want to make sure I'm not coming across as defensive or aggressive. I really appreciate you engaging with all of us! And I am continuing to process all new information and ideas as they come in)

yes, in several interviews it was reported that at the moment that you have more than one context receiver, scope pollution starts being a problem. In some cases the problem is not per se in compilation, but the reduced understandability of the code.

Just to check, have you had people try using context fun long enough to have codebases full of classes / interfaces with mixed context and non-context methods, and then interviewed people about that? Just to get a fair comparison.

I totally get people expressing confusion due to a context with multi-scope being flooded by multiple versions of the same method. After all, I've been bit with it myself when in deeply, multi-nested with / apply blocks. But a codebase full of interfaces and classes with partial context fun and fun methods is (IMO) also confusing, and likely more visibly so.

In my experience, issues caused by code pollution due to extra methods happen very occasionally (like I see about one instance every two years), but if codebases start requiring tons of context fun declarations, they will be everywhere and I will see them weekly if not daily. The average new developer can generally ignore the former but not the latter.

I think context fun will require me to think about class design every single time I create a new class. It will be a ton of boilerplate for scope classes. Meanwhile, issues around scope method ambiguity should only make me pause to think about it in rare cases when I'm doing something particularly trickly involving multiple complex scope classes.

Note that we do not consider 100% satisfactory an approach which distinguishes interfaces from (abstract) classes...

This is a really interesting point.

Of course, you can also resolve it by saying that context interface is a stronger type than a regular interface.

Sure, a regular interface can be converted into an abstract base class -- in some cases they can almost feel interchangeable. But a context interface should never be converted. Its whole existence would be to specify a clear API for what methods can be automatically surfaced inside a function with context.

(This may sound like I'm a big defender for context interfaces, but this discussion is actually pushing me more to side with @quickstep24 and just allow anything to be an unnamed context parameter, to be honest.)

@TadeasKriz Sure, at this point I'm convinced that unnamed context parameters are useful. I'm still not convinced that context fun is the right way to opt-in APIs that get pulled into scope. I think you're coming to a similar conclusion?

The last paragraph in your comment about using annotations is an interesting idea which I can probably get behind. It might even be a stricter default in explicit API mode.

In my own case, I am having trouble imagining designing scope classes where I only want a subset of it to be available to contexts (and if I really wanted that, I would just split the interface and implementation, and then use the interface as the unnamed context parameter).

@bitspittle I see, sorry I misunderstood! I think you're right that we're now seeing it mostly the same way. And I think you've raised a great point that just because people in interviews disliked the current implementation, doesn't necessarily mean they'd like this one better. It reminds me of what @kpgalligan says about flying cars. That asking people if they'd want them, would give you a resounding "Yes, of course". But once you show them what that means though in reality (because we can't have the Fifth Element's flying cars), it becomes less and less appealing.

Damn, language design is super difficult, isn't it? πŸ˜…

@bitspittle

I totally get people expressing confusion due to a context with multi-scope being flooded by multiple versions of the same method. After all, I've been bit with it myself when in deeply, multi-nested with / apply blocks. But a codebase full of interfaces and classes with partial context fun and fun methods is (IMO) also confusing, and likely more visibly so.

Now that I think about it, most of my issues with this were because there's no such thing as a named context right now. So with named contexts, there'd be less need for unnamed overall, so going with full implicit might actually make sense?

@TadeasKriz No need to apologize at all! I enjoyed our interactions and appreciated your concrete examples and patience -- it really helped me understand the problem better. And I find it hard to juggle conversation threads in active GitHub discussions like this, so I'm sure some of the lack of clarity is my fault.

What about @DslMarker and its behaviour in this design?

I'm also feeling that with the introduction of context parameters, there's no need for A.() -> R thing as it is primarily used to inject context. context(A) () -> R can do all the stuff, and if you need this, just call summon<A>(). What makes fun A.f() and A.() -> Unit different is that the former could be an operator which must involve this operand, while the latter doesn't have much to do with this.

I don't find it useful to have unnamed context parameters, and it looks terrible when named and unnamed interleave. I'd suggest that the name is required. This way summon should be fine to drop too because you can just use the name. Note that it's still ambiguous when there's subtyping:

interface A

interface B : A

context(A, B)
fun doStuff() {
    summon<A>() // Which A?
}

Also, it's interesting that you can use your own name even if the function is a overrider, because you can't use name = ... syntax as in the value parameters.

Β§12 (context receivers, warning): [...] named context parameters solve many of the problems related to implicitly passing information around, without the caveat of scope pollution.

For me this sounds like a named context parameter doesn't make things available in the function with the named context parameter, i.e. this shouldn't compile:

interface MyScope {
    context fun foo1()
}

context(MyScope) fun foo2() {}

context(MyScope) fun bar() {
    foo1() // ok
    foo2() // ok
}

context(scope: MyScope) fun baz() {
    scope.foo1() // ok
    foo1() // not ok
    foo2() // not ok
}

However the following parts sound like the opposite to me:

Β§6 (naming ambiguity): We use the term context with two meanings:

  1. [...]
  2. Within a body, we use context to refer to the combination of [...], and [named] context parameters. This context is the source for context resolution, that is, for "filling in" the implicit context parameters.

(sounds like it at least allows calling foo2 from within baz)

Β§30 (resolution, context resolution): Once the overload candidate is chosen, we resolve context parameters (if any). For each context parameter:

  • We traverse the tower of scopes looking for exactly one [...], or named context parameter with a compatible type.

(sounds like it at least allows calling foo2 from within baz)

So my question is would this code compile or not?

serras commented

@bitspittle (no worries, part of the point of KEEP is to discuss about options, and fortunately the Kotlin community is a very civilized one. I expected questioning the design and the assumptions about it)

Our interviews included diverse set of teams, but we have a few which had been using the prototype (thus, the version with only context receivers), and a few more which were planning how to update their API.

I think context fun will require me to think about class design every single time I create a new class.

This is part of where I disagree. Almost every developer should still be designing classes as they are now, because types that work as receivers are a very narrow amount. Let me bring a point of my personal experience as maintainer of Arrow; even in such a library at most three types (Raise, ResourceScope, and Saga) have members that one would mark as context.

@serras I write scope classes a lot. Surely those will all need to be aggressively tagged with context fun, no?

serras commented

Let me clarify why the following is the case, because I think this may clarify some questions about how context work.

interface MyScope {
    context fun foo1()
}

context(MyScope) fun foo2() {}

context(scope: MyScope) fun baz() {
    scope.foo1() // ok
    foo1() // not ok
    foo2() // ok (different from above)
}

There are three steps to make a function accepted by the compiler.

  1. We need to find the name in the scope (this is called candidate resolution).
    • scope.foo1() searches for the foo1 function in the type of scope, and it's there. Note that for explicit function whether context is declared in foo1 doesn't matter.
    • foo1() is not found at this stage, because the named parameter doesn't bring anything into implicit scope.
    • foo2() resolves to the top-level function, like println would do.
  2. If there is more than one candidate for a function (for overloads) then we choose a "best one". This phase doesn't apply here.
  3. If the function declares required context, then we search in (1) the implicit scope and (2) any other context paramater in scope.
    • In the case of foo2(), the context is resolved to the scope context parameter. Note that being a context receiver or named context parameter is irrelevant here, as they are searched by type.

This last fact also explains why we may "fill in" a context parameter with the same value if the type is OK.

interface A
interface B
class C: A, B

context(a: A, b: B) fun foo() = ...

fun example() = context(C()) {
    foo()  // both context parameters are resolved to (the same instance of) C
}
serras commented

I write scope classes a lot. Surely those will all need to be aggressively tagged with context fun, no?

@bitspittle I would really love to hear about your use cases! (as far as you can tell) And if possible, also (1) how many functions would need to be changed, and (2) whether named context parameters would provide an acceptable solution for the problem.

How can I create a top level one which works the same as the one inside the interface?

interface MyScope {
    context fun foo1()
}

// top level foo2

context(scope: MyScope) fun baz() {
    scope.foo1() // ok
    scope.foo2() // ok
    foo1() // not
    foo2() // not
}
serras commented

How can I create a top level one which works the same as the one inside the interface?

fun MyScope.foo2()

Edit: you don't even need the context since scope is a named context parameter. But if you want to get 100% the same behavior as foo1 in every mode of usage, the empty context is needed.

@serras I was originally thinking I'd just use named parameters only (if you look at my original comments in this conversation I was wondering if we should just punt unnamed context parameters entirely) but @TadeasKriz and @quickstep24 convinced me that scopes were in fact a pretty good usecase for unnamed context parameters. I know this thread is pretty long now, but you can search for my discussions back and forth with them above for concrete details.

I think the most intriguing suggestion came from TadeasKris about configuring the scoping behavior via annotations, so codebases can opt into the what works best for them (this comment: #367 (comment)).

serras commented

@bitspittle I've been looking at your comments, but I still miss some specifics. You mention that you create a lot of scope classes, and this is what feels interesting to me. In most projects I've worked on (except for Arrow, but you could argue it's a special case) you never define new scope classes, you just use those from libraries (stdlib, coroutines, Ktor, DSLs to build text or HTML...)

interface MyScope {
    context fun foo1()
}

context(MyScope) fun foo2() {}

context(scope: MyScope) fun baz() {
    scope.foo1() // ok
    foo1() // not ok
    foo2() // ok (different from above)
}

There are three steps to make a function accepted by the compiler.
...

Explaining how things work conceptually by referring to how it is implemented is unfortunately a bad sign. For a language feature to be understood intuitively and accepted by a broad audience, there must be a simple mental model for the concept. It would be an insane amount of cognitive overload while developing when one has to repeatedly think about how things work internally in the compiler. I'm sorry to say but exactly this example shows me that I do not have a good mental model of the concept yet (especially for the plain "context"). But maybe it's just me?

serras commented

Fair enough, let me try to clarify the mental model.

On the one hand we have context parameters. These are just additional parameters to functions, but instead of explicitly passing them, these are implicitly resolved by the compiler. The sources of those context parameters are other context parameters, and any other source of implicit values in the current language (anything you refer with this).

Now, Kotlin has both value parameters and receivers. If you declare foo as follows,

interface A { fun baz() }
interface B { fun boo() }

fun A.foo(x: B)

you can access the members or extensions of A without qualifying them. In other words, you don't need to write this.baz(), you just write baz(). However, for x you need to write x.boo(). The very same distinction applies to context parameters: named context parameters are the "value" ones, context receivers the "receiver" ones.

Why add the additional complexity of contextual visibility? The problem is that when you have several context receivers, and maybe even regular receivers, there may be too many things in scope. Let's go for a minute with an alternative design in which every function from any receiver is at the same scope level.

context(A, B) fun bop() = toString()

The only possibility for the code above is to give an ambiguity error! However, many people have mentioned as that they don't want functions like toString or let to even be available, since they only make sense if we have either a explicit (thing.toString()) or a "proper" receiver in the function.

In summary, the design stems from a unique addition to the language: context parameters which are not written implicitly when calling the functions. However, if you want context receivers, there's a complicated intersection with "regular" receivers that needs some additional addressing.

Explaining how things work conceptually by referring to how it is implemented is unfortunately a bad sign.

100% agree, the function should feel intuitive. However, I think that for better understanding we need to look at the Kotlin specification, hence my reference.

serras commented

Another comment here; I think a lot of the problems are related to seemingly having to add context to many functions,

context fun Scope.blah()

However, this is not the case for most of them, since you can just write,

context(Scope) fun blah()

Technically it's not 100% the same, but they would both work in a block where Scope is in implicit scope.

with(scope) { blah() }
fun Scope.boo() = blah()

Since the change is ABI-compatible, this is a way you can migrate your API.

edrd-f commented

@serras, first of all, thanks for taking the time to address every comment here ❀️

This new version of the proposal makes context receivers' usage stricter at the declaration site while at the same time keeping the ergonomics at the call site, which is very nice.

However, I'd like to discuss the example you gave above:

interface MyScope {
    context fun foo1()
}

context(MyScope) fun foo2() {}

context(scope: MyScope) fun baz() {
    scope.foo1() // ok
    foo1() // not ok
    foo2() // ok (different from above)
}

If the function declares required context, then we search in (1) the implicit scope and (2) any other context paramater in scope.

  • In the case of foo2(), the context is resolved to the scope context parameter. Note that being a context receiver or named context parameter is irrelevant here, as they are searched by type.

I think this case breaks the mental model of context receivers vs. parameters and makes things go in the opposite direction of stricter declaration + easy usage.

When I declare context(MyScope) fun foo2() {} I think of "fun2() must be called whenever there's a MyScope receiver". However, context(scope: MyScope) fun baz() declares a parameter. Although I understand it's nice to have a "smart" resolution, I think this implicitness can create confusion.

I agree, it's very confusing that scope can both be accessed explicitly (scope.foo1()) AND implicitly (but only sometimes).

Exactly as @edrd-f and @lukellmann said, I was so confused by this and thought my understanding must be either wrong, or @serras made a mistake writing the example. With one difference that I wouldn't expect the foo2 to be usable on receiver, because it's context-bound, not receiver-bound, so instead having to call it using with(scope) { foo2() } would be my expectation. It's boilerplatey though, although consistent.

@serras I write a πŸ’©-ton of scopes and builders in regular projects (not just libraries), so I'll try to find them and pull them out as examples, so we can chat about them.

... In most projects I've worked on (except for Arrow, but you could argue it's a special case) you never define new scope classes, you just use those from libraries (stdlib, coroutines, Ktor, DSLs to build text or HTML...)

Maybe this is the source of our different views on having to mark context fun. For libraries it is perfectly ok to spend lots of time on carefully crafting the context interface. In agile projects with agile code, you want to keep things simple. Having to mark your own callables when required would be a minor pain. Not being able to use foreign code as context receivers (just because it's Java or the library designers thought 'context' was bad) would be a major drawback.

I believe context receivers are a very powerful tool. It will get even more powerful in combination with context parameters. Restricting its usage to 'context ecosystem only' would cripple it significantly.

@quickstep24 yes, in several interviews it was reported that at the moment that you have more than one context receiver, scope pollution starts being a problem. In some cases the problem is not per se in compilation, but the reduced understandability of the code.

Undoubtedly, mutliple receivers add complexity. But in how many cases would explicit markers actually help? For example, html DSL has an id property. That's likely to get in the way. But it would be marked context var in future, too, just because it is needed.
Moreover, named context parameters would already give developers a much better option to reduce complexity which they do not have today (in @TadeasKriz metapher: no need for flying cars when you can teleport).

Another problem with marking types instead of functions: how does it work with extension functions? Are they also accesible? And if so, why are generic extension functions like let or apply not accesible (or why they are)?

If the extension is on interface LoggerScope, then yes, it is available. If the extension is on class Logger and you only pull in LoggerScope, then no, it is not available. Generic extensions are not a problem, like toString local resolution always has higher priority. And there is really no need to 'mark' types. That is just being restrictive on the user with no additional benefit.

serras commented

Not being able to use foreign code as context receivers (just because it's Java or the library designers thought 'context' was bad) would be a major drawback.

This is indeed another point of view. Why do you find this a drawback? When discussing this, we came to the conclusion that in those cases named context receivers would be a better fit in almost every case. We don't need to allow every possible usage if it hurts understandability of the code.

serras commented

One point in the design which is maybe not obvious is that we being a context receiver or named context parameter only affects the body of the declaring function. Callers of the function don't care in which way it is declared. This is good because everything is implicit, so having too many different sources (some parameters look at this one, others at other one) is really confusing.

Not distinguishing between having a name or not is also in line with how platforms actually handle ABI compatibility. For example, in the JVM functions are distinguished only by their types, parameter names have no role.

serras commented

Note that in these days many of us will be on holidays, so communication may be slower. We've also decided that we are not going to update the KEEP at least until the second week of January, because of the holidays + additional time needed to process the feedback and discuss new options.

In the meanwhile, it's really nice to hear all the different points of view. Kotliners write code in many different styles, and this means that whatever design we come up with will definitely benefit some users more than others.

@serras

In most projects I've worked on (except for Arrow, but you could argue it's a special case) you never define new scope classes, you just use those from libraries (stdlib, coroutines, Ktor, DSLs to build text or HTML...)

You just have to look through this thread to see that other people in here don't think this way, and they want to use unnamed scope classes pretty liberally in some cases (like scopes).

@quickstep24 gave a code example here, with multiple classes they consider scope classes: #367 (comment)

context(User, FlowContent, Binder<User>)
fun userTemplate() {
    div { bind(login, textContent) }
    div { bind(name, textContent) }
}

You could tell him he should be using named parameters and then withing them, but we don't ship an IDE with our own copy of an Alejandro Serrano :) I honestly think most people will want to use unnamed parameters more than the feature designers are expecting, and if they can, they probably will. @TadeasKriz himself pushed back on the with approach earlier in one of our conversations as generating a lot of boilerplate, and he actually changed my mind about it.

context(A, B) fun bop() = toString()
The only possibility for the code above is to give an ambiguity error! However, many people have mentioned as that they don't want functions like toString or let to even be available, since they only make sense if we have either a explicit (thing.toString()) or a "proper" receiver in the function.

The team keeps bringing this up as if it's a deal breaker, or even a new situation, but Kotlin already has things like this today, and it is rarely a problem to deal with it.

Think about wildcard imports vs. fully qualified imports. Some will make the argument that wildcard imports are bad practices and you should be using fully qualified imports everywhere, but 1) wildcard imports have moments where they are really convenient and 2) I think people would get upset if you told them that you were going to take it away from them because sometimes you can get an ambiguity error.

With wildcard imports, ambiguity is resolved with ordering rules. If a qualified import is specified, that gets priority; otherwise, first matching wildcard import takes priority.

What I would expect context(A, B) fun bop() = toString() to do is use A.toString() and warn me that it's doing that (so I can choose to make the resolution explicit by qualifying it). No big deal.

It's also worth noting I'm never going to write context(A, B) fun bop() = toString() even though I technically can -- given that, it feels like we're spending an inordinate amount worrying about it.

And finally, if people get in the habit of writing with(summon<A>(), summon<B>()) to work around it, they're still going to have to deal with the ambiguity; we've just pushed the problem down the line.

I think the easiest mental model you can have with context parameters is what others have said -- context(A, B, c: C, d: D)means two parameters are receivers (and you think of them like a combined this) and two parameters are named.

serras commented

You could tell him he should be using named parameters and then withing them [..]

Not at all! FlowContent and Binder<User> seems like perfectly good examples of scopes. But I'll argue that User should be a regular extension receiver, since it's the "subject" of the template (the previous KEEP went into more detail into why you shouldn't use context receivers in types like User).

I am not suggesting that you completely switch to named parameters, but rather that (a) extension receivers should be used for the "subject", (b) named context parameters for implicit dependencies, and (c) context receivers only for DSL-like.

I honestly think most people will want to use unnamed parameters more than the feature designers are expecting.

I think that our role as designers is not only to define a feature technically, but also explain developers how it should be used. For example, we explain in the previous KEEP that context(Int) is often a bad idea. And in this case, I think we need to be very cautious into pushing context receivers too much, because it really needs careful consideration as opposed to other types of parameters.

Apart from that, I should emphasize that there are other users which have told us that they may forbid context receivers in their organizations (except for a few selected types); at the very least because they want every parameter to have a name and have a very direct link to a function and its origin. Not many of them seem to be taking part on this discussion, though.

The team keeps bringing this up as if it's a deal breaker, or even a new situation, but Kotlin already has things like this today, and it is rarely a problem to deal with it.

It's almost never about toString, indeed. But it's not strange to have two functions called getAll when you have several data repositories. In this case, this points to the fact that maybe those Repository classes shouldn't be context receivers.

Not at all! FlowContent and Binder<User> seems like perfectly good examples of scopes. But I'll argue that User should be a regular extension receiver, since it's the "subject" of the template (the previous KEEP went into more detail into why you shouldn't use context receivers in types like User).

I have to look for an example (or verify that it doesn't exist) where I'd love to use "the subject" as an extension receiver, but couldn't do so. I think it was due to call-site limitations that required way too much boilerplate to use, but that was on a project where I couldn't use context receivers at all (a KMP project).

It's almost never about toString, indeed. But it's not strange to have two functions called getAll when you have several data repositories. In this case, this points to the fact that maybe those Repository classes shouldn't be context receivers.

I agree that toString is just one of many examples, but as you said, things like getAll will probably be used on context parameters anyway, so it wouldn't be a problem if trying to call getAll() in context(RepositoryA, RepositoryB) would fail the compilation with ambiguity error. Then you could use this<RepositoryA>.getAll() (I'm playing around with different options for summon 😬) to disambiguate.

On the same note, I can see a lot of builder scopes having context fun add(..) where you'd get the ambiguity problem anyway.

So far, I'm really leaning towards dropping the context fun modifier for context receivers and instead recommending the use of named context parameters wherever possible. Then, if you have a type that's a scope, you'll use it as a receiver and be able to use it as one, with the receiver<AType> for disambiguation wherever necessary. What do you think?

EDIT: Actually it might be nice to have a way to explicitly say that a declaration is not to be used implicitly, which would solve cases where you have some configuration that shouldn't be an easily accessible part of the DSL. Something like this:

interface ConfigurableScope {
  @Context(explicit) 
  var name: String

  fun doSomething()
}

context(ConfigurableScope)
fun foo() {
  doSomething() // ok
  this<ConfigurableScope>.name = "Hello!" // ok
  name = "World" // compile error
}

@bitspittle

What I would expect context(A, B) fun bop() = toString() to do is use A.toString() and warn me that it's doing that (so I can choose to make the resolution explicit by qualifying it). No big deal.

Honestly I'd rather it be a compilation error, because this is super error prone. Imagine a scenario, where you add a @Suppress("ambiguous") above the call site and expect that A.toString() is always going to be called. And then A type removes the toString() (bad example, imagine it's a fun foo() instead) and all of the sudden B.toString() is called instead.

@TadeasKriz I'm OK with an error. I was mentally anchored to competing wildcard imports and nested with blocks which don't say anything right now, so compared to that, a warning is a luxury :) But I think an error is a good idea especially for a first release.

To be complete, we'd also have to make a decision about the function receiver case:

context(SessionScope, BinderScope) fun User.bindToLoggedInParameters() {
   // Can we use toString() or hashCode() in this context without explicitly writing `this@User`?
   // Would doing so generate a warning? Or an error?
}
serras commented

Extensions keep working as of now, and we explicitly mention that context receivers get lower priority because of cases like that. So you can write toString() and it will resolve to the User one, as (intuitively) expected.

@serras

I think that our role as designers is not only to define a feature technically, but also explain developers how it should be used.

I'm nervous then because we're debating a feature here where it seems the best practice is that you probably shouldn't use it. (Even though this thread is full of people who very yes want to use it :)

How many features in Kotlin are like this?

Maybe lateinit is a feature with sharp edges, where using it comes with best practice warnings? But despite that, it really needs to exist -- sometimes devs really do know more about the state of a variable than the compiler does (especially in the world of Android, where initialization is often postponed to onCreate)

I can't think of any other such "please don't use unless you know what you're doing" Kotlin features -- but maybe others can?

Edit: maybe inline?

Not to add another shallow opinion about the summon naming, but it does feel a little grandiose and out of place.

Would calling it getContext be a problem, as opposed to introducing a new verb? It feels natural, and searching GitHub for getContext<...>() gets me 16 results in ~5 projects, all unqualified calls to a member function, mostly against GraphQL's Java API

And for the other function, context instead of withContext that's previously been floated, I'm a fan. Reminds me of the suspend function that acting like a keyword to make a block suspend. I like it :)

Now that it is even called "context parameters", it even makes less sense to me to declare it syntactically as a modifier or annotation. I think it's not just a modifier, it's part of the signature and carries input parameters just like any other parameter. As a consequence, I don't like that it's placed so prominently at the very beginning of the function declaration, even before fun and the type parameters it may refer to.

context(scope: Scope<T>) fun <T> Rec.func(param: Param): Ret

When comparing with the function type declaration context(Scope) Rec.(Param) -> Ret the closest thing would be to put it right in front of the receiver:

fun <T> context(scope: Scope<T>) Rec.func(param: Param): Ret

One could play with all sorts of syntaxes, some examples for the function type (from which the function declaration syntax should be derived):

context(Scope) Rec.(Param) -> Ret // current syntax
(Scope)Rec.(Param) -> Ret
[Scope]Rec.(Param) -> Ret
Rec.(Param) context(Scope) -> Ret
Rec.(Param)(Scope) -> Ret
Rec.(Param)[Scope] -> Ret
Rec.(Param | Scope) -> Ret
Rec.(Param, context Scope) -> Ret

@thumannw I prefer it before fun, with a lot of params the format could be weird:

fun <T> context(
    scope: Scope<T>,
    ...
) Rec.func(
    param: Param,
    ...
): Ret {
    ...    
}

The next one is better for me:

context(
    scope: Scope<T>,
    ...
)
fun <T> Rec.func(
    param: Param,
    ...
): Ret {
    ...
}

@thumannw

I'd be with @JavierSegoviaCordoba on this matter: the reason being that this is a context which has been added onto the function. My mental model on this matter constitutes more of "added context" (context receivers, receiver functions, etc.) and less "implicit parameters" (thanks Scala).

So it makes more sense, imho, of adding them in like annotations on functions.


On the point of naming, I would go with context<T>(), as you get the context. And yes, getContext sounds weird to me, as Kotlin has properties which discards the get part.

Interesting to see that for a feature which tries to make things implicit and invisible, the preferred syntactical representations seems to be the most explicit and prominent one. When reading the source code from top to bottom, I will be confronted with the context parameters first, before even knowing what callable it is, as if it is the most important part.

Anyway, I guess the ship has sailed here already.

@JavierSegoviaCordoba
Your first snippet is not my preferred one, but it's still better than the current syntax. In fact, I do not have the preferred one yet, but something like this is even better (see my list of function type proposals):

fun <T> Rec.func(
    param: Param
) context(
    scope: Scope<T>
): Ret {
    ...
}
serras commented

On the matter of syntax, it’s important to consider that it must not make ambiguous programs which compiled before. This is the reason why fun context(A) foo(…) won’t cut it.

It’s also a non-goal of this proposal to blur the lines between receivers. We still want to have a default receiver accesible with this, and the syntax should reflect that.

edrd-f commented

On the matter of syntax, it’s important to consider that it must not make ambiguous programs which compiled before. This is the reason why fun context(A) foo(…) won’t cut it.

What about no context keyword at all?

fun <T> (Scope<T>, other: OtherScope) Rec.func(
  param: Param,
  lambda: (Scope<T>) Rec.() -> Unit
): Ret {
  // ...
}

Benefits:

  • Allows declaring generic types before referencing them
  • More concise syntax
  • Resembles tuples
  • Avoids suspend context(...) "weirdness":
val lambda: suspend (OtherScope) Rec.() -> Unit = { /* ... */ }

// versus

val lambda: suspend context(OtherScope) Rec.() -> Unit = { /* ... */ }

On the matter of syntax, it’s important to consider that it must not make ambiguous programs which compiled before. This is the reason why fun context(A) foo(…) won’t cut it.

What about no context keyword at all?

fun <T> (Scope<T>, other: OtherScope) Rec.func(
  param: Param,
  lambda: (Scope<T>) Rec.() -> Unit
): Ret {
  // ...
}

Benefits:

  • Allows declaring generic types before referencing them
  • More concise syntax
  • Resembles tuples

I honestly dislike this very much. I agree that the generic parameters are a downside of the context(..) fun ... syntax, but as @JavierSegoviaCordoba said, with multiple parameters and multiple context receivers+parameters, it'll be really noisy.

Additionally I like the way the following can be "read" as "With context Foo and Bar, a suspend function foo". Where the context is something that's purposefully outside. And hopefully with class-bound contexts, it'd make even more sense as that'll be part of the context (+ IDE could show what context is available from the outside nicely).

context(Foo, Bar)
suspend fun hello() { }
  • Avoids suspend context(...) "weirdness":
val lambda: suspend (OtherScope) Rec.() -> Unit = { /* ... */ }

// versus

val lambda: suspend context(OtherScope) Rec.() -> Unit = { /* ... */ }

As for the suspend context(..), wouldn't that be written context(OtherScope) suspend Rec.() -> Unit?

You could tell him he should be using named parameters and then withing them [..]

Not at all! FlowContent and Binder<User> seems like perfectly good examples of scopes. But I'll argue that User should be a regular extension receiver, since it's the "subject" of the template (the previous KEEP went into more detail into why you shouldn't use context receivers in types like User).

Maybe it was not clear but User is a DAO is this example. You could choose to make it a subject if you like to express a strong coupling between model and view, but in this case a weaker (mental) coupling was preferred. It just shows how programmers value the freedom to express their intentions in code.

Not being able to use foreign code as context receivers (just because it's Java or the library designers thought 'context' was bad) would be a major drawback.

This is indeed another point of view. Why do you find this a drawback?

I believe power of programming comes from powerful tools. I don't believe in giving people blunt knives just because they could hurt themselves.
Interoperability has always been a strength of Kotlin language design. It cannot quite believe that this should have changed.