Skip to content
academia.sh

Lesson 01 / 14

Stack and Heap

A fourteen-value measurement core runs and places every value's life into one of four classes — scope end, where it was moved, an explicit call, together with its container; the result is fixed at 6/3/1/4. It measures that values ending at scope end finish in the exact reverse of their declaration order, and that a heap-allocated value drops into the same ledger.

Published Updated

In this lesson

  • Defines and distinguishes the four classes (scope end, where it was moved, an explicit call, together with its container)
  • Verifies the 6/3/1/4 distribution of the fourteen values by running the measurement core
  • Demonstrates by running it that values ending at scope end finish in the reverse of their declaration order
Contents

The previous course measured what a line written in source actually produces: an expression, a statement, an item. That three-way classification determined what a line was at the moment it ran, but it deliberately left one question unasked — how long the value that line produces lives. This course looks at exactly that, and one single question runs through every lesson in it: where does this value’s life end?

This question has come up before, in a different context. The Concurrency course measured how a goroutine stops — the termination of a running unit. This course applies the same verb to a different subject: there, a running unit stopped; here, a value ends. A unit’s stopping is triggered by an outside decision (a channel closing, a context being canceled), while a value’s ending can be read straight from the source’s own structure — with no separate decision mechanism needed.

Four Answers

In Rust, a value’s life ends in one of four forms, and these four names stay the same throughout the course.

Class Definition
scope end The value ends at the close of the block it is bound to
where it was moved Ownership passed to someone else; the life ends where the new owner ends
an explicit call A call written in the source ends the life early
together with its container The value is inside a container; it ends when the container does

These four classes are mutually exclusive: a value cannot end both at scope end and where it was moved at the same time, because a moved value’s old binding is already dead — the ending happens exactly once in a value’s life, through exactly one of the four paths. The ending point is read from the source’s own structure and does not depend on a runtime decision; that is why the measurement produces not a duration or a counter, but an ordered record — a list of which value ended when.

The Programming Fundamentals course already established the concept of scope: which block a name appears in, and how the name becomes unreachable once that block is left. This course does not redefine that concept; what this topic contributes is that Rust uses scope not only as a visibility boundary but also as an ending point. A name going out of scope means, in most languages, only that the name can no longer be reached; here, the same event also means that the value the name held ends — and that ending happens at a point known at compile time, with certainty, at runtime.

The Measurement Core

The core used through the rest of the course is life_core.rs. Fourteen separate values are created in four sections (A, B, C, D); each value’s ending is written to a shared observation ledger, and a total per class is printed at the end.

Every section takes its own measurement the same way: before the section starts, the ledger’s current length (a0, b0, c0, d0) is recorded; once the section’s scope closes, the new_entries function returns only the lines added after that length. This keeps the four sections from bleeding into each other’s measurement — the three values that end in section A never show up again in section B’s measurement, because B’s starting point already takes the length after A finished. The same ledger is shared across all four, but each section reads its own share starting from its own starting point.

The observation ledger is held by the Ledger type — a shared record that lets multiple Trace instances write to the same list. The internal structure behind this sharing (Rc<RefCell<Vec<String>>>) is a design choice of the measurement tool; how it works will be treated as its own subject in the Reference Counting and Interior Mutability lessons. What matters here is only this: every Trace value implements the Drop trait, so when a value’s life ends, the compiler automatically runs a closing code path, and that code adds the value’s own name to the ledger. Drop‘s own rules — which types can implement it, its relationship to Clone/Copy — are not this course’s subject; the only thing measured is where in the source this triggering happens.

OR1. Ledger and Trace themselves are design choices of the measurement tool, not a requirement of the language; what decides which class a value falls into is the structure of the code itself — a scope closing, a move, an explicit call, or being inside a container. The Trace type was written to make these four situations visible; it is not itself one of them.

// life_core.rs — where does a value's life end: at scope end, where it was moved,
// as an explicit call, or together with its container.

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());
    }
}

struct Container {
    content: Trace,
}

fn consume(_received: Trace) {
    // The value was moved in here; its life ends here when the body finishes.
}

fn read_log(ledger: &Ledger) -> Vec<String> {
    ledger.borrow().clone()
}

fn new_entries(ledger: &Ledger, previous: usize) -> String {
    let all = read_log(ledger);
    all[previous..].join(", ")
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
    let mut tally: Vec<(&str, &str)> = Vec::new();

    // --- A: scope end ---
    let a0 = read_log(&ledger).len();
    {
        let _first = Trace::new("scope-1", &ledger);
        let _second = Trace::new("scope-2", &ledger);
        let _third = Trace::new("scope-3", &ledger);
    }
    println!("A scope end                    -> {}", new_entries(&ledger, a0));
    for name in ["scope-1", "scope-2", "scope-3"] {
        tally.push((name, "scope end"));
    }

    // A shadowed value also lives until scope end: a new binding does not end the old one.
    let g0 = read_log(&ledger).len();
    {
        let _shadow = Trace::new("shadowed", &ledger);
        let _shadow = Trace::new("shadower", &ledger);
        println!("   at the moment of shadowing -> {}", new_entries(&ledger, g0));
    }
    println!("A shadowing                    -> {}", new_entries(&ledger, g0));
    tally.push(("shadowed", "scope end"));
    tally.push(("shadower", "scope end"));

    // --- B: where it was moved ---
    let b0 = read_log(&ledger).len();
    {
        let moved_in = Trace::new("into-function", &ledger);
        consume(moved_in);
        println!("   right after the function returns -> {}", new_entries(&ledger, b0));

        let inner = Trace::new("inner-scope", &ledger);
        {
            let _new_owner = inner;
        }
        println!("   as soon as the inner scope ends -> {}", new_entries(&ledger, b0));

        let field_value = Trace::new("into-struct", &ledger);
        {
            let container = Container { content: field_value };
            let _name_length = container.content.name.len();
        }
    }
    println!("B where it was moved            -> {}", new_entries(&ledger, b0));
    for name in ["into-function", "inner-scope", "into-struct"] {
        tally.push((name, "where it was moved"));
    }

    // --- C: an explicit call ---
    let c0 = read_log(&ledger).len();
    {
        let early = Trace::new("early-dropped", &ledger);
        let _late = Trace::new("stays-in-scope", &ledger);
        drop(early);
        println!("   right after the explicit call -> {}", new_entries(&ledger, c0));
    }
    println!("C an explicit call              -> {}", new_entries(&ledger, c0));
    tally.push(("early-dropped", "an explicit call"));
    tally.push(("stays-in-scope", "scope end"));

    // --- D: together with its container ---
    let d0 = read_log(&ledger).len();
    {
        let mut list = Vec::new();
        list.push(Trace::new("container-1", &ledger));
        list.push(Trace::new("container-2", &ledger));
        list.push(Trace::new("container-3", &ledger));
        let _boxed = Box::new(Trace::new("on-heap", &ledger));
        println!("   while the container is full -> [{}]", new_entries(&ledger, d0));
    }
    println!("D together with its container   -> {}", new_entries(&ledger, d0));
    for name in ["container-1", "container-2", "container-3", "on-heap"] {
        tally.push((name, "together with its container"));
    }

    // --- report ---
    let finished = read_log(&ledger);
    println!();
    println!("total finished values: {}", finished.len());

    let classes = ["scope end", "where it was moved", "an explicit call", "together with its container"];
    for class in classes {
        let count = tally.iter().filter(|(_, c)| *c == class).count();
        println!("{:<29}{}", class, count);
    }
}
A scope end                    -> scope-3, scope-2, scope-1
   at the moment of shadowing -> 
A shadowing                    -> shadower, shadowed
   right after the function returns -> into-function
   as soon as the inner scope ends -> into-function, inner-scope
B where it was moved            -> into-function, inner-scope, into-struct
   right after the explicit call -> early-dropped
C an explicit call              -> early-dropped, stays-in-scope
   while the container is full -> []
D together with its container   -> on-heap, container-1, container-2, container-3

total finished values: 14
scope end                    6
where it was moved           3
an explicit call             1
together with its container  4

Reading the Four Sections

Section A creates three values (scope-1, scope-2, scope-3), and all three drop into the ledger the moment the inner scope closes — in the output, in the order scope-3, scope-2, scope-1. This ordering is returned to in the Boundary Measurement section. The second part of section A measures shadowing: binding the same name twice (shadowed, then shadower) does not produce a single ending — the two live separately, and both end at scope end. The measurement taken at the moment of shadowing comes out empty, because nothing has ended yet; shadowing only sets up a new binding, it does not shorten the old one’s life. The Programming Fundamentals course established shadowing as a concept, and the Variables and Immutability lesson deepened it with the ability to change type; what this topic contributes here is narrow: shadowing is not an ending.

Section B shows three separate forms of moving that fall into the same class: a value being passed to a function (into-function), a value being rebound in an inner scope (inner-scope), and a value being placed in a struct field (into-struct). All three end before the outer scope closes, because ownership moves to a narrower scope each time, and that narrower scope closes first. The consume function and the Container struct were added to the core to make these three forms concrete: consume takes a value as a parameter and drops it at the end of its scope without returning anything, while Container places a value into its own field and shows that the owner of that field is now the Container instance. Neither is part of the language — both are small helpers written to place the three forms of moving side by side. The detail of these three forms will be taken up in the Ownership and Moves lesson; here, only that all three fall into the same class (where it was moved) is shown.

Section C shows a rare class with a single example: of two values in the same scope, one (early-dropped) is dropped by a call before its scope ends, while the other (stays-in-scope) waits for the normal end of scope. Only one of the fourteen values ends this way — an explicit call moves an ending forward without waiting for the scope’s own flow.

Section D collects four values in a single container: a growable array holds three elements (container-1, container-2, container-3), and a single heap-allocated value (on-heap) sits in the same scope. While the container is full, the ledger comes out empty — no element ends before the container closes. When the container closes, all four values drop into the ledger at the same time, but they keep their own order among themselves: on-heap ends before the list (the reverse of declaration), while the list’s own elements end in the order they were added.

OR2. The drop order of the elements inside a growable array — in insertion order, from the lowest index to the highest — is the language’s own guarantee; this behavior is not an example the core chose, it is a rule the standard library defines.

Stack, Heap, and the Single Owner

Memory layout itself is not this course’s subject — the How Computers Work course and the Escape Analysis lesson had already established the stack/heap distinction as a concept, and this third encounter is not a repeat of that theory. What this topic contributes here is narrow, and it can be summed up in two sentences: the distinction is visible in the type, and every value has a single owner.

The on-heap variable makes this concrete. An ordinary Trace value (scope-1, for example) sits directly where it is bound; Box::new(Trace::new("on-heap", &ledger)), in contrast, places the value on the heap, and this placement is visible in the code — the type itself is Box<Trace>, not Trace. Heap allocation itself as a subject — how it makes recursive types possible, how ownership behaves there — will be taken up later in the Heap-Allocated Single Ownership lesson. The only thing that needs to be seen here is this: on-heap drops into the same ledger through the same Drop mechanism, just like the three array elements on the stack. Where a value lives (on the stack or on the heap) and when it ends are separate questions — one is read from the type, the other from the scope.

The phrase “single owner” is not a slogan here; it is an observation read straight from the ledger itself. None of the fourteen values drops into the ledger twice — every name appears exactly once across the whole output. Even when whether a value sits on the stack or the heap changes, this singularity does not: on-heap, container-1, and scope-1 each end exactly once. This observation is not incidental; if a value could be owned in two separate places at the same time, that value would be dropped twice, and the same name would appear twice in the ledger. The next lesson measures directly how this singularity is preserved during a move — why the old owner can no longer reach the value once ownership changes hands.

Boundary Measurement: Drop Order Is the Reverse of Declaration

Section A’s three values are the clearest example of this. scope-1, scope-2, scope-3 are declared in that order, but when the inner scope closes, the ending order is exactly reversed: scope-3, scope-2, scope-1. This is not a choice made by the measurement tool — it is the language’s own word.

OR3. Values in a local scope always end in the reverse of their declaration order — this is part of Rust’s language specification and does not vary from implementation to implementation. The same rule shows up in section D as well: on-heap ends before list, which is declared after it.

This order is not incidental: the last binding opened in a scope ending first guarantees that nested resources — a connection a file opened, for example — always close in the reverse of the order they were opened. Someone reading the source can state a scope’s ending order without running anything, just by reversing the declaration order.

This guarantee is not just an ordering detail — it also establishes a dependency order. If a value bound second in a scope shares a resource belonging to a value bound first — for example, holding the same ledger in its own field — the second value has to end before the first one; otherwise, by the time it ended, the resource it held would already be closed. Reversing the declaration order does exactly this: whatever opened last ends while its dependencies are still alive. In the life_core.rs core, this dependency is the ledger itself — every Trace value holds its own ledger field, and that field ends not after the Trace value itself, but exactly together with it.

Summary

  • A value’s life ends in one of four forms: scope end, where it was moved, an explicit call, together with its container; of the fourteen values, 6 ended at scope end, 3 where they were moved, 1 at an explicit call, and 4 together with their container.
  • Shadowing is not an ending; rebinding the same name does not shorten the old binding’s life.
  • Whether a value lives on the stack or the heap is read from the type (the difference between Trace and Box<Trace>); where it lives is a separate question from when it ends.
  • Values that end at scope end always end in the reverse of their declaration order; this is the language’s word, not a choice made by the measurement tool.

Next Step

This lesson split where a value’s life ends into four classes and measured scope end’s own order. Of the remaining three classes, the most common — where it was moved — has so far only been seen through three examples. The next lesson deepens that class: when ownership passes from one place to another, exactly what happens to the ending point, and why three separate forms of moving (into a function, into an inner scope, into a struct field) always arrive at the same result.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close