一、 基本概念
语法分析作为编译过程的第二步,由语法分析器(Parser)实现。其核心功能是验证用户输入的代码是否符合目标语言的语法规则,接收词法分析器输出的词法单元序列作为输入。语法分析器采用流式处理方式,逐词法单元进行分析,直至完成全部处理或遇错误中断。分析过程中会构建语法分析树(Parse Tree),其输出结果通常为中间表示形式(如抽象语法树),对于结构简单的语言,也可能直接输出运行结果。
笔者在此引入了两个重要概念:语法分析树与抽象语法树。在深入解析前,需先明确语法分析器的理论依据——文法(Grammar)。文法是描述语言层次结构和语法规则的形式化文本,定义了合法句子的构成规则。语法分析器本质上是文法的具体实现,通过算法验证输入代码是否符合文法规则。
文法的表示形式并无强制标准,业界通常采用巴科斯范式-诺尔(Backus-Naur Form,BNF)进行语法描述。需注意,BNF本身是一种表示体系而非严格标准,任何遵循其基本约束的文法均可视为标准BNF。若使用语法分析器生成器,则需遵循特定工具的文法格式要求。尽管不同工具的格式存在细节差异,但核心思想一致,掌握一种后可快速迁移至其他工具。
描述编程语言结构时,上下文无关文法(Context-Free Grammar,CFG)是最常用的形式化方法。其具有简洁、灵活的特点,既便于人类理解,也易于程序实现,广泛应用于编译原理和自然语言处理领域。读者现阶段仅需形成初步认知,后续章节将给出严格定义并展开详细讨论。
文法2-1展示了Java中的变量声明语句“int x;”(注:我们只考虑简单的声明,不考虑值的初始化)所对应的文法:
文法2-1
DECLARE -> TYPE identity ';'
TYPE -> 'int' | 'float' | 'double'
……
上述文法示例包含了两行文本,每一行都描述了一条语法规则。这样的规则有个更专业的名称:产生式(Production)。箭头(->)是一个标识,表示其左边的内容可以由右边的内容推导出来。箭头左边的内容称为产生式左部;右边的则为产生式右部,右部中的所有元素都可统称为符号。产生式之间是有先后顺序的,或者准确地说:第一条产生式所表示的应是语法规则的起点(本例中是DECLARE),其顺序不可以乱;而对于其他的产生式,虽然没有特别的要求,但仍建议保持良好的推导顺序,即按符号的出现先后顺序进行编写,以方便阅读。至于产生式的内部,尤其是右部中的符号,各元素之间的顺序隐含了语法规则,不可以随意调换位置。
以Java非静态类的语法规则为例,它的前两条产生式可能为文法2-2所示的样子(注:如下示例仅用于展示,真实的Java语法并不会如此简单;另,符号#表示注释,属个人习惯),请读者重点关注一下产生式的顺序。
文法2-2
CLASS -> PACKAGE IMPORT* CLASS_DES #包信息、导入信息、类描述信息
CLASS_DES -> MODIFIER 'class' BLOCK #类修饰符、类关键、类的代码块
MODIFIER -> ……
……
对于语法规则的解释,有点像数学中的变量替换。以文法2-1为例,第一条产生式转换成文字描述的话大概是这样的:声明标识符的语句必须包含标识符类型、标识符名称两个元素并以分号(;)作为句子的结尾(注:产生式之间一般通过换行或不加引号的分号进行分隔)。那么标识符类型又该如何解释呢?通过第二个产生式可知,类型值只能是int、float、double三个字符串中的一个(|符号表示“或者”)。可以看到,第一条产生式所定义的其实只是语法的结构,只有把其中的符号进行替换才能得到最终的解释。而替换的过程,实际上就相当于变量的替换的过程。因此,对于上下文无关文法而言,无论结构多么复杂,我们都可以使用这样一种类似于数学公式的方式来将其规则表达出来。
二、语法分析树
了解过文法的大致含义之后,让我们再看一下语法分析树的含义。语法分析树本质上是一种图形表示法,正如其名字所示,它以树型图的形式描述了语法分析的过程,或者说其展示了产生式的应用过程。语法分析树会随着语法分析工作的开展而被隐式地建立出来,其包含了所有的输入信息,主要用于帮助编译器理解程序代码的结构,并将其转换为其他中间形式或语义模型。想要真正地理解语法分析树,我们应该首先去了解什么是语法分析。实际上,它所做的事情很简单,主要检查用户输入的代码文本能否根据文法规则推导出来。仍以声明语句“int x;”为例,结合文法2-1,笔者首先以文字的方式描述其推导过程:
- 输入到语法分析器中的第一个Token是int。(注意:推导要从第一个产生式开始)。
- 尽管第一条产生式右部的第一个符号是TYPE而非int,但通过第二个产生式,可发现int是TYPE的可选值之一,匹配成功,输入代码语法正确。
- 第二个Token是变量x,根据第一条产生式的要求(注意:当前步骤中,第一条产生式的分析工作尚未完成,前两步仅仅是完成了第一条产生式中的第一个符号的分析),TYPE后面的符号应是identity,和输入Token的类型匹配,输入代码语法正确。
- 第三个Token是分界符分号(;),第一条产生式identity部分最后的符号也是分号,输入正确。
此时,第一条产生式右部的所有符号已经匹配完毕,同时也没有新的Token再进来,说明用户输入的代码符合当前语法要求。请读者再思考一下,如果用户的输入是这样的:“int x”,会有什么结果?很明显,少了一个分号,与语法要求不匹配,语法分析器应报错才对。
通过案例可知,当文法起始符号对应的产生式右部完全推导完成时,语法分析即宣告结束。若此时仍有剩余词法单元未被处理,或推导过程中出现无法匹配文法规则的情况,则表明输入代码存在语法错误(此处仅讨论最简情形,实际语法分析可能涉及回溯机制——当文法的不确定性导致产生式选择错误时,需回退并尝试其他产生式,后文将详述该机制)。因此,语法分析的核心流程可归纳为以下三步骤:
- 将用户输入的代码解析成Token序列。
- 以文法中的第一个产生式为起点进行推导,判断输入的内容可否根据文法规则推导出来。
- 循环第二步,直至所有的Token推导完毕或在无法匹配时做中断处理。
回到具体案例,对于语句“int x;”,若以图形化方式展示其语法分析流程,树型结构是典型选择,即语法分析树,如图 2.5所示。尽管还有其它的选择,但树结构能够清晰呈现该语句基于文法规则的推导过程。
有关树的特性,笔者并不想使用过多的笔墨进行说明,如果您的专业是计算机相关学科的话,相信对这一数据结构应该是非常了解的。笔者在此只想进行两点说明:
- 节点的构建顺序。语法分析树兄弟节点之间有着严格的要求,必须按从左至右的方式进行排列,它们代表的是产生式右部符号的序列。
- 应采用深度优先的方式进行树的构建和遍历。
为方便读者理解,笔者在图 2.5中加入了和语法分析流程(即文字版说明)描述一致的序号信息。实际上,语法分析树更多的是一种形式上的表达,代表着语法分析的顺序,是给人去看的,一般来说并不会真的在内存中建立这样一棵树。甚至于我们可以这么去理解:语法分析过程和语法分析树的构建过程所描述的是同一事物,只是表现形式不同。或者再技术化一点:如果针对每个左部符号都有对应的方法(函数)的话,语法分析树中的非叶子节点表示的就是方法的调用,而叶子节点所表示的则是不可分解或不可再推导的符号。按此描述的话,图 2.5所对应的代码可表示为代码2-3所示的方式:
代码2-3
declare() {
type();
...
}
值得注意的是,上述说法以及代码2-3主要针对得是自顶向下语法分析方式,您可参考后续文章来了解更多的细节。
三、抽象语法树
解释过语法分析树之后,接下来我们需要进一步探讨一下抽象语法树的概念。其与语法分析树的核心差异在于:抽象语法树是内存中实际存在的数据结构,而语法分析树是语法分析过程的图形化表示。此外,抽象语法树更轻量,仅保留具有语义含义的内容,更适用于语义模型构建、代码生成等后续阶段。若对此存疑,可回顾图 2.5并思考:树中TYPE、分号等节点对语法分析是必需的,但在分析完成后,其是否仍有存在价值?显然,这类节点不承载语义信息,若纳入抽象语法树会徒增空间消耗。尽管当前硬件配置下空间问题可忽略,但过多无关节点会增加树遍历的复杂度。
因此,抽象语法树可视为语法分析树的修剪版本,通过移除无关节点并合并同类节点形成。以图 2.5为例,其对应的抽象语法树如图 2.6所示。可以看到,修剪后的AST仅保留有效节点Identity,其整合了变量名及类型等语义信息,分号节点则因无实际语义被移除。至于Root节点,仅仅是为了作为遍历操作的起点而被引入的,其中并没有包含任何语义相关的信息。
让我们再看一个相对复杂的例子:赋值语句,如代码2-4所示:
代码2-4
x = 1 + 1;
上述代码所对应的抽象语法树如图 2.7所示。父节点Assign代表“赋值语句”这个语言结构,其包含了两个子节点:左边的Identity表示变量x;右边的Expression表示“1+1”。同Assign,Expression也是一个语言结构,代表了“表达式”,其也有自己的子节点。可以想象,如果将1+1换成更复杂的表达式的话,比如(1+1)-2*3,Expression子树的深度也将会变得更高。
抽象语法树与语法分析树在结构上具有相似性,常导致概念混淆。尽管二者均为树状结构,但语义内涵存在本质差异:
- 抽象语法树的非叶子节点通常代表程序构造(如if、while、赋值运算符=、算术运算符+/-等),聚焦代码的语义逻辑。
- 语法分析树的非叶子节点对应语法结构,反映输入代码在语法分析过程中识别出的短语或子结构,侧重语法规则的推导过程。
需注意,两类树的结构并非必然为二叉树,其形态由目标语言的语法规则和程序结构决定,同时需兼顾后续遍历操作的便利性。
在术语使用上,笔者习惯将抽象语法树简称为“语法树”(后文统一采用此简称),而语法分析树名称保持不变。由于不同文献对两类树的命名可能存在差异,建议读者在阅读时留意作者的相关声明,避免概念混淆。
四、语法分析算法
语法分析算法可分为自顶向下(Top-Down)和自底向上(Bottom-Up)两类。自顶向下分析以文法的开始符号为根节点,递归展开非终结符,逐步构建语法分析树(如图2.5所示);自底向上分析则从输入符号串(叶子节点)开始,通过归约操作逐步合并子树,直至构造出根节点。相较而言,自顶向下分析更符合人类思维习惯,具有直观、易实现的特点,适合手工编写语法分析器;自底向上分析则更为复杂,但其优势在于能高效处理包含左递归的文法,且性能更优,因此广泛应用于语法分析器生成工具(如Yacc/Bison)。关于二者之间的详细区别,读者可参看表 2.1。
特性 | 自顶向下 | 自底向上 |
语法分析树构建方式 | 自根节点向下 | 从叶子节点向上 |
文法推导方式 | 推导 | 归约 |
算法 | 递归下降,LL(k) | LR(k),移进-归约 |
左递归 | 难以处理 | 可以处理 |
实现复杂度 | 简单 | 复杂 |
运行效率 | 取决于具体算法 | 较高 |
两种分析策略的核心差异可归纳如下:
- 自顶向下分析。需预先消除文法中的左递归,否则会导致无限循环。修改后的无左递归文法可读性较差,增加了人工维护的难度。
- 自底向上分析。天然支持左递归文法,无需进行文法转换,且能在归约过程中自动处理算符优先级和结合性。
选择策略建议:若手工实现语法分析器,推荐采用自顶向下方法(如递归下降分析法);若使用生成工具,则无需关注具体实现细节,工具会自动处理文法转换和优化。值得注意的是,现代编译器框架(如ANTLR)通过内部优化技术,已显著降低了用户对文法形式的关注度。
前面的内容当中,笔者数次提到了一个概念:左递归,那么它到底是什么意思呢?请考虑文法2-3:
文法2-3
P -> Pa
P -> b
在推导第一个产生式右部符号P的时候,推导出的结果可能是另一个以P作为前缀的产生式(即总是使用第一个产生式来替换右部符号P),并且这样的推导会以循环的方式展开,结果如下所示:
P -> Paa...a
之所以出现上述问题,根源在于产生式右部的第一个符号和左部符号出现了相同(即文法2-3中的第一个产生)的情况,这便是所谓的“左递归”。
尽管正式的文法中不太可能让推导过程出现死循环的情况,但对于使用了自顶向下算法的语法分析器而言,技术上解决不了左递归的问题。前文中我们曾说过,读者可以将产生式推导的过程想象成函数的调用过程。对于文法2-3中的第一个产生式,我们会建立一个与其左部同名的函数p()。实现该函数的时候,根据右部的定义,首先会有一个p()函数的调用,之后再是对符号a的处理。如果将对a符号的处理也包装成函数的话,那么p()函数的定义将为代码 2-5所示的形式:
代码 2-5
void p() {
p();
a();
}
上述代码中,对于p()函数的调用没有任何条件。很明显,这样的代码会陷入死循环之中,而这便是左递归所引发的问题。实践中,一般会把存在左递归的文法变成非左递归的,具体公式笔者会在后面进行详细说明。另外便是关于代码2-5的实现思路,读者也许会好奇为什么要在p()函数之中再调用一次自已。实际上,这是一种自顶向下语法分析算法的实现模式,后文会对此做进一步的解释。
五、符号表
结束本小节之前,笔者需要再对符号表相关的内容做一下补充。前文已经说过,语法分析过程中会建立一棵虚拟的语法分析树,它是随着函数的调用在调用栈中形成的。这样的运作方式出现了一个问题:历史数据不可见。以图 2.8所示的语法树为例,假如在分析左子树的时候产生了数据x,该数据会在分析右子树时使用到。很明显,我们可以通过遍历左子树的方式来获取到该值。很可惜,由于语法分析树不是物理存在的,所以当我们在操作右子树的时候左子树已经消失了,自然也就无法再获取到x的信息。
图 2.8 解析右子树节点Right的时候,无法读取到左子树中节点x的值
针对上述情况,可以通过使用符号表的方式来解决。简单来说,随着树的构建,您可以将需要的数据放到符号表中进行暂存,需要的时候只需要再从表中取出来即可,这样便达到了数据共享的目的。无论是外部DSL还是内部DSL,符号表都可以被使用到,但它却并不是必需的。不过对于通用编程语言如C或Java而言,符号表在编译器中则扮演着非常重要的角色。比如“标识符作用域”概念的实现,便需要使用到符号表。关于这一方面的内容,建议读者找一些专门的材料进行了解。DSL毕竟还是太简单了,虽然后面的案例中也会使用到符号表,但肯定达不到通用编程语言的那种复杂度。
初步介绍过语法分析相关概念之后,按顺序的话应该对语义模型和语义模型构建器进行说明,可是笔者已经在前面的内容当中花了不少的笔墨在它们上面,与其将大段的理论摆在您的面前,不如在后续讲解案例时再对它们做更详细的介绍。所以,让我们先暂时略过这一方面的内容,首先介绍代码生成相关的知识,请读者移步下一章。