or
or
By clicking below, you agree to our terms of service.
New to HackMD? Sign up
Syntax | Example | Reference | |
---|---|---|---|
# Header | Header | 基本排版 | |
- Unordered List |
|
||
1. Ordered List |
|
||
- [ ] Todo List |
|
||
> Blockquote | Blockquote |
||
**Bold font** | Bold font | ||
*Italics font* | Italics font | ||
~~Strikethrough~~ | |||
19^th^ | 19th | ||
H~2~O | H2O | ||
++Inserted text++ | Inserted text | ||
==Marked text== | Marked text | ||
[link text](https:// "title") | Link | ||
 | Image | ||
`Code` | Code |
在筆記中貼入程式碼 | |
```javascript var i = 0; ``` |
|
||
:smile: | ![]() |
Emoji list | |
{%youtube youtube_id %} | Externals | ||
$L^aT_eX$ | LaTeX | ||
:::info This is a alert area. ::: |
This is a alert area. |
On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?
Please give us some advice and help us improve HackMD.
Do you want to remove this version name and description?
Syncing
xxxxxxxxxx
Scopes and effect handlers
This document was written because I learned something surprisingly obvious about effect handlers. It may help others since I haven't seen this blind spot discussed before.
While this begins with a quick introduction to effects, I am going to skip on a lot of details so you should get your hands dirty if you are not already familiar with the topic. It's really fun and if you want to follow along, this local environment works perfectly:
The effect system makes good use of advanced OCaml features, so I will try to explain them as we go. You may want to read https://github.com/ocamllabs/ocaml-effects-tutorial as a better introduction and https://github.com/ocaml-multicore/effects-examples to get hyped. You can also try
eio
from this local switch.But if you are already familiar with those topics, then the trickery is near the end!
Let's begin! When playing with "pure" effects, you will see this weird pattern a lot: A
match
within parenthesis, where each case produces a lambda. The initial arguments of the lambda are given at the very end after the parenthesis. Eachcontinue
expects to receive the arguments of the lambda and is an opportunity to update them.As you can see, the flow is a bit reversed but you will get used to it very fast in practice.
Performing an effect is a lot like raising an exception: The corresponding case in the
match
will receive the argument ofperform
and a continuationk
.k
, then the effect behaves exactly like a standard exception.continue k
with an argument, in which case the originalperform
will evaluate to that argument and the program will continue as if the effect never happened.The only catch is that you can only
continue
the continuation once.As a simple example, let's implement a "counter" effect. Each time we perform the
Incr
effect, we will receive the next natural number:The
with_counter
takes a function as argument and calls it within thematch ... with
. This ensures that effects performed byfn
will be caught and handled.Notice how the
continue
takes bothcounter
andcounter + 1
? The first one is the response, while the second is updating our counter state for the next time. The initial state of the counter is0
, provided on the very last line.Once this function is defined, you can use it as follow: We first set up the
with_counter
handler, then eachperform Incr
triggers this effect handler and receive the new value of the counter.If we forget to wrap this code within
with_counter
, then we will get anUnhandled
exception!Back to effects: If we use multiple
with_counter
in our code, each one will start from0
and if we nest them, it's always the nearest one that will handle theIncr
effect:How could we reach the outer counter from the nested one? The question is a bit weird because something is missing: We have no way of telling the system that we want to perform this operation. We would need some way of "naming" which counter we want to increment… such that we could write this code:
Let's implement this new
with_counter
. It's exactly the same code as before, but with a twist: We don't use the globalIncr
effect anymore, but rather we create a new local effect specific to each counter.The
let open struct ... end in
syntax allow us to dynamically create new types, exceptions, and effects, that will only be available locally. We pass the counter as(fun () -> perform Local_incr)
to the user functionfn
and handle its effects as before. When the user callsx ()
, it performs the correspondingLocal_incr
for which there is only one handler available… so there's no confusion as to which counter should be incremented.This might not be entirely obvious: when we create multiple counters, we define multiple
Local_incr
effects that have the same "name" in the code, but at runtime each one will be unique. We setup one effect handler for each of our counter, but since they match on different effects, they can't intercept messages intended to another.If all of this makes sense, let's move on to implementing references. This isn't so different from the counter example, since both allow us to query and update state. The big difficulty is that references are not limited to storing
int
values.. we want them to be polymorphic.Here is an example usage, showing that we can create references for different types of values:
Once again, we will hide the actual effects from the user: This time there are two operations
get
andput
, so we package them in a record:The effect handler uses the same trick of locally defined effects as the counter and the code is very similar:
The last lines are pretty important since they define the semantic of our references, but there is nothing new here.
The main difficulty is convincing the typechecker that references can hold different types of values. It happens on the second line where we specify the type of
with_eref
:The
type a.
notation is explicitly introducing the polymorphic type variablea
(when we would normally use'a
). Most importantly, this notation binds the type variablea
for the rest of the code, so we are able to define local effects that depend on it:(You might want to try removing
type a
and using'a
instead to see the difference!)It's very impressive how algebraic effects are able to benefit from advanced features of OCaml that were not created for them!
While I hope this introduction was clear… I must now reveal that those examples are actually a really bad use of algebraic effects.
The counters and references might have given you the wrong idea about algrebraic effects: They are not a real replacement for impure mutable side effects! In fact, they come with a very strong limitation when compared to the standard references of OCaml.
Remember the
Unhandled
exception when we try to perform an effect without first setting up a handler?The effectful reference is created by
with_eref
… but then it escapes its effect handler. When we then try to update theeref
, thePut 666
effect is not handled by anyone and crashes the program.In other words, the "pure" references are only usable from within the scope of their handler. A standard OCaml
ref
on the other hand will always be available… until it is collected by the GC, but at this point if was already clear that it wasn't going to be used again.The typesystem can't yet protect us from performing effects outside their handler, so you have to be careful. You might think that it's pretty easy to visually check that
escape.get ()
only happens within thewith_eref
, but that's exactly why I'm writing this document. It's not!In the mean time, the use of algebraic effects to simulate impure references kind of misses the point. Effects are a lot more useful for scripting control flow rather than value flow. Typically, this will involve changing the normal execution of our programs to create enumerators, coroutines, async/await, green thread, etc, as libraries. And it's going to be insanely cool!
This article is already too long to present a real scheduler as a more interesting use of algebraic effects. But we can cheat and create a really crappy one!
The
Spawn (fun () -> ...)
effect is intended to create a new thread of execution that will execute concurrently with the rest of our code:But since we won't do a real scheduler, we'll just evaluate the spawned thread right away before moving on:
(A real implementation would store the spawned
child
somewhere, to evaluate it later, with an additionalYield
effect to cooperatively switch between threads…)It may not be obvious, but even this simple handler has a small bug: A spawned child can't itself perform a
Spawn
effect! That's easy enough to fix:Now let's see how this works with the effectful references we defined before. We are not doing anything fancy, so it's easy to see that the following
spawn
will execute before we try printing the value of the reference:Clearly, this should give us
666
right? Well… The function(fun () -> x.put 666)
will be executed by thewith_spawn
handler, outside of thewith_eref
handler! As a result, itsPut 666
effect will not be handled by anyone and will crash with anUnhandled
exception. The fact that the function(fun () -> x.put 666)
is defined inside thewith_eref
doesn't matter at all since we performed aspawn
effect to evaluate it in the scheduler (1).The correct order of effect handlers should be reversed:
But that's just weird: we would normally setup the scheduler at the beginning of our
main
function, then setup the local effect handlers for the shorter lived effects like references.And what would be the fix when there's a dynamic number of references? While the following code isn't great with a real scheduler because we don't wait for the threads to terminate before reading their outcome, that's not the reason why it doesn't work here:
I want to state again that this is a self-injured issue, the problem doesn't exist with real references.
But it is however a control flow issue! The
with_eref
are not executed at the right time during the lifetime of the program… And we can solve this with more effects!This combines a bit of everything we saw before, and even adds an explicit forall with the type
cc
. The type trickery is required to maximize the usefulness of this handler, as otherwise the type would be too restrictive for some programs (2).This is dark magic from scheme sorcerers, so it's easier to understand what's going on by looking at its usage. The
with_scope
allow us to add dynamic effect handlers around the scheduler:The
lift.cc
will apply thewith_eref
effect handler at the position of thewith_scope
, at which point execution will continue normally. When the scheduler executes the spawned function, it does so at the position of thewith_spawn
… so our reference effects will work correctly since thewith_eref
was lifted just above!So even though it looks like we are using the reference
x
outside the scope of itswith_eref
, it actually became globally available. This lifting isn't free however: Even if the variablex
can be reclaimed at some point, its referenced value and its effect handler will be held until we exitwith_scope
.In conclusion, you can clearly do some insane stuff with effects!.. But it wasn't obvious to me that it would be so hard to tell when an effect handler is in scope.
(1) Here's a minimal example of escaping the scope of an event handler. The continuation of
A
doesn't capture theB
effect handler, so its evaluation fromC
triggers anUnhandled
exception:This is not a bug with effects, but a feature! They are really one-shot delimited continuation, so keeping the
B
handler in scope would require duplicating the stack.(2) If you add the creation of another reference with a different type, you will quickly discover why we need the forall in
cc
: