The Goose Language — Specification (v4 draft)
This working specification defines Goose's syntax, semantics, and compilation model. It provides the detail needed to implement the language, without the formal structure of an ISO specification. Appendix B lists unresolved items. A readable account of the same rules, with an example for each, is the language reference.
Priorities, in order: Speed, Safety, Expressiveness. Safety here means: no out-of-bounds access, no access to memory at a type other than the one it was written with, no dangling references into freed or retyped memory. It does not mean Rust-style aliasing control; type-safe reuse of an element (reading a valid but logically stale value) is permitted where explicitly noted.
1. Core concepts
1.1 Size classes
Every type belongs to exactly one size class:
- fixed — byte size is a compile-time constant (after monomorphization).
- variable — size is chosen at construction time and never changes afterwards.
- resizable — size may change after construction. Two flavors: grow-only and grow-shrink.
A compound type's class is the max over its parts (fixed < variable < resizable), subject to the placement rules in §3.4.
Additionally, a type is flat if it contains no references, slices, or relative references at any depth. Only flat values may cross thread boundaries (§11.2).
1.2 Memory model
A Goose program's memory consists of:
- The native call stack (and registers) — holds
fixed-size locals and temporaries whenever possible, plus variable-size
locals via
alloca-style allocation when profitable. - N data stacks — large virtual-address-space reservations (reserved up front, committed on use). N is computed statically by the compiler (§10.3). Each data stack is a bump pointer. Growth never moves memory; references into a data stack are stable for the life of the data beneath them.
- Static data — string literals and
constglobals with compile-time initializers. This read-only storage is shared by all program instances (§11.2).
There is no general-purpose heap and no malloc. All
dynamic allocation is expressed as values living on data stacks, owned
by locals (or globals). (The runtime itself may allocate internally for
thread queues, §11.2.)
1.3 The stack invariant
For each data stack:
- Values are pushed in lifetime order and popped en masse when the owning scope exits. Lifetimes on one stack are strictly nested.
- At most one resizable value is live per stack, and it is always the topmost value on that stack, for its entire lifetime: the region from a live resizable's first element byte to the stack's current top belongs exclusively to its elements (its own length header lives below its elements or outside the stack entirely, Appendix C). Growth is a pointer bump; no capacity checks are emitted (address space is pre-reserved; guard pages catch pathological overflow and abort safely).
- Two simultaneously-live resizable values never share a stack. Locals with disjoint (sequential) lifetimes reuse stacks.
- A variable value may be buried under later allocations on the same stack once its construction completes. While a value is under construction on stack S, nothing else may allocate on S, nor may the resizable value it is being added to shrink; construction of nested variable parts proceeds in order as part of the same construction.
- Fixed-size values are placed on the native stack / in registers whenever possible; the length header of an outermost resizable local likewise lives in the owning frame, not on the data stack (Appendix C).
The compiler proves all of this statically; there is no runtime bookkeeping beyond the bump pointers themselves.
2. Lexical structure and syntax style
The syntax follows C and Rust: {} blocks,
// and /* */ comments (block comments nest),
semicolon-terminated statements, postfix type annotations
(x: T). Identifiers [A-Za-z_][A-Za-z0-9_]*. A
UTF-8 byte-order mark at the start of a source file is ignored.
Literals:
Integer: decimal and
0xhex; the full unsigned 64-bit range is accepted (a value abovei64.maxis au64constant, §6.2). Character literals'a'are integer constants.Float: with
.and/or a decimal exponent (1.5,2.5e-3); C99-style hex floats with a mandatory binary exponent (0x1.8p3). A.starts a fraction only when a digit follows, so1..2lexes as a range.String
"...", with escapes\n \t \r \0 \\ \" \' \xNN(two hex digits).Raw string
"""...""": no escapes, and the next"""ends it. On one line, its text is what lies between the delimiters ("""a "quoted" C:\path"""). For a multiline string, the opening"""must end its line. The closing"""must start its line after any indentation. The content is the lines between them, joined by\n, with the closing delimiter's indentation removed from each line. Every content line must start with that indentation; a whitespace-only line may be shorter and then becomes empty. The line breaks immediately after the opening delimiter and before the closing delimiter are excluded. To include a final newline, add an empty content line before the closing delimiter. Source line endings are normalized to\n.let usage = """ usage: goose [options] file.goose -o file.c write C instead of running it """; // "usage: goose [options] file.goose\n -o file.c write C ..."null— the empty value of any optional typeT?(§3.8).self— inside a struct or variant literal, the value that literal is constructing; it exists to initialize non-optional relative-reference fields (§3.9, §4.2).
The language is expression-oriented:
if, match, block and a bare
{ … } block are expressions; a block's value is its
trailing expression. Assignment and ++/-- are
statements, not expressions. A trailing construct that cannot
supply a value — an if without a final else
(an else if chain included) or with a branch that cannot, a
match with an arm that cannot, a loop none of
whose breaks carries a value (§6.4), or a block or scope
ending in one of those — is a statement rather than the block's value,
so a function whose result type is inferred, and a function value's
body, may end in one and produce nothing.
Statement termination. Expression statements end
with ;, but a statement that is a block-ended
construct — if, match, block, the
loops, guard … else { }, a nested fn, or a
call with a trailing function block (§7.6) — needs none. Such a
statement ends at its closing }: operators never continue
it (foo(1) { it } followed by -x; is two
statements), and using one as an operand requires parens
(let v = (if c { 1 } else { 2 }) + 3;). A redundant
; after a block is harmless. Trailing commas are allowed in
every comma-separated bracketed list: struct/array literals, call
arguments, parameter and generic lists, field lists, match arms.
Type syntax is postfix throughout: T[k] array of T,
T& reference to T, T[:] slice of T,
T? optional T, T&<u8> relative
reference, T&<u8 in pool> one measured from a
named pool, Shape.. variable-mode ADT. One prefix,
const, qualifies the first reference or slice built on the
base type: const T& and const T[:] are a
reference and a slice through which the pointee, or the elements, cannot
be written (§9.5); const (u8[:])& parenthesizes to
reach an outer one.
ns::name names a declaration of namespace
ns (§11.1) wherever a declaration can be named -- a type, a
call, a global, a return … from target, a pool;
::name names a global one explicitly. The ::
token is distinct from ., so
image::Shape.Circle is the variant of a namespaced
enum.
Evaluation order is left-to-right everywhere (operands, arguments, field initializers — which named struct literals keep aligned with construction order by requiring declaration order, §4.2). Overlapping copies have memmove semantics.
An earlier by-value operand is read before a later operand executes; a reference or slice retains its address or view, not a snapshot of its contents. Indexing and slicing sample the receiver's region and length before their index or bounds run. Assignment resolves the destination first; compound assignment also reads its old value before the right-hand side. Whole-resizable assignment clears before constructing (§4.4).
A grammar sketch and precedence table are in Appendix D.
3. Types
3.1 Scalars
i8 i16 i32 i64 u8 u16 u32 u64— the integer types, two's-complement, usable everywhere: fields, array elements, locals, parameters, returns, and expression temporaries. Every integer operation computes at its operands' exact type and width (§6.2) — a loop overi32data runs in 32-bit registers end to end, which is what lets backends vectorize it.i64is the conventional default for indices, sizes, and counts (it is what.len, integer literals, and the builtins produce); the other integer widths are useful for compact data and kernels that require a particular width.varint— variable-length integer storage type (§3.6), the one integer spelling restricted to fields and array elements. Reads decode toi64; writable only at construction.f32 f64— IEEE floats, likewise usable everywhere.f32arithmetic stays 32-bit;f32widens tof64implicitly (§6.3), never the reverse. Every integer converts to either float type implicitly, so where a float is expected2works as well as2.0. Float literals adapt to either type, and so does an expression whose float-ness comes only from float literals and integers:n * 0.5computes in 32 bits where anf32is expected and is anf64where nothing asks for a type, as is an untypedletbound to one (§6.3).bool— 1-byte storage, valuestrue/false. Produced by comparisons; required byif/whileconditions (no int-to-bool coercion).
Integer literals are constants without a committed type:
they adapt to any integer type whose range holds their value
(let x: u8 = 255; is fine, = 256 is a compile
error), and where nothing constrains them they are i64
(u64 for values above i64.max). A
named constant — a let or
const global with no written type whose initializer is an
integer constant expression (let SZ = 15;,
let B = SZ * 2;, let NEG = -3;) — is such a
constant as well: each use of it adapts as its value written there
would, at a destination (D { len: SZ } with
len: u8) and as an operand alike (b + SZ with
b: u8 is an 8-bit add), and it is an i64 where
nothing constrains it. A global with a written type
(let SZ: i64 = 15;), a local let and a
var are values of their type. (An untyped float
let, local or global, adapts too, §6.3.) A use adapting a
named constant relies on its keeping its initializer's value, which a
writable reference to it could change, so as for a compile-time size
(§11.1) such a reference is an error in either order; and a division by
a named constant that is zero is left to run time rather than rejected
as a constant one (§6.2). All other conversions follow one rule —
implicit when provably value-preserving, an explicit cast
otherwise — with one exception: an integer becomes a float
implicitly, rounding as float arithmetic does (§6.3).
3.2 Structs
struct X { a: i8[3], b: i32, c: i64 = 0 }- Packed by default: a struct of
i8[3]+i32is 7 bytes. Unaligned access is assumed cheap; there is no automatic padding. pad ninserts n bytes explicitly; barepadaligns the next real field to its scalar storage width, for compatibility with foreign layouts:struct X { a: i8[3], pad, b: i32 }. Pad bytes are never read and are ignored by==. Plain references and slices use pointer alignment (8 in the C backend); composite fields use alignment 1. No automatic tail padding is added. In variable-class layouts (where offsets are dynamic)pad nstill inserts n bytes, but barepadhas no defined alignment to aim for and inserts nothing.- Fields are mutable by default;
letbefore a field name means the field is not assigned after construction (§4.4), andconst f: Tislet f: const T: its contents are read-only too (§9.5). - A field may declare a default value (
c: i64 = 0above,name: u8[] = []); constructors may then omit it (§4.2), and a literal ending in..may also omit a field without one, which takes its type's default value (an array's is empty). Defaults resolve in the declaration's namespace and generic environment, can name globals, and cannot name the constructor's locals or sibling fields. A used default executes at each construction, in field order among explicit initializers; it is not a cached value. Its effects obey the same rules as an explicit initializer's. A default may not lead to a construction that takes it again, of whatever instantiation of its type: one in its own expression (S { .. }instruct S { a: i64 = 1, d: i64 = S { .. }.a }, adefault<T>(), a slice pool's allocation, §5.4), or one reached through the defaults those take, the parameter defaults of the calls it makes (§7.1), and the blocks and functions written in it. That would run without end; it is an error even where a condition would end it at run time, since every construction checks the defaults it takes. A construction taking the default again in the body of a function declared outside it, which it calls, makes that function recursive instead (§7.8). - Layout is declaration order; variable/resizable fields obey §3.4.
Struct and enum declarations introduce nominal
types. type Name = T; declares an alias. Generic structs:
struct Pair<T> { a: T, b: T }, monomorphized like
functions.
A generic type's instantiations must be finite, as a function's
specializations must (§7.7). Each instantiation checks its fields' types
(a variant's payload included), instantiating the types they name, so
fields that name the declaration again — directly or through other
declarations — with a larger type argument each round would instantiate
without end:
struct P<T> { v: T, next: P<T[]>? } needs a
P<T[]>, which needs a P<T[][]>,
and so on, whether or not a program ever follows next. As
for polymorphic recursion (§7.8), the compiler rejects an instantiation
that would make a chain of more than 16 instantiations of one
declaration, each needed by the one before, naming the first few. Fields
naming the declaration with the same arguments
(struct L<T> { v: T, next: L<T>? }), or with
ones that stop changing, reach an instantiation already made instead, so
only a type nesting one declaration more than 16 deep in its own
arguments meets the bound.
3.3 The array family
All array types share element type T and differ in how
their size behaves and is stored. In all of them, the representation is
[metadata][element data...], inline, packed (exact layouts
in Appendix C).
| Type | Class | Stored metadata | Notes |
|---|---|---|---|
T[k] |
fixed | none | k a compile-time constant expression (literals, named
constants, a global's fixed .len/.cap,
arithmetic) |
T[] |
variable | length (u32 default) |
size chosen at construction |
T[u8] etc. |
variable | length of given unsigned int type | explicit length field type: u8–u64
only |
T[varint] |
variable | varint length | most compact |
T[..k] |
fixed | length only (smallest int type fitting k) |
capacity k is static (constant expression); in-place
grow/shrink up to k |
T[..] |
variable | capacity + length (u32 default) |
capacity chosen at construction; in-place grow/shrink up to it |
T[>..] |
resizable (grow-only) | length | element region tops a data stack; grow = bump |
T[>..<] |
resizable (grow-shrink) | length | as above, may also shrink |
A size or capacity k is fixed at compile time, as is the
count n of a fill literal [v; n] (§4.2): a
name in one is a named constant, a let or
const global (§11.1), and resolves as any name does,
lexically first. One may also take X.len of a global
X whose length its declaration fixes, and
X.cap of one whose capacity it fixes
(const names = ["a", "bc"]; var counts: i64[names.len];): a
written type T[k] fixes the length and T[..k]
the capacity; with no written type, an array, fill or string literal
initializer fixes the length, and one naming another such global what
that global's declaration fixes. Where X's type fixes it
too — a written one, or the T[k] an array or fill literal
gives — nothing can change it, and X may be a
var. Where it does not, as a string literal's
const u8[:] does not (§3.7), the length is taken as a named
constant's value is: X may not be a var, nor
be bound to a writable reference (§11.1), as a let holding
a literal's slice never is (§9.5). Either is an i64, as any
.len is, which adapts as no literal does (§3.1). A local of
that name — a parameter, a local variable, or one around a nested
function or block —, a type parameter or a nested function hides the
global there and is no constant, so a size naming it is an error, as a
match pattern naming it is (§8.1); a qualified name (::K,
ns::K) reaches the global past it. So is, in a function's
or a block's parameter and result types, the name of one of its
parameters or type parameters, and in a generic struct's or enum's field
types the name of one of its type parameters: a type parameter stands
for a type, and there are no size parameters
(fn f<N>(a: i64[N]) is an error). A type alias's
sizes name what they name where the alias is declared.
Indexing is bounds-checked — against k for fixed arrays
(checks statically elided where provable), against the current length
for all others.
A stored length must hold the actual element count without
truncation. For example, T[u8] can hold at most 255
elements. A construction exceeding its length field's range must be
rejected when known statically or abort when discovered at runtime,
including when adapting another array, a call result or a string. It
must never produce an image whose metadata and element region
disagree.
Element restrictions:
- Elements of
T[>..<],T[..k],T[..]must be fixed-size (shrink or reuse of storage must never change the type layout of a location). - Elements of
T[]andT[>..]may be fixed or variable (e.g. an array of strings, all flat). They may not be resizable. - An array with variable-size elements is sequential: it
cannot be indexed randomly, only iterated / advanced element-by-element
(offsets are data- dependent). It also has no
pop-like operations (the start of the last element is not findable). Random access takes an index the program keeps itself: a second array of references to the elements, stored as they are pushed (T&<u32 in pool>, 4 bytes each, where the array is a global pool, §3.9).
Growth operations (push, append, …) exist
only on resizable arrays and on limited arrays ([..k],
[..]) up to capacity (exceeding capacity aborts). Shrink
operations (pop, pop_n, resize
downward, clear) exist on limited arrays anywhere, on
[>..<] wherever no live variable refers into it
(§5.2), and on a grow-only [>..] exactly where the
compiler can see that no reference or slice into it is live (§5.1); they
abort when they would shrink below empty (pop on an empty
array, pop_n of more elements than the array holds,
resize to a negative length).
Built-in members: .len (always, returns
i64), .cap (limited arrays),
.push(v) (returns a reference to the new element on
resizable and limited arrays — the idiomatic way to link up just-built
data), .append(src) (src an array/slice of the element
type, whose elements are copied: the references they hold must outlive
the array, §9.2, and none may be self-relative, §3.9; an array literal
there is built instead as a run of such elements, §4.2),
.pop(), .pop_n(n) (drops the last
n elements at once and returns nothing; a negative
n aborts), .resize(n, v) (grow with fill value
v, copied into every slot added, or shrink),
.resize(n) (shrink only), .clear() per the
rules above, and .index_of(r) -> i64 (fixed, limited and
resizable arrays of fixed-size elements): the index of the element
r refers to, (addr − base) / elemsize.
r must be rooted at the array exactly (§9.2), so
the division is exact and the result is in range without a runtime
check. A root names a variable, not a part of it, so where the array is
a field or an element of what its root holds, the reference counts as
rooted at the array only if that storage holds the element type nowhere
else: not in another field or element, nor inside an element. A
parameter's storage is judged at each call (§10.2). A reference rooted
elsewhere is a compile error. Growth always supplies element values — no
operation can expose uninitialized slots (§5.3). Per UFCS these are
ordinary functions: a.push(v) is
push(a, v).
3.4 Placement rules (what may contain what)
- fixed values: anywhere — locals, globals, fields at any position, array elements, ADT payloads, params, returns.
- variable values: locals/globals, params, returns, ADT payloads, array elements (making the array sequential), and struct fields at any position. A field placed after a variable-size field is reached by dynamic offset computation (fine when iterating, costly for random access — a documented performance note, not a rule).
- resizable values: locals/globals, by-value params, returns, and the final field of a struct — recursively: a struct ending in a resizable is itself resizable and may only appear where resizables may. Also as the payload of a variable ADT (making that ADT resizable, same tail rules). Never an array element.
- zero-size values, though fixed: zero-length arrays
(
T[0], andT[..0]of capacity 0) and variant types with no fields (Shape.Point, or one with only pads that insert nothing, §3.2), whose payload takes no bytes behind its ADT's tag. Only as values of their own — locals/globals, params, returns, and behind references — never a struct field, a payload field, or the element type of an array or slice (one an array literal infers included). C has no empty arrays or structs, so the C backend gives each an element slot or a byte (Appendix C.2), which inside another value would take bytes its layout does not count.
At most one resizable per struct (the tail). (Future extension: two resizables with memmove-on-insert semantics, opt-in.)
No type contains itself by value, directly or through what it
contains -- fields, payloads in either ADT mode, variant types, array
elements: it would be part of itself. It may refer to itself, since a
reference or a slice is fixed-size whatever it points at: an ADT's
payload may link to its own ADT or one of its variants, and a type
behind a reference may contain the type holding the reference
(enum List { Nil, Cons { v: i64, next: List? } }, or
struct A { b: B? } with
struct B { a: A }).
3.5 Algebraic data types
enum Shape {
Circle { r: f64 },
Rect { w: f64, h: f64 },
Point,
}The only dynamic-polymorphism mechanism in the language (no
inheritance, no traits, no vtables). Representation: integer tag
(smallest storage type that fits the variant count, default
u8) followed by the payload.
Every ADT type can be used in two modes, chosen at the use site by the containing declaration:
- fixed mode (
x: Shape) — usable only if all payloads are fixed-size and hold no self-relative references (§3.9). Size = tag + max payload size, padded to the largest variant (padding never read, ignored by==). A fixed-mode value may be overwritten in place by a different variant, but interior references into its payload may never be created, so its payloads bind only by value (§8.1, §8.2), and a copy does not keep self-relative references;in poolones copy fine. - variable mode (
x: Shape..) — size = tag + the actual variant's payload. Class is variable (or resizable, if the stored variant's payload contains a resizable tail). May have interior references into the payload, but the variant may never be replaced in place — except by a whole assignment of a resizable one (§4.4), which therefore binds its payloads only by value, as fixed mode does (§8.1, §8.2). A payload of a resizable ADT that is not itself resizable thus holds no self-relative references, which a copy does not keep (§3.9); plain orin poolreferences copy fine.
Allowing either variant replacement or interior references, but not both, preserves the soundness of existential types (Grossman, "Existential Types for Imperative Languages").
Payload-less ADTs are ordinary C-like enums and are fixed, 1 byte by
default. Variant types (Shape.Circle) are themselves
nominal struct-like types (§8.2).
3.6 varint fields
varint is LEB128: little-endian
base-128, 7 payload bits per byte, high bit = continuation; 1–10 bytes;
full 64-bit range. Stored encodings are canonical: the shortest encoding
of the unsigned or zigzag-transformed value. A verified byte image must
preserve that invariant (§12). LEB was chosen because this type is
optimized for values that are usually very small but have occasional
outliers, where it outperformed other formats (measurements summarized
in Appendix B, Resolved, item 1).
There is one varint type, with two encodings by position
— this is user-visible whenever Goose data is serialized directly:
- As an array length field type
(
T[varint]): unsigned ULEB128. - Everywhere else (struct fields, ADT payloads, self-relative
offsets): signed, zigzag-transformed
(
(v << 1) ^ (v >> 63)), so small negatives are as compact as small positives. A pool-relative offset (§3.9) is unsigned, so it is ULEB128 like a length. The transform sits on the value path, not the length/advance path, so it costs no decode latency.
Because the two encodings differ, varint values are never copied
byte-wise between contexts: any varint-to-varint construction (e.g. a
struct varint field initialized from arr.len) goes through
i64 — decode, re-encode.
Restrictions: a varint field/element is written only at
construction of its containing value; changing it means reconstructing
the container. Reads decode to i64; construction stores
accept any integer type except u64 (a varint holds exactly
the i64 value range). A struct containing
varint fields is variable-class. References to
varint fields are always read-only (§3.8). Such a field is
still storage of type varint, which a
varint& destination binds without &,
as a reference destination binds any lvalue of its pointee type (§4.1);
an i64& does not bind it, though it reads as an
i64. That i64 is no storage, so a format
overload taking an i64 by reference is given it in a
read-only temporary (§3.7).
3.7 Strings
There is no built-in string type. A "string" is any array-family type
with element u8: u8[] (flat string with a
fixed length), u8[varint] (compact),
u8[>..] (string builder), etc. String literals are
static constant u8 data, implicitly copyable into any of
these representations and usable directly as slices, of type
const u8[:] (§9.5): read-only through the type, so a
literal is stored only in a slot declared const. No
encoding is enforced; UTF-8 is a library-level convention.
Text. Every scalar, bool, and
u8 array or slice has a text form: integers in decimal,
floats in the shortest form that reads back to the same value of their
own type (so an f32 0.1 prints as 0.1, not as
the digits of the f64 it widens to), with .0
after a whole number so it still reads as a float (1.0,
-0.0, 2147483600.0; 1e+20 keeps
its exponent form), infinities as inf and -inf
and every NaN as nan; true/false,
and a u8 array's bytes as they are. Three builtins produce
it, each taking any number of arguments and inserting nothing between
them: print(a, b, …) writes the forms to standard output
followed by a newline, as one write, so lines printed by different
threads (§11) never interleave; format(out, a, b, …)
appends them to out, any growable u8 array;
str(a, b, …) builds a fresh u8[>..] of
them, constructed at its destination like any resizable result (§7.3),
so words.push(str("item", i)) writes straight into the
element. A character literal is an integer (§2), so
str('x') is "120"; a byte is appended with
push.
Every other value type renders structurally, so print(v)
shows any value: an array or slice of other elements as
[1, 2, 3], a struct or ADT variant as its positional
literal with the type name
(vec3<f32> { 1.0, 2.0, 3.0 },
Circle { 1.5 }, a payload-less variant as
Shape.Dot), a reference as its pointee and a null optional
as null; a u8 array nested inside an aggregate
is quoted and escaped. References are followed as deep as they go, into
values of the very type holding them too, so a value whose references
lead back into it never finishes rendering: its thread runs out of
native stack, a safe abort (§9.3). A user overload
fn format(out: u8[>..]&, v: T) (taking
T by value or by reference) renders a T
through itself instead, wherever a T occurs in an argument,
so a type can choose its own text once for all three builtins. Taking it
by reference, the overload is given the storage the T lies
in, as a reference parameter is (§4.1): the variable, field or element
holding it, or what a reference argument names, as writable as that is
(§9.5); a T that is no storage lies in a temporary,
read-only: a call's, an operator's or a cast's result, a literal, a
.len or .cap, or the value of an
if, match or block, a copy of what the branch
taken gives even where that names a variable (§4.1). The overload is
looked up in the namespace T is declared in, then globally
(§11.1): rendering follows the type, not the namespace of the code
printing it. A type that reaches itself through references renders
structurally only where no overload renders a part of it, at any depth:
otherwise it needs an overload of its own, which may render its parts
through theirs. The three builtins evaluate and render their arguments
in order, each just before its text, and call the overload in the middle
of rendering the argument holding the T: neither the
variables the arguments after that one name nor the rest of that
argument may refer into what the overload shrinks (§5.1). An array's
elements, and a variable-mode ADT's payload (§3.5), are rendered where
they lie, by the count and the variant read as their rendering begins:
an overload called for one of their parts may not shrink the storage
they lie in, which frees the elements, or, as a whole assignment does
(§4.4), rebuilds every variable-size part of the value holding them, a
reference to a whole resizable value being no exception. A slice's
elements are rendered from the view their rendering began with, and a
value behind a reference from where the reference pointed then: an
overload that re-points the slice or rebinds the reference meanwhile
does not change the rest (§2). A fixed-mode ADT's payload is rendered
from a copy taken once its tag is read, since an overload may overwrite
the ADT with another variant and nothing may refer into its payload
(§3.5): the rest of the payload is still the variant the rendering began
with, and an overload taking a part of it by reference is given the
copy's, read-only. The payload renders as its variant's literal even
where there is an overload for the variant type, which renders values of
that type.
3.8 References
T& is a reference to a T: one machine
address, no pointer arithmetic, no casts, never dangling.
References are transparent (like C++ references): an
expression that denotes a reference behaves as its pointee in every
value context — reads, arithmetic, comparisons (r1 == r2
compares the pointees), passing to a by-value parameter,
print(r) — all operate on the target. There is no
dereference operator; the load is implicit (copy(r) names a
copy of the pointee where §4.1 asks for one).
- Created with
&lvalue. Lvalues are: local/global names, fields, indexed elements, dereferenced references, and (for reference creation) variable elements reached by iteration.&of an rvalue (temporary) is an error.&of a location that itself holds a reference yields the stored reference (there are no references to references). - Writes:
r = v(andr += v,r++, …) write the pointee, subject to writability (§9.5) and the target's own rules. Aconst T&does not allow writes to its pointee. - Rebinding: the special assignment
r .= &xupdates the reference value itself, rather than writing through it..=applies to any reference-typed location — variables (subject to theirlet/var), fields, elements. On non-reference locations.=is an error, except in generic code — the body of a function with type parameters or untyped parameters, and the nested functions and function values written in one — where it assigns as=does. Generic code thus stores a whole value of a type parameter's type with.=, which moves a reference itself where the parameter is bound to a reference type and=would write through it, and keeps one in a local of that type, where an inferredletwould copy the pointee (below): std'sswap_atislet t: T = xs[i]; xs[i] .= xs[j]; xs[j] .= t;, which swaps the elements of an array of references, relative ones included (the local holds the plain reference, §3.9), as it does those of any other array. The declaration formlet r .= e;/var r .= e;bindsrtoeby reference whatevereis — an lvalue of any size class (var cur .= pool[0];names the element, no&), a reference-returning call (let e .= pool.push(v);), a narrowed optional — and is how a reference variable is declared without spelling its type: the.=says the binding is a reference, exactly as the rebind does.leton a reference variable says it does not rebind, not that its pointee is const:let r .= xs[i]; r = 5;writes the element (§9.5). - Identity:
r1 .== r2andr1 .!= r2compare the references themselves — whether they have the same address (==compares the pointees). Both operands are references to one pointee type, ornullfor an optional; storage (a variable, field or element) is taken by reference, as.=takes it, son .== pool[head]asks whethernnames that element. - Binding contexts keep the reference rather than loading through it:
an initializer/argument/field whose declared type is a
reference type binds the reference value, and binds an lvalue of the
pointee type by reference without
&(§4.1; writing the&warns). Where a variable's type is inferred (let x = r;), a reference to a fixed-size value decays to a pointee copy — except an explicit&lvalueinitializer, which infers the reference type (but not a construct choosing one, such asif c { &a } else { &b }, whose value is a copy, §4.1) — while a non-fixed lvalue binds by reference (let w = words[0];names the element), and a reference to a non-fixed value denotes one (let a = c.args;, withargs: u8[>..]&, binds what the field refers to, as.=does). To bind a reference-returning call to a fixed-size value, use.=(let e .= pool.push(v);) or annotate (let e: T& = pool.push(v);); a value holding self-relative references cannot be copied at all (§3.9). A function whose result type is inferred takes it from a return the same way (§7.1). An untyped parameter is an anonymous type variable and binds the argument's exact type, reference or not, exactly as an explicit<T>does (§7.7): for a fixed valuef(&x)handsfa reference andf(x)a copy; a non-fixed lvalue is a reference either way. - References to
varintfields are always read-only (varints are written only at construction, §3.6).
Optionals. References are non-nullable by default.
T? is an optional T: represented as a nullable
reference to T (null = address 0, no space cost), with all reference
semantics and restrictions (roots, lifetimes, writability). This
composes with any type — pass i64? for an optional integer.
Applied to a type that is already a reference, ? simply
makes that reference nullable (T&? ≡ T?),
and so does it to a type argument that is one: T? of
Node& is Node?, of
Node&<u32 in pool> the optional relative
reference. T& of a reference type argument is that
reference itself, as & of a location holding one gives
the stored one (loaded, if relative). The literal null is
the empty value of any optional type; an optional struct field with no
declared default defaults to null. Optionals are not
transparent: access requires narrowing via
if/while/guard/assert(r)/== null/!= null
tests. Flow typing: inside the guarded region the optional is a
T& in every respect — transparent, writable through,
bound by .= (if n { let m .= n; … }) — and a
while condition narrows its body the same way even when the
body rebinds the variable, since the test runs again before each
iteration (while n { …; n .= n.next; }). Elsewhere
narrowing is killed by rebinding the variable. o .= &x
/ o .= null rebind an optional; a rebind to a plain
reference narrows it, a rebind to anything possibly null un-narrows
it.
What references may point to. Anything except the
interior of a fixed-mode ADT payload (§3.5). A reference into a
grow-shrink array [>..<] lives in a variable only and
must no longer be live when the array next shrinks (§5.2).
References into a grow-only resizable [>..] remain
valid for as long as they can be named: grown memory never moves, and
the array shrinks only where the compiler can see that no such reference
or slice is live (§5.1). References into limited arrays
[..k]/[..] are also allowed and stay
type-valid across pop/push reuse (§5.3).
3.9 Relative references
A relative reference is a storage form of reference stored as a
narrow offset instead of an address. Spelled
T&<u8>, T&<u16>,
T&<u32>, T&<u64>,
T&<varint> — widths are spelled unsigned (or
varint), like array length field types. It comes in two
forms, differing only in what the offset is measured from:
self-relative, the default, measured from the offset
field itself; and pool-relative, written
T&<u32 in pool>, measured from a named pool's
base.
Both forms have the optional spelling T&<u8>?,
which reserves offset 0 for null. A pool-relative offset is biased by
one. A self-relative field can share its address with an enclosing
value, so a non-null store must not encode as the optional's null: such
a store aborts, while a statically known collision may be rejected
during compilation. A non-optional self-relative reference has no null
sentinel: offset 0 denotes its field address, as in self in
a value's first field. Null is therefore representable in any relative
location whatever the location's root: a value known to be null stores
into an optional relative slot without a root check of its own. Known to
be null are the literal; a variable bound to nothing but null so far
(§9.5), a parameter given null, and a call whose every
return gives null, none of which has a root; and an
optional reference whose only root is static data where static data
holds nothing but null: a writable one (§9.5), or one whose pointee type
no literal supplies, since a read-only u8 reference may
point into a string literal. As one alternative of a merged value (§9.2)
a null names no array: if c { null } else { &pool[i] }
is a link into pool. So a sentinel-ended chain does not
force plain links on the whole structure.
Self-relative. Constrained to point within the same enclosing array/pool as the location storing it.
- Stored as a signed offset of the given width (
varintoffsets use the signed zigzag encoding, §3.6). Loading one yields an ordinaryT&(base = address of the offset field itself). Storing one requires the compiler to see that both the reference and the destination location derive from the same root array: the two roots must be the same variable and both exact (§9.2), since a root that only bounds a lifetime does not say which array the offset would span. A reference read out of a container qualifies exactly when the read-back rule of §9.5 names one candidate; otherwise the error names the ones it could not choose between. The offset is range-checked at the store (abort on overflow of the width). Both ends lie in one root array, so the offset cannot exceed that array's span, and the check exists only where a root can be wider than the width's signed range: a root on a data stack spans at most the reservation (§10.4), so 2 GB or less of it needs no check foru32, and 32 KB or less none foru16. Varint-width relative references are written only at construction, like varint fields (re-encoding could change the byte length); fixed widths may be re-stored with.=/=. - Copying a value that contains self-relative references
(assignment from an lvalue, a by-value argument, a by-value match binder
or case function parameter (§8.1, §8.2), an element copy, an
appendof anything but an array literal, the value aformatoverload takes by value (§3.7), aresizefill value, which is copied into every slot it adds even when it is a literal,copy(x)) is a compile error: the copied offsets would still be measured from the source location. What a reference names is copied the same way wherever a value destination meets the reference (§3.8, §4.1):let n = f();, for anfreturningNode&, copies the node out of the array its links point within, as do avar, an annotated declaration, a by-value argument, a return (one whose result type is inferred included), an assignment, a literal's field or element, a pushed element and each reference result of a multi-value binding given it; the error suggests the bindinglet n .= f();(§3.8) where a declaration was likely meant to bind one. Nor may a link be read out of a copy a temporary holds, a popped element or a control construct's value, which copies the value of its branch even where that names storage (§4.1): such a value's fields or elements holding self-relative references are not read, sliced or iterated, amatchdoes not bind its payload by reference, andprint, which renders a reference as its pointee, does not render it, while a scalar field of it reads as usual (a.pop().v). Construct such values in place (literals; a field a literal's..fills takes its type's default, whose links are null and whose limited arrays are empty, and is no copy), and bind their match payloads, take their variants in case functions and haveformatoverloads take them by reference: an ADT whose payloads hold them is used in variable mode, whose payloads bind by reference where the ADT is not resizable (§3.5). None of these is a copy: a call's by-value result, which is built where it is received (§4.3, §7.3), including a fixed-size aggregate or a trailing block's fresh result. Links to existing elements require the receiving storage and those elements to share an exact root, checked at every call, including forwarded and recursive calls. An argument slot, discarded result, or operand temporary does not inherit the enclosing result's root. A result relying on that root cannot be a resize fill or undergo a representation conversion that stages or moves its bytes; a variant may construct behind its ADT's tag. Also no copy: a function's named result (§7.3), a local every return of the function gives, built at the destination from its declaration (a struct ending in a resizable needs its fields before the resizable tail free of self-relative references and of anything one in the tail points at, since those fields reach the caller as a copy, C.3); and a default value (a literal's..,default<T>()), whose links are nulls, orselfwhere a field's default says so. (TODO 16: track the region a relative reference ranges over, so provably whole-region copies can be allowed.) - Because they are position-independent, structures linked by
self-relative references are trivially serializable / mappable:
to_bytes(a)writes an array's image out,bytes_of(a)views it without copying, andfrom_bytes<T[>..]>(bytes)verifies one back in (§12,design/serialization.md). Verifying is what keeps the arriving bytes from being the one place a reference could enter the program unproven (§9.4), and is also why abytes_ofview is never writable: bytes written through it would be links the checker never proved.
Pool-relative (in pool).
pool names a global var (or
reusable var) of a grow-only resizable type
([>..]) whose storage can hold a T by value
— an element, or a by-value field of one, transitively (the candidate
notion of §9.5). Anything else is a compile error, which for a local or
parameter pool points at the self-relative form: the pool is named where
the field is declared, and a local's name means nothing there.
- The pool is part of the type's identity:
Node&<u32 in pool>is neitherNode&<u32>norNode&<u32 in spare>. Loading either form yields an ordinaryNode&, and storing re-encodes. - The stored value is
(target − base(pool)) + 1, unsigned, so 0 is the null of the optional form and auNwidth covers a pool of up to 2^N − 1 bytes;varintis the unsigned LEB form (§3.6). Loading isbase(pool) + stored − 1, and the result is aT&rooted atpoolexactly (§9.2) whatever it was read out of. - Storing requires the value's root to be exactly
pool; otherwise the error names the value's root, or, for an inexact read-back, the candidates §9.5 could not choose between. The destination may be anywhere — another global, a local, a parameter's pointee — since the offset does not depend on where it is stored. This permits links between arrays: a slot array can hold 4-byte links into the pool. Since the pool is global, its lifetime satisfies the §9.2 store rule. - A width bounds the pool, not the distance between the two
ends, so the store is range-checked exactly where the pool's reservation
can exceed it (§10.4):
u32at a 2 GB reservation needs no check,u16at any realistic one does.base(pool)never moves — a grow-only global's element region starts at its stack's reservation and growth only bumps the top. - A value whose relative fields are all
in poolcopies like any other (assignment, by-value arguments, element copies,pop): its offsets do not depend on where it sits. A value mixing both forms does not. - Whether a parameter points into
poolis settled at each call site, so a function that relinks (fn front(head: Node&, n: Node&)) is specialized per pool like every other root class (§10.2) and needs nothing passed to it.
Values. A relative reference is storage — a field,
an element, or a variable or parameter whose declaration writes the type
(or an alias of it) — and never a value: loading one yields the plain
reference, and that is what anything else receives. A function's result
may not be declared relative (declare the plain T& or
T?, which a relative slot receiving it encodes), while a
result a type argument makes relative, the -> T of
fn get<T>(a: T[>..]&, i: i64) over an array of
links, is the plain reference, as indexing gives it. So is a variable or
parameter a type argument makes relative: let t: T and
v: T over an array of links hold the plain reference, which
is how generic code moving elements through a T local
(std's swap_at, §3.8) moves links as it moves plain
references. Each store back into an element encodes the reference again,
so a self-relative one must still point within that element's root and
an in pool one into its pool. A field or element of type
T (T[4],
struct Box<T> { x: T }) is a link.
default<T>() of an optional relative reference is
likewise the plain null, at any width.
self. A non-optional relative reference
has no null, so a value whose links point back at itself — the sentinel
of a circular list, the first node of a pool — could not be constructed
at all: there is nothing yet for it to point at. self,
written as the entire initializer of such a field in a struct or variant
literal, denotes the value that literal is constructing:
Node { key: -1, prev: self, next: self }. It is legal only
there, and only when the field's type is a non-optional relative
reference whose pointee is the type of the value the literal constructs
(for a variant literal in either enum mode, §3.5, that is the enum, tag
included); self in a nested literal names the literal it is
written in, never an enclosing one. That type must not be resizable: an
offset alone cannot reach a resizable value's header, which is why the
same-root rule keeps every other relative reference away from
one. In a self-relative field the stored offset is minus the field's own
byte offset within the value, so it is the one relative reference whose
meaning does not depend on where the value lives. In an
in pool field it is the value's own offset in the pool,
which only a literal being built inside pool has —
a push or append, an
alloc_index/alloc_ref, or an element store
into it; anywhere else it is a compile error. This allows a structure to
use non-optional links throughout. A sentinel eliminates the need for
null links, and loading a non-optional relative reference requires only
an addition, without a null test.
Which form. Self-relative for position-independent
blobs — a compact tree that is saved, mapped or moved whole (single-byte
links to nearby nodes, see A.2) — and for structures living in a local
or parameter pool, which has no name to write. in pool for
relink-heavy structures in a global pool, where a store is a subtraction
from a base already in a register rather than from the field's own
address; for links into that pool from other arrays, which
self-relative cannot express at all; and wherever the offset is wanted
as an index, since &pool[i] encodes as
i * sizeof(T) + 1 and index_of (§3.3) reads it
back.
3.10 Slices
T[:] = reference + element count, referring to a
contiguous run of Ts inside some root. Slices are the
universal "process a range" parameter type, unifying all array
representations. Like references, slices carry a root and participate
fully in the lifetime system (§9), which prevents dangling slices. They
are the intended read path; mutation idiomatically goes through
references — but writes through a slice are legal when its provenance is
writable (§9.5).
- Created by slicing any array-family value or slice:
a[x..y](x inclusive, y exclusive; omit for 0 / len;^kmeans "len − k"), or allocated from a slice pool (§5.4). An array, or a reference to one, reaching a destination whose type is a slice of its element type implicitly becomes a whole-array slice of that array itself, asa[..]would: a parameter (where an exact-type overload wins over this coercion), an annotatedlet/varor global, an assignment to a slice variable, field or element, a struct or variant literal's field and a field's default, an array literal's element, apush, areturnor a body's final expression, and a branch whose construct's value is a slice (§6.4). The slice isconstwhere the array is read-only (§9.5), and the lifetime rules judge it as any other (§9.2):let s: const u8[:] = item.name;viewsitem.name, whilelet s: const u8[:] = make();binds a view of a temporary and is an error. (An array literal of variable-size elements is not such an array: it has no temporary to be sliced, §4.2.) - Slices of fixed-element arrays index and iterate, bounds-checked against the slice's own length. Slices never grow; shrinking a slice (re-slicing) is always safe.
- Slices of variable-element arrays iterate only (no indexing); the count is an element count.
- Slices obey the same restrictions as interior references (§3.8): a slice of a grow-shrink array is held only by variables and must no longer be live when the array next shrinks (§5.2).
- A slice of a grow-only resizable taken before growth remains valid (it just doesn't see the new elements).
3.11 Index and bound types
An index type uN in a is an unsigned integer
that is below a.len wherever it can be observed. A
bound type uN upto a is one that is at most
a.len: the end of a range. Indexing a with an
index of a needs no check, and an index stored in data
keeps that guarantee wherever it travels. This is how an index loaded
out of one array (a graph's edge targets, a mesh's index buffer) is
checked once, where it enters the program, rather than wherever it is
used (docs/design/index_types.md).
struct Node { lo: u32 upto edges, hi: u32 upto edges }
var nodes: Node[>..] = [];
type NodeId = u32 in nodes;
var edges: NodeId[>..] = [];uNisu8,u16,u32oru64.anames a globalvar(orreusable var) of a grow-only typeT[>..]with fixed-size elements, resolved where the type is written, as a pool's name is (§3.9). Anything else is a compile error: a local or parameter has no name where a type is declared, any other array kind can shrink, and a sequential array cannot be indexed.- The array is part of the type's identity:
u32 in nodesis neitheru32,u16 in nodes,u32 upto nodesnoru32 in edges. The value is the plain integer; the C type is that ofuN. - An index type takes no postfix of its own: an array of them is
(u32 in nodes)[>..], or an alias'sNodeId[>..]. Aforbinder's written type is followed by the loop'sin, so one there is parenthesized too:for w: (u32 in nodes) in .... - Making one.
x as (uN in a), forxof any integer type, aborts unless0 <= x < a.len, with the errora[x]would give, in every build: it is a bounds check, which the bounds-check pass elides where it would elidea[x](§10.5).x as (uN upto a)is the same withx <= a.len. A value thatuNcannot hold then converts as anyasdoes (§6.3), and what it wraps to is still in range.as!cannot make one. A wider width of one array's type converts implicitly (u16 in atou32 in a), and so does an index type to the bound type of its array; the other directions take anas, which checks. A constant is never of an index type and never adapts to one, since nothing abouta.lenis known where the program is compiled. - Loops.
for i in lo..hiwhose bounds are both of index or bound types of one arrayagivesithe index type ofaat their joined width, sincelo <= i < hi <= a.len; so does a countfor i in nof an index or bound type. - As a number. A value converts implicitly to its
plain integer type, and from there as that type does (§6.3). Operators
compute with the plain value, so
w + 1,w << 3,-wandw & mare plain integers. Compound assignment and++/--on a place of an index type are errors. - Passing through. Declarations without a written
type, parameters, results, fields, ADT payloads and elements carry the
type, and a store into one takes only a value of it. A branch,
matchor block value has one array's index or bound type where every branch has one (at the widest width, and a bound if any branch is one), and otherwise the join of the plain integers (if c { w } else { 0 }is au32). A reference or slice to index-typed storage is(u32 in a)&or(u32 in a)[:], never au32&oru32[:], which could write anyu32. A type argument may be an index type, and each instantiation is checked with it (§7.7). - No default. An index type has no default value:
default<T>(), a literal's.., a field default and a global without an initializer all need one, and an empty array has no index at all. - Never shrunk. An array an index or bound type names
is never shrunk:
pop,pop_n,resize,clearand a whole assignment of it are errors anywhere, directly or through a reference parameter, as is a shrink through a reference that may only bound such an array's storage (§9.5). Its length only grows, so a value once in range stays in range. - What stays out.
formatandto_bytes(a, out)do not write into an array ofu8 in x, which is no byte array.from_byteschecks each index in an image against its array's length when it verifies the image, which it rejects otherwise.qput,qget,qpoll,qlimitandthread_spawnarguments take no type holding an index type: each thread program's copy of the array grows on its own (§11.2). Anextern fnorexport fnsignature holds none either, since C code is not checked against the array.
4. Values, copying, and assignment
4.1 Everything is a value; passing by size class
Goose has value semantics: a variable, field or element owns its value inline, and a destination (an assignment, an argument, a return, a push, a field initializer) receives a value of its own. How a value gets there depends on its size class (§1.1):
- Fixed-size values connect by value. Scalars, structs, fixed arrays, references and slices are copied — a reference or slice copy is the reference/slice itself, never the pointee. Large fixed values copy silently; there is no size threshold. A fixed array meeting a slice of its element type is not copied but viewed, as any array is (§3.10).
- Non-fixed values are never copied implicitly. A
variable- or resizable-class lvalue (a variable, field or
element) reaching a destination binds by reference where the
destination's type is a reference or slice, an untyped parameter (§7.7),
an un-annotated
let/var, aforbinding, or a result whose type is inferred (§7.1) — and is an error at a value-typed destination. A value-typed destination takes an rvalue (a literal, a call's result, a constructing expression, built in place per §4.3) or an explicitcopy(x), which is a fresh copy of the stored value, constructed at the destination like any other rvalue. A function's own local is moved byreturn, or as its body's final expression (§7.3).
So f(xs) hands a u8[][>..] variable to
fn f(xs: u8[][:]), fn f(xs: u8[][>..]&)
and fn f(xs) alike by reference, out.push(w)
needs out.push(copy(w)) when w names storage,
and x.f(a) (§7.1) reads as f(x, a) whatever
x's size class. &x is still how a
reference to a fixed value is spelled at an untyped destination
(let r = &n;, or equivalently let r .= n;,
§3.8; f(&n) into an untyped parameter); wherever the
destination's own type is a reference it is redundant, and a redundant
& is a warning.
Where an if, match, block,
loop or bare { } has no destination type — an
un-annotated let/var, a result whose type is
inferred (§7.1), an untyped parameter, an operand, or the value a
for, [..] or builtin works on — its value is a
copy of what the branch taken gives, unless its branches join as a slice
(§6.4). A reference to a fixed-size value, &x included,
is copied as its pointee there, as it is wherever a reference meets a
value (§3.8): let r = if c { &a } else { &b };
makes r a copy of a or b, so the
&s are redundant and warn. A branch naming non-fixed
storage, or a reference to it, is an error there, as at a value-typed
destination: let x = if c { copy(a) } else { copy(b) };
spells the copy. At a reference-typed destination — an annotated
let/var, a reference parameter — each branch
binds by reference instead, whatever its size class:
let r: i64& = if c { a } else { b }; binds
a or b itself,
f(if c { a } else { b }) hands a or
b itself to fn f(xs: i64[>..]&) as
g(if c { i } else { j }) hands the i64
i or j to fn g(x: i64&), and
fn h(p: u8[:]&) given
if c { s } else { t } re-points the slice variable the
branch taken names. A function value's call is a call, not such a
construct: its value is a temporary even where its body's value names a
variable (§9.2).
There is no ownership transfer beyond the return move, no
destructors, no Drop, no reference counting. Deallocation
is exclusively scope exit resetting stack pointers. (A move
operation for resizable arrays — assign + leave source empty — is
anticipated but not in v1.)
Copying a variable or resizable value costs O(size). The explicit
copy marks that cost in the source.
Shadowing: an inner scope may re-declare a name (a distinct variable).
4.2 Construction contexts
Variable and resizable values come into existence only at these points, each of which provides fresh storage in a statically known place:
let/varinitialization of a local or global;- a by-value argument slot (§7.2);
- a
returned value (§7.3); push/appendinto a resizable (the new element region);- a field/element inside a larger value under construction.
Construction writes metadata first, then elements or fields in order, as required by invariant §1.3(4). Construction of a limited array writes only its metadata and any provided elements; the remaining capacity is reserved but uninitialized — this is safe because no read path to uninitialized slots exists (§5.3), and cheap because the runtime commits skipped address ranges explicitly (Appendix C.4).
While a value is built in place in an array — an element
pushed into or allocated in it that is variable-size or
holds relative references, a call's array result appended
to it, an appended array literal of such elements, or the
new contents of a whole assignment (§4.4) — nothing may grow that array,
neither the expression being built nor a function it calls (§1.3(4)).
The compiler rejects a growth it cannot show to be of a different array.
A whole assignment builds its new contents over the old ones, so its
right-hand side may not use the array at all (§4.4).
Literal forms usable in any construction context:
- array literals
[1, 2, 3];[]where the element type is known from context — the array type of the variable, parameter, field, element or result it constructs, or the slice type of one it is a temporary for (below), and never that of an operand, branch or element beside it:x == [],if c { [] } else { x }with no destination type, and[[], [1]]are errors — or as the whole initializer of avarlocal (var out = [];), which makes the local a grow-onlyT[>..]whoseTis fixed by the firstpush,append,formator whole assignment into it — a string literal pushed into one makes it au8[][>..]— and must be fixed before the local is otherwise used or its scope ends;[v; n]fill form with a non-negative compile-time element count (a constant expression, as a size is: §3.3).vis evaluated once, including whennis zero, and its value is repeated. Relative links retain their target at every destination;selfin a literal refers to each constructed element. A literal whose destination names no array type is aT[k], or aT[]when its elements are not fixed-size (§3.3). A fixed one may also be a temporary for a slice destination, afor,[..]orbytes_ofto view (f([])views an emptyi64[0]forfn f(xs: const i64[:])); aT[]one is a variable value and exists only in a construction context, so viewing it takes a variable bound to it first.==and!=compare either one with any array or slice of its element type (§4.5), since their result holds no view of it. A literal whose elements, at any depth of nesting, are all integer constants or all floats of literals meets a parameter as they would (§3.1, §6.3): it is a value of an array or slice parameter of its shape whose elements they fit,take([0, 0, 0, 7])forfn take(c: u8[4])and[1, 2, 250]for aconst u8[:], a coercion in overload resolution (§7.1); so is a construct whose branches are all such literals of one type. Anappended literal is the run it adds: the array's element typeTmakes it aT[k], or aT[]whenTis not fixed-size, and its elements are constructed as the array's own (bytes.append([1, 2])for au8[>..],[]adding nothing, astr()built in its element). Elements that are variable-size or hold relative references are built where they stay, so a relative reference among them may point into the array; others are evaluated before the run is added, as a pushed fixed-size element is; - struct literals
X { a: 1, b: 2 }(named) orX { 1, 2 }(positional, in declaration order; no mixing). Named initializers may be written out of declaration order only when every pair reversed by that order is proven free of observable effects, runtime failures and nonlocal exits. A reversed pair may not span an effectful omitted default either. Evaluation and construction always proceed in declaration order, directly into each field; no temporaries or copies are introduced to preserve source order. The conservative check admits reads, nontrapping computations and calls whose checked bodies prove safe; unknown/native calls, recursion, mutation, indexing, loops, checked arithmetic/conversions and potentially checked destination adaptations (capacities, length prefixes, relative offsets) prevent a crossing. It runs before optimization and includes debug checks in every build, so acceptance does not depend on optimization or runtime debug mode. The diagnostic lists the supplied fields in declaration order; omitted defaults and padding do not need to be added. If side effects must run in a different order, perform them before constructing the literal. Fields with declared defaults (§3.2) may be omitted: trailing ones in the positional form, any of them in the named form. An omitted optional field without a declared default is null; other omitted fields are errors, unless the literal ends in..(Entity { kind: k, .. }): each of them then takes its type's default value (below),[]for an array field, and one whose type has none is still an error; [..cap]— an empty limited arrayT[..]with the given construction-time capacity (capa runtime expression); the reserved slots stay uninitialized (§5.3, C.4). An array or string literal constructing a limited array of static capacity must fit it (a compile error otherwise);- variant literals
Shape.Circle { r: 1.0 }, whose fields follow a struct literal's rules,..included; self, inside a struct or variant literal only, as the initializer of a non-optional relative-reference field pointing at the very value being constructed (Node { prev: self, next: self }, §3.9) — the one way to give such a field a value before anything else it could point at exists;- string literals (§3.7);
copy(x): a fresh copy of the stored valuexnames (or the pointee of a reference), constructed at the destination like any rvalue -- the one way a non-fixed value reaches a value-typed destination from storage (§4.1). Copying a temporary is an error, since it is fresh already.default<T>(), for any fixed-sizeT: the value aThas before anything is written to it — numbers 0,false, null optionals, empty slices and empty limited arrays, variant 0 of an ADT — with declared field defaults (§3.2) applied wherever a struct or payload declares them, so an invariant a declaration encodes as a default survives being filled in by generic code (asum's accumulator, a hash table's empty slots). A type that contains a non-optional reference without a declared default has no default value, anddefault<T>()for it is a compile error. (The zero value a missedqpollorqget<T>(ms)yields, §11.2, is all zeros; the two agree except where a field declares a non-zero default.) A type that is not fixed-size has a default value too, which a literal's..gives a field thoughdefault<T>()does not: an array other than a fixed one is empty ([]; aT[..]then has no capacity), avarintis 0, and a struct, a variant or an ADT's first variant is built from its fields' declared defaults and default values the same way (S { .. }).
4.3 The in-place construction guarantee
A constructed nonfixed value is built directly in its
receiving storage, including through calls and the selected
branch of an if/match/case-dispatch. Passing
through such an expression is not a reason to materialize a separate
result and then copy it into place.
The same guarantee applies to fresh fixed-size aggregates containing relative references when the receiver takes their representation. Their result channels name the receiving storage, including through multiple and nonlocal returns; correctness does not depend on inlining or C compiler optimization. Other fixed-size results may travel as ordinary C values.
The packed byte layout may require relocation within the receiving storage: a length prefix whose width becomes known after constructing the elements can grow or shrink, and a value-form result delivered as an element run can lose its header and unused capacity. Moving the element bytes for these layout changes is permitted. The final representation, reference targets and evaluation order must remain the same; an initializer must not be re-executed to discover its size.
A limited-capacity receiver must establish that the complete live result fits before writing outside its available slots. When the result's length is not available before evaluating it, temporary construction for this capacity check is permitted. This exception concerns a bounded receiver; it does not permit staging an ordinary fresh result for a resizable one.
Copying an already-constructed value (an explicit
copy, a by-value argument from fixed storage, or the
repeated value of a fill) is an ordinary copy. The guarantee concerns
fresh construction; §7.3 extends it to returned locals and specifies the
copies associated with different returned locals and exits that abandon
a partial destination.
4.4 Assignability
An lvalue may be assigned (=) after construction iff its
size cannot change or it can absorb the change (for reference-typed
lvalues these rules govern the pointee write that
= performs; the reference value itself is updated only by
.=, §3.8):
- fixed-size lvalues declared
var: assignable (plain overwrite copy); - whole resizable arrays (top of their stack), and values holding one
as their tail (§3.4): assignable — semantically clear-then-construct;
the bump pointer resets to the array's element start and the new
contents are built in (from an rvalue or
copy(x), §4.1), so the right-hand side may not use the array (below); - limited arrays
[..k]/[..]: assignable if the new length fits capacity; - fixed-mode ADT lvalues: assignable, including with a different variant;
- not assignable: variable-class lvalues
(
T[]locals/fields/elements, variable-mode ADT lvalues,varintfields) — these are frozen at construction; rebuild the container instead.
The right-hand side of a whole assignment of a resizable runs once
the old contents are gone, while the new ones are built in their place,
so it may not use the array: not name it (its length included), nor a
reference to it or to the value holding it, and neither may a function
it calls, through a global or captured variable that function names.
x = [x[1], x[0]], x = f(x) and
g = rebuilt() for a rebuilt that reads
g are errors. As with growth (§4.2), so is a use the
compiler cannot show to be of a different array: of a reference
parameter a caller may bind to the global being assigned, say, or
through a call into a recursive cycle still being checked, which counts
as using every global and every variable in scope. Where the assignment
names the array as a field (t.chars = …), the right-hand
side may still name the holding value's other fields that cannot lead
back to it: flat ones (§1.1), and ones whose references, however many it
takes, are to storage of no type that can be the assigned location, a
resizable on the way from it to the array, or hold the value it is a
field of. t.chars = render(t.font) is fine, and so is
s.path = find(s.start) through s: Walk&
for struct Walk { start: Cell&, path: Pos[>..] }, as
is a reference of such a type bound to a variable first; a field
last: Walk& or other: Pos[>..]& may
refer to s or to s.path, and naming it is an
error. An assignment through a reference field (d.r = … for
r: u8[>..<]&) writes the array the field refers
to, which may lie anywhere an array of its type can, the holding value's
other fields included: there a field that is, or holds by value, an
array of that type may be the one assigned (d.r .= d.own),
and so may what another field refers to, so naming either is an error,
but for a local whose record shows where the reference field may point.
A slice or reference into the old elements is the shrink rule's to judge
(§5.1): the right-hand side runs after the shrink the assignment starts
with, so one it uses is live there. New contents computed from the old
are built in a variable of their own and assigned as copy()
of it, the one copy spelled out where it is paid (§4.3).
let forbids assigning that name or field as a whole, and
nothing more: the contents of a let array or
struct are as writable as their type says
(let xs = [1, 2, 3]; xs[0] = 9; is fine), and so is what a
reference to it reaches. Contents are made read-only by the type,
const T (§9.5), and const x = e; declares a
let of type const T: neither reassigned nor
written into. A var of type const T is the
converse: reassigned (=, compound assignment,
++), never written into. Definite assignment is enforced:
no reads of uninitialized locals; every declaration either has an
initializer or is provably assigned on all paths before use. A
let declared without an initializer gets its value once:
assigning it, or binding it with .= (§3.8), is an error
wherever some path may have given it one already -- in a branch that
joins there, an earlier iteration of a loop it is declared outside of,
or a function called on the way, the assigned value's own calls included
(§7.5). A recursive cycle (§7.8) cannot assign such a let
declared outside it, which all its activations share. Fixed arrays
require full initialization (every slot is indexable); the
[v; n] fill literal makes large ones cheap.
4.5 Equality and comparison
==/!= are structural and
require both operands to have the same type: scalars and bools by value;
structs and fixed arrays memberwise (pad and ADT padding bytes excluded
— semantic comparison is per-member; memcmp is a valid optimization only
for gap-free layouts without floats, since a float compares by IEEE
value wherever it sits: -0.0 == 0.0, and NaN is unequal to
itself); array-family values and slices by length then elements
(sequential walk for variable elements) — two operands of
different array kinds compare too, as slices, whenever their
slices would have one type (§3.10): a u8[>..] field
against a string literal, a u8[..32] against a
u8[:], with no [..] needed; ADTs by tag then
payload.
References and slices follow a top-level rule: as the direct
operands of == they have value-like semantics — a reference
compares its pointee (transparency, §3.8), a slice compares length then
elements. As members of a compared composite they compare by
identity (the reference address, the slice's address+length): recursing
through them would turn == into an unbounded pointer
traversal. Optionals compare as nullable references
(o == null is the null test);
.==/.!= compare two references by address
rather than by pointee (§3.8). Ordering
< <= > >= exists on the numeric types only,
with operands unified per §6.1.
5. The resizable/shrink rules
5.1 Grow-only [>..]
"Grow-only" names the guarantee, not the operation set: the
array never shrinks while a reference or slice into it can be
live. Memory below the top never moves and is never reused
while the owner lives, so every interior reference and slice stays valid
for as long as it can be named, and a bound on the length holds across
every push.
push/appendbump the stack.- May contain variable-size elements (build strings/ADTs in place, §7.3).
- Shrinking —
pop,pop_n,resizedownward,clear— is legal exactly where the compiler can see that nothing is rooted in the array, under the conditions below.pop,pop_nandresizeadditionally need fixed-size elements, since a sequential array cannot find its last element (§3.3).
Use grow-only arrays for arenas, pools, string builders, tree storage, and scratch buffers that are refilled or popped between phases.
When a grow-only array may shrink. The receiver is
the array's variable, a reference variable or parameter bound to the
whole array, a global, or the tail field of a struct reached through any
of those (s.items.pop() for s: Stack&),
which shrinks the storage the struct lies in as a reference to the field
would — not a temporary, and not a reusable pool (§5.4: its
freelist keeps every slot live). The shrink may be anywhere in an
expression — an operand, an argument, a condition, the value of a block,
if, match or loop — so
print(a.pop()) and if !load(s) { … } are fine.
At the shrink, nothing live may refer into the array. That
rules out what its statement evaluated before the shrink and uses after
it (§2): an earlier argument or operand that is, or holds, a reference
or slice, an earlier field or element of a literal being built, the
array an indexing or slicing reads, the destination of an assignment. So
use(a[0], a.pop()), a[a.pop()],
S { r: a[0], n: a.pop() } and a[0] = a.pop()
are errors, while a[0] + a.pop() reads its left operand
first and is not. Nor may a value be under construction in the array's
storage (§1.3): an element push builds in place (§4.3), a
literal run append builds, or a call's result appended. And
it rules out any reference or slice variable rooted at the array (a
var reference the same-depth rebinding rule (§9.2) could
retarget into it counts, as does one not bound yet further down a loop
body), and any value that holds a reference into it — a struct
with a reference field, an array of slices, a let copy of
an element of such, and the struct whose tail the array is, through its
other fields. Values hold references only where a store put them, and
every store the checker has seen is on record (§9.2), so this half of
the test is exact to the store: the error names the holder and the line
where a reference into the array was stored into it. A value whose type
cannot hold a reference to anything the array's elements contain by
value, however many references it leads through, is never a holder, nor
is one linked by relative references alone (a node pool): those point
within their own root. A reference to a whole array or table is no
reference into its elements, which a shrink leaves it pointing at:
struct Walk { start: Cell&, text: u8[>..]&, path: Pos[>..] }
holds nothing a shrink of path frees, and
w.path.clear() through w: Walk& may be
followed by any use of w. A view of the elements taken
through such a reference is stored, if at all, where the holder's type
has room for one. The same holds after a whole assignment:
s.r = [1, 2] for r: u8[>..]& may be
followed by any use of s. (The right-hand side of a whole
assignment is another matter: it runs while the array is rebuilt, and
may not reach the array at all, §4.4; a variable it takes a reference
out of counts as holding what that reference leads to, but one whose
flat fields alone it reads does not.) A reference to a slice variable or
to a holder, the storage whose tail shrinks among them, counts as what
it refers to: while the reference is live, so is whatever that slice or
holder may refer into.
A whole assignment of a value that holds no references —
refs = [], s.refs = [], a struct literal with
none in it — empties the part of the holder it writes: what was
stored there before is gone. The part is a variable's own storage or the
pointee of a let reference or of a reference parameter,
which no rebinding moves, and the fields named after it. A holder each
of whose slots that can hold a view into the array lies, on every path
to the shrink, in a part so emptied — the whole of it, or its fields one
by one — holds only what was stored into its storage since; but no
longer once a store into that storage the record cannot show is made,
one moving the holder's own references within it, or once a call that
may write into the part runs, a callee's stores never being on record in
their order: one handed a reference that may point at the holder's
storage, with room for the part or lying in it, or anything whose
references may lead to storage of the part's type, whatever it points
at; a nested function; one handed a block or any other function value;
and a block's own call. A call handed a struct of the function's own
built from the holder's reference fields, whose references lead only to
what those do, may not. So
for i in 3 { refs = []; pool = []; refs.push(pool.push(i)); }
passes, the store at the end of one iteration being gone by the shrink
at the start of the next, while emptying refs on some paths
only, after a store that reaches the shrink, or only in a copy of it,
does not; and so does
s.refs = []; s.pool = []; s.refs.push(s.pool.push(i));
through s: S&,
struct S { pool: i64[>..]&, refs: (i64&)[>..] },
the path to the pool being no reference into it, and a view taken
through it stored, if at all, in refs.
Nor may the shrink free what a for loop around it walks
(§6.5): each reference or slice on the path to its sequence, which the
loop loads again on every iteration, and the sequence it iterates in
place — a slice, an element, a field — unless that is a resizable array,
whose length the loop reads again.
A variable is live at the shrink when it can be read again
afterwards: it is named later in the shrink's statement — in an operand
evaluated after it, or, where the shrink is in the condition of an
if or guard, the scrutinee of a
match or the sequence of a for, in the parts
that one leads to — later in its own block or in an enclosing block it
was declared in, or anywhere in a loop that contains the shrink and that
the variable was declared outside of, since the next iteration runs the
rest of the body again. But where a statement of the shrink's own block
after the shrink's statement is a return or a
break, and neither the shrink's statement nor one between
them has a continue that could lead back into a loop,
nothing that exit leaves runs again from the shrink: the rest of that
block, and for a return the rest of the function's body,
for a break the rest of the loop or block it
ends, that loop's next iteration included. So a decoder may
result.clear(); return result; inside its loop while a
dictionary of views into result that the loop fills is
still in scope; the exit's own statement is read after the shrink like
any other. A function value's body runs in the middle of a statement of
the function calling it (§7.6), so that function's variables count as
well, and that function may call it again: a variable declared outside
the body counts wherever the body names it, as in a loop. Only code that
can name the variable counts — its own function's, and that of the
nested functions and function values written in it — so the same name in
another function is another variable. "Named" is syntactic — any
mention, a call of a nested function that mentions it included — so the
test never depends on what the optimizer proved. A reference whose last
use is before the shrink is dead, and its block need not end:
let w = line[..5]; print(w); line.clear(); is fine, and a
scratch buffer refilled per iteration, or a stack popped between phases,
hands out slices of itself freely — "reusable scratch" and "structure I
can point into" are the same type. The operations themselves are the
grow-shrink ones: a stack-top move and a length store. Assigning the
array whole (a = …), or a value holding it, replaces its
elements and is a shrink under the same rule, wherever its location
could be the receiver of a clear: for
s: Stack&, s.items = [],
s.items = load(f) and s = fresh() are shrinks
of the storage s names, as s.items.clear() is.
The right-hand side runs after the shrink, so a variable that side names
is used after it. print, str and
format evaluate and render their arguments in order (§3.7),
so a variable a later argument names is used after a shrink an earlier
one makes, by a call in it or through a format overload
rendering it.
Through a reference or of a global, a shrink cannot see the callers'
variables, so it is checked at every level: each function specialization
records which globals, and which parameters' pointees, it shrinks —
directly or through its own callees — and what its calls stored into
those pointees, and a call is checked as if it performed those shrinks
and stores in the caller, each grow-only shrink where it stands in the
body: against the stores the body made before it (in a loop, any the
body makes), not those it makes after, and with the parts of reference
parameters' pointees the body had emptied before it on every path empty
in the caller's holders too, where the argument names such a part — the
caller's variable, a field path in it, or the pointee of its own
let reference or reference parameter, which its own callers
judge the same way — of storage no nested function or function value
names. A function doing
s.refs = []; s.pool = []; s.refs.push(s.pool.push(i)); may
be called again and again on the same holder, in a loop or through a
function taking the holder by reference. The pointees of a parameter
that holds references by value (a struct with a reference field, an
array of slices) are what those references point to: a shrink or a store
through one of them is of the caller's storage, not of the callee's copy
of the holder. The other globals are live wherever a global receiver
shrinks, and what is stored into them may come from functions checked
after the shrink, so they are judged once the whole program has been
checked: by then every store into a global, and every binding of a
global reference or slice, is on record, wherever in the program it is
made, and the shrink is an error where another global may hold a
reference into the array — the error names that global and the line of
the store. A call into a recursive cycle still being checked counts as
shrinking every array a function of the cycle textually shrinks. A user
format overload (§3.7) is a call at the print,
str or format that renders a value through it,
with the format call's receiver as the builder where the
text lands in it. The rest of the argument holding that value is
rendered around the call, so it is still in use there — where the
argument lies, the elements of the arrays and slices it renders, what
its references lead to — unless the overload takes the whole argument,
the value itself or a reference to it.
A reference whose root is inexact (§9.2) — a value chosen between
arrays, if c { a } else { b }, or one read back out of a
container — may point at any array of its type that the root bounds, and
a shrink through it counts as a shrink of each of them: those in
variables at the root's scope or outside it, and the caller's arrays a
parameter's references lead to, for which the callers check it. The same
holds where a call shrinks what an inexactly rooted argument points at.
Read out of the storage behind a reference parameter —
s.text = … for s: S& — the reference may
point at anything the callers or the function stored there: the function
judges its own variables against every array of the type outliving the
call, and its callers judge the shrink as one of an array that storage
leads to, each by its argument. A struct of the caller's leads to the
arrays its record says it refers to, of the type shrunk, or holding or
leading to one: s.text = copy(t) in a callee frees no
global u8[>..] the caller views, where the caller's
struct refers to a local text and a node pool. One built from the
caller's own struct parameter's reference fields leads where those do,
which the caller's callers judge in turn.
Nor can a shrink inside a function tell what its parameters point into: a parameter's pointee is whatever each call passes, which may be a global, a variable of an enclosing function, or what another parameter points into — an inexactly rooted argument may be any array its root bounds. Where a reference, slice or holder parameter, or a variable bound from one, is still used after a shrink of a global, of such a variable or of another parameter's pointee — or a view of a global or of such a variable is still used after a shrink of a parameter's pointee — the specialization records the pair, and each call judges it by the arguments it passes: a call that may make the two one array is an error, as a variable of the caller still used would be, and a call passing its own parameters on records the pair for its own callers. A call into a recursive cycle still being checked is judged again against the pairs the cycle records once the whole cycle is.
5.2 Grow-shrink [>..<]
- Fixed-size elements only.
pop()returns the element by value: a reference or slice element as the one it is, pointing where it did and as writable as its slot, as reading the element would give it (§9.5), since what a temporary holds is not rooted at the temporary (§9.2);pop_n/resize/clearallowed, from anywhere: on a local, through a reference, on a global, on a struct's tail. Assigning the array whole is a shrink too.- References and slices into it are created like any other (§3.8,
§3.10), and are held by variables only: bound to a
local, passed down, returned — never stored into a field, element, or
global. Rationale: after a pop, that stack-top memory can later be
reused by different types (other locals, other pushes), so no
reference into it may outlive the next shrink; keeping such references
out of storage is what makes the next rule a scan of the variables in
scope. A literal's fields and elements are storage wherever the literal
lands, so no struct, variant or array literal holds one, even where the
literal itself is passed down or returned:
f(S { a[..] })is an error, aslet s = S { a[..] };is, and so is binding a global reference or slice variable to one, a global being storage as well. What counts is where a reference may point, whatever its root says: a value that may be any of several (§9.2) is stored only where each of them could be, and a parameter given one that may point into the array is not stored either, nor a function's result where one of its returns may, nor a reference to a slice variable whose slice may. Where what a value's root says only bounds where it points (§9.5) — it was read out of the storage behind a parameter, or of a value holding references, or a callee returned such a read — it may point into any grow-shrink array that storage leads to through references: witht: Top&and a fielda: Cell[>..<]&ofTop,tandt.amay be stored, but nott.a[0], nor the bytes oft.a. A reference or slice read out of a field, an element or a global never may, having been stored there, whatever storage bounds it (§9.5), nor may a slice such a slot holds, loaded through a reference to the slot. Nor does a value holding references that is read out of a field or an element, popped, or copied out of one by aforor amatch, hold one that may: what it holds lies in that slot, and it is stored wherever what holds it outlives the destination. A variable that points into no grow-shrink array — one bound to such a read, say — may not be rebound to a value that may: what read it earlier through a reference to it may have stored it. The rule is about references that can point into the array: one merely rooted at a value that holds one, whose pointee type the array's elements cannot contain — a slice key read back out of a dictionary's slots — stores like any other, passed to a parameter or not: akeys: u8[>..]&read out of a struct with au8[>..<]tail, and a slice a callee makes of the buffer it refers to, are stored as dictionary keys freely. - A shrink is an error while any live variable or value may
refer into the array — the test a grow-only shrink applies
(§5.1), the values its statement still uses, its liveness rule and its
call summaries for a shrink through a reference or of a global included,
minus the store record: references into a grow-shrink array live only in
variables and in the values of the statement being run, so checking
those still in use is sufficient, a reference to a slice variable among
them. For the same reason a plain reference or slice read out of a
field, an element or a global never refers into a grow-shrink array,
since none is ever stored there: the shrink does not consider it, nor a
slice of such a slice or a reference into what it views, whatever
storage the read-back rule (§9.5) bounds it by, nor what a value read
out of such a slot holds. That stops at a reference, since what one read
out of a field leads to may be a whole grow-shrink array, or a variable
holding a view into one; and a
varbound to such reads still counts where a rebind could retarget it into the array: a same-depth one (§5.1), or, inside a loop the variable was declared outside of, one later in the body to a value whose root only bounds the array (§9.2). The error is at the shrink and names the variable and where it was bound, so either end can be changed: use the slice for the last time before the shrink, or move the shrink. A call into a recursive cycle still being checked counts as shrinking every grow-shrink array it can reach, through the references its arguments hold as well. Function values run inline, so a shrink inside a block is checked against the block's own enclosing scopes, and against the function calling it, in the middle of whose statement it runs. - Balanced calls. A call is balanced for an
array when the array is never shorter, while the call runs, than it was
when the call began. Such a call frees nothing a view taken before it
points into: a live view lies within the array's length at the call,
since every shrink after the view was taken was checked against it, and
one a reference to a slice variable reaches is no newer, since the
callee can only return a view, never store one where the caller would
read it. A balanced call is therefore no shrink where it is made: no
view is checked against it there, nor recorded with it for the callers
to judge (§5.1), a parameter's view having been taken before the call as
well. It still counts for the callers, as a balanced one where the
caller is balanced too. A function is balanced for an array X — what a
reference parameter points at, a global, or a captured variable — when
each shrink of X in it is
X.resize(m), with or without a fill value, wheremis aletof the same activation initialized to exactlyX.lenof the same path X, or a call that is balanced for X. The same path is one variable that cannot be rebound (avarreference can), then the same fields, none of them a reference.pop,pop_n,clear, a resize to anything else, and assigning X or a value holding it whole are not balanced, and neither is a resize to anmbound to a writable reference (§4.4): by&,.=, or a reference parameter, field or binding that is notconst, in a nested function too, since a write through it can lowerm. Bindingmto one after a resize back to it is an error: that resize has already been judged balanced, while a loop can run the write before it the next time round. Every length X has during such an activation is at least its length on entry, so every mark is too, and so is every resize back to one. A call is balanced for an array of the caller's when each shrink it may make of any array that can be that one is balanced: the caller's roots decide which can be, and a parameter's array can be a global, a captured variable, or another parameter's. Grow-only arrays have no balanced calls (§5.1): a callee can store references to its new elements into the caller's holders before shrinking them away. A call into a recursive cycle still being checked cannot be judged by the part of the callee checked so far, so it is taken to be balanced for every grow-shrink array it can reach until the cycle has been checked. It is balanced if, then, no function such a call enters shrinks a grow-shrink array unbalanced when such calls count as balanced: by induction on how deeply a run nests its calls, no run of the cycle shortens an array. Otherwise it, and every call judged balanced on the strength of it, counts as a shrink after all: against the views live across it, and with them for the callers to judge. pushreturns a reference to the new element, andindex_ofworks, as on grow-only arrays.- Iterating the array with
foruses indices under the hood and reads its length on every iteration, so changing the length inside a by-valuefor xloop over it is legal and merely a logic hazard (bounds checks keep it safe); the&xbinding is a variable in scope, so a shrink inside such a loop is an error. So is a shrink inside a loop over a slice, an element or a field of the array, which the loop walks in place, or over a sequence it reaches through a reference or slice stored in the array (§5.1, §6.5).
5.3 Limited arrays [..k] /
[..]
Grow and shrink freely within fixed capacity; not stack-top-bound
(their capacity is reserved at construction), so they can live anywhere
fixed/ variable values live ([..k] is fixed-class,
[..] variable-class). A T[..] constructs
either from element contents (capacity = initial length) or with the
[..cap] literal (capacity cap, length 0).
Interior references and slices are allowed,
including surviving pops: because capacity and element type are fixed,
every slot within capacity, once first written, remains a valid value of
the element type forever — pop-then-push reuses memory at the same type.
Never-written slots are unreachable: indexing checks len,
references/slices can only be created into [0, len), and
every growth operation supplies values (§3.3). A stale reference reads a
valid, possibly-different value ("type-safe reuse", §9.4), never corrupt
memory. Indexing through the array checks against current length; access
through retained references does not (they were validly created).
5.4
reusable arrays (safe allocation escape hatch)
A grow-only resizable local of fixed-size elements may be declared
reusable (the keyword prefixes the declaration:
reusable var pool: Slot[>..] = [];, also valid on
globals). The compiler pairs it with a hidden freelist (itself a small
resizable of indices, on its own stack, counted in N):
a.alloc_index(v) -> i64— index of a slot: a freelist slot if available, else a freshpush.a.alloc_ref(v) -> T&— same, returning&a[i].a.free(i)— records slotifor reuse. The element remains a valid, live value of its type forever. The index must lie in[0, a.len); otherwise the operation aborts with the ordinary array-index out-of-bounds diagnostic, in every build, before changing the freelist. The index expression is evaluated once. Where the code holds references rather than indices,a.free(a.index_of(r))(§3.3) is what turns one back.
Semantics, not just implementation: all elements remain valid at all times. A reference to a freed-then-reused slot reads a different (same-typed) value — type-safe reuse, never memory corruption. This is the language's answer to tree-mutation workloads that would otherwise need an allocator.
Slice pools. Declared reusable[]
(reusable[] var pool: T[>..] = [];, also valid on
globals), the array hands out runs of elements instead. Its hidden
freelist holds (index, count) spans sorted by index, and a freed run
merges with the spans it touches:
a.alloc_slice(n) -> T[:]—nelements from the front of the first free span, in index order, that holds them; failing that, from the end of the array, which grows byn— less the elements of a free span that reaches its end, which the run starts with.a.free_slice(s)— recordss's elements for reuse.a.realloc_slice(s, n) -> T[:]—sresized tonelements. Shrinking frees the elements pastnand keeps the front. Growing a non-emptyskeeps its elements in place whensends the array, which grows, or when a free span starts wheresends and either holds the difference or reaches the end of the array, which grows by the rest. Otherwise, and always for an emptys, the slice's elements are freed and copied to wherealloc_slice(n)then places the run, which may be their old place merged with free elements around it.
Every element an allocation or a growth adds is a default value
(§4.2), so those two need an element type that has one. The length is
evaluated once, and a negative one, or one no data stack could hold,
aborts before the pool changes. The slice handed to
free_slice or realloc_slice must be one of the
pool's runs: one the pool handed out, a re-slice of it, or a slice of
the pool itself. Where the checker sees that, because the slice is
rooted at the pool exactly (§9.2), nothing is checked when the call
runs. Where the pool is a global or a variable of the calling function,
or a pool reference that can only be one of several such (Pool
references below), a slice rooted exactly at a global or a
variable of the calling function that the pool cannot be is a compile
error. Any other slice, such as one read out of storage where runs of
other arrays could be kept as well, is checked when the call runs:
unless it is empty, its elements must lie inside the pool's length
starting on an element boundary, or the call aborts before the pool
changes. An empty slice frees nothing and grows like a new run, wherever
it points. A grown slice may be copied, which a value holding
self-relative references cannot survive (§3.9), so
realloc_slice is a compile error for such an element type;
in pool references copy fine, and the other two operations
never move an element. The single-slot operations do not exist on a
slice pool, nor these on a reusable one; push
and append add at the end of either without consulting the
freelist.
The guarantee is the same: a slice still naming freed elements reads whatever their next owner wrote, or the default values an allocation put there. Freeing a run twice is a logic error of the same kind — the freelist can then hand the same elements out twice, but never anything outside the array.
Pool references. A T[>..]&
variable or parameter bound to a pool is a pool reference: the
pool's operations work through it as on the pool, since it carries the
pool's freelist beside the address. So is one bound to a value that
chooses among pools of one kind, or pool references to them — an
if, a match or a block giving one wherever it
gives a value — which carries the freelist of the pool it chose; a
choice between the two kinds of pool, or between a pool and another
array, binds a plain reference. A variable is one from its first binding
on, and .= rebinds it only to a pool of the same kind, or
to a pool reference to one; pointing it at any other array needs a new
variable. A variable first bound to anything else stays a plain
reference, which .= may point at a pool, as at any array,
but which offers none of the pool's operations. Only a plain reference
variable or parameter is a pool reference: an optional one, a field, an
element and a function's result are not, even where they point at a
pool.
6. Expressions and arithmetic
6.1 Operators
C/Rust set: + - * / % (% is Euclidean on
integers, §6.2; on floats it is C fmod), comparisons,
! && || (short-circuit, bool only),
bitwise ~ & | ^ << >> (on integers),
assignment statements =, += etc. (the compound
form of every binary operator, shifts included) on assignable lvalues,
.= (reference rebinding, §3.8), reference identity
.==/.!= (§3.8), and
++/-- as statements on integer lvalues (no
expression form). Precedence is Rust's (Appendix D): the bitwise
operators bind tighter than comparisons, so
x & mask != 0 is (x & mask) != 0, and
shifts bind tighter than &. Range expressions
a..b appear only in slicing brackets, for
headers, and match arms.
Operand unification. A binary numeric operator's
operands must reach one common type, which is also the result
type, found as follows: equal types stand; a constant adapts to the
other operand's type (compile error if its value does not fit);
otherwise, if exactly one operand implicitly widens into the other's
type (§6.3), the wider type wins; and an integer operand meeting a float
one converts to the float's type, so i32 * f32 is an
f32 multiply and i64 + f64 an f64
add. A float that takes its type from float literals — the literal
itself, or n * 0.5 built from literals and integers —
adapts to the other operand's float type as a literal does
(x * (n * 0.5) with x: f32 computes in 32 bits
throughout), and is an f64 where neither operand gives it a
type. Anything else — same-width signed/unsigned, u64 with
anything signed — is a compile error asking for a cast. Nothing here
invents a type absent from the expression: u8 + i64 is an
i64 add, but u32 + i32 does not become 64-bit
arithmetic implicitly. Exceptions: shifts take the left
operand's type as the result (the count is any integer type, masked per
§6.2), and ==/!=/orderings unify the same way
but produce bool. So a constant shifted by a count that is
no constant keeps the constant's freedom: 1 << k is
no constant, but takes the integer type its destination or other operand
has as a constant does, where the constant fits it
(let m: u64 = 1 << k;,
m | (1 << k), a u8 field), and is an
i64 where nothing gives it one. So does an integer
operation over such shifts and constants
((1 << k) - 1, ~(1 << k)),
computed at that type throughout; a typed operand, unary -
or a cast gives it a type of its own. So does ~c of a
constant c >= 0, whose value depends on the width it is
computed at: it adapts where c fits, so
let a: u8 = ~4; is 251 and flags & ~F_ROAD
clears a flag in a u8, while ~4 is -5 at a
signed type or where nothing gives it one; -~c is the
constant c + 1, and a named constant ~c (§3.1)
adapts as ~c does. Unary - requires a signed
(or float) operand; ~ any integer, keeping its type.
Comparisons and u64. A comparison
produces bool, so it does not need a common numeric result
type. However, comparing signed and unsigned operands must preserve
their values. u64 is the only unsigned type with no signed
supertype (u8–u32 widen into i64,
§6.3). A comparison between u64 and a signed type is
therefore allowed when the signed operand is known
non-negative: it converts to u64 without changing
value, and the comparison is a single unsigned one — no wider than
either operand, and never a hidden branch.
"Known non-negative" is deliberately a syntactic property,
not an inferred one: a non-negative integer literal, a .len
or .cap (non-negative by §10.4), or a let
bound to one of those. A let is never reassigned, but a
writable reference to it can still change its value (§4.4), so a
comparison may not rely on a let (or on one it was bound
to) that is also bound to a writable reference anywhere: by
&, .=, or a reference parameter, field or
binding that is not const, in a nested function too. Either
order is an error. Whether a comparison compiles thus depends only on
what is written, never on how much the optimizer managed to prove. Other
cases require an explicit cast because conversion may change the value.
A cast does not, however, preserve every comparison:
x as! i64 on a u64 above i64.max
silently compares as negative, and the checked x as i64
aborts in debug on a value that was perfectly legitimate to compare.
When the rule permits it, use the direct comparison to preserve the
values without conversion overhead.
Elementwise math: the arithmetic operators apply
memberwise to any two values of the same struct/fixed-array
type whose scalar leaves are all integers or all floats, none of an
index or bound type (§3.11); the result has that same type. The compound
forms += -= *= /= %= apply to these same types, resolving
the destination once and reading its old value before the right-hand
side runs (§2). Unary - negates such a value memberwise
where no leaf is an unsigned integer (-v, with the same
result type; a float member negates exactly, so 0.0 becomes
-0.0, and an integer one overflows as a scalar
- does). This covers vector math without an
operator-overloading feature. The standard library supplies
float3 and friends; named vector ops (dot,
cross, normalize, …) are ordinary stdlib
overloads per math type, not language builtins.
* and / also accept one scalar operand, in
either order, when every numeric leaf of the struct/fixed array has
exactly the same type. The scalar converts implicitly to that leaf type
(§6.3): float3 * 2.0, 2 * float3, and
float3 / f32 produce a float3;
2.0 / float3 divides the scalar by each component. The
aggregate's type never widens: an f64 variable scaling a
float3 needs as f32, and an integer scalar
must fit or widen into the integer leaves. Nested structs and fixed
arrays work the same way; aggregates with mixed leaf widths or kinds, or
no numeric leaves, do not support scalar operands. +,
-, and % still require two matching
aggregates. The compound forms aggregate *= scalar and
aggregate /= scalar follow the same rule. Both operands are
evaluated once, left to right, before any result member is written,
including when the scalar is a member of the destination.
6.2 Integer semantics
- Every integer operation computes at its operands'
type:
u8 + u8is an 8-bit add,i32 * i32a 32-bit multiply. There is no promotion — operands reach a common type only by the unification rule of §6.1, which never widens both sides behind the programmer's back. - Signedness is part of the type:
/,>>, and the ordering comparisons are unsigned operations on unsigned types (>>shifts in zeroes) and signed on signed ones (>>replicates the sign bit). Integer/truncates toward zero:-7 / 3 == -2, while-7 >> 1 == -4. %is Euclidean at every integer type: the result lies in[0, |b|)and is never negative, whatever the dividend's sign. Sox % nis a valid index into a length-narray for anyx— the reduction idiom means what it looks like, and the optimizer can drop the resulting bounds check (§10.5) instead of demanding a guard the programmer knows is redundant. On unsigned types this is ordinary remainder; on signed ones it costs one predictable conditional add over C's truncating%, on an operation that is already a division. Consequentlyi64.min % -1is0, not a trap. Float%stays Cfmod, so-7.0 % 3.0 == -1.0, while-7 % 3 == 2.- Division pairs. Integer
/and%use different conventions, sob * (a / b) + a % b == adoes not hold in general. Withimport std;,rem(a, b)is the truncating remainder (zero or the dividend's sign), anddiv_euclid(a, b)is the Euclidean quotient. The matching identities areb * (a / b) + rem(a, b) == aandb * div_euclid(a, b) + a % b == a, as mathematical identities when the quotient is representable; evaluating their intermediate products can overflow. Both functions accept all eight integer types, returning the operands' type.div_euclidrounds down for a positive divisor and up for a negative one, and has the same zero-divisor and quotient-overflow behavior as/.remaborts on a zero divisor and returns zero forMIN, -1at every signed width. For unsigned integersdiv_euclidis ordinary/andremis%. - Signed overflow (at the operation's width): aborts
in debug builds; wraps two's-complement (defined) in release. The debug
abort is per operation as it executes, not per operation as
written: the optimizer may regroup an associative chain or drop an
operation altogether, and each check travels with the operation it
belongs to. So a debug build is a bug-finding tool, not a promise about
every intermediate the source spells out; what a release build computes
never depends on any of it. Unsigned arithmetic wraps modulo
2^width by definition, in every build — modular arithmetic is
what hashing, PRNGs, and bit manipulation mean by unsigned math, and it
is why those kernels are written on unsigned types. Division/modulo by
zero: aborts always.
i64.min / -1aborts in every build (it would trap in hardware); at narrower signed widths the same case is an ordinary overflow — debug abort, release wrap. Shift counts are masked to0..width-1(defined). - Integer literals above
i64.max(up tou64.max) areu64constants (§2, §3.1); negating one is a compile error (except-(2^63), which is exactlyi64.min).
6.3 Conversions
One principle: within a kind, a conversion the machine can prove value-preserving is implicit; anything that could lose a bit or flip a sign takes a cast. Between the kinds, an integer becomes a float implicitly, and a float becomes an integer only through a cast: dropping the fraction is the real loss of information, while an integer too large for the float's mantissa rounds as every float result does.
- Implicit (silent, everywhere a value meets a
differently-typed destination or operand): to a wider integer
type of the same signedness (
i8→i16/i32/i64,u8→u16/u32/u64); from an unsigned type to any strictly wider signed type (u8→i16..i64,u32→i64);f32 → f64; constants into any type their value fits (§3.1); and every integer type into both float types,i64andu64included, a literal as much as any other value (2works where a float is expected as well as2.0does). Also: any integer type exceptu64into avarintstore (§3.6). - An integer becoming a float takes the float's nearest value where it
has no exact one (beyond 2^24 in magnitude for
f32, 2^53 forf64). No conversion to a float traps, in a debug build either. - Float literal types. A float literal has no
committed type: it adapts to
f32orf64, whichever its destination or other operand has, and is anf64where nothing gives it a type. So does every float whose float-ness comes only from float literals and the integers they meet:n * 0.5,-(i + 0.25),(n - 1) * 2.5with integernandi, and a construct whose branches are all such (§6.4). Such an expression is computed at the type it adapts to, throughout — anf32argument, field or operand gets(n as f32) * 0.5in 32 bits — and is anf64where nothing gives it a type. A variable, parameter, call result or explicitascommits a type; §7.7 describes the limited case where anf32destination chooses a generic call's computation width first. A constant part of it is folded at full precision and rounds once to the type the whole adapts to, as a constant does anywhere: beyondf32's range (about ±3.4e38) to an infinity, asas f32rounds. - Float literal
lets. Alet(orconst) with no written type, local or global, whose initializer is such a float (let a0 = i * 6.2831853 / n;,let K = 0.25;, std'sPI) is literal-like as well: it holds its value as anf64, computed once, and each use adapts as a float literal there would — at anf32destination, beside anf32operand (a0 * rwithr: f32is anf32multiply), in a construct's branches and as a generic argument beside anf32— taking thatf64rounded once tof32; where nothing gives it a type it is thef64. Solet h = n * 0.1;stored in anf32isn * 0.1computed in 64 bits and then rounded, which can differ in the last bit from the same expression written at thef32destination, computed in 32 bits throughout. A use adapting it relies on its keeping its value, which a writable reference to it could change to anyf64, so, as for an integer named constant (§3.1), such a reference is an error in either order; a reference to it is anf64&. Avar, and aletwith a written type, are values of their type. Unlike an integer named constant this holds for locallets too: adapting a float changes only how it rounds, as writing the literal there would, where adapting an integer local would change the width at which its arithmetic wraps. - Never implicit: narrowing; same-width signedness
changes (
i32 ↔︎ u32); anything signed into any unsigned type (a negative value can hide in any signed operand — sou32→i64is silent buti32→u64is not);u64into any signed type;f64 → f32; and float → int. x as T— explicit conversion between any two numeric types, range-checked in debug (abort on value change), truncates/wraps/ rounds-toward-zero silently in release. A conversion to a float is never checked, in any build: from an integer it rounds as the implicit one does, from anf64to the nearestf32, as float arithmetic rounds every result it computes, and a value beyondf32's range (about ±3.4e38) becomes an infinity — nothing there that a debug abort would usefully catch.x as! T— always-unchecked wrap/truncate, for when losing bits is what is intended, even in debug. To a float it is the same conversion asas.- Into an index or bound type (§3.11),
asfirst checks the value against the array in every build, andas!is an error. - float → int (both forms in release,
as!always) is defined exactly: truncate toward zero, then wrap modulo 2^64 into the target's width; NaN yields 0. The common in-range case is a range test and a hardware conversion; only the out-of-range tail pays for the defined wrap.
Redundant casts. An as or
as! that changes nothing is a warning: one whose operand
has its type already, or converts to it implicitly where the cast
stands, so that deleting the cast leaves every type, operation, value
and call of the program as it is. n as i32 of an
i32 n warns, as do 17 as u32 passed to a
u32 parameter, b as i64 of a u8 b
stored in an i64, and w as f32 in
(w as f32) * 0.5 passed to an f32 parameter,
where w * 0.5 is an f32 multiply too. A cast
that decides anything stays silent: a type nothing else gives
(let k = 5 as u32;, print(b as u8)), an
operation's width or signedness ((a as i64) * b with
i32 operands), a float's rounding
((n as f32) * 0.1 stored in an f64), a call's
overload or a generic's instantiation. Of two casts that cannot both go,
as in (a as f32) * (b as f32) of two integers, the right
one warns, and of a cast directly inside another where both could, the
outer one. A generic function's body, a function value's, and what is
declared in either are not judged: another instantiation may need the
cast. The warning comes once per cast as written, where every check of
it agrees.
6.4 Control expressions
if c { e } else { e } // expression; both arms required when used as value
while c { s }
for v in e { s } // see §6.5
loop { s } // infinite; exit via break
block { s } // early-out construct: break E exits with value E
{ s } // a plain scope with a value; not a break target
guard c else { s } … // if c { … } else { s }: … is the rest of the block
guard c; … // if c { … }
match e { ... } // §8
return e? (from f)? // §7.3, §7.9
break e? // exits innermost loop/block, optionally with value
continueblock { } exists to promote early-out style anywhere,
not just at function top level. break binds to the
innermost
loop/while/for/block;
labels are not in v1. For definite assignment (§4.4) and narrowing
(§3.8), what holds after one of these is what holds at every way out of
it: each break, and the end of a block's body,
a while's condition found false or a for's
last iteration; a loop has only its breaks. All
break E of one construct must agree on E's type: a later
break's value constructs into the type an earlier one gave the
construct.
A construct's branches — its arms, its tail and its breaks — join
into one type. A literal branch adapts to the others' type, as at any
typed destination (§3.1): an integer constant to an integer type it fits
(if c { x } else { 0 } has x's type, a
u8 for an x: u8), and a float literal, or a
float of literals and integers (§6.3), to an f32
(if c { x } else { 0.62 } is an f32 for an
x: f32). An integer branch beside a float one converts to
the float's type (§6.3): if c { n } else { 0.5 } is an
f64 for an integer n. A [] branch
adapts to no other branch: it takes an element type only from a
destination (§4.2). A construct whose branches are all literals is
itself literal-like, wherever it meets a type, as at a typed
destination: integer constants adapt to any integer type every one of
them fits (g(if c { 1 } else { 2 }) passes an
i32 to fn g(x: i32), and
(if c { 200 } else { 100 }) + b is a u8 add
for a b: u8), and are an i64 where nothing
gives them a type; float literals, and floats of literals and the
integers among them, adapt to either float width
(k(if c { 0.5 } else { 0.25 }) passes an f32
to fn k(x: f32)), and are an f64 where nothing
gives them a type; branches that are all [] take the array
type they meet (h(if c { [] } else { [] }) passes an
i64[] to fn h(x: i64[])), and are an error
where nothing gives them one. A break with such a number settles no type
for the later ones: loop { if c { break 1; } break 2.5; }
is an f64. A bare { … } in expression position
— a match arm of several statements, say — is only a scope:
break inside it still leaves the enclosing loop.
Arrays and slices of one element type join as a slice. Where one
branch's value is a slice — a string literal, say — and another's is an
array of the same element type, or where the construct's destination
type is such a slice (an annotated variable, a declared result, a
parameter declared as a slice), an array a branch gives is a whole-array
slice of that array itself (§3.10) rather than a copy of it (§4.1):
let name = if item.count == 0 { "none" } else { item.name };
makes name a const u8[:], which views the
u8[..48] item.name where that branch is taken.
The slice is read-only where any branch is: a const slice
(a string literal among them), or an array a view of which would be
(§9.5). Like any slice it must not outlive what it views (§9.2): an
array declared in the branch, or a temporary made there — a call's array
result, an array literal — is an error. Arrays of two different types
join so only where a slice joins them as well, or the destination is
one; otherwise they do not agree. A break agrees with the
first: after a slice, a break's array is a slice of itself, while an
array first is what a later slice constructs, where it can.
guard guards the rest of the block it is in, whatever
kind of block that is — a function's body, a loop's, a branch, a
block, a plain scope, a function value's body (§7.6):
{ s1; guard c; s2 } is { s1; if c { s2 } },
and { s1; guard c else { s3 } s2 } is
{ s1; if c { s2 } else { s3 } }. The code after a guard
runs only where c holds, and knows it — including
flow-narrowing of T? (guard r else { return; }
leaves r: T&). A guard jumps nowhere:
guard c; skips the rest of its block, which directly in a
function's body returns from it, in a loop's body goes on to the next
iteration, and in a function value's body ends that call of the value,
not the function running it. Leaving more than the block is the
else block's job: guard c else { break; }
leaves the loop, guard c else { return; } the function. An
else block that does not leave runs instead of the rest of
the block, which then ends. Where the rest of the block ends in a value,
the if is the block's value
(block { guard ok else { break 0; } n * 2 }); a guard is a
statement of its block, never an operand. What is declared after a guard
is declared in the if's block, so a nested function
declared before the guard cannot call one declared after it (§7.5).
6.5 for
Built-in iteration only (no iterator protocol):
for i in a..b— integer range, half-open[a..b); the bounds unify per §6.1 andiruns at that type (ani32range gives a 32-bit loop variable).for i: T in a..b— the same range withiof the integer typeT: each bound is a value ofT, a constant adapting as at any typed destination (§3.1) and a typed bound only by implicit widening (§6.3), so with ann: i64,for i: i32 in 0..nis an error asking forn as i32. The end may be one pastT's largest value, whereTis narrower than 64 bits:for i: u8 in 0..256visits everyu8, its counter never overflowing. Such an end is written with literals alone (256,255 + 1): an end naming a constant is a value ofTas any bound is, computed at run time as the same expression is anywhere (§6.2).for i in n— sugar for0..n;ihasn's type.for i: T in nisfor i: T in 0..n.for x in arr— element copies for fixed-size elements, at the element's type; element references for non-fixed ones (§4.1), whose walk is sequential. A copy is not written (x.f = 1is an error naming&x): the write would update the copy and nothing else. An element that is a relative reference (§3.9) binds as the loaded plain reference, exactly as indexing it gives; an element with relative references inside it cannot be copied out of its root at all, and needs the&xform.for &x in arr— element references (the mutation form for fixed-size elements; redundant, and a warning, for non-fixed ones). Over a[>..<], the binding is a reference in scope: no shrink inside the loop (§5.2). An element that is itself a reference binds as the one it holds, loaded if relative (there are no references to references), pointing where the element does by the read-back rule (§9.5), not into the array:&xmakes that binding one that writes through, and a varint-width relative element, non-fixed, binds so without the&.for x, i in arr/for &x, i in arr— with index (i: i64).for x, i: T in arrgives the index the integer typeTwhereTholds every indexarrcan have: a fixed-size or static-capacity array's length bounds them, while any other array's or slice's index can reach 2^48 − 1 (§10.4), which no type narrower than 64 bits holds, and converts withasin the body. A range's or count's index counts iterations as ani64and takes no type; an element binder has its element's type and takes none either.
All array-family types and slices are iterable. Custom access patterns are provided by HOFs taking static function values (§7.6), which compile to plain loops.
Range/count bounds are evaluated once, before the first iteration; an
empty or reversed range performs no iterations. continue
advances to the next index or sequential element just as reaching the
end of the body does.
A loop iterates its sequence in place, not a copy of it. Traversal of
a resizable array tests the current length before each iteration, so
permitted growth can add elements to the traversal and a shrink ends it
early; its elements never move. A slice keeps its own length: growing
its source does not extend that view. A slice, and anything else the
loop walks in place — a fixed or limited array, an element or a field of
a larger value — is live for the whole body, as a slice variable would
be: a shrink of a resizable array it lies in or views is an error,
however the body reaches it (a call, a function value; §5.1, §5.2). An
index on the path to the sequence is evaluated once, but the references
and slices on it (refs[i].items) are loaded again on every
iteration, so the arrays they lie in may not shrink either. Iterating a
copy (let row = a[i]; for x in row over fixed-size rows),
or a reference read out first
(let r .= refs[i]; for x in r), leaves the array free.
Specialization-time expansion.
const for specializes a fragment once per entry of a
compiler-known sequence. Its v1 sources are fields<T>
with two bindings (field name string and field accessor),
variants<E> with two bindings (variant name string
and variant), types<T1, T2, ...> with one type
binding, and [c1, c2, ...] with one constant binding.
fields, variants and types are
contextual identifiers in this header, not keywords or ordinary function
calls. Bindings use required angle brackets:
fn visit_fields<T, F>(value: T&) {
const for <N, A> in fields<T> { F(N, &value.A); }
}
fn transform_fields<T, F>(value: T&) -> T {
T { const for <N, A> in fields<T> { A: F(N, value.A), } }
}
fn each_case<T, F>(value: T) {
match value { const for <N, V> in variants<T> { V c => F(N, c), } }
}fields<T> visits direct, non-padding fields of a
concrete struct or variant type (a variant's payload) in declaration
order. Its accessor identifies the field of that particular struct
instantiation; another struct with the same field name does not match; a
variant's accessor reaches a value of that variant type only, not the
ADT before it is matched. It can appear in a member access or
initializer label. variants<E> visits every variant
of a concrete ADT in declaration order, with or without payload; its
variant binding is written where a variant's name is: a type, a
constructor, a match pattern with or without a binder, and,
for a variant without payload only, a value. It is not stored, passed or
turned into a number, and its name string is no persistence identifier.
Bindings follow lexical scope and shadowing, including in nested
functions. Types and constants may be heterogeneous; all three sources
may be empty. Constants are scalar or string literals, named global
constants, and the integer constant expressions supported for array
sizes, including enclosing expansion bindings. There is no arbitrary
compile-time execution.
A statement fragment is a separate scope for each entry. A
constructor fragment contributes comma-separated initializer entries,
flattened before ordinary default, completeness, ordering and
destination checks. A match fragment contributes arms,
flattened before the checks of §8.1: every variant once, _
last, and the reference-binder and lifetime rules for each generated
arm. A generated arm may bind a variant without payload, to an empty
value of its variant type. An expansion produces no runtime iterator or
reflection metadata. Its body contains ordinary runtime code, with the
usual reference, lifetime and construction rules. Returns retain their
lexical targets; a break or continue cannot
cross an expansion boundary. The full v1 rules and implementation limit
are in the const for
design.
7. Functions
7.1 Declarations and generics
fn name(a: i64, var b: f64, xs: i32[:]) -> i64 { ... }
fn generic<T>(a: T, b: T) -> T { ... }
fn also_generic(a, b) { ... } // untyped params are generic
fn scaled(a: i32, b: i32 = 0, c: f32 = 1.0) -> f32 { ... } // scaled(2) is scaled(2, 0, 1.0)- Free functions only. No methods, no impl blocks. UFCS:
x.f(a)is exactlyf(x, a); resolution tries fields/built-in members first, then functions. - Parameters are
letbindings by default (not reassigned; their contents are as writable as their type says, §4.4);varmakes the (by-value) parameter a reassignable local. - A parameter with no type annotation is generic (Lobster-style);
explicit
<T>parameters express same-type constraints and let signatures name types. Return types may be omitted where inferrable (required across recursive cycles, §7.8). An omitted result type is taken from the first return checked, as an un-annotatedlettakes its initializer's (§3.8, §4.1): a reference to a non-fixed value, a non-fixed lvalue other than the function's own local (which the return moves, §7.3), or an explicit&xmakes the result a reference.fn args(c: Ctx&) { c.args }returns what the field refers to, just asc.argswould be, andcopy(args(c))copies it; a reference to a fixed-size value is returned as its pointee. A part of the function's own local, or of a temporary, dies with it, so it is returned by neither:return copy(x). An omitted result type cannot be taken from a[], which has no element type of its own (§4.2). - A trailing parameter may declare a default value,
c: f32 = 1.0: the parameters after one with a default have one too, and one with a default has a type (an untyped parameter's is its argument's). A call may leave out any number of the trailing parameters that have defaults; each one left out takes its default, evaluated at that call after the written arguments, in parameter order, and anew each time:scaled(2)above isscaled(2, 0, 1.0). The default is a value of its parameter's type as the written arguments and any explicit type arguments make it, adapting as an argument would —fn scale<T>(x: T, by: T = 2)doubles au8and anf64alike — and is an error at a call where it does not fit:fn back<T>(x: T, by: T = -1)takes nou8. It decides no type variable itself:fn f<T>(a: i64, b: T = 0)is calledf<i32>(1)to leavebout. Each call leaving a default out checks it there, as the argument written in its place, so a default may not lead to a call that takes it again, as a field default may not lead to a construction that does (§3.2):fn f(a: i64 = g())besidefn g(b: i64 = f())is an error. A named function passed as a static function value keeps its defaults (§7.6); a block literal's parameters, athread_fn's (§11.2) and a tag-dispatched call (§8.2) have none. - A default names what a top-level declaration would, in the
declaration's namespace: globals, constants, top-level functions and
types, and the type parameters the call binds to types, the function's
and, for a nested function, those of the functions around it (§11.1). It
never sees a local of the code calling the function, whatever its name.
A name the declaration's own scope makes something else is an error in a
default rather than the global of that name: a parameter of the function
(
::xnames the globalx), a type parameter bound to a function value (§7.6), and for a nested function (§7.5) a variable or nested function around its declaration, or a type parameter bound to a function value of a function around it. - Overloading by parameter types is allowed; resolution is static: the
unique concrete exact match wins, then a generic exact match, then a
match requiring coercion (array→slice §3.10, literal fit §3.1 -- an
array literal's constants too, §4.2 -- implicit widening §6.3). A
candidate's rank is its worst argument rank; ties are errors, without
further specificity or declaration-order tiebreaking. A default is no
argument: a candidate the call leaves parameters of to their defaults
ranks by the written arguments alone, so
f(a, b = 0)besidef(a)makes every one-argument call offambiguous. Only if no ordinary candidate matches is tag dispatch (§8.2) tried. A candidate that converts an integer argument to a float (§6.3) is the last resort, after tag dispatch and after a builtin of the same name:f(2)callsf(i32)besidef(f64), andabs(n)std'sabs<T>, notabs(f64). An integer converts to either float type equally well, so two such candidates are ambiguous:sqrt(n)with integernhas to saysqrt(n as f64), whilesqrt(n * 0.5)issqrt(f64), assqrt(0.5)is. The expected result type never selects an overload. Anf32destination may refine an otherwise unconstrained floating generic computation (§7.7), provided resolution still selects that same overload. - Multiple return values:
fn f() -> A, B; received aslet a, b = f();, or assigned to places that exist asx, xs[i] = f();: the call runs first, then each target is assigned its result in order, asx = f()would assign one (a non-fixed result is built at its target; a reference to a non-fixed value needs aletand an explicitcopy, §4.1). Only=takes several targets. There is no tuple type — multiple returns are a calling convention; structs are the way to keep data together. (Function types with multiple returns need parens —fn(i64) -> (A, B)— to disambiguate the comma; declaration headers don't.) Nonfixed types are allowed in any return position; each nonfixed result gets its own destination per §7.3 (possibly distinct stacks). An ordinary value context takes only the first result; a call statement discards them all. Onlyreturnand calls carry several values: a block or branch cannot end ina, b, soreturn if c { 1, 2 } else { 3, 4 };is an error and each branch returns on its own. A multi-name binding requires exactly that many results.return f()forwards all of a multi-result call's results, adapting each to its corresponding declared return type.
Declarations at top level are order-independent (whole-program compile); only global initializers have ordered semantics (§11.1).
7.2 Parameter passing
Uniform with = (§4.1): the parameter's type is the
destination, and the argument connects to it by size class. A fixed-size
argument is copied into a by-value parameter and bound by a
reference-typed one (fn inc(n: i64&) takes
inc(n); inc(&n) says the same and warns).
A non-fixed lvalue argument binds by reference to a reference, slice or
untyped parameter and is an error at a by-value one —
f(copy(xs)) spells the copy, f(make())
constructs the result in the parameter's slot (§4.3). Array→slice at
call sites (§3.10) is the one shape change, cheap and copy-free by
definition. A by-value non-fixed parameter reserves the statically
assigned stack region for the value at the call site, constructs it
there, and the callee owns it like any local. A parameter the call
leaves to its default (§7.1) takes it the same way, as the argument
written in its place: xs: T[>..] = [] builds an empty
array in the slot, and r: i64& = counter binds the
global.
7.3 Return values and result placement
Normal completion. The checker infers whether each
function specialization can resume its immediate caller. A body whose
paths all terminate, loop without an exit, or
return ... from an outer function does not resume that
caller; this propagates through ordinary calls and static function
values. A variant-dispatched call has this property only when every case
does. A return targeting the function itself, including one taken by a
nested callee, resumes its caller normally.
A function declared to return values needs no result on a path ending
in such a call. For example, fn fail() { abort("failed"); }
permits fn value() -> i64 { fail(); }. This is a
control-flow fact, not a value type: fail() still produces
no value to pass as an i64 argument or to forward in an
explicit return fail();.
After a proven exit, the remaining statements and trailing expression
in that block are neither typechecked nor emitted. They still must
parse. This does not make checking depend on constant folding: both
branches of if false { ... } else { ... } are checked.
Unknown recursive calls are assumed to return, so inference may miss a
recursive function that never returns; it does not skip a continuation
merely because the recursive callee has not yet been checked.
Fixed-size returns use registers/native stack or a caller-owned data-stack slot for large values (C.3). A variable or resizable return value is constructed directly in its destination (§4.3); the callee is compiled knowing the destination stack (statically or as a hidden argument), writes element data there, and returns the value's metadata (lengths) outside the element run. Where a result is first built with its packed value header, the header is removed in place under §4.3's relocation exception (Appendix C.3):
let x = f();— a fresh stack region (statically assigned tox).v.push(f());/v.append(f());— the top ofv's stack; the push is a no-op on return beyondv.lenadjustment (the data is already in place).g(f())— the argument slot ofg(its statically reserved stack).x = f();(resizablex) —x's stack, replacing its contents, whichfmay therefore not use (§4.4).
Named results (guaranteed NRVO). When every
return of a nonfixed result returns the same local variable
(and those returns are its last uses), that local is allocated at the
return destination from its declaration — return x then
costs exactly the same as returning the constructing expression
directly. The same holds where the other returns hand back values whose
construction does not read or write that local, such as
return [] or return str("none") beside
return x: such a value is an exit's value under
Exits below, built behind the local and moved down over
it. When different locals are returned on different paths, only one can
live at the destination and the others are copied on return; so is a
local another return's value may read or write. The compiler warns where
a return keeps a returned local from its destination that way. This is
in addition to §4.3's layout relocation.
Exits. A return or break
taken while its destination already holds part of a value -- inside an
element of a literal being built there, say, or, for a
return from (§7.9), below a named result built in the
destination the target handed down -- abandons that part. Its own value,
constructed behind it, is moved down over it: a copy only such an exit
costs.
Destination requests. The construct-here information
handed to a callee (or any constructing expression) comes in one of two
forms, chosen by the receiver: one value of type R (receivers
that store a value: let, params, =,
push of one element — R's nested metadata, if any, is part
of the constructed bytes), or a run of elements of type T
(receivers that splice: append) — the callee emits raw
elements and returns the count. The request kind is part of the
specialization signature (§10.2); the same function may be compiled in
both forms for different callers. A callee that merely constructs its
result passes the destination through. A callee that must first operate
on its result as a whole array may make one copy into the requested
element run, just before returning. Where its packed value can occupy
the receiving storage, removing the header is instead the layout
relocation of §4.3 and may be performed by the callee or receiver. A
local resizable can use metadata outside the element region (Appendix
C.2), with its elements already in place.
Consequence: returning a built-up value and out-parameter style are the same cost, and building a variable element "inside" a container is idiomatically a function call in argument/push position.
7.4 Growing through a reference
Passing &v where v is resizable lets
the callee push/append: per call site the compiler specializes on the
identity of the stack v tops (§10.2), or passes it as a
hidden argument / fat reference (Appendix E) when one body serves
multiple stacks. No surface syntax distinguishes these.
An in-scope "current pool" mechanism (allocation without naming the array, compile-time bound to whichever suitable array is in scope) is deferred (TODO).
7.5 Nested functions and free variables
Functions may be declared inside functions. A nested function may
read and write the enclosing function's locals ("free variables"),
subject to those variables' normal rules (writability §9.5, roots §9.2,
shrink rules §5). For definite assignment (§4.4), a call assigns a free
variable only where every way out of the body does: each
return, those of a function value's body and a callee's
return … from among them (§7.6, §7.9), and the end; where
any of them may have assigned it, a let declared without an
initializer takes no value after the call. A function value's body
(§7.6) encloses what is written in it the same way: a function declared
there, or a function value written there, may also name the value's
parameters and the body's locals.
Names in a nested function resolve where it is declared, whatever surrounds a call of it: a free variable is the one in scope at the declaration, not one of the same name that a scope around the call declares, or that is declared further on, and none that one of the function's own type parameters hides (§11.1). The functions it calls are those in scope there and every function declared in the blocks around its declaration, before or after it, so nested functions may call each other in either order, as a recursive descent parser's do; calling one of them before the enclosing code has reached its declaration is an error. That code names a nested function from its declaration to the end of its scope. It may pass the name directly as a static function argument (§7.6).
Nested functions of one name declared in one block overload as top-level functions do (§7.1), so case functions (§8.2) may be nested and use free variables. A name reaches the set of the innermost block declaring it: for the enclosing code the members declared so far, for a nested function's body all of them, before or after it. A later one with the parameter types of an earlier one shadows it instead, as a later local of a name does: the enclosing code calls the latest declared so far, a nested function's body the latest declared at or before it, else the first after it. A set in an inner block hides an outer block's of the name entirely, and a top-level one, as a namespace's set hides the global one (§11.1).
Implementation model: free variables become hidden reference
parameters of the nested function. A nested function's value never
outlives the function declaring it (function values do not escape,
§7.6), and a call where a variable it names is out of scope is rejected,
so every call can pass them. As by its parameters, a nested function is
specialized by how a call finds its free variables (§10.2): where each
points, what it holds and whether it is assigned, and for a
let whether it may be. So is a function calling it, and one
calling a function value that names such a variable. A call sharing a
specialization with an earlier one leaves them as that one's check did:
rebound, assigned, holding what the body stored. When a nested function
is passed as a static function value and inlined into its HOF — the
expected, common case — the hidden parameters disappear entirely;
un-inlined builds (debug) keep them as real arguments.
7.6 Static function values
fn foo<F: fn(i64)>(a: i64) { F(a); }
foo(1) { print(it) } // trailing-block sugar, implicit `it`
foo(1) { x => print(x) } // named block params
fn h(v: i64) { print(v) } foo(1, h) // or pass a named functionFunction values are compile-time entities passed as generic
parameters, not runtime data. The : fn(...) bound is
optional documentation: a bare <F> works identically
— the call F(a) typechecks per instantiation like
everything else (passing a non-function just produces the error at that
call, reported with the instantiation chain). F is the
function value wherever it is in scope, hiding a variable around a
nested function's declaration and a global or function of its name
(§11.1), and is called as F(a): a member call
a.F() names it too, and is an error. A block's value is
checked without the destination type its call's value
meets, so it cannot be a [] (nor a construct whose branches
all are one), which would have no element type (§4.2).
Its receiving storage is supplied independently of that type inference: relative links in a fresh result are checked against where it will be constructed, including when the block is called from another function.
A fresh block result follows the same construction rules as a named
function's result, including literals containing self-relative
references. Selecting a fresh result through if,
match, loop, or block preserves
that property. A tail that instead names existing storage (or a
reference to it) is still a copy and must obey §3.9's relative-reference
restrictions.
A function argument must be a function name (including a bound
generic function parameter) or a block literal. A name with several
overloads passes them all, and each call of the value resolves among
them as a call by that name would: each(xs, sort) with
fn each<F>(xs: i64[:]) { F(xs); } calls std's
one-argument sort<T>, and one value may reach
different overloads at different calls. Runtime expressions producing a
function value, such as a call, if, match,
block, or loop, are rejected. Evaluate runtime
work in ordinary statements before the call; use locals when its
ordering with other arguments matters. A named function keeps its
parameter defaults (§7.1) as a value: F(x) may leave out
what it declares defaults for. A block's parameters have none. A
function value passed as an argument may follow fewer arguments than the
callee has parameters where the rest have defaults:
each_step(5, show) for
fn each_step<F>(n: i64, step: i64 = 1).
Function values capture enclosing locals per §7.5, and cannot escape: storing them, returning them, or putting them in data is a compile error. Every call is direct and inlinable; HOFs compile to hardcoded loops (Lobster-style guarantee). There are no closures-as-objects and no runtime function pointers in v1.
A plain return inside a function value returns from the
lexically enclosing named function, not from the HOF
that calls the value — so a HOF-based iterator has the same structuring
power as for. This is implemented with the
return from mechanism (§7.9), the HOF's frames unwinding
transparently.
7.7 Generics and monomorphization
Monomorphic specialization, C++/Lobster-style: type parameters are unconstrained; the body is re-typechecked per unique combination of (type arguments, reference roots (§9.2), writability provenances (§9.5), static function values, destination stacks). Errors are reported at the offending instantiation with the compile-time call chain — the whole-program, call-graph-order compiler can always show which call path produced the failing instantiation. Every instantiation the program reaches is checked, so there must be finitely many: a recursive call may not keep making new ones (§7.8), nor may a generic type's fields (§3.2).
Type variables are never checked abstractly. A generic body is
checked only at an instantiation where every type is concrete —
including the result of every call it makes on a function-value
parameter, since that value's body is checked inline against the
concrete argument types at that point (§7.6). So a HOF never needs to
state what its function value returns: map's result element
type is the type of F(x) in that instantiation, even when
the block is { generic(it) } and that type depends on the
instantiation.
Call-site type arguments. Type arguments are
inferred from the argument types whenever they appear in the parameter
list: fn foo<T>(x: T) can be called as
foo(1) without an explicit type argument. The typechecker
must support inference for both <T>-style and untyped
(implicitly generic) parameters. Where several arguments mention one
type variable, the typed ones bind it and a literal then adapts
(max(n, 0) with n: u32 is the u32
instantiation), so an i64 literal never fixes the type by
coming first, nor does an array literal of constants (§4.2); among
literals, a float one binds before an integer one, which converts to it
(§6.3): max(1, 0.5) is the f64 instantiation.
A float of literals and integers (n * 0.5) binds as a float
literal does. A [] or null binds nothing: it
takes the type the other arguments, literals included, give its
parameter, wherever it stands among them (both([], xs) for
fn both<T>(x: T[], y: T[]) is the instantiation
both(xs, []) is). An explicit list
f<i64>(x) is allowed; it is needed when no
argument mentions the parameter (e.g. qget<i64>(), or
zero<f64>() for
fn zero<T>() -> T) or only a [] or
null does (g<i64>([]) for
fn g<T>(xs: T[])); it binds the leading type
parameters in order, and the rest are inferred. Syntactically,
f< commits to a type argument list only when the
<…> is immediately followed by (, by
{ for a struct literal
(Pair<i64> { … }), or by .ident { for a
variant literal (Opt<i64>.Some { … }); otherwise
< is the comparison operator.
Floating computation context. After ordinary
argument inference and overload resolution, an f32
destination can refine a call's inferred f64 type parameter
to f32. This applies when the declaration returns that bare
type parameter (-> T) and every parameter mentioning it
is a written, bare value parameter (x: T). Its arguments
must be adaptable float literals or expressions (§6.3), adaptable
integer constants, or directly nested calls satisfying this same rule.
Matching those arguments as f32 must select the same
overload with all other bindings unchanged; ambiguity or a different
selected overload prevents the refinement. Normal resolution must
succeed first: result context never rescues a failed or ambiguous
call.
With integer width and height, the
destination below therefore chooses f32 for both calls and
their argument arithmetic:
let scale: f32 = max(0.5, min(width / 960.0, height / 600.0));An explicit type argument for T, an already typed
numeric argument, a reference or container parameter mentioning
T, or a default supplying such a parameter commits its
type. The rule does not infer a type parameter from the result alone,
change an integer computation into floating arithmetic, or extend
through an intervening operator or stored call result. Overload
selection still sees the call's ordinary inferred type. Without an
f32 destination, calls continue to default to
f64; an inferred binding such as
let saved = min(width / 960.0, height / 600.0); stores an
f64 result.
This is not a general change to mixed-argument unification. With
var count: i32 = 1;,
let value: f32 = min(max(0.25, 0.5), count); still errs:
count is a typed integer argument, so result context does
not refine that call. Reversing the arguments does not make it valid.
Write min<f32>(max<f32>(0.25, 0.5), count) to
choose floating arithmetic explicitly for that mixture. In contrast,
count * 0.5 is an adaptable floating expression and can
participate in the contextual rule above.
An explicit inner min<f32>(...) also chooses
f32. Alternatively,
max(0.5, min(width / 960.0, height / 600.0)) as f32
computes in f64 and then rounds the result once: a cast
does not pass its target type into its operand. These are different
choices: min<f32>(n * 0.1, 1.0) with integer
n = 9 computes 0.90000004, while
min(n * 0.1, 1.0) as f32 computes 0.9. A typed
intermediate is also valid, but is not needed just to choose a generic
call's type.
Literal arguments. Where a literal is the only thing
binding a type variable — the parameter is untyped, or its type is a
bare T no typed argument mentions — the variable takes the
literal's default type (§3.1), and inside the specialization the
parameter is a literal parameter: a constant of unknown value
that adapts wherever the bare parameter meets a type, as the literal
would have, so push_n(flags, 1, n) with
fn push_n<A, T>(xs: A&, v: T, n: i64) pushes a
u8 into a u8[>..], and
fill(small, -7, 2) an i16 into an
i16[..4], with no suffix or cast at the call. The value is
not part of the specialization: every call with a literal shares one
body, checked once with the value unknown, and the body records each
type the parameter adapted to (in itself, in a callee it passed the
parameter on to as a literal, and in a block it handed it to). Each
call's literal is then checked against every recorded type —
push_n(flags, 300, n) is an error naming the
push that takes a u8 — once the whole program
is checked and the records are complete. What the body cannot do with an
unknown value it cannot do with a literal parameter: [0; n]
needs a constant, and v + 1 is an ordinary i64
expression, exactly as let y = v; commits y to
i64, so a recursive call on v + 1 passes a
typed value and terminates. var parameters and
extern functions take literals as ordinary values.
Passing arrays by reference is the generic way to write mutating range algorithms (each array-family type instantiates its own copy); slices are the uniform non-mutating way.
7.8 Recursion
Non-recursive call graphs are the default and require nothing.
Recursion is opt-in and annotated: the entry function of every recursive
cycle is marked recursive fn (the keyword alone: roots and
destinations follow from the entry call as for any specialization,
§7.7), all functions in the cycle need fully explicit signatures (no
inference across the cycle back-edge; a result inferred as a reference,
§7.1, is an error in any of them, since the back edges need its roots
before its returns are checked), and — the key restriction — no
function in the cycle may call into the cycle while one of its nonfixed
locals is in scope. A call into the cycle is any call that can
lead back to the caller: the back edge, and every other call reaching a
member of the cycle. A nonfixed local — a variable- or resizable-class
let or var, or a match arm's
by-value copy of such a payload — holds a data stack (§10.3) from its
declaration, whose initializer builds it in place (§4.3), to the end of
its block, so it counts at a call in its own initializer too; were one
in scope at such a call, every activation would hold a stack of its own.
So a scratch buffer declared in a block or a loop body that ends before
the recursive call costs the recursion nothing, its stack free again
before any deeper activation starts; one declared before a loop whose
body recurses is still in scope at that call, and is an error there,
reported with the local. A by-value nonfixed parameter is in
scope for the whole body, so no function in a cycle can have one: it
takes a reference or a slice instead. A function value's body is part of
the function it is written in, so the locals it declares count where it
calls into the cycle, as do those of the function calling it; a nested
function in the cycle is a member like any other, while the locals of an
enclosing function outside the cycle are owned outside it (below).
Growable data that must outlive a recursive call is owned outside the
cycle and passed in (references, slices, reusable pools, or one struct
of references to several tables, A.6). The compiler checks every call
into a cycle in call-graph order; recursion depth then only consumes
native call stack.
Temporaries. The same holds of the unnamed nonfixed
values of the statement making the call: at a call into the
cycle, no nonfixed value of the enclosing statement may be
live, since each lives on a data stack of the activation
(§10.3) — a temporary until its statement ends (§9.2), a value under
construction until it is complete — and would otherwise cost a stack per
activation as a local in scope does. That forbids: (a) a nonfixed
temporary as an argument of the call — a call's result, a
copy, a str, a literal of nonfixed class, or
the copy a by-value nonfixed parameter takes of its argument (§4.1) —
which the callee's view keeps alive across the call; (b) the call nested
inside a nonfixed value under construction: an element or field
initializer of an array or struct literal of nonfixed class, or an
argument of print or str, whose text is being
rendered (format is no such value: its text goes into the
storage it is given); (c) a nonfixed temporary evaluated earlier in the
same statement, whether or not something has consumed it since — a
temporary lives to the end of its statement, as does one built by the
head of a construct the call is inside (an if condition, a
match scrutinee, a while condition, a
for sequence), which runs in the scope around the
construct; (d) the call's own nonfixed result anywhere but as the whole
value of a return of the same result type — through the
branches of an if or match and a block's tail,
or by return … from — where it is built in the caller's
destination, or pushed or appended in place into resizable storage owned
outside the cycle, where it is built at that storage's top (which
§1.3(4) then rejects wherever a function of the cycle grows that
storage, as every one appending into it does); anywhere else — an
argument, an operand, a dropped statement value, an element of a
literal, a value assigned (which §4.4 rejects as well) — it needs a
destination temporary taken before the call and held across it. The
compiler reports each case with the call and the value. What to do
instead: evaluate the value in a statement of its own before the call
(let width = str(n).len;, the binding being fixed-size),
pass scratch in from outside the cycle (the mark /
resize(mark) pattern below), or take an output buffer by
reference and build into it in place instead of returning a growable
value. Fixed-size values and temporaries are never affected: a slice of
a fixed literal, a struct of fixed fields, a large fixed value (§10.3)
all cost no data stack at the call. So a program's stack count is a
compile-time constant, with the number of worker threads (§11.2) the
only dynamic factor.
Scratch passed down this way can be viewed across the recursive
calls. An activation that takes let mark = scratch.len of a
grow-shrink scratch array, pushes onto it, views its own part and ends
with scratch.resize(mark) leaves the array as long as it
found it, so its calls back into the cycle are balanced (§5.2) and the
view stays in use across them:
recursive fn walk(scratch: i64[>..<]&, depth: i64) -> i64 {
let mark = scratch.len;
scratch.push(depth);
let mine = scratch[mark..];
var total = 0;
if depth < 3 { total = walk(scratch, depth + 1); }
total += mine[0];
scratch.resize(mark);
return total;
}Polymorphic recursion. A recursive call may
instantiate its callee with other type arguments than those of the call
it sits in (§7.7): flip<A, B> calling
flip<B, A> is back at flip<A, B>
one round later, and that call is the cycle's back edge. The
instantiations a recursion reaches must be finite, though. Every branch
of every specialization is checked, whatever values reach it at run
time, so a call whose types grow with each round —
g(a, n - 1) with let a: T[1] = [x] inside
g<T>, or grow<T[2]>(n - 1) inside
grow<T> — would instantiate without end, even where
n bounds the recursion at run time. The compiler rejects
such a call once it would put more than 16 specializations of one
function in progress on one compile-time call path, the way C++ bounds
template instantiation depth, and reports it with the chain of
instantiations that led there. A finite recursion has one specialization
in progress per instantiation on its cycle, or per level of a nested
type it descends through, so only an unusually deep one meets the
bound.
Cycle store rule. Within a recursive cycle, distinct
activations of the same local are statically indistinguishable, so the
§9.2 depth check is not sufficient there. Therefore: a reference whose
root is a local of a cycle function may be passed down as an
argument, but may not be stored into any location, nor returned. A
function is in the cycle for its whole body, not from its call into the
cycle on: the stores it makes before that call are inside the cycle too.
Rebinding one of the activation's own reference variables
(cur .= cur.next) is not such a store: the variable dies
with the activation, and what it is bound to came from one that outlives
it. References rooted outside the cycle are unrestricted — in particular
a pool handed to the cycle by reference (a parameter whose pointee is
resizable-class, which no cycle function can hand on to the cycle,
holding none across a call into it), so a recursive builder can push
into a caller's local pool and link what it pushed. A local of an
enclosing function outside the cycle — a free variable (§7.5) of a
nested recursive fn — outlives every activation the same
way, so a parser keeps its pool, its key table and its input as locals
of a non-recursive parse, and the nested recursive
functions store, link and return references rooted at them freely.
Because the cycle's functions are checked against the entry call's
roots, every recursive call must pass such a pool by the same reference
the entry call did: swapping two pools, or passing a different one, at a
back edge is a compile error.
A reference or slice parameter the cycle passes on is rooted outside
it the same way, whatever its pointee's size. Where the call that first
reached the function rooted the parameter's argument exactly, outside
every recursive cycle — a local of a function that is neither recursive
nor in a cycle, or a global — and every call back into the cycle passes
the parameter on — itself, a subslice or field of it, or a parameter of
the calling activation passed on in the same way — the parameter points
there in every activation, and references rooted at it or read through
it may be stored inside the cycle, into storage that is the same in
every activation: a local, a pool, or what another such parameter points
at or leads to, not what a parameter the cycle passes something else
does. So a recursive walk over a context of borrowed tables (A.6) stores
the views it reads through the context into the tables it reaches
through it. A parameter whose argument is, on every call, a reference or
slice read through one such parameter, or through a pool, points outside
the cycle in every activation as well, though not at the same storage,
so what is rooted at it may be stored the same way while it is no
storage to store into: a builder hands the node read through the context
to the function that links it under a new one
(rest(c, c.nodes.push(…))). Nothing makes a recursive call
pass such a parameter on, so it is restricted only once a store relies
on it: a later call into the cycle that passes it anything else is a
compile error reported at the store, and one before any store makes the
store the error. (Further refinements are future work, TODO.)
Cycle return roots. A back edge reaches a function
whose own returns may not have been checked yet, so the roots of its
result cannot come from them at once. Instead a cycle is checked
in rounds: in the first, a back edge's result points nowhere
yet, and nothing checks or stores it, nor does the back edge shrink,
grow, store or rebind anything; each later round gives every back edge
what the round before recorded of its callee — the union of the roots
its returns give, one root for each way a return can go, mapped through
the back edge's own arguments (a back edge reuses the body whatever it
passes, so it maps a parameter's root through the arguments it gives the
parameters the entry call gave that root — it may give two of them two
arrays where the entry call gave one — and merges the lot as a call
merges its callee's returns, §9.2), read-only where any return is — and
the round after the last that changed anything is the one whose errors
stand. That includes a variable outside the cycle, which is one variable
at every level of a nested function's recursion (§7.5): a round that
rebinds it, or stores into it, has read it for the next level as it was
before. A cycle function may therefore return the result of
a back-edge call, and a parenthesised subexpression in a
recursive-descent parser needs no wrapper node
(bench/goose/calc_noparen.goose); one that returns either
of two pools it is handed can store the result where both outlive the
store, and one that returns a view of a grow-shrink array on any path
has a result no store may keep (§5.2). A result that holds
references — a struct with slice fields, say — is checked the same way,
by the roots of what it holds (§9.2), so a recursive builder may return
views into an arena that outlives the cycle, built from its children's,
with the recursive call ahead of the base case's return or after it:
struct Fragment { text: const u8[:] }
var arena: u8[>..] = [];
recursive fn nest(n: i64) -> Fragment {
if n == 0 { arena.append("x"); return Fragment { text: arena[..] }; }
let child = nest(n - 1);
return Fragment { text: child.text };
}7.9
return … from (long-distance return)
return E from f // f = a named function on every compile-time call path
// from f to this statementf is resolved where the returning function is written --
a nested function in scope, else the name's overload set by the rules of
§11.1 (from image::parse is explicit) -- and the target is
the innermost enclosing call of a function in that set. An unrelated
function that merely shares the leaf name never catches the return.
Returns E as the result of the innermost active call of
f, unwinding all frames in between. It is the language's
opt-in, lightweight "exception" mechanism (e.g. a deep parser/loader
error doing return err from load_level), and the mechanism
behind function-value return (§7.6).
Semantics and implementation:
- Validity is checked statically in call-graph order: every call site
of the returning function must lie within the dynamic extent of an
fcall. - Each function on a path between
fand thereturn fromgains a hidden return discriminant: "result is for my caller" vs "propagate for targetf" (an enum when multiple targets are possible). Callers check it and immediately return in the propagate case. Normal epilogues run, so all intermediate data-stack watermarks restore correctly. There is no unwinder, no tables, no destructors (there are none to run). E's destination follows §7.3 forf's call site. No special stack rule is needed: a pending nonfixed return's destination stack is in the in-use watermark fromf's call site throughf's entire dynamic extent (§10.3), so no function betweenfand thereturn fromcan own data on it — unwinding intermediate frames cannot disturb the in-flight value. What they had built there towardf's result is abandoned, as for any exit (§7.3).- Multiple
return fromsites and multiple targets compose; agreement withf's return type applies as usual.
Error handling has no required error-value
convention: an application may use a bool, enum, string, i64,
or another type. By convention, the error is the last of multiple return
values. Use ordinary multiple returns for local propagation and
return from to return across several calls.
7.10 extern fn
extern fn sqrt(x: f64) -> f64;
extern "sqrtf" fn sqrt(x: f32) -> f32;
extern "gs_os_read_file" fn read_file(path: u8[:], out: u8[>..]&) -> bool;
extern fn gl_vertex3(v: float3&);A declaration without a body binds a Goose signature to a C function:
the optional string is the C symbol (default: the Goose name). It is
top-level only, fully typed, not generic, and joins overload resolution
like any function. A call compiles to a direct C call with no
calling-convention extras, and a prototype is emitted from the
declaration unless the runtime declares the symbol (the os
library's gs_os_* functions, declared in
src/runtime/runtime_ext.h and defined in
src/runtime/runtime_os.h). User C reaches the program
through --include <header>, emitted after the
generated type declarations so a header can implement shims against the
generated typedefs. The types in every extern fn signature
are emitted even when the function is unused, including the complete
types behind reference and slice parameters. An unused extern does not
require a C implementation to link. Every name that comes from the
program appears in the C with a _g suffix -- a struct
Stats with a field lo is Stats_g
with lo_g -- which is what keeps the generated file clear
of the C keywords and of whatever the platform's headers declare,
without depending on a list of names to avoid. A namespaced declaration
ns::x (§11.1) is ns_x_g followed by the
namespace's length -- image::Pixel is
image_Pixel_g5 -- so a::b_c and
a_b::c stay apart and no namespaced name coincides with a
global one. An extern fn's own symbol is the exception:
that is the C name the declaration gives, unchanged.
An extern fn's trailing parameters may have defaults
(§7.1), which each call passes: the C function gets every argument.
What crosses (layouts per C.2): the integer and float scalars and
bool; any flat fixed-size value (including structs, fixed
and static-capacity limited arrays, and fixed-mode ADTs), by value as
its packed C type; T& to such a T, as a
pointer; T[:] of such a T, as the slice struct
({ data, len }); and u8[>..]&, as the
resizable's header plus its stack (gs_rref), which C
appends to through gs_bld_append(ref, p, n); C compiled on
its own, a package's, finds both in goose.h
(docs/packages.md). Returns are one scalar or flat fixed-size value by
value, or nothing; references, slices and builders are parameter-only
forms. Everything else — varints, references inside structs, variable
and resizable values by value, slices of variable elements, optionals —
is rejected at the declaration. A parameter of reference or slice type
is what it says (§9.5): a read-only argument needs it declared
const.
C must preserve the types and lifetimes of borrowed storage, must not retain a borrowed argument beyond the call, and may only append to a builder. The compiler assumes an extern may write through its arguments but does not mutate unrelated Goose globals; C code is responsible for honoring those obligations.
7.11 Exporting Goose functions to C
export fn hello_world() -> const u8[:] {
return "Hello, world!"
}
export "c_sum" fn sum(a: i32, b: i32) -> i32 { a + b }An export fn defines a normal Goose function and emits a
C-callable wrapper. The optional string names its C symbol; otherwise
the function's leaf name is used. Exported functions must be top-level,
non-generic, have explicit parameter types, and have at most one
explicitly typed return value. Default arguments,
return ... from, reusable-pool parameters, and
string-builder parameters are not supported at the C boundary.
Parameters otherwise use the same fixed-size C shapes as §7.10.
Scalars and flat fixed-size returns are returned by value. A
read-only const u8[:] return is exposed as
const uint8_t * and an additional
int64_t *out_len parameter; the returned bytes are not
NUL-terminated, and their storage remains borrowed according to the
Goose function's lifetime. Other references, slices, and variable-size
returns are rejected.
Generated C retains its normal main unless the program
is compiled with --header out.h, which builds it for a C
host: the generated C has no main and a source without a
Goose fn main() is accepted. The header declares
goose_init(argc, argv), which a C host must call before any
exported function. It initializes the runtime and the globals and then
runs the Goose fn main(), if there is one, so
main is where a library keeps its own start-up code;
exported functions may be called from it. A second call does nothing.
Initialization is idempotent on the initializing thread; exported calls
and mutable Goose globals are not thread-safe and must remain on that
thread. --header requires generated C output and at least
one export, and writes declarations and the required packed C type
definitions for the exported API.
An exported C symbol is emitted as written, as an extern
one is, and must be a C identifier that is not a C keyword and that the
program does not otherwise reserve (main,
goose_init, or a name beginning with gs_).
The wrapper calls the normal generated Goose specialization, not its hidden internal C signature. This keeps Goose's stack argument and other compiler implementation details out of the function's public C signature.
7.12
simd fn: versions per instruction set
simd fn encode(out: u8[>..]&, src: u8[:]) { ... }simd asks the implementation to build the function once
for the baseline instruction set of its target and once more for each
wider one it supports (AVX2 and AVX-512 on x86-64), and to run, every
time the function is called, the widest version the CPU running the
program has. It is meant for a function holding a hot loop the C
compiler can vectorize, which the baseline's instruction set can only do
poorly or not at all: byte transforms, codecs, kernels over arrays.
simd has no meaning of its own: every version computes
exactly what the baseline does, so no program's output, aborts or exit
status depends on which one runs, or on whether there are versions at
all. Integers already compute at their exact widths (§6.2), checks abort
where they would, and float operations round as the baseline's do, one
rounding per operation: no version fuses a multiply and an add into one
rounding where the baseline would not. An implementation may ignore
simd entirely, as the C backend does under compilers other
than clang on x86-64 and under the JIT.
Versions are per function: everything the function contains is in each, including the callees inlined into it. The function itself stays out of line, since a caller it was inlined into would run the baseline's code, and each call to it pays for one test of the CPU's level, so it suits a function that runs a loop rather than one called from inside one. The cost is code size: the function's body once per version.
simd comes after export and before
recursive; it applies to functions with bodies, nested ones
and generic ones included (each specialization has its versions), and
extern simd fn is an error.
7.13
inline fn: inlined whatever its size
inline fn project(cam: Camera, p: double3) -> f64, f64, bool { ... }inline asks for the function to be inlined at every
call, however big it is and however many calls there are. Without it,
the implementation decides from the function's size and its number of
calls, and a function called from a hot loop can stay a call because it
is called from several places.
inline has no meaning of its own: a program computes the
same with or without it. It is a request the implementation may not be
able to grant at every call. Where Goose cannot inline a function itself
(one returning several values, §7.3), the C backend asks the C compiler
to inline it instead. Where inlining is off (-O0), every
call stays a call.
inline is a keyword only directly before
fn, recursive or thread_fn, so it
stays usable as a name. It is an error together with extern
(no body to copy), export (its C wrapper calls it),
simd (a caller would run the baseline only),
recursive and thread_fn.
7.14
checkfree fn: no bounds check left
checkfree fn bfs(dist: i32[>..]&, q: NodeId[>..<]&, src: NodeId) -> i64 { ... }checkfree asks the compiler to reject the program if a
bounds check is left anywhere in the function's text after bounds-check
elimination (§10.5): in each specialization of it, each
simd version and each copy inlined into a caller. It
changes nothing about what the program computes or how it is compiled. A
check belongs to the text it is written in, so a caller's argument
expression is the caller's, and a nested function or block written
inside a checkfree function is checked as part of it.
- What counts. The index and slice checks of §9.3,
conversions into index and bound types (§3.11), and nothing else: the
other aborts of §9.3 remain,
assertin particular, which is how such a function states its preconditions once, outside its loops. - What it calls. Every call in a
checkfreefunction is to acheckfreefunction (directly, through a case function's dispatch or a function value naming one, or aformatoverload aprintreaches), a builtin or anextern fn; a block it calls must be written in acheckfreefunction.inlineexempts nothing: a call that stays a call runs the callee's own checks. - What it may rely on. The analysis's facts, call
sites' included (§10.5), so a new caller that does not establish what an
entry relied on can make a
checkfreefunction fail to compile; the error names the check and why it stayed. Index types in the signature andasserts at entry make it independent of its callers. An implementation must prove, inside acheckfreefunction, at least: a constant index into a fixed array in range, and one whose type's range fits the array; an index ofa's index type intoa; one ofa's index type intobwhereb.len >= a.lenwas asserted, with no growth ofaand no shrink ofbsince; aforindex into the array whose length is its count or range end;a[i]where a condition established0 <= i < a.lenand nothing changed either since; and a repeat of a check already passed on the same path. Anything else it proves is implementation-defined. checkfreeis a keyword only directly beforeinline,simd,recursive,fnorthread_fn, afterexport, and is an error withextern. A nested function inherits it. Under--no-bcenothing is verified, which the compiler warns about.
8. ADTs in use
8.1 match
match shape {
Circle c => c.r * c.r * 3.14159, // payload by value (a copy)
Rect r => r.w * r.h,
Point => 0.0,
}
match sexp {
Sym &s => use(&s.name), // payload by reference (&-binder)
List &l => walk(l.kids),
}
match n { 0 => "zero", 1..10 => "small", _ => "big" }
match t { GROUND => "ground", world::GATE => "gate", LO..HI => "mid", _ => "other" }
match d { 0, 6 => "weekend", 1..6 => "weekday", _ => "no day" }
match dir { North, South => "vertical", East, West => "horizontal" }- Over ADTs: exhaustive over variants;
_ =>wildcard allowed, never in a list; an arm names a variant and may bind its payload (c: Shape.Circleabove), or lists several (North, South =>) and binds none, since their payloads differ in type. No variant is named twice. - Over integers (any integer type): constant and half-open-range arms,
or a comma-separated list of them (
0, 6,1, 3..5, LAST) that matches when any one of them does;_required, and never in a list; every pattern value must fit the scrutinee's type. The first arm that matches is taken. A constant or a range bound is an integer literal or a named constant — aletorconstglobal whose initializer is a constant expression, taken at that value, as an array size may name (§11.1) — either one optionally negated:GROUND,world::GATE,::GATE,LO..HI,-LIMIT..0. The name resolves as any other does (§11.1): a local, type parameter or nested function of that name hides the global and is no constant, and neither is avarglobal. A bare name is a variant when the scrutinee is an ADT and a constant when it is an integer, where it binds nothing. - Every pattern must be able to select its arm:
_is the last arm, and a value or range that earlier arms match whole is an error (1..5 => a, 3 => b). Overlap that leaves a pattern something to match is fine:0 => "none", 0..10 => "few"takes 1 to 9 to its second arm. Within one arm, a pattern listed twice and literals that overlap are errors, but differently named constants may overlap, being possibly one value's aliases (EAGAIN, EWOULDBLOCK, which Linux gives one number). - Arm binders are explicit about copy vs reference, like the rest of
the language:
Circle c =>binds the payload by value — a copy, potentially a large one for variable-size payloads, and not written, as aforcopy is not (§6.5) — andCircle &c =>binds it by reference (thefor &xspelling;Circle& cis the same tokens). &-binders are legal only on variable-mode payloads (whether the scrutinee is a value or a reference): a fixed-mode value may be overwritten with another variant — inside the arm included — so a reference into its payload is exactly what §3.5 forbids, and matching a fixed-mode ADT (even through a reference) offers by-value binding only. So does matching a resizable ADT, which a whole assignment gives another variant the same way (§4.4). The variant can never be reassigned through a&-binder. A payload holding self-relative references binds by reference only (§3.9), which is why an ADT with such payloads is used in variable mode, and a resizable ADT holds them only in its resizable payloads (§3.5).- An arm list may hold
const forexpansions (§6.5) beside written arms; their arms are flattened first, and every rule above applies to the result. T?narrows viaif r { … }/guard/assert(r)(flow typing, §3.8).
8.2 Case functions (match as an overload set)
The cases of a match may instead be written as separate
functions, each taking one variant type in the same parameter
position:
fn area(c: Shape.Circle) -> f64 { c.r * c.r * 3.14159 }
fn area(r: Shape.Rect) -> f64 { r.w * r.h }
fn area(p: Shape.Point) -> f64 { 0.0 }
let a = area(s); // s: Shape — dispatches on the tag, like a match- Variant types (
Shape.Circle) are first-class types, usable directly and behind references (Shape.Circle&). - Calling the overload set with the ADT type (or a reference to it)
performs tag dispatch (jump table); exhaustiveness is checked exactly
like
match: every variant must have exactly one applicable overload; return types must agree. All arms construct any nonfixed result to the same destination (§4.3). The overloads' parameter types choose copy vs reference like match binders do (Shape.CirclevsShape.Circle&), with the same rules: a fixed-mode or resizable scrutinee — even behind a reference — dispatches to by-value variant parameters only (§3.5), and a payload holding self-relative references to by-reference ones (§3.9). - Dispatch is on one parameter position (v1 rule: multi-position dispatch is an error). Other parameters pass through unchanged.
- A dispatched call writes every argument: it evaluates them once, before the tag picks the case, so no case's defaults (§7.1) are passed, and a call that would dispatch only by leaving parameters to them is an error.
This is the language's "virtual function" idiom, without vtables or inheritance, and it keeps the closed-world exhaustiveness guarantee.
9. References, lifetimes, and safety
9.1 The one rule
A reference/slice must not outlive the variable that owns its target, and must never observe its target at a wrong type. Everything below is the static enforcement of this; there are no lifetime annotations anywhere in the surface language, and no aliasing/exclusivity restrictions at all (aliased references are fine — without shared-memory concurrency, aliasing alone cannot break type safety given the layout rules of §3/§5). This outlives-rule is the language's entire "borrow checker".
9.2 Roots and the depth check
Every reference/slice value has one or more static
roots: each a local or global variable, or a temporary
(below), that bounds the scope its target lives in; a value that may
point at several places has one root per place (merged values, below),
and every rule asks each of them. A root is exact when
that variable's own storage contains the target, and inexact when it
only bounds the target's lifetime — the owner is then that variable or
one further out. Every root a &lvalue creates is exact;
the reads out of containers of §9.5 are where inexact ones come from.
Compilation in call-graph order with per-instantiation specialization
means roots are always statically known — parameters' roots come from
each call site, and functions are specialized per distinct root
(Rust-lifetime precision via monomorphization, with zero syntax).
The rules below are the scope rules, and hold of exact and
inexact roots alike. Only rules that need the target's
identity rather than its lifetime consult exactness —
storing a reference into a relative-reference location (§3.9),
converting one to an index (index_of, §3.3), which array a
shrink through a reference frees (§5.1), where a store through a
reference lands (the store rule below), and the compiler's proof that
two references name different arrays — and each of those takes its
conservative answer without it. A relative reference that names a pool
(§3.9) is where an exact root also comes from: a load out of
one is rooted at that pool, exactly, whatever container it was read out
of.
Rules (scopes ordered by nesting; globals are the outermost scope, §11.1):
- Store:
rmay be stored into a location owned by rootLonly ifscope(root(r)) ⊇ scope(L)— the pointee provably outlives the container. Where the location is reached through a reference whose root is inexact, its owner is that root or anything further out that can hold what the reference points at, which the location lies in (for a slice, an element): every read-back candidate for that type at the root's scope or outside it (§9.5), the caller's storage behind a parameter included.rmust outlive each of them, and the store is on record for each: inlet n: Node& = if c { a } else { b }; n.p .= x;the field isa's orb's, soxmust outlive both, but not storage that could holdp's type and noNode, which the field is never in. A call checks what its callee stores through an argument with such a root, or into storage an argument's references lead to, in the same way, since the callee sees only a root for it. A value that holds references (a struct with a reference field, an array of slices, an ADT payload with one) stores under the same rule for what it holds: its root is that of the references stored into it, and each such store is on record for the shrink rules (§5.1). So do the elementsappendcopies: an array's as the whole array would store, and a slice's as each element read out of it would (§9.5). A declaration stores its value into the variable it declares, with a type annotation or without:let s = { let t: u8[] = "abc"; t[..] };is an error, sincetends with its block. - Branch values: whatever receives the value of an
if,match,block,loopor bare{ }— a call's argument as much as a variable — gets it after the scopes its branch opened have ended. So a reference or slice that value is, or holds, must not be rooted at a variable declared in them, a match arm's binder included, or at a temporary made there:f(if c { var t: i64[>..] = [1, 2]; t[..] } else { a[..] })is an error, as is the declaration above, and so isf(if c { var t: i64[>..] = [1, 2]; t } else { a })for a slice parameter, where the arrays join as a slice (§6.4). - Return: a returned reference's root must be visible to the caller (a caller-supplied root, a global, or the function's own in-place-constructed return value) — every return's, whatever the others give. A returned value that holds references is judged by the ones it holds, which for a local are those stored into it (§9.5): one whose reference fields were only ever null returns as freely as a value holding no references.
- Merged values: a value that may be any of several —
the branches of an
iformatch, thebreaks of ablockorloop, a reference variable's bindings, or a function'sreturns, which each call maps to its own arguments — has every one of their roots, and names one array exactly only where every one of them names that one root exactly (anullnames none); what a value holding references holds is rooted the same way. It must outlive a destination by every root, it is writable only where all of them are (§9.5), and it is stored only where each of them could be: not at all where one may point into a grow-shrink array (§5.2), and not inside a recursive cycle where one is rooted where the cycle stores nothing (§7.8). A shrink frees what any of them points into (§5.1). A parameter given such a value stands for all of its places, and so does a call's result where the argument behind a return may be one. So a function may return a view of its input on one path and of a global arena or a string literal on another, andlonger(x, y), for alongerreturning whichever of its two slice parameters is longer, may point into eitherxory, whichever it returns at run time, and into nothing else. - Struct types with reference fields are implicitly generic over those
fields' roots; struct instances with different root bindings are
distinct types for checking purposes (same layout). One whose references
all point into one variable is bound to that root exactly; one whose
references point into several has each of their roots, so a function
given both kinds is checked for each. A
recursive fn's back edges reuse its body whatever they pass (§7.8), so its by-value parameters are always taken to be of the second kind. - References into a value being copied by value do not transfer to the copy; they keep referring to the source (plain value semantics).
- A reference variable commits to its first binding's root:
.=may rebind it only within the same root, or to one at the same scope depth (the common case: retargeting to another element of the same or a sibling container in a loop). Anything else needs a new variable. What a callee reads out of storage it was given is rooted at that argument (§9.5), so a key a dictionary'seachhands a function value is rooted at the dictionary passed, at its depth, whatever text the keys view. A slice variable commits the same way, and a store through a reference to its slot (§3.8) assigns it ass = vdoes, aletincluded (§4.4): through one that may name it -- read out of storage, merged, or a parameter given one of those -- as well as through one that does. A rebind to a different root leaves the variable inexact, since it no longer names one array, and a merged value (above), since it may still hold its earlier binding. A loop body is checked in the state its earlier iterations leave it in, a rebind in a nested function or a function value it calls included: a read earlier in the body than such a rebind sees both roots, so it may not need the root exactly (a relative link's identity, §3.9), and a variable declared before a loop and bound only inside it (var last: Node? = null;beforeloop { if last { last.next .= child; } … last .= child; }) has, at a use earlier in the body than the rebind, the root the rebind gives it, exactly where the root's storage is its own. A variable the loop never binds is null throughout it, and a use of it is that null (§9.5). A globalvarmay be rebound by any function, one compiled after a function using it as well as before, so a use of it in a function's body has every root it can be given, the read-back rule's answer for a global (§9.5), rather than the ones bound so far. - Inside recursive cycles the stricter §7.8 cycle store rule applies.
- A temporary — an array, struct or variant literal,
a call's result,
copy(x),default<T>(), or the value of anif,match,block,loopor bare{ }whose branches do not join as a slice (§6.4), which is a copy of what the branch taken produced even where that names a variable — viewed where it stands rather than built into a destination (by a slice destination, afor,[..],bytes_ofor a path into it, §4.2) — lasts for the rest of its statement, or of the block whose final expression made it. Its scope is that statement's: the variables the statement declares outlive it, and those of the scopes the statement opens — the body of aforover it, the arms of amatchon it — do not. So a reference or slice into it may be passed down, and stored or bound only in those inner scopes:let s = f()[..];is an error, whilelet t = f(); let s = t[..];is not, and neither is a view intoxbound insidefor x in f() { … }; it is never returned. A function it is passed to may keep it in its own locals, which die first. What a temporary holds is not rooted at the temporary (§9.5). So at a slice parametertake(if c { str("a", n) } else { str("b") })is an error, as each branch's string ends with its branch, whiletake((if c { str("a", n) } else { str("b") })[..])views theif's own value, an array that lasts for the rest of the statement. A temporary is no storage for.=or a reference parameter to bind (§4.1), which binds a construct's branches instead, and a view of one is read-only (§9.5).
Violations are compile errors. There is no escape hatch in v1.
9.3 What the runtime still checks
Aborts (message + exit; not catchable). In a contained worker (§11.2), each but the last two ends only that worker:
- array/slice indexing out of bounds (elided wherever the compiler proves it cannot fire, §10.5; can be disabled wholesale in a designated unsafe-fast build), and a conversion into an index or bound type outside its array (§3.11), elided the same way;
- a reusable pool's
free(i)with an index outside[0, pool.len); - a slice pool's
alloc_slice/realloc_slicewith a negative length, or one no data stack could hold (§5.4); - a slice pool's
free_slice/realloc_slicewith a non-empty slice that is not one of its runs, where the checker could not tell (§5.4); - limited-array capacity overflow;
- shrinking below empty (
popon an empty array,pop_nof a negative count or of more elements than the array holds,resizeto a negative length); - relative-reference offset overflow at store (only where a root array, or a named pool, can span more than the width holds, §3.9);
- debug only: integer overflow (per operation as it executes, §6.2),
asconversions that change the value (§6.3; conversions to a float excepted); - division by zero (always);
assertfailures, and the program's ownabort(msg)(msganyu8array or slice; printed asgoose runtime error: <msg>);- guard-page hits (stack budget exceeded) — safe abort, never corruption;
- native call stack exhaustion (recursion deeper than a thread's stack holds, §7.8) — the same safe abort, with a diagnostic of its own.
exit(code) ends the program normally with the given
process exit code. Both abort and exit never
return, which the checker knows: code after them is unreachable, so a
branch ending in one gives no value (§6.4).
9.4 Type-safe reuse and stale references
With reusable arrays and limited arrays, a stale
reference can read a different value of the correct type
(type-safe reuse). This can cause a logic error, but not memory
corruption, type confusion, or out-of-bounds access. Slot reuse allows
these errors while avoiding a general-purpose allocator.
External bytes must be validated before they become a Goose value.
from_bytes (§12) checks an image's framing, tags, lengths,
and self-relative links. Each link must point to an element start in the
same image. Invalid input returns false and an empty array,
so it cannot introduce an unchecked reference. This verifies
safety, not data integrity: edited bytes may still describe a
valid structure. Detecting changes to the original data requires a
separate check, such as a checksum.
9.5 Writability
Everything is writable unless its type says otherwise. Any type may
be qualified const (§2): a const i64[3] or
const Point is a value whose contents cannot be written
into (its elements, its fields, and whatever a reference to it reaches),
and const T& / const T[:] are a reference
and a slice through which the pointee, or the elements, cannot be
written: no assignment, compound assignment, ++, or
growing/shrinking operation through it compiles. Assigning a
const value as a whole is the binding's business
(let, §4.4), and a copy of one is a fresh, writable value:
a cursor var s: const u8[:] = text; advances with
s = s[1..] and never writes a byte of text.
const is shallow: a reference or slice read out of a field
of a const Box& is as writable as the field's own type
says, and so is a path through it (b.r.n = 7,
b.s[0] = 7). The exception is a self-relative reference
(§3.9), which points within the value or array holding it, and so is no
more writable than the path that reaches it. Every value of reference or
slice type is either read-only or writable, and where that comes from is
inferred, per instantiation, exactly like roots, with no
annotation needed:
- Read-only: a string literal (
const u8[:], §3.7);&xandx[..]of aconstvalue, of a field of one, or of a by-valuefor/matchbinding (§6.5), and such anxbound by reference (§4.1) — these areconst T&andconst T[:]; a view into a temporary (§9.2), which a write would change and nothing else; abytes_ofview (§12); whatever is read out of a slot declaredconst; and a self-relative reference read along a read-only path. A slice loaded through a reference to a slice is the one in the slot the reference names, writable only where both are, so a reference to a slice variable holding a read-only slice is read-only too, however it is made (&s, orsbound by reference, §4.1). Aletbinding of a reference or slice names the reference: it does not rebind, and writes through it follow the value's own constness (let r .= xs[i]; r = 0;writes an element of avararray, §3.8). - Writable: everything else —
&xandx[..]of avaror of a plainlet, and whatever else is read out of a slot that is notconst. - Parameters and results are generic over constness.
A parameter declared
u8[:]orT&takes a read-only or a writable argument, and the specialization is checked with the argument's constness (a write through a parameter given a literal is an error at that instantiation); one declaredconst u8[:]is read-only whatever it is given, which is what a function that only reads its input documents. A declared result type likewise names the shape, and the constness of a call's result is that of what the function returns, read-only where any return is (au8[:]result ofreturn sfor aconst u8[:]parameter is read-only at that call);-> const u8[:]makes it read-only always. Addingconstis implicit everywhere (u8[:]fitsconst u8[:]); dropping it is never. - Slots say what they hold. A field, an element, a
global, an annotated variable and an assignment target are
slots, and a read-only reference or slice is stored in a slot
only if the slot's type is
const:struct Named { name: const u8[:] }holds a literal,struct Buf { bytes: u8[:] }does not, andviews: const u8[:][>..]takes either kind of view. So what is read out of a slot is exactly as writable as the slot's type says (a self-relative reference at most, above), and nothing is laundered through storage: au8[:]field that could be written through can only ever have been given a writable slice. An un-annotated variable's type is its initializer's, madeconstwhere that value is read-only — at this instantiation, say — sovar t = trim(line);for a read-onlylineis aconst u8[:]cursor, whicht = trim(t[..n]);can advance, and stays read-only whatever is assigned to it later, as if it were written so. - An
extern fn(§7.10) is a C function and its parameters are what they say: a read-only argument needs the parameter declaredconst.
Read-back roots. A container names a scope, not the
storage its contents point into, so the root of a reference or slice of
pointee type T read out of a container C is
re-derived. A candidate is a variable whose own storage
can hold a T by value — an array of T in any
array kind, a struct or ADT payload with a T field, a
T itself, and so on through by-value nesting; a variable
that merely holds references to T is not one, and
a reference or slice field ends the search. Static data is a candidate
for the element types a literal can supply; for a writable reference or
slice only when nothing else is, since a writable slot is never given a
literal (above) and so holds static data only as a null or an empty
slice. Out of a field, an element or a global, a variable holding a
grow-shrink array whose elements can hold a T is no
candidate either: a reference rooted at it is never stored (§5.2),
whatever else of it could hold the T. A reference to the
whole array, or to what holds it, is stored like any other: its pointee
is no element, and the variable stays its candidate. Then, by where
C's own root lies:
- A global. Only globals outlive globals (§11.1), so the owner is a global candidate whatever local scope is open. One candidate: that variable, exact. Otherwise the global scope, inexact.
- A local of the function being checked, at any block
depth, by-value parameters included. Everything stored into
Cwas reachable from this frame and had to outliveC, so the owner is a local declared at or outsideC's block, a reference parameter's pointee, a global, static data, or storage of the caller's that a parameter leads to through references: the ones a by-value parameter holds, which its argument filled, and the ones in what a reference parameter points at. This function cannot enumerate that storage, and it all outlives the parameter's root — for a by-value parameter the root of what its argument held (§9.2's generic roots of reference fields) — so that root is its candidate. Every candidate is a root of the value: a local or a global exactly, such a parameter's root as a bound on the storage behind it. The value names one array only when there is exactly one candidate in all, and it is not such a parameter's root. The candidates are the variables in scope at the read: an array that comes into scope after it cannot be the owner and is no root of it. WhereCis a local holding values rather than a reference or slice, everything stored into it since it was made is on record -- through a reference to it, by a function it was passed to, or later in a loop around the read -- and where each of those was rooted at one variable exactly, at static data, or at the storage a parameter's argument holds, the value's roots are theirs instead, but for a variable whose storage cannot hold the value's pointee type, which owns none of what was stored there of that type: ani64&read out of a holder that also views au8buffer is not rooted at the buffer. A word ofwords(buf)for a localbufpoints intobuf, exactly, whatever else in scope could hold au8, and a by-value parameter holds what its argument did, so a slice ofg.edgesfor ag: Graphparameter is bounded by that argument's root, not by the callee's other parameters, which can hold its element type but none of the caller's views. A copy of a field or an element of such a local -- aletof one, aforormatchbinder's copy, a popped element, a value loaded whole through a reference to it -- holds no more than was stored into the local, which is then on record for the copy too: out oflet hs = [H { r: a }],hs[0].rpoints intoa, exactly. A copy out of any other container has only its container's scope on record, and takes the candidates. A local of a function that a nested function's or a function value's body is written in is read here as one of the body's own, its stores on record alike: the body is checked for the state each call finds it in (§7.5, §7.6). And aforloop reads its binder so in each of its checks, so that what the loop body stores into the holder it walks reaches the elements later iterations read. - A reference parameter's pointee, or itself inexact.
The owner may be caller storage this function cannot enumerate: the root
is
C's, inexact. A non-recursive function can retain the caller's more precise record when its argument names a local holder exactly, all the holder's recorded contents point into one root exactly, and only parameters can reach that holder during the call: neither the function nor a function value it runs can name it directly. Reads then have that contents root, including any roots stored through possibly aliased parameters while the body runs. A nested function or callback's stores keep the distinction that the resulting references came from that holder's contents; call sites judge them by the actual stores into its storage, including aliased stores. Stores feed back around loops, and calls are specialized for the contents they receive; a call after a rebind is checked for the new contents. The same rule applies when a parameter forwards such a holder to another function. It does not separate fields whose contents have different roots: those retain the conservative bound. For example, aCache&naming a localCache { bytes: buffer }keepsbufferas the root ofcache.bytes, so an unrelated reusable byte pool cannot be that buffer. Shrinking it still checks every live reference and stored view intobuffer, including those in the caller. - A temporary (§9.2). Everything in it came from the literal's initializers, from the call whose result it is, or from the value it is a copy of, so the root is theirs: the innermost root among the initializers, the one the result's contents are rooted at, or the one the copied value's are, exact when that is one variable exactly.
The globals a candidate may be are all of the program's. A function that a global initializer calls is compiled before the globals declared after that initializer, but the compilation serves the calls made once they exist too, so in it the global scope stands for those as well, inexact.
A global var of reference or slice type is a slot any
function may bind (§9.2), and the compilation of a function using it
serves every call, those after a function compiled later has bound it
included. So a use of one in a function's body is a read out of a global
(case 1): each global whose storage can hold its pointee, exact where
there is one, but never a grow-shrink array's elements, where no binding
of a global points (§5.2). One naming a pool (§3.9) points into that
pool, and a global let keeps its binding. A global
initializer runs once, when the bindings made so far are all there are,
and a use there has their roots.
An optional variable bound only to null so far
(let none: Node? = null;, or
var best: Node? = null; before anything binds it) holds
null. Where every binding that can come before a use of it has been
checked, the use is a null, which has no root (§3.9). For a local that
is wherever it is used: in the body of the function declaring it, a
loop's later bindings included (§9.2), and in a nested function's or a
function value's body, which is checked for how a call finds the
variable (§7.5), so that a call after a binding has a check of its own,
and whose own bindings reach a loop or a recursion around the call as
the declaring body's do. For a global it is in the global initializers,
and anywhere for a global let, which only its initializer
binds. A global var used in a function's body is read as
above.
A relative reference T&<w> read out of
C points within C's own root array by
construction (§3.9), so it takes C's root and
C's exactness whichever case applies. One that names a pool
needs no candidates at all: it points into that pool, so it is rooted
there and exact, and this is how a container of links stays usable where
a container of plain references would be ambiguous.
A diagnostic that turns on an inexact read-back names the container
it came out of and the candidates it could not choose between
("n was read out of slots and may point into
pool or spare", "g is a global
var, which any function may bind, and may point into pool
or spare"), or, where the candidates are the caller's to
know, the parameter whose pointee bounds it.
Writability checks depend on the callee's operations. A function that
mutates its slice argument can compile for a writable argument and fail
for a read-only one. The compiler reports the full compile-time call
chain, including the source of the read-only data. const is
required on slots that must accept read-only references or slices;
parameters and results may also use it to state that they are always
read-only.
10. Compilation model
10.1 Whole program, call-graph order
Goose is a closed-world, whole-program compiler. Functions are typechecked and specialized in call-graph order (callers before callees), so argument types, reference roots, writability, destination stacks, and static function values are always concrete. Recursion is the annotated exception (§7.8).
10.2 Monomorphization
Each function is compiled per unique (argument types, reference roots, writability provenances, destination/target stacks and request kinds, static fn values), a nested function's free variables counting as arguments (§7.5). Roots and stacks are compile-time constants inside each specialization — pushes through references compile to direct bumps of a known global — except where the compiler chooses a hidden stack argument to share one body across contexts (§4.3, §7.4).
10.3 Stack assignment (deterministic recipe)
Traverse specializations DFS from main (and global
initializers). Each specialization carries a compile-time stack
environment: the watermark of stack indices in use on entry, and a
map from each of its nonfixed locals / by-value params / in-flight
returns to an index. Assign each new simultaneously-live resizable (and
each data-stack-resident variable value, which shares by nesting per
§1.3(4)) the lowest index not in use at that point; indices free again
when the owning scope exits (sequential reuse), so a function called
after a local's block has ended may use that local's index again (which
is what lets a recursive cycle's functions own scratch, §7.8). A pending
nonfixed return's destination stack is part of the in-use set from the
call site until the value is received — which is also what makes
return from safe (§7.9). N = the maximum index + 1 reached
anywhere. Threads run the same algorithm per thread program (§11.2).
Implementations may replace per-context specialization with hidden runtime stack arguments where profitable; semantics are identical.
Fixed locals normally go to the native stack / registers. The C
backend places fixed locals and temporaries larger than 4096 bytes on
data stacks, using the same scope watermarks and hidden stack indices as
nonfixed values. This threshold is a backend policy, not a language
limit or a change to a type's size class, packed layout, value semantics
or reference lifetime. It prevents large arrays and records, including
temporaries introduced by inlining, from exhausting a platform's much
smaller native call stack. Inside a function of a recursive cycle (§7.8)
such values stay on the native stack instead: a data stack held across a
call into the cycle would cost a stack per activation, which §7.8 rules
out for every value, while the recursion's depth is bounded by the
native stack already, and a fixed value belongs with fixed values. A
large fixed parameter copy, local or temporary of a cycle function is
therefore a native value, which deep recursion over large values avoids
by taking them by reference; rejecting such parameters and locals in
cycle functions instead would have made the backend's threshold a
language rule. Variable-class locals may be placed on the native stack
(alloca) instead of a data stack when the compiler chooses;
the current backend places them on data stacks. Resizable frame objects
(C.2) still keep their fixed prefix and tail header in the native
frame.
10.4 Runtime environment
At startup, reserve N address regions (target: multiple GB each;
commit-on- touch via guard pages — prototyped at
github.com/aardappel/stackalloc), plus guard gaps between regions so
runaway growth aborts cleanly. An implementation budgets the address
space it spends on regions over every thread program at once (32 TB by
default here), which is what bounds hardware_threads()
(§11.2); a region the platform refuses may be reserved smaller, which
nothing the implementation elides depends on. Platforms without
address-space reservation (wasm today) fall back to index-based
references + bounds-checked growth, with reduced performance.
The 48-bit size limit. A data stack reserves at most 2^48 bytes. Consequently, in every conforming implementation:
- no value's byte size exceeds 2^48;
- no array's element count exceeds 2^48 (stated separately because an
element type may be zero-size, and because most array types are already
far tighter —
T[]'s length field defaults tou32, §3.3); - therefore every length, capacity, index, and byte offset lies in
[0, 2^48], and.lenand.capare non-negative by construction.
This limit allows up to 256 TB in one value while leaving headroom for arithmetic the compiler can reason about. The guard region after each reservation already turns an attempt to exceed it into a safe abort (§9.3), so no growth operation needs a separate check for this limit. A fixed array's size is a constant checked at compile time. Implementations may impose a smaller limit (wasm32 is inherently capped at 2^32); they may not raise it, so a program's meaning never depends on the target having more.
Making the limit part of the specification provides three guarantees:
- Size arithmetic cannot overflow. With 15 bits of
headroom below
i64,len - 1,i + 1fori < len,len + len,len * 2, andi * element sizeare all in range. The optimizer may assume this rather than prove it, and the bounds-check analysis (§10.5) relies on it directly. - Signed is the right default for sizes.
.lenreturnsi64(§3.1) and the top bit is provably unused, so the sign bit costs nothing real, while subtraction and difference math stay natural. Signed sizes avoid unsigned wraparound in ordinary differences. - Non-negativity is a type-level fact, not an inferred one, which is what lets §6.1 admit the one mixed-signedness comparison that matters without any analysis being involved.
Compilation target: C/C++ first, LLVM later. Representation and calling convention: Appendix C.
10.5 Bounds-check elimination
The index and slice checks of §9.3 are the one abort the compiler
routinely proves unnecessary, so the analysis that removes them is part
of the compilation model rather than an implementation detail. It runs
over each specialization after optimization, and it is required only to
be sound: every check it removes is one that could not have
aborted, and what it fails to prove stays in the program. Nothing about
a program's meaning depends on how much it proves, except whether a
checkfree function compiles (§7.14), for which the
specification fixes what every implementation must prove.
What the language gives it, beyond ordinary flow facts (loop headers,
conditions and their negations, assert, match arms):
Index types. A value of
uN in ais belowa.lenat any later point, sinceanever shrinks (§3.11), so an index ofaintoaneeds no check, and one into another arraybneeds onlya.len <= b.len: a relation between two lengths, which the analysis states and keeps as it does any other, rather than a fact about every element of an array, which it cannot.Types. An array is of one type, and a reference reaches only an array of its own type, so a growth or shrink through a reference, or a callee's write through an argument that is an array of scalars, leaves every array of another type as it was.
Monotonicity in the type. A grow-only
[>..]shrinks only at apop,pop_n,resizeorclearon the array itself (§5.1), which the analysis sees, so a bound established before apushstill holds after it. This is a guarantee a resizable-array type without the grow-only/grow-shrink split cannot offer.Roots. Every reference's root is static per specialization (§9.2), so a call cannot invalidate a length the callee has no path to — storage is reachable only through a global, a capture, or an explicit
&(§3.8).Static extents.
T[k]'s length and[..k]'s capacity are constants.Total operations.
%is Euclidean (§6.2), so a reduction is in range by construction; a completedpopproves the array was non-empty, and a completedpop_n(n)that0 <= n <= len, because the other cases abort.
Known gaps are TODO 0f. Whole-program compilation lets the analysis
reach across calls: each specialization sees concrete argument roots, a
caller's facts reach an inlined callee body directly, and a call that is
not inlined carries them too. Every call site records what it proves
about the arguments it passes — constant bounds on an array's length,
and how the passed lengths and integers relate — and a specialization
enters with the meet of those facts once all of its sites have been
analyzed (callers are analyzed first; a site inside a recursive cycle
leaves its callee with nothing). A thread's entry point and an
export fn, which C may call with any arguments, enter with
nothing. In the other direction a callee is summarized rather than
assumed hostile: the storage it may resize or overwrite, through its
reference parameters, in globals, or in captured outer locals, is
computed as a fixpoint over the call graph, and a call kills only what
those effects can name. A kernel
fn blur(src: u8[>..]&, dst: u8[>..]&) that
only reads and writes elements thus sees src.len == W * W
when every caller established it, and leaves the caller's own facts
about src intact.
11. Program structure
11.1 Modules and globals
- One program, compiled whole.
import a.b.c;includes the filea/b/c.gooseonce, resolved relative to the root of the importing file's package: for the program's own files the directory of the root file being compiled, for a library's modules the library's directory; then, whereanames a package the root file declared (below), as that package's moduleb/c.goose(import a;is its entrya.goose); then in the standard library's directory (--stdlib <dir>, elseGOOSE_STDLIB, else thestdlib/of the source tree the compiler was built in). A file of the importing package with the name of a declared package is an error. The formimport .a.b;(leading dot) resolves relative to the importing file instead. - Packages (
docs/design/packages.md).import name from "source";in the root file declares the packagename, a directory holding its entryname.goose, and imports that entry; the source is a path relative to the root file ("./lib","../lib") or a git repository at a ref ("owner/repo@v1"on GitHub,"host/owner/repo@commit", or a URL), fetched by the compiler. Only the root file declares packages, so a program's dependencies are flat and in one place. The same form in any other file is a requirement: the root must declare a package of that name, from the same source, or compilation fails naming the line to add; a different ref is a warning. One version of a package exists in a program. All declarations are public in v1; a name collision within one namespace is an error, except that a global variable and functions may share a name (below). Top-level declarations are order-independent. - Namespaces (docs/design/namespaces.md).
namespace image;, at most once and before a file's declarations, places them in namespaceimage; a file without it declares into the global namespace, and several files may declare the same namespace. An import loads a file; it neither creates a namespace nor brings a namespace's names into scope. From elsewhere a declaration isimage::Pixel,image::brightness(p),image::Shape.Circle { r: 1.0 },image::pool;::nameselects a global declaration (or builtin) that a namespaced one shadows. A declaration may spell its namespace itself (fn image::brightness(…),fn ::main()inside a namespaced file); the qualifier decides the declaration's namespace entirely, names inside it included. An unqualified name resolves lexically first, to what the scopes around its use declare: to a variable or a type parameter, the innermost of the name — a function's parameters and locals, then its type parameters, then what is around its declaration (§7.5) —, else to the nested functions in scope (the innermost block's set, §7.5). Only a name no scope declares resolves in the current declaration's namespace, then in the global namespace and the builtins: a type parameter or a nested function hides a global and a function of its name as a local does. A namespace keeps its global variables apart from its functions, and no variable can be called (function values are no data, §7.6), so the name a call is made by resolves past the global variables to the functions, in each namespace of this order: a globalvar last: i32in any module leaves std'slast(xs)callable from every module, its own included, while any other use oflast(last = 7, an argument) is the variable. A variable in scope still hides the functions of its name: a localcountmakescount(xs)an error. A type parameter bound to a type is no value (infn f<N>(x: N),Nas an expression is an error, and::Nnames a globalN), and no type parameter or nested function is a constant a size or a match pattern may name (§3.3, §8.1). A function name's overload set is that of the first namespace in this order that declares the name at all; sets never merge across namespaces (a namespacedhashoverload reaches the global integer ones as::hash(x)). UFCS follows the same rule for the calling code's namespace, a generic body resolves names where it is defined, and theformathook is the one type-directed exception (§3.7). Namespaces affect only name resolution, type identity and generated C names (§7.10): no runtime representation, no privacy, no re-exports. - Globals are declared like locals (
let/var, any type including resizable). Semantically the whole program runs inside an implicit outermost scope owning them: they participate in the depth check (§9.2) as the outermost roots, initialize beforemainin declaration order within a file and imported-file-first across files (so a global initializer may name one from a module it imports; their initializers may call functions), and a global resizable simply owns a stack's bottom for the program's life (a natural whole-program arena). A global has no value until its initializer has run, and nothing else can give it one: naming it earlier, to read or to write it, in an earlier global's initializer, in its own or in a function one of those calls, is an error (§4.4). A compile-time size or match pattern may still name a later named constant (below). That outermost scope is a frame like any other: a global's storage belongs to the program instance running it and is never shared with another (§11.2). Every global has an initializer, except thatvar g: T;leaves it to its type:gstarts asT's default value (§4.2), taken where an initializer would run, sovar log: i64[>..];isvar log: i64[>..] = [];, and aTwithout one (a reference) is an error. Aletorconstglobal must be given its value, since it can never take another.constglobals (letones of aconsttype; avarof one is assigned as a whole, §4.4) of flat fixed type with compile-time-evaluable initializers live in static data; the initializer of anyletorconstglobal is a named constant a compile-time size may use (i64[N]) wherever no local of its name hides it (§3.3); an untyped integer or float one also adapts wherever it is used, as a literal does (§3.1, §6.3). A size, a fill count ([v; N]), a match pattern (§8.1) or a use adapting an untyped one to another type takes a named constant at its initializer's value, and a size takes a.lenan initializer gives and the type does not fix (§3.3) at that value's length, so aletit names, directly or through another named constant's initializer, may not also be bound to a writable reference anywhere, through which its value could change (§4.4): by&,.=, or a reference parameter, field or binding that is notconst. As for a comparison relying on aletbeing non-negative (§6.1), either order is an error; declare itconst, or bind the reference to a copy. - Entry point:
fn main() { }, in the global namespace. Only the root file'smainis the entry; afn mainin an imported file is ignored entirely (not an entry, not callable, no collision). A runnable file can thus double as an importable library: give itfn main_x() { ... }plus afn main() { main_x(); }wrapper, and importers callmain_xdirectly. A namespaced file does the same withfn ::main() { ns::main_x(); }.
11.2 Concurrency
No shared mutable memory, ever. The model is a statically typed cousin of the Linda tuple-space / coordination style.
- A worker entry point is declared
thread_fn worker(a: i64, ...) { }. The compiler compiles athread_fnand everything it calls as a separate program with its own stack assignment (its own N′). Because many instances of a thread program can run at once, a thread program's stack references are inherently dynamic: each thread carries a pointer to its own block of N′ stacks, and stack accesses in thread-program code are indexed off it (one hidden register-resident base). A function is shared between thread programs / the main program only when all its stack references arrive via (hidden) arguments; any static stack use re-specializes it per program, exactly like a template instantiation. - Worker count is decided at runtime (no static
maximum):
thread_spawn(worker, args…) -> i64reserves a fresh stack block, copies the args, starts the worker, and returns its id.workernames athread_fn, directly or as a function parameter bound to one (§7.6), sofn spawn<W>(n: i32) -> i64 { thread_spawn(W, n) }spawns whichever worker its caller passes. IDs increase monotonically and are never reused.hardware_threads() -> i64exists for sizing, and reports no more workers than the implementation's address space for data stacks (§10.4) holds beside the main program, so a pool sized by it never exhausts it.thread_wait(id) -> boolblocks until that worker has ended and all of its Goose storage has been released — enabling both scoped fork/join parallelism and orderly shutdown (send quit messages, then wait). Repeated waits on a completed id return immediately; multiple workers may wait for the same id. Every wait returnsfalsefor a contained worker that aborted, andtrueotherwise.thread_wait(id, msg) -> boolwaits the same way and appends the abort's message tomsg, a growableu8array: what the abort would print aftergoose runtime error:, its location included. The first such wait for the worker takes the message; later waits, and a worker that returned, append nothing. A program that wants the message asks for it once and keeps it. A negative or never-issued id, or a worker waiting for itself, aborts with a runtime diagnostic. thread_spawn_contained(worker, args…) -> i64starts a contained worker, asthread_spawndoes otherwise. An abort in it (§9.3) ends only that worker, which releases what a return would, and leaves its message forthread_waitinstead of printing it. The program and other workers run on. Resources it opened throughextern fnstay open. Guard-page hits, native stack exhaustion,exit(code), and the runtime's own failures still end the program, as does any abort on main's thread. A worker it starts withthread_spawnis not contained.- A worker's return releases its copied arguments, stack block, every
reserved and committed data-stack region, and active-worker record,
whether or not anyone waits for it. The runtime retains no
per-completed-worker record or native handle, except the id of each
contained worker that aborted, so that waits can answer, and its message
until a wait takes it. Native threads are detached; their final native
stack/TLS teardown occurs when the runtime entry wrapper returns,
immediately after publishing Goose cleanup completion. A child worker
owns its storage independently and may outlive the worker that spawned
it. Queued messages belong to their queues and remain available to
receivers after the sender exits. Workers still running when
mainreturns are killed. - A thread program is an instance of the program with globals of its
own: at
thread_spawnevery global the worker's program uses is copied from the spawning instance, exactly as the arguments are, and the worker reads and writes its copies from then on. Nothing is shared: a worker that increments a global increments its own, and main's is what it was. A global whose type is not flat (§1.1) cannot be copied and is an error in any function athread_fnreaches, unless it is aletorconstglobal whose references all lead to static data, as a string literal's do (let NAMES = ["a", "b"];): its copy views the same read-only bytes, which no program can tell from a copy of its own.constglobals with compile-time initializers are static data (§1.2), read-only and therefore shared as they are. - Args and queue elements must be flat types (§1.1);
values are copied in and out, which is cheap because Goose values are
contiguous. A
thread_fn's parameters have no defaults (§7.1):thread_spawnpasses every argument. - Typed queues: conceptually one queue per flat
element type;
qput(v) -> bool,qget<T>() -> T(blocking),qpoll<T>() -> T, bool(non-blocking; bool = got one; on a miss the value is T's zero value — zeroed scalars, empty arrays, variant 0),qget<T>(ms: i64) -> T, bool(asqpoll, after waiting at mostmsmilliseconds; zero or less does not wait) andqlimit<T>(n: i64)select the queue by type. Queues live outside stack memory (runtime-internal allocation permitted here). - Bounded queues:
qputnever blocks. It returnstrueonce the value is queued, andfalseat once, queueing nothing, when the queue holds its limit of messages. A call statement drops the result (§7.1), soqput(v);ignores a refusal. A queue has no limit untilqlimit<T>(n)gives it one ofnmessages; lowering a limit below what the queue holds keeps the messages queued, and puts fail until fewer thannremain. A limit of zero or less aborts with a runtime diagnostic.
11.3 Dynamic stacks (future)
A resizable-of-resizables local, each element owning its own dynamically created stack, accessed via fat references. Noted for the future; not in v1. (The thread mechanism of §11.2 already introduces the dynamic-stack-block machinery this needs.)
12. Builtins, the standard library, and what v1 leaves out
The language's own functions:
| Builtin | Meaning |
|---|---|
print(a, b, …) |
the arguments' text and a newline to standard output (§3.7) |
str(a, b, …) -> u8[>..] |
the arguments' text as a fresh string, built at its destination (§3.7) |
format(out, a, b, …) |
the arguments' text appended to out, any growable
u8 array (§3.7) |
assert(c) |
aborts unless c, a bool or an optional,
which it narrows (§9.3, §3.8) |
abort(msg: u8[:]), exit(code: i64) |
end the program; both diverge (§9.3) |
copy(x) -> T |
a fresh copy of the stored value x names (§4.1) |
default<T>() -> T |
the value a fixed-size T has before anything is written
to it (§4.2) |
hardware_threads() -> i64,
thread_spawn(worker, args…) -> i64,
thread_spawn_contained(worker, args…) -> i64,
thread_wait(id) -> bool,
thread_wait(id, msg) -> bool |
workers (§11.2) |
qput(v) -> bool,
qget<T>() -> T,
qget<T>(ms) -> T, bool,
qpoll<T>() -> T, bool |
the typed queues (§11.2) |
qlimit<T>(n) |
a queue's limit of messages (§11.2) |
to_bytes(a) -> u8[>..],
to_bytes(a, out) |
an array's image — a varint byte count then its element region —
fresh, or appended to a growable u8 array (§3.9,
design/serialization.md) |
bytes_of(a) -> u8[:] |
the element region alone, as a view: no copy, and never writable |
from_bytes<T[>..]>(b: u8[:]) -> T[>..], bool |
a verified array from an untrusted image; empty and
false if it is not one. Also builds
T[>..<] and the T[] family |
embed_shader(path) or
embed_shader(stage, source, …) -> const u8[:] |
compile-time GLSL compilation to a static shader blob; graphics extension, detailed in the implementation notes, section 3.14 |
embed_file(path) -> const u8[:] |
the bytes of a file, read when the program is compiled, as a view of
static data; path is a string literal (or a
let/const global holding one), relative to the
file with the call; each distinct file is read once |
And the array members, ordinary functions of their receiver per UFCS
(a.push(v) is push(a, v)):
| Member | On | Meaning |
|---|---|---|
.len -> i64 |
every array kind and slices | the element count (§3.3) |
.cap -> i64 |
limited arrays | the capacity (§3.3) |
.push(v) -> T& |
limited, grow-only, grow-shrink | one element, constructed in place (§3.3) |
.append(src) |
limited, grow-only, grow-shrink | an array or slice of elements (§3.3) |
.pop() -> T, .pop_n(n),
.resize(n, v?), .clear() |
limited, grow-shrink; grow-only where §5.1 allows a shrink | shrinking, and growing with a fill value (§3.3, §5) |
.index_of(r) -> i64 |
fixed, limited, grow-only, grow-shrink | the index of the element r refers to (§3.3) |
.alloc_index(v) -> i64,
.alloc_ref(v) -> T&, .free(i) |
reusable pools |
slot reuse (§5.4) |
.alloc_slice(n) -> T[:],
.realloc_slice(s, n) -> T[:],
.free_slice(s) |
reusable[] pools |
slice reuse (§5.4) |
For serialization, the byte-count prefix must account for exactly the
remaining input. Validation establishes complete elements, representable
lengths, in-range tags, booleans encoded as 0 or 1, canonical varints,
and valid self-relative targets before exposing a value. A link must
name an outer element or the correctly tagged payload of one;
interior-field targets are currently unsupported. Failure returns an
empty array and false, not a partially verified value.
Success owns a copy of the payload. The implementation notes, section
6.9, give the byte contract and current resource limits; section 11
identifies violations still to fix.
Everything else is the standard library: Goose source under
stdlib/, reached by import (§11.1) and
documented in stdlib.md (the design it came from is
design/stdlib_design.md). The math types of §6.1 are its
vec module; the C behind math and
os enters through extern fn (§7.10).
Deliberately out of scope for v1: error-value conventions (§7.9);
move operations for resizables; multiple resizables per struct; two-way
growth arrays; inline compaction / copying GC for pools; mixed-type
pools; alignment annotations and explicit vector types
(simd fn, §7.12, only widens the instruction set a function
is built for); dynamic stacks; labeled break; namespace privacy,
re-exports and nesting (§11.1).
Appendix A. Worked examples (informative)
A.1 A list of strings, flat
fn read_words(text: u8[:]) -> u8[][>..] { // grow-only array of variable strings
var words: u8[][>..] = [];
var i = 0;
while i < text.len {
let start = i;
while i < text.len && text[i] != ' ' { i++; }
words.push(text[start..i]); // copies the slice into place, flat
while i < text.len && text[i] == ' ' { i++; }
}
return words; // NRVO: built on the caller's stack
}One contiguous block: len | (len | bytes)*. Iteration is
sequential; no allocations happened anywhere.
A.2 A compact tree with relative references
enum Sexp {
Sym { name: u8[varint] },
List { kids: (Sexp..&<varint>)[varint] }, // tiny self-relative links
}
var pool: Sexp..[>..] = []; // grow-only pool of variable ADTsChildren are built first (pushed earlier into pool),
parents store tiny backward offsets: (a (b c d)) ≈ 21 bytes
total, serializable by memcpy.
A.3 A growable hash table (open addressing), in-model
struct Map { slots: Slot[>..] } // Slot fixed-size; grow-onlyGrowth: slots.append(…) doubles in place (a bump, no
move), then rehashes in place by cycle-walking; or rebuild into a fresh
local and copy back via whole-resizable assignment (§4.4). Deletions use
tombstones or the reusable mechanism. No allocator, no
reference invalidation during lookup.
A.4 Case functions as virtuals
enum Node { Num { v: f64 }, Add { l: Node..&<u16>, r: Node..&<u16> } }
recursive fn eval(n: Node.Num&) -> f64 { n.v }
recursive fn eval(n: Node.Add&) -> f64 { eval(n.l) + eval(n.r) }A.5 Long-distance errors
fn parse_expr(l: Lexer&) -> Expr.. {
guard l.tokens_remain() else { return Expr.Nothing {}, "eof" from parse; }
...
}
// All deep failures land here; the error is the last return value (§7.9).
fn parse(src: u8[:]) -> Expr.., u8[] { ...; return parse_expr(&l), ""; }A.6 A context of borrowed tables
struct Ctx { // a slice and three references: fixed-size
source: const u8[:],
nodes: Node[>..]&,
text: u8[>..]&,
scratch: i64[>..<]&, // the recursion's working stack
}
recursive fn walk(c: Ctx&, n: i64, depth: i64) -> i64 {
let mark = c.scratch.len;
c.scratch.push(depth); // above its callers' entries
...
c.scratch.resize(mark);
return total;
}
fn compile(source: const u8[:]) -> i64 {
var nodes: Node[>..] = []; // one local per table
var text: u8[>..] = [];
var scratch: i64[>..<] = [];
...
var c = Ctx { source: source, nodes: nodes, text: text, scratch: scratch };
return walk(c, 0, 1);
}A struct owns at most one resizable (§3.4), and a function in a
recursive cycle none that stays in scope across a call back into the
cycle (§7.8), so a program whose state is several growable tables owns
each as a local of a driver function and passes the rest of the program
one struct of references to them. The tables are rooted outside the
cycle, which grows and shrinks them through c like any pool
handed to it (§7.4, §7.8), and since every call back into the cycle
passes c on, the views read through it — a node's name
copied into text, say — may be stored into the other tables
inside the cycle too. They last for one call of the driver: each
compilation starts from fresh tables, and no state is global.
Appendix B. TODO / open items
Collected from the design discussion; each needs future resolution work. The newest, highest-priority items first; items since resolved are kept at the end, each with where its resolution lives.
0a. Mixed-signedness ergonomics in practice — §6.1's
unification deliberately rejects u32 + i32, and
u64 against anything signed outside the comparison rule now
in §6.1. Watch whether real code (hash kernels, size math against
.len's i64) still accumulates casts. Note this
is a u64-only problem:
u8/u16/u32 all widen implicitly
into i64 (§6.3), so the same code on a u32
hash composes already. What remains is the analysis half rather
than the language half: the bounds-check pass (§10.5) follows a
u64 value's range through %,
& and casts, but drops it the moment the value is bound
to a u64 local, because in general a u64 need
not fit i64. A let has one value, so it could
carry its initializer's range the way §6.1's non-negativity already does
— the narrow, checkable version of "track that this u64
fits i64". Until then, the open-addressed map in
bench/goose/words.goose keeps the four checks around
slots[idx]. 0f. Bounds-check analysis, known
gaps (§10.5) — a loop's exit condition is a disjunction
(!(i < n && p)), which a difference-constraint
domain cannot represent, so post-loop bounds rest on the inferred
invariants instead; LLVM meets the same wall and works around it by
duplicating blocks so the conditions never merge, which is available
here too. A value loaded from an array has no known range, so indirect
indexing (dist[out[k]],
bench/goose/graph_csr.goose) keeps its check unless what is
stored has an index type (§3.11). Release-mode wrapping (§6.2) is
deliberate and stays, so the analysis proves absence of wrap explicitly
where it needs to. 0c. Pointee writes through optionals
— a narrowed optional writes through fine, but there is no way to write
through an optional without narrowing; and rebinding to a plain
reference first (let r: T& = o;) is the only escape
hatch. Possibly fine; revisit with usage. 0d. Lifetime
precision, remaining cases — two checker conservatisms still
exceed the spec: long-distance returns (§7.9) written in a named
function only carry references to globals/static data (precise rule,
which a function value's return follows: rooted at or above the target's
frame), and the recursive-cycle store rule (§7.8) admits only globals,
free variables, pool parameters and parameters every call back into the
cycle passes on as roots of stored references — a reference to a
caller's fixed-size local that such a call replaces is still
pass-down-only inside a cycle, and so is a merged value that may be one
rooted at a parameter the cycle passes on.
(One-root-per-reference-variable is a language rule, §9.2; a function's
result is rooted as a branch's value is, at the innermost of its
returns' roots. Writability follows const types; storage no
longer removes read-only restrictions, §9.5.) 2. Error
propagation sugar — return from is the mechanism;
revisit whether a convention/sugar layer (a try-alike) is
wanted once idioms emerge. 3. Move operation for
resizables — assign-and-leave-source-empty, as the one
sanctioned "move". 5. Cycle store rule refinement
(§7.8) — the pass-down-only rule is conservative. Parameters the cycle
passes on as it was given are exempt; one a recursive call gives other
storage stays pass-down-only even where every activation's store would
outlive what it is given, and so does one whose first argument is a
local of a recursive function outside the cycle. Explore per-activation
reasoning. 6. "Current pool" implicit destinations —
allocation without naming the array, bound at compile time to the
in-scope array of the right type. 7. Mixed-type pools & pool
GC — internal vs external references, inline compaction,
copying; interacts with 6. 8. Multiple resizables per
struct (memmove-on-insert, opt-in) and two-way
growth arrays. 9. Concurrency surface
finalization — queue fairness/capacity, select, worker-local
init, flat-type relaxation for self-contained relative-reference values
(send whole trees through queues). 10. SIMD/alignment —
measure whether packed layouts cost real SIMD performance; consider
opt-in aligned types if so. Related: narrow-lane elementwise ops (§6.1)
should vectorize now that arithmetic runs at the element width; verify
with the particle/sum benchmarks. simd fn (§7.12) gives a
function versions for the wider instruction sets; what remains open is
alignment, and whether a function should be able to say which versions
it wants (an AVX-512 version is not faster for every loop). 13.
Labels for break — if early-out patterns
demand them. 15. Wasm fallback — index-based reference
representation details (docs/design/wasm.md compares the
options). 16. Relative-reference region tracking —
copies of values containing self-relative references are
currently rejected outright (§3.9; the in pool form already
copies); track the region an offset ranges over so whole-region copies
(and serialization moves) can be proven safe.
Resolved
0e. Per-array index types — DONE, see §3.11 and
§7.14 (docs/design/index_types.md): u32 in a
is an index validated once against a grow-only global a,
which then never shrinks, so indexing a with it, and
another array once their lengths are related, needs no check;
checkfree fn makes a check left in a function an error. 0b.
Reference address identity — DONE, see §3.8:
r1 .== r2 / r1 .!= r2 compare references by
address; == stays the pointee comparison.
0h. Passing by size class — DONE, see §4.1 (and
§7.2, §3.8, §6.5): fixed values connect to destinations by value;
non-fixed lvalues bind by reference at reference-typed, untyped and
un-annotated destinations and are an error at value-typed ones, which
take an rvalue or an explicit copy(x); a redundant
& is a warning. 0i. Resizable tails get their
own frame header — DONE, see C.2: a resizable-tailed struct
with an all-fixed prefix is a frame object, so &s.tail
is an ordinary reference; variable-size prefixes and resizable-class
ADTs keep the bytes-on-stack shape. 0j. Slices of grow-shrink
arrays — DONE, see §5.2 (superseding TODO 4): a slice or
reference into a [>..<] is created like any other and
held by variables only; a shrink is an error while one is live, naming
it and where it was bound; specializations record what they shrink so
that calls are checked the same way. 0g. Recursive results'
roots at back edges — DONE (§7.8, cycle return roots): a cycle
is checked in rounds, so a back edge's result carries the merge of the
roots the cycle's returns give, mapped through that back edge's own
arguments, and points nowhere yet in the first round, whose checks the
next round repeats with it. A result holding references is checked by
the roots of what it holds, the same way. What remains: a back edge that
passes storage of its own for a parameter the entry call gave static
data gets a holder result that may only be passed down.
- varint format benchmark — DONE: ULEB128 adopted (§3.6). Break-even vs the best branchless format sits at ~70–75% single-byte values (a cliff, not a slope); above it ULEB wins ~3x, below it loses up to ~3x. Revisit only if a per-field format choice is ever wanted for unpredictable-length data.
[>..<]interior-reference relaxation — DONE, see §5.2: the liveness test of §5.1, plus per-specialization shrink summaries for the shrinks it cannot see directly (through a reference, or of a global).- FFI — DONE, see §§7.10–7.11:
extern fnbinds a Goose signature to a C function, andexport fnexposes a Goose function through a C-callable wrapper. The flat fixed-size types by value, references and slices to them, andu8[>..]&builders cross where supported; other shapes are rejected at the declaration. - Open syntax details — DONE: both trailing-block
parameter forms exist (the implicit
itandx =>, §7.6), andrecursive fnis the keyword alone on a cycle's entry function (§7.8). - Stdlib math types — DONE, see
stdlib.md(vec):vec2/3/4<T>withfloat3and friends as aliases; the named ops are overloads per size, and elementwise arithmetic is the language's (§6.1). - Serialization of relative-reference structures —
DONE, see §12 and
docs/design/serialization.md:to_bytes(a)copies an array's element region into a freshu8[>..],from_bytes<T[>..]>(bytes)verifies an image and returns the array plus a bool. The verifier is generated per element type next to the size and equality walkers, and a rejected image yields an empty array, never an unproven reference (§9.4). An image is little-endian by definition and carries a varint byte count in front, so a reader of a stream can size its read;bytes_of(a)is the payload as a view, for a save that copies nothing. What v1 does not admit: references into a field of an element,from_bytesinto a fixed or limited array, and a big-endian host (which aborts rather than writing bytes only it can read).
Appendix C. Representation and calling convention (normative for the C backend)
C.1 Data stacks
Each data stack S has a control block
{ top: byte* } — a global for the main program; threads use
an array of control blocks reached from a hidden per-thread base pointer
(§11.2). Allocation is p = S.top; S.top += size. Scope exit
restores the watermark recorded at scope entry. No other runtime state
exists.
C.2 Value layouts
- Scalars: natural little-endian storage of their declared width;
boolis 1 byte, 0 or 1. All loads/stores may be unaligned. - Fixed structs/arrays: packed concatenation in declaration order. A
zero-length array (
T[0], orT[..0]after its length field) has one element slot holding no element, since C has no empty arrays (§3.4). A struct with no bytes of its own (no fields, and nopad n) takes one, since C has no empty structs; a variant type with none takes that byte only as a value of its own, and none behind its ADT's tag (§3.4). T[]family: length field (of the declared storage type) then elements.T[..k]: length field (smallest unsigned type fittingk), thenkelement slots (uninitialized until first written).T[..]: capacity then length (bothu32default), then capacity slots.- ADT tags are zero-based variant indices in declaration order, stored
in
u8for up to 256 variants, otherwiseu16in the current C backend. Fixed mode: tag, then payload area of max size (trailing padding uninitialized, never read). ADT variable mode: tag, then the actual variant's payload. - varint: §3.6 (ULEB128; zigzag where signed).
- Reference: one pointer, except the fat form for resizables below.
Optional: 0 = null. Self-relative: signed offset of the declared width
from the offset field's own address; 0 = null only in the optional form
(§3.9). Pool-relative (
in pool, §3.9): unsigned offset of the declared width,(target − base(pool)) + 1, so 0 stays null and auNwidth spans 2^N − 1 bytes of the pool.base(pool)is the global's element region, fixed when its stack is reserved before any initializer runs; a function that loads or stores such a reference reads it once into a local at entry. - Slice:
{ data: T*, len: int64 }(len = element count).
Resizable values. A resizable's element region
always tops its data stack (§1.3(2)). A resizable array is represented
as a header in the owning frame —
{ base: byte*, len: int64 } — plus the element region on
the data stack. &v yields the header's address (a fat
reference is that pointer plus the stack identity); with the stack
identity known statically (or carried as a fat reference / hidden
argument), push compiles to
*(T*)S.top = e; S.top += size; h.len++.
A resizable-tailed struct whose other fields are fixed-size (and hold
no relative reference) is a frame object: a C struct of
those fields followed by the tail's own header (or, for a tail that is
itself such a struct, its frame object), held in the owning frame
exactly as a fixed value would be, with only the innermost tail's
elements on the data stack. &s is the object's address
plus the stack identity, &s.tail the tail header's, so
a tail can be referenced, sliced and grown through a reference like any
standalone resizable; s.f through either is a plain member
access. Copying, passing by value and returning such a value move the
frame object where an array moves its header (C.3) and the elements
where an array moves its elements. Nesting composes: a struct whose tail
is a frame object embeds it.
The two shapes that are not frame objects keep the bytes-on-stack
form behind a base pointer: a struct with a variable-size
prefix (a u8[] field before the tail) and a resizable-class
ADT, whose payload shape is per variant. Their header's
base is the value's start on the data stack and
len the tail's count; a reference to such a nested tail is
a compile error — reference the owning variable. A tail of such a shape
whose type is a frame object takes the same form: its fixed fields are
bytes of the value, where a bare pad inserts nothing
(§3.2), and its own tail's count is that header's len, so
its tail cannot be referenced either.
C.3 Calling convention
- Fixed-size parameters and returns up to 4096 bytes: native C values (structs as packed C structs). The first such return uses the C return value; additional fixed returns use out-pointers. A slice, a reference to a resizable value and a reusable pool's reference parameter are passed as their members, each a C parameter of its own. A fixed aggregate result containing relative references instead uses a typed out-pointer at every size, and constructs directly through it. Ordinary C-return selection skips these results. A nonlocal return uses a saved pointer to the same receiver, rather than a copied result value.
- Larger fixed-size parameters: the caller makes a private value copy
on a scoped data stack and passes its typed pointer. The callee can
mutate that copy without affecting the argument's source. Larger fixed
returns use typed out-pointers to caller-owned storage; any return
temporaries also live on data stacks. Captures and
return fromkeep their ordinary lifetime and value rules. extern fnretains its declared native C ABI (§7.10), including large packed structs passed and returned by value. C ABI copies can still use the native stack; pass a reference or slice when crossing C with a large value to avoid those copies.- Nonfixed by-value parameters: caller allocates/constructs on the parameter's assigned stack (§4.3), passes the header (base, len — or metadata appropriate to the type) by value; callee owns and may grow it if it is resizable (its stack identity is part of the specialization or a hidden argument).
- Nonfixed return values: the destination stack is part of the
callee's specialization or a hidden argument, as is the destination
request's kind (one value vs element run, §7.3); the callee writes data
at the destination's top and returns the outermost metadata
(lengths/counts) in registers — in the element-run form it emits raw
elements only. Because in-flight return data carries no inline header
above the caller's live data,
v.append(f())is contiguous by construction. - Hidden arguments, in order after declared ones: free-variable
references (§7.5), destination/target stack identities. The
return fromdiscriminant is not in the signature: it is a single thread-local int (0 = normal, k = propagate to target k) that is zero except between areturn … fromand the catch in its target frame, so an ordinary return writes nothing and a call on a propagation path costs a load and a never-taken branch.
C.4 Guard pages and commit order
Construction rules (§4.2) write stacks front-to-back. When an allocation skips ahead without writing (a large limited-array capacity), the runtime commits the skipped range explicitly (VirtualAlloc/mprotect) instead of relying on touch order — Windows-style guard-page auto-commit covers the ordinary sequential-write case; Linux uses reserve + explicit commit. Fresh pages arrive zeroed from the OS; reused stack memory does not — harmless, since uninitialized slots are unreachable (§5.3). A final unmapped gap after each region turns runaway growth into a safe abort.
Appendix D. Grammar sketch (informative)
program := namespace? topdecl* // imports may precede namespace
namespace := "namespace" ident ";"
topdecl := import | struct | enum | typealias | fndecl | globaldecl
import := "import" "."? ident ("." ident)* ";" | "import" ident "from" strlit ";"
declname := ident | ident "::" ident | "::" ident // the namespace, else the file's
qname := ident | ident "::" ident | "::" ident // a declaration reference
struct := "struct" declname generics? "{" fieldlist "}"
enum := "enum" declname generics? "{" variant ("," variant)* ","? "}"
variant := ident ( "{" fieldlist "}" )?
fieldlist := field ("," field)* ","?
field := ("let" | "const")? ident ":" type ("=" expr)? | "pad" intlit?
typealias := "type" declname "=" type ";"
generics := "<" ident (":" type)? ("," ident (":" type)?)* ">"
fndecl := "checkfree"? ("export" strlit? | "extern" strlit?)? "checkfree"? "simd"?
"inline"? "recursive"? ("fn" | "thread_fn")
declname generics? "(" params? ")" ("->" rettypes)? (blockexpr | ";")
// checkfree at most once (§7.14)
// nested: ident only
params := param ("," param)* ","?
param := "var"? ident (":" type ("=" expr)?)?
// untyped => generic; defaults
// trailing, not in a block (§7.1)
rettypes := type ("," type)* // no parens in declarations
globaldecl := ("reusable" ("[" "]")?)? ("let" | "var" | "const") declname (":" type)? ("=" expr)? ";"
// no initializer: var with a type only
type := "const"? (prim | qname tyargs? | "(" type ")") postfix*
| "const"? indextype // no postfix (§3.11)
// const: the first & or [:] (§9.5)
indextype := ("u8"|"u16"|"u32"|"u64") ("in" | "upto") qname
// parenthesized as a for binder's type
prim := "bool"|"varint"|"i8"|…|"u64"|"f32"|"f64"
| "fn" ( "(" types? ")" ("->" (type | "(" types ")"))? )?
tyargs := "<" type ("," type)* ","? ">"
uint := "u8"|"u16"|"u32"|"u64"|"varint"
postfix := "[" expr "]" // fixed array (const expr)
| "[" uint? "]" // variable array
| "[" ".." expr? "]" // limited (const expr)
| "[" ">" ".." "<"? "]" // resizable
| "[" ":" "]" // slice
| "&" ("<" uint ("in" qname)? ">")? // reference / relative
| "?" // optional (any type)
| ".." // variable-mode ADT
| "." ident // variant type
stmt := decl | assign | incdec | guardstmt | exprstmt
decl := ("reusable" ("[" "]")?)? ("let" | "var" | "const") identlist (":" type)?
(("=" | ".=") exprlist)? ";" // .= binds by reference (§3.8)
assign := lvalue assignop expr ";" // = .= += -= *= /= %= &= |= ^= <<= >>=
incdec := lvalue ("++" | "--") ";"
exprstmt := expr ";"
guardstmt := "guard" expr ("else" blockexpr | ";") // guards the rest of its block (§6.4)
expr := control | binary
control := ifexpr | matchexpr | blockexpr | loops | jumps
ifexpr := "if" expr blockexpr ("else" (ifexpr | blockexpr))?
loops := ("while" expr | "for" forbind "in" iter | "loop") blockexpr
forbind := "&"? ident (":" type)? ("," ident (":" type)?)?
iter := expr | expr ".." expr
jumps := "return" exprlist? ("from" qname)? | "break" expr? | "continue"
matchexpr := "match" expr "{" arm ("," arm)* ","? "}"
arm := pattern "=>" expr
pattern := ident "&"? ident | item ("," item)* | "_" // a variant and its binder, ...
item := bound (".." bound)?
bound := "-"? (intlit | qname) // qname: a variant or a constant (§8.1)
blockexpr := "{" stmt* expr? "}"
binary := (precedence climbing; tightest → loosest)
postfixe := primary ( "(" args ")" trailingblock?
| "[" sliceargs "]" | "." ident | "as" "!"? type )*
unary := ("-" | "!" | "~" | "&") unary
levels := * / % → + - → << >> → & → ^ → |
→ < <= > >= → == != .== .!= → && → ||
primary := literal | qname | "null" | "self" | "(" expr ")"
| arraylit | structlit | genericcall
genericcall := qname tyargs "(" args ")" trailingblock?
trailingblock := "{" (params "=>")? stmt* expr? "}"
sliceargs := expr | bound? ".." bound? // bound := "^"? expr
args := (expr ("," expr)* ","?)?
arraylit := "[" (exprlist ","? | expr ";" expr)? "]"
structlit := (qname tyargs? | varianttype) "{" (fieldinits | fieldinits? "..")? "}"
fieldinits := fieldinit ("," fieldinit)* ","? // before "..": the "," required
fieldinit := (ident ":")? expr
Known parse notes: structlit vs
trailingblock vs blockexpr ambiguity is
resolved as in Rust (no struct literals or trailing blocks directly in
if/while/for/match/guard
scrutinee position); a statement that is a block-ended construct needs
no ; and ends at its } (§2);
f< commits to a type argument list only when followed by
(, {, or .ident { (§7.7),
otherwise < is comparison; T&<u8>
tokenizes as & < in type context only;
a..b ranges exist only in for headers, slice
brackets, and match arms; multiple return types in a function
type (not a declaration header) require parens around the list;
T&? parses to the same type as T?, and
? on an already-optional type is an error;
from is not reserved — return E from f is
recognized by the identifier that follows it, so from
remains usable as a name.
Appendix E. Implementation notes (informative)
What the current compiler does where the text above leaves it a
choice. The compiler's own description, pass by pass and analysis by
analysis, is implementation.md.
- Two C backends. The generated C is normally written
to a file for a C compiler to build (
-o out.c). Where the compiler was built with TinyCC (thethird_party/tinyccsubmodule), giving no output file compiles that same C inside the compiler's own process instead and calls itsmain, so a program runs with no C toolchain present and no file written. The program shares the process: its exit status becomes the compiler's, and the compiler's own progress lines move to stderr to leave stdout to the program. It runs on the compiler's main thread, whose stack the compiler reserves at 64 MB on Windows and macOS, where an executable's main thread gets 1 MB and 8 MB by default; on Linux it is the stack limit the process started with (ulimit -s), as for an executable. The program's own C is the same for both, and-Dis written into that C rather than passed to a backend so that stays true. What differs is the runtime around it: the in-process build compiles all of it with the program, where the file holds only what has to inline into the program and declares the rest, whichgoose --emit-runtimewrites as a C file of its own, built once and linked with every program (--standalonewrites a file that holds it all, as the in-process build does). TinyCC does not optimize and cannot place thread-local storage in an in-memory run, so a program using workers (§11.2) is refused there (docs/design/jit_backend.md). - Fat references. A callee that grows a resizable
through a reference (
pushthrough af64[>..]&) must know which data stack to bump. The compiler does not pin that stack per call site (§10.2): every reference to a resizable-class value carries the stack identity beside the header address (C.2), and a pool reference (§5.4) carries the freelist as well. There is no surface syntax; the form is per instantiation, and a variable keeps the one its first binding gives it (§5.4). - Stack assignment is the hidden-argument strategy
§10.3 permits: every function that uses data stacks takes its base index
as a hidden argument and addresses its nonfixed locals at constant
offsets from it, callees start above its in-use watermark, and every
stack the program can reach (a compile-time count, §7.8) is reserved as
its thread program starts. The globals of a program instance, with the
dedicated stacks of the resizable ones, form one struct reached through
a single thread-local pointer (a plain one in a program without
workers): main's is a static instance, a worker's is allocated at its
start and filled from the spawn image, which carries the used globals
behind the arguments. The only C statics are string literals and
constglobals with compile-time initializers. Every generated function is shared between thread programs and the main program. - Element-run results (§7.3) exist for variable
array results (
T[]): an element-run receiver compiles the callee a second time in run form (raw elements plus a count out-parameter), sov.append(f())is contiguous with no copy, and a named result pays the specified single callee-side copy. Callees with no run form (builtins, tag dispatch) fall back to the value form, and the receiver slides the length prefix out with one memmove. A runtime-capacity limited result is built on the destination stack, then compacted there to discard its header and unused slots. Non-array variable results have only the value form. - Queue images (§11.2) are one contiguous byte image
per value; a resizable's is its count, its fixed fields and its tail's
elements, built on a scratch stack at
qputand unpacked at the receiver's destination. - C locals at function scope. Every aggregate-typed C local (a struct, array, slice or header) is declared at the top of its C function rather than in the block that uses it, and a literal temporary with unused limited-array slots is zero-initialized. Both work around MSVC 19.44–19.51, which at any optimization level miscompiles a read through a copy of a struct holding the address of a block-scoped local (a slice of a fixed array declared in a loop body, passed to an inlined callee), and exploits the indeterminate bytes of a copied partially-written struct. Neither changes what the program means.
- The store record. Every store of a reference, slice, or value holding one into a variable, field, element or global is logged with the stored value's root, exactness, and source container, program-wide; a specialization also summarizes the stores into its parameters' pointees, replayed onto the caller's containers at each call. A grow-only shrink (§5.1) asks this record whether any live value holds a reference into the array, following source links (a copy holds what its source holds; a global source is judged by its type), and a shrink inside a loop is re-asked at the loop's end for stores the rest of the body made.
- Liveness at a shrink. The checker keeps the
statement index of every open block; a holder is live when its name
occurs in a later statement of an open block at or inside its scope, in
that block's tail, or anywhere in an enclosing loop it outlives. Nested
functions in scope are followed by name from the calls in that code.
What a
forwalks in place, and the references and slices on the path to it that the loop loads again, are live throughout its body (§6.5). - Literal parameters (§7.7). Which parameters are
literals is part of the specialization key, their values are not. A read
of one is a value with no constant folding but the literal's
adaptability; every adaptation of the bare parameter is recorded on the
specialization with its line, a literal parameter passed on as a literal
records a link to the callee's parameter, and each call site's literal
is verified against the closure of those records after the whole program
is checked. The parameter is passed at its nominal type
(
int64_t,double) and each use casts to the type it adapted to, which the verification proved exact.
Goose