编译原理学习笔记01
-------------------------------------------------------
-
Lexical analyzer生成token给parser用,分析过程还得和symbol table交互;在分析过程中需要干掉comments和whitespace(whitespace指的是blank,newline和tab或其他用于分隔token的东东,而不仅仅是空格)。
-
token的样子是(token-name,attribute-value),其中attribute-value对保留字、操作符是不需要的;对于token-name是id的情况,attribute-value一般是指向其所在符号表中的项的pointer;对于数字这样的输入,可以让attribute-value表示对应的值,但实际一般还是用一个pointer指向表示它的字符串。
-
lexeme是源程序中的具体字符序列;pattern是对模式的描述;token可看成对lexeme的泛化(类似于OO中的class)
-
识别lexeme时需要向前看几个字符(比如看到<有可能是<=或<);同时为减少io读取次数,使用双buffer。
-
每次需检查buffer终点并确定读入的字符是什么,为求统一使用sentinel(输入中不会出现的字符,如eof)
-
DFA$\subset$NFA,DFA初初始态不可为$\varepsilon$且每一个状态只有一个出边;DFA和NFA等价,可以类比群中的加法和乘法;其中DFA中的一个状态可看成NFA中的状态组成的集合(subset construction)。
编译原理学习笔记02
-
- 为每个NFA中的状态$i$生成一个nonterminal $A_i$
- 若对于输入$i$有从$i$到$j$的状态转移,添加production $A_i\rightarrow aA_j$(若$a=\varepsilon$则添加production $A_i\rightarrow A_j$)
- 若$i$为初始态,令$A_i$为grammer的start symbol;若$i$为终态添加$A_i\rightarrow\varepsilon$
- regex(或FA)不能表达全部CFG能够表达的语法(“有限状态机不会数数”)。比如$L= \{a^nb^n|n\ge1\}$,可以用反证法证明它不能用有限状态机表述。
- 解释性语言天生就比编译型语言跨平台;它们至多只需要有统一的解释器罢了。Java就是这样一个另类(半编译半解释),而且还假装把这个解释器用的虚拟机做得像真的机器似的。所以虚拟化很重要啊,当解决不了问题或解决问题的方法不够妥当时,考虑加一个中间层的确有帮助。
- derivation和parser tree之间存在$1\rightarrow n$的关系,因为derivation是整个过程而parser tree是最终的结果而已;不过如果限定某种派生的准则(如leftmost/rightmost)两者就一一对应了。下面是两种不同的派生:
- parser tree是具体的语法树,其内部节点必然是非终结的(nonterminal,表示规则名),关注的是树节点的类型;syntax tree又称抽象语法树(AST),其内部节点仅仅表示程序结构。($S_{nonterminal}\varsubsetneqq S_{programming constructs}$)。parser tree比较严格,比如9-5+2中的9一定得画成像$number\rightarrow 9$这样的形式。大多数情况下,parse tree不如AST合适,但parser容易自动生成parse tree。
- JavaCC是词法分析器和语法生成器,用的是top-down的方式生成LL(k)语法,所以left recursive不能用。Lex是词法扫描器,Yacc是语法分析的,bottom-up。其中概念较多,以后单独对这部分内容作笔记。
编译原理学习笔记03
BNF是CFG的一种表示方法,有这样的形式:
1
|
symbol ::= __expression__
|
其中symbol(三角括号不是必须的)是nonterminal;__expression__是一个包含symbol的表达式,仅可以由“|”连接;尽在BNF的右边出现的symbol是terminal。即只要在左边出现过一次,任何一个symbol就失去了成为terminal的资格。反过来看,现实世界其实只有terminal出现,任何句子、词语最终都是terminal组成的;nonterminal都只是便于理解加的中间层。
ABNF仅在BNF上加了注释信息,换汤不换药:
1
|
rule ::= definition ; comment CR LF
|
EBNF是基于BNF的一种扩展,按照wiki的定义有这些记号:
但是更多情况下涉及到的是下面的符号:
- (token) 打包成整体
- token? $\le 1$个token
- token* kleene start,$\ge 0$个token
- token+ kleene cross,$\ge 1$个token
- . 匹配任意一个token
- ~token 没有token(即“非”)
- a..z a到z间的所有字符
具体是什么好像没有绝对的说法,只是为了便于表达;ANTLR用的是后面的说法。
BNF和EBNF可以相互转化,既可以用于lexer规则也可用于parser规则(这也可以从CFG比regex牛逼看出,而regex完全可以搞定词法)。
lexer规则(BNF左边)要么是literal要么是是其他规则的引用;parser规则可以引用词法规则、语法规则,也可以包含literal。比如下面这个例子:
1
2
3
4
5
|
decl :'int'ID'='INT';'// E.g.,"int x = 3;"
|'int'ID';' // E.g.,"int x;"
;
ID : ('a'..'z'|'A'..'Z')+;
INT :'0'..'9'+;
|
编译原理学习笔记04_LL及谓词解析
-----------------------------------------------------
对于一个parser,规范的命名是LL(k),LR(k)之类。LL(k)的含义:第一个L只按从左到有顺序解析输入内容,第二个L表示自顶向下解析时按照从左到右顺序遍历子节点,k表示forward的token为k个。而LL(k)中的r表示的是自底向上解析时按照从右向左推导(从左向右规约)。
LL分析的时候先看到的是产生式左值,然后确定其是由什么右值构成的;于是要做的事就是迭代地匹配产生式,这涉及到了不断地猜测、回溯,通常将A的产生式分为first(A)和follow(A)(和lisp中的car/cdr看起来有些像哈>o<)。这里的“顶”应该是从语法树上看的。LR正好相反,是从产生式的右值开始分析的,开始拿到的是推导式右值中的一个文法(grammar)符号;也可以说是从语法树的叶子节点开始分析。
上下文无关文法(context free grammar)是指没有二义性的文法。一个具有二义性的语法可以参照C++:
1
|
T(a)
|
在只知道T和a是token的情况下,它可能指下面几种情况(或其中一部分):
- 函数声明,如int foo(int);
- 对象初始化,如Fraction f(42);
- 函数调用,如bar(-67);
- 强制类型转换,如float(11);
当然在C++手册中规定遇到这种情况声明(a)比表达式(b)(c)(d)优先级较高,所以在构造解析规则的时候通常隐含(用了if-else等分支结构)对解析选项进行了排序,例如:
1
2
3
|
stat:(declaration)=>declaration
|expression
;
|
也就是说只要看起来像声明了,就认为是声明;确定不是声明那种货色的了,才考虑它匹配表达式。但问题在于仍然没有办法区分(b)(c)(d)的情况。如果用LL(k)的话,因为在对象初始化前还有一个token(如Fraction),是可以区分(b)和(c)(d)。然而,仅凭这些还是无法区分(c)(d)。事实上这两者的CFG是一样的:
1
2
3
4
|
expr:INTEGER
|ID'('expr')'//bar(-67)
|ID'('expr')'//float(11);
;
|
这时就需要采用语义信息了,通常LL(k)是采用形如这样的谓词判断:
1
2
3
4
5
6
|
void expr() {
if ( LA(1)==INTEGER) match(INTEGER);
else if ( LA(1)==ID && isFunction(LT(1).text) ) «match-function-call »
else if ( LA(1)==ID && isType(LT(1).text) ) «match-typecast »
else «error »
}
|
在编写解析不同同种语言的解析器是也会用到谓词逻辑;比如gcc的那100+个坑爹的选项,比如java的各个兼容模式……
编译原理学习笔记05_解析树和AST
----------------------------------------------
识别语句时,需要关心元素的顺序,同时也要考虑元素之间的关系(嵌套);用树状结构较好(同时可参考xml及lisp,话说含嵌套的list应该就可以理解为tree了吧);parse在进行自顶向下解析的时候一般是可以描绘出parse tree的(它恰好是parse的执行轨迹)。比如在解析x=0;时是通过这样的规则的:
1
2
3
4
5
6
7
8
|
statement:assignment
|...
;
assignment:ID'='expr
|...
;
expr:...
;
|
用(已经简化了的)解析树表示就是:
但是这其中涉及到的内部节点其实只在解析过程中有用,那些assignment这类的都只是中间表达形式,真正有用的是叶子节点(terminal);同时这样的树遍历起来也不方便。所以只要能解析(在这里就是抽象地表达赋值语句)并构建其他数据结构(可能下面的阶段会用到),用AST就行了。
在AST中,操作符的优先级越高,位置越低;这和写规则的时候用到的中间变量的层次是一样的。比如含有+,-,*,/的单数字文法:
1
2
3
4
|
expr:expr+term|expr-term|term;
term:term*factor|term/factor|factor;
factor:digit|(expr);
digit:[0-9];
|
前序表达式可以唯一确定树的形式,这可以作为树的文本表示形式(又是lisp)。
对于不含可执行语句的语句,可定义伪操作(如graphivz);对输入中没有作为root节点的token时可定义虚token(如c中的声明)。
理论上说AST节点其实只要一种数据结构就OK了,这样设计的AST结构成为同型AST。下面是java的一个实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
publicclassAST {
Token token;
List<AST> children;
publicAST(Token token) {
this.token = token;
}
publicvoidaddChild(AST t) {
if(children ==null)
children =newArrayList<AST>();
children.add(t);
}
...
}
|
这里用于区分不同类型语句的其实是靠token的类型来完成的,就像这样tree.token.getType()。这样的设计渣的地方显而易见。首先类型token的属性而不是节点的属性,带来的性能上的问题很大;很明显一般来说这样的代码是需要重构的。其次如果要添加额外信息的话不管三七二十一所有节点都得加上,代价太大。虽然simple&stupid,但对于OO思想占主流的时代这样的数据结构显然不能令人满意啊(这不gcc都用C++写了)。因此有了异型AST(又分为规范子节点和不规则子节点两种),以后详述。
编译原理学习笔记06_antlr初步
权威解释见官方或wikipedia,我只负责八卦。作者Terence Parr似乎是个有洁癖的人,写了antlr第一个版本之后觉得效率不高又写了v2,因为同样的理由又推倒重来写了v3。尽管以java为主打,但这个antlr非常有野心,Java/C#/Python/Ruby/Scala等语言通吃;由grammar生成lexer和parser使得这玩意儿非常适合教学用,也非常适合菜鸟级程序员提高编写编译器的自信。虽说没有JavaCC这么正统,但是个人觉得非常实用,现在Hibernate framework/Jython/Groovy可都是基于antlr的哦!这哥们还开发了一套template engine,同样是一个妄图大一统文本解析的干活。有了antlr这把锤子他就开始到处乱敲:paper自不用说,然后是《The Definitive ANTLR Reference》、《Language Implementation Patterns》。后者近期被译成中文,号称“屠龙”;虽说的确过誉了,但是不得不承认这本书的确深入浅出。尽管作者一直在自卖自夸antlr和StringTemplate,但是对于java系的人来讲这的确是学习编译原理基础知识的好东西;连Guido大叔和Dalvik设计者Dan Bornstein也对这本书颇有好评。
安装工具
antlr:http://www.antlr.org/download.html
对于我这样的习惯了eclipse的懒人来说antlrworks并不是上上之策,插件才是王道http://antlrv3ide.sourceforge.net/。
该插件特点:
- 新建一般的java project(如果要生成的目标语言是java的话)并convert成antlr即可,之后只需写grammar文件。
- 默认情况下保存每一次grammar文件就会进行自动生成对应的lexer和parser的java文件(默认target language是java)和对应tokens文件;每次打开eclipse时会重新解析一次。
- 似乎生成的lexer和parser并不会添加grammar文件所在package信息(吐槽:这一点和上面一个特点加起来足以让人抓狂!!!)。
- 编辑器的content assit功能和java编辑器的功能有不少出入,括号、引号、缩进等功能做得并不好。
- grammar(g文件)除编辑器的两个tab Interpreter和Railroad view非常棒,一个用于傻瓜式写,一个用于GUI显示,并且可以导出成html。
还可以考虑使用StringTemplate:http://www.stringtemplate.org/download.html
开始使用
以含有加减乘法的计算器且目标语言java为例。
- 在g文件中写出所要定义的grammer(一般是词法和语法的综合)
grammar Expr;
prog : stat+;
stat : expr NEWLINE
| ID '=' expr NEWLINE
| NEWLINE
;
expr: multiExpr (('+'|'-') multiExpr)*
;
multiExpr : atom('*' atom)*
;
atom : INT
| ID
| '(' expr ')'
;
ID : ('a'..'z'|'A'..'Z')+ ;
INT : '0'..'9'+;
NEWLINE : '\r'?'\n';
WS : (' '|'\t'|'\n'|'\r')+{skip();};
- 生成的ExprLexer.java文件中会将'(',')'等涉及判定的字符用新的identifier如T__8等来对应,识别这样的字符的方法形如T__8。新生成的ExprLexer类继承了antlr定义的Lexer类,封装了一些方法。ExprParser继承Parser类,诸如expr等g文件中的左式会有个对应的方法(例如expr())来处理它;为了处理出错和从错误状态恢复,该类还生成了FOLLOW_expr_in_stat34这样的变量并调用了pushFollow()这类方法。
- 这样,从输入流中可以生成lexer对象,将由lexer得到的tokens作为输入就得到一个parser对象;调用顶层的方法就可以完成语法分析了。
1
2
3
4
5
6
7
8
|
publicstaticvoidmain(String[] args)throwsIOException, RecognitionException {
// TODO Auto-generated method stub
ANTLRInputStream input =newANTLRInputStream(System.in);
ExprLexer lexer = newExprLexer(input);
CommonTokenStream tokens =newCommonTokenStream(lexer);
ExprParser parser =newExprParser(tokens);
parser.prog();
}
|
- 上述代码对于符合grammar的输入不会有输出,如果需要给出相应的输出的话仍可以在g文件中添加(java)代码。
- 添加包含的package
1
2
3
|
@header{
importjava.util.HashMap;
}
|
- 添加parser中的成员变量
1
2
3
4
|
@members{
/** Map variable name to Integer object holding value */
HashMap memory =newHashMap();
}
|
- 添加parser中左式处理函数的返回值(前面代码中的方法的返回值都为void),并且在每个生成式上在{}里添加特定的方法。值得注意的是对于诸如INT这样在lexer中得到的Token用的是antlr定义好的Token类的一个对象,包含了getText()方法;故不需要定义INT的text返回值。如下面的一段:
expr returns [int value]
: e=multExpr {$value = $e.value;}
( '+' e=multExpr {$value+=$e.value;}
| '-' e=multExpr {$value-=$e.value;}
)*
;
multExpr returns [int value]
: e = atom {$value=$e.value;}('*' e=atom {$value *= $e.value;})*
atom returns [int value]
: // value of an INT is the int computed from char sequence
INT {$value=Integer.parseInt($INT.text);}
| ID // variable reference
{
// look up value of variable
Integer v = (Integer)memory.get($ID.text);
// if found, set return value else error
if(v!=null) $value = v.intValue();
else System.err.println("undefined variable "+$ID.text);
}
// value of parenthesized expression is just the expr value
| '(' expr ')'{$value=$expr.value;}
;
--------------------------------------------------
参考自《The Definitive ANTLR Reference》,又是一本没付版权费的电子书:(
PS:通过antlr生成的代码,我才知道java对于退出多重循环有个不错的做法,就是在指定的循环前面添加上某个label,然后在要退出的时候加上break label。我们都知道尽管java把goto作为保留字,但是在咱码农是没法用goto的。使用这样break的做法肯定没有C/C++来得凶残;但是无疑很好地避免了goto带来的无结构编程恶习,同时又保留了灵活性(似乎该对D.E.Knuth致敬?)。