Goose, by example
This tutorial introduces Goose through examples. It assumes some
experience with C, C++, Rust, Go, or a similar language and focuses on
Goose's differences. Start here, go on to the
language reference for 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.
1. The one idea
Here is an array of strings.
var words: u8[][>..] = [];
for i in 5 { words.push(str("word", i)); }In C++ that is a vector<string>: one heap
allocation for the vector, one per string, a pointer and a capacity per
element, and a walk over all of them when it goes out of scope. In Rust
it is a Vec<String> with the same shape.
In Goose it is one contiguous block of bytes — a
length, then five back-to-back (length, characters) runs —
that came into existence by bumping a pointer, and that disappears when
the enclosing scope ends by moving that pointer 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:
- Memory is a handful of big data stacks. Growth is a pointer bump.
- Every dynamic value is owned by a variable. The only deallocation is a scope ending, which resets a pointer.
- Values nest inline. A string inside a struct inside an array is bytes inside bytes inside bytes.
- You may still take references into a growing
container, and the compiler proves they never dangle — no
annotations, no
'a, noRc.
This model has limitations, listed in section 18 with the rules that impose
them and how to work with each, and 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, on 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. packages.md has the
rest of goose build, and how a program uses libraries from
other repositories.)
The bundled TinyCC backend skips the 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.gooseHere is a minimal program:
fn main() {
print("hello, goose");
}print takes any number of arguments of any type, renders
each as text and writes the lot as a single line (a single
write, so two threads never interleave). There is no format
string and no <<.
print("answer=", 42, " ratio=", 0.75, " yes=", 3 > 2);Everything renders, not just scalars: arrays print as
[1, 2, 3], structs as Point { 1, 2 }, a
u8 array as its bytes. This is why the samples print so
much — 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:
let answer = 42;
var count: i32 = 0;
let ratio = 0.75;
count += 1;
count++; // a statement, not an expressionA struct literal names its fields or lists them in order, and may
leave out those that declare a default. Ending it in ..
leaves out any field: one without a declared default gets its type's own
(zero, an empty array or string, null). Globals are declared like
locals, and a var global given only a type starts out the
same way:
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[][>..]; // emptyFunctions are free functions. There are no methods and no
impl blocks, but x.f(a) is exactly
f(x, a), so you can write method-call syntax wherever it
reads better:
fn total(xs: i64[:]) -> i64 {
var t = 0;
for x in xs { t += x; }
return t;
}if, match, block and
{ } are expressions — a block's value is
its trailing expression — so there is no ternary and no "declare it then
assign in every branch":
let kind = if hp > 50 { "healthy" } else { "hurt" };
let level = match hp { 0 => "dead", 1..50 => "low", _ => "fine" };guard states what 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, which is where a return,
break or continue leaves more than the block.
block gives break a target without a loop, so
a chain of checks can bail out with a value:
let verdict = block {
guard p.alive else { break "gone"; }
guard p.hp > 0 else { break "dead"; }
"standing"
};Loops are for x in range_or_array, while,
and loop:
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 indexSeveral return values are a calling convention, not a tuple type —
there is no tuple type — and by custom a trailing bool says
whether the rest means anything:
fn divide(a: i64, b: i64) -> i64, bool {
if b == 0 { return 0, false; }
return a / b, true;
}
let q, ok = divide(7, 2);Variables (or fields, or elements) that already exist take them with
=: q, ok = divide(9, 3);.
Only return and a call's results carry several values,
so a branch can't end in a, b:
return if c { 1, 2 } else { 3, 4 }; is an error, and each
branch returns on its own, as divide does.
Trailing parameters can have defaults, which a call may leave out:
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 -5A default is evaluated at each call that leaves it out. It names what a top-level declaration can — globals, constants, functions — and never the caller's variables, whatever their 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 asking which one you meant —
the language will not silently promote both to 64 bits. The declared
widths determine the operation. 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 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 as a literal does, and a literal branch takes the other branches'
type: if big { x } else { 0.5 } is an f32 for
an x: f32.
A cast that changes nothing is a warning, so the compiler points out
the ones these rules make unnecessary: with b: u8,
let w: i64 = b as i64; says
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 no
such cast.
% 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.
print(-7 % 3, " ", 7 % -3); // 2 1Integer / 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 — that is what hashes and PRNGs mean by unsigned math. Signed overflow aborts in debug and wraps in release.
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. That is the entire memory
system. There is no malloc, no free list, no GC, no
reference counting, no destructors.
So every dynamic value is owned by a variable, and the rules that make this work are:
- Values are pushed in lifetime order and popped en masse when the owning scope exits.
- 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 no move, ever.
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 simply get three stacks, and stacks are recycled the moment a scope frees one. 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 — there is no syntax for a stack anywhere in the language.
Scope exit is the free
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");
}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. No list is walked. The cost of "freeing" that buffer is one store.
Nothing moves, so references survive growth
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]);1000000 items, first is Item { 1, 99.5 }
A vector<T> cannot promise that and a
Vec<T> cannot either: both reallocate, so both
invalidate. This single property is why so much Goose code holds typed
references where the equivalent Rust holds u32 indices into
an arena — and it is where a good share of the benchmark wins come
from.
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, like C. Non-fixed values never copy implicitly, because that copy is O(size) and you should see it:
var a: i64[>..] = [1, 2, 3];
var b = copy(a); // an explicit, visible O(n) copy
b.push(4);Without the copy, var b = a; binds
b to a by reference. Same at a call:
f(xs) hands a growable array to
fn f(xs: i64[:]), to
fn f(xs: i64[>..]&) and to an untyped
fn f(xs) alike, all by reference, without copying the
elements. A function that needs its own copy uses copy.
5. The array family
There is no one "array type". There are several, and they differ in exactly one respect: what happens to the size.
| Spelling | 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.
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');Which to reach for:
[>..]is the default. Arenas, pools, string builders, tree storage, scratch that gets refilled between phases. "Grow-only" names the guarantee — references into it stay valid — not the operation set; it can stillpopandclearat points where the compiler can see nothing is pointing into it.[>..<]for a stack or priority queue. The price is that references into it may live in variables only, never in storage, so the compiler can track them when checking a shrink.[..k]/[..]when the thing must sit inline inside something else: a 16-byte name inside a struct, a small list inside an array element.[]/[varint]for finished data: a string in a record, a node in a pool, anything built once and then only read.
Only [>..] and [>..<] are
"resizable", and it is those that the one-per-stack rule applies to.
Everything else can go anywhere.
6. Strings are just byte arrays
There is no string type. A string is any array-family type with
u8 elements, and which one you pick says what you want:
u8[>..]— a builder. Appending is a pointer bump.u8[]/u8[varint]— a finished string, stored inline in whatever holds it.u8[..16]— a small string that lives inside a struct with no indirection.u8[:]— a view of somebody else's bytes. A string literal is one of these, specifically aconst u8[:], since literals live in read-only static data.
var s: u8[>..] = [];
s.append("hello");
s.push(',');
format(s, " world! ", 6 * 7); // format appends the text of anything
print(s, " [", s.len, " bytes]");hello, world! 42 [16 bytes]
Text that spans lines is a """ string. It is raw, with
no escapes, and the indentation of its closing """ comes
off every line, so it sits in the code at the code's own
indentation:
print("""
usage: wc [-l] file
-l count lines only
""");usage: wc [-l] file
-l count lines only
str(...) formats into a fresh string built directly
at its destination:
words.push(str("word", i)); // written straight into the new elementThere is no temporary string that is then copied into the array. The copy-free construction guarantee (spec §4.3) says a constructed value is always built in its final home, propagated top-down through calls, and the compiler is required not to need a fallback.
Slicing text costs nothing, because a slice is a pointer and a count into bytes that already exist:
let line = " key = some value ";
let body = trim(line);
let eq = find(body, "=");
print("'", trim(body[..eq]), "' -> '", trim(body[eq + 1..]), "'");'key' -> 'some value'
split gives you an array of slices into the
input — a few bytes per part, with no text copied, where most
languages hand back a list of freshly allocated strings:
let parts = split("alice,30,paris", ',');
print(parts.len, " parts: ", parts); // 3 parts: ["alice", "30", "paris"]words splits at runs of whitespace instead, and drops
the empty parts, as a command line wants:
let cmd = words(" go north ");
print(cmd.len, " words: ", cmd); // 2 words: ["go", "north"]And a struct can hold its strings inline, so an array of them is one block you can sort and copy as plain data:
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);[ada (36), grace (45), linus (20)]
A value that is one string or another is a view of whichever it is:
where one branch is a slice, as a literal is, a branch giving such a
field gives a whole-array slice of it, with nothing copied, and the
if is a const u8[:]:
let who = if people.len > 3 { "a crowd" } else { people[1].name };
print(who); // grace7. Slices that cannot dangle
T[:] is a reference plus a count: C++'s
span/string_view, or Rust's
&[T], and lets a function process a range from any
array kind without copying its elements.
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 endAny slice-typed destination takes an array that way, not only a
parameter: a variable, a field, an element or a return. The slice views
the array itself, as fixed[..] would, and is
const where the array is:
let view: const i64[:] = fixed; // fixed itself, not a copy
fixed[0] = 99;
print(view[0]); // 99The difference from string_view is that this one is
checked. Every reference and slice carries a static
root — the variable that bounds the lifetime of what it
points at — and the entire lifetime system is 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, which gets you Rust-lifetime precision through monomorphization with zero 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 here. 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:
var line: u8[>..] = [];
// ...
let w = line[..5];
print(w);
line.clear(); // fine: w's last use is behind the clearA scratch buffer that is refilled per iteration hands out slices of itself freely. "Reusable scratch" and "structure I can point into" are the same type.
Because a slice never copies, it is also the natural key.
The library's dictionary keyed by u8[:] stores no strings
at all — just addresses and lengths into text you already have:
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");That is the whole 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 it (15_word_freq
does exactly this over a book).
The const qualifier describes writability. Writability
is inferred rather than annotated: a string literal is read-only,
&x of a var is writable, and a
slot — a field, an element, 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 nothing
indirect in it at all:
struct Item { sku: u8[varint], qty: varint, cents: varint }
struct Order { id: varint, customer: u8[varint], items: Item[varint] }In most languages Order is a struct owning a
String and a Vec<Item>, each
Item owning another String. Here an order is
its header followed by its items, laid end to end, and the whole order
book is one array of those.
// "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");
}alice owes 4498 cents over 2 items
It is worth counting the bytes, because this is the sort of record a
real program has millions of. That order — id 1001, customer
alice, two items with 5 and 7-character SKUs — is:
| 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++ gets off lightly here only because all three strings are short
enough for the small-string optimization — one character more in a SKU
and it is four allocations too.
The smaller layout reduces cache traffic when traversing the order
book. And id, qty and cents cost
one byte each rather than eight, because a varint is as
wide as its value needs.
Note also parse_items returning a growable array by
value and that costing nothing: the callee is compiled knowing its
destination and writes the items directly into the order's field, inside
the book. Returning a built-up value and out-parameter style are the
same cost in Goose, which is why the out-parameter style mostly does not
appear.
The price of variable-size elements is that the array becomes sequential: offsets are data-dependent, so you can iterate it but not index it.
for o in book { print(o); } // fine
print(book[0]); // error: arrays of variable-size elements
// cannot be indexed, only iteratedThat is a real restriction, and it is why the array family has both
kinds. Use variable elements for things you walk (records, parsed nodes,
log lines); use fixed elements, with a u8[..k] for the
string part, for things you index and sort. When you need both, keep an
index beside the compact array: a second array of references to its
elements can be indexed and sorted while the elements stay packed where
they are. Section 10 ends with one.
9. Enums, in two sizes
Algebraic data types are the only dynamic polymorphism in the language. No inheritance, no traits, no vtables.
enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }What is unusual is that every ADT type can be stored two ways, chosen at the point of use:
Fixed mode (Shape) is what Rust's
enum and C++'s variant do: a tag plus room for
the largest payload, here 1 + 16 = 17 bytes for every value,
Dot included. In exchange it is indexable and you may
overwrite an element with a different variant.
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 placeVariable mode (Shape.. — note the
trailing dots) gives each value exactly its own variant's size: a
Dot is 1 byte, a Circle 9, a Rect
17. The price is that the elements are packed against each other, so the
array is sequential (short of an index you keep beside it, section 10) and a value
can never change variant. In exchange you may take references
into a payload:
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
_ => {},
}
}The two modes offer different guarantees: fixed-mode values can
change variant; variable-mode values allow references into their
payloads. 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. That is what keeps it 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 single biggest source of the memory column in the benchmarks: the
records benchmark uses 4.0x less memory than a Rust
enum whose every element is sized for its
String arm, and runs 2.0–2.3x faster for the same reason —
the cache does less work.
match, and
match written as functions
The inline form is what you expect:
fn name(s: Shape) -> u8[:] {
match s {
Circle c => "circle",
Rect r => if r.w == r.h { "square" } else { "rectangle" },
_ => "other",
}
}An arm may list several variants, as in
Circle, Dot => "round", and then binds no payload: each
variant's payload is a type of its own.
A match on an integer takes literals, half-open ranges and named
constants (let globals, ns::NAME from another
namespace), so a program's own codes read as names there too; one arm
may list several of them. Whether a bare name is a variant or a constant
is up to the scrutinee's type:
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",
}
}The other form is case functions: one overload per variant, called with the enum. This is the virtual-call idiom, without the vtable:
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)); }The call dispatches on the tag through a jump table, and
exhaustiveness is checked exactly as for a match — add a
variant and this stops compiling until it gets its 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: eight bytes, no
arithmetic, no casts, never dangling. 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 are a link stored as a narrow offset:
T&<u32>— self-relative: an offset from the field itself to the target, which must live in the same enclosing array. Position-independent, so a structure built from them can be relocated as a whole. Section 15 explains how this supports serialization.T&<u32 in pool>— pool-relative: an offset from a named global pool's base. A store is a subtraction from a base already in a register, and — the thing self-relative cannot do — other arrays can hold links into the pool.
Widths are u8, u16, u32,
u64 or varint. Loading either form gives you
an ordinary T&; storing one re-encodes.
T&<u32>? uses offset 0 as null, so a link costs
four bytes including its null.
Here is a binary search tree living in one grow-only array, with 4-byte self-relative children:
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;
}
}The example uses three reference features:
.= binds and rebinds a reference. Since
references are transparent — an expression that denotes a
reference behaves as its target everywhere, with no * and
no -> — there has to be some way to say "I mean the
reference itself", and .= is it.
var cur .= pool[0]; names the element;
cur .= l; retargets. (.== is the matching
identity test: == compares the two nodes, .==
asks whether they are the same node.)
Optionals are references that can be null, narrowed by
flow. Node? is a nullable Node& —
address 0, no space cost. You cannot touch it until an if,
while, guard or assert has
narrowed it, and inside that region it simply 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,
where a tree of Box<Node> or
unique_ptr<Node> would be 24 bytes of node plus an
allocation header per node. A thousand random inserts:
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 — there is nothing
yet to point at. self, legal only as the whole initializer
of such a field, means "the value this literal is constructing":
struct Node { val: i64, prev: Node&<u32 in pool>, next: Node&<u32 in pool> }
reusable var pool: Node[>..] = [];
let head = pool.alloc_index(Node { val: 0, prev: self, next: self });That is a circular doubly-linked list's sentinel, allocated before
main runs. Because the sentinel exists, no link in the
whole 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.
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.index_of(n));
}
fn is_end(n: Node&) -> bool { n .== pool[head] }A node is 8 + 4 + 4 = 16 bytes, against a std::list node
plus a map node in the C++ version of the same thing — which is most of
why 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 — a second array with one 4-byte link per element, pushed alongside it:
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"),
}
}
}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; stored in
mark_at, that reference becomes the mark's offset from the
base of marks. mark_at[i] is then a load and
an add, in any order.
Look at what marks holds. A Label is its
tag, a one-byte length and its text, all inside the element:
hello, goose makes it 14 bytes, and the next mark starts
where the text ends. The five marks, strings included, are 44 bytes in
one block, with no pointer and no allocation anywhere. An enum in Rust
or C++ has one fixed size, so a string variant holds a fixed-size string
object that keeps whatever text does not fit in it somewhere else, and
every element is as large as the largest variant. Here each value is
exactly as long as its contents, and the index still reaches any of them
for 4 bytes an element.
The array an in offset is measured from has to be a
global. An array local to a function is indexed the same way with plain
references, (Mark..&)[>..], at 8 bytes an entry. 05_shapes ends
with this example.
11. When lifetimes really are not nested
Some data has lifetimes that do not nest: cache entries, mutable
graph nodes, or files deleted in arbitrary order. Goose supports these
uses with a reusable pool: a grow-only array the compiler
pairs with a hidden freelist.
reusable var 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 backalloc_index / alloc_ref take a free slot if
there is one and push 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.
freedoes not release any memory and does not end any lifetime — it adds an index to a freelist. The slot is still a live, well-typedItemafterwards, and it still belongs to the pool, which still belongs to its owning scope.
Reuse preserves memory and type safety. A reference to a
freed-and-reused slot reads a different Item — a
perfectly good one, just 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 through it:
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)
There is a second flavour, reusable[], that 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 (26_file_tree
builds a whole mkdir/mv/ rm -r
file tree out of the two pools together).
12. Errors, and
return … from
There is no exception mechanism and no Result type, and
no specified error convention either — it is the application's choice.
For a call with two outcomes the trailing bool you have
already seen is enough (let age, ok = parse_age(s);), and
for "found or not" it is a T?, which costs nothing because
it is a reference into the input. Both are ordinary return values.
The one that is not obvious is
return E from f: it returns E
as the result of the innermost active call of f, unwinding
every frame in between. It is the language's lightweight exception, and
it exists because the two idioms above scale badly — a failure eight
frames deep inside a parser would otherwise mean eight signatures
carrying an error they have nothing to do with.
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 };
}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>, not (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 is the two guards that produce a message and the
one if err.len > 0 that reports it.
Three properties keep the cost low:
- It is checked statically. Validity is a
compile-time property: every call site of a function containing
return … from loadmust lie inside the dynamic extent of aloadcall, and the whole-program compiler verifies that in call-graph order. There is no "uncaught" case at runtime. - The implementation is a hidden discriminant, not an
unwinder. Each frame on the path gains a "this result is mine" versus
"propagate to
load" flag; a caller checks it and returns immediately in the second case. Normal epilogues run, so every data-stack watermark restores by itself. No tables, nosetjmp, no destructors — there are none to run. - The result lands where
load's caller wanted it.str("line ", …)builds its message directly atload's return destination, straight past the frames being unwound.
So the happy path pays for a flag check per frame on the way out and
nothing else, which 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 are all 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; it does not provide finally.
The same machinery is what lets a block return from its
enclosing function rather than from the HOF calling it (section 13), which is
the other place you will meet it.
And the ones that stop the program rather than reporting:
assert(c) for invariants, abort(msg) with a
message, exit(code). The checker knows the last two never
return, so a branch ending in either gives no value. That knowledge also
propagates through ordinary calls, including wrappers around
exception::throw: a value-returning function can end by
calling one, 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:
fn twice(x) { x + x }
fn span<T>(a: T, b: T) -> T { if a > b { a - b } else { b - a } }<T> says that two parameters must agree, or names
a type 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:
fn zero<T>() -> T { default<T>() } // T's default value
let half = zero<f64>() + 0.5;Everything is monomorphized, so there is nothing abstract to typecheck: a generic body is only ever checked at an instantiation where every type is concrete, and errors come with the compile-time call chain that produced them.
Overloads resolve on argument types, and a slice parameter takes any array kind:
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..]));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 closures-as-objects and no runtime function pointers. A higher-order function can therefore compile to a direct loop:
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{ ... } after a call is the trailing-block sugar, with
it as the implicit parameter or a, b => for
named ones. The standard library is written this way throughout, and
filter/map build their results straight into
the variable receiving them — no intermediate array, no allocation:
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 };A block may also return from the lexically
enclosing function, not from the HOF calling it — implemented
with the same return from machinery — so a hand-written
iterator has the same structuring power as a for loop:
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;
}Nested functions see the enclosing function's locals, which is how
the parsers in the samples are written: parse's input,
cursor and pools are its locals, and the recursive descent functions
nested inside it use them as free variables instead of passing a state
struct around (17_calc, 18_json).
One thing to know about recursion: it 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 — every activation would need a data stack of its own. So
a recursive builder takes the pool it grows as a parameter:
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); }
}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:
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); }
}That is a real constraint on how you write recursive code, and it is also most of why recursion depth costs nothing but native stack.
Several tables, one context
A compiler or a simulation keeps its state in several growable tables (nodes, symbols, output) and wants to hand them around as one thing. A struct cannot own them, since it holds at most one resizable array, as its last field, and a recursive function cannot keep them across its recursive calls. What works is to make each table a local of a driver function, and to pass the code that works on them one struct of references:
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;
}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, and
they are compile's locals, owned outside the cycle and
outliving every activation of walk. The grow-shrink
scratch is a stack the whole recursion shares: 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 each call's scope exit frees its tables at once, so there is no
global state to reset. Where the code that works on the tables is small
enough to live inside the driver, nested functions reach them as free
variables with no struct at all, 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, at all. 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 cross between them through typed queues — one queue per type, selected by the type — and the values that may cross must be flat: no references, no slices, at any depth. Which is cheap, because a flat Goose value is contiguous, so "copy it into the queue" is a memcpy.
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");
}Because a "flat value" includes variable-size parts, a job or a
result can carry real data rather than a handle to it — a
struct Row { y: i32, cells: u8[] } crosses the queue as one
copy, pixels included, which is exactly what 23_mandelbrot_threads
does.
A worker that increments a global increments its own copy;
main's is untouched. If that sounds restrictive, notice
what it removes: there are no data races to have, and no locks, atomics
or memory ordering anywhere in the language.
(Threads need a real C compiler — the TinyCC in-process backend
cannot place thread-local storage, so compile with -o for
these.)
15. Your data is already a file format
If a structure's links are self-relative offsets, it means the same thing wherever it sits in memory. So saving it is a write of its bytes, and loading it is a read. No serializer, no schema, no pointer fixups, no allocation per node.
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"));bytes_of(a) is the same thing without the copy — a
read-only view of the element region, which you can hand
straight to write_file.
The interesting half is from_bytes. Bytes arriving from
outside the program are the one place a reference could enter without
the compiler having proved it, so from_bytes
verifies before the bytes become a value: the framing,
every tag, every length, and every link landing on an element start of
that same image. A corrupt or hostile file is a false and
an empty array, never a wild reference.
image[image.len - 3] = 200; // tamper with a link
var bad, bok = from_bytes<Node[>..]>(image);
print("accepted: ", bok, ", nodes ", bad.len);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, and it is a checksum, not the verifier, that tells you the file is the one you wrote. What comes back is an ordinary array: it indexes, it grows, 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.
extern "cbrt" fn cube_root(x: f64) -> f64;
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 toWhat may cross is exactly what has 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 arrives 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. No allocator on any path. No teardown — a scope exit is a pointer store, not a walk. Contiguous data, so the cache does less work: an array of records is one block, not one block per record. Narrow links: 2 and 4-byte offsets where a pointer would be 8. And variable-mode enums, where a tree of mostly-small nodes stops paying for its largest variant everywhere.
Where it does need the optimizer, and where you can help it. Every index is bounds-checked, and the compiler proves most of them away — but "most" is not "all", and what it fails to prove stays in the program. The idiom that works is to give the analysis a length it can use. Here is a blur kernel over row slices:
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;
}
}bce: elided 11/11 index and 0/4 slice checks
One assert per row, and every index inside the pixel
loop is a loop counter plus a constant against a known length, so all
eleven checks go and the C backend sees the loop it would have seen from
C. Without it, none of them do, and the loop does not vectorize.
--bce-lines will tell you, per line, exactly which checks
survived — 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 % being Euclidean means a
reduction is in range by construction.
What the numbers actually say (bench/summary.md, bench/results.md): 3.3x
the speed of idiomatic C++, 1.16x hand-optimized C++, 1.05–1.11x the
best safe Rust, on 1.9x/1.3x/1.2x less memory, over sixteen benchmarks.
Read the losses too — they are in there, 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 one loss the summary attributes to Goose's own
design rather than to a backend, and it says exactly which three things
cost it. Every measurement is whole-process wall clock including
teardown, which is a real cost the other languages pay and Goose does
not.
18. What it costs you
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.
- You have to think about where data lives. Not
constantly, but the question "who owns this, and how long does its scope
last" is one you now answer explicitly. Most of the time the answer is
"the function that builds it", and that is free. When it is not, it is a
reusablepool (section 11; spec §1.2, §5.4). - Recursive functions cannot keep growable data across a recursive call. Scratch in a block that ends before the call is fine; anything that lives longer is passed in. This changes how a recursive-descent parser is structured — the state becomes locals of the non-recursive entry function, or a struct of references to them, which is arguably nicer, but it is a change (Several tables, one context; spec §7.8).
- Arrays of variable-size elements cannot be indexed. You pick, per container, between "compact and walkable" and "indexable", or pay for an index of references beside a compact one (section 10; spec §3.3).
- A fixed-mode enum cannot be pointed into; a variable-mode one cannot be overwritten. You pick, per use site (section 9; spec §3.5, §8.1).
- No closures that escape, no function pointers, no dynamic dispatch beyond ADT tags. The closed-world compiler is what makes roots and destinations static; it is also what stops you writing a plugin system (section 13; spec §7.6, §8.2).
- Whole-program compilation. No separate compilation, no shared libraries of Goose code (spec §10.1).
- A
reusablepool manages its elements loosely. Nothing is freed and nothing is unsafe, but a slot you handed back and then still name reads whatever its next owner put there — the array equivalent of a stale index, and yours to avoid (section 11; spec §9.4). - v1 omissions you will notice: no move for
resizables, one resizable per struct, no labeled break, no namespace
privacy (spec
§12), no subprocesses or networking in the library yet (
os).
19. Calling Goose from C
extern fn goes the other way: export fn
makes a Goose function callable from C, so a Goose module can be linked
into a C or C++ program as a library.
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
}An export fn is an ordinary Goose function; the compiler
adds a C wrapper for it. The optional string picks the C symbol, and
otherwise the Goose name is used. Ask for a header and the compiler
builds the C for a C host, 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. 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
docs/reference.md— the whole language, topic by topic, with a compiling example for every rule: where to go after this tutorial, and where to look things up.samples/— thirty-one complete programs in reading order, each one commented for what it demonstrates. Start with01_tourand02_memory, then jump to whatever looks like your problem.13_linked_list,14_bstand18_jsonare the ones that show the data structures in detail;26_file_treeuses both pool kinds together.docs/stdlib.md— the library reference. Fourteen modules:std,dictionary,vec,math,os,binary,base64,csv,json,regex,audio,gfx,physics, andui, with Goose interfaces understdlib/.docs/goose_spec.md— the actual rules, when you want to know why something did not compile. It is precise rather than friendly, and it is where every "spec §" in this document points.bench/— the numbers and the design notes behind them.docs/implementation.md— how the compiler works, if you want to hack on it.
One last piece of advice: when the checker rejects something, read the whole error. It names both ends — the reference and where it was bound, the holder and the line that stored into it, the instantiation and the call chain that produced it. Use those locations to decide which binding, store, or scope needs to change.
Goose