Odin · procedures as values

A procedure is a value, four levels deep

A procedure in Odin is a value, just like an integer. It has a type — written as its signature, proc(int, int) -> int — and a variable of that type holds the address of one compiled procedure. Below, op is one such variable: a single slot. Reassign the slot and the exact same call expression op(6, 2) runs a different procedure.

Sizes & results below are real output from a compiled Odin program. The address shown is one representative run (a code address differs per build).
op : Binop
a proc value — 8 bytes that hold a code address
the executable's code segment
the 8 address bytes in the slot

Widths (size_of)

proc(int) -> int     = 8
proc(int, int) -> int = 8
proc(string)        = 8
proc()             = 8
rawptr            = 8

Every proc type is the same 8 bytes, whatever its signature — the value is just an address, the same width as a bare rawptr. No second word for an object, an environment, or a dispatch table.

What you're seeing

Level 1 was what it is. Now: the case where a proc value isn't a curiosity but the right tool — when the behavior to run is data, picked at runtime by a key. That's a dispatch table: an array (or map) whose element type is a proc type.

Map each key to a procedure once, in a literal. Then table[kind] looks up the proc value for that key and the (8, 3) after it calls whatever it found. The same two-character lookup-and-call serves every key — adding a new behavior is adding a row to the table, not editing the call site.

table : [Op]Binop  —  one proc value per key

The counterfactual: if you only ever ran one fixed operation, you'd just write that operation — no table, no proc values, nothing. The table earns its keep precisely when the operation isn't known until the program is running: it arrives as an Op read from input, a config file, a packet, a script. The key is the data; the table turns the data into the procedure to run.

Why the alternative is worse

The other way to turn a key into a behavior is a branch ladder that re-lists every case at every call site:

The branch ladder costs you at every site. Every place that needs to dispatch re-writes the same switch. Add a fourth operation and you must hunt down and edit each of those sites — miss one and that site silently keeps doing the old set. The dispatch table collapses all of it to one table[kind](…): the cases live in one literal, and every site that indexes the table gets the new behavior for free. Same key-to-behavior idea, one copy instead of many, and the call site never grows.

Level 2 sold you on the slot. Level 3 is the bill — two edges that come with storing behavior in a variable: the slot can be empty, and the type is strict. Both are checkable; one is checked for you at compile time, the other you guard at runtime.

The bill, part one — the slot can be empty. The zero value of a proc type is nil: a declared-but-unassigned slot holds no procedure. Calling through it has no address to branch to, so the program crashes on the indirect call (it exits with a nonzero, failing status). The safe path guards the slot first:

op : Binop // never assigned

op == nil  — the slot is empty
op(1, 2)  → crash on the indirect call (nonzero exit)

guard before you call

if op != nil { op(1, 2) }
empty slot → skipped; a set slot → called

This is why an optional callback — a proc value stored in a struct field, set only sometimes — is almost always invoked as if cb != nil { cb(…) }. The default state of a callback field is the empty slot; the guard is one compare-to-zero, the cheapest branch there is.

The bill, part two — the type is strict. A proc type is its whole signature. Two proc types match only if parameter count, parameter types, and return type all line up. A three-argument proc is a different type from a two-argument one, and the compiler refuses the assignment by name — at build time, before the program ever runs:

Binop :: proc(a, b: int) -> int
div3  :: proc(a, b, c: int) -> int { return a / b / c }

op: Binop = add
op = div3   // a 3-arg proc into a 2-arg slot

Error: Cannot assign value 'div3' of type 'proc(int, int, int) -> int' to 'Binop'
    Expected: int, int
    Got:      int, int, int
a proc literal is not a capturing thing A proc value written inline — square := proc(x: int) -> int { return x * x } — is the same procedure it would be at package scope; it just isn't given a name. It captures nothing from the surrounding scope. Reach for an outer local from inside the literal and the build stops you cold: writing count += 1 in a literal that has no count parameter gives Error: Undeclared name: count — the literal's body simply can't see main's locals. When the procedure needs state, you pass it in (a parameter, or a pointer to a struct that holds the state). The slot carries an address and nothing else, which is exactly why there's nowhere for captured variables to live.

The last level is the property that emerges once behavior lives in a slot: you can swap what a system does at runtime by writing one variable. The call sites don't change; the slot does.

Put a proc value in a struct field — a plug-in point. The rest of the program calls through that field every frame, never touching it. Change the mode and you reassign just the field; every call site downstream now runs the new behavior. Flip the mode below and watch the same step() call produce a different result:

world.step : proc(int, int) -> int  —  one field, swapped live

The emergent payoff: the behavior became a value you can move. Reassigning one 8-byte field re-routes every world.step(a, b) in the program at once — no branch threaded through every call site, no re-compile, no class to subclass. The same move scales out: a per-state tick function for an actor's behavior, an on_click handler set when a widget is built, a comparator handed to a sort, a parser's per-token rule. Each is one slot you can read, fill, guard, and reassign — the whole vocabulary from the first three levels, applied.

That's the arc: L1 a proc is a value — one 8-byte slot holding a code address, reassign it and the same call runs a different proc → L2 a table of those slots turns a runtime key into the behavior to run → L3 the bill is an empty slot (guard != nil) and a strict signature (the compiler checks it) → L4 because behavior is now a movable value, one assignment re-routes the whole program.

probes reproduce with odin run · sizes, outputs & the L3 errors are real compiler output (claims/lessons/13-procedures-as-values)