Youssef Shoaib [MOD]
04/24/2024, 6:44 PMbind
or by
on the shift
β’ You need to surround the body of reset
in maybe
β’ You need to surround any side-effect or heavy-calculation in an effect
block
The first 2 issues are Compose limitations around throwing exceptions, but in theory they should be supported eventually
The 3rd one is unavoidable due to how Compose works. Everything inside shift
or effect
will run the proper number of times, but anything outside will run as many times as the continuation is run. It's annoying, but again merely surrounding your code in effect
fixes the issue.Youssef Shoaib [MOD]
04/24/2024, 7:53 PMif
, else
, while
and for
, and probably other control flow, and thus you're not allowed to throw inside them, so I'm having to create special versions of them. This should be the same as problems 1 and 2 though, and hence it should go away if Compose improves. In fact, the solution is so mechanical and simple that I think they could probably implement it in their compiler plugin easily.simon.vergauwen
04/25/2024, 6:42 AMif
, else
, while
, for
, we felt it broke the language too much.
But still super useful! Perhaps something in combination with Inikio @Alejandro Serrano.MenaYoussef Shoaib [MOD]
04/25/2024, 9:48 AMifSafe
wrapper, one can do (if (condition) maybe { ... } else maybe { ... }).bind()
In fact, Compose is helping a great deal here because it magically transforms for
and if
etc to "do the right thing", it just does that without supporting exceptions coming from them.
The only reason they exist though isn't because of any conceptual limitations, it's just because Compose can't handle exceptions well just yet. That should hopefully be supported in the future, and then everything I've been complaining about will dissolve away (except writing effects/calculations in specialised blocks. It's practically a manual state-machine transformation, but it's so mindless and simple that I don't think it's that hard to get used to). Thankfully, if one forgets to wrap and unwrap, Compose throws its own exception that seemingly is always guaranteed to happen, and so at least no logical errors happen π€·πΌββοΈ
If there is a pre-existing way to cancel composition reliably and not immediately schedule recomposition, I'd love to know about it. It would also fix everything magically.simon.vergauwen
04/25/2024, 9:56 AMreset
is your entrypoint I assume. So that's going to replay the effect on re-composition. Not sure if bind
comes from maybe
? But maybe
is what protects from exceptions here?
That was indeed the problem we got stuck on last time... Compose can replay from certain points in the tree, but without it you cannot. I'd need to dig up the other gotchas, but this might indeed by solved by Compose.
I assume you cannot put maybe
, or something like that in your runtime? The compose generated code in between is what blows up due to exceptions?simon.vergauwen
04/25/2024, 9:58 AMcoroutineContext.cancelChildren()
needed in some of the MonadTest.kt
?
That seems to point to an older problem I had in the past, this seems to indicate a coroutine is created somewhere but never completed, and thus there will be an accumulation of uncompleted continuations accumulating. Not sure if they get GC'ed or not, might depend on the classpath due to KotlinX Debug π±... Otherwise can be OOM in long running apps.simon.vergauwen
04/25/2024, 9:58 AMtry/finally
is broken in some placesYoussef Shoaib [MOD]
04/25/2024, 9:59 AMreset
is the entry point. It launches a composition for me to use.
Whenever shift
is called, it needs to do the equivalent of COROUTINE_SUSPENDED
, and rn I simulate that with raise(Unit)
. In other words, we need to stop the execution of the composition until the continuation passed to shift
is called. From there, it's smooth sailing. It's that COROUTINE_SUSPENDED
idea that's requiring exceptions.simon.vergauwen
04/25/2024, 10:00 AMYoussef Shoaib [MOD]
04/25/2024, 10:01 AMshift
is controlling the block, it basically replays the block up until the point that this shift
was called, then it actually runs any code after that. The only issue I have is giving control to the shift
block requires ending the compositionYoussef Shoaib [MOD]
04/25/2024, 10:03 AMcoroutineContext.cancelChildren
is needed for any lazy value that you want to produce out of a comprehension. For instance, if you're producing Flow
, the flow won't get started until you later call collect
, so we need to keep the composition coroutine alive. It's just a minor hack for now. My plan is to instead provide a use
function perhaps where you're expected to produce a non-lazy value out of it, and then after the block finishes we kill the composition coroutine.simon.vergauwen
04/25/2024, 10:04 AMlazyReset
?simon.vergauwen
04/25/2024, 10:04 AMYoussef Shoaib [MOD]
04/25/2024, 10:05 AMcancelChildren
, I probably want to use a custom Job anyways, just haven't done so yet because cancelChildren
did the job just fine.simon.vergauwen
04/25/2024, 10:06 AMJannis
04/25/2024, 10:07 AMJannis
04/25/2024, 10:09 AMYoussef Shoaib [MOD]
04/25/2024, 10:20 AMshift
is conceptually just suspendCoroutine
. You know how suspendCoroutine
returns COROUTINE_SUSPENDED
, gives you the continuation, and then later you can resume it? Resuming the continuation is easy to do here. The issue is "suspending" the composition. The way coroutines do this is by rewriting the function to early-return with a special COROUTINE_SUSPENDED
value. I can't do that here without a custom compiler plugin, which I'd really like to avoid. I can easily "advance" the composition until the point I want, and I can be sure that it's done whenever it produces a value into my outputBuffer
, the only issue is how do I short-circuit the composition? Currently, I use a raise(Unit)
and I wrap everything in a value class Maybe
which is just an optimized Option
. The issue is Compose doesn't play well with raise
whenever it "crosses" a @Composable
or control-flow (if
, while
, etc). Hence, we need to "wrap" things in a maybe { }
(which just takes a block: Raise<Unit>.() -> R
) and then bind
them after the boundary (which ends up calling raise(Unit)
. Alternatively, a user can also just call getOrElse { return@reset nothing() }
but that'd need to be done for every reset
call, which is incredibly annoying.
So what I need is a way to short-circuit a composition so that the block passed to shift
can be allowed to run and can get control over the composition.simon.vergauwen
04/25/2024, 10:21 AMshift
that doesn't work this way. Might be even possible to avoid exceptions all together if you use my original shift
impl.simon.vergauwen
04/25/2024, 10:22 AMYoussef Shoaib [MOD]
04/25/2024, 10:25 AM@Composable suspend fun
, I would've, and it would solve all the issues, sadly the Compose compiler explicitly forbids this construct. One other option is to just hang the composition until shift's continuation is called. That would require the continuation being called at least once though, so for instance you wouldn't be able to bind
on List
, only on NonEmptyList
. Hanging in that way is a waste of a thread also.Youssef Shoaib [MOD]
04/25/2024, 11:23 AMpublic
) API which is used for early returns. Might be quite promising!simon.vergauwen
04/25/2024, 11:42 AM@RestrictSuspension
, not sure if you could leverage it right before the @Composable
function but that'd depends on how the internals are wired.
I am super busy preparing for KotlinConf, but I am going to dig deeper whenever I have time. I have some stuff I'd like to try implementing on top π
Youssef Shoaib [MOD]
04/25/2024, 11:54 AMRaise
dsl and 2) all my issues (except surrounding with effect
) go away completely π€― . It's worrying to use an internal API though, but as a non-production-ready toy this is fantastic!
UPDATE: all tests are running great! The updated version is now in this branch because I'm still cautious about using an internal API.simon.vergauwen
04/25/2024, 12:35 PMsimon.vergauwen
04/25/2024, 12:35 PMJannis
04/26/2024, 2:22 PMYoussef Shoaib [MOD]
04/26/2024, 2:30 PMeffect
block memoizes its result, and only recalculates if we've reached the point where the user previously `shift`ed. Compose is really good at doing this type of memoization and is designed to rerun very cheaply.
This one supports nested reset
really easily too. That's because reset
is just a simple suspend
call, so one can simply shift { k -> k(reset(body)) }
. It's also really smart about handling if
, for
, etc, so the code runs exactly as you'd expect, which is something that even the hacky continuation copying couldn't do. For example (from kotlin-monads):
val result = reset<List<Int>> {
for (i in 1..10) {
listOf(0, 0).bind()
}
listOf(0)
}
result shouldBe List(1024) { 0 }
With the continuation hack, this fails with a list of size 11
I think, while with my impl, it succeeds with 1024 items as expected. That's because Compose creates 10 different Shift
and the first one takes control, reruns the block, then the second one takes control (and thus the first one is waiting), reruns the block, then the third one... Until the end. Right at the end, the 10th one takes control, reruns the block twice, and then gives control back to the 9th one, which reruns the block a second time.Jannis
04/26/2024, 3:00 PMJannis
04/26/2024, 3:02 PMYoussef Shoaib [MOD]
04/26/2024, 3:14 PMStateFlow
, and thus it's how this rabbit hole even started because I figured that surely if it can do that, it can probably do delimited continuations.Jannis
04/26/2024, 3:26 PMYoussef Shoaib [MOD]
06/05/2024, 10:38 AMsuspend
using reflection (yuck!) to clone `Continuation`s. The library now supports coroutineContext
properly too, which allows delimited dynamic variables, or in other words the Reader
monad is really easily supported! I haven't implemented dynamic-wind
yet because frankly I don't quite understand it yet, but I've implemented a ForkingReader
that seems to function intuitively.
A lot of examples for different effects are now supported, including List
, State
, Reader
, and prominently resetWithHandler
(which means it should support Algebraic Effects with handlers!).
This is a big update, and I'm desperate for more examples and especially for bug reports. My aim is to work similarly to Racket's delimited continuations while also providing an effect handling system.Youssef Shoaib [MOD]
06/05/2024, 10:58 AMcopy(completion: Continuation<*>)
and a val completion
. If those 2 methods were added with a bytecode plugin for instance, or by the compiler itself, no reflection would be necessary. This is analogous to "cutting the stack" at a certain point and "gluing it back" at another, which is how delimited continuations are described.simon.vergauwen
06/05/2024, 4:25 PM