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

花下猫语: Python 之父在 Medium 上开了博客,如今写了两篇文章,本文是第二篇的译文。前一篇的译文 在此 ,宣布了将要用 PEG 解析器来替换当前的 pgen 解析器。python

本文主要介绍了构建一个 PEG 解析器的大致思路,并介绍了一些基本的语法规则。根据 Python 之父的描述,这个 PEG 解析器仍是一个很笼统的实验品,而他也预告了,将会在之后的系列文章中丰富这个解析器。数组

阅读这篇文章就像在读一篇教程,虽然很难看懂,可是感受很奇妙:咱们居然能够见证 Python 之父如何考虑问题、如何做设计、如何一点一点地丰富功能、而且传授出来。这种机会很是可贵啊!app

我会持续跟进后续文章的翻译,因为能力有限,可能翻译中有不到位之处,恳请读者们批评指正。函数

本文原创并首发于公众号【Python猫】,未经受权,请勿转载。学习

原文地址:https://mp.weixin.qq.com/s/yUQPeqc_uSRGe5lUi50kVQui


原题 | Building a PEG Parseratom

做者 | Guido van Rossum(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_statement
expr: expr '+' term | expr '-' term | term
term: term '*' atom | term '/' atom | atom
atom: NAME | NUMBER | '(' expr ')'
assignment: target '=' expr
target: NAME
if_statement: 'if' expr ':' statement

这种语言中超级抽象的递归降低解析器将为每一个符号定义一个函数,该函数会尝试调用与备选项相对应的函数。

例如,对于statement,咱们有以下函数:

def statement():
    if assignment():
        return True
   if expr():
        return True
    if if_statement():
        return True
    return False

固然这是极其简化的版本:没有考虑解析器中必要的输入及输出。

咱们就从输入端开始讲吧。

经典解析器使用单独的标记生成器,来将输入(文本文件或字符串)分解成一系列的标记,例如关键字、标识符(名称)、数字与运算符。

(译注:标记生成器,即 tokenizer,用于生成标记 token。如下简称为“标记器”)

PEG 解析器(像其它现代解析器,如 ANTLR)一般会把标记与解析过程统一。可是对于个人项目,我选择保留单独的标记器。

对 Python 作标记太复杂了,我不想拘泥于 PEG 的形式来从新实现。

例如,你必须得记录缩进(这须要在标记器内使用堆栈),并且在 Python 中处理换行颇有趣(它们很重要,除了在匹配的括号内)。字符串的多种引号也会增长复杂性。

简而言之,我不抱怨 Python 现有的标记器,因此我想保留它。(CPython 有两个标记器,一个是解析器在内部使用的,写于 C,另外一个在标准库中,用纯 Python 重写。它对个人项目颇有帮助。)

经典的标记器一般具备一个简单的接口,供你做函数调用,例如 get_token() ,它返回输入内容中的下一个标记,每次消费掉几个字符。

tokenize 模块对它做了进一步简化:它的基础 API 是一个生成器,每次生成(yield)一个标记。

每一个标记都是一个 TypeInfo 对象,它有几个字段,其中最重要之一表示的是标记的类型(例如 NAMENUMBERSTRING),还有一个很重要的是字符串值,表示该标记所包含的字符(例如 abc42 或者 "hello world")。还有的字段会指明每一个标记出如今输入文件中的坐标,这对于报告错误颇有用。

有一个特殊的标记类型是 ENDMARKER ,它表示的是抵达了输入文件的末尾。若是你忽略它,并尝试获取下一个标记,则生成器会终结。

离题了,回归正题。咱们如何实现无限回溯呢?

回溯要求你能记住源码中的位置,而且可以从该处从新解析。标记器的 API 不容许咱们重置它的输入指针,但相对容易的是,将标记流装入一个数组中,并在那里作指针重置,因此咱们就这样作。(你一样可使用 itertools.tee() 来作,可是根据文档中的警告,在咱们这种状况下,效率可能较低。)

我猜你可能会先将整个输入内容标记到一个 Python 列表里,将其做为解析器的输入,但这意味着若是在文件末尾处存在着无效的标记(例如一个字符串缺乏结束的引号),而在文件前面还有语法错误,那你首先会收到的是关于标记错误的信息。

我以为这是种糟糕的用户体验,由于这个语法错误有多是致使字符串残缺的根本缘由。

因此个人设计是按需标记,所用的列表是惰性列表。

基础 API 很是简单。Tokenizer 对象封装了一个数组,存放标记及其位置信息。

它有三个基本方法:

  • get_token() 返回下一个标记,并推动数组的索引(若是到了数组末尾,则从源码中读取另外一个标记)
  • mark() 返回数组的当前索引
  • reset(pos) 设置数组的索引(参数必须从 mark() 方法中获得)

咱们再补充一个便利方法 peek_token() ,它返回下一个标记且不推动索引。

而后,这就成了 Tokenizer 类的核心代码:

class Tokenizer:
    def __init__(self, tokengen):
        """Call with tokenize.generate_tokens(...)."""
        self.tokengen = tokengen
        self.tokens = []
        self.pos = 0
    def mark(self):
        return self.pos
    def reset(self, pos):
        self.pos = pos
    def get_token(self):
        token = self.peek_token()
        self.pos += 1
        return token
    def peek_token(self):
        if self.pos == len(self.tokens):
            self.tokens.append(next(self.tokengen))
        return self.tokens[self.pos]

如今,仍然缺失着不少东西(并且方法和实例变量的名称应该如下划线开头),但这做为 Tokenizer API 的初稿已经够了。

解析器也须要变成一个类,以即可以拥有 statement()、expr() 和其它方法。

标记器则变成一个实例变量,不过咱们不但愿解析方法(parsing methods)直接调用 get_token()——相反,咱们给 Parser 类一个 expect() 方法,它能够像解析类方法同样,表示执行成功或失败。

expect() 的参数是一个预期的标记——一个字符串(像“+”)或者一个标记类型(像NAME)。

讨论完了解析器的输出,我继续讲返回类型(return type)。

在我初稿的解析器中,解析函数只返回 True 或 False。那对于理论计算机科学来讲是好的(解析器要解答的那类问题是“语言中的这个是不是有效的字符串?”),可是对于构建解析器却不是——相反,咱们但愿用解析器来建立一个 AST。

因此咱们就这么办,即让每一个解析方法在成功时返回 Node 对象,在失败时返回 None

Node 类能够超级简单:

class Node:
    def __init__(self, type, children):
        self.type = type
        self.children = children

在这里,type 表示了该 AST 节点是什么类型(例如是个“add”节点或者“if”节点),children 表示了一些节点和标记(TokenInfo 类的实例)。

尽管未来我可能会改变表示 AST 的方式,但这足以让编译器生成代码或对其做分析了,例如 linting (译注:不懂)或者是静态类型检查。

为了适应这个方案,expect() 方法在成功时会返回一个 TokenInfo 对象,在失败时返回 None。为了支持回溯,我还封装了标记器的 mark() 和 reset() 方法(不改变 API)。

这是 Parser 类的基础结构:

class Parser:
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer
    def mark(self):
        return self.tokenizer.mark()
    def reset(self, pos):
        self.tokenizer.reset(pos)
    def expect(self, arg):
        token = self.tokenizer.peek_token()
        if token.type == arg or token.string == arg:
            return self.tokenizer.get_token()
        return None

一样地,我放弃了某些细节,但它能够工做。

在这里,我有必要介绍解析方法的一个重要的需求:一个解析方法要么返回一个 Node,并将标记器定位到它能识别的语法规则的最后一个标记以后;要么返回 None,而后保持标记器的位置不变。

若是解析方法在读取了多个标记以后失败了,则它必须重置标记器的位置。这就是 mark() 与 reset() 的用途。请注意,expect() 也遵循此规则。

因此解析器的实际草稿以下。请注意,我使用了 Python 3.8 的海象运算符(:=):

class ToyParser(Parser):
    def statement(self):
        if a := self.assignment():
            return a
        if e := self.expr():
            return e
        if i := self.if_statement():
            return i
        return None
    def expr(self):
        if t := self.term():
            pos = self.mark()
            if op := self.expect("+"):
                if e := self.expr():
                    return Node("add", [t, e])
            self.reset(pos)
            if op := self.expect("-"):
                if e := self.expr():
                    return Node("sub", [t, e])
            self.reset(pos)
            return t
        return None
    def term(self):
        # Very similar...
    def atom(self):
        if token := self.expect(NAME):
            return token
        if token := self.expect(NUMBER):
            return token
        pos = self.mark()
        if self.expect("("):
            if e := self.expr():
                if self.expect(")"):
                    return e
        self.reset(pos)
        return None

我给读者们留了一些解析方法做为练习(这实际上不只仅是为了介绍解析器长什么样子),最终咱们将像这样从语法中自动地生成代码。

NAME 和 NUMBER 等常量可从标准库的 token 库中导入。(这能令咱们快速地进入 Python 的标记过程;但若是想要构建一个更加通用的 PEG 解析器,则应该探索一些其它方法。)

我还做了个小弊:expr 是左递归的,但个人解析器用了右递归,由于递归降低解析器不适用于左递归的语法规则。

有一个解决方案,但它还只是一些学术研究上的课题,我想之后单独介绍它。大家只需知道,修复的版本与这个玩具语法并不是 100% 相符。

**我但愿大家获得的关键信息是: **

  • 语法规则至关于解析器方法,当一条语法规则引用另外一条语法规则时,它的解析方法会调用另外一条规则的解析方法
  • 当多个条目构成备选项时,解析方法会一个接一个地调用相应的方法
  • 当一条语法规则引用一个标记时,其解析方法会调用 expect()
  • 当一个解析方法在给定的输入位置成功地识别了它的语法规则时,它返回相应的 AST 节点;当识别失败时,它返回 None
  • 一个解析方法在消费(consum)一个或多个标记(直接或间接地,经过调用另外一个成功的解析方法)后放弃解析时,必须显式地重置标记器的位置。这适用于放弃一个备选项而尝试下一个,也适用于彻底地放弃解析

若是全部的解析方法都遵照这些规则,则没必要在单个解析方法中使用 mark() 和 reset()。你能够用概括法证实这一点。

顺便提醒,虽然使用上下文管理器和 with 语句来替代显式地调用 mark() 与 reset() 颇有诱惑力,但这无论用:在成功时不该调用 reset()!

为了修复它,你能够在控制流中使用异常,这样上下文管理器就知道是否该重置标记器(我认为 TatSu 作了相似的东西)。

举例,你能够这样作:

def statement(self):
        with self.alt():
            return self.assignment()
        with self.alt():
            return self.expr()
        with self.alt():
            return self.if_statement()
        raise ParsingFailure

特别地,atom() 中用来识别带括号的表达式的 if-语句,能够变成:

with self.alt():
            self.expect("(")
            e = self.expr()
            self.expect(")")
            return e

但我发现这太“神奇”了——在阅读这些代码时,你必须清醒地意识到每一个解析方法(以及 expect())均可能会引起异常,而这个异常会被 with 语句的上下文管理器捕获并忽略掉。

这至关不寻常,尽管确定会支持(经过从 __exit__ 返回 true)。

还有,个人最终目标是生成 C,不是 Python,而在 C 里,没有 with 语句来改变控制流。

无论怎样,下面是将来的一些主题:

  • 根据语法生成解析代码
  • packrat 解析(记忆法)
  • EBNF 的特性,如(x | y)、[x y ...]、x* 、x+
  • tracing (用于调试解析器或语法)
  • PEG 特性,如前瞻和“切割”
  • 如何处理左递归规则
  • 生成 C 代码

相关连接:

一、PEG解析器(考虑替换现有解析器)

二、pgen解析器(现有解析器的由来)

公众号【Python猫】, 本号连载优质的系列文章,有喵星哲学猫系列、Python进阶系列、好书推荐系列、技术写做、优质英文推荐与翻译等等,欢迎关注哦。

相关文章
相关标签/搜索