Posted in Engineering

Memory Safety Explained: How Rust’s Borrow Checker Prevents the Bugs That Still Plague C and C++

Buffer overflows, use-after-free, dangling pointers, double frees — these bug classes have been showing up in CVE databases since before most working engineers were born, and they’re still there. A 2023 joint guidance from CISA, the NSA, the FBI, and international partners states plainly that “two-thirds of reported vulnerabilities in memory unsafe programming languages still relate to memory issues.” Rust’s answer isn’t a runtime garbage collector or extra discipline — it’s a compile-time checker that refuses to build programs with certain classes of memory bugs at all. Here’s how that actually works.

At a Glance

ApproachStrengthsBest For
Manual memory management (C/C++)Maximum control, zero forced overheadLegacy systems, environments where a GC or borrow checker genuinely can’t fit
Garbage collection (Java, Go, Python)Simple mental model, no manual frees, no borrow-checker learning curveMost application code where occasional GC pauses are acceptable
Rust ownership + borrow checkerCompile-time memory safety with no runtime GC pause, zero-cost abstractionsSystems code, performance-critical services, and anywhere memory-safety CVEs are unacceptable

Why C and C++ Keep Producing These Bugs

In C and C++, nothing in the type system tracks who owns a piece of memory or how long a pointer into it stays valid. A pointer can outlive the memory it points to (use-after-free), two pointers can alias the same memory while one mutates it (data races and aliasing bugs), and free() can be called twice on the same block (double free). The compiler has no concept of “this pointer is no longer valid” — that bookkeeping is entirely the programmer’s job, by convention, across every function that touches the pointer. CISA’s guidance is a response to just how often that convention fails in practice, at scale, across the industry.

Ownership: One Owner at a Time

Rust’s foundational rule is that every value has exactly one owner, and when that owner goes out of scope, the value is dropped (its memory freed) automatically — no free() call required, and no way to forget one. Moving a value (assigning it to a new variable, passing it by value) transfers ownership; the old variable becomes invalid and the compiler rejects any later use of it. This alone eliminates double frees and most use-after-free bugs, because there’s only ever one place responsible for cleanup, and the compiler tracks when that place goes away.

Borrowing: References Without Giving Up Ownership

Ownership alone would force constant copying, so Rust also lets code “borrow” a value through a reference (&T for a shared, read-only reference, &mut T for an exclusive, writable one) without taking ownership. The official Rust book states the core rule compactly: at any given time, you can have either one mutable reference or any number of immutable references to the same value — never both, and never two mutable references at once. This is what the borrow checker actually enforces, and it’s what rules out data races at compile time: immutable readers can never be surprised by a concurrent writer, because the compiler won’t let a writer and readers coexist in the first place.

Here is a real violation of that rule, and the exact error rustc 1.97.0 produces for it:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];   // immutable borrow starts here
    v.push(4);           // ERROR: tries to mutate while borrowed
    println!("{}", first);
}
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &v[0];
  |                  - immutable borrow occurs here
4 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{}", first);
  |                    ----- immutable borrow later used here

This is not a pedantic compiler being overly cautious: Vec::push can reallocate the vector’s backing buffer to make room, which would leave first pointing at freed memory — a use-after-free, caught before the program ever runs. The fix is to not hold a live borrow across the mutation. Since i32 implements Copy, the simplest fix is to copy the value instead of borrowing it:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = v[0];    // copies the i32, no outstanding borrow
    v.push(4);           // fine -- no live reference into v
    println!("{}", first); // prints 1
}

This version compiles and runs cleanly, printing 1 (verified with rustc 1.97.0). Note also that the Rust book points out a reference’s scope runs from where it’s introduced to its last use, not to the end of the enclosing block — so in cases where you do need the original reference, simply moving the println! before the mutation, or wrapping the borrow in its own inner scope, also fixes the error without copying.

Lifetimes: Making “How Long Is This Valid” Explicit

Every reference has a lifetime — the scope for which it’s guaranteed valid — and the borrow checker’s job is to confirm no reference ever outlives the data it points to. Most of the time lifetimes are inferred silently. They become visible as explicit annotations ('a) only when the relationships between multiple references are ambiguous to the compiler, most commonly in a function signature that returns a reference derived from more than one input. The classic example from the Rust book:

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

This fails to compile with error[E0106]: missing lifetime specifier, because the compiler can’t tell whether the returned reference is tied to x‘s lifetime or y‘s. The fix makes that relationship explicit:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

This doesn’t change how long anything actually lives at runtime — lifetime annotations are purely a compile-time contract. It tells the compiler the returned reference is valid only as long as both x and y are valid, so if a caller uses the result after the shorter-lived of the two arguments has already been dropped, the compiler rejects it with error[E0597]: ... does not live long enough instead of letting a dangling reference slip through — exactly the class of bug that shows up as a use-after-free CVE in C and C++ code.

Comparison Table

DimensionC / C++GC Languages (Java, Go)Rust
Use-after-freePossible; caught only by testing/sanitizers, if at allPrevented at runtime by the collectorPrevented at compile time by the borrow checker
Double freePossibleNot applicable (GC owns freeing)Prevented by single-ownership + drop
Runtime overheadNone beyond the code you writeGC pauses and heap overheadNone — checks happen at compile time
Learning curveLow syntax, high bug riskLowSteep initially (borrow checker), easier over time

Trade-offs

  • The borrow checker rejects some programs that are actually memory-safe but that it can’t prove are safe using its current rules — this is why Rust also offers an explicit, clearly-marked unsafe escape hatch for the rare cases that need it.
  • Fighting the borrow checker is a real, well-documented onboarding cost for teams new to Rust; it front-loads work that C/C++ would otherwise defer to production incidents.
  • Because the checks are static, there’s no runtime cost and no GC pause — a meaningful advantage over garbage-collected languages in latency-sensitive systems code.

Bottom Line

Rust doesn’t eliminate memory bugs by making programmers more careful — it eliminates an entire category of them by making the compiler refuse to build programs where ownership is ambiguous or a reference could outlive its data. That trade (a steeper initial learning curve for compile-time guarantees with zero runtime cost) is precisely the trade CISA and the NSA are now telling the industry to make more often, given how persistently memory bugs dominate vulnerability reports in memory-unsafe languages.

FAQ

Does the borrow checker add runtime overhead? No. All borrow and lifetime checking happens at compile time; the compiled binary has no extra runtime bookkeeping for it.

Can Rust still have memory bugs? Inside unsafe blocks, yes — Rust’s guarantees don’t extend past code explicitly marked unsafe, which exists for cases like FFI or low-level data structures the checker can’t verify. Outside unsafe, the compiler won’t accept code that violates the ownership/borrowing rules.

Is the borrow checker the same as a garbage collector? No. A garbage collector tracks and frees memory at runtime; the borrow checker is a static analysis that runs once, at compile time, and then disappears — the compiled program has no GC.

Why do I need explicit lifetime annotations sometimes? Only when the compiler’s lifetime-elision rules can’t infer a unique relationship between input and output references on their own — most commonly in functions that return a reference derived from more than one reference parameter.

Does this mean Rust programs can’t leak memory? The borrow checker targets use-after-free, double-free, and dangling references — not all forms of leaks. It’s possible, for example, to leak memory intentionally or via reference cycles in Rc/Arc-based structures.

Sources


Discover more from Software Online Review

Subscribe to get the latest posts sent to your email.

Unknown's avatar

Author:

Innovations, logic & design thinking