Jam 编程语言
摘要
Raphael Amorim 宣布了 Jam,一种旨在结合 Rust 的安全性和 Zig 的简洁性的新编程语言,解决了在 AI 生成代码时代现有系统语言的复杂性和验证开销。
<p><a href="https://lobste.rs/s/r0xrm0/jam_programming_language">评论</a></p>
查看缓存全文
缓存时间: 2026/07/06 10:03
# Jam 编程语言 - Raphael Amorim
来源:https://rapha.land/jam-programming-language/
在进入正题之前,我先说明:我不是在抨击任何语言。我对它们都怀有真正的尊重。每种语言都有其他语言不具备的优点,世界上也不存在完美的语言。作为 Rio Terminal 的创建者,我从未对任何其他终端说过负面的话,在这里也不会对任何其他语言如此。任何人愿意花时间构建自己相信的东西,我都敬佩。以下内容是对各种权衡的思考,而非针对背后的开发者。
再补充一点:Jam 仍在向 v1.0 迈进。下面提到的机制已经在编译器中实现并运行,但在语言稳定之前,具体细节可能发生变化。因此,请将本文内容视为当前状态,而非最终形式。
不过还有一点:如果你认为在 LLM 时代学习编程语言是浪费时间,那么这篇文章不适合你。提前说明,好让你节省一些时间。
好了,话都说了。开始吧。
---
我非常喜欢 Rust。真的。我办过 Rust 研讨会,也曾在工作过的几家公司率先推动 Rust 的采用。我花了很多精力去带动团队。
问题是 Rust 变得越来越复杂。请不要误解。Rust 有清晰的哲学,并且正在做出这种哲学所要求的选择。但在实际工作中,在真实的团队里,你想快速交付东西,也希望团队能对语言保持投入。从“能写一些 Rust”到“在 Rust 中高效工作”之间的悬崖太陡了,即使是优秀的工程师也会卡在这里,你需要花几个月的时间去帮助他们。
这就是 Zig 最近备受青睐的原因。Zig 让程序员保持贴近类 C 语言的感觉:小语言表面、直接的思维模型、没有语法噪声。享受 C 的乐趣,却避开了大部分 C 的“自爆地雷”。但代价是 Zig 并非安全语言。未初始化读取、手动清理、语言层面没有任何机制阻止 use-after-free。Zig 把这一切都交给你,并相信你会小心。
你可以辩解说这没问题。一个经验丰富的程序员单独工作,可以在脑子里记住所有不变量,不太可能交付一个有 use-after-free 的代码。但问题是,真正的软件几乎从来不是这样。真正的软件有依赖,任何一个依赖中的 CVE 最终都会落到你头上。真正的团队中经验水平参差不齐,资历较浅的成员犯错的频率更高。看看生产环境中的大型 Zig 或 C++ 项目:它们严重依赖 Valgrind、AddressSanitizer 和 fuzzing,一遍又一遍地对同一流程运行相同的检查,有时检查的甚至是团队不拥有的依赖。这就是默认为不安全的代价。验证工作并没有消失,只是从语言中被推到了工具、CI 和事后分析中。最终软件在生产环境中变得更不可靠,且长期维护更困难。
Jarred Sumner (https://x.com/jarredsumner/status/2053048478486708562),Bun (https://bun.sh/) 的创建者,在解释为什么将 Bun 从 Zig 迁移到 Rust:新代码库让编译器强制管理生命周期并发出析构函数,不安全的代码部分看起来更丑陋并促使重构,而且他已经厌倦了修复那些语言本来可以防止的内存泄漏、崩溃和稳定性问题。这正是一开始描述的那种代价。
在 AI 时代,安全性已经成为必须,或者至少是非常可取的。今天生产环境中的大量代码是由非人类编写(或至少起草)的。我不是在做价值判断,这只是现状。瓶颈的形状已经改变:十年前大多数人写代码,现在大多数人审查代码。十年前编译器捕捉一半的错误,人类捕捉另一半;现在编译器必须捕捉所有错误,因为那个逐行检查且全神贯注的人类已经消失或分心了。随着代码量增加而审查面不变,语言必须成为保持一切诚实的那一方。
安全性、较低的学习曲线和高性能是我开始设计 Jam 编程语言的最主要原因。
我一直在研究的问题:如何在保持类 C 语言(Go、Zig、现代 C)那种愉悦和直接感的同时,让语言在无垃圾回收的情况下实现安全?如何既给人 C 的易用性,又避免 C 的 bug 类别?最终形成的折衷方案是一种从四个方向汲取灵感的语言。今天我将重点介绍其中两个;另外两个将单独发文。
- **可变值语义**如 Racordon、Abrahams 等人 2022 年¹ (https://rapha.land/jam-programming-language/#fn:mvs) 所述。绑定拥有它们的值,参数借用只存在于单个函数调用期间,并且用户代码中不出现任何引用或生命周期语法。这就是替代借用检查器的机制。
- **Rust 的 drop 系统。** 类型声明一个 `drop` 函数,编译器在每次作用域退出时合成调用,并通过一个简单的数据流分析在编译时捕获未初始化使用。
结果是一种这样的语言:绑定拥有它们的值,资源自动清理,因此每个具有 drop 类型的绑定在其作用域结束时自动触发其 drop 函数。
### Jam 的 drop 系统
在 Jam 中,你写成:
```jam
const File = struct {
fd: i32,
fn drop(self: mut File) {
close(self.fd);
}
};
export fn useFile() i32 {
const f: File = { fd: 7 };
return f.fd;
}
```
没有显式清理。没有 `defer`,没有手动调用,没有标记 `f` 生命周期结束的语法。编译器会自动合成 drop 调用。
这是 Jam 为 `useFile` 生成的 LLVM IR:
```llvm
define i32 @useFile() #0 {
%1 = alloca %File, align 4
store %File { i32 7 }, ptr %1, align 4
%2 = getelementptr inbounds nuw %File, ptr %1, i32 0, i32 0
%3 = load i32, ptr %2, align 4
call void @__drop_File(ptr %1)
ret i32 %3
}
```
`call void @__drop_File(ptr %1)` 在 `ret` 前一行就是全部故事(这里的 `%1` 是源代码级绑定 `f` 的 alloca)。编译器追踪到 `f` 是一个具有 drop 类型的绑定,在退出时遍历函数的 drop 作用域,并自动发出调用。名称修整(`__drop_File` 而非 `drop`)使得多个类型可以有自己的 drop 函数而不会在 LLVM 级别发生冲突。通过指针传递的 `self` 是模式感知 ABI 发挥作用的地方:drop 函数接受 `self: mut File`,这被降低为 `ptr` 参数,因此调用点直接传递绑定的地址,`drop` 实际修改调用者的存储。
同样的程序在 Zig 中需要程序员记住清理:
```zig
const File = struct {
fd: i32,
pub fn deinit(self: *File) void {
_ = close(self.fd);
}
};
export fn useFile() i32 {
var f: File = .{ .fd = 7 };
defer f.deinit(); // <-- 你必须记得这一行
return f.fd;
}
```
Zig 编译器不会自动为你合成 `defer`。忘记这一行,文件描述符就会泄漏。IR 中之所以有 deinit 调用,完全是因为源代码写了 `defer f.deinit()`:
```llvm
define dso_local i32 @useFile() #0 {
Entry:
%0 = alloca %zig_demo.File, align 4
...
store i32 7, ptr %8, align 4
%10 = load i32, ptr %9, align 4
call fastcc void @zig_demo.File.deinit(ptr %1, ptr nonnull align 4 %0)
ret i32 %10
}
```
移除 `defer` 行,调用也随之消失。终端的使用体验是诚实的:在 Zig 中,你写的就是你得到的。
C++ 做了和 Jam 一样的事情。析构函数会在作用域退出时自动运行(RAII):
```cpp
struct File {
int fd;
~File() { close(fd); }
};
extern "C" int useFile() {
File f{7};
return f.fd;
}
```
```llvm
define i32 @useFile() #0 {
%1 = alloca %struct.File, align 4
%3 = getelementptr inbounds nuw %struct.File, ptr %1, i32 0, i32 0
store i32 7, ptr %3, align 4
%4 = getelementptr inbounds nuw %struct.File, ptr %1, i32 0, i32 0
%5 = load i32, ptr %4, align 4
%6 = call noundef ptr @_ZN4FileD1Ev(ptr noundef nonnull align 4 dereferenceable(4) %1) #3
ret i32 %5
}
```
析构函数 `_ZN4FileD1Ev` 在 `ret` 之前无条件运行。到目前为止,Jam 和 C++ 在这个维度上处于同一阵营。
区别在于 C++ 围绕其析构函数堆积的所有其他东西。0/3/5 规则迫使你在编写析构函数时思考拷贝和移动。多态删除需要虚析构函数,很容易忘记,忘记就会导致泄漏。抛出异常的构造函数会部分构造对象,而该对象自身的析构函数永远不会运行。从析构函数中抛出异常会调用 `std::terminate`(自 C++11 起析构函数隐式为 `noexcept(true)`,所以任何抛出都会触发;如果标记一个为 `noexcept(false)`,在正在进行的栈展开期间抛出仍然会终止)。
而且 C++ 的析构函数甚至不是保证。`std::exit` 不会运行栈驻留对象的析构函数(只会运行静态存储期的对象)。`std::abort` 完全不运行任何析构函数。`longjmp` 跨越具有非平凡析构函数的作用域会跳过它们。未捕获的信号会跳过它们。抛出异常的构造函数会跳过对象自身的析构函数。
Rust 抛弃了大部分这些东西:每个类型一个 `Drop::drop(&mut self)`,没有拷贝或移动构造函数(移动是字节拷贝,不运行用户代码),没有需要管理的虚标识(trait 对象通过 vtable 的 `drop_in_place` 自动处理多态 drop),因为没有继承所以也没有切片问题。Jam 直接采用了相同的模型。一个类型只有一个 drop 函数。它在每个作用域退出时运行。不需要学习五法则,不会忘记虚标识,也没有异常安全性的舞蹈。
### 读取未初始化内存
Jam 的示例使用结构体字面量构建了 `f`:`const f: File = { fd: 7 };`。Zig 示例使用了 `.{ .fd = 7 }`。两个绑定在创建的时刻都带有实际值。这不是风格选择。在 Jam 中这是唯一的方式;关键字 `undefined` 不存在。
Zig 则不同。你可以声明一个没有值的绑定:
```zig
var f: File = undefined;
return f.fd; // 运行时未定义行为;读取栈垃圾
```
`undefined` 是 Zig 中任何类型的值。编译器接受对 `f.fd` 的读取,因为静态上 `f` 具有 `File` 类型。在运行时,存储位置上存放着栈上的任意字节:Debug 模式下是 `0xaa`(Zig 的调试填充使得误用在调试器中可见),Release 模式下是任意字节。经过 `undefined` 读取的生产代码本身就是错误的。这是 Zig 刻意的权衡:最大能力,程序员承担责任。
Go 则处于相反的一端。每个 `var` 默认零初始化:`var x int` 写入 0,`var p *T` 写入 nil,`var s SomeStruct` 将每个字段清零。安全性很好(没有垃圾读取),但 `nil` 和零模式仍然是内存中真实的字节,在每次声明时都会写入,即使下一行就要覆盖所有字段。Zig 用安全性换取周期;Go 用周期换取安全性;两者都付出了程序本不需要付出的代价。
Jam 拒绝这种选择。没有 `undefined` 值,没有隐式零,没有办法在未提供真实初始化器的前提下声明绑定。每个 `var` 和 `const` 都需要一个初始化器。如果你需要增量构建某物,可以使用结构体字面量:先计算字段值,然后构造结构体,最后绑定。中间不会有任何持有未指定或占位值的状态,也不会为了即将被覆盖而 memset 为零。
在那些本会因此变得尴尬的模式——*延迟初始化*(槽存在,值稍后到来)和*输出参数*(被调用者填充调用者拥有的存储)——中,使用了不同的机制:一个叫做 `Maybe(T)` 的包装类型。
```jam
var slot: Maybe(File) = Maybe(File).empty();
slot.write(makeFile(...));
const f: File = slot.unsafeAssumeInit();
```
`Maybe(T)` 是一个普通的泛型结构体,具有三个操作:`empty()` 构造一个内容尚无效的槽,`write()` 填充它,`unsafeAssumeInit()` 提取值。命名传达了安全故事。每个消费点都包含单词 `unsafe`,因此审查者(人类和 AI)可以 grep 它并找到每个断言运行时不变量的位置。
一个 lint 过程会追踪哪些槽已被写入,并拒绝任何分析器无法证明已被初始化的槽上的 `unsafeAssumeInit` 调用。误用 API 会导致编译器报错,无法编译。`unsafe` 前缀作为审查者(人类和 AI)的 grep 锚点,用于找到每个断言运行时不变量的位置,但明显的错误绝不会通过分析器。
### 块作用域、提前返回、break、continue
一旦自动 drop 在函数级别工作,其余部分就机械地随之而来。编译器维护一个 drop 作用域栈,在每个词法块边界处推入一个新作用域,在块结束时弹出,并在分支之前发出该作用域绑定的 drop 操作:
- **块作用域。** 在 `if` 内部声明的绑定会在该主体结束时 drop,也就是在 `if` 之后的任何代码运行之前。`else`、`match` 分支、`while` 和 `for` 循环主体同理。
- **提前返回。** 嵌套块内部的 `return` 会先 drop 每个活跃的作用域(从最内层开始),然后才是实际的 ret 指令。
- **`break`/`continue`。** 两者都会 drop 在封闭循环主体内部打开的所有作用域(包括当时打开的嵌套 `if` 作用域),然后分支到循环出口(对于 `break`)或下一次迭代(对于 `continue`)。
这些规则正是 RAII 良好实现时你期望的规则。它们由编译器强制执行。IR 中恰好包含这些规则所需的 drop 调用,没有多余的。
一个嵌套 break 的例子,用 Jam 表达:
```jam
const std = import("std");
const Bumper = struct {
label: str,
fn drop(self: mut Bumper) {
std.fmt.println(self.label);
}
};
fn nestedBreak() {
for i in 0:10 {
const outer: Bumper = { label: "outer" };
if (i == 1) {
const inner: Bumper = { label: "inner" };
// break 先 drop inner,再 drop outer,然后退出。
break;
}
// 非 break 迭代:outer 在循环主体结束时 drop。
}
}
```
运行这段代码会按顺序打印三行:`outer`(迭代 0 的 outer 在循环主体结束时 drop),然后是 `inner` 和 `outer`(迭代 1 的 break 路径先 drop 最内层)。
### 传递值而不 drop 它们
在所有这些之后,一个自然的问题是:如果每个绑定在离开作用域时都会 drop,那么当我将它传递给一个函数时会发生什么?调用点会 drop 它吗?还是被调用者?
答案是:这取决于参数模式,并且四种模式中只有一种会进行 drop。
```jam
fn distance(a: Point, b: Point) f64 {
const dx: f64 = a.x - b.x;
const dy: f64 = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}
fn caller() {
const p: Point = origin();
const q: Point = elsewhere();
const d: f64 = distance(p, q);
// p 和 q 在这里仍然有效。调用点没有运行 drop。
}
```
默认模式是只读借用。被调用者通过参数可以读取值,调用者的绑定保持有效,调用返回时不会触发 drop。`mut` 是同样的思路,但增加了写入权限:一种独占的读写借用。调用者的绑定在调用后仍然有效,状态不变。
只有 `move` 会消耗:
```jam
fn consume(buf: move []u8) {
// buf 在此处被拥有。它在 consume 结束时 drop。
}
fn caller() {
var data: []u8 = makeBuffer();
consume(data);
// data 现在是 Uninit。读取它是编译错误。
// drop 发生在 consume 内部,而不是这里。
}
```
签名中的参数模式决定了每个调用点的行为。调用点上没有额外的标记。每种模式的调用形状完全相同:
| 模式 | 调用点写法 | 调用后调用者的绑定状态 |
|------|------------|------------------------|
| (默认)只读借用 | `f(x)` | 不变,仍有效 |
| `mut` 独占借用 | `f(x)` | 不变,仍有效 |
相似文章
2026 年的 Zig 与 Rust
本文在 2026 年的背景下对比了 Zig 和 Rust,认为编程代理通过自动化生成 Rust 代码,削弱了 Zig 在人机交互体验上的优势。
重返Zig
作者描述了从Zig到Rust再回到Zig的历程,探讨了编程语言中稳定性与表达力之间的权衡。
Gossamer:一种具有真实goroutines和无暂停内存的Rust风格语言
Gossamer是一种受Rust启发的新编程语言,具有真实goroutines、基于引用计数和区域的无暂停确定性内存管理,以及配备LLVM编译的字节码虚拟机。它旨在提供富有表现力的语法,无需借用检查器或垃圾回收暂停。
在充斥着AI生成内容的世界里举办软件创作比赛
Hack Club的一位组织者分享了在充斥着低质量AI生成作品的时代举办软件创作比赛的方法,讨论了资金挑战和激励结构,以鼓励真正的匠心工艺。
Catjam 2026
Catjam 2026 是 itch.io 上的一个游戏开发活动,参与者使用串联式编程语言(catlangs)创建游戏或玩具,规则禁止使用生成式 AI 辅助。