Goose

Goose, by example

Rust-focused companion to the existing Goose tutorial. Original chapter order and all 59 Goose examples are preserved. Markdown · Example pairs (JSON) · Pinned original. Rust representations that differ in layout or language behavior are labeled.

This tutorial introduces Goose through examples. It assumes experience with Rust and focuses on Goose's differences. Start here, then go on to the language reference, which covers the whole language topic by topic. Consult the specification for exact rules, and see samples/ for complete programs.

Much of the syntax is familiar: braces, let, fn, struct, and match. The main difference is how arrays store and manage data. We start with where values live, since that explains many of the language's other choices.

Adapted from Goose, by example, Apache-2.0. Original Goose examples are unchanged.


1. The core idea

Here is an array of strings.

Goose
var words: u8[][>..] = [];
for i in 5 { words.push(str("word", i)); }
Rust counterpart
let mut words: Vec<Vec<u8>> = Vec::new();
for i in 0..5 { words.push(format!("word{i}").into_bytes()); }
Example 1

Difference: Same values, different storage: each inner Vec owns a separate byte buffer; this is not Goose's packed inline array.

The shown Rust counterpart is a byte-oriented Vec<Vec<u8>>; a Vec<String> is the UTF-8 text version. Both use a buffer for the vector, a separate buffer per element, pointer/length/capacity handles, and per-element cleanup at scope exit.

In Goose it is one contiguous block of bytes: a length, then five back-to-back (length, characters) runs. The block was created by bumping a pointer, and it disappears when the enclosing scope ends and that pointer moves back. No heap allocator or per-string deallocation is needed, and the block contains no pointers.

words:  05 00 00 00 | 05 w o r d 0 | 05 w o r d 1 | 05 w o r d 2 | ...

The memory model has four main properties:

This model has limitations. They are listed in section 18, together with the rules that impose them and how to work with each. It also has measurable benefits: over sixteen benchmarks Goose runs at about 3.3x the speed of idiomatic C++, roughly level with hand-tuned C++ and with the best safe Rust, while using 1.9x less memory than the C++ and 1.2x less than the Rust (bench/summary.md). Most of the gains come from differences in data layout and memory management.


2. Running something

Goose compiles the whole program to C and builds that with whatever C compiler is around, into an executable beside the source:

goose build hello.goose && ./hello

goose -o hello.c hello.goose writes the C for you to build yourself, and goose build --dry-run hello.goose prints the commands. command_line.md describes the rest of goose build and the other commands, and packages.md how a program uses libraries from other repositories.

The bundled TinyCC backend needs no separate C compiler. With no verb, or with goose run, the program is compiled into the compiler's own process and run there, which is what you want while you are reading this:

goose hello.goose

Here is a minimal program:

Goose
fn main() {
    print("hello, goose");
}
Rust counterpart
fn main() {
    println!("hello, goose");
}
Example 2

print takes any number of arguments of any type, renders each as text and writes them all as a single line. It uses a single write, so the output of two threads never interleaves. There is no format string, unlike Rust's println!.

Goose
print("answer=", 42, " ratio=", 0.75, " yes=", 3 > 2);
Rust counterpart
println!("answer={} ratio={} yes={}", 42, 0.75, 3 > 2);
Example 3

Every value can be printed, not only scalars: arrays print as [1, 2, 3], structs as Point { 1, 2 }, and a u8 array as its bytes. The samples print a lot because printing is a convenient way to inspect values.


3. Familiar syntax

let creates a binding that cannot be reassigned; var allows reassignment. Neither makes the contents read-only: use const for that. Types follow the name and can usually be inferred. For Rust readers, var roughly corresponds to let mut, but Goose's let does not freeze writable contents:

Goose
let answer = 42;
var count: i32 = 0;
let ratio = 0.75;
count += 1;
count++;              // a statement, not an expression
Rust counterpart
let answer = 42_i64;
let mut count: i32 = 0;
let ratio = 0.75_f64;
count += 1;
count += 1; // Rust has no ++ statement.
Example 4

Difference: Rust let mut permits rebinding and mutation; Goose let can still allow mutation of contents.

A struct literal either names its fields or lists them in order, and it may leave out fields that declare a default. A literal that ends in .. may leave out any field: a field without a declared default then gets its type's default (zero, an empty array or string, or null). Globals are declared like locals, and a var global given only a type starts out with these same defaults:

Goose
struct Unit { kind: i32, hp: i32 = 100, x: f32, y: f32, name: u8[] }

let scout = Unit { kind: 2, x: 1.5, .. };   // hp 100, y 0, name ""
var selected: Unit;                         // kind 0, hp 100, x 0, y 0, name ""
var log: u8[][>..];                         // empty
Rust counterpart
struct Unit { kind: i32, hp: i32, x: f32, y: f32, name: Vec<u8> }
impl Default for Unit {
    fn default() -> Self {
        Self { kind: 0, hp: 100, x: 0.0, y: 0.0, name: Vec::new() }
    }
}
let scout = Unit { kind: 2, x: 1.5, ..Unit::default() };
let mut selected = Unit::default();
let mut log: Vec<Vec<u8>> = Vec::new();
Example 5

Difference: Rust needs an explicit Default implementation for hp=100. These are local bindings; mutable globals need a separate Rust design. Vec fields are indirect, not packed inline.

Functions are free functions. There are no methods and no impl blocks, but x.f(a) calls f(x, a), so you can write method-call syntax wherever it reads better:

Goose
fn total(xs: i64[:]) -> i64 {
    var t = 0;
    for x in xs { t += x; }
    return t;
}
Rust counterpart
fn total(xs: &[i64]) -> i64 {
    let mut t = 0;
    for &x in xs { t += x; }
    t
}
Example 6

if, match, block and { } are expressions: a block's value is its trailing expression. So there is no ternary operator, and no need to declare a variable and then assign it in every branch:

Goose
let kind  = if hp > 50 { "healthy" } else { "hurt" };
let level = match hp { 0 => "dead", 1..50 => "low", _ => "fine" };
Rust counterpart
let kind = if hp > 50 { "healthy" } else { "hurt" };
let level = match hp { 0 => "dead", 1..=49 => "low", _ => "fine" };
Example 7

guard states a condition that the rest of its block depends on. guard c; runs the rest of the block only where c holds, and guard c else { … } runs the else block instead. To leave more than the current block, put a return, break or continue in that else block. block gives break a target without a loop, so a chain of checks can bail out with a value:

Goose
let verdict = block {
    guard p.alive else { break "gone"; }
    guard p.hp > 0 else { break "dead"; }
    "standing"
};
Rust counterpart
let verdict = 'verdict: {
    if !p.alive { break 'verdict "gone"; }
    if p.hp <= 0 { break 'verdict "dead"; }
    "standing"
};
Example 8

defer runs a statement or a block when the enclosing block ends, whichever way it ends: the end of the block, break, continue, return, or a return … from passing through. The cleanup stays next to what it cleans up, and runs on the ways out that the code after it does not mention:

Goose
guard gfx::open("demo", 640, 480, 0) else { return; }
defer gfx::close();                 // every way out of this block from here on
Rust counterpart
struct OnDrop<F: FnOnce()>(Option<F>);
impl<F: FnOnce()> Drop for OnDrop<F> {
    fn drop(&mut self) { if let Some(close) = self.0.take() { close(); } }
}
fn demo(open: impl FnOnce(&str, i32, i32, i32) -> bool, close: impl FnOnce()) {
    if !open("demo", 640, 480, 0) { return; }
    let _cleanup = OnDrop(Some(close));
    // Window work goes here; normal scope exits run the guard.
}
Example 9

Difference: Rust uses a Drop guard for scoped cleanup. Supply real graphics callbacks; the harness checks cleanup control flow, not a graphics backend. Process abort does not run Drop.

A defer in a loop body runs at the end of each iteration, and an exit runs only the defers it has run past, latest first. The body cannot jump out, and it cannot use long-distance returns, since it may run while one is in flight.

Loops are for x in range_or_array, while, and loop:

Goose
for i in 3 { }              // 0, 1, 2
for i in 10..13 { }         // 10, 11, 12
for i: i32 in 0..n { }      // i is an i32; n must widen to one
for i: u8 in 0..256 { }     // every u8
for x in xs { }             // copies, for fixed-size elements
for &x in xs { x *= 2; }    // references: the mutation form
for x, i in xs { }          // with the index
Rust counterpart
for i in 0..3 { }
for i in 10..13 { }
for i in 0_i32..i32::from(n) { }
for i in 0_u8..=u8::MAX { } // all 256 u8 values
for &x in &xs { }          // copy fixed-size elements
for x in &mut xs { *x *= 2; }
for (i, &x) in xs.iter().enumerate() { }
Example 10

Difference: Rust makes the widening explicit and enumerate yields (index, element). Mutation needs a mutable borrow.

Several return values are a calling convention, not a tuple type (Goose has no tuple type). By custom, a trailing bool says whether the other values mean anything:

Goose
fn divide(a: i64, b: i64) -> i64, bool {
    if b == 0 { return 0, false; }
    return a / b, true;
}

let q, ok = divide(7, 2);
Rust counterpart
fn divide(a: i64, b: i64) -> (i64, bool) {
    if b == 0 { return (0, false); }
    (a / b, true)
}
let (q, ok) = divide(7, 2);
Example 11

Difference: Rust returns a tuple; Goose's multiple results are not a tuple type.

To assign the results to variables, fields or elements that already exist, use =: q, ok = divide(9, 3);.

Only return and a call's results carry several values, so a branch cannot end in a, b. return if c { 1, 2 } else { 3, 4 }; is an error; instead, each branch returns on its own, as in divide.

Trailing parameters can have defaults, which a call may leave out:

Goose
fn limit(x: i64, lo: i64 = 0, hi: i64 = 100) -> i64 {
    if x < lo { lo } else if x > hi { hi } else { x }
}

print(limit(250), " ", limit(-5, -10));   // 100 -5
Rust counterpart
fn limit(x: i64, lo: i64, hi: i64) -> i64 {
    if x < lo { lo } else if x > hi { hi } else { x }
}
println!("{} {}", limit(250, 0, 100), limit(-5, -10, 100));
Example 12

Difference: Rust has no default function arguments; the two calls pass the omitted defaults explicitly.

A default is evaluated at each call that leaves it out. It can refer to the same things a top-level declaration can (globals, constants and functions), but never to the caller's variables, even ones with matching names.

Three arithmetic rules need particular attention:

Arithmetic happens at the operands' own width. u8 + u8 is an 8-bit add. A narrower type widens into a wider one implicitly; nothing narrows without as (range-checked in debug) or as! (never checked). u32 + i32 is a compile error that asks which one you meant; the language does not silently promote both to 64 bits. The declared widths determine the operation. Rust requires explicit numeric conversions here; Rust's as does not perform Goose's debug cast checks.

Integers become floats implicitly, so n * x with x: f32 is an f32 multiply; a float becomes an integer only through as. A float literal has no width of its own, and neither does an expression made of float literals and integers: n * 0.5 is computed in f32 when it is passed to an f32 parameter, and is an f64 in let h = n * 0.5;. An if or match whose branches are all literals adapts to its context as a literal does. A branch that is a literal takes the type of the other branches: if big { x } else { 0.5 } is an f32 for an x: f32.

A cast that changes nothing gets a warning, so the compiler points out the casts that these rules make unnecessary. With b: u8, let w: i64 = b as i64; gives the warning redundant `as i64`: a u8 converts to i64 implicitly here. A cast that picks a type or an operation's width, as in let id = b as i32; or (a as i64) * b, is not redundant and gets no warning.

% is Euclidean. The result is in [0, |b|) and never negative, at every integer type. So x % n is a valid index into a length-n array for any x, and the compiler can drop the bounds check instead of demanding a guard you know is redundant.

Goose
print(-7 % 3, " ", 7 % -3);       // 2 1
Rust counterpart
println!("{} {}", (-7_i64).rem_euclid(3), 7_i64.rem_euclid(-3));
Example 13

Difference: Use rem_euclid: Rust's % is a truncating remainder, unlike Goose's Euclidean %.

Integer / truncates toward zero: -7 / 3 is -2. It does not pair with Euclidean % for negative dividends. Import std for rem(a, b), the remainder that pairs with /, and div_euclid(a, b), the quotient that pairs with %. For example, a coordinate of -1 in tiles 10 wide has tile div_euclid(-1, 10) == -1 and offset -1 % 10 == 9.

Unsigned arithmetic wraps, in every build, by definition, because that is what hashes and PRNGs mean by unsigned math. Signed overflow aborts in debug and wraps in release. Rust's unsigned operators can also panic when overflow checks are enabled; use wrapping_* for unsigned wrapping in Rust.


4. Where values live

A Goose program has the native call stack, some static data, and N data stacks, where N is a small number the compiler works out for your program. A data stack is a multi-gigabyte address-space reservation with a bump pointer. There is no other memory system: no malloc, no free list, no GC, no reference counting and no destructors.

So every dynamic value is owned by a variable, and the rules that make this work are:

  1. Values are pushed in lifetime order and popped en masse when the owning scope exits.
  2. At most one resizable value is live per stack, and it is the topmost thing on that stack for its entire lifetime. So growth is a pointer bump with no capacity check, and the value never moves.

The compiler proves all of this statically. There is no runtime bookkeeping beyond the bump pointers.

"One resizable per stack" sounds alarming until you notice the plural in "N data stacks": three growable arrays alive at once get three stacks, and a stack is reused as soon as a scope frees it. N is whatever the deepest point of your program needs, computed once at compile time. An unused stack needs only reserved address space. You never write any of this down: the language has no syntax for stacks.

Leaving a scope frees its data

Goose
for round in 3 {
    var scratch: u8[>..] = [];
    for i in 100000 { scratch.push((i % 256) as! u8); }
    print("round ", round, ": ", scratch.len, " bytes of scratch");
}
Rust counterpart
for round in 0..3 {
    let mut scratch: Vec<u8> = Vec::new();
    for i in 0..100_000 { scratch.push((i % 256) as u8); }
    println!("round {round}: {} bytes of scratch", scratch.len());
}
Example 14

Difference: The values match, but Rust frees a heap buffer through Drop; it does not rewind Goose data stacks. The cast is in range because of the reduction.

Each iteration gets a fresh 100 KB buffer, and at the closing brace the stack's top goes back to where it was. No destructor runs and no list is walked, so "freeing" that buffer takes a single store.

Nothing moves, so references survive growth

Goose
struct Item { id: i32, weight: f32 }

var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });          // a reference to element 0
for i in 2..1000001 { items.push(Item { i as i32, 0.0 }); }
first.weight = 99.5;                               // still valid, a million pushes later
print(items.len, " items, first is ", items[0]);
Rust counterpart
struct Item { id: i32, weight: f32 }
let mut items: Vec<Item> = Vec::new();
items.push(Item { id: 1, weight: 0.5 });
let first = 0; // retain an index, not a reference across push
for i in 2..1_000_001 { items.push(Item { id: i, weight: 0.0 }); }
items[first].weight = 99.5;
println!("{} items, first is Item {{ {}, {} }}", items.len(), items[0].id, items[0].weight);
Example 15

Difference: Index adaptation: Vec may reallocate, so an ordinary reference into it cannot remain live across push. This preserves the values, not Goose's stable-address guarantee.

1000000 items, first is Item { 1, 99.5 }

A Rust Vec<T> does not promise that: growth may reallocate, and Rust's borrow checker prevents using references invalidated by mutation. This property is why so much Goose code holds typed references where the equivalent Rust code holds u32 indices into an arena, and it accounts for a good share of the benchmark gains.

push returns a reference to the element it just made, which is the idiomatic way to link up data you are building.

Copies are visible

Fixed-size values (scalars, structs, fixed arrays, references, slices) copy silently on assignment; Rust makes such copying conditional on Copy. Non-fixed values never copy implicitly, because that copy is O(size) and you should see it:

Goose
var a: i64[>..] = [1, 2, 3];
var b = copy(a);      // an explicit, visible O(n) copy
b.push(4);
Rust counterpart
let a: Vec<i64> = vec![1, 2, 3];
let mut b = a.clone(); // explicit element copy
b.push(4);
Example 16

Difference: Rust assignment of a Vec moves it; borrowing needs & or &mut. Goose's unadorned binding of a non-fixed value instead borrows it.

Without the copy, var b = a; binds b to a by reference. The same holds at a call: f(xs) passes a growable array by reference, without copying the elements, whether the parameter is declared as fn f(xs: i64[:]), as fn f(xs: i64[>..]&) or as an untyped fn f(xs). A function that needs its own copy calls copy.


5. The array family

Goose has no single "array type". It has several, and they differ only in what can happen to their size.

Type What it is Metadata
T[k] fixed size, known at compile time none
T[] sized at construction, frozen after a length
T[varint] the same, with a variable-length length 1–2 bytes usually
T[..k] capacity k inline, grows and shrinks within it a small length
T[..] capacity chosen at construction length + capacity
T[>..] grow-only: the workhorse a length
T[>..<] grow-shrink: stacks, queues, heaps a length

In every one of them the representation is [metadata][elements...], inline and packed. There is never a pointer to an element block.

Goose
var fixed: i32[4] = [1, 2, 3, 4];
let zeros: f64[8] = [0.0; 8];      // fill form
let frozen: i64[] = [7, 8, 9];     // sized once, never changes

var grow: i64[>..] = [];           // arenas, builders, pools, tree storage
for i in 5 { grow.push(i * i); }

var stack: i64[>..<] = [];         // the one that pops
stack.push(1); stack.push(2); stack.push(3);
let top = stack.pop();
stack.pop_n(2);                    // drops the last two at once

var small: u8[..8] = [];           // lives inline anywhere a fixed value can
small.push('h'); small.push('i');
Rust counterpart
let mut fixed: [i32; 4] = [1, 2, 3, 4];
let zeros: [f64; 8] = [0.0; 8];
let frozen: Box<[i64]> = vec![7, 8, 9].into_boxed_slice();
let mut grow: Vec<i64> = Vec::new();
for i in 0..5 { grow.push(i * i); }
let mut stack: Vec<i64> = Vec::new();
stack.push(1); stack.push(2); stack.push(3);
let top = stack.pop().unwrap();
stack.truncate(stack.len() - 2);
let mut small = [0_u8; 8];
let mut small_len = 0;
for &byte in b"hi" { small[small_len] = byte; small_len += 1; }
Example 17

Difference: Box<[T]> and Vec use indirect heap storage. The bounded byte buffer is an actual inline array plus length; Vec::with_capacity alone would not enforce a maximum. Rust Vec has no grow-only type guarantee.

Which to reach for:

Only [>..] and [>..<] are "resizable", and the one-per-stack rule applies only to those two. The other kinds can go anywhere.


6. Strings are byte arrays

There is no string type. Unlike Rust's UTF-8 str/String, a Goose string can contain arbitrary bytes. Rust counterparts using str below apply to the UTF-8 input shown. A string is any array-family type with u8 elements, and which one you pick says what you want:

Goose
var s: u8[>..] = [];
s.append("hello");
s.push(',');
format(s, " world! ", 6 * 7);       // format appends the text of anything
print(s, " [", s.len, " bytes]");
Rust counterpart
use std::io::{self, Write};
let mut s: Vec<u8> = Vec::new();
s.extend_from_slice(b"hello");
s.push(b',');
write!(&mut s, " world! {}", 6 * 7).unwrap();
let mut out = io::stdout().lock();
out.write_all(&s).unwrap();
writeln!(&mut out, " [{} bytes]", s.len()).unwrap();
Example 18

Difference: Vec preserves byte data but uses Rust heap allocation; the Rust formatting operation explicitly handles its I/O result.

hello, world! 42 [16 bytes]

Text that spans several lines is written as a """ string. It is raw, with no escapes, and the indentation of its closing """ is removed from every line, so the string can be indented to match the code around it:

Goose
print("""
    usage: wc [-l] file
      -l   count lines only
    """);
Rust counterpart
println!("usage: wc [-l] file\n  -l   count lines only");
Example 19

Difference: Rust raw strings preserve indentation; this literal spells out the newline and spaces instead of Goose's automatic dedent.

usage: wc [-l] file
  -l   count lines only

str(...) formats into a fresh string built directly at its destination:

Goose
words.push(str("word", i));         // written straight into the new element
Rust counterpart
words.push(format!("word{i}").into_bytes());
Example 20

Difference: Rust formats a separately allocated buffer and moves its Vec handle into words; it does not give Goose's final-destination construction guarantee.

There is no temporary string that is then copied into the array. The copy-free construction guarantee (spec §4.3) says that a constructed value is always built in its final location, which is passed down through calls, and that the compiler must not need to fall back to building it somewhere else first.

Slicing text copies nothing, because a slice is a pointer and a count into bytes that already exist:

Goose
let line = "  key = some value  ";
let body = trim(line);
let eq   = find(body, "=");
print("'", trim(body[..eq]), "' -> '", trim(body[eq + 1..]), "'");
Rust counterpart
use std::io::{self, Write};
let line: &[u8] = b"  key = some value  ";
let body = line.trim_ascii();
let eq = body.iter().position(|&b| b == b'=').unwrap();
let mut out = io::stdout().lock();
out.write_all(b"'").unwrap();
out.write_all(body[..eq].trim_ascii()).unwrap();
out.write_all(b"' -> '").unwrap();
out.write_all(body[eq + 1..].trim_ascii()).unwrap();
out.write_all(b"'\n").unwrap();
Example 21

Difference: Byte slices keep the no-copy views and accept arbitrary bytes; trim_ascii supplies the whitespace rule for this input.

'key' -> 'some value'

split gives you an array of slices into the input. Each part takes a few bytes and no text is copied, where most languages return a list of newly allocated strings:

Goose
let parts = split("alice,30,paris", ',');
print(parts.len, " parts: ", parts);      // 3 parts: ["alice", "30", "paris"]
Rust counterpart
let parts: Vec<&str> = "alice,30,paris".split(',').collect();
println!("{} parts: {:?}", parts.len(), parts);
Example 22

words splits at runs of whitespace instead, and drops the empty parts, which is what you want for a command line:

Goose
let cmd = words("  go   north ");
print(cmd.len, " words: ", cmd);          // 2 words: ["go", "north"]
Rust counterpart
let cmd: Vec<&str> = "  go   north ".split_ascii_whitespace().collect();
println!("{} words: {:?}", cmd.len(), cmd);
Example 23

A struct can also hold its strings inline, so an array of such structs is one block that you can sort and copy as plain data:

Goose
struct Person { name: u8[..16], age: i32 }

// A `format` overload makes print/str/format render a Person your way.
fn format(out: u8[>..]&, p: Person) { format(out, p.name, " (", p.age, ")"); }

var people: Person[>..] = [];
people.push(Person { name: "ada", age: 36 });
people.push(Person { name: "grace", age: 45 });
var p = Person { name: "", age: 20 };
format(p.name, "linus");        // a limited array is a builder too, up to capacity
people.push(p);
print(people);
Rust counterpart
use small_bytes::SmallBytes; // checked inline [u8; N] + length helper
struct Person { name: SmallBytes<16>, age: i32 }
impl std::fmt::Display for Person {
    fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let name = std::str::from_utf8(self.name.as_bytes()).map_err(|_| std::fmt::Error)?;
        write!(out, "{} ({})", name, self.age)
    }
}
let mut people: Vec<Person> = Vec::new();
people.push(Person { name: SmallBytes::new(b"ada"), age: 36 });
people.push(Person { name: SmallBytes::new(b"grace"), age: 45 });
let mut p = Person { name: SmallBytes::new(b""), age: 20 };
p.name.append(b"linus");
people.push(p);
println!("[{}]", people.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "));
Example 24

Difference: SmallBytes is the accompanying safe fixed-capacity byte helper, not Vec::with_capacity. Rust padding/length metadata differ from Goose packing; Display here formats the UTF-8 names shown.

[ada (36), grace (45), linus (20)]

A value that can be one of several strings is a view of whichever string it turns out to be. When one branch is a slice, as a string literal is, a branch that gives an inline string field gives a slice of that whole field, with nothing copied, and the if is a const u8[:]:

Goose
let who = if people.len > 3 { "a crowd" } else { people[1].name };
print(who);                             // grace
Rust counterpart
use std::io::{self, Write};
let who: &[u8] = if people.len() > 3 { b"a crowd" } else { people[1].name.as_bytes() };
let mut out = io::stdout().lock();
out.write_all(who).unwrap();
out.write_all(b"\n").unwrap();
Example 25

7. Slices that cannot dangle

T[:] is a reference plus a count, like Rust's &[T] or &mut [T]. It lets a function process a range of any array kind without copying its elements.

Goose
fn total(xs: i64[:]) -> i64 { var t = 0; for x in xs { t += x; } return t; }

total(fixed);         // a T[k]
total(grow);          // a T[>..]
total(grow[1..4]);    // a sub-range
total(grow[^2..]);    // ^k counts from the end
Rust counterpart
fn total(xs: &[i64]) -> i64 { xs.iter().sum() }
total(&fixed);
total(&grow);
total(&grow[1..4]);
total(&grow[grow.len() - 2..]);
Example 26

Any destination of slice type accepts an array this way, not only a parameter: a variable, a field, an element or a return value. The slice views the array itself, as fixed[..] would, and is const when the array is:

Goose
let view: const i64[:] = fixed;   // fixed itself, not a copy
fixed[0] = 99;
print(view[0]);                   // 99
Rust counterpart
use std::cell::Cell;
let fixed = [1_i64, 2, 3, 4].map(Cell::new);
let view: &[Cell<i64>] = &fixed;
fixed[0].set(99);
println!("{}", view[0].get());
Example 27

Difference: Cell adaptation: a shared &[Cell] supports this aliasing mutation. An ordinary &[i64] kept live across fixed[0] = 99 is rejected by Rust.

As with Rust slices, lifetimes are checked. Every reference and slice carries a static root: the variable that bounds the lifetime of what it points at. The lifetime system consists of one rule:

A reference must not outlive the variable that owns its target, and must never observe its target at a wrong type.

There are no lifetime annotations anywhere in the language. Roots are inferred, and functions are specialized per root. Through this monomorphization you get the precision of Rust lifetimes without writing any lifetime syntax. There are also no aliasing or exclusivity rules: two references to the same thing are fine, because without shared-memory concurrency, aliasing alone cannot break type safety in Goose. The checker tracks lifetimes and valid access; it does not require references to be exclusive.

A lifetime error identifies both the reference's root and its destination:

Goose
var line: u8[>..] = [];
// ...
let w = line[..5];
print(w);
line.clear();        // fine: w's last use is behind the clear
Rust counterpart
use std::io::{self, Write};
let mut line: Vec<u8> = Vec::new();
// ... fill line before slicing ...
line.extend_from_slice(b"hello");
let w = &line[..5];
let mut out = io::stdout().lock();
out.write_all(w).unwrap();
out.write_all(b"\n").unwrap();
line.clear(); // w's last use is behind the clear
Example 28

Difference: The original ellipsis must fill at least five bytes before slicing; this counterpart explicitly supplies hello. Rust also permits clear after the borrow's last use.

A scratch buffer that is refilled on each iteration can hand out slices of itself freely. A reusable scratch buffer and a structure you can point into have the same type.

Because a slice never copies, it also makes a natural key. The library's dictionary keyed by u8[:] stores no strings, only addresses and lengths into text you already have:

Goose
let text = "the quick brown fox jumps over the lazy dog the fox";
var counts = dictionary<const u8[:], i32> {};
each_split(text, ' ') { counts.update(it, 0) { it += 1; }; };
print(counts.count, " distinct words");
Rust counterpart
use std::collections::HashMap;
let text = "the quick brown fox jumps over the lazy dog the fox";
let mut counts: HashMap<&str, i32> = HashMap::new();
for word in text.split(' ') { *counts.entry(word).or_insert(0) += 1; }
println!("{} distinct words", counts.len());
Example 29

That is all of word-frequency counting, with nothing copied. The file is read once into one buffer, and every key, every table row and every line of the final report points into that buffer (15_word_freq does this over a book).

The const qualifier describes writability. Writability is inferred rather than annotated: a string literal is read-only, and &x of a var is writable. A slot (a field, an element or a global) has to say which kind it holds. dictionary<const u8[:], i32> says "these keys may be read-only views", which allows literals and other read-only views to be keys. Parameters are generic over constness, so you almost never write const, except on a slot that must accept read-only data and on a parameter you want to document as read-only.


8. Nested data stays inline

A struct may contain variable-size parts, and they sit inline, in declaration order. Combine that with varint fields (a LEB128 integer that takes as many bytes as its value needs), and a record becomes a run of bytes with no indirection in it:

Goose
struct Item  { sku: u8[varint], qty: varint, cents: varint }
struct Order { id: varint, customer: u8[varint], items: Item[varint] }
Rust counterpart
#[derive(Debug)]
struct Item { sku: Vec<u8>, qty: i64, cents: i64 }
#[derive(Debug)]
struct Order { id: i64, customer: Vec<u8>, items: Vec<Item> }
Example 30

Difference: Vec fields preserve byte strings, but Rust stores separate heap buffers and fixed-width integers; these are not inline LEB128 records.

In Rust, Order would usually own a String and a Vec<Item>, with each Item owning another String. In Goose an order is its header followed by its items, laid end to end, and the whole order book is one array of such orders.

Goose
// "SKU:qty:cents;..." -> items, built straight into the field that gets them
fn parse_items(field: u8[:]) -> Item[>..] {
    var items: Item[>..] = [];
    each_split(field, ';') {
        let p = split(it, ':');
        let qty, ok1 = parse_int(p[1]);
        let cents, ok2 = parse_int(p[2]);
        assert(ok1 && ok2);
        items.push(Item { sku: p[0], qty: qty, cents: cents });
    };
    return items;
}

var book: Order[>..] = [];
book.push(Order { id: 1001, customer: "alice",
                  items: parse_items("SKU-441:2:1999;SKU-7:1:500") });

for o in book {
    var t = 0;
    for it in o.items { t += it.qty * it.cents; }
    print(o.customer, " owes ", t, " cents over ", o.items.len, " items");
}
Rust counterpart
fn parse_items(field: &[u8]) -> Vec<Item> {
    field.split(|&b| b == b';').map(|record| {
        let p: Vec<&[u8]> = record.split(|&b| b == b':').collect();
        let qty = std::str::from_utf8(p[1]).unwrap().parse::<i64>().unwrap();
        let cents = std::str::from_utf8(p[2]).unwrap().parse::<i64>().unwrap();
        Item { sku: p[0].to_vec(), qty, cents }
    }).collect()
}
let mut book: Vec<Order> = Vec::new();
book.push(Order { id: 1001, customer: b"alice".to_vec(),
                  items: parse_items(b"SKU-441:2:1999;SKU-7:1:500") });
for o in &book {
    let t: i64 = o.items.iter().map(|item| item.qty * item.cents).sum();
    use std::io::Write;
    let mut out=std::io::stdout().lock();
    out.write_all(&o.customer).unwrap();
    writeln!(&mut out," owes {t} cents over {} items",o.items.len()).unwrap();
}
Example 31

Difference: Same records and total, with owned byte copies and separate Vec allocations. The numeric fields shown are UTF-8/ASCII; parse failures panic here, matching the example's assertion for invalid numbers.

alice owes 4498 cents over 2 items

It is worth counting the bytes, because a real program can have millions of records like this one. That order (id 1001, customer alice, and two items with 5- and 7-character SKUs) takes:

bytes allocations
Goose, as declared above 29 0
C++ std::string + std::vector<Item> 160 1
Rust String + Vec<Item> 153 4

The C++ and Rust figures are sizeof plus the blocks actually allocated, measured with MSVC and rustc; they do not include the allocator's own per-block header and rounding, which would add more. C++ needs a single allocation here only because all three strings are short enough for the small-string optimization; with one more character in a SKU, C++ would need four allocations too.

The smaller layout reduces cache traffic when traversing the order book. And id, qty and cents take one or two bytes here rather than eight, because a varint is as wide as its value needs.

Note also that parse_items returns a growable array by value without any copying: the callee is compiled knowing its destination, and writes the items directly into the order's field, inside the book. In Goose, returning a built-up value costs the same as writing through an out-parameter, which is why out-parameters rarely appear.

The downside of variable-size elements is that the array becomes sequential: offsets are data-dependent, so you can iterate it but not index it.

Goose
for o in book { print(o); }     // fine
print(book[0]);                 // error: arrays of variable-size elements
                                // cannot be indexed, only iterated
Rust counterpart
for o in &book { println!("{o:?}"); }
println!("{:?}", book[0]); // valid: Vec<Order> has fixed-size, indexable elements
Example 32

Difference: Contrast: Rust's Vec permits indexing because every Order has a fixed-size Vec handle. Goose rejects book[0] for its packed variable-size elements. This Rust layout uses separate buffers.

Use variable elements for data you traverse, such as records, parsed nodes, and log lines. Use fixed elements for data you index and sort, with a u8[..k] if an element needs a string. When you need both, keep a second array of references as an index. You can index and sort those references while the variable-size elements stay packed in place. Section 10 ends with an example.


9. Enums, in two sizes

Algebraic data types are the only dynamic polymorphism in the language. Goose has no inheritance, traits or vtables.

Goose
enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }
Rust counterpart
#[derive(Debug)]
enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }
Example 33

Difference: Rust enums have a fixed size and alignment/padding rules; do not infer Goose's 17-byte layout from this declaration.

What is unusual is that every ADT type can be stored two ways, chosen at the point of use:

Fixed mode (Shape) resembles Rust's enum: a tag plus room for the largest payload. In Goose that is 1 + 16 = 17 bytes for every value, including Dot. In return, an array of fixed-mode values can be indexed, and you may overwrite an element with a different variant.

Goose
var shapes: Shape[>..] = [];
shapes.push(Shape.Circle { r: 1.0 });
shapes.push(Shape.Rect { w: 2.0, h: 3.0 });
shapes.push(Shape.Dot);
shapes[2] = Shape.Rect { w: 1.0, h: 1.0 };      // a different variant, in place
Rust counterpart
let mut shapes: Vec<Shape> = Vec::new();
shapes.push(Shape::Circle { r: 1.0 });
shapes.push(Shape::Rect { w: 2.0, h: 3.0 });
shapes.push(Shape::Dot);
shapes[2] = Shape::Rect { w: 1.0, h: 1.0 };
Example 34

Variable mode (Shape.., note the trailing dots) gives each value exactly the size of its own variant: a Dot is 1 byte, a Circle 9, a Rect 17. The downside is that the elements are packed against each other, so the array is sequential (unless you keep an index beside it, see section 10), and a value can never change variant. In return, you may take references into a payload:

Goose
var packed: Shape..[>..] = [];
packed.push(Shape.Circle { r: 1.0 });
packed.push(Shape.Dot);
packed.push(Shape.Rect { w: 2.0, h: 3.0 });

for s in packed {
    match s {
        Rect &r => { r.w += 1.0; },     // edits the payload in place, inside the array
        _ => {},
    }
}
Rust counterpart
let mut packed: Vec<Shape> = Vec::new(); // name only; Rust elements are fixed-size
packed.push(Shape::Circle { r: 1.0 });
packed.push(Shape::Dot);
packed.push(Shape::Rect { w: 2.0, h: 3.0 });
for s in &mut packed {
    if let Shape::Rect { w, .. } = s { *w += 1.0; }
}
Example 35

Difference: Closest safe adaptation: Vec is not packed variable-mode storage. Rust can borrow a payload while its exclusive borrow prevents conflicting replacement; Goose instead forbids changing a variable-mode value's variant.

The two modes offer different guarantees. A fixed-mode value can be overwritten with another variant, so nothing may point inside it. A variable-mode value can be pointed into, so it can never be overwritten. This keeps both modes sound, and it is why &-binders in a match are legal only on variable-mode payloads.

For a tree of mostly-small nodes the memory difference is large. It is the biggest single source of the memory savings in the benchmarks: the records benchmark uses 4.0x less memory than a Rust enum in which every element is sized for its String variant, and it runs 2.0–2.3x faster for the same reason, because the cache does less work.

match, and match written as functions

The inline form is what you expect:

Goose
fn name(s: Shape) -> u8[:] {
    match s {
        Circle c => "circle",
        Rect r => if r.w == r.h { "square" } else { "rectangle" },
        _ => "other",
    }
}
Rust counterpart
fn name(s: &Shape) -> &'static str {
    match s {
        Shape::Circle { .. } => "circle",
        Shape::Rect { w, h } => if w == h { "square" } else { "rectangle" },
        _ => "other",
    }
}
Example 36

An arm may list several variants, as in Circle, Dot => "round". Such an arm binds no payload, because each variant's payload is a type of its own.

A match on an integer accepts literals, half-open ranges and named constants (let globals, or ns::NAME from another namespace), so a program's own codes can appear by name in a match too. One arm may list several of them. The type of the scrutinee decides whether a bare name is a variant or a constant:

Goose
let GROUND = 0;
let SHALLOW = 1;
let DEEP = 4;
let LAVA = 7;

fn terrain(t: i64) -> u8[:] {
    match t {
        GROUND => "ground",
        SHALLOW..DEEP => "water",
        LAVA, 10..12 => "hot",
        _ => "rock",
    }
}
Rust counterpart
const GROUND: i64 = 0;
const SHALLOW: i64 = 1;
const DEEP: i64 = 4;
const LAVA: i64 = 7;
fn terrain(t: i64) -> &'static str {
    match t {
        GROUND => "ground",
        x if (SHALLOW..DEEP).contains(&x) => "water", // 1, 2, 3
        LAVA | 10..=11 => "hot",                    // 7, 10, 11
        _ => "rock",
    }
}
Example 37

Difference: The guard preserves SHALLOW..DEEP's excluded end, and 10..=11 preserves Goose's half-open 10..12.

The other form is case functions: one overload per variant, called with the enum. This is the virtual-call idiom, without the vtable:

Goose
fn area(s: Shape.Circle) -> f64 { 3.14159 * s.r * s.r }
fn area(s: Shape.Rect) -> f64   { s.w * s.h }
fn area(s: Shape.Dot) -> f64    { 0.0 }

for s in shapes { print(s, " area ", area(s)); }
Rust counterpart
fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { r } => 3.14159 * r * r,
        Shape::Rect { w, h } => w * h,
        Shape::Dot => 0.0,
    }
}
for s in &shapes {
    let description = match s {
        Shape::Circle { r } => format!("Shape.Circle {{ {r} }}"),
        Shape::Rect { w, h } => format!("Shape.Rect {{ {w}, {h} }}"),
        Shape::Dot => "Shape.Dot".to_owned(),
    };
    println!("{description} area {}", area(s));
}
Example 38

Difference: Rust uses one exhaustive match: enum variants are not independently named parameter types for overloading. The explicit description preserves the Goose-style display for this fixture.

The call dispatches on the tag through a jump table, and exhaustiveness is checked as it is for a match: if you add a variant, this code stops compiling until the new variant gets its own case. Variant types (Shape.Circle) are ordinary types you can name and pass around.


10. Linking things without pointers

A T& is a machine address. It is eight bytes, allows no arithmetic or casts, and never dangles. For a data structure with a lot of links, eight bytes per link is often more than you want, and an address is not something you can save to a file.

So Goose also has relative references, which store a link as a narrow offset:

Widths are u8, u16, u32, u64 or varint. Loading either form gives you an ordinary T&, and storing a T& into one re-encodes it. T&<u32>? uses offset 0 as null, so a link takes four bytes including its null.

Here is a binary search tree stored in one grow-only array, with 4-byte self-relative children:

Goose
struct Node { key: i32, count: i32 = 1, left: Node&<u32>?, right: Node&<u32>? }

fn insert(pool: Node[>..]&, key: i32) {
    if pool.len == 0 { pool.push(Node { key: key }); return; }
    var cur .= pool[0];                  // .= binds the element; = would copy it
    loop {
        if key == cur.key { cur.count += 1; return; }
        if key < cur.key {
            let l = cur.left;
            if l { cur .= l; continue; }             // narrowed by `if`: l is a Node&
            cur.left .= pool.push(Node { key: key });   // the push cannot move cur
            return;
        }
        let r = cur.right;
        if r { cur .= r; continue; }
        cur.right .= pool.push(Node { key: key });
        return;
    }
}
Rust counterpart
struct Node { key: i32, count: i32, left: Option<usize>, right: Option<usize> }
fn insert(pool: &mut Vec<Node>, key: i32) {
    if pool.is_empty() {
        pool.push(Node { key, count: 1, left: None, right: None });
        return;
    }
    let mut cur = 0;
    loop {
        if key == pool[cur].key { pool[cur].count += 1; return; }
        let left = key < pool[cur].key;
        let child = if left { pool[cur].left } else { pool[cur].right };
        if let Some(next) = child { cur = next; continue; }
        let next = pool.len();
        pool.push(Node { key, count: 1, left: None, right: None });
        if left { pool[cur].left = Some(next); } else { pool[cur].right = Some(next); }
        return;
    }
}
Example 39

Difference: Indices replace stable/self-relative references. Option is not a four-byte link, and this Rust layout is not a relocatable Goose byte image.

The example uses three reference features:

.= binds and rebinds a reference. References are transparent: an expression that denotes a reference behaves as its target everywhere, with no * and no ->. So there has to be some way to say "I mean the reference itself", and .= does that. var cur .= pool[0]; binds cur to the element; cur .= l; retargets it. (.== is the matching identity test: == compares the two nodes, while .== asks whether they are the same node.)

Optionals are references that can be null, narrowed by flow. Node? is a nullable Node&: null is address 0, so it takes no extra space. You cannot use it until an if, while, guard or assert has narrowed it, and inside the narrowed region it is a Node&.

The insert is one store. cur.left .= pool.push(...) pushes the node and writes the offset, and the push cannot invalidate cur because nothing moves. In safe Rust this is where you switch to Vec<Node> plus u32 indices.

The node is 16 bytes: two i32s and two 4-byte links, whereas a Rust tree of Box<Node> links would need 24 bytes of node plus an allocation header per node. A thousand random inserts give:

442 nodes, 7072 bytes of tree, no allocator involved

self, and sentinels

A non-optional relative reference has no null, so a structure whose links point back at itself could never be constructed, because there is nothing to point at yet. self means "the value this literal is constructing", and it is legal only as the whole initializer of such a field:

Goose
import slot_pool;

struct Node { val: i64, prev: Node&<u32 in pool.items>, next: Node&<u32 in pool.items> }

var pool = slot_pool<Node> {};
let head .= pool.alloc_with() { Node { val: 0, prev: self, next: self } };
Rust counterpart
use slot_pool::SlotPool;
struct Node { val: i64, prev: usize, next: usize }
let mut pool: SlotPool<Node> = SlotPool::new();
let head = pool.items.len();
pool.alloc(Node { val: 0, prev: head, next: head });
Example 40

Difference: An index chosen before allocation makes a safe sentinel. SlotPool is the accompanying Vec/freelist helper; its links are indices, not Goose's pool-relative byte offsets or global owner.

This is the sentinel of a circular doubly-linked list, allocated before main runs. Because the sentinel exists, no link in the list is ever null. Every insert and remove is the same four stores, with no special cases, and every link load is a plain add rather than an add plus a null test. alloc_with selects the slot before running its builder, so self names the node in its final location. The ordinary alloc_ref value argument suffices for nodes whose links already name existing elements:

Goose
fn insert_after(p: Node&, v: i64) -> Node& {
    let n .= pool.alloc_ref(Node { val: v, prev: p, next: p.next });
    n.next.prev .= n;
    p.next .= n;
    return n;
}

fn remove(n: Node&) {
    n.prev.next .= n.next;
    n.next.prev .= n.prev;
    pool.free(pool.items.index_of(n));
}

fn is_end(n: Node&) -> bool { n .== head }
Rust counterpart
fn insert_after(pool: &mut SlotPool<Node>, p: usize, v: i64) -> usize {
    let next = pool.items[p].next;
    let n = pool.alloc(Node { val: v, prev: p, next });
    pool.items[next].prev = n;
    pool.items[p].next = n;
    n
}
fn remove(pool: &mut SlotPool<Node>, n: usize) {
    let (prev, next) = (pool.items[n].prev, pool.items[n].next);
    pool.items[prev].next = next;
    pool.items[next].prev = prev;
    pool.free(n);
}
fn is_end(n: usize, head: usize) -> bool { n == head }
Example 41

Difference: Pass the pool explicitly and use slot indices for identity. Reusing a freed slot changes its logical contents, just as in the Goose pool; the Rust helper is not a compact-offset layout.

A node is 8 + 4 + 4 = 16 bytes, while the benchmark's C++ comparison uses a std::list node plus a map node for the same structure. That difference is most of the reason the lru benchmark uses 3.2x less memory.

An index for variable-size elements

Section 8 said an array of variable-size elements can be walked but not indexed: where an element starts depends on every element before it. Since other arrays can hold links into a pool, you can keep the index yourself in a second array, with one 4-byte link per element, pushed alongside it:

Goose
enum Mark {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
    Label { text: u8[varint] },
    Dot,
}

var marks: Mark..[>..] = [];
var mark_at: (Mark..&<u32 in marks>)[>..] = [];

fn main() {
    mark_at.push(marks.push(Mark.Label { text: "hello, goose" }));
    mark_at.push(marks.push(Mark.Circle { r: 1.5 }));
    mark_at.push(marks.push(Mark.Dot));
    mark_at.push(marks.push(Mark.Rect { w: 2.0, h: 3.0 }));
    mark_at.push(marks.push(Mark.Label { text: "x" }));
    for i in [3, 0, 4, 1, 2] {
        match mark_at[i] {
            Circle c => print(i, ": circle ", c.r),
            Rect r => print(i, ": rect ", r.w, " by ", r.h),
            Label l => print(i, ": label \"", l.text, "\""),
            Dot => print(i, ": dot"),
        }
    }
}
Rust counterpart
enum Mark {
    Circle { r: f64 }, Rect { w: f64, h: f64 },
    Label { text: Vec<u8> }, Dot,
}
fn main() {
    use std::io::{self, Write};
    let mut marks: Vec<Mark> = Vec::new();
    let mut mark_at: Vec<usize> = Vec::new();
    for mark in [Mark::Label { text: b"hello, goose".to_vec() },
                 Mark::Circle { r: 1.5 }, Mark::Dot,
                 Mark::Rect { w: 2.0, h: 3.0 }, Mark::Label { text: b"x".to_vec() }] {
        mark_at.push(marks.len()); marks.push(mark);
    }
    let mut out=io::stdout().lock();
    for i in [3, 0, 4, 1, 2] {
        match &marks[mark_at[i]] {
            Mark::Circle { r } => writeln!(&mut out,"{i}: circle {r}").unwrap(),
            Mark::Rect { w, h } => writeln!(&mut out,"{i}: rect {w} by {h}").unwrap(),
            Mark::Label { text } => {
                write!(&mut out,"{i}: label \"").unwrap();
                out.write_all(text).unwrap(); writeln!(&mut out,"\"").unwrap();
            }
            Mark::Dot => writeln!(&mut out,"{i}: dot").unwrap(),
        }
    }
}
Example 42

Difference: Indices preserve lookup order, but Vec uses fixed-size enum elements and separate label buffers. It is neither the 44-byte packed layout nor a table of four-byte relative references.

3: rect 2 by 3
0: label "hello, goose"
4: label "x"
1: circle 1.5
2: dot

push builds each mark in place at the end of marks and returns a reference to it. When that reference is stored in mark_at, it becomes the mark's offset from the base of marks. mark_at[i] is then a load and an add, so the marks can be reached in any order.

Look at what marks holds. A Label consists of its tag, a one-byte length and its text, all inside the element: with hello, goose it is 14 bytes, and the next mark starts where the text ends. The five marks, including their strings, take 44 bytes in one block, with no pointers and no allocations. A Rust enum has one fixed size: a String or Vec<u8> variant holds a fixed-size buffer handle, with its bytes elsewhere, and every element has room for the largest variant, including alignment padding. Here each value is exactly as long as its contents, and the index can still reach any of them at 4 bytes per element.

The array that an in offset is measured from has to be a global. An array local to a function can be indexed the same way with plain references, (Mark..&)[>..], at 8 bytes per entry. 05_shapes ends with this example.


11. Lifetimes that do not nest

Some data has lifetimes that do not nest: cache entries, mutable graph nodes, or files deleted in arbitrary order. Goose supports these uses with the standard library's slot_pool<T>: a root struct bundling an items array and a freelist. Declare its owner as a local or global and pass it to functions as slot_pool<T>&.

Goose
import slot_pool;
struct Item { id: i32, weight: f32 }

var pool = slot_pool<Item> {};

let s0 = pool.alloc_index(Item { 10, 1.0 });
let s1 = pool.alloc_index(Item { 11, 1.0 });
pool.free(s0);
let s2 = pool.alloc_index(Item { 12, 2.0 });   // takes slot s0 back
Rust counterpart
use slot_pool::SlotPool;
struct Item { id: i32, weight: f32 }
let mut pool: SlotPool<Item> = SlotPool::new();
let s0 = pool.alloc(Item { id: 10, weight: 1.0 });
let s1 = pool.alloc(Item { id: 11, weight: 1.0 });
pool.free(s0);
let s2 = pool.alloc(Item { id: 12, weight: 2.0 });
Example 43

Difference: The small accompanying Rust helper keeps values alive when slots are returned, then overwrites reused slots. Index identity is memory-safe but is not generation-checked logical identity.

alloc_index / alloc_ref take a free slot if there is one, and push a new element otherwise. free(i) records a slot for reuse. That looks like an allocator, so it is worth being precise about what free does and does not do:

All elements remain valid at all times. free does not release any memory and does not end any lifetime; it adds an index to a freelist. The slot is still a live, well-typed Item afterwards, and it still belongs to the pool, which still belongs to its owning scope.

Reuse preserves memory and type safety. A reference to a slot that was freed and reused reads a different Item: a valid one, but not the one you were thinking of. That is the same class of mistake as keeping an index into an array you have since overwritten: a logic bug, and one you can reason about locally. Memory is never accessed at a type it was not written with, and nothing goes out of bounds, because nothing was freed.

Within a pool, the program manages slot reuse independently of scope. After returning a slot, stop using references to its old contents: they remain memory safe but may read a replacement value.

Running the linked list above on such a pool gives:

built: 10 20 30 40 50   (6 slots)
evens removed: 10 30 50   (6 slots)
two more, both from the freelist: 10 30 50 7 8   (6 slots)

The slice_pool<T> module hands out runs of elements rather than single slots — alloc_slice(n), realloc_slice(s, n), free_slice(s) — with a freelist of spans that merges neighbours. That is what you use for the child lists of an n-ary tree, where each node's entries want to be one contiguous run you can walk as a plain slice. Both pool types expose their backing array as pool.items; its length includes freed storage.

reusable and reusable[] are deprecated in favor of these library types. They remain available for existing code; removal is a later step. 26_file_tree combines both library pool types with compact links naming their global items components. Copying a whole pool into a worker still requires the deprecated builtins; worker-local library pools are supported.


12. Errors, and return … from

There is no exception mechanism, no Result type, and no specified error convention either: the application chooses. For a call with two outcomes, the trailing bool you have already seen is enough (let age, ok = parse_age(s);). For "found or not" it is a T?, which copies nothing because it is a reference into the input. Both are ordinary return values.

return E from f returns E as the result of the innermost active call of f, unwinding every frame in between. It provides lightweight exceptions. With the two idioms above, a failure eight calls deep in a parser would require all eight functions to carry an error in their signatures, even if they only pass it along.

Goose
fn load(text: u8[:]) -> User[>..], u8[] {
    var users: User[>..] = [];
    var lineno = 0;
    each_split(text, '\n') {
        lineno++;
        if it.len > 0 { users.push(parse_record(it, lineno)); }
    };
    return users, "";
}

fn parse_record(line: u8[:], lineno: i64) -> User {
    let comma = find(line, ",");
    guard comma >= 0 else { return [], str("line ", lineno, ": expected name,age") from load; }
    let age, ok = parse_age(line[comma + 1..]);
    guard ok else { return [], str("line ", lineno, ": bad age") from load; }
    return User { name: trim(line[..comma]), age: age };
}
Rust counterpart
fn load(text: &[u8]) -> Result<Vec<User>, Vec<u8>> {
    let mut users = Vec::new();
    for (number, line) in text.split(|&b| b == b'\n').enumerate() {
        if !line.is_empty() { users.push(parse_record(line, number + 1)?); }
    }
    Ok(users)
}
fn parse_record(line: &[u8], lineno: usize) -> Result<User, Vec<u8>> {
    let comma = line.iter().position(|&b| b == b',')
        .ok_or_else(|| format!("line {lineno}: expected name,age").into_bytes())?;
    let age = parse_age(&line[comma + 1..])
        .ok_or_else(|| format!("line {lineno}: bad age").into_bytes())?;
    Ok(User { name: line[..comma].trim_ascii().to_vec(), age })
}
Example 44

Difference: Result and ? explicitly propagate errors through each Rust caller; they are not Goose's nonlocal return from load. The harness supplies the User/parse_age definitions omitted by the original fragment.

loaded 2 users
error: line 2: expected name,age
error: line 1: bad age

Look at what parse_record does not have. Its return type is User, not Result<User, E> or (User, bool). It has no error parameter, no ? on the calls it makes, and nothing to propagate. The same is true of every function between it and load, however many there are. All the error handling in the program consists of the two guards that produce a message and the one if err.len > 0 that reports it.

Three properties keep the cost low:

So on the success path the only cost is one flag check per frame on the way out. That is why the parsers in the samples use this for every syntax error rather than reserving it for catastrophes (17_calc, 18_json: a bad token, a missing bracket and a bad escape take one line each).

The exception module packages this mechanism as exception::try<E>(body) { error => ... } and exception::throw(value). It can catch owned text or application-defined error values without naming the application's outer function at each throw site. See its reference for the ownership and recursion restrictions. Its finally is a defer.

The same machinery lets a block return from its enclosing function rather than from the HOF that calls it (section 13). That is the other place you will meet it.

Some calls stop the program instead of reporting an error: assert(c) for invariants, abort(msg) with a message, and exit(code). The checker knows that the last two never return, so a branch that ends in either one gives no value. That knowledge also propagates through ordinary calls, including wrappers around exception::throw: a value-returning function can end by calling a function that never returns, without a dummy result afterward. Statements and a trailing expression after a proven exit are not typechecked. Unknown recursive calls are conservatively assumed to return.


13. Generics, and blocks that disappear

An untyped parameter is generic:

Goose
fn twice(x) { x + x }
fn span<T>(a: T, b: T) -> T { if a > b { a - b } else { b - a } }
Rust counterpart
fn twice<T: Copy + std::ops::Add<Output = T>>(x: T) -> T { x + x }
fn span<T: PartialOrd + std::ops::Sub<Output = T>>(a: T, b: T) -> T {
    if a > b { a - b } else { b - a }
}
Example 45

Difference: Rust checks the generic body against declared bounds; Goose checks it at concrete instantiations. These Rust bounds express the numeric uses shown.

<T> either says that two parameters must have the same type, or names a type that no parameter carries. Type arguments are normally inferred from the arguments, as in foo(1). An explicit list such as foo<i64>(1) can supply leading type arguments; it is required for a type variable that no argument determines:

Goose
fn zero<T>() -> T { default<T>() }   // T's default value

let half = zero<f64>() + 0.5;
Rust counterpart
fn zero<T: Default>() -> T { T::default() }
let half = zero::<f64>() + 0.5;
Example 46

Everything is monomorphized, so there is nothing abstract to typecheck: a generic body is only checked at an instantiation where every type is concrete, and errors are reported with the compile-time call chain that produced them.

Overloads resolve on argument types, and a slice parameter takes any array kind:

Goose
fn describe(x: i64) -> u8[>..]     { str("int ", x) }
fn describe(x: f64) -> u8[>..]     { str("float ", x) }
fn describe(xs: i64[:]) -> u8[>..] { str(xs.len, " ints, first ", xs[0]) }

print(describe(7), "; ", 2.5.describe(), "; ", xs.describe(), "; ", describe(xs[2..]));
Rust counterpart
trait Describe { fn describe(&self) -> String; }
impl Describe for i64 {
    fn describe(&self) -> String { format!("int {self}") }
}
impl Describe for f64 {
    fn describe(&self) -> String { format!("float {self}") }
}
impl Describe for [i64] {
    fn describe(&self) -> String { format!("{} ints, first {}", self.len(), self[0]) }
}
fn describe<T: Describe + ?Sized>(x: &T) -> String { x.describe() }
println!("{}; {}; {}; {}", describe(&7_i64), 2.5_f64.describe(),
         xs.describe(), describe(&xs[2..]));
Example 47

Difference: Rust uses trait implementations for this overload set. String is appropriate for the formatted numeric text here, but uses separate UTF-8 heap buffers.

Function values are compile-time entities. They are passed as generic parameters, every call is direct and inlinable, and they cannot escape: storing one, returning one or putting one in data is a compile error. There are no closure objects and no runtime function pointers. A higher-order function can therefore compile to a direct loop:

Goose
fn each_pair<T, F>(xs: T[:]) {
    for i in 0..xs.len - 1 { F(xs[i], xs[i + 1]); }
}

var gaps: i64[>..] = [];
each_pair(xs) { a, b => gaps.push(b - a); };     // writes an enclosing local
Rust counterpart
fn each_pair<T: Copy>(xs: &[T], mut f: impl FnMut(T, T)) {
    for pair in xs.windows(2) { f(pair[0], pair[1]); }
}
let mut gaps: Vec<i64> = Vec::new();
each_pair(&xs, |a, b| gaps.push(b - a));
Example 48

Difference: Rust's FnMut closure captures gaps; it is a closure value even when monomorphized/inlined. Rust also allows escaping closures, unlike Goose's static function values.

{ ... } after a call is the trailing-block sugar, with it as the implicit parameter, or a, b => for named parameters. The standard library is written this way throughout, and filter/map build their results directly in the variable that receives them, with no intermediate array and no allocation:

Goose
let evens   = xs.filter() { it % 2 == 0 };
let squares = xs.map()    { it * it };
let total   = fold(xs, 0) { acc, x => acc + x };
sort(xs) { a, b => a > b };
Rust counterpart
let evens: Vec<i64> = xs.iter().copied().filter(|it| it.rem_euclid(2) == 0).collect();
let squares: Vec<i64> = xs.iter().map(|&it| it * it).collect();
let total = xs.iter().fold(0, |acc, &x| acc + x);
xs.sort_by(|a, b| b.cmp(a));
Example 49

Difference: collect allocates owned Rust buffers. The reversed comparator preserves the Goose a > b descending order; rem_euclid retains its remainder rule.

A block may also return from the lexically enclosing function, not from the HOF that calls it. This uses the same return from machinery, and it means that a return in the block of a hand-written iterator works as it would in the body of a for loop:

Goose
fn each_step<F>(lo: i64, hi: i64, step: i64) {
    var i = lo;
    while i < hi { F(i); i += step; }
}

fn first_multiple_of_7(lo: i64, hi: i64) -> i64 {
    each_step(lo, hi, 3) { if it % 7 == 0 { return it; } };   // returns from first_multiple_of_7,
                                                              // not from each_step
    return -1;
}
Rust counterpart
use std::ops::ControlFlow;
fn each_step<B>(lo: i64, hi: i64, step: i64,
                mut f: impl FnMut(i64) -> ControlFlow<B>) -> ControlFlow<B> {
    let mut i = lo;
    while i < hi { f(i)?; i += step; }
    ControlFlow::Continue(())
}
fn first_multiple_of_7(lo: i64, hi: i64) -> i64 {
    match each_step(lo, hi, 3, |it| {
        if it.rem_euclid(7) == 0 { ControlFlow::Break(it) }
        else { ControlFlow::Continue(()) }
    }) {
        ControlFlow::Break(value) => value,
        ControlFlow::Continue(()) => -1,
    }
}
Example 50

Difference: Rust explicitly propagates ControlFlow through the callback API. A return inside a Rust closure returns from the closure, not the enclosing function.

Nested functions can use their enclosing function's locals. The sample parsers use this to share their input, cursor, and pools: all are locals of parse, accessible to the recursive descent functions nested inside it. This avoids passing a state struct through every call (17_calc, 18_json).

Recursion is opt-in and annotated. The entry of a recursive cycle is a recursive fn, every function in the cycle needs an explicit signature, and no function in a cycle may hold growable data across a call back into the cycle, because every activation would need a data stack of its own. So a recursive builder takes the pool it grows as a parameter:

Goose
recursive fn in_order(n: Node&, out: i32[>..]&) {
    let l = n.left;
    if l { in_order(l, out); }
    out.push(n.key);
    let r = n.right;
    if r { in_order(r, out); }
}
Rust counterpart
fn in_order(n: &Node, out: &mut Vec<i32>) {
    if let Some(left) = &n.left { in_order(left, out); }
    out.push(n.key);
    if let Some(right) = &n.right { in_order(right, out); }
}
Example 51

Difference: The harness supplies an ordinary Box-linked Rust tree. Rust recursion needs no recursive annotation and does not impose Goose's restriction on owned growable locals.

A function in a cycle may still own scratch it is done with before it recurses, as long as the scratch's scope ends before the call. Here each line is built and printed in a block, so its stack is free again when the recursive calls start:

Goose
recursive fn show(n: Node&, depth: i64) {
    {
        var line: u8[>..] = [];
        for i in depth { line.append("  "); }
        line.append(str(n.key));
        print(line);
    }
    let l = n.left;
    if l { show(l, depth + 1); }
    let r = n.right;
    if r { show(r, depth + 1); }
}
Rust counterpart
fn show(n: &Node, depth: usize) {
    {
        let mut line = "  ".repeat(depth);
        line.push_str(&n.key.to_string());
        println!("{line}");
    }
    if let Some(left) = &n.left { show(left, depth + 1); }
    if let Some(right) = &n.right { show(right, depth + 1); }
}
Example 52

Difference: The scope ends before recursion in both versions; Rust drops each heap String instead of rewinding a Goose data stack.

That is a real constraint on how you write recursive code, and it is also the main reason that each level of recursion uses only native stack.

Several tables, one context

A compiler or a simulation keeps its state in several growable tables (nodes, symbols, output) and wants to pass them around together. A struct cannot own them, because a struct holds at most one resizable array (as its last field), and a recursive function cannot keep them across its recursive calls. Instead, make each table a local of a driver function, and pass the code that works on the tables a single struct of references to them:

Goose
struct Node { at: i64, first: i64, next: i64 }   // a source offset, first child, next sibling

struct Ctx {                 // a slice and three references: fixed-size, cheap to pass
    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);                 // this activation's entries, above its callers'
    c.text.push(c.source[c.nodes[n].at]);
    var total = c.scratch[mark];
    var e = c.nodes[n].first;
    while e >= 0 {
        total += walk(c, e, depth + 1);
        e = c.nodes[e].next;
    }
    c.scratch.resize(mark);                // gone again before it returns
    return total;
}

fn compile(source: const u8[:]) -> i64 {
    var nodes: Node[>..] = [];
    var text: u8[>..] = [];
    var scratch: i64[>..<] = [];
    nodes.push(Node { at: 0, first: 1, next: -1 });   // a root with two children
    nodes.push(Node { at: 1, first: -1, next: 2 });
    nodes.push(Node { at: 2, first: -1, next: -1 });
    var c = Ctx { source: source, nodes: nodes, text: text, scratch: scratch };
    let r = walk(c, 0, 1);
    print(text, " ", scratch.len);
    return r;
}
Rust counterpart
struct Node { at: i64, first: i64, next: i64 }
struct Ctx<'a> {
    source: &'a [u8], nodes: &'a mut Vec<Node>,
    text: &'a mut Vec<u8>, scratch: &'a mut Vec<i64>,
}
fn walk(c: &mut Ctx<'_>, n: i64, depth: i64) -> i64 {
    let n = usize::try_from(n).unwrap();
    let mark = c.scratch.len();
    c.scratch.push(depth);
    let at = usize::try_from(c.nodes[n].at).unwrap();
    c.text.push(c.source[at]);
    let mut total = c.scratch[mark];
    let mut e = c.nodes[n].first;
    while e >= 0 {
        total += walk(c, e, depth + 1);
        e = c.nodes[usize::try_from(e).unwrap()].next;
    }
    c.scratch.truncate(mark);
    total
}
fn compile(source: &[u8]) -> i64 {
    let mut nodes=vec![Node {at:0,first:1,next:-1},
                       Node {at:1,first:-1,next:2}, Node {at:2,first:-1,next:-1}];
    let mut text=Vec::new(); let mut scratch=Vec::new();
    let mut c=Ctx {source,nodes:&mut nodes,text:&mut text,scratch:&mut scratch};
    let r=walk(&mut c,0,1);
    use std::io::Write;
    let mut out=std::io::stdout().lock();out.write_all(&text).unwrap();
    writeln!(&mut out," {}",scratch.len()).unwrap();
    r
}
Example 53

Difference: Rust explicitly names the borrowed context lifetime and uses exclusive mutable borrows. Vec ownership/drop replaces the Goose storage model, and signed offsets are checked before conversion to usize.

compile("abc") prints abc 0 and returns 5. walk pushes into, indexes and shrinks the tables through c, as it would through separate reference parameters. The tables are locals of compile, so they are owned outside the cycle and outlive every activation of walk. The grow-shrink scratch is a stack shared by the whole recursion: each activation pushes above its callers' entries and truncates back to its mark before it returns.

The context lives for one call of compile. A second call, for another file in the same process, starts from fresh tables, and when each call's scope exits, all its tables are freed at once, so there is no global state to reset. When the code that works on the tables is small enough to fit inside the driver, nested functions can use the tables as free variables with no struct, as the samples' parsers do. The struct is for code spread over many functions or files.


14. Threads that share nothing

There is no shared mutable memory. A worker is declared thread_fn, and the compiler compiles it and everything it calls as a separate program with its own data stacks and its own copies of the globals it uses.

Values pass between threads through typed queues, with one queue per type, selected by the type. The values that may cross must be flat: they hold no references and no slices, at any depth. Copying them is cheap, because a flat Goose value is contiguous, so copying it into the queue is a memcpy.

Goose
struct Job    { n: i32 }
struct Result { n: i32, digits: i32 }

thread_fn worker() {
    loop {
        let job = qget<Job>();           // blocks
        guard job.n >= 0 else { break; } // -1 means stop
        qput(Result { n: job.n, digits: count_digits(job.n) });
    }
}

fn main() {
    let n = max(1, hardware_threads());
    for i in 8 { qput(Job { n: (1 << (i * 3)) as i32 }); }
    for i in n { qput(Job { n: -1 }); }              // one quit job per worker
    var ids: i64[>..] = [];
    for i in n { ids.push(thread_spawn(worker)); }
    var total = 0;
    for i in 8 { let r = qget<Result>(); total += r.digits; }
    for id in ids { thread_wait(id); }
    print(n, " workers, ", total, " digits in total");
}
Rust counterpart
use std::sync::mpsc::{self, Receiver, Sender};
struct Job { n: i32 }
struct Result { n: i32, digits: i32 }
fn worker(jobs: Receiver<Job>, results: Sender<Result>) {
    for job in jobs {
        if job.n < 0 { break; }
        results.send(Result { n: job.n, digits: count_digits(job.n) }).unwrap();
    }
}
fn main() {
    let n=std::thread::available_parallelism().map_or(1,usize::from);
    let (results_tx,results_rx)=mpsc::channel();
    let mut jobs=Vec::new();let mut ids=Vec::new();
    for _ in 0..n {
        let (tx,rx)=mpsc::channel(); let results=results_tx.clone();
        jobs.push(tx);ids.push(std::thread::spawn(move || worker(rx,results)));
    }
    for i in 0..8 { jobs[i % n].send(Job {n:1 << (i*3)}).unwrap(); }
    for queue in &jobs { queue.send(Job {n:-1}).unwrap(); }
    let mut total=0;
    for _ in 0..8 { let r=results_rx.recv().unwrap();total+=r.digits; }
    for id in ids { id.join().unwrap(); }
    println!("{n} workers, {total} digits in total");
}
Example 54

Difference: Rust transfers owned messages through per-worker std channels (round-robin dispatch), not Goose's type-selected queue. Rust threads share an address space and do not automatically clone globals. The aggregate result matches; queue scheduling differs.

Because a "flat value" can include variable-size parts, a job or a result can carry the data itself rather than a handle to it. A struct Row { y: i32, cells: u8[] } crosses the queue as one copy, including its pixels, which is what 23_mandelbrot_threads does.

A worker that increments a global increments its own copy, and main's copy is unchanged. That may sound restrictive, but it means there can be no data races, and the language has no locks, atomics or memory ordering.

(Threads need an external C compiler: the in-process TinyCC backend cannot place thread-local storage, so compile threaded programs with -o.)


15. Saving and loading data as bytes

If a structure's links are self-relative offsets, the structure means the same thing wherever it is in memory. So saving it is a write of its bytes, and loading it is a read. There is no serializer, no schema, no pointer fixup and no allocation per node.

Goose
struct Node { word: u8[..24], count: i32, left: Node&<u32>?, right: Node&<u32>? }

var index: Node[>..] = [];
// ... build the tree ...

var image: u8[>..] = index.to_bytes();      // a framed image of the array
var loaded, ok = from_bytes<Node[>..]>(image);
print("loaded ", ok, ", ", loaded.len, " nodes, 'fox' = ", lookup(loaded, "fox"));
Rust counterpart
use index_image::{Node, encode_nodes, decode_nodes, lookup}; // accompanying safe codec
let index: Vec<Node> = build_index(); // the omitted tree-building context
let mut image: Vec<u8> = encode_nodes(&index).unwrap();
let decoded = decode_nodes(&image);
let ok = decoded.is_ok();
let loaded = decoded.unwrap_or_default();
println!("loaded {ok}, {} nodes, 'fox' = {}", loaded.len(), lookup(&loaded, b"fox"));
Example 55

Difference: Different-format adaptation: the accompanying codec explicitly encodes bytes and node indices. It is not Goose-wire-compatible and never treats Vec or Rust struct memory bytes as a serialized value.

bytes_of(a) is the same thing without the copy: a read-only view of the element region, which you can pass directly to write_file.

The more interesting half is from_bytes. Bytes arriving from outside the program are the only place where a reference could enter without the compiler having proved it valid, so from_bytes verifies before the bytes become a value. It checks the framing, every tag, every length, and that every link points to the start of an element in that same image. A corrupt or hostile file gives false and an empty array, never a wild reference.

Goose
image[image.len - 3] = 200;                  // tamper with a link
var bad, bok = from_bytes<Node[>..]>(image);
print("accepted: ", bok, ", nodes ", bad.len);
Rust counterpart
let at = image.len() - 3;
image[at] = 200; // corrupt an index in this Rust codec's image
let decoded = decode_nodes(&image);
let bok = decoded.is_ok();
let bad = decoded.unwrap_or_default();
println!("accepted: {bok}, nodes {}", bad.len());
Example 56

Difference: This rejection belongs to the different RIDX format from the Rust counterpart above, not Goose's image verifier. The edited child index is out of bounds; valid data edits need separate integrity checks.

loaded true, 9 nodes, 'fox' = 4
accepted: false, nodes 0

What it verifies is safety, not integrity. An image whose data bytes were edited still describes a well-formed structure, so it takes a checksum, not the verifier, to tell you that the file is the one you wrote. What comes back is an ordinary array: you can index it and grow it, references into it are ordinary references, and the lifetime system needs no new rule for it.


16. Calling C

An extern fn binds a Goose signature to a C symbol. The math and os modules use this foreign-function interface.

Goose
extern fn cube_root(x: f64) -> f64 = "cbrt";
extern fn hypot(x: f64, y: f64) -> f64;

extern fn crc32_bytes(s: const u8[:]) -> u32;   // a slice in, a scalar out
extern fn stats_of(xs: i32[:], out: Stats&);    // a struct filled through a reference
extern fn c_version(out: u8[>..]&);             // a builder C appends to
Rust counterpart (FFI declaration fragment)
use std::ffi::c_void;
#[repr(C, packed)]
struct ByteSlice { data: *const u8, len: i64 }
#[repr(C, packed)]
struct I32Slice { data: *mut i32, len: i64 }
#[repr(C, packed)]
struct GooseBuilder { header: *mut c_void, stack: *mut c_void }
unsafe extern "C" {
    #[link_name = "cbrt"] fn cube_root(x: f64) -> f64;
    fn hypot(x: f64, y: f64) -> f64;
    fn crc32_bytes(s: ByteSlice) -> u32;
    fn stats_of(xs: I32Slice, out: *mut Stats);
    fn c_version(out: GooseBuilder);
}
Example 57

Difference: FFI declaration adapter only: Stats needs its real definition and all descriptors must match the generated C header/target ABI. Goose's builder is a header/stack descriptor passed by value, not a Rust Vec reference. No C implementation is linked or exercised here.

The types that may cross are exactly those with a plain C shape: the scalars and bool, flat fixed-size structs and arrays (by value, or through a reference as a pointer), slices of those (as a { data, len } struct), and a u8[>..]& builder. Anything else is rejected at the declaration rather than mis-marshalled at the call. A call compiles to a direct C call with no calling-convention extras. Your own C can be added with --include header.h.


17. About the speed

Goose's performance depends on both its memory model and compiler optimization. The distinction helps when choosing data layouts and tuning loops.

Benefits of the memory model. There is no allocator on any path. There is no teardown: a scope exit is a pointer store, not a walk. Data is contiguous, so the cache does less work: an array of records is one block, not one block per record. Links are narrow: 2- and 4-byte offsets where a pointer would be 8. And with variable-mode enums, a tree of mostly-small nodes no longer reserves room for its largest variant in every node.

Where it does need the optimizer, and where you can help it. Every index is bounds-checked, and the compiler proves most of the checks unnecessary and removes them. But "most" is not "all", and any check it cannot prove unnecessary stays in the program. What helps is to give the analysis a length it can use. Here is a blur kernel over row slices:

Goose
let W = 512;

// Each row of the image is a slice; the caller passes three source rows and
// one destination row per output row.
fn blur_row(above: u8[:], here: u8[:], below: u8[:], out: u8[:]) {
    assert(above.len == W && here.len == W && below.len == W && out.len == W);
    for x in 1..W - 1 {
        let sum = above[x - 1] as i64 + above[x] + above[x + 1] +
                  here[x - 1]  + here[x]  + here[x + 1] +
                  below[x - 1] + below[x] + below[x + 1];
        out[x] = (sum / 9) as! u8;
    }
}
Rust counterpart
const W: usize = 512;
fn blur_row(above: &[u8], here: &[u8], below: &[u8], out: &mut [u8]) {
    assert!(above.len() == W && here.len() == W && below.len() == W && out.len() == W);
    for x in 1..W - 1 {
        let sum = i64::from(above[x - 1]) + i64::from(above[x]) + i64::from(above[x + 1]) +
                  i64::from(here[x - 1])  + i64::from(here[x])  + i64::from(here[x + 1]) +
                  i64::from(below[x - 1]) + i64::from(below[x]) + i64::from(below[x + 1]);
        out[x] = u8::try_from(sum / 9).unwrap();
    }
}
Example 58

Difference: Rust explicitly widens all nine operands. The average is always 0..=255, so the checked narrowing succeeds. Rust requires an exclusive output slice; this signature does not permit overlapping input/output aliases. No Rust bounds-check-elimination result is asserted.

goose check --stats reports what it removed:

bce: elided 11/11 index and 0/4 slice checks

With one assert per row, every index inside the pixel loop is a loop counter plus a constant, checked against a known length. So all eleven checks are removed, and the C backend gets the same loop it would get from C. Without the assertion, none of them are removed, and the loop does not vectorize. --bce-lines tells you, per line, which checks remain; use it when a kernel is slower than you expect.

Two language rules also help the analysis. A grow-only array can only shrink at a pop/pop_n/resize/clear the compiler can see, so a bound established before a push still holds after it. And because % is Euclidean, the result of a reduction is always in range.

What the numbers say (bench/summary.md, bench/results.md): over sixteen benchmarks, Goose runs at 3.3x the speed of idiomatic C++, 1.16x the speed of hand-optimized C++ and 1.05–1.11x the speed of the best safe Rust, on 1.9x/1.3x/1.2x less memory. The losses are listed too, with explanations. particles is a flat float kernel where the design never predicted an advantage, and there is none. calc, at 0.82x against Rust, is the only loss the summary attributes to Goose's own design rather than to a backend, and the summary names the three things that cause it. Every measurement is whole-process wall-clock time including teardown, which takes time in the other languages and none in Goose.


18. Limitations

You will meet all of these. Each one links to the section above that shows how to work with it, where there is one, and to the specification rule that imposes it.

An ordinary struct still has at most one resizable tail. A root struct bundles state with independently stored fields, including multiple resizable arrays. Declare the owner as a local or global variable and pass it by reference.


19. Calling Goose from C

export fn is the reverse of extern fn: it makes a Goose function callable from C, so a Goose module can be linked into a C program or called from Rust through the C ABI.

Goose
var calls: i32 = 0;

export fn hello_world() -> const u8[:] {
    calls += 1;
    return "Hello, world!"
}

export "c_add" fn add(a: i32, b: i32) -> i32 { a + b }

fn main() {
    calls = 100;    // runs once, inside goose_init
}
Rust counterpart
use std::cell::Cell;
use std::ffi::c_char;
thread_local! { static CALLS: Cell<i32> = const { Cell::new(0) }; }
#[unsafe(no_mangle)]
pub extern "C" fn goose_init(_argc: i32, _argv: *mut *mut c_char) {
    CALLS.with(|calls| calls.set(100)); // this example's startup state only
}
/// Safety: length must point to a live, writable, aligned i64.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn hello_world(length: *mut i64) -> *const u8 {
    CALLS.with(|calls| calls.set(calls.get() + 1));
    const MESSAGE: &[u8] = b"Hello, world!";
    unsafe { *length = MESSAGE.len() as i64; }
    MESSAGE.as_ptr() // static bytes, not NUL-terminated
}
#[unsafe(export_name = "c_add")]
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }
Example 59

Difference: Rust spells the C ABI, exported symbols and pointer-length result explicitly. This goose_init only initializes this example's Rust state; it is not a replacement for the Goose runtime. Calls follow the original same-thread contract.

An export fn is an ordinary Goose function; the compiler adds a C wrapper for it. The optional string picks the C symbol; without it, the Goose name is used. When you ask for a header, the compiler builds the C for a C host program, with no main of its own:

goose --standalone --header hello.h -o hello.c hello.goose
cc hello.c app.c -o app
#include "hello.h"
#include <stdio.h>

int main(int argc, char **argv) {
    int64_t length;
    goose_init(argc, argv);
    const uint8_t *message = hello_world(&length);
    fwrite(message, 1, (size_t)length, stdout);   // not NUL-terminated
    putchar('\n');
    printf("%d\n", c_add(20, 22));
    return 0;
}

The host calls goose_init once before anything else. It sets up the runtime and the globals and then runs your Goose fn main(), so main is the place for a library's own start-up code. A library with nothing to start up may leave main out. When the host is done with the program, goose_exit() releases what goose_init set up, and goose_init can start it again: that is how a host reloads a library it has rebuilt. A const u8[:] result comes back as a pointer plus an extra int64_t * parameter that receives its length; scalars and flat fixed-size values are returned by value. The generated functions are not thread-safe: call them from the thread that called goose_init. The signatures that may cross are the ones from section 16, with a few more limits; see spec §7.11.


20. Where to go next

One last piece of advice: when the checker rejects something, read the whole error. It names both ends of the problem: the reference and where it was bound, the holder and the line that stored into it, or the instantiation and the call chain that produced it. Use those locations to decide which binding, store, or scope needs to change.