Gleam 不再编译为 Erlang 源代码

Gleam 过去会编译为 Erlang 源码并交给 erlc 处理。1.19 版本改为直接生成 BEAM 字节码。这篇文章解释了这样做带来了什么好处、为什么没有一路做到 BEAM 汇编,以及这一改动带来的优化。

中文
复制

Gleam v1.19.0 发布!

2026 年 10 月 5 日,作者 Louis Pilfold

Gleam 是一门类型安全、可扩展的语言,面向 Erlang 虚拟机和 JavaScript 运行时。今天 Gleam v1.19.0 正式发布,下面来看看有哪些新变化。

新的编译目标

过去几个月里,Giacomo Cavalieri 完全重写了 Gleam 的 Erlang 代码生成器。新生成器的设计截然不同,最值得注意的是它输出的格式也变了:以前 Gleam 生成 Erlang 源代码,现在生成的是 Erlang abstract forms。

Erlang abstract forms 是 Erlang 编译器使用的一种中间表示。它是一棵带有元数据标注的树,用来表示 Erlang 语法,通常由 Erlang 的词法分析器和语法分析器生成。它有一种基于 Erlang external term format 的二进制编码,借助这种二进制格式,我们可以直接加载生成的代码,跳过 Erlang 编译器的前半部分。

新的 Erlang 代码生成器带来了几项好处:

  • 编译器性能提升,在 Erlang 上运行的 Gleam 项目构建时间显著缩短。
  • 运行时拿到的位置元数据现在精确对应原始的 Gleam 源代码,而不是编译器本会生成的 Erlang 源代码。也就是说,BEAM 崩溃报告和堆栈跟踪里的行号完全准确,而以前这些行号可能不准,只能指向最近的那个函数。这些元数据还有望让 edb 之类的调试器完整支持 Gleam,不过这部分工作我们还没做。
  • Gleam 编译器的代码质量得到提升。Erlang 代码生成器是 Gleam 代码库里最古老、最稳定的部分之一,它虽然没给我们惹过麻烦,但也不符合我们今天的标准和惯例。新的替代实现非常出色,可以说拉高了整个编译器的水准。
  • 我们再也不用听人拿“transpiler”这个词当贬义词用了。1

那么,它到底有多快?

稍后我会给出一些数字,但请记住,基准测试总是人为构造的,从来无法说明全部问题。这些数据可以作为一个不错的入门或起点,但要真正理解,还需要进一步研究和亲身实践。

这个基准测试基于 José Valim 的 langcompilebench 项目。感谢 José!它衡量的是编译 100 个模块所需的时间,每个模块包含 100 个返回 "hello world" 字符串的函数。这种测试项目的形式便于在不同语言之间轻松复制,从而得到尽可能对等的测试项目,因此很实用;但它能告诉我们的信息有限,因为每种语言只有一小部分特性会被编译到。在真实项目中,代码的多样性会大得多,而不同特性在不同语言中的编译开销也各不相同。

代码生成器重写的第一阶段在 v1.18.0(上一个版本)中发布,所以我们来比较一下 v1.17.0 和新发布的 v1.19.0。这张图展示了编译基准测试项目所需的时间,越低越好。

0ms100ms200ms300ms400ms500msGleam v1.19Gleam v1.17

如你所见,提升相当可观!这是完全从零开始的全量构建,没有任何缓存。Gleam 的编译是增量的,所以在日常开发中会快得多,因为它不会编译整个项目。

最初的 langcompilebench 只包含 Erlang、Elixir 和 Gleam,但我又加入了各种其他流行编程语言,帮助大家大致感受一下 Gleam 的编译速度与你们熟悉的语言相比如何。我还加入了编译到 JavaScript 的 Gleam。结果如下:

0ms200ms400ms600ms800ms1000msGleam (JavaScript)GoGleam (Erlang)ErlangJavaElixirElmRustC#TypeScript 7

请记住:这是一个人为构造的基准测试,仅凭它不足以对这些语言下任何确定的结论。话虽如此,这些结果确实表明 Gleam 的编译速度相当快,而且作为一名 Gleam 程序员,我可以告诉你,用 Gleam 开发非常愉快,几乎不用花时间等电脑。

为什么不直接生成 BEAM 字节码?

我们已经从编译喂给 Erlang 编译器的源代码,转向编译喂给 Erlang 编译器的中间表示,但为什么不干脆绕过 Erlang 编译器?难道不能做一个比 Erlang 编译器更强的 BEAM 字节码生成器?也许还能利用 Gleam 的类型信息生成更优化的代码。

这些好处虽然有可能实现,但实际上多半实现不了。与 Erlang 源码和 Erlang abstract forms 不同,BEAM 字节码并非固定不变。虚拟机每次发新版本,字节码都可能演进、改进,增加新功能,有时也会删掉已经冗余的功能。要跟上这种演进,就得承诺永远追下去:和 Erlang 维护者紧密合作,提前为即将到来的变更做准备,虚拟机一发布新版本就得让 Gleam 的新版本跟上。而且,Erlang 编译器几十年积累下来的那些优化,要全部复现一遍是巨大的工程,哪怕有 Gleam 更强的静态分析帮忙也一样。

Gleam 是一个靠赞助支撑的社区项目。和那些有公司或学术机构背书的语言相比,我们的资金只是零头,所以必须仔细考虑怎样最有效率、最可持续地使用资源。Gleam 是软件开发中可靠的基础,我们做的每一个决定都得在未来几年、几十年里站得住。编译到 Erlang abstract forms,是 Gleam 当下性价比最高的选择。

这个决定也有很好的先例。我们敬爱的老大哥语言 Elixir 同样通过 abstract forms 编译到 Erlang。Elixir 够用,Gleam 就够用!

好了,这事就聊到这。Gleam v1.19.0 里还有不少内容值得一看。

JavaScript 决策树赋值优化

Erlang 代码生成并非唯一得到改进的部分,JavaScript 方面也有不少提升。

在 Gleam 中,流程控制通过 case 表达式的模式匹配实现,编译后会生成嵌套的 if 语句。由于模式匹配是声明式的,编译器能够重新排列并优化运行时逻辑,采用分治策略尽快找到正确的分支。

John Downey 改进了这一过程,生成的代码更加扁平,嵌套的 if 语句被合并为单个条件,中间变量也更少。例如,下面这段 Gleam 代码:

pub fn go(x) {
  case x {
    Wibble(1, 2) -> 1
    _ -> 2
  }
}

以前,这一小段 Gleam 代码会编译成下面这一大段 JavaScript 2:

export function go(x) {
  if (isWibble(x)) {
    let $ = x[0];
    if ($ === 1) {
      let $1 = x[1];
      if ($1 === 2) {
        return 1;
      } else {
        return 2;
      }
    } else {
      return 2;
    }
  } else {
    return 2;
  }
}

现在则生成这样的代码:

export function go(x) {
  if (isWibble(x) && x[0] === 1 && x[1] === 2) {
    return 1;
  } else {
    return 2;
  }
}

不错的改进,想必你也会同意。出乎意料的是,经过压缩和 gzip 之后,代码包的体积几乎没有变化(gzip 确实是魔法),但生成的代码分支更少,JavaScript 引擎优化起来更轻松。

谢谢你,John!

列表字面量优化

虽然 Gleam 和 JavaScript 各自语言中的列表语法相同,但 Gleam 的不可变持久列表类型与 JavaScript 的可变连续数组类型并不一样。当 Gleam 代码编译为 JavaScript 时,任何列表字面量都必须编译成构造运行时数据结构的 JavaScript 代码。例如,下面这段 Gleam 代码:

let numbers = [1, 2, 3]

会编译成这样的 JavaScript 代码 2,其中构造了一个 JavaScript 数组,并传给函数将其转换为 Gleam 列表。

const numbers = arrayToList([1, 2, 3])

本次发布后,编译器会针对短列表字面量生成不同的代码,直接构造而不经过数组转换。

const numbers = prepend(1, prepend(2, prepend(3, empty)))

在现代 JavaScript 引擎上,这带来了不错的性能提升,对于大量使用短列表的项目尤其明显,比如使用 Lustre 库的项目。较长的列表没有观察到改进,因此仍然沿用数组转列表的方式。

感谢 Giacomo Cavalieri 的贡献!

TypeScript API 重载

编译到 JavaScript 时,Gleam 编译器还会为程序员自定义的数据结构生成可在 JavaScript 中使用的函数。除此之外,编译器还能提供 TypeScript 声明文件,让 TypeScript 与 Gleam 在同一个项目中实现完整集成。

为每个自定义类型提供的函数中,有一个用于判断某个值是否属于特定变体。例如,给定以下类型:

pub type Box(value) {
  Full(value)
  Empty
}

生成的函数会带有这样的 TypeScript 声明:

export function Box$isFull(value: any): value is Full<unknown>;

眼尖的 TypeScript 程序员可能会发现这里有个问题。如果已经知道值的类型是 Box<number>,那么这个函数可以把容器类型收窄为 Full,但 number 的类型参数被泛化成了 unknown,导致类型信息丢失。这用起来非常别扭。

Giacomo Cavalieri 在定义中加入了重载,这样只要有可能,类型就能保留下来。

export function Box$isFull<I>(value: Box$<I>): value is Full<I>;
export function Box$isFull(value: any): value is Full<unknown>;

感谢 Giacomo!!

对其他构建工具的改进

Gleam 用户通常使用 gleam 可执行文件自带的官方构建工具,但有时人们会希望在其他环境中编译和使用 Gleam 代码。比如,Elixir 或 Erlang 程序员可能想使用一个用 Gleam 编写的依赖包。这在运行时表现得非常好,因为这三种 BEAM 语言之间有出色的零成本互操作,但要走到这一步可能并不容易,因为 Elixir 和 Erlang 的主流构建工具并没有内置对 Gleam 的支持。

gleam 可执行文件提供了若干命令来暴露编译器功能,供其他构建工具使用。本次发布对这些命令做了多项改进,目标是在 Elixir 的 Mix 和 Erlang 的 rebar3 中获得 Gleam 支持。

Erlang 虚拟机要求所有包除了编译器字节码之外,还必须带有 .app 资源文件。以前,支持 Gleam 的构建工具需要自己提供这些文件,而现在编译到 BEAM 时,gleam 会为它们生成这些文件。

compile-package 命令新增了 --no-dev 标志,编译器只会从 src 目录加载代码,并跳过列为 dev_dependencies 的包。

export package-information 和 export package-interface 命令现在可以把信息打印到 stdout,而以前只能写入文件。同时,export javascript-prelude 和 export typescript-prelude 命令现在可以写入文件。

感谢 Rodrigo Álvarez 贡献这些改动!希望 Elixir 的 Mix 构建工具很快就能支持 Gleam。

语言服务器支持标签

Gleam 内置了出色的语言服务器,为所有支持 language server protocol 的编辑器提供 IDE 功能。最后一块主要功能大概就是完整支持字段和参数的标签了。Alistair Smith 修复了这个问题,加入了跳转到定义、查找引用和重命名标签的支持!谢谢你 Alistair,我知道很多人会对这个省时功能感到非常高兴。

在浏览器中格式化 Gleam 代码

编译器有一个 WebAssembly 构建版本,语言导览和演练场用它来在网页浏览器中编译 Gleam。John Downey 新增了一个 format_source 函数,让人们可以在浏览器里运行 Gleam 代码格式化器。我们很快会把这项功能加入演练场。谢谢你 John!

一如既往,更好的错误信息

让错误信息尽可能清晰、有用对我们来说非常重要。一个工具在一切顺利时好用是一回事,真正能帮到或加重程序员压力的,是出错的时候。

偶然的小语法错误很让人头疼,尤其是当你不确定错在哪里、错的是什么的时候。

0xda157 新增了一个专门的错误,用于代码中出现 git 合并冲突标记的情况;还有一个错误用于 User(..lucy, score: 10) 记录更新语法把原记录写错位置的情况,比如 User(score: 10, ..lucy)。她还为 Gleam 中不存在的过程式运算符添加了错误提示,比如 += 和 *=。

n0kk23 为一种情况加上了更友好的报错信息:在模式匹配中使用 |。这种写法在 Gleam 中不是合法语法,但在 Java 等其他语言里是合法的。

Giacomo Cavalieri 为常量表达式中不允许出现的二元运算符加上了更友好的报错信息,同时改进了编译器在这类错误存在时的容错能力 3。

Andrey Kozhev 为「同一包内的模块试图使用另一个模块的私有类型或私有值」这一情况补充了报错信息的上下文,让用户知道该类型或值确实存在,只是私有的。对于来自依赖包的模块,我们不会提供这类提示,以免泄露程序员并不维护的代码的信息。

最后,James Dolan 改进了类型检查器:无效的类型别名定义不再会沿着该别名的所有使用处引发一连串后续错误。

感谢各位让 Gleam 的调试体验越来越好。

其他贡献

同样感谢修复 bug、打磨体验的各位:

0xda157、Amr Kadry、Andrey Kozhev、Giacomo Cavalieri、Hari Mohan、Ian Chamberlain、Jack Programs、John Downey、Lillian Rose、Mar Bloeiman、mmustafasenoglu、Naomi Roberts、Rodrigo Álvarez、Senthilnathan、Surya Rose 和 Vivid。

他们实现的众多修复和改进的完整细节见 changelog。

呼吁支持

Gleam 不属于任何公司,完全靠赞助者支持,其中大多数人每月赞助 5and20 美元,而 Gleam 是我唯一的收入来源。

在能够给核心团队成员合理报酬这一目标上,我们已经取得了很大进展,但仍有距离。请考虑支持这个项目或核心团队成员。

来源: Gleam← 返回首页