跳到正文
zenn · 游戏开发社区· 来源· 灯里(akari)·· 9 小时前AI 评分57

Goose 语言为何不需要序列化器?

Goose言語はなぜシリアライザを必要としないのか?

正文 · AI 翻译

译文尚不完整,完整内容请切换到原文。

引言

2026年10月27日,我在GIGAZINE上看到了一篇关于名为Goose的编程语言的文章。标题是“内存安全、比C++更快、内存占用更少”,到这一步为止都是最近常见的发布宣传。让我停下目光的,是作者的名字和README里的一句话。

作者は Wouter van Oortmerssen さんです。FlatBuffers の作者で、今は自分のゲームスタジオを経営しています。README の「History & Use of AI」には、数年前に Goose を設計して実装を始めたが、ゲーム会社の経営で時間がなくなり、Claude Fable が出て実装できるようになった、とあります。設計は人間、コンパイラの実装とドキュメントは Fable、という分担です。とても現代的ですよね。

FlatBuffers 是一种无需解析(解码)字节序列即可直接读取的序列化格式。它不像 JSON 那样每次读取都重新创建对象,而是沿着字节序列中的相对偏移量直接读取值。

什么是 FlatBuffers

2014年に Google が公開したシリアライズ形式で、設計したのが Wouter van Oortmerssen さんです。もともとはゲーム開発のために作られました。

我想解决的问题是读取游戏资产(地图、模型、设置)。JSON 和 Protocol Buffers 在读取文件后会解析整个内容,并在内存中重新构建对象。资产较大时,这种构建会耗费时间和内存。

FlatBuffers 摒弃了这种做法。文件内容是由从模式(.fbs 文件)生成的代码所构建的一个字节数组,所有值都能在固定位置或从该位置的相对偏移处找到。读取方将字节数组保留在内存中,直接读取所需的值即可。既无复制也无分配。无论从文件读取、从网络接收还是内存映射,都可以直接使用同一个字节数组。

由于来自外部的字节序列不可信,verifier 会检查“所有偏移量是否都指向缓冲区范围内”。它不检查值的内容是否正确。

在 Google 以外,Facebook 于 2015 年在 Android 应用的数据加载中采用了这一方案,这一例子广为人知。

如果创造这种格式的人来设计语言,语言中的数据会变成什么样呢?出于好奇试了一下,结果在 Goose 中,“程序用于计算的内存中的字节序列”和“写入文件的字节序列”是同一个东西。不需要序列化器的原因就在于此。我通过写入并读取存档数据的实验确认了这一点。

作为比较对象,我也用 Rust 和 C++ 写了同样的程序。这样做是为了并排观察 Goose 的语法来自哪种语言,以及省去编写序列化器究竟减少了什么。

首先,我写一下这篇文章的范围。我接触它只有一天,版本是 v2026.3。

我把官方页面的说明简短地汇总一下(截至2026年10月28日)。

Goose 是一门面向系统编程的语言。它在保持内存安全的同时,没有堆分配器、垃圾收集器、引用计数、析构函数,也没有生命周期标注。所有动态值都以内联方式放置在由编译器管理的数据栈上,在离开作用域时即被释放。编译器用 C++ 编写,输出为一个 C 文件。许可证为 Apache-2.0,仓库如下。

官方页面的数值是对 16 项自研基准测试汇总得出的值(几何平均值)。速度方面,是普通编写的 C++ 的 3.51 倍、手动优化的 C++ 的 1.24 倍、不使用 unsafe 的 Rust 的 1.13 倍。峰值内存则分别低 1.95 倍、1.32 倍、1.25 倍。落败的案例也刊登在同一页面上。另外,GIGAZINE 文章中提到的 3.3 倍和 1.12 倍与官方页面的当前数值不同。本文使用官方页面的数值。

我来整理一下作者的履历。他在1990年代创造了名为 Amiga E 的语言,制作了名为 Cube 和 Sauerbraten 的开源 FPS 引擎,在 Google 开发了 FlatBuffers,并为自己的游戏创造了名为 Lobster 的语言。根据 README,现在的工作室(VoxRay Games)建立在 Lobster 之上。他一直是一个持续追求让游戏快速运行的数据放置方式的人。Goose 的设计在几年前就已完成,据说并非面向 AI 设计。

本文要进行三个实验。第一个是,针对“即使不写生命周期标注,也能保持内存安全”这一官方说明,用其他语言编写会破坏内存的代码,通过是否会产生编译错误来验证。第二个是标题中的问题。将游戏存档数据在不编写序列化器的情况下写入文件并读回,还会让它读取被破坏的文件。第三个是,关于“比普通编写的 C++ 快 3.51 倍”这一官方数值,由于手头没有相同的基准测试,所以用自己编写的程序来比较 Rust 和 C++。在第二个和第三个之间,插入一节将这三种语言的程序并列展示并比较语法的内容。

环境与步骤

运行环境如下。执行日期为 2026年10月28日。

項目 値
操作系统 Windows 11
Goose v2026.3(2026-10-21 发布)的 Windows x64 预编译二进制文件。goose version 是 goose runtime 4d3da949e563
C 编译器 MSVC 19.44(Visual Studio 2022 Build Tools)
Rust rustc 1.99.1
CPU / 内存 AMD Ryzen 7 5700X / 32 GB

只需解压 GitHub Releases 的 zip 包,就能用 goose file.goose 在本地直接运行程序。随附的 TinyCC 会在进程内编译并执行生成的 C 代码。在 Windows 上无需额外工具。构建可执行文件的 goose build 需要 C 编译器。goose toolchain 这个命令即使在 PATH 中没有 cl 的情况下也能找到 Visual Studio Build Tools 的 MSVC。

goose samples/01_tour.goose
goose build samples/01_tour.goose

速度测量方法是:将用 goose build(-O2)、cargo build --release、cl /O2 构建的可执行文件通过 Python 以整个进程方式启动,记录墙钟时间和峰值工作集大小。每个输入运行 10 次(大输入运行 5 次),表中列出的是最佳值。由于包含启动时间,小输入对应的数值不能用于比较。

随附的 samples/ 包含 31 个程序,docs/ 中包含教程、规格说明、参考文档和设计笔记。本文中所称的"设计文档"是 docs/design/ 中的文件。

本文中的代码和运行结果放在以下仓库中。

首先,故意制造一个错误

这是第一个实验。我们要验证「不写生命周期注解也能保证内存安全」这一说明。为了观察编译器会在哪里报错,在查阅语言文档之前,我故意写了一段应该触发编译错误的代码。首先是一个返回局部数组引用的函数。

fn make() -> i64& {
    var xs: i64[>..] = [];
    xs.push(1);
    return xs[0];
}

i64[>..] 是一种保证元素不会移动的数组类型。即使在末尾追加元素,已有元素的位置也不会改变,因此引用不会失效(数组的种类将在「存档数据」一节之前进行梳理)。编译后出现了以下错误。

01_dangling_return.goose:8: error: returning a reference rooted in xs, which dies with this function (§9.2)
    return xs[0];

行号对应的是实验文件中的编号,所公布的代码是节选。错误信息点名指出了引用的 root(决定生命周期的变量,此处为 xs),并附带了规范书中的章节编号。docs/goose_spec.md 的 §9.2 中写道:引用必须有 root,只有在 root 的存活时间比存储目标更长时,才能返回或存储该引用。xs 会随函数一同销毁,因此无法返回。

还做了另一个测试:使用指向数组一部分的引用(切片)w,但在其之前将原数组置空的例子。

var line: u8[>..] = [];
line.append("hello world");
let w = line[..5];
line.clear();
print(w);
02_clear_while_sliced.goose:8: error: cannot clear line while w is still used: it may hold a reference or slice into it (§5.1)

编译器甚至能看出 w 的最后一次使用在 clear() 之后。将 print(w) 移到 clear() 之前就能通过编译。

反过来,我也验证了应当能通过的情况。取了一个数组元素的引用之后,继续扩展数组,该引用是否仍然可用。取完引用后 push 了 10 万次,然后尝试往第一个引用写入。Item 是一个拥有 id 和 weight 的结构体。在 Goose 中,= 是拷贝,要持有引用则使用 .=。在 Rust 中,一边持有 first 一边执行 items.push 会触发编译错误。

var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i in 2..100001 { items.push(Item { i as i32, i * 0.5 }); }
first.weight = 99.5;
print(items.len, " items, first weight ", items[0].weight);
100000 items, first weight 99.5

由于 [>..] 的数组不会移动元素,push 返回的引用仍然有效。之所以不移动,是因为每个可扩展数组被单独放在一块专用的数据栈(预先预留的地址区间)上,只需在末尾追加即可增长。std::vector 和 Vec 在容量不足时会把元素整体拷贝到另一块区域,因此指向旧区域的引用便无法再使用。Goose 的 [>..] 中不会出现这种情况。

官方页面将一类限制项加以承认的漏洞我也复现了出来。用 reusable 声明的池会复用槽位,因此若持续持有对已释放槽位的引用,便能读取到后来填入该槽位的其他值。

reusable var pool: Item[>..] = [];
let stale: Item& = pool.alloc_ref(Item { 10, 1.0 });
pool.free(pool.index_of(stale));
let next: Item& = pool.alloc_ref(Item { 12, 2.0 });
print(stale.id);   // 12

将类型写为 Item& 的变量,在 = 中也会被当作引用绑定。编译通过,stale.id 的值为 12。官方将其解释为"类型匹配,因此不是内存破坏,而是逻辑错误"。确实如此。

此处看到的"元素不移动的数组"和"引用的 root",成为下一组实验中以相对偏移持有存档数据树的前提。

写入存档数据、读取、破坏它

在第二个实验中,回答标题提出的问题。作者是游戏开发者,官方提供的包(gfx、audio、ui、physics)也都是面向游戏的,因此把题材定为游戏存档数据。存档数据是“写入、读取、遍历”这类游戏数据的典型,固定大小的值、可变大小的值、树结构三者齐备。用 Goose 写入文件再读回,并用 C++ 手写序列化器做同样的事,进行了比较。

没有与 Rust 进行比较。使用 serde 的话就不必编写序列化器,但读取后重新创建对象这一点与 C++ 相同。Rust 的 rkyv 通过相对指针和验证器,以库的形式实现了与 Goose 相同的格式,但读取到的值是专用的 Archived 类型,无法像普通值那样直接 push 或改写。这一差异将在后面的“FlatBuffers 与 Goose 的对应关系”中提及。

数据共有3种:玩家、持有物,以及任务(游戏内的课题)。任务具有大目标之下挂着小步骤的父子关系,因此做成了树结构。例如“守护村中的池塘”之下是“收集芦苇”,再往下是“寻找镰刀”,就是这样的形式。Goose 是鹅,所以题材定为池塘。

首先,数组的类型名

在读取存档数据的结构体之前,先整理一下数组的类型名。这是 Goose 独有的部分。数组的类型名根据长度的确定方式和增长方式的组合共有 8 种。图中展示了其中具有代表性的 6 种。可以把它理解为:用类型来声明“这个值如何增长、被放在哪里”。

除此之外,还有指向数组一部分的引用——切片 T[:](第一个实验中的 line[..5] 就是它)。在 Rust 中,引用之间的生命周期关系通过注解书写。而在 Goose 中,值的存放位置和增长方式作为数组类型写入类型中,生命周期则由编译器从 root 进行推断。

3种数据

struct Vec2 { x: f32, y: f32 }
struct Player { name: u8[..16], hp: i32, gold: i32, pos: Vec2 }

struct Item { name: u8[varint], qty: varint, cents: varint }

struct Quest {
    id: u32,
    title: u8[..24],
    done: bool = false,
    first_child: Quest&<u32>?,
    next: Quest&<u32>?,
}

Player 是固定大小的。名称是容量为16的 inline 数组,因此 Player 是一个扁平的值,Player 的数组可以通过下标访问。

Item 是可变大小的。名称只占用所需的字节数,数值则用 LEB128(一种数值越小表示越短的整数表示法)来存储。

Quest 是树。first_child 和 next 的类型 Quest&<u32>? 是“指向同一数组中另一个元素的 4 字节相对偏移”。存储的值是引用目标的地址减去该字段自身地址所得的差。其中没有任何指针(内存上的绝对地址)。如果把指针写入文件,下次执行时它就会指向别的位置,而相对偏移即使把整个数组放到别的位置,也仍指向同一个元素。

保存时,只需将3个数组依次追加到字节串中。

var file: u8[>..] = [];
file.append("GSAV");
players.to_bytes(file);
bag.to_bytes(file);
quests.to_bytes(file);
write_file(path, file);

to_bytes 会将数组在内存中占用的字节序列,连同长度前缀一起追加写入。无需编写序列化器。理由有三点:内存中的数组没有任何指针;树的链接是相对偏移量;而能够建立这种链接的只有元素不会移动的 [>..] 数组内部(这是第一个实验中观察到的性质)。由于这三点齐备,即使把字节序列原样放到别处,含义也不会改变。这就是对标题所提问题的回答。

读回是 from_bytes。image 是从文件中切出的 Quest 段的字节序列。来自文件的字节序列不能像编译器组装的值那样信任,因此在读取前要进行验证。

var lq, ok = from_bytes<Quest[>..]>(image);

验证通过后,返回的是普通数组。从文件读取的数据和程序使用的数据之间没有区分。可以对读回的 lq 用 push 添加新任务,也可以用相对偏移追踪子节点。在 C++ 中要做到这一点,需要在读取文件后重新构建 std::vector<Quest> 的处理。而在 Goose 中,没有这个处理。

尺寸

C++ 版本采用了使用 std::string 和 std::vector 的常见写法。如果用与 C 相同的扁平结构体(char title[24] 和下标)来写,在 C++ 中也可以直接写入文件,但下标范围检查和“链接指向同一数组内”这一保证需要手写。本文将从由谁来编写下标范围检查和链接目标数组检查这一角度进行比较。在 Goose 中,由编译器和 from_bytes 来完成。

在 Goose 中,内存中的大小就是文件上的大小。C++ 版的内存中的值与文件形式不同,因此表格按内存中的大小进行比较。

数据 Goose(内存上=文件上) C++ 内存上(MSVC)
Player 1个 33 字节 48 字节。如果名字超过16个字符,则另外在堆上分配内存空间
Item 3 项 35 字节 144 字节
Quest 1 项 38 字节 72 字节。子数组和长标题在堆上单独分配(7 项中 5 次)

Goose 的结构体按字段声明顺序紧凑排列,也不存在 C++ 编译器插入的对齐填充字节。其设计将非对齐读写视为低开销,如有需要则自行编写 pad(规格书 §3.2)。Quest 的 38 字节为:id 4、title 1 + 24、done 1、链接 4 × 2。

整个文件 Goose 为 375 字节,C++ 为 369 字节,两者几乎相同。C++ 会把内存中的值重新打包后写入文件,因此文件是 369 字节,但在内存中会变大。Goose 在内存中也保持 375 字节不变。

写下的量

シリアライザは、Goose では書いていません。to_bytes の呼び出しが1行ずつあるだけです。C++ では Writer と Reader、型ごとの write と read で 78 行書きました。読み込み時の検証も、Goose では from_bytes が組み込みで行い、C++ では長さのチェックと、子の添字が範囲内かの検査を手書きしました。Goose 側で自分で書いたのは、セクションの区切りを読む8行だけです。長さの前置きを読んで、その分だけ切り出す処理です。

文件内容

这是 Goose 所写 save.gsav 的开头。

00000000: 4753 4156 4203 6775 7300 0000 0000 0000  GSAVB.gus.......
00000010: 0000 0000 0000 4600 0000 e204 0000 0000  ......F.........
00000020: 4841 0000 40c0 076d 616c 6c61 7264 0000  HA..@..mallard..

47 53 41 56 是我自己写的标签 "GSAV"。42 是 LEB128 编码的 66,表示 Player 部分的数据体为 66 字节。03 67 75 73 是名称,长度为 3,对应 "gus",剩余 13 字节未使用。46 00 00 00 是 hp = 70,e2 04 00 00 是 gold = 1250,接下来 8 字节是 pos 的两个 f32。07 6d 61 6c 6c 61 72 64 开始第二只 "mallard" 的数据。

这里排列的是程序在计算中实际使用的内存上的字节序列本身。用设计文档(docs/design/serialization.md)的话来说,Goose 的数据「本身就是已序列化的形式」。保存时直接写出元素的内存区域,读取时直接读入。读取之后没有替换指针的处理。

读取被破坏的文件

存档数据可能会因磁盘故障或修改工具而损坏。为了观察 from_bytes 的校验逻辑拒绝什么、放行什么、边界在哪里,我按 4 种方式破坏了文件并让程序读取。

操作 Goose from_bytes C++ 手写 Reader
翻转链接(相对偏移)的 1 个字节 拒否 拒绝(因为写了附加字符的范围检查)
改写数据(done 标志)的 1 个字节 受理。树根请求的 done 变为 true。 受理
截去末尾 1 个字节 拒否 拒否
将 Item 节的字节序列作为 Quest 读取 拒否 未実施

设计文档明确写清了这条界限。from_bytes 验证的是安全性,而非一致性。损坏的链接可能成为"指向数组外部内存的引用",因此会被拒绝。即使数值已损坏,只要所有链接都指向数组内部,树结构仍可遍历,因此会被接受。from_bytes 不校验值的正确性。设计文档写道,要校验值需要使用校验和。校验和是根据整个数据计算出的用于检验的值,哪怕只有1字节发生变化,该值也会随之改变。

FlatBuffers 的 verifier 也是如此。它只检查偏移量是否指向缓冲区范围内,而不检查其中的值是否正确。

要在 C++ 一侧获得同样的安全性,需要在 Reader 中加入长度检查,并在读取完成后检验下标范围。如果这两项都遗漏了,损坏的文件就会导致越界访问。

最像 Goose 风格的错误

这次实验中最令人印象深刻的,不是成功运行的结果,而是中途出现的编译错误。最初,我写了一个向任务树添加子节点的函数,将父节点以引用形式接收。

fn add_child(pool: Quest[>..]&, parent: Quest&, ...) {
    let node .= pool.push(Quest { ... });
    ...
    cur.next .= node;   // cur は parent からたどった参照
}
error: a relative reference must point within the same root as its location (§3.9); this reference is rooted at pool

相对偏移只在「同一数组内部」才有意义。因此,编译器会静态地确认建立链接的两个引用来自同一个数组。若让 parent 由 Quest& 接收,编译器就无法看到该引用基于哪个数组。修复方式是,通过索引(i64)接收父项,然后在函数内部将其解析为 pool[parent_at]。

fn add_child(pool: Quest[>..]&, parent_at: i64, ...) -> i64 {
    let node .= pool.push(Quest { ... });
    let parent .= pool[parent_at];
    ...
}

「不持有指针而持有下标」在游戏的 C++ 开发中应该是常见做法。在 Goose 中,编译器会要求你这样做。

还有一个问题。包含相对引用的数组不能作为值复制。因为与把整个数组按相同顺序写入文件不同,只把部分值复制到别处时,相对偏移所指向的位置会发生偏移。失败时想返回空数组而写了 var empty: T = []; return empty, false;,结果被告知 copying a value of type Quest[>..], which contains self-relative references, is not supported; construct it in place。写成 return from_bytes<T>(""); 的话,就能从空字节序列当场构建并返回“空数组和 false”。

FlatBuffers 与 Goose 的对应关系

标题中问题的答案,如保存数据一节中所述。这里列出该机制对应 FlatBuffers 的哪些部分。

FlatBuffers Goose
缓冲区内的相对偏移 像 Quest&<u32> 这样的相对引用。存储值为「target − 字段自身地址」,由于两端一起移动,因此位置无关
以 inline 方式持有嵌套的字符串或数组 u8[..16] 和 u8[varint] 以 inline 方式嵌入到结构体中
读取时经过 verifier 校验 from_bytes 以同样的边界(仅限安全性)进行校验
将缓冲区原样 write 「Goose 的数据本身就是已序列化的形式」
即使在模式中添加字段,也能读取旧的缓冲区 未确认。from_bytes 根据类型的字节布局进行验证,因此修改结构体后旧文件会被拒绝,这一点可以推断出来,但并未实际测试
可以从其他语言读取同一个缓冲区 未确认。字节布局在规范文档的附录中,因此可以编写读取端,但没有生成代码

区别有两点。FlatBuffers 需要模式语言和代码生成,且基本上是只读的。Rust 的 rkyv 也一样,读取的值是专用类型。而在 Goose 中,语言的普通值在保持可变的同时具备同样的性质。可以向声明为 var quests: Quest[>..] 的数组 push,通过引用改写,然后直接写入文件。

其代价是,需要用类型来声明数组的种类。正因为是元素不会移动的数组([>..]),相对偏移才不会失效。官方页面也在限制事项的开头列出了“需要考虑数据位于何处、由谁拥有”。

data-oriented design との対応

ゲーム業界で独自エンジンを作る人たちがたどり着いた設計の考え方を、data-oriented design と呼びます。Insomniac Games の Mike Acton さんの CppCon 2014 の講演で広まり、Unity は DOTS としてエンジンに取り込み、Richard Fabian さんが本にまとめています。

要素有4个。

  1. 不使用指针连接对象,而是将其连续排列在数组中
  2. 用索引或句柄引用,而非指针
  3. 采用按字段的数组(SoA),而非结构体数组
  4. 资源在加载时不重新组装,而是以文件原样放入内存

Goose 将其中第 1、2、4 项作为语言特性实现。关于第 3 项,docs/design/soa.md 中有一个尚未实现的提案,内容为"将 SoA 作为数组类型添加"。

用三种语言编写同一个程序

从这里开始,我们暂且搁置标题中的问题,来感受 Goose 的编码体验。为了观察其语法源自何处,我们用 Goose、Rust、C++ 分别编写了一个用 L-system 输出 SVG 的程序。L-system 是一种通过字符串替换规则生成图形的机制:将规则反复应用以生成长字符串,再逐个字符将其解读为"前进"或"转弯"的指令来画线,就能得到植物或曲线。

由于统一了函数的划分方式和输出格式,4个预设中,3种语言的输出在字节级别上完全一致。

预设 深度 線分数 SVG 大小
plant(植物) 6 6,048 182,943 字节
koch(Koch 曲线) 4 625 17,517 字节
dragon(Dragon 曲线) 12 4,096 112,031 字节
sierpinski(谢尔宾斯基) 6 2,187 68,397 字节

这是用深度 5 绘制的 plant(共 1,488 条线段)。

Goose版が描いた植物(plant、深さ5)

4 个预设的 SVG 文件存放在仓库的 notes/images/ 中。

骨架用 Rust,细节来自各种语言

函数定义写作 fn rewrite(src: const u8[:], out: u8[>..]&)。与 Rust 的 fn rewrite(src: &[u8], out: &mut Vec<u8>) 形式几乎相同,只有 const 的位置用的是 C++。match、if 是表达式,as、:: 也保持 Rust 原样。

不同的是引用的传递方式。在 Goose 中,传递需要被修改的数组时,调用方直接写 rewrite(cur, next)。不需要像 Rust 那样写 &mut next。这与 C++ 的引用相同。将引用绑定到变量时使用 let first .= items.push(...) 和 .=,= 是当左边类型不是引用类型时进行复制,.== 是判断是否指向同一对象。这三种区分是 Goose 独有的。

还有其他语言的写法。多个返回值 -> f64, f64 和 let depth, ok = parse_int(s) 是 Go 的多值返回,Preset? 和 guard ... else 是 Swift 和 Kotlin,从末尾计数的索引 xs[^2..] 是 C# 的形式。

没有字符串类型。如果是 u8(1 字节整数)的数组,那么无论哪种类型都可以当作字符串使用。要扩展的话用 u8[>..],在结构体里则用 u8[..16] 或 u8[varint]。像 "hello" 这样的字面量是只读切片 const u8[:],可以复制到任何类型的数组中。感觉上接近 C 的 char*。

数组的类型名,正如在存档数据一节中整理的那样。在结构体中写 to: u8[..32] 时,容量为32的数组会以内联方式放入结构体中。这接近 C 的 char to[32],但带有长度。

同时编写 Rust 和 Goose 的人会踩的坑

for &x in xs 的含义在 Rust 和 Goose 中是相反的。

写法 Goose Rust
for x in xs 通过复制元素来迭代 如果 xs 是切片或 &Vec<T>,则使用 &T;如果是 Vec<T> 本身,则转移所有权给 T
for &x in xs 通过引用元素来迭代。向 x 写入会修改原数组 剥掉引用,用值来遍历

符号相同但意思相反。Goose 的 & 无论是作为参数类型 out: T& 还是绑定 let r .= ...,都是一致地表示「这个名字是引用」的声明。如果按 C++ 的 for (auto& x : xs) 同样的方式去读,就能对上了。

写代码过程中踩到的语法错误有 3 个,光靠错误信息就能修好。

踩到的 3 个语法错误

  • 要在 match 的括号内写表达式,{} 是必须的。'+' => t.heading += p.angle, 是语法错误
  • pad 是用于结构体填充的关键词,不能用作变量名
  • for depth { ... } 不能写,要写成 for _ in depth { ... }

速度也测了

这是第三个实验。我们要用自己的程序来验证官方给出的数值——“比普通编写的 C++ 快 3.51 倍”。由于手头没有与官方相同的基准测试,所以用 L-system 程序来比较 Rust 和 C++。测量条件如“环境与步骤”所述。

最初に測ったとき、plant depth 10 で Goose は C++ の8倍速く見えました。ただし、この差はメモリ管理の差ではありません。この入力の出力は 55 MB の SVG で、処理のほとんどは f64 を小数2桁の文字列にする部分です。差がついたのは、Goose の format_flt、Rust の {:.2}、MSVC の snprintf("%.2f") の速さでした。

因此,我也为 C++ 和 Rust 手写了与 Goose 的 format_flt 相同的“乘以 100 后四舍五入,再逐位写出数字”的格式化器,并重新进行了测量。这种舍入方式通常与 %.2f 的舍入方式(选择最接近二进制真值的十进制数)不一致,但在该 4 个预设的输入下,三种语言的输出按字节完全一致。C++ 也可以使用标准的 std::to_chars,但为了与 Goose 的处理保持一致,我选择了手写实现。

每个单元格为“挂钟时间 / 峰值工作集”。

入力 Goose Rust 标准 Rust 手写 C++ 标准 C++ 手写
plant 深度 8 17 ms / 11 MB 39 ms / 16 MB 19 ms / 15 MB 98 ms / 14 MB 25 ms / 13 MB
种植深度 10 167 ms / 113 MB 520 ms / 162 MB 205 ms / 161 MB 1459 ms / 141 MB 298 ms / 141 MB

差距从 8 倍缩小到了 1.8 倍。与 Rust 的差距是 1.2 倍。剩余差距是由什么造成的,本文并未进一步探究。如果要引用数值,请引用"手写"那一列。官方的 3.51 倍这一数值本身,在本文中没有得到验证。得到验证的仅仅是"在自己编写的一个程序中,除去字符串化的差异后,为 C++ 的 1.8 倍、Rust 的 1.2 倍"这一点。

这是面向谁的语言?

我们从官方页面的限制事项中,看看 Goose 放弃了什么。

  • 関数の外へ持ち出せるクロージャ(escaping closure)がない。TypeScript でいう、setTimeout や addEventListener にコールバックを渡して後で呼んでもらう書き方ができない
  • 没有函数指针。无法将函数存入变量中传递
  • 动态分发只有基于 enum 标签进行分支的 match。没有针对接口替换实现的写法
  • 整个程序一次性编译。无法事后加载插件
  • 标准库中没有网络和子进程(超出 v1 范围。如有需要,可在 extern fn 中调用 C)

其他限制

  • 递归函数无法在递归调用之间保持可扩展的数组
  • 具有可变大小元素的数组,不能用下标访问(需从头依次遍历)
  • 正则表达式仅限 ASCII
  • WebAssembly 在设计备忘录阶段尚未实现

将这一限制按游戏和商业领域分别来看,适用与否便会一目了然。

商业类/实用类 游戏
时间限制 每次请求的响应时间。取平均值即可。 全部装进 16.6 ms(60 fps 的一帧)之内
内存的限制 可添加 上限固定
令人困扰之处 规格变更、外部集成、异常输入 GC 停顿、碎片化、缓存未命中
期望的特性 易于修改,插件结构 每帧在相同时间内完成,连续数据

在 Goose 放弃的特性中,函数指针和动态分派在游戏核心循环内部(每处理一个元素就进行虚拟调用的位置)是要避免的。但由于任务提交和事件回调中会用到,Goose 中会将这些位置用枚举标签和 match 来改写。对于大量使用插件结构和动态分派的企业级系统,设计前提不成立。

'不适合'也有两种。没有网络功能属于'当前 v1 标准中不存在'的情况,有可能通过 extern fn 或包来填补。没有动态分派属于'设计本身就不匹配'的情况,今后也不会改变。

适合的是那些'创建具有结构的数据,保存、读回、遍历'处于处理核心的场景。游戏核心(模拟、保存与加载)就是典型。在游戏之外,我摸索之后觉得可能适合的领域列举如下。并没有实际测试过,这只是推测。

領域 可能匹配的理由
索引和内置的 KVS 可以用相对引用池构建树和哈希表,并直接写入文件。随附的 samples/25_serialize.goose 是保存和读回单词索引的示例
模拟器的存档状态 如果将机器状态表示为一个结构体,to_bytes 的输出即可直接作为存档状态
编译器和解释器 以相对引用的树形式持有 AST,就能将解析结果以字节序列原样保存,下次读取时无需再次解析
日志与遥测数据的记录和回放 向仅支持追加的数组中堆叠记录并写出。中途截断的文件可在校验环节被排除
通信载荷的定义 FlatBuffers 最初的设计用途。结构体定义直接成为线上格式。套接字通过 extern fn 调用 C

在视频处理方面,由于帧从一开始就是扁平的 u8 数组,Goose 的优势(无指针的结构化数据)并不适用,线程间传递退化为 memcpy 这一点也是不利因素(这是官方 README 中的描述,未对成本进行测量)。真正契合的是帧之外的部分——视频编辑软件的项目文件(时间线、片段、特效设置)与存档数据具有相同的性质。感觉上像是在问:这算是一个可行的用途吗?

官方提供了 gfx、audio、ui、physics 包。就 PC 游戏所需的各项功能而言,标准覆盖范围与未覆盖范围的划分如下。

Web 应用在网络尚未成为标准的时点就不在考虑范围内。智能手机应用以应用商店分发、计费和 OS 的 SDK 集成为前提,因此会收敛到 Swift 或 Kotlin,或者 Unity 或 Flutter。如果是 PC(Steam),分发和计费由 Steam 承担,窗口、渲染、声音、输入、物理由 Goose 的包覆盖。OS 的通知和账户集成仍然存在,但在 PC 游戏中并非必需。Goose 在现实中成为选项的场景是面向 PC、希望自己拥有引擎的小团队,这与作者自身的立场一致。

ビジネス系の読者には、別の読み方を勧めます。FlatBuffers と data-oriented design の考え方を、コンパイラのエラーメッセージで教わる教材として読む、という読み方です。「同じ root の中でしか相対参照を張れない」というエラーは、どの言語で書いていても、データを連続で置くときに守っている規則そのものです。Redux で状態を正規化して、オブジェクトの参照ではなく ID で持つやり方と同じ発想です。

总结

Goose 并不是汇集各种语言优点的语言。它是把 FlatBuffers 的思想提升为语言中的值、语法则借用读者熟悉形式的语言。用一句话概括,就是“能把 FlatBuffers 的缓冲区直接当作变量处理的语言”。正因如此才不需要序列化器,取而代之的是用类型来声明数组的种类。实际接触后感受最深的不是速度,而是无需编写序列化器就能让存档数据往返,并且只有损坏的链接会被拒绝。

正如在“引言”中提到的,Goose 是一门由 AI 实现数年前人类设计并问世的语言。其设计方向是让编写者无需写注解,而由编译器进行推断。本文中遇到的编译错误,无论是语法方面的三个,还是相对引用的 root,只要阅读错误消息中写明的规格书章节编号就能修正。不要求写注解、而是以具体错误返回问题的设计,应该与 AI 编写代码并修正的循环方式相契合。数年前的设计,恰好与这个时代相吻合。撰写本文时最有趣的一点,正是这种巧合。

实验所用的代码和运行结果位于以下仓库中。其中包含 4 个 Goose 实验、L-system 的 3 种语言版本、存档数据的 Goose 版和 C++ 版、测量脚本以及验证笔记。

参考

来源:zenn · 游戏开发社区 · zenn.dev