跳到正文
Hacker News· magoghm·· 9 天前AI 评分62

Goose 编程语言发布:无堆分配、无 GC 的内存安全系统语言

The Goose Programming Language

AI 导读

Wouter van Oortmerssen 发布 Goose 编程语言,一种无堆分配器、无 GC、无生命周期标注的内存安全系统语言,所有动态值内联存放在编译器管理的数据栈上。

正文 · AI 翻译

The Goose programming language

一种内存安全的系统语言,没有堆分配器、垃圾回收器或生命周期标注。在本仓库的基准测试中,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 让每个值具有其自身变体的大小,而非最大变体的大小:在测试它的基准测试中,内存减少 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。

纯 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 报告剩余的检查。基准测量使用整个进程的墙钟时间,包括销毁。

16 个基准的几何平均值 对比惯用 C++ 对比手工优化的 C++ 对比最佳安全 Rust
速度,MSVC 后端 3.30x 1.16x 1.04x
速度,clang 后端 3.35x 1.19x 1.12x
峰值内存 少 1.93x 少 1.30x 少 1.24x

摘要 解释了得失;results.md 包含所有测量结果,design.md 解释了测试套件测试的内容。

代价是什么

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

教程 §18 是完整列表。

构建与运行

你需要 CMake 3.20 或更高版本、一个 C++20 编译器(MSVC、clang 或 gcc)以及 Python 3 用于测试和示例运行器。四个子模块是可选的:TinyCC,进程内后端由它构建;SDL3,gfx 图形模块由它构建;Box3D,physics 模块由它构建;以及 Nuklear,ui 模块由它构建。编译器可以在没有这些子模块的情况下构建,但相应功能不可用:JIT 执行、图形、物理或窗口部件。

git clone --recursive https://github.com/aardappel/goose
cd goose
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release

要排除 SDL、Box3D 或 Nuklear,克隆时不使用 --recursive,然后对想要的运行 git submodule update --init,或者使用 -DGOOSE_GFX=OFF、-DGOOSE_PHYSICS=OFF 或 -DGOOSE_UI=OFF 进行配置。在 Linux 上,SDL 需要 X11 或 Wayland 开发包(third_party/SDL/docs/README-linux.md);没有它们,构建会排除 gfx 模块并说明需要安装什么。

编译器是 build/goose(使用 Visual Studio 生成器时为 build/Release/goose.exe),它在构建它的源代码树中查找标准库。直接从源代码在进程内运行程序:

build/goose samples/01_tour.goose

或者生成 C 代码,然后用手边任意编译器构建它。使用线程的程序需要走这条路,因为 TinyCC 无法放置线程局部存储。-o 写出的 C 代码包含程序以及内联进程序的那部分运行时;运行时的其余部分(操作系统层、数据栈预留、线程、打印)是另一个 C 文件,由 --emit-runtime 写出,只需编译一次并与每个程序链接:

build/goose --emit-runtime goose_runtime.c && cc -O2 -c goose_runtime.c
build/goose -o tour.c samples/01_tour.goose && cc -O2 tour.c goose_runtime.o -o tour -lm -pthread && ./tour

在 Windows 上,那就是一次 cl /O2 /c goose_runtime.c 和 cl /O2 tour.c goose_runtime.obj。用程序所用的编译器和标志构建运行时;它自身不占用 -D,而来自另一版本编译器的程序无法与它链接。--standalone 则写出一个包含整个运行时的 C 文件,并自行构建(cc tour.c -o tour -lm -pthread)。有用的标志:--check 进行类型检查而不生成 C,-O0/-O1/-O2 设置内联级别,--bce-lines 报告每行保留的边界检查,-DGS_DEBUG=1 开启生成 C 中的溢出、范围和标签检查,每次中止都会指明其源代码行(以及溢出或转换所涉及的值)。

使用 audio 的程序通过 SDL3 播放过程式单声道/立体声 PCM,而不打开图形窗口。可选的 GOOSE_AUDIO 构建设置独立于 GOOSE_GFX;生成 C 的程序链接由 goose --audio-link msvc|cc 命名的响应文件。参见音频参考。

使用 gfx 的程序还会链接图形层和 SDL,编译器在响应文件中命名它们:

build/goose samples/27_gfx_cube.goose
build/goose -o cube.c samples/27_gfx_cube.goose && cc cube.c goose_runtime.o -o cube @$(build/goose --gfx-link cc)

在 MSVC 上那就是 cl cube.c goose_runtime.obj @<the path goose --gfx-link msvc prints>。使用 physics 的程序以同样方式链接 goose --physics-link 命名的内容,如果它也绘图,则紧挨着 gfx 的那个:

build/goose samples/28_physics_boxes.goose
build/goose -o boxes.c samples/28_physics_boxes.goose && cc boxes.c goose_runtime.o -o boxes @$(build/goose --gfx-link cc) @$(build/goose --physics-link cc)

使用 ui 的程序链接 goose --ui-link 命名的内容,连同 gfx 的那个,因为它的窗口是通过 gfx 绘制的:

build/goose samples/29_ui_todo.goose
build/goose -o todo.c samples/29_ui_todo.goose && cc todo.c goose_runtime.o -o todo @$(build/goose --ui-link cc) @$(build/goose --gfx-link cc)

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

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

编辑方面,VS Code 扩展提供语法高亮、保存时编译器检查,以及通过 JIT 的一键运行:

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

文档

  • 教程:以示例进行的友好介绍。请先读这个。
  • 语言规范:当你想知道某个东西为什么编译不过时,这里有精确的规则。
  • 示例:按阅读顺序排列的二十九个完整程序,从语言导览到 JSON 解析器、多线程 Mandelbrot、由两个池构建的文件树、GPU 上旋转的立方体、成千上万个盒子如雨般落入堆中,以及由控件窗口组成的待办事项列表。
  • 标准库:十个模块,全部是用 stdlib/ 编写的可读 Goose 代码,包括 gfx、基于 SDL3 GPU API 的图形(如何构建)、physics、基于 Box3D 的刚体物理(如何构建),以及 ui、字体、文本渲染和游戏 HUD、基于 Nuklear 的窗口和控件,通过 gfx 绘制(如何构建)。
  • 基准测试:数字,连同完整结果和其背后的设计。
  • 实现说明:编译器如何工作,逐个 pass 地讲解,以及如何测试。

状态

Goose 是新的。今天已有的东西包括:一个约三万行 C++ 的整程序编译器、规范、标准库、示例,以及一个由 CI 在 Windows、macOS 和 Linux 上运行的测试套件,外加一个额外的 sanitizer 任务。目前有意排除在范围之外的内容:可调整大小值的移动、每个结构体多于一个可调整大小字段、带标签的 break、命名空间私有性,以及更多的操作系统/库访问。未完成事项记录在规范的附录 B 中。

许可证

Goose 采用 Apache License, Version 2.0 许可。

作者

Wouter van Oortmerssen:语言设计、编译器设计、编码规范。 主页,twitter。

Claude Fable:编译器实现、基准测试、示例与文档撰写。

AI 的历史与使用

这个仓库几乎完全由 AI 根据人类语言设计生成。 我在几年前设计了 Goose,并开始实现它, 但经营一家游戏初创公司(该公司基于我的另一种编程语言 Lobster 构建)让我没有时间完成它。 这很遗憾,因为我知道 Goose 能做到其他语言做不到的事情, 它应该存在。

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

这也是一个使用 AI 实现新语言,而非 复现现有语言的实验。

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

来源:Hacker News · github.com