Skip to Content
Course content

220: RAII Guards Pattern

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this exact pattern trip up a lot of developers moving from C++ or Java into Rust. They try to manage resources manually—calling a close(), unlock(), or rollback() method at the end of a function—and then they discover the "early return" problem.

Take a look at this snippet. Imagine we're writing a simple wrapper for a database transaction. We want to make sure that if anything goes wrong, the transaction is rolled back so we don't leave the database in a corrupted state.

struct Transaction {
    id: u32,
}

impl Transaction {
    fn start(id: u32) -> Self {
        println!("Transaction {} started", id);
        Self { id }
    }

    fn commit(self) {
        println!("Transaction {} committed to disk", self.id);
    }

    fn rollback(self) {
        println!("Transaction {} rolled back!", self.id);
    }
}

fn process_payment(amount: i32) -> Result<(), String> {
    let tx = Transaction::start(101);

    if amount < 0 {
        // Oops! We return early here.
        return Err("Negative amount".to_string());
    }

    // Imagine more complex logic here that could also return Err(...)
    
    tx.commit(); 
    Ok(())
}

The Early Return Leak

If you run process_payment(-10), you'll notice something missing: the rollback never happens. Because we returned early using the return keyword (or the ? operator in a real app), the execution never reaches tx.commit(), and we never called tx.rollback(). The transaction is now "hanging" in the database, potentially holding locks on rows that other users need.

You could manually call tx.rollback() before every single error return, but that's a nightmare to maintain. In a large function with ten different error paths, you'll eventually forget one. That's where the RAII (Resource Acquisition Is Initialization) Guard pattern comes in.

Automating Cleanup with the Drop Trait

In Rust, the "Guard" pattern relies on the Drop trait. Instead of relying on the programmer to remember to clean up, we tie the cleanup logic to the lifetime of the object itself. When the guard goes out of scope—regardless of whether the function finished successfully, returned an error, or even panicked—Rust automatically calls drop().

Here is how we rewrite that transaction logic to be bulletproof:

struct Transaction {
    id: u32,
    committed: bool, // We need to track if we already committed
}

impl Transaction {
    fn start(id: u32) -> Self {
        println!("Transaction {} started", id);
        Self { id, committed: false }
    }

    fn commit(mut self) {
        println!("Transaction {} committed to disk", self.id);
        self.committed = true;
    }
}

impl Drop for Transaction {
    fn drop(&mut self) {
        if !self.committed {
            println!("Transaction {} rolled back automatically!", self.id);
        }
    }
}

fn process_payment(amount: i32) -> Result<(), String> {
    let tx = Transaction::start(101);

    if amount < 0 {
        return Err("Negative amount".to_string()); 
        // tx goes out of scope here, drop() is called, rollback happens!
    }

    tx.commit(); 
    // tx is consumed by commit(), but because we set committed = true,
    // the drop logic knows not to roll back.
    Ok(())
}

Why this is the "Rust Way"

The beauty here is that the Transaction struct is now a "Guard." It protects the resource. I personally love this pattern because it moves the responsibility of correctness from the caller (the person writing the business logic) to the type (the person designing the API).

Notice how process_payment is now much cleaner. It doesn't have to care about the failure state of the transaction; it only cares about the "happy path." If the function exits for any reason, the Drop implementation acts as a safety net.

One small detail: I added a committed boolean. This is a common requirement for guards. Since drop is called even after a successful commit(), we need a way to tell the guard, "Hey, everything went fine, you don't need to trigger the emergency cleanup."




📋 Practical Task

Building a Scoped Temporary File Guard

In this exercise, you will implement a TempFileGuard that ensures a temporary file is deleted from the disk when the guard is dropped, unless the user explicitly decides to "persist" the file.

Requirements:

  • Create a struct TempFileGuard that holds a path: String and a persist: bool.
  • Implement a new(path: &str) method that prints "Creating temp file at [path]" and returns the guard.
  • Implement a persist(&mut self) method that sets the persist flag to true and prints "File marked for persistence".
  • Implement the Drop trait for TempFileGuard. If persist is false, it should print "Deleting temp file at [path]". If true, it should print "Keeping temp file at [path]".

Test your implementation:

  1. Create a scenario where the guard is created and then the function returns early (simulate an error), verifying the file is deleted.
  2. Create a scenario where persist() is called before the guard goes out of scope, verifying the file is kept.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.