第 1 部分
使用 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 的信息)。
解析 101
在我开始介绍一些实际的 XQuery 语法之前,让我们先从一个非常简单的 BNF 开始,它描述了一种语言,该语言仅由两个只对整数进行运算的算术运算符构成。我称这种语言为 SimpleLang
:
|
该语法中的每个规则都是一个 结果(production) ,其中左边的项(结果的名称)是依据语法中的其它结果描述的。最上面的结果 simpleLang
表明,该语言中有效的(或合法的)表达式是这样构成的,一个整数值,可以任意选择其后跟一个加号(+)或减号(-)以及另一个整数值或不跟任何东西。按照这种语法,单个整数“42”是有效的,同样,表达式“42 + 1”也是有效的。第二个结果以类 regex 的方式更特定地描述了一个整数值看上去象什么:一个或多个数字的连续序列。
该语法描述了两个结果 simpleLang
和 integerLiteral
之间存在的抽象关系。它还详细描述了三个 记号(加号、减号和整数)组合的具体项,解析器在扫描整个输入流时希望遇到这些项。解析器中负责该任务的部件称为 扫描器(scanner)或 记号赋予器(tokenizer) 一点也不稀奇。在该语言中, simpleLang
是 非终端(non-terminal) 符号的一个示例,它对其它结果进行引用;另一方面,规则 integerLiteral
描述了 终端(terminal)符号:这是一种不能进一步分解成其它结果的符号。
如果解析器在其扫描期间发现了除这三个记号外的任何 其它记号,则认为它正在扫描的表达式是无效的。解析器的主要工作之一就是确定您传递给它的任何表达式的有效性,并且让您知道。一旦认为某个表达式是有效的,则它的第二项工作是将输入流分解成其组件块,并以某个有用的方式将它们提供给您。
从 BNF 到 JavaCC
让我们看看如何使用 JavaCC 实现该语法。JavaCC 使用称为 .jj 的文件。该文件中的语法描述是使用非常类似于 BNF 的表示法编写的,这样从一种形式转换到另一种形式通常就相当容易。(该表示法有自己的语法,从而使其在 JavaCC 中是可表达的。)
JavaCC .jj 文件语法和标准的 BNF 之间的主要区别在于:利用 JavaCC 版本,您可以在语法中嵌入操作。一旦成功遍历了语法中的那些部分,则执行这些操作。操作都是 Java 语句,它们是解析器 Java 源代码的一部分,该部分作为解析器生成过程的一部分产生。
(注:除了一条 Java println()
语句外, 清单 1并不包含您需要用来对该语言中的表达式实际求值的嵌入式 Java 代码。当您研究过 JJTree 及其解析树表示后,我将对此做更详细的研究。)
清单 1. 编码 SimpleLang 语法的完整 .jj 脚本
|
请注意有关该文件的下述情形:
PARSER_BEGIN
和 PARSER_END
伪指令指定了要生成的 Java 解析器的名称(Parser_1.java),并提供一个位置以便将 Java 语句插入该类。在这个案例中,您正将一个 Java package
语句放置在文件的顶部。该 package
语句也放置在 Java 助手类文件的顶部,该文件是作为生成过程一部分产生的(请参阅 JavaCC 编译过程 )。尽管我在本示例中没有这样做,但是这也是一个声明实例变量的好场所,该实例变量将由您结果中的 Java 语句引用。如果您喜欢,甚至可以在这里插入 Java main()
过程,并且使用它来构建独立的应用程序,以启动和测试您正在生成的解析器。 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 语法生成的方法以及其在运行时的执行顺序是很有用的:
尽管深入研究已生成解析器的详细信息超越了本文的范围,但是 清单 2 还是显示了为方法 Parser_1.integerLiteral()
生成的代码。这可能会让您对最终代码看起来象什么有一些了解。特别需要注意方法中的最后一条语句: System.out.println( "integer = "+t.image)
。该语句作为嵌入 .jj 脚本的 Java 操作发挥作用。
清单 2. Parser_1 中生成的方法
|
以下高级、详尽的描述说明了这个解析器将做什么:
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()
项,而未成功遍历第二项:
|
换而言之,第二个 integerLiteral()
方法未被执行,因为未遇到希望的整数标记。
JavaCC 编译过程
当您对 .jj 文件运行 JavaCC 时,它会生成许多 Java 源文件。其中一个是主解析代码 Parser_1.java,当您有一个要解析的表达式时,您将从您的应用程序调用该代码。JavaCC 还创建了其它六个由解析器使用的辅助文件。JavaCC 总共生成了以下七个 Java 文件。前三个是特定于这个特殊语法的;后四个是通用的助手类 Java 文件,无论语法是怎么样的,都会生成这几个文件。
一旦 JavaCC 生成了这七个 Java 源文件,则可以编译它们并将它们链接到您的 Java 应用程序中。然后可以从您的应用程序代码调用新的解析器,从而将表达式传递给它进行求值。下面是一个样本应用程序,它实例化您的解析器,并且为它提供了一个硬连接在应用程序顶部的表达式。
清单 3:调用第一个解析器
|
这里有什么是值得注意的呢?
Parser_1
中的解析代码,需要调用该类中的方法 simpleLang()
。.jj 文件中的结果顺序通常是无关的,而本案例除外,在本案例中,语法中最顶部的结果名称用于调用解析器。 ParseException
或 LexicalError
。
结束语
这篇文章结束后还有第 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 基础知识
JJTree 是一个预处理器,为特定 BNF 生成解析器只需要简单的两步:
幸运的是,.jjt 文件的结构只是我在第 1 部分中向您显示的 .jj 格式的较小扩展。主要区别是 JJTree 添加了一个新的语法 node-constructor构造,该构造可以让您指定在解析期间在哪里以及在什么条件下生成解析树节点。换句话说,该构造管理由解析器构造的解析树的形状和内容。
清单 1 显示了一个简单的 JavaCC .jj 脚本,它类似于您在第 1 部分中看到的脚本。为简便起见,我只显示了结果。
清单 1. simpleLang 的 JavaCC 语法
|
该语法说明了该语言中的合法表达式包含:
对应的 JJTree .jjt 脚本(再次声明,略有简化)看上去可能如下:
清单 2. 等价于清单 1 中的 JavaCC 语法的 JJTree
|
该脚本对您已经看到的脚本添加了一些新的语法特性。现在,我们只讨论突出显示的部分。以后,我会详细说明。
逐句说明 JJTree 语法
首先请注意,最顶层的 simpleLang()
结果的 JavaCC 的过程性语法现在指定了一个返回类型: SimpleNode
。它与嵌入的 Java 操作 return jjtThis
(有一点为 JJTree 虚张声势)一起指定了从应用程序代码调用解析器的 simpleLang()
方法将返回解析树的根,然后这个根将用于树遍历。
在 JavaCC 中,解析器调用看上去如下:
|
而现在看上去象下面这样:
|
注:所抓取的根节点并不仅仅是 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()
就是这样一个例子。它还充当了我以前的论点(使用解析树使调试更容易,从而缩短开发时间)的示例。以下三行客户机端代码的代码片段实例化了解析器、调用解析器、抓取所返回的解析树,并且将一个简单的解析树的文本表示转储到控制台:
|
图 2中的树的调试输出是:
|
辅助导航
另一个有用的内置 SimpleNode
方法是 jjtGetChild(int)
。当您在客户机端向下浏览解析树,并且遇到 Add
节点时,您会要抓取它的 IntLiteral
子节点、抽取它们表示的整数值,并将这些数字加到一起 ― 毕竟,那是用来练习的。假设下一段代码中显示的 addNode
是表示我们感兴趣的 Add
类型节点的变量,那我们就可以访问 addNode
的两个子节点。( lhs
和 rhs
分别是 左边(left-hand side)和 右边(right-hand side)的常用缩写。)
|
即使到目前为止您已经执行了所有操作,但您仍然没有足够的信息来计算该解析树表示的算术运算的结果。您的当前脚本已经省略了一个重要的细节:树中的两个 IntLiteral
节点实际上不包含它们声称要表示的整数。那是因为当记号赋予器(tokenizer)在输入流中遇到它们时,您没有将它们的值保存到树中;您需要修改 integerLiteral()
结果来执行该操作。您还需要将一些简单的存取器方法添加到 SimpleNode
。
保存和恢复状态
要将所扫描的记号的值存储到适当的节点中,将以下修改添加到 SimpleNode
:
|
将 JJTree 脚本中的以下结果:
|
更改成:
|
该结果抓取它刚在 t.image
中遇到的整数的原始文本值,并使用您的 setText()
setter 方法将该字符串存储到当前节点中。 清单 5 中的客户机端 eval()
代码显示了如何使用相应的 getText()
getter 方法。
可以很容易地修改 SimpleNode.dump()
,以提供任何节点的 m_text
值供其在解析期间存储 ― 我把这作为一个众所周知的练习留给您来完成。这将让您更形象地理解在进行调试时解析树看起来是什么样子。例如,如果您解析了“42 + 1”,略经修改的 dump()
例程可以生成以下有用的输出:
|
组合:XQuery 的 BNF 代码片段
让我们通过研究实际语法的一个代码片段来进行组合和总结。我将向您演示一段非常小的 XQuery 的 BNF 子集,这是 XML 的查询语言的 W3C 规范(请参阅 参考资料)。我在这里所说的大多数内容也适用于 XPath,因为这两者共享了许多相同的语法。我还将简要地研究运算符优先级的问题,并将树遍历代码推广到成熟的递归例程中,该例程可以处理任意复杂的解析树。
清单 3显示了您要使用的 XQuery 语法片段。这段 BNF 摘自 2002 年 11 月 15 日的工作草案:
清单 3:一部分 XQuery 语法
|
您将要构建一个刚好适合的 JJTree 语法脚本来处理结果 [36] 和 [37] 中的 +
、 -
、 *
和 div
运算符,而且简单地假设该语法所知道的唯一数据类型是整数。该样本语法 非常小,并不能妥善处理 XQuery 支持的丰富的表达式和数据类型。但是,如果您要为更大、更复杂的语法构建解析器,它应该能给您使用 JavaCC 和 JJTree 的入门知识。
清单 4 显示了 .jjt 脚本。请注意该文件顶部的 options{}
块。这些选项(还有许多其它可用选项开关)指定了其中树构建在本例中是以 多重 方式运行的,即节点构造器用于显式地命名所生成节点的类型。备用方法(不在这里研究)是结果只将 SimpleNode
节点提供给解析树,而不是它的子类。如果您想要避免扩散节点类,那么该选项很有用。
还请注意原始的 XQuery BNF 经常将多个运算符组合到同一个结果中。在 清单 4中,我已经将这些运算符分离到 JJTree 脚本中的单独结果中,因为这让客户机端的代码更简单。要进行组合,只需存储已扫描的运算符的值,就象对整数所进行的操作一样。
清单 4:清单 3 中的 XQuery 语法的 JJTree 脚本
|
该 .jjt 文件引入了几个新的特性。例如,该语法中的运算结果现在是 迭代的 :通过使用 *
(零次或多次)发生指示符来表示它们的可选的第二项,这与 清单 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() 例程
|
结束语
如果您想要查看可以处理许多实际 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 的技术前景。他和他的妻子夏天去划船,冬天去边远地区滑雪。可以通过 [email protected]与 Howard 联系。 |