深度、速度、美感——寻找贤者之石

我为什么还需要一门编程语言

项目: 阿拉泰(Alatyr)

每个新项目开始时,我都会面对同一个问题:用什么语言写。而每一次,答案都是一种妥协。

需要做实时系统——每个操作都有自己的时间预算,垃圾回收器无权在不恰当的时刻“停止世界”,时间和资源由我而不是运行时来掌控——我就选一门所有杠杆俱全、写起来却又慢又痛苦的语言。需要快速搭起一个庞大而鲜活的东西——我就选一门抽象顺手的语言,代价是不再明白机器实际上在做什么。需要程序跑得快——我去找折中,结果还是要牺牲点什么。到了大项目里,这一切往往被拼成好几门语言,而语言之间的接缝本身又成了一份额外的工作。

说到底,选技术栈选的不是语言,而是在三个顶点中牺牲哪一个。

取舍三角

深度。 我能触及多少机器。能否不离开语言,就抵达硬件的任何一个技术细节——直到一个比特,直到一条具体的处理器指令。

速度。 程序在机器上运行得有多高效。每一行代码要花多少时钟周期和字节,有没有我没要求过的额外开销。

美感。 用这门语言思考和书写有多舒服、多顺手:强大的抽象,熟悉的语法,让人愿意反复读的代码。

那条熟悉的“三选二”法则在这里几乎从不失灵。要深度和速度,就没有美感。要美感,就没有深度,往往连速度也没有。

阿拉泰(Alatyr),补天之石,是我不做取舍的一次尝试:把三个顶点都拧到最大,一个也不牺牲。让我在开始一个项目时,不必担心语言的杠杆够不够用,也不会因为不顺手而半途放弃。按设想,它覆盖从引导加载程序和实时系统,到 Web 和游戏。

听起来像在寻找贤者之石。事实也正是如此。

深度:直到比特,直到时钟周期

阿拉泰表达能力的上限是汇编。凡是能在机器层面说出的东西,都能用阿拉泰说出来。

在这里,汇编不是从另一门语言借来、用引号包住的嵌入片段,而是语言本身的一部分:带类型的指令、标签、跳转。类型系统本身也建立在机器模型之上:最底层是原始的比特块,而有符号整数、无符号整数和浮点数,只是读取这些比特的不同方式。

但深度不只是比特,还有时间和资源。阿拉泰没有垃圾回收器,也就没有程序在运行时整理内存时陷入停顿的那些时刻。每一次内存分配都是显式的,并且经过代码中点名的分配器:arena、region,或者你自己的分配器。释放同样是显式的——通过 defer,没有在幕后某处悄悄触发的析构函数。对实时系统来说,这是最要紧的:程序里不会发生任何既不是我亲手写下、也没有在语言规范中写明的事情。

这里也没有“层级”的阶梯,不需要为了操作硬件而下降到一个独立的、缩水的方言。语言只有一门。如果某个模块需要严格的保证——不分配内存、不用抽象、不用标准库——它就声明这些约束,由编译器来检查。“只用汇编”也不是另一门语言,而只是这类约束中的一种。

速度:没有隐藏的代价

阿拉泰是预先编译的,经由平台自身的汇编器直接生成机器码。程序旁边没有常驻的运行时,程序内部也没有隐藏的拷贝、隐式的调用,以及代码里没写明的分派。

我在这里有意不给出数字。项目里有基准测试,但一个无法在自己机器上复现的数字,是广告而不是测量。等到数字能被诚实地验证时,自然会给出。在那之前,速度的依据是设计本身:程序里没有任何你在不知情的情况下付出的代价。

美感:强大的抽象,熟悉的语法

我希望一门上限是汇编的语言,读起来像现代高级语言。关键的一步是:所有东西都用同一种声明形式——名字、类型、值。

Point := struct { x : u64, y : u64 }
add   := fn(a : u64, b : u64) -> u64 { a + b }
Vec   := fn(T : type) -> type { struct { ptr : ptr(T), len : usize } }
mut count : u64 = 0

结构体、函数和泛型类型的声明方式完全相同。泛型类型 Vec 只是一个在编译期运行、返回类型的函数。没有单独的模板机制;只有一个被贯彻到底的想法。

同样的思路也用在项目配置上。包清单不是用另一种配置语言写的,而是用阿拉泰本身:构建目标、架构、约束——都是由编译器求值的普通值。

为什么三个顶点不会互相拖累

三角形的顶点通常相互冲突,这并非偶然。抽象之所以好用,是因为它隐藏了细节——而不可预测的代价恰恰藏在被隐藏的部分里。深度之所以不好用,是因为一切都得明说。

阿拉泰立足于一条原则:程序员始终知道机器在做什么。 语言的每个构造都按照规范中写明的规则变成机器码——结果不必运行编译器就能预测。抽象的代价不会高于它所替代的代码。

顶点之间的关系由此而来。既然抽象是透明的,美感就不会夺走速度。既然在任何地方都能一路下到比特,顺手就不会挡住深度。美感并不隐藏代价——它只是让你不必亲手把代价写出来。

从同一条原则还引出了这门语言大概最严格的一条规则:它没有未定义行为。一个操作要么正确执行,要么以清晰的错误确定性地终止程序。如果需要不带检查的纯粹速度,就要显式地、针对具体的一段代码打开它:

unchecked {
  while j * j <= n {
    if n % j == 0 { return false }
    j += 1
  }
}

在 unchecked 内部,行为由硬件决定——溢出遵循处理器的规则——但绝不由“编译器怎么方便”来决定。而且编译器无权自行开启这种模式。

邻居们站在哪里

我不把现有的语言看作失败的尝试,而是看作对优先级的不同排列。

C 和汇编选择了深度和速度。它们是诚实的语言:什么都给你。但 C 里的未定义行为,代价是优化器有权破坏看起来正确的代码。

带垃圾回收器和运行时的语言选择了美感和开发速度。在各自擅长的领域里它们很出色——代价是深度:从它们那里够不到机器,停顿和抽象的代价也不可预测。

离阿拉泰最近的是 Rust 和 Zig,我从它们身上学到了很多。Rust 通过借用检查让内存安全成为强制要求;我选择一个更简单的模型——通过 defer 显式释放,加上 arena 和 region。Zig 的显式风格和编译期计算让我很有共鸣;我想再往前走一步——让未定义行为彻底不存在,让汇编不是嵌入片段,而是语言的一部分。

为什么叫阿拉泰

在斯拉夫传说中,阿拉泰是“众石之父”——一块位于世界中心、岿然不动的石头,万物都从它开始丈量。对一门以从不隐藏的机器为根基的语言来说,我很难想出更贴切的名字。在阿拉泰里,起点就是机器。

项目现状

坦白说说现状。阿拉泰的编译器用阿拉泰本身写成,并且能构建自身,而且是逐字节可复现的:每次构建都产出完全相同的二进制文件。这在 Linux 上的 x86_64 平台运行,不依赖 C 标准库。语言规范已经完整写出,但目前仍是待审阅的草案。编译器是预览版。

aarch64、riscv64 和 WebAssembly 的后端已经有了,但目前只支持语言的一个子集。通过 WebAssembly 支持 Web 是一个方向,而不是现成的能力。“要么正确,要么明确地报错”这一原则是项目努力的目标,已发现的偏离都已作为需要修复的缺陷记录在案。

炼金术士们最终没有找到贤者之石。但他们在路上发现了化学。这两种结局,我都准备好了……