Python-CookBook:38、编写一个简单的递归下降解析器

本贴最后更新于 1701 天前,其中的信息可能已经斗转星移

 问题

我们需要根据一组语法规则来解析文本,以此执行相应的操作或构建一个抽象语法树来表示输入。语法规则很简单,因此我们倾向于自己编写解析器而不是使用某种解析器框架。

 解决方案

在这个问题中,我们把重点放在根据特定的语法来解析文本上。要做到这些,应该以 BNF 或 EBNF 的形式定义出语法的正式规格。比如,对于简单的算术运算表达式,语法看起来是这样的:

expr ::= expr + term
     | expr - term
     | term
term ::= term * factor
     | term / factor
     | factor
factor ::= ( expr )
       | NUM

又或者以 EBNF 的形式定义为如下形式:

expr ::= term { (+|-) term }*
term ::= factor { (*|/) factor }*
factor ::= ( expr )
     | NUM

在 EBNF 中,部分包括在{ ... }*中的规则是可选的。*意味着零个或更多重复项(和在正则表达式中的意义相同)。

现在,如果我们对 BNF 还不熟悉的话,可以把它看做是规则替换或取代的一种规范形式,左侧的符号可以被右侧的符号所取代(反之亦然)。一般来说,在解析的过程中我们会尝试将输入的文本同语法做匹配,通过 BNF 来完成各种替换和扩展。为了说明,假设正在解析一个类似于 3 + 4 * 5 这样的表达式。这个表达式首先应该被分解为标记流,这可以使用 2.18 节中描述的技术来实现。得到的结果可能是下面这样的标记序列:

NUM + NUM * NUM

从这里开始,解析过程就涉及通过替换的方式将语法匹配到输入标记上:

expr
expr ::= term { (+|-) term }*
expr ::= factor { (*|/) factor }* { (+|-) term }*
expr ::= NUM { (*|/) factor }* { (+|-) term }*
expr ::= NUM { (+|-) term }*
expr ::= NUM + term { (+|-) term }*
expr ::= NUM + factor { (*|/) factor }* { (+|-) term }*
expr ::= NUM + NUM { (*|/) factor}* { (+|-) term }*
expr ::= NUM + NUM * factor { (*|/) factor }* { (+|-) term }*
expr ::= NUM + NUM * NUM { (*|/) factor }* { (+|-) term }*
expr ::= NUM + NUM * NUM { (+|-) term }*
expr ::= NUM + NUM * NUM

完成所有的替换需要花上一段时间,这是由输入的规模和尝试去匹配的语法规则所决定的。第一个输入标记是一个 NUM,因此替换操作首先会把重点放在匹配这一部分上。一旦匹配上了,重点就转移到下一个标记 + 上,如此往复。当发现无法匹配下一个标记时,右手侧的特定部分({ (/) factor })就会消失。在一个成功的解析过程中,整个右手侧部分会完全根据匹配到的输入标记流来相应地扩展。

有了前面这些基础,下面就向各位展示如何构建一个递归下降的表达式计算器:

import re
import collections

# Token specification
NUM    = r'(?P<NUM>\d+)'
PLUS   = r'(?P<PLUS>\+)'
MINUS  = r'(?P<MINUS>-)'
TIMES  = r'(?P<TIMES>\*)'
DIVIDE = r'(?P<DIVIDE>/)'
LPAREN = r'(?P<LPAREN>\()'
RPAREN = r'(?P<RPAREN>\))'
WS = r'(?P<WS>\s+)'

master_pat = re.compile('|'.join([NUM, PLUS, MINUS, TIMES,
                                  DIVIDE, LPAREN, RPAREN, WS]))
# Tokenizer
Token = collections.namedtuple('Token', ['type','value'])

def generate_tokens(text):
    scanner = master_pat.scanner(text)
    for m in iter(scanner.match, None):
        tok = Token(m.lastgroup, m.group())
        if tok.type != 'WS':
            yield tok

# Parser
class ExpressionEvaluator:
'''
Implementation of a recursive descent parser. Each method
implements a single grammar rule. Use the ._accept() method
to test and accept the current lookahead token. Use the ._expect()
method to exactly match and discard the next token on on the input
(or raise a SyntaxError if it doesn't match).
'''

def parse(self,text):
    self.tokens = generate_tokens(text)
    self.tok = None               # Last symbol consumed
    self.nexttok = None          # Next symbol tokenized
    self._advance()               # Load first lookahead token
    return self.expr()

def _advance(self):
    'Advance one token ahead'
    self.tok, self.nexttok = self.nexttok, next(self.tokens, None)

def _accept(self,toktype):
    'Test and consume the next token if it matches toktype'
    if self.nexttok and self.nexttok.type == toktype:
        self._advance()
        return True
    else:
        return False

def _expect(self,toktype):
    'Consume next token if it matches toktype or raise SyntaxError'
     if not self._accept(toktype):
         raise SyntaxError('Expected ' + toktype)

# Grammar rules follow

def expr(self):
    "expression ::= term { ('+'|'-') term }*"

    exprval = self.term()
    while self._accept('PLUS') or self._accept('MINUS'):
        op = self.tok.type
        right = self.term()
        if op == 'PLUS':
            exprval += right
        elif op == 'MINUS':
            exprval -= right
    return exprval

def term(self):
    "term ::= factor { ('*'|'/') factor }*"

    termval = self.factor()
    while self._accept('TIMES') or self._accept('DIVIDE'):
        op = self.tok.type
        right = self.factor()
        if op == 'TIMES':
            termval *= right
        elif op == 'DIVIDE':
            termval /= right
    return termval

def factor(self):
    "factor ::= NUM | ( expr )"

    if self._accept('NUM'):
        return int(self.tok.value)
    elif self._accept('LPAREN'):
        exprval = self.expr()
        self._expect('RPAREN')
        return exprval
    else:
         raise SyntaxError('Expected NUMBER or LPAREN')

下面是以交互式的方式使用 ExpressionEvaluator 类的示例:

>>> e = ExpressionEvaluator()
>>> e.parse('2')
2
>>> e.parse('2 + 3')
5
>>> e.parse('2 + 3 * 4')
14
>>> e.parse('2 + (3 + 4) * 5')
37
>>> e.parse('2 + (3 + * 4)')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "exprparse.py", line 40, in parse
     return self.expr()
  File "exprparse.py", line 67, in expr
     right = self.term()
  File "exprparse.py", line 77, in term
     termval = self.factor()
  File "exprparse.py", line 93, in factor
     exprval = self.expr()
  File "exprparse.py", line 67, in expr
     right = self.term()
  File "exprparse.py", line 77, in term
     termval = self.factor()
  File "exprparse.py", line 97, in factor
     raise SyntaxError("Expected NUMBER or LPAREN")
SyntaxError: Expected NUMBER or LPAREN
>>>

如果我们想做的不只是纯粹的计算,那就需要修改 ExpressionEvaluator 类来实现。比如,下面的实现构建了一棵简单的解析树:

class ExpressionTreeBuilder(ExpressionEvaluator):
    def expr(self):
        "expression ::= term { ('+'|'-') term }"

        exprval = self.term()
        while self._accept('PLUS') or self._accept('MINUS'):
            op = self.tok.type
            right = self.term()
            if op == 'PLUS':
                exprval = ('+', exprval, right)
            elif op == 'MINUS':
                 exprval = ('-', exprval, right)
        return exprval

def term(self):
    "term ::= factor { ('*'|'/') factor }"

    termval = self.factor()
    while self._accept('TIMES') or self._accept('DIVIDE'):
        op = self.tok.type
        right = self.factor()
        if op == 'TIMES':
            termval = ('*', termval, right)
        elif op == 'DIVIDE':
              termval = ('/', termval, right)
        return termval

def factor(self):
    'factor ::= NUM | ( expr )'

    if self._accept('NUM'):
        return int(self.tok.value)
    elif self._accept('LPAREN'):
        exprval = self.expr()
        self._expect('RPAREN')
        return exprval
    else:
         raise SyntaxError('Expected NUMBER or LPAREN')

下面的示例展示了它是如何工作的:

>>> e = ExpressionTreeBuilder()
>>> e.parse('2 + 3')
('+', 2, 3)
>>> e.parse('2 + 3 * 4')
('+', 2, ('*', 3, 4))
>>> e.parse('2 + (3 + 4) * 5')
('+', 2, ('*', ('+', 3, 4), 5))
>>> e.parse('2 + 3 + 4')
('+', ('+', 2, 3), 4)
>>>

 讨论

文本解析是一个庞大的主题,一般会占用学生们编译原理课程的前三周时间。如果你正在寻找有关语法、解析算法和其他相关信息的背景知识,那么应该去找一本编译器方面的图书来读。无需赘言,本书是不会重复那些内容的。

然而,要编写一个递归下降的解析器,总体思路还是比较简单的。我们要将每一条语法规则转变为一个函数或方法。因此,如果我们的语法看起来是这样的:

expr ::= term { ('+'|'-') term }*

term ::= factor { ('*'|'/') factor }*

factor ::= '(' expr ')'
       | NUM

就可以像下面这样将它们转换为对应的方法:

class ExpressionEvaluator:
    ...
    def expr(self):
        ...
    def term(self):
        ...

    def factor(self):
        ...

每个方法的任务很简单——必须针对语法规则的每个部分从左到右扫描,在扫描过程中处理符号标记。从某种意义上说,这些方法的目的就是顺利地将规则消化掉,如果卡住了就产生一个语法错误。要做到这点,需要应用下面这些实现技术。

  • 如果规则中的下一个符号标记是另一个语法规则的名称(例如,term 或者 factor),就需要调用同名的方法。这就是算法中的“下降”部分——控制其下降到另一个语法规则中。有时候规则中会涉及调用已经在执行的方法(例如,在规则 factor ::= '(' expr ')'中对 expr 的调用)。这就是算法中的“递归”部分。

  • 如果规则中的下一个符号标记是一个特殊的符号(例如'('),需要检查下一个标记,看它们是否能完全匹配。如果不能匹配,这就是语法错误。本节给出的_expect()方法就是用来处理这些步骤的。

  • 如果规则中的下一个符号标记存在多种可能的选择(例如 + 或-),则必须针对每种可能性对下一个标记做检查,只有在有匹配满足时才前进到下一步。这就是本节给出的_accept()方法的目的所在。这有点像_except()的弱化版,在_accept()中如果有匹配满足,就前进到下一步,但如果没有匹配,它只是简单的回退而不会引发一个错误(这样检查才可以继续进行下去)。

  • 对于语法规则中出现的重复部分(例如 expr ::=term { ('+' | '-') term }*),这是通过 while 循环来实现的。一般在循环体中收集或处理所有的重复项,直到无法找到更多的重复项为止。

  • 一旦整个语法规则都已经处理完,每个方法就返回一些结果给调用者。这就是在解析过程中将值进行传递的方法。比如,在计算器表达式中,表达式解析的部分结果会作为值来返回。最终它们会结合在一起,在最顶层的语法规则方法中得到执行。

尽管本节给出的例子很简单,但递归下降解析器可以用来实现相当复杂的解析器。例如,Python 代码本身也是通过一个递归下降解析器来解释的。如果对此很感兴趣,可以通过检查 Python 源代码中的 Grammar/Grammar 文件来一探究竟。即便如此,要自己手写一个解析器时仍然需要面对各种陷阱和局限。

局限之一就是对于任何涉及左递归形式的语法规则,都没法用递归下降解析器来解决。例如,假设需要解释如下的规则:

items ::= items ',' item
       | item

要完成这样的解析,我们可能会试着这样来定义 items()方法:

def items(self):
    itemsval = self.items()
    if itemsval and self._accept(','):
         itemsval.append(self.item())
    else:
         itemsval = [ self.item() ]

唯一的问题就是这么做行不通。实际上这会产生一个无穷递归的错误。

我们也可能会陷入到语法规则自身的麻烦中。例如,我们可能想知道表达式是否能以这种加简单的语法形式来描述:

expr ::= factor { ('+'|'-'|'*'|'/') factor }*

factor ::= '(' expression ')'
        | NUM

这个语法从技术上说是能实现的,但是它却并没有遵守标准算术中关于计算顺序的约定。比如说,表达式“3 + 4 * 5”会被计算为 35,而不是我们预期的 23。因此这里需要单独的“expr”和“term”规则来确保计算结果的正确性。

对于真正复杂的语法解析,最好还是使用像 PyParsing 或 PLY 这样的解析工具。如果使用 PLY 的话,解析计算器表达式的代码看起来是这样的:

from ply.lex import lex
from ply.yacc import yacc

# Token list
tokens = [ 'NUM', 'PLUS', 'MINUS', 'TIMES', 'DIVIDE', 'LPAREN', 'RPAREN' ]

# Ignored characters

t_ignore = ' \t\n'

# Token specifications (as regexs)
t_PLUS = r'\+'
t_MINUS = r'-'
t_TIMES = r'\*'
t_DIVIDE = r'/'
t_LPAREN = r'\('
t_RPAREN = r'\)'

# Token processing functions
def t_NUM(t):
    r'\d+'
    t.value = int(t.value)
    return t

# Error handler
def t_error(t):
    print('Bad character: {!r}'.format(t.value[0]))
    t.skip(1)

# Build the lexer
lexer = lex()

# Grammar rules and handler functions
def p_expr(p):
    '''
    expr : expr PLUS term
    | expr MINUS term
    '''
    if p[2] == '+':
        p[0] = p[1] + p[3]
    elif p[2] == '-':
        p[0] = p[1] - p[3]

def p_expr_term(p):
    '''
    expr : term
    '''
    p[0] = p[1]

def p_term(p):
    '''
    term : term TIMES factor
         | term DIVIDE factor
    '''
    if p[2] == '*':
        p[0] = p[1] * p[3]
    elif p[2] == '/':
        p[0] = p[1] / p[3]

def p_term_factor(p):
    '''
    term : factor
    '''
    p[0] = p[1]

def p_factor(p):
    '''
    factor : NUM
    '''
    p[0] = p[1]

def p_factor_group(p):
    '''
    factor : LPAREN expr RPAREN
    '''
    p[0] = p[2]

def p_error(p):
    print('Syntax error')

parser = yacc()

在这份代码中会发现所有的东西都是以一种更高层的方式来定义的。我们只需编写匹配标记符号的正则表达式,以及当匹配各种语法规则时所需要的高层处理函数就行了。而实际运行解析器、接收符号标记等都完全由库来实现。

下面是如何使用解析器对象的示例:

>>> parser.parse('2')
2
>>> parser.parse('2+3')
5
>>> parser.parse('2+(3+4)*5')
37
>>>

如果想在编程中增加一点激动兴奋的感觉,编写解析器和编译器会是非常有趣的课题。再次说明,一本编译器方面的教科书会涵盖许多理论之下的底层细节。但是,在网上同样也能找到许多优秀的在线资源。Python 自带的 ast 模块也同样值得去看看。

  • Python

    Python 是一种面向对象、直译式电脑编程语言,具有近二十年的发展历史,成熟且稳定。它包含了一组完善而且容易理解的标准库,能够轻松完成很多常见的任务。它的语法简捷和清晰,尽量使用无异义的英语单词,与其它大多数程序设计语言使用大括号不一样,它使用缩进来定义语句块。

    536 引用 • 672 回帖
  • 书籍

    宋真宗赵恒曾经说过:“书中自有黄金屋,书中自有颜如玉。”

    76 引用 • 390 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...