Turmeric ships a family of delimited control operators: shift/reset,
shift0/reset0, call/cc, and escape (plus the multi-shot call/cc* and
the cloneable/serializable variants documented elsewhere). They look similar
and share one runtime (tur_cont, one-shot delimited continuations backed by
fiber-context save/restore), but they differ in two precise ways that decide
which one you want. This guide explains those differences.
Every delimited control operator has two halves:
reset / reset0) marks a boundary on the stack: "the
captured continuation reaches this far and no further."shift / shift0) grabs the slice of stack from the
capture site up to the nearest enclosing delimiter, reifies it as a callable
function k, and hands it to your body.The whole family is one runtime mechanism. What distinguishes the operators is the answer to two questions:
k, is the delimiter re-installed around the resumed work?call/cc and escape are sugar layered on top of shift and shift0
respectively, specialized for the common "escape / early return" use case.
| Pops delimiter on capture? | Re-installs delimiter on resume? | Value of (k v) surfaces at ... |
|
|---|---|---|---|
shift / reset |
No | Yes | the capture (shift) site |
shift0 / reset0 |
Yes | No | the boundary (reset0) |
call/cc (= shift sugar) |
No | Yes | the call/cc site |
escape (= shift0 sugar) |
Yes | No | the boundary |
The remaining two corners of the lattice -- Felleisen's control/prompt and
control0/prompt0 -- differ in how composed continuations stack their
delimiters. Turmeric does not expose those; the four above cover every
motivating use case in this codebase.
shift / reset -- static, re-delimitingThis is the well-behaved default (Danvy--Filinski semantics):
shift captures, the surrounding reset stays in place.k, its body is re-wrapped in a fresh
reset -- the delimiter is reinstated around the resumed computation.Because every invocation of k is freshly delimited, captured continuations
compose like ordinary functions, and a shift always sees the same
statically-enclosing prompt. This is exactly why shift/reset is the right
lowering target for resumable effect handlers (each resume runs inside a
fresh handler scope).
(reset
(+ 1 (shift k (k (k 10)))))
;; k = (fn [x] (+ 1 x)), re-delimited on each call
;; => (+ 1 (+ 1 10)) = 12, all surfacing inside the reset
shift0 / reset0 -- pops the promptshift0/reset0 are strictly more expressive, at the cost of being harder to
reason about locally:
shift0 captures, it also removes the enclosing reset0 (it "pops
the prompt").k, the delimiter is not reinstated.The consequence: a shift0 can "see past" its immediate prompt to the next
dynamically-enclosing one. This is what lets you reach outward through multiple
prompt layers -- something shift cannot do, because it always re-establishes
its own boundary first.
The local result-typing rule is identical to shift: the (shift0 f x)
expression has the type of f's codomain. Only the runtime delimiter behavior
differs.
;; The expression has f's codomain type (here :bool), exactly like shift.
(defn is-zero [n : int] : bool
(reset
(shift0 (fn [v : int] (= v 0)) n)))
call/cc -- delimited capture, value at the capture sitecall/cc is shift-flavored sugar:
(call/cc f) ==> (reset (shift k (f k)))
It allocates a one-shot continuation for "the rest of the computation from the
call/cc site up to the boundary" and passes it to f:
f returns normally, that value is the value of the call/cc expression
(the captured k is dropped).f (or anything it calls) invokes (k v), control returns to the
call/cc site with v as its value; whatever f was doing is abandoned.Because it is built on shift, the resumed continuation is re-delimited, and
the value surfaces at the capture site -- not the boundary.
Delimited, not undelimited. Unlike Scheme's top-level
call-with-current-continuation, Turmeric'scall/cccaptures only up to the nearest enclosingreset. Usecall/cc*for the multi-shot cloneable variant.
escape -- abort to the boundaryescape is the abort-flavored sibling, lowered via shift0:
(escape f) ==> (reset (shift0 k (f k)))
The difference from call/cc is where the value surfaces: invoking (k v)
unwinds to the boundary and produces v from the boundary (the reset),
not the escape site. Nothing is re-delimited on the way out. This is the
classic non-local exit, matching C longjmp, Common Lisp return-from, Java
throw, and OCaml discontinue.
Reach for escape when you want to bail out of deep recursion with a value and
not resume -- early return without manual Option/Result plumbing:
;; Conceptual: exit produces the value at the boundary, abandoning the rest.
(defn first-negative [xs : list<int>] : int
(escape (fn [exit]
(for [x xs]
(when (< x 0)
(exit x))) ; unwinds straight to the boundary with x
0))) ; no negative found
Where shift/call/cc is for splicing a captured slice back into a
computation (possibly composed, possibly more than once with call/cc*),
escape is purely abortive: you jump out and you do not re-enter.
Implementation status.
shift/resetandshift0/reset0are runtime-backed today.call/cc/escapeare delimited sugar over them; the full early-exit lowering (escapeviashift0, an enforced enclosing boundary) is tracked incall-cc-completion-plan.md. Until that lands, treat the semantics above as the contract and check the plan for the current stub caveats.
All four operators are delimited: a capture extends only to the nearest
enclosing reset/reset0, never to the top of the program. A call/cc or
escape with no enclosing boundary is a compile-time error (TUR-E0705 once
the completion plan lands) rather than an implicit whole-program capture. A
captured k cannot escape past its boundary; calling k after the boundary
has already returned is a defined runtime error, not undefined behavior.
| You want to ... | Reach for |
|---|---|
| Compose a captured continuation, resume inside a fresh scope, lower an effect handler | shift / reset |
| Reach past the immediate prompt into an outer one (layered handlers, multi-prompt control) | shift0 / reset0 |
| Capture once, resume with the value surfacing at the capture site | call/cc |
| Non-local early exit -- bail out with a value, surface it at the boundary, do not resume | escape |
| Resume a captured continuation more than once (backtracking, generators) | call/cc* (cloneable) |
| Persist or migrate a suspended computation across processes | serial-shift / serial-reset |
These operators are more than surface theory here -- the compiler leans on their semantics:
perform/handle) lower to shift/reset in
PASS_EFFECT_LOWER. The re-delimiting property of shift is exactly what
makes each resume run inside a fresh handler scope.shift/reset are then CPS-transformed to trampolined IR in
PASS_CPS, which compiles down to the tur_cont runtime.The symbol bindings live in
src/compiler/elab_internal.h (sym_shift,
sym_reset, sym_shift0, sym_call_cc, sym_escape, ...), elaboration in the
elab_* entry points there, and codegen in src/compiler/emit_internal.h
(emit_effects_shift, emit_effects_reset, emit_effects_shift0, ...). The
runtime structures are in
src/runtime/runtime.h (tur_cont,
tur_cloneable_cont, tur_frame).
A base shift is abortive: it discards its delimited context. The value
delivered to the enclosing reset is f(body-value), where f is the shift's
receiver -- not the body value itself. Two invariants fall out of this, and both
have been the root cause of silent miscompiles:
(shift f body) as merely the CPS
of the body value -- dropping f -- makes (shift (fn [v] v) x) and
(shift (fn [v] (* 2 v)) x) produce identical IR. The receiver is part of
the semantics, so the IR must lower it as "apply f to the body value."reset must not degrade to plain body-eval. If a
backend cannot lower a given reset shape and falls back to
emit_value(body), the enclosed shift collapses to "return its operand"
and the reset yields the wrong value (the abortive continuation is never
discarded). The direct backend instead lowers a branch-bearing base reset
through a setjmp/longjmp escape (emit_cps_reset_escape) to the
innermost reset landing.Because the direct and CPS backends lower these shapes independently, they are
cross-checked by cps-oracle-* fixtures (e.g. cps-oracle-reset-join-escape,
cps-oracle-reset-both-branch-shift) that assert direct == cps on the same
program. When you extend the admitted reset/shift subset, add or re-admit the
matching oracle -- a lowering retired or narrowed on a usage metric alone can
silently drop the sole correct emitter for a shape, so guard the shapes you stop
handling with a hard codegen error (as TUR-E0710 does for cloneable contexts)
rather than a fallback.
shift/resetcall/cc*) for backtrackingcall-cc-completion-plan.md -- The plan finishing call/cc/escapeThe semantics above come from the delimited-continuation literature; see the Bibliography for full citations:
shift/reset semantics.control/prompt family.shift0/reset0 hierarchy and its relation to shift/reset.