[Kotlin翻译]为什么我们构建了一个不会使用的编译器?

1,421 阅读14分钟

原文地址:medium.com/flock-commu…

原文作者:medium.com/@flock-blog…

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

Niels Simonides和Jerre van Veluw著。

照片:Vipul Jha on Unsplash

介紹

编译者很奇怪。完全忏悔。我们从来没有建立过编译器。是的,我们是软件工程师,我们也曾构建过将一种语言转化为另一种语言的程序(graphQLSimpleBindingsCMACC),但这些都不是完整的编译器。也许你创建了将文本转化为另一种文本的工具,和我们一样从来没有构建过编译器,即使你是一个专业的软件工程师。你接受的教育可能不是计算机科学,或者你没有上过编译器101。不过编译器很有意思,不是吗?它们接收一种语言,通过词法分析的过程,发出另一种语言。而且,在计算机科学的情况下,发出的语言是一个程序。更妙的是,这个程序可以是一个新的编译器,以创造更好的语言,以创造更好的编译器。编译器很奇怪。这就好比在某种程度上通过拔自己的头发让自己走出沼泽地。这个事实使得编译器变得有趣,比其他程序更有趣。为了了解编译器是如何工作的,你可以读一读它,或者听一听讲座。我们在QCon 2019上听了这样一个演讲(视频以及文字记录在这里)。Colin Eberhardt的演讲很有趣,内容丰富。你得到了令牌化、解析和发射所需要的东西。在这个特定的情况下,它展示了你如何在TypeScript中创建一个编译器,该编译器接收你自己设计的语言并将其转换为WebAssembly字节码。到目前为止还不错。如果你只想 "得到它",你就完成了。当然,当你想了解的时候,你需要创造。

"我不能创造的东西,我就不懂。" - Richard Feynman

所以我们决定创建一个编译器。

构建一个编译器

为了理解和构建一个编译器,我们需要先决定几件事。我们要用什么编程语言来构建编译器。我们的编译目标是什么,最重要的是,我们设计的编译语言将是什么。由于我们喜欢Kotlin,我们使用Kotlin Native来创建我们的编译器。编译目标应该是低级的,有了WebAssembly,我们已经可以利用Colin的见解来加快这个过程。但是我们想编译什么语言呢?因为我们是荷兰人,而且不希望我们的编译器有什么用处,所以我们选择创建一种带有荷兰关键字的语言。这是为了表明你可以选择任何字符,而不拘泥于英语。此外,它确保没有一个正常人会使用这种语言。更重要的是,用荷兰语想出一些词来描述一个程序是相当滑稽的。

架构

在高层次上,我们的编译器由3个不同的组件组成。tokenizer, parser, 和 emitter.

每个组件都会将一种表示方式转化为另一种表示方式。首先是将源码转化为一个标记列表,然后将标记转化为一个抽象语法树(AST),最后将AST转化为一个字节数组。其次,将标记转换为一个抽象语法树(AST),最后将AST转换为一个字节数组。

符号化器

tokenizer将源代码分割成token。从技术上讲,一个定义的regex匹配一串源代码,并被映射到一个标记。这意味着源代码和标记列表之间存在着一对一的映射。例如,考虑以下源代码。"waarde getal wordt 14; druk af getal;"(在英文伪代码中,它将被翻译成 "val number = 14; print number;")。这条语句被分解成13个不同的部分。

tokenizer会用一个 "匹配器 "列表来匹配文本的第一部分。

override val matchers = listOf(
   Regex("^waarde") to Value,
   Regex("^druk af") to Print,
   Regex("^wordt") to Assignment,
   Regex("^-?[.0-9]+") to Number,
   Regex("^\\s+") to Whitespace,
   Regex("^[a-zA-Z]+") to Identifier,
   Regex("^;") to EndOfLine
)

当找到一个匹配器时,该字符串就会从源代码中移除。tokenizer会继续匹配源代码的其余部分,直到它被完全标记化。

private fun String.tokenize(languageSpec: LanguageSpec, index: Long = 1L): List<Token> = when (val token = findToken(index, languageSpec)) {
    null -> throw noTokenFoundException(index)
    else -> with(removePrefix(token.value)) {
        if (isEmpty()) listOf(token) else listOf(token) + tokenize(languageSpec, index + token.value.length)
    }
}

让我们来分析一下

首先,findToken尝试在源代码中匹配一个匹配器。当找到匹配器时,它会返回第一个token。可以有多个匹配器。例如,'druk'也可以作为'Identifier'标记。这就是为什么匹配器的顺序很重要。我们要为'druk af'创建一个'Print'标记,而不是为'druk'创建一个'Identifier'标记。如果没有找到匹配器,我们会抛出一个异常,使编译失败。

匹配器中的正则表达式可以用^来匹配字符串的开头。通过这种方式,tokenizer可以从源码的开头删除匹配的字符串,然后递归分析,直到什么都不剩。在一个简单的例子中,它看起来像这样。

tokenize(“druk af 14;”):
- token(value: “druk af”, type: print)
tokenize(“ 14;);
- token(value: “druk af”, type: print)
- token(value: “ ”, type: whitespace)
tokenize(“14;”):
- token(value: “druk af”, type: print) 
- token(value: “ ”, type: whitespace)
- token(value: “14”, type: number)
tokenize(“;”):
- token(value: “druk af”) 
- token(value: “ ”, type: whitespace)
- token(value: “14”, type: number) 
- token(value: “;”, type: EOL)

当token化完成后,源代码就成功地转化为一个token列表。换句话说。源代码已经被分析为具有正确的语法。下一步是分析源代码是否有意义。

解析器

解析器将一个标记列表转换为抽象语法树(AST)。AST是一棵描述标记之间关系的节点树。代币只是源语法的平面表示,而AST则是源语法的结构化表示。由于 "Whitespace "标记对解析器没有任何意义,因此可以安全地丢弃它们。随后,它希望标记的组是按一定的顺序排列的。

fun List<Token>.parse(): AST = filterNot { it.type is Whitespace }
   .toProvider()
   .run { parser() }

我们创建了一个TokenProvider来迭代所有的标记并解析它们。通过这种方式,解析器将属于同一条语句的不同标记分组到一个ProgramNode中。

private fun TokenProvider.parser() = mutableListOf<ProgramNode>()
    .also { while (hasNext()) it.add(parseStatement()) }

由于语句是以关键字开始的,所以语句的第一个标记是关键字类型的。如果parseStatement发现其他的token,就会抛出一个异常。换句话说。如果源码在语法上是正确的,但不是以关键字开始的,那就没有任何意义。更有甚者:因为parseStatement函数是这样定义的,而TokenProvider只解析语句,所以源代码必须以语句开头,语句必须以关键字开头。

private fun TokenProvider.parseStatement(): ProgramNode = token
    .also { log("Parsing Statement with token: '${it.type}'") }
    .run {
        when (type) {
            is Keyword -> parseKeyword()
            else -> throw ParserException("Statement does not start with keyword or identifier: '$type'")
        }
    }
private fun TokenProvider.parseKeyword(): ProgramNode = token
    .also { log("Parsing keyword: '${it.value}'") }
    .run {
        when (type) {
            is Print -> parsePrintStatement()
            is Value -> parseVariableDeclarationAndAssignmentStatement()
            else -> throw ParserException("Unknown keyword: '$value'")
        }
    }

Print和Value都是关键字令牌。"druk af "Print令牌将触发parsePrintStatement函数,"waarde "将触发parseVariableDeclarationAndAssignmentStatement函数。 在我们的源码 "waarde getal wordt 14; druk af getal; "中,我们首先处理的是一个变量赋值和声明。

private fun TokenProvider.parseVariableDeclarationAndAssignmentStatement(): VariableAndAssignmentDeclaration = token
    .also { log("Parsing Variable Declaration And Assignment statement with Token: ${it.type}") }
    .let {
        eatToken()
        val identifierNode = parseExpression() as IdentifierNode
        parseExpression() as AssignmentNode
        val numberNode = parseExpression() as NumberNode
        parseEndOfLine()
        VariableAndAssignmentDeclaration(identifierNode, numberNode)
    }

首先,eatToken被调用,这将TokenProvider移动到下一个token。显然,"eat token "是一个与编译器相关的常用术语,所以我们在这里只用这个术语。此外,我们知道要以特定的顺序来期待令牌。我们要分配的表达式, 分配本身, 值, 最后是EOL. 如果其中有一个不是这样,那么变量赋值就没有意义。当语句是有意义的时候,就会返回一个VariableAndAssignmentDeclaration,持有标识符和数字节点。

我们程序中的下一条语句是打印语句。

private fun TokenProvider.parsePrintStatement(): PrintStatement = token
    .also { log("Parsing Print statement with Token: ${it.type}") }
    .let {
        eatToken()
        val expression = parseExpression()
        parseEndOfLine()
        PrintStatement(expression)
    }

首先,eatToken消耗当前的token。随后,我们期望打印的是表达式token,最后是行结束token。同样,如果有一条不是这样,打印语句就没有意义。当打印语句有意义时,会返回一个持有表达式的PrintStatement。我们已经展示了几个要解析的表达式。为了解析一个有意义的表达式,我们定义了一个这样的函数。

private fun TokenProvider.parseExpression(): ExpressionNode = token
    .also { log("Parsing Expression starting with Token: ${it.type}") }
    .run {
        when (type) {
            is Identifier -> IdentifierNode(value)
            is Number -> NumberNode(value.toFloat())
            is Assignment -> AssignmentNode(value)
            else -> throw ParserException("Unknown Token of type: '$type'")
        }
    }
    .also { eatToken() }

在这里,parseExpression将某些标记映射到相应的N节点上,并消耗该标记。

现在,我们 "waarde getal wordt 14; druk af getal; "程序的所有标记都被消耗掉了。总结一下,以下是解析后的token列表。

- token(value=“waarde”, type=value)
- token(value“ ”, type=whitespace)
- token(value=“getal”, type=identifier)
- token(value=“ ”, type=whitespace)
- token(value=“wordt”, type=assignment)
- token(value=“ ”, type=whitespace)
- token(value=14, type=number)
- token(value=“;”, type=EOL)
- token(value=“ ”, type=whitespace)
- token(value=“druk af”, type=print)
- token(value=“ ”, type=whitespace)
- token(value=“getal”, type=identifier)
- token(value=“;”, type=EOL)
- token(value=“EOP”, type=EOP)

这些都转化为两个ProgramNode的。PrintStatement(expression: IdentifierNode(getal))和VariableAndAssignmentDeclaration(identifier=IdentifierNode(getal), expression=NumberNode(number=14))

示意上AST是这样的。

在这个简单的例子中,VariableAssignmentAndDeclaration和PrintStatement是唯一的程序节点,它们只有一层子节点。对于一个更复杂的程序来说,"树 "的作用会更明显。

发射器

发射器将AST转化为一个字节数组;一个WebAssembly实例可以理解的模块。在代码部分旁边,这个字节数组包含了其他部分,使其成为一个有效的WebAssembly模块。虽然这篇博客不是关于WebAssembly的,但为了完整起见,我们将列出这些部分。

该模块以魔数头和版本开始。其他部分由其SectionCode标识,只出现一次。您可以在这里阅读更多关于模块结构的信息。

发射器会创建所有的部分,并将它们连接成一个单一的字节数组。

fun AST.emit(): ByteArray = createHeader() +
        createModuleVersion() +
        createTypeSection() +
        createImportSection() +
        createFunctionSection() +
        createExportSection() +
        createCodeSection()

我们最感兴趣的部分是代码部分。在这里我们将AST转换为一个字节数组。

private fun AST.createCodeSection() = emitCode()
    .let { Create.section(Section.Code, listOf((emitLocals() + it + Opcode.end).encode()).encode()) }

代码部分包括2个部分,locals和代码。locals部分规定了你可以使用的变量值的最大指针数量。这样,一个标识符的索引就是指向值的指针。换句话说,如果这个数字等于变量的数量,这是对内存最有效的利用。在使用它们之前,必须先声明它们。

private fun emitLocals(): ByteArray = identifiers
    .run { if (isEmpty()) listOf() else listOf(unsignedLeb128(size) + ValueType.f32) }
    .encode()

在我们的例子中,标识符 "getal "在标识符列表中得到一个索引,因此被声明为指向值14的指针。标识符列表包含代码使用的所有变量。如果这个列表是空的,那么locals将不会被编码(一个空的字节数组)。否则,变量的数量将与ValueType.f32字节一起发出。

在字节码中编码完整的变量名称是很浪费空间的。因此每个变量都是一个索引。发射器建立了一个标识符列表,其中包含所有的变量。这个列表中的索引就是对变量值的引用。

private fun getIdentifier(identifierNodeValue: String): ByteArray = with(identifiers) {
    if (!contains(identifierNodeValue)) add(identifierNodeValue)
    indexOf(identifierNodeValue)
}.let { byteArrayOf(unsignedLeb128(it)) }

我们现在已经打好了基础,所以代码可以被发射了。这是通过遍历AST,发射节点并将它们连接成一个单一的字节数组来完成的。

private fun AST.emitCode() = map { it.emit() }.reduce { acc, cur -> acc + cur }

fun Node.emit(): ByteArray = also { log("Emitting Program Node $it") }
    .run {
        when (this) {
            is PrintStatement -> emitPrintStatement()
            is VariableAndAssignmentDeclaration -> emitVariableAndAssignmentDeclaration()
            else -> throw EmitterException("Unknown program node: $this")
        }
    }

起初,发射器会检查语句。每条语句都会转化为一个特定的WebAssembly指令序列。对于 "waarde getal wordt 14; "和 "druk af getal; "这些函数是。

private fun VariableAndAssignmentDeclaration.emitVariableAndAssignmentDeclaration(): ByteArray = also { log("Emitting Variable And Assignment Declaration") }
    .let { expression.emit() + identifier.set().emit() }

private fun PrintStatement.emitPrintStatement(): ByteArray = also { log("Emitting Print Statement") }
    .let { expression.emit() + Opcode.call + unsignedLeb128(0) }

注意,顺序是相反的。对于print语句,首先发出要打印的值。之后是一个调用指令。最后,发出对函数的引用。在这种情况下,它是索引0的导入函数。也就是与 console.log 的绑定。这种相反的顺序是WebAssembly基于栈的直接结果。当这条语句被执行时,堆栈的顶部会是这样的。

由于所有的语句都是以其大小进行编码的,所以WebAssembly知道为了执行完整的语句,需要从堆栈中弹出多少条记录。

在这两个语句中,都会发出一个表达式。我们为此写的函数是。


private fun ExpressionNode.emit(): ByteArray = also { log("Emitting Expression $it") }
    .let {
        when (this) {
            is NumberNode -> Opcode.f32_const + number.toIEEE754Array()
            is IdentifierNode -> when (set) {
                true -> Opcode.set_local + getIdentifier(value)
                false -> Opcode.get_local + getIdentifier(value)
            }
            else -> throw EmitterException("Unknown expression: $this")
        }
    }

表达式可以是NumberNode或IdentifierNode。如果是IdentifierNode,则有两个分支。节点包含一个set属性,表示要在标识符列表中存储一个新的值,产生索引。如果set为false,则检索到的标识符索引将被调用。举个例子,在语句中 "waarde getal is 14 "需要set_local, "druk af getal "需要get_local.

当我们的程序被发出后,最终的字节码看起来像这样。

[0,97,115,109,1,0,0,0,1,8,2,96,1,125,0,96,0,0,2,13,1,3,101,110,118,5,112,114,105,110,116,0,0,3,2,1,1,7,7,1,3,114,117,110,0,1,10,17,1,15,1,1,125,67,0,0,96,65,33,0,32,0,16,0,11]

字节码可以被输入到WebAssembly实例中,并通过调用导出的运行函数来执行,在控制台上显示:"14"。"14 "显示在控制台。

我们学到了什么

作为开发者,我们每天都在使用这些叫做编译器的 "黑盒子"。我们出于好奇心开始了这个项目,想了解它们的工作原理。乍一看,编译器可能看起来很吓人。它看起来像一个复杂的机制,将源代码转化为可执行的字节码。

我们从Colin的博客和他的TypeScript编译器代码库开始。这样我们就有了一个全局性的认识,并从tokenizer开始。幸运的是,事实证明这比我们想象的要简单。使用递归的方法,我们只用了几行代码就能将我们的程序标记化。

继续进行解析器,结果又一次发现它没有我们想象的那么复杂。解析器只是期望令牌按照一定的顺序排列,并将它们归入一个语句中。不过最难的部分是处理eatToken的调用。这是一个副作用,并且修改了一个可变的属性,即当前的token。我们把这些代码放在一个地方,TokenProvider。这样一来,就可以管理了,而且尽可能的清晰。

emitter是复杂的地方吗?的确,发射器花了我们大部分的时间,而且很复杂,但在逻辑上并不复杂。它只是将程序节点以逻辑的方式转化为字节码。

困难的是如何把编码做好。由于我们使用的是Kotlin原生,我们不能使用任何现有的Java库来编码不同的类型。此外,我们不得不使用位移来构建一些方法。此外,调试发射器函数的输出也很困难。例如,有时它发出:[0,2,13,1,3,101]而不是[0,3,13,1,3,102]。好在找到了问题所在! 对于我们人类来说,它只是一个数字阵列,很难推断出每一个数字的含义。

三个组件(tokenizer、parser、emitter)显示了定义正确的抽象的力量。对于解析器来说,它的作用最为明显。它把一个复杂的过程变成了可理解、可消化、独立的步骤。选择正确的抽象不仅适用于编译器,而且适用于所有软件。

我们很感激Colin能通过WebAssembly规范。更何况我们可以检查他的编译器代码,看看他是如何架构各部分的。更重要的是,我们可以看到需要哪些操作码。

从我们的代码库来看,我们很高兴选择了Kotlin Native。标准库没有让我们失望。不幸的是,我们不能使用任何Java库进行编码,但这些功能很容易被复制。

Kotlin的工作真的很好。很多类型,比如tokens和节点,都是在密封的类中建模的。这样一来,处理就发生在详尽的 "when子句 "中。通过这种方式,Kotlin编译器帮助我们处理了所有情况。如下,用特性扩展DASM是很琐碎的。如果你添加一个,编译器会告诉你在哪里添加额外的代码。

扩展功能有助于创建简洁、雄辩的语句。例如,通过重载'+'运算符,字节数组的连接就会尽可能的流畅。

尽管编写编译器可能不是你的人生目标之一,但我们鼓励软件开发人员尝试一下。编译器可能是一个与你日常编写的软件截然不同的软件。它需要你用不同的数据结构和抽象来思考。从这个角度出发,会帮助你拓宽知识面,获得新的见解,用于日常工作。

参考文献

Colin Eberhardt: 博客:blog.scottlogic.com/2019/05/17/… 谈话:www.infoq.com/presentatio…

github.com/flock-commu…


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