all_lessons/Rust/02 · Places, values, and the stacklesson 3 / 23

Places, values, and the stack

Lesson 01 reduced the promise to a few cheap local fixes plus one law about names and places, and left its words undefined. This lesson defines them in the simplest world — no heap, no borrowing, only the slots of a stack frame — where a value is bytes plus a type, a place is a slot sized before the program runs, and a name cannot write unless it says so. A length beside the address, a proof that every read follows a write, and a defined answer for every arithmetic operation pay three of the cheap rows. What is left is where this world breaks: a value whose size is decided at run time.

The thesis, here
Three of Lesson 01's five cheap rows are paid here, each by one local decision and none needing a collector or a run-time system. The words they are stated in (value, place, name, effect) are also the words of Part II's law, so they must be exact first.
Linear position
Forced by: Seven kinds of bug collapse into a handful of cheap local fixes plus one law: never mutate or free a place while another name can still reach it. To enforce that we need a precise vocabulary — place, value, name, lifetime — and a model of where values live. Where do values live, and what happens to them, in the simplest case?
New idea: with no heap and no borrowing, a value is bytes plus a type in a place whose size is fixed at compile time, reached through a name that cannot write without mut and ended at the closing brace. A slice carries its length beside its address and every path to a read must pass a write, so bounds, initialization and overflow become local rules.
Forces next: On the stack every value has a scope: it is created at its declaration and ends at the closing brace, automatically, exactly once. But a String, a Vec, anything whose size is decided at run time, lives on the heap, where nothing ends it automatically. Who frees it, and how do we make sure it happens exactly once?
The plan
Six moves. (1) Take let x = 5 apart and see why names are read-only by default. (2) Put the places in a frame: static sizes, alignment, padding, field order. (3) Carry the length with the address and buy the bounds check for one compare. (4) Predict layouts with an explorer checked against rustc. (5) Close the other rows inside a slot: definite assignment, defined overflow and casts. (6) Find the edge: a value whose size is decided at run time.

1 · The smallest world: a value, a place, a name

Start with the smallest program that has anything in it (let introduces a name; println! prints, and {x} inserts the value of x):

fn main() {
    let x: i32 = 5;                     // a place named x, holding 5
    let y = x + 1;                      // read x's place, write a new place y
    println!("{x} {y}");
    println!("{:?}", x.to_ne_bytes());  // the four bytes in x's place
}

Four words describe it. A value is bytes plus a type: 5, 0, 0, 0 (low byte first on this machine) read as an i32, the 32-bit signed integer an unconstrained literal defaults to; as another type the same bytes would be another value. A place holds one value — here, one slot per let — and a name, or binding, is an identifier bound to one. An effect is what a name does to its place: x + 1 reads, let y writes, and at the closing brace both places end. Now write a place twice:

fn main() {
    let limit = 30;
    println!("limit is {limit}");
    limit = 35;               // a second write through a read-only name
    println!("limit is {limit}");
}

Why is that an error? The law is about effects — many readers or one writer — so the compiler must know which names can write, and there are two defaults. Writable unless marked (C and C++) tells it nothing until every use is analyzed: any alias might write. Read-only unless marked hands it a free fact: a name without mut never writes, so sharing it conflicts with nothing, and every writer is marked at its declaration. The price is one keyword per writer, and E0384 when it is missing. The keyword belongs to the binding, not to the value: after let mut limit = 30; let frozen = limit; both hold 30 and only limit may write. (&mut is another thing, Lesson 05's.)

Reading the error
error[E0384]: cannot assign twice to immutable variable `limit`
 --> src/main.rs:4:5
  |
2 |     let limit = 30;
  |         ----- first assignment to `limit`
3 |     println!("limit is {limit}");
4 |     limit = 35;               // a second write through a read-only name
  |     ^^^^^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut limit = 30;
  |         +++
It names the binding, points at both writes and offers the edit that makes the second one legal. It cannot know what you meant: if this line wanted another threshold for a single computation, the fix is a second place, let limit = 35;, and the suggestion would turn a constant into a variable that every later line must be read against. "This place changes" versus "this is another place" is a design decision.

That second fix is shadowing: a new let with an old name. It is not a write. It makes a new place and leaves the old one untouched until its own block ends, as an inner block (any pair of braces) shows:

fn main() {
    let limit = 30;
    {
        let limit = limit as f64 + 0.5;   // a NEW place: same name, new type
        println!("inner {limit}");
    }
    println!("outer {limit}");            // the first place was never written
}
inner 30.5
outer 30

The new place even has a new type (as f64 converts; §5). A place keeps one type for life, because its size was fixed for that type: mut changes what a place holds, and only a shadow, being another place, can change what it is.

Toolbox — the words you need to type it in (a side box)
use, paths, the prelude
Standard-library items have paths such as std::collections::HashMap. A few, Vec and String among them, are imported everywhere: the prelude. Anything else needs a use line (below).
A trailing !, {} and {:?}
println!, assert! and vec! are macros: code that writes code, expanded before type and borrow checking. {} is a type's user-facing format, {:?} its debugging one (arrays and slices have only that).
fn main() {
    let sensors = Vec::from(["t1", "t2"]);   // Vec is in the prelude
    let mut seen = HashMap::new();           // HashMap is not
    seen.insert(sensors[0], 21);
}
error[E0433]: cannot find type `HashMap` in this scope
help: consider importing this struct
  |
1 + use std::collections::HashMap;

Every place so far ended at a brace, which is a fact about where places live.

2 · The frame: every slot sized before the program runs

Each call gets a frame, a block of memory with a slot for every local, laid out before the program runs. That works because every local's size is known — the Reference fixes 1 byte for a bool, 4 for an i32, 8 for an f64 — and §3 shows rustc refusing a local whose size is not. Returning releases the frame whole; a block's locals end at its brace. How long a place exists is its lifetime, and here it is exactly its scope. (A borrow's lifetime, which the compiler's model calls a region, is Lesson 06's.) The frame is a model: the optimizer may keep a local in a register, or let two locals whose lives do not overlap share a slot. What is fixed is that every local has a static size and a scope.

Inside a slot a value has a size and an alignment: an f64 starts at a multiple of 8, a u16 at a multiple of 2 (platform-specific; here equal to the size). A struct groups named fields, as in struct Reading { valid: bool, celsius: f64, sensor: u16 }, and each field is a sub-place: for a Reading named r, r.celsius is a path to the eight bytes that hold it, and mut on r covers them all. Fields never overlap (the Reference guarantees it), so r.celsius and r.valid are disjoint places, which Lessons 04 and 07 put to work.

Where do the fields go? #[repr(C)] asks for the C rule, an algorithm the Reference states: declaration order, each field at the next multiple of its alignment, the total rounded up to the largest alignment so the next array element is aligned too. Reading becomes valid at 0, 7 bytes of padding, celsius at 8, sensor at 16 and 6 more: 24 bytes, 13 of them padding. The default, repr(Rust), promises only aligned, non-overlapping fields and at least the largest field's alignment — not the order:

#![allow(dead_code)]      // the fields exist only to be measured
use std::mem::size_of;

struct Reading   { valid: bool, celsius: f64, sensor: u16 }
struct Reordered { celsius: f64, sensor: u16, valid: bool }
#[repr(C)] struct CReading   { valid: bool, celsius: f64, sensor: u16 }
#[repr(C)] struct CReordered { celsius: f64, sensor: u16, valid: bool }

fn main() {
    println!("repr(Rust): {} {}", size_of::<Reading>(), size_of::<Reordered>());
    println!("repr(C):    {} {}", size_of::<CReading>(), size_of::<CReordered>());
}
repr(Rust): 16 16
repr(C):    24 16

Order decides repr(C) and does not matter to the default, which fit Reading in 16 bytes — its fields' 11, rounded up to a multiple of 8.

Road not taken · one fixed layout rule for every struct
C's rule is predictable and charges for declaration order: 13 of Reading's 24 bytes are padding. Rust's default lets the compiler choose; the price is a layout the Reference leaves unspecified, free to change between compilations, so anything that depends on it — a C library, bytes reinterpreted as the struct — must ask for #[repr(C)].

Every slot so far has a size fixed by its type. Next comes a run of elements whose length is not.

3 · When the length is decided at run time: slices and fat pointers

An array carries its length in its type: [21, 23, 19, 22, 20] has type [i32; 5], 20 bytes in a slot like any other. Now ask for some of its elements — week[1..4], elements 1 to 3, or however many arrived today. That count can be decided at run time, so it cannot be in a type, and [i32], a run of no particular length, has no size to build a slot for:

fn main() {
    let week = [21, 23, 19, 22, 20];
    let days: [i32] = week[1..4];   // how many bytes should this slot have?
}
error[E0277]: the size for values of type `[i32]` cannot be known at compilation time
  = help: the trait `Sized` is not implemented for `[i32]`
  = note: all local variables must have a statically known size

The note is §2's frame in the compiler's words. What can be a local is a pointer to the elements plus their count: &week[1..4], of type &[i32], a slice. The & asks for a view, not a copy; what a view promises is Lesson 05's subject. A pointer that carries something beside the address is a fat pointer:

use std::fmt::Debug;
use std::mem::size_of;

fn main() {
    println!("{}", size_of::<&i32>());         // 8: an address
    println!("{}", size_of::<&[i32]>());       // 16: address + number of elements
    println!("{}", size_of::<&str>());         // 16: address + length in bytes
    println!("{}", size_of::<&dyn Debug>());   // 16: address + vtable address
}

The second word is an element count, a byte length of UTF-8 text, or, for &dyn Debug, the address of a table of functions, a vtable (Lesson 13); pointers to sized things stay one word. The Reference promises only that a fat pointer is at least as big as a thin one; two words is today's size, not a guarantee.

Because the length travels with the address, a[i] can compare i with it and, when i is out of range, stop the program with a panic:

fn nth(readings: &[i32], i: usize) -> i32 {
    readings[i]                  // compares i with the length the slice carries
}

fn main() {
    let week = [21, 23, 19, 22, 20];
    println!("{}", nth(&week, 2));
    println!("{}", nth(&week[1..3], 2));   // a view of 2 elements: there is no index 2
}

It prints 19, then stops: index out of bounds: the len is 2 but the index is 2. A panic is defined behavior — an orderly stop (Lesson 10), not a read past the end — and Lesson 01's first cheap row is paid. C cannot check, because the array arrives as a bare address:

int nth(const int *readings, int i) {
    return readings[i];      /* no length here: nth(week, 7) reads past the end, undefined */
}

The price is one compare per checked index, Lesson 00's honest concession, and the optimizer drops it where it can prove the index in range. A constant index it can check at compile time is caught earlier: for let x = week[5]; in a file week.rs, rustc 1.98.1 stops with error: this operation will panic at runtime (index out of bounds: the length is 5 but the index is 5). That is a lint, a check with a configurable level, denied by default (#[deny(unconditional_panic)]), so it has no error code. The promise rests on the run-time check; the lint only brings the news earlier.

Text is the same shape plus one rule: a str must be valid UTF-8, &str carries a length in bytes, and slicing by byte offsets can land inside a character. Bytes and length are both at hand, so the check is local again:

fn main() {
    let city = "Zürich";              // 'ü' takes two bytes in UTF-8
    println!("{}", &city[0..1]);      // "Z": byte 1 starts a character
    println!("{}", &city[0..2]);      // byte 2 is inside 'ü'
}

It prints Z, then panics: end byte index 2 is not a char boundary; it is inside 'ü' (bytes 1..3 of string). This is the smallest type that carries a proof: every str promises UTF-8, so readers never re-check, and the one operation that could split a character checks instead. The library keeps that promise, not the compiler: std::str::from_utf8_unchecked skips the check and is callable only inside unsafe, where the caller takes over the promise — the first honest obligation of Lesson 19.

Sizes, alignment and fat pointers make the bytes of every fixed-size value predictable — so predict them.

4 · Predict the bytes

The explorer runs §2's repr(C) algorithm on a struct you build. The toggle switches to the default, modeled as "largest alignment first": a model of the size and alignment you may rely on, not of rustc's order.

Layout & fat-pointer explorer
The first slider keeps the first k fields of the list; sort reorders them largest-alignment-first, add field appends one from the palette, and N sets the length of every [u8; N]. A fat pointer is drawn as its two words.
size_of
—
align_of
—
padding
—
the other repr
—
Show the core JS
function layout(fields, repr) {
  var order = repr === 'Rust' ? sortByAlign(fields) : fields.slice();
  var off = 0, align = 1, used = 0, blocks = [];
  order.forEach(function (f) {
    var at = alignUp(off, f.align);
    if (at > off) blocks.push({ pad: true, at: off, size: at - off });
    blocks.push({ field: f, at: at, size: f.size });
    off = at + f.size; used += f.size; align = Math.max(align, f.align);
  });
  var size = alignUp(off, align);
  if (size > off) blocks.push({ pad: true, at: off, size: size - off });
  return { size: size, align: align, used: used, padding: size - used, blocks: blocks };
}

What to try. Drag the first slider from 1 to 8. valid alone is 1 byte; with celsius it is 16, seven of them padding; sensor takes repr(C) to 24 while the default stays at 16; all eight fields cost 72 against 56. Press sort and repr(C) drops to 56 too. Load Reading (§2), add a [u8; N] and drag N: in repr(Rust) the array is free up to N = 5, because it moves into the tail padding, and at N = 6 the struct grows to 24.

What you should have found. Padding is the price of alignment, and under repr(C) declaration order sets it; the default reorders and reaches the smallest size the rules allow — the fields' sizes summed, rounded up to the largest alignment. Over 558 field lists (500 random, each palette entry alone, every example on this page) under both reprs, the algorithm matched rustc 1.98.1 on every repr(C) size, alignment and offset and every default size and alignment (tools/rust_verify/02_layout.js). The order is another matter: in the 500 random lists rustc's matched the model's only 219 times. Predict the size, never the order.

5 · The other cheap rows: never written, or does not fit

Bounds is paid. Two more rows concern the bytes inside a place: a read before any write, and a write whose answer does not fit. (The other two point forward: absence as a type is Lesson 09's, and reinterpreting bytes, which needs unsafe, is Lesson 19's.)

definite assignment — no read before a write

A slot exists as soon as its frame does, before anything is written into it; reading it then is Lesson 01's uninitialized read, undefined in C. Two roads rule it out. Write a default into every slot on entry, as Go's zero values do (the Go track's Lesson 02): a store per slot, a forgotten assignment turned into a valid-looking wrong value, and no answer for types with nothing to default to, since a reference may never be null. Or prove before the program runs that every path to a read passes a write — definite assignment, free at run time. Rust takes the second:

fn main() {
    let celsius = 31;
    let status: &str;           // a place, not yet written
    if celsius > 30 {
        status = "hot";
    }
    println!("{status}");       // on the other path nothing was written
}
error[E0381]: used binding `status` is possibly-uninitialized
 --> src/main.rs:7:16
  |
4 |     if celsius > 30 {
  |        ------------ if this `if` condition is `false`, `status` is not initialized
...
7 |     println!("{status}");       // on the other path nothing was written
  |                ^^^^^^ `status` used here but it is possibly-uninitialized
  |
  = note: when checking initialization, the compiler describes possible control-flow paths without evaluating whether branch conditions can actually have the values shown

Read the note: celsius is 31, so this program could never read an empty slot, yet it is rejected. The analysis follows paths, not values — Lesson 01's "sound, not complete" in its smallest form. The fix writes on every path, else { status = "ok"; }, and status still needs no mut: one deferred write is the only write, which is why the error above is E0381 and not E0384.

overflow — every answer defined

A u8 holds 0 to 255; what is 200 + 100? For signed integers C leaves such answers undefined, and its optimizer may assume overflow never happens (C++ track, Lesson 02). Checking every operation costs a compare and branch each time; wrapping silently is free and wrong without a word. Rust defines both outcomes, for every integer type, and lets the build choose: with overflow checks on — the Reference requires them whenever debug assertions are, as in Cargo's dev profile — the operation panics; with them off, Cargo's release default, it wraps in two's complement. Never undefined. (The operands arrive as parameters, so no constant is visible to the compiler.)

fn total(a: u8, b: u8) -> u8 {
    a + b                      // 200 + 100 does not fit in a u8
}

fn main() {
    println!("{}", total(200, 100));
}

rustc total.rs builds a program that stops with attempt to add with overflow; rustc -O builds one that prints 44 (300 − 256); -O -C overflow-checks=on panics again (rustc 1.98.1, this machine). When you know which answer you want, say so, and the build stops mattering. For a u8 holding 200, checked_add(100) is None (an Option: absence as a type, Lesson 09's subject), wrapping_add(100) is 44, saturating_add(100) is 255, and overflowing_add(100) is (44, true), the wrap plus a flag.

casts — an answer for every input

Numeric as conversions follow the same principle: never a panic, never undefined, an answer for every input. try_from makes a misfit visible:

fn main() {
    println!("{}", 300_i32 as u8);             // 44: keep the low 8 bits
    println!("{}", 21.9_f64 as u8);            // 21: toward zero
    println!("{}", 300.0_f64 as u8);           // 255: saturates at the maximum
    println!("{}", f64::NAN as u8);            // 0: defined, and wrong for a glitched thermometer
    println!("{:?}", u8::try_from(300_i32));   // Err(TryFromIntError(PosOverflow)): failure made visible
}

The language guarantees an answer; asking for the one you meant is your job.

Two kinds of error (a side box)
Local errors: read the message, fix the line
E0425 unknown name, a misspelled type included (below); E0433 unresolved path; E0432 unresolved import; E0308 mismatched types; E0599 no such method; E0277 missing trait. One line, one name, usually a suggested fix. They are the common ones: four of the ten most common errors in JetBrains' RustRover telemetry (opted-in users, December 2023) are name resolution, E0425, E0433, E0432 and E0412; the last was a missing type, and rustc 1.98.1 no longer emits that code (rustc --explain E0412).
Law errors: they need the engine
E0382, E0499, E0502, E0505, E0506, E0597, E0515 name several program points (creation, conflict, later use: Lesson 00), and the fix is a change of design, not of spelling. Lessons 03–08 build the engine that reads them.
fn main() {
    let reading: Celcius = 31;    // a misspelled type: rustc 1.98.1 reports E0425
}

Every rule in this section is local — one function, one place, one operation — and each assumed the same kind of place: a slot of fixed size, created at its declaration and ended at a brace.

6 · Three places a value can live, and the one that does not end itself

A value can live in three kinds of place. On the stack, in frame slots, created at the declaration and ended at the brace. In static memory, for the whole run: the bytes of every string literal, and every static item, which is one place however often it is used. And on the heap: places made and ended while the program runs, at sizes chosen while it runs.

const LIMIT: i32 = 30;                 // a value, copied into each use
static UNIT: &str = "celsius";         // one place for the whole run

fn main() {
    let label: &'static str = "hot";   // a view of bytes in static memory
    let owned = String::from("hot");   // a copy of those bytes in a heap buffer
    println!("{LIMIT} {UNIT} {label} {owned}");
}

"hot" is a &'static str, a fat pointer to bytes in static memory (what 'static promises is Lesson 08's to say). String::from("hot") is another type, the owner of a heap buffer holding a copy of those bytes, and the compiler keeps the two apart:

fn main() {
    let label: &str = String::from("hot");   // a view was expected; an owner was given
}

Here is the edge of this lesson. A frame slot's size is fixed when the program is compiled, but a list of readings that grows as they arrive has a size decided while the program runs. Such a value cannot be a slot: it needs a place made at run time, on the heap, like owned's buffer, and that place gives up what made everything here automatic. A frame slot ends when its brace pops the frame, exactly once, with no decision by anyone. Nothing pops the heap.

Common mistakes / failure modes

"let mut makes the value mutable"
mut belongs to a binding: after let frozen = limit; the value is the same and frozen still cannot write (§1).
"Shadowing is mutation"
A shadowing let makes a new place, possibly of a new type; the old one keeps its value until its own block ends (inner 30.5, then outer 30).
"An array is a pointer, and a slice is a copy of part of it"
[i32; 5] is a value, 20 bytes in the slot, with its length in its type. A slice copies no elements: it is a view, address plus length, 16 bytes here.
"Overflow is undefined, like in C"
Every result is defined: a panic with overflow checks on, a two's-complement wrap with them off, and as never panics (§5).

Checkpoint exercise

Try it
Take struct Record { tag: u8, id: u64, ok: bool, count: u32 }. On paper, predict its size under #[repr(C)] and under the default, then find a declaration order that makes repr(C) as small as the default. Check twice: load Record (checkpoint) in the widget, and in a scratch crate write a #[test] function (a test fails if it panics) around assert_eq!(std::mem::size_of::<Record>(), …) (it panics if the sides differ) for each version, and run cargo test, the exercise harness from here on. Answer: 24 bytes and 16. The sort button's order, id, count, tag, ok, reaches 16 under repr(C), and largest-alignment-first always works, because every field's size is a multiple of its alignment.

Where this points next

A place on the stack was created at its declaration and ended at its closing brace, exactly once, and nobody decided either; that rested on slot sizes the compiler knew. A place on the heap has neither property. Lesson 03 starts from that gap: once a value lives on the heap, which name is responsible for ending it, and what guarantees the ending happens once — not never, and not twice?

Takeaway
With no heap and no borrowing, a value is bytes plus a type, a place is a slot sized at compile time, a name cannot write its place without mut, and every place ends at its closing brace. Struct fields are sub-places at multiples of their alignment: repr(C) keeps declaration order and pays in padding, while the default may reorder. A fat pointer, address plus length, makes indexing one compare that panics instead of reading past the end. Definite assignment proves every read follows a write, and arithmetic and casts always have a defined answer. What this world cannot hold is a value whose size is decided at run time: it needs the heap, where nothing ends a place by itself.

Interview prompts

Companion reads: C++ · 02 Objects and the memory model (the frame, drawn), C++ · 08 Data layout and the cache (padding by hand), C++ · 19 Undefined behavior (what the cheap rows rule out), and Go · 02 Values and the zero value (the road not taken for definite assignment).