开发一门编程语言需要多久的时间?要盘活这门编程语言又需要多久?
一位来自荷兰的软件开发者 Yorick Peterse 用十年开发编程语言的经验回答道:如果你希望自己的语言取得成功,请做好至少需要 10-15 年的准备。如果你期望它在短短一年内风靡全球,那你将大失所望。
Yorick Peterse 于 2021 年离开 GitLab 全职开始从事自己研发的编程语言 Inko 工作,在本文中,他将分享自十年前开始研究到全职投入构建编程语言所学到的一些东西。
原文:
https://yorickpeterse.com/articles/a-decade-of-developing-a-programming-language/
以下为译文:
2013 年,我萌生了一个想法:“如果我要构建自己的编程语言,应该怎么做?”
当时我的想法很简单,就是要做一款「混合了 Ruby 和 Smalltalk 元素的解释型语言」。
2013 年到 2015 年间,我断断续续地尝试了不同的语言(C、C++、D 和其他各种我记不清的语言),看看用哪种来构建属于我自己的编程语言。虽然这并没有帮我找到我想用的语言,但却帮我淘汰了其他语言。例如,事实证明 C 语言真的太难用了;D 语言似乎更有趣些,我也设法实现了一些类似于虚拟机的东西,但我最终决定不使用它。现在的我不太能记清当时不使用的确切原因,但我相信是由于 D 语言版本 1 和 2 之间的差异、学习资源和软件包的普遍缺乏以及垃圾收集器的存在而造成的“弃用”。
2014 年某个时候,我发现了 Rust 这门语言。虽然用“粗糙”来形容 Rust 当时的状态再合适不过了,而且学习它(尤其是在当时缺乏指南的情况下)也很困难,但我很喜欢使用它;与我在此之前尝试过的其他语言相比,我更喜欢它。
2015 年,Rust 1.0 发布,同年,我为自己所开发的 Inko 提交了最初的几行 Rust 代码。不过又过了两个多月,代码才开始(隐约)像一门编程语言。
时至 2023 年,Inko 已经可以编写有意义的程序(例如 HVAC 自动化软件、Markdown 解析器、更新日志生成器等)。这些年来,Inko 也发生了很大变化:它曾经是一种渐进类型的解释型语言,而现在则是静态类型的,并使用 LLVM 编译为机器代码。虽然 Inko 曾经从 Ruby 和 Smalltalk 中汲取了大量灵感,但如今它更接近 Rust、Erlang 和 Pony,而不是 Ruby 或 Smalltalk。
鉴于从我开始开发 Inko 到现在已经过去了 10 年,我想重点介绍一下(排名不分先后)从我开始开发 Inko 到现在,我在构建编程语言方面学到的一些东西。这绝不是一份详尽无遗的清单,而是我在撰写本文时所能记住的内容。
避免渐进类型(gradual typing)
我做出的一个重大改变是将 Inko 从渐进式类型语言转换为静态类型语言。
渐进类型(gradual typing)背后的理念是,它可以让你在短时间内使用动态类型构建原型或简单脚本,然后随着时间的推移将程序转换为静态类型程序(如果有益的话)。
实际上,渐进式类型最终给你带来了动态类型和静态类型的最坏结果:你得到了动态类型的不确定性和缺乏安全性(在动态类型上下文中),以及试图将你的想法融入静态类型的成本系统。
我还发现,与使用静态类型相比,使用渐进类型实际上并没有提高我的工作效率。结果是,我发现自己在 Inko 的标准库和我编写的程序中都避免了动态类型。事实上,在标准库中使用动态类型的少数地方是因为类型系统不够强大,无法提供更好的替代方案。
渐进式类型对性能也有影响。请看这个使用关键字参数的例子:
let x: Any = some_value
x.foo(b: 42, a: 10)
在这里,x 被键入为 Any,这意味着值是动态类型的。由于我们不知道 x.foo(...) 中 x 的类型,因此无法在编译时将关键字参数解析为位置参数。这意味着 Inko 的虚拟机必须提供运行时回退,并且必须将关键字参数编码到字节码中。虽然成本并不显著,但在静态类型语言中,代价为零,因为我们可以在编译时解决参数问题。
另一个问题是,动态类型的存在会抑制编译时优化,例如编译时内联(以及所有依赖于内联的优化)。如果一种语言使用即时编译器(JIT),例如 JavaScript(以及 TypeScript),那么就可以在运行时优化代码,但这意味着必须编写 JIT 编译器,而这本身就是一项艰巨的任务。
动态类型的存在也意味着即使是静态类型的代码也可能是不正确的,不过这取决于你如何将动态类型的值转换为静态类型的值。如果这种转换不需要运行时检查,那么最终可能会将错误类型的数据传递给静态类型的代码。如果执行了某种运行时检查,当这种类型转换很常见时,可能会影响性能。
建议:要么使语言采用静态类型,要么采用动态类型(最好是静态类型化,但这是另一个话题),因为渐进类型化对新语言毫无意义。
避免自托管编译器
在开发 Inko 的早期,我决定在 Inko 中编写编译器,这通常被称为“自托管编译器”。我的想法是,通过这样做,编译器可以通过标准库公开,并有一个足够复杂的程序来测试 Inko 提供的所有功能。
虽然这在纸面上看起来很不错,但在实际操作中却变成了真正的挑战。维护一个编译器已经是一项挑战,而维护两个编译器(一个用于引导自托管编译器,另一个用于自托管编译器本身)则更加困难。
编译器的构建过程也更加复杂:首先,你必须构建引导编译器,然后才能使用引导编译器构建自托管编译器。理想情况下,你还可以使用自托管编译器进行第二次编译,这样就能确保编译行为不会因编译自托管编译器时使用的编译器(引导编译器或自托管编译器)不同而发生微妙变化。
由于这些挑战,我放弃了这一想法,转而使用 Rust 编写编译器,并在可预见的未来保持这种方式。
建议:推迟编写自托管编译器,直到你拥有可靠的语言和生态系统。可靠的语言和生态系统比自托管编译器对用户更有用。
避免编写自己的代码生成器、链接器等
在编写一门语言时,很容易承担超出自己能力或应该承担的任务。特别是,编写自己的本地代码生成器、链接器、C 标准库等(即 Zig 和 Roc 等语言正在做的事情)可能很有诱惑力。
我的总体建议是避免这样做,除非你有明确的需求。当你认为确实有必要时,我还是会避免这样做。编写一门语言就其本身而言已经够难了,很容易就会花费数年时间。你在上面每添加一个这样的组件(链接器、代码生成器等),整个堆栈就需要多花几年时间才能变得有用。这还忽略了一个令人痛苦的事实,那就是这些定制组件的性能极有可能无法超越现有的替代品。
建议:有很多开发人员认为他们可以编写更好的链接器、代码生成器等,但真正成功做到这一点的开发人员却寥寥无几。虽然听起来很残酷,但你可能不是其中之一。当然,一旦你拥有了一种成熟的语言,你就可以随意重新发明这些轮子。
避免乱改语法
语言的语法及其解析方式是构建编程语言过程中最枯燥的方面之一。一般来说,编写解析器是非常枯燥的,而且也没有什么可以创新的地方。
然而,许多开发人员在构建自己的语言时却似乎在这个问题上花费了太多时间。还有很多文章的标题大意是“如何构建自己的编程语言”,但只涉及编写解析器的基础知识,再无其他。
对于 Inko,我在早期采用了不同的方法: 我使用了 S 表达式语法,而不是自己设计语法和编写解析器。这意味着我可以对语言的语义和虚拟机进行实验,而不用担心函数定义使用什么关键字。
建议:在设计语言原型时使用现有的语法和解析器,这样就可以专注于语义而不是语法。一旦你对自己的语言有了更好的理解,就可以改用自己的语法。
跨平台支持是一项挑战
这本不足为奇,但支持不同平台(Linux、macOS、Windows 等)却很难。例如,Inko 在使用解释器时曾支持 Windows。当切换到编译语言时,我不得不放弃对 Windows 的支持,因为我无法让某些东西正常工作(例如用于切换线程栈的汇编)。
在不同平台上运行测试也不那么容易。以 GitHub Actions 为例:你可以使用它在 Linux、macOS 和 Windows 上运行测试。遗憾的是,免费套餐(在撰写本文时)仅支持 AMD64 运行程序,虽然支持 macOS ARM64 运行程序,但每分钟的费用为 0.16 美元。
成本甚至不是最大的问题,因为根据测试运行的频率,成本可能并不高。相反,问题在于付费运行程序通常不能用于 forks,这意味着第三方贡献者的拉取请求将无法使用这些运行程序运行测试。
而且,这还忽略了你选择的持续集成平台(如 GitHub Actions)不支持平台的问题。FreeBSD 就是一个很好的例子:GitHub Actions 就是不支持 FreeBSD,所以你需要使用 qemu 或类似软件在虚拟机中运行 FreeBSD。
即使你只支持 Linux,你仍然必须处理 Linux 发行版之间的差异。例如,Inko 使用 LLVM 的 Rust 封装器(Inkwell),但它使用的低级 LLVM 封装器(llvm-sys)无法在 Alpine Linux 上编译,因此 Inko 暂时不支持 Alpine Linux。
这个问题的严重程度取决于你要构建的语言。例如,如果你要构建一个用 Rust 编写的解释器,情况可能不会那么糟糕(尽管 Windows 总是一个挑战),但你需要做好准备。
建议:如果你不确定是否支持某个平台,那就选择不支持并记录下来,而不是支持了却又不完全支持。
编译器书籍不值钱
虽然有很多关于编译器开发的书籍,但它们往往并不那么有用。尤其是,这类书籍往往会用大量时间来介绍解析,而解析可以说是编译器中最枯燥和无聊的部分,然后只简单介绍优化等更有趣的主题。
哦,祝你好运能找到一本解释如何编写类型检查器的书,更不用说涵盖支持子类型、泛型等更实用主题的书了。
建议:从阅读《精巧的解释器》(Crafting Interpreters,https://craftinginterpreters.com/)开始,并阅读 Reddit 上的 /r/ProgrammingLanguages(https://www.reddit.com/r/ProgrammingLanguages/)。如果你有兴趣了解模式匹配的更多信息,这个 Git 仓库可能会对你有所帮助(https://github.com/yorickpeterse/pattern-matching-in-rust)。
发展一门语言是很难的
构建一门语言本身就是一项巨大的挑战。要想更多的人使用你的语言和用你的语言编写的库,那就更难了。现实中,语言要么在流行/兴趣方面呈现爆炸式增长,要么需要数年时间才能获得少数用户的支持。
靠编程语言谋生异常困难,因为愿意捐钱的人比愿意试用你的新语言的人还要少。这就意味着,要么投入大量业余时间来构建自己的语言,要么辞去工作,自己出资开发(比如用自己的积蓄)。这就是我在 2021 年底所做的事情,虽然我并不后悔这样做,但看着自己的钱包随着时间的推移而缩水,还是有点心痛。
至于建议,我不知道该如何去做,因为我自己也还在摸索。我所知道的是,很多现有的建议根本无济于事,因为这些建议无非是“多争取用户,哈哈”。也许再过 10 年,我就会知道答案了。
最好的测试套件是真实的应用程序
这一点显而易见,但无论如何都值得强调:为你的语言编写单元测试(例如为标准库函数编写单元测试)是重要而有用的,但远不如用该语言编写一个真正的应用程序来得有用。例如,我用 Inko 编写了一个控制我家 HVAC(暖通空调系统)的程序(https://github.com/yorickpeterse/openflow),在这个过程中我发现了各种错误和需要改进的地方。这些应用程序还可以作为你的语言的一个展示窗口,让潜在用户更容易理解你的语言构建的一个普通项目可能是什么样的。
建议:用你的语言编写几个足够复杂且实际有用的程序,然后用它们来测试你的语言的功能性和稳定性。如果你想不出要编写什么程序,可以考虑移植这个用 Inko 编写的更新日志生成器,因为它足够复杂,可以对你的语言进行良好的压力测试,但又不会复杂到需要花费数周时间来移植。
不要将性能置于功能之上
在构建一种语言时,人们很容易将注意力集中在提供快速的实现上,例如快速、内存效率高的编译器,并且很容易为此花费数月的时间。虽然新编程语言的潜在用户可能会在一定程度上关心性能,但他们更关心的是能否使用你的语言、用它编写库,以及是否不必因为缺乏标准库而不得不自己重新实现每一个基本功能。
换句话说:良好性能的价值与用一种语言编写的有意义代码(= 实际应用)的数量成正比。
建议:俗话说:先让它好用,再让它快。这并不意味着你完全不应该关心性能,相反,你应该将 70-80% 的精力放在功能性上,剩下的 20-30% 用于使语言的速度不至于太慢。
构建语言需要时间
最后,还有一个显而易见但值得提出的观点:在短时间内为自己构建一门简单的语言是可行的。而构建一门供许多人使用多年的语言则需要很长时间。
为了说明这一点,下面列举了一些语言以及它们发布第一个稳定版本的时间(? 表示在撰写本文时还没有稳定版本):
此外,一门语言从稳定到流行之间可能会有相当长的时间间隔。Ruby 1.0 发布于 1996 年,但直到 2005 年左右,随着 Ruby on Rails 的发布,Ruby 才开始流行起来。反过来,Rust 在发布第一个稳定版本后也开始流行起来,但仍需要几年的时间才能让这门语言起飞。Scala 在 2004 年发布了 1.0.0 版本,但直到 2010 年至 2015 年间才得到广泛应用。
基于这些模式,我猜测大多数语言至少需要 5-10 年的开发时间才能发布第一个稳定版本,然后再花 5 年左右的时间才能开始起飞。这一切的前提是你足够幸运,你的语言能真正起飞,因为有很多语言会逐渐默默无闻。
建议:如果你希望自己的语言取得成功,请做好至少需要 10-15 年的准备。如果你期望它在短短一年内风靡全球,那你将大失所望。