Python 之父的解析器系列之七:PEG 解析器的元语法

原题 | A Meta-Grammar for PEG Parserspython

做者 | Guido van Rossum(Python之父)git

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

声明 | 本翻译是出于交流学习的目的,基于 CC BY-NC-SA 4.0 受权协议。为便于阅读,内容略有改动。本系列的译文已在 Github 开源,项目地址:github.com/chinesehuaz…编程

本周咱们使解析器生成器完成“自托管”(self-hosted),也就是让它本身生成解析器。bootstrap

首先咱们有了一个解析器生成器,其中一部分是语法解析器。咱们能够称之为元解析器(meta-parser)。该元解析器与要生成的解析器相似:GrammarParser 继承自Parser ,它使用相同的 mark()/reset()/expect() 机制。然而,它是手写的。可是,只能是手写么?bash

在编译器设计中有一个传统,即编译器使用它要编译的语言编写。我深切地记得在我初学编程时,当时用的 Pascal 编译器是用 Pascal 自己编写的,GCC 是用 C 编写的,Rust 编译器固然是用 Rust 编写的。数据结构

这是怎么作到的呢?有一个辅助过程(bootstrap,引导程序,一般译做“自举”):对于一种语言的子集或早期版本,它的编译器是用其它的语言编写的。(我记得最初的 Pascal 编译器是用 FORTRAN 编写的!)而后用编译后的语言编写一个新的编译器,并用辅助的编译器来编译它。一旦新的编译器运行得足够好,辅助的编译器就会被废弃,而且该语言或新编译器的每一个新版本,都会受到先前版本的编译器的编译能力的约束。app

让咱们的元解析器如法炮制。咱们将为语法编写一个语法(元语法),而后咱们将从中生成一个新的元解析器。幸运的是我从一开始就计划了,因此这是一个很是简单的练习。咱们在上一篇文章中添加的动做是必不可少的因素,由于咱们不但愿被迫去更改生成器——所以咱们须要可以生成一个可兼容的数据结构。学习

这是一个不加动做的元语法的简化版:网站

start: rules ENDMARKER
rules: rule rules | rule
rule: NAME ":" alts NEWLINE
alts: alt "|" alts | alt
alt: items
items: item items | item
item: NAME | STRING
复制代码

我将自下而上地展现如何添加动做。参照第 3 篇,咱们有了一些带 name 和 alts 属性的 Rule 对象。最初,alts 只是一个包含字符串列表的列表(外层列表表明备选项,内层列表表明备选项的条目),但为了添加动做,我更改了一些内容,备选项由具备 items 和 action 属性的 Alt 对象来表示。条目仍然由纯字符串表示。对于 item 规则,咱们有:

item: NAME { name.string } | STRING { string.string }
复制代码

这须要一些解释:当解析器处理一个标识符时,它返回一个 TokenInfo 对象,该对象具备 type、string 及其它属性。咱们不但愿生成器来处理 TokenInfo 对象,所以这里加了动做,它会从标识符中提取出字符串。请注意,对于像 NAME 这样的全大写标识符,生成的解析器会使用小写版本(此处为 name )做为变量名。

接下来是 items 规则,它必须返回一个字符串列表:

items: item items { [item] + items } | item { [item] }
复制代码

我在这里使用右递归规则,因此咱们不依赖于第 5 篇中添加的左递归处理。(为何不呢?保持事情尽量简单老是一个好主意,这个语法使用左递归的话,不是很清晰。)请注意,单个的 item 已被分层,但递归的 items 没有,由于它已是一个列表。

alt 规则用于构建 Alt 对象:

alt: items { Alt(items) }
复制代码

我就不介绍 rules 和 start 规则了,由于它们遵循相同的模式。

可是,有两个未解决的问题。首先,生成的代码如何知道去哪里找到 Rule 和 Alt 类呢?为了实现这个目的,咱们须要为生成的代码添加一些 import 语句。最简单的方法是给生成器传递一个标志,该标志表示“这是元语法”,而后让生成器在生成的程序顶部引入额外的 import 语句。可是既然咱们已经有了动做,许多其它解析器也会想要自定义它们的导入,因此为何咱们不试试看,可否添加一个更通用的功能呢。

有不少方法能够剥了这只猫的皮(译注:skin this cat,解决这个难题)。一个简单而通用的机制是在语法的顶部添加一部分“变量定义”,并让生成器使用这些变量,来控制生成的代码的各个方面。我选择使用 @ 字符来开始一个变量定义,在它以后是变量名(一个 NAME)和值(一个 STRING)。例如,咱们能够将如下内容放在元语法的顶部:

@subheader "from grammar import Rule, Alt"
复制代码

标准的导入老是会打印(例如,去导入 memoize),在那以后,解析器生成器会打印 subheader 变量的值。若是须要多个 import,能够在变量声明中使用三引号字符串,例如:

@subheader """ from token import OP from grammar import Rule, Alt """
复制代码

这很容易添加到元语法中,咱们用这个替换 start 规则:

start: metas rules ENDMARKER | rules ENDMARKER
metas: meta metas | meta
meta: "@" NAME STRING NEWLINE
复制代码

(我不记得为何我会称它们为“metas”,但这是我在编写代码时选择的名称,我会坚持这样叫。:-)

咱们还必须将它添加到辅助的元解析器中。既然语法不只仅是一系列的规则,那么让咱们添加一个 Grammar 对象,其中包含属性 metasrules。咱们能够放入以下的动做:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING { (name.string, eval(string.string)) }
复制代码

(注意 meta 返回一个元组,并注意它使用 eval() 来处理字符串引号。)

说到动做,我漏讲了 alt 规则的动做!缘由是这里面有些混乱。但我不能再无视它了,上代码吧:

alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }
复制代码

这个混乱是因为我但愿在描绘动做的花括号之间容许任意 Python 代码,以及容许配对的大括号嵌套在其中。为此,咱们使用了特殊标识符 OP,标记生成器用它生成可被 Python 识别的全部标点符号(返回一个类型为 OP 标识符,用于多字符运算符,如 <= 或 ** )。在 Python 表达式中能够合法地出现的惟一其它标识符是名称、数字和字符串。所以,在动做的最外侧花括号之间的“东西”彷佛是一组循环的 NAME | NUMBER | STRING | OP 。

呜呼,这没用,由于 OP 也匹配花括号,但因为 PEG 解析器是贪婪的,它会吞掉结束括号,咱们就永远看不到动做的结束。所以,咱们要对生成的解析器添加一些调整,容许动做经过返回 None 来使备选项失效。我不知道这是不是其它 PEG 解析器的标准配置——当我考虑如何解决右括号(甚至嵌套的符号)的识别问题时,立马就想到了这个方法。它彷佛运做良好,我认为这符合 PEG 解析的通常哲学。它能够被视为一种特殊形式的前瞻(我将在下面介绍)。

使用这个小调整,当出现花括号时,咱们可使 OP 上的匹配失效,它能够经过 stuff 和 action 进行匹配。

有了这些东西,元语法能够由辅助的元解析器解析,而且生成器能够将它转换为新的元解析器,由此解析本身。更重要的是,新的元解析器仍然能够解析相同的元语法。若是咱们使用新的元编译器编译元语法,则输出是相同的:这证实生成的元解析器正常工做。

这是带有动做的完整元语法。只要你把解析过程串起来,它就能够解析本身:

@subheader """ from grammar import Grammar, Rule, Alt from token import OP """
start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING NEWLINE { (name.string, eval(string.string)) }
rules: rule rules { [rule] + rules }
     | rule { [rule] }
rule: NAME ":" alts NEWLINE { Rule(name.string, alts) }
alts: alt "|" alts { [alt] + alts }
    | alt { [alt] }
alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
items: item items { [item] + items }
     | item { [item] }
item: NAME { name.string }
    | STRING { string.string }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }
复制代码

如今咱们已经有了一个能工做的元语法,能够准备作一些改进了。

但首先,还有一个小麻烦要处理:空行!事实证实,标准库的 tokenize 会生成额外的标识符来跟踪非重要的换行符和注释。对于前者,它生成一个 NL 标识符,对于后者,则是一个 COMMENT 标识符。以其将它们吸取进语法中(我已经尝试过,但并不容易!),咱们能够在 tokenizer 类中添加一段很是简单的代码,来过滤掉这些标识符。这是改进的 peek_token 方法:

def peek_token(self):
        if self.pos == len(self.tokens):
            while True:
                token = next(self.tokengen)
                if token.type in (NL, COMMENT):
                    continue
                break
            self.tokens.append(token)
            self.report()
        return self.tokens[self.pos]
复制代码

这样就彻底过滤掉了 NL 和 COMMENT 标识符,所以在语法中再也不须要担忧它们。

最后让咱们对元语法进行改进!我想作的事情纯粹是美容性的:我不喜欢被迫将全部备选项放在同一行上。我上面展现的元语法实际上并无解析本身,由于有这样的状况:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
复制代码

这是由于标识符生成器(tokenizer)在第一行的末尾产生了一个 NEWLINE 标识符,此时元解析器会认为这是该规则的结束。此外,NEWLINE 以后会出现一个 INDENT 标识符,由于下一行是缩进的。在下一个规则开始以前,还会有一个 DEDENT 标识符。

下面是解决办法。为了理解 tokenize 模块的行为,咱们能够将 tokenize 模块做为脚本运行,并为其提供一些文本,以此来查看对于缩进块,会生成什么样的标识符序列:

$ python -m tokenize
foo bar
    baz
    dah
dum
^D
复制代码

咱们发现它会产生如下的标识符序列(我已经简化了上面运行的输出):

NAME     'foo'
NAME     'bar'
NEWLINE
INDENT
NAME     'baz'
NEWLINE
NAME     'dah'
NEWLINE
DEDENT
NAME     'dum'
NEWLINE
复制代码

这意味着一组缩进的代码行会被 INDENT 和 DEDENT 标记符所描绘。如今,咱们能够从新编写元语法规则的 rule 以下:

rule: NAME ":" alts NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, alts + more_alts) }
    | NAME ":" alts NEWLINE { Rule(name.string, alts) }
    | NAME ":" NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, more_alts) }
more_alts: "|" alts NEWLINE more_alts { alts + more_alts }
         | "|" alts NEWLINE { alts }
复制代码

(我跨行地拆分了动做,以便它们适应 Medium 网站的窄页——这是可行的,由于标识符生成器会忽略已配对的括号内的换行符。)

这样作的好处是咱们甚至不须要更改生成器:这种改进的元语法生成的数据结构跟之前相同。一样注意 rule 的第三个备选项,对此让咱们写:

start:
    | metas rules ENDMARKER { Grammar(rules, metas) }
    | rules ENDMARKER { Grammar(rules, []) }
复制代码

有些人会以为这比我以前展现的版本更干净。很容易容许两种形式共存,因此咱们没必要争论风格。

在下一篇文章中,我将展现如何实现各类 PEG 功能,如可选条目、重复和前瞻。(说句公道话,我本打算把那放在这篇里,可是这篇已写太长了,因此我要把它分红两部分。)

本文内容与示例代码的受权协议:CC BY-NC-SA 4.0

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

相关文章
相关标签/搜索