Skip to content
HN On Hacker News ↗

GitHub - aardappel/goose: The Goose Programming Language

▲ 54 points • 69 comments • by bobbydigitales • 3w ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI and human-written content.

88 %

AI likelihood · overall

AI
11% human-written 89% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,741
PEAK AI % 98% · §1
Analyzed
Sep 18
backend: pangram/v3.3
Segments scanned
1 windows
avg 1741 words each
Distribution
11 / 89%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,741 words · 1 segments analyzed

Human AI-generated
§1 AI · 98%

A memory-safe systems language that is faster than C++ and Rust, on less memory, with no allocator/GC and no lifetime annotations. Tutorial · Specification · Samples · Benchmarks · Standard library Goose looks familiar like C or Rust, and is built on one idea: there is no heap. Every dynamic value lives inline on a data stack the compiler manages, growth is a pointer bump, and scope exit is the only free. The rest of the language is what it takes to make that work for real programs, and what it buys is measurable. Why Goose Faster than C++ and than safe Rust, while memory safe. Over sixteen benchmarks Goose runs at 3.3x the speed of idiomatic C++, 1.16x hand-optimized C++ and 1.12x the best safe Rust, on 1.9x, 1.3x and 1.2x less memory (summary, full results). The wins are structural: they come from things the other languages cannot express. No allocator, no GC, no reference counting, no destructors. Memory is a handful of data stacks that the compiler assigns statically. Freeing a million-element structure is one store, however deeply it nests. Nothing ever moves. A reference into a growing array stays valid for as long as the array does. You keep typed references where C++ must reserve and safe Rust retreats to u32 indices. Memory safe with zero annotations. No lifetime syntax, no aliasing or exclusivity rules, no unsafe. The compiler infers what every reference is rooted in and objects to exactly one thing: outliving the owner. Flat all the way down. A string, an array of strings, a record with variable-size fields and an array of those records are each one contiguous block with no pointer in it. A record that is 160 bytes and an allocation in C++ is 29 bytes and none in Goose. Enums that cost what they hold. Variable-mode ADTs give each value its own variant's size rather than the largest one's: 4x less memory and 2x the speed of a Rust enum on the benchmark that exercises it. Links narrower than pointers. A relative reference stores a typed, checked link as a 1, 2 or 4-byte offset. Structures built from them are position independent, so your data structure is already its file format: saving is a write, loading is a read plus a verification pass that rejects hostile bytes. Everything is built in place, guaranteed. A value is constructed at its final destination through any depth of calls. items.push(parse(line)) writes the parsed record straight into the array, and returning a growable array by value costs nothing. Errors without plumbing. return err from load returns from a function any number of frames up, statically checked, with no unwinder, no Result type and no ? on every call. Threads that share nothing. A worker is compiled as a separate program with its own memory, and flat values cross typed queues as a memcpy. Data races, locks, atomics and memory orderings do not exist in the language. Generics and higher-order functions with no overhead. An untyped parameter is generic. Function values are compile-time entities, so xs.filter() { it > 0 } compiles to the loop it looks like and builds its result straight into its destination. Plain C in, plain C out. Goose compiles to one C file, so it runs wherever a C compiler does and calls C directly through extern fn. The bundled TinyCC backend compiles and runs a program in-process, with no build step. The tutorial walks through all of this by example, the specification has the exact rules, and the benchmarks have the numbers, losses included. Features Only what is different about Goose is shown here. The tutorial covers the same ground properly, and the samples are twenty-six complete programs doing it for real. 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 keeps nothing but 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 element it just made, which is how data gets linked up while it is being built. A vector<T> or Vec<T> reallocates, so neither can promise this, and it is where a good share of the benchmark wins come from. Safe references, no annotations Every reference and slice carries a static root, the variable that bounds its target's lifetime, and the whole lifetime system is one rule: a reference must not outlive the variable that owns its target, and must never see it at a wrong type. Roots are inferred and functions are specialized per root, so there is no syntax for any of it, and no aliasing or exclusivity rules either. When the checker objects, it names both ends: 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[:] is the universal "process a range" parameter: every array kind coerces to it for free, and it never copies, so split returns slices into its input and a dictionary keyed by u8[:] stores no strings at all. Flat all the way down 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 } match has a second spelling, case functions: one overload per variant, dispatched on the tag through a jump table and checked for exhaustiveness. It is the virtual call 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 packed { print(s, " area ", area(s)); } Links narrower than pointers 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