No need to return errors; carve holes instead
Intro
Have you ever used Result in Rust and is bothered by it not carrying enough debug information? Have you used errors in Zig and is bothered by it not having data fields?
Chances are, all paths besides the happy path can be written separately from the happy path.
In this article, we use Zig, not Koka, for demostration.
Warning: stack overflow
Try to compile the following code with Zig 0.17.x (currently the master branch).
const std = @import("std");
pub fn main() !void {
const x = entry(effect_handler, 0);
std.debug.print("Done: {}\n", .{x});
}
fn entry(h: fn (i32) i32, x: i32) i32 {
return h(x);
}
fn effect_handler(x: i32) i32 {
if (x == 10000000) {
return std.process.abort();
} else {
return entry(x + 1);
}
}
In Debug mode, the program stack overflows, because there is no tail call optimization.
In ReleaseFast mode, the program terminates with SIGABRT (the correct behavior).
Using effect handlers in programs might cause it to stack overflow. This is a limitation of the hardware and the compiler, not a limitation of the theory.
Carving a hole
Let’s say you have the following code:
const std = @import("std");
pub fn main() !void {
var i: i32 = 0;
while (i < 500) : (i += 1) {
const output = some_task();
std.debug.print("{}\n", .{output});
}
}
fn some_task() i32 {
if (some_io_succeeds()) {
return 42;
} else {
// todo
}
}
var rng = std.Random.DefaultCsprng.init("hello there");
// For demostration only.
// Real applications interact with the outside world,
// and is affected by that
fn some_io_succeeds() bool {
return rng.random().boolean();
}
When the I/O fails, you don’t know what to do. (denoted by // todo)
You might have learned that you should return an error here. But then, you need to think of an error message. That’s too much work. Instead, you ask someone else to make up a number and return it.
fn some_task(someone_else: fn() i32) i32 {
if (some_io_succeeds()) {
return 42;
} else {
return someone_else();
}
}
You have now carved a hole in some_task.
You don’t have to finish computation
In an effect handler, it is possible to just abort and ignore what comes after the handler.
fn someone_else_impl() i32 {
return std.process.abort();
}
This technique is more useful when you do something first before abort().
Group holes
You can do this, I believe in you!
const Holes = struct {
someone_else: fn () i32,
alice: fn () bool,
bob: fn (x: i32) void,
};
fn some_other_task(holes: Holes) void {
_ = holes; // use holes
}
What
Above, Holes is an “effect”. I first came to know about effects when I met Koka. Still, it’s easy to implement effects and effect handlers in many languageswhere it is not designed as a language feature.