Goose: 实验性语言,比 C++ 快 1.16 倍,比安全 Rust 快 1.12 倍,内存安全

Hacker News Top 工具

摘要

Goose 是一种实验性的内存安全系统编程语言,声称比 C++ 和 Rust 更快,同时使用更少的内存,没有分配器或垃圾回收器。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/18 03:14

aardappel/goose 源代码:https://github.com/aardappel/goose

Goose 编程语言

一种内存安全的系统语言,比 C++ 和 Rust 更快,内存消耗更少,无需分配器/GC,也无需生命周期标注。

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

Goose 看起来像 C 或 Rust,其构建基于一个核心理念:不存在堆。每个动态值都内联存在于编译器管理的数据栈上,增长是指针移动,而作用域退出是唯一的释放方式。语言其余部分的设计都是为了让这一理念在真实程序中生效,而其带来的收益是可衡量的。

为何选择 Goose

  • 比 C++ 和安全的 Rust 更快,同时保证内存安全。 在十六项基准测试中,Goose 运行速度分别是地道 C++ 的 3.3 倍、手动优化 C++ 的 1.16 倍和最佳安全 Rust 的 1.12 倍,内存消耗则分别低至 1.9 倍、1.3 倍和 1.2 倍(摘要完整结果)。这些优势是结构性的:它们源于其他语言无法表达的特性。
  • 无分配器、无 GC、无引用计数、无析构函数。 内存是编译器静态分配的少量数据栈。释放一个百万元素的结构体只需一次存储,无论其嵌套多深。
  • 内容永不移动。 指向增长中数组的引用在数组存在期间始终有效。在 C++ 必须使用 reserve 而安全 Rust 退守 u32 索引的地方,你可以保留类型化的引用。
  • 零标注的内存安全。 无需生命周期语法、无别名或排他性规则、无 unsafe。编译器推断每个引用的根在哪,并且只反对一件事:引用活得比所有者更久。
  • 彻底扁平。 一个字符串、一个字符串数组、一个包含可变大小字段的记录及其数组,每一个都是连续的块,其中没有任何指针。C++ 中 160 字节并需要分配的记录,在 Goose 中只有 29 字节且无需分配。
  • 枚举的成本就是其内容的成本。 变长模式 ADT 为每个值分配其变体所需的大小,而非最大变体的大小:在测试其性能的基准中,比 Rust enum 内存减少 4 倍,速度提升 2 倍。
  • 比指针更窄的链接。 相对引用将类型化、经过检查的链接存储为 1、2 或 4 字节的偏移量。由此构建的结构与位置无关,因此你的数据结构本身就是文件格式:保存即写入,加载即读取加一次验证(拒绝恶意字节)。
  • 一切皆就地构建,保证如此。 值通过任意深度的调用在其最终位置构造。items.push(parse(line)) 将解析后的记录直接写入数组,按值返回可增长数组没有开销。
  • 无管道化的错误处理。 return err from load 可从函数中任意数量的调用帧返回,经过静态检查,无需展开器、无需 Result 类型、无需在每次调用后加 ?
  • 不共享任何内容的线程。 一个 worker 被编译为一个拥有自己内存的独立程序,扁平值通过类型化队列作为 memcpy 传输。数据竞争、锁、原子操作和内存顺序在语言中不存在。
  • 零开销的泛型和高阶函数。 无类型参数即泛型。函数值是编译时实体,因此 xs.filter() { it > 0 } 编译出来就是它看起来的循环,并将结果直接构建到目标中。
  • 纯粹的 C 输入,纯粹的 C 输出。 Goose 编译为一个 C 文件,因此它可以在任何有 C 编译器的地方运行,并通过 extern fn 直接调用 C。捆绑的 TinyCC 后端在进程内编译并运行程序,无需构建步骤。

教程 通过示例讲解了所有这些内容,规范 包含了精确规则,而基准测试 则提供了数据,包括失败的情况。

特性

此处仅展示 Goose 的独特之处。教程 会正确涵盖相同内容,示例 则是二十六个真实运作的完整程序。

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

一个程序拥有原生调用栈、静态数据和 N 个数据栈(N 由编译器确定)。数据栈是一个带有移动指针的大地址空间预留区域,没有其他内存。每个栈最多只有一个可调整大小的值存活,且它始终位于栈顶,因此增长永不移动任何内容,也永不检查容量。这一切都在编译时得到证明;运行时只保留移动指针。

for round in 3 {
  var scratch: u8[>..] = []; // 仅增长:增长是指针移动
  for i in 100000 {
    scratch.push((i % 256) as! u8);
  }
  print("round ", round, ": ", scratch.len, " bytes");
} // 释放:一次存储到栈顶

无内容移动,因此引用在增长中存活

struct Item {
  id: i32,
  weight: f32
}
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 }); // 指向元素 0 的引用
for i in 2..1000001 {
  items.push(Item { i as i32, 0.0 });
}
first.weight = 99.5; // 在一百万次 push 后仍然有效

push 返回一个指向其刚创建元素的引用,这使得数据在构建过程中就能链接起来。vectorVec 会重新分配内存,因此两者都无法承诺这一点,而这正是基准测试优势的重要来源之一。

安全的引用,无标注

每个引用和切片都带有一个静态,即界定其目标生命周期的变量,整个生命周期系统只有一条规则:引用不能比拥有其目标的变量存活更久,并且永远不能以错误的类型查看它。根是推断的,函数针对每个根特化,因此没有相关语法,也没有别名或排他性规则。当检查器反对时,它会命名两端:

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); // 错误:存储了一个根在 inner 的引用,
} // 而 inner 不会比目标(§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,它是 160 字节和 1 次分配;作为 Rust String + Vec,则是 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; }, // 就地编辑数组内的有效载荷
    _ => {}
  }
}

match 还有另一种写法: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& 从字段自身到同一数组中的目标测量,因此结构与位置无关;T& 从命名池的基址测量,因此其他数组可以链接到池中。T&? 使用偏移量 0 作为空值。

这是一个在一仅增长数组中的二叉搜索树,节点为 12 字节:

struct Node {
  key: i32,
  left: Node&?,
  right: Node&?
} // 12 字节,包含链接
fn insert(pool: Node[>..]&, key: i32) {
  if pool.len == 0 {
    pool.push(Node { key: key });
    return;
  }
  var cur .= pool[0]; // .= 绑定一个引用;= 会复制节点
  loop {
    if key == cur.key {
      return;
    }
    let next = if key < cur.key { cur.left } else { cur.right };
    if next { cur .= next; continue; } // `if` 缩窄:next 是一个 Node&
    if key < cur.key {
      cur.left .= pool.push(Node { key: key });
    } else {
      cur.right .= pool.push(Node { key: key });
    }
    return;
  }
}

引用是透明的(无 *,无 ->),.= 绑定或重定向一个引用,Node? 是一个可空引用,被 ifguardassert 缩窄。插入操作是一次 push 和一次存储,并且 push 不会使 cur 无效。当生命周期不嵌套时,一个 reusable 池将一个仅增长数组与一个隐藏的空闲列表配对:alloc_ref 重用槽位或 push,free 将其归还,由于实际上没有任何内容被释放,一个过时的槽位读取的是正确类型的不同值,而非损坏内存。

你的数据已经是文件格式

var image: u8[>..] = tree.to_bytes(); // 整个数组的有帧字节映像
var loaded, ok = from_bytes..]>(image); // 在变成值之前验证
image[image.len - 3] = 200; // 篡改一个链接 ...
var bad, bok = from_bytes..]>(image); // ... 结果是 false 和空数组

树的链接是自相对的,因此无论它位于何处,含义都相同:保存是写入其字节,加载是读回它们,无需序列化器、模式或指针修正。from_bytes 在字节变成值之前检查帧、每个标签、每个长度和每个链接,因此损坏或恶意文件得到 false,而非野指针。

就地构建,始终如此

无拷贝构建保证(规范 §4.3)规定,构建的值始终在其最终位置构建,通过调用自顶向下传播。按值返回仅增长数组的函数将其元素直接写入调用者的变量,或写入另一个数组内正在构建的记录的字段中,因此输出参数大多不出现。

words.push(str("word", i)); // 格式化后直接进入新元素
let evens = xs.filter() { it % 2 == 0 }; // 直接构建到 `evens` 中:无临时变量
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 { // 返回一个 User:无 Result,无错误参数
  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 fE 作为最内层活动 f 调用的结果返回,无论向上多少层,其间所有函数都保持其简单签名。这是静态检查的,因此 parse_user 的每个调用都必须位于 load 调用内,运行时不会有任何未捕获的错误。这是一个每帧检查的隐藏判别符,而非展开器,消息直接在 load 的调用者希望的地方构建。示例中的解析器对每个语法错误都使用它。

无仪式感的泛型,消失的块

fn twice(x) { x + x } // 无类型参数即泛型
fn each_pair(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 队列上阻塞
    guard job.n >= 0; // -1 表示停止
    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; // 直接来自 libm
extern fn crc32_bytes(s: const u8[:]) -> u32; // 切片作为 { data, len } 传输
extern fn stats_of(xs: i32[:], out: Stats&); // 结构体通过指针填充

extern fn 将 Goose 签名绑定到 C 符号,这就是整个 FFI;mathos 模块都构建于此之上。只有具有简单 C 形状的内容可以跨边界传输,其他任何内容都会在声明时被拒绝。编译器为整个程序发出一个 C 文件,任何 C 编译器都可以构建它,并且使用捆绑的 TinyCC,它会在自己的进程内编译并运行程序。

速度来源

不是聪明的优化器。任何路径都没有分配器,没有销毁,连续数据使缓存负担更轻,窄链接,枚举无需在所有地方为最大变体付费。每个索引都进行边界检查,编译器证明大部分检查是多余的;通常切片长度上的一个 assert 就足以让内核移除所有检查并进行向量化,--bce-lines 报告哪些检查保留了下来。所有测量都是包含销毁的整个进程挂钟时间。

| 16 项基准测试的几何平均值 | 与地道 C++ 相比 |

相似文章

依赖 Go

Lobsters Hottest

Solod 是一种新的系统编程语言,它是 Go 的一个子集,复用 Go 的工具链和标准库,同时编译为 C11,提供手动内存管理。

内存安全是生死攸关的问题

Lobsters Hottest

作者认为,内存不安全的开源软件极易受到即将到来的人工智能漏洞查找代理的攻击,这使内存安全成为道德义务,并且Rust必须作为领先且零开销的内存安全语言取得成功。

Rust 中的 GPU 卸载:可移植、安全且快速

Hacker News Top

本文介绍了一种零开销、多厂商的 GPU 编译框架,该框架内置于 Rust 编译器中,利用 Rust 的所有权模型确保内存安全,并实现与原生 CUDA 和 HIP 基准相媲美的性能。