Effects
An effect is an operation a function can perform without saying how it gets done. The caller decides that, by installing a handler. Metaxu uses this one mechanism for things other languages build separately: exceptions, generators, mutable state, threads, GPU launches. All of them are effects with different handlers.
Declaring and performing
effect declares a named set of operations. perform invokes one.
metaxueffect Say {
say(msg: string) -> Unit
}
fn greet() performs Say {
perform Say.say("hello");
perform Say.say("goodbye")
}
fn main() -> int {
handle Say with {
say(msg) -> {
print(msg);
resume(())
}
} in {
greet()
};
0
}
The performs Say clause on greet says the function needs a Say handler somewhere above it. Inside the handler arm, resume(v) continues the suspended computation, delivering v as the value of the perform expression. The continuation is delimited and single-shot: it runs from the perform site to the end of the handled block, and you can call it once.
Handlers that answer
Operations return values. Whatever you pass to resume is what the perform expression evaluates to.
metaxueffect Ask {
ask() -> int
}
fn add_two_answers() -> int performs Ask {
perform Ask.ask() + perform Ask.ask()
}
fn main() -> int {
let total = handle Ask with {
ask() -> resume(21)
} in {
add_two_answers()
};
print(total);
0
}
Handlers that abort
An arm doesn't have to resume. If it returns without calling resume, the rest of the handled block is torn down and the arm's value becomes the value of the whole handle expression. That's an exception, built from nothing but a handler.
metaxueffect Bail {
bail(code: int) -> Unit
}
fn work() -> int performs Bail {
print("before");
perform Bail.bail(3);
print("after"); # never runs
99
}
fn main() -> int {
let r = handle Bail with {
bail(code) -> code
} in {
work()
};
print(r);
0
}
std.throw, std.fail, and std.early_return are this pattern with names; chapter 9 uses them.
State without mutation
The classic demonstration: get and set as operations, interpreted by handlers. This is std.state's design in miniature.
metaxueffect Counter {
get() -> int
bump() -> Unit
}
fn count_to_three() performs Counter {
perform Counter.bump();
perform Counter.bump();
perform Counter.bump();
print(perform Counter.get())
}
fn main() -> int {
let @mut cell = Vec.new();
cell.push(0);
handle Counter with {
get() -> resume(cell[0])
bump() -> {
cell[0] = cell[0] + 1;
resume(())
}
} in {
count_to_three()
};
0
}
The handler closes over cell, so state lives in the handler, not the code performing the operations. Swap the handler and the same count_to_three logs every bump, or counts down, or feeds a test.
Default implementations
An operation can carry a default. Performing it with no handler in scope runs the default; installing a handler overrides it. This is how std.gpu ships a launch that works everywhere:
metaxufn run_twice(f: fn(int) -> ()) -> () {
f(0);
f(1);
()
}
effect Job {
submit(f: fn(int) -> ()) -> () = run_twice(f)
}
fn main() -> int {
# no handler: the default runs the job twice, inline
perform Job.submit(fn(i: int) -> print(i));
# a handler can virtualize the same perform completely
handle Job with {
submit(f) -> {
print("intercepted");
resume(())
}
} in {
perform Job.submit(fn(i: int) -> print(i * 100))
};
0
}
In std.gpu, Gpu.launch(n, f) defaults to a sequential CPU loop in pid order. The Metal handler intercepts the same operation and sends the kernel to a GPU. Kernel code doesn't change; chapter 13 shows it.
Streams are effects
std.stream builds iteration from a single Emit effect, following Ante's stdlib. A stream is a thunk that performs emit once per element. Consumers are handlers around calling it. Transformers are handlers that re-emit.
metaxufrom std.stream import iota, map, filter, sum;
fn main() -> int {
let total = sum(filter(map(iota(200000), fn(x: int) -> x * 2),
fn(x: int) -> x > 5));
print(total);
0
}
No intermediate collections exist here. Each element flows from iota through map's handler, filter's handler, into sum's accumulator, one at a time, by suspending and resuming. for_ in the same module adds break_/continue_ as abort-style operations of a Loop effect, which is all a for loop is.
Effect classes
Every effect belongs to a class that bounds what its handlers may do. A stack effect's handlers finish or abort on the spot; a suspend effect's handlers may hold the continuation across a suspension (this is what threads and schedulers need). The emitter checks the class, and the native backend uses it to decide when a function needs the coroutine machinery. You'll mostly never write the class; the checker infers the need.
Some effects map straight onto the runtime instead of user handlers: Thread.spawn and Mutex.lock are declared with EFFECT_SPAWN, with EFFECT_MUTEX_LOCK, and so on, binding them to pthreads natively and to real OS threads in the interpreter. Chapter 11 is about those. A handler can still interpose on them, which is how tests virtualize spawning.