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.

fvasco commented 2 years ago

In the section VM ABI and Java compatibility, the Kotlin code:

context(C1, C2)
fun R.f(p1: P1, p2: P2)

should be equal the Java code

public static final kotlin.Unit f(C1 c1, C2 c2, R r, P1 p1, P2 p2)

Kotlin does not allows void return type, it should be Unit

altavir commented 2 years ago

First of all, let me thank you for a superb (and long-awaited) proposal.

As for the discussion, my primary concern is still the syntax. The proposal mentions that the context(T) construct could be mixed with a function call in the code and it should be treated properly. But what Is more important, it confuses the reader. When I read the code, I can't see clearly that this declaration is attached to the following declaration. I can understand the reasoning behind not using @ (a lot of people have negative emotions about that), but some kind of visible syntax would be nice. Another concern is about using round brackets for types. In kotlin, we always use round brackets for instances, so context(MyContext) reads as a companion object of MyContext, not as the type itself. In Groovy one can use class name instead of class and it brings a lot of confusion sometimes. So I would recommend at least using triangle brackets <> for receiver type to provide clear separation. Like context<MyContext> fun A.doB().

The second point is about the proposal itself. One of the important uses of multiple receivers is the ability to flexibly define behavior intersections. For example, consider this code:

interface A

interface B

context(A,B) fun doSomething()

It could be called as with(a,b){ doSomething}, but in some cases, we also can have:

interface C: A, B

c.doSomething()

This seems to be an idiomatic example of using multiple receivers and I think, it should be covered in the proposal.

fluidsonic commented 2 years ago

Great and thorough proposal with quite a lot of history 🙌

context Syntax

I thought exactly the same as @altavir. Here it's especially bad: var g: context(Context) Receiver.(Param) -> Unit. Looks like a call within a function body.

context<A, B> is easier to understand and also makes more sense to me.

Could also be context<A & B> instead, having future union types in mind. May cause problems with this@A syntax. And then later on allows typealias C = A & B with context<C>.

Builders

The use case Creating JSONs with JSONObject and custom DSL is basically a builder DSL. However in Contexts and coding style you write "Context receivers shall not be used for such builders". I'd say it depends on the DSL's purpose whether or not a context makes more sense.

edrd-f commented 2 years ago

I think the proposal should go deeper on how context receivers would work with annotations and the suspend modifier. It only says:

Also, suspend and @Composable functions can be retrofitted to work as if they are declared with context(PropertiesScope) modifier and so will pass a set of current context properties via their calls, ensuring interoperability of the corresponding mechanisms.

However, while this retrofitting doesn't happen, how would these functions be declared? There are several options:

1:

@DslMarker context(JsonBuilder) fun String.by(obj: @DslMarker context(JsonBuilder) suspend () -> JsonObject)

2:

context(JsonBuilder) @DslMarker fun String.by(obj: context(JsonBuilder) @DslMarker suspend () -> JsonObject)

3:

context(JsonBuilder) @DslMarker fun String.by(obj: context(JsonBuilder) suspend @DslMarker () -> JsonObject)

4:

context(JsonBuilder) @DslMarker fun String.by(obj: suspend context(JsonBuilder) @DslMarker () -> JsonObject)
fluidsonic commented 2 years ago

@mcpiroman it's already mentioned as a potential future extension.

fluidsonic commented 2 years ago

Another naming-related issue came to mind:

I already found code that accidentally used with instead of withContext. With the new context soft keyword this will probably get more confusing.

But it may also be an issue with kotlinx-coroutines because withContext is a really generic name.

edrd-f commented 2 years ago

@altavir @fluidsonic - about the suggested <> syntax, the problem is that in context<Comparable<T>>, Comparable is a literal type while T is a generic type, so the semantic meaning of <> gets ambiguous.

fluidsonic commented 2 years ago

@edrd-f

@altavir @fluidsonic - about the suggested <> syntax, the problem is that in context<Comparable<T>>, Comparable is a literal type while T is a generic type, so the semantic meaning of <> gets ambiguous.

I'm not sure I understand you correctly. It could also be context<Comparable<Int>>. Then Int is a literal type used as an argument to a generic parameter. The same way context<Foo> has Foo as an argument to a generic parameter that is unnamed and brought into scope in the function (more or less).

We're entering the ambiguous realm anyway. Consider object Foo. context(Foo) in the fun position is quite different from context(Foo) in regular code.

altavir commented 2 years ago

@edrd-f I don't not see the problem, because we use the receiver type here exactly the same way, we would use it in any other generic case like typeOf<> which takes the type parameter. And in any case, it is better than round brackets since there is no distinction from the function call and, as I already said, association with the companion.

I would prefer something even more distinct, But triangular brackets are better than round ones.

fvasco commented 2 years ago

@edrd-f please consider that context<Comparable<T>>() is already valid Kotlin syntax.

BenWoodworth commented 2 years ago

The main tradeoff we had to make with the proposed syntax is that in case when context is generic, then the use of the generic parameter happens before its declaration, e.g (from Use cases section):

context(Monoid<T>) // T is used
fun <T> List<T>.sum(): T = ...
//  ^^^ T is declared

I might've missed something, but why not put the contexts between <T> and the function's receiver/name? Noise/parsing issues?

fun <T> context(Monoid<T>) List<T>.sum(): T = ...
mcpiroman commented 2 years ago

I might've missed something, but why not put the contexts between <T> and the function's receiver/name? Noise/parsing issues?

fun <T> context(Monoid<T>) List<T>.sum(): T = ...

The problem is, generally, the name matters the most (that's why it's before type) but with such syntax it is shifted near the end of the line. When looking at the function's definition, the context it has is more of a implementation detail, yet, you have to read it first before you get to the name or parameters, which are more important. Secondly, this aligns more with where annotations are put and this feature for me is closer to annotations.

b-camphart commented 2 years ago

to @mcpiroman's point, maybe the syntax can put the context in the next line, similar to the existing where syntax:

fun <T> List<T>.sum(): T     
context(Monoid<T>) { 

}

It would follow an existing kotlin design pattern and reduce the noise in the main function definition line. Basically, the fact that the function requires additional context is a detail and thus should be below the main part of the definition.

I think this would also help with discoverability on the calling side. Instead of the example sum function simply not existing without all the extra context and thus having to go hunt for the definition, instead it would be treated like an extension function with missing parameters and simply show you a red underline (and tell you what context parameters are missing).

rnett commented 2 years ago

Big +1 for the Scope Properties and Contextual Classes proposals. Scope properties would make my compiler plugins quite a bit nicer (put IrPluginContext in context and a few other things), and contextual classes is exactly what I need for a deep learning library (so that layers can only be defined inside a graph).

rnett commented 2 years ago

to @mcpiroman's point, maybe the syntax can put the context in the next line, similar to the existing where syntax:

fun <T> List<T>.sum(): T     
context(Monoid<T>) { 

}

It would follow an existing kotlin design pattern and reduce the noise in the main function definition line. Basically, the fact that the function requires additional context is a detail and thus should be below the main part of the definition.

This was considered and rejected with

This placement would be consistent with Kotiln's where clause, but it is not consistent with receivers being specified before the function name. Moreover, Kotlin has a syntactic tradition of matching declaration syntax and call-site syntax and a context on a call-site is established before the function is invoked.

which I very much agree with, I don't like having parameters after the declaration.

YarnSphere commented 2 years ago

Great proposal, thank you!

Syntax wise, in the spirit of solving the "usage of generic before its definition" I'd like to propose something perhaps slightly more verbose but that I think reads quite well and I didn't see suggested:

context fun <T> with Monoid<T> List<T>.sum(): T = ...

or (smaller and because in is already an existing keyword):

context fun <T> in Monoid<T> List<T>.sum(): T = ...

With multiple receivers and suspend:

context suspend fun <T> with/in Monoid<T>, Scope<T> List<T>.sum(): T = ...

In my opinion, it turns context into a function modifier that reads very similarly to suspend while still allowing the definition of generics before their usage. However, it introduces an additional keyword in the function definition, making it slightly more verbose (although the removal of the parenthesis might make the syntax less "noisy").

In type form, something like this could be used:

val sum: context suspend with/in Monoid<T>, Scope<T> List<T>.() -> T

However, in type form, since (if I'm not mistaken) the "usage of generic before its definition" is not a problem, I'd be in favour of keeping the originally proposed syntax (or the, imo better variant, with triangle brackets).

The problem is, generally, the name matters the most (that's why it's before type) but with such syntax it is shifted near the end of the line.

With this in mind, the contexts could also be moved to the end:

context fun <T> List<T>.sum(): T with Monoid<T> = ...

or, because this could read as "returning a T together with a Monoid<T>", a different keyword could be used:

context fun <T> List<T>.sum(): T given Monoid<T> = ...

which would read as "returning a T given a Monoid<T>. Although, according to the proposal, given means something different in Scala which might be a problem and given could also be confused to mean an argument (return T given an argument of type Monoid<T>).

With multiple receivers and suspend:

context suspend fun <T> List<T>.sum(): T with/given Monoid<T>, Scope<T> = ...

And in type form (although, once again, the originally proposed syntax could still be used):

val sum: context suspend List<T>.() -> T with/given Monoid<T>, Scope<T> 

All in all, I enjoy the idea of having context as a modifier akin to suspend. However, maybe the "usage of generic before its definition" is not a problem worth solving.

Regardless, I just thought I'd provide my 2 cents on the syntax (as everyone enjoys doing whenever new proposals appear :stuck_out_tongue:) and I'll be happy to use whatever comes out in the end. :)

TheBestPessimist commented 2 years ago

Hello,

I'm looking at the example for AutoCloseScope

interface AutoCloseScope {
    fun defer(closeBlock: () -> Unit)
}

context(AutoCloseScope)
fun File.open(): InputStream

fun withAutoClose(block: context(AutoCloseScope) () -> Unit) {
    val scope = AutoCloseScopeImpl() // Not shown here
    try {
        with(scope) { block() }
    } finally {
        scope.close()
    }   
}

// usage
withAutoClose {
    val input = File("input.txt").open()
    val config = File("config.txt").open()
    // Work
    // All files are closed at the end
}

However I still cannot understand how this would work IRL.

  1. What is scope.close()? I do not see that declared anywhere.
  2. What is the purpose of AutoCloseScope.defer? That is not used anywhere.

The way I imagine using this example is

interface AutoCloseScope {
    fun defer(closeBlock: () -> Unit)
    fun close() // This does not exist in the example
}

context(AutoCloseScope)
fun File.open(): InputStream {
    defer { this@File.close() } // this defer must be added for every different type that we want to autoclose, correct?
    return this@File.openAsInputStream() // Didn't work with files a lot. Please accept this pseudocode
}

fun withAutoClose(block: context(AutoCloseScope) () -> Unit) {
    val scope = AutoCloseScopeImpl() // Shown below
    try {
        with(scope) { block() }
    } finally {
        scope.close()
    }   
}

class AutoCloseScopeImpl : AutoCloseScope {
    private val closeables = mutableListOf<() -> Unit>()

    override fun defer(closeBlock: () -> Unit) {
        closeables += closeBlock
    }

    override fun close() = closeables.asReversed().forEach { it.invoke() }
}

// usage
withAutoClose {
    val input = File("input.txt").open()
    val config = File("config.txt").open()
    // Work
    // All files are closed at the end
}

Is my understanding correct? Do you think of a different alternative?

elizarov commented 2 years ago

@fvasco In the section VM ABI and Java compatibility, the Kotlin code .... ... Kotlin does not allows void return type, it should be Unit

The example in the text is correct. Unit-returning Kotlin functions are compiled to void functions on JVM. Unit is carried over in functional types, though.

elizarov commented 2 years ago

@altavir First of all, let me thank you for a superb (and long-awaited) proposal. As for the discussion, my primary concern is still the syntax.

Thanks a lot for bringing up the context(Ctx) vs context<Ctx> discussion. In fact, it was one of our biggest design discussions earlier in the design process, but we've failed to include it into the resulting text. I've added the corresponding section to the document. Please find the detailed answer in the Parentheses vs angle brackets section.

elizarov commented 2 years ago

@altavir ... It could be called as with(a,b){ doSomething}, but in some cases, we also can have:

interface C: A, B

c.doSomething()

This seems to be an idiomatic example of using multiple receivers and I think, it should be covered in the proposal.

That's a very interesting extension to the call resolution algorithm that we did not even consider during our design discussions. I'd love to see more specific examples in the actual code-base. So far, we've been trying to narrowly constrain the set of interfaces that are "appropriate to use as context" and it seemed to us to be pretty distinct from the set of interfaces that are "appropriate to use as an object (qualifier) of the call". However, it does not mean that the intersection is zero, and it might turn out to be useful to start the greedy resolution of the context parameters with the qualifier of the call (c in your example).

elizarov commented 2 years ago

@fluidsonic context Syntax

I've answered on parentheses vs angle brackets above to @altavir. See the new Parentheses vs angle brackets section for answers.

Builders The use case Creating JSONs with JSONObject and custom DSL is basically a builder DSL. However in Contexts and coding style you write "Context receivers shall not be used for such builders". I'd say it depends on the DSL's purpose whether or not a context makes more sense.

Thanks for noting that. In fact, @ilya-g had noticed it, too, in the pre-publication review, but we failed to update the text to correct it. I've now added a clarification to the Kotlin builders section.

altavir commented 2 years ago

@elizarov thanks for the clarification (about brackets), The point about adding named arguments does not seem to be valid to me. You are introducing a new syntax anyway, something like context<name: Type> is as possible as context(name: Type), both will introduce new syntax, but the learning curve will be smoother for the first one. If we are talking about not blocking future possibilities while experimenting, won't it be better to use initially proposed annotation-like syntax @with<A, B> which does not require new entities? We can decide on replacement syntax later, when we better understand all the use cases.

As for the intersection of behaviors, it was discussed a lot inside the initial KEEP-176 proposal. The case in mathematics is a simple one. Consider that you want to do some higher-level operations on matrices. You want addition, subtraction defined in MatrixAlgebra, but you also want inversion operation which could be done in different ways on the same algebra in DecompositionOps. Basically, you pass to different contexts - one for algebra and one for decomposition, but in some cases, matrix algebra has one dedicated way of inverting, or even better algebra inherits DecompositionOps for the default inversion method. Then I want to pass a single context that will suit both type requirements.

A similar situation arises in other cases. For example, consider that we have a context-bound operation, that requires coroutine scope. In general, you require two receivers - a context and a coroutine scope. But the context could be a coroutine scope itself if it is an application scope or whatever. A recommendation to use an intersecting interface seems meaningless in this case since it could be the same object and could be not. Or even better, it is possible that you want to use application scope by default like:

with(application){
  doInContext()
}

but in some cases you want to substitute the coroutinscope:

with(application){
  withContext(Dispatchers.IO){
    doInContext()
  }
}

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.

elizarov commented 2 years ago

@BenWoodworth I might've missed something, but why not put the contexts between <T> and the function's receiver/name? Noise/parsing issues?

fun <T> context(Monoid<T>) List<T>.sum(): T = ...

It is about visual noise, not about parsing. We strongly feel that contexts belong to the realm of "additional annotations" for a function. They are not explicitly passed on the call site and should not obscure the reading and understanding of the regular function's signature that lists all its explicit parameters that should be mentioned on the call site. That is why we recommend formatting the context(Ctx) modifier as a separate line and we could not find a way to make it look nice if it is written after the fun. However, with the "before the fun" modifier it all plays out nicely:

context(Monoid<T>) // additional modifier, like @Transactional or @Logged
fun <T> List<T>.sum(): T = ...
//  ^^^^^^^^^^^^^^^^^ the actual call signature a reader should be primarily concerned with
elizarov commented 2 years ago

@nunocastromartins Syntax wise, in the spirit of solving the "usage of generic before its definition" I'd like to propose something perhaps slightly more verbose but that I think reads quite well and I didn't see suggested:

context fun <T> with Monoid<T> List<T>.sum(): T = ...

See the answer above. We've considered something along these lines (albeit failed to mention it in the text) and rejected it because it does not lend itself to the nice multi-line formatting.

elizarov commented 2 years ago

@TheBestPessimist I'm looking at the example for AutoCloseScope

  1. What is scope.close()? I do not see that declared anywhere.
  2. What is the purpose of AutoCloseScope.defer? That is not used anywhere.

It is all declared and used inside AutoCloseScopeImpl which is not shown for the conciseness of the example.

The way I imagine using this example is... Is my understanding correct? Do you think of a different alternative?

Yes. That is the way we envision it, too.

mcpiroman commented 2 years ago

If context was annotation-like (@context), couldn't it be declared for, say, all functions in a file at once? 🤔

fvasco commented 2 years ago

@nunocastromartins I consider more readable the parameters declaration, like the context's one, before the return type.

However, we need/prefer some kind of brackets? Should a comma separated list enough?

context TimeSource, TransactionContext, LoggingContext
fun doSomeTopLevelOperation() { ... }
altavir commented 2 years ago

@mcpiroman I think we need this functionality with any syntax, it is quite useful and I think that there are hints to it in the proposal. The problem with annotation-like markers is that they are not annotations and therefore could confuse people. Still, I am for annotation likeness since it helps a lot with my primary concern of readability and does not introduce new syntax in the language. It also is in line with what Compose does (the @Composable annotation is also a context definition). The only rule we need to introduce is not all that starts with @ is annotation and it is fine by me.

altavir commented 2 years ago

@fvasco

context TimeSource, TransactionContext, LoggingContext
fun doSomeTopLevelOperation() { ... }

And what about lambdas? You need always remember lambdas. And they could have additional modifiers like inline or suspend.

fvasco commented 2 years ago

@altavir sorry, I miss the point. Can you explain better?

altavir commented 2 years ago

@fvasco

fun doSomething(block: suspend inline context TimeSource, TransactionContext, LoggingContext (String)->Unit)
fvasco commented 2 years ago
fun doSomething(block: context TimeSource, TransactionContext, LoggingContext suspend inline (String)->Unit)

I think the point is on readability.

quickstep24 commented 2 years ago

Assuming Kotlin gets decorators, will it be a possibility to combine decorators and context? Like

context(Connection)
decorator fun transactional(block: Transaction.() -> Unit) {
   ...
}
quickstep24 commented 2 years ago

Can you elaborate on the behavior of a nullable context receiver? Does context(Logger?) mean that the context can be unmatched (and will then map to null), or does it require Logger? to be a valid (nullable) context? The first would be more valueable, the second would be more consistent.

elizarov commented 2 years ago

@quickstep24 Can you elaborate on the behavior of a nullable context receiver? Does context(Logger?) mean that the context can be unmatched (and will then map to null), or does it require Logger? to be a valid (nullable) context? The first would be more valueable, the second would be more consistent.

Just as with other nullable receivers. It will require Logger? to be available in the context and you'll be able to call Logger? extensions from the scope of the function (methods on the Logger will not resolve).

zhelenskiy commented 2 years ago
  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?
    class C {
        operator fun String.unaryPlus() = ...
    }

    This way has lots of limitations such as I cannot make this method an extension one because it already has a receiver. Also, such a way (creating a class or interface and adding extension properties and methods inside it) to declare DSLs looks quite contr-intuitive for newcomers, unlike the suggested new one. That is why it would be great to hear about what is going to happen with current implementations. Would they be deprecated or what?

  2. 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.
  3. I am very interested in the code colouring concept. So I would be glad to see it developed or at least discussed.
edrd-f commented 2 years ago

I think the keyword within would make code more readable. Some examples taken from the proposal, comparing context vs. within:

context-coroutines

within-ann


image

image


image

image


It would also be a better match for with. For example:

image

The declaration-site would match the use-site really closely.

raniejade commented 2 years ago

The declaration-site would match the use-site really closely.

@edrd-f within makes sense when using with but there are other ways to bring loggingContext (for example) into scope.

erikhuizinga commented 2 years ago

I understand the signature readability requirements (https://github.com/Kotlin/KEEP/issues/259#issuecomment-863826364) and the need for multiline formatting (https://github.com/Kotlin/KEEP/issues/259#issuecomment-863829057).

Still, I think syntactically it looks weird to require parentheses. Parentheses group code that otherwise would interact differently with the surroundings, like in an expression ((x + y) * z) != (x + y * z) or a function call f(a, b) != f a, b // What is this even?.

The arguments for potential future use cases aren't good arguments for parentheses:

  • [...] named context receivers [...]

Can be done with angle brackets too: context<name: Ctx> or with optional parentheses for readability context<(name: Ctx)>.

  • [...] "type parameter use before declaration" [...]

Can be done with angle brackets too: context<T, Monoid<T>>, although I'm not sure if that's unambiguous. It should be if T doesn't resolve to a type, then it must be a type parameter.

  • [...] modifiers on context receivers [...]

Can be done with angle brackets too: context<inline Ctx>, e.g. like Kotlin already has <reified T>.


Furthermore, I would prefer no brackets or parentheses at all, so that the context modifier and its parameters become much more like other modifiers, e.g. internal suspend inline. And it becomes less cumbersome to read and especially write. For example:

internal context A B C D suspend fun E.f(g: G): H = TODO()

Yes, that's complex and hard to read, but it is what it is. Line breaks should be allowed too:

internal context A B C D
suspend fun E.f(g: G): H = TODO()

Or with longer names:

internal context
    AVeryLongName
    ButThisOneAlsoIsLong
    CanTheyBecomeEvenLonger
    DontThinkSo
suspend fun ExtensionReceiver.functionName(
    greatArgument: GreatArgument
): HelloIAmTheReturnType = TODO()

(or the same with angle brackets) (or with optional parentheses, just for grouping/readability) (or with optional commas between the context parameters, if somehow required for disambiguation)

It's not very different from the multiline issues when extending/implementing many super types: that also needs formatting guidelines and it doesn't need parentheses. It does use commas, though.

And it's also similar to the problems when using many type parameters, e.g. class X<T1, T2, T3, T4, T5, T6, T7>: what if the generic types have long names? How to format the code?

I'd like to hear your opinion and professional insights on this!

LDVSOFT commented 2 years ago

Given that putting values that are used as primary receiver/arguments of function call in context is considered a bad style (and I agree with that point), have you considered restricting types that can be used as contexts? Like right now Result<T> is somehow restricted to be returned from functions? That would limit some usage (like context(View) on Android I'd guess).

mcpiroman commented 2 years ago

@LDVSOFT

@ContextReceiver
interface SomeContext { }

context(SomeContext)
fun foo() { }

? Interesting, though I suspect I'd find that rather frustrating and limiting than helpful.

LDVSOFT commented 2 years ago

@mcpiroman Maybe something like that, I don't know. I liked the part there extension interfaces are special things, but I guess between having two interface-like entities that are different and just allowing things to happen second would be more kotlinish, but I'd like to hear Kotlin Team opinion on that.

edrd-f commented 2 years ago

@edrd-f within makes sense when using with but there are other ways to bring loggingContext (for example) into scope.

@raniejade yes, but it's always into scope, as you said. So within still makes sense.

edrd-f commented 2 years ago

About "Scope properties" and "Contextual classes and contextual constructors", I want to show an example of how we can bring scope into a class using existing mechanisms (this code compiles):

interface HelloService {
    fun execute(name: String)
}

interface Logger {
    fun log(message: String)
}

class Context(val logger: Logger)

fun Context.HelloService() = object : HelloService {
    override fun execute(name: String) {
        logger.log("Hello, $name!")
    }
}

val defaultLogger = object : Logger {
    override fun log(message: String) = println(message)
}

fun main() = with(Context(logger = defaultLogger)) {
    HelloService().execute(name = "Kotlin")
}

The difference here is that it's necessary to have a "god context" and this doesn't scale well in large applications. With multiple receivers, it will be possible to declare specific contexts:

within(LoggingContext, TransactionScope)
fun HelloService() = object : HelloService {
    override fun execute(name: String) {
        doSomethingWithTransaction(name)
        log(name)
    }
    // ... other members ...
}

fun main() = with(LoggingContext(defaultLogger), TransactionScope()) {
    HelloService().execute(name = "Kotlin")
}

What is nice about it is that it's not necessary to learn about new scoping mechanisms. Also, this "constructor function" is a pattern already used in some popular projects like Ktor.

The downside is having to create interfaces for every object and declaring function signatures twice.

mcpiroman commented 2 years ago

Idea: default values for context

interface TransactionContext { ... }
val defaultTransactionCtx = object : TransactionContext { ... }

context(TransactionContext = defaultTransactionCtx)
fun foo() { ... }

fun main() {
  foo()

  val specializedCtx = object : TransactionContext { ... }
  with(specializedCtx) {
     foo()
  }
}

Analogous to parameters, which the contexts really are.

This, if accepted, makes one more point behind using parentheses - to resemble syntax for parameters. While type parameters in future might have defaults too, these will be types (class Foo<T = String>) whereas here they are values, so having context<Foo = bar> would be not-so-nice.

elect86 commented 2 years ago

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

fun json(build: JSONObject.() -> Unit) = JSONObject().apply { build() }

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

infix fun JsonBuilder.by(build: JSONObject.() -> Unit) = put(this, JSONObject().build())

infix fun JsonBuilder.by(value: Any) = put(this, value)

fun main() {
    val json = json {
        "name" by "Kotlin"
        "age" by 10
        "creator" by {
            "name" by "JetBrains"
            "age" by "21"
        }
    }
}

This expands the usage of typealias, which actually makes perfectly sense also in this scenario, imho

edrd-f commented 2 years ago

@mcpiroman

Idea: default values for context

This opens the path for global contexts, increasing the potential for abuse of this feature.

kyay10 commented 2 years ago

For Named context receivers, I believe there's a simple (albeit slightly verbose) solution. I believe the following should (hopefully) work:

// In stdlib 
context(T)
inline fun <T> fromContext(): T = this@T // Could absolutely be named better, but this naming is good enough for this example

// In user code
context(TimeSource, TransactionContext, LoggingContext) // I know it is considered a BAD and unidiomatic example but it's just here for the sake of showcasing how this can work
fun doSomeTopLevelOperation() { 
    val myTimeSource = fromContext<TimeSource>()
    val logger: LoggingContext = fromContext() // Type inference should be able to easily figure this one out
    doSomethingMagicalWithTransaction(fromContext()).map { logger.log(it) } // Type inference should realise that the type parameter needed here is TransactionContext and so the fromContext call should work properly.
}

fun doSomethingMagicalWithTransaction(transaction: TransactionContext): Result<MyDesiredType> = Result.success(MyDesiredType("yay!"))

With some tweaking and workarounds, the above example can be rewritten to work today which functions pretty well, ableit with some slight issues due to the contexts being in different resolution hierarchy levels

altavir commented 2 years ago

@edrd-f

This opens the path for global contexts, increasing the potential for abuse of this feature.

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.

kyay10 commented 2 years ago

Idea: default values for context

can't this be done by simply declaring an overload that doesn't take the contextual parameter and then passes the default value to the overload that needs the contextual parameter? I feel as though maybe default contexts could be quite rare in practice, and so having a special syntax for them is kind of unusual. Alternatively, we could also have the context be context(TransactionContext?), have a with null in each caller, and then handle the null with something along the lines of with this@TransactionContext ?: defaultTransactionContext