导航菜单

Python 之父新发文,将替换现有解析器

几年前,有人问过Python是否会转换为PEG解析器(或PEG语法,我不记得确切的内容,谁说过,当它被说出来时)。我已经看过这个话题了,但我已经毫无头绪地放弃了。

最近,我学到了很多关于PEG(Parsing Expression Grammars)的知识,现在我觉得它是一个有趣的替代方案,只是为了取代我30年前开始创建Python时创建的本土解析器。 (解析器生成器)(解析器生成器,称为“pgen”,是我为Python编写的第一段代码)。

Python资源共享组:

我现在对PEG感兴趣,因为我对pgen的局限性感到恼火。

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

与此同时,我还发明了一组类似EBNF的语法符号(扩展Backus-Naur形式,BNF的扩展,用于描述特定语言语法的形式符号),我仍然非常喜欢。

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

LL(1)名称中的“1”表示它仅使用单个令牌前瞻,这限制了我们编写漂亮语法规则的能力。例如,Python语句可以是表达式或赋值(或其他东西,但那些以特殊关键字开头,如if或def)。

我们想使用pgen表示法来编写以下语法。 (请注意,此示例描述了一种玩具语言,它是Python的一小部分,就像传统语言设计一样。)

声明:赋值| expr | if_statementexpr: expr'+'term | expr' - 'term | termterm: term'*'atom |术语'/'原子| atomatom: NAME | NUMBER | '('expr')'赋值: target'='exprtarget: NAMEif_statement:'如果'expr':'语句

对于这些符号,请解释几个词:NAME和NUMBER是令牌,它们是在语法之外预定义的。引号中的字符串如“+”或“if”也是标记。 (我稍后会讨论标签。)语法规则以其名称开头,后跟:后跟一个或多个以|分隔的替代方法。符号。

问题是,如果你写这样的语法,解析器将无法工作,pgen会打击。

一个原因是某些规则(例如expr和term)是递归的,并且pgen不足以明智地解析。这通常需要通过重写规则来解决(例如,其他规则不变):

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

这揭示了peNF的EBNF功能的一部分:您可以在括号中嵌套可选内容,并且您可以通过在括号后面添加*来创建重复项,因此这里的expr规则意味着:它是一个术语,后跟零个或多个语句块,它们是加上标志和术语,或减号和术语。

此语法与该语言的第一个版本兼容,但它并不反映语言设计者的意图 - 特别是它并不表示运算符是左边界的,这在您尝试生成代码时非常重要。

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

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

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

答案=42

该程序将被解析为三个标签:NAME(值为答案),'='和NUMBER(值为42)。在程序开始时,我们唯一的前向标记是NAME。此时,我们试图满足的规则是statement(此语法的起始标志)。此规则有三个可选内容:expr,assignment和if_statement。我们可以排除if_statement,因为forward标记不是“if”。

但是expr和赋值都可以从NAME标记开始,因此它会引起歧义,而pgen将拒绝我们的语法。

(这不完全正确,因为语法不会导致技术含糊不清;但让我们不管它,因为我想不出更好的词来表达。然后pgen语法规则计算一个叫做FIRST组的东西,如果给出的话固定点,FIRST组中有一个重叠选项,它会抱怨。)(注释:抱怨?这应该意味着分析不能继续,以前的翻译是一个打击)。

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

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

但是在像Python这样更现实的语言中,你可能需要一个无限的前向缓冲区,因为“=”标记左边的东西可能非常复杂,例如:

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

在“=”标签之前,它使用了10个标签,如果你想挑战,我可以引用任何长的例子。为了在pgen中解决它,我们的方法是修改语法并添加额外的检查以使其接受一些非法程序,但如果它检查左边的赋值是无效的,它将抛出一个SyntaxError。

对于我们的玩具语言,这可以总结如下:

声明: assignment_or_expr | if_statementassignment_or_expr: expr ['='expr]

(方括号表示可选部分。)然后在随后的编译过程中(例如,生成字节码时),我们将检查是否存在“=”,如果存在,我们将检查左侧是否存在目标语法侧。

调用函数时,关键字参数也有类似的问题。我们想写这个(再次,这是Python调用语法的简化版本):

调用: atom'('arguments')'参数: arg(','arg)* arg: posarg | kwargposarg: exprkwarg: NAME'='expr

但是前向单一令牌无法告诉解析器参数开头的NAME是否是posarg的开头(因为expr可能以NAME开头)或kwarg的开头。

Arg: expr ['='expr]

然后在后续编译过程中解决问题。 (我们甚至犯了一个小错误,允许像foo((a)=1这样的东西)赋予它与foo(a=1)相同的含义,直到Python 3.8被修复。)

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

通过使用无限制的前向缓冲! PEG解析器的经典实现使用称为“packrat解析”的东西,它不仅在解析之前将整个程序加载到内存中,而且还允许解析器任意回溯。

尽管术语PEG主要指语法符号,但PEG语法中生成的解析器是递归下降解析器,可以无限期地回溯。通过记住匹配每个位置的规则来使用“packrat解析”。有效。

这使一切变得简单,但当然还有成本:记忆。

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

幸运的是,运行CPython的计算机拥有比30年前更多的内存,将整个文件存储在内存中不再是负担。例如,我可以在标准库中找到的最大的非测试文件是_pydecimal.py,它大约有223千字节(KB)。在GB级世界中,这基本上没什么。

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

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

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

为什么不直接从解析树编译?这实际上是最早的工作方式,但是大约15年前,我们发现编译器因解析树的结构而变得复杂,所以我们引入了一个单独的AST并将解析树的转换引入了AST的链接。随着Python的发展,AST比解析树更稳定,这减少了编译器错误的可能性。

AST对于想要检查Python代码的第三方代码也更容易,并且它通过流行的ast模块公开。此模块还允许您从头开始构建AST节点,或修改现有AST节点,然后您可以将新节点编译为字节码。

后一种能力支持一整套手工艺品,它们增加了Python语言的扩展(注释:ast模块有助于Python的三向扩展)。 (使用解析器模块,解析树也对Python用户开放,但使用起来太麻烦,因此与ast模块相比它已经过时了。)

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

我还没有进展到这一点,但我已经有了一个原型,可以将Python的一个子集编译成AST,速度与当前的CPython解析器大致相同。但是,它会占用更多内存,因此我希望在将PEG解析器扩展到整个语言时放慢速度。

但是,我还没有优化它,所以我仍然非常有前途。

转换为PEG的最终好处是它为语言的未来发展提供了更大的灵活性。

过去曾经说过,pgen的LL(1)缺陷有助于Python保持语法简单。这是有道理的,但是我们有许多适当的流程来防止语言的不受控制的扩展(主要是PEP流程,具有非常严格的向后兼容性要求和新治理结构的帮助)。所以我并不担心。

我仍然有很多内容要写关于PEG解析和我的具体实现,但在完成代码之后,我将在后续文章中写出来。

澳门总统网站