Hacker News 中文摘要

RSS订阅

Gleam 不再编译为 Erlang 源码 -- Gleam doesn't compile to Erlang source anymore

格莱姆(Gleam)是面向 Erlang 虚拟机和 JavaScript 运行时的类型安全可扩展语言。今日格莱姆(Gleam)v1.19.0 发布。过去数月贾科莫·卡瓦列里(Giacomo Cavalieri)完全重写了格莱姆(Gleam)的 Erlang 代码生成器,设计迥异,输出格式改变:此前生成 Erlang 源代码,现生成 Erlang abstract forms。该形式是 Erlang 编译器的中间表示,具二进制编码,可跳过编译器前端直接加载。

正文

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"当贬义词用了。转译器指的是输出人类可读格式的编译器,这个词听着不错,但多数时候人们用它来暗示某个编译器低人一等。这很荒唐,因为输出人类可读格式并不会让编译器更容易实现;如果你还在意输出格式是否漂亮,那可能比用二进制格式更难。

那么它有多快?

先说一句:基准测试总是人为构造的,从来说不清全貌。这些数据可以当作引子,但要真正理解还得自己做更多研究和实践。

这个基准测试基于 José Valim 的 langcompilebench 项目。它衡量的是编译 100 个模块、每个模块包含 100 个返回 "hello world" 字符串的函数所需的时间。这种测试项目的形状容易在不同语言里复刻,能做出一比一的对比,但它能说明的问题有限,因为每种语言只有一小部分特性会被编译。真实项目的代码要多样得多,不同特性在不同语言里的编译开销也不一样。

代码生成器重写的第一阶段在上一个版本 v1.18.0 中发布,所以这里比较 v1.17.0 和刚发布的 v1.19.0。图表显示的是编译基准项目所需的时间,越低越好。

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

最初的 langcompilebench 只包含 Erlang、Elixir 和 Gleam,我把它扩展到其他一些流行语言,方便大家大致感受 Gleam 的编译速度。我还加入了 Gleam 编译到 JavaScript 的结果。

再次提醒:这是人为构造的基准,仅凭它不足以对所有这些语言下硬结论。不过这些结果确实说明 Gleam 的编译相当快,作为 Gleam 程序员我也可以说,用 Gleam 开发很愉快,很少需要等电脑。

为什么不直接针对 BEAM 字节码?

我们从把源代码交给 Erlang 编译器,变成了把中间表示交给 Erlang 编译器,那为什么不干脆绕过 Erlang 编译器?难道不能做一个性能超过 Erlang 编译器的 BEAM 字节码生成器?也许还能利用 Gleam 的类型信息生成更优化的代码。

这些好处虽然有可能实现,但我们大概做不到。与 Erlang 源代码和 Erlang abstract forms 不同,BEAM 字节码不是固定不变的。虚拟机的每个新版本都可能演进和改进字节码,增加新功能,有时也会移除已经多余的功能。那样我们就必须承诺永远跟上这种演进,与 Erlang 维护者紧密合作以应对即将到来的变化,并让新版本 Gleam 跟上新版本虚拟机。此外,即使有 Gleam 更强的静态分析帮忙,要重现 Erlang 编译器几十年来积累的所有优化也是一项巨大的工作。

Gleam 是一个靠赞助支持的社区项目。我们的资金只有企业或学术机构支持的语言的一小部分,所以必须仔细考虑如何最有效、最可持续地使用资源。Gleam 是软件开发的可靠基础,我们做的每个决定都必须能支撑未来几年甚至几十年。编译到 Erlang abstract forms 是 Gleam 今天在成本和收益之间的最佳平衡点。

这个决定也有很好的同伴。我们深受喜爱的"老大哥"语言 Elixir 同样通过 abstract forms 编译到 Erlang。Elixir 够用,Gleam 也够用。

JavaScript 决策树赋值优化

得到改进的不只是 Erlang 代码生成,JavaScript 这边也有不少提升。

在 Gleam 里,流程控制通过 case 表达式的模式匹配完成,它会被编译成嵌套的 if 语句。由于模式匹配是声明式的,编译器可以重排和优化运行时逻辑,用分治方式尽快找到正确的分支。

John Downey 改进了这个过程,生成更扁平的代码,把嵌套的 if 语句合并成单个条件,中间变量也更少。比如这段 Gleam 代码:pub fn go(x) { case x { Wibble(1, 2) -> 1 _ -> 2 } }。

以前它会编译出相当大的一段 JavaScript,层层嵌套判断 isWibble(x)、x[0] === 1、x[1] === 2,每个分支里都要重复 return 2。现在则生成 if (isWibble(x) && x[0] === 1 && x[1] === 2) { return 1 } else { return 2 }。

这是不错的改进。意外的是,经过压缩和 gzip 之后,代码包的体积几乎没有变化(gzip 真的很神奇),但生成的代码分支更少,JavaScript 引擎优化起来更容易。

列表字面量优化

Gleam 的不可变持久列表类型和 JavaScript 的可变连续数组类型语法相似,但并不是一回事。当 Gleam 代码编译到 JavaScript 时,任何列表字面量都必须编译成构造运行时数据结构的 JavaScript 代码。比如 let numbers = [1, 2, 3] 以前会编译成 const numbers = arrayToList([1, 2, 3]),即先构造一个 JavaScript 数组再传给函数转换成 Gleam 列表。

这个版本里,编译器会为短列表字面量生成不同的代码,更直接,不再从数组转换,而是 const numbers = prepend(1, prepend(2, prepend(3, empty)))。

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

TypeScript API 重载

编译到 JavaScript 时,Gleam 编译器还会生成一些函数,供 JavaScript 操作程序员定义的数据结构。同时编译器也能提供 TypeScript 声明文件,让 TypeScript 和 Gleam 能在同一个项目里完整集成。

为每个自定义类型提供的函数之一,是判断某个值是否为特定变体的函数。给定类型 pub type Box(value) { Full(value) Empty },生成的函数声明是 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>;

对其他构建工具的改进

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

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

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

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

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

语言服务器标签支持

Gleam 内置了出色的语言服务器,为所有支持语言服务器协议的编辑器提供 IDE 功能。最后一块主要功能大概就是对字段和参数标签的完整支持。Alistair Smith 修好了这个问题,增加了标签的跳转到定义、查找引用和重命名功能。

在浏览器中格式化 Gleam 代码

编译器有一个 WebAssembly 构建,被语言导览和 playground 用来在浏览器中编译 Gleam。John Downey 新增了 format_source 函数,让人们可以在浏览器里运行 Gleam 代码格式化器。我们会在不久后把这项功能加入 playground。

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

让错误信息尽量清晰、有帮助对我们非常重要。工具在一切顺利时好用是一回事,出问题时能不能帮到程序员,会真切影响他们的压力水平。

小小的意外语法错误很烦人,尤其是你不确定错在哪、错在哪儿的时候。

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

n0kk23 新增了一条有用的错误信息,用于在模式匹配中使用 | 的情况——这在 Gleam 里不是合法语法,但在 Java 等其他语言里合法。

Giacomo Cavalieri 为常量表达式中不允许出现的二元运算符添加了有用的错误信息,同时改进了编译器在出现这类错误时的容错能力。Gleam 编译器是语言服务器的核心,所以和传统编译器不同,即使代码处于无效状态它也必须能提供信息;如果只能完整分析有效代码,程序员在重构或其他大规模编辑做到一半时,语言服务器的体验就会大打折扣。

Andrey Kozhev 为同一包内模块试图使用另一个模块的私有类型或值时的错误信息补充了上下文,告诉用户它虽然存在但是私有的。对来自依赖包的模块我们不这样做,以免泄露程序员并不维护的代码的信息。

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

完整的修复和改进细节见 changelog。

评论

多数人对 Gleam 团队表示祝贺和喜爱,少数人则质疑这次变化背后的编译器实现方式,另有零星对语言本身和表达方式的吐槽。

恭喜团队,写 Gleam 是件开心的事

  • 多人对项目方向和新进展表示真诚祝贺,提到自己写 Gleam 代码时获得乐趣。(较多人)
  • 有人特别感谢核心成员在直播中耐心答疑、不带评判的教学风格,认为这在编程直播里很难得。(较多人) > Giacomo 的直播教会我一些 Gleam 和 Rust 的特性,而且他人很随和,会放下自我来回答问题。(@zachrip)

质疑在完全陌生的生态里实现编译器

  • 有人对 Gleam 评价本就不高,但更意外的是 1.0 之后的编译器仍在输出文本 Erlang 源码,认为用与目标生态完全无关的语言写编译器可能不是好主意;同时强调「transpiler」这个词本身并无贬义。(少数人)
  • 另一人追问为什么团队一开始不这样做,认为既然没有直接的 Gleam AST 到 Erlang 源码的管道,就只能自建一套 Erlang AST 再翻译回源码。(少数人) > 为什么 Gleam 团队一开始不这么做?他们显然没有直接的 Gleam AST 到 Erlang 源码的管道,必须自己造一套 Erlang AST 再翻译成源码。(@impoppy)

对语言本身和表达方式的零星吐槽

  • 有人讽刺一个宣称用于构建用户界面的语言至今没有字符串插值。(少数人)
  • 有人抱怨原文逗号误用让人分心,但补了一句至少比读 AI 博客强。(少数人) > 让我猜猜:一个号称用来构建用户界面的语言,还是没有字符串插值?(@jazzypants)

期待与 Erlang 调试工具集成

  • 有人对这次变化感到意外和兴奋,好奇是否会有人实现与 Whatsapp Erlang 调试器的集成。(少数人)

有价值的补充

  • Erlang abstract form 是 Erlang 编译器等工具使用的 AST 表示,由 Erlang term 规范构成,标准库提供了方便操作它的例程;它也是 Elixir 的编译目标,以及 parse transform 操作的对象,Erlang 的语法糖(如 qlc)和 merl 的元编程都依赖这一表示。
  • 大多数 BEAM 语言最终都选择 Erlang abstract format,而非看起来更像传统函数式 IR 的 Core Erlang;后者基本只有 LFE 在用,因为它逐版本变动、没有稳定性保证。