Skip to content
academia.sh

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; Box does 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close