Skip to Content
Course content

19: The Borrow Checker in Practice

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

By now, you've probably run into the borrow checker. You've likely felt that specific frustration where you know the code is logically sound, but the compiler is screaming at you that you're violating some rule about mutable and immutable borrows. It feels like the compiler is fighting you, but in reality, it's preventing a memory disaster that would have taken you three days to debug in C++.

Let's look at a scenario I see all the time. Imagine we're building a simple user management system. We want to find a specific user in a list and, if they meet a certain condition, add a "log" entry to the end of that same list.

struct User {
    id: u32,
    name: String,
    is_active: bool,
}

fn process_users(users: &mut Vec<User>) {
    for user in users.iter() {
        if !user.is_active {
            println!("User {} is inactive. Adding a system alert.", user.name);
            // We want to add a dummy "System" user as an alert
            users.push(User {
                id: 0,
                name: "SYSTEM_ALERT".to_string(),
                is_active: true,
            });
        }
    }
}

If you try to compile this, Rust will hit you with a wall of text. The core of the error is: cannot borrow `*users` as mutable because it is also borrowed as immutable. To a beginner, this feels contradictory. You passed users as &mut Vec, so why is it complaining about an immutable borrow?

The Conflict Between Iterators and Mutation

Here is what's happening under the hood. When you call users.iter(), you are creating an immutable borrow of the entire vector. The user variable in your loop is a reference pointing directly into the memory owned by that vector. As long as that loop is running, the borrow checker considers the vector "locked" for reading.

Then, inside the loop, you call users.push(...). Pushing to a vector requires a mutable borrow. But here's the danger: if the vector is full and needs to grow, Rust has to allocate a larger chunk of memory elsewhere and move all the existing elements there. If Rust allowed this, the user reference you're currently holding would suddenly point to old, deallocated memory. That's a use-after-free bug, and Rust's primary job is to make those impossible.

Decoupling the Search from the Modification

To fix this, we have to stop holding a reference to an element while we are trying to change the structure of the collection. I usually handle this in one of two ways, depending on the goal. If you just need to know which element to change or act upon, don't store the reference—store the index.

By using .position() or a simple range loop, we can find the "where" and then perform the "what" separately.

fn process_users(users: &mut Vec<User>) {
    let mut alert_indices = Vec::new();

    // Phase 1: Immutable borrow. 
    // We find the indices of inactive users and store them in a separate list.
    for (index, user) in users.iter().enumerate() {
        if !user.is_active {
            alert_indices.push(index);
        }
    }

    // Phase 2: Mutable borrow.
    // The immutable borrow from the loop above has ended.
    for idx in alert_indices {
        println!("User {} is inactive. Adding a system alert.", users[idx].name);
        users.push(User {
            id: 0,
            name: "SYSTEM_ALERT".to_string(),
            is_active: true,
        });
    }
}

Notice the shift. In the first loop, we borrow users immutably. Once that loop finishes, that borrow ends. Now the vector is "unlocked," and we are free to borrow it mutably to push new elements. I've effectively split the logic into a "Read Phase" and a "Write Phase."

Another quick tip: if you only need to mutate the elements themselves and not change the size of the vector (no pushing or popping), use iter_mut(). But the moment you need to change the capacity or length of the collection, you must ensure no references to the interior of that collection are still alive.




📋 Practical Task

Exercise: Fixing the Message Buffer Overflow

You are working on a chat application. You have a MessageBuffer that stores a list of strings. You need to implement a function called cleanup_buffer that iterates through the messages and, if it finds a message that is "too long" (over 100 characters), it should remove that message and add a "Message truncated" warning to the end of the buffer.

The following code is broken and will not compile. Your task is to refactor it so that it satisfies the borrow checker while maintaining the logic: find the long messages, remove them, and push a warning for each one removed.

struct MessageBuffer {
    messages: Vec<String>,
}

impl MessageBuffer {
    fn cleanup_buffer(&mut self) {
        // BUG: This loop borrows self.messages immutably
        for msg in self.messages.iter() {
            if msg.len() > 100 {
                // BUG: This attempts to borrow self.messages mutably 
                // while the iterator above is still active.
                self.messages.push("Warning: Message truncated".to_string());
                
                // Note: You'll also need to figure out how to remove 
                // the original long message without breaking the loop!
            }
        }
    }
}

fn main() {
    let mut buffer = MessageBuffer {
        messages: vec![
            "Hello!".to_string(),
            "This is a very long message...".repeat(10), // Over 100 chars
            "Short msg".to_string(),
        ],
    };
    buffer.cleanup_buffer();
    println!("Buffer size: {}", buffer.messages.len());
}

Requirements:

  • The final code must compile.
  • For every message over 100 characters, the original must be removed and a "Warning: Message truncated" string must be added to the end.
  • Avoid using unsafe blocks.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.