I’d like to propose a mechanism for exposing stack-unwinding as a bounded continuations API in Rust. This would make available a limited, type-safe, and explicit non-local control flow mechanism. I believe the mechanism proposed would allow for first-class non-local control flow, but without many of the concerns that have been expressed about classical exceptions.
I’ll lay out the basic signatures in the API first:
API & Usage
A continuation type, parameterized to a value type, which has a divergent resume
method on it
pub struct Cont<T>;
impl Cont<T> {
pub fn resume(&self, value: T) -> !;
}
A top-level function, call_cc
of the following signature:
pub fn call_cc<F, T>(f: F) -> T
where F: FnOnce(&Cont<T>) -> T
In usage, this would look like the following:
...
let result = call_cc(|cont| {
...
if some_condition {
// Equivalent to "return value;"
cont.resume(value);
}
...
// Pass continuation to some helper function
// which may call cont.resume(), which would
// perform a non-local return from this closure.
try_compute_result(cont);
...
// Return from the closure without using "cont"
value
});
...
Benefits
Firstly, this API leverages Rust’s type system to ensure that the reference to the continuation cannot escape the dynamic scope of the closure activation. The closure receives an immutable reference to an opaque Cont<T>
, and Rust’s borrow-checker semantics will prevent that reference from escaping.
Secondly, every instance of a Cont<T>
corresponds to exactly one occurrence of a continuation resume point on the stack. Exceptions have the downside that the connection between the throw
and the catch
is opaque and nonspecific - a catch can occur basically anywhere up the call stack, and the thrower has no idea or real control over where it’s caught. This approach doesn’t exhibit that problem since every Cont<T>
value corresponds to a specific instance of call_cc
in the callstack.
Thirdly, this approach avoids the problem of a caller not knowing where exceptions can be thrown within a callee. Even with checked exceptions, a caller only knows that the callee may throw an exception of a particular type, not where that exception is caught and handled. This approach guarantees that the programmer can easily understand both which callee functions may invoke a non-local resume, and also an exact understanding of where those resumes lead. For example:
let result = call_cc(|cont1| { // *1
let result2 = call_cc(|cont2| { // *2
// The following call cannot perform any non-local returns,
// since neither cont1 nor cont2 are passed in.
some_func_1(x, y, z);
// The following call may resume to *1, but not *2
some_func_2(bar, bang, cont1);
// The following call may resume to *2, but not *1
some_func_3(cont2, quux);
// The following call may resume to either *2 or *1
some_func_4(cont1, cont2, ...);
});
});
In all cases, the programmer writing the code is explicitly aware of which pieces of their code are able to perform non-local returns, and where those non-local returns lead.
Issues Addressed
One of the big itches i’ve had with the Result<T,E>
(and Option<T>
) mechanisms currently available is that in many cases, it feels like I’m forced to use them for the cosmetic purposes of wrapping values which I want “bubbled up”.
In many cases, what I want (conceptually) is non-local control flow where I resume to a previous point higher up the call stack, but the only way I have of expressing that is to wrap values with disjunctive types like Result
or Option
, and then explicitly clutter my code to process those using try!
macros, match
, or if let
.
Making stack-unwinding explicit via this mechanism would address many circumstances where I’ve had to begrudgingly wrap values with disjunctives and manually thread them up the call stack.
It would also reduce compiled code size in these circumstances - fewer explicit conditionals for the sole purpose of bubbling up values. Additionally, it would enhance performance by reducing the number of branches in the code included solely for the purpose of bubbling up values.