Skip to content

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 defers init; let does the same but is immutable after initialization.

12-var-lazy.zolo
Playground
// 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.

13-var-persistent.zolo
Playground
// 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.

14-var-lazy-persistent.zolo
Playground
// 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 hits: int — identical declaration to var, but with future thread-safety guarantees.

15-var-atomic.zolo
Playground
// 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.

16-phony-discard.zolo
Playground
// 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.
enespt-br