The Goose Programming Language
TL;DR · AI 摘要
Goose是一种内存安全的系统编程语言,无需堆分配器和垃圾回收,基准测试显示其性能优于C++和Rust,内存使用更少。
核心要点
- Goose在基准测试中比C++快3.3倍,内存使用减少1.9倍。
- 无堆分配器、垃圾回收和生命周期注解,内存通过编译器管理的数据栈分配。
- 使用相对引用节省内存,结构体无需指针,数据布局紧凑。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Goose编程语言
- 核心特性
- 内存安全
- 无堆分配
- 无垃圾回收
- 性能优势
- 比C++快3.3倍
- 内存使用减少1.9倍
- 数据模型
- 内联数据结构
- 相对引用优化
金句 / Highlights
值得收藏与分享的关键句。
Goose在基准测试中比C++快3.3倍,内存使用减少1.9倍。
无堆分配器、垃圾回收和生命周期注解,内存通过编译器管理的数据栈分配。
使用相对引用节省内存,结构体无需指针,数据布局紧凑。
Goose编程语言 · Goose
Goose编程语言
一种无堆分配器、无垃圾回收器、无生命周期注解的内存安全系统语言。在本仓库的基准测试中,Goose的运行速度比C++和Rust更快,同时使用的内存更少。
教程 · 参考 · 规范 · 包 · 示例 · 基准测试 · 标准库
Goose的语法对C和Rust程序员来说非常熟悉,但没有堆分配。每个动态值都以内联方式存储在编译器管理的数据栈中。增长操作会推进指针,当作用域结束时内存会自动释放。语言的类型和生命周期规则使这种内存模型安全可用。
为什么选择Goose
- 比C++和安全的Rust更快,同时保证内存安全。在十六个基准测试中,Goose的速度达到惯用C++的3.3倍、手动优化C++的1.16倍和最佳安全Rust的1.12倍,使用的内存分别为C++的1/1.9倍、Rust的1/1.2倍(测试结果)。这些性能提升来自于Goose的数据布局和内存模型。
- 无分配器、无GC、无引用计数、无析构函数。内存仅由编译器静态分配的几个数据栈组成。释放一个包含百万元素的结构体只需一次存储操作,无论嵌套深度如何。
- 增长操作保留引用。对增长数组的引用在数组存在期间始终有效。你可以保留类型化引用,而C++必须保留空间,安全的Rust则在相应基准测试中使用u32索引。
- 零注解实现内存安全。无需生命周期语法,无需别名或独占规则,无需unsafe代码。编译器会推断每个引用的根源,并拒绝可能超出所有者作用域的引用。
- 嵌套数据保持内联。字符串、字符串数组、包含可变大小字段的记录以及这些记录的数组,每个都构成一个无指针的连续内存块。一个160字节的记录在C++中需要29字节的分配,而在Goose中则无需分配。
- 枚举的内存开销与其实际存储内容相当。可变模式的代数数据类型为每个值分配其对应变体的大小,而非最大变体的大小:在相关基准测试中,内存占用减少4倍,速度提升2倍,相比Rust枚举。
- 链接比指针更紧凑。相对引用以1、2或4字节偏移量存储类型化、校验过的链接。由它们构建的结构是位置无关的,因此你的数据结构本身就是其文件格式:保存只需写入,加载只需读取加上一个验证过程,可拒绝恶意字节。
- 所有内容都原地构建,保证实现。值在最终位置通过任意深度的调用链直接构造。items.push(parse(line))会将解析后的记录直接写入数组,按值返回可增长数组的成本为零。
- 支持跨多层调用返回。return err from load可从任意数量的调用帧返回,静态检查确保正确性,无需展开器、Result类型或每个调用的?操作符。
- 无共享内存的线程。工作线程作为独立程序编译,拥有独立内存,平面值通过memcpy跨类型化队列传递。语言中不存在数据竞争、锁、原子操作和内存顺序。
- 无开销的泛型和高阶函数。未类型化参数即为泛型。函数值是编译时实体,因此xs.filter() { it > 0 }会编译成看起来像循环的代码,并直接构建到目标位置。
- 输入是纯 C,输出也是纯 C。Goose 编译成一个 C 文件,因此可以在任何有 C 编译器的环境中运行,并通过 extern fn 直接调用 C。附带的 TinyCC 后端可以在进程中编译并运行程序,无需构建步骤。
教程通过示例逐步讲解所有内容,参考文档按主题覆盖整个语言特性,规范包含精确的规则,基准测试包含所有数据(包括损耗)。
特性
本节重点介绍 Goose 的独特之处。教程会更详细地解释这些特性,示例则通过 31 个完整程序展示它们。
一种内存模型:堆栈,作用域退出即释放
程序拥有原生调用堆栈、静态数据和 N 个数据堆栈(编译器决定 N 的值)。数据堆栈是大地址空间保留区域,采用 bump 指针,没有其他内存。每个堆栈最多有一个可调整大小的值,且始终位于顶部,因此增长从不移动任何内容,也无需检查容量。所有这些特性在编译时已证明;运行时仅跟踪 bump 指针。
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 laterpush 返回对新元素的引用,因此程序在构建数组时可以链接元素。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[>..<]grow-shrink
: 栈、队列、堆
字符串仅仅是 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 字节且没有内存分配。如果使用 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 和 1 字节,而非每个 17 字节
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
;
}
,
_
=>
{} }
// 在数组内部直接修改有效载荷
}案例函数提供了另一种根据变体进行分发的方式:为每个变体定义一个重载。通过跳转表根据标签进行调用,编译器会检查重载是否覆盖所有变体:
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& 是机器地址:8 字节,永远不会悬空。相对引用以窄偏移量存储相同链接:T&<u32> 从字段本身到同一数组中的目标进行测量,因此结构是位置无关的;T&<u32 in pool> 从命名池的基地址进行测量,因此其他数组可以链接到该池。T&<u32>? 使用偏移量 0 作为 null。这里有一个使用 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 和一次存储,push 操作不会使 cur 失效。当生命周期不嵌套时,可重用的池将只增长的数组与隐藏的空闲列表配对: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树的链接是自相对的,因此无论其位置如何都表示相同含义:保存即写入其字节,加载即读回这些字节,无需序列化器、无需模式且无需指针修复。from_bytes 会在字节成为值之前验证帧格式、每个标签、每个长度和每个链接,因此损坏或恶意文件只会返回 false,而不会产生野指针。
原地构建,始终如此
无复制构建保证(规范 §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 }
// 未类型化的参数即为泛型参数
fn
each_pair
<
T
,
F
>
(xs
:
T
[
:
]) {
// F 是一个函数值:编译时实体
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
;
} }
;
// 从 first_gap 返回,而非 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 }
;所有内容均为单态化。类型参数从参数类型中推断得出;未出现在这些类型中的参数可以显式提供。函数值作为泛型参数传递:每个调用均为直接且可内联的,无法逃逸,没有闭包对象或函数指针,因此高阶函数编译为与其外观完全一致的循环。一个块可以返回其词法封闭函数,嵌套函数可访问封闭函数的局部变量。
无共享内存的线程
thread_fn
worker
() {
// 一个拥有独立内存的独立程序
loop
{
let
job
=
qget
<
Job
>
()
;
// 在 Job 类型化队列上阻塞
guard
job
.
n
>=
0
else
{
break
;
}
// -1 表示停止
qput
(
Result
{ n
:
job
.
n
,
digits
:
count_digits
(job
.
n) })
;
}
}
for
i
in
n { ids
.
push
(
thread_spawn
(worker))
;
}不存在共享可变内存。thread_fn 及其调用的所有内容均编译为独立程序,拥有独立的数据栈和全局变量副本。值通过类型化队列传递,每个类型一个队列,且必须是扁平结构,任何深度都不能有引用。这非常高效,因为扁平的 Goose 值是连续存储的:一个任务或结果携带真实数据(包括像素),传递过程只需 memcpy。
通过 thread_spawn_contained 启动的 worker 中发生检查失败或中止时,仅终止该 worker。其消息发送给等待它的调用者,而非 stderr,因此监督者可以记录该消息并启动另一个:
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
;
// 直接来自 libm
extern
fn
crc32_bytes
(s
:
const
u8
[
:
])
->
u32
;
// 切片以 { data, len } 形式传递
extern
fn
stats_of
(xs
:
i32
[
:
]
,
out
:
Stats
&
)
;
// 通过指针填充结构体extern fn 将 Goose 签名绑定到 C 符号。math 和 os 模块使用此接口。只有具有受支持 C 表示的类型才能跨越边界;不支持的签名在声明时会被拒绝。编译器为整个程序生成一个 C 文件。捆绑的 TinyCC 后端可以在 Goose 编译器进程中编译并运行该 C 代码。
性能来源
性能提升很大程度上归功于内存模型:无需堆分配或每个对象的清理操作,数据连续存储,链表指针精简,枚举类型根据所含变体大小进行调整。每个索引都会进行越界检查,而编译器会移除可以证明不必要的检查。对切片长度的断言通常能让内核消除剩余检查并实现向量化。--bce-lines 报告剩余的检查项。若任何越界检查无法消除,checkfree fn 会拒绝该内核;索引类型允许图边在构建时保留验证信息。基准测试使用整个过程的墙钟时间(包括清理阶段)。
16个基准测试的几何平均值
对比惯用C++
对比手工优化C++
对比最佳安全Rust
速度,MSVC后端
3.51倍
1.24倍
1.13倍
速度,clang后端
3.54倍
1.26倍
1.19倍
内存峰值
减少1.95倍
减少1.32倍
减少1.25倍
总结部分解释了性能增益与损失;results.md 包含所有测量数据,design.md 说明该测试套件的验证内容。
你需要付出的代价
- 你需要思考数据的存储位置:谁拥有这段数据,它的作用域持续多久。通常答案是"构建它的函数",这无需额外成本;当不是这种情况时,就需要使用可重用池。
- 递归函数无法在递归调用间保留可扩展数据;临时数据在调用前销毁,持久数据需作为池传入。
- 可变大小元素数组只能迭代无法索引(除非额外保存引用索引),固定模式枚举无法被指向,可变模式枚举无法被覆盖。你需为每个容器单独选择。
- 无逃逸闭包、无函数指针、无ADT标签以外的动态调度,且仅支持全程序编译。
- 返回给可重用池的槽位若仍保留原名称,将读取下一个所有者写入的数据:这是逻辑错误而非内存破坏。
教程第18节包含完整列表。
安装发布版
Windows(x64)、Linux(x64)和macOS(苹果芯片和Intel)的预编译器在发布页面提供,包含图形、音频、UI和物理包。解压到任意位置并将目录加入PATH即可:编译器会自动找到标准库、TinyCC运行时和周边包,并将构建内容保存在~/.goose (GOOSE_HOME)。压缩包中还包含本文档、示例代码和VS Code扩展。
在Windows上以内进程方式运行程序(goose file.goose)无需其他依赖。Linux需要C库头文件(libc6-dev或glibc-devel);macOS需要Xcode命令行工具SDK(xcode-select --install)。goose build还需要C编译器:Windows需Visual Studio 2022或更新版本,或clang;其他平台需gcc或clang。Linux编译器需要glibc 2.35或更新版本(Ubuntu 22.04、Debian 12),macOS编译器需要macOS 13.3或更新版本。macOS会阻止下载的压缩包文件运行,直到对解压目录执行xattr -dr com.apple.quarantine。
构建与运行
你需要 CMake 3.27 或更高版本、支持 C++20 的编译器(MSVC、clang 或 gcc)以及 Python 3 来运行测试和示例程序。TinyCC 是进程内后端的构建基础,它已包含在代码库中。图形、音频、UI 和物理模块分别以独立仓库的形式存在(goose_gfx 和 goose_physics;详见 docs/design/packages.md):-DGOOSE_BUILTIN_LIBS=all 会从固定标签(构建时通过 git)获取这些模块并编译到编译器中,使程序无需额外安装即可运行。若未指定此参数,编译器将独立构建,当程序首次导入这些模块时,会在 ~/.goose 目录下构建,此过程需要 git、cmake 和 C 编译器。
git
clone https://github.com/aardappel/goose
cd
goose
cmake
-B
build
-DCMAKE_BUILD_TYPE
=
Release
-DGOOSE_BUILTIN_LIBS
=
all
cmake
--build
build
--config
ReleaseGOOSE_BUILTIN_LIBS 也可以指定部分模块(如 physics 或 gfx,后者包含 audio 和 ui)。在 Linux 系统中,SDL 需要安装 X11 或 Wayland 开发包(详见 third_party/SDL/docs/README-linux.md);若未安装这些依赖,构建过程将跳过 gfx 模块并提示所需安装内容。
运行脚本优先使用 build/Release,其次是 build/RelWithDebInfo,然后是 build,最后是 build/Debug。使用 --exe 参数可指定特定编译器;在启用编译器断言进行测试时,应使用显式的 Debug 可执行文件。编译器位于 build/goose(使用 Visual Studio 生成器时为 build/Release/goose.exe),它会在构建时的源码树中查找标准库。在 Windows 10 1903 及以上版本中,其内嵌的清单文件会设置 UTF-8 进程代码页,使编译器路径不受系统区域设置影响。更早的 Windows 版本仍使用传统代码页。生成的本地可执行文件会保留自身的进程设置。
可直接从源码运行程序(进程内执行,goose run 显式声明此行为,goose check 仅执行类型检查):
build/goose
samples/01_tour.goose或构建可执行文件,生成的文件将位于源码旁(-o 参数可指定其他路径)。编译器会在本机查找 C 编译器(goose toolchain 显示具体信息:Windows 使用 MSVC 或 clang,其他平台使用 cc、gcc 或 clang;--cc 可手动指定),并链接生成的 C 代码、运行时库以及程序使用的本地模块(包括图形模块)。使用线程的程序必须通过此方式构建,因为 TinyCC 不支持线程局部存储。
build/goose
build samples/01_tour.goose
&&
samples/01_tour
build/goose
build samples/27_gfx_cube.goose
&&
samples/27_gfx_cube--debug 参数会在生成的 C 代码中启用溢出检查、范围检查和标签检查,每次异常都会标注对应的源码行(包括溢出或类型转换涉及的值),并以调试信息编译 C 代码;--dry-run 会打印构建命令(供自定义构建系统使用);-v 会在执行时打印命令。中间文件存储在 ~/.goose(GOOSE_HOME)目录下,每个 C 编译器对应的运行时对象仅编译一次。两种模式的实用参数:-O0/-O1/-O2 设置内联级别(构建默认 -O2,运行默认 -O1);--bce-lines 报告每行保留的边界检查;-DGS_DEBUG=1 是 --debug 的定义。所有可调整大小的值都位于 2GB 预留地址空间的数据栈中,按需提交内存:--stack-reserve 16G 可修改单个程序的栈预留空间,--stacks 报告程序及其每个工作线程可使用的数据栈数量及对 hardware_threads() 的限制,-DGS_STACK_STATS=1 会让程序在结束时报告实际内存使用情况。
一个包含自身C代码的程序会在源文件旁生成一个侧边文件,例如foo.build位于foo.goose旁:c_sources = ["impl.c"],并使用c_include、c_defines、c_libs以及针对不同平台的_windows、_linux或_macos变体。两种形式都会构建它:goose build会将其链接进去,而goose run会将其构建为库文件,由进程内运行时加载,因此无论哪种方式extern fn都能访问到它。库文件会以相同方式处理,将其侧边文件置于入口文件旁(或指定自身CMake项目的静态库目标cmake_target = "x",由编译器通过cmake构建),程序则从"source"中通过导入名称获取库文件,根文件中可指定目录("./lib")或Git仓库标签/提交(GitHub上为"owner/repo@v1",其他主机为"host/owner/repo@ref"),文件会被下载到~/.goose并按C编译器单独构建一次(详见docs/design/packages.md)。
底层实现中,goose -o tour.c tour.goose会生成C文件,其中包含程序和运行时内联部分;运行时其余部分(操作系统层、数据栈预留、线程、打印功能)是另一个C文件,由goose --emit-runtime goose_runtime.c生成,需单独编译后与每个程序链接(编译命令示例:cc -O2 tour.c goose_runtime.o -o tour -lm -pthread;Windows下为cl /O2 tour.c goose_runtime.obj)。运行时需使用与程序相同的编译器和参数构建;它不接受自己的-D参数,使用其他版本编译器生成的程序将无法链接到它。--standalone会生成包含完整运行时的C文件,并独立构建。使用audio、gfx、physics或ui的程序还会链接对应模块的层,goose --audio-link msvc|cc、--gfx-link、--physics-link和--ui-link会将这些链接信息写入响应文件(示例:cc cube.c goose_runtime.o -o cube @$(goose --gfx-link cc),UI程序的响应文件会在其绘制的图形模块响应文件之前)。“goose build --dry-run”会打印当前程序的所有构建信息。
使用audio的程序可通过SDL3播放过程生成的单声道/立体声PCM音频,无需打开图形窗口。详见音频参考文档。
测试套件和示例可在Windows、macOS和Linux上运行:
test/run_tests.py
samples/run_samples.py编辑时,VS Code扩展提供语法高亮、编译器语言服务器(goose lsp:边输入边报错、悬停提示、跳转定义、自动补全、重命名、符号导航)以及一键通过JIT运行:
code --install-extension vscode/goose-language.vsix其他编辑器可自行运行goose lsp;编辑器配置支持Neovim、Helix和Emacs。
文档
- 文档索引:下方所有内容的地图,也是由这些文件构建的文档站点根目录(如何构建)。
- 教程:通过示例介绍的友好入门指南。请先阅读此部分。
- 语言参考:按主题介绍所有特性,每个主题附带可编译的示例代码:完整语言文档,专为阅读而编写。
- 语言规范:精确规则,当你需要了解为何某段代码无法编译时查阅。
- 示例:按阅读顺序排列的三十一份完整程序,从语言导览到JSON解析器、多线程曼德博罗集、由两个池构建的文件树、GPU上旋转的立方体、数千个方块坠入堆栈以及带小部件窗口的待办事项列表。
- 标准库:包含十四个模块,所有模块均可在 stdlib/ 目录下阅读,包括 gfx(基于 SDL3 GPU API 的图形渲染,其构建方式)、physics(基于 Box3D 的刚体物理模拟,其构建方式)、ui(基于 Nuklear 的字体、文本渲染、游戏 HUD、窗口和控件,通过 gfx 渲染,其构建方式)等。
- 构建程序和使用包:goose build 工具,从 Git 仓库或目录导入库,程序自身的 C 代码,以及如何创建自己的包。
- 性能基准:具体数值,包含完整测试结果和设计原理。
- 实现说明:编译器如何逐阶段工作,以及其测试方式。
当前状态
Goose 是一个新项目。目前实现的是一个约三万行 C++ 代码的全程序编译器,包含语言规范、标准库、示例代码,以及 CI 在 Windows、macOS 和 Linux 上运行的测试套件(包含额外的 sanitizer 测试)。当前明确不在范围内的功能包括:可变大小值的移动操作、结构体中多个可变大小字段、带标签的 break 语句、命名空间隐私控制,以及更多操作系统/库的访问权限。未完成事项记录在语言规范附录 B 中。
许可证
Goose 采用 Apache License 2.0 许可证。
作者
Wouter van Oortmerssen:语言设计、编译器设计、编码规范。个人主页、Twitter 账号。
Claude Fable:编译器实现、性能测试、示例与文档编写。
AI 的使用历史
这个项目几乎完全由 AI 生成,基于人类设计的语言规范。几年前我设计了 Goose 并开始实现,但当时正在运营一家游戏初创公司(使用我设计的另一种编程语言 Lobster 开发),没有时间完成它。这让我感到遗憾,因为我深知 Goose 能够实现其他语言无法做到的事情,它本应存在。
直到 Fable 出现之前,我从未预料到 AI 能帮助完成这项工作。我认为它可能足够强大,能够胜任这项任务。我让 AI 基本上复制了我最近另一个编译器(Lobster)的风格和结构,这也是为什么如果你查看代码,会发现它看起来与 Lobster 非常相似。我最初的许多设计细节未明确说明,而与 Fable 的多次讨论让我最终确定了所有细节,这使 Goose 成为了更好的语言。
这也是一次使用 AI 实现新语言的实验,而非简单复制现有语言。
你可能会好奇为什么我选择用 C++ 实现编译器,明明我完全可以用 Rust 或我自己的 Lobster,甚至 Goose 本身(这仍可能发生)。通过 Fable 的观察,我发现 AI 几乎在任何语言中都能胜任这项工作,而 Rust 等语言的严格安全机制的重要性已不如从前。由于编译器输出的是 C 代码,你很可能已经需要安装 C/C++ 编译器,因此坚持使用单一生态系统似乎能简化部署和采用流程。我还集成了 libtcc,未来可能还会整合其他 C 库。此外,正如我所说,让编译器代码模仿我已有的 Lobster 编译器似乎很有趣,至少我能像阅读自己的代码一样阅读它。