通过前文【小白打造编译器系列】编译器的前端技术是什么?我们已经对整个编译器的前端有了一定的了解,那么我们接下来实战一下,实现一个简单的词法分析器。
实现词法分析器,就是写出正则表达式,画出有限自动机的图形,然后根据图形直观地写出解析代码的过程。
我们实现的词法分析器,包括:
- 关系表达式:age >= 45
- 变量声明:int age = 40
- 算术表达式:2*3+5
解析 age >= 45
因为这里实现的是简单版的词法分析器,我们减少一些需求。
定义标识符、比较操作符和数字字面量 Token 的词法规则:
- 标识符:第一个字符必须是字母,后面的字符可以是字母或数字。
- 比较操作符:> 和 >=(其他比较操作符暂时忽略)。
- 数字字面量:全部由数字构成(像带小数点的浮点数,暂时不考虑)。
我们就根据上面的规则,构建一个有限自动机。有限自动机分为被细分为 5 种状态。
- 初始状态:刚开始启动词法分析的时候,程序所处的状态。
- 标识符状态:在初始状态时,当第一个字符是字母的时候,迁移到状态 2。当后续字符是字母和数字时,保留在状态 2。如果不是,就离开状态 2,写下该 Token,回到初始状态。
- 大于操作符(GT):在初始状态时,当第一个字符是 > 时,进入这个状态。它是比较操作符的一种情况。
- 大于等于操作符(GE):如果状态 3 的下一个字符是 =,就进入状态 4,变成 >=。它也是比较操作符的一种情况。
- 数字字面量:在初始状态时,下一个字符是数字,进入这个状态。如果后续仍是数字,就保持在状态 5。
状态转移图如下:
举个例子:一开始我们处于初始状态。当识别出第一个字符是字母的时候就从初始状态1转移到状态2,会继续扫描下一个字符,如果是数字或者字母,就继续保持在状态2,否则将整个读入的字符串作为ID存储起来,然后返回初始状态1,继续如此反复。
定义 Token 和 状态 STATE(完整代码见文末)
- Token 结构体,包括了 Type(类型)和 Text(文本)。
//创建Token结构体
typedef struct {
//状态
STATE state = INIT;
//内容
string text = "";
}Token;
- STATE 状态,使用枚举定义。
//5个状态,使用enum表示
enum STATE
{
INIT = 0,//初始状态
ID,//标识符状态
DIGIT,//数字字面量状态
GE,//大于等于
GT //大于
};
代码实现 Token 的状态初始化过程
要记住一点:进入新状态前,要对上一个状态进行收尾工作!!
因此,这里的函数要做的就是把上一个状态的 Token 存起来,然后开启一个新的 Token。
- 如果现在的 token 的 text 中有值,说明上一个状态已经结束(我们把拼接字符串的操作在switch中完成),所以我们对上一个状态所记录的 Token 可以直接保存。这一步实际上就是对上一个状态的收尾。
- 接下来,开启一个新的 Token,对它初始化。
- 下一步我们可以进行判断了,根据我们上面的有限自动机转移图。
- ch 是字母,则转为 ID 状态
- ch 是数字,则转为 DIGIT 状态
- ch 是 > ,则转为 GT
- 最后需要返回当前 ch 字符所表示的状态,作为上一个状态用于下一次判断(有点拗口)。
//内联函数,解决频繁大量的使用就会造成因栈空间不足而导致程序出错的问题
//状态转移过程
inline STATE MyTokenReader::Init_Token(char ch) {
//如果有token,则直接放入结果
if (token.text.length() > 0) {
vecResult.push_back(token);
token = Token(); //清0,重新记录
}
//设置最初为INIT状态
token.state = INIT;
//如果是字母,则状态转为ID
if (isalpha(ch)) {
token.state = ID;
token.text += ch;
}
//如果是数字,则状态转为DIGIT
else if (isdigit(ch)) {
token.state = DIGIT;
token.text += ch;
}
//如果是>,则状态转为
else if(ch == '>'){
token.state = GT;
token.text += ch;
}
return token.state;
}
代码实现 Token 的状态转移过程
状态转移的前提是我们需要知道前一个状态是什么,才能根据现在的到的字符来判断转移到哪一个状态。
于是这里需要记录上一个状态,并且根据上一个状态进行分类讨论。
- 前状态为 INIT。说明需要将 Token 初始化(进入新状态)。
- 前状态为 ID。若当前 ch 是数字或者是字符,可以继续加入到 Text 中,否则需要进入新状态。
- 前状态为 DIGIT。若当前 ch 是数字,可以继续加入 Text 中,否则需要进入新状态。
- 前状态是 > 。若当前 ch 是 = ,需要转移状态为 GE,并且加入 Text 中,否则需要进入新状态。
- 前状态是 >= 。说明需要将 Token 初始化(进入新状态)。
//解析语句函数
void MyTokenReader::parse() {
//初始状态为INIT
STATE lastState = INIT;
for (size_t i = 0; i < str.length(); i++) {
//语句从头开始遍历
char ch = str[i];
//根据状态来讨论
switch (lastState) //上一个状态
{
case INIT:
lastState = Init_Token(ch);
break;
case ID:
if (isalpha(ch) || isdigit(ch)) {
token.text += ch;
}
else {
lastState = Init_Token(ch);
}
break;
case DIGIT:
if (isdigit(ch)) {
token.text += ch;
}
else {
lastState = Init_Token(ch);
}
break;
case GT:
if (ch == '=') {
token.state = GE;
token.text += ch;
lastState = GE;
}
else {
lastState = Init_Token(ch);
}
break;
case GE:
lastState = Init_Token(ch);
break;
default:
break;
}
}
Init_Token(' ');
}
输出结果
输出结果可以看出,我们对 age、>= 和 45 都做了比较准确的分类。
这正是我们想要的输出结果。我们依据构造好的有限自动机,在不同的状态中迁移,从而解析出 Token 来,只要我们把有限自动机的状态讨论清楚,增加详细里面的状态和迁移路线,就能实现一个完整的词法分类器了。
更准确简单的匹配:正则表达式
当然,如果我们一个一个手写规则肯定工作量很大,同时也没有那么严谨。
对于规则的匹配,我们通常使用一个非常牛逼的表达式——正则表达式。它常被用在各种地方,比如网页前端(或者后端)的输入格式的校验,可以通过字符串判断输入的是不是符合某种格式的;或者一些简单的爬虫上,通过正则表达式取匹配相应的字段内容等。
那么对于上面我们定义的几个状态的正则表达式的定义:
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
IntLiteral: [0-9]+
GT : '>'
GE : '>='
这里复习一下正则表达式的规则:
当然,在现代编写编译器都是使用正则表达式的,前面只是为了方便解释与理解词法分析器的原理,自己写写,动手总时没有坏处的。
解析 int age = 40(处理标识符和关键字规则冲突)
解析“int age = 40”这个语句,以这个语句为例研究一下词法分析中会遇到的问题:多个规则之间的冲突。
如果我们把这个语句涉及的词法规则用正则表达式写出来,是下面这个样子:
Int: 'int'
Id : [a-zA-Z_] ([a-zA-Z_] | [0-9])*
Assignment : '='
这时候,就发生了一个问题了:int 这个关键字,与标识符很相似,都是以字母开头,后面跟着其他字母。也就是 int 这个字符串同时满足关键字规则和标识符规则。
这时候怎么处理呢?添加状态呀!
我们当然知道关键字的优先级是高于标识符的,因此当我们读入第一个字符为 i 的时候,就需要讨论了,建立一个新的状态 ID_int1;继续读入一个字符,如果不是 n ,则可以返回ID状态,如果是 n ,那就更要注意了,建立一个新的状态 ID_int2;如果在 ID_int2 后的下一个读入的字符是 t,说明关键字来了,建立新状态 INT。使用图表示如下:
于是,我们很好地避免了规则之间的冲突。
至于 2*3+5 这种计算表达式,我们也很好处理,增加规则即可。
总结
要实现一个词法分析器,首先需要写出每个词法的正则表达式,并画出有限自动机,之后,只要用代码表示这种状态迁移过程就可以了。
本文代码:https://github.com/SongJain/TheBeautyOfCompiling/tree/master/BianYiCode
备注:个人原创学习笔记,参考《极客时间-编译原理之美》。