AnyLearn
All lessons
Programmingadvanced

Borrowing: Aliasing XOR Mutation

Moving a value to read it is unusable, so ownership needs a way to lend. The lending rule is one line, and it is the reason Rust can guarantee no dangling references: you may have many readers or one writer, never both. This lesson builds that rule and the lifetimes that enforce it across function boundaries.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

Lending without transferring

The previous lesson left an ergonomic problem. If passing a value moves it, then every function that merely reads something must give it back, and code becomes a chain of returns.

The fix is a reference: a way to access a value without owning it. Writing &x creates one, and the value's owner is unchanged.

fn length(s: &String) -> usize { s.len() }

let s = String::from("hello");
let n = length(&s);          // borrowed, not moved
println!("{s} has {n}");     // s is still usable

This is called borrowing, and the name is precise: the reference has temporary access and must return it before the owner is done with the value.

The hard part is guaranteeing exactly that. A reference outliving the value it points to is a dangling pointer, which is the defect ownership was introduced to eliminate. So the compiler needs a rule strong enough to make that impossible, and it turns out one rule handles both dangling references and data races at once.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. Lending without transferring

The previous lesson left an ergonomic problem. If passing a value moves it, then every function that merely reads something must give it back, and code becomes a chain of returns.

The fix is a reference: a way to access a value without owning it. Writing &x creates one, and the value's owner is unchanged.

fn length(s: &String) -> usize { s.len() }

let s = String::from("hello");
let n = length(&s);          // borrowed, not moved
println!("{s} has {n}");     // s is still usable

This is called borrowing, and the name is precise: the reference has temporary access and must return it before the owner is done with the value.

The hard part is guaranteeing exactly that. A reference outliving the value it points to is a dangling pointer, which is the defect ownership was introduced to eliminate. So the compiler needs a rule strong enough to make that impossible, and it turns out one rule handles both dangling references and data races at once.

2. The rule

At any point in a program, for any value, you may have either any number of immutable references or exactly one mutable reference. Never both.

That is the whole borrowing discipline, usually stated as aliasing XOR mutability.

let mut v = vec![1, 2, 3];

let a = &v;          // shared borrow
let b = &v;          // another shared borrow: fine
println!("{a:?} {b:?}");

let m = &mut v;      // exclusive borrow: fine, the shared ones ended
m.push(4);

Swap the order and it fails: taking &mut v while a is still in use is rejected, because that would allow mutation through one path while another path expects stability.

The justification is worth internalising because it explains a large fraction of the language. Aliasing is safe when nothing changes. Mutation is safe when nothing else is looking. Both together is what makes memory bugs possible: a pointer into a vector is invalidated when the vector reallocates, and that is only a problem if something can still read the old pointer.

So the rule does not forbid aliasing or mutation. It forbids their intersection.

3. The bug it eliminates

The canonical case shows why the rule is not academic.

let mut v = vec![1, 2, 3];
let first = &v[0];       // shared borrow of an element
v.push(4);               // needs &mut v: REJECTED here
println!("{first}");

In C++ the equivalent compiles and is undefined behaviour. push may exceed capacity, allocate a larger buffer, copy the elements and free the old one, leaving first pointing into freed memory. It usually appears to work, which is worse than failing.

The iterator invalidation family of bugs is exactly this, and it is common enough to have its own name in every language with growable containers.

Rust rejects it at compile time, with an error naming both the borrow and the conflicting use. The check requires no runtime work and no annotation: the shared borrow of an element and the mutable borrow push requires cannot coexist under the rule.

Notice that the rule was not designed for this case specifically. Iterator invalidation, data races, and use-after-free by reference are all consequences of aliasing plus mutation, so one restriction removes all three.

4. Why the intersection is the problem

The first two branches are permitted and the third is not, and the diagram is worth reading as a general principle rather than a Rust rule.

The same condition underlies the data races in the lesson Threads and Shared State: a race requires two accesses to the same location, at least one of them a write, with no ordering between them. That is aliasing plus mutation, arrived at from concurrency rather than from memory management.

Which is why the same rule gives both guarantees, and it is the basis of the next lesson. Rust did not add a separate concurrency safety mechanism. The borrow checker was already forbidding the condition that makes data races possible.

It also explains why the rule feels restrictive in single-threaded code where you can see nothing goes wrong. The checker is enforcing a condition sufficient for safety in general, and it does not know your code is single-threaded or that your particular interleaving is benign.

flowchart TD
A["Access to a value"] --> B["Many readers, no writer"]
A --> C["One writer, no readers"]
A --> D["Readers and a writer together"]
B --> E["Safe: nothing changes underneath"]
C --> F["Safe: nobody observes the change"]
D --> G["Unsafe: invalidation, races, torn reads"]
G --> H["This is what the rule forbids"]

5. Lifetimes: how long a borrow is valid

Within one function the compiler sees the whole picture. Across a function boundary it does not, and that is what lifetimes are for.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

The 'a is a lifetime parameter, and it is not a duration. It is a name for a region of code, used to state a relationship: the returned reference is valid for as long as both inputs are.

Without it the signature is ambiguous. A returned reference must borrow from something, and the compiler cannot tell whether it came from x, from y, or from a local. Getting that wrong means returning a reference to a value that has been dropped.

The annotation supplies the missing fact, and then the compiler checks every call site against it. If a caller passes one long-lived and one short-lived string and keeps the result too long, the call is rejected.

So lifetimes are descriptive rather than prescriptive. They do not change how long anything lives; they state a relationship the compiler then verifies. Writing one does not make a reference live longer, which is the most common early misunderstanding.

6. Why you rarely write them

Lifetimes have a reputation for being everywhere, and in practice most code has none, because the compiler infers them from a few rules covering the common shapes.

Each reference parameter gets its own lifetime. If there is exactly one input lifetime, it is assigned to all outputs. In a method taking &self, the lifetime of self is assigned to the outputs.

That third rule covers most methods, and the second covers most free functions. Annotation is needed only when a function takes several references and returns one, and the compiler cannot tell which it borrows from.

A second improvement removed most of the remaining friction. Originally a borrow lasted until the end of its lexical scope, so this was rejected:

let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}");     // last use of `first`
v.push(4);               // once rejected; now accepted

Under non-lexical lifetimes a borrow ends at its last use rather than at the closing brace. The example is safe, since nothing reads first after the push, and the compiler now sees that.

The pattern is worth noting: the rule did not change, only the precision with which the compiler tracks where borrows end. Much of Rust's improving ergonomics is analysis getting sharper rather than guarantees weakening.

7. The patterns that fight the checker

Some shapes are genuinely awkward, and recognising them saves hours of trying to annotate your way out.

Self-referential structs. A struct holding both a buffer and a reference into that buffer cannot be expressed, because moving the struct would invalidate the reference. The usual fix is to store an index instead of a reference.

Graphs and back-pointers. A tree where children point at parents has two references to each node. Ownership permits one, so the standard answer is an arena: store nodes in a vector and use indices as edges. This is often faster anyway, for the cache reasons in The Cache Hierarchy and Why Locality Decides Speed.

Returning a reference to a local. Rejected, correctly: the local is dropped at return. Return the value instead.

Holding a borrow across a mutation. The commonest beginner error, usually fixed by narrowing the borrow's scope or by cloning the small piece actually needed.

The recurring remedy is the same one: replace a reference with an index or an owned value. An index is a reference the compiler does not have to reason about, at the cost of a bounds check and the possibility of it being stale, which is a memory-safe bug rather than an unsafe one.

8. What borrowing established

SituationAllowedWhy
Many &TYesReaders cannot invalidate each other
One &mut TYesThe writer is the only observer
&T and &mut T togetherNoMutation under an observer is the bug
Reference outliving its ownerNoThat is a dangling pointer
Two &mut TNoTwo writers is the race condition

The table is the borrow checker, and every error message it produces is one of these five rows.

What it buys is worth restating precisely: no dangling references, no iterator invalidation, and no use-after-free through a reference, checked entirely at compile time with no runtime representation at all. References compile to bare pointers, and the safety was in what the compiler refused to accept.

The lesson Types as Propositions places this in a wider frame: the borrow checker is a type-level argument, and it makes a class of errors unrepresentable rather than detectable. It is not general dependent typing, and it is the same move.

The next lesson collects the payoff. Since a data race requires exactly the condition this rule forbids, concurrency safety follows from what has already been built rather than from anything new.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What does 'aliasing XOR mutability' permit?
    • Any number of immutable references, or exactly one mutable reference, but never both
    • One reference of each kind at a time
    • Any number of references provided none escape the function
    • Mutable references only within a single scope
  2. Why is taking `&v[0]` and then calling `v.push(4)` rejected?
    • Because vectors cannot be borrowed and mutated in the same function
    • Because push requires a mutable borrow while a shared borrow of an element is live, and reallocation would dangle it
    • Because indexing always moves the element
    • Because push may fail and the borrow could not be unwound
  3. What is a lifetime annotation like `'a` actually doing?
    • Extending how long the referenced value lives
    • Requesting that the value be heap-allocated
    • Deferring the drop until the reference is finished
    • Naming a region of code to state a relationship the compiler then verifies
  4. What did non-lexical lifetimes change?
    • A borrow ends at its last use rather than at the end of its lexical scope
    • Lifetimes became inferrable across function boundaries
    • Multiple mutable borrows became legal within one scope
    • Borrows began to be tracked at runtime
  5. What is the standard remedy for a graph with parent pointers in Rust?
    • Annotate every node with an explicit lifetime
    • Store nodes in a vector and use indices as edges
    • Wrap every node in a mutable reference
    • Make the struct self-referential and pin it

Related lessons

Programming
advanced

Scheduling and Resources: Requests Are Not Limits

Two numbers govern where a pod lands and how it behaves under pressure, and they do completely different jobs. Requests are used for placement and are a promise; limits are enforced at run time and are a ceiling. This lesson separates them, covers the asymmetry between CPU and memory enforcement, and explains what actually happens when a node runs out.

8 steps·~12 min
Programming
advanced

The Runtime Stack, and What Isolation Is Worth

One command hides four layers of software and a set of standards that made them interchangeable. This lesson takes the stack apart, then asks the question the whole path has been building toward: given a shared kernel, how much is container isolation actually worth, and what has to be added before it is a security boundary.

8 steps·~12 min
Programming
advanced

Networking and Storage: Connecting an Isolated Thing

Isolation is the point, and a container that can reach nothing and keep nothing is useless. This lesson covers how a network namespace is wired to the outside, why published ports and container-to-container traffic work completely differently, and where data has to live given that the writable layer disappears.

8 steps·~12 min
Programming
advanced

Images: A Filesystem You Can Address by Hash

The kernel gives isolation for files already on the machine. The image format is what turned a directory tree into something you can build once, name by digest and run anywhere. This lesson covers layers and the union filesystem that stacks them, why the build cache behaves as it does, and the content addressing that makes it all work.

8 steps·~12 min