rust · level 1

Ownership & Borrowing

Move semantics, references, and the borrow-checker rules.

150 XP

Ownership & Borrowing

Rust's ownership model is the single feature that makes the rest of the language possible — memory safety without a garbage collector, fearless concurrency, zero-cost abstractions. It's also the part that frustrates newcomers the most, because the compiler enforces rules other languages let you violate at your own risk.

The pay-off: once your code compiles, an entire class of bugs simply cannot happen. No use-after-free. No double-free. No data races between threads. The borrow checker is a strict editor that prevents footguns before they ship.

The three rules

Internalise these. Everything else follows.

  1. Every value has exactly one owner. When the owner goes out of scope, the value is dropped (memory freed, file handles closed, locks released).
  2. You may have any number of immutable references (&T) OR exactly one mutable reference (&mut T) — never both at the same time.
  3. References must always be valid. A reference can never outlive the value it points to.

That's it. The rest is mechanics.

Move semantics

Assigning or passing an owned value transfers ownership:

let s1 = String::from("hello");
let s2 = s1;            // s1 is MOVED into s2
println!("{}", s1);     // ❌ compile error: borrow of moved value `s1`

String owns a heap allocation. Letting both s1 and s2 think they own it would mean a double-free when both go out of scope. So Rust invalidates s1. The data didn't physically move — only the right to free it did.

If you want both bindings to remain usable, you have two choices:

let s1 = String::from("hello");
let s2 = s1.clone();    // explicit deep copy — both own their own allocation
println!("{} {}", s1, s2);

// or borrow:
let s1 = String::from("hello");
let s2 = &s1;           // s2 is a reference; s1 still owns the data
println!("{} {}", s1, s2);

.clone() is intentionally explicit. Cheap copies happen silently; expensive ones don't.

Copy types

Some types are cheap enough to copy by-value automatically. Anything that's a fixed-size stack value with no destructor implements Copy:

let a: i32 = 5;
let b = a;              // a is COPIED — both bindings remain valid
println!("{} {}", a, b);

Copy types: i32, u64, bool, char, f64, fixed-size tuples of Copy ((i32, bool)), fixed-size arrays of Copy. Notably not Copy: String, Vec<T>, Box<T>, anything owning a heap allocation.

Rule: if the type implements Drop (has a destructor), it can't implement Copy. The two are mutually exclusive — copying would defeat the deterministic-cleanup model.

Borrowing

A reference is a "view" into a value owned by someone else. Two flavours:

fn read(s: &String) {       // immutable reference: read-only
    println!("{}", s);
}

fn write(s: &mut String) {  // mutable reference: read-write
    s.push_str(" world");
}

let mut s = String::from("hello");
read(&s);                   // pass an immutable borrow
write(&mut s);              // pass a mutable borrow

The keyword to remember is mut: it appears three times for a mutable borrow — on the binding (let mut s), on the parameter type (&mut String), and on the call site (&mut s).

The aliasing-XOR-mutation rule

This is the rule that catches the data races:

let mut s = String::from("hello");
let r1 = &s;                // immutable borrow #1
let r2 = &s;                // immutable borrow #2 — fine, no mutation
let r3 = &mut s;            // ❌ cannot borrow `s` as mutable while
                            //    immutable borrows are alive

Why? Because if the mutable borrow grew the string and reallocated, the immutable borrows would point at freed memory. The compiler refuses to compile that situation rather than hoping you won't trigger it.

Multiple immutable references are fine — they only read, so they can't observe inconsistent state. One mutable reference is fine — there's no aliasing, so no risk of stale views. Both at once is forbidden.

Non-lexical lifetimes (NLL)

The compiler is smarter than the rules suggest. References are considered "alive" only up to their last use, not the end of the scope:

let mut s = String::from("hello");
let r = &s;                 // immutable borrow starts
println!("{}", r);          // last use of r — borrow ENDS here
let r2 = &mut s;            // ✅ fine, no overlap with r
r2.push_str(" world");

This is non-lexical lifetimes (stable since 2018). The earlier rule "you can't have & and &mut at the same time" still holds — the compiler is just precise about what "at the same time" means.

What gets dropped, when

When an owned value goes out of scope, its Drop impl runs:

{
    let s = String::from("hello");
    // s is owned in this scope
}                           // s goes out of scope → String::drop runs
                            //   → heap allocation freed

Drop order is reverse declaration order within a scope. For struct fields, drop order is declaration order. Rust gives you fully deterministic cleanup — no GC pauses, no finalizer surprises.

When the borrow checker hurts

The classic stumbling block:

let mut v = vec![1, 2, 3];
let first = &v[0];          // immutable borrow of v
v.push(4);                  // ❌ cannot borrow v as mutable
println!("{}", first);

Why this matters: Vec::push may reallocate. If it does, first would dangle. The compiler doesn't know whether this push will reallocate, so it conservatively rejects all of them.

Three idiomatic workarounds:

  1. Restructure so the borrow ends before mutation: copy out (let first = v[0] for Copy types), or finish reading before writing.
  2. Index by usize to avoid holding a reference: let first_idx = 0; then access v[first_idx] after the push.
  3. Split the data structure: hold the borrowed part and the mutated part in separate values.

'static — the special lifetime

A reference annotated &'static T is valid for the lifetime of the entire program. The most common source: string literals, which are baked into the binary:

let s: &'static str = "hello, world";

You'll see 'static in trait bounds for things that get sent across threads (T: Send + 'static). It means "doesn't borrow anything that could go out of scope before the thread finishes".

What you get in exchange

It feels like fighting the compiler at first. Then it stops feeling like fighting and starts feeling like editorial pressure: every shape of code that compiles is, by construction, free of memory-safety bugs and free of data races. You stop writing tests for "what if this is freed twice" because the question isn't legal to ask.

That's the trade. More characters at the keyboard, fewer 3 AM pages.

Tools in the wild

4 tools
  • rustupfree tier

    The Rust toolchain installer — manages stable / beta / nightly and components.

    cli
  • cargofree tier

    Rust's build system + package manager. `cargo check` is the borrow-checker feedback loop.

    cli
  • clippyfree tier

    Lint suite that flags ownership / borrowing footguns the compiler accepts.

    cli
  • Browser-based Rust compiler — paste a snippet, see the borrow checker react.

    service