3个月前,我写了一篇文章,详细讲述了用解析库编写计算器的过程。然而,读者们普遍反应,他们对于见到一个从头开始写并且除了电池以外别无他物的计算器更感兴趣。我想,为什么不呢?
写一个计算机很简单,如果你使用针对算术表达式的hacks 的话。但是hacks的产生的后果也几乎总是一样的:解决方案不够优雅,不可扩展,并且很难直观的理解。我喜欢挑战,并且打算发一个有益的帖子,所以我决定用通用递归下降解析器来写它。本着与上次相同的精神,我打算用尽可能少的行数来干这件事,所以它充满了hacks和tricks。但它们是表面的,并且不止限于我手头的任务。
这篇文章我将一步一步详细的解释一下。如果你想直接跳到代码,你可以滚动到这篇文章的最后。我希望当你读完后你能更好的理解如何解析内部的工作,启发你用适当的解析库,以避免混乱。
要理解这篇文章,你应该很好的理解Python,建议你要了解一些它是怎么解析,它是用来干什么的。如果你不知道,我建议你阅读我的前一篇文章,在里面我详细解释的语法及怎么去使用。
第一步:标记化
处理表达式的第一步就是将其转化为包含一个个独立符号的列表。这一步很简单,且不是本文的重点,因此在此处我省略了很多。
首先,我定义了一些标记(数字不在此中,它们是默认的标记)和一个标记类型:
1 2 3 4 5 |
token_map = {'+':'ADD', '-':'ADD', '*':'MUL', '/':'MUL', '(':'LPAR', ')':'RPAR'} Token = namedtuple('Token', ['name', 'value']) |
下面就是我用来标记 expr
表达式的代码:
1 2 |
split_expr = re.findall('[\d.]+|[%s]' % ''.join(token_map), expr) tokens = [Token(token_map.get(x, 'NUM'), x) for x in split_expr] |
第一行是将表达式分割为基本标记的技巧,因此
1 |
'1.2 / ( 11+3)' --> ['1.2', '/', '(', '11', '+', '3', ')'] |
下一行命名标记,这样分析器就能通过分类识别它们:
1 2 3 |
['1.2', '/', '(', '11', '+', '3', ')'] --> [Token(name='NUM', value='1.2'), Token(name='MUL', value='/'), Token(name='LPAR', value='('), Token(name='NUM', value='11'), Token(name='ADD', value='+'), Token(name='NUM', value='3'), Token(name='RPAR', value=')')] |
任何不在 token_map 中的标记被假定为数字。我们的分词器缺少称为验证的属性,以防止非数字被接受,但幸运的是,运算器将在以后处理它。
就是这样。现在我们有了一个标记列表,下一步就是将它解析为一个 AST。
第二步: 语法定义
我选择的解析器实现自一个本地垂直解析器,其来源于LL解析器的一个简单版本。它是一个最简单的解析器实现,事实上,只有仅仅14行代码。它是一种自上而下的解析器,这意味着解析器从最上层规则开始解析(like:expression),然后以递归方式尝试按照其子规则方式解析,直至符合最下层的规则(like:number)。换句话解释,当自底向上解析器(LR)逐步地收缩标记,使规则被包含在其它规则中,直到最后仅剩下一个规则,而自顶向下解析器(LL)逐步展开规则并进入到少数的抽象规则,直到它能够完全匹配输入的标记。
在深入到实际的解析器实现之前,我们可对语法进行讨论。在我之前发表的文章中,我使用过LR解析器,我可以像如下方式定义计算器语法(标记使用大写字母表示):
1 2 3 4 |
add: add ADD mul | mul; mul: mul MUL atom | atom; atom: NUM | '(' add ')' | neg; neg: '-' atom; |
(如果您还不理解上述语法,请阅读我之前发表的文章)
现在我使用LL解析器,以如下方式定义计算器的语法:
1 2 3 4 5 6 |
rule_map = { 'add' : ['mul ADD add', 'mul'], 'mul' : ['atom MUL mul', 'atom'], 'atom': ['NUM', 'LPAR add RPAR', 'neg'], 'neg' : ['ADD atom'], } |
大家可以看到,这里有一个微妙的变化。有关”add and mul”的递归定义被反转了。这是个非常重要的细节,我会向大家详细说明这一点。
LR版本使用了左递归的模式。当LL解析器遇到递归的时候,它会尝试去匹配规则。所以,当左递归发生是,解析器会进入无穷递归。甚至连聪明的LL解析器例如ANTLR也逃避不了这个问题,它会以友好的错误提示代替无穷的递归,而不像我们这个玩具解析器那样。
左递归可以很容易的转变为右递归,我就这么做的。但是解析器并不是那么简单,它又会产生另一个问题:当左递归正确的解析 3-2-1 为(3-2)-1,而右递归却错误的解析为3-(2-1)。我还没想到一个简单的解决办法,所以为了让事情简单,我决定让它继续使用错误的解析格式,并在后面处理这个问题(请看步骤4)
第三步:解析为一个AST
算法其实很简单。我们会定义一个接收两个参数的递归方法:第一个参数是我们要尝试匹配的规则名称,第二个参数是我们要保留的标识列表。我们从add(最上层规则)方法开始,其已包含完整的标识列表,递归调用已非常明确。方法将返回一个数组,其包含元素为:一个是当前匹配项,另一个是保留匹配的标识列表。我们将实现标识匹配功能,以使这段代码可用(它们都是字符串类型;一个是大写格式,另一个是小写格式)。
以下是解析器实现的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
RuleMatch = namedtuple('RuleMatch', ['name', 'matched']) def match(rule_name, tokens): if tokens and rule_name == tokens[0].name: # 是否匹配标识? return RuleMatch(tokens[0], tokens[1:]) for expansion in rule_map.get(rule_name, ()): # 是否匹配规则? remaining_tokens = tokens matched_subrules = [] for subrule in expansion.split(): matched, remaining_tokens = match(subrule, remaining_tokens) if not matched: break # 运气不好,跳出循环,处理下一个扩展定义! matched_subrules.append(matched) else: return RuleMatch(rule_name, matched_subrules), remaining_tokens return None, None # 无匹配结果 |
代码4至5行说明:如果规则名称(rule_name)确实是一个标识,并被包含在标识列表(tokens)中,同时检查其是否匹配当前标识。如果是,表达式将返回匹配方法,标识列表任然进行使用。
代码第6行说明:迭代将循环检查是否匹配该规则名称对应的子规则,通过递归实现每条子规则的匹配。如果规则名称满足匹配标识的条件,get()方法将返回一个空数组,同时代码将返回空值(见16行)。
第9-15行,实现迭代当前的sub-rule,并尝试顺序地匹配他们。每次迭代都尽可能多的匹配标识。如果某一个标识无法匹配,我们就会放弃整个sub-rule。但是,如果所有的标识都匹配成功,我们就到达else语句,并返回rule_name的匹配值,还有剩下标识。
现在运行并看看1.2/(11+3)的结果。
1 2 3 4 5 |
>>> tokens = [Token(name='NUM', value='1.2'), Token(name='MUL', value='/'), Token(name='LPAR', value='('), Token (name='NUM', value='11'), Token(name='ADD', value='+'), Token(name='NUM', value='3'), Token(name='RPAR', value=')')] >>> match('add', tokens) (RuleMatch(name='add', matched=[RuleMatch(name='mul', matched=[RuleMatch(name='atom', matched=[Token(name='NUM', value='1.2')]), Token(name='MUL', value='/'), RuleMatch(name='mul', matched=[RuleMatch(name='atom', matched=[Token(name='LPAR', value='('), RuleMatch(name='add', matched=[RuleMatch(name='mul', matched=[RuleMatch(name='atom', matched=[Token(name='NUM', value='11')])]), Token(name='ADD', value='+'), RuleMatch(name='add', matched=[RuleMatch(name='mul', matched=[RuleMatch(name='atom', matched=[Token(name='NUM', value='3')])])])]), Token(name='RPAR', value=')')])])])]), []) |
结果是一个tuple,当然我们并没有看到有剩下的标识。匹配结果并不易于阅读,所以让我吧结果画成一个图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
add mul atom NUM '1.2' MUL '/' mul atom LPAR '(' add mul atom NUM '11' ADD '+' add mul atom NUM '3' RPAR ')' |