返回资讯中心
外部精选
软件工程
#社区热议

The Goose Programming Language

https://twitter.com/ID_AA_Carmack/status/2113704269274636568

Hacker Newsandsoitis2026/10/24 · 2 天前30 分钟阅读

以下正文同步自 Hacker News,版权归原站所有,已转换为易读排版。

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 · Reference · Specification · Packages · 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.

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

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

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: i32 in 2..1000001 { items.push(Item { i, 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.

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.

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:

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 (unless you keep a second array of references into it as an index).

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)); }

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 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.

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.

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.

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.

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 else { break; } // -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.

A failed check or abort in a worker started with thread_spawn_contained ends only that worker. Its message goes to whoever waits for it, not to stderr, so a supervisor can log it and start another:

var why: u8[>..] = []; while !thread_wait(thread_spawn_contained(serve, attempt), why) { log(why); // "assert failed (serve.goose:12)" why.clear(); attempt++; }

The first thread_wait(id, msg) for a worker takes the message; a caller that doesn't ask for it loses it.

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 comp

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →