</>PatchNote
Back to posts

2024-07-13

HSPACE Rust Lectures #4 — References

RustHSPACE 2024ReferencesLifetime

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 = &rx;
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(&parabola);
}
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 Vec and reference them by usize index 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.