1.4 References: how to use data without taking over cleanup duty
By the end of the previous section, our minimal model stood on two rules: when a stack variable goes out of scope (}), the heap resource it points to is freed automatically; when a heap-backed value is assigned with =, it follows Move semantics and invalidates the old variable on the spot.
We ended with a test question: under those two rules alone, can we still write the most routine collaboration in everyday programming—passing a value into a function just to let the function use it?
The parameter-passing dilemma: one read invalidates the variable
Section titled “The parameter-passing dilemma: one read invalidates the variable”Real programs cannot get far without calling functions. Suppose we write the simplest possible helper, print_it, which does nothing more than take a string s and print it once:
fn print_it(msg) { print(msg)} // ← msg goes out of scope here
{ s = alloc_heap("hello") print_it(s) print(s) // ← can we still use s here?}Trace what happens when print_it(s) runs under our minimal model:
- Passing an argument is an assignment: calling
print_it(s)assigns the argumentsto the parametermsg(effectively executingmsg = s). - A Move is triggered: because the string is backed by a heap resource,
msg = sfollows Move semantics—the caller’s variablesis invalidated immediately, and the responsibility for freeing the heap transfers to the parametermsginside the function. - The heap is freed when the function returns: as soon as
print_itfinishes printing and returns,msggoes out of scope, firing our scope-exit rule and freeing the"hello"buffer on the heap right there. - The caller is left with nothing: back in the outer block, not only is
sinvalid, the heap data itself has already been destroyed byprint_it.
If we refuse to change the model, the only way to keep using the string after calling print_it is to have the function return the parameter back at the end, and have the caller catch it in a variable all over again:
fn print_it(msg) { print(msg) return msg // ← forced to hand the value back after reading it}
{ s = alloc_heap("hello") s = print_it(s) // ← every call has to rebind the returned value print(s)}Imagine a function that merely wants to inspect three strings and return a computed result of its own: you would have to pass in three arguments, return a four-element tuple (result, s1, s2, s3), and unpack all of them at every call site.
When even a read-only function call becomes that clumsy, a language built solely on the minimal model is unusable in real engineering.
Diagnosis: where is the flaw?
Section titled “Diagnosis: where is the flaw?”Why does calling a simple read-only function put us in such a bind?
Because in our minimal model, the ability to use data and the responsibility for freeing heap memory are welded into the same value.
The string variable s sits on the stack as three machine words (heap pointer ptr, length len, capacity cap). Whoever holds that three-word String value is obligated to free the heap block upon leaving scope. Because our model has only this one representation, letting print_it read the string forces you to hand over the entire String—and with it, the duty to free the heap.
There is only one way out of this dead end: we need to design a new kind of type for our model that separates “locating data to use it” from “being responsible for freeing the heap.”
Designing it ourselves: an address type stripped of cleanup duty
Section titled “Designing it ourselves: an address type stripped of cleanup duty”Put yourself in the language designer’s shoes. How can we let a function reach "hello" without freeing the heap when the function returns?
Look at the memory layout and the answer is straightforward: instead of passing the three machine words of s into the function, create a tiny value that records only the stack address of s, and pass that address instead.
To distinguish it from s itself, let’s write &s for the action of taking the stack address of s, and &String for this new type that holds the stack address of a String. Here is what it looks like in memory:
Compare the physical layout of the new value we just designed against the original variable:
- The original variable
s(String) occupies three machine words on the stack (ptr,len,cap), whereptrholds the starting address of the heap character buffer. Only the variable holding this full control header is responsible for freeing the heap block when it goes out of scope. - Our newly designed
&s(&String) occupies one machine word (8 bytes) on the stack, holding nothing more than the stack address of variables. Reading the characters through it takes two hops: first follow the stack address tos, then followptrinsidesto"hello"on the heap.
Now ask a question about role separation: why doesn’t a variable of this new type free the heap when it goes out of scope?
Because from the moment we designed it, it was built to read data without managing cleanup—the two types play fundamentally different roles in the model:
- The original type
String: directly holds the three-word control header and is responsible for freeing the heap when it goes out of scope. - The new type
&String: holds only the 8-byte stack address pointing tosand is stripped of cleanup responsibility at the type level (nor is it allowed to move the value out ofsthrough that address)—when it goes out of scope, it simply discards its 8-byte stack address and leaves the heap resource untouched.
Cleanup duty stays firmly with the original variable s—you get the address to use the data; the duty to free it stays with me.
Now let’s give the concept we just invented its official name: in Rust, a type that holds an address while carrying no responsibility for freeing the underlying resource is called a reference.
A natural corollary: read-only references follow Copy semantics
Section titled “A natural corollary: read-only references follow Copy semantics”Once we define a reference as “an 8-byte address on the stack stripped of heap-cleanup responsibility,” the rules from the previous section hand us an immediate bonus.
Recall how we drew the line between Copy and Move in the previous section: because a heap block can have only one variable responsible for freeing it at any moment, assigning a value that carries cleanup duty must invalidate the old variable (Move); by contrast, stack bytes that carry no heap-cleanup duty—like an ordinary integer—can be copied bitwise so that both variables remain valid (Copy).
Now look at our newly designed reference type &String:
- On the stack, it is just an 8-byte address.
- It carries zero responsibility for freeing any heap resource.
Even if you copy &s bitwise a hundred times, when those hundred reference variables eventually go out of scope, each one merely discards its own 8-byte stack slot. Not one of them touches the heap, so a double free is impossible.
In our model, read-only references naturally follow Copy semantics—just like an ordinary integer, you can assign them to as many variables or pass them to as many functions as you like, copying only the 8-byte address while the original variable and every reference stay valid.
Rewrite our opening pseudocode with references, and the parameter-passing dilemma vanishes:
Pseudocode for reasoningfn print_it(msg: &String) { // ← receives a reference; takes on no cleanup duty print(msg)} // ← msg goes out of scope; only the 8-byte stack address is discarded
{ s = alloc_heap("hello") print_it(&s) // ← pass only the address of s r1 = &s // ← copy the address (Copy semantics) r2 = &s // ← copy the address again; multiple read-only references coexist print(s) // ← s was never invalidated and works as usual} // ← only s frees the heap resource once, right here at scope exitIs this model finally complete?
Section titled “Is this model finally complete?”Let’s take stock of what our automatic reclamation model can do now that we have added reference types (&T):
- Deterministic, single-point cleanup: each heap resource is the responsibility of a single original variable (such as
s) and is freed automatically when that variable goes out of scope—no GC, and no double frees. - Frictionless reading and sharing: when code only needs to use data, it passes a reference
&s. Because a reference is stripped of cleanup duty, functions can return without destroying the heap, and multiple read-only references can be freely duplicated under Copy semantics.
Automatic cleanup, no double frees, clean function calls, and multi-reader sharing—everything seems to fit together.
So ask yourself: has this model finally solved every problem? Is there any blind spot left?
Follow the rule “a reference does not manage cleanup; when the heap gets freed is decided entirely by the original variable s” one step further into a corner case: what if the original variable responsible for freeing the heap and the reference holding its address live in different scopes—could the original variable go out of scope first and free the heap, while a reference is still sitting in an outer variable waiting to be read?
Keep that question in mind as we head into the next section.