01 · Ownership & Borrowing Deep Dive¶
Level 1 introduced ownership as "every value has one owner, and assignment
moves non-Copy values." That's true, but it only gets you so far — the
moment you want two parts of a program to look at the same data without
constantly moving or cloning it, you need borrowing: references that let
code read (or even modify) a value without taking ownership of it. This
module covers the actual rules the borrow checker enforces, why they exist,
and the specific ways beginners get stuck fighting the compiler.
Why borrowing exists¶
Without it, sharing data would force a choice between two bad options: move ownership everywhere (so only one function can ever touch a value again) or clone everywhere (paying a runtime cost just to read something). References give you a third option — temporary, checked access — with zero runtime overhead, because all the checking happens at compile time.
fn print_length(s: &String) {
// `s` is a reference -- this function borrows, it doesn't own
println!("length: {}", s.len());
}
fn main() {
let name = String::from("Ferris");
print_length(&name); // pass a reference with `&`
print_length(&name); // fine -- name is still owned by main, can be borrowed again
println!("{name}"); // still valid -- ownership never left main
}
Compare this to passing name by value: the function would take ownership,
and the second call plus the final println! would both fail to compile.
The rule: many readers, or one writer, never both¶
This is the single rule that generates most "fighting the borrow checker"
moments, so it's worth stating precisely: at any point in the code, for a
given value, you may have either any number of immutable references
(&T) or exactly one mutable reference (&mut T) — never both kinds at
the same time.
fn main() {
let mut score = 10;
let r1 = &score; // immutable borrow
let r2 = &score; // another immutable borrow -- fine, reading is safe to share
println!("{r1} {r2}"); // 10 10
let r3 = &mut score; // mutable borrow -- only allowed once r1/r2 are done being used
*r3 += 5;
println!("{r3}"); // 15
}
The compiler tracks a reference's last use, not its scope — r1 and
r2's last use is the println! above, so r3 is allowed afterward. This
is called non-lexical lifetimes, and it's why the rule feels less rigid
than "no mixing borrows in the same block."
fn main() {
let mut score = 10;
let r1 = &score;
let r3 = &mut score; // ERROR: cannot borrow `score` as mutable
// because it is also borrowed as immutable
println!("{r1} {r3}");
}
Here r1 is still alive (it gets used in the final println!), so the
mutable borrow on the next line overlaps with it — that's the conflict the
compiler rejects.
Why the rule exists: preventing data races at compile time¶
The "one writer XOR many readers" rule is exactly the condition that prevents data races, generalized to single-threaded code too: if two parts of the code can both see a value and one of them can change it out from under the other, you get bugs that are notoriously hard to reproduce (iterator invalidation, stale reads, use-after-free in other languages). Rust makes this a compile error instead of a runtime surprise.
fn main() {
let mut v = vec![1, 2, 3];
for item in &v {
// v.push(4);
// ERROR: cannot borrow `v` as mutable because it is also
// borrowed as immutable (by the `for` loop's iterator)
println!("{item}");
}
v.push(4); // fine -- the immutable borrow from the loop has ended
println!("{:?}", v); // [1, 2, 3, 4]
}
The commented-out v.push(4) would resize the vector while iterating over
it — in a language without this check, that can invalidate the iterator and
either panic or read garbage. Rust catches it before the program ever runs.
Mutable references must be exclusive¶
fn add_one(n: &mut i32) {
*n += 1; // `*` dereferences to get at the value behind the reference
}
fn main() {
let mut x = 5;
add_one(&mut x);
add_one(&mut x);
println!("{x}"); // 7
}
Only one &mut to a value can exist at a time, and it can't coexist with
any &. This isn't a restriction to work around — it's what lets the
compiler (and you, reading the code) reason locally: if you hold &mut x,
you know with certainty nothing else can read or write x while you have
it.
Dangling references are impossible¶
A classic C/C++ bug is returning a pointer to a local variable that gets freed when the function returns. Rust's borrow checker rejects this at compile time via lifetimes (the full topic of the next module) — here's the shape of the error you'd see:
fn dangle() -> &String { // ERROR: missing lifetime specifier
let s = String::from("hi");
&s
// `s` is dropped here -- the reference would point at freed memory
}
error[E0106]: missing lifetime specifier
help: this function's return type contains a borrowed value, but
there is no value for it to be borrowed from
The fix isn't a lifetime annotation — it's to return the owned value instead, letting ownership move out of the function:
fn no_dangle() -> String {
let s = String::from("hi");
s // ownership moves out -- no reference, nothing left behind to dangle
}
fn main() {
let s = no_dangle();
println!("{s}"); // hi
}
Borrowing struct fields independently¶
The borrow checker (since the 2021 edition's "disjoint closure captures" and general field-level borrow tracking) understands that borrowing two different fields of the same struct at once is fine, even though it looks like "two mutable borrows":
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Point { x: 1, y: 2 };
let x_ref = &mut p.x;
let y_ref = &mut p.y; // fine -- disjoint fields, not the same data
*x_ref += 10;
*y_ref += 20;
println!("{}, {}", p.x, p.y); // 11, 22
}
This only works for direct field access, not through a method call — a
method taking &mut self borrows the whole struct, because the compiler
can't see inside the method to know it only touches one field.
Slices: borrowing part of a collection¶
A slice (&[T] or &str) is a reference to a contiguous run of elements
without owning them — it's how you borrow "part of" a Vec or String.
fn main() {
let numbers = vec![10, 20, 30, 40, 50];
let middle: &[i32] = &numbers[1..4]; // borrow indices 1, 2, 3
println!("{:?}", middle); // [20, 30, 40]
let text = String::from("hello world");
let first_word: &str = &text[0..5]; // borrow just "hello"
println!("{first_word}"); // hello
}
Slices are why fn print_length(s: &str) is generally preferred over
fn print_length(s: &String) for function parameters: a &str accepts a
borrow of a String, a string literal, or a slice of either, while &String
only accepts the first.
How It Actually Works¶
References carry zero runtime metadata beyond the address they point to — a
&i32 is exactly one machine word, exactly like a raw pointer, and
dereferencing it is a single load instruction with no bounds check, no
reference count, no lock. All the safety comes from a static analysis
called the borrow checker, which runs on MIR (mid-level IR, after your
code is desugared and control-flow-graphed) and computes, for every
reference, the region of program points over which it's "alive" — its
lifetime. The rule the borrow checker enforces is: at any single program
point, either one &mut reference exists, or any number of & references
exist, never both — and it proves this by tracking, for each borrow, exactly
where that borrow's lifetime region ends (its last use, under NLL —
non-lexical lifetimes — not necessarily the end of the enclosing scope).
None of this analysis leaves any trace in the compiled binary; by the time
LLVM sees the code, all that's left is plain loads and stores.
Field-level "split borrows" (&mut p.x and &mut p.y simultaneously) work
because the borrow checker can see the concrete field projections at compile
time and prove the two memory regions are disjoint — but a method call
p.some_method() is opaque to that analysis (the compiler doesn't inline
and re-derive field access through arbitrary function calls before borrow
checking), so it must conservatively borrow the entire struct. Slices
(&[T], &str) are two words — pointer plus length — computed once at
slicing time; indexing then still bounds-checks against that stored length
at each access, which is the one small runtime cost slices do pay in
exchange for memory safety without a garbage collector.
Cheat sheet¶
| Situation | Allowed? |
|---|---|
Multiple &T to the same value |
Yes |
One &mut T to the same value |
Yes |
&T and &mut T to the same value at once |
No |
Two &mut T to the same value at once |
No |
&mut to two different fields of the same struct |
Yes |
| Returning a reference to a local variable | No (dangling) — return owned data instead |
| Reference outliving the data it points to | No — caught at compile time |
Exercise¶
Write a function fn longest_word(text: &str) -> &str that returns the
longest whitespace-separated word in text as a slice (no cloning). Then
write a function fn shout(word: &mut String) that appends "!" to a
mutable String in place. In main, call longest_word on a sentence and
print the result, then build a String, pass it to shout with &mut, and
print it afterward to confirm the mutation stuck. Finally, try (in a
comment) writing code that holds an immutable reference from longest_word
across a call to shout on the same string, and explain in a comment why
the compiler would reject it.