根据专栏编译原理之美 总结而来
编译器的组成
编译器由前端技术和后端技术组成。这里的"前端"指的是编译器对程序代码的分析和理解过程。 它通常只跟语言的语法有关,跟目标机器无关。而与之对应的"后端"则是生成目标代码的过程,跟目标机器有关。,如下图所示:

前端技术
编译器的"前端"技术分为词法分析、语法分析 和语义分析三个部分。
- 词法分析作用是把程序分割成一个个 Token 的过程,可以通过构造有限自动机来实现。
- 语法分析作用是把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树。
- 语义分析作用是消除语义模糊,生成一些属性信息,让计算机能够依据这些信息生成目标代码。
词法分析
编译器的第一项工作是词法分析。对于如下代码:
ini
int intA = 18
词法分析主要作用是把上述代码分割成 int
、intA
、=
、18
四个 token。其中四个token的类型是不同的, 比如int
是关键字类型、intA
是标识符类型、=
是赋值操作符类型、18
是数字字面量类型。需要注意词法分析的工作是一边读取一边识别字符串的 ,比如对于上面的例子,是先读取字符i
、在读取字符 n
、然后在读取字符 t
,后面的以此类推。要实现这个效果,一般需要三步:
第一步,写出每个词法的正则表达式 。比如对于 int
、intA
、=
、18
,其正则表达式为:
kotlin
// 匹配 int 关键字
Int: 'int'
// 匹配 intA 这种标识符类型
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
// 匹配 =
Assignment : '='
// 匹配 18 这种数字字面量类型
IntLiteral: [0-9]+
通过正则表达式,我们可以对各种类型进行严格的定义,而不是口头的描述
第二步,画出有限状态机。有了正则表达式,我们就可以利用它画出有限状态机。如下图所示:

由于 int
关键字和 intA
标识符之间存在冲突,这里需要在识别为标识符类型前,先看看它是否是关键字。
除了上面解决
int
关键字和intA
标识符之间存在冲突的方法,我们还可以
- 完成词法分析后,遍历所有ID token,如果标识符在关键字内,则修改该token类型
- 每次识别出标识符类型的token,都检查一下其是否在关键字内,如果在就修改token类型
第三步,编写代码。
java
public SimpleTokenReader tokenize(String code) {
...
DfaState state = DfaState.Initial;
try {
while ((ich = reader.read()) != -1) {
ch = (char) ich;
switch (state) {
case Initial:
state = initToken(ch); //重新确定后续状态
break;
case Id:
if (isAlpha(ch) || isDigit(ch)) {
tokenText.append(ch); //保持标识符状态
} else {
state = initToken(ch); //退出标识符状态,并保存Token
}
break;
case GT:
if (ch == '=') {
token.type = TokenType.GE; //转换成GE
state = DfaState.GE;
tokenText.append(ch);
} else {
state = initToken(ch); //退出GT状态,并保存Token
}
break;
case IntLiteral:
if (isDigit(ch)) {
tokenText.append(ch); //继续保持在数字字面量状态
} else {
state = initToken(ch); //退出当前状态,并保存Token
}
break;
case Id_int1:
if (ch == 'n') {
state = DfaState.Id_int2;
tokenText.append(ch);
}
else if (isDigit(ch) || isAlpha(ch)){
state = DfaState.Id; //切换回Id状态
tokenText.append(ch);
}
else {
state = initToken(ch);
}
break;
case Id_int2:
if (ch == 't') {
state = DfaState.Id_int3;
tokenText.append(ch);
}
else if (isDigit(ch) || isAlpha(ch)){
state = DfaState.Id; //切换回id状态
tokenText.append(ch);
}
else {
state = initToken(ch);
}
break;
case Id_int3:
if (isBlank(ch)) {
token.type = TokenType.Int;
state = initToken(ch);
}
else{
state = DfaState.Id; //切换回Id状态
tokenText.append(ch);
}
break;
default:
}
}
// 把最后一个token送进去
if (tokenText.length() > 0) {
initToken(ch);
}
} catch (IOException e) {
e.printStackTrace();
}
return new SimpleTokenReader(tokens);
}
private DfaState initToken(char ch) {
if (tokenText.length() > 0) {
// 这里表示,之前的token已经获取完毕
token.text = tokenText.toString();
tokens.add(token);
tokenText = new StringBuffer();
token = new SimpleToken();
}
// 这里确定现在是哪个状态
DfaState newState = DfaState.Initial;
if (isAlpha(ch)) { //第一个字符是字母
if (ch == 'i') {
newState = DfaState.Id_int1;
} else {
newState = DfaState.Id; //进入Id状态
}
token.type = TokenType.Identifier;
tokenText.append(ch);
} else if (isDigit(ch)) { //第一个字符是数字
newState = DfaState.IntLiteral;
token.type = TokenType.IntLiteral;
tokenText.append(ch);
} else if (ch == '=') {
newState = DfaState.Assignment;
token.type = TokenType.Assignment;
tokenText.append(ch);
} else {
newState = DfaState.Initial; // skip all unknown patterns
}
return newState;
}
语法分析
词法分析完成后,编译器会把程序的结构识别出来,并形成一棵便于由计算机处理的抽象语法树(AST)。例如 2 + 3 * 5
进行语法分析后会形成如下的 AST 树,如下图所示。形成 AST 以后有什么好处呢?就是计算机很容易去处理。

左结合和右结合
同样优先级的运算符是从左到右计算还是从右到左计算叫做结合性 。其中运算从左侧向右侧 依次执行叫做左结合 ,比如常见的加减乘除等算术运算,以及.
运算符;而运算从右侧向左侧 依次执行叫做右结合 ,比如 =
赋值运算符。
语法分析的方式
语法分析有两种方式,分别为自顶向下的语法分析、自底向上的语法分析。
- 自顶向下的语法分析
自顶向下的语法分析由语法分析树的根节点开始进行语法分析的方法,常见的算法有递归下降算法、LL算法(LL算法是一种不需要回溯的递归下降语法分析技术)。
在二元表达式的语法规则中,如果产生式的第一个元素是它自身,那么程序就会无限地递归下去,这种情况就叫做左递归。
自顶向下的语法分析可能 会产生左递归的问题。比如我们有左递归文法: A → Aa | b
。使用若干次Aa替换A,则可推导出句型:Aaaaaa...,从而导致死循环。
这里你可能会奇怪,我们多加几个判断不就可以解决左递归的循环问题吗。对于A → Aa | b
简单的文法是没问题的,但是编程语言的语法规则是非常复杂而且繁多的,需要根据文法自动生成,因此需要注意这个左递归问题。
- 自底向上的语法分析
自底向上的语法分析从叶子节点 (底部)开始逐渐向上到达根节点(顶部),算法有 LR 算法。
一般我们使用现成的工具来实现语法分析,比如Yacc(或 GNU 的版本,Bison)、Antlr、JavaCC 等。
语义分析
通过语法分析得到一颗语法树后,我们就可以基于这棵树来执行我们的操作。比如 +
,我们可以让数字之间相加、也可以实现让对象之间相加。
语义分析比较复杂,下一篇文章继续介绍。