Skip to content

Unit-level storage: the var declaration

Status: DECIDED (#546). A var is one storage per program, in the data segment, with an address.

The rule

public var File stdout = File(fd: STDOUT_FD, owned: false)   # another unit may name it
var i32 next_id = 0                                          # this unit only

A var declaration stands at the top level of a unit, beside a const. It has the same shape -- a marker, a type, a name, an initializer -- and a different kind:

const var
storage .rodata, one copy per module that reads it the data segment, ONE per program
address none: a read copies the value out yes: a rebind, a poke, a field write and a mutating method reach it
initializer a constant expression a constant expression, plus an EMPTY container
lifetime none initialized before main, never destroyed at exit
moved out of never (CE2436); a plain value copies out the same
visibility public explicit, private by default the same

What it is for

The console handles are the main case. stdout, stderr and stdin are public var File, because the Writer contract takes poke self (so that a BufWriter@(W) can implement it), and nothing writes a constant (CE2400). A var gives the console handle storage the contract can write.

A private var is the common case in Go and Zig code, and it is what keeps public var honest:

  • A flag set once, read everywhere in the unit. var bool verbose = false, set from argv in main, read by every printer in the file, with no parameter threaded through twenty signatures.
  • A counter or an id generator. var i32 next_id = 0 behind fn fresh_id() i32.
  • State a module keeps for itself. A random generator's seed.
  • A cache or a registry, filled on first use: var List@(Entry) cache = List.new().

The initializer

The initializer is a constant expression, evaluated by the same evaluator a const uses: a literal, a constant, an operator, as, an interpolation, a struct or an enum variant built from constants. There is no run-before-main initializer (Go's), because that brings initialization ORDER with it. Two consequences:

  • A var cannot name another var in its initializer, and a const cannot name a var at all: the value is read at run time (CE0108 either way).
  • An empty container qualifies: List.new(), from([]) and new() are the literal descriptor {0, 0, null} and allocate nothing, so the backend emits them as the zero value of the type. HashMap.new() does not qualify: it mallocs its buckets on the spot. from([1, 2]) does not either: the elements need a buffer. Both are CE0108. One predicate, allocates_nothing in semantics/const_eval.py, is read by the typecheck pass and the backend alike, so the two cannot disagree about what qualifies.
  • An enum variant qualifies: a payload-free variant is a tag over a zero payload, so var Maybe@(HashMap@(K, V)) cache = Maybe.None is the cache-filled-on- first-use shape the ruling names, and the first call rebinds it with cache := Maybe.Some(map). The variant is built against the DECLARED type -- the interned Maybe<HashMap<K, V>>, not the bare Maybe -- which is why the declaration is resolved before the initializer is read.

The borrow checker: one storage class beside "local"

A var gets a BorrowState at every function's entry (is_unit_var), so the rules that already exist apply to it with one addition:

  • Borrowable like a local. peek v and poke v take its address; one poke at a time (CE2403); a poke beside a peek is CE2407. foreach(poke r in v.iter()) points into its element storage.
  • Never moved out of (CE2436). f(nom v), let T x = v, return v and a nom self method such as close() would hand storage nothing re-initializes to a callee or a binding that frees it. The rule is CE2410's, the one that fences main's argv view, and it applies to an OWNING type only: a plain var i32 copies out freely. It reads the same for a const, which has no owner either: a take of a const string is CE2436 and .clone() is the escape, while a const i32 copies out.
  • A rebind is the one way to change what it holds. stdout := f consumes f, drops the old value the way a local's rebind does, and stores the new one. A let-borrow out of a var (let string first = names[0]) freezes it exactly as it freezes a local: a mutation of the var while the binding is live is CE2412 at the binding's next use.
  • Never frozen across a call, because no function owns it. A callee may rebind a var the caller is reading; that is what storage means, and it is the caller's to order.

The scope pass owns "what kind of name is this". It and the typecheck pass ask one gate for every borrow position: reject_borrow_of_constant in semantics/constant_borrow.py. A var passes every position a constant fails there -- a poke/peek of it, a poke foreach over it, a let poke/let peek bound from it, a poke self call on it, a poke pattern binding into it, and the same through an alias -- and it passes the CE1002 gate on a rebind target. The typecheck pass's CE2096 gate (a write into a constant) asks the record's is_var and lets a var through -- behind an alias too, so geo.count := 3 writes and geo.SIZE := 3 is refused.

Every one of those readers asks the SCOPED lookup, never ConstantTable.by_name. The flat view holds one record per NAME over the whole program and is first-wins, so it cannot tell the var of one unit from the const of the same name in another unit.

Never destroyed at exit

A var is registered with no scope, so nothing frees it when main returns. That is deliberate: the process ends, the operating system reclaims the pages, and an exit-time destructor pass would need an order between units that nothing else needs. A program whose var holds heap at exit therefore shows those bytes to a leak checker; a test over such a program carries no EXPECT_NO_LEAKS.

The backend: one storage, external linkage

A constant is emitted internal into every module that reads it, which is harmless for a value. A var is ONE storage, so:

  • the declaring unit's module DEFINES it (@io$fs$stdout = global %File {...}), with external linkage under the unit-mangled symbol (docs/design/unit-namespaces.md section 9);
  • every other module DECLARES it (@io$fs$stdout = external global %File);
  • a consumer of a BINARY library declares it under the manifest's link_symbol, and the library's bitcode carries the definition.

The reads and writes reach it through the seams a constant uses: resolve_name_slot answers the global where it answered a local's slot, and namespaced_storage (backend/expressions/names.py) is the ONE reader of an alias that reaches storage -- a rebind, a poke, a field write and a mutating method all ask it.

Library manifest

public_variables mirrors public_constants -- name, unit, type, the declaration as source, an optional doc -- plus link_symbol. A source library needs none of it: its units are recompiled and the per-unit rule above applies. A private var is named in not_exported with kind variable, so a consumer naming it hears CE3005 and not CE1001. A private var an exported generic's body names ships in the export closure's constants with its link_symbol, and the consumer declares it.

What was refused

  • static: in Sushi "static" already means a function called on a TYPE name (List.new(), f64.from_bits(b); Static(ty, name) in docs/design/ir.md), "static dispatch" is everywhere, and in C static means internal linkage, close to the opposite of an exported unit-level value. The word is the surface marker for a receiver-less method -- extend Vec static at(i32 x, i32 y) Vec: -- so it means exactly what it means internally, and Sushi has no static STORAGE at all. The record is docs/design/method-resolution.md.
  • let at the top level: let names a block-scoped binding with RAII drop, and one word would carry two lifetimes.
  • global: the runner-up. var is the Go, Zig, Swift, Nim and Pascal word for unit-level storage, and it reads against const the way the language needs.
  • A kind public by nature: a var that could only be public would be the one place where the visibility rule bends, and a unit's own counter, cache or flag has every reason to stay private.
  • Two contracts (a BufRead-style perk for the buffered types) and relaxing CE2400 so a constant could satisfy a poke self contract: both were the routes the ruling did not take. A buffered-direction contract for lines(), read_line() and fill() is a separate, later question.