Prior art
In Asynchronous clean-up @withoutboats offers the do { .. } final { .. }
feature to allow executing code on exit, whether that exit occurs on the regular path, the error path, or via panic.
In Pre-pre-pre-RFC: implicit code control and defer statements, @zetanumbers proposes a fairly elaborate solution including many different aspects.
This discussion wishes to focus on "just" defer
.
Motivation
As discussed in @withoutboats' post, AsyncDrop
is an elusive dream, whereas a surefire way to execute code on scope exit -- no matter why -- could plausibly be one of the building bricks to achieving cancellation in asynchronous contexts.
Today, guaranteeing execution on scope exit in Rust requires either:
- Nesting that code -- such as with
catch_unwind
-- so as to have a single exit point. - Using the Scope Guard pattern, a mainstay in C++ as well.
Unfortunately, neither solution can be said to be practical:
- Nesting the code into another function/lambda makes control-flow compose poorly.
- While Scope Guard works well in C++, in Rust it runs afoul of borrowing rules.
The functionality the user wishes to express is somewhat trivial, it's just hard to express in current Rust code, without jumping through hoops and writing much boilerplate.
To block or not to block
@withoutboats proposes what is essentially a try { ... } finally { ... }
block. It's a well-known construct, however it is typically painful to use: pick either of uninitialized variables or rightward drift.
By comparison, a defer
statement is more composable: any number of defer
statements may be weaved within the code, at appropriate places. This makes it more flexible, once again relieving the user from jumping through hoops.
Anatomy of defer
Basics of defer
At its simplest, defer
is just a way to insert a piece of code so that runs whenever the scope exits, no matter how the scope exits.
Just like the compiler inserts a call to drop
, it would insert a call to the deferred action. The one benefit from a built-in language feature being that borrow-checking will know to defer the start of the borrow to the moment the code is injected.
If we think of desugaring, it means:
// Original source code
let mut some_queue = ...;
while Some(item) = some_queue.pop() {
defer || some_queue.push(item);
if item.is_special() {
some_queue.push(SpecialItem);
}
}
Will be turned into:
// Original source code
let mut some_queue = ...;
while Some(item) = some_queue.pop() {
if item.is_special() {
some_queue.push(SpecialItem);
}
// Defers & Drops.
some_queue.push(item);
}
Note that in this example, defer
refers to some_queue
mutably yet no borrow checking issue arises.
Why did I use a closure syntax? I'll become apparent later, just ignore it for now.
Defer/Drop ordering
Given that Drop
drops a value -- making it unusable -- it is evident that defer statements must precede drops, since they access the values.
That is, upon exiting a scope:
1. The scope current defer
statements are executed, in reverse order.
2. The scope current values are dropped, in reverse order.
The defer
statements are interleaved with the calls to drop
as if they were themselves the Drop implementation of an anonymous variable created at the point of the defer
statement.
Dismissal
A common functionality found in Scope Guard libraries is the ability to "cancel" a deferred statement.
This is often used to maintain invariants: a Scope Guard is created to restore an invariant, the invariant is then broken, the algorithm executed, and the invariant restored at which point the Scope Guard is superfluous and must be dismissed.
It's notable that this very functionality is already built in Drop
, and it thus seems appropriate to simply build it into defer
as well:
let guard: DeferGuard<'_> = defer || abandon_hope();
// break things down, and put them back together.
guard.forget();
Where DeferGuard
contains a reference to the "drop-flag" of the defer
statement. The most direct implementation would be for the drop-flag to be referred to be &mut bool
, but there may be a case for &Cell<bool>
or even &AtomicBool
.
Result handling
Apart from the flexibility, a key reason to use defer
is to execute fallible statements, since Drop
is fallible instead.
This leaves us in a pickle: if the defer
statement is executed on the failure path, and fail itself, which error should be surfaced to the user?
This calls for the ability to inspect and modify the result -- prior to it being returned -- which a defer
can request by simply taking an argument:
defer |result| result.and_then(|| take_action());
The type of result
is simply the return type of the current enclosing function (or lambda).
There are multiple options available as to which defaults to pick, and how to handle that. A solid starting choice would be for the defer
closure:
- To either take no argument, in which case it must be infallible, and return
()
or diverge. - Or to take one argument, in which case it must return the same type as the enclosing function.
Other choices are available, such as defaulting to returning the error from the enclosing function... but it may be less obvious to the reader, and it can always be added later.
Panic handling
If a defer
closure takes as an argument the result to enclosing function... then how is it invoked when unwinding? No such result exists!
This is a pickle, and a possible argument for not allowing inspection of the result, though such would be a missed opportunity.
A reasonable option would be double-wrapping. The result of the enclosing function is wrapped into a std::thread::Result<...>
as per the return type of catch_unwind
, and that is what is passed to the defer
closure.
A facility to resume unwinding -- such as let result = result.unwrap_or_resume_unwind();
-- would help with ergonomics, though let result = result.map_err(|e| resume_unwind(e)).unwrap();
is already possible if a tad clunky.
Async
Since the original motivation of the original post was asynchronous cancellation, the defer
closure should potentially be async
.
It seems simple enough to allow annotating the closure -- defer async || ...
-- and in doing so allow differentiating between closures which require async
and those which don't. The desguaring would require adopting the poll_cancel
extension of Future
, and is a whole other topic.
Conclusion
The Scope Guard pattern is a mainstay of systems programming languages such as C++, D, and other languages such as Go due to its flexibility in expressing "on exit" code, which leads to it being favored over try
/catch
and co.
The pattern cannot really be implemented as a library in Rust due to borrow-checking issues, hence why a language construct would make the more sense.
Furthermore, as a language construct, it becomes possible to get more out of it: namely inspecting return values as they go.