Rust Memory Safety: Ownership, Borrowing, Lifetimes, unsafe

Why Rust Memory Safety Matters

C++ and Rust both give you manual control over memory, but with a fundamental difference: C++ trusts you to get it right at runtime, while Rust verifies you got it right at compile time.

Common C++ memory bugs that Rust eliminates at compile time:

  • Use-after-free: accessing memory after it has been freed
  • Dangling references: pointers to stack variables that have gone out of scope
  • Double-free: freeing the same memory twice
  • Data races: two threads accessing the same memory with at least one write and no synchronization

Rust’s three mechanisms — ownership, borrowing, and lifetimes — work together to make these bugs impossible in safe Rust code.


Ownership and Move Semantics

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed). Assignment moves ownership by default for non-Copy types.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // ownership moves to s2

    // println!("{}", s1);  // compile error: value moved
    println!("{}", s2);     // OK
}
// s2 dropped here — memory freed automatically

Contrast this with C++, where assigning after a std::move leaves the original in a “valid but unspecified” state. Rust makes using the moved-from value a compile error, not a runtime surprise.

Uncommenting the first println! produces error[E0382]: borrow of moved value: 's1', and the compiler points at the line where the move happened. The move itself is cheap and identical to C++‘s in spirit: a String is a pointer, a length and a capacity on the stack, and moving copies those three words while the heap buffer stays where it is. The difference is what happens to the source. In C++ the moved-from std::string still exists, still runs its destructor, and must be left in a state that destructor can handle; the compiler cannot tell you that reading it is almost certainly a bug. In Rust the source is statically dead: no destructor runs for it, and no code can touch it, so there is nothing to double-free.

Moves also happen in places that are easy to miss: for s in vec consumes the vector, match on a non-Copy value can move fields out of it, and calling a method that takes self (like into_iter() or unwrap()) consumes the receiver. When the compiler reports a move you did not expect, borrowing (for s in &vec, match &value) is usually the fix, not .clone().

Clone vs Move

When you need a copy, use .clone() explicitly:

let s1 = String::from("hello");
let s2 = s1.clone();  // deep copy — both valid

println!("{} {}", s1, s2);  // OK

Simple types like integers implement Copy, so they are copied rather than moved:

let x = 5;
let y = x;  // copied, not moved
println!("{} {}", x, y);  // both valid

Ownership Through Functions

Passing a value to a function moves it into that function’s scope:

fn take_ownership(s: String) {
    println!("{}", s);
}  // s dropped here

fn borrow(s: &String) {
    println!("{}", s);
}  // s NOT dropped — caller still owns it

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

    borrow(&s);         // lend it — s still valid
    take_ownership(s);  // move it — s is gone

    // println!("{}", s);  // compile error: moved
}

Borrowing and the Borrow Checker

Rust has two kinds of references:

  • &T — shared (immutable) reference: multiple allowed simultaneously
  • &mut T — exclusive (mutable) reference: only one allowed at a time, and no shared references can coexist

The rule: shared XOR mutable. You can have many readers or one writer, never both.

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

    let a = &v;     // shared borrow
    let b = &v;     // another shared borrow — fine
    println!("{:?} {:?}", a, b);

    // Now the shared borrows are done
    let c = &mut v;     // mutable borrow — OK
    c.push(4);
    println!("{:?}", c);
}

This compiles even though a and b are still “in scope” when c is created, because since the 2018 edition the borrow checker uses non-lexical lifetimes: a borrow lasts until its last use, not until the end of the block. a and b are last used in the first println!, so by the time c exists they no longer count. Add println!("{:?}", a); at the end of main and the program is rejected, because now the shared borrow overlaps the mutable one.

The rule looks restrictive for single-threaded code, and it is worth understanding why it exists there too. A &mut guarantee means “nobody else can observe this value while I change it”. That is what makes it safe for push to reallocate the vector: no other reference can be pointing into the old buffer. The same guarantee lets the compiler assume two &mut parameters never alias, which is the optimization C++ only gets with the non-standard __restrict.

This prevents iterator invalidation, a common C++ footgun:

// C++ — undefined behavior
std::vector<int> v = {1, 2, 3};
for (auto& x : v) {
    v.push_back(x * 2);  // modifies v while iterating — UB
}
// Rust — compile error, caught before running
let mut v = vec![1, 2, 3];
for x in &v {
    v.push(*x * 2);  // error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
}

In the C++ version, push_back may reallocate, after which the loop’s hidden iterator points into freed memory; the program might print garbage, crash, or appear to work. The Rust version never runs. The usual rewrites are to collect the new elements first and extend afterwards (let extra: Vec<_> = v.iter().map(|x| x * 2).collect(); v.extend(extra);), or to loop over indices with the length captured up front.

This is also where most people first “fight the borrow checker”. My experience matches the common advice: when a borrow error feels unreasonable, the fix is usually to restructure data flow (shorter borrows, indices instead of references, splitting a struct so two fields can be borrowed separately, or split_at_mut for two halves of a slice) rather than to reach for clone() everywhere or for unsafe. RefCell moves the same shared-XOR-mutable check to runtime for cases the compiler cannot prove, and violating it there panics with already borrowed: BorrowMutError instead of failing to compile.


Lifetimes

Lifetimes ensure that references never outlive the data they point to. Most of the time Rust infers them (lifetime elision), but sometimes you need to be explicit.

The Dangling Reference Problem

fn dangle() -> &String {  // compile error
    let s = String::from("hello");
    &s  // returns reference to s, but s is dropped at end of function
}

In C++, this compiles (usually with a warning such as reference to local variable 's' returned) and runs — returning a reference to a local variable. In Rust, it’s a compile error. The first error you actually see is error[E0106]: missing lifetime specifier: a returned reference must borrow from something, the function has no reference parameters to borrow from, so there is no valid lifetime to give it. The fix is to return the owned String itself and let ownership move to the caller.

Explicit Lifetime Annotations

When a function returns a reference derived from its inputs, you tell Rust which input the output’s lifetime is tied to:

// 'a means: the returned reference lives as long as the shorter-lived input
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("xy");
        result = longest(s1.as_str(), s2.as_str());
        println!("{}", result);  // OK — used inside s2's scope
    }
    // println!("{}", result);  // error if used here — s2 dropped
}

A lifetime annotation does not change how long anything lives. It is a constraint that connects inputs to outputs so the compiler can check each call site. longest could return either argument, so its signature promises only that the result is valid while both inputs are. At the call above, that intersection is s2’s scope, and using result after the inner block is rejected with `s2` does not live long enough, even though at runtime the function would have returned s1. The compiler reasons from signatures, not from what the function body happens to do.

Most functions need no annotations thanks to the elision rules: with exactly one reference parameter, the output borrows from it; with &self, the output borrows from self. You write lifetimes only when those rules cannot decide, as with two reference inputs.

Lifetimes in Structs

If a struct holds a reference, it needs a lifetime parameter:

struct Excerpt<'a> {
    text: &'a str,  // this struct cannot outlive the string it borrows from
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let excerpt = Excerpt { text: first_sentence };
    println!("{}", excerpt.text);
}

The C++ equivalent is a struct holding a std::string_view, which compiles happily when the viewed string is destroyed first. Rust will not let excerpt outlive novel. Structs with lifetime parameters are the right tool for short-lived views such as parsers and iterators; for data that must be stored long-term, owning types (String, Vec) avoid spreading lifetime parameters through every type that contains the struct.


unsafe Rust

unsafe blocks tell the compiler: “I’ve verified the invariants you can’t check.” Five things require unsafe:

  1. Dereferencing raw pointers (*const T, *mut T)
  2. Calling unsafe functions (including C functions via FFI)
  3. Accessing mutable static variables
  4. Implementing unsafe traits
  5. Accessing fields of unions
// FFI — calling a C function
// (Rust 2024 edition requires `unsafe extern "C"`; accepted since Rust 1.82)
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("{}", abs(-3));  // 3
    }
}
// Raw pointers — manual memory management
fn main() {
    let mut x = 42;
    let raw = &mut x as *mut i32;

    unsafe {
        *raw = 100;  // dereference raw pointer
    }

    println!("{}", x);  // 100
}

Rule of thumb: keep unsafe blocks as small as possible, document what invariant makes this safe, and test the boundary thoroughly. The unsafe block doesn’t disable the borrow checker for surrounding safe code — it only unlocks the five operations above.

Creating the raw pointer is safe; only dereferencing it needs unsafe, because that is the moment the compiler has to trust you that the pointer is valid, aligned and not aliased by a live &mut. The idiomatic pattern is a safe function that wraps a small unsafe block and checks the preconditions itself, so callers cannot misuse it. Vec, String and HashMap are built exactly this way: unsafe inside, safe API outside. When a safe Rust program crashes with memory corruption, the bug is by definition in some unsafe code, often in a dependency, which narrows the search dramatically compared with C++, where any line could be responsible. Miri (cargo +nightly miri test) interprets code and detects undefined behavior in unsafe blocks, much as sanitizers do for C++.


Concurrency — Send and Sync

Rust’s thread safety is enforced by two marker traits:

  • Send: a type is safe to transfer to another thread (ownership moves across threads)
  • Sync: a type is safe to share references across threads (&T can be sent to another thread)
use std::thread;

let v = vec![1, 2, 3];  // Vec<i32> is Send

let handle = thread::spawn(move || {
    println!("{:?}", v);  // v moved into new thread
});

handle.join().unwrap();

Shared Mutable State — Arc and Mutex

To share data across threads, combine Arc (atomic reference counting) with Mutex (mutual exclusion):

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());  // always 10
}

The equivalent in C++ requires manual discipline. In Rust, using Rc instead of Arc here would be a compile error — Rc is not Send, so the compiler refuses to let you move it into a thread. The message reads `Rc<Mutex<i32>>` cannot be sent between threads safely.

Two design choices make this work. First, Mutex<T> contains the data it protects, so there is no way to reach the counter without calling lock(); in C++, std::mutex sits next to the data and nothing stops code from touching the data without locking. Second, the lock guard returned by lock() is the only handle to the data and unlocks when dropped, so forgetting to unlock is impossible. lock() returns a Result because a thread that panics while holding the lock “poisons” it; the .unwrap() here turns that into a panic in the next thread, which is a reasonable default for examples but worth handling deliberately in servers.

For a simple counter, std::sync::atomic::AtomicUsize with fetch_add would avoid the lock entirely, just as std::atomic<int> would in C++.


Ownership vs C++ Smart Pointers

ConceptRustC++ Equivalent
Unique ownershipDefault / Box<T>std::unique_ptr<T>
Shared ownershipArc<T>std::shared_ptr<T>
Non-thread-safe sharedRc<T>No direct equivalent (std::shared_ptr’s count is always atomic)
Mutable shared accessMutex<T> / RwLock<T>std::mutex + manual locking
Raw pointersunsafe { *ptr }Raw pointer dereferencing

The key difference: in Rust, the type system enforces correct usage at compile time. In C++, misuse compiles fine and fails at runtime.

The Rc row is the one without a real C++ counterpart. std::shared_ptr always updates its reference count atomically (libstdc++ can skip the atomics when a program is not linked with threads), because C++ has no way to prove that a pointer will never cross threads. Rust can prove it, so it offers the cheaper non-atomic Rc and simply refuses to compile code that sends one to another thread. Note also that shared_ptr’s atomic count makes copying the pointer thread-safe, not the pointed-to object; the Rust equivalent of shared_ptr<T> shared between threads is Arc<T>, and mutation still needs Arc<Mutex<T>>.


Where C++ habits collide with the borrow checker

Most of the friction C++ programmers feel in their first Rust weeks comes from two patterns that C++ allows and Rust rejects. The first is keeping a reference into a collection while modifying it, such as holding &v[0] and then calling v.push(...). In C++ that compiles and may dangle after a reallocation; in Rust it is a borrow error. The fix is usually to copy the value out, store an index instead of a reference, or finish the read before the write.

The second is object graphs with back pointers: doubly linked lists, trees with parent links, observers that point at their subject. Writing them with plain references fights the single-owner rule. The idiomatic options are to store nodes in a Vec and link them by index, or to use Rc<RefCell<T>> for the owning direction and Weak for the back link, which is the same shape as shared_ptr and weak_ptr in C++, with borrow checking moved to run time.


Frequently Asked Questions (FAQ)

Q. Does the borrow checker also prevent memory leaks and deadlocks?

A. No. Safe Rust rules out use-after-free, dangling references and data races, but leaking memory is considered safe: an Rc or Arc reference cycle is never freed, and std::mem::forget leaks on purpose. Deadlocks are also possible, for example two threads locking two Mutexes in opposite order. Break cycles with Weak and keep a consistent lock order, just as you would in C++.