当前位置: 首页 > news >正文

自己动手开发编译器(七)递归下降的语法分析器

回我们说到语法分析使用的上下文无关语言,以及描述上下文无关文法的产生式、产生式推导和语法分析树等概念。今天我们就来讨论实际编写语法分析器的方法。今天介绍的这种方法叫做递归下降(recursive descent)法,这是一种适合手写语法编译器的方法,且非常简单。递归下降法对语言所用的文法有一些限制,但递归下降是现阶段主流的语法分析方法,因为它可以由开发人员高度控制,在提供错误信息方面也很有优势。就连微软C#官方的编译器也是手写而成的递归下降语法分析器。

使用递归下降法编写语法分析器无需任何类库,编写简单的分析器时甚至连前面学习的词法分析库都无需使用。我们来看一个例子:现在有一种表示二叉树的字符串表达式,它的文法是:

N →a (N,N)
N →ε

其中终结符a表示任意一个英文字母,ε表示空。这个文法的含义是,二叉树的节点要么是空,要么是一个字母开头,并带有一对括号,括号中逗号左边是这个节点的左儿子,逗号右边是这个节点的右儿子。例如字符串A(B(,C(,)),D(,))就表示这样一棵二叉树:

注意,文法规定节点即使没有儿子(儿子是空),括号和逗号也是不可省略的,所以只有一个节点的话也要写成A(,)。现在我们要写一个解析器,输入这种字符串,然后在内存中建立起这棵二叉树。其中内存中的二叉树是用下面这样的类来表示的:

<span style="color:#000000"><span style="color:#0000ff">class </span><span style="color:#2b91af">Node </span>{ <span style="color:#0000ff">public </span><span style="color:#2b91af">Node </span>LeftChild { <span style="color:#0000ff">get</span>; <span style="color:#0000ff">private set</span>; } <span style="color:#0000ff">public </span><span style="color:#2b91af">Node </span>RightChild { <span style="color:#0000ff">get</span>; <span style="color:#0000ff">private set</span>; } <span style="color:#0000ff">public char </span>Label { <span style="color:#0000ff">get</span>; <span style="color:#0000ff">private set</span>; } <span style="color:#0000ff">public </span>Node(<span style="color:#0000ff">char </span>label, <span style="color:#2b91af">Node </span>left, <span style="color:#2b91af">Node </span>right) { Label = label; LeftChild = left; RightChild = right; } } </span>

这是一道微软面试题,曾经难倒了不少参加面试的候选人。不知在座各位是否对写出这段程序有信心呢?不少参选者想到了要用栈,或者用递归,去寻找逗号的位置将字符串拆解开来等等方法。但是若是使用递归下降法,这个程序写起来非常容易。我们来看看编写递归下降语法分析器的一般步骤:

  1. 使用一个索引来记录当前扫描的位置。通常将它做成一个整数字段。
  2. 为每个非终结符编写一个方法。
  3. 如果一个非终结符有超过一个的产生式,则在这个方法中对采用哪个产生式进行分支预测
  4. 处理单一产生式时,遇到正确终结符则将第一步创建的扫描索引位置向前移动;如遇到非终结符则调用第二步中创建的相应方法。
  5. 如果需要产生解析的结果(比如本例中的二叉树),在方法返回之前将它构造出来。

我们马上来试验一下。首先建立一个类,然后存放一个索引变量来保存当前扫描位置。然后要为每一个非终结符创建一个方法,我们的文法中只有一个非终结符N,所以只需创建一个方法:

<span style="color:#000000"><span style="color:#0000ff">class </span><span style="color:#2b91af">BinaryTreeParser </span>{ <span style="color:#0000ff">private string </span>m_inputString; <span style="color:#0000ff">private int </span>m_index; <span style="color:#008000">//初始化输入字符串和索引的构造函数,略 </span><span style="color:#2b91af">Node </span>ParseNode() { } } </span>

回到刚才的产生式,我们看到非终结符N有两个产生式,所以在ParseNode方法的一开始我们必须做出分支预测。分支预测的方法是超前查看(look ahead)。就是说我们先“偷窥”当前位置前方的字符,然后判断应该用哪个产生式继续分析。非终结符N的两个产生式其中一个会产生a(N, N)这个的结构,而另一个则直接产生空字符串。那现在知道,起码有一种可能就是会遇到一个字母,这时候应该采用N → a(N, N)这个产生式继续分析。那么什么时候应该采用N → ε进行分析呢?我们观察产生式右侧所有出现N的地方,倘若N是空字符串,那么N后面的字符就会直接出现,也就是逗号和右括号。于是这就是我们的分支预测:

  1. 如果超前查看遇到英文字母,预测分支N → a(N, N)
  2. 如果超前查看遇到逗号、右括号预测分支N → ε

转化成代码就是这样:

<span style="color:#000000"><span style="color:#2b91af">Node </span>ParseNode() { <span style="color:#0000ff">int </span>lookAheadIndex = m_index; <span style="color:#0000ff">char </span>lookAheadChar = m_inputString[lookAheadIndex]; <span style="color:#0000ff">if </span>(<span style="color:#2b91af">Char</span>.IsLetter(lookAheadChar)) { <span style="color:#008000">//采用N → a(N, N)继续分析 </span>} <span style="color:#0000ff">else if </span>(lookAheadChar == <span style="color:#a31515">',' </span>|| lookAheadChar == <span style="color:#a31515">')' </span>) { <span style="color:#008000">//采用N → ε继续分析 </span>} <span style="color:#0000ff">else </span>{ <span style="color:#0000ff">throw new </span><span style="color:#2b91af">Exception</span>(<span style="color:#a31515">"语法错误"</span>); } } </span>

接下来我们分别来看两个分支怎么处理。先来看N → ε,这种情况下非终结符是个空字符串,所以我们不需要移动当前索引,直接返回null表示空节点。再来看N → a(N, N) 分支,倘若输入的字符串没有任何语法错误,那就应该依次遇到字母、左括号、N、逗号、N右括号。根据上面的规则,凡是遇到终结符,就移动当前索引,直接向前扫描;而要是遇到非终结符,就递归调用相应节点的方法。所以(不考虑语法错误)的完整方法代码如下:

<span style="color:#000000"><span style="color:#2b91af">Node </span>ParseNode() { <span style="color:#0000ff">int </span>lookAheadIndex = m_index; <span style="color:#0000ff">char </span>lookAheadChar = m_inputString[lookAheadIndex]; <span style="color:#0000ff">if </span>(<span style="color:#2b91af">Char</span>.IsLetter(lookAheadChar)) { <span style="color:#008000">//采用N → a(N, N)继续分析 </span><span style="color:#0000ff">char </span>label = m_inputString[m_index++]; <span style="color:#008000">//解析字母 </span>m_index++; <span style="color:#008000">//解析左括号,因为不需要使用它的值,所以直接跳过 </span><span style="color:#2b91af">Node </span>left = ParseNode(); <span style="color:#008000">//非终结符N,递归调用 </span>m_index++; <span style="color:#008000">//解析逗号,跳过 </span><span style="color:#2b91af">Node </span>right = ParseNode(); <span style="color:#008000">//非终结符N,递归调用 </span>m_index++; <span style="color:#008000">//解析右括号,跳过 </span><span style="color:#0000ff">return new </span><span style="color:#2b91af">Node</span>(label, left, right); } <span style="color:#0000ff">else if </span>(lookAheadChar == <span style="color:#a31515">',' </span>|| lookAheadChar == <span style="color:#a31515">')'</span>) { <span style="color:#008000">//采用N → ε继续分析 //无需消耗输入字符,直接返回null </span><span style="color:#0000ff">return null</span>; } <span style="color:#0000ff">else </span>{ <span style="color:#0000ff">throw new </span><span style="color:#2b91af">Exception</span>(<span style="color:#a31515">"语法错误"</span>); } } </span>

因为存在语法约束,所以一旦我们完成了分支预测,就能清楚地知道下一个字符或非终结符一定是什么,无需再进行任何判断(除非要进行语法错误检查)。因此根本就不需要寻找逗号在什么位置,我们解析到逗号时,逗号一定就在那,这种感觉是不是很棒?只需要寥寥几行代码就已经写出了一个完整的Parser。大家感兴趣可以继续补全一些辅助代码,然后用真正的字符串输入试验一下,是否工作正常。前面假设输入字符串的语法是正确的,但真实世界的程序总会写错,所以编译器需要能够帮助检查语法错误。在上述程序中加入语法错误检查非常容易,只要验证每个位置的字符,是否真的等于产生式中规定的终结符就可以了。这就留给大家做个练习吧。

上面我们采用的分支预测法是“人肉观察法”,编译原理书里一般都有一些计算FIRST集合或FOLLOW集合的算法,可以算出一个产生式可能开头的字符,这样就可以用自动的方法写出分支预测,从而实现递归下降语法分析器的自动化生成。ANTLR就是用这种原理实现的一个著名工具。有兴趣的同学可以去看编译原理书。其实我觉得“人肉观察法”在实践中并不困难,因为编程语言的文法都特别有规律,而且我们天天用编程语言写代码,都很有经验了。

下面我们要研究一下递归下降法对文法有什么限制。首先,我们必须要通过超前查看进行分支预测。支持递归下降的文法,必须能通过从左往右超前查看k个字符决定采用哪一个产生式。我们把这样的文法称作LL(k)文法。这个名字中第一个L表示从左往右扫描字符串,这一点可以从我们的index变量从0开始递增的特性看出来;而第二个L表示最左推导,想必大家还记得上一篇介绍的最左推导的例子。大家可以用调试器跟踪一遍递归下降语法分析器的分析过程,就能很容易地感受到它的确是最左推导的(总是先展开当前句型最左边的非终结符)。最后括号中的k表示需要超前查看k个字符。如果在每个非终结符的解析方法开头超前查看k个字符不能决定采用哪个产生式,那这个文法就不能用递归下降的方法来解析。比如下面的文法:

F → id
F → ( E )
E → F * F
E → F / F

当我们编写非终结符E的解析方法时,需要在两个E产生式中进行分支预测。然而两个E产生式都以F开头,而且F本身又可能是任意长的表达式,无论超前查看多少字符,都无法判定到底应该用乘号的产生式还是除号的产生式。遇到这种情况,我们可以用提取左公因式的方法,将它转化为LL(k)的文法:

F → id
F → ( E )
G → * F
G → / F

E → FG

我们将一个左公因式F提取出来,然后将剩下的部分做成一个新的产生式G。在解析G的时候,很容易进行分支预测。而解析E的时候则无需再进行分支预测了。在实践中,提取左公因式不仅可以将文法转化为LL(k)型,还能有助于减少重复的解析,提高性能。

http://www.jsqmd.com/news/1125913/

相关文章:

  • 3个核心优势解析:G-Helper如何成为华硕笔记本用户的轻量化性能管理方案
  • 中小企业选 SaaS 定制开发公司,这几个坑我踩过
  • 绝区零一条龙:全自动游戏助手完整指南,解放你的双手!
  • 【OpenHarmony/HarmonyOs 】零敏感权限启动:从 module 配置到 AI 识图禁用的精细化权限方案
  • GBFR-Logs终极指南:从零开始掌握《碧蓝幻想:Relink》伤害统计
  • 企业内网集成Twitter RSS的实战指南:基于办公室的信息流治理
  • 网络日志自动化分析实战:OpenClaw 清洗访问日志、定位异常攻击、生成安全报表
  • 【域攻防】⼯作组内信息收集
  • 数据库设计Step by Step (7)——概念数据建模
  • ICT vs Flying Probe: Which PCB Test Method Actually Reduces Manufacturing Risk?
  • 金蝶AI套件在汽车零部件ERP的5个解法:VMI寄售、滚动计划、批次追溯、ECN管控、模具摊销
  • 2000-2025年全国逐年NDVI栅格数据:基于MODIS MOD13A3的年均值处理方法与数据详解
  • C语言内存管理——内存对齐与共用体union
  • 5分钟掌握ExtDiff:终极免费的Word文档差异比较工具
  • 如何快速配置文件备份工具:ChoEazyCopy 完整教程
  • Win11Debloat终极指南:3分钟让Windows系统性能提升50%的完整教程
  • 鹤壁婚宴宴席,备酒水不浪费又体面
  • 3步掌握高效窗口管理:DockDoor终极工作流优化指南
  • Windows运维体验AMD AI云:领取算力到跑通PyTorch
  • 对象存储的适用场景
  • 公寓管理系统选型趋势:门店经营正在进入总部视角
  • OpenCompass大模型评测实战:从原理到应用
  • 客户进厂考察,3 个细节决定是否下单
  • 售后负责人视角抖店售后工具怎么选重点看退货地址和补发记录
  • 企业财税风控实操指南:从“裸奔”到“合规”的架构设计与数据排查
  • 破解城镇化与生态健康耦合难题、迈向精细化人地关系研究:基于GIS、RS、VORS模型、CCDM模型geodetecto集成的生态系统健康的耦合协调分析
  • 企业级 AI 落地的一个现实转向:为什么开始强调复制专家判断,而不是放大 Agent 自主权
  • Cantian connector for MySQL高可用性设计:故障快速恢复机制详解
  • LLM 学习笔记 Day 5:Agent 核心组件——Planner、Memory 与 Reflection
  • AI Agent 入门实战:用 Function Calling 让大模型学会调用工具