Odin · parameters & passing

A parameter, four levels deep

A parameter is an immutable binding welded to the value it arrived with. Inside the procedure you may read it, never reassign it — n += 1 on a parameter n is a compile error, for every type. So a procedure can't reach back and change what the caller handed it… unless the caller hands over an address. Pick how the call is made and watch whether the change escapes.

Real outputs & the error below are from compiled Odin programs (claims/lessons/06c-parameters-and-passing).
caller — main()
the caller's own storage the callee's separate value, discarded on return

The one rule

A parameter cannot be reassigned.

Want a scratch variable? Ask for one out loud by shadowing: n := n declares a brand-new local that happens to share the name. The change stays in the callee.

What you're seeing

The rule, enforced. Try to write a value parameter directly and the build stops — and the error names the way out:

increment :: proc(some_int: int) {
    some_int += 1   // a parameter is an immutable binding
}

Error: Cannot assign to 'some_int' which is a procedure parameter
    Suggestion: Did you mean to pass 'some_int' by pointer?

That suggestion is the whole lesson in one line: the only way to make a write the caller can see is to take a ^T and write through it.

Level 1 was the rule. Level 2 is the ergonomics that ride on it: because each parameter is a fixed, named slot, the call site can be expressive — give some parameters defaults so callers can omit them, and name arguments at the call so the call reads itself.

Default values live in the signature: crit: bool = false. Omit that argument and the default is supplied for you. Named arguments let the caller write y = 5, x = 3 — in any order — instead of relying on position, and they compose with defaults so you can set a later parameter while leaving the earlier ones alone. Build a call and watch the result:

choose each argument — omit one to fall back to its default

Why this is more than sugar. A positional call like spawn(3, 5, 100) makes the reader memorize the parameter order to know what 100 means. Naming it — spawn(x = 3, y = 5, hp = 100) — moves that knowledge into the call itself, and a default keeps the common case short. Both are possible only because a parameter is a named slot with a fixed identity, not a free local the body can reshuffle.

read it as: the caller fills named slots Whether the caller passes by position, by name, or leans on a default, the procedure still receives one immutable value per parameter. The defaults and names are entirely a call-site convenience — they change how the call is written, never what the body is allowed to do with what it gets (Level 1 still holds: it can read, not reassign).

Level 2 sold you on the call site. Level 3 is the part that surprises people: even a large aggregate is passed as an immutable value — and to make that cheap, the compiler is allowed to hand the big one over as a hidden pointer. That promotion is invisible, with one exact exception worth seeing once.

A Big :: struct { a, b, c, d: int } is size_of = 32 bytes. The odin calling convention passes anything over 16 bytes by reference rather than copying every byte — but because Level 1 makes the parameter unwritable, you can't tell whether you got a copy or that hidden pointer. It changes nothing about how you reason… until you pass the same variable as both a big value parameter and an explicit ^Big. Toggle the two calls:

passing 32 bytes by value
what actually crosses

The fix: when you genuinely need a guaranteed-independent copy of a big value parameter, force one the same way you make any mutable local — shadow it: v := v as the first line. That makes a fresh value the explicit pointer can't reach. In practice the leak needs that one weird shape — the same object handed in twice, once by value and once by ^T — so you almost never meet it; the takeaway is that "passed by value" is a promise about semantics (immutable, can't be changed by the callee), not a promise that 32 bytes were physically copied.

not the same as the slice element-write A []int, map, or [dynamic]T is a small header that contains a pointer. Pass one by value and you copy the header, but its inner pointer still aims at the same backing run — so s[0] = 0 inside the proc reaches the caller's array with no ^ in sight. That's not the hidden-pointer promotion above; it's the slice carrying a pointer as part of its value. What you still can't do is reassign the header itself (s = ...) — Level 1 forbids it, exactly as it forbids n += 1.

The last level isn't a mechanism — it's a property that emerges once immutability and the explicit ^ work together: the call site becomes a readable contract. You can tell what a procedure is allowed to do to your data without reading its body.

Because a procedure can never reassign or mutate a plain value parameter, the only way it changes the caller is through a pointer the caller chose to hand over — and handing it over is spelled with a literal & at the call. So the call site advertises intent: a bare argument means "you only get to read this"; an & means "yes, I'm letting you change this." Reading a block of calls, your eye scans for the carets:

// no & — these can only read what they're given
    sum    := inspect(world)
    pretty := format(player)

// & — these are allowed to write back into the caller's storage
    add_point(&score)
    update_entity(&player, dt)

And the contract is machine-checked, not a convention. You can't accidentally let a write escape, and you can't accidentally pass-by-pointer — if a proc wants a ^int and you forget the &, the build stops you and even spells the fix:

add_point :: proc(score: ^int) { score^ += 1 }

    add_point(score)   // forgot the &

Error: Cannot assign value 'score' of type 'int' to '^int' in a procedure argument
    Suggestion: Did you mean `&score`

The emergent payoff: in a codebase with hundreds of calls, "which of these can change my state?" stops being a question you answer by reading procedure bodies and becomes one you answer by scanning for &. Immutability removes every other channel; the explicit caret is the single, visible, compiler-enforced one. Mutation is opt-in, written at the call, and impossible to do by accident — that's the whole reason a parameter is welded shut by default.

That's the arc: L1 a parameter is an immutable binding — to change the caller you take a ^T and write through it → L2 that fixed-slot identity is what lets the call site carry defaults and named arguments → L3 even big aggregates pass as immutable values (a hidden pointer for speed, observable only in the same-object aliasing case) → L4 immutability + the explicit & make the call site a readable, compiler-checked contract.

probes reproduce with odin run … -file · outputs, sizes & the L1/L4 errors are real compiler output (claims/lessons/06c-parameters-and-passing)