Storage Classes and Discard
Storage classes (<lazy>, <persistent>, <atomic>) modify when and how a
var is initialized without changing its read/write syntax — and are restricted
to module level by the typechecker.
var<lazy> — the initial expression runs only on first access, not at the
declaration line. Use it for expensive values that may never be needed:
var
// Feature: `var<lazy>` — initializer runs on the FIRST READ, exactly once
// Syntax: `var<lazy> name = expensive_init()`
// When to use: expensive globals you do not always need —
// shaders, parsed configs, large computations, connection pools.
// Skips the work entirely if nothing ever reads the binding.
// Top-level only (lazy is rejected inside functions by the typeck).
// `teste(1)` does NOT run at the declaration line.
fn teste(cur) {
print("current = {cur}")
}
var<lazy> a = teste(1)
teste(2) // runs first → "current = 2"
print(a) // forces lazy init → "current = 1", then prints nil
// Ordering proof: with eager `var`, output would be 1, 2, nil.
// With `var<lazy>`, output is 2, 1, nil — init is deferred until
// the first read of `a`, so `teste(2)` reaches the screen before it.
// Use `let<lazy>` for the more common case: lazy AND immutable
// after init (lazy_static / lazy val / lazy_global pattern).
let<lazy> heavy = compute_pi(1000000)
fn compute_pi(n) {
print("computing pi to {n} digits...")
return 3.14159 // pretend this is expensive
}
print(heavy) // forces init → "computing pi...", then prints 3.14159
print(heavy) // already initialized → just prints 3.14159
// `var<lazy>` is mutable AFTER init. Direct writes also mark it as
// initialized, so reads after a write skip the init entirely.
var<lazy> cache = expensive_default()
fn expensive_default() {
print("default never reached")
return 0
}
cache = 99 // assigns and marks done — init never runs
print(cache) // 99 (no "default never reached")
var<persistent> — the value survives hot reload during zolo dev. On the
first run the init expression executes normally; on subsequent reloads the runtime
restores the saved value, ignoring the initial expression:
player_pos and high_score pick up where they left off after each edit/reload.
// Feature: `var<persistent>` — value survives plugin / runtime hot reload
// Syntax: `var<persistent> name = init`
// When to use: state you want to keep "alive" across edits in
// `zolo dev` — game state, scores, session data, caches.
// Top-level only (persistent is rejected inside functions).
// On the FIRST run of the program, init runs and the value is stored
// in the runtime's persistent registry (per-module key).
var<persistent> player_pos = 0
var<persistent> high_score = 0
fn step(d) {
player_pos = player_pos + d
if player_pos > high_score {
high_score = player_pos
}
print("pos={player_pos} hi={high_score}")
}
step(10) // pos=10 hi=10
step(15) // pos=25 hi=25
step(-5) // pos=20 hi=25
// On subsequent runs (under `zolo dev` after editing this file), the
// init is SKIPPED — `player_pos` and `high_score` start at their last
// values, not at 0. This works because the registry lives on the VM
// state, outside the GC heap of any module, so hot_swap_module never
// touches it.
//
// Reads of a persistent binding cost the same as a plain local
// (1 opcode) — the registry is consulted only at decl and on writes
// (write-through keeps the registry in sync).
// Compose with arithmetic the same as plain bindings.
var<persistent> tick_count = 0
tick_count += 1
print("tick = {tick_count}")
// Persistence is keyed by (module_path, name, type_hash). Renaming a
// binding or changing its declared type invalidates the key — init
// runs from scratch and the old value becomes orphan in the registry.
//
// Two different modules with `var<persistent> counter = 0` get
// DIFFERENT keys (module path is part of the key) — no silent sharing.
//
// Persistence is in-memory only (survives hot reload, not process
// restart). Disk persistence is future work.
// Combine `<lazy>` and `<persistent>` for "expensive init that also
// survives reload" — see 14-var-lazy-persistent.zolo.
The two classes combine: var<lazy, persistent> makes the init both deferred
and preserved across reloads — the expensive computation runs exactly once per
development session:
generate_world() runs once; subsequent reloads use the stored value.
// Feature: `var<lazy, persistent>` — combo: lazy init AND survives reload
// Syntax: `var<lazy, persistent> name = expensive_init()`
// When to use: heavy init you want both deferred AND preserved
// across hot reload — generated worlds, parsed model files,
// pre-warmed caches. Best of both classes.
// Top-level only.
// On first run, registry is empty:
// - `done` starts false.
// - First read fires the init.
// - Init writes the value to the registry.
//
// On subsequent runs (after hot reload):
// - Registry has the value → `done` starts true (via try_get).
// - Reads short-circuit; init NEVER fires.
// - The "expensive" work is paid exactly once across the whole
// editing session, not per reload.
// Persistent requires a POD-like type — primitives, or structs/tuples
// /arrays whose fields are all POD-like. Maps and types with external
// handles (File, Socket, GpuBuffer) are rejected by the typecheck
// (E_PersistentTypeInvalid). Define an explicit struct here.
struct World {
width: int,
height: int,
seed: int,
}
fn generate_world() {
print("=== generating world (this is the expensive bit) ===")
return World { width: 1024, height: 1024, seed: 42 }
}
var<lazy, persistent> world = generate_world()
// At this point: `world` not yet read, registry empty → init NOT fired.
print("about to use world for the first time")
print("world.seed = {world.seed}")
// ^^^^^ THIS read forces the init.
// Output: "=== generating world ... ===" then "world.seed = 42"
// Subsequent reads use the cached value.
print("world.seed = {world.seed}") // just "world.seed = 42", no init log
// Storage class ordering does not matter — these are equivalent:
// var<lazy, persistent> x = ...
// var<persistent, lazy> x = ...
// On reload (under `zolo dev`):
// - Registry already has the world value.
// - First read of `world` after reload short-circuits to the cached
// value — `generate_world()` does NOT run again.
//
// Equivalent for IMMUTABLE-after-init: `let<lazy, persistent>`. Use
// when the value should not be reassigned (the typical case).
//
// Forward refs work cleanly: the typeck pre-infers `now()`'s return
// type from its body before checking this binding (Pass 1.75), so
// the POD check sees `int`, not `Any`. No `-> int` needed; order
// independent.
let<lazy, persistent> startup_time = now()
fn now() {
return 1747000000 // pretend timestamp
}
print("startup = {startup_time}") // first read → fetches/inits
print("startup = {startup_time}") // cached
var<atomic> — declares primitives (int, bool) with atomic access
semantics. In the current Lua backend this is a structural annotation with no
observable effect (single-threaded VM); in the native Cranelift backend it will
emit real atomic intrinsics with no changes to the source code:
var
// Feature: `var<atomic>` — atomic load/store for thread-safe primitives
// Syntax: `var<atomic> counter = 0`
// When to use: shared primitive state read/written from multiple
// threads. Restricted to word-sized primitives: int, bool, ptr, ref.
// Atomic on `let` is rejected (atomic implies mutability).
// In the Lua VM backend (the default runtime today), `<atomic>` is a
// STRUCTURAL NO-OP — emission is identical to plain `var`. Justification:
// - Each LuaState is single-threaded by design.
// - Cross-thread access via plugin bridge is already serialized by
// `Mutex<LuaState>`.
// - Adding atomic wrappers would protect nothing.
//
// In the Cranelift native backend (Plan 3, future work), `<atomic>` will
// emit real atomic intrinsics with `seq_cst` ordering. Programs that
// declare `var<atomic>` today will gain real atomicity automatically
// when running on the native backend, with no source changes required.
// Declaration is identical to plain `var` from the user's perspective.
var<atomic> hits = 0
hits += 1
hits += 1
hits += 1
print(hits) // 3
var<atomic> active: bool = false
active = true
print(active) // true
// Type restrictions enforced by the typeck:
//
// var<atomic> name = "Zolo"
// ^^^^^^ E_AtomicTypeInvalid: `atomic` requires
// a word-sized primitive (int, bool); got `str`.
//
// let<atomic> x = 0
// ^^^^^^ E_AtomicRequiresVar: `atomic` requires `var`, not `let`.
// Compound op restriction (typeck):
// var<atomic> n = 0
// n *= 2 // E_AtomicCompoundOp — no native atomic_fetch_mul on most archs
//
// Allowed: =, +=, -=, ++, --
// (When `|=`, `&=`, `^=` are added to the language grammar, they will
// be allowed too — they map to atomic_fetch_or/and/xor.)
// `<atomic>` composes with the other classes; in those combos atomic
// remains structurally inert in Lua but gains semantics on native.
// var<atomic, persistent> total_kills = 0 // see 13 + 15
// var<atomic, lazy> conn = make_pool() // see 12 + 15
Finally, _ = expr is the phony assignment: it evaluates the expression
(running its side effects) and immediately discards the result. This differs from
let _ = expr, which creates an invisible binding that lives until the end of the
block:
_ = double(5) discards the return value; _ never becomes a readable name afterward.
// Feature: phony assignment — `_ = expr`
// Syntax: `_ = <expression>`
// When to use: evaluate an expression for its side effects (or to satisfy
// a `@must_use` constraint in the future) while explicitly discarding the
// returned value. `_` is the discard wildcard — it is never a usable
// binding, so reading it later is a compile-time error.
//
// See `specs/phony-assignment.md` for the full specification.
fn double(x: int) -> int {
x * 2
}
fn make_list() -> [int] {
[1, 2, 3]
}
// Discard the return value of a function call. `double(5)` is fully
// evaluated (its side effects, if any, run); only its result is dropped.
// Works for any expression type — string, list, arithmetic.
_ = double(5)
_ = make_list()
_ = "ignored string"
_ = 42 + 8
print("phony works at top level")
// expected: phony works at top level
// Phony also works inside function bodies.
fn run() {
_ = double(10)
_ = double(20)
_ = make_list()
print("phony works inside fn")
}
run()
// expected: phony works inside fn
// Restrictions enforced by the type checker:
//
// 1. `_` is NOT a usable binding — reading it is `E_UnderscoreNotBinding`.
//
// _ = 42
// print(_)
// // ^ error[E_UnderscoreNotBinding]: `_` is not a usable binding
// (use a real name to keep the value)
//
// 2. Only `=` is allowed. Compound ops on `_` are `E_PhonyCompoundOp`:
//
// _ += 1
// // error[E_PhonyCompoundOp]: phony assignment only supports `=`
// // (not compound ops)
//
// 3. `_` as a PATTERN (in `match`, destructuring) is unchanged — this
// spec only touches `_` as the LHS of `=` in statement position.
//
// let (_, b) = (1, 2) // OK — pattern usage
// match x { _ => "default" } // OK — pattern usage
//
// 4. Difference vs. `let _ = expr`: phony discards IMMEDIATELY (no
// binding created, no lexical lifetime). `let _ = expr` creates an
// invisible local that survives to end of scope — relevant for types
// with `Drop` semantics like file handles or lock guards.
See also