跳到正文
Hacker News· andsoitis·· 5 小时前AI 评分58

Wouter van Oortmerssen 发布 Goose 编程语言

The Goose Programming Language

AI 导读

Wouter van Oortmerssen 发布 Goose,一门无堆分配器、无 GC、无生命周期标注的内存安全系统语言,编译器约三万行 C++ 并输出单个 C 文件。

正文 · AI 翻译

The Goose programming language

一种内存安全的系统级语言,无需堆分配器、垃圾回收器或生命周期标注。在本仓库的基准测试中,Goose 运行速度比 C++ 和 Rust 更快,同时占用更少内存。

教程 · 参考 · 规范 · 包 · 示例 · 基准测试 · 标准库

Goose 的语法对 C 和 Rust 程序员来说很熟悉,但没有堆。每个动态值都内联存放在由编译器管理的数据栈上。增长会推进指针,内存在其作用域结束时释放。该语言的类型和生命周期规则使这一内存模型可以安全使用。

Discord Chat Twitter Follow

为什么选择 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 让每个值占用其自身变体的大小,而非最大变体的大小:在测试它的基准测试中,内存少 4 倍,速度是 Rust enum 的 2 倍。
  • 链接比指针更窄。相对引用以 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 的独特之处。教程更详细地解释了这些特性,示例通过三十一个完整程序展示了它们。

单一内存模型:栈,作用域退出即释放

程序拥有原生调用栈、静态数据和 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 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

树的链接是自相对的,因此无论它位于何处,含义都相同:保存就是写入其字节,加载就是读回它们,无需序列化器、无需模式、无需指针修正。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 }                              // 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 值是连续的:一个作业或一个结果携带真实数据,包括像素,而跨越只是一个 memcpy。

以 thread_spawn_contained 启动的 worker 中,检查失败或 abort 只会结束该 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;             // 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 通常能让内核消除其余检查并向量化。--bce-lines 报告剩余的检查。checkfree fn 如果任何边界检查存活则拒绝该内核;索引类型让图边保留构造期间完成的验证。基准测量使用整个进程的墙钟时间,包括销毁。

16 个基准的几何平均值 对比惯用 C++ 对比手工优化 C++ 对比最佳安全 Rust
速度,MSVC 后端 3.51x 1.24x 1.13x
速度,clang 后端 3.54x 1.26x 1.19x
峰值内存 少 1.95x 少 1.32x 少 1.25x

摘要 解释了收益和损失;results.md 包含每项测量,design.md 解释了测试套件测试的内容。

它给你带来的成本

  • 你要考虑数据存放在哪里:谁拥有它,它的作用域持续多久。通常答案是“构建它的函数”,这是免费的;当不是时,就是一个 reusable 池。
  • 递归函数无法在递归调用之间保留可增长的数据;临时数据在调用前结束,比它存活更久的内容作为池传入。
  • 可变大小元素的数组可以迭代但不能索引(除非在它们旁边保留一个引用索引),固定模式枚举不能被指向,可变模式枚举不能被覆盖。你按容器选择。
  • 没有逃逸闭包,没有函数指针,没有超出 ADT 标签的动态分派,且仅限全程序编译。
  • 交还给 reusable 池但仍被命名的槽位会读取其下一个所有者放入的内容:这是逻辑错误,绝不是内存损坏。

教程 §18 是完整列表。

安装发行版

Windows (x64)、Linux (x64) 和 macOS(Apple 芯片和 Intel)的预构建编译器在 发行版页面 上,图形、音频、ui 和物理包已编译在内。将其解压到任意位置,并将其目录放入 PATH:编译器会在自身旁边找到标准库、TinyCC 运行时和包,并将构建的内容保存在 ~/.goose 下(GOOSE_HOME)。归档还包含这些文档、示例和 VS Code 扩展。

在进程内运行程序(goose file.goose)在 Windows 上不需要其他东西。在 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 Release

GOOSE_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 所定义的内容。每个可调整大小的值都位于一个保留 2 GB 地址空间的数据栈上,随使用而提交:--stack-reserve 16G 为程序更改这一点,--stacks 报告程序及其每个 worker 可以占用多少数据栈,以及这对 hardware_threads() 施加的上限,而 -DGS_STACK_STATS=1 使程序在结束时报告其实际使用量。

一个自带 C 代码的程序会在源码旁的 sidecar 中声明它, foo.build 在 foo.goose 旁: c_sources = ["impl.c"];,带有 c_include, c_defines、c_libs 和 _windows, 每个都有 _linux 或 _macos 变体,用于某个 平台的部分。两种形式都能构建它:goose build 将其链接 进来,而 goose run 将其构建为一个库,由进程内 运行加载,因此 extern fn 无论哪种方式都能到达它。库 也以同样方式处理,在其入口旁放置 sidecar(或指定其自身 CMake 项目的静态 库目标, cmake_target = "x";,编译器通过 cmake 构建),而程序在其根文件中用 import name from "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 程序的响应文件位于其绘制所经由的 gfx 之前)。 goose build --dry-run 会为当前程序打印所有这些信息。

使用 audio 的程序通过 SDL3 播放程序化单声道/立体声 PCM, 而不打开图形窗口。参见 音频参考。

测试套件和示例在 Windows、macOS 和 Linux 上运行:

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

编辑方面,VS Code 扩展 提供语法高亮、编译器的语言服务器 (goose lsp:输入时显示错误、悬停、转到定义、 补全、重命名、符号)以及通过 JIT 的一键运行:

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

其他编辑器可以自行运行 goose lsp;编辑器 提供了 Neovim、 Helix 和 Emacs 的配置。

文档

  • 文档索引:下方所有内容的导图,也是由 这些文件构建的文档站点的根(如何构建)。
  • 教程:友好的 入门介绍,以示例讲解。请先读这个。
  • 语言参考:每个特性, 逐主题讲解,每个都有可编译的示例:整个语言, 为阅读而写。
  • 语言规范:精确的 规则,当你想知道某些东西为什么无法编译时使用。
  • 示例:三十一个完整 程序,按阅读顺序排列,从语言导览到 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 能做到其他语言做不到的事情,它应该存在。

在 Fable 出现之前,我并未预料到 AI 能在这方面帮上忙。我认为它可能足够有能力做好这件事。我让它基本上克隆我另一个近期编译器(Lobster)的风格和结构,这就是为什么如果你看代码,它看起来和那个相当相似。我最初的设计留下了许多未指定的地方,而与 Fable 的大量来回讨论让我对所有这些做出了决定,现在它因此成为一门更好的语言。

这也是一次使用 AI 实现一门新语言、而非复现一门现有语言的实验。

你可能会问,既然我显然可以使用任何语言,比如 Rust、我自己的 Lobster,或者……Goose 本身(这仍有可能发生),为什么我让它用 C++ 工作。借助 Fable,我观察到它在任何语言中的能力几乎相同,而 Rust 或其他语言的强护栏已不像从前那样紧迫。由于编译器输出 C,你很可能已经需要手边有一个 C/C++ 编译器,所以待在一个生态系统内似乎能简化部署和采用。我还集成了 libtcc,未来还不知会集成哪些其他 C 库。而且,就像我说的,让编译器代码模仿我现有的 Lobster 编译器似乎很有趣,至少我可以像读自己的代码一样读它。

来源:Hacker News · aardappel.github.io