Kotlin / KEEP

Kotlin Evolution and Enhancement Process
Apache License 2.0
3.29k stars 357 forks source link

Context receivers #259

Open shadrina opened 2 years ago

shadrina commented 2 years ago

The goal of this proposal is to introduce the support of context-dependent declarations in Kotlin, which was initially requested under the name of "multiple receivers" (KT-10468).

See the proposal text here.

mcpiroman commented 2 years ago

@kyay10 Of course it can be done this way, just like with regular parameters. But the defaults are there just so do not have to make such overloads and explicit null handling. I agree that default contexts should be rare, but I don't see why not to reuse the existing syntax for parameters in a case when this comes handy. EDIT: I mean, I see why. As pointed out, this might tempt to overuse this feature, though doing the same thing with overload would be even worse.

altavir commented 2 years ago

@mcpiroman I do not think I like the idea of default context. It is too easy to forget to put a method in an appropriate context. It is not that hard to debug, but very easy to make a mistake.

edrd-f commented 2 years ago

@altavir

I do not see how global e.g. file-level contexts are bad. On the contrary, I see a lot of uses for them. As long as they are explicit, it seems to be OK.

It's bad because it constrains dependencies, meaning one cannot build a different dependency tree for other purposes, such as unit testing.

altavir commented 2 years ago

@edrd-f I do not see any problems here.

@Test
fun testSomethingWithContext() = with(TestContext, OtherTestContext){
  ...
}

It is even more simple than with coroutines. Since contexts are explicit, you just need to explicitly pass them to the scope. Also, it probably will be possible to call a whole bunch of tests in a given context.

edrd-f commented 2 years ago

It's also pretty easy to remove one of such contexts and have the code compile fine while using a production dependency for testing.

altavir commented 2 years ago

Again, I do not see a problem. If you do not need a context to write a function or class, don't declare it. If you need it, you will need to pass it either as a parameter or through a DI framework (implicitly). Context receivers actually could completely replace DI frameworks in this regard. You just need to create a test context that contains all required objects and pass it as a receiver (it could even fit several needs if it implements several required interfaces. No DI implicitness, no codegen, everything is statically resolved. It is not hard also to tweak test framework to automatically pass appropriate receivers to all functions which want one.

elizarov commented 2 years ago

@edrd-f I think the keyword within would make the code more readable.

Our very first prototype was using the with keyword -- the same word that you use to bring receivers to the scope (using with scope function). We quickly ran into the same problem as Scala (before version 3) did with its implicit keyword. It was hard to talk about the feature, even discussing a design was a challenge, as it was difficult to figure out what feature the person was talking about when they mentioned with. So, we felt that anything that sounds similar (like within) would suffer from the same problem.

elizarov commented 2 years ago

@mcpiroman Idea: default values for context

This feature has the potential to bring more harm than benefit. We discussed it during design but forgot to mention it in the design documentation. Fixed now. See the new section on Overloading by the presence of context that explains it and other similar pitfalls in detail.

elizarov commented 2 years ago

@elect86 I'd like to propose another idea, which may play quite well with the current syntax, let's take the json example

typealias JsonBuilder = context(JSONObject, String) // `context` or whatever other syntax

The context(...) does not denote a type, so using typealias is not appropriate here. However, we are thinking about how, in the future, it might become possible to "shorten" compound context declarations and our thinking is going into using decorators for that purpose.

edrd-f commented 2 years ago

@altavir I think we are actually talking about different things. My original comment was about how default contexts would allow creating a global (and possibily stateful) context instance. Declaring top-level functions/vals with contexts is fine, IMO. The last update to the proposal also addresses this:

For the same reason, it is a bad idea to add language support for any kind of default values for contextual receivers, as it will make a similar mistake (of forgetting to pass the context along) undetectable during compilation.

:pray:

edrd-f commented 2 years ago

@elizarov

Our very first prototype was using the with keyword -- the same word that you use to bring receivers to the scope (using with scope function). We quickly ran into the same problem as Scala (before version 3) did with its implicit keyword. It was hard to talk about the feature, even discussing a design was a challenge, as it was difficult to figure out what feature the person was talking about when they mentioned with. So, we felt that anything that sounds similar (like within) would suffer from the same problem.

I see, thanks for explaining. Still, I think the keyword doesn't need to match the feature name. For example, delegation uses the by keyword, but we call it delegation when referring to the feature. We could do the same for context receivers. When I read context(LoggingContext) I feel it's quite redundant. But within(LoggingContext) and within(CoroutineScope), for example, are closer to natural language. Anyway, that's just my preference. Maybe we could vote for the keyword? :grin:

elect86 commented 2 years ago

The context(...) does not denote a type, so using typealias is not appropriate here. However, we are thinking about how, in the future, it might become possible to "shorten" compound context declarations and our thinking is going into using decorators for that purpose.

Right, maybe then it would be nice to introduce another keyword for that

contextalias JsonBuilder = context(JSONObject, String) // `context` or whatever other syntax

I love too much the receiver-style syntax (receiver.foo), it's concise and self-explanatory

altavir commented 2 years ago

@elect86 If we skip syntax problem (I am not sure about contextalias, but it does not look bad), the idea is worth discussing in the context (pun intended) of https://github.com/Kotlin/KEEP/issues/259#issuecomment-863822139. It also allows solving @ilmirus's concerns about stacking receiver types together.

elizarov commented 2 years ago

@zhelenskiy 1. What is expected to happen with the old way to implement DSLs when I add an extension method to some class inside my class?

It does not go away. Just like with members vs extensions. You can write members (in the class), you class write extensions (outside of the class).

That is why it would be great to hear about what is going to happen with current implementations. Would they be deprecated or what?

They are not going to be deprecated. Member extensions will stay. Moreover, you can write contextual members if you need to, too.

  1. There was info about label generation restriction. Can we specify the label name explicitly? If so, we could escape creating typealiases if the label wasn't generated as they are too verbose for single usage.

We'd discussed explicit labels but decided to start without them until we find some real use cases. Typealiases is the only way for now. They are a bit on the verbose side, but that is so by design, as we don't expect them to be needed much. I mean, if you need a label that often, you should probably reconsider using the context receivers and pass them as regular parameters into your functions instead.

  1. I am very interested in the code colouring concept. So I would be glad to see it developed or at least discussed.

Context requirement support for functions is the first small step into user-defined code coloring.

elizarov commented 2 years ago

@altavir As for the intersection of behaviors, it was discussed a lot inside the initial KEEP-176 proposal. .... Such usage could produce some level of ambiguity in the resolution of receivers, but it was also discussed a lot in KEEP-176 and so far I see no problem because the receiver type is bound to the first appropriate receiver type it finds up the context tree. The resolution is context-aware and contexts are explicit in the function signature, so it seems fine.

I'm not sure I follow this comment. The rules in the KEEP are also explicitly stated in a way that makes the intersection of behaviors and overrides possible. The resolution of contexts happens greedily starting from the innermost receiver in the scope. Let me use your example to show how it is supposed to work per the design proposal in this KEEP:

context(Application, CoroutineScope)
fun doInContext() // a function with two context requirements

Now if you have an application instance that is of ApplicationImpl class (that happens to implement both Application and CoroutineScope) then you can write:

with(application) { // this: ApplicationImpl: Application, CoroutineScope
     // resolves both Application and CoroutineScope context requirements to 'application' instance
     doInContext()
}

But you can also write:

with(application) { // this: ApplicationImpl: Application, CoroutineScope
    withContext(Dispatchers.IO) { // this: CoroutineScope
        // resolves Application requirement to 'appication' instance.
        // resolves CoroutineScope requirement to 'withContext`-provided receiver (the closest one!)
        doInContext()
    }
}
zhelenskiy commented 2 years ago

@elizarov But the old style has several limitations (I cannot add operator to string that would be visible only in some other not my context, I cannot add the second receiver, etc.), so there would be a way to do almost the same with the limitations and without them using different syntax. Is that good?

The question about code colouring was actually about its priority: is it going to be discussed in some close future after this KEEP or is it general idea to think about that may be implemented somewhen.

fluidsonic commented 2 years ago

Another question: Why is with re-used for this KEEP?

with changes this and thus the implicit dispatch receiver. Now in addition it's supposed to change the context receiver.

One purpose of having a context receiver is to not alter this / the implicit dispatch receiver. So something explicit like withContext(scope) that doesn't alter this makes more sense than re-using with(scope).

Could be something like adding:

inline fun <T,R> withContext(context: T, block: context(T) () -> R): R =
    with(context) { block() }
altavir commented 2 years ago

@elizarov Maybe it was bad wording on my part. The current proposal solves the intersection problem perfectly, my initial comment only about explicitly showing this possibility in the documentation. There is a little old which is this problem, but I do not think that additional receivers add something new to add.

fvasco commented 2 years ago

KEEP-87 is really similar to this. In such case the "context" is a regular parameter: fun <A> fetchById(id: Int, with repository: Repository<A>): A?.

This syntax avoids the issues: multiple contexts with the same generated label and use generic before its declaration.

Moreover, it is pretty simple to define the context explicitly, without any extra indentation level.

fetchById<User>(11829) // compiles since we got evidence of a `Repository<User>` in scope.
fetchById<User>(11829, UserRepository()) // you can provide it manually.
fetchById<User>(11829, repository=UserRepository()) // you can provide it manually with named application.

Plus, the compiler is able to detect unuseful "context" in simple cases.

with(SimpleLogger()) { fetchById<User>(11829) } // fetchById does not require Logging, ignored
fetchById<User>(11829, SimpleLogger()) // compile-time error

Finally, the "context" figures as a regular implicit parameter, and its position is obvious in Java/JavaScript interoperability.

I think you should integrate the work on KEEP-87 in Alternative syntax options.

Last note: I don't think that a name clash with Scala should be the no-go for the Kotlin syntax.

BenWoodworth commented 2 years ago

KEEP-87 is really similar to this.

One thing to note is that this approach (and any other approach that involves parameters) doesn't translate well to properties, only functions/constructors/etc. Having context be a modifier is something I like about this KEEP's proposed syntax, since it opens the door for applying it to other constructs later on.

fvasco commented 2 years ago

@BenWoodworth do you see an issue with this line?

val Float.dp get(with view: View) = this * view.resources.displayMetrics.density
BenWoodworth commented 2 years ago

@fvasco

do you see an issue with this line?

val Float.dp get(with view: View) = this * view.resources.displayMetrics.density

Heh, interesting! I wonder if extra getter/setter parameters like that is something the Kotlin folks would want to consider.

quickstep24 commented 2 years ago

About the naming: Other Kotlin function modifiers suspend and inline are verbs. receive(LoggingContext) is a verb form that is easy to read.

fvasco commented 2 years ago

Yeah, @BenWoodworth, it is syntactically possible but I not really convinced that a context-aware property is good to have. I feel that a property should be context-independent.

Many more concerns are on contextual constructors, I fear the supposed syntax not so clear at first glance. Instead, a syntax like:

class Service(with private val serviceContext: ServiceContext)

looks more familiar for a Kotlin developer like me.

kyay10 commented 2 years ago

About Future Decorators:

A decorator, such as @transaction, even if it adds some declarations to the scope of the function's body, should not be empowered to change the meaning of this inside of it. The concept of context receiver is such a mechanism.

Is the intention here that some decorators will have the ability to change the meaning of this inside of a decorated function? In other words, were decorators not chosen as the mechanism for contextual receivers (with an added caveat of forcing decorators to not be able to change the meaning of this) *because* there are some valid use-cases where a decorator can and should change the meaning ofthis`?

kyay10 commented 2 years ago

And in a related question, currently contexts are on the same hierarchical level when it comes to call resolution (since there's no discernible order between them), but could that possibly be expanded to allow some form of order? To put this in a simpler manner, is there a possible future where you can use multiple context modifiers before a function so that the latter modifiers have a higher call resolution priority than the former modifiers? As in this case for example:

// Current context proposal
context(LoggingScope, TransactionScope, ApplicationScope)
fun weirdToString() = toString() // Error
// With multiple context modifiers

context(LoggingScope, ApplicationScope)
context(TransactionScope)
fun weirdToString() = toString() // Resolves to the TransactionScope's toString()

Is such a feature going to be supported? and if not, then in a decorator world, what if a user has 2 decorators that both declare their own context, would the latter-applied decorator's contexts have a higher resolution priority than the former-applied decorator's contexts? For the sake of argument, imagine 2 decorators @transactional (which declares that it itself takes a context(TransactionScope) and that the function that it decorates takes a context(TransactionScope)) defined like this:

// Declaring transactional decorator
context(TransactionScope)
decorator fun transactional(block: context(TransactionScope) () -> Unit) {
    markTransactionOpen()
    block()
    markTransactionClosed()
}

and a @classBasedLogger decorator that brings a logger into scope and requires a ClassName as context like this:

// Declaring transactional decorator
context(ClassName)
decorator fun classBasedLogger(block: context(Logger) () -> Unit) {
    with(Logger(prefix = this@ClassName.simpleName)) {
        block()
    }
}

and for the final piece, both Logger and TransactionScope have a function named synchronize that, in the case of a logger, ensures that pending messages are printed out, and in the case of a transaction scope, ensures that new data is pulled from a database. Now imagine a user function, whose main purpose is to perform a transaction, like this:

@classBasedLogger
@transactional
fun updateUserInformation() {
    log("begging user update")
    synchronize() // so that the user information we're about to push is as up-to-date as possible
    log("user update successful") 
}

The body of that function simply has both a Logger and a TransactionScope context in scope. Now, clearly the user's intention here is that performing a transaction is the #1 priority of this function, while logging is a secondary yet needed effect that the function performs. From that user's prespective, the call to synchronize of course should resolve to the transaction scope, and that intention is made clear by the order that the decorators are placed in. So the question now is, would synchronize actually resolve to TransactionScope.synchronize, therefore making it so that context parameters can have a call-resolution order depending on the order of decorators, OR will the type system instead compound all the context receivers instead so that they are in the same call-resolution hierarchical level? I'm hoping that the Kotlin team currently has an answer to this, and if not then hopefully this design nuance gets taken into consideration.

Side note: even with the current context proposal, having different resolution orders for contexts is possible with some boilerplate. Simply, one can define the weirdToString function in my example above like this:

context(LoggingScope, ApplicationScope, TransactionScope)
fun weirdToString() = withContext(this@TransactionScope){
    toString() // Resolves to the TransactionScope's toString()
} 

(withContext is borrowed from this comment) However, the question about different levels of contexts has to be addressed because of decorators, and personally I think embracing them in the type-system will be better than making them a quirk of decorators. Also keep in mind that making contexts defined by decorators flatten into a single level seems inconsistent since decorators are analogous to functions that wrap a lambda, and functions that wrap a lambda would not exhibit such flattening behaviour, and so decorators doing that flattening seems counter-intuitive.

elizarov commented 2 years ago

@kyay10 Is the intention here that some decorators will have the ability to change the meaning of this inside of a decorated function?

No. We currently think that decorators should not be able to change the meaning of this and hence will be based on the concept of context receiver that don't touch the meaning of unqualified this expression. That is, we don't currently have decorator use-cases that would call for such a feature as "changing this" and we feel that if decorators are allowed to change this it might negatively affect the readability of the code.

elizarov commented 2 years ago

@kyay10 And in a related question, currently contexts are on the same hierarchical level when it comes to call resolution (since there's no discernible order between them), but could that possibly be expanded to allow some form of order?

We are trying to come up with a design where the order of modifiers (contexts, decorators) does not affect the semantics of the code to large extent, similar to how the order of imports or annotations is (usually) irrelevant. That actually is the key challenge in designing a decoration mechanism for the Kotlin language (and we don't have a design for decorators that we are happy with yet).

In your hypothetical case of conflicting names in different contexts, the solution is to follow the suggested coding style when you design your context classes, as explained in the KEEP in the Contexts and coding style section.

fatjoem commented 2 years ago

A big thank you for this great proposal. Very well written and thorough.

Except for the proposed syntax it follows my most favorite approach of solving all the related keeps that have been closed in favor of this one.

I especially like that you consider scope properties and contextual classes for a future enhancement.

In my former project we extensively used a "contextual interface pattern". If we had contextual class support natively, we could have reduced boilerplate and remove the necessity to make our context properties public.

Current code using "contextual interface pattern":

class Controller(
  override val i18n: I18n,
  override val urls: Urls
) : I18nContext, UrlContext {
  fun handleRequest(req: Request) {
    val bc = createBreadcrumbs(req)
  }
}

fun <C> C.createBreadcrumbs(req: Request): Breadcrumbs
  where C : I18nContext,
        C : UrlContext

If contextual classes were introduced, it would become this:

context(I18nContext, UrlContext)
class Controller  {
  fun handleRequest(req: Request) {
    val bc = createBreadcrumbs(req)
  }
}

context(I18nContext, UrlContext)
fun createBreadcrumbs(req: Request): Breadcrumbs
CLOVIS-AI commented 2 years ago

I love many things about this proposal, thanks a lot for the hard work.

I'm not sure if it should be a part of this proposal or somewhere else, but I think the language should allow the where clause to be specified before fun as well as where it currently is. Maybe even, we could allow where to replace type parameter declaration only if it is specified before the function.

// Current proposal without modifications
context(Foo)
fun <T> bar(titi: T) where T : … {}

// Version 1: allow 'where' to appear before the function, like 'context'
where T : …
context(Foo)
fun <T> bar(titi: T) {}

// However, that is awkward, as 'where' appears 
// before the type parameter declaration, yet contains
// more information; the declaration could be optional in
// this specific case (where appears before)
where T : …
context(Foo)
fun bar(titi: T) { }

// for even more syntactic similarity, with parenthesis?
where(T : …)
context(Foo)
fun bar(titi: T) {}

I think this is a relatively small change because it's quite specific (where is already rarely used) but it makes the syntax more similar and also solves the "main tradeoff" of context(Monoid<T>), since the type parameter would essentially be declared in the where block (of course, where wouldn't change its behavior or become mandatory, but I'm willing to bet that the cases where you currently want to use where coincide fairly well with the cases where you'd have a complex type parameter in context, so I think it's a nice solution).

As an additional note, Contracts do not have (AFAIK) a stable syntax, and bringing them in front of the function with context and where could tie all of them into a simple cohesive convention.

Overall, it's amazing to see the thought process of the Kotlin team, and how many feature requests are merged into a single cohesive feature!

CLOVIS-AI commented 2 years ago

Regarding https://github.com/Kotlin/KEEP/blob/context-receivers/proposals/context-receivers.md#scope-properties, I think a potentially simpler solution could be to allow with & co to be variadic (either through actual vararg, or compiler magic, or even declaring many different version of it).

This is going to be quite frequent:

with(CoroutineScope) {
  with(TransactionScope) {
    with(…) {
      // your code
    }
  }
}

It could be replaced by:

with(CoroutineScope, TransactionScope, …) {
  // your code
}

I have no idea how difficult that is on the compiler / language, but I believe it is so close to how the language already works that it doesn't even require teaching ("it's just with that brings an argument into scope, but with multiple arguments").

altavir commented 2 years ago

@CLOVIS-AI Multiple argument with is automatically achieved with multi-receiver feature like this:

inline fun <T1, T2, R> with(arg1: T1, arg2: T2, block: context(T1,T2) ()->R): R = with(arg1){ with(arg2){ block()}}

Obviously, something like this will be added to stdlib.

zhelenskiy commented 2 years ago

@CLOVIS-AI Multiple argument with is automatically achieved with multi-receiver feature like this:

inline fun <T1, T2, R> with(arg1: T1, arg2: T2, block: context(T1,T2) ()->R): R = with(arg1){ with(arg2){ block()}}

Obviously, something like this will be added to stdlib.

@altavir What about 3+ arguments? Do you expect it to be code-generated?

altavir commented 2 years ago

@zhelenskiy I do not think that using more than 3 will be ever a good idea, but you can certainly create functions with any arity you want in advance. Up to, say, 5. Each one will take one line after all.

schielek commented 2 years ago

Did anyone consider these two already?

context fun <T> (C1, C2<T>) R.foo(arg: T): Unit

val foo: context (C1, C2) R.(arg: String) -> Unit

The context keyword should make it easy for the compiler to require the parenthesis.

or

fun <T> context(C1, C2<T>) R.foo(arg: T): Unit

Easy syntax as well. Generics are used after declaration. Everything is in order (context first, then receiver, then arguments) and consistent with type declaration:

val foo: context(C1, C2) R.(arg: String) -> Unit
GavinRay97 commented 2 years ago

I don't have anything material to offer, but I just wanted to thank the authors of the proposal and whoever will implement this.

Recently learned/tried Scala 3, and given/using are so powerful and fit naturally in to so many scenarios where you have some notion of a "context" that is consistent.

It lets you do everything from pass around global application state, or config objects, or singletons -- you can even use it for a full-fledged Dependency Injection solution.

The design of this feels very similar and the mentions of Scala's given/using make me think it was at least in part inspired.

Absolutely amazing, thank you!

quickstep24 commented 2 years ago

Did anyone consider these two already?

context fun <T> (C1, C2<T>) R.foo(arg: T): Unit

val foo: context (C1, C2) R.(arg: String) -> Unit

The context keyword should make it easy for the compiler to require the parenthesis.

or

fun <T> context(C1, C2<T>) R.foo(arg: T): Unit

Easy syntax as well. Generics are used after declaration. Everything is in order (context first, then receiver, then arguments) and consistent with type declaration:

val foo: context(C1, C2) R.(arg: String) -> Unit

@schielek There are some issues with your proposal:

trevorhackman commented 2 years ago

Naming a keyword context would be confusing. Context is already an abused word in programming without clear meaning, especially in Android.

schielek commented 2 years ago

@quickstep24 Which proposal are you referring to? There are two.

your fun and val declaration are not consistent (context and receiver are before the fun-name but after the val-name)

The val was meant to be type declaration in an assignment of a context function to a variable, not a context property. I will add a context property.

// 1st proposal
context fun <T> (C1, C2<T>) R.foo(arg: T): Unit // function declaration
val foo: context (C1, C2) R.(arg: String) -> Unit // type declaration
context val (C1, C2) R.foo get() = Unit

// 2nd proposal
fun <T> context(C1, C2<T>) R.foo(arg: T): Unit // context function declaration
val foo: context(C1, C2) R.(arg: String) -> Unit // type declaration
val context(C1, C2) R.foo get() = Unit // context property

the fun-name is considered the most important part of the declaration, but your syntax moves it to the end of the line, where it is hard to find

Which is not different how extension functions work atm

in your syntax the fun-name R.foo gets separated more from the fun keyword (possibly even forcing line breaks), making the declaration hard to read (long type parameter bounds can have the same effect, that is why you can move them to the end of the declaration with where)

Agreed. Definitely a disadvantage compared to the "where"-syntax. The advantage would be that the arguments in the declaration are in the same order when called: Context, receiver, arguments.

quickstep24 commented 2 years ago

@schielek

context fun (C1, C2) R.foo(arg: T): Unit // function declaration val foo: context (C1, C2) R.(arg: String) -> Unit // type declaration context val (C1, C2) R.foo get() = Unit Ok, I misunderstood the second line to be a declaration for val R.foo. Now it makes sense

the fun-name is considered the most important part of the declaration, but your syntax moves it to the end of the line, where it is hard to find Which is not different how extension functions work atm True, but adding more doesn't make it better. I read this is why Kotlin has the return type at the end (contrary to Java/C tradition).

xxfast commented 2 years ago

i think proposal of context(View) val Float.dp get() {} can be little misleading in the following case.

Suppose we have an object called View and a function that happens to be named as context() that takes that View as a parameter

object View

fun context(view: View) { ... }

If we were to write this in top-level; without a line break inbetween makes it a contextual receiver

context(View)  // With no line break 
val Float.dp get() {}

where as just by adding a line break this becomes a call to that function

context(View) 
// With a line break
val Float.dp get() {}

Has this been brought up before?

demiurg906 commented 2 years ago

@xxfast Function calls are not allowed on top-level/among declaration inside class by grammar, so parser will understand that this is declaration of contextual receiver

Even this will work

context(View)

val Float.dp: Int
    get() = ...
xxfast commented 2 years ago

@demiurg906 i see. Forgot that context keyword is only scoped within class declarations

thumannw commented 2 years ago

I think the context keyword is quite verbose. So I throw in yet another alternative. Idea: Context parameters are also just parameters, but are always resolved implicitly. Because of the latter, call-site syntax is not really existent, so it can't be matched with declaration syntax. Also, it comes after generic parameters.

fun Receiver.method(param: Parameter)[Logger, Transaction]: Result

The functional type would be

Receiver.(Parameter)[Logger, Transaction] -> Result
CLOVIS-AI commented 2 years ago
fun Receiver.method(param: Parameter)[Logger, Transaction]: Result

Taking a look at the 'long form' of function declarations:

fun foo(
  a: Int,
  b: Int,
  c: Int,
  d: String,
) {
  …
}

I think the brackets syntax works very well here:

fun foo(
  a: Int,
  b: Int,
  c: Int,
  d: String,
) [
  Logger,
  Transaction,
] {
  …
}

The kotlin team has expressed that they want contextual parameters to feel like annotations because they're not really a part of the function itself, but I think this syntax is really good because it solves most problems of the context syntax:

The only downside I see are that the brackets don't really mirror the call-site, which is a Kotlin convention, but contextual parameters are in some way "implicit parameters" so having them look like parameters is still pretty good in my opinion.

demiurg906 commented 2 years ago

@CLOVIS-AI Syntax you proposed may clash with new syntax for contracts which we are considering to add to a language instead of calling contract function in function body. Most probably it will look like this:

fun myRequire(condition: Boolean, message: () -> String) contract [
    returns() implies condition,
    callsInPlace(message, AT_MOST_ONCE)
] {
    if (!condition) {
        throw IllegalArgumentError(message())
    }
}
fvasco commented 2 years ago

It looks not so problematic.

fun myRequire(condition: Boolean, message: () -> String)
  context [ Logger ]
  contract [ returns() implies condition, callsInPlace(message, AT_MOST_ONCE) ] {
    if (!condition) {
        val text = message()
        logger.debug("Failed requirement: text")
        throw IllegalArgumentError(text)
    }
}
GavinRay97 commented 2 years ago

It looks not so problematic.

fun myRequire(condition: Boolean, message: () -> String)
  context [ Logger ]
  contract [ returns() implies condition, callsInPlace(message, AT_MOST_ONCE) ] {
    if (!condition) {
        val text = message()
        logger.debug("Failed requirement: text")
        throw IllegalArgumentError(text)
    }
}

I wonder if this would preclude the possibility of defining "blocks" of contextual functions though. Something like the below, which is similar to realworld code I have using this feature with Scala's given/using:

// GraalVM "Polyglot" architecture:
//
//         Engine
//        /   |   \
// Context Context Context
//  /    \ 
// Source Source

// GraalVM "Engine" is a shared driver that can hold multiple "Contexts"
context(org.graalvm.polyglot.Engine) {
  fun mkContext() = Context.newBuilder()
        .allowIO(true)
        .engine(this@Engine)
        .build()
  // GraalVM "Context" is what can execute scripts ("Source" objects) in various languages
  context(org.graalvm.polyglot.Context) {
     fun mkSource(lang: Language, scriptContent: String): org.graalvm.polyglot.Source
     fun runSource(source: Source): Value
     fun someOtherThing()
  }
}

fun mkEngine(): Engine =
    Engine.newBuilder()
        .allowExperimentalOptions(true)
        .build()

fun main() {
  // Now we can provide the Engine for a scope (likely the scope is the entire application for an Engine)
  with val engine = mkEngine()
  // And a Context for each scope using the engine
  with val context = mkContext()

  val source = mkSource(Language.JS, "console.log('hello from Javascript')")
  runSource(source)

  // Less flexible, but this is what is currently possible
  // Without the "with val X = Y" syntax implicit scoping
  with (mkEngine()) {
    with (mkContext()) {
      // Engine 1, Context 1
    }
    with (mkContext()) {
      // Engine 1, Context 2
    }
  }
}

@demiurg906

Sorry to go off-topic a bit, but in those contract changes were there any discussions of allowing conditions, to enable Design-By-Contract style programming?

This is something that would be incredible.

Groovy v4 has them as so:

@Invariant({ speed() >= 0 })
class Rocket {
    int speed = 0
    boolean started = true

    @Requires({ isStarted() })
    @Ensures({ old.speed < speed })
    def accelerate(inc) { speed += inc }

    def isStarted() { started }
    def speed() { speed }
}

It would be amazing to be able to do something like this in Kotlin:

requires({ (a, b) -> a > 0 && b > 0 })
ensures({ result -> result > 0 })
fun divide(a: Int, b: Int): Int {}

// Or
fun divide(a: Int, b: Int): Int
  requires { a > 0; b > 0 }
  ensures { it > 0 }
  invariant { /* this must be true before and after */ }
{
  // Code
}

// Or
fun divide(a: Int, b: Int): Int {
  requires {
    a > 0
    b > 0
  } ensures {
    it > 0
  } invariant {
    // this must hold true before and after invocation
  }

 // Method body
}
rnett commented 2 years ago

I like the way the [] context syntax looks, but I don't like having it after the parameter list for the same reasons mentioned in the proposal.

thumannw commented 2 years ago

I do not really understand the concept of declaration and call-site syntax matching in this context (pun intended). There is a difference in the creation of the context and the consumption of arguments from the context. In my opinion, only the latter is the real call-site. However, it is intended to by always implicit, so basically invisible. Consequently, there is no way to match the declaration to it.

On the other hand, also implicit parameters are essential to the function signature. They are used in the function implementation and without them, the function cannot work. My concern is that function types become more unwieldy the more features are added to functions. Hence I would try to make the notation as compact as possible. I think that

Receiver.(Parameter)[Suspendable, Logger] -> Result

is more uniform than

context(Logger) suspend Receiver.(Parameter) -> Result

The order of parameter kinds reflects their prominence: Receiver comes first since it is a very distinguished parameter. Then come standard parameters. Last come contextual parameters because they are implicit (like a footnote).