Goose 没有堆、没有 GC,也没有生命周期标注,编译器是 AI 写的
Wouter van Oortmerssen 的数据栈内存模型,让释放一个百万元素的结构只花一次存储。16 项基准里 Goose 比惯用 C++ 快 3.3 倍、比最好的安全 Rust 快 1.12 倍,而支撑它的三万行 C++ 编译器几乎全部由 Claude Fable 写成。
中文
复制

一门内存安全的系统级语言,没有堆分配器,没有垃圾回收,也不需要生命周期标注。在本仓库的基准测试中,Goose 比 C++ 和 Rust 跑得更快,占用内存却更少。
教程 · 参考手册 · 语言规范 · 包 · 示例 · 基准测试 · 标准库
Goose 的语法对 C 和 Rust 程序员来说并不陌生,但它没有堆。每个动态值都内联存放在由编译器管理的数据栈上。增长时只需推进指针,作用域结束时内存随之释放。语言的类型规则和生命周期规则保证了这套内存模型可以安全使用。
为什么选 Goose
-
比 C++ 和安全的 Rust 更快,同时内存安全。 在十六项基准测试中,Goose 的速度是惯用 C++ 的 3.3 倍、手工优化 C++ 的 1.16 倍、最佳安全 Rust 的 1.12 倍,内存占用则分别少 1.9 倍、1.3 倍和 1.2 倍(结果)。这些优势来自 Goose 的数据布局和内存模型。
-
没有分配器,没有 GC,没有引用计数,没有析构函数。 内存就是若干个数据栈,由编译器静态分配。释放一个百万元素的结构只需一次写入,无论嵌套多深。
-
增长不破坏引用。 指向正在增长的数组的引用,只要数组还在就始终有效。在相应的基准测试里,C++ 只能用
reserve,安全 Rust 只能用u32索引,而你可以保留带类型的引用。 -
内存安全,零标注。 没有生命周期语法,没有别名或独占规则,没有
unsafe。编译器推断每个引用根植于什么,并拒绝那些可能比所有者活得更久的引用。 -
嵌套数据保持内联。 一个字符串、一个字符串数组、一个含变长字段的记录,以及由这种记录组成的数组,各自都是一块连续内存,内部不含指针。一个在 C++ 中占 160 字节、需要一次分配的记录,在 Goose 中只有 29 字节,且无需分配。
-
枚举的开销等于它实际容纳的内容。 可变模式 ADT 让每个值只占其所属变体的大小,而不是最大变体的大小:在测试这一特性的基准中,内存是它的 1/4,速度是它的 2 倍,对比对象是 Rust 的
enum。 -
链接比指针更窄。 相对引用以 1、2 或 4 字节的偏移量存储带类型、带检查的链接。由它构建的结构与位置无关,因此你的数据结构本身就是它的文件格式:保存就是一次写入,加载就是一次读取加一遍校验,恶意字节会被拒绝。
-
一切都在最终位置就地构建,有保证。 一个值无论经过多少层调用,都在它的最终目的地构造。
items.push(parse(line))把解析出的记录直接写入数组,按值返回可增长数组也不产生任何开销。 -
跨多层调用返回。
return err from load可以从任意多层栈帧之上的函数返回,静态检查,无需 unwinder,无需Result类型,每次调用也没有?开销。 -
线程之间不共享任何东西。 worker 被编译成拥有独立内存的独立程序,扁平值以
memcpy的形式穿过带类型的队列。数据竞争、锁、原子操作和内存序在这门语言里根本不存在。 -
泛型与高阶函数,零额外开销。 未标注类型的参数即为泛型。函数值是编译期实体,因此
xs.filter() { it > 0 }会编译成它看起来的那个循环,并把结果直接构建到目标位置。 -
纯 C 进,纯 C 出。 Goose 编译为单个 C 文件,因此只要有 C 编译器的地方就能运行,并通过
extern fn直接调用 C。内置的 TinyCC 后端在进程内完成编译和运行,无需构建步骤。
教程用示例把这一切走了一遍,参考手册按主题覆盖了整个语言,规范给出精确的规则,基准测试给出数字,包括损耗。
特性
本节聚焦 Goose 与众不同之处。教程更详细地讲解这些特性,示例用三十一个完整程序把它们展示出来。
一种内存模型:栈,作用域退出免费
一个程序有原生调用栈、静态数据和 N 个数据栈,N 由编译器算出。数据栈是一大块预留的地址空间,配一个 bump pointer,除此之外没有别的内存。每个栈上最多只有一个可增长的值存活,而且它始终在栈顶,所以增长从不移动任何东西,也从不检查容量。这一切都在编译期得到证明;运行时只跟踪 bump pointer。
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
什么都不移动,所以引用能在增长后存活
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 返回指向新元素的引用,因此程序可以在构建数组的同时把元素链接起来。vector<T> 或 Vec<T> 可能重新分配,给不了这个保证。稳定的引用贡献了若干项基准测试的提升。
安全的引用,无需标注
每个引用和切片都有一个静态的根:即限定其目标生命周期的那个变量。引用的存活时间不得超过拥有其目标的变量,也不得通过不兼容的类型访问该目标。编译器推断根并针对根对函数做特化,因此不需要任何标注。没有别名规则,也没有独占规则。生命周期检查失败时,错误信息会指出引用的根和它的去向:
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)
切片 T[:] 让函数可以接受来自任意数组类型的区间,而不复制其中的元素。例如,split 返回指向其输入的切片,而以 u8[:] 为键的字典存储的是字符串的视图,不是副本。
嵌套数据保持内联
没有单一的数组类型。有一族类型,彼此的区别只在于大小如何处理,而每个成员都是 [metadata][elements...],内联且紧凑排列,绝不是指向元素块的指针:
| 拼写 | 是什么 |
|---|---|
T[k] | 固定大小,编译期已知 |
T[]、T[varint] | 构造时确定大小,之后冻结;长度固定或可变 |
T[..k]、T[..] | 容量内联;在其中增长和收缩 |
T[>..] | 只增:主力选手;指向它的引用始终有效 |
T[>..<] | 可增可减:栈、队列、堆 |
字符串就是 u8 数组:u8[>..] 是构建器,u8[] 是成品字符串,内联存放在持有它的东西里,u8[..16] 是结构体内的小字符串,u8[:] 是视图。结构体可以包含可变大小的部分,它们按声明顺序内联排列,所以一条记录就是一串字节,中间没有任何间接层:
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 }] });
这个顺序是 29 字节、0 次分配。同样的东西写成 C++ 的 std::string + std::vector<Item> 是 160 字节、1 次分配,写成 Rust 的 String + Vec<Item> 是 153 字节、4 次分配。整个订单簿就是一个数组,在缓存里一路流过。代价是可变大小元素的数组是顺序的:可以迭代,但不能索引(除非另存一个指向它的引用数组当索引)。
枚举的两种尺寸
代数数据类型是唯一的动态多态:没有继承,没有虚表。每个 ADT 都有两种存储方式,在使用处选择。固定模式(Shape)是一个标签加上最大载荷的空间,可索引、可覆写。可变模式(Shape..)让每个值恰好占用其变体的大小;数组变成顺序的,值永远不会改变变体,换来的是你可以取指向载荷内部的引用:
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 函数提供了另一种按变体分派的方式:为每个变体定义一个重载。调用通过跳转表按标签分派,编译器会检查这些重载是否覆盖了所有变体:
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)); }
链接比指针更窄
T& 是机器地址:八个字节,永不悬空。相对引用是把同样的链接存成窄偏移:T&<u32> 从字段自身量到同一数组中的目标,因此结构体与位置无关;T&<u32 in pool> 从具名池的基址量起,于是其他数组可以链接进这个池。T&<u32>? 用偏移 0 表示空。下面是一个放在只增数组里的二叉搜索树,节点 12 字节:
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;
}
}
引用是透明的(没有 *,也没有 ->),.= 会绑定或重定向一个引用,Node? 是可空引用,由 if、guard、assert 收窄。插入操作就是一次 push 加一次 store,而且这次 push 不会让 cur 失效。当生命周期不嵌套时,reusable 池把一个只增数组和一个隐藏的空闲链表配在一起:alloc_ref 复用槽位或 push,free 归还一个槽位;由于实际上从不释放任何东西,读到过期槽位得到的是同类型的另一个值,而不是内存损坏。
你的数据本身就是一种文件格式
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
树的链接是自相对的,所以它放在哪里含义都一样:保存就是写出它的字节,加载就是把字节读回来,不需要序列化器,不需要 schema,也不需要指针修正。from_bytes 在字节变成值之前检查帧结构、每个 tag、每个长度和每条链接,因此损坏或恶意的文件只会得到一个 false,绝不会变成野引用。
始终原地构造
免拷贝构造保证(spec §4.3)规定,构造出的值总是在它最终的归宿里构建,并沿调用自上而下传播。一个按值返回只增数组的函数,会把元素直接写进调用方的变量,或写进另一个数组内部正在构建的记录字段,因此出参基本不会出现。
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") });
跨调用返回错误
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 把 E 作为 f 最内层活跃调用的结果返回,无论向上隔了多少帧,中间每个函数都保持原本的签名。这一点在静态期检查,所以每次调用 parse_user 都必须位于一次 load 调用之内,运行时不会有未捕获的错误。它按帧检查一个隐藏的判别值,而不是用栈展开器;消息直接在 load 的调用方需要的地方构建。示例中的解析器对每个语法错误都用它。
泛型与函数块
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 };
一切都是单态化的。类型实参从参数类型推断;不出现在这些类型中的参数可以显式提供。函数值作为泛型参数传递:每次调用都是直接的、可内联的,它们无法逃逸,也没有闭包对象或函数指针,因此一个高阶函数编译出来就是它看起来的那个循环。块可以 return 其词法外层函数,嵌套函数能看到外层函数的局部变量。
互不共享的线程
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)); }
不存在共享的可变内存。一个 thread_fn 及其调用的所有内容都会编译成独立的程序,拥有自己的数据栈和自己的全局变量副本。值通过带类型的队列传递,每种类型一个队列,而且必须是扁平的,任何深度都不能有引用。这样做开销很低,因为扁平的 Goose 值是连续存放的:一个 job 或一个 result 携带的是真实数据,包括像素在内,而跨线程传递只是一次 memcpy。
以 thread_spawn_contained 启动的 worker 中,一次失败的检查或 abort 只会终止该 worker。它的消息会发给等待它的一方,而不是 stderr,因此 supervisor 可以记录该消息并再启动一个:
var why: u8[>..] = [];
while !thread_wait(thread_spawn_contained(serve, attempt), why) {
log(why); // "assert failed (serve.goose:12)"
why.clear();
attempt++;
}
worker 的第一个 thread_wait(id, msg) 会取走消息;不主动索取消息的调用方就会丢失它。
纯 C 进,纯 C 出
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
一个 extern fn 把 Goose 签名绑定到 C 符号。math 和 os 模块使用这一接口。只有具备受支持 C 表示的类型才能跨越边界;不支持的签名会在声明处被拒绝。编译器为整个程序生成一个 C 文件。内置的 TinyCC 后端可以在 Goose 编译器进程内编译并运行这个 C 文件。
速度从何而来
速度很大程度上来自内存模型:没有堆分配,也没有逐对象的销毁,数据连续存放,链接窄,枚举按其承载的变体确定大小。每个索引都做边界检查,编译器会移除它能证明多余的检查。对切片长度做一次 assert,往往就能让 kernel 消除剩余的检查并向量化。--bce-lines 会报告仍然保留的检查。只要还有任何边界检查残留,checkfree fn 就会拒绝该 kernel;索引类型让图的边得以保留构造期间完成的校验。基准测试使用整个进程的墙钟时间,包含销毁阶段。
| 16 项基准的几何平均 | 对比惯用 C++ | 对比手工优化 C++ | 对比最佳安全 Rust |
|---|---|---|---|
| 速度,MSVC 后端 | 3.51x | 1.24x | 1.13x |
| 速度,clang 后端 | 3.54x | 1.26x | 1.19x |
| 峰值内存 | 少 1.95x | 少 1.32x | 少 1.25x |
summary 解释了得失;results.md 包含全部测量数据,design.md 说明了测试套件测的是什么。
代价是什么
- 你得时刻想着数据存在哪里:谁拥有它,它的作用域能持续多久。答案通常是“构建它的那个函数”,这是免费的;如果不是,那就是一个
reusable池。 - 递归函数无法在递归调用之间保留可增长的数据;临时区在调用前就结束了,能活过调用的数据要以池的形式传进去。
- 变长元素的数组可以迭代,但不能索引(除非在旁边另存一份引用索引),固定模式的枚举不能被指向,可变模式的枚举不能被覆写。每个容器由你自行选择。
- 没有逃逸闭包,没有函数指针,没有 ADT 标签之外的动态派发,且只支持全程序编译。
- 一个已经交还给
reusable池、却仍被引用的槽位,读到的是下一个拥有者放进去的内容:这是逻辑错误,永远不会是内存损坏。
完整清单见教程 §18。
文档
- 文档索引:下面所有内容的导航图,也是由这些文件构建的文档站点的根(构建方式)。
- 教程:以示例入门的友好介绍。先读这个。
- 语言参考:按主题逐一讲解每个特性,每个特性都配有可编译的示例:整门语言,写来就是给人读的。
- 语言规范:精确的规则,当你想知道某段代码为什么编译不过时看这里。
- 示例:三十一个完整程序,按阅读顺序排列,从语言导览到 JSON 解析器、多线程 Mandelbrot、由两个池构建的文件树、GPU 上旋转的立方体、成千上万个箱子落进堆里,以及用控件窗口做的待办清单。
- 标准库:十四个模块,全部是
stdlib/下可读的 Goose 代码,包括gfx(基于 SDL3 GPU API 的图形,构建方式)、physics(基于 Box3D 的刚体物理,构建方式),以及ui(字体、文本渲染与游戏 HUD、基于 Nuklear 的窗口与控件,通过 gfx 绘制,构建方式)。 - 构建程序与使用包:
goose build、来自 git 仓库或目录的库、程序自带的 C 代码,以及如何制作你自己的包。 - 基准测试:数据,附完整结果和背后的设计。
- 实现说明:编译器如何工作,逐个 pass 讲解,以及如何测试。
现状
Goose 还很新。目前已有的是一个约三万行 C++ 的整程序编译器、语言规范、标准库、示例,以及一套由 CI 在 Windows、macOS 和 Linux 上运行的测试,另有一个额外的 sanitizer 任务。目前刻意不做的:可调整大小值的移动、每个 struct 只能有一个可调整大小值、带标签的 break、命名空间私有性,以及更多的操作系统/库访问。待办事项记录在规范的附录 B中。
许可证
Goose 采用 Apache License, Version 2.0 授权。
作者
Wouter van Oortmerssen:语言设计、编译器设计、编码规范。主页,twitter。
Claude Fable:编译器实现、基准测试、示例与文档撰写。
历史与 AI 的使用
这个仓库几乎完全由 AI 产出,语言设计则来自人类。Goose 是我几年前设计的,也动手实现过,但当时在经营一家游戏创业公司(它建立在我的另一门语言 Lobster 之上),没时间把它做完。这挺可惜的,因为我知道 Goose 能做到别的语言做不到的事,它应该存在。
我原本没指望 AI 能帮上忙,直到 Fable 出现。我觉得它也许足够能干好这件事。我让它基本照搬我最近那个编译器(Lobster)的风格和结构,所以如果你去看代码,会发现两者相当相似。我最初的设计留下了很多没定的地方,和 Fable 反复来回讨论之后,这些我都做了决定,Goose 也因此成了一门更好的语言。
这也是一次实验:用 AI 去实现一门新语言,而不是复现一门已有的语言。
你可能会问,既然我明明可以用任何语言,比如 Rust、我自己的 Lobster,或者……Goose 本身(这也许仍会发生),为什么让它用 C++ 来做。用 Fable 时我观察到,它在任何语言里的能力几乎都一样,Rust 等语言那些强力的护栏也不像从前那么必要了。既然编译器输出 C,你多半本来就得装一个 C/C++ 编译器,待在一个生态里似乎能简化部署和采用。我还集成了 libtcc,将来还不知会接进哪些别的 C 库。而且就像我说的,让编译器代码模仿我现有的 Lobster 编译器挺有意思,至少我能像读自己的代码一样读它。