Skip to content
beginner Phase 3 · Borrowing, References, and the Borrow Checker

Shared References

Lend values with ampersand to read them without taking ownership.

40m
0 problems
Topic Progress 0%

&T Borrows Without Taking: The Read-Only Lease

Ownership answers "who frees this?". Borrowing answers "who else may look?". A shared reference — written &T — lends access to a value without transferring ownership:

fn length(s: &String) -> usize { s.len() }   // borrows; does not own

fn main() {
    let name = String::from("Ferris");
    let len = length(&name);      // &name: shared borrow for the call
    println!("{} has {} chars", name, len);   // name still yours
}

&name creates a reference; the parameter s: &String receives it; when length returns, the borrow ends and name is exactly as usable as before. No move occurred, so no ownership bookkeeping changed — this is the mechanism that lets a function read data without consuming it, and it is why &T parameters are the default shape of read-only APIs across the ecosystem.

Why share instead of pass-by-value? Two concrete reasons. Passing T moves it — the caller loses the value (and for large data, a Copy impl would be wrong). Passing &T costs one pointer, keeps the caller's ownership, and lets any number of readers hold references simultaneously:

fn describe(person: &Person, role: &Role) { ... }   // two borrows at once
let p = ...; let r = ...;
describe(&p, &r);        // both borrowed; both still owned by main

The type signature is the contract: fn f(x: T) promises the function consumes the value (it may drop it, store it, return it later); fn f(x: &T) promises it only reads. An interviewer asking "how do you signal that your function does not take ownership?" is asking for &T — and the follow-up, "how do you signal it may modify but not consume?", is &mut T (the next topic).

References are ordinary values with their own rules. They can be shadowed, stored in bindings, and passed along (let r = &name; let r2 = r; — &T is Copy, copying the reference, not the data). Returning a reference from a function is legal but constrained — the referent must outlive the borrow, which the compiler checks against lifetimes you will meet in The Borrow Checker Rules. For now the working rule: borrow for the call, use the original after — that pattern covers most day-one code.

Borrows have lifetimes measured in uses, not braces — a distinction you will lean on constantly:

fn main() {
    let mut data = vec![1, 2, 3];
    let head = &data[0];        // shared borrow begins
    println!("{}", head);       // last use of head
    data.push(4);               // OK: the borrow ended at its last use (NLL)
}

Before non-lexical lifetimes, head's borrow would have run to the end of the block and push would fail. Today the borrow region ends where the reference is last used, so the practical rule is scope your references tightly and use them promptly — most "borrow conflicts" disappear when you move the use closer to the mutation.

Borrowing composes, including into data structures. A struct can hold references as long as it names the lifetime that keeps them valid:

struct Header<'a> { name: &'a str, value: &'a str }   // borrows, owns nothing

and slices are the workhorse parameter type for reading collections:

fn total(items: &[Order]) -> u64 { items.iter().map(|o| o.amount).sum() }

total(&orders);        // &Vec<Order> coerces to &[Order]
total(&array);         // &[Order; 3] coerces too — one signature, every caller

&[Order] is the read-only contract made precise: the function cannot grow, shrink, or reorder the collection (it only has a shared borrow), and the caller keeps ownership of whatever container it happened to have. Compare the alternatives the signature quietly rules out — Vec<Order> by value would move the caller's vector, &Vec<Order> would demand a Vec specifically — and you can see why &[T] is the default parameter type for any function that only iterates. The same reasoning extends to text (&str) and maps (&HashMap<K, V>); the pattern is one sentence in an API: "I read, you keep."

Dereferencing, Auto-Deref, and What a Reference Actually Is

A reference is a pointer-like value; reading the data behind it is dereferencing, done with the unary *:

fn main() {
    let x = 5;
    let r = &x;          // r: &i32 — borrow x
    assert_eq!(*r, 5);   // dereference: read the i32 behind the reference
    assert_eq!(r, &5);   // comparing references compares their referents too
}

In practice you dereference far less than you expect, because Rust performs auto-deref almost everywhere. Method calls are the main site — when you write name.len() on a &String, the compiler inserts the dereferences to find str::len:

let name = String::from("hello");
let len = (&name).len();      // what you write (implicitly)
let len = (*(&name)).len();   // what the compiler does via auto-deref

The mechanism is the Deref coercion chain: String implements Deref<Target = str>, so &String coerces to &str automatically wherever a &str is expected. This is why the idiomatic signature fn f(s: &str) accepts string literals (&'static str), &String, and slices without any caller-side ceremony — the compiler does the last hop for free.

What a reference is not also matters. It cannot be null — Rust has no null pointer; the "maybe absent" case is Option<&T>, checked at compile time. It cannot be reassigned to point elsewhere unless the reference binding itself is mut. And it carries no length or metadata for plain T — a reference to a slice or trait object does (you will see fat pointers in the next topic). Physically, &T is one machine word with the same address as the referent's first byte; semantically, it is a checked permission — the compiler has proven the referent outlives the borrow and will not be mutated while this shared lease exists.

The read-only guarantee is enforced, not promised: through a &T you cannot mutate the referent, even if it is mut underneath:

fn main() {
    let mut count = 0;
    let r = &count;      // shared borrow
    count += 1;          // error[E0502]: cannot borrow `count` as mutable
    println!("{}", r);  //   because it is also borrowed as immutable
}

That E0502 — mutation blocked because a shared borrow exists — is the first half of the aliasing model. Shared references say "everyone may read, nobody may write," and the borrow checker proves it holds for every reference's entire live range.

One place you dereference without noticing is indexing: data[0] desugars to *data.index(0) — the Index trait returns &Output, and the brackets perform the *. The paths where you will write an explicit * are the ones no coercion reaches: reading through a raw pointer created in unsafe, or unwrapping a double reference (&&str) that a generic parameter refuses to see through.

The Deref chain is also longer than String → str. Every owning pointer joins it:

let owned = String::from("keys");
let slice: &str = &owned;           // String → str: one hop at the expectation site
let boxed: Box<String> = Box::new(owned);
let from_box: &str = &boxed;        // Box<String> → String → str: two hops, still free

Box<T>, Vec<T>, Rc<T>, and Mutex<T> all implement Deref<Target = T>, which is why boxed.length() finds methods on the inner type and a Box<dyn Fn(i32)> can be called like a function — the impl lives on the pointee, and auto-deref walks to it. The chain is consulted at compile time, left to right, until a method or an expected type resolves.

What the chain never does is convert at run time. There is no vtable lookup for a coercion and no runtime check — only a different address, computed statically. Stated crisply for an interview: coercion is compile-time pointer adjustment, not a conversion. When you need an actual conversion — UTF-8 validation, parsing, allocation — you write .parse(), .as_bytes(), or .to_owned() and pay for it where it is visible.

References Are Always Valid — The Compiler Proves It

The single most valuable promise Rust makes about references: they are never dangling. A reference to a value that has been freed, moved, or gone out of scope cannot be created in safe code — the compiler rejects it at compile time, in the same family of errors as the moves you have already seen.

The classic shape:

fn dangling() -> &String {
    let s = String::from("hi");
    &s                      // error[E0106]: missing lifetime specifier
}                           // (or E0597 `s` does not live long enough, on variants)

The local s will be dropped when dangling returns, so a reference to it cannot escape — the borrow would outlive the referent. The compiler demands proof otherwise (a lifetime annotation) or rejects the signature. Compare the working version:

fn no_dangle() -> String {
    let s = String::from("hi");
    s                       // ownership MOVED out — no reference, no problem
}

The move version is fine because the value, not a reference to a dead stack slot, travels to the caller. This is the design principle behind the error messages: whenever you see "does not live long enough," the compiler is refusing to let a reference outlive the data it points at — the fix is to move ownership, borrow something that outlives it, or return owned data.

Internal temporaries get the same protection:

let r = {
    let local = String::from("temp");
    &local            // error[E0597]: `local` does not live long enough
};                    //   the block ends before the reference could be used

And the boundary rule for safe sharing: a shared reference forbids mutation of its referent while the reference is live (the E0502 you saw), which closes the data-race-at-a-distance problem at compile time — one thread writing while another reads through a shared reference is exactly the pattern the checker prevents, and that guarantee is what later lets &T be safely shared across threads (Sync).

The interview framing that ties it together: references are not raw pointers you are trusted to keep honest — they are proof-carrying borrows. The proof (borrow region analysis) is compiled away, so there is zero runtime cost, but the guarantee it buys — no null checks, no use-after-free, no dangling pointers in safe code — is the same one garbage collectors and sanitizer tooling chase at runtime. That contrast is the ownership pitch in one sentence.

The most common dangling-borrow error in practice is not the one you write by hand — it is the temporary you never named:

fn first_name(list: Vec<String>) -> &str { &list[0] }   // E0106: missing lifetime

let first = names().first().unwrap();
// error[E0716]: temporary value dropped while borrowed
//   --> the Vec returned by names() dies at the end of this statement,
//       but `first` would still point into it

E0716 is the signature of borrowing into a value that is not kept alive: a function call's return value is a temporary, temporaries die at the semicolon, and anything borrowed from them dies with them. The fixes mirror the principle — bind the container to a name first (let names = names(); let first = &names[0];), return an owned value, or return an index instead of a borrow.

Once you accept that references can be stored (in structs, returned, passed along), the guarantee becomes impressive: the compiler computes, for every reference in the program, a region during which the referent is live and unmutated, and rejects any program where those regions disagree. Nothing at run time checks it — no shadow stack, no write barrier — which is why safe Rust references cost exactly what C pointers cost while offering guarantees C needs ASan and a careful programmer to approximate.

The controlled exceptions are worth naming so you are not surprised later: Cell/RefCell let you mutate behind a shared reference without a &mut (runtime-checked, panic on violation), and unsafe lets raw pointers do anything. Neither weakens the safe-code guarantee — they are the escape hatches, and reaching for them is a deliberate, greppable decision. Everything else in safe code holds one line: while any &T may still be used, nobody writes — which is also the invariant that makes &T safe to hand to another thread, since a data race is exactly a write that such a rule forbids.

Quiz

1. What does `&name` do when passed to `fn length(s: &String)`, and what is the state of name after the call?

Question 1 options

2. Why does `fn f(s: &str)` accept a string literal, a &String, and a slice?

Question 2 options

3. What compiler error do you get for `let r = &s;` inside a function that tries to return r where s is a local String — and why?

Question 3 options

4. Why can't you mutate a variable through a shared reference even if the variable is declared `mut`?

Question 4 options

Flashcards

Question

What does &T mean, and how does passing &T differ from passing T to a function?

Answer

&T is a shared (read-only) reference: many simultaneous readers, no ownership transfer, no move. Passing T moves ownership into the callee (the caller loses it); passing &T lends read access for the call and the caller keeps the value. The signature declares the contract: consume vs read.

Question

What is dereferencing, and how often do you actually write it?

Answer

*r reads the value behind a reference — but auto-deref almost always saves you: method calls resolve through Deref impls (String → str), and &String coerces to &str wherever expected (Deref coercion). You write * explicitly mainly for primitive pointer reads; the compiler handles the rest.

Question

Why is `fn f(s: &str)` preferred over `fn f(s: &String)`?

Answer

&str accepts all string-ish inputs via deref coercion — literals (&'static str), &String, &mut String, and slices — with no caller ceremony, and it signals 'I only read text' rather than 'I need a String specifically'. &String needlessly narrows the API to one representation.

Question

What does Rust guarantee about references, and what errors enforce it?

Answer

References are never dangling — the referent outlives the borrow, proven statically (zero runtime cost). Violations: E0106 missing lifetime on a signature that would return a local's borrow; E0597 'does not live long enough' for internals; the fix is moving ownership out, borrowing longer-lived data, or returning owned values.

Question

Is &T nullable? How do you express 'maybe absent'?

Answer

No — safe Rust has no null references; &T always points at a live value. Absence is Option<&T> (or Option<T> for owned), so the compiler forces you to handle the empty case. This is why Rust needs no null-checks on references and why use-after-free is impossible in safe code.

Revision Notes

Key Takeaways

  • 1. &T is a read-only lease: no ownership transfer, unlimited simultaneous readers, and the signature documents consume-vs-read
  • 2. Deref coercion (&String → &str) plus auto-deref in method calls is why &str is the universal text parameter and explicit * is rare
  • 3. The compiler guarantees references are never dangling — a borrow cannot outlive its referent (E0106/E0597) — with zero runtime cost
  • 4. While a shared borrow is live, the referent cannot be mutated (E0502) — that read-stability invariant is what makes sharing safe, locally and across threads
  • 5. No null references: absence is Option<&T>, enforced by the type system rather than checked at runtime

Interview Tips

  • • "What does &T give you over passing T?" — no move (caller keeps ownership), no clone (one pointer), unlimited parallel readers; frame it as the signature declaring intent: consuming vs reading
  • • "Why can't a function return a reference to a local?" — the borrow would outlive the referent (dangling); the compiler demands lifetime proof or rejects it; the fix is returning the owned value (a move), which costs only the 24-byte descriptor
  • • "Deref coercion — give an example" — &String auto-coerces to &str at call sites, so fn f(s: &str) accepts literals and Strings alike; mention String: Deref<Target=str> explicitly to show the mechanism
  • • "Are Rust references just pointers?" — physically one word, semantically a proof-carrying borrow: no null, no dangling, no mutation-while-borrowed — guarantees C pointers need runtimes/sanitizers to approximate

Cheat Sheet

Shared References (&T)

Contract in signatures:

Signature Meaning
fn f(x: T) consumes — may drop/store/return the value
fn f(x: &T) reads — caller keeps ownership; any number of such borrows coexist
fn f(x: &mut T) exclusive write access (next topic)

Borrow, don't move: length(&name) — no move, no clone; the borrow ends at the call, the caller's value is untouched. &T is Copy (copies the reference, not the data).

Dereference: *r reads the referent — but auto-deref covers method calls ((&name).len() → (*(&name)).len()), and Deref coercion turns &String into &str automatically. Hence fn f(s: &str) accepts literals, &String, and slices.

Guarantees: never dangling (E0106/E0597 prove referent-outlives-borrow) · never null (use Option<&T>) · no mutation of the referent while shared borrows live (E0502 enforces read-stability).

Rule of thumb: borrow for reading; move only when the callee must own; return owned values rather than references to locals.

Continue Learning