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

Discuss: Use coroutines instead of exceptions for binding #93

Open
dronda-t opened this issue Nov 15, 2023 · 2 comments
Open

Discuss: Use coroutines instead of exceptions for binding #93

dronda-t opened this issue Nov 15, 2023 · 2 comments

Comments

@dronda-t
Copy link

Hi!

I would've posted this in discussion if it was available, but I was curious if it had ever been considered to use coroutines instead of exceptions for the binding blocks.

Using exceptions has always seemed slightly error prone to me since all it takes is for someone to catch a generic exception and you now potentially have unexpected behavior. In addition stacktraces can be slightly expensive to produce.

On the flip side, coroutines have their drawbacks as well. For example, the stack traces can sometimes be confusing and there are limitations for where you can actually use bind.

For example I made a proof of concept and tried it out in a repo that made decent usage of binding. The issues are that any function that is not inline or suspend will produce a compilation error because you need to be within the custom coroutine context.

A map function is okay because it's inline, but a sequence function is not

listOf(1, 2, 3).asSequence().map { 
    provideX().bind()
}
Suspension functions can be called only within coroutine body

The other issue I discovered is when the binding context is put into the receiver of another function it fails as well (this is only an issue when restricting the scope of what suspend functions can be called from your scope).

run { 
    provideX().bind()
}

doesn't work but

kotlin.run { 
    provideX().bind()
}

In conclusion, I'm not convinced yet whether one solution is better than the other. I think the coroutine based approach has some benefits over the exception based approach, but it's slightly more restrictive in how you use it. I'm curious to know what you and others think! Thanks for your time.

Here's a link to my POC if you want to try it out. I made both a pure kotlin.coroutines version and a kotlinx.coroutines version.

dronda-t/kotlin-result@master...dronda-t:kotlin-result:dronda/kotlin-coroutines

@dronda-t dronda-t changed the title Feat: Use coroutines instead of exceptions for binding Discuss: Use coroutines instead of exceptions for binding Nov 15, 2023
@michaelbull
Copy link
Owner

Thanks for opening this discussion. This is very interesting and to be honest I agree with a lot of your points, e.g. avoiding the use of exceptions.

With regards to the Suspension functions can be called only within coroutine body, could you paste an example of code you are talking about that fails to work with our current solution but would be solved with your proposal? I'm struggling to wrap my head around the description provided.

On the topic of the exception stacktrace, the CancellationException is an internal implementation detail of kotlin coroutines that indicates a coroutine was successfully cancelled in a safe manner, it's used a lot by the library itself so I don't think it being expensive would be problematic if they are using it so frequently internally.

Looking at the implementations in your PR is also confusing me, the distinction between the coroutines vs kotlinx.coroutines is not clear to me. The main difference seems to be that one uses withContext, and nothing else. It is also unclear why one of the scopes needs annotating with @RestrictsSuspension and one doesn't - would this change what code consumers can write within the scope?

Looking at the two beneath they both seem to have the same constraints on their bind function so I also don't understand why one is named "Suspendable" and one isn't. What does the "Suspendable" refer to here if the bind is marked as suspend fun in both instances?

public sealed interface ResultSuspendableBindingScope<E> {
    public suspend fun <V> Result<V, E>.bind(): V
}

@RestrictsSuspension
public sealed interface ResultBindingScope<E> {
    public suspend fun <V> Result<V, E>.bind(): V
}

Another small thing is that I'm pretty sure the code beneath would be incorrect - we would have to catch the potential exception and wrap it in an Err. Maybe I'm wrong here though.

    override fun resumeWith(result: kotlin.Result<V>) {
        finalResult = Ok(result.getOrThrow())
    }

Overall the 'pure coroutines' implementation looks cleanest to me, but I don't know what the tradeoff is. I'd be very keen to understand which one you think is better and why, which one improves the developer experience for consumers, and what the tradeoffs between all three of the solutions (existing and the two proposed ones) are.

@dronda-t
Copy link
Author

With regards to the Suspension functions can be called only within coroutine body, could you paste an example of code you are talking about that fails to work with our current solution but would be solved with your proposal? I'm struggling to wrap my head around the description provided.

Apologies for the confusion, this is a limitation of using coroutines. The current exception-based solution handles this case, but if using coroutines it would not allow you to bind unless you are within a suspend function.

On the topic of the exception stacktrace, the CancellationException is an internal implementation detail of kotlin coroutines that indicates a coroutine was successfully cancelled in a safe manner, it's used a lot by the library itself so I don't think it being expensive would be problematic if they are using it so frequently internally.

Here I was not referring to CancellationExceptions. I was referring to the fact that if you throw an exception within a coroutine and view the stacktrace, you get extra lines which indicate where the continuation code happened. Not a big deal, but could be slightly more confusion for people trying to analyze the stack trace.

Looking at the implementations in your PR is also confusing me, the distinction between the coroutines vs kotlinx.coroutines is not clear to me. The main difference seems to be that one uses withContext, and nothing else. It is also unclear why one of the scopes needs annotating with @RestrictsSuspension and one doesn't - would this change what code consumers can write within the scope?

The distinction is similar to that of a sequence builder and a flow builder. sequence { } flow { }. In a sequence you cannot call suspend functions that weren't defined to be used by the sequencebuilder.

For example the following is does not compile:

suspend fun test() = withContext(Dispatchers.IO) {
    println("Hello")
}

sequence {
     test()
     yield("Hey")
}

However, in a flow it will compile:

suspend fun test() = withContext(Dispatchers.IO) {
    println("Hello")
}

flow {
     test()
     emit("Hey")
}

This is because flows can handle both coroutine contexts for kotlinx.coroutines and coroutine context from the flow builder.

The same is true between the pure coroutines binding and the kotlinx coroutines binding.

Looking at the two beneath they both seem to have the same constraints on their bind function so I also don't understand why one is named "Suspendable" and one isn't. What does the "Suspendable" refer to here if the bind is marked as suspend fun in both instances?

You're right, the naming here wasn't well thought through as they both "suspend". Perhaps ResultKotlinxCoroutineBindingScope would've been a better name.

Another small thing is that I'm pretty sure the code beneath would be incorrect - we would have to catch the potential exception and wrap it in an Err. Maybe I'm wrong here though.

Why do you want to catch the exception? We are not doing a runCatching here. If someone throws inside a binding I would expect that to get thrown up the stack, not returned as an Error. If you caught, then you would have to somehow join a throwable and the custom Error type together, which would mean we would have to return an Error type of Any.


Re: restrictions on where you can call bind, I learned recently that you cannot "bind" in rust when you're in a separate closure. For example `map` in an iterator. That would be closer to how this POC implementation works compared to the current one.

I want to make it clear that I'm not confident that this is the right solution for this library. Overall, I think that using pure coroutines feels more correct to me than using an exception, but there's still some research to be done around performance. Most of my points around performance are inferences, but I don't really have any data to prove that this solution is any better from that perspective. Plus the downsides of not being able to call this from a non-suspending function would be pretty disruptive to users of this library.

Feel free to close this issue if you want, I think it could be an interesting solution, but I think there's still more research to be done and I don't really have the time to pursue it at the moment.

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

No branches or pull requests

2 participants