【转】(译)Python 之父写的和解析器有关的 posts



PEG Parsers

原题 | PEG Parsers

作者 | Guido van Rossum(Python之父)

译者 | 豌豆花下猫(“Python猫”公众号作者)

原文 | https://medium.com/@gvanrossum_83706/peg-parsers-7ed72462f97c

声明 | 翻译是出于交流学习的目的,欢迎转载,但请保留本文出处,请勿用于商业或非法用途。

几年前,有人问 Python 是否会转换用 PEG 解析器(或者是 PEG 语法,我不记得确切内容、谁说的、什么时候说的)。我稍微看过这个主题,但没有头绪,就放弃了。

最近,我学了很多关于 PEG(Parsing Expression Grammars)的知识,如今我认为它是个有趣的替代品,正好替换掉我在 30 年前刚开始创造 Python 时自制的(home-grown)语法分析生成器(parser generator)(那个语法分析生成器,被称为“pgen”,是我为 Python 写下的第一段代码)。

我现在感兴趣于 PEG,原因是对 pgen 的局限性感到有些恼火了。

它使用了我自己写的 LL(1) 解析的变种——我不喜欢可以产生空字符串的语法规则,所以我禁用了它,进而稍微地简化了生成解析表的算法。

同时,我还发明了一套类似 EBNF 的语法符号(译注:Extended Backus-Naur Form,BNF 的扩展,是一种形式化符号,用于描述给定语言中的语法),至今仍非常喜欢。

以下是 pgen 令我感到烦恼的一些问题。

LL(1) 名字中的 “1” 表明它只使用单一的前向标记符(a single token lookahead),而这限制了我们编写漂亮的语法规则的能力。例如,一个 Python 语句(statement)既可以是表达式(expression),又可以是赋值(assignment)(或者是其它东西,但那些都以 if 或 def 这类专用的关键字开头)。

我们希望使用 pgen 表示法来编写如下的语法。(请注意,这个示例描述了一种玩具语言(toy language),它是 Python 的一个微小的子集,就像传统中的语言设计一样。)

statement: assignment | expr | if_statement
expr: expr '+' term | expr '-' term | term
term: term '*' atom | term '/' atom | atom
atom: NAME | NUMBER | '(' expr ')'
assignment: target '=' exprtarget: NAME
if_statement: 'if' expr ':' statement

关于这些符号,解释几句:NAMENUMBER 是标记符(token),预定义在语法之外。引号中的字符串如 ‘+’ 或 ‘if’ 也是标记符。(我以后会讲讲标记符。)语法规则以其名称开头,跟在后面的是 : 号,再后面则是一个或多个以 | 符号分隔的可选内容(alternatives)。

但问题是,如果你这样写语法,解析器不会起作用,pgen 将会罢工。

其中一个原因是某些规则(如 exprterm)是左递归的,而 pgen 还不足以聪明地解析。这通常需要通过重写规则来解决,例如(在保持其它规则不变的情况下):

expr: term ('+' term | '-' term)*
term: atom ('*' atom | '/' atom)*

这就揭示了 pgen 的一部分 EBNF 能力:你可以在括号内嵌套可选内容,并且可以在括号后放 * 来创建重复,所以这里的 expr 规则就意味着:它是一个术语(term),跟着零个或多个语句块,语句块内是加号跟术语,或者是减号跟术语。

这个语法兼容了第一个版本的语言,但它并没有反映出语言设计者的本意——尤其是它并没有表明运算符是左绑定的,而这在你尝试生成代码时非常重要。

但是在这种玩具语言(以及在 Python)中,还有另一个烦人的问题。

由于前向的单一标记符,解析器无法确定它查看的是一个表达式的开头,还是一个赋值。在一个语句的开头,解析器需要根据它看到的第一个标记符,来决定它要查看的 statement 的可选内容。(为什么呢?pgen 的自动解析器就是这样工作的。)

假设我们的程序是这样的:

answer = 42

这句程序会被解析成三个标记符:NAME(值是answer),‘=’ 和 NUMBER(值为 42)。在程序开始时,我们拥有的唯一的前向标记符是 NAME。此时,我们试图满足的规则是statement(这个语法的起始标志)。此规则有三个可选内容:exprassignment以及if_statement。我们可以排除if_statement,因为前向标记符不是 “if”。

但是 exprassignment 都能以 NAME 标记符开头,因此就会引起歧义(ambiguous),pgen 会拒绝我们的语法。

(这也不完全正确,因为语法在技术上并不会导致歧义;但我们先不管它,因为我想不到更好的词来表达。那么 pgen 是如何做决定的呢?它会为每条语法规则计算出一个叫做 FIRST 组的东西,如果在给定的点上,FIRST 组出现了重叠选项,它就会抱怨)(译注:抱怨?应该指的是解析不下去,前文译作了罢工)。

那么,我们能否为解析器提供一个更大的前向缓冲区,来解决这个烦恼呢?

对于我们的玩具语言,第二个前向标记符就足够了,因为在这个语法中,assignment 的第二个标记符必须是 “=”。

但是在 Python 这种更现实的语言中,你可能需要一个无限的前向缓冲,因为在 “=” 标记符左侧的东西可能极其复杂,例如:

table[index + 1].name.first = 'Steven'

在 “=” 标记符之前,它已经用了 10 个标记符,如果想挑战的话,我还可以举出任意长的例子。为了在 pgen 中解决它,我们的方法是修改语法,并增加一个额外的检查,令它能接收一些非法的程序,但如果检查到对左侧的赋值是无效的,则会抛出一个 SyntaxError

对于我们的玩具语言,这可归结成如下写法:

statement: assignment_or_expr | if_statement
assignment_or_expr: expr ['=' expr]

(方括号表示了一个可选部分。)然后在随后的编译过程中(比如,在生成字节码时),我们会检查是否存在 “=”,如果存在,我们再检查左侧是否有 target 语法。

在调用函数时,关键字参数也有类似的麻烦。我们想要写成这样(同样,这是 Python 的调用语法的简化版本):

call: atom '(' arguments ')'
arguments: arg (',' arg)*
arg: posarg | kwarg
posarg: expr
kwarg: NAME '=' expr

但是前向的单一标记符无法告诉解析器,一个参数的开头中的 NAME 到底是 posarg 的开头(因为 expr 可能以 NAME 开头)还是 kwarg 的开头。

同样地,Python 当前的解析器在解决这个问题时,是通过特别声明:

arg: expr ['=' expr]

然后在后续的编译过程中再解决问题。(我们甚至出了点小错,允许了像 foo((a)=1) 这样的东西,给了它跟 foo(a=1) 相同的含义,直到 Python 3.8 时才修复掉。)

那么,PEG 解析器是如何解决这些烦恼的呢?

通过使用无限的前向缓冲!PEG 解析器的经典实现中使用了一个叫作“packrat parsing”(译注:PackRat,口袋老鼠)的东西,它不仅会在解析之前将整个程序加载到内存中,而且还能允许解析器任意地回溯。

虽然 PEG 这个术语主要指的是语法符号,但是以 PEG 语法生成的解析器是可以无限回溯的递归下降(recursive-descent)解析器,“packrat parsing”通过记忆每个位置所匹配的规则,来使之生效。

这使一切变得简单,然而当然也有成本:内存。

三十年前,我有充分的理由来使用单一前向标记符的解析技术:内存很昂贵。LL(1) 解析(以及其它技术像 LALR(1),因 YACC 而著名)使用状态机和堆栈(一种“下推自动机”)来有效地构造解析树。

幸运的是,运行 CPython 的计算机比 30 年前有了更多的内存,将整个文件存在内存中确实已不再是一个负担。例如,我能在标准库中找到的最大的非测试文件是 _pydecimal.py,它大约有 223 千字节(译注:kilobytes,即 KB)。在一个 GB 级的世界里,这基本不算什么。

这就是令我再次研究解析技术的原因。

但是,当前 CPython 中的解析器还有另一个 bug 我的东西。

编译器都是复杂的,CPython 也不例外:虽然 pgen-驱动的解析器输出的是一个解析树,但是这个解析树并不直接用作代码生成器的输入:它首先会被转换成抽象语法树(AST),然后再被编译成字节码。(还有更多细节,但在这我不关注。)

为什么不直接从解析树编译呢?这其实正是它最早的工作方式,但是大约在 15 年前,我们发现编译器因为解析树的结构而变得复杂了,所以我们引入了一个单独的 AST,还引入了一个将解析树翻译成 AST 的环节。随着 Python 的发展,AST 比解析树更稳定,这减少了编译器出错的可能。

AST 对于那些想要检查(inspect)Python 代码的第三方代码,也更加容易,它还通过被大众欢迎的 ast 模块而公开。这个模块还允许你从头构建 AST 节点,或是修改现有的 AST 节点,然后你可以将新的节点编译成字节码。

后一项能力支撑起了一整个为 Python 语言添加扩展的家庭手工业(译注:ast 模块为 Python 的三方扩展提供了便利)。(借助 parser 模块,解析树同样能面向 Python 的用户开放,但它使用起来太麻烦了,因此相比于 ast 模块,它就过时了。)

综上所述,我现在的想法是看看能否为 CPython 创造一个新的解析器,在解析时,使用 PEG 与 packrat parsing 来直接构建 AST,从而跳过中间解析树结构,并尽可能地节省内存,尽管它会使用无限的前向缓冲。

我还没进展到这个地步,但已经有了一个原型,可以将一个 Python 的子集编译成一个 AST,其速度与当前 CPython 的解析器大致相当。只不过,它占用的内存更多,所以我预计在将它扩展到整个语言时,将会降低 PEG 解析器的速度。

但是,我还没去优化它,所以还是挺有希望的。

转换成 PEG 的最后一个好处是它为语言的未来演化提供了更大的灵活性。

过去有人曾说,pgen 的 LL(1) 缺陷帮助了 Python 保持语法的简单。这很有道理,但我们还有很多适当的流程,可以防止语言不受控制地膨胀(主要是 PEP 流程,在非常严格的向后兼容性要求以及新的治理结构的帮助下)。所以我并不担心。

我还有很多内容要写,关于 PEG 解析以及我的具体实现,但是要等我整理好代码后,在后续的文章中再去写了。


Building a PEG Parser

原题 | Building a PEG Parser

作者 | 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 代码

相关链接:

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

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


Generating a PEG Parser

原题 | Generating a PEG Parser

作者 | Guido van Rossum(Python之父)

译者 | 豌豆花下猫(“Python猫”公众号作者)

声明 | 本翻译是出于交流学习的目的,基于 CC BY-NC-SA 4.0 授权协议。为便于阅读,内容略有改动。

我已经在本系列第二篇文章中简述了解析器的基础结构,并展示了一个简单的手写解析器,根据承诺,我们将转向从语法中生成解析器。我还将展示如何使用@memoize装饰器,以实现packrat 解析。

【这是 PEG 系列第 3 篇。参见[第1篇](#PEG Parsers)、[第2篇](#Building a PEG Parser)】

上篇文章我们以一个手写的解析器结束。给语法加上一些限制的话,我们很容易从语法中自动生成这样的解析器。(我们稍后会解除那些限制。)

我们需要两个东西:一个东西读取语法,并构造一个表现语法规则的数据结构;还有一个东西则用该数据结构来生成解析器。我们还需要无聊的胶水,我就不提啦。

所以我们在这创造的是一个简单的编译器编译器(compiler-compiler)。我将语法符号简化了一些,仅保留规则与备选项;这其实对于我在本系列的前面所用的玩具语法来说,已经足够了。

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

使用完整的符号,我们为语法文件写出语法:

grammar: rule+ ENDMARKER
rule: NAME ':' alternative ('|' alternative)* NEWLINE
alternative: item+
item: NAME | STRING

用个花哨的叫法,这是我们的第一个元语法(语法的语法),而我们的解析器生成器将是一个元编译器(**编译器是一个程序,将其它程序从一种语言转译为另一种语言;**元编译器是一种编译器,其输入是一套语法,而输出是一个解析器)。

有个简单地表示元语法的方法,主要是使用内置的数据类型:一条规则的右侧只是由一系列的条目组成的列表,且这些条目只能是字符串。(Hack:通过检查第一个字符是否为引号,我们可以区分出NAMESTRING

至于规则,我用了一个简单的 Rule 类,所以整个语法就是一些 Rule 对象。

这是 Rule 类,省略__repr____eq__

class Rule:
    def __init__(self, name, alts):
        self.name = name
        self.alts = alts

调用它的是这个GrammarParser类(关于基类Parser ,请参阅我之前的帖子):

class GrammarParser(Parser):
    def grammar(self):
        pos = self.mark()
        if rule := self.rule():
            rules = [rule]
            while rule := self.rule():
                rules.append(rule)
            if self.expect(ENDMARKER):
                return rules    # <------------- final result
        self.reset(pos)
        return None
    def rule(self):
        pos = self.mark()
        if name := self.expect(NAME):
            if self.expect(":"):
                if alt := self.alternative():
                    alts = [alt]
                    apos = self.mark()
                    while (self.expect("|")
                           and (alt := self.alternative())):
                        alts.append(alt)
                        apos = self.mark()
                    self.reset(apos)
                    if self.expect(NEWLINE):
                        return Rule(name.string, alts)
        self.reset(pos)
        return None
    def alternative(self):
        items = []
        while item := self.item():
            items.append(item)
        return items
    def item(self):
        if name := self.expect(NAME):
            return name.string
        if string := self.expect(STRING):
            return string.string
        return None

注意 ENDMARKER ,它用来确保在最后一条规则后没有遗漏任何东西(如果语法中出现拼写错误,可能会导致这种情况)。

我放了一个简单的箭头,指向了 grammar() 方法的返回值位置,返回结果是一个存储 Rule 的列表。

其余部分跟上篇文章中的 ToyParser 类很相似,所以我不作解释。

只需留意,item() 返回一个字符串,alternative() 返回一个字符串列表,而 rule() 中的 alts 变量,则是一个由字符串列表组成的列表。

然后,rule() 方法将规则名称(一个字符串)与 alts 结合,放入 Rule 对象。

如果把这份代码用到包含了我们的玩具语法的文件上,则 grammar() 方法会返回以下的由 Rule 对象组成的列表:

[
  Rule('statement', [['assignment'], ['expr'], ['if_statement']]),
  Rule('expr', [['term', "'+'", 'expr'],
                ['term', "'-'", 'term'],
                ['term']]),
  Rule('term', [['atom', "'*'", 'term'],
                ['atom', "'/'", 'atom'],
                ['atom']]),
  Rule('atom', [['NAME'], ['NUMBER'], ["'('", 'expr', "')'"]]),
  Rule('assignment', [['target', "'='", 'expr']]),
  Rule('target', [['NAME']]),
  Rule('if_statement', [["'if'", 'expr', "':'", 'statement']]),
]

既然我们已经有了元编译器的解析部分,那就创建代码生成器吧。

把这些聚合起来,就形成了一个基本的元编译器:

def generate_parser_class(rules):
    print(f"class ToyParser(Parser):")
    for rule in rules:
        print()
        print(f"    @memoize")
        print(f"    def {rule.name}(self):")
        print(f"        pos = self.mark()")
        for alt in rule.alts:
            items = []
            print(f"        if (True")
            for item in alt:
                if item[0] in ('"', "'"):
                    print(f"            and self.expect({item})")
                else:
                    var = item.lower()
                    if var in items:
                        var += str(len(items))
                    items.append(var)
                    if item.isupper():
                        print("            " +
                              f"and ({var} := self.expect({item}))")
                    else:
                        print(f"            " +
                              f"and ({var} := self.{item}())")
            print(f"        ):")
            print(f"            " +
              f"return Node({rule.name!r}, [{', '.join(items)}])")
            print(f"        self.reset(pos)")
        print(f"        return None")

这段代码非常难看,但它管用(某种程度上),不管怎样,我打算将来重写它。

在"for alt in rule.alts"循环中,有些代码细节可能需要作出解释:对于备选项中的每个条目,我们有三种选择的可能:

  • 如果该条目是字符串字面量,例如'+' ,我们生成self.expect('+')
  • 如果该条目全部是大写,例如NAME ,我们生成(name := self.expect(NAME))
  • 其它情况,例如该条目是expr,我们生成 (expr := self.expr())

如果在单个备选项中出现多个相同名称的条目(例如term '-' term),我们会在第二个条目后附加一个数字。这里还有个小小的 bug,我会在以后的内容中修复。

这只是它的一部分输出(完整的类非常无聊)。不用担心那些零散的、冗长的 if (True and … ) 语句,我使用它们,以便每个生成的条件都能够以and 开头。Python 的字节码编译器会优化它。

class ToyParser(Parser):
    @memoize
    def statement(self):
        pos = self.mark()
        if (True
            and (assignment := self.assignment())
        ):
            return Node('statement', [assignment])
        self.reset(pos)
        if (True
            and (expr := self.expr())
        ):
            return Node('statement', [expr])
        self.reset(pos)
        if (True
            and (if_statement := self.if_statement())
        ):
            return Node('statement', [if_statement])
        self.reset(pos)
        return None
    ...

注意@memoize 装饰器:我“偷运”(smuggle)它进来,以便转向另一个主题:使用记忆法(memoization)来加速生成的解析器。

这是实现该装饰器的 memoize() 函数:

def memoize(func):
    def memoize_wrapper(self, *args):
        pos = self.mark()
        memo = self.memos.get(pos)
        if memo is None:
            memo = self.memos[pos] = {}
        key = (func, args)
        if key in memo:
            res, endpos = memo[key]
            self.reset(endpos)
        else:
            res = func(self, *args)
            endpos = self.mark()
            memo[key] = res, endpos
        return res
return memoize_wrapper

对于典型的装饰器来说,它的嵌套函数(nested function)会替换(或包装)被装饰的函数(decorated function),例如 memoize_wrapper() 会包装 ToyParser 类的 statement() 方法。

因为被包装的函数(wrapped function)是一个方法,所以包装器实际上也是一个方法:它的第一个参数是 self,指向 ToyParser 实例,后者会调用被装饰的函数。

包装器会缓存每次调用解析方法后的结果——这就是为什么它会被称为“口袋老鼠解析”(packrat parsing)!

这缓存是一个字典,元素是存储在 Parser 实例上的那些字典。

外部字典的 key 是输入的位置;我将 self.memos = {} 添加到 Parser.__init__() ,以初始化它。

内部字典按需添加,它们的 key 由方法及其参数组成。(在当前的设计中没有参数,但我们应该记得 expect(),它恰好有一个参数,而且给它新增通用性,几乎不需要成本。)

一个解析方法的结果被表示成一个元组,因为它正好有两个结果:一个显式的返回值(对于我们生成的解析器,它是一个 Node,表示所匹配的规则),以及我们从 self.mark() 中获得的一个新的输入位置。

在调用解析方法后,我们会在内部的记忆字典中同时存储它的返回值(res)以及新的输入位置(endpos)。

再次调用相同的解析方法时(在相同的位置,使用相同的参数),我们会从缓存中取出那两个结果,并用 self.reset() 来向前移动输入位置,最后返回那缓存中的返回值。

缓存负数的结果也很重要——实际上大多数对解析方法的调用都是负数的结果。在此情况下,返回值为 None,而输入位置不会变。你可以加一个assert 断言来检查它。

注意:Python 中常用的记忆法是在 memoize() 函数中将缓存定义成一个局部变量。但我们不这么做:因为我在一个最后时刻的调试会话中发现,每个 Parser 实例都必须拥有自己的缓存。然而,你可以用(pos, func, args) 作为 key,以摆脱嵌套字典的设计。

下周我将统览代码,演示在解析示例程序时,所有这些模块实际是如何配合工作的。

我仍然在抓头发中(译注:极度发愁),如何以最佳的方式将协同工作的标记生成器缓冲、解析器和记忆缓存作出可视化。或许我会设法生成动画的 ASCII 作品,而不仅仅是跟踪日志的输出。(译注:感觉他像是在开玩笑,但很难译出这句话的原味。建议阅读原文。)

本文及示例代码的授权协议:CC BY-NC-SA 4.0

英文原文https://medium.com/@gvanrossum_83706/generating-a-peg-parser-520057d642a9

**作者简介:**Guido van Rossum,是 Python 的创造者,一直是“终身仁慈独裁者”,直到2018年7月12日退位。目前,他是新的最高决策层的五位成员之一,依然活跃在社区中。

**译者简介:**豌豆花下猫,生于广东毕业于武大,现为苏漂程序员,有一些极客思维,也有一些人文情怀,有一些温度,还有一些态度。公众号:「Python猫」(python_cat)。



The origins of pgen

原题 | The origins of pgen

作者 | Guido van Rossum(Python之父)

译者 | 豌豆花下猫(“Python猫”公众号作者)

原文 | https://python-history.blogspot.com/2018/05/the-origins-of-pgen.html

声明 | 翻译是出于交流学习的目的,欢迎转载,但请保留本文出处,请勿用于商业或非法用途。

David Beazley 在 US PyCon 2018 上的演讲,关于语法分析生成器(parser generators),提醒了我应该写一下关于它的历史。这是一个简短的脑转储(也许我今后会解释它)。

(译注:我大胆揣测一下“脑转储”吧,应该说的是,把个人的记忆以及 Python 的历史细节,转化成文字,这是个存储固化的过程,方便传承。而我做的翻译工作,就是把这份文档财富,普及给更多的 Python 爱好者。)

实际上,有两个 pgen,一个是最初的,用 C 语言写的,还有一个则是用 Python 重写的,在 lib2to3/pgen2 下面。

两个都是我写的。最早那个实际上是我为 Python 编写的第一份代码。尽管从技术上讲,我必须首先编写词法分析程序(lexer)(pgen 和 Python 共用词法分析程序,但 pgen 对大多数标记符不起作用)。

之所以我要写自己的语法分析生成器,原因是当时这玩意(我熟悉的)相当稀少——基本上就是用 Yacc(有个 GNU 的重写版,叫作 Bison(译注:美洲野牛),但我不确定那时的自己是否知道);或者是自己手写一个(这是大多数人所做的)。

我曾在大学里用过 Yacc,从“龙书”中熟悉了它的工作原理,但是出于某些原因,我并不喜欢它;IIRC 关于 LALR(1) 语法的局限性,我很难解释清楚。

(译注:1、龙书,原文是 Dragon book,指代《Compilers: Principles, Techniques, and Tools》,这是一本讲编译原理的书,属于编译原理界的殿堂级存在。另外还有两本经典著作,称号分别是“虎书”、“鲸书”,三者常常一起出现。2、IIRC,If I Remember Correctly,如果我没记错。)

n/a

集齐三书,可以召唤神龙?

我也熟悉 LL(1) 解析器,并已认真地编写过一些递归下降的 LL(1) 解析器——我很喜欢它,而且还熟悉 LL(1) 解析器的生成技术(同样是因为龙书),所以我有了一个改进念头想要试验下:使用正则表达式(某种程度的)而不是标准的 BNF 格式。

龙书还教会了我如何将正则表达式转换成 DFA,所以我把所有这些东西一结合,pgen 就诞生了。【更新:请参阅下文,对于这个理由,有个略微不同的版本。】

我曾不熟悉更高级的技术,或者曾认为它们效率太低。(在当时,我觉得工作在解析器上的大多数人都是这样。)

至于词法分析器(lexer),我决定不使用生成器——我对 Lex 的评价要比 Yacc 低得多,因为在尝试扫描超过 255 个字节的标记符时,我所熟悉的 Lex 版本会发生段错误(真实的!)。此外,我认为缩进格式很难教给词法分析器生成器。

(译注:1、这里的生成器并非 Python 语法中的生成器,而是指用来生成分析器的工具。Lex 是“LEXical compiler”的简称,用来生成词法分析器;Yacc 是“Yet another compiler compiler”的简称,用来生成语法分析器。2、段错误,原文是 segfault,全称是segmentation fault,指的是因为越界访问内存空间而导致的报错。)

pgen2 的故事则完全不同。

我曾受雇于 San Mateo 的一家创业公司(即 Elemental Security,倒闭于 2007,之后我离开并加入了 Google),在那我有一项设计定制语言的任务(目标是作关于系统配置的安全性判定),并拥有相当大的自主权。

我决定设计一些稍微像 Python 的东西,用 Python 来实现,并且决定要重用 pgen,但是后端要基于 Python,使用 tokenize.py 作为词法分析器。所以我用 Python 重写了 pgen 里的那些算法,然后继续构建了剩余的部分。

管理层觉得把工具开源是有意义的,因此他们很快就批准了,而在不久之后(我当时很可能已经转移到 Google 了?),这工具对于 2to3 也是有意义的。(因为输入格式跟原始的 pgen 相同,用它来生成一个 Python 解析器很容易——我只需将语法文件喂给工具。?

更新:创建 pgen 的原因,还有更多故事

我不完全记得为什么要这样做了,但我刚偷看了https://en.wikipedia.org/wiki/LL_parser#Conflicts,我可能觉得这是一种新的(对我而言)不通过添加帮助性的规则而解决冲突的方式。

例如,该网页所称的的左分解(将 A -> X | X Y Z 替换成 A -> X B; B -> Y Z | <empty>),我会重写成 A -> X [Y Z]。

如果我没记错,通过“正则表达式 -> NFA -> DFA”的转换过程,解析引擎(该网页中前面的 syntacticAnalysis 函数)依然可以工作在由这些规则所派生的解析表上;我认为这里需要有不出现空白产物的诉求。(译注:“空白产物”,原文是 empty productions,对应的是前文的 <empty>,指的是不必要出现 empty。)

我还想起一点,由解析引擎生成的解析树节点可能有很多子节点,例如,对于上面的规则 A -> X [Y Z],节点 A 可能有 1 个子节点(X)或者 3 个(X Y Z)。代码生成器中就需要有一个简单的检查,来确定它遇到的是哪一种可能的情况。(这已经被证明是一把双刃剑,后来我们添加了一个由单独的生成器所驱动的“解析树 -> AST”步骤,以简化字节码生成器。)

所以我使用正则表达式的原因,很可能是为了使语法更易于阅读:在使用了必要的重写以解决冲突之后,我发现语法不是那么可读(此处应插入《Python 之禅》的说法 ? ,而正则表达式则更符合我对于经典语言的语法的看法(除了起着奇怪名字的帮助规则、[optional] 部分以及 * 号重复的部分)。

正则表达式没有提高 LL(1) 的能力,更没有降低它的能力。当然了,所谓“正则表达式”,我想说的其实是 EBNF ——我不确定 “EBNF” 在当时是否是一个被明确定义了的符号,它可能就指对 BNF 的任意扩展。

假如将 EBNF 转换为 BNF,再去使用它,将会导致尴尬的多解析树节点问题,所以我不认为这会是一种改进。

如果让我重做一遍,我可能会选择一个更强大的解析引擎,可能是 LALR(1) 的某个版本(例如 Yacc/Bison)。LALR(1) 的某些地方要比 LL(1) 更给力,也更加有用,例如,关键字参数。

在 LL(1) 中,规则 “arg: [NAME =] expr” 无效,因为 NAME 出现在了表达式的第一组里(FIRST-set),而 LL(1) 算法没法处理这样的写法。

如果我没记错,LALR(1) 则可以处理它。但是,在我写完 pgen 的第一个版本的好些年之后,关键字参数写法才出现,那时候我已不想重做解析器了。

2019 年 3 月更新: Python 3.8 将删除 pgen 的 C 版本,转而使用重写的 pgen2 版本。参阅 https://github.com/python/cpython/pull/11814

发布了164 篇原创文章 · 获赞 76 · 访问量 9万+

猜你喜欢

转载自blog.csdn.net/qq_29757283/article/details/99290037