Reference Parameters
Reference Parameters🔗
Nybl normally passes values independently: changing a function parameter does not change the caller’s variable. Use a ref parameter when the function is specifically designed to replace a mutable variable in its caller.
ref is required in both the function declaration and the call:
fn grow(ref items, count) {
repeat count {
items.push(0)
}
}
let values = []
grow(ref values, 3)
print(values) // [0, 0, 0]The two markers make mutation part of the function’s visible API. Omitting ref for a reference parameter, or adding it to an ordinary value parameter, is an error with a hint that identifies the argument.
Copy-in/copy-out, not aliasing🔗
A reference parameter is a staged local value, not an observable alias into the caller’s scope:
- Nybl snapshots the caller place when the call begins.
- The function reads and changes its staged parameter.
- A normal return writes every staged reference parameter back to its caller variable.
- An error discards every staged change.
This is also called copy-in/copy-out or call by value-result. Nybl’s copy-on-write containers keep the initial snapshot cheap while preserving ordinary value semantics.
Reaching the end of the function and an explicit return are both normal returns. A language-level Result::Err is also an ordinary returned value, so it commits:
fn validate(ref attempts) {
attempts += 1
return Err("not accepted")
}
let attempts = 0
let result = validate(ref attempts)
print(result.is_err(), attempts) // true 1By contrast, panic, another runtime error, or a fatal step/memory/call-depth limit failure rolls the call back. Rollback happens before try_call catches a non-fatal error:
fn update_then_fail(ref items) {
items.push(2)
panic("not committed")
}
let items = [1]
fn attempt() {
update_then_fail(ref items)
}
let result = try_call(attempt)
print(result.is_err(), items) // true [1]Valid reference targets🔗
An explicit ref argument must be a mutable place rooted in a let binding. Fields and indexes can be chained to any depth:
let value = 1
set(ref value) // valid
set(ref (value)) // also valid; grouping is transparent
let rows = [[1, 2]]
set(ref rows[0][1]) // valid nested place
let record = Record { items: [1] }
set(ref record.items[0])These are not valid targets:
const FIXED = 1
// set(ref FIXED) // constant
// set(ref 1) // literal or other expression
// set(ref make_record().field) // temporary rootA variable captured by a closure cannot be a reference target. Pass it through an explicit reference parameter instead. A reference parameter itself also cannot be captured by a nested function or lambda.
The same root binding cannot fill two reference positions in one call, even when the projections differ:
fn pair(ref left, ref right) {}
let value = 1
// pair(ref value, ref value) // error
let values = [1, 2]
// pair(ref values[0], ref values[1]) // same root, also an errorUse distinct variables. This fence prevents observable aliasing and lets all targets commit as one transaction.
Multiple targets and forwarding🔗
All reference parameters in one call commit together. If the function fails after changing any of them, none are written back:
fn replace(ref left, ref right, should_fail) {
left = 10
right = 20
if should_fail {
panic("roll back both")
}
}A function can forward its reference parameter into another reference call:
fn inner(ref value) {
value += 1
}
fn outer(ref value) {
inner(ref value)
value *= 2
}
let score = 3
outer(ref score)
print(score) // 8The inner call commits into outer’s staged local. The original score changes only when outer returns normally; a later failure in outer still rolls the whole operation back.
Evaluation and preflight order🔗
Calls use a deterministic order:
- evaluate the callee expression once;
- check that it is callable and verify arity, argument modes, and reference target shapes that can be rejected immediately;
- evaluate ordinary argument expressions from left to right;
- evaluate index expressions and snapshot reference places in parameter order;
- execute the function.
Mode or target-shape errors therefore prevent ordinary argument side effects. Changes made by valid ordinary arguments are visible in the later reference snapshots.
This contract also applies when a function is called through an alias, closure, module export, or other first-class function value. Parameter modes travel with the callable.
Methods and mutating receivers🔗
User-defined methods can use either a read-only value receiver or a mutable reference receiver. An ordinary self is a value snapshot. Assigning to self, one of its fields, or one of its indexes is a parse error instead of a silent mutation of a discarded copy.
Declare ref self when a method should update its caller:
struct Counter { amount }
fn Counter.add(ref self, amount) {
self.amount += amount
}
let counter = Counter { amount: 3 }
counter.add(4)
print(counter.amount) // 7Method-call syntax supplies the receiver reference implicitly, so the call is counter.add(4), not ref counter.add(4). The receiver may be any mutable field/index place rooted in a let binding. Constants, temporary roots, and captured bindings are rejected.
A method may also declare explicit reference parameters after the receiver:
fn Counter.transfer(ref self, ref total) {
total += self.amount
self.amount = 0
}
let counter = Counter { amount: 3 }
let total = 4
counter.transfer(ref total)
print(counter.amount, total) // 0 7Receiver index expressions are evaluated once before call preflight and ordinary arguments. The mutable receiver and explicit targets are snapshotted in parameter order after ordinary arguments run. They must identify distinct roots and commit together on a normal return; any runtime or resource error rolls all of them back.
Built-in mutating array methods use the same transaction model implicitly for a mutable place receiver. You do not write ref before the receiver:
let items = [1]
items.push(2)
let groups = [[1]]
groups[0].push(2) // writes back through the indexMethod arguments run before Nybl snapshots the receiver. A true temporary may be mutated, but its mutation is discarded after the method returns: ([1, 2]).pop() returns 2, while [1, 2].push(3) returns none. Nested receiver updates rebuild and commit the root atomically; an error leaves the complete root unchanged.
See Methods → Mutating receivers for the built-in behavior.
Built-ins, host functions, and instances🔗
Explicit reference parameters belong to user-defined Nybl functions. Built-in functions, built-in method arguments, and functions supplied by NyblHost accept value arguments only.
Rust’s NyblInstance::call and call_value APIs also accept owned Value arguments rather than Nybl binding locations. They reject a ref-bearing entry or callback before its body executes. Keep host-facing pub fn entries value-only and call reference-based helpers from inside Nybl:
fn increment(ref value) {
value += 1
}
let count = 0
pub fn next() {
increment(ref count)
return count
}The tree-walker, bytecode VM, and AOT-generated runtime implement the same reference semantics and diagnostics.