Home Design Patterns RAII and Drop Guards
RAII and Drop Guards
Ownership and LifetimesTie release to scope, so it still happens on the error path nobody tested.
Purpose
Acquiring a resource creates an object. Releasing it happens when that object goes out of scope. The programmer never writes the release call, which means the programmer never forgets to write the release call.
The name is Bjarne Stroustrup's, and it is a bad one: Resource Acquisition Is Initialization describes the half of the mechanism that matters least. Destruction is where the value is. Rust's name for the same idea, a guard, is more honest about what the object is for.
Design
A guard type owns or borrows the resource and implements a destructor. In
Rust that is the Drop trait, whose drop method runs
automatically when the value's scope ends. In C++ it is the destructor. The
guard is usually a distinct type from the resource itself, returned by the
method that acquires it, which is how the type system enforces that you cannot
use the resource without holding a guard.
The critical property is that this survives every exit path. A normal return,
an early return, a ? that propagates an error, and a panic that
unwinds the stack all run the destructor on the way out.
Example
An Autobot cell has a motion interlock. While it is engaged, the arm cannot move. Running a cycle means releasing it, doing the work, and re-engaging it afterwards, and the re-engaging is not optional: an arm left live in a cell that a person can walk into is the thing the interlock exists to prevent.
The version of this code I would have written in C# has the release and the
re-engage as matched calls with the work in between, and a
try/finally around it once somebody noticed that an
exception in the middle skipped the re-engage. That works. What it relies on is
every future author of every future cycle routine remembering to write the
finally, and the failure is silent, and it only manifests on the
error path.
struct Interlock {
cell: u8,
}
struct InterlockGuard<'a> {
lock: &'a Interlock,
}
impl Interlock {
fn release(&self) -> InterlockGuard<'_> {
// energize motion for this cell
InterlockGuard { lock: self }
}
}
impl<'a> Drop for InterlockGuard<'a> {
fn drop(&mut self) {
// re-engage, however we are leaving
}
}
fn run_cycle(interlock: &Interlock) -> Result<(), Error> {
let _guard = interlock.release();
place_part()?; // early return here still re-engages
drill_holes()?; // and here
inspect()?; // and a panic in here does too
Ok(())
}
run_cycle, and no
finally. Adding a fourth step that can fail requires the author to
know nothing at all about the interlock.Seen in the Wild
This is one of the most thoroughly adopted ideas in systems programming.
C++ has std::lock_guard, unique_ptr and
fstream. Rust has MutexGuard, File,
Box, and essentially every type that owns anything. Other languages
attack the same problem with scoped syntax rather than object lifetime: Python's
with, C#'s using and IDisposable, Java's
try-with-resources, and Go's defer are all recognizably the same
pattern with the cleanup attached to a block instead of to a value.
Comparisons
- Proxy is what a guard is, structurally. A
MutexGuardis a smart reference that controls access to the thing behind it, which is the textbook definition. - Object Pool depends on this to be usable at all. A pool whose objects are returned by an explicit call will leak the first time somebody returns early; a pool that hands out guards will not.
- Facade handles the ordering half of the same problem. A Facade knows that release must follow acquire; RAII makes the release happen without anyone knowing anything.
- Arena is the bulk version. RAII releases one resource when one scope ends; an arena releases thousands when one value drops.
Why It Exists
My take
RAII exists because manual release is not a discipline problem that better programmers solve. It is a structural problem. The release call has to appear on every path out of a scope, the number of those paths grows with every early return and every error check, and the paths that get skipped are exactly the ones least likely to be exercised in testing. A rule that must be followed at every exit point will eventually be missed at one of them, and the one it is missed at will be an error path.
What makes it the strongest pattern in this group is that it converts a
recurring decision into a one-time one. You get the guard type right once, when
you write it, and then correctness is free for every caller forever. Compare
that with try/finally, which is correct but has to be
re-decided by every author of every function that touches the resource.
I have a specific reason to rate this highly. A missed release in an ordinary application is a leak that somebody notices next Tuesday. In the controller, a lock not released was a stall, and a stall on a production line is people standing around at two in the morning waiting for someone to work out which machine is holding what. I have debugged that. Every hour of it would have been saved by a type that could not be left un-released.
Criticisms
Destructors cannot fail usefully. drop returns nothing and cannot
be called with a result to check, so a guard that closes a file has nowhere to
report that the final flush failed. The standard advice is to expose an explicit
close method for callers who care and treat the destructor as a fallback, which
means the pattern has quietly stopped being automatic for exactly the cases where
failure matters.
There is a genuine footgun in Rust worth naming, because it bites people
repeatedly. Binding a guard to _ rather than to a name drops it
immediately, so let _ = mutex.lock(); acquires and releases the lock
on the same line and protects nothing at all, while
let _guard = mutex.lock(); is correct. The two lines look almost
identical and behave completely differently. Similarly, drop order is by reverse
declaration order within a scope, which is usually what you want and occasionally
is not, and the code gives no hint either way.
Finally, RAII is not a guarantee. Leaking a value is safe in Rust, so
std::mem::forget, a reference cycle, or a process that aborts rather
than unwinding will all skip the destructor. It is a very good default rather
than a proof, and any resource whose release genuinely must happen, such as a
physical interlock, needs a hardware watchdog behind it regardless of how good
the software is.