使用70行Python代码实现一个递归下降解析器的教程


Posted in Python onApril 17, 2015

 第一步:标记化

处理表达式的第一步就是将其转化为包含一个个独立符号的列表。这一步很简单,且不是本文的重点,因此在此处我省略了很多。
首先,我定义了一些标记(数字不在此中,它们是默认的标记)和一个标记类型:
 

token_map = {'+':'ADD', '-':'ADD',
       '*':'MUL', '/':'MUL',
       '(':'LPAR', ')':'RPAR'}
 
Token = namedtuple('Token', ['name', 'value'])

下面就是我用来标记 `expr` 表达式的代码:
 

split_expr = re.findall('[\d.]+|[%s]' % ''.join(token_map), expr)
tokens = [Token(token_map.get(x, 'NUM'), x) for x in split_expr]

第一行是将表达式分割为基本标记的技巧,因此
 

'1.2 / ( 11+3)' --> ['1.2', '/', '(', '11', '+', '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 中的标记被假定为数字。我们的分词器缺少称为验证的属性,以防止非数字被接受,但幸运的是,运算器将在以后处理它。
就是这样
第二步: 语法定义

我选择的解析器实现自一个本地垂直解析器,其来源于LL解析器的一个简单版本。它是一个最简单的解析器实现,事实上,只有仅仅14行代码。它是一种自上而下的解析器,这意味着解析器从最上层规则开始解析(like:expression),然后以递归方式尝试按照其子规则方式解析,直至符合最下层的规则(like:number)。换句话解释,当自底向上解析器(LR)逐步地收缩标记,使规则被包含在其它规则中,直到最后仅剩下一个规则,而自顶向下解析器(LL)逐步展开规则并进入到少数的抽象规则,直到它能够完全匹配输入的标记。
在深入到实际的解析器实现之前,我们可对语法进行讨论。在我之前发表的文章中,我使用过LR解析器,我可以像如下方式定义计算器语法(标记使用大写字母表示):
 

add: add ADD mul | mul;
mul: mul MUL atom | atom;
atom: NUM | '(' add ')' | neg;
neg: '-' atom;

(如果您还不理解上述语法,请阅读我之前发表的文章)

现在我使用LL解析器,以如下方式定义计算器的语法:
 

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(最上层规则)方法开始,其已包含完整的标识列表,递归调用已非常明确。方法将返回一个数组,其包含元素为:一个是当前匹配项,另一个是保留匹配的标识列表。我们将实现标识匹配功能,以使这段代码可用(它们都是字符串类型;一个是大写格式,另一个是小写格式)。

以下是解析器实现的代码:
 

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)的结果。
 

>>> 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,当然我们并没有看到有剩下的标识。匹配结果并不易于阅读,所以让我吧结果画成一个图:
 

add
  mul
    atom
      NUM '1.2'
    MUL '/'
    mul
      atom
        LPAR  '('
        add
          mul
            atom
              NUM '11'
          ADD '+'
          add
            mul
              atom
                NUM '3'
        RPAR  ')'

这就是概念上的AST。通过你思维逻辑,或者在纸上描绘,想象解析器是如何运作的,这样是个很好的锻炼。我不敢说这样是必须的,除非你想神交。你可以通过AST来帮助你实现正确的算法。

到目前为止,我们已经完成了可以处理二进制运算,一元运算,括号和操作符优先权的解析器。

现在只剩下一个错误待解决,下面的步骤我们将解决这个错误。

第四步:后续处理

我的解析器并非在任何场合管用。最重要的一点是,它并不能处理左递归,迫使我把代码写成右递归方式。这样导致,解析 8/4/2 这个表达式的时候,AST结果如下:
 

add
  mul
    atom
      NUM 8
    MUL '/'
    mul
      atom
        NUM 4
      MUL '/'
      mul
        atom
          NUM 2

如果我们尝试通过AST计算结果,我们将会优先计算4/2,这当然是错误的。一些LL解析器选择修正树里面的关联性。这样需要编写多行代码;)。这个不采纳,我们需要使它扁平化。算法很简单:对于AST里面的每个规则 1)需要修正 2)是一个二进制运算 (拥有sub-rules)3) 右边的操作符同样的规则:使后者扁平成前者。通过“扁平”,我意思是在其父节点的上下文中,通过节点的儿子代替这个节点。因为我们的穿越是DFS是后序的,意味着它从树的边缘开始,并一直到达树根,效果将会累加。如下是代码:
 

fix_assoc_rules = 'add', 'mul'
 
def _recurse_tree(tree, func):
  return map(func, tree.matched) if tree.name in rule_map else tree[1]
 
def flatten_right_associativity(tree):
  new = _recurse_tree(tree, flatten_right_associativity)
  if tree.name in fix_assoc_rules and len(new)==3 and new[2].name==tree.name:
    new[-1:] = new[-1].matched
  return RuleMatch(tree.name, new)

这段代码可以让任何结构的加法或乘法表达式变成一个平面列表(不会混淆)。括号会破坏顺序,当然,它们不会受到影响。

基于以上的这些,我可以把代码重构成左关联:
 

def build_left_associativity(tree):
  new_nodes = _recurse_tree(tree, build_left_associativity)
  if tree.name in fix_assoc_rules:
    while len(new_nodes)>3:
      new_nodes[:3] = [RuleMatch(tree.name, new_nodes[:3])]
  return RuleMatch(tree.name, new_nodes)

但是,我并不会这样做。我需要更少的代码,并且把计算代码换成处理列表会比重构整棵树需要更少的代码。

第五步:运算器

对树的运算非常简单。只需用与后处理的代码相似的方式对树进行遍历(即 DFS 后序),并按照其中的每条规则进行运算。对于运算器,因为我们使用了递归算法,所以每条规则必须只包含数字和操作符。代码如下:
 

bin_calc_map = {'*':mul, '/':div, '+':add, '-':sub}
def calc_binary(x):
  while len(x) > 1:
    x[:3] = [ bin_calc_map[x[1]](x[0], x[2]) ]
  return x[0]
 
calc_map = {
  'NUM' : float,
  'atom': lambda x: x[len(x)!=1],
  'neg' : lambda (op,num): (num,-num)[op=='-'],
  'mul' : calc_binary,
  'add' : calc_binary,
}
 
def evaluate(tree):
  solutions = _recurse_tree(tree, evaluate)
  return calc_map.get(tree.name, lambda x:x)(solutions)

我使用 calc_binary 函数进行加法和减法运算(以及它们的同阶运算)。它以左结合的方式计算列表中的这些运算,这使得我们的 LL语法不太容易获取结果。

第六步:REPL

最朴实的REPL:
 

if __name__ == '__main__':
  while True:
    print( calc(raw_input('> ')) )

不要让我解释它 :)
附录:将它们合并:一个70行的计算器
 

'''A Calculator Implemented With A Top-Down, Recursive-Descent Parser'''
# Author: Erez Shinan, Dec 2012
 
import re, collections
from operator import add,sub,mul,div
 
Token = collections.namedtuple('Token', ['name', 'value'])
RuleMatch = collections.namedtuple('RuleMatch', ['name', 'matched'])
 
token_map = {'+':'ADD', '-':'ADD', '*':'MUL', '/':'MUL', '(':'LPAR', ')':'RPAR'}
rule_map = {
  'add' : ['mul ADD add', 'mul'],
  'mul' : ['atom MUL mul', 'atom'],
  'atom': ['NUM', 'LPAR add RPAR', 'neg'],
  'neg' : ['ADD atom'],
}
fix_assoc_rules = 'add', 'mul'
 
bin_calc_map = {'*':mul, '/':div, '+':add, '-':sub}
def calc_binary(x):
  while len(x) > 1:
    x[:3] = [ bin_calc_map[x[1]](x[0], x[2]) ]
  return x[0]
 
calc_map = {
  'NUM' : float,
  'atom': lambda x: x[len(x)!=1],
  'neg' : lambda (op,num): (num,-num)[op=='-'],
  'mul' : calc_binary,
  'add' : calc_binary,
}
 
def match(rule_name, tokens):
  if tokens and rule_name == tokens[0].name:   # Match a token?
    return tokens[0], tokens[1:]
  for expansion in rule_map.get(rule_name, ()):  # Match a rule?
    remaining_tokens = tokens
    matched_subrules = []
    for subrule in expansion.split():
      matched, remaining_tokens = match(subrule, remaining_tokens)
      if not matched:
        break  # no such luck. next expansion!
      matched_subrules.append(matched)
    else:
      return RuleMatch(rule_name, matched_subrules), remaining_tokens
  return None, None  # match not found
 
def _recurse_tree(tree, func):
  return map(func, tree.matched) if tree.name in rule_map else tree[1]
 
def flatten_right_associativity(tree):
  new = _recurse_tree(tree, flatten_right_associativity)
  if tree.name in fix_assoc_rules and len(new)==3 and new[2].name==tree.name:
    new[-1:] = new[-1].matched
  return RuleMatch(tree.name, new)
 
def evaluate(tree):
  solutions = _recurse_tree(tree, evaluate)
  return calc_map.get(tree.name, lambda x:x)(solutions)
 
def calc(expr):
  split_expr = re.findall('[\d.]+|[%s]' % ''.join(token_map), expr)
  tokens = [Token(token_map.get(x, 'NUM'), x) for x in split_expr]
  tree = match('add', tokens)[0]
  tree = flatten_right_associativity( tree )
  return evaluate(tree)
 
if __name__ == '__main__':
  while True:
    print( calc(raw_input('> ')) )
Python 相关文章推荐
将Django框架和遗留的Web应用集成的方法
Jul 24 Python
详解Python的Flask框架中的signals信号机制
Jun 13 Python
Python使用Django实现博客系统完整版
Sep 29 Python
python实现简单登陆流程的方法
Apr 22 Python
selenium3+python3环境搭建教程图解
Dec 07 Python
python将txt等文件中的数据读为numpy数组的方法
Dec 22 Python
详解python3安装pillow后报错没有pillow模块以及没有PIL模块问题解决
Apr 17 Python
Python发展史及网络爬虫
Jun 19 Python
Python使用Pandas库实现MySQL数据库的读写
Jul 06 Python
Python生成器generator原理及用法解析
Jul 20 Python
python实现扫雷游戏的示例
Oct 20 Python
appium+python自动化配置(adk、jdk、node.js)
Nov 17 Python
python类继承与子类实例初始化用法分析
Apr 17 #Python
python中split方法用法分析
Apr 17 #Python
仅用50行代码实现一个Python编写的计算器的教程
Apr 17 #Python
python字典get()方法用法分析
Apr 17 #Python
详解Python中__str__和__repr__方法的区别
Apr 17 #Python
使用Python设置tmpfs来加速项目的教程
Apr 17 #Python
在Python上基于Markov链生成伪随机文本的教程
Apr 17 #Python
You might like
php下通过IP获取地理位置的代码(小偷程序)
2011/06/09 PHP
PHP将session信息存储到数据库的类实例
2015/03/04 PHP
Laravel6.18.19如何优雅的切换发件账户
2020/06/14 PHP
node.js中的socket.io入门实例
2014/04/26 Javascript
node.js中的http.response.end方法使用说明
2014/12/14 Javascript
js中json处理总结之JSON.parse
2016/10/14 Javascript
arcgis for js 修改infowindow样式的方法
2016/11/02 Javascript
JS用斜率判断鼠标进入DIV四个方向的方法
2016/11/07 Javascript
关于js中的鼠标事件总结
2017/07/11 Javascript
angular4中关于表单的校验示例
2017/10/16 Javascript
新版vue-cli模板下本地开发环境使用node服务器跨域的方法
2018/04/03 Javascript
解决vue中虚拟dom,无法实时更新的问题
2018/09/15 Javascript
vue.js的vue-cli脚手架中使用百度地图API的实例
2019/01/21 Javascript
JS实现图片轮播效果实例详解【可自动和手动】
2019/04/04 Javascript
node.js域名解析实现方法详解
2019/11/05 Javascript
详解在IDEA中将Echarts引入web两种方式(使用js文件和maven的依赖导入)
2020/07/11 Javascript
微信小程序实现自定义动画弹框/提示框的方法实例
2020/11/06 Javascript
python数据结构之二叉树的建立实例
2014/04/29 Python
windows系统中python使用rar命令压缩多个文件夹示例
2014/05/06 Python
python编写网页爬虫脚本并实现APScheduler调度
2014/07/28 Python
PYTHON 中使用 GLOBAL引发的一系列问题
2016/10/12 Python
Python无损音乐搜索引擎实现代码
2018/02/02 Python
使用python验证代理ip是否可用的实现方法
2018/07/25 Python
Python多进程原理与用法分析
2018/08/21 Python
Python读取YUV文件,并显示的方法
2018/12/04 Python
Python深拷贝与浅拷贝用法实例分析
2019/05/05 Python
pycharm 关闭search everywhere的解决操作
2021/01/15 Python
anaconda安装pytorch1.7.1和torchvision0.8.2的方法(亲测可用)
2021/02/01 Python
html5记忆翻牌游戏实现思路及代码
2013/07/25 HTML / CSS
印度尼西亚最大的电商平台:Tokopedia(印尼版淘宝)
2017/12/02 全球购物
实习证明格式范文
2015/06/16 职场文书
宝宝满月祝酒词
2015/08/10 职场文书
小学生安全教育主题班会
2015/08/12 职场文书
2019年暑期法院实习报告
2019/12/18 职场文书
Pytorch 实现变量类型转换
2021/05/17 Python
使用pd.merge表连接出现多余行的问题解决
2022/06/16 Python