Skip to content

Fire Code

[译]自上而下的运算符优先解析

介绍

递归下降解析器一直是我感兴趣的,在过去的一年半时间里,我写了几篇关于这个主题的文章。下面是按时间顺序排列的:

第三篇文章介绍了一种将 RD 解析与不同的算法结合起来解析表达式的方法,以达到更好的效果。这种方法在现实世界中实际使用,例如 GCC 和 Parrot 中(来源)。

1973年,Vaughan Pratt 发现了另一种解析算法。称为自上而下运算符优先,它与修改的 RD 解析器有一些共同点,但承诺简化代码,并提供更好的性能。最近它又被 Douglas Crockford 在他的文章中普及,并被他在 JSLint 中用来解析 JavaScript。

我在美丽的代码一书中遇到了 Crockford 的文章,但发现它很难理解。我可以跟着代码走,但很难理解为什么这些会工作。最近,我又对这个话题产生了兴趣,尝试着再读了一次文章,结果又一次陷入了困境。最后,通过阅读 Pratt 的原论文和 Frederick Lundh 的优秀的基于 Python 的作品[1],我理解了这个算法。

所以这篇文章是我通常的尝试,向自己解释这个主题,确保当我在几个月后忘记它的工作原理时,有一个简单的方法来回忆。

基本原理

自上而下的运算符优先解析(以下简称 TDOP)基于几个基本原则:

  • 一个“绑定力”机制来处理优先级。
  • 一种手段,根据标记相对于其邻居的位置 - 前缀或中缀 - 来实现标记的不同功能。
  • 与将语义动作与语法规则(BNF)相关联的传统 RD 相反,TDOP 将其与标记相关联。

绑定力

运算符优先级和结合性是解析技术需要处理的一个基本问题。TDOP 通过给它解析的每个标记分配一个“绑定力”来处理这个问题。

考虑一个子串 AEB,其中 A 取右参数,B 取左参数,E 是表达式。E 是与 A 关联还是与 B 关联?我们为每个运算符定义了一个数字绑定力具有较高绑定力的运算符“获胜” - 与 E 相关联。让我们检查下这个表达式:

1 + 2 * 4

这里再次列出,标识出了 A,E,B:

1 + 2 * 4
  ^ ^ ^
  A E B

如果想表达乘法比加法有更高优先级的惯例,我们定义 * 的绑定力(bp)为 20,+ 的绑定力为 10(数字是任意的,重要的是 bp(*) > bp(+))。因此,根据上面的定义,2 将与 * 关联在一起,因为它的绑定力高于 + 的绑定力。

前缀和中缀运算符

为了解析传统的中缀符号表达式语言[2],我们需要区分标记的前缀形式和中缀形式。最好的例子是减号运算符(-)。在中缀形式中,它是减法:

a = b - c  # a 是 b 减 c

在前缀形式中,它是取反。

a = -b   # b 和 a 大小相同但符号相反

为了适应这种差异,TDOP 允许对前缀和中缀语境中的标记进行不同的处理。在 TDOP 术语中,作为前缀标记的处理器称为 nud(代表“空符号”),作为后缀标记的处理器称为 led(代表 "左符号")。

TDOP 算法

这是一个基本的 TDOP 解析器:

def expression(rbp=0):
    global token
    t = token
    token = next()
    left = t.nud()
    while rbp < token.lbp:
        t = token
        token = next()
        left = t.led(left)

    return left

class literal_token(object):
    def __init__(self, value):
        self.value = int(value)
    def nud(self):
        return self.value

class operator_add_token(object):
    lbp = 10
    def led(self, left):
        right = expression(10)
        return left + right

class operator_mul_token(object):
    lbp = 20
    def led(self, left):
        return left * expression(20)

class end_token(object):
    lbp = 0

我们只需要增加一些支持代码,包括一个简单的分词器[3]和解析器驱动:

import re
token_pat = re.compile("\s*(?:(\d+)|(.))")

def tokenize(program):
    for number, operator in token_pat.findall(program):
        if number:
            yield literal_token(number)
        elif operator == "+":
            yield operator_add_token()
        elif operator == "*":
            yield operator_mul_token()
        else:
            raise SyntaxError('unknown operator: %s', operator)
    yield end_token()

def parse(program):
    global token, next
    next = tokenize(program).next
    token = next()
    return expression()

我们有了完整的解析器和求值器,用于处理涉及加法和乘法的算术表达式。

现在来看看它的实际工作原理。注意,标记类有几个属性(不是所有类都有所有类型的属性)。

  • lbp - 运算符的左绑定力。对于中缀运算符,它告诉我们运算符与它左参数的绑定力有多强。
  • nud - 前缀处理器。在这个简单的解析器中,它只为字面量(数字)存在。
  • led - 中缀处理器。

在这里,启蒙的关键是注意表达式函数是如何工作的,以及运算符处理器如何调用它,传递一个绑定力。

当表达式被调用时,它提供 rbp - 调用它的运算符的右绑定力。它消费标记,直到遇到一个左绑定力等于或低于 rbp 的标记。具体来说,就是在返回调用它的运算符之前,它收集所有绑定在一起的标记。

运算符的处理器调用表达式来处理它们的参数,为它提供它们的绑定力,以确保它从输入中获得恰到好处的标记。

例如,我们来看看这个解析器是如何处理表达式的:

3 + 1 * 2 * 4 + 5

下面是解析器函数在解析这个表达式时的调用追踪:

<<expression with rbp 0>>
    <<literal nud = 3>>
    <<led of "+">>
    <<expression with rbp 10>>
       <<literal nud = 1>>
       <<led of "*">>
       <<expression with rbp 20>>
          <<literal nud = 2>>
       <<led of "*">>
       <<expression with rbp 20>>
          <<literal nud = 4>>
    <<led of "+">>
    <<expression with rbp 10>>
       <<literal nud = 5>>

下图显示了不同递归层上对表达式的调用:

箭头显示了每次执行表达式所依据的标记,上方的数字是这次调用赋给表达式的 rbp。

除了跨越整个输入的初始调用(带有 rbp=0)外,表达式在每个运算符之后被(其 led 处理器)调用,以收集右边的参数。如图所示,绑定力机制确保表达式不会“走得太远” - 只在调用运算符的优先级允许的范围内。理解这个的最好的地方是第一个 + 之后的长箭头,它收集所有的乘法项(由于 * 的优先级较高,它们需要被组合在一起),并在遇到第二个 + 之前返回(当优先级不再高于其 rbp 时)。

另一种看待方式是:在解析器执行的任何一点上,每个优先级都有一个表达式实例在那一刻是活跃的。这个实例等待着更高优先级实例的结果,并一直进行下去,直到需要停止自己,并将结果返回给它的调用者。

如果你理解了这个例子,你就理解了 TDOP 解析。其余所有内容其实都是相同的。

增强解析器

到目前为止,所介绍的解析器是非常初级的,所以我们来增强它,以更加实际。首先,一元运算符呢?

正如在前缀和中缀运算符一节中提到的,TDOP 对两者进行了明确的区分,将其编码在 nud 和 led 方法的差异中。添加减法运算符处理器[4]

class operator_sub_token(object):
    lbp = 10
    def nud(self):
        return -expression(100)
    def led(self, left):
        return left - expression(10)

nud 处理减号的一元(前缀)形式,它没有左参数(因为是前缀),对其右参数进行取反。传递到表达式中的绑定能力很高,因为前缀减号有很高的优先级(比乘法高)。led 处理中缀情况类似于 + 和 * 的处理器。

现在我们可以这样处理表达式:

3 - 2 + 4 * -5

并得到一个正确的结果(-19)。

那右关联运算符呢?让我们来实现乘方(使用插入符号 ^)。为了让操作右关联,我们希望解析器将后续的乘方操作符视为第一个操作符的子表达式。我们可以通过在乘方处理器中调用表达式来实现,表达式的 rbp 低于乘方的 lbp。

class operator_pow_token(object):
    lbp = 30
    def led(self, left):
        return left ** expression(30 - 1)

当表达式到了循环中的下一个 ^ 时,会发现仍然 rbp < token.lbp,不会马上返回结果,而是先收集子表达式的值。

那用括号分组呢?由于每个标记都可以在 TDOP 中执行动作,所以可以通过给 ( 标记添加动作来轻松处理。

class operator_lparen_token(object):
    lbp = 0
    def nud(self):
        expr = expression()
        match(operator_rparen_token)
        return expr

class operator_rparen_token(object):
    lbp = 0

其中 match 是通常的 RD 基元:

def match(tok=None):
    global token
    if tok and tok != type(token):
        raise SyntaxError('Expected %s' % tok)
    token = next()

请注意, ( 的 lbp=0,意味着它不与左边的任何标记绑定。它被当作一个前缀,它的 nud 在 ( 之后收集一个表达式,直到遇到 ) (它停止表达式解析器,因为它也是 lbp=0)。然后它消耗掉 ) 本身,并返回表达式的结果。[5]

这是完整的解析器代码,处理加、减、乘、除、乘方和括号分组:

import re

token_pat = re.compile("\s*(?:(\d+)|(.))")

def tokenize(program):
    for number, operator in token_pat.findall(program):
        if number:
            yield literal_token(number)
        elif operator == "+":
            yield operator_add_token()
        elif operator == "-":
            yield operator_sub_token()
        elif operator == "*":
            yield operator_mul_token()
        elif operator == "/":
            yield operator_div_token()
        elif operator == "^":
            yield operator_pow_token()
        elif operator == '(':
            yield operator_lparen_token()
        elif operator == ')':
            yield operator_rparen_token()
        else:
            raise SyntaxError('unknown operator: %s', operator)
    yield end_token()

def match(tok=None):
    global token
    if tok and tok != type(token):
        raise SyntaxError('Expected %s' % tok)
    token = next()

def parse(program):
    global token, next
    next = tokenize(program).next
    token = next()
    return expression()

def expression(rbp=0):
    global token
    t = token
    token = next()
    left = t.nud()
    while rbp < token.lbp:
        t = token
        token = next()
        left = t.led(left)
    return left

class literal_token(object):
    def __init__(self, value):
        self.value = int(value)
    def nud(self):
        return self.value

class operator_add_token(object):
    lbp = 10
    def nud(self):
        return expression(100)
    def led(self, left):
        right = expression(10)
        return left + right

class operator_sub_token(object):
    lbp = 10
    def nud(self):
        return -expression(100)
    def led(self, left):
        return left - expression(10)

class operator_mul_token(object):
    lbp = 20
    def led(self, left):
        return left * expression(20)

class operator_div_token(object):
    lbp = 20
    def led(self, left):
        return left / expression(20)

class operator_pow_token(object):
    lbp = 30
    def led(self, left):
        return left ** expression(30 - 1)

class operator_lparen_token(object):
    lbp = 0
    def nud(self):
        expr = expression()
        match(operator_rparen_token)
        return expr

class operator_rparen_token(object):
    lbp = 0

class end_token(object):
    lbp = 0

示例用法:

>>> parse('3 * (2 + -4) ^ 4')
48

结束语

当人们考虑要实现的解析方法时,通常会在手工编码的 RD 解析器、自动生成的 LL(k) 解析器或者自动生成的 LR 解析器之间进行争论。TDOP 是另一种选择[6]。它是一种独创的、不寻常的解析方法,可以处理复杂的语法(不限于表达式),相对容易编码,而且速度很快。

TDOP 之所以快,是因为它不需要深度递归下降来解析表达式 - 无论语法如何,每个标记只需要几次调用。如果你跟踪我在本文中介绍的示例解析器中的标记动作,你会发现平均而言,对于每个标记,expression 和一个 nudled 方法被调用,仅此而已。Frederik Lundh 在他的文章中对比了 TDOP 和其他几种方法的性能,得到了非常有利的结果。


[1] 这也是本文大部分代码的来源 - 所以版权是 Fredrik Lundh 的。

[2] 如 C、Java、Python。一个没有中缀符号的语言的例子是 Lisp,它的表达式是前缀符号。

[3] 这个分词器只识别数字和单字符运算符。

[4] 请注意,为了让解析器能够实际识别 -,应该在分词函数中添加一个适当的调度器 - 这作为练习留给读者。

[5] 测验:左括号也有 led 处理器有用吗?提示:你如何实现函数调用?

[6] 顺便说一下,我不知道在 LL/LR 量表上该把它归类在哪儿?有什么想法吗?