2024-07-13
HSPACE Rust Lectures #4 — References
The natural sequel to ownership. Using a value without taking ownership means borrowing it, and references are the tool.
Why references exist
This example states the problem exactly.
type Table = HashMap<String, Vec<String>>;
fn show(table: Table) {
for (artist, works) in table { // iterating consumes table
println!("works by {artist}:");
for work in works {
println!(" {work}");
}
}
}
fn main() {
let mut table = Table::new();
// ... fill it in ...
show(table);
// assert_eq!(table["Gesualdo"][0], "many madrigals");
// ^^^^^ error: table was moved into show
}
show only prints, and yet it ate the table — because
for (artist, works) in table iterates the HashMap by value and takes
ownership along the way.
The fix is one &:
fn show(table: &Table) {
for (artist, works) in table { // iterating &Table yields references
println!("works by {artist}:");
for work in works {
println!(" {work}");
}
}
}
show(&table);
assert_eq!(table["Gesualdo"][0], "many madrigals"); // works fine
Iterating a &Table yields (&String, &Vec<String>), so nothing is consumed.
This one example burned in the rule that a function that only reads should take
a reference.
Working with references
Rust references look like C++ references but differ in decisive ways.
1. Dereferencing is explicit
let x = 10;
let r = &x; // &x is a shared reference to x
assert!(*r == 10); // explicitly dereference r
let mut y = 32;
let m = &mut y; // &mut y is a mutable reference to y
*m += 32; // dereference to set y's value
assert!(*m == 64);
A C++ reference is syntactically indistinguishable from its referent. Rust makes
you write *, so the code says whether you're handling a reference or a
value.
The . operator and method calls do dereference automatically, though:
let anime_ref = &aria;
assert_eq!(anime_ref.name, "Aria: The Animation");
assert_eq!((*anime_ref).name, "Aria: The Animation"); // same thing
let mut v = vec![1973, 1968];
v.sort(); // implicitly borrows a mutable reference to v
(&mut v).sort(); // equivalent, but verbose
Realizing that v.sort() creates a &mut v under the hood explained a lot of
the "why is there a borrow error here?" moments. A method call is itself a
borrow.
2. References can be reseated
A C++ reference is bound once. A Rust reference is just a value, so it can be reassigned.
let x = 10;
let y = 20;
let mut r = &x;
if b { r = &y; } // now r refers to y instead of x
assert!(*r == 10 || *r == 20);
3. References to references are fine
let point = Point { x: 1000, y: 729 };
let r: &Point = &point;
let rr: &&Point = &r;
let rrr: &&&Point = &rr;
assert_eq!(rrr.y, 729); // the dot operator peels off as many levels as needed
4. Comparison operators compare referents
let x = 10;
let y = 10;
let rx = &x;
let ry = &y;
let rrx = ℞
let rry = &ry;
assert!(rrx == rry); // compares the final referents
assert!(rx == ry); // their referents are equal
assert!(!std::ptr::eq(rx, ry)); // but they're at different addresses
// assert!(rx == rrx); // error: type mismatch, &i32 vs &&i32
assert!(rx == *rrx); // this is fine
Comparing addresses requires reaching for std::ptr::eq explicitly — exactly the
opposite default from C, where == on pointers compares addresses, and the safer
one.
5. You can reference arbitrary expressions
fn factorial(n: usize) -> usize {
(1..=n).product()
}
let r = &factorial(6);
// arithmetic operators see through one level of reference
assert_eq!(r + &1009, 1729);
&factorial(6) creates an anonymous temporary and points at it; the temporary
lives as long as the reference does. The C++ rules about temporary lifetime
extension that you had to memorize are handled here by the borrow checker.
Reference safety
The real guarantee is that a reference never points at a dead value.
Borrowing a local variable
{
let r;
{
let x = 1;
r = &x; // error: `x` does not live long enough
}
assert_eq!(*r, 1); // bad: reads the memory x used to occupy
}
In C this compiles silently, and you read either a stale value or garbage
depending on luck. Rust rejects it purely on the grounds that r outlives
x.
Returning a reference
// v should have at least one element
fn smallest(v: &[i32]) -> &i32 {
let mut s = &v[0];
for r in &v[1..] {
if *r < *s { s = r; }
}
s
}
let s;
{
let parabola = [9, 4, 1, 0, 1, 4, 9];
s = smallest(¶bola);
}
assert_eq!(*s, 0); // error: points into a dropped array
Lifetime elision reads that signature as
fn smallest<'a>(v: &'a [i32]) -> &'a i32: the returned reference lives no
longer than the input slice. parabola dies at the end of the inner block, so
s does too. Note that the check happens from the signature alone, not by
looking inside the function body.
Structs holding references
A struct with reference fields needs lifetime parameters.
struct S<'a, 'b> {
x: &'a i32,
y: &'b i32,
}
let x = 10;
let r;
{
let y = 20;
{
let s = S { x: &x, y: &y };
r = s.x;
}
}
println!("{r}");
With a single lifetime parameter, x and y would be forced to share one
lifetime and this code is rejected. Splitting them lets each field have its own,
which is what makes pulling s.x back out legal. That's where I learned that
how many lifetime parameters to use is an API design decision.
Sharing vs mutation
The heart of the reference rules, in one line:
Many shared references, or one mutable reference. Never both.
// Part 1
let mut x = 10;
let r1 = &x;
let r2 = &x; // OK: multiple shared borrows permitted
x += 10; // error: cannot assign, x is borrowed
let m = &mut x; // error: cannot borrow mutably, already borrowed immutably
println!("{r1}, {r2}, {m}"); // used here, so the borrows must live this long
let mut y = 20;
let m1 = &mut y;
let m2 = &mut y; // error: cannot borrow as mutable more than once
let z = y; // error: y is mutably borrowed
println!("{m1}, {m2}, {z}");
Note that x += 10 is an error too. Even the owner can't mutate while the
value is borrowed out. For as long as a shared reference lives, the value is
frozen.
Reborrowing follows the same logic:
// Part 2
let mut w = (107, 109);
let r = &w;
let r0 = &r.0; // OK: reborrowing shared as shared
let m1 = &mut r.1; // error: can't reborrow shared as mutable
println!("{r0}");
// Part 3
let mut v = (136, 139);
let m = &mut v;
let m0 = &mut m.0; // OK: reborrowing mutable from mutable
*m0 = 137;
let r1 = &m.1; // OK: shared from mutable, and doesn't overlap m0
v.1; // error: access through other paths still forbidden
println!("{r1}");
Part 3 is the interesting one. m.0 and m.1 don't overlap, so both can be
borrowed at once — the borrow checker tracks individual fields, not whole
structs. But reaching v directly while it's borrowed through m is still
blocked.
Why be this strict? Because the equation shared = immutable buys a lot:
- data races become impossible by definition
- a collection can't reallocate mid-iteration and invalidate your references (C++'s iterator invalidation)
- the compiler knows there's no aliasing, so it can optimize more aggressively
Facing the sea of objects
The title of the last slide stuck with me. A "sea of objects" — everything pointing at everything — is a normal shape in GC languages, and Rust makes it hard.
At first that felt like a defect. Following the course changed my view: a structure where anything can point at anything is also a structure where nobody knows who owns what or when it changes. Rust blocks that and makes you design the ownership tree first.
When you genuinely need a graph, there are options:
Rc<RefCell<T>>— shared ownership plus interior mutability (borrow checking moves to runtime)- index-based — keep nodes in a
Vecand reference them byusizeindex instead of pointer. In practice this is the most common way to write graphs and trees in Rust Weak— for breaking cycles
Takeaways
- functions that only read take
&T; they don't eat ownership - dereferencing is explicit (
*), but.and comparison operators peel automatically - reference safety is checked from function signatures alone
- while a shared reference lives, the value is immutable even to its owner
- borrow checking is per-field
Next: expressions — how the fact that if and match produce values changes
the shape of the code.