
在计算机科学的世界里,文法是定义语言结构的蓝图,从复杂的编程语言到简单的数据格式都概莫能外。解析器作为这些蓝图的解释者,必须遵循其规则来理解代码。然而,一种看似无害的模式,即左递归,却带来了一个根本性的挑战,能让解析器寸步难行。这个问题中,一条规则将一个实体定义为其自身的第一个组成部分,从而产生了一个逻辑循环,这对某些优雅的解析策略是致命的。本文将深入探讨左递归的核心,解决其直观表述与问题实现之间的鸿沟。在接下来的章节中,我们将首先揭示这一概念的内部工作原理,探索其失败的原因以及修复它的巧妙转换。然后,我们将拓宽视野,看看这个“缺陷”如何在其他情境下成为一种强大的工具,将编译器设计与更广泛的计算和语言学领域联系起来。
要真正理解任何思想,我们绝不能满足于仅仅知道它是什么;我们必须踏上一段旅程,去发现它为何如此,去观察其内部运作,并欣赏赋予其生命的精巧机制。计算机科学中的左递归概念也不例外。表面上看,它似乎只是编译器编写者遇到的一个技术麻烦。但如果我们仔细观察,就会发现它为了解结构、语言以及连接二者的算法的本质打开了一扇迷人的窗口。
让我们想象一位程序员,即我们的“解析器”,是一位一丝不苟的故事讲述者。这位讲述者有一本文法书,里面包含一套构建句子的规则,其工作是读取一串单词并弄清楚故事的结构。它以“自顶向下”的方式执行此操作:从最宏大的概念开始,比如“表达式”,然后尝试根据其规则手册将其分解为更小的部分。这种分解过程自然是递归的。
我们的故事讲述者从文法书中挑选了一条看起来非常直观的规则来定义算术表达式:“一个表达式可以是另一个表达式,后跟一个 + 号和一个数字。”用形式文法表示,这看起来异常简洁:
在这里, 代表“表达式”(Expression), 代表“项”(Term)(在此暂且视为一个数字)。对我们人类的直觉来说,这完全合理。但对我们刻板的故事讲述者来说,这是一个陷阱。为了理解一个 Expression,规则告诉它要做的第一件事就是……理解一个 Expression。于是,它递归地调用了自己。这个新的调用查看同一条规则,并立即再次调用自己。一次又一次。故事讲述者陷入了一个恶性循环,就像一个词典条目写着“递归:见递归”。它甚至没有机会查看输入文本中是否有 + 号。它无法取得任何进展,其待处理任务的堆栈无限增长,最终导致壮观的崩溃——栈溢出。
这就是左递归的本质:一条文法规则中,一个非终结符(如 )在其产生式的右侧以自身作为第一个符号。对于一大类简单而优雅的自顶向下解析器,比如我们所设想的递归下降解析器,这是一个根本性的障碍。
有趣的是,这并非一个普遍存在的问题。另一种类型的解析器,即“自底向上”解析器,则不会遇到这个问题。它的工作方式是从输入标记开始逐步构建结构,所以它会愉快地识别出一个 Term,然后是一个 Expression,接着看到一个 + 和另一个 Term,然后说:“啊哈!我可以把这个 Expression 和 Term 组合成一个更大的 Expression。”但我们的目标是拯救我们直观的自顶向下故事讲述者,而不是替换它。为此,我们不能改变故事讲述者;我们必须巧妙地重写它的文法书。
解决左递归的方法是一套优美的智力体操。我们取其自我引用的规则并重新表述它。我们不再说“一个表达式是一个表达式加一个项”,而是说“一个表达式以一个必需的项开始,后跟一个由零个或多个 + Term 片段组成的链条”。
把它想象成一列火车。旧规则试图通过说“一列火车是一列火车后面跟着一节车厢”来定义火车,这是一个逻辑循环。新规则说:“一列火车是一个火车头,后面跟着一个可选的车厢序列。”火车头是必需的,这为我们提供了一个具体的起点。
形式上,我们取一个像这样的文法: 并将其转换为一个等价的文法:
我们花了一些时间来了解左递归,这个在我们文法规则中看似奇特的循环,其中一个事物在其定义的最左边由其自身来定义。初看之下,它可能像一个缺陷,一个在开始任何严肃工作之前需要被解开和“消除”的逻辑疙瘩。但在科学和工程领域,我们经常发现,最初被标记为“问题”的现象,实际上是通往更深层次理解的钥匙,或者在适当的情境下,是出人意料的优雅解决方案。
左递归也是如此。现在,让我们踏上一段旅程,去看看这个所谓的缺陷在哪些地方根本不是缺陷,而是一种强大、优美且极其有用的工具,它将抽象的文法世界与具体的计算任务联系起来。
你如何计算 ?你几乎肯定会从左到右计算:首先 得 ,然后 得 。这种属性,即左结合性,已深深植根于我们对算术的理解中。如果一个编译器要将人类可读的代码翻译成机器可执行的指令,它必须尊重这一约定。我们如何教一台机器这种从左到右的特性呢?
一种方法是编写一个体现这种特性的文法。像 这样的左递归文法完美地做到了这一点。文法本身的结构反映了计算的结构。要解析像 w * x * y * z 这样的表达式,文法会自然地将其分组为 (((w * x) * y) * z)。
这不仅仅是一种美学上的匹配;它为生成代码提供了一个直接的蓝图。想象一个简单的基于栈的计算器。要计算 ,你将 推入栈,将 推入栈,然后执行一条 MUL 指令,该指令弹出它们,将它们相乘,并将结果推回栈中。左递归文法可以用来极其优雅地生成这一系列指令。规则 转化为一个简单的配方:首先,为子表达式 生成代码(这会将其结果留在栈上),然后为 生成代码(这会将其结果放在第一个结果之上),最后,追加 MUL 指令。这个过程,被称为语法指导的翻译,展示了左递归最光辉的时刻:不是一个需要解决的问题,而是一个将文法结构直接、直观地转化为计算行动的指南。
正如我们所见,一些解析方法——特别是那些试图从根向下构建解析树的自顶向下解析器——会在左递归上“窒息”,进入无限循环。对它们而言,我们必须进行转换,将像 这样的规则变成一组非左递归的规则,例如 和 。这巧妙地解决了自顶向下解析器的无限循环问题。
但这种转换是“免费的午餐”吗?如果我们把这个新的、“修复”过的文法交给一个自底向上解析器会发生什么?这种解析器从叶子向上构建树,通常对左递归没有问题。
为了找出答案,我们可以看看自底向上解析器的“大脑”:一个名为 LR 自动机的状态机。这个机器中的每个状态代表了解析器可能正在识别的一组模式。状态的总数是解析器复杂度的粗略度量。当我们进行这个实验——为原始的左递归文法和转换后的文法构建自动机时——一个显著的结果出现了。转换后的文法,即为自顶向下解析器“修复”过的那个,通常会生成一个具有更多状态的自动机。
消除左递归的行为引入了新的规则和空()产生式,这迫使自底向上自动机需要跟踪更多的可能性,从而使其复杂度膨胀。这是一个关于权衡的深刻教训。“解决方案”在一个领域中,却在另一个领域中引起了新的复杂性。它教导我们,文法的优雅并非绝对属性,而是相对于我们用来解释它的工具而言。
即使对于能够适应左递归的自底向上解析器,它仍然可能带来有趣的挑战,推动我们走向更复杂的设计。考虑一个典型的算术文法:。想象一个自底向上解析器刚刚成功地将一串标记识别为一个有效的 。根据文法,这个 也可能是一个有效的 (通过 )。所以现在解析器处于一种模棱两可的状态。假设输入中的下一个符号是 * 号。解析器知道它不能在一个 后面附加一个 *。但也许它刚刚找到的 是一个更大的乘法的一部分,比如 。
这就是“移入-归约冲突”的核心。解析器是应该“归约”它找到的 成为一个 ,从而完成一条规则?还是应该“移入”即将到来的 * 号到它的栈上,希望能构建一个更大的表达式?一个简单的 解析器,它只看当前状态,无法做出决定。它看到了两条有效的前进路径,因而陷入瘫痪。这不是文法的失败,而是解析器的局限性。文法通过其左递归,优美地揭示了需要更强智能的必要性。解决方案是赋予解析器“前瞻”输入流中下一个标记的能力——这种能力称为前瞻。正是这种源于左递归结构的冲突,是开发更强大的解析器如 SLR、LALR 和 LR(1) 的主要动机,这些解析器是现代编译器构造的主力军。
到目前为止,我们一直专注于使用文法来识别输入的结构。但解析也是通往理解其意义的门户。这就是属性文法的领域,我们在这里为我们的文法规则附加计算。
想象一个简单的用于逗号分隔列表的左递归文法:。现在,假设我们不仅要验证列表,还想计算每个逗号的确切字符位置。要找到第三个项目后的逗号的位置,我们需要知道前三个项目的总长度加上前面两个逗号的长度。这个信息必须在我们处理列表时从左到右流动。
在这里,左递归结构是这种计算的完美伙伴。当解析器构建向下向左生长的树时,我们可以使用“继承属性”沿着树的左侧主干向下传递信息。在每个对应于规则 的节点上,我们可以继承到目前为止整个列表的起始位置。一旦子列表 被完全处理,它就可以计算自己的长度,并将该信息作为“综合属性”向上传递。有了起始位置和子列表的长度,计算逗号的位置就变得轻而易举。这种信息在解析树上向下流动然后向上传递的优雅舞蹈,正是由文法的左递归结构所促成的。文法形式和计算流程之间的这种协同作用是语法指导设计的基石。
我们已经看到,左递归可以是朋友也可以是敌人,这取决于解析策略。这就引出了一个问题:我们能否发明一种强大到不在乎这些的解析器?一个能接受我们能想到的任何上下文无关文法的解析器,无论是左递归、歧义性还是其他问题?
答案是响亮的“是”。像 Earley 解析器和 GLR(广义 LR)这样的算法正是如此。这些算法诞生于处理自然语言(NLP)这一困难而混乱的领域,人类语言以其臭名昭著的歧义性和对简单语法限制的抗拒而闻名,这些解析器是解析领域的通用机器。它们使用巧妙的动态规划技术(通常称为图表解析)来同时探索所有可能的解析路径,巧妙地避开了自顶向下解析器的无限循环和确定性自底向上解析器的冲突。
一个 Earley 解析器只是从容地处理左递归规则,将适当的项目添加到其图表中,然后继续前进,其记忆化方案保护它免受无限循环的影响。如果一个文法有歧义,它不会失败;它会勤奋地生成所有可能解析树的紧凑表示。
当然,这种巨大的能力和灵活性是有代价的:性能。对于定义大多数编程语言的那些行为良好、确定性的文法,LALR(1) 解析器生成器速度极快。而 Earley 解析器在最坏情况下的速度则较慢。在这里,我们发现了计算机科学的一个宏大、统一的原则:通用性与效率之间的权衡。
因此,左递归不仅仅是编译器构造中的一个技术细节。它是一个镜头,通过它我们可以审视这一根本性的权衡。它教导我们要明智地选择我们的工具:为可预测的、工程化的环境选择专门化的高速工具,为人类语言的狂野和不可预测的领域或新思想的快速原型设计选择稳健的通用工具。它是一座桥梁,连接着编程语言的形式世界和语言学的复杂世界,提醒我们,即使在机器的精确逻辑中,也存在着不同视角、策略和设计哲学的空间。