Lesson 10 / 14
Heap-Allocated Single Ownership
How a value can still have exactly one owner while it lives on the heap, how a value that ends together with its container depends on the container's own order, and how that single ownership makes a self-referential type possible.
Published Updated
In this lesson
- Runs code showing that a heap-allocated value keeps exactly one owner even when it is moved
- Compares the end order of a single value inside a container with that of the values in a list
- Demonstrates that heap-allocated single ownership makes a self-referential type possible
Contents
The References in Structs lesson closed by measuring that a struct either moves a piece of data or borrows it, and that in both cases exactly one owner exists. This lesson’s question targets that very assumption: can a value have more than one owner? The answer itself is not coming yet — first it is not the number of owners that changes, but where a single-owned value sits. This intermediate step is necessary, because understanding how shared ownership will be measured first requires knowing how a single-owned value behaves on the heap. The measurement axis does not change: it still asks where a value’s life ends, not how a running unit stops the way the Go curriculum’s Concurrency course asks.
This course’s opening lesson treated whether a value sits on the stack or the heap as a
third repetition — after How Computers Work and Production Go: Testing, Tooling and
Performance; memory theory closed there and is not reopened here. That same lesson built
four classes: a value ends at scope end, where it was moved, with an explicit call, or
together with its container. This lesson asks: when a value sits on the heap, which of
these four classes does it fall into, and does the ownership rule still hold? Box is a
container that moves a value onto the heap without ever changing the ownership count — that
is the first thing this lesson measures.
The Container Is a Class Too, But What Is Inside Is Singular
Ending “together with its container” does not come in a single form. A list carries several
values at once; Box carries exactly one. Both fall into the container class, in both the
content ends together with the container, but the container’s own end order is not the same.
This lesson’s measurement also uses the Trace/ledger rig set up at the course’s opening:
every Trace value appends its own name to the ledger when it is dropped, the Drop
implementation that makes this possible is not rewritten here, only excerpted. The run below
holds a three-element list and a single Box value in the same scope, and prints the order
the ledger receives as the scope closes.
SO1. — the claim that “the single value on the heap ends before the list” is confirmed
by the end order line of the run below: the name on-heap-single comes before the list’s
three elements.
// container_classes.rs — the class of ending with a container: a list and a single value on the heap
use std::cell::RefCell;
use std::rc::Rc;
type Ledger = Rc<RefCell<Vec<String>>>;
struct Trace {
name: String,
ledger: Ledger,
}
impl Trace {
fn new(name: &str, ledger: &Ledger) -> Trace {
Trace {
name: name.to_string(),
ledger: Rc::clone(ledger),
}
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
{
let mut list = Vec::new();
list.push(Trace::new("list-1", &ledger));
list.push(Trace::new("list-2", &ledger));
list.push(Trace::new("list-3", &ledger));
let _on_heap = Box::new(Trace::new("on-heap-single", &ledger));
println!("ledger length while the container is full: {}", ledger.borrow().len());
}
let record = ledger.borrow();
println!("end order: {}", record.join(", "));
}
ledger length while the container is full: 0 end order: on-heap-single, list-1, list-2, list-3
When the scope closes, bindings end starting from the one bound last: _on_heap was bound
after list, so it is the first to end. list ends next, but the three Trace values inside
the list end in the order they were added. The end order of bindings and the end order
of a container’s elements are not the same rule — one follows the reverse of declaration, the
other follows insertion order; because Box is a single-element container, this second rule
never shows up in it — only the first rule ever applies.
Living on the Heap Does Not Change How a Value Is Used
A value sitting on the heap does not change how it is accessed. A struct’s fields and methods
inside a Box are reached as if the value sat right there; no separate step is written to
open the container in between.
// box_access.rs — access through a box works as if the value were right there
struct Point {
x: i32,
y: i32,
}
fn main() {
let point = Box::new(Point { x: 3, y: 4 });
println!("x: {}, y: {}", point.x, point.y);
let total = point.x + point.y;
println!("total: {}", total);
}
x: 3, y: 4 total: 7
The lines point.x and point.y are written the same way whether point’s type is Point
or Box<Point>. The run below compares this directly: the same Point value is read once
without being boxed, and once boxed.
// access_comparison.rs — boxed and unboxed access are written on the same line
struct Point {
x: i32,
y: i32,
}
fn main() {
let direct = Point { x: 1, y: 2 };
let on_heap = Box::new(Point { x: 1, y: 2 });
println!("direct: x={}, y={}", direct.x, direct.y);
println!("on-heap: x={}, y={}", on_heap.x, on_heap.y);
}
direct: x=1, y=2 on-heap: x=1, y=2
The only difference between the two lines is how the value was built (Point { ... }
versus Box::new(Point { ... })); the field-reading lines are identical. Living on the heap
only decides where a value is held; it does not change how the value is read and used.
This is a pattern the shared containers ahead will keep following too: whatever the
container’s type, access to the value inside it is always written on the same line.
Boxing a Value Does Not Multiply Ownership
The Ownership and Moves lesson measured that a value moved into a function, into an inner
scope, or into a struct’s field carries its end point along with it, but never changes its
count. The same principle holds for a value on the heap: no matter how many times a Box
value changes hands, the Trace it carries ends only once.
The run below passes a single Box<Trace> value through two separate function calls and
leaves it in the last owner’s hands.
SO2. — the claim that “even after three changes of hands, exactly one end record is
produced” is confirmed by the total record count: 1 line of the run below.
// box_move.rs — moving a boxed value does not change the single ownership count
use std::cell::RefCell;
use std::rc::Rc;
type Ledger = Rc<RefCell<Vec<String>>>;
struct Trace {
name: String,
ledger: Ledger,
}
impl Trace {
fn new(name: &str, ledger: &Ledger) -> Trace {
Trace {
name: name.to_string(),
ledger: Rc::clone(ledger),
}
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
fn to_another_scope(received: Box<Trace>) -> Box<Trace> {
// Ownership is taken over here, and the same value is returned.
received
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
{
let first = Box::new(Trace::new("single-owner", &ledger));
let second = to_another_scope(first);
let third = to_another_scope(second);
println!("ledger length after three moves: {}", ledger.borrow().len());
println!("final owner: {}", third.name);
}
let record = ledger.borrow();
println!("total record count: {}", record.len());
println!("records: {}", record.join(", "));
}
ledger length after three moves: 0 final owner: single-owner total record count: 1 records: single-owner
The binding named first moves into the to_another_scope function, comes back out under
the name second, then moves into the same function again and comes back as third. Three
bindings, three separate names, but all of them are the single owner of the same Trace
value at different times. The ledger’s length being zero as third ends shows that neither
of the two earlier moves produced an end: only when the final owner’s scope closes does a
record open. A single single-owner record appearing in the ledger confirms that boxing
does not multiply ownership — moving only updates who owns the value, it does not open a
new record.
This result might not seem unexpected at first, but thinking through the opposite case makes
the distinction clear: if ownership multiplied, every call to to_another_scope would
produce a separate end record in the ledger, and three records total would appear. The
“single owner” rule established for a value on the stack in the Ownership and Moves lesson
is not broken when the value moves onto the heap; what moves is not the value itself, only the
right to own that value.
A Box Can Also End Early With an Explicit Call
A value on the heap does not always have to wait for its container to close. The Borrowing
lesson measured that an explicit drop call ends a value’s life early; the same call works
on a Box too. This shows that the difference between the “with its container” and “with an
explicit call” classes comes not from the type, but from how the value is ended.
SO3. — the claim that “the value dropped early ends before the value that stays in
scope” is confirmed by the end order line of the run below.
// early_drop.rs — a boxed value can be dropped early with an explicit call
use std::cell::RefCell;
use std::rc::Rc;
type Ledger = Rc<RefCell<Vec<String>>>;
struct Trace {
name: String,
ledger: Ledger,
}
impl Trace {
fn new(name: &str, ledger: &Ledger) -> Trace {
Trace {
name: name.to_string(),
ledger: Rc::clone(ledger),
}
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
{
let early = Box::new(Trace::new("early-dropped", &ledger));
let remaining = Box::new(Trace::new("scope-retained", &ledger));
drop(early);
println!("ledger length after the explicit call: {}", ledger.borrow().len());
println!("remaining is still readable: {}", remaining.name);
}
let record = ledger.borrow();
println!("end order: {}", record.join(", "));
}
ledger length after the explicit call: 1 remaining is still readable: scope-retained end order: early-dropped, scope-retained
Even though early is bound before remaining, it ends without waiting for scope end;
remaining follows the ordinary scope-end rule. The same type — Box<Trace> — can fall
into two different end classes; what decides the class is not the container’s type, but what
is done to that value in the source.
When this lesson’s three measurements come together, a Box value can be seen entering
three of the four classes built at the course’s opening: if its scope closes naturally, it
ends with its container (the Container Is a Class Too section); if it moves into a function
or another binding, its end point moves with it (the Boxing a Value section); if it is
dropped with an explicit call, it ends with an explicit call (this section). It can never
fall into the scope-end class alone, because a value on the heap is always either inside a
container or the subject of a move — it never stands on its own the way an ordinary binding
on the stack does.
A Recursive Type: The Door the Heap Opens
The Data Structures course established self-referential structures such as linked lists and trees as a concept; this lesson does not re-teach that structure, it only measures that heap-allocated single ownership makes such a type possible.
A definition where each link of a chain carries the next link of its own type directly — without a container — is rejected by the compiler: when each link contains the next one directly, the type’s own size is never considered settled, because the number of links can never be read from the source as a fixed bound. This is a different problem from a function calling itself: function recursion proceeds step by step at runtime, but a type’s definition must be read in a single pass at compile time — a definition that keeps unfolding inside itself can never finish that reading.
The rule’s plainest form can be summed up like this: a definition that writes the Record
type directly into a Record struct’s next field is never accepted, because next is
again a Record, and this definition never closes inside itself. A definition that writes
Box<Record> into the same field is accepted, because Box is a separate container of
fixed shape that lives on the heap — the definition places not a direct Record inside
Record, but the fixed-shape container itself. The accepted form carries the next link the
same exact way: not directly, but through a Box, and that is why the chain can run as long
as it needs to.
SO4. — the claim that “the chain’s end order proceeds in the same order it was built” is
confirmed by the end order: link-a, link-b, link-c line of the run below — the order does
not follow the reversal seen in binding ends.
// chain.rs — the door the box opens: a type that holds itself inside itself
use std::cell::RefCell;
use std::rc::Rc;
type Ledger = Rc<RefCell<Vec<String>>>;
struct Trace {
name: String,
ledger: Ledger,
}
impl Trace {
fn new(name: &str, ledger: &Ledger) -> Trace {
Trace {
name: name.to_string(),
ledger: Rc::clone(ledger),
}
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
enum Chain {
Empty,
Node(Trace, Box<Chain>),
}
fn length(c: &Chain) -> usize {
match c {
Chain::Empty => 0,
Chain::Node(trace, next) => {
println!("visited link: {}", trace.name);
1 + length(next)
}
}
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
{
let chain = Chain::Node(
Trace::new("link-a", &ledger),
Box::new(Chain::Node(
Trace::new("link-b", &ledger),
Box::new(Chain::Node(
Trace::new("link-c", &ledger),
Box::new(Chain::Empty),
)),
)),
);
println!("chain length: {}", length(&chain));
}
let record = ledger.borrow();
println!("end order: {}", record.join(", "));
}
visited link: link-a visited link: link-b visited link: link-c chain length: 3 end order: link-a, link-b, link-c
This order might look surprising at first, because the rule measured earlier in this lesson
was that bindings end starting from the one bound last. But here there is only a single outer
binding — chain — not three separate bindings. When chain ends, its own Trace field
ends first (link-a), then its own Box<Chain> field ends; that Box, in turn, ends its
own inner value first by ending its Trace field (link-b), then its own Box, and so on.
Because the end is a single chained call proceeding from outside in, it proceeds in the same
direction as construction — a different end shape from the list example in the Container Is
a Class Too section, where there were many independent bindings; here there is a single
nested chain of bindings.
When the four measurements come together, one picture emerges: Box is a container that
changes where a value sits, but never changes how many owners it has. Access to the
value stays the same, moving carries the end point but does not multiply the count, an
explicit call can end it early, and it is this single ownership that lets a type carry the
next link of its own kind. The next question’s ground is laid right here: in none of these
four measurements did the ownership count ever exceed one — it was always a single binding,
at a single time, owning that value.
Summary
- A value has exactly one owner even while it sits on the heap;
Boxdoes not change that count. - A single value inside a container and the values in a list follow different end-order rules: one follows the reverse of declaration, the other follows insertion order.
- Living on the heap does not change how a value is read and used.
- A value on the heap produces exactly one end record in the ledger even when it moves.
- The same type can fall into either the “with its container” or “with an explicit call” class, depending on what is done to it in the source.
- Heap-allocated single ownership makes a self-referential type compile; a chain type’s end order can then proceed in the same direction as its construction.
Next Step
In this lesson a value on the heap kept a single owner throughout — whether it moved, was dropped early, or was chained. The next lesson lifts that assumption: a mechanism where several bindings can own the same value at once — and how that sharing is tracked with a counter readable from the source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.