过去几天,我做了很多研究,以更好地理解为什么存在这些单独的技术,以及它们的优缺点。
一些已经存在的答案暗示了它们之间的某些差异,但是它们并没有给出完整的图片,并且似乎有些自以为是,这就是为什么编写此答案的原因。
这个展览很长,但是很重要。和我一起忍受(或者,如果您不耐烦,请滚动至末尾以查看流程图)。
要了解解析器组合器和解析器生成器之间的区别,首先需要了解存在的各种解析之间的区别。
解析中
解析是根据形式语法分析符号字符串的过程。(在计算科学中)解析用于使计算机能够理解用某种语言编写的文本,通常创建一个表示该编写文本的解析树,在树的每个节点中存储不同书写部分的含义。然后,可以将此解析树用于各种不同的目的,例如将其翻译为另一种语言(在许多编译器中使用),以某种方式直接解释书面指令(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 /野牛。
有趣的是,如今的景观有些混乱:
通常,创建一个LR解析器生成器并调试在语法上运行的(LA)LR风格的解析器生成器的输出是困难的,因为将原始语法转换为“由内而外”的LR形式。另一方面,Yacc / Bison之类的工具已经进行了多年的优化,并且在野外得到了广泛使用,这意味着许多人现在将其视为解析的方法,并对新方法持怀疑态度。
您应该使用哪一种取决于您的语法有多难,以及解析器需要多快。根据语法的不同,这些技术中的一种(/不同技术的实现)可能比其他技术更快,内存占用更小,磁盘占用更小,或者更可扩展或更容易调试。您的里程可能会有所不同。
旁注:关于词法分析的主题。
词法分析可用于解析器组合器和解析器生成器。这个想法是要有一个“哑”解析器,该解析器易于实现(因此非常快),可以对源代码执行第一遍操作,例如删除重复的空格,注释等,并可能在非常粗略地构成您的语言的不同元素。
主要优点是,第一步使真正的解析器更加简单(并且因此可能更快)。主要缺点是您有一个单独的转换步骤,例如,由于删除了空格,使用行号和列号进行错误报告变得更加困难。
最后的词法分析器只是“另一个”解析器,可以使用上面的任何技术来实现。由于其简单性,通常使用除主解析器之外的其他技术,例如,存在额外的“词法生成器”。
Tl;博士:
这是适用于大多数情况的流程图:
javac
,Scala),手动编写解析器是实现的首选形式。它可以让你的内部解析器的状态最多的控制,这与创造良好的错误信息(这是近年来...帮助