使用Antlr+Stringtemplate生成method chaining,一个不太简单的案例(6)
- 题外话
在森林里迷路的时候,我们希望手里有一张地图,还要有个指南针。我们心里有一个目标,要到那里去。
这是很多人首先想到的。还缺什么呢?还缺少我们当前的位置。你只有知道到自己在哪里,才能接下来的步骤。
我们要开发一个杨氏语言编译器,用 input.pipe 中的那些指令,生成C++代码。在这条路上,我们已经走了多远。
我们根据输入的格式确定了语法 pipe.g,根据输出的格式确定了模板st/header.stg,根据语法制导写翻译出了语义动作
decl.g。我们在decl.g中应用了模板。
接下来,我们需要一个东西,它能够把 调用 pipe.g 和 decl.g,并且输入文件 pipe.g 输给它们。严格的说,被调用的不是
*.g,而是antlr由 *.g 生成的词法解析、语法解析、AST遍历的java程序。
这个推动大跑的东西,可以名之为 driver,它是个java程序。
- driver java
这个程序是用于header生成的,所以我们称之为 header.java。你可能还记得,我们不只要生成头文件,还有cpp和go.cpp。
代码不复杂,但是略微有点长,我们分成三段来看。
-- 头部
我得承认,我没有命名的天份。除了头部,我还是想不出什么名字称呼这一段。在java中,它应该有专门的术语吧。
代码1:1 import java.io.*;2 import org.antlr.runtime.*;3 import
org.antlr.runtime.tree.*;
因为我们要在程序里用到这些类,所以import进来。这是常规的java写法。
题外话:有的时候,我们因为初涉足一个全新的领域,动物本能让我们保持恐惧和谨慎。在进化中,这具有优势,凡是连那是什么都不知道,就敢去碰敢吃的家伙,都年纪轻轻时候就死掉了,没有机会成为我们的祖先。所以,我们每个人的身上都保留了这样的特质。但是在学习中,有的时候,恐惧和谨慎可能过了头,阻碍我们。
我初中的时候参加数字竞赛培训。通化的初中分为山上片和山下片,山下片--我不记得那个时候的术语了--山下片的的生源较好,或者说那是富人区。我惊恐地看到老师才把题写到黑板上,有的学校的同学答案就出来了。这令我震恐。你可以想像一个非常非常难,你一辈子可能都编不出来的程序,一位大牛抽着烟喝着茶,可能还看着碟,谈笑间就写出来了。当你佩服得五体投地时,他说:没啥,就是个小小地练习。
这就是我当时的感觉。后来我看到老师写了一个式子,要因式分解的:
a^2 - b^2
全班同学瞬间就解出来啦。(a+b)(a-b)。而我完全不知道他们是怎么解出来的。我毛了,小声问旁边的同学,"这是咋整出来的啊。"如果我现在不问,老师马上就讲过去啦。
他说:这非常简单。
是的,那确实非常简单,是因式分解中最简单的公式之一,叫平方差公式,就是这个公式本身,不是灵活应用。
你明白我的意思了。恐惧,阻碍我们思考,让我们不敢假设。
其实上面的那些import就是java本身,因为我们正写的,就是java程序。纯正的,不是.g文件中的。我这么说的意思就是:.g文件的那些{}中的动作,也不过就是java程序而已,只是出现的位置略有些奇怪。如果你知道它们会在什么时候执行,就与java无异。
-- 词法和语法
接下来,我们在一个类 header 里跑 main函数。
代码2:45 public class header {6 public static void main(String
args[]) throws Exception {7 pipeLexer lex = new pipeLexer(new
ANTLRFileStream(args[0]));8 CommonTokenStream tokens = new
CommonTokenStream(lex);9 10 pipeParser parser = new
pipeParser(tokens);11 pipeParser.starting_return r =
parser.starting(); // launch parsing12 if ( r!=null )
System.out.println("parser tree:
"+((CommonTree)r.tree).toStringTree());13 14
System.out.println("---------------");15
这个main函数的前半段,如上所述。
第7行,我们构造了一个 词法分析器。
7 pipeLexer lex = new pipeLexer(new ANTLRFileStream(args[0]));
其中 pipeLexer 这个类的名字是这么来的:pipe是我们的grammar的名字,参见pipe.g(请参考昨天博客里的pipe.g源代码。);
Lexer是词法分析器的意思。
new ANTLRFileStream(args[0]) 的意思,是以此作为词法分析器的输入。
我们用这个lexer做什么呢?
8 CommonTokenStream tokens = new CommonTokenStream(lex);
我们用它作为参数,构造了一个 CommonTokenStream。token 的流。
这个流用来做什么呢?
10 pipeParser parser = new pipeParser(tokens);
我们用这个流构造了 pipeParser,这是一个
(语法的)解析器。类似pipeLexer,pipeParser的名字由两部分组成:pipe是grammar的名字,Parser是解析器。
pipeLexer,pipeParser这两个类的名字,是antlr处理pipe.g时生成的两个类。就是我这一篇博客上面提到的
"而是antlr由 *.g 生成的词法解析、语法解析"。
当终于沿 输入文件 input.pipe (即new
ANTLRFileStream(args[0]))、词法分析器pipeLexer、语法解析器pipeParser这条线走到这里,我们就可以调用语法解析器了。
11 pipeParser.starting_return r = parser.starting(); // launch parsing
我们调用了parser。调用的方法是
parser.starting()。starting()这个名字,来自我们在pipe.g中的一条规则的名字,starting。请参考昨天博客里的pipe.g源代码。
parser.starting()的返回值的类型 pipeParser.starting_return,其中starting_return
的命名,就是规则 starting 加 下划线 _,再加上 return。
以上这些命名规则,是 antlr 约定的。由antlr处理 .g 文件后,生成的lexer& parser 将遵循这样的规则,我们也遵循这样的规则来调用。
这个世界遵循两类规则。一种是强制性的。比如,如果你的C代码写得不符合C编译处标准,它就啪地给你个错误,然后甩脸子不干了。还有传说故事里的美国交警拦住你的车,要求你出示驾照,你要是敢醉么哈的冲过去,还敢动武把超啥的,他就可能会一枪把你撂倒。这是强制性的规则,有些是自然的法则,有些是人为的。
还有一种规则,是约定,即使你违反了,没有严重后果的时候似乎也没有惩罚。比如当红灯亮起,如果车辆还是强行压过斑马线,如果没有行人,也没有其他车辆,也没有摄像头和交警叔叔,那么,似乎,什么也不会发生。似乎。我们考试都做过弊,可能你没有,我有。我们口口声声说这于人无害,只要监考老师对我们仁慈一些就可以了。我们并非于人无害,这个世界上,于己有益,却于人无害的事情不多--罗素的观点,大致,你拥有很多数学知道是无害的。当我们作弊,我们无疑地伤害了没有作弊的那些同学。更严重的,我们破坏了规则。前面我说了,我也做过弊,之所以这么说的意思就是,即使我也做过,也并不意味着这件事就是正确的。
antlr的约定,大致类似于第二种。你没有遵循约定,它似乎也没有什么抱怨的。事实上,不是。它只是以另一种方式抱怨,它不工作,或者说,它不按你想像的方式工作。
当我们不认真对待代码,她也将以相同的方式回报你。君视民如草芥,民当视君如寇仇。然后我们只能感叹德国人如何如何,中国人如何如何,好像能把自己摘出去,中国人里没有你我一份似的。
如果你前面全都按 antlr 的规则,那么现在,你可以得到结果了。
12 if ( r!=null ) System.out.println("parser tree:
"+((CommonTree)r.tree).toStringTree());
那个 (CommonTree)r 里的 r,就是刚刚的规则返回值 starting_return 。它是一棵AST。为什么?因为我们在
pipe.g 里面写着 output = AST,请参见昨天博客里的 pipe.g。这不是 delc.g 里的同一条语句,还没到它。
第12行的意思是,把 pipe.g (严格地说,antlr用它生成的 lexer & parser)处理输入 input.pipe
的结果,那棵AST,转化为 toStringTree() 打印到控制台上。
我之所以写这一条语句的目的,是检查解析输入文件是否正确。
我输入了
mario:pipe_a 123 | pipe_b | pipe_c
peach:stage_1 123 | stage_2
bowser:lose_1 123 | lose_2 | lose_3 | lose_4 234
header.java运行到此处,我得到了:
parser tree: (CLASS mario (NODE pipe_a PARA 123) (NODE pipe_b)
(NODEpipe_c)) (CLASS peach (NODE stage_1 PARA 123) (NODE stage_2))
(CLASSbowser (NODE lose_1 PARA 123) (NODE lose_2) (NODE lose_3) (NODE
lose_4PARA 234))
我们看到了那些大写字母,它们是 imaginary tokens,在pipe.g中定义的。
有的同学可能发现,这里为什么没有NEXT,我们明明在 pipe.g 中定义了它,
NEXT='|';
而且,在输入文件中,我们看到了那些非常明显的 |。
因为,此处我们得到的,是 pipe.g 的输出树,而不是解析时的树。它的输出树,应用了 rewrite 规则,我们整理了这棵树,把 |
这样不携带信息的结点砍掉了。有了AST,我们可以通过结点在树中的位置确定它的语法功能,进而决定语义, | 就没有必要存在了。以下是
pipe.g 中的一段,供懒人同学们查看。我之所以没有总是贴上引用的代码,是因为那会打乱我们叙述的线索。
game : SYMBOL_NAME ':' node? ( NEXT node)* -> ^(CLASS
SYMBOL_NAME (node)*) ;
以上,我们完成了词法分析和语法解析,得到了AST。这棵抽象树,就供下面的步骤遍历,并在遍历过程中执行语义。
-- 语义
在 decl.g 中描述语义很复杂,但是调用则简单的多。
代码3:16 // walker17 try18 {19
CommonTreeNodeStream nodes = new
CommonTreeNodeStream((CommonTree)r.tree);20
nodes.setTokenStream(tokens);21 decl walker = new
decl(nodes);22 walker.starting();23 }24
catch (RecognitionException e) { 25 System.err.println(e);
26 }27 28 }29 }
我们从前往后看。
第19行,我们由AST构造出了 节点的流。
19 CommonTreeNodeStream nodes = new CommonTreeNodeStream((CommonTree)r.tree);
第20行,我们指定,这个 节点的流 里的 tokens 将使用 tokens
20 nodes.setTokenStream(tokens);
这里的 tokens,就是在代码2第8行里定义的那个。为什么需要这一步呢?
回顾代码2和代码3,我们生成这些东西的流程:
args[0](即 input.pipe) -> lex -> tokens -> parser -> r -> nodes
注意,nodes 是由 tokens 间接生成的。既然 r 是由 tokens 生成的,那么 r中原本就应该包含 token
的信息,为什么还要多余地再设置由 r 而来的 nodes的 tokenstream呢?
antlr的作者在 The Definitive ANTLR Reference 一书中这样说:
"The one key piece that is different from a usual parser and
treeparser test rig is that, in order to access the text for a tree,
youmust tell the tree node stream where it can find the token stream:"
我猜测可能在上述生成的流程中,tokens的信息被抛弃了。这一猜测是否正确,感谢哪位老师同学指点。
不过,我注释了第20行,似乎也没有什么改变,运行结果没有什么不同。也许,新的版本中,tokens信息始终携带着?
我们得到了由AST构造出的stream,接下来,我们要遍历它了,并在遍历的过程中动作。
第21行,我们用 nodes 这个 tree nodes stream 构造出一个遍历器-- walker。
21 decl walker = new decl(nodes);
你注意到了,这个 walker 的类型是 decl,这个名字从 decl.g 中的 grammar的名字而来,它被声明为 tree
grammar。请参见昨天博客中的 decl.g 。
然后,我们用这个遍历器开始:
22 walker.starting();
startinging() 的名字来自 decl.g 中的一条规则。请参见昨天博客中的decl.g 。
从 starting 开始,遍历AST,然后在遍历的过程中,执行语义动作。
有的同学可能会问,在以上java代码中,动作在哪里?动作在 decl.g 的动作部分中。当遍历 starting
这个树枝(也就是根)时,动作同时执行着。这,就是那些动作被调用的时机,解析或遍历到特定的结点,动作就开始执行。
你是不是想起了 龙书 里如何表示动作的位置。
以上,这个 header.java 调用了 *.g 产生的 *.java(里的类和方法),一边解析 input.pipe
(或遍历树),一边做着这个杨氏语言源代码要求的动作。
我们看到,一台大机器在精确地运行,输入 input.pipe 中的字符,不断地转换状态,输出 input.pipe 所规定的产品。
- 脚本,或者 调用/跑起来的 方法
调用antlr把*.g翻译为*.java,编译以上的*.java和header.java,编译并运行得到的那些c++代码,这些动作在写编译器的时候,会不断地重复。
会不断重复很多次的动作,我们应该写个程序来完成。换句话说,我们描述重复很多次的动作并命名它。
实现这个需求的最简单的工具是shell脚本。
题外话,昨天,给同学们看我写的一小段脚本,用来把一个叫做 unicode
的程序输出的东西转换为特定的格式。建一说,windows下也肯定有这样的程序,能求一个字符的 unicode 编号,弹出一个窗口……
那个弹出窗口的程序估计是存在的,它与linux下的这个程序的区别在于,linux下的这个,能用shell非常方便地取出数据,然后加工成另一种形式。易于自动化。弹出窗口那个,你如何取其中的数据呢?用hook么,是的,我们会有很多办法,但是,那是多么地不方便。因为GUI程序特意地关闭了允许你取得输出的途径,它封闭如国内的很多站点,根本就不想提供API供你调用。
张炜同学建议我贴博文的时候,同时提供主博客的URL。我还是犹豫,因为我的主博客在
blogspot,它在这个世界上是不存在的,至少在我看来。是的,我看不到我的博客。我为什么坚持使用呢?因为在那上面发贴子真的非常简单,简单到它支持向某个信箱发封信,那信的正文就是博文。
如果一个人关闭自己的心灵,不喜欢你了解他,还有什么理由抱怨大家不愿意了解和理解他呢。难道他喜欢破门而入或者喜欢各种猜测--还是他所要的不是了解和理解,而仅仅是关注。
Linux,承袭了Unix shell的血统,他一直对你张开怀抱。
代码4:1 echo cleaning2 rm -rf output && \3 rm -rf method_chaining_demo
&& \4 echo mkdir && \5 mkdir output && \6 mkdir output/classes && \7
mkdir method_chaining_demo && \8 9 echo header file generating10 echo
generating code && \11 java -cp
/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar org.antlr.Tool
-o output pipe.g decl.g && \12 echo compiling lexer and parser && \13
javac output/*.java -cp ~/Downloads/antlr-3.4-complete-no-antlrv2.jar
-d output/classes && \14 echo compiling header.java && \15 javac -cp
/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar:output/classes
header.java && \16 echo running test.java && \17 java -cp
.:/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar:output/classes
header input.pipe
第8行以前,是删除前次运行的结果,避免对本次运行造成干扰。
那些 echo 是提醒我他运行到了哪里,避免我担心。你看,他不会一直停在那不动,跟个青春期叛逆少年一样什么也不告诉你。
第11行,11 java -cp
/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar org.antlr.Tool
-o output pipe.g decl.g && \告诉 antlr 由 pipe.g 和 decl.g 两个文件,生成
*.java,放在 output 目录下。
生成了以下东西:
decl.java decl.tokens pipe.tokens pipeLexer.java pipeParser.java
你可以根据名字猜测它们的用途,相信你还看到了熟悉的面孔。
第13行,13 javac output/*.java -cp
~/Downloads/antlr-3.4-complete-no-antlrv2.jar -d output/classes && \
编译这些东西。生成一堆 .class。
我把 antlr-3.4-complete-no-antlrv2.jar 放在了 ~/Downloads/
目录下,一个糟糕的选择,它表明我没有良好的组织文件位置的习惯。
"-cp" 是做什么的?请 javac -help ,然后 RTFM。
第15行,15 javac -cp
/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar:output/classes
header.java && \
编译 header.java,我们今天写的东西。
第17行,跑。17 java -cp
.:/home/young/Downloads/antlr-3.4-complete-no-antlrv2.jar:output/classes
header input.pipe
JVM启动,许多class争先恐后装载进来,header 读入 input.pipe,然后调用那些载入的 class。大机器开动,产品在源代码的指令下生产出来。
- 后记
头文件以外,我们还需要 *.cpp 和 go.cpp 的生成,但是其余的那些,也没有什么不同。就像,当你坐上班车地铁公交,一切日子,看似没有什么不同。
当它们全部生成,我们执行:
g++ -I. *cpp -o go
*.h & *.cpp 被编译链接成了一个可执行程序 go。当我们运行go,它说:
I am mario, created in: marioI am peach, created in: peachI am bowser,
created in: bowserI am running in pipe_adata: 123I am running in
pipe_bI am running in pipe_cI am running in stage_1data: 123I am
running in stage_2I am running in lose_1data: 123I am running in
lose_2I am running in lose_3I am running in lose_4data: 234I am mario,
and game is over in: ~marioI am peach, and game is over in: ~peachI am
bowser, and game is over in: ~bowser
这像一首诗或者歌曲,让我想起另一个宣言 "I'm youth, I'm joy"。少年总会成长,承担起责任。不是保护公主,而是其他的什么人。
承担责任,也不是念两句诗,或者唱几句歌,甚至也不是声明 我愿意为你承受何种苦难。
承担责任,是虽然这些日子没有什么不同,但是如果没有你的工作,这些日子将非常不同,非常糟烂;承担责任,是拿起工具,开几亩自留地,种上土豆白菜。
这样的工具,能让你使某些人的世界不同的,有很多。其中有两种,分别叫做antlr 和 stringtemplate。
祝你开垦顺利,有收获。