When you pass a value to a function, what can that function change about it? The answer depends on the value's type and on one annotation, &. This chapter is about the whole of that rule, because the half of it people usually assume is the wrong half.
struct Item { val: integer }
struct Box { items: vector<Item> }
A scalar argument is copied
The callee gets its own number. Nothing it does can reach the caller.
fn bump(n: integer) { n = n + 1; }
A struct or collection argument is SHARED
No annotation needed. Writing a field, writing an element, appending, removing and clearing are all done to the caller's value.
fn set_first(b: Box) { b.items[0].val = 7; }
fn add_one(b: Box) { b.items += [Item { val: 1 }]; }
fn empty_it(b: Box) { b.items.clear(); }
What & adds: REPLACEMENT
Assigning the whole binding — b = ... — is the one operation that does NOT reach the caller through a plain parameter. It rebinds the callee's own name and the caller keeps what it had. Declaring the parameter & turns that assignment into a write-back, and that is all & does. The compiler reports the plain spelling below: the value b came in with is never read (warning[never-read]), and neither is the one it is given (warning[dead-assignment]), each with b: &Box as the cure.
fn replace_plain(b: Box) { b = Box { items: [Item { val: 99 }] }; }
fn replace_ref(b: &Box) { b = Box { items: [Item { val: 99 }] }; }
Forwarding
A & parameter can be handed to another function that also takes &. The reference is forwarded rather than dereferenced, so the innermost function's replacement still reaches the outermost caller, at any depth.
fn forward_replace(b: &Box) { replace_ref(b); }
These two only append and write elements, so neither needs & — a plain parameter already does both to the caller's value. The compiler says so if you add one: advice[slow-reference-parameter].
fn ensure(b: Box) {
if len(b.items) == 0 { b.items += [Item { val: 0 }]; }
}
fn set_val(b: Box, val: integer) {
ensure(b);
for sv_item in b.items {
if sv_item.val == 0 { sv_item.val = val; return; }
}
}
fn main() {
A scalar is copied
n = 1;
bump(n);
assert(n == 1, "a scalar argument is copied");
A struct or collection is shared — no & required
shared = Box { items: [Item { val: 0 }] };
set_first(shared);
assert(shared.items[0].val == 7, "an element write reaches the caller");
add_one(shared);
assert(len(shared.items) == 2, "an append reaches the caller");
empty_it(shared);
assert(len(shared.items) == 0, "a clear reaches the caller");
This is the half people assume is the other way round. & is NOT the permission you need in order to append to a vector parameter — a plain parameter already grows the caller's vector.
Replacement is the one thing that needs &
keep = Box { items: [Item { val: 1 }] };
replace_plain(keep);
assert(keep.items[0].val == 1, "a plain parameter cannot replace the whole value");
swap = Box { items: [Item { val: 1 }] };
replace_ref(swap);
assert(swap.items[0].val == 99, "a `&` parameter can");
So read & on a signature as "this function may hand you back a different value", not as "this function may modify what you give it".
Forwarding carries it through
The forwarder needs the & as much as the function it calls. Its own body never assigns to the parameter, so the & can look unnecessary — but it is what carries the inner replacement out. Written fn forward_replace(b: Box) the assertion below fails — and the compiler says why: the replacement lands in the forwarder's own b, which nothing reads after the call (warning[dead-assignment]).
deep = Box { items: [Item { val: 1 }] };
forward_replace(deep);
assert(deep.items[0].val == 99, "the replacement survives one forward");
A chain of plain parameters carries the shared value just as far, which is why set_val below needs no annotation at any level.
b = Box { items: [] };
set_val(b, 42);
assert(b.items[0].val == 42, "a plain parameter forwards its sharing too");
Calling the inner function directly does the same thing, and calling it twice is harmless: it only fills an empty box.
b2 = Box { items: [] };
ensure(b2);
assert(len(b2.items) == 1, "ensure added one item");
ensure(b2);
assert(len(b2.items) == 1, "ensure is idempotent");
A worked example
set_val fills the first zero slot, leaving anything already set alone.
b3 = Box { items: [] };
b3.items += [Item { val: 10 }];
b3.items += [Item { val: 0 }];
set_val(b3, 99);
assert(b3.items[0].val == 10, "an existing value is left alone");
assert(b3.items[1].val == 99, "the empty slot is filled");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Reference parameter forwarding
// @TITLE: What a function can change about the values you hand it
// When you pass a value to a function, what can that function change about
// it? The answer depends on the value's type and on one annotation, `&`.
// This chapter is about the whole of that rule, because the half of it
// people usually assume is the wrong half.
struct Item { val: integer }
struct Box { items: vector<Item> }
// ## A scalar argument is copied
// The callee gets its own number. Nothing it does can reach the caller.
fn bump(n: integer) { n = n + 1; }
// ## A struct or collection argument is SHARED
// No annotation needed. Writing a field, writing an element, appending,
// removing and clearing are all done to the caller's value.
fn set_first(b: Box) { b.items[0].val = 7; }
fn add_one(b: Box) { b.items += [Item { val: 1 }]; }
fn empty_it(b: Box) { b.items.clear(); }
// ## What `&` adds: REPLACEMENT
// Assigning the whole binding — `b = ...` — is the one operation that does
// NOT reach the caller through a plain parameter. It rebinds the callee's
// own name and the caller keeps what it had. Declaring the parameter `&`
// turns that assignment into a write-back, and that is all `&` does. The
// compiler reports the plain spelling below: the value `b` came in with is
// never read (`warning[never-read]`), and neither is the one it is given
// (`warning[dead-assignment]`), each with `b: &Box` as the cure.
fn replace_plain(b: Box) { b = Box { items: [Item { val: 99 }] }; }
fn replace_ref(b: &Box) { b = Box { items: [Item { val: 99 }] }; }
// ## Forwarding
// A `&` parameter can be handed to another function that also takes `&`.
// The reference is forwarded rather than dereferenced, so the innermost
// function's replacement still reaches the outermost caller, at any depth.
fn forward_replace(b: &Box) { replace_ref(b); }
// These two only append and write elements, so neither needs `&` — a plain
// parameter already does both to the caller's value. The compiler says so
// if you add one: `advice[slow-reference-parameter]`.
fn ensure(b: Box) {
if len(b.items) == 0 { b.items += [Item { val: 0 }]; }
}
fn set_val(b: Box, val: integer) {
ensure(b);
for sv_item in b.items {
if sv_item.val == 0 { sv_item.val = val; return; }
}
}
fn main() {
// ## A scalar is copied
n = 1;
bump(n);
assert(n == 1, "a scalar argument is copied");
// ## A struct or collection is shared — no `&` required
shared = Box { items: [Item { val: 0 }] };
set_first(shared);
assert(shared.items[0].val == 7, "an element write reaches the caller");
add_one(shared);
assert(len(shared.items) == 2, "an append reaches the caller");
empty_it(shared);
assert(len(shared.items) == 0, "a clear reaches the caller");
// This is the half people assume is the other way round. `&` is NOT the
// permission you need in order to append to a vector parameter — a plain
// parameter already grows the caller's vector.
// ## Replacement is the one thing that needs `&`
keep = Box { items: [Item { val: 1 }] };
replace_plain(keep);
assert(keep.items[0].val == 1, "a plain parameter cannot replace the whole value");
swap = Box { items: [Item { val: 1 }] };
replace_ref(swap);
assert(swap.items[0].val == 99, "a `&` parameter can");
// So read `&` on a signature as "this function may hand you back a
// different value", not as "this function may modify what you give it".
// ## Forwarding carries it through
// The forwarder needs the `&` as much as the function it calls. Its own
// body never assigns to the parameter, so the `&` can look unnecessary —
// but it is what carries the inner replacement out. Written
// `fn forward_replace(b: Box)` the assertion below fails — and the
// compiler says why: the replacement lands in the forwarder's own `b`,
// which nothing reads after the call (`warning[dead-assignment]`).
deep = Box { items: [Item { val: 1 }] };
forward_replace(deep);
assert(deep.items[0].val == 99, "the replacement survives one forward");
// A chain of plain parameters carries the shared value just as far, which
// is why `set_val` below needs no annotation at any level.
b = Box { items: [] };
set_val(b, 42);
assert(b.items[0].val == 42, "a plain parameter forwards its sharing too");
// Calling the inner function directly does the same thing, and calling it
// twice is harmless: it only fills an empty box.
b2 = Box { items: [] };
ensure(b2);
assert(len(b2.items) == 1, "ensure added one item");
ensure(b2);
assert(len(b2.items) == 1, "ensure is idempotent");
// ## A worked example
// `set_val` fills the first zero slot, leaving anything already set alone.
b3 = Box { items: [] };
b3.items += [Item { val: 10 }];
b3.items += [Item { val: 0 }];
set_val(b3, 99);
assert(b3.items[0].val == 10, "an existing value is left alone");
assert(b3.items[1].val == 99, "the empty slot is filled");
}