Logo
Logo

Atharva Pandey/Lesson 4: The Borrow Checker — What It's Really Doing

Created Tue, 21 May 2024 16:33:00 +0000 Modified Tue, 21 May 2024 16:33:00 +0000

I used to think the borrow checker was my enemy. It rejected valid programs! It made simple things hard! It was too conservative!

Then I started maintaining a large C++ codebase and found three use-after-free bugs in one week. The borrow checker stopped looking like an enemy real fast.

Borrowing: Using Without Owning

Ownership transfer is clean but inflexible. You can’t always afford to give your data away — sometimes you just want to let another function look at it for a moment. That’s borrowing.

fn calculate_length(s: &String) -> usize {
    s.len()
    // s goes out of scope, but since it's a reference,
    // the underlying String is NOT dropped
}

fn main() {
    let s = String::from("hello");
    let len = calculate_length(&s);  // borrow s
    println!("'{}' has length {}", s, len);  // s is still valid
}

&s creates a reference — a pointer that borrows the data without taking ownership. The function sees the data, uses it, and when the reference goes out of scope, nothing happens to the original value.

The Two Rules

The borrow checker enforces exactly two rules. Every borrow checker error you’ll ever see boils down to one of these:

Rule 1: You can have either one mutable reference OR any number of immutable references. Not both.

Rule 2: References must always be valid (no dangling references).

That’s it. Two rules. The entire borrow checker is an enforcement mechanism for these two constraints.

Rule 1 in Action

Multiple immutable borrows? Fine.

fn main() {
    let s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &s;

    println!("{} {} {}", r1, r2, r3);  // all good
}

One mutable borrow? Also fine.

fn main() {
    let mut s = String::from("hello");

    let r = &mut s;
    r.push_str(" world");

    println!("{}", r);
}

Mutable + immutable at the same time? Rejected.

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;        // immutable borrow
    let r2 = &mut s;    // ERROR: can't borrow as mutable while immutable borrow exists

    println!("{} {}", r1, r2);
}

Two mutable borrows? Also rejected.

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;  // ERROR: can't have two mutable borrows

    println!("{} {}", r1, r2);
}

Why These Rules Exist

This isn’t arbitrary. These rules prevent real bugs:

Data races require three conditions: (1) two or more pointers accessing the same data, (2) at least one is writing, (3) no synchronization. The borrow checker eliminates condition (2) — you can’t have a writer while readers exist, period.

Iterator invalidation — mutating a collection while iterating over it:

fn main() {
    let mut v = vec![1, 2, 3];

    // This won't compile — and that's a GOOD thing
    // for item in &v {
    //     if *item == 2 {
    //         v.push(4);  // ERROR: can't mutate while borrowed
    //     }
    // }

    // In C++, this is undefined behavior
    // In Java, ConcurrentModificationException at runtime
    // In Rust, compile-time error
}

Non-Lexical Lifetimes (NLL)

Before Rust 2018, borrow lifetimes were tied to lexical scopes. The compiler was conservative — a borrow lasted until the end of the scope even if you stopped using it.

Modern Rust is smarter. Borrows end at their last use:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    println!("{} {}", r1, r2);
    // r1 and r2 are no longer used after this point

    let r3 = &mut s;  // fine! the immutable borrows are done
    r3.push_str(" world");
    println!("{}", r3);
}

This is called Non-Lexical Lifetimes (NLL). The compiler tracks where each reference is actually used, not just where it goes out of scope. This made a huge class of previously-rejected programs valid without weakening any safety guarantees.

The Problem: Returning References

Here’s where people get genuinely stuck:

// This won't compile
// fn first_word(s: &String) -> &str {
//     let word = String::from("temporary");
//     &word  // ERROR: returns reference to local variable
// }

The reference would outlive the data it points to. word gets dropped when the function returns, and the reference would dangle. The borrow checker catches this at compile time.

The solution: return owned data or make sure the reference points to something that outlives the function call.

// Solution 1: return owned data
fn first_word_owned(s: &str) -> String {
    s.split_whitespace()
        .next()
        .unwrap_or("")
        .to_string()
}

// Solution 2: return a reference into the input (borrows from parameter)
fn first_word(s: &str) -> &str {
    s.split_whitespace()
        .next()
        .unwrap_or("")
}

fn main() {
    let sentence = String::from("hello world");
    let word = first_word(&sentence);
    println!("First word: {}", word);
}

In solution 2, the returned &str borrows from the input s. The compiler understands this — the return value lives as long as the input. This is lifetime elision at work, and we’ll cover it in lesson 6.

Reborrowing

This is a subtle but important concept:

fn takes_ref(s: &str) {
    println!("{}", s);
}

fn main() {
    let mut s = String::from("hello");
    let r = &mut s;

    // You'd think this fails — r is a &mut, and takes_ref wants &
    // But Rust implicitly "reborrows" — creates a temporary &*r
    takes_ref(r);

    // r is still valid after the call
    r.push_str(" world");
    println!("{}", r);
}

When you pass &mut T to a function expecting &T, Rust creates an implicit immutable reborrow. The mutable reference is “paused” while the immutable reborrow exists. This is why the call doesn’t consume the mutable reference.

Borrow Checker Errors: A Survival Guide

When the borrow checker rejects your code, ask these questions in order:

1. Am I trying to mutate while something else is reading?

// Problem
let mut data = vec![1, 2, 3];
let first = &data[0];
data.push(4);  // mutation while first still borrowed
println!("{}", first);

// Fix: finish with the immutable borrow first
let mut data = vec![1, 2, 3];
let first = data[0];  // copy the value, don't borrow
data.push(4);
println!("{}", first);

2. Am I returning a reference to something that gets dropped?

// Problem
fn make_greeting() -> &str {
    let s = String::from("hello");
    &s  // s is dropped, reference dangles
}

// Fix: return owned data
fn make_greeting() -> String {
    String::from("hello")
}

3. Am I trying to move something that’s borrowed?

// Problem
let s = String::from("hello");
let r = &s;
let s2 = s;  // can't move — r still borrows s
println!("{}", r);

// Fix: drop the borrow first
let s = String::from("hello");
let r = &s;
println!("{}", r);  // use r before moving
let s2 = s;  // now the borrow is done

A Real-World Pattern

Here’s something I do constantly — collecting items from an iterator while filtering based on another collection:

fn main() {
    let all_users = vec![
        String::from("alice"),
        String::from("bob"),
        String::from("charlie"),
        String::from("diana"),
    ];

    let blocked = vec!["bob", "diana"];

    // Borrow-checker friendly: both collections borrowed immutably
    let active: Vec<&String> = all_users
        .iter()
        .filter(|user| !blocked.contains(&user.as_str()))
        .collect();

    println!("{:?}", active);  // ["alice", "charlie"]
    println!("Total users: {}", all_users.len());  // still accessible
}

Both all_users and blocked are borrowed immutably — multiple readers, no writers. The borrow checker is happy because there’s no possible data race or invalidation.

The Borrow Checker Is a Design Tool

The biggest mindset shift I had: stop thinking of the borrow checker as a validator and start thinking of it as a design advisor. When it rejects your code, it’s often telling you your data flow is confused.

If a function needs to both read and write to the same data structure — maybe that data structure is doing too much. If you’re passing mutable references five levels deep — maybe your abstraction boundaries are wrong.

The borrow checker won’t make you a better programmer automatically. But if you listen to it instead of fighting it, it’ll push you toward cleaner designs that happen to be memory-safe.

Next lesson: lifetime annotations — what 'a actually means and when you need to write it.