一种简单但鲜为人知的解析法,暂时告别编译原理各种算法,上手一个js解析器

668 阅读7分钟

本文写作的动机是之前学习了编译原理相关算法之后,惊叹于各种算法巧妙之时,也被其复杂性所困扰。恰巧发现了一种简单的解析器构造法,百度之后发现中文对该解析方法的介绍几乎没有,所以尝试翻译国外一位大佬的文章,才疏学浅,能达到抛砖引玉效果最好,如有错误之处多多包涵。
原文链接:engineering.desmos.com/articles/pr…

代码链接:github.com/desmosinc/p…

希望直接看一手这种解析方法的原理解释的话,忽略本文章,直接google搜Pratt Parsing就可

什么是parser?

当你阅读一个表达式时,比如 1/2+3.4,你可以立即理解它含义,你认识到存在三个数字,并且这些数字与运算符相结合。你可能还记得除法的优先级高于加法,因此在计算表达式时,您会先算 1/2,然后再加上 +3.4。

将以上思考与您对 2H3SGKHJD 的看法进行比较。乍一看,它似乎是一个无意义的字符序列。如果我告诉你字符应该成对分组,G 是分隔符,你的心智模型可能看起来更接近 2H 3S ; KH JD,至此我们大致可以认为该字符串代表纸牌游戏中的手牌。

解析是获取一串字符并将它们转换为抽象语法树(或 AST)的过程。这是表达式结构的表示,例如

OperatorNode( o
    perator: "+", 
    left: OperatorNode( 
        operator: "/", 
        left: 1, 
        right: 2 ), 
    right: 3.4 )

图像表示

image.png

那么,如何创建 AST?

在 Desmos,我们使用 Vaughan Pratt 描述的方法。本文将首先对 Pratt Parsing 的工作原理进行清晰简洁的解释。然后,我们将解释我们在 Desmos 采用这种技术的动机,并将其与我们之前的方法 jison 解析器生成器进行比较。

最后,我们在 Typescript 中提供了解析器(和词法分析器)的示例实现,与 CodeMirror 集成。我们希望这对于有兴趣在浏览器中进行解析的任何人来说都是一个有用的参考和起点

它是如何工作的?

我们的 parse 函数将对token对象进行操作。这是一个token序列,例如 [1, "/", 2, "+", 3.4],该序列是通过一个称为词法分析的过程从我们的输入生成的。我们不会在这里详细介绍词法分析。

token对象是一个token流,它允许我们消费一个token,返回下一个token并推进流。我们还可以peek一个令牌,这会在不推进流的情况下为我们提供下一个令牌。

image.png

我们从一个递归调用开始,并在我们进行的过程中填充要讲的东西。我们将用伪代码介绍我们的方法,但欢迎您在我们进行过程中参考 Typescript 实现。

function parse(tokens): 
    firstToken = tokens.consume() 
    if firstToken is a number 
        leftNode = NumberNode(firstToken) 
    otherwise Error("Expected a number") 
        nextToken = tokens.consume() 
        
    if nextToken is an operator token 
        rightNode = parse(tokens) 
       
            return OperatorNode( 
                operator: nextToken, 
                leftNode, rightNode
           )
    otherwise 
        return leftNode

我们期望一个数字标记后跟一个可选的运算符。然后我们执行递归调用以找到右侧的子表达式。到目前为止一切顺利——我们开始解析像 3 * 2 + 1 这样的表达式可能会如何工作:

image.png

优先级

如果我们要计算这个表达式,我们会先加 2 + 1,然后将该子树的结果乘以 3,得到 9。这是不可取的,因为传统上乘法的优先级高于加法,我们会让树看起来像这样:

image.png 普拉特用约束力(binding power)这个词来代表这个想法。乘法比加法具有更高的结合能力,因此上面表达式中的 3 * 2 优先。当我们执行递归调用来解析 2 + 1 时,我们正在寻找代表我们产品右侧的节点。所以,当我们看到“+”时,我们想停下来,因为它的绑定不如“*”强。让我们将它添加到我们的代码中,注意这仍然是不完整的,我们会随着我们的进展而改进:

image.png

现在重新思考如何解析 3 * 2 + 1 的执行:

image.png

左侧累积

正如所愿,在解析子表达式 2 + 1 时,我们的递归调用在 + 之前停止。这使我们能够正确地将 3 * 2 组合成外部调用中的产出节点。然而,计算过早地停止了,我们留下 + 1 未处理。现在让我们解决这个问题:

image.png 新的执行结果:

image.png

合成

我们现在可以看到绑定能力如何引导我们在构建树时进行正确的分组。只要我们遇到的运算符具有更高的优先级,我们就会继续进行递归调用,从而在树的右侧构建我们的表达式。当遇到优先级较低的算子(即操作符)时,我们会将结果沿调用链向上传播,直到达到优先级足以继续分组的级别。在那里,我们将累积的term转移到 leftNode,并继续构建表达式的右侧。

换句话说,当前操作符优先级高于我们的上下文时,我们使用递归调用关联到右侧。当它较低时,我们使用重复循环关联到左侧。这确实是理解 Pratt 解析器如何工作的关键

因此值得花一点时间让自己通过执行 3 + 4 * 2 ^ 2 * 3 - 1 来感受一下。

关联性和binding power(优先级)

我们还没有明确提到的一件事是运算符关联性。一些运算符,如加法和减法是左结合的,这意味着当我们重复应用它们时,3 - 2 - 1,我们将左关联 (3 - 2) - 1。其他的,如求幂关联到右,所以 2 ^ 3 ^ 4 与 2 ^ (3 ^ 4) 相同。 希望到目前为止的说明清楚地说明了我们如何使用greaterBindingPower函数来实现它。我们希望左结合运算符在遇到相同的运算符时停止递归。所以,greaterBindingPower(-, -) 应该是fasle。另一方面,当运算符是右关联时,我们希望继续递归,因此 GreaterBindingPower(^, ^) 应该为真。 在实践中,这种行为是通过为每个运算符类分配一个binding power number来实现的。例如,

image.png 我们将这个数字传递给 parse 函数,并查找下一个token的binding power来做出我们的决定。右关联运算符是通过在进行递归调用时从其binding power中减去 1 来实现的。

parse(tokens, currentBindingPower):
    ...
    repeat
        ...
        nextToken = tokens.peek()
        if bindingPower[nextToken] <= currentBindingPower
            stop repeating

        nextBindingPower = if nextToken is left-associative 
                            then bindingPower 
                            otherwise bindingPower - 1
        rightNode = parse(tokens, nextBindingPower)
    ...

扩展语法

到目前为止,我们可以解析 形式的数字和二元运算符,但我们可能必须处理其他形式,例如 ( )、log 或 if 然后是 <表达式> 否则是 <表达式>。 我们可以使用 parse 调用,它可以给我们一个比给定上下文绑定更强的子表达式。有了这个,我们可以以一种优雅、易读的方式解析这些不同的形式。例如,要解析包含在一对大括号中的表达式,

...
currentToken = tokens.consume()
if currentToken is "("
    expression = parse(tokens, 0) // Note, reset the binding power to 0 so we parse a whole subexpression
    next = tokens.consume()
    if next is not ")"
        Error("Expected a closing brace")
...

我们可以将这些概念——子表达式的解析、传递给递归调用的binding power的调整、左/右关联性和错误处理组合成一个称为 Parselet 的单元。 我们的代码中有两个地方可以调用 parselets。第一个是达式之间(用普拉特的话来说,这被称为“led”)。另一个是新表达的开头(在普拉特的论文中,“nud”)。(译者注:中序表达式和前序表达式)目前我们在那里处理数字类型的token,将它们转换为数字节点。这很好地抽象为一个 parselet - 一个将单个token转换为节点并且不执行任何递归调用来解析子表达式的解析器。这也是上述解析大括号的代码所在。 在示例代码中,我们将它们标识为 initialParselet 和 consequentParselet。每组 parselet 都存储在一个映射中,由标识 parselet 的token类型作为键。

initialPareselets = { "number": NumberParselet, 
    "(": ParenthesisParselet, ... } 
NumberParselet: parse(tokens, currentToken):
    return NumberNode(currentToken) 

consequentParselets = { "+": OperatorParselet("+"), 
    "-": OperatorParselet("-"), ... } 

OperatorParselet(operator): 
    parse(tokens, currentToken, leftNode): 
        myBindingPower = bindingPower[operator] 
        rightNode = parse(tokens, 
            if operator is left associative 
            then myBindingPower 
            otherwise myBindingPower - 1) 
        return OperatorNode( operator, leftNode, rightNode )

最终实现

通过上述更改,我们为完成的解析函数获得以下伪代码:

image.png

再放一遍参考实现链接:
github.com/desmosinc/p…

(作者后面解释了为什么要迁移到普拉特解析法的原因,对于理解普拉特解析法个人觉得帮助不大,因此没有在翻译)

感谢阅读,译者在参加2022届应届春招,大厂还有没有前端岗,求推荐!