[LLVM翻译]创建BOLT编译器:第1部分——为什么要写自己的编程语言?

2,541 阅读11分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年5月10日-6分钟阅读

系列。创建BOLT编译器

上图是我们将要构建的语言Bolt的编译器。这些阶段是什么意思?我要学习OCaml和C++?等等,我连OCaml都没听说过......

别担心。当我在6个月前开始这个项目时,我从来没有建立一个编译器,也没有在任何严肃的项目中使用OCaml或C++。我会在适当的时候解释一切。我们真正应该问的问题是,为什么要设计自己的语言?可能的答案是

  1. 它很有趣
  2. 拥有自己的编程语言是一件很酷的事情。
  3. 这是个不错的副业

心理模型

虽然这三条(或者没有!)可能都是真的,但还有一个更大的动机:拥有正确的心理模型。你看,当你学习你的第一门编程语言时,你通过该语言的视角来看待编程。快进到你的第二种语言,似乎很难,你必须重新学习语法,这种新语言的做法也不一样。使用更多的编程语言,你会意识到这些语言有共同的主题。Java和Python有对象,Python和JavaScript不需要你写类型,这样的例子不胜枚举。进一步深入到编程语言理论中,你读到了语言构造的存在--Java和Python是面向对象的编程语言,Python和JavaScript是动态类型的。

你一直在使用的编程语言实际上是建立在你可能没有听说过的老语言中存在的思想之上的。Simula和Smalltalk引入了面向对象编程语言的概念。Lisp引入了动态类型的概念。而且还有更新的研究语言不断出现,引入了新的概念。一个比较主流的例子。Rust在低级系统编程语言中建立了内存安全。

构建自己的语言(尤其是当你加入新的想法时)可以帮助你更严谨地思考语言设计,所以当你去学习一门新的语言时,就会轻松很多。例如,去年夏天在Facebook实习之前,我从来没有用Hack编程,但知道这些编程语言的概念后,学起来就容易多了。

什么是编译器?

所以你设计了你的花哨的新语言,它将彻底改变世界,但有一个问题。你如何运行它?这就是编译器的作用。为了解释编译器是如何工作的,让我们先把思绪闪回19世纪,在电报时代。在这里,我们有了这个花哨的新电报机,但是我们如何发送消息呢?同样的问题,不同的领域。电报员需要接收语音,并将其转换成摩尔斯电码,并将电码拍出。操作员做的第一件事就是让语音变得有意义--他们将其分割成单词(词性),然后了解这些单词在句子中是如何使用的(解析)--它们是名词短语的一部分,还是从句的一部分等等。他们通过将单词分类或类型(形容词、名词、动词)来检查是否有意义,并检查句子是否有语法意义(我们不能用 "run "来描述名词,因为它是动词而不是名词)。最后,他们将每个单词翻译(编译)成点和破折号(莫尔斯码),然后沿着电线传输。

这似乎是劳民伤财,因为对人类来说,很多事情都是自动完成的。编译器的工作方式也是一样的,只是我们必须明确地给计算机编程来完成这些工作。上面的例子描述了一个简单的编译器,包括4个阶段:lex、解析、类型检查,然后翻译成机器指令。操作员还需要一些额外的工具来实际挖掘出摩尔斯代码;对于编程语言来说,这就是运行时环境。

在实践中,操作者很可能构建了一些速记符号,他们知道如何将其转换为摩斯码。现在,他们不是直接将语音转换成摩尔斯码,而是将语音转换成他们的速记符号,然后再将速记符号转换成摩尔斯码。在许多实用的语言中,你不能直接从源码到机器码,你有去ugaring或降级阶段,即你逐级删除语言构造(例如为循环解卷),直到我们只剩下一小套可以执行的指令。解卷使后面的阶段变得更容易,因为它们是在一个更简单的表示方式上操作的。编译器阶段分为前端、中端和后端,其中前端负责大部分的解析/类型检查,中端和后端则负责简化和优化代码。

编译器的设计选择

其实我们可以用上面的类比来架构很多语言和编译器的设计。

操作者是在传输文字时将文字即时翻译成摩斯码,还是事先将文字转换成摩斯码,然后再传输摩斯码?像Python这样的解释语言做的是前者,而像C(和Bolt)这样的超前编译语言做的是后者。Java实际上介于两者之间--它使用的是一个及时编译器,它事先做了大部分工作,将程序翻译成字节码,然后在运行时将字节码编译成机器码。

现在考虑一个场景,一个新的Lorse代码出来了,它是Morse代码的替代品。如果教给操作者如何将速记码转换为Lorse码,那么说话的人不需要知道怎么做,他们就能免费得到。同样,说不同语言的人只需要告诉操作者如何将其翻译成速记,然后他们就可以得到翻译成Lorse码和摩尔斯码!这就是LLVM的工作原理。这就是LLVM的工作原理。LLVM IR(中间表示法)作为介于程序和机器代码之间的垫脚石。C、C++、Rust和一大堆其他语言(包括Bolt)都以LLVM IR为目标,然后将代码编译到各种机器架构上。

静态打字与动态打字?在第一种情况下,操作者要么在开始敲击之前检查这些词是否有语法意义。或者,他们没有,然后中途他们就会觉得 "呵呵,这没有意义",然后停止。动态输入可以看做是比较快的实验(比如Python、JS),但是当你发送这个消息的时候,你不知道操作者会不会中途停止(崩溃)。

我已经用一个想象中的电报员来解释了,但任何类比都可以。建立这种直觉对理解哪些语言特性适合你的语言有很大的帮助:如果你要做实验,那么也许动态类型更好,因为你可以更快地移动。如果你使用的代码库比较大,那么就很难对所有代码进行校对,而且你更容易出错,所以你可能应该转向静态类型化,以避免破坏东西。

类型

编译器中最有趣的部分(在我看来)是类型检查器。在我们的类比中,运算器将单词分类为语篇(形容词、名词、动词),然后检查它们是否被正确使用。类型的工作原理是一样的,我们根据我们希望它们具有的行为对程序值进行分类。如:int代表可以相乘的数字,String代表可以连在一起的字符流。类型检查器的作用是防止不良行为的发生--比如将ints连在一起或将Strings相乘--这些操作没有意义,所以不应该被允许。通过类型检查,程序员用类型注释值,编译器检查它们是否正确。在类型推理中,编译器同时推断和检查类型。我们把检查类型的规则称为类型判断,这些规则的集合(以及类型本身)形成了一个类型系统。

其实事实证明,你能做的还有很多:类型系统不仅仅是检查ints或Strings是否被正确使用。更丰富的类型系统可以证明关于程序的更强的不变性:它们会终止,安全地访问内存,或者它们不包含数据竞赛。例如,Rust的类型系统保证了内存安全和数据竞赛的自由,同时也检查了传统类型ints和Strings。

Bolt的优势在哪里?

编程语言仍然没有破解编写安全并发代码的问题。Bolt和Rust一样,可以防止数据竞赛(在这个Rust文档中解释),但对并发采取了更精细的方法。在键盘侠们在Twitter上向我发难之前,我认为Rust已经做了一项出色的工作,让关于这个问题的对话得以进行--虽然Bolt可能永远不会成为主流,但它展示了另一种方法。

如果我们回顾一下现在的管道,你可以看到Bolt包含了词法、解析和去ugaring/降低阶段。它还包含几个Protobuf序列化和反序列化阶段:这些纯粹是为了在OCaml和C++之间进行转换。它的目标是LLVM IR,然后我们链接几个运行时库(pthreads和libc),最后我们输出我们的对象文件,一个包含机器代码的二进制文件。

不过与大多数编译器不同的是,Bolt的类型检查阶段不是一个,而是两个! Bolt有传统的类型和能力,非正式的说,这又是一套类型检查数据竞赛。我已经写了一篇论文,比较正式地探讨了这个问题,如果你对理论感兴趣,如果不感兴趣可以跳过本系列的数据竞赛检查文章。我们先对传统类型进行类型检查,在去ugaring阶段简化一下语言,然后再进行数据竞赛类型检查。

那这个系列呢?

这个系列可以从两个角度来思考:第一,我们将讨论语言设计,并沿途比较Bolt与Java、C++等语言。其次,它是一个实用的逐步建立自己的编译器的教程。与许多构建你自己的编译器教程不同,它告诉你如何构建一门玩具语言,本教程所探讨的一些主题构成了Java等并发面向对象语言的基础:类是如何实现的,继承是如何工作的,通用类,甚至是如何在引擎盖下实现并发。

Bolt也不输出玩具指令,而是针对LLVM IR。实际上,这意味着Bolt可以钩住C/C++编译器中存在的惊人优化功能。LLVM API很强大,但它的文档也很难浏览。我花了很多个漫长的夜晚来逆向工程C++程序--希望这个系列能让至少一个人免于经历这种痛苦。

在下一部分中,我们将探讨建立编译器项目的实际问题--我将介绍Bolt仓库,并解释为什么我们在前端使用所有语言中的OCaml。

在Twitter上分享此文

如果你喜欢这篇文章,请考虑与你的网络分享。如果你有任何问题,请发微博,我会回答:) 当有新文章发布时,我也会在推特上发布

PS:我也会在学习的过程中分享有用的技巧和链接--这样你就可以在它们进入文章之前就得到它们了。


通过www.DeepL.com/Translator (免费版)翻译