Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Context parameters #367

Open
serras opened this issue Dec 21, 2023 · 365 comments
Open

Context parameters #367

serras opened this issue Dec 21, 2023 · 365 comments

Comments

@serras
Copy link
Contributor

serras commented Dec 21, 2023

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

@JakeWharton
Copy link

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?

@jamesward
Copy link

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

@Jellymath
Copy link

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

@serras
Copy link
Contributor Author

serras commented Dec 21, 2023

@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
Copy link

alllex commented Dec 21, 2023

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.

@zhelenskiy
Copy link
Contributor

zhelenskiy commented Dec 21, 2023

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.

@gpopides
Copy link

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.

@JakeWharton
Copy link

JakeWharton commented Dec 22, 2023

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
Copy link

xxfast commented Dec 22, 2023

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

@bitspittle
Copy link

bitspittle commented Dec 22, 2023

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?

@JavierSegoviaCordoba
Copy link

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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.

@bitspittle
Copy link

bitspittle commented Dec 22, 2023

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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() }

@quickstep24
Copy link

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
}

@Mishkun
Copy link

Mishkun commented Dec 22, 2023

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

@quickstep24
Copy link

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.

@pablisco
Copy link

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()

@quickstep24
Copy link

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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
Copy link
Contributor Author

serras commented Dec 22, 2023

"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")
  }
}

@SPC-code
Copy link

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
Copy link

kyay10 commented Dec 22, 2023

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.

@quickstep24
Copy link

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.

@quickstep24
Copy link

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
Copy link
Contributor Author

serras commented Dec 22, 2023

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.

@CLOVIS-AI
Copy link

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).

@mr-kew
Copy link

mr-kew commented Oct 11, 2024

Will this be released for multiplatform as well straight away?

@serras
Copy link
Contributor Author

serras commented Oct 13, 2024

Will this be released for multiplatform as well straight away?

That is the plan, full support in all platforms

@thumannw
Copy link

thumannw commented Oct 31, 2024

(1) Regarding the name declaration in context(name: Type): I think this is redundant and will probably lead to a lot of boilerplate code.

  • The only use-case listed, in which this name would actually be used, is §4.1 (services Logger, Repository etc.). In most other cases, we will be forced to write context(_: Type) which is boilerplate. The "implicit service use-case" is (in my opinion) also the one for which the context feature should be used the least. Until now, we solved the problem of explicitly passing around logger objects by introducing classes having the logger as property and then use the scope of the class to access the logger, and this would still be a good approach. See also @hyperfnugg's post above.
  • It's inconsistent with lambdas, where the context parameters cannot be named (which is good imo).
  • Instead of context(name: Type) fun m() = name.something we could always write context(_: Type) fun m() = implicit<Type>.something. The latter cannot be dropped (see §1.8). So why support an alternative way?
  • As a consequence, the name part could just be dropped. We just write context(Type) fun Rec.myFun(param: Param): Ret which would mean the same as context(_: Type) fun Rec.myFun(param: Param): Ret in the context of this proposal.

(2) Regarding §1.4 (definition of implicitness, i.e. how values for context arguments are resolved).

  • A good step in the evolution of the proposal was to distinguish between the classical receiver scope and the newly introduced context scope. So, as far as I have understood, the following is not possible (which is good as it reduces scope pollution and complexity):
fun X.m() { ... }
context(_: X) fun n() {
    m() // cannot resolve value of type X from context
}
  • However, this idea has not been followed consequently, because, as §1.4 states, the other way round IS possible:
context(_: X) fun m() { ... }
fun X.n() {
    m() // ok, resolved the value of type X from the receiver scope
}

Could you explain why this should be the case and what benefit it brings? I have not found convincing arguments in the proposal.

  • On the other hand, not allowing it would just be consistent, would even further reduce scope pollution and reduce the complexity of the feature significantly, because there are even less interactions with existing features.

@Irineu333
Copy link

Irineu333 commented Nov 4, 2024

I prefer the syntax of the original request:

fun (A, B).foo(value: String) {
    ...
}

And i don’t understand why it’s necessary to name the contexts.

@Peanuuutz
Copy link

Peanuuutz commented Nov 4, 2024

@Irineu333 That may completely close off the potential path to tuples.

val pairs: (Int, String) = (1, "a")

fun (Int, String).first(): Int = this.0

@SPC-code
Copy link

SPC-code commented Nov 4, 2024

@Peanuuutz I don't think tuples will ever happen. They do not make a lot of sense with data classes and decomposition already there. As for best variant of KEEP-176, it was fun [Context, Receiver].foo(), so not clashes either.

@CLOVIS-AI
Copy link

@Irineu333 Having multiple "true" receivers plays really badly with the language as it is currently. It makes many things ambiguous and creates risks of hard-to-understand code that changes behavior with new versions of libraries. Therefore, (A, B).() -> Unit is too risky.

Context parameters are not true receivers. They are similar, but slightly different to avoid the main issues. Therefore they should have their own syntax. context(A, B) () -> Unit seems reasonable to me.

Now, whether they need a name or not is another question.

  • In favor of not needing a name: DSLs are isolated sections of code in which all (or almost all) functions come from the scope. In this situation, having context receivers to insert functions into scope is extremely powerful and solves many of the problems of current extension lambdas. However, this requires having unnamed context parameters (otherwise, the existing syntax is more concise and less confusing).
  • In favor of needing a name: When used more broadly, e.g. context(logger: Logger), it is important to have a name to ensure method call resolution doesn't change in the future when a new function is added to Logger

@SPC-code
Copy link

SPC-code commented Nov 4, 2024

@CLOVIS-AI I do not agree. It was discussed a lot in #176. The only difference between dispatch receiver and context receiver is ability to inherit dispatch receiver. I the current proposal additional difference is that only dispatch receiver could be called as this. It makes some sense for function declaration, but it could be easily solved by using the last receiver in brackets as dispatch receiver.

In lambdas, dispatch receivers do not make any sense as well as mixing between context receivers and dispatch receivers.

The bracket variant is also applicable to named parameters:

fun [context: Context, receiver: Receiver].doSomething( block: [Context, Receiver].() -> Unit ){...}

I think it looks much better than:

context(context: Context, receiver: Receiver)
fun doSomething( block: context(context: Context, receiver: Receiver) () -> Unit ){...}

@Irineu333
Copy link

Irineu333 commented Nov 4, 2024

I don't think this:

fun doSomething(block: (A, B).() -> Unit) {
    ...
}

is harder to understand than this:

fun doSomething(block: context(A, B) () -> Unit) {
    ...
}

or this:

fun doSomething(block: [A, B].() -> Unit) {
    ...
}

Maybe the problem is dealing with line breaks. Or perhaps it's because, in the future, they want to add context for other things, like classes.

@CLOVIS-AI
Copy link

@SPC-code

he current proposal additional difference is that only extension receiver could be called as this.

It's not the only difference. Here is another:

context(foo: Foo)
fun bar()

Foo().bar() // does not compile

but it could be easily solved by using the last receiver in brackets as extension receiver

Why? If two concepts are different, they should have different syntax. We don't have parameters that magically change how they work depending on the order in which they are placed.

Using your syntax, but splitting context parameters and the receiver:

protected suspend inline fun <T : List<String>> [context: Context, receiver: Receiver] T.doSomething( block: [Context, Receiver].() -> Unit ){...}

Each time a new language feature is added, the harder it is to find the name of the function. We can't continue having everything on the same line, Kotlin is already starting to be quite hard to read.

@Irineu333 It's not about syntax, it's about concepts. (A, B).() -> Unit implies that two things are doing the action. context(A, B) C.() -> Unit is clear that C is doing the action, and A and B are merely contextual information.

@serras it could be great to add a section to the KEEP summarizing why the team didn't go with multiple dispatch receivers, I see the question being asked quite often.

@Irineu333
Copy link

Irineu333 commented Nov 4, 2024

@CLOVIS-AI I get it now! Thanks for the explanation.

Considering the following syntax:

context(A)
fun B.doSomething()

We have the following behavior:

with(B()) {
    A().doSomething() // does not compile
}

with(A()) {
    B().doSomething() // compiles
}

Whereas, for the definition:

fun (A, B).doSomething()

The behavior changes to:

with(B()) {
    A().doSomething() // compiles
}

with(A()) {
    B().doSomething() // compiles
}

I understand the difference now. The syntax (A, B).doSomething() not only requires the contexts, but also makes the function an extension of all declared contexts, which can definitely make things confusing.

@SPC-code
Copy link

SPC-code commented Nov 4, 2024

Using your syntax, but splitting context parameters and the receiver

Mixing dispatch and context receivers is the major problem in all my experiments with previous iteration of context receivers. It should be prohibited wherever it is possible. If we replace context syntax with brackets, the problem with distinguishing them. You use either:

fun T.function()

or

fun [input: T, output: R].function()

They look similar.

Dispatch receiver is also complex concept that would make adoption much harder.

@Irineu333
Copy link

Irineu333 commented Nov 4, 2024

@SPC-code I have a question: what if I want to add additional contexts to an extension function?

For example:

context(A, B)
fun C.doSomething()

How would you reproduce this using brackets?

@Peanuuutz
Copy link

Peanuuutz commented Nov 4, 2024

Each time a new language feature is added, the harder it is to find the name of the function. We can't continue having everything on the same line, Kotlin is already starting to be quite hard to read.

Maybe it's finally the time to introduce "receiver blocks"?

extension <L> for L
    where L: List<String>
    in context: Context
{
    public suspend inline fun doSomething(block: (this: L, in context: Context) -> Unit) {
        // Proposed usage
        this.block()

        // Same as
        block(this, context)
    }
}

Why blocks?

Functions with context often come in a group. With current syntax, it's hard to both write and read multiple functions because of too much repetition. This is already the case for extension functions on generic collections.

@SPC-code
Copy link

SPC-code commented Nov 4, 2024

@Irineu333

The idea is to avoid mixing context receivers and "regular" receivers at all. I've experimented a lot with context receivers (besides being one of the authors of KEEP 176) and in my experience, mixing them brings a lot of problems.

Do you have a case, where it is needed?

In my opinion, fun T.function() looks equivalent to fun [this: T].function() and this equivalency simplifies things a lot.

I do not strongly object to the current design since it won't be used a lot outside of libraries, but I like square brackets design much more because it is consistent. One must also remember that idea of using context() comes from the concept that "things must be called the same way they are created" which does not seem to be valid to me.

@Irineu333
Copy link

Irineu333 commented Nov 4, 2024

I see, so the equivalent of:

context(A, B)
fun C.doSomething()

would be:

fun [A, B, this: C].doSomething()

I think it could also be:

fun [A, B, receiver: C].doSomething()

@Irineu333
Copy link

Now I understand why this has been under discussion for years.

@holloszaboakos
Copy link

holloszaboakos commented Nov 6, 2024

I fill like the usage of context receivers is very similar to varargs.
It is a special parameter that should be marked for its aditional functionality.
However while varargs must be the last parameter, I think context parameters should be the first ones.
Since we used with in the past to create a scope with a reciever I suggest with as a parameter.

How it would look:

fun myFun(with myContext1 : MyContext1, with myContext2 : MyContext2, otherParam1 : Param, otherParam2 : Param){
...
}

fun otherFun{
   context(myContext1(),myContext2()){
       myFun(Param(),Param())
   }
}

If only one context is used than a syntactich sugar might be added:

fun myFun(with   MyContext, otherParam1 : Param, otherParam2 : Param){
...
}

fun otherFun{
   context(myContext()){
        myFun(Param(),Param())
   }
}

@holloszaboakos
Copy link

holloszaboakos commented Nov 6, 2024

Each time a new language feature is added, the harder it is to find the name of the function. We can't continue having everything on the same line, Kotlin is already starting to be quite hard to read.

Maybe it's finally the time to introduce "receiver blocks"?

extension <L> for L
    where L: List<String>
    in context: Context
{
    public suspend inline fun doSomething(block: (this: L, in context: Context) -> Unit) {
        // Proposed usage
        this.block()

        // Same as
        block(this, context)
    }
}

Why blocks?

Functions with context often come in a group. With current syntax, it's hard to both write and read multiple functions because of too much repetition. This is already the case for extension functions on generic collections.

The ide is cool, but the syntax is ugly full of unnecessary formalism and not kotlin like.

I suggest:

extend <L> L where L : List<String> {
    context (myContext:Context){
        public suspend inline fun doSomething(block: (this: L, in context: Context) -> Unit) {
            // Proposed usage
            this.block()

            // Same as
            block(this, context)
        }
    }
}

This syntax allows defining multiple extentions for the same type in different contexts too.

@Peanuuutz
Copy link

Peanuuutz commented Nov 6, 2024

Yeah I agree splitting dispatch receiver and context receiver is better.

extend <C> Collection<C> {
    fun <D> maxBy(selector: (C) -> D): C? { /* ... */ }

    fun <D> minBy(selector: (C) -> D): C? { /* ... */ }
}

extend <C> Collection<C> where C: Comparable<C> {
    fun max(): C? { /* ... */ }

    fun min(): C? { /* ... */ }
}

context (logger: Logger) {
    fun run(tag: String, block: context(Logger) () -> Unit) {
        logger.debug("BEGIN $tag")
        block()
        logger.debug("END $tag")
    }
}

extend Server {
    context (env: Env) {
        suspend fun start(loop: context(Env) suspend () -> Unit) { /* ... */ }

        fun stop() { /* ... */ }
    }
}

context (builder: StringBuilder) {
    extend String {
        operator fun unaryPlus() {
            builder.append(this)
        }
    }
}

@CLOVIS-AI
Copy link

@holloszaboakos:

It is a special parameter that should be marked for its aditional functionality.
However while varargs must be the last parameter, I think context parameters should be the first ones.
Since we used with in the past to create a scope with a reciever I suggest with as a parameter.

There is another difference: varargs appear on the callsite, context parameters do not. It is important for the language (and has already been discussed both in this thread and the previous KEEPs and issues) that the parameter list when a function is declared, and the argument list when a function is called, to be similar. Especially because by default, the parameter order is important (parameters are by default resolved by index). Changing this would make functions harder to read.

The reason this KEEP isn't progressing isn't because of syntax. It's because the behavior in quite a few edge cases is weird and hard to understand. Once these cases are handled, the proposal will (hopefully) progress enough that it at least becomes plausible that this feature reaches Kotlin, no matter its shape.

@serras
Copy link
Contributor Author

serras commented Nov 6, 2024

I see this thread is picking up steam again. Let me reiterate that context parameters are going to be implemented as the current proposal, and afterwards we'll make a retrospective and decide whether we need to make any adjustments for the final implementation. In the meantime, please don't hesitate to ask for further clarifications.

I would kindly ask everybody not to bring questions that were already answered in this thread or in the Q&A about decisions in the KEEP proposal itself. In particular,

  • I understand that the chosen syntax may not be the preferred for everybody, but reiterating on whether we should use context(a: A, b: B), (a: A, b: B).foo or [a: A, b: B].foo only adds to the noise;
  • The choice about dropping receiver is a deliberate one, and has been discussed at length. We may bring back some receivers in the future, but right now parameters feel enough for almost every use case.

@thumannw
Copy link

thumannw commented Nov 6, 2024

In the meantime, please don't hesitate to ask for further clarifications.

Ok then, why do you mention "... dropping receiver ..." now? I have the feeling that a syntax without name of the form context(Type) automatically is a "context receiver" for you, with different resolution rules, interacting with dispatch and extension receivers and suffering from "scope pollution". If that is the case, I don't understand why this implication is necessary. I have already mentioned that removing any interaction between context and receivers might be an option worth thinking about, regardless of the syntax and name topics.

I see and understand that the discussion is already quite convoluted and my intention is not to add more to it. But I would propose one thing after the implementation: Perform a prototypical migration of the kotlinx.coroutines library, not paying attention to backward compatibility, as if context parameters would have been available from the start. Just to see how the concept would fit to the demands in this library. I'm curious on how often "_:" would appear there in relation to all "context" usages.

@zhelenskiy
Copy link
Contributor

I have just reread the current version of the document I previously read one year ago. I would like to thank @serras as I am far more satisfied with its current state and chosen design and its simplicity.

However, I have a couple of comments about the design.

Getter/setter contexts

I suppose it makes sense to allow getters/setters to have additional context parameters. The general reason is that for modifying an object, you may be practical to require a wider context than just for reading, e.g. MutableSomeContext instead of SomeContext. It can be reached by passing an extra context or requiring it to be a narrowed type.

Syntactically speaking, I would expect these two code snippets to be equivalent:

context(A)
val x: T
    get()
    set(value)
val x: T
    context(A)
    get()
    context(A)
    set(value)

This will allow users to do the following:

val x: T
    context(A)
    get()
    context(A, B)
    set(value)

or

val x: T
    context(A)
    get()
    context(B)
    set(value)

Of course, these features can be added later.

Placing generics before contexts

context(ctx: A) fun <A> a() has a problem that when the user types the context, the types cannot be resolved as the compiler does not know that the type A is a not yet declared generic parameter and therefore one gets red highlighting.

I see two results:

  • Special heuristics for this case in the IDE. It might be fragile.
  • Allowing putting type parameters after context keyword as well as after fun keyword. In this case no special handling is needed, but a second place for placing type parameters appears which is not good. Of course, we can discourage putting regular type parameters not needed for context receivers there by adding a warning.

@gnawf
Copy link

gnawf commented Nov 26, 2024

Question: is it bad if I use context receivers as a scope to deprecate/introduce compiler errors?

e.g.

fun A.haveFun() // This is ok

@Deprecated("No fun at work", level = DeprecationLevel.ERROR)
context(_: WorkScope) 
fun A.haveFun(): Nothing // This is not ok

Where having WorkScope automatically resolves to the second function instead, and generates an error?

Basically my use case is that I have some extension functions that are slow, and they end up being precomputed in the scope, so it's a mistake to use the slow functions when the the code can access the precomputed data when the scope is present.

Both functions are in the same package so they're always imported together.

@serras
Copy link
Contributor Author

serras commented Nov 26, 2024

is it bad if I use context receivers as a scope to deprecate/introduce compiler errors?

It is definitely not the preferred option. Note that this would only work with one function having no context parameters and the other having some, you cannot use this to distinguish between different variations.

@david-kubecka
Copy link

In the current version of context receivers, I've come up with a use case of creating a helper function to wrap commonly used contexts (especially in tests).

fun withTestContext(block: context(Context1, Context2) () -> Unit) =
  block(TEST_CONTEXT_1, TEST_CONTEXT_2)

context(Context1, Context2)
fun myFun() {}

fun myTest() = withTestContext {
  myFun(..., ...) // how to access the corresponding contexts here?
}

AFAIK it's not easily possible to call myFun with the correct contexts. Will this be solved in the new proposal?

Also, will there be an option to create alias for commonly used set of contexts? Or is it up to the user to set up an interface with the contexts as members?

@mgroth0
Copy link

mgroth0 commented Dec 1, 2024

Now that 2.1.0 is released, can we check in about the migration plan/timeline? KT-67119 hasn't been updated for a while, and it is marked as "fixed" which prevents subscribing to it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests