Tonight I stumbled upon a cute implementation of an aborting lazy stream using call/cc
and foldr
:
let validate xs =
call/cc (λk → Right (foldr (λx xs → if alnum? x then x ∷ xs else k (Left x)) ◻ xs))
...
case validate "abc#d" {
Left x → print x
Right xs → iterate print xs
}
I tried it out with Guile (using pairs and thunks) and a friend did with SML/NJ and it actually works, laziness and all.
The idea is that xs
would be a possibly infinite character stream ['a', 'b', 'z', 'h', ...]
and this validate
function would lazily fold over it, initially returning a Right
identical lazy stream, until it reaches a non-alphanumeric character. Once it does, it will stop the computation, “go back in time” and return a Left
with the incorrect character.
When approaching this behavior using e.g. OCaml exceptions or effects, it becomes apparent that you have to ensure that the handler surrounds the forcing. Another approach would be to create a different data structure which encodes failure, and fold to that. Both ideas would result in leaking the implementation to the caller. Therein lies the cuteness of this implementation: consumer code does not need to install a handler or use a different data structure—it’s all self-contained.
But that’s not the point, there ought to be approaches which allow us to recover all the nice properties of validate
in its current form after all, the point is just having fun and playing with weird control flow.
Now I feel like I sort-of understood what was going on here: the reason this “time rewind” works is because there actually is a “handler surrounding forcing”, call/cc
turns its evaluation context inside-out, and forcing happens within the evaluation context of call/cc
. So then I tried to limit the context to that of the definition site:
let validate xs =
reset (shift k → Right (foldr (... else k (Left x)) ...))
Tried that in Guile (using ice-9 control), and my expectation was that it would just return the wrong type of argument on evaluation… it’d act like an early return, rather than reaching out to outside context (case) and re-running it. indeed that was what happened.
Now I just want to know if I’m on the right track in understanding this, and I’m also curious if there is any kind of trick to recover call/cc
’s undelimited control from shift/reset
’s delimited control (without converting the whole program to CPS…
Oh! One more thing, if I see any (reset .. (shift k ..) ..)
in the wild, is there a mechanical way to translate it effect handlers? I’m looking for a 1:1 correspondence if there is any… I can translate examples, I just want to make sure if there’s a principled approach to the translations, and possible nuance I’m missing. My current approach is: take shift
body out into a function, replace calls to k
in it with perform ∘ Eff
, move reset
body into the handler, as continue k body
, with the vacancy (shift ..)
has left being replaced with k
’s input. So does that sound right?