Odin · pointers

A pointer, four levels deep

A pointer is a variable whose value is the address of some other variable. It does not hold the data — it holds where the data lives. Below, x is an int sitting somewhere in memory; p := &x is a second variable whose 8 bytes are that address. Write through p and watch x change, even though you never named x.

Real bytes, measured from a compiled Odin program (little-endian). The int's bytes are stable; the address differs every run, so it's shown as one representative run.
p : ^int
a pointer — 8 bytes that hold an address
x : int
an int — 8 bytes that hold a value
value bytes address bytes (point at x) zero

Widths (size_of)

int            = 8
^int         = 8
^Player      = 8
^[dynamic]string = 8

Every ^T is the same 8 bytes — an address is an address regardless of what sits at the other end.

What you're seeing

Level 1 was what it is. Level 2 is why you'd want one: shared mutation without copying. A proc that takes ^Player and one that takes Player have the same body — only the caret differs — and they behave completely differently.

Picture a heal proc that adds HP. You call it on a Player{name = "Ada", hp = 20}. The only question is whether the proc sees the caller's Player or a throwaway copy of it. Step through both:

heal :: proc(p: ^Player, ...)

heal :: proc(p: Player, ...)

The mechanism: a Player parameter is passed by copy — the proc gets its own Player, mutates that, and the copy is discarded when the proc returns. A ^Player parameter is passed an address, so the proc and the caller are reading and writing the same bytes. The pointer is what lets a callee reach back and change the caller's data. This is the same aliasing you met with slices in lesson 06 — two names for one storage, a write through either is visible through both.

The counterfactual: if heal only needed to read the Player (say, to print its HP), the copy would be fine — even preferable, because the callee then can't accidentally clobber the caller's data. You reach for ^Player precisely when you want the write to stick: the proc's whole job is to mutate the caller's object in place. No mutation needed, no pointer needed.

two conveniences worth naming You write the ^Player yourself — there's no hidden pass-by-reference, so the type at the call site always tells you whether a proc can reach back and change your data. And you never spell the dereference out to reach a field: the dot auto-dereferences one layer, so p.hp = 50 works whether p is a Player or a ^Player. Explicit in the type, implicit at the field — that's the whole ergonomic story.

Level 2 sold you on the pointer. Level 3 is the bill: a pointer is just a number, and nothing keeps the thing it points at alive. Lifetimes are your job — Odin has no garbage collector and no compile-time lifetime tracking.

The signature footgun is the dangling pointer: a pointer into storage that has already been reclaimed. Watch what happens when a proc returns the address of its own local:

Where Odin helps — and where it doesn't. Odin has a targeted escape check that rejects the most obvious shape at compile time: returning the address of a local. That exact program does not build —

dangling :: proc() -> ^int {
    local := 7
    return &local   // the address of a local that's about to vanish
}

Error: It is unsafe to return the address of a local variable

But that check guards one shape, not lifetimes in general. An address that escapes indirectly — stored through an out-pointer, tucked into a struct that outlives the call, or held past an arena reset (lesson 09) — compiles silently and still dangles. The pointer has no idea whether its target is alive; reading through a stale one gives you whatever the next code left there, or a crash.

the other unchecked footgun: dereferencing nil A fresh pointer is nil — Odin zero-initializes it, so bad: ^int is nil, not garbage. Forcing a real load through it (y := bad^, then using y) reads address 0, and the OS kills the process with an access violation — not a friendly Odin panic, a hardware-level crash (exit code 0xC0000005 on Windows). Curiously, fmt.println(bad^) does not crash: fmt receives bad^ as an any whose data pointer is nil and prints <nil> rather than ever loading from 0. So the crash needs a genuine load, and the guard is a plain if p != nil check — which is one compare-to-zero, the cheapest branch a CPU has.

The last level is the payoff that emerges from the type system: Odin splits "pointer" into three distinct types, and the type itself tells you what you're allowed to do. Reading Odin, you know the author's intent; the compiler enforces it.

"Pointer" actually bundles three different jobs: name exactly one value, mark the start of a run whose length you track separately, and carry a bounded view that knows its own length. Collapse them into a single type and every reader has to guess which job a given pointer is doing. Odin refuses to guess — it gives each job its own type, and each type forbids the operations that don't belong to it:

TypeMeansLength?Index?Deref p^?p + i?
^Tpointer to one Tno (always 1)noyesno
[^]Tmulti-pointer: an unknown count of Tnoyes (unchecked)nono (slice instead)
[]Tslice: a bounded view (lesson 06)yes (carries len)yes (checked)nono

widths: ^T = 8 · [^]T = 8 · []T = 16   (a slice is a ^T plus a length — one extra word of bookkeeping.)

Each "no" in that table is a real compile error, not a convention. Try to break the rules and the build stops you — pick one:


    

The emergent payoff: because the three jobs live in three types, the type is the documentation. A ^Player parameter promises "I touch exactly one Player." A [^]int announces "a run of unknown length that arrived with no count attached — I'll turn it into a []int the moment I know the length." A []Player says "a bounded run I can iterate safely." You don't read a comment to learn intent; you read the type, and the compiler has already checked that the code obeys it. That's the whole reason a language that hands you raw addresses spends a keyword on this: it makes sharing and mutation explicit and machine-checked instead of conventional and hopeful.

That's the arc: L1 a pointer is an address — write through it, the target changes → L2 that's why you pass ^T: shared mutation, no copy → L3 the bill is lifetimes, which are yours (Odin guards the obvious dangling shape, nothing more) → L4 three pointer types make intent explicit and compiler-checked.

probes reproduce with odin run · widths, bytes, outputs & the L3/L4 errors are real compiler output (claims/lessons/06b-pointers)