JavaCC、解析树和 XQuery 语法
2006-11-08 09:06
483 查看
第 1 部分
使用 BNF 和 JavaCC 构建定制的解析器
在简要讨论了语法、解析器和 BNF 后,本文将介绍 JavaCC,这是一个流行的解析器生成器工具。您将开发使用 JavaCC 的样本代码来构建定制的解析器,先从语法的 BNF 描述开始。第 2 部分接着将演示如何使用辅助工具 ― JJTree 来构建同一解析的解析树表示,以及如何在运行时遍历该树,以发现其状态信息。文章将以开发构建和遍历解析树的样本代码作为结束,该解析树是您为一小部分 XQuery 语法生成的。
要完成最简单的日常解析任务,您不需要使用象自动化解析器生成器那样复杂的任何东西。例如,同“梳理”CSV(逗号分割的值,Comma-Separated-Value)文件的各部分同样简单的编程练习需要了解文件的结构,可能还需要了解如何使用 Java
但是,一旦正式描述它的某种语言和语法变得复杂,那么语言中的有效表达式数量将迅速增加。而能够手工处理将任意表达式解析成其组成部分(这或多或少是解析更简明的定义)所需的代码将变得越来越困难。自动化解析器生成器减轻了这种困难。其他程序员或许也可以将您生成的解析器用于他们自己的用途。
从 BNF 开始
复杂语言的语法通常都是使用 BNF(巴科斯-诺尔范式,Backus-Naur Form)表示法或者其“近亲”― EBNF(扩展的 BNF)描述的。自动化工具可以使用那些描述(我将使用通用的术语 BNF来指代这两种变体)或与它们近似的描述来为您生成解析代码。本文就描述了这样的一种解析器-生成器工具,称为 JavaCC。我将简要地研究一下 JavaCC 的基本知识,并在结束的时候花些时间研究一下它的一个辅助工具 ― JJTree,但是在讨论中不会介绍太多的理论知识,以免偏离主题。本文力图阐明我的理念:您并不需要了解很多有关正规的解析理论就能进行解析!
为什么使用 JavaCC 呢?有几个原因:我对 XQuery 有着强烈的兴趣,而 W3C 的 XML Query 工作组恰好使用 JavaCC 来构建并测试 XQuery 语法的版本,并且构建和测试它与 XSL 组共享的 XPath 语法。我还使用 JavaCC 来提供 XQEngine 中的查询-解析代码,XQEngine 是我自己的开放源码 XQuery 实现(请参阅 参考资料)。
最后一点(但不是最不重要的),价格是完全合适的:尽管 JavaCC 不是开放源码,但它是完全免费的。(请参阅 参考资料以了解有关如何获得 JavaCC 的信息)。
解析 101
在我开始介绍一些实际的 XQuery 语法之前,让我们先从一个非常简单的 BNF 开始,它描述了一种语言,该语言仅由两个只对整数进行运算的算术运算符构成。我称这种语言为
该语法中的每个规则都是一个 结果(production) ,其中左边的项(结果的名称)是依据语法中的其它结果描述的。最上面的结果
该语法描述了两个结果
如果解析器在其扫描期间发现了除这三个记号外的任何 其它记号,则认为它正在扫描的表达式是无效的。解析器的主要工作之一就是确定您传递给它的任何表达式的有效性,并且让您知道。一旦认为某个表达式是有效的,则它的第二项工作是将输入流分解成其组件块,并以某个有用的方式将它们提供给您。
从 BNF 到 JavaCC
让我们看看如何使用 JavaCC 实现该语法。JavaCC 使用称为 .jj 的文件。该文件中的语法描述是使用非常类似于 BNF 的表示法编写的,这样从一种形式转换到另一种形式通常就相当容易。(该表示法有自己的语法,从而使其在 JavaCC 中是可表达的。)
JavaCC .jj 文件语法和标准的 BNF 之间的主要区别在于:利用 JavaCC 版本,您可以在语法中嵌入操作。一旦成功遍历了语法中的那些部分,则执行这些操作。操作都是 Java 语句,它们是解析器 Java 源代码的一部分,该部分作为解析器生成过程的一部分产生。
(注:除了一条 Java
清单 1. 编码 SimpleLang 语法的完整 .jj 脚本
请注意有关该文件的下述情形:
JavaCC 语法看上去象一个过程,而结果看上去非常象进行方法调用。这并非偶然。在 JavaCC 编译这个脚本时产生的 Java 源代码包含与这些结果具有相同名称的方法;这些方法在运行时按照它们在 .jj 脚本中调用的顺序执行。我将在 遍历解析代码中为您演示那是如何工作的。(这里的术语“编译”也不是偶然的 ― 解析器生成器通常也称为“编译器的编译器”。)
花括号({ 和 }) 内描述了结果的主体,并且排除了任何您正在嵌入的 Java 操作。请注意
文件结尾的
第二个结果
遍历解析器代码
让我们非常简要地了解一下您生成的解析器的内部原理。出于下面两个原因,稍微了解由特殊的 .jj 语法生成的方法以及其在运行时的执行顺序是很有用的:
有时候(特别是当您第一次做的时候),解析器似乎返回了不同的结果,而您认为是 .jj 文件指示它这样做的。您可以在运行时单步遍历产生的解析器代码,以便查看它到底在做什么,并相应地调整语法文件。我现在还经常这样做。
如果您知道结果/方法的执行顺序,那么您将对在脚本中的什么地方嵌入 Java 操作以获得特定的结果有更好的理解。在第 2 部分谈论有关 JJTree 工具和解析树表示时,我将回过头来更详细地讨论这一内容。
尽管深入研究已生成解析器的详细信息超越了本文的范围,但是 清单 2 还是显示了为方法
清单 2. Parser_1 中生成的方法
以下高级、详尽的描述说明了这个解析器将做什么:
最上面的方法
如果记号赋予器返回的记号不是整数记号或
如果它是整数记号,表达式仍然可能是有效的,
如果记号赋予器返回加号或减号记号,则表达式仍然是有效的,
注:如果解析器失败了,则抛出
这里的 要点 是,只有当解析器成功地遍历了嵌入 Java 操作的那部分结果后,才能执行嵌入到这两个结果中的任何 Java 操作。如果将表达式“42 + 1”传递给该解析器,则语句
换而言之,第二个
JavaCC 编译过程
当您对 .jj 文件运行 JavaCC 时,它会生成许多 Java 源文件。其中一个是主解析代码 Parser_1.java,当您有一个要解析的表达式时,您将从您的应用程序调用该代码。JavaCC 还创建了其它六个由解析器使用的辅助文件。JavaCC 总共生成了以下七个 Java 文件。前三个是特定于这个特殊语法的;后四个是通用的助手类 Java 文件,无论语法是怎么样的,都会生成这几个文件。
Parser_1.java
Parser_1Constants.java
Parser_1TokenManager.java
ParseException.java
SimpleCharStream.java
Token.java
TokenMgrError.java
一旦 JavaCC 生成了这七个 Java 源文件,则可以编译它们并将它们链接到您的 Java 应用程序中。然后可以从您的应用程序代码调用新的解析器,从而将表达式传递给它进行求值。下面是一个样本应用程序,它实例化您的解析器,并且为它提供了一个硬连接在应用程序顶部的表达式。
清单 3:调用第一个解析器
这里有什么是值得注意的呢?
要调用
如果正在传递给解析器代码的表达式不能根据语法合法地构造,则将抛出
如果表达式是有效的,则执行嵌入语法各部分的任何 Java 操作,这些语法部分都被成功遍历,就象 遍历解析器代码结尾描述的一样。
结束语
这篇文章结束后还有第 2 部分。您将从类似的样本代码开始着手,学习如何使用 JavaCC 的“伙伴”工具 JJTree 来创建在运行时构建解析的解析树表示的解析器,而不是执行嵌入 .jj 脚本的操作。正如您将看到的,这有很多优点。
第 2 部分
本文的第 1 部分简要讨论了语法、解析器和 BNF。然后它介绍了 JavaCC,一个流行的解析器生成器。第 2 部分演示了如何修改第 1 部分中的样本代码,这样就可以使用附加工具 JJTree 来构建相同解析的解析树表示。您将探索这种方法的优点,并研究如何编写 Java 代码在运行时遍历该解析树以便恢复其状态信息,并对正在解析的表达式求值。本文结尾将演示如何开发通用例程,用于遍历从一小部分 XQuery 语法生成的解析树,并对其求值。
使用 JavaCC 解析器生成器有一个严重缺点:许多或大多数客户机端 Java 代码需要嵌入到 .jj 语法脚本中,该脚本编码了您的 BNF(巴科斯-诺尔范式,Backus-Naur Form)。这意味着您失去了在开发周期中合适的 Java IDE 可以向您提供的许多优点。
开始使用 JJTree 吧,它是 JavaCC 的伙伴工具。JJTree 被设置成提供一个解析器,该解析器在运行时的主要工作不是执行嵌入的 Java 操作,而是构建正在解析的表达式的独立解析树表示。这样,您就可以独立于生成该解析树的解析代码,捕捉在运行时易于遍历和查询的单个树中的解析会话的状态。使用解析树表示还会使调试变得更容易,并缩短开发时间。JJTree 是作为 JavaCC 分发版(distribution)的一部分发布的(请参阅 参考资料)。
在我们继续之前,我要特别提一下,术语 解析树和 抽象语法树(或 AST)描述了非常相似的语法结构。严格地讲,对于我在下面提到的解析树,语言理论家更精确地把它称作 AST。
要使用 JJTree,您需要能够:
创建 JJTree 作为输入获取的 .jjt 脚本
编写客户机端代码以遍历在运行时生成的解析树并对其求值
本文演示了如何执行这两种操作。它并没有涵盖所有内容,但肯定能带您入门。
JJTree 基础知识
JJTree 是一个预处理器,为特定 BNF 生成解析器只需要简单的两步:
对所谓的 .jjt 文件运行 JJTree;它会产生一个中间的 .jj 文件
用 JavaCC 编译该文件( 第 1 部分中讨论了这个过程)
幸运的是,.jjt 文件的结构只是我在第 1 部分中向您显示的 .jj 格式的较小扩展。主要区别是 JJTree 添加了一个新的语法 node-constructor构造,该构造可以让您指定在解析期间在哪里以及在什么条件下生成解析树节点。换句话说,该构造管理由解析器构造的解析树的形状和内容。
清单 1 显示了一个简单的 JavaCC .jj 脚本,它类似于您在第 1 部分中看到的脚本。为简便起见,我只显示了结果。
清单 1. simpleLang 的 JavaCC 语法
该语法说明了该语言中的合法表达式包含:
单个整数文字,或
一个整数文字,后面跟一个加号,再跟另一个整数文字。
对应的 JJTree .jjt 脚本(再次声明,略有简化)看上去可能如下:
清单 2. 等价于清单 1 中的 JavaCC 语法的 JJTree
该脚本对您已经看到的脚本添加了一些新的语法特性。现在,我们只讨论突出显示的部分。以后,我会详细说明。
逐句说明 JJTree 语法
首先请注意,最顶层的
在 JavaCC 中,解析器调用看上去如下:
而现在看上去象下面这样:
注:所抓取的根节点并不仅仅是
它是参数化的。树构建器在构造树期间使用节点堆栈;没有参数的节点构建器的缺省行为是将自己放在正在构造的解析树的顶部,将所有节点弹出在同一个 节点作用域 中创建的节点堆栈,并把自己提升到那些节点父代的位置。参数
将
一图胜千言(引用一句古老的谚语)。以下是由上述语法生成的两种类型的解析树的图形表示:
图 1:单个整数表达式的解析树
图 2:加法操作的解析树
让我们更详细地研究
使用解析树
在 .jjt 脚本中声明的每个节点都指示解析器生成 JJTree
所有
图 2中的树的调试输出是:
辅助导航
另一个有用的内置
即使到目前为止您已经执行了所有操作,但您仍然没有足够的信息来计算该解析树表示的算术运算的结果。您的当前脚本已经省略了一个重要的细节:树中的两个
保存和恢复状态
要将所扫描的记号的值存储到适当的节点中,将以下修改添加到
将 JJTree 脚本中的以下结果:
更改成:
该结果抓取它刚在
可以很容易地修改
组合:XQuery 的 BNF 代码片段
让我们通过研究实际语法的一个代码片段来进行组合和总结。我将向您演示一段非常小的 XQuery 的 BNF 子集,这是 XML 的查询语言的 W3C 规范(请参阅 参考资料)。我在这里所说的大多数内容也适用于 XPath,因为这两者共享了许多相同的语法。我还将简要地研究运算符优先级的问题,并将树遍历代码推广到成熟的递归例程中,该例程可以处理任意复杂的解析树。
清单 3显示了您要使用的 XQuery 语法片段。这段 BNF 摘自 2002 年 11 月 15 日的工作草案:
清单 3:一部分 XQuery 语法
您将要构建一个刚好适合的 JJTree 语法脚本来处理结果 [36] 和 [37] 中的
清单 4 显示了 .jjt 脚本。请注意该文件顶部的
还请注意原始的 XQuery BNF 经常将多个运算符组合到同一个结果中。在 清单 4中,我已经将这些运算符分离到 JJTree 脚本中的单独结果中,因为这让客户机端的代码更简单。要进行组合,只需存储已扫描的运算符的值,就象对整数所进行的操作一样。
清单 4:清单 3 中的 XQuery 语法的 JJTree 脚本
该 .jjt 文件引入了几个新的特性。例如,该语法中的运算结果现在是 迭代的 :通过使用
实现优先级
该语法还知道 运算符优先级。例如,您期望乘法的优先级比加法高。在实际例子中,这表示诸如“1 + 2 * 3”这样的表达式将作为“1 + ( 2 * 3 )”进行求值,而不是“( 1 + 2 ) * 3”。
优先级是通过使用级联样式实现的,其中每个结果会调用紧随其后的较高优先级的结果。级联样式和节点构造的位置和格式保证了以正确的结构生成解析树,这样树遍历可以正确执行。用一些直观图形也许更易于理解这一点。
图 3 显示了由此语法生成的解析树,它可以让您正确地将“1 + 2 * 3”当作“1 + ( 2 * 3 )”进行求值。请注意,
图 3. 结构正确的树
而 图 4显示的树(该语法 不会生成这样的树)表示您(错误地)想要将该表达式当作“(1 + 2) * 3”求值。
图 4. 结构不正确的树
遍历解析树客户机端
就象我曾答应的,我将用客户机端代码的清单作为总结,该清单将调用该解析器并遍历它生成的解析树,它使用简单而功能强大的递归
清单 5. 可容易泛化的 eval() 例程
结束语
如果您想要查看可以处理许多实际 XQuery 语法的功能更丰富的
关于作者
使用 BNF 和 JavaCC 构建定制的解析器
在简要讨论了语法、解析器和 BNF 后,本文将介绍 JavaCC,这是一个流行的解析器生成器工具。您将开发使用 JavaCC 的样本代码来构建定制的解析器,先从语法的 BNF 描述开始。第 2 部分接着将演示如何使用辅助工具 ― JJTree 来构建同一解析的解析树表示,以及如何在运行时遍历该树,以发现其状态信息。文章将以开发构建和遍历解析树的样本代码作为结束,该解析树是您为一小部分 XQuery 语法生成的。
要完成最简单的日常解析任务,您不需要使用象自动化解析器生成器那样复杂的任何东西。例如,同“梳理”CSV(逗号分割的值,Comma-Separated-Value)文件的各部分同样简单的编程练习需要了解文件的结构,可能还需要了解如何使用 Java
StringTokenizer。另外,CSV 练习还需要了解很少的解析理论知识或者将自动化工具应用于任务的需求。
但是,一旦正式描述它的某种语言和语法变得复杂,那么语言中的有效表达式数量将迅速增加。而能够手工处理将任意表达式解析成其组成部分(这或多或少是解析更简明的定义)所需的代码将变得越来越困难。自动化解析器生成器减轻了这种困难。其他程序员或许也可以将您生成的解析器用于他们自己的用途。
从 BNF 开始
复杂语言的语法通常都是使用 BNF(巴科斯-诺尔范式,Backus-Naur Form)表示法或者其“近亲”― EBNF(扩展的 BNF)描述的。自动化工具可以使用那些描述(我将使用通用的术语 BNF来指代这两种变体)或与它们近似的描述来为您生成解析代码。本文就描述了这样的一种解析器-生成器工具,称为 JavaCC。我将简要地研究一下 JavaCC 的基本知识,并在结束的时候花些时间研究一下它的一个辅助工具 ― JJTree,但是在讨论中不会介绍太多的理论知识,以免偏离主题。本文力图阐明我的理念:您并不需要了解很多有关正规的解析理论就能进行解析!
为什么使用 JavaCC 呢?有几个原因:我对 XQuery 有着强烈的兴趣,而 W3C 的 XML Query 工作组恰好使用 JavaCC 来构建并测试 XQuery 语法的版本,并且构建和测试它与 XSL 组共享的 XPath 语法。我还使用 JavaCC 来提供 XQEngine 中的查询-解析代码,XQEngine 是我自己的开放源码 XQuery 实现(请参阅 参考资料)。
最后一点(但不是最不重要的),价格是完全合适的:尽管 JavaCC 不是开放源码,但它是完全免费的。(请参阅 参考资料以了解有关如何获得 JavaCC 的信息)。
在我开始介绍一些实际的 XQuery 语法之前,让我们先从一个非常简单的 BNF 开始,它描述了一种语言,该语言仅由两个只对整数进行运算的算术运算符构成。我称这种语言为
SimpleLang:
simpleLang ::= integerLiteral ( ( "+" | "-" ) integerLiteral )? integerLiteral ::= [ 0-9 ]+ |
simpleLang表明,该语言中有效的(或合法的)表达式是这样构成的,一个整数值,可以任意选择其后跟一个加号(+)或减号(-)以及另一个整数值或不跟任何东西。按照这种语法,单个整数“42”是有效的,同样,表达式“42 + 1”也是有效的。第二个结果以类 regex 的方式更特定地描述了一个整数值看上去象什么:一个或多个数字的连续序列。
该语法描述了两个结果
simpleLang和
integerLiteral之间存在的抽象关系。它还详细描述了三个 记号(加号、减号和整数)组合的具体项,解析器在扫描整个输入流时希望遇到这些项。解析器中负责该任务的部件称为 扫描器(scanner)或 记号赋予器(tokenizer) 一点也不稀奇。在该语言中,
simpleLang是 非终端(non-terminal) 符号的一个示例,它对其它结果进行引用;另一方面,规则
integerLiteral描述了 终端(terminal)符号:这是一种不能进一步分解成其它结果的符号。
如果解析器在其扫描期间发现了除这三个记号外的任何 其它记号,则认为它正在扫描的表达式是无效的。解析器的主要工作之一就是确定您传递给它的任何表达式的有效性,并且让您知道。一旦认为某个表达式是有效的,则它的第二项工作是将输入流分解成其组件块,并以某个有用的方式将它们提供给您。
让我们看看如何使用 JavaCC 实现该语法。JavaCC 使用称为 .jj 的文件。该文件中的语法描述是使用非常类似于 BNF 的表示法编写的,这样从一种形式转换到另一种形式通常就相当容易。(该表示法有自己的语法,从而使其在 JavaCC 中是可表达的。)
JavaCC .jj 文件语法和标准的 BNF 之间的主要区别在于:利用 JavaCC 版本,您可以在语法中嵌入操作。一旦成功遍历了语法中的那些部分,则执行这些操作。操作都是 Java 语句,它们是解析器 Java 源代码的一部分,该部分作为解析器生成过程的一部分产生。
(注:除了一条 Java
println()语句外, 清单 1并不包含您需要用来对该语言中的表达式实际求值的嵌入式 Java 代码。当您研究过 JJTree 及其解析树表示后,我将对此做更详细的研究。)
清单 1. 编码 SimpleLang 语法的完整 .jj 脚本
PARSER_BEGIN( Parser_1 ) package examples.example_1; public class Parser_1 {} PARSER_END( Parser_1 ) void simpleLang() : {} { integerLiteral() ( ( "+" | "-" ) integerLiteral() )? <EOF> } void integerLiteral() : {Token t;} { t=<INT> { System.out.println("integer = "+t.image); }} SKIP : { " " | "/t" | "/n" | "/r" } TOKEN : { < INT : ( ["0" - "9"] )+ > } |
PARSER_BEGIN和
PARSER_END伪指令指定了要生成的 Java 解析器的名称(Parser_1.java),并提供一个位置以便将 Java 语句插入该类。在这个案例中,您正将一个 Java
package语句放置在文件的顶部。该
package语句也放置在 Java 助手类文件的顶部,该文件是作为生成过程一部分产生的(请参阅 JavaCC 编译过程 )。尽管我在本示例中没有这样做,但是这也是一个声明实例变量的好场所,该实例变量将由您结果中的 Java 语句引用。如果您喜欢,甚至可以在这里插入 Java
main()过程,并且使用它来构建独立的应用程序,以启动和测试您正在生成的解析器。
JavaCC 语法看上去象一个过程,而结果看上去非常象进行方法调用。这并非偶然。在 JavaCC 编译这个脚本时产生的 Java 源代码包含与这些结果具有相同名称的方法;这些方法在运行时按照它们在 .jj 脚本中调用的顺序执行。我将在 遍历解析代码中为您演示那是如何工作的。(这里的术语“编译”也不是偶然的 ― 解析器生成器通常也称为“编译器的编译器”。)
花括号({ 和 }) 内描述了结果的主体,并且排除了任何您正在嵌入的 Java 操作。请注意
integerLiteral规则中用花括号包括的
System.out.println()语句。该操作作为方法
Parser_1.integerLiteral()的一部分产生。每当解析器遇到整数时,都执行该操作。
文件结尾的
SKIP语句表明,在记号之间可以出现空白(空格、跳格、回车和换行),空白将被忽略。
TOKEN语句包含类似 regex 的表达式,该表达式描述了整数记号看起来象什么。在前面的结果中,对这些记号的引用是用尖括号括起来的。
第二个结果
integerLiteral()声明了类型
Token(JavaCC 的内置类)的局部变量
t。当在输入流中遇到整数时会 触发 该规则,该整数(象文本一样)的值被赋给实例变量
t.image。另一个
Token字段
t.kind被赋值为一个枚举(enum),表明这个特殊的记号是一个整数,而不是解析器所知的另一种类型的记号。最后,在解析器中生成的 Java
System.out.println()代码可以在解析时在那个记号的内部使用
t.image进行访问并且打印其文本值。
让我们非常简要地了解一下您生成的解析器的内部原理。出于下面两个原因,稍微了解由特殊的 .jj 语法生成的方法以及其在运行时的执行顺序是很有用的:
有时候(特别是当您第一次做的时候),解析器似乎返回了不同的结果,而您认为是 .jj 文件指示它这样做的。您可以在运行时单步遍历产生的解析器代码,以便查看它到底在做什么,并相应地调整语法文件。我现在还经常这样做。
如果您知道结果/方法的执行顺序,那么您将对在脚本中的什么地方嵌入 Java 操作以获得特定的结果有更好的理解。在第 2 部分谈论有关 JJTree 工具和解析树表示时,我将回过头来更详细地讨论这一内容。
尽管深入研究已生成解析器的详细信息超越了本文的范围,但是 清单 2 还是显示了为方法
Parser_1.integerLiteral()生成的代码。这可能会让您对最终代码看起来象什么有一些了解。特别需要注意方法中的最后一条语句:
System.out.println( "integer = "+t.image)。该语句作为嵌入 .jj 脚本的 Java 操作发挥作用。
清单 2. Parser_1 中生成的方法
static final public void integerLiteral() throws ParseException { Token t; t = jj_consume_token(INT); System.out.println( "integer = "+t.image); } |
最上面的方法
simpleLang()调用
integerLiteral()。
integerLiteral()希望在输入流中立即遇到一个整数,否则该表达式将无效。为了验证这一点,它调用记号赋予器(Tokenizer.java)以返回输入流中的下一个记号。记号赋予器穿过输入流,每次检查一个字符,直到它遇到一个整数或者直至文件结束。如果是前者,则以
<INT>记号将值“包”起来;如果是后者,则当作
<EOF>;并将记号返回给
integerLiteral()做进一步处理。如果记号赋予器未遇到这两个记号,则返回词法错误。
如果记号赋予器返回的记号不是整数记号或
<EOF>,那么
integerLiteral()抛出
ParseException,同时解析完成。
如果它是整数记号,表达式仍然可能是有效的,
integerLiteral()再次调用记号赋予器以返回下一个记号。如果返回
<EOF>,则由单个整数构成的整个表达式都是有效的,解析器将控制返还给调用应用程序。
如果记号赋予器返回加号或减号记号,则表达式仍然是有效的,
integerLiteral()将最后一次调用记号赋予器,以寻找另一个整数。如果遇到一个整数,则表达式是有效的,解析器将完成工作。如果下一个记号不是整数,则解析器抛出异常。
注:如果解析器失败了,则抛出
ParseException或
TokenMgrError。任何一种异常都表明您的表达式是无效的。
这里的 要点 是,只有当解析器成功地遍历了嵌入 Java 操作的那部分结果后,才能执行嵌入到这两个结果中的任何 Java 操作。如果将表达式“42 + 1”传递给该解析器,则语句
integer = 42将被打印到控制台,后跟
integer = 1。如果运行无效的表达式“42 + abc”,则产生消息
integer = 42,后跟 catch 块消息
a Token Manager error!。在后一种情形中,解析器只成功地遍历了
simpleLang结果中的第一个
integerLiteral()项,而未成功遍历第二项:
void simpleLang() : {} { integerLiteral() ( ("+" | "-") integerLiteral() )? <EOF> } |
integerLiteral()方法未被执行,因为未遇到希望的整数标记。
当您对 .jj 文件运行 JavaCC 时,它会生成许多 Java 源文件。其中一个是主解析代码 Parser_1.java,当您有一个要解析的表达式时,您将从您的应用程序调用该代码。JavaCC 还创建了其它六个由解析器使用的辅助文件。JavaCC 总共生成了以下七个 Java 文件。前三个是特定于这个特殊语法的;后四个是通用的助手类 Java 文件,无论语法是怎么样的,都会生成这几个文件。
Parser_1.java
Parser_1Constants.java
Parser_1TokenManager.java
ParseException.java
SimpleCharStream.java
Token.java
TokenMgrError.java
一旦 JavaCC 生成了这七个 Java 源文件,则可以编译它们并将它们链接到您的 Java 应用程序中。然后可以从您的应用程序代码调用新的解析器,从而将表达式传递给它进行求值。下面是一个样本应用程序,它实例化您的解析器,并且为它提供了一个硬连接在应用程序顶部的表达式。
清单 3:调用第一个解析器
package examples.example_1; import examples.example_1.Parser_1; import examples.example_1.ParseException; import java.io.StringReader; public class Example_1 { static String expression = "1 + 42"; public static void main( String[] args ) //-------------------------------------- { new Example_1().parse( expression ); } void parse( String expression ) //----------------------------- { Parser_1 parser = new Parser_1( new StringReader( expression )); try { parser.simpleLang(); } catch( ParseException pe ) { System.out.println( "not a valid expression" ); } catch( TokenMgrError e ) { System.out.println( "a Token Manager error!" ); } } } |
要调用
Parser_1中的解析代码,需要调用该类中的方法
simpleLang()。.jj 文件中的结果顺序通常是无关的,而本案例除外,在本案例中,语法中最顶部的结果名称用于调用解析器。
如果正在传递给解析器代码的表达式不能根据语法合法地构造,则将抛出
ParseException或
LexicalError。
如果表达式是有效的,则执行嵌入语法各部分的任何 Java 操作,这些语法部分都被成功遍历,就象 遍历解析器代码结尾描述的一样。
这篇文章结束后还有第 2 部分。您将从类似的样本代码开始着手,学习如何使用 JavaCC 的“伙伴”工具 JJTree 来创建在运行时构建解析的解析树表示的解析器,而不是执行嵌入 .jj 脚本的操作。正如您将看到的,这有很多优点。
第 2 部分
本文的第 1 部分简要讨论了语法、解析器和 BNF。然后它介绍了 JavaCC,一个流行的解析器生成器。第 2 部分演示了如何修改第 1 部分中的样本代码,这样就可以使用附加工具 JJTree 来构建相同解析的解析树表示。您将探索这种方法的优点,并研究如何编写 Java 代码在运行时遍历该解析树以便恢复其状态信息,并对正在解析的表达式求值。本文结尾将演示如何开发通用例程,用于遍历从一小部分 XQuery 语法生成的解析树,并对其求值。
使用 JavaCC 解析器生成器有一个严重缺点:许多或大多数客户机端 Java 代码需要嵌入到 .jj 语法脚本中,该脚本编码了您的 BNF(巴科斯-诺尔范式,Backus-Naur Form)。这意味着您失去了在开发周期中合适的 Java IDE 可以向您提供的许多优点。
开始使用 JJTree 吧,它是 JavaCC 的伙伴工具。JJTree 被设置成提供一个解析器,该解析器在运行时的主要工作不是执行嵌入的 Java 操作,而是构建正在解析的表达式的独立解析树表示。这样,您就可以独立于生成该解析树的解析代码,捕捉在运行时易于遍历和查询的单个树中的解析会话的状态。使用解析树表示还会使调试变得更容易,并缩短开发时间。JJTree 是作为 JavaCC 分发版(distribution)的一部分发布的(请参阅 参考资料)。
在我们继续之前,我要特别提一下,术语 解析树和 抽象语法树(或 AST)描述了非常相似的语法结构。严格地讲,对于我在下面提到的解析树,语言理论家更精确地把它称作 AST。
要使用 JJTree,您需要能够:
创建 JJTree 作为输入获取的 .jjt 脚本
编写客户机端代码以遍历在运行时生成的解析树并对其求值
本文演示了如何执行这两种操作。它并没有涵盖所有内容,但肯定能带您入门。
JJTree 基础知识
JJTree 是一个预处理器,为特定 BNF 生成解析器只需要简单的两步:
对所谓的 .jjt 文件运行 JJTree;它会产生一个中间的 .jj 文件
用 JavaCC 编译该文件( 第 1 部分中讨论了这个过程)
幸运的是,.jjt 文件的结构只是我在第 1 部分中向您显示的 .jj 格式的较小扩展。主要区别是 JJTree 添加了一个新的语法 node-constructor构造,该构造可以让您指定在解析期间在哪里以及在什么条件下生成解析树节点。换句话说,该构造管理由解析器构造的解析树的形状和内容。
清单 1 显示了一个简单的 JavaCC .jj 脚本,它类似于您在第 1 部分中看到的脚本。为简便起见,我只显示了结果。
清单 1. simpleLang 的 JavaCC 语法
void simpleLang() : {} { addExpr() <EOF> } void addExpr() : {} { integerLiteral() ( "+" integerLiteral() )? } void integerLiteral() : {} { <INT> } SKIP : { " " | "/t" | "/n" | "/r" } TOKEN : { < INT : ( ["0" - "9"] )+ > } |
单个整数文字,或
一个整数文字,后面跟一个加号,再跟另一个整数文字。
对应的 JJTree .jjt 脚本(再次声明,略有简化)看上去可能如下:
清单 2. 等价于清单 1 中的 JavaCC 语法的 JJTree
SimpleNode simpleLang() : #Root {} { addExpr() <EOF> { return jjtThis; }} void addExpr() : {} { integerLiteral() ( "+" integerLiteral() #Add(2) )? } void integerLiteral() : #IntLiteral {} { <INT> } SKIP : { " " | "/t" | "/n" | "/r" } TOKEN : { < INT : ( ["0" - "9"] )+ > } |
首先请注意,最顶层的
simpleLang()结果的 JavaCC 的过程性语法现在指定了一个返回类型:
SimpleNode。它与嵌入的 Java 操作
return jjtThis(有一点为 JJTree 虚张声势)一起指定了从应用程序代码调用解析器的
simpleLang()方法将返回解析树的根,然后这个根将用于树遍历。
在 JavaCC 中,解析器调用看上去如下:
SimpleParser parser = new SimpleParser(new StringReader( expression )); parser.simpleLang(); |
SimpleParser parser = new SimpleParser(new StringReader( expression )); SimpleNode rootNode = parser.simpleLang(); |
SimpleNode类型。它其实是
Root类型,正如 清单 2 中的
#Root伪指令所指定的(虽然您不会在上述调用代码中那样使用)。
Root是
SimpleNode子代,就象 JJTree 生成的解析器构造的每个节点一样。我将在下面向您显示
SimpleNode的一些内置方法。
addExpr()结果中的
#Add(2)构造与上述的
#Root伪指令不同,体现在以下几方面;
它是参数化的。树构建器在构造树期间使用节点堆栈;没有参数的节点构建器的缺省行为是将自己放在正在构造的解析树的顶部,将所有节点弹出在同一个 节点作用域 中创建的节点堆栈,并把自己提升到那些节点父代的位置。参数
2告诉新的父节点(在此示例中是一个
Add节点)要恰好采用 两个子节点,它们是 下一段文字 中描述的两个
IntLiteral子节点。JJTree 文档更详细地描述了这个过程。使用好的调试器在运行时遍历解析树是另一个宝贵的辅助方法,它有助于理解树构建在 JJTree 中是如何工作的。
将
#Root伪指令放在其结果的主体之外表示 每次 遍历该结果时都会生成一个
Root节点(而在此特定示例中,只允许发生一次),这一点具有同等的重要性 。但是,将
#Add(2)伪指令放在可选的“零或一”项中表示仅当在解析期间遍历包含它的选择子句时 ― 换句话说,当该结果表示一个真的加法操作时 ― 才 有条件地 生成一个
Add节点。当发生这种情况时,会遍历
integerLiteral()两次,每次调用时都将一个
IntLiteral节点添加到树上。这两个
IntLiteral节点都成为调用它们的
Add节点的子节点。但是,如果正在解析的表达式是单个整数,那么作为结果的
IntLiteral节点将直接成为
Root的一个子节点。
一图胜千言(引用一句古老的谚语)。以下是由上述语法生成的两种类型的解析树的图形表示:
图 1:单个整数表达式的解析树
图 2:加法操作的解析树
让我们更详细地研究
SimpleNode的类层次结构。
在 .jjt 脚本中声明的每个节点都指示解析器生成 JJTree
SimpleNode的一个子类。接下来,
SimpleNode又实现名为
Node的 Java 接口。这两个类的源文件都是由 JJTree 脚本和定制 .jj 文件一起自动生成的。 清单 1 显示了定制 .jj 文件的当前示例。在当前示例中,JJTree 还提供了您自己的
Root、
Add和
IntLiteral类以及没有在这里看到的一些附加的助手类的源文件。
所有
SimpleNode子类都继承了有用的行为。
SimpleNode方法
dump()就是这样一个例子。它还充当了我以前的论点(使用解析树使调试更容易,从而缩短开发时间)的示例。以下三行客户机端代码的代码片段实例化了解析器、调用解析器、抓取所返回的解析树,并且将一个简单的解析树的文本表示转储到控制台:
SimpleParser parser = new SimpleParser(new StringReader( expression )); SimpleNode rootNode = parser.simpleLang(); rootNode.dump(); |
Root Add IntLiteral IntLiteral |
另一个有用的内置
SimpleNode方法是
jjtGetChild(int)。当您在客户机端向下浏览解析树,并且遇到
Add节点时,您会要抓取它的
IntLiteral子节点、抽取它们表示的整数值,并将这些数字加到一起 ― 毕竟,那是用来练习的。假设下一段代码中显示的
addNode是表示我们感兴趣的
Add类型节点的变量,那我们就可以访问
addNode的两个子节点。(
lhs和
rhs分别是 左边(left-hand side)和 右边(right-hand side)的常用缩写。)
SimpleNode lhs = addNode.jjtGetChild( 0 ); SimpleNode rhs = addNode.jjtGetChild( 1 ); |
IntLiteral节点实际上不包含它们声称要表示的整数。那是因为当记号赋予器(tokenizer)在输入流中遇到它们时,您没有将它们的值保存到树中;您需要修改
integerLiteral()结果来执行该操作。您还需要将一些简单的存取器方法添加到
SimpleNode。
要将所扫描的记号的值存储到适当的节点中,将以下修改添加到
SimpleNode:
public class SimpleNode extends Node { String m_text; public void setText( String text ) { m_text = text; } public String getText() { return m_text; } ... } |
void integerLiteral() : #IntLiteral {} <INT> } |
void integerLiteral() : #IntLiteral { Token t; } { t=<INT> { jjtThis.setText( t.image );} } |
t.image中遇到的整数的原始文本值,并使用您的
setText()setter 方法将该字符串存储到当前节点中。 清单 5 中的客户机端
eval()代码显示了如何使用相应的
getText()getter 方法。
可以很容易地修改
SimpleNode.dump(),以提供任何节点的
m_text值供其在解析期间存储 ― 我把这作为一个众所周知的练习留给您来完成。这将让您更形象地理解在进行调试时解析树看起来是什么样子。例如,如果您解析了“42 + 1”,略经修改的
dump()例程可以生成以下有用的输出:
Root Add IntLiteral[42] IntLiteral[1] |
让我们通过研究实际语法的一个代码片段来进行组合和总结。我将向您演示一段非常小的 XQuery 的 BNF 子集,这是 XML 的查询语言的 W3C 规范(请参阅 参考资料)。我在这里所说的大多数内容也适用于 XPath,因为这两者共享了许多相同的语法。我还将简要地研究运算符优先级的问题,并将树遍历代码推广到成熟的递归例程中,该例程可以处理任意复杂的解析树。
清单 3显示了您要使用的 XQuery 语法片段。这段 BNF 摘自 2002 年 11 月 15 日的工作草案:
清单 3:一部分 XQuery 语法
[21] Query ::= QueryProlog QueryBody ... [23] QueryBody ::= ExprSequence? [24] ExprSequence ::= Expr ( "," Expr )* [25] Expr ::= OrExpr ... [35] RangeExpr ::= AdditiveExpr ( "to" AdditiveExpr )* [36] AdditiveExpr ::= MultiplicativeExpr (("+" | "-") MultiplicativeExpr )* [37] MultiplicativeExpr ::= UnionExpr (("*" | "div" | "idiv" | "mod") UnaryExpr )* ... |
+、
-、
*和
div运算符,而且简单地假设该语法所知道的唯一数据类型是整数。该样本语法 非常小,并不能妥善处理 XQuery 支持的丰富的表达式和数据类型。但是,如果您要为更大、更复杂的语法构建解析器,它应该能给您使用 JavaCC 和 JJTree 的入门知识。
清单 4 显示了 .jjt 脚本。请注意该文件顶部的
options{}块。这些选项(还有许多其它可用选项开关)指定了其中树构建在本例中是以 多重 方式运行的,即节点构造器用于显式地命名所生成节点的类型。备用方法(不在这里研究)是结果只将
SimpleNode节点提供给解析树,而不是它的子类。如果您想要避免扩散节点类,那么该选项很有用。
还请注意原始的 XQuery BNF 经常将多个运算符组合到同一个结果中。在 清单 4中,我已经将这些运算符分离到 JJTree 脚本中的单独结果中,因为这让客户机端的代码更简单。要进行组合,只需存储已扫描的运算符的值,就象对整数所进行的操作一样。
清单 4:清单 3 中的 XQuery 语法的 JJTree 脚本
options { MULTI=true; NODE_DEFAULT_VOID=true; NODE_PREFIX=""; } PARSER_BEGIN( XQueryParser ) package examples.example_2; public class XQueryParser{} PARSER_END( XQueryParser ) SimpleNode query() #Root : {} { additiveExpr() <EOF> { return jjtThis; }} void additiveExpr() : {} { subtractiveExpr() ( "+" subtractiveExpr() #Add(2) )* } void subtractiveExpr() : {} { multiplicativeExpr() ( "-" multiplicativeExpr() #Subtract(2) )* } void multiplicativeExpr() : {} { divExpr() ( "*" divExpr() #Mult(2) )* } void divExpr() : {} { integerLiteral() ( "div" integerLiteral() #Div(2) )* } void integerLiteral() #IntLiteral : { Token t; } { t=<INT> { jjtThis.setText(t.image); }} SKIP : { " " | "/t" | "/n" | "/r" } TOKEN : { < INT : ( ["0" - "9"] )+ > } |
*(零次或多次)发生指示符来表示它们的可选的第二项,这与 清单 2 中的
?(零次或一次)表示法相反。该脚本所提供的解析器可以解析任意长的表达式,如“1 + 2 * 3 div 4 + 5”。
实现优先级
该语法还知道 运算符优先级。例如,您期望乘法的优先级比加法高。在实际例子中,这表示诸如“1 + 2 * 3”这样的表达式将作为“1 + ( 2 * 3 )”进行求值,而不是“( 1 + 2 ) * 3”。
优先级是通过使用级联样式实现的,其中每个结果会调用紧随其后的较高优先级的结果。级联样式和节点构造的位置和格式保证了以正确的结构生成解析树,这样树遍历可以正确执行。用一些直观图形也许更易于理解这一点。
图 3 显示了由此语法生成的解析树,它可以让您正确地将“1 + 2 * 3”当作“1 + ( 2 * 3 )”进行求值。请注意,
Mult运算符与它的项之间的联系比
Plus更紧密,而这正是您希望的:
图 3. 结构正确的树
而 图 4显示的树(该语法 不会生成这样的树)表示您(错误地)想要将该表达式当作“(1 + 2) * 3”求值。
图 4. 结构不正确的树
就象我曾答应的,我将用客户机端代码的清单作为总结,该清单将调用该解析器并遍历它生成的解析树,它使用简单而功能强大的递归
eval()函数对树遍历时遇到的每个节点执行正确操作。 清单 5中的注释提供了关于内部 JJTree 工作的附加详细信息。
清单 5. 可容易泛化的 eval() 例程
// return the arithmetic result of evaluating 'query' public int parse( String query ) //------------------------------ { SimpleNode root = null; // instantiate the parser XQueryParser parser = new XQueryParser( new StringReader( query )); try { // invoke it via its topmost production // and get a parse tree back root = parser.query(); root.dump(""); } catch( ParseException pe ) { System.out.println( "parse(): an invalid expression!" ); } catch( TokenMgrError e ) { System.out.println( "a Token Manager error!" ); } // the topmost root node is just a placeholder; ignore it. return eval( (SimpleNode) root.jjtGetChild(0) ); } int eval( SimpleNode node ) //------------------------- { // each node contains an id field identifying its type. // we switch on these. we could use instanceof, but that's less efficient // enum values such as JJTINTLITERAL come from the interface file // SimpleParserTreeConstants, which SimpleParser implements. // This interface file is one of several auxilliary Java sources // generated by JJTree. JavaCC contributes several others. int id = node.id; // eventually the buck stops here and we unwind the stack if ( node.id == JJTINTLITERAL ) return Integer.parseInt( node.getText() ); SimpleNode lhs = (SimpleNode) node.jjtGetChild(0); SimpleNode rhs = (SimpleNode) node.jjtGetChild(1); switch( id ) { case JJTADD : return eval( lhs ) + eval( rhs ); case JJTSUBTRACT : return eval( lhs ) - eval( rhs ); case JJTMULT : return eval( lhs ) * eval( rhs ); case JJTDIV : return eval( lhs ) / eval( rhs ); default : throw new java.lang.IllegalArgumentException( "eval(): invalid operator!" ); } } |
如果您想要查看可以处理许多实际 XQuery 语法的功能更丰富的
eval()函数版本,欢迎下载我的开放源码 XQuery 实现(XQEngine)的副本(请参阅 参考资料 )。它的
TreeWalker.eval()例程 例举了 30 多种 XQuery 节点类型。还提供了一个 .jjt 脚本。
关于作者
Howard Katz 居住在加拿大温哥华,他是 Fatdog Software 的唯一业主,该公司专门致力于开发搜索 XML 文档的软件。在过去的大约 35 年里,他一直是活跃的程序员(一直业绩良好),并且长期为计算机贸易出版机构撰写技术文章。Howard 是 Vancouver XML Developer's Association 的共同主持人,还是 Addison Wesley 即将出版的书籍 The Experts on XQuery的编辑,该书由 W3C 的 Query 工作组成员合著,概述了有关 XQuery 的技术前景。他和他的妻子夏天去划船,冬天去边远地区滑雪。可以通过 howardk@fatdog.com与 Howard 联系。 |
相关文章推荐
- JavaCC、解析树和 XQuery 语法第 1 部分
- JavaCC、解析树和 XQuery 语法,第 2 部分
- lucene javacc 语法解析器结构分析
- JavaCC解析XQuery 代码
- solr/lucene查询语法解析
- java语法解析
- android启动过程配置文件的解析与语法
- 大白话解析Objective-C(二):点语法
- hive QL 插入语法解析
- JSON【介绍、语法、解析JSON】
- Generator函数语法解析
- Generator函数语法解析
- SQL的JOIN语法解析(inner join, left join, right join, full outer join的区别)
- 【discuzX2】模板解析语法就template()函数分析
- Lucene学习总结之八:Lucene的查询语法,JavaCC及QueryParser
- Gradle语法基础解析
- merge 语法解析
- spark SQL源码阅读002——sql.core包核心类——002执行SQL语法2次解析SQL词(analyse)
- 正则表达式语法解析
- Android .mk文件语法解析