javacc 教程7 LOOKAHEAD

本文详细探讨了解析器如何处理语法匹配中的回溯问题,介绍了JavaCC中不同选择点的处理方式,以及LOOKAHEAD算法在优化解析性能中的应用,包括语法前瞻和语义前瞻的使用示例。

解析器的工作是读取输入流并确定输入流是否符合语法。一般情况下,这种确定可能非常耗时。我们看下以下示例:

void Input() :
{}
{
    "a" BC() "c"
}

void BC() :
{}
{
    "b" [ "c" ]
}

在上面的例子中,我们很容易就能分析出只有两个字符串与上述语法匹配,即:abc和abcc

我们使用“abc”作为输入字符串,其解析的步骤如下:

  1. 这里只有一种选择 - 第一个输入字符必须是“a” - 而字符串’abc‘也确实如此,所以这里是ok的。
  2. 现在我们继续处理非终结符BC。这里再次只有一种选择,即下一个输入字符必须是“b”。输入也匹配了这个字符,所以这里仍然没有问题。
  3. 现在我们来到了语法中的“选择点”。我们可以进入 ["c"] 中匹配它,也可以完全忽略它。我们决定进入 ["c"], 因此下一个输入字符必须是“c”。依旧没有问题。
  4. 现在我们已经完成了非终结符BC的匹配,回到了非终结符Input。现在语法规定下一个字符必须是另一个“c”。但是没有更多的输入字符了。所以这里遇到了问题。
  5. 我们推测可能是在某个选择点做出了错误的选择。在这种情况下,我们在步骤3中做出了错误的选择。因此,我们回溯到步骤3并做出另一个选择并尝试。这个过程称为“回溯”。
  6. 现在我们回溯到步骤3并作出另一个选择,即忽略[...]。现在我们已经完成了非终结符BC的匹配,回到了非终结符Input。现在语法规定下一个字符必须是另一个“c”。下一个输入字符是“c”,所以现在整个解析是ok的。

默认的选择确定算法

从上面的例子可以看出,将输入与语法匹配的过程中可能会造成”回溯“从而做出新的选择,而这种回溯所带来的性能损失是不可接受的。因此,大多数解析器不会采用”回溯”这种方式来处理选择点的歧义。

首先我们回顾下javaCC中的选择点,javaCC中有4中不同类型的选择点:

  • An expansion of the form: ( exp1 | exp2 | ... ). 在这种情况下,生成的解析器必须以某种方式确定选择exp1、exp2等中的哪一个来继续解析。
  • An expansion of the form: ( exp )?. 在这种情况下,生成的解析器必须以某种方式确定是否选择exp。注意:( exp )?也可以写作[ exp ]。
  • An expansion of the form ( exp )*. 在这种情况下,生成的解析器必须执行与前一情况相同的操作,而且,每次成功匹配exp(如果选择了exp)后,都必须再次进行这种选择确定。
  • An expansion of the form ( exp )+. 这基本上与前面提到的情况相似,只不过在这里首次匹配exp是强制性的。

下面的例子演示javaCC在选择点默认的选择确定算法,

( exp1 | exp2 | ... )结构

void basic_expr() :
{}
{
    <ID> "(" expr() ")" // Choice 1
|
    "(" expr() ")" // Choice 2
|
    "new" <ID> // Choice 3
}

当解析器解析到选择点时,其默认的选择确定算法可理解成如下伪代码:

	if (next token is <ID>) {
	  choose Choice 1
	} else if (next token is "(") {
	  choose Choice 2
	} else if (next token is "new") {
	  choose Choice 3
	} else {
	  produce an error message
	}

在上面的示例中,语法已经被编写得能够让默认的选择确定算法做出正确的决策。现在我们把上面的例子稍作修改:

void basic_expr() :
{}
{
  <ID> "(" expr() ")"	// Choice 1
|
  "(" expr() ")"	// Choice 2
|
  "new" <ID>		// Choice 3
|
  <ID> "." <ID>		// Choice 4
}

实际测试过程中以上产生式,生成的代码是无法运行的,这应当是javacc的bug,其官方示例的预期是:默认算法将总是选择“选择1”,而永远不会选择“选择4”,即使跟在<ID>后面的令牌是“.”。单生成的解析器是可以正常运行的。

构建上述代码的解析器时,它会给出如下警告消息:

Warning: Choice conflict involving two expansions at
Java Compiler Compiler Version 7.0.13 (Parser Generator)
         line 54, column 3 and line 60, column 3 respectively.
         A common prefix is: <ID>
         Consider using a lookahead of 2 for earlier expansion.

可见,在构建的过程中JavaCC是可以检测出当前产生式的写法有可能会导致解析器出现异常!

( exp )*结构

再看下( exp )*结构的例子

	void identifier_list() :
	{}
	{
	  <ID> ( "," <ID> )*
	}

假设第一个<ID>已经被匹配,并且解析器已经达到了选择点“( "," <ID> )*”。我们将此时选择确定算法的工作流程解释成伪代码如下:

while (下一个token是",") {
  进入( "," <ID> )*
  消费token","
  if (下一个token是<ID>)消费他, 否则报错
}

在上面的示例中只要<ID> 后面跟了 "," 选择确定算法就一定会进入(...)*。假设有另一个产生式包裹了identifier_list(),如下所示

void funny_list() :
{}
{
    identifier_list() "," <INT>
}

按照我们的预期,funny_list调用identifier_list时,当","后面的标记是一个<INT>时应该会跳过(...)*结构,返回到funny_list。然而事实并非如此。

当我们输入a,b,c,d,1 报错信息如下:

Exception in thread "main" com.github.gambo.javacc.lookahead.ParseException: Encountered " <INT> "1 "" at line 1, column 9.
Was expecting:
    <ID> ...

大体意思就是期望获取<ID>但是却遭遇了<INT>。

也就是说当使用嵌套结构编写上述语法时,只要第一个<ID>后面跟了“,”则必然会进入( "," <ID> )*,而一旦进入( "," <ID> )*,则输入的token只能按照( "," <ID> )的模式进行下去,遇到其他的token就会报错,无法跳出当前的结构。

另外两种选择点 - "(exp)+" 和 "(exp)?" - 的行为类似于 (exp)*,这里就不赘述了。

构建上述代码的解析器时,如果出现如下的警告一定要引起注意,在代表这某些情况下它可能会在选择点做出错误的选择。反之,如果你的语法文件没有产生任何警告就通过了JavaCC的构建,那么该语法就是LL(1)语法,也就是说每次消费一个token就可以确定每一个选择点的走向。

语法优化

我们将上面( exp1 | exp2 | ... )结构的示例稍作修改:

	void basic_expr() :
	{}
	{
	  <ID> ( "(" expr() ")" | "." <ID> )
	|
	  "(" expr() ")"
	|
	  "new" <ID>
	}

这里我们把第四个选项合并到第一个选项中的括号内,然后在括号内,我们又有一个选择,这个选择现在只需要查看输入流中的一个标记,并与 "(" 和 "." 进行比较即可完成。 将语法修改为LL(1)的过程称为“左因式分解”。

我们还可以将上面的( exp )*结构做如下修改:

	void funny_list() :
	{}
	{
	  <ID> "," ( <ID> "," )* <INT>
	}

相当于把identifier_list里面的内容拿到外面来,重新输入a,b,c,d,1 这次是OK的。

但是假如没有办法将语法修改成符合LL(1)的机构呢?比如绑定了词法 action

	void basic_expr() :
	{}
	{
	  { initMethodTables(); } <ID> "(" expr() ")"
	|
	  "(" expr() ")"
	|
	  "new" <ID>
	|
	  { initObjectTables(); } <ID> "." <ID>
	}

这里可以回顾一下关于词法动作的相关内容。

此时我们无法通过修改语法结构来解决选择点的歧义,所以javaCC还提供了在指定选择点加标注的方式,以帮助解析器在非LL(1)的情况下做出选择!

LOOKAHEAD选择确定算法

在输入流中进一步探索令牌的过程被称为“向前看”输入流——因此使用了“LOOKAHEAD”这个词。

我们可以通过命令行选项或语法文件开头中的选项部分设置全局的LOOKAHEAD值。 这个值是一个整数,表示在做出选择决策时要提前查看的token数。LOOKAHEAD选项的默认值为1,也就是上面描述的默认的前瞻算法。

我们对之前例子做一个改造:

	void basic_expr() :
	{}
	{
	  LOOKAHEAD(2)
	  <ID> "(" expr() ")"	// Choice 1
	|
	  "(" expr() ")"	// Choice 2
	|
	  "new" <ID>		// Choice 3
	|
	  <ID> "." <ID>		// Choice 4
	}

经过改造后的例子生成的解析器可以通过前瞻做出正确的选择,其执行伪代码如下:

	if (next 2 tokens are <ID> and "(" ) {
	  choose Choice 1
	} else if (next token is "(") {
	  choose Choice 2
	} else if (next token is "new") {
	  choose Choice 3
	} else if (next token is <ID>) {
	  choose Choice 4
	} else {
	  produce an error message
	}

也就是说在这个选择点,会消费两个token来判断接下来的选择。

由于大多数情况下LL(1)足以做出正确的解析,所以强烈建议不要修改全局LOOKAHEAD的默认值,这会带来很大的新能损耗。

语法前瞻

首先看下如下示例:

	void TypeDeclaration() :
	{}
	{
	  ClassDeclaration()
	|
	  InterfaceDeclaration()
	}

	void ClassDeclaration() :
    {}
    {
      ( "abstract" | "final" | "public" )*
      UnmodifiedClassDeclaration()
    }

	void InterfaceDeclaration() :
    {}
    {
      ( "abstract" | "public" )*
      UnmodifiedInterfaceDeclaration()
    }

以上产生式定义了Java语法中class和interface的书写规范。

在语法层面,ClassDeclaration 可以以任意数量的 "abstract"、"final" 和 "public" 开头,InterfaceDeclaration 可以以任意数量的 "abstract" 和 "public" 开头。同一修饰符的多次使用在这里是符合语法检查的,后续的语义检查会对此产生错误消息,但这直到解析完全结束才会发生,所以这一点我们暂且不做讨论。

假如输入流中的下一个token是一系列非常多的“abstract”,紧接着一个“interface”,那么显然,使用固定数量的前瞻(例如LOOKAHEAD(100))是不足够的,虽然这种情况比较极端,几乎不会出现,但出于学些的目的,我们还是要想办法精确的处理这个问题。

这里就要通过“语法前瞻”(syntactic LOOKAHEAD)来解决这个问题。在语法前瞻中,你指定一个要尝试的”扩展“,如果成功,则选择接下来的动作。
用语法前瞻重写上面的例子:

	void TypeDeclaration() :
	{}
	{
	  LOOKAHEAD(ClassDeclaration())
	  ClassDeclaration()
	|
	  InterfaceDeclaration()
	}

上面的写法翻译成伪代码的意思就是:

	if (如果输入流中的一些列token匹配ClassDeclaration) {
	  choose ClassDeclaration()
	} else if (下一个token匹配InterfaceDeclaration) {
	  choose InterfaceDeclaration()
	} else {
	  produce an error message
	}

上述写法的问题是,前瞻计算花费的时间太多,ClassDeclaration产生式是一个庞大的结构,除了对class的声明,其中对变量和方法等的定义都包含其中,如果在LOOKAHEAD环节就进行如此庞大的匹配是很没有必要的。

我们知道java中”类“和"接口"的区别在声明阶段就可以判断出来,当解析器解析到遇到标记“class”时,证明目前正在定义一个”类“,前瞻计算就可以停止了。就像以下示例所示:

	void TypeDeclaration() :
	{}
	{
	  LOOKAHEAD( ( "abstract" | "final" | "public" )* "class" )
	  ClassDeclaration()
	|
	  InterfaceDeclaration()
	}

翻译成伪代码如下:

	if (从输入流中接下来的标记(token)集合是一个序列,包括 "abstract"、"final" 和 "public",它们之后跟着一个 "class"。) {
	  choose ClassDeclaration()
	} else if (下一个token匹配上InterfaceDeclaration) {
	  choose InterfaceDeclaration()
	} else {
	  produce an error message
	}

通过这样做,我们可以让选择确定算法在看到“class”时立即停止,使其能做出的最早时间做出决定。

我们还可以在语法前瞻期间对要消耗的token数量设置限制,如下所示:

	void TypeDeclaration() :
	{}
	{
	  LOOKAHEAD(10, ( "abstract" | "final" | "public" )* "class" )
	  ClassDeclaration()
	|
	  InterfaceDeclaration()
	}

在这种情况下,如果前瞻已经消耗了10个token,并且仍然成功匹配(("abstract" | "final" | "public")* "class")结构,即便没有遇到”class“,也会直接选择ClassDeclaration。

实际上,当没有指定这样的限制时,它默认设置为最大的整数值(2147483647)。

语义前瞻

让我们回到文章开头的示例:

	void Input() :
	{}
	{
	  "a" BC() "c"
	}

	void BC() :
	{}
	{
	  "b" [ "c" ]
	}

正如之前提到的,这个语法识别了两个字符串"abc"和"abcc"。这里的问题是,默认的LL(1)算法每次看到"c"时都会进入["c"],因此"abc"永远不会被匹配。我们需要指定,这个选择只有在下一个标记是"c",并且那个标记后面的标记不是"c"时才能进行。这是一个否定陈述——一个不能使用语法前瞻(syntactic LOOKAHEAD)来做的陈述。

我们对其作如下改造:

void BC() :
{}
{
  "b"
  [ LOOKAHEAD( { getToken(1).kind == C && getToken(2).kind != C } )
    <C:"c">
  ]
}

首先,我们给标记"c"一个标签' C',这样我们就可以从语义' LOOKAHEAD '中引用它。可以此时的lookahead里面是一个布尔表达式,当表达式为true则进入[...]。翻译成伪代码如下:

	if (下一个token是"c",并且接下来的token不是"c") {
	  选择嵌套扩展(即进入[...]构造)
	} else {
	  跳过[...]构造
	}

 上面是官方给出的示例,意思是说只有不出现cc结构的时候才会进入[...],比如‘abca’,这样的话一旦进入["c"]那么就不符合跳出来以后的“c”匹配了,所以这个示例应该是有问题的。测试结果如下:
 

Connected to the target VM, address: '127.0.0.1:64352', transport: 'socket'
abca
Exception in thread "main" com.github.gambo.javacc.lookahead.ParseException: Encountered " "a" "a "" at line 1, column 4.
Was expecting:
    "c" ...
    

最后一个字符希望获取c但是却获得了a。

正确的示例如下:

void BC() :
{}
{
  "b"
   [ LOOKAHEAD( { getToken(1).kind == C && getToken(2).kind == C } )
  	    <C:"c">
  	  ]
}

当只有出现‘cc’结构的时候才会进入["c"],但是这样的话就没有必要使用“语义前瞻”的写法了

void BC() :
{}
{
  "b"
   [ LOOKAHEAD( { getToken(1).kind == C && getToken(2).kind == C } )
  	    <C:"c">
  	  ]
}

使用语法前瞻,也是可以的

void BC() :
{}
{
  "b"
   [ LOOKAHEAD( "c""c" )
  	    "c"
  	  ]
}

实际上语法前瞻可以和语义前瞻结合在一起使用:

	void BC() :
	{}
	{
	  "b"
	  [ LOOKAHEAD( "c", { getToken(2).kind == C } )
	    <C:"c">
	  ]
	}

使用语法前瞻识别第一个"c",并使用语义前瞻识别第二个"c",二者同时成立(相当于“与”操作)则进入["c"]。

总结

上面的的例子展示了LOOKAHEAD,三种类型的参数,分别书:数字,语法和布尔表达式

实际上LOOKAHEAD规范的一般结构是:
LOOKAHEAD ( amount, expansion, { boolean_expression } )

amount:指定LOOKAHEAD 的令牌数量,默认为2147483647,如果为0,这当前LOOKAHEAD失效。

expansion:用于执行LOOKAHEAD 的语法。

boolean_expression :用于通过语义生成布尔表达式,默认值为true

至少存在一个参数。如果有多个,则用逗号分隔。其中expansion和boolean_expression 是与的关系。

 文中示例代码 :GitHub - ziyiyu/javacc-tutorial: javacc教程

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值