程序语言的词法分析与语法分析
计算机是无法对程序语言的产生人一样的“理解”的,对于计算机一个程序只是一个字符串。因此要在计算机上运行一段程序就需要把程序语言转化为机器语言,这个过程就是“编译”。编译的第一步(通常称为前端)就是对程序语言做词法分析和语法分析 。
词法分析
词法分析的任务是把一整串程序代码切分成一个一个的token,token就是数字、变量名、关键字、函数名这些程序语言中的基本单位。我们在这里考虑最简单的While语言,它包括以下token:运算符(+,-,*,/,...
)、赋值符号(=
)、间隔符((,),{,}
)、自然数(0,23,...
)、变量名(x1,yyy,__x,...
)、关键字(if,while,var,...
)、函数名(read,write
)。例如,对于表达式(1+x)*y
,我们期待词法分析结果为TOK_LEFT_PAREN TOK_NAT(1) TOK_PLUS TOK_IDENT(x) TOK_RIGHT_PAREN TOK_MUL TOK_IDENT(y)
因此,词法分析本质上是做字符串匹配——用token匹配程序代码。我们可以用“正则表达式”这一语言来描述字符串(token),正则表达式\(r\)递归定义如下:\(r=\epsilon\)表示空串;\(r=c\)表示单个字符\(c\);\(r=r_1|r_2\)表示\(r_1\)或\(r_2\);\(r=r_1r_2\)表示\(r_1\)和\(r_2\)首位连接;\(r^*\)表示空或\(r\)或\(rr\)或\(rrr\cdots\)。通常我们有一些惯用的简写,用[a-cA-C]
表示a|b|c|A|B|C
;用r?
表示r|ε
。于是变量名的正则表达式写作[_a-zA-Z][_a-zA-Z0-9]*
,自然数的正则表达式写作0|[1-9][0-9]*
。
匹配字符串的最常用办法就是转化为在自动机上做状态转移(走路)。我们很容易把正则表达式转化为一个非确定性有限状态自动机(NFA,Nondeterministic Finite Automata),在这种自动机上状态转移的字符可以是ASCII码或\(\epsilon\)(空),同时从一个状态出发一个字符有多种转移方式。我们在自动机上选出起点状态和若干终点状态,期待从起点出发能到达终点当且仅当走过的所有状态转移字符一起串成的字符串落在正则表达式内。为此,对于单个字符\(c\)只需要一个起点和终点中间连接\(c\)这一条状态转移边;空字符\(\epsilon\)只需要一个起点和终点以及一条\(\epsilon\)边;\(r_1|r_2\)我们从一个起点出发连两条\(\epsilon\)边连向\(r_1,r_2\)的自动机的起点,再让它们的终点以\(\epsilon\)边连向一个终点;\(r_1r_2\)只需把\(r_1\)的终点用\(\epsilon\)边连向\(r_2\)的起点;对于\(r^*\),我们从\(r\)的终点连一条\(\epsilon\)边向\(r\)的起点,再从起点用一条\(\epsilon\)边连向\(r\)的起点。至此,我们已经能把一切正则表达式转化为NFA了。
然而在实践中我们是不可能用NFA来匹配字符串的,因为从一个状态出发我们并不知道应当选择哪条路径。而接下来我们说明,我们可以高效地把NFA转化为DFA(有限状态自动机,Deterministic Finite Automata),这种自动机的转移边只有ASCII码而没有\(\epsilon\)边,从一个状态出发一个字符只有一种转移方式。方法是:DFA的每个状态是NFA状态的集合。从起点出发,把所有只经过\(\epsilon\)边能到达的状态集合作为DFA的起始状态。对于每个DFA的状态,它经过字符\(c\)能到达的状态是,把NFA中这些状态对应的状态只经过\(\epsilon\)边或\(c\)边能到达的新状态收集起来作为一个新的状态集合。可以证明这样构建得到的一定是正确的DFA。
语法分析
在词法分析以后,我们希望能够得到表达式和程序语句的抽象语法树。例如对于(1+x)*y
,我们希望根节点为*
,左儿子为+
,右儿子为y
,+
的儿子分别是1
和x
。
上下文无关语法
对抽象语法树的构建是基于一套“上下文无关语法”完成的。一套上下文无关语法是一系列“产生式”,它规定了表达式和程序语句的基本单元(终结符)、包含关系(产生式、非终结符)以及一个初始符号。例如
S -> S ; S E -> ID L -> E S -> ID := E E -> NAT L -> L , E S -> PRINT ( L ) E -> E + E E -> ( E )
就是一套上下文无关语法,S
是初始符号,表示程序语句。这套语法允许程序语句的顺序执行、赋值语句、打印语句。E
表示表达式,这套语法允许表达式的加法运算。L
是表达式的参数列表。
如果能把一个程序语句,例如ID := ID + NAT; PRINT(ID, ID)
按以上规则一步一步缩减到初始符号S
,我们就完成了抽象语法树的构建。这个过程称之为“归约”。与之相反的称为“派生”,是从S
出发按照语法规则展开得到具体的程序语句。规约是派生的反向,我们先来考察派生。
派生
我们规定在派生时每次都展开最左侧的非终结符,这称为最左派生(当然也可以有最右派生)。例如对上述语法,我们有如下最左派生:
S -> S; S -> ID := E; S -> ID := E + E; S -> ID := ID + E; S -> ID := ID + NAT; S -> ID := ID + NAT; PRINT(L) -> ID := ID + NAT; PRINT(L, E) -> ID := ID + NAT; PRINT(E, E) -> ID := ID + NAT; PRINT(ID, E) -> ID := ID + NAT; PRINT(ID, ID)
有了派生过程以后,我们就得到了抽象语法树。树的根节点是S
,它有三个子节点S
,;
,S
……以此类推。
我们希望对于一个标记串只对应唯一的抽象语法树。并不是所有上下文无关语法都有这样的唯一性的。如果一个标记串对应的抽象语法树不唯一,我们就称它出现了“歧义”。例如:E -> ID, E -> E + E, E -> E * E, E -> ( E )
这一上下文无关语法没有规定乘法和加法的优先级,因此对于E+E*E
这样的标记串就能对应两个完全不同的抽象语法树。为此,我们要重新设计语法消除歧义:E -> F, E -> E + E, F -> F * F, F -> ( E ), F -> ID
。如果一个标记串没有歧义,那么只有唯一的最左派生可以生成这一标记串。
规约
规约是派生的反过程。由于规约和派生的过程是相反的,最左派生对应着最右规约,最右派生对应着最左规约。现在我们要讨论,在给定一个标记串时,如何对它做最左规约。
我们采用一个称为“移入规约分析”的方法。从左到右扫描标记串,每一时刻我们可以选择做两个操作中的一个:移入或者规约。移入是指把扫描线向右移动一格;规约是指对紧贴扫描线左侧的标记串根据语法规则做一步代换。以下是一个在E -> F, E -> E + E, F -> F * F, F -> ( E ), F -> ID
语法下对ID+ID+ID
做移入规约分析的例子:
| ID + ID + ID -> ID | + ID + ID -> G | + ID + ID -> F | + ID + ID -> E | + ID + ID -> E + | ID + ID -> E + ID | + ID -> E + G | + ID -> E + F | + ID -> E | + ID -> E + | ID -> E + ID | -> E + G | -> E + F | -> E |
移入判断
要判断某一时刻能否做移入,就是要分析扫描线左侧的结构。如果在移入一个字符后形成的新的结构是可行的,我们就认为移入是可行的。对于任何一个扫描线左侧结构,它都应当可以被拆分成若干段,使得每一段是某个产生式右侧符号串的一个前缀,而这个产生式一定是前一段中缺失的那个非终结符对应的产生式。只有这样才是一个可行的结构。于是我们可以从初始状态出发,写出产生式,在产生式右侧和当前标记串前缀第一个不匹配的地方截断,然后写出截断后的第一个非终结符对应的所有产生式,在所有产生式与标记串不匹配的地方截断,再依次写出新的产生式……如果进行到某一步以后,发现没有可以匹配的产生式了,就意味着当前扫描线左侧结构是不可行的。例如,在E->F, E->E+E, F->F*F, F->(E), F->ID
语法下判定E+F+|
是否是可行的扫描线左侧结构:
初始状态是.E
,句号表示截断点。由于截断点后的第一个非终结符为E
,因此要考虑所有E
的产生式,E
又有右侧紧跟F
的产生式,所以又要写出F
的产生式,然后又要写出G
的,至此不再有非终结符了。因此初始时我们要考虑以下产生式:
START -> . E E -> . F E -> . E + F F -> . G F -> . F * G G -> . ( E ) G -> . ID
得知第一个字符为E
以后,截断点向右移动一位,只需考虑以下产生式:
START -> E . E -> E . + F
下一个是+
:
E -> E + . F F -> . G F -> . F * G G -> . ID G -> . ( E )
然后是F
:
E -> E + F . F -> F . * G
然后是+
。但此时截断点后面没有能匹配加号的了,由此可得E+F+
不是一个可行的扫描线左侧结构。
上面这个过程可以用一个NFA来完成,一个标记串是否是一个扫描线左侧的可行结构就看它能否被这个NFA接受。
规约判断
我们已经看到,能否规约依赖的是标记串对应的右侧产生式前缀之间的包含关系。我们可以采用一个基于First集合和Follow集合的方法来判断规约。对于终结符X
和任意的标记Y
,定义X
是Follow(Y
) 的元素当且仅当... Y X ...
是可能被完全规约的。X
是First(Y
) 的元素当且仅当X ...
是可能被规约为Y
的。X
\(\in\) Follow(Y
)意味着考虑规约时X
被允许follow在Y
以后;X
\(\in\) First(Y
)意味着考虑规约时X
被允许作为Y
中的第一个标记;
我们可以这样计算First集合
• 对任意终结符 X
, X
\(\in\) First(X
) 。
• 对任意产生式 Y -> Z ...
, First(Z
) \(\subseteq\) First(Y
) 。
这样计算Follow集合:
• 对任意产生式 U -> ... Y Z ...
, First(Z
) \(\subseteq\) Follow(Y
) 。
• 对任意产生式 Z -> ... Y
, Follow(Z
) \(\subseteq\) Follow(Y
) 。
如果紧贴扫描线左侧的标记没有落在其左侧标记的Follow集合中,那么此时一定不可以进行规约操作。
冲突
在移入规约分析中,如果某一时刻只能选择移入,那么自然选择移入;如果只能选择规约并且只有唯一的规约方式,那么自然选择这样规约;如果既不能移入也不能规约,那么说明表达式不合语法,失败。
问题是:如果某一时刻又可以选择移入又可以选择规约,或者只能规约但是存在多种不同的规约方式,这时怎么办呢?前者称为移入/规约冲突,后者称为规约/规约冲突。冲突并不是移入规约分析能解决的问题,而是语法设计的问题。一个好的上下文无关语法设计应当不容许冲突的发生,使得移入规约分析能顺利的完成,抽象语法树得到顺利的构建。