本章翻译人 CowNew开源团队 周晓
记号流
长久以来, 词法分析器和语法分析器是紧紧耦合在一起的; 也就是说, 你不可以在他们中间做任何事情,也不能修改记号流。但是,用记号流来处理词法分析器和语法分析器之间的连接的话,会给代码识别和翻译带来极大的帮助。这个想法类似于Java的输入输出流,利用输入输出流你可以用管道的方式处理多个深加工的数据流。
介绍
ANTLR识别任何满足TokenStream接口的记号流对象(2.6以前的版本, 这个接口叫做Tokenizer);也就是说记号流对象要实现以下的方法。
Token nextToken();
如图, 在分析过程中的某一时刻,从词法分析器(生产者)到语法分析器(消费者)的一般记号流会像是下面的样子。
最普通的记号流是一个词法分析器,但是想象一下,在词法分析器和语法分析器中间有一个流的实体,都能做哪些有趣的事情呢。比如说,你可以:
- 过滤掉不想要的记号
- 插入一些辅助的记号,帮助语法分析过程识别一些复杂的结构
- 把一个流分成多个流,把某些感兴趣的记号送到不同的流中
- 把多个记号流合并成一个流,从而“模拟”PCCTS,lex等词法分析工具的状态。
记号流概念的优雅在于它对词法分析器和语法分析器没有影响--它们只不过是流的生产者和消费者。流对象是消费者用来生产,处理,合并或者分离记号流的过滤器。可以使已有的词法分析器和语法分析器在不修改的情况下合并成一种新的工具。
这份文档正式提出了记号流的概念,详细描述了一些非常有用的流过滤器。
让记号通过的记号流
一个记号流是任何满足下面接口的对象。
public interface TokenStream { public Token nextToken() throws java.io.IOException; }
例如, 一个"没有操作"或者说仅仅是让记号通过这里的过滤器是这样工作的:
import antlr.*; import java.io.IOException; class TokenStreamPassThrough implements TokenStream { protected TokenStream input; /** Stream to read tokens from */ public TokenStreamPassThrough(TokenStream in) { input = in; } /** This makes us a stream */ public Token nextToken() throws IOException { return input.nextToken(); // "short circuit" } }
在下面的main()程序中,你使用这个简单的流对象从词法分析器中获得记号,然后语法分析器从流对象中获得记号。
public static void main(String[] args) { MyLexer lexer = new MyLexer(new DataInputStream(System.in)); TokenStreamPassThrough filter = new TokenStreamPassThrough(lexer); MyParser parser = new MyParser(filter); parser.startRule(); }
记号流过滤
多数情况下,你希望词法分析器忽略掉空白符和注释,然而,你还希望在语法分析器必须使用注释的情况下重用词法分析器。这时,你只要为需要注释和空白符连同普通记号的情况设计一个词法分析器。然后,当你想忽略空白符的时候,只要在词法分析器和语法分析器中间加入一个过滤器,过滤掉空白符。
针对这种情况,ANTLR提供了TokenStreamBasicFilter。你可以在不修改词法分析器的情况下让它过过滤掉任何类型的记号。下面TokenStreamBasicFilter的用法的例子中过滤掉了注释和空白符。
public static void main(String[] args) { MyLexer lexer = new MyLexer(new DataInputStream(System.in)); TokenStreamPassThrough filter = new TokenStreamPassThrough(lexer); filter.discard(MyParser.WS); filter.discard(MyParser.COMMENT); MyParser parser = new MyParser(filter); parser.startRule(); }
可以看到,它比修改词法分析器的词法结构要来的有效,你也会这么做的吧,因为这样你不用去构建一个记号对象。另一方面,采用这种过滤流的方法使词法分析器的设计更加灵活。
记号流分离
有时,在识别阶段,你想让翻译器忽略但不是丢弃输入的部分记号。比如说,你想在语法分析时忽略注释,但在翻译时又需要注释。解决办法是将注释发送到一个隐藏的记号流中,所谓隐藏,就是语法分析器没有对它进行监听。在识别期间,通过动作来检查这些隐藏的流,收集注释等等。流分离过滤器有点像棱镜把白光分离成彩虹。
下面的图中示出了把一个记号流分成三个的情况。
让语法分析器从最上面的流中获得记号。
用流分离器可以实现很多功能。比如,"Y-分离器"像有线电视Y连接器一样,复制符号流。如果过滤器是线程安全的而且有缓冲器缓冲,过滤器就可以同时为多个语法分析器提供记号。
这一节描述了ANTLR提供的一个叫做TokenStreamHiddenTokenFilter的流过滤器,它工作的时候有点像给一堆硬币分类,把一分一分的放到一个箱子里,把一角一角的放到另一个箱子里,等等。这个过滤器把输入流分离成两个流,一个包含主要记号,另一个被缓冲以便以后可以访问。因为这种实现方式,无论怎么做,你都无法让语法分析器直接访问隐藏流。下面你将会看到,过滤器实际上把隐藏记号交织在主记号中。
例子
考虑以下的简单文法,该文法用来声明整型变量.
decls: (decl)+ ; decl : begin:INT ID end:SEMI ;
比如说有以下输入:
int n; // list length /** doc */ int f;
假定词法分析器忽略空白符,你用过滤器把注释分离到一个隐藏流。那么现在如果语法分析器从主记号流中获得记号,它只会看到"INT ID SEMI FLOAT ID SEMI",注释在隐藏流中。实现了语法分析器可以忽略注释,而你的语义动作可以从过滤器中查询隐藏流中的记号。
第一次识别出文法规则decl,begin记号前后没有对隐藏记号的引用,但
filter.getHiddenAfter(end)
返回一个对下面记号的引用
// list length
接下来就会访问到
/** doc */
第二次识别出decl
filter.getHiddenBefore(begin)
指向
/** doc */
的引用
过滤器实现
下图示出记号对象是如何组织记号来模拟两个不同的流。
因为记号是假定的,TokenStreamHiddenTokenFilter对象通过链表来连接隐藏记号和主记号。过滤器只提供了一个物理上的记号流,通过交织指针维护和管理记号次序。
因为这个额外的指针需要把记号连接到一起,你必须要用一个叫CommonHiddenStreamToken的特殊记号对象(普通记号对象叫做CommonToken)。前面曾说过,你可以用以下的方法去指定词法分析器为特定的类制造记号。
lexer.setTokenObjectClass("classname");
技术上,如果不请求特殊记号对象,也可以实现同样功能的过滤器,但这样实现非常有效率而且它很容易告诉词法分析器去生成什么种类的记号。进一步说,这样实现使得它很容易去自动完成包含隐藏流信息的树结点的创建。
这个过滤器影响ANTLR的懒惰消费。在识别每一个主记号之后, TokenStreamHiddenTokenFilter必须获取下一个记号看它是不是一个隐藏记号。因此,这个过滤器在交互程序(比如命令行)下工作的不是很好。
怎样使用这个过滤器
要使用TokenStreamHiddenTokenFilter,仅仅要做的是:
- 创建词法分析器,让它创建链接隐藏记号的记号对象。
MyLexer lexer = new MyLexer(some-input-stream); lexer.setTokenObjectClass( "antlr.CommonHiddenStreamToken" );
- 创建一个TokenStreamHiddenTokenFilter对象,该对象从上面的词法分析器中获得记号。
TokenStreamHiddenTokenFilter filter = new TokenStreamHiddenTokenFilter(lexer);
- 设置TokenStreamHiddenTokenFilter要隐藏哪些记号,要丢弃哪些记号。例如,
filter.discard(MyParser.WS); filter.hide(MyParser.SL_COMMENT);
- 创建一个语法分析器,从TokenStreamHiddenTokenFilter而不是从词法分析器中获得记号。
MyParser parser = new MyParser(filter); try { parser.startRule(); // parse as usual } catch (Exception e) { System.err.println(e.getMessage()); }
查看ANTLR指南,在preserving whitespace处有一个完整的例子。
树的构建
最后,在翻译阶段,如果需要这些隐藏的流记号,在遍历树的时候,则可以按正常的方式使用。怎么做才能在不打乱树文法的情况下把隐藏流的信息送给翻译器呢?很简单: 用AST结点储存这些隐藏流记号。ANTLR定义了CommonASTWithHiddenTokens来自动连接隐藏流记号到树结点; 联合一个树结点,适用于树结点的方法在这里也适用于访问这些隐藏记号。你只需要告诉语法分析器去创建这种类型的结点而不是默认的CommonAST类型的结点。
parser.setASTNodeClass("antlr.CommonASTWithHiddenTokens");
树结点作为记号对象的功能被创建。当ASTFactory创建树结点的时候,树结点的initialize()方法被调用。从记号创建的树结点包含隐藏记号,不管在前还是在后,都有着同样的对隐藏记号的引用。你不需要用这个结点定义,但它工作在很多翻译任务中:
package antlr; /** CommonAST在初始化时把从记号中获得 * 的隐藏记号的信息复制,用来创建结点 */ public class CommonASTWithHiddenTokens extends CommonAST { // 指向隐藏记号 protected Token hiddenBefore, hiddenAfter; public CommonHiddenStreamToken getHiddenAfter() { return hiddenAfter; } public CommonHiddenStreamToken getHiddenBefore() { return hiddenBefore; } public void initialize(Token tok) { CommonHiddenStreamToken t = (CommonHiddenStreamToken)tok; super.initialize(t); hiddenBefore = t.getHiddenBefore(); hiddenAfter = t.getHiddenAfter(); } }
注意到这个结点定义假设你用了CommonHiddenStreamToken对象。如果你没有让词法分析器创建CommonHiddenStreamToken对象,就会出现运行时类下行异常。
垃圾回收问题
利用对主流记号的引用分隔输入流,保存隐藏流记号时,GC允许在记号流上工作。在上面的整数声明的例子中,当没有更多的对第一个SEMI记号以及第二个INT记号的引用时,comment记号将会作为垃圾回收的候选人。如果所有的记号是连在一起的,一个单独的对任意记号的引用会阻止任何记号被回收。这在ANTLR实现不是问题。
提示
翻译时,过滤器在保存空白符和注释方面做得很好,但在处理输出和输入相差很远的情况下,用过滤器不是一个最好的办法。例如,有3个注释散布在一个输入声明语句中,你想在翻译阶段合并到输出声明语句的头部。比起察看注释周围的每一个分析的记号,更好的办法是有一个真正实际上分开的流来保存注释以及一个方法去联系分析好的记号的组和注释流记号的组。你或许想支持像"给我在注释流上从开始分析到结束分析时最初出现的的所有记号."
这个过滤器实现了同JavaCC中special记号及其相似的功能。Sriram Sankar (JavaCC之父) 关于特殊记号有一个非常好的想法,在1997的Dr. T's Traveling Parsing Revival and Beer Tasting Festival,出席者认同了泛化记号流的概念。现在JavaCC特殊记号功能仅仅是另一个ANTLR流过滤器,好处是你不用去为指定哪些记号是特殊的而修改词法分析器。
记号流多路技术 (又叫 "词法分析器多状态")
现在,考虑一下相反的问题,你想合并多个流而不是把一个流分成多个。当你的输入包含根本上不同的片段,比如说Java和JavaDoc注释,你会发现仅用一个词法分析器去识别所有的输入片段是很难的。这主要是因为合并各种部分的记号定义会造成二义性词法语言或者识别出一些错误的记号。例如,"final"在某些部分里是一个关键字,但在另一个部分里它可能会是一个标示符。同样,"@author"是一个合法的javadoc注释里的标记,但在Java代码中,它是不合法的。
大部分人为了解决这个问题,为词法分析器设定了很多状态,在不同的部分里切换到不同的状态来工作(例如,在"读取Java模式"和"读取JavaDoc模式"中间切换)。词法分析器开始是以Java模式工作的,然后在遇到"/**"后切换到JavaDoc模式; "*/"强制切换回Java模式。
多词法分析器
让一个词法分析器在多个状态下工作可以解决上述的问题,但有一个更好的解决问题的办法,让多个词法分析器协同工作,生成一个记号流。为什么说这种办法更好呢?因为分开的词法分析器更利于重用(不是剪贴粘贴,而仅仅是告诉流的多元管理器去切换不同的词法分析器,就得到了一个新的词法分析器)。例如,JavaDoc词法分析器可以在解决任何有JavaDoc注释的语言问题时得到重用。
ANTLR预设了一个记号流叫做TokenStreamSelector,可以用它在多个词法分析器间进行切换。不同词法分析器中定义的动作控制这个选择器切换输入流。考虑下面的Java代码片段。
/** Test. * @author Terence */ int n;
给出2个词法分析器,JavaLexer和JavaDocLexer,2个词法分析器的动作序列可能看起来是下面的样子:
JavaLexer: 匹配JAVADOC_OPEN, 切换到JavaDocLexer
JavaDocLexer: 匹配AUTHOR
JavaDocLexer: 匹配ID
JavaDocLexer: 匹配JAVADOC_CLOSE, 切换回JavaLexer
JavaLexer: 匹配INT
JavaLexer: 匹配ID
JavaLexer: 匹配SEMI
在Java词法分析器的文法中,你需要定义一个规则去切换到JavaDoc词法分析器(把需要切换的词法分析器记录在栈中):
JAVADOC_OPEN : "/**" {selector.push("doclexer");} ;
同样地,在JavaDoc词法分析器中定义一个规则切换回去:
JAVADOC_CLOSE : "*/" {selector.pop();} ;
选择器中有一个栈,所以JavaDoc词法分析器不用知道谁调用了它。
入图,选择器和并2个词法分析器流,为下游的语法分析器提供单独的一个记号流。
选择器可以为你维护流列表,所以你可以通过名字或者实际对象的引用来切换到另一个输入流。
public class TokenStreamSelector implements TokenStream { public TokenStreamSelector() {...} public void addInputStream(TokenStream stream, String key) {...} public void pop() {...} public void push(TokenStream stream) {...} public void push(String sname) {...} /** Set the stream without pushing old stream */ public void select(TokenStream stream) {...} public void select(String sname) throws IllegalArgumentException {...} }
用选择器很简单:
- 创建一个选择器。
TokenStreamSelector selector = new TokenStreamSelector();
- 为流命名(不是一定要命名--在切换的时候你可以用流对象的引用来避免使用哈希表查找)。
selector.addInputStream(mainLexer, "main"); selector.addInputStream(doclexer, "doclexer");
- 选择哪一个词法分析器先从字符流中读数据。
// start with main java lexer selector.select("main");
- 语法分析器可以像用词法分析器一样使用选择器。
JavaParser parser = new JavaParser(selector);
词法分析器共享同一字符流
在介绍语法分析器如何使用选择器之前,注意一下这2个词法分析器都要从同一个输入流中读取字符。以前的ANTLR2.6.0版本,每一个词法分析器都有它自己的记录行号的变量,输入字符流变量等等。为了共享同样的输入状态,ANTLR2.6.0代理词法分析器来处理字符输入到对象中,LexerSharedInputState可以被n个词法分析器共享(单线程)。为了让多个词法分析器共享状态,你创建第一个词法分析器,获得它的输入状态对象,然后在构建其它词法分析器并且需要共享输入状态的时候使用它:
// 创建Java词法分析器 JavaLexer mainLexer = new JavaLexer(input); // 创建javadoc词法分析器; 使用 // java词法分析器的共享输入状态 JavaDocLexer doclexer = new JavaDocLexer(mainLexer.getInputState());
分析多元记号流
就像一个词法分析器在从不同的输入片段中生产一个单独的流会遇到很多麻烦,一个单独的语法分析器在处理多记号流的时候也会遇到一些麻烦。又是同样的问题,一个记号在一个词法分析器中可能是一个关键字,在另一个词法分析器中可能会是一个标示符。把语法分析器分解成子分析器,为每一个输入片段单独处理它们的单词表,这样做同时也利于语法分析器的重用。
下面的语法分析器文法用主词法记号的词表(用importVocab指定),在遇到JAVADOC_OPEN的时候,它创建并且调用一个JavaDoc分析器去处理接下来在注释中的记号流。
class JavaParser extends Parser; options { importVocab=Java; } input : ( (javadoc)? INT ID SEMI )+ ; javadoc : JAVADOC_OPEN { // 创建一个分析器去处理javadoc注释 JavaDocParser jdocparser = new JavaDocParser(getInputState()); jdocparser.content(); // 用jdocparser继续分析 } JAVADOC_CLOSE ;
你会发现从2.6.0版本起,ANTLR语法分析器也共享记号输入流状态。当创建"子分析器"时, JavaParser告诉它从同一输入状态对象中获取记号。
JavaDoc分析器匹配一串标签:
class JavaDocParser extends Parser; options { importVocab=JavaDoc; } content : ( PARAM // includes ID as part of PARAM | EXCEPTION | AUTHOR )* ;
当子分析器规则content结束后,控制权自然地返回给它的调用方,也就是Java分析器中的javadoc。
多记号流超前扫描的作用
如果语法分析器需要去查看JavaDOc注释起始位置后的2个记号,那会发生什么呢?换句话说,以主分析器来看,JAVADOC_OPEN之后的记号是什么呢? 自然是记号 JAVADOC_CLOSE! 主分析器把任何JavaDoc注释都看作是一个实体,不管这个注释有多复杂; 它不用去了解注释记号流内部情况,它也不需要这么做--子分析器会去处理这件事情。
在子分析器中,content规则后是什么记号呢?是"End of file"记号。子分析器的分析过程不能确定你的代码中将会调用怎样的方法。但这不是一个问题,因为一般情况会有一个单独的记号标示子分析过程的结束。即使因为某种原因EOF被载入到分析过程,EOF也不会出现在记号流中。
多词法分析器vs调用另一条词法规则
多词法分析器状态也经常被用来处理那些非常复杂的单个记号,比如说嵌入转义字符的字符串,输入"\t"应该被识别为一个字符串。针对这种情况,典型的做法是在第一个引号之后,切换词法分析器到"字符串状态"然后在识别完字符串之后切换回"普通状态"。
所以把这种代码依靠模式做事情的方式叫做"模态"编程,这通常是一个比较差的方式。在这种复杂记号的情况下,最好是用多个规则直接指定复杂的记号。这里有一个什么时候该用多记号流和什么时候不该用的黄金规则:
复杂的单个记号应该通过调用另一个(protected)词法规则来匹配,对一段输入片段来说应该用多个词法分析器处理此输入流并提供给分析器。
例如,词法分析器字符串的定义应该仅仅是调用另一个规则来处理转义字符的情况:
STRING_LITERAL : '"' (ESC|~('"'|'\\'))* '"' ; protected // 不是一个记号; 仅仅被另一个规则调用 ESC : '\\' ( 'n' | 'r' | 't' | 'b' | 'f' | '"' | '\'' | '\\' | ('u')+ HEX_DIGIT HEX_DIGIT HEX_DIGIT HEX_DIGIT ... ) ;
TokenStreamRewriteEngine 简单的语法制导翻译
在很多情况下,你希望在原代码基础上修改或者增加一段程序或数据文件。ANTLR 2.7.3 介绍了一个(只有Java/C#版本)非常简单但非常强大的类TokenStream处理以下问题:- 输出语言和输入语言相似
- 语言元素的相对次序不改变
将来
ANTLR 2.6版本为使用记号流提供了基本结构,一旦我们有经验使用它们,今后的版本将会更加强大。
现在的"隐藏记号"流过滤器解决"忽略但保存空白符"的问题解决的很好,但它在很多情况下不能很好的处理注释。例如,在真正的翻译过程中,你想在各种单独的树结点上收集注释(像DECL或者METHOD)而不是把它们分布在树的各个地方。你迫切需要一个流分离器缓冲注释到一个隔离的流中,这时你就可以说"给我在识别这个规则上所用掉的所有注释"或者 "给我这2个记号之间的所有注释." 这几乎是你在注释翻译时所需要的所有功能。
记号流会带来很多便利。大部分人不经常思考关于记号流的事,使得很难去想象可以在其他一些什么地方可以变的更好。让思想更奔放一些。怎样处理嵌入语言的输入片段,就像你所能看到的Java中嵌入SQL(每一个输入的部分被分开并通过不同的流)。怎么样分析含有和不含有调试信息的Java .class文件?如果你有一个可以分析不含调试信息的.class文件分析器,你想用这个来分析含有调试信息的.class文件,不要去管这个分析器,为你的词法分析器新增关于调试信息的结构。然后用一个过滤器分离调试信息记号到另一个流,这样,对两种类型的.class文件,原来的分析器都可以正常工作了。
稍后,我想加一个"视图",这是真正的另一个查看过滤器的方法。想象一下从一个词法分析器(根视图)中发射出一个未加工的记号流。对它,我可以非常轻松的构建一棵视图树。例如,给出一个嵌有SQL的Java程序,你可能想为分析或翻译在输入流上建立视图,会是下面的样子:
你可以把SQL流或者去掉注释的Java流交给你的语法分析器处理,分析器中的动作也可以去访问注释流。
将来,我还会增加分析器的另一个功能,去生成记号流(或文本)作为输出,就像建立了树一样。用这样方式,多传递分析变得十分自然和简单,因为语法分析器也变成了流的生产者。一个语法分析器的输出可以是另一个语法分析器的输入。
Version: $Id: //depot/code/org.antlr/release/antlr-2.7.6/doc/streams.html#1 $