本文由 简悦SimpRead 转码,原文地址 quickbirdstudios.com
深入了解抽象语法树,以及它如何帮助我们的应用程序实现语言独立......。
你有没有想过,为什么我们必须在Android上使用Kotlin,在iOS上使用Swift开发?我们也问过自己同样的问题,并很快得出结论,可能有办法解决这个问题。在这次冒险中,我们将学习为什么和如何存储源代码,以及抽象语法树如何帮助实现App开发的语言独立性。
如果我们生活在一个想象的世界里,你和你的同事都能看到同一个源代码文件,看到Swift代码,而你却看到你喜欢的语言--Kotlin,那会怎样?如果我们以独立于语言的方式存储源代码,使你团队中的每个开发人员都能以自己喜欢的编程语言查看文件,那会怎样?
🧑💻源代码是如何存储的
现在,我们将代码存储为文本文件。它有很多优点。文本文件是非常简单的文件;人们可以搞乱编码,但除此之外,它只是一个包含,嗯,文本的文件。通常,你会使用IDE来编写源代码,但鉴于它只是文本文件,任何文本编辑器,如VS Code、Notepad++、Nano甚至Windows的内置记事本都可以完成这项工作。如果你很冒险,你甚至可以使用Vim(但在进行这种冒险之前,请谷歌一下如何退出它)。
问题
然而,如果你看一下这些文本文件,它们实际上是相当低效的。像缩进、空白、甚至一些下划线和大括号等东西,只是为了使源代码对我们人类来说更容易理解。缩进对程序的执行没有任何影响(除非你是一个Python程序员),然而,我们却把它存储在每一个源文件中。
Kotlin vs. Swift
如果我们比较一下Swift和Kotlin(或者任何其他编程语言),有很多概念是非常相似的:虽然一种语言调用函数func。而另一种语言则认为编程其实就是fun,在关键词的末尾加上这第4个字母,对程序的工作方式真的有区别吗?可以理解的是,一些程序员可能更喜欢阅读func而不是fun,但我们并不真的需要在磁盘上存储这些。
一个解决方案?
所以,回到将Kotlin转换为Swift的想法,反之亦然:我们如何才能存储源代码,使所有这些低效的东西都消失,并且我们可以很容易地将代码从一种语言转换或转译到另一种语言?
这个想法已经有好几年了,而且实际上已经被一些工具所采用,比如Jetbrains的MPS:如果我们把代码部分编译成所谓的 "抽象语法树"(简称AST)并存储起来,会怎么样呢?
在存储源代码之前,先对其进行预编译。让我们来存储抽象语法树!
🌲抽象语法树
为了继续讨论,我们首先需要了解,什么是 "抽象语法树"。每一个编译器都需要解析源代码并剖析其含义。为了做到这一点,它将源代码转化为一个树状结构。它需要这个树来进行语义分析(这反过来又被用来发布编译器警告和错误)。这不能仅靠源代码来完成,因为源代码实际上比你想象的更模糊。
如果我们看看像Kotlin中的推断类型、Go中的鸭子类型、运算符重载,或者简单的自定义类型(=custom classes),这些都不能由语言的语法来定义,因此需要用一些不同的结构--逻辑树--或者说AST来表示。 以这个语句为例。
result = 40 + 2
编译器将把它翻译成一棵树,看起来像这样。
首先,编译器意识到我们要存储一个值(在这种情况下作为一个成员属性),并且这个属性应该是只读的(与var result = 40 + 2相反,后者是可变的)。除此之外,它还存储了变量的名称,并且类型还需要被推断(type=null)。我们在计算中使用的+运算符是一个所谓的二进制运算符(在树中简称为BinaryOp),因为它需要两个参数:+前面的数字和后面的数字。
正如你所看到的,所有的格式化都消失了,只保留了我们的意图。将数字40和2相加,并将结果存储在一个名为result的只读属性中。而这个树被称为 "抽象语法树"。
🤔 为什么是 "抽象"?
考虑一下这个语句。
val result = (20 + 1) * 2
在这种情况下,括号表示应该先计算20+1,然后将结果乘以2。抽象语法树可以省略括号,因为它隐含地存储了执行顺序,像这样。
同样,编译器看到我们想把我们的值存储为一个只读的属性。就像之前的+运算符一样,也需要2个参数,因此也是由一个BinaryOp节点表示。然而,我们现在不是像以前那样将2个常数送入BinaryOp,而是将另一个BinaryOp20和1的加法送入乘法。因此,执行顺序隐含在树中,因为BinaryOp必须先被执行,然后才能被送入乘法。
编译器在创建AST后显然会做更多的事情来编译我们的程序,但这是我们目前所感兴趣的部分。
🔠转译将如何工作
因此,我们的目标是获取Kotlin源代码并生成AST。然后我们将AST保存在磁盘上,每当我们打开它时,我们可以选择是把它看成Kotlin代码还是Swift代码。 让我们举一个更复杂的例子。
fun doCalculation(first: Int, second: Int): Int = first + second
这个例子对应的Kotlin的AST会是这样的。
这一次,我们使用了一个名为doCalculation的函数,以及两个整数参数first和second。AST还包含我们定义的返回类型的信息,即Int。最后,实际的计算被存储在AST的表达式节点中,就像我们第一个例子一样。
使用这个AST,我们可以继续构建相应的Swift源代码。
doCalculation(first: Int, second: Int) -> Int { first + second }
注意,我们最后存储的源文件既不包含Kotlin代码,也不包含Swift代码,它只包含这棵树,从这棵树上可以重构出任何一个。
🪄 无尽的可能性
现在我们知道什么是AST了,让我们开始做梦吧。格式化在AST中消失了,根据AST的生成方式,它包括变量名,也可以包括注释。
所以,首先让我们结束Tabs-vs-spaces的辩论:AST不包括缩进。因此,每个团队成员都可以在他或她的编辑器设置中拥有自己的偏好,编辑器只是根据缩进的设置,以不同的方式渲染AST。其他格式化选项也是如此,例如,打开的大括号是否应该有自己的行。
改进代码审查
比较两个版本的抽象语法树,而不是做一个文本差异,会好得多。与其看构成程序的文本如何变化,AST的差异突出了程序逻辑的变化,而忽略了那些甚至不影响程序工作的无用变化。
Swift和Kotlin的AST有多相似
在撰写这篇博文的过程中,我们显然弄脏了自己的手,同时查看了Kotlin编译器和Swift编译器生成的AST。而且,这样做其实是很容易的。Swift编译器有一个内置的选项叫做-dump-ast,所以,通过调用swiftc yourSourceFile.swift -dump-ast,你可以直接看到AST的样子。对于Kotlin来说,情况要比这复杂一些,但也不多。有一个叫做KAST的库,它可以解析任何Kotlin文件并输出AST。
如果你用Kotlin(第一张图)和Swift(第二张图)生成上述例子的AST,你不需要多看就能发现第一个问题。
(Kotlin--抽象语法树)
(Swift - 抽象语法树)
差异
正如你所看到的,AST的总体结构是相似的,它们绝对可以相互翻译。虽然Kotlin称其为Function,Swift称其为函数声明,但这两个树都包含了函数名称、函数的参数列表(包括参数名称和类型)和函数的返回类型的信息。最显著的区别是,Kotlin存储的是+操作符存储在它的两个参数之间(所谓的infix符号),而Swift则是先存储操作符,后存储其参数(所谓的前缀符号或波兰语符号)。虽然这些都是很容易转换的。但是再看看Swift AST中的函数参数列表。你有没有注意到标签apiName?
如果你了解Swift,你可能也知道,函数参数可以有两个名字。一个用于在函数主体中指代参数,另一个在调用函数时使用,比如说,像这样。
func doCalculation(firstApiName first: Int, secondApiName second: Int) -> Int { first + second }
doCalculation(firstApiName: 40, secondApiName: 2)
并且apiName是AST中存储这些信息的地方。
我们应该如何处理这个问题呢?从Swift翻译到Kotlin时,我们可以简单地丢弃它,但反过来翻译时,我们会怎么做?而且我们还没有开始处理更复杂的事情,比如在扩展中实现一个接口,这在Swift中是可能的,但在Kotlin中是不可能的。或者拿Kotlin的泛型功能来说,比如共变和反变,这在Swift中也需要奇怪的变通。
所有这些的弊端和代价
如果你还没有意识到,整个方法是完全的乌托邦。我们不仅会被限制在语言特性的最小公分母上,而且从一种语言转译到另一种语言也不是把一个编译器生成的AST插入另一种语言的编译器那么简单。每个编译器通常会生成一个为该语言优化的AST,这是有原因的。
另外,虽然我们可以选择我们想用哪种语言编程(所谓的语言自由),但我们失去了工具的自由。虽然我们可以为Android Studio、IntelliJ、Xcode甚至VSCode等IDE编写插件,但我们如何在Git中合并文件或审查合并请求?还有,如果你没有IDE,需要在终端上进行编码,又该怎么办?
最后但并非最不重要。如何处理编译器错误?虽然程序员可能会避免提交破损的代码,但人们肯定要时不时地保存一个包含编译器错误的源代码文件。虽然AST在某种程度上可以代表错误的代码,但它很快就会变成一个奇怪的混乱,你不会希望你的IDE因为你保存了一个包含编译器错误的文件而奇怪地乱动。
结论
虽然语言的自由可能感觉很吸引人,但很清楚为什么这种方法永远不会真正发挥作用。好处是存在的,但是它们真的很小,而且缺点远远超过了它们。然而,在一般情况下,使用AST的工作是很有用的,例如在分析、提示或格式化源代码时。因此,我们希望你喜欢这次旅行,了解一下编译器的工作原理,并喜欢这个有趣的思想实验。
如果你想了解其他Kotlin语言的特点,这些文章可能是你的一个好的读物。
谢谢你的阅读! 另外,由于这是我们第一次在博客上探讨这样一个理论性的话题,我们非常希望听到你的反馈。我们是否遗漏了AST的任何优点,或者你对如何存储源代码有任何其他深奥的想法?请在Twitter上联系我们!