goose README

The project's README from github.com/aardappel/goose. As one markdown file: README.md.

The Goose programming language

The Goose Programming Language

A memory-safe systems language with no heap allocator, garbage collector, or lifetime annotations. In this repository's benchmarks, Goose runs faster than C++ and Rust while using less memory.

Tutorial · Specification · Samples · Benchmarks · Standard library

Goose has syntax familiar to C and Rust programmers, but there is no heap. Every dynamic value lives inline on a data stack managed by the compiler. Growth advances a pointer, and memory is freed when its scope ends. The language's type and lifetime rules make this memory model safe to use.

Why Goose

The tutorial walks through all of this by example, the specification has the exact rules, and the benchmarks have the numbers, losses included.

Features

This section focuses on what distinguishes Goose. The tutorial explains these features in more detail, and the samples show them in twenty-eight complete programs.

One memory model: stacks, and scope exit is the free

A program has the native call stack, static data and N data stacks, where the compiler works out N. A data stack is a large address-space reservation with a bump pointer, and there is no other memory. At most one resizable value is live per stack and it is always on top, so growth never moves anything and never checks a capacity. All of this is proved at compile time; the runtime tracks only the bump pointers.

for round in 3 {
    var scratch: u8[>..] = [];                        // grow-only: growth is a pointer bump
    for i in 100000 { scratch.push((i % 256) as! u8); }
    print("round ", round, ": ", scratch.len, " bytes");
}                                                     // the free: one store to the stack top

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

push returns a reference to the new element, so a program can link elements while building the array. A vector<T> or Vec<T> may reallocate and cannot provide this guarantee. Stable references account for several benchmark gains.

Safe references, no annotations

Every reference and slice has a static root: the variable that bounds its target's lifetime. A reference must not outlive the variable that owns its target or access that target through an incompatible type. The compiler infers roots and specializes functions for them, so no annotations are needed. There are no aliasing or exclusivity rules. When a lifetime check fails, the error names the reference's root and its destination:

fn longest(a: u8[:], b: u8[:]) -> u8[:] { if a.len >= b.len { a } else { b } }

var outer: u8[>..] = [];
var w = outer[..];
{
    var inner: u8[>..] = [];
    format(inner, "inner text");
    w = longest(outer, inner);     // error: storing a reference rooted at inner,
}                                  //        which does not outlive the destination (§9.2)

A slice T[:] lets a function accept a range from any array kind without copying its elements. For example, split returns slices into its input, and a dictionary keyed by u8[:] stores views of strings rather than copies.

Nested data stays inline

There is no single array type. There is a family that differs only in what happens to the size, and every member is [metadata][elements...], inline and packed, never a pointer to an element block:

Spelling What it is
T[k] fixed size, known at compile time
T[], T[varint] sized at construction, frozen after; a fixed or variable-width length
T[..k], T[..] capacity inline; grows and shrinks within it
T[>..] grow-only: the workhorse; references into it stay valid
T[>..<] grow-shrink: stacks, queues, heaps

Strings are just u8 arrays: u8[>..] is a builder, u8[] a finished string stored inline in whatever holds it, u8[..16] a small string inside a struct, u8[:] a view. A struct may contain variable-size parts, and they sit inline in declaration order, so a record is a run of bytes with nothing indirect in it:

struct Item  { sku: u8[varint], qty: varint, cents: varint }
struct Order { id: varint, customer: u8[varint], items: Item[varint] }

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

That order is 29 bytes and 0 allocations. As C++ std::string + std::vector<Item> it is 160 bytes and 1 allocation, and as Rust String + Vec<Item> 153 bytes and 4. A whole order book is one array that streams through the cache. The price is that an array of variable-size elements is sequential: you can iterate it but not index it.

Enums in two sizes

Algebraic data types are the only dynamic polymorphism: no inheritance, no vtables. Every ADT can be stored two ways, chosen at the point of use. Fixed mode (Shape) is a tag plus room for the largest payload, indexable and overwritable. Variable mode (Shape..) gives each value exactly its variant's size; the array becomes sequential and a value never changes variant, and in exchange you may take references into a payload:

enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }

var packed: Shape..[>..] = [];                 // 9, 17 and 1 bytes, not 17 each
packed.push(Shape.Circle { r: 1.0 });
packed.push(Shape.Rect { w: 2.0, h: 3.0 });
packed.push(Shape.Dot);
for s in packed {
    match s { Rect &r => { r.w += 1.0; }, _ => {} }   // edits the payload in place, inside the array
}

Case functions provide another way to dispatch on a variant: define one overload per variant. Calls dispatch on the tag through a jump table, and the compiler checks that the overloads cover every variant:

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 packed { print(s, " area ", area(s)); }

A T& is a machine address: eight bytes, never dangling. A relative reference is the same link stored as a narrow offset: T&<u32> is measured from the field itself to a target in the same array, so the structure is position independent, and T&<u32 in pool> from a named pool's base, so other arrays can link into the pool. T&<u32>? uses offset 0 as null. Here is a binary search tree in one grow-only array, with 12-byte nodes:

struct Node { key: i32, left: Node&<u32>?, right: Node&<u32>? }   // 12 bytes, links included

fn insert(pool: Node[>..]&, key: i32) {
    if pool.len == 0 { pool.push(Node { key: key }); return; }
    var cur .= pool[0];                             // .= binds a reference; = would copy the node
    loop {
        if key == cur.key { return; }
        let next = if key < cur.key { cur.left } else { cur.right };
        if next { cur .= next; continue; }          // narrowed by `if`: next is a Node&
        if key < cur.key { cur.left .= pool.push(Node { key: key }); }
        else             { cur.right .= pool.push(Node { key: key }); }
        return;
    }
}

References are transparent (no *, no ->), .= binds or retargets one, and Node? is a nullable reference that if, guard and assert narrow. The insert is one push and one store, and the push cannot invalidate cur. When lifetimes are not nested, a reusable pool pairs a grow-only array with a hidden freelist: alloc_ref reuses a slot or pushes, free hands one back, and since nothing is ever actually freed, a stale slot reads a different value of the correct type rather than corrupting memory.

Your data is already a file format

var image: u8[>..] = tree.to_bytes();           // a framed byte image of the whole array
var loaded, ok = from_bytes<Node[>..]>(image);  // verified before it becomes a value
image[image.len - 3] = 200;                     // tamper with a link ...
var bad, bok = from_bytes<Node[>..]>(image);    // ... and it is false and an empty array

The tree's links are self-relative, so it means the same thing wherever it sits: saving is writing its bytes and loading is reading them back, with no serializer, no schema and no pointer fixups. from_bytes checks the framing, every tag, every length and every link before the bytes become a value, so a corrupt or hostile file is a false, never a wild reference.

Built in place, always

The copy-free construction guarantee (spec §4.3) says a constructed value is always built in its final home, propagated top-down through calls. A function returning a grow-only array by value writes its elements straight into the caller's variable, or into a field of the record being built inside another array, so out-parameters mostly do not appear.

words.push(str("word", i));                    // formatted straight into the new element
let evens = xs.filter() { it % 2 == 0 };       // built straight into `evens`: no temporary
book.push(Order { id: 1001, customer: "alice", items: parse_items("SKU-441:2:1999;SKU-7:1:500") });

Returning errors across calls

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

fn load(text: u8[:]) -> User[>..], u8[] {
    var users: User[>..] = [];
    each_split(text, '\n') { users.push(parse_user(it)); };
    return users, "";
}

fn parse_user(line: u8[:]) -> User {              // returns a User: no Result, no error parameter
    let comma = find(line, ",");
    guard comma >= 0 else { return [], str("expected name,age: ", line) from load; }
    let age, ok = parse_int(line[comma + 1..]);
    guard ok else { return [], str("bad age: ", line) from load; }
    let name = trim(line[..comma]);
    return User { name: name, age: age as i32 };
}

return E from f returns E as the result of the innermost active call of f, however many frames up, and every function in between keeps its plain signature. It is checked statically, so every call of parse_user must lie inside a load call and nothing is uncaught at runtime. It is a hidden discriminant checked per frame rather than an unwinder, and the message is built directly where load's caller wants it. The parsers in the samples use it for every syntax error.

Generics and function blocks

fn twice(x) { x + x }                              // an untyped parameter is a generic one

fn each_pair<T, F>(xs: T[:]) {                     // F is a function value: a compile-time entity
    for i in 0..xs.len - 1 { F(xs[i], xs[i + 1]); }
}

fn first_gap(xs: i64[:]) -> i64 {
    each_pair(xs) { a, b => if b - a > 1 { return a; } };   // returns from first_gap, not each_pair
    return -1;
}

let evens = xs.filter() { it % 2 == 0 };
let total = fold(xs, 0) { acc, x => acc + x };
sort(xs) { a, b => a > b };

Everything is monomorphized. Type arguments are inferred from argument types; parameters absent from those types can be supplied explicitly. Function values are passed as generic parameters: every call is direct and inlinable, they cannot escape, and there are no closure objects or function pointers, so a higher-order function compiles to exactly the loop it looks like. A block may return from its lexically enclosing function, and nested functions see the enclosing function's locals.

Threads that share nothing

thread_fn worker() {                                // a separate program with its own memory
    loop {
        let job = qget<Job>();                      // blocks on the typed queue for Job
        guard job.n >= 0;                           // -1 means stop
        qput(Result { n: job.n, digits: count_digits(job.n) });
    }
}

for i in n { ids.push(thread_spawn(worker)); }

There is no shared mutable memory. A thread_fn and everything it calls is compiled as its own program with its own data stacks and its own copies of the globals. Values cross through typed queues, one per type, and must be flat, with no references at any depth. That is cheap because a flat Goose value is contiguous: a job or a result carries real data, pixels included, and crossing is a memcpy.

Plain C in, plain C out

extern fn hypot(x: f64, y: f64) -> f64;             // straight from libm
extern fn crc32_bytes(s: const u8[:]) -> u32;       // a slice crosses as { data, len }
extern fn stats_of(xs: i32[:], out: Stats&);        // a struct filled through a pointer

An extern fn binds a Goose signature to a C symbol. The math and os modules use this interface. Only types with supported C representations may cross the boundary; unsupported signatures are rejected at the declaration. The compiler emits one C file for the whole program. The bundled TinyCC backend can compile and run that C inside the Goose compiler's process.

Where the speed comes from

Much of the speed comes from the memory model: no heap allocation or per-object teardown, contiguous data, narrow links, and enums sized for the variant they contain. Every index is bounds-checked, and the compiler removes checks it can prove unnecessary. An assert on a slice length often lets a kernel eliminate its remaining checks and vectorize. --bce-lines reports the checks that remain. Benchmark measurements use whole-process wall-clock time, including teardown.

Geometric mean over 16 benchmarks vs idiomatic C++ vs hand-optimized C++ vs best safe Rust
speed, MSVC backend 3.30x 1.16x 1.04x
speed, clang backend 3.35x 1.19x 1.12x
peak memory 1.93x less 1.30x less 1.24x less

The summary explains the gains and losses; results.md has every measurement, and design.md explains what the suite tests.

What it costs you

Tutorial §18 is the full list.

Build and run

You need CMake 3.20 or later, a C++20 compiler (MSVC, clang or gcc) and Python 3 for the test and sample runners. Three submodules are optional: TinyCC, which the in-process backend is built from, SDL3, which the gfx graphics module is built from, and Box3D, which the physics module is built from. The compiler builds without these submodules, but the corresponding features are unavailable: JIT execution, graphics, or physics.

git clone --recursive https://github.com/aardappel/goose
cd goose
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release

To leave SDL or Box3D out, clone without --recursive and then run git submodule update --init on the ones wanted, or configure with -DGOOSE_GFX=OFF or -DGOOSE_PHYSICS=OFF. On Linux, SDL needs the X11 or Wayland development packages (third_party/SDL/docs/README-linux.md); without them the build leaves the gfx module out and says what to install.

The compiler is build/goose (build/Release/goose.exe with the Visual Studio generator), and it finds the standard library in the source tree it was built from. Run a program straight from source, in-process:

build/goose samples/01_tour.goose

Or generate C and build it with whatever compiler is around. Programs that use threads need this route, since TinyCC cannot place thread-local storage:

build/goose -o tour.c samples/01_tour.goose && cc tour.c -o tour -lm -pthread && ./tour

On Windows that is cl tour.c. Useful flags: --check typechecks without emitting C, -O0/-O1/-O2 set the inlining level, --bce-lines reports the bounds checks kept per line, and -DGS_DEBUG=1 turns on the overflow, range and tag checks in the generated C.

A program using gfx also links the graphics layer and SDL, which the compiler names in a response file:

build/goose samples/27_gfx_cube.goose
build/goose -o cube.c samples/27_gfx_cube.goose && cc cube.c -o cube @$(build/goose --gfx-link cc)

With MSVC that is cl cube.c @<the path goose --gfx-link msvc prints>. A program using physics links what goose --physics-link names the same way, next to the gfx one if it draws too:

build/goose samples/28_physics_boxes.goose
build/goose -o boxes.c samples/28_physics_boxes.goose && cc boxes.c -o boxes @$(build/goose --gfx-link cc) @$(build/goose --physics-link cc)

The test suite and the samples run on Windows, macOS and Linux:

python test/run_tests.py
python samples/run_samples.py

For editing, the VS Code extension gives syntax highlighting, compiler checks on save and one-key runs through the JIT:

code --install-extension vscode/goose-language.vsix

Documentation

Status

Goose is new. What exists today is a whole-program compiler of about thirty thousand lines of C++, the specification, the standard library, the samples, and a test suite that CI runs on Windows, macOS and Linux with an extra sanitizer job. Deliberately out of scope for now: moves for resizable values, more than one resizable per struct, labeled break, namespace privacy, and more OS/library access. Open items are tracked in the specification's Appendix B.

License

Goose is licensed under the Apache License, Version 2.0.

Authors

Wouter van Oortmerssen: Language Design, Compiler Design, Coding standards. home page, twitter.

Claude Fable: Compiler implementation, Benchmarking, Sample & Doc writing.

History & Use of AI

This repo was produced almost entirely with AI, from a human language design. I had designed Goose several years ago, and had started to implement it, but running a game startup (which is built on another programming language of mine, Lobster) there was no time to finish it. Which was sad, because I knew Goose could do things other languages can't, and it should exist.

I had not expected AI to help with this until Fable came out. I thought it might be capable enough to do a good job. I made it essentially clone the style and structure of my other recent compiler (Lobster), which is why if you look at the code, it looks rather similar to that. My initial design had left lots of things unspecified, and lots of back and forth with Fable made me decide on all of those, and it is now a better language for it.

It is also an experiment in using AI to implement a new language, rather than reproduce an existing one.

You may wonder why I had it work in C++, if clearly I could have used any language, like Rust, or my own Lobster, or.. Goose itself (that may still happen). With Fable, it was my observation that it is almost equally capable in any language, and the strong guardrails of Rust or other languages are not as pressing as they once were. Since the compiler emits C, you likely already need to have a C/C++ compiler around, so sticking inside one ecosystem would seem to simplify deployment and adoption. I've also integrated libtcc and who knows what other C libraries in the future. And, like I said, to have the compiler code mimic my existing Lobster compiler seemed fun, at least I can read it like it is my own.

goose (aardappel/goose)

Searching Database…

607 ·GitHub
What is this?Goose is a memory-safe systems programming language with no heap allocator, garbage collector, or lifetime annotations. In its own benchmarks it runs faster than C++ and safe Rust while using less memory.
Is it a whole new language, or something built on top of C or Rust?It is a brand-new language whose syntax will feel familiar to C and Rust programmers, but the memory model is its own: every dynamic value lives inline on data stacks the compiler manages, and there is no heap at all.
Where do I start if I want to learn the language?The README links a tutorial that walks through the language by example, a specification with the exact rules, and twenty-eight complete sample programs; benchmarks and standard library docs sit alongside.
What does this memory model cost me in practice?You reason about where data lives: recursive functions cannot own growable data and take a reusable pool as a parameter, arrays of variable-size elements iterate but do not index, and only whole-program compilation is supported. There are no escaping closures or function pointers either.
What does it mean that your data is already a file format?Goose structures use relative references stored as 1, 2, or 4-byte offsets, so they are position independent: saving is writing the bytes, and loading is a read plus a frombytes verification pass that rejects corrupt or hostile files before the bytes become values.
What is special about its enums?Its algebraic data types have two storage modes: fixed mode is indexable and overwritable but pays for the largest variant, while variable mode sizes each value to its own variant — 4x less memory and 2x the speed of a Rust enum on the benchmark that exercises it.
Where does the speed actually come from?Mostly the memory model: no heap allocation or per-object teardown, contiguous inline data, narrow relative links, and variant-sized enums. Every index is bounds-checked, but the compiler removes checks it can prove unnecessary, and --bce-lines reports the ones that remain.
Creator
aardappel
Size
10.9 MB
Stars
607
Age
1mo
Commits · 90 Days