Skip to content

Instantly share code, notes, and snippets.

@elizarov
Last active October 23, 2023 20:19
Show Gist options
  • Save elizarov/5bbbe5a3b88985ae577d8ec3706e85ef to your computer and use it in GitHub Desktop.
Save elizarov/5bbbe5a3b88985ae577d8ec3706e85ef to your computer and use it in GitHub Desktop.
Delimited Continuations shift/reset in Kotlin
import kotlin.coroutines.*
import kotlin.coroutines.intrinsics.*
/**
* Implementation for Delimited Continuations `shift`/`reset` primitives via Kotlin Coroutines.
* See [https://en.wikipedia.org/wiki/Delimited_continuation].
*
* The following LISP code:
*
* ```
* (* 2 (reset (+ 1 (shift k (k 5)))))
* ```
*
* translates to:
*
* ```
* 2 * reset<Int> {
* 1 + shift<Int> { k -> k(5) }
* }
* ```
*/
fun <T> reset(body: suspend DelimitedScope<T>.() -> T): T =
DelimitedScopeImpl<T>().also { impl ->
body.startCoroutine(impl, impl)
}.runReset()
interface DelimitedContinuation<T, R>
@RestrictsSuspension
abstract class DelimitedScope<T> {
abstract suspend fun <R> shift(block: suspend DelimitedScope<T>.(DelimitedContinuation<T, R>) -> T): R
abstract suspend operator fun <R> DelimitedContinuation<T, R>.invoke(value: R): T
}
private typealias ShiftedFun<T> = (DelimitedScope<T>, DelimitedContinuation<T, Any?>, Continuation<T>) -> Any?
@Suppress("UNCHECKED_CAST")
private class DelimitedScopeImpl<T> : DelimitedScope<T>(), Continuation<T>, DelimitedContinuation<T, Any?> {
private var shifted: ShiftedFun<T>? = null
private var shiftCont: Continuation<Any?>? = null
private var invokeCont: Continuation<T>? = null
private var invokeValue: Any? = null
private var result: Result<T>? = null
override val context: CoroutineContext
get() = EmptyCoroutineContext
override fun resumeWith(result: Result<T>) {
this.result = result
}
override suspend fun <R> shift(block: suspend DelimitedScope<T>.(DelimitedContinuation<T, R>) -> T): R =
suspendCoroutineUninterceptedOrReturn {
this.shifted = block as ShiftedFun<T>
this.shiftCont = it as Continuation<Any?>
COROUTINE_SUSPENDED
}
override suspend fun <R> DelimitedContinuation<T, R>.invoke(value: R): T =
suspendCoroutineUninterceptedOrReturn sc@{
check(invokeCont == null)
invokeCont = it
invokeValue = value
COROUTINE_SUSPENDED
}
fun runReset(): T {
// This is the stack of continuation in the `shift { ... }` after call to delimited continuation
var currentCont: Continuation<T> = this
// Trampoline loop to avoid call stack usage
loop@while (true) {
// Call shift { ... } body or break if there are no more shift calls
val shifted = takeShifted() ?: break
// If shift does not call any continuation, then its value becomes the result -- break out of the loop
try {
val value = shifted.invoke(this, this, currentCont)
if (value !== COROUTINE_SUSPENDED) {
result = Result.success(value as T)
break
}
} catch (e: Throwable) {
result = Result.failure(e)
break
}
// Shift has suspended - check if shift { ... } body had invoked continuation
currentCont = takeInvokeCont() ?: continue@loop
val shiftCont = takeShiftCont()
?: error("Delimited continuation is single-shot and cannot be invoked twice")
shiftCont.resume(invokeValue)
}
// Propagate the result to all pending continuations in shift { ... } bodies
currentCont.resumeWith(result!!)
// Return the final result
return result!!.getOrThrow()
}
private fun takeShifted() = shifted?.also { shifted = null }
private fun takeShiftCont() = shiftCont?.also { shiftCont = null }
private fun takeInvokeCont() = invokeCont?.also { invokeCont = null }
}
import org.junit.*
import kotlin.test.*
class DelimitedTest {
@Test
fun testNoShit() {
val x = reset<Int> { 42 }
assertEquals(42, x)
}
@Test
fun testShiftOnly() {
val x = reset<Int> {
shift<Int> { k -> k(42) }
}
assertEquals(42, x)
}
@Test
fun testShiftRight() {
val x = reset<Int> {
40 + shift<Int> { k -> k(2) }
}
assertEquals(42, x)
}
@Test
fun testShiftLeft() {
val x = reset<Int> {
shift<Int> { k -> k(40) } + 2
}
assertEquals(42, x)
}
@Test
fun testShiftBoth() {
val x = reset<Int> {
shift<Int> { k -> k(40) } +
shift<Int> { k -> k(2) }
}
assertEquals(42, x)
}
@Test
fun testShiftToString() {
val x = reset<String> {
shift<Int> { k -> k(42) }.toString()
}
assertEquals("42", x)
}
@Test
fun testShiftWithoutContinuationInvoke() {
val x = reset<Int> {
shift<String> {
42 // does not call continuation, just override result
}
0 // this is not called
}
assertEquals(42, x)
}
// From: https://en.wikipedia.org/wiki/Delimited_continuation
// (* 2 (reset (+ 1 (shift k (k 5)))))
// k := (+ 1 [])
@Test
fun testWikiSample() {
val x = 2 * reset<Int> {
1 + shift<Int> { k -> k(5) }
}
assertEquals(12, x)
}
// It must be extension on DelimitedScope<Int> to be able to shift
private suspend fun DelimitedScope<Int>.shiftFun(x: Int): Int =
shift<Int> { k -> k(x) } * 2
@Test
fun testShiftFromFunction() {
val x = reset<Int> {
2 + shiftFun(20)
}
assertEquals(42, x)
}
@Test
// Ensure there's no stack overflow because of many "shift" calls
fun testManyShifts() {
val res = reset<String> {
for (x in 0..10000) {
shift<Int> { k ->
k(x)
}
}
"OK"
}
assertEquals("OK", res)
}
@Test
// See https://gist.github.com/elizarov/5bbbe5a3b88985ae577d8ec3706e85ef#gistcomment-3304535
fun testShiftRemainderCalled() {
val log = ArrayList<String>()
val x = reset<Int> {
val y = shift<Int> { k ->
log += "before 1"
val r = k(1)
log += "after 1"
r
}
log += y.toString()
val z = shift<Int> { k ->
log += "before 2"
val r = k(2)
log += "after 2"
r
}
log += z.toString()
y + z
}
assertEquals(3, x)
assertEquals(listOf(
"before 1",
"1",
"before 2",
"2",
"after 2",
"after 1"
), log)
}
}
@b-studios
Copy link

b-studios commented May 14, 2020

Hey @elizarov, it looks like the remainder of the shift-body is dropped in this implementation.

I would expect the following program

val x = reset<Int> {
    val y = shift<Int> { k -> println("before 1"); val r = k(1); println("after 1"); r }
    println(y)
    val z = shift<Int> { k -> println("before 2"); val r = k(2); println("after 2"); r }
    println(z)
    y + z
}
println(x)

to print

before 1
1
before 2
2
after 2
after 1
3

but I get

before 1
1
before 2
2
after 2
3

I think the issue is that invokeWith does not really run the continuation to its end. In my ruby tutorial of effect handlers in terms of fibers, I am using a recursive function (run) for that purpose. However, this will consume stack (also in the tail-resumptive case) and will inevitably run into a stack overflow.

@elizarov
Copy link
Author

@b-studios Good catch. There's indeed a logical bug. Line 64 return takeResult() should be replaced with return runReset(). It makes your test code to correctly pass, but as you correctly indicated it creates a recursive usage of call-stack and testManyShifts starts to fail. I think that it is actually possible to fix this unbounded stack usage by making invokeWith a suspending function itself and relying on tail-call optimization for suspending functions.

@b-studios
Copy link

@elizarov thanks for confirming. IIUC what you propose is different from what I sketeched on the loom mailing list (and much simpler). Since the JVM/loom does not feature TCO (yet) I capture the continuation on invokeWith and trampoline it.

@raulraja
Copy link

raulraja commented Jul 18, 2020

@elizarov @b-studios I'm currently working on building something similar to this to support multi prompt in Kotlin.
We need those in Arrow for computational blocks for types like List, Collections, etc that shift more than once. I have a version of this over ContT that works since it's a higher abstraction but imposes blocks to remain wrapped until lifted to a particular data type like List via listOf/flatMap.

@elizarov I'm interested in your idea on how this example could be made stack safe in invoke.

Going over this example I implemented the following with the changes you suggested for runReset:

suspend fun <A, B> DelimitedScope<List<A>>.bind(list: List<B>): B =
  shift { cb ->
    list.fold(emptyList()) { acc, b ->
      acc + cb(b)
    }
  }

fun main() {
  val result: List<String> = reset {
    val a = bind(listOf("a", "b", "c"))
    val b = bind(listOf(1, 2, 3))
    reset { listOf(a + b) }
  }
  println(result)

This results into takeResult() eventually returning null which then it throws in the check:

/Library/Java/JavaVirtualMachines/adoptopenjdk-11.jdk/Contents/Home/bin/java -javaagent:/Users/raulraja/Library/Application Support/JetBrains/Toolbox/apps/IDEA-C/ch-0/202.6397.20/IntelliJ IDEA 2020.2 CE EAP.app/Contents/lib/idea_rt.jar=57074:/Users/raulraja/Library/Application Support/JetBrains/Toolbox/apps/IDEA-C/ch-0/202.6397.20/IntelliJ IDEA 2020.2 CE EAP.app/Contents/bin -Dfile.encoding=UTF-8 -classpath /Users/raulraja/workspace/arrow/arrow-core/arrow-continuations/build/classes/atomicfu/main:/Users/raulraja/workspace/arrow/arrow-core/arrow-continuations/build/tmp/kapt3/classes/main:/Users/raulraja/workspace/arrow/arrow-core/arrow-core/build/classes/kotlin/main:/Users/raulraja/workspace/arrow/arrow-core/arrow-core/build/tmp/kapt3/classes/main:/Users/raulraja/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-jdk7/1.3.61/70dffc5f8ac5ea7c34f30deac5b9d8b1d48af066/kotlin-stdlib-jdk7-1.3.61.jar:/Users/raulraja/workspace/arrow/arrow-core/arrow-annotations/build/classes/kotlin/main:/Users/raulraja/workspace/arrow/arrow-core/arrow-annotations/build/tmp/kapt3/classes/main:/Users/raulraja/workspace/arrow/arrow-core/arrow-core-data/build/classes/atomicfu/main:/Users/raulraja/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib/1.3.61/4702105e97f7396ae41b113fdbdc180ec1eb1e36/kotlin-stdlib-1.3.61.jar:/Users/raulraja/.gradle/caches/modules-2/files-2.1/io.kindedj/kindedj/1.1.0/462731347602a3f24e3f21feec50928f9a657741/kindedj-1.1.0.jar:/Users/raulraja/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib-common/1.3.61/65abb71d5afb850b68be03987b08e2c864ca3110/kotlin-stdlib-common-1.3.61.jar:/Users/raulraja/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/13.0/919f0dfe192fb4e063e7dacadee7f8bb9a2672a9/annotations-13.0.jar arrow.continuations.conttxxxx.DelimCCXKt
Exception in thread "main" java.lang.IllegalStateException: Check failed.
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.takeResult(DelimCCX.kt:80)
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.reset(DelimCCX.kt:71)
	at arrow.continuations.conttxxxx.DelimCCXKt.reset(DelimCCX.kt:28)
	at arrow.continuations.conttxxxx.DelimCCXKt$main$result$1.invokeSuspend(DelimCCX.kt:102)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.invokeWith(DelimCCX.kt:64)
	at arrow.continuations.conttxxxx.DelimCCXKt.invoke(DelimCCX.kt:35)
	at arrow.continuations.conttxxxx.DelimCCXKt$bind$2.invoke(DelimCCX.kt:94)
	at arrow.continuations.conttxxxx.DelimCCXKt$bind$2.invoke(DelimCCX.kt)
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.reset(DelimCCX.kt:73)
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.invokeWith(DelimCCX.kt:65)
	at arrow.continuations.conttxxxx.DelimCCXKt.invoke(DelimCCX.kt:35)
	at arrow.continuations.conttxxxx.DelimCCXKt$bind$2.invoke(DelimCCX.kt:94)
	at arrow.continuations.conttxxxx.DelimCCXKt$bind$2.invoke(DelimCCX.kt)
	at arrow.continuations.conttxxxx.DelimitedScopeImpl.reset(DelimCCX.kt:73)
	at arrow.continuations.conttxxxx.DelimCCXKt.reset(DelimCCX.kt:28)
	at arrow.continuations.conttxxxx.DelimCCXKt.main(DelimCCX.kt:99)
	at arrow.continuations.conttxxxx.DelimCCXKt.main(DelimCCX.kt)

I'm guessing to do multi prompt repeatedly inside a single shift we would need some state as in https://gist.github.com/b-studios/65e9e614f006d2125659267f6113efac#file-delimcc-java-L28-L82.

If you have any suggestions any help is appreciated. We are trying to build this to get around the current reflection tricks that we use in Arrow to clone the continuation state and reset in between flatMap :
https://github.com/arrow-kt/arrow-core/blob/master/arrow-core-data/src/main/kotlin/arrow/typeclasses/ContinuationUtils.kt
https://github.com/arrow-kt/arrow-core/blob/6df4ae3f9ab973d4cef366425629571aaff4ea1b/arrow-core-data/src/main/kotlin/arrow/typeclasses/MonadContinuations.kt#L35-L37

Thank you @elizarov and @b-studios for these great motivating examples.

@b-studios
Copy link

b-studios commented Jul 18, 2020

@raulraja this is not addressing your problem at hand, but maybe you are also interested in my old monadic reflection experiments

RE your problem: To me storing the continuation in a mutable field is confusing since in the case of multiple resumptions you are running into aliasing problems.

Personally, I am a big fan of continuations as immutable liked lists of immutable frames
since it makes sharing a lot easier.

@raulraja
Copy link

@b-studios thanks for the info. would you mind posting the link again to 'monadic reflection experiments'? it's linking to my comment on the gist. Thanks!

@b-studios
Copy link

@raulraja sorry for posting the wrong link, I updated it

@raulraja
Copy link

thanks @b-studios!

@raulraja
Copy link

@b-studios looks great and I'm gonna give it a shot to see if I can make it work with coroutines and suspension. This uses loom continuations but we can't depend on that.

In our current case what here is modeled as a program being a Prompt and its body, in our case we need to deal with suspension.
You may be already aware but even if we cast a suspend function to a regular function is still goes throw the internals of the compiler-generated state machine checking for completion. https://github.com/b-studios/cats-reflect/blob/master/src/main/scala/cats/reflect/internal/Coroutine.scala#L9 I'm gonna give it a shot and see if I can model that with suspending functions.

Thanks so much for all your help and impressive research in algebraic effects and gists! Had fun and learned a lot from them.

@elizarov
Copy link
Author

@b-studios I've fixed implementation so that it is correct and does not use the call-stack. All the calls are now in a single top-level runReset loop. All the inner shift and continuation calls just suspend and relinquish control to this top-level loop. This way you can shift many times without using call-stack and if you have any code in shift { .... } body after continuation invocation, then the corresponding "after invocation continuations" are stored in the heap and are called in the correct order at the end.

@elizarov
Copy link
Author

To be clear, this implementation is still not a 100% port of shift/reset from LISP: you cannot call a continuation an arbitrary number of times, nor you can save a continuation somewhere and call it at an arbitrary moment of time later. It only supports calling a continuation from inside of shift at most once.

@b-studios
Copy link

b-studios commented Jul 20, 2020

you cannot call a continuation an arbitrary number of times

I was about to point that out :) But still it is very cool to see that you managed to implement it in a stack-safe manner!

BTW in our design of the Effekt language we also treat resume (that is, the continuation) as an "effect" (or block, in our lingo). If you are interested, you can read up on it in our paper

@elizarov
Copy link
Author

elizarov commented Jul 20, 2020

@b-studios Thanks for the paper. If you squint well enough, you might notice similarities with context-based programming practice that is growing in Kotlin ecosystem due to the Kotlin's convenient syntax for functions with a receiver (context). Kotlin coroutines (suspending functions) are also context-based, as well as composable functions form JetPack Compose. Another Kotlin-specific enabling feature for this programming style are inline functions, which serve as "co-pure" functions. Yes, they are second-class, but in return they automatically propagate caller's context without having to use effect polymorphism in their signature (indeed, effect polymorphism is no-go for a general-purpose non-research language).

For pragmatic reasons, though, Kotlin approach does not readily generalize to arbitrary effects, since we a looking for an efficient runtime mapping. All the various complex contexts (like suspending context or composable context) cannot be simply implemented as a library in Kotlin, but need context-specific support from the compiler. It also drives various pragmatic restrictions like single-shot-only continuations (creating an immutable snapshot of continuation at each suspending point would be simply too expensive for many real-life use-cases we were designing Kotlin coroutines for).

@raulraja
Copy link

@b-studios @elizarov thanks for the conversation and proposed suggestions. Jannis, Simon, and I worked in attempting multi-prompt and similar techniques and we have now a version of reset / shift that is gonna allow us to build effect handlers and remove the state label reflection tricks in the Arrow continuations. In case you are interested, this will keep evolving but this is the first attempt https://github.com/arrow-kt/arrow-core/pull/226/files if you have any comments on it feel free to do directly in the PR even if closed and we will address them there or as issues. Thanks again for all your help.

@flash-gordon
Copy link

@elizarov I figured testNoShit is named incorrectly

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