20111205

使用Antlr+Stringtemplate生成method chaining,一个不太简单的案例(5)

使用Antlr+Stringtemplate生成method chaining,一个不太简单的案例(5)

- 一个悲惨的开始

这个悲惨的开始是我今天的生活,而不是 antrl+stringtemplate。如果你从头
看到这里,最苦的地方已经过去了。

昨天一点或两点睡的时候,心里想,这早晨6:40起来赶班车,对付吧。结果没睡
着,3:17,爬起来写了半张白板,又睡下,又爬起来写了不少。早晨,如果我没
记错的话,第一次,我没有听到闹表。后来复查了一下设置,没错,它响了,我
没有听到。约的是9点,8:55,孙同学电话来,她到应化所了。我当时头脑蒙
着,对话大致是这样的:啊,你去那干什么。应该在软件所啊。你回来吧。不用
急,我也得迟到。

结果,迟到26分钟。屋子不大,但是有一屋子人坐好了等你。

唉。

我们之悲惨缘于很多因素,其中之一希望一蹴而就。我们希望开个企业,然后它
自己就能赚钱了,再也不用管;我们希望上了大学,然后就能玩了;我们希望在
什么什么之前表现得非常怎么怎么的,然后以后就可以再也不怎么怎么的了。

后来发现,由于阻尼的存在,理想的无磨擦世界竟然是不存在的。

我也也希望一下子就把什么学明白,或者得到什么结果。坐着抻懒腰也能减肥
啦,在家里玩也能赚钱啦,吃点什么病就好了,从此不再复发啦。

而现实世界的规律有时原本就复杂,或者简单的那些规律,比如麦克斯韦四个方
程、爱因斯坦的质能方程,这些简单的规律却需要复杂的基础才能理解。我们往
往忘了,理解这些规律的复杂基础,这一工作本身也异常艰苦。

编译器生成器 antlr 的使用也是如此。

- 上次忘了的

在语法解析中,其中有个符号 ^ ,它是AST的根。至于什么是AST,什么是
根,RTMF。我提到这个概念的动机,就是想说它时挺重要,你可能会用到。

另外,我们写的语法解析的表达式,叫 EBNF,即 扩展的巴克斯-诺尔范式。编
译原理书中提到。

- 语义

我们用语法匹配了输入的源代码,接下来,就要在匹配的时候做点什么。这就是
语义。

我们仍然以生成头文件为例。

语义也写在一个.g文件里,叫decl.g,后面会和昨天我们写的 pipe.g 一起由
antlr一起生成为杨氏语言的编译器。因为anltr用于生成编译器,所以是编译器
生成器。

之所以在使用语义的decl.g文件时还需要pipe.g这个语法文件,是因为我们要使
用pipe.g中的语法规则。decl.g和pipe.g共享一些语法规则,decl.g利用这些语
法规则为纲,在匹配到某个结点的时候执行特定的动作,这称为 语法制导的翻
译。

语法制导,就是因为沿语法树遍历结点。这个语法树,即AST,pipe.g(严格的
说,由它生成的编译器的parser部分)的输出。

我们来看decl.g的内容。

-- 头部

我也是词汇贫乏,想不起来什么新词,还是叫头部吧。手册里可能有专门的名字
人,但是我猜距离优雅应该同样很远。

代码1:
1 tree grammar decl;
2
3 options {
4 tokenVocab=pipe;
5 output=AST;
6 ASTLabelType=CommonTree;
7 }
8
9
10 @header {
11 import org.stringtemplate.v4.*;
12 import java.util.HashMap;
13 import java.io.FileWriter;
14 }

第1行,表示这是grammar,并且,这是用来遍历树的,不是lex or parser。

这里的decl必须与文件同名,也就是说 tree grammar 什么东西必须放在 什么东
西.g文件 里。不然,其实也好解决,antlr会报错。

上次忘了,grammar pipe 的那个文件,也同理,要叫做 pipe.g。

第3行至第7行,一些配置。其中第4行,表示这一grammar将与pipe共享相同的
token。

说到这里,题外话,读技术文章与小说有一处相同:如果你从中间读起,要么读
不懂,需要看前面,要么你已经看过这个故事的电影或者电视剧或者缩写版了。
还有一种可能,就是那个小说非常地好,或者非常得简单。我曾经捡到几张当时
称为大书的小说页面,看了半个下午。后来知道那是 天龙八部,萧峰用拳头慢
慢钻透墙,救段的那个场景。相信如果你看我这篇,从中间看起的话,断断不会
有那个效果,一定如坠五里雾中,除非你是来指点我的。

第5行,
5 output=AST;
是很有意思的一行。它告诉antlr,我要输出一个AST。这pipe.g是一样的。后
面,我们会看到,有个东西能遍历AST。

这里,因为马上就要有语义,已经是杨氏语言编译器的末端,其实也可以输出别
的东西,比如直接的结果。我们之所以选择AST的原因,请参见
[http://www.cnblogs.com/sonce/archive/2011/03/13/1982555.html],探索
Antlr(Antlr 3.0更新版)。感谢这位牛人教导我明白了AST与SAX/DOM间的类比
关系。谢谢。

第10行至第14行,是因为我们的语义动作中要用到这些类,所以import进来。你
猜对了,实现动作的,就是Java语言。

-- 规则,语法制导的翻译

接下来的部分,就是在语法的指引下,我们来告诉antlr,遇到某些结点或者
token,我们需要做哪些特定动作。

代码2:
1 starting : game+ ;

这一行简单,简单到与昨天的pipe.g没有任何区别。也就是说,遇到starting的
时候,解析为+个game,然后呢,啥也不错,没有动作。

接下来是规则game,这段相当之长,请有心理准备。我把它拆成了几段来介绍。

--- 开始之前

代码3:
1 //^(CLASS SYMBOL_NAME (node)*)
2 game
3 :
4 {
5 STGroup header = new STGroupFile("st/header.stg");
6 ST class_delc = header.getInstanceOf("class_delc");
7 }
8 ^(CLASS SYMBOL_NAME
9 {
10 class_delc.add("CLASS_NAME", $SYMBOL_NAME.text);
11 class_delc.add("CLASS_UPPER",
$SYMBOL_NAME.text.toUpperCase());
12 }

第1行是用来我自己备忘的。下边的动作把语法打得七零八碎的,不然我根本记
不住自己正写的动作匹配的是哪个结点。

1 //^(CLASS SYMBOL_NAME (node)*)
这一行,刚好就是pipe.g的game规则的rewrite规则。如果你还记着的话。不,
你十有八九不会记得,你得翻回昨天的博客去看pipe.g的game规则。

这里,就匹配pipe.g的AST输出的东西。

{} 里面的东西,就是动作;{} 以外的,就是被折散了的这个东西:
1 //^(CLASS SYMBOL_NAME (node)*)

第4行至第7行意思是,在开始匹配子结点以前,初始化模板相关的东西。模板,
就是StringTemplate。

5 STGroup header = new STGroupFile("st/header.stg");
6 ST class_delc = header.getInstanceOf("class_delc");

第5行,从文件 st/header.stg 中载入 string template group。文件
st/header.stg 的内容和解释,请参见昨天的博客。

第6行,我们要使用这个 string template group 中的 class_delc 模板。这一
模板的定义和解释,请参见昨天的博客。

--- 一个简单的动作

接下来,
8 ^(CLASS SYMBOL_NAME
9 {
10 class_delc.add("CLASS_NAME", $SYMBOL_NAME.text);
11 class_delc.add("CLASS_UPPER",
$SYMBOL_NAME.text.toUpperCase());
12 }

这是我们遇到的第一个真正的动作,语法导制下的语义。

CLASS 结点是一个imaginary结点,有印象没?参见……

当我们遇到SYMBOL_NAME结点的时候,我们要执行第10行至第11行的动作。

这个动作的意义是,向模板 class_delc(它是谁呢,看上面第6行)中填加变
量,这个变量的名字叫做 CLASS_NAME,它的值是$SYMBOL_NAME.text,即
SYMBOL_NAME 这个结点的文本。

比如 mario。

CLASS_NAME,参见昨天的博客中 header.stg 文件中的 class_delc。你是不是
把今天和昨天的博客都打开来对比着往下行进呢?我也在这么做。如果你也是的
话,请感慨一下,这个世界没有多少记忆超群的人,至少你我不是。握手。

11 class_delc.add("CLASS_UPPER", $SYMBOL_NAME.text.toUpperCase());

这行就简单了,我们要再加入一个变量--你可能已经找到了,模板class_delc有
三个参数,还有一个在后面--这第二个变量是CLASS_UPPER,值是
$SYMBOL_NAME.text.toUpperCase()。

$SYMBOL_NAME.text是一个String,所以toUpperCase()可以RTFM
[http://docs.oracle.com/javase/1.5.0/docs/api/java/lang/String.html#toUpperCase()]

有的同学已经想到了,有时还可以把这个text转成int,转成float。

此外,有的同学可能也注意到了,加入变量的时候,我们总是关注两个东西:变
量的名字,变量的值,这就像map,键 和 值。

以上,就是语义动作。没有看到输出?因为我们的动作,就是把一些变量放到模
板里,后面统一渲染。没错,还是这个小资词汇,render。那个时候,我们就得
到了真正的输出。

我们为什么不直接输出,而要使用stringtemplate这么个间接的东西呢?跟我们
不用CGI perl的道理是一样的,因为很多要输出的东西,是固定的,而要填充的
东西,那些占位符,只是其中的少数。我们不想把固定的东西放在程序的逻辑中
输出。

--- 一个复杂的动作,带有聚合的,应用模板于变量之上

接下来,我们遇到了一个复杂的语义动作。

代码4:
13 (node
14 {
15 HashMap mf = new HashMap();
16 mf.put("class_name", $SYMBOL_NAME.text);
17 mf.put("function_name", $node.node_name);
18 mf.put("para_name", $node.para_name);
19 class_delc.add("member_function_list", mf);
20 }
21 )*)

看第19行,我们也是加入了一个变量,名为 member_function_list 。也许你还
记得,这本身就是一个模板(函数),我们要 apply that template on 这个传
入的变量的值上。那个函数只有一个参数,就是mf;而在那个函数中,我引用了
这个参数的成员。

代码4.1
1 member_function (mf) ::= <<
2 $mf.class_name$* $mf.function_name$($mf.para_name$);
3 >>

第15行,我们建立一个HashMap,它有来形成聚合(aggregation),也就是说,
传一个对象,就是mf,进去。

我们看到,这个对象有三个成员,
16 mf.put("class_name", $SYMBOL_NAME.text);
17 mf.put("function_name", $node.node_name);
18 mf.put("para_name", $node.para_name);
刚好与代码4.1,也就是header.stg中的函数里引用的变量的成员对应。

19 class_delc.add("member_function_list", mf);
我们把做好的这个对象做为变量加进去。

以上,是一个复杂的动作,带有聚合的,需要应用模板(函数)的。

--- 渲染

当我们把模板中所有的变量都赋了值,就可以渲染模板了。

代码5:
22 {
23 String result = class_delc.render();
24 System.out.println(result);
25
26 try{
27 FileWriter fw = new
FileWriter("method_chaining_demo/"+$SYMBOL_NAME.text+".h");
28 fw.write(result);
29 fw.flush();
30 }
31 catch (java.io.IOException e)
32 {
33 System.err.println(e);
34 }
35 }
36 ;

第23行,渲染模板。渲染这个词挺优雅的,实质就是得到一个字符串--把模板里
的占位符,那些洞,都用变量填上,然后把模板作为字符串返回来。

第24行,把这个字符串输出到控制台。

第26至第35行,是把这个字符串输出到磁盘文件中,并以
$SYMBOL_NAME.text+".h" 作为文件名。这里的$SYMBOL_NAME,根据语法
1 //^(CLASS SYMBOL_NAME (node)*)
正是 类名mario。

之所以要操作文件的原因,是因为我还要生成 cpp,要生成go.cpp(driver),并
且不希望自动为输出文件命名,而不希望由 go.sh 负责命名。

我们以上为模板中的占位符赋值了变量。事实上,即使不对任何变量赋值,也可
以渲染模板。stringtempalte会认为那些变量都是null,直接跳过,输出占位符
没有被代换的模板。

--- 语义动作的返回值

在 antlr 中,动作还可以有返回值。我们在上面的代码6的第17行和第18行,引
用过node规则的返回值。

17 mf.put("function_name", $node.node_name);
18 mf.put("para_name", $node.para_name);

接下来,是node规则的动作。

代码6:
1 //^(NODE SYMBOL_NAME (PARA INT)?)
2 node
3 returns [String node_name, String para_name]
4 @init {
5 $node_name = "";
6 $para_name = "";
7 }
8 :
9 ^(NODE SYMBOL_NAME (PARA INT
10 {
11 $para_name = "int par";
12 }
13 )?)
14 {
15 $node_name=$SYMBOL_NAME.text;
16 }
17 ;

第3行,表示 返回值分别为 String node_name, String para_name。当在上一
级规则中引用的时候,我们就使用 $node.node_name.text,
$node.para_name.text 这样的形式。

是的, antlr的规则可以有多个返回值,这与C/C++不太一样。

第4行至第7行,是初始化部分,在匹配这条规则之前执行。这与前面代码3中的第
4行至第7行的不同之处在于,代码3是执行于某个 alternative(若干个由 | 分
隔开的规则匹配"路径") 之前,而这里的初始化,是在整个规则所有的
alternative之前。

我们在初始化部分中把要返回值赋值为空串了。也可以赋值为报错信息,如果在
语义动作中没有正确赋值,就报错。

9 ^(NODE SYMBOL_NAME (PARA INT
10 {
11 $para_name = "int par";
12 }
13 )?)

第9行至第13行的动作,是当 (PARA INT)? 存在的时候执行的,因为我们把动作
放在了这个位置:

(PARA INT 这个位置 )?

这完成了一个逻辑判断,即 只有当参数 PARA INT 存在的时候,才会对
$para_name 赋值。这样,当输入的杨氏语言源代码中有参数时,函数的声明就有
参数;当源代码中没有参数时,$para_name 就是空串,模板渲染以后,在函数
的参数列表里什么也没有。

14 {
15 $node_name=$SYMBOL_NAME.text;
16 }

这个动作,不同于带?的部分,只要匹配了 node 这条规则,就一定会在最后执
行--为 $node_name 赋值。这就是目标代码中的函数名。

今天又整了这么多,相信你也累了。明天继续,将介绍header.java将如何调用
lexer, paser, and tree walker,也将介绍脚本如何编译和运行一切。

附录 以上涉及到的源代码

1. st/header.stg

delimiters "$", "$"

class_delc(CLASS_UPPER, CLASS_NAME, member_function_list) ::= <<
#ifndef _$CLASS_UPPER$_H_
#define _$CLASS_UPPER$_H_

#include <iostream>

class $CLASS_NAME$
{
private:
int data;

public:
$member_function_list:member_function(); separator="\n"$
$CLASS_NAME$();
~$CLASS_NAME$();
};

No comments: