何时使用解析器组合器?何时使用解析器生成器?


59

最近,我想深入了解解析器领域,希望创建自己的编程语言。

但是,我发现存在两种不同的编写解析器的方法:解析器生成器和解析器组合器。

有趣的是,我找不到任何可以解释哪种资源更好的资源。相反,我查询的许多资源(和人员)都不知道另一种方法,只是将方法解释为该方法,而根本没有提及另一种方法:

  • 著名的龙书进入词法/扫描,并提到(F)法,但都没有提到解析器组合。
  • 语言实现模式在很大程度上依赖于Java内置的ANTLR解析器生成器,根本没有提到解析器组合器。
  • Parsec上的Parsec 简介教程(是Haskell中的Parser Combinator)根本没有提到Parser Generators。
  • Boost :: spirit(最著名的C ++解析器组合器)根本没有提到解析器生成器。
  • 很棒的说明性博客文章“ 您可能已经发明了解析器组合器”根本没有提到解析器生成器。

简单概述:

解析器生成器

解析器生成器获取用DSL编写的文件,该文件是Extended Backus-Naur形式的某种方言,并将其转换为源代码,然后可以(在编译时)成为该DSL中描述的输入语言的解析器。

这意味着编译过程分为两个单独的步骤。有趣的是,解析器生成器本身也是编译器(其中许多确实是自托管的)。

解析器组合器

解析器组合器描述了称为解析器的简单函数,这些函数均将输入作为参数,如果匹配则尝试提取此输入的第一个字符。它们返回一个元组(result, rest_of_input),如果解析器无法解析此输入中的任何内容,则该字段result可能为空(例如nilNothing)。一个例子是digit解析器。其他解析器当然可以将解析器作为第一个参数(最后一个参数仍保留在输入字符串中)来组合它们:例如,many1尝试尽可能多地匹配另一个解析器(但至少要匹配一次,否则它本身会失败)。

现在,您当然可以结合(组成)digitmany1创建新的解析器,例如integer

同样,choice可以编写一个更高级别的解析器,该解析器获取解析器列表,依次尝试每个解析器。

这样,可以构建非常复杂的词法分析器/解析器。在支持运算符重载的语言中,这看起来也很像EBNF,尽管它仍然是直接用目标语言编写的(并且您可以使用所需的目标语言的所有功能)。

简单差异

语言:

  • 解析器生成器使用EBNF-ish DSL和这些语句匹配时应生成的代码的组合编写。
  • 解析器组合器直接以目标语言编写。

乐兴/解析:

  • 解析器生成器在“词法分析器”(将字符串拆分成可能被标记以表明我们正在处理的值的标记)与“解析器”(从词法分析器获取标记的输出列表)之间有非常明显的区别并尝试将它们组合起来,形成一个抽象语法树)。
  • 解析器组合器不需要/不需要这种区别;通常,简单的解析器执行“词法分析器”的工作,而更高级别的解析器将这些更简单的解析器称为确定要创建哪种AST节点。

但是,即使存在这些差异(这是差异列表,可能还远远不够!),我仍无法就何时使用哪一个做出明智的选择。我看不出这些差异的含义/后果。

哪些问题属性表明使用Parser Generator可以更好地解决问题?哪些问题属性表明使用Parser Combinator可以更好地解决问题?


4
还有至少两种您没有提到的实现解析器的方式:解析器解释器(类似于解析器生成器,除了不是直接将解析器语言编译为C或Java,而是直接执行解析器语言),以及简单地编写手动解析器。对于许多现代的可立即投入生产的工业强度语言实现(例如GCC,Clang javac,Scala),手动编写解析器是实现的首选形式。它可以让你的内部解析器的状态最多的控制,这与创造良好的错误信息(这是近年来...帮助
约尔格W¯¯米塔格

3
……已成为语言实施者的高度优先事项)。同样,许多现有的解析器生成器/解释器/组合器并不是真正设计来满足现代语言实现必须满足的各种需求。例如,许多现代语言实现将同一段代码用于批处理编译,IDE后台编译,语法突出显示,自动重构,智能代码完成,自动文档生成,自动绘制等。Scala甚至将编译器用于运行时反射及其宏系统。 。许多现有的解析器...
约尔格W¯¯米塔格

1
……框架不够灵活,无法应对。还要注意,有些解析器框架不基于EBNF。例如用于解析表达式语法的packrat解析器
约尔格W¯¯米塔格

2
我认为这在很大程度上取决于您要编译的语言。它是什么类型(LR,...)?
qwerty_so 2016年

1
上面的假设基于BNF,通常可以使用lexer / LR解析器组合简单地对其进行编译。但是语言不一定基于LR语法。那么,您打算编译哪个?
qwerty_so 2016年

Answers:


59

过去几天,我做了很多研究,以更好地理解为什么存在这些单独的技术,以及它们的优缺点。

一些已经存在的答案暗示了它们之间的某些差异,但是它们并没有给出完整的图片,并且似乎有些自以为是,这就是为什么编写此答案的原因。

这个展览很长,但是很重要。和我一起忍受(或者,如果您不耐烦,请滚动至末尾以查看流程图)。


要了解解析器组合器和解析器生成器之间的区别,首先需要了解存在的各种解析之间的区别。

解析中

解析是根据形式语法分析符号字符串的过程。(在计算科学中)解析用于使计算机能够理解用某种语言编写的文本,通常创建一个表示该编写文本的解析树,在树的每个节点中存储不同书写部分的含义。然后,可以将此解析树用于各种不同的目的,例如将其翻译为另一种语言(在许多编译器中使用),以某种方式直接解释书面指令(SQL,HTML),从而允许诸如Linters之类的工具 执行其工作。等等。有时,解析树不是显式的生成,而是直接在树中的每种类型的节点上执行的操作。这提高了效率,但是在水下仍然存在隐式解析树。

解析是计算上困难的问题。关于这一主题的研究已有五十多年,但仍有很多东西要学习。

粗略地说,有四种让计算机解析输入的通用算法:

  • LL解析。(无上下文,自上而下的解析。)
  • LR解析。(无上下文,自下而上的解析。)
  • PEG + Packrat解析。
  • Earley解析。

请注意,这些类型的解析是非常笼统的理论描述。有多种方法可以在物理机器上实现这些算法中的每一个,但要权衡取舍。

LL和LR只能查看上下文无关的语法(也就是说,所写标记周围的上下文对于理解其用法并不重要)。

PEG / Packrat解析和Earley解析的使用较少:Earley解析的好处在于它可以处理更多的语法(包括那些不一定是无上下文语法),但效率较低(如龙所言)书(第4.1.1节);我不确定这些声明是否仍然正确)。 解析表达式语法+ Packrat解析是一种相对有效的方法,比LL和LR都可以处理更多的语法,但是隐藏了歧义,下面将对此进行快速介绍。

LL(从左到右,最左导数)

这可能是考虑解析的最自然的方法。这个想法是查看输入字符串中的下一个标记,然后确定应该采取多个可能的递归调用中的哪一个来生成树结构。

这棵树是“自顶向下”构建的,这意味着我们从树的根部开始,并且以与遍历输入字符串相同的方式传播语法规则。也可以看作是为正在读取的“ infix”令牌流构造等效的“ postfix”。

可以编写执行LL样式的解析器,使其看起来与指定的原始语法非常相似。这使得相对容易理解,调试和增强它们。古典解析器组合器不过是可以组合在一起以构建LL样式解析器的“乐高积木”。

LR(从左到右,最右派生)

LR解析从下往上进行另一种方式:在每个步骤中,将堆栈中的顶部元素与语法列表进行比较,以查看它们是否可以简化 为语法中的更高级别规则。如果不是,则将输入流中的下一个令牌移位并放置在堆栈的顶部。

如果最后我们在堆栈上结束一个节点,该节点代表语法的起始规则,那么该程序是正确的。

展望

在这两个系统中的任何一个中,有时必须先从输入中查看更多令牌,然后才能决定做出哪个选择。这是(0)(1)(k)(*)-syntax你的这两个通用的算法,如名称后看到LR(1)LL(k)k通常代表“与您的语法需求一样多”,而*通常代表“此解析器执行回溯”,该功能更强大/更易于实现,但比仅能继续解析的解析器具有更高的内存和时间使用率线性地。

请注意,当LR样式的解析器可能决定“向前看”时,它们在堆栈上已经有许多令牌,因此它们已经有更多要分发的信息。这意味着对于相同的语法,他们通常比LL样式的解析器需要更少的“超前”。

LL vs. LR:歧义

阅读以上两个描述时,您可能会想知道为什么存在LR样式的解析,因为LL样式的解析似乎更加自然。

但是,LL样式的解析存在一个问题:Left Recursion

像这样写语法是很自然的:

expr ::= expr '+' expr | term term ::= integer | float

但是,LL样式的解析器在解析此语法时将陷入无限递归循环中:当尝试该expr规则最左端的可能性时,它将再次递归到该规则,而不会消耗任何输入。

有解决此问题的方法。最简单的方法是重写语法,以使这种递归不再发生:

expr ::= term expr_rest expr_rest ::= '+' expr | ϵ term ::= integer | float (此处,ϵ代表“空字符串”)

现在,这种语法是正确的递归。请注意,立即阅读起来要困难得多。

在实践中,左递归可能在其间的许多其他步骤中间接发生。这使它成为一个很难解决的问题。但是,尝试解决该问题会使语法难以阅读。

正如《龙书》第2.5节所述:

我们似乎存在冲突:一方面,我们需要一种语法来促进翻译,另一方面,我们需要一种截然不同的语法来促进解析。解决方案是从语法开始,以便于翻译,并仔细进行转换以方便语法分析。通过消除左递归,我们可以获得适用于预测递归下降翻译器的语法。

LR样式的解析器不存在这种左递归的问题,因为它们是自底向上构建树的。 但是,很难将上述语法从语法上转换为LR式解析器(通常以有限状态自动机实现
,因为通常会有成百上千个状态+状态转换要考虑。这就是为什么LR-风格解析器通常产生由解析器生成,这也被称为一个“编译器编译”。

如何解决歧义

上面我们看到了两种解决左递归歧义的方法:1)重写语法2)使用LR解析器。

但是还有其他类型的歧义更难解决:如果两个不同的规则同时适用,该怎么办?

一些常见的示例是:

LL风格和LR风格的解析器都存在这些问题。解析算术表达式的问题可以通过引入运算符优先级来解决。以类似的方式,可以通过选择一种优先行为并坚持下去来解决其他问题,例如“悬空”。(例如,在C / C ++中,悬空的else始终属于最接近的'if')。

对此的另一个“解决方案”是使用解析器表达语法(PEG):这与上面使用的BNF语法相似,但是在模棱两可的情况下,请始终“选择第一个”。当然,这并没有真正“解决”问题,而是掩盖了实际上存在的歧义:最终用户可能不知道解析器做出的选择,这可能导致意外的结果。

比本文更深入的信息,包括为什么通常无法知道您的语法是否没有歧义,以及其含义是有关上下文的精彩博客文章LL和LR:为什么解析工具很难。我强烈推荐它;它帮助我了解了我现在正在谈论的所有内容。

50年的研究

但生活还要继续。事实证明,实现为有限状态自动机的“正常” LR样式解析器通常需要数千个状态+转换,这在程序大小上是一个问题。因此,编写了诸如Simple LR(SLR)和LALR(Look-ahead LR)之类的变体,它们结合了其他技术来使自动机变小,从而减少了解析器程序的磁盘和内存占用。

另外,解决上面列出的歧义的另一种方法是使用通用技术,其中,在歧义的情况下,将两种可能性都保留下来并进行解析:任何一种可能都无法解析(在这种情况下,另一种可能性是“正确”),并在两者都正确的情况下返回两者(并以此方式表明存在歧义)。

有趣的是,在描述了通用LR算法之后,事实证明可以使用类似的方法来实现通用LL解析器,这同样快(歧义语法的时间复杂度为$ O(n ^ 3)$,$ O(n) $用于完全明确的语法,尽管与简单的(LA)LR解析器相比,簿记更多,这意味着更高的常数因子),但又允许解析器以递归下降(自上而下)的方式编写,更加自然编写和调试。

解析器组合器,解析器生成器

因此,经过漫长的阐述,我们现在得出了问题的核心:

解析器组合器和解析器生成器有什么区别,什么时候应该使用另一个?

它们实际上是不同种类的野兽:

之所以创建解析器组合器,是因为人们在编写自上而下的解析器,并且意识到其中许多都有很多共同点

之所以创建解析器生成器,是因为人们希望构建不具有LL样式解析器(即LR样式解析器)所没有的问题的解析器,事实证明,手工解决这些问题非常困难。常见的包括实现(LA)LR的Yacc /野牛。

有趣的是,如今的景观有些混乱:

  • 可以编写GLL算法一起使用的Parser Combinators,以解决传统LL样式解析器所存在的歧义问题,同时与所有自上而下的解析一样可读/可理解。

  • 解析器生成器也可以为LL样式的解析器编写。ANTLR正是这样做的,并使用其他启发式方法(自适应LL(*))来解决经典LL样式解析器所具有的歧义。

通常,创建一个LR解析器生成器并调试在语法上运行的(LA)LR风格的解析器生成器的输出是困难的,因为将原始语法转换为“由内而外”的LR形式。另一方面,Yacc / Bison之类的工具已经进行了多年的优化,并且在野外得到了广泛使用,这意味着许多人现在将其视为解析方法,并对新方法持怀疑态度。

您应该使用哪一种取决于您的语法有多难,以及解析器需要多快。根据语法的不同,这些技术中的一种(/不同技术的实现)可能比其他技术更快,内存占用更小,磁盘占用更小,或者更可扩展或更容易调试。您的里程可能会有所不同

旁注:关于词法分析的主题。

词法分析可用于解析器组合器和解析器生成器。这个想法是要有一个“哑”解析器,该解析器易于实现(因此非常快),可以对源代码执行第一遍操作,例如删除重复的空格,注释等,并可能在非常粗略地构成您的语言的不同元素。

主要优点是,第一步使真正的解析器更加简单(并且因此可能更快)。主要缺点是您有一个单独的转换步骤,例如,由于删除了空格,使用行号和列号进行错误报告变得更加困难。

最后的词法分析器只是“另一个”解析器,可以使用上面的任何技术来实现。由于其简单性,通常使用除主解析器之外的其他技术,例如,存在额外的“词法生成器”。


Tl;博士:

这是适用于大多数情况的流程图: 在此处输入图片说明


@Sjoerd确实有很多文字,因为事实证明这是一个非常困难的问题。如果您知道我可以使最后一段更清楚的一种方式,我将不胜枚举:“您应该使用哪个,取决于您的语法有多难,以及解析器需要有多快。取决于语法,这些技术中的一种(/不同技术的实现)可能比其他技术更快,内存占用更小,磁盘占用更小,或者更可扩展或更容易调试。您的里程可能会有所不同。”
Qqwy

1
其他答案都更短,更清晰,并且在回答方面做得更好。
Sjoerd

1
@Sjoerd我写这个答案的原因是因为其他答案要么简化了问题,要么将部分答案作为完整答案提出,和/或陷入了谬论的陷阱。以上答案是讨论约尔格W¯¯米塔格,托马斯·基利安的embodiement和我在这个问题的意见后,了解他们在谈论和而不承担先验知识呈现它。
Qqwy

无论如何,我都向问题添加了tl; dr流程图。@Sjoerd,您满意吗?
qqwy

2
当您实际上不使用解析器组合器时,确实无法解决问题。关键不止于此|,这就是重点。正确的重写expr更为简洁expr = term 'sepBy' "+"(此处的单引号代替了反引号以转为函数中缀,因为mini-markdown没有字符转义)。在更一般的情况下,也有chainBy组合器。我意识到很难找到一个不很适合PC的简单解析任务作为示例,但这确实是对PC的强烈支持。
史蒂文·阿姆斯特朗

8

对于保证没有语法错误的输入,或者对于语法正确性的总体通过/失败,可以使用解析器组合器,尤其是在函数编程语言中,解析器组合器更易于使用。这些情况包括编程难题,读取数据文件等。

使您想增加解析器生成器的复杂性的功能是错误消息。您需要将用户指向行和列的错误消息,并希望人类也可以理解。正确执行此操作需要大量代码,而更好的解析器生成器(如antlr)可以帮助您解决这一问题。

但是,自动生成只能让您步入正轨,大多数商业且寿命长的开源编译器最终都需要手动编写其解析器。我想如果您觉得这样做很舒服,就不会问这个问题,因此建议您使用解析器生成器。


2
谢谢您的回答!为什么使用解析器生成器比解析器组合器构建可读的错误消息会更容易?(特别是,无论我们讨论的是哪种实现方式),例如,我知道Parsec和Spirit都具有打印包括行+列信息在内的错误消息的功能,因此在Parser Combinators中似乎也可以执行此操作。
Qqwy

并不是说您不能使用解析器组合器打印错误消息,而是当您将错误消息放入混合器中时,它们的优势并不明显。使用这两种方法进行相对复杂的语法,您将明白我的意思。
Karl Bielefeldt

根据定义,使用Parser Combinator可以在错误条件下获得的所有信息是“此时开始,未找到合法输入”。这并不能真正告诉您出了什么问题。从理论上讲,此时调用的单个解析器可以告诉您所期望的内容,但找不到,但是您所能做的就是将所有内容打印出来,从而产生一条错误的错误消息。
John R. Strohm

1
坦白讲,解析器生成器也不以其良好的错误消息而闻名。
Miles Rout

缺省情况下,不是,但是它们具有用于添加良好错误消息的更方便的挂钩。
卡尔·比勒费尔德

4

ANTLR解析器生成器的维护者之一Sam Harwell 最近写道

我发现[组合器]无法满足我的需求:

  1. ANTLR为我提供了处理歧义之类的工具。在开发过程中,有一些工具可以向我显示模糊的解析结果,因此我可以消除语法中的歧义。在运行时,我可以利用IDE中不完整的输入所产生的歧义来在诸如代码完成之类的功能中产生更准确的结果。
  2. 在实践中,我发现解析器组合器不适合满足我的性能目标。这部分可以回溯
  3. 当将解析结果用于概述,代码完成和智能缩进等功能时,对语法进行细微更改很容易影响这些结果的准确性。ANTLR提供了可以将这些不匹配转换为编译错误的工具,即使在其他情况下这些类型也会编译。我可以信心十足地对影响语法的新语言功能进行原型设计,因为知道构成IDE的所有其他代码将从一开始就为新功能提供完整的体验。我所知道的ANTLR 4(基于C#目标)的分支是我唯一尝试提供此功能的工具。

从本质上讲,解析器组合器是一个很酷的玩具,但是他们根本无法胜任认真的工作。


3

正如卡尔提到的那样,解析器生成器倾向于具有更好的错误报告。此外:

  • 它们往往会更快,因为生成的代码可以专用于语法,并为超前生成跳转表。
  • 他们倾向于使用更好的工具,识别模棱两可的语法,删除左递归,填充错误分支等。
  • 他们倾向于更好地处理递归定义。
  • 它们往往更坚固,因为发电机的使用寿命更长,可以为您做更多的样板工作,从而减少了您拧紧样板的机会。

另一方面,组合器具有自己的优势:

  • 它们是用代码编写的,因此,如果您的语法在运行时有所不同,则可以更轻松地对内容进行更改。
  • 它们往往更易于绑定和实际使用(解析器生成器的输出往往非常通用且难以使用)。
  • 它们是用代码编写的,因此当您的语法没有达到您的期望时,调试起来会更容易一些。
  • 由于它们的工作方式与其他任何代码一样,它们的学习曲线往往较浅。解析器生成器倾向于有自己的怪癖来学习使事物工作。

相对于现实世界中使用的手写LL递归下降解析器,解析器生成器往往具有可怕的错误报告。解析器生成器很少提供添加出色诊断所必需的状态表转换挂钩。这就是为什么几乎每个真正的编译器都不使用解析器组合器或解析器生成器的原因。LL递归体面的解析器的构建很简单,尽管不是“干净”的PC / PG,但它们更有用。
dhchdhd
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.