Python 之父再发文:构建一个 PEG 解析器

花下猫语:Python之父在Medium上开了博客,现在写了两篇文章,本文是第二篇的译文。前一篇的译文在此,宣布了将要用PEG解析器来替换当前的pgen解析器。
本文主要介绍了构建一个PEG解析器的大体思路,并介绍了一些基本的语法规则。根据Python之父的描述,这个PEG解析器还是一个很笼统的实验品,而他也预告了,将会在以后的系列文章中丰富这个解析器。
阅读这篇文章就像在读一篇教程,虽然很难看懂,但是感觉很奇妙:我们竟然可以见证Python之父如何考虑问题、如何作设计、如何一点一点地丰富功能、并且传授出来。这种机会非常难得啊!
我会持续跟进后续文章的翻译,由于能力有限,可能翻译中有不到位之处,恳请读者们批评指正。
本文原创并首发于公众号【Python猫】,未经授权,请勿转载。
原文地址:https://mp.weixin.qq.com/s/yUQPeqc_uSRGe5lUi50kVQ
原题|BuildingaPEGParser
作者|GuidovanRossum(Python之父)
译者|豌豆花下猫(“Python猫”公众号作者)
原文|https://medium.com/@gvanrossum_83706/building-a-peg-parser-d4869b5958fb
声明|翻译是出于交流学习的目的,欢迎转载,但请保留本文出处,请勿用于商业或非法用途。
仅仅理解了PEG解析器的小部分,我就受到了启发,决定自己构建一个。结果可能不是一个很棒的通用型的PEG解析器生成器——这类生成器已经有很多了(例如TatSu,写于Python,生成Python代码)——但这是一个学习PEG的好办法,推进了我的目标,即用由PEG语法构建的解析器替换CPython的解析器。
在本文中,通过展示一个简单的手写解析器,我为如何理解解析器的工作原理奠定了基础。
(顺便说一句,作为一个实验,我不会在文中到处放参考链接。如果你有什么不明白的东西,请Google之:-)
最常见的PEG解析方式是使用可以无限回溯的递归下降解析器。
以上周文章中的玩具语言为例:
statement:assignment|expr|if_statementexpr:expr’+‘term|expr’-‘term|termterm:term’‘atom|term’/‘atom|atomatom:NAME|NUMBER|’(‘expr’)‘assignment:target’=‘exprtarget:NAMEif_statement:‘if’expr’:‘statement 这种语言中超级抽象的递归下降解析器将为每个符号定义一个函数,该函数会尝试调用与备选项相对应的函数。 例如,对于statement,我们有如下函数: defstatement():ifassignment():returnTrueifexpr():returnTrueifif_statement():returnTruereturnFalse 当然这是极其简化的版本:没有考虑解析器中必要的输入及输出。 我们就从输入端开始讲吧。 经典解析器使用单独的标记生成器,来将输入(文本文件或字符串)分解成一系列的标记,例如关键字、标识符(名称)、数字与运算符。 (译注:标记生成器,即tokenizer,用于生成标记token。以下简称为“标记器”) PEG解析器(像其它现代解析器,如ANTLR)通常会把标记与解析过程统一。但是对于我的项目,我选择保留单独的标记器。 对Python做标记太复杂了,我不想拘泥于PEG的形式来重新实现。 例如,你必须得记录缩进(这需要在标记器内使用堆栈),而且在Python中处理换行很有趣(它们很重要,除了在匹配的括号内)。字符串的多种引号也会增加复杂性。 简而言之,我不抱怨Python现有的标记器,所以我想保留它。(CPython有两个标记器,一个是解析器在内部使用的,写于C,另一个在标准库中,用纯Python重写。它对我的项目很有帮助。) 经典的标记器通常具有一个简单的接口,供你作函数调用,例如get_token(),它返回输入内容中的下一个标记,每次消费掉几个字符。 tokenize模块对它作了进一步简化:它的基础API是一个生成器,每次生成(yield)一个标记。 每个标记都是一个TypeInfo对象,它有几个字段,其中最重要之一表示的是标记的类型(例如NAME、NUMBER、STRING),还有一个很重要的是字符串值,表示该标记所包含的字符(例如abc、42或者”helloworld”)。还有的字段会指明每个标记出现在输入文件中的坐标,这对于报告错误很有用。 有一个特殊的标记类型是ENDMARKER,它表示的是抵达了输入文件的末尾。如果你忽略它,并尝试获取下一个标记,则生成器会终结。 离题了,回归正题。我们如何实现无限回溯呢? 回溯要求你能记住源码中的位置,并且能够从该处重新解析。标记器的API不允许我们重置它的输入指针,但相对容易的是,将标记流装入一个数组中,并在那里做指针重置,所以我们就这样做。(你同样可以使用itertools.tee()来做,但是根据文档中的警告,在我们这种情况下,效率可能较低。) 我猜你可能会先将整个输入内容标记到一个Python列表里,将其作为解析器的输入,但这意味着如果在文件末尾处存在着无效的标记(例如一个字符串缺少结束的引号),而在文件前面还有语法错误,那你首先会收到的是关于标记错误的信息。 我觉得这是种糟糕的用户体验,因为这个语法错误有可能是导致字符串残缺的根本原因。 所以我的设计是按需标记,所用的列表是惰性列表。 基础API非常简单。Tokenizer对象封装了一个数组,存放标记及其位置信息。 它有三个基本方法: 我们再补充一个便利方法peek_token(),它返回下一个标记且不推进索引。 然后,这就成了Tokenizer类的核心代码: classTokenizer:definit(self,tokengen):“”“Callwithtokenize.generate_tokens(…).”““self.tokengen=tokengenself.tokens=[]self.pos=0defmark(self):returnself.posdefreset(self,pos):self.pos=posdefget_token(self):token=self.peek_token()self.pos+=1returntokendefpeek_token(self):ifself.pos==len(self.tokens):self.tokens.append(next(self.tokengen))returnself.tokens[self.pos] 现在,仍然缺失着很多东西(而且方法和实例变量的名称应该以下划线开头),但这作为TokenizerAPI的初稿已经够了。 解析器也需要变成一个类,以便可以拥有statement()、expr()和其它方法。 标记器则变成一个实例变量,不过我们不希望解析方法(parsingmethods)直接调用get_token()——相反,我们给Parser类一个expect()方法,它可以像解析类方法一样,表示执行成功或失败。 expect()的参数是一个预期的标记——一个字符串(像“+”)或者一个标记类型(像NAME)。 讨论完了解析器的输出,我继续讲返回类型(returntype)。 在我初稿的解析器中,解析函数只返回True或False。那对于理论计算机科学来说是好的(解析器要解答的那类问题是“语言中的这个是否是有效的字符串?”),但是对于构建解析器却不是——相反,我们希望用解析器来创建一个AST。 所以我们就这么办,即让每个解析方法在成功时返回Node对象,在失败时返回None。 该Node类可以超级简单: classNode:definit(self,type,children):self.type=typeself.children=children 在这里,type表示了该AST节点是什么类型(例如是个“add”节点或者“if”节点),children表示了一些节点和标记(TokenInfo类的实例)。 尽管将来我可能会改变表示AST的方式,但这足以让编译器生成代码或对其作分析了,例如linting(译注:不懂)或者是静态类型检查。 为了适应这个方案,expect()方法在成功时会返回一个TokenInfo对象,在失败时返回None。为了支持回溯,我还封装了标记器的mark()和reset()方法(不改变API)。 这是Parser类的基础结构: classParser:definit(self,tokenizer):self.tokenizer=tokenizerdefmark(self):returnself.tokenizer.mark()defreset(self,pos):self.tokenizer.reset(pos)defexpect(self,arg):token=self.tokenizer.peek_token()iftoken.type==argortoken.string==arg:returnself.tokenizer.get_token()returnNone 同样地,我放弃了某些细节,但它可以工作。 在这里,我有必要介绍解析方法的一个重要的需求:一个解析方法要么返回一个Node,并将标记器定位到它能识别的语法规则的最后一个标记之后;要么返回None,然后保持标记器的位置不变。 如果解析方法在读取了多个标记之后失败了,则它必须重置标记器的位置。这就是mark()与reset()的用途。请注意,expect()也遵循此规则。 所以解析器的实际草稿如下。请注意,我使用了Python3.8的海象运算符(:=): classToyParser(Parser):defstatement(self):ifa:=self.assignment():returnaife:=self.expr():returneifi:=self.if_statement():returnireturnNonedefexpr(self):ift:=self.term():pos=self.mark()ifop:=self.expect(”+“):ife:=self.expr():returnNode(“add”,[t,e])self.reset(pos)ifop:=self.expect(“-”):ife:=self.expr():returnNode(“sub”,[t,e])self.reset(pos)returntreturnNonedefterm(self):#Verysimilar…defatom(self):iftoken:=self.expect(NAME):returntokeniftoken:=self.expect(NUMBER):returntokenpos=self.mark()ifself.expect(“(”):ife:=self.expr():ifself.expect(“)”):returneself.reset(pos)returnNone 我给读者们留了一些解析方法作为练习(这实际上不仅仅是为了介绍解析器长什么样子),最终我们将像这样从语法中自动地生成代码。 NAME和NUMBER等常量可从标准库的token库中导入。(这能令我们快速地进入Python的标记过程;但如果想要构建一个更加通用的PEG解析器,则应该探索一些其它方法。) 我还作了个小弊:expr是左递归的,但我的解析器用了右递归,因为递归下降解析器不适用于左递归的语法规则。 有一个解决方案,但它还只是一些学术研究上的课题,我想以后单独介绍它。你们只需知道,修复的版本与这个玩具语法并非100%相符。 我希望你们得到的关键信息是: 如果所有的解析方法都遵守这些规则,则不必在单个解析方法中使用mark()和reset()。你可以用归纳法证明这一点。 顺便提醒,虽然使用上下文管理器和with语句来替代显式地调用mark()与reset()很有诱惑力,但这不管用:在成功时不应调用reset()! 为了修复它,你可以在控制流中使用异常,这样上下文管理器就知道是否该重置标记器(我认为TatSu做了类似的东西)。 举例,你可以这样做: defstatement(self):withself.alt():returnself.assignment()withself.alt():returnself.expr()withself.alt():returnself.if_statement()raiseParsingFailure 特别地,atom()中用来识别带括号的表达式的if-语句,可以变成: withself.alt():self.expect(“(”)e=self.expr()self.expect(“)”)returne 但我发现这太“神奇”了——在阅读这些代码时,你必须清醒地意识到每个解析方法(以及expect())都可能会引发异常,而这个异常会被with语句的上下文管理器捕获并忽略掉。 这相当不寻常,尽管肯定会支持(通过从exit返回true)。 还有,我的最终目标是生成C,不是Python,而在C里,没有with语句来改变控制流。 不管怎样,下面是未来的一些主题:
相关链接:
1、PEG解析器(考虑替换现有解析器)
2、pgen解析器(现有解析器的由来)
公众号【Python猫】,本号连载优质的系列文章,有喵星哲学猫系列、Python进阶系列、好书推荐系列、技术写作、优质英文推荐与翻译等等,欢迎关注哦。