首页 > 代码库 > 完整cmm解释器构造实践(二):词法分析

完整cmm解释器构造实践(二):词法分析

cmm是c的一个子集,保留字只有如下几个

if else while read write int real

特殊符号有如下几个

+ - * / = < == <> ( ) ; { } [ ] /* */
标识符:由数字,字母或下划线组成的字符串,且不能使关键字,第一个字母不能是数字

如果了解c很容易明白上面的是什么意思,也会明白cmm其实有的东西并不多,所以做cmm解释器相对来说比较简单。


上面的特殊符号实际上比较少,我个人实现的时候还对> >= <=等做了相关的支持,当然,原理上都是一样。


简要介绍了cmm之后,就开始进入正题——词法分析。

假设我们有个.cmm文件,我们读取的时候是一个字符一个字符的读取的,也就是字符流,人能准确的判断字符流,但是程序做不到,所以我们需要将字符转换成程序方便处理的形式,也就是Token流,或者说是Token序列。

比如下面这句代码

int a = 10;

我们可以将它转换为 INT ID ASSIGN NUMBER SEMI

INT就是int,ID代表标识符,也就是a,ASSIGN代表赋值运算符,NUMBER代表一个数字,SEMI代表代码末尾的分号。

我们将一句代码解析成这样的Token序列之后,我们就可以很方便的进行进一步的处理了。

那么现在的问题在于我们如何将字符流转换成Token流呢。

有一个东西叫javacc,我们在里面可以通过正则表达式来定义一些Token,然后javacc会根据我们提供的正则表达式来处理输入字符流,转换成Token流,非常强大,有兴趣的东西可以去试一试,我们这里用比较简单的方法,当然也比较笨,那就是我们通过自己的经验总结出来的规律来写一段代码将字符流转换为Token流。

我们先把几种Token定义好:

    /** if */
    public static final int IF = 1;
    /** else */
    public static final int ELSE = 2;
    /** while */
    public static final int WHILE = 3;
    /** read */
    public static final int READ = 4;
    /** write */
    public static final int WRITE = 5;
    /** int */
    public static final int INT = 6;
    /** real */
    public static final int REAL = 7;
    /** + */
    public static final int PLUS = 8;
    /** - */
    public static final int MINUS = 9;
    /** * */
    public static final int MUL = 10;
    /** / */
    public static final int DIV = 11;
    /** = */
    public static final int ASSIGN = 12;
    /** < */
    public static final int LT = 13;
    /** == */
    public static final int EQ = 14;
    /** <> */
    public static final int NEQ = 15;
    /** ( */
    public static final int LPARENT = 16;
    /** ) */
    public static final int RPARENT = 17;
    public static final int SEMI = 18;
    /** { */
    public static final int LBRACE = 19;
    /** } */
    public static final int RBRACE = 20;
//    /** /* */
//    public static final int LCOM = 21;
//    /** *\/ */
//    public static final int RCOM = 22;
//    /** // */
//    public static final int SCOM = 23;
    /** [ */
    public static final int LBRACKET = 24;
    /** ] */
    public static final int RBRACKET = 25;
    /** <= */
    public static final int LET = 26;
    /** > */
    public static final int GT = 27;
    /** >= */
    public static final int GET = 28;
    /** 标识符,由数字,字母或下划线组成,第一个字符不能是数字 */
    public static final int ID = 29;
    /** int型字面值 */
    public static final int LITERAL_INT = 30;
    /** real型字面值 */
    public static final int LITERAL_REAL = 31;

上面是从我的代码里面截取的一部分,每一行代码都是定义的一个Token类型,注释就是解释这个Token类型具体指的什么,注意有几行被注释掉的定义代码大家可以忽略,保留只是出于个人爱好,实际上被我注释掉的代码完全没有任何作用。

可以看出Token类型还是非常多的,而且有些的名字还不好辨别,大家要仔细。

下面讲解一下转换的方法,每次读取一个字符,假设我们有个char类型的变量c,存储当前最新读到的字符。

假设现在我们读到了第一个字符,将其存入c中

如果c是; + -* ( ) [ ] { }中的任何一个,那么可以直接得到对应的Token,比如如果c==‘+’,那么我们就可以理直气壮的说我们这里有一个类型为PLUS的Token。

如果c是/,我们还不能直接确定这是个什么Token,因为/开头的Token有好几个,所以我们再读取一个字符,还是存入c中,注意此时c已经是第二个字符了,根据这个字符的不同有几种情况:

    ①:c==‘*‘,我们可以确定这是一个多行注释的开始,也就是说是/*,注意这里还需要完成对多行注释的处理,我们等会儿看代码再说,这里先略过。

    ②:c==’/‘,我们可以确定这是一个单行注释的开始,也就是说是’//‘,注意这里还需要完成对单行注释的处理,这里先略过。

    ③:不满足上面的任何一个条件,我们可以确定这是一个除号,也就是类型为DIV的Token。

    到此c是/的的情况就处理完了

这里还需要给大家看一下代码,因为有对注释的处理的问题,我在代码中加了注释,大家可以粗略看一下,处理注释的方法就是读入字符,消耗掉字符,但是要时刻判断多行注释是否结束了:

            if (currentChar == '/') { //currentChar就是我们最新读入的字符
                readChar();  //调用这个函数可以读入下一个字符,currentChar中的内容会更新
                if (currentChar == '*') {//多行注释
//                    tokenList.add(new Token(Token.LCOM, lineNo));
                    readChar();  //确定是多行注释,现在开始处理多行注释,再读取一个字符
                    while (true) {  //使用死循环消耗多行注释内字符
                        if (currentChar == '*') { //如果是*,那么有可能是多行注释结束的地方
                            readChar();
                            if (currentChar == '/') {  //多行注释结束符号
//                                tokenList.add(new Token(Token.RCOM, lineNo));
                                readChar();  //再读入下一个字符结束循环
                                break;
                            }
                        } else { //如果不是*就继续读下一个,相当于忽略了这个字符
                            readChar();
                        }
                    }
                    continue; // 循环结束,肯定是break出来的,也就是多行注释结束
                } else if (currentChar == '/') {//单行注释
//                    tokenList.add(new Token(Token.SCOM, lineNo));
                    while (currentChar != '\n') {//消耗这一行之后的内容
                        readChar();
                    }
                    continue;
                } else { // 是除号
                    tokenList.add(new Token(Token.DIV, lineNo));
                    continue;
                }
            }


如果c是=,有两种情况,再读入一个字符到c中来进行下一步判断:

    ①:c还是=,那么说明这是一个逻辑运算符==,这里是一个类型为EQ的Token。

    ②:c不满足上面的条件,那么说明这是一个单纯的赋值符号,这里是一个类型为ASSIGN的Token

如果c是>,有两种情况,再读入一个字符到c中来进行下一步判断:

    ①:c==’=’,那么说明这是一个逻辑运算符>=,这里是一个类型为GET的Token。

    ②:c不满足上面的条件,那么说明这是一个单纯的大于符号,这里是一个类型为GT的Token

如果c是<,有多种情况,再读入一个字符到c中来进行下一步判断:

    ①:c==’=’,那么说明这是一个逻辑运算符<=,这里是一个类型为LET的Token。

    ②:c==‘>’,说明这是一个逻辑运算符<>,这里是一个类型为NEQ的Token。

    ③:c不满足上面的,说明这是一个单纯的小于符号,这里是一个类型为LT的Token。

如果c是数字,也就是说‘0’<=c<=‘9’,说明接下来是一个数字,我们将c存储起来,继续读下面的字符,包括小数点‘.’也需要读入,直到一个非小数点且非数字的字符出现,停止读入,将之前存储的c拼接起来就是我们要的数字,我们可以根据有没有小数点来判断是不是整数,这里的逻辑需要大家慎重考虑,下面是我的代码片段:

            if (currentChar >= '0' && currentChar <= '9') {
                boolean isReal = false;//是否小数
                while ((currentChar >= '0' && currentChar <= '9') || currentChar == '.') {
                    if (currentChar == '.') {
                        if (isReal) {
                            break;
                        } else {
                            isReal = true;
                        }
                    }
                    sb.append(currentChar);
                    readChar();
                }
                if (isReal) {
                    tokenList.add(new Token(Token.LITERAL_REAL, sb.toString(), lineNo));
                } else {
                    tokenList.add(new Token(Token.LITERAL_INT, sb.toString(), lineNo));
                }
                sb.delete(0, sb.length());
                continue;
            }

其中sb是一个StringBuilder对象,相当于一个存放字符的缓冲区,在正确的时机将里面积累的值取出来,然后将其清空,因为我的代码会反复使用sb。

如果c是字母或者下划线,那么我们需要将c存储起来,继续读下面的字符,遇到同时满足非字母,非下划线,非数字三个条件的字符时将缓冲区中积累的字符取出,和保留字一一比较,如果积累的字符串是int,那么我们说这里有一个类型为INT的Token,如果和每一个保留字都不相同,那么我们认为这是一个用户自定义的标识符,所以这里有一个类型为ID的Token。

读入的字符一定符合上面的某一种情况,如果每一种情况都不符合,那么我们可以直接丢弃,因为有几个换行符和制表符我们是要忽略的。

要忽略的字符:“\r” “\n” “\f” “ ”。分别代表回车,换行,换页,空格。当然我在忽略\n的同时会统计一下行数。

经过上面的一番处理,我们可以拿到cmm代码中的第一个Token,我们再执行一遍上面的流程,可以拿到第二个Token,不过要注意我们使用了一种类似LL(1)的思想,我们在不确定目前处理的字符属于哪一个Token类型时会看下一个字符,有时两个字符组合成一个Token,有时前面一个字符单独成为一个Token,此时最后读取的字符还没有被处理,要得到下一个Token,就需要把最后读到的字符作为第一个字符来处理。否则你会发现有时会漏掉几个字符。


最后我把今天涉及到的代码上传了,不需要积分,欢迎大家下载。

点我下载代码

完整cmm解释器构造实践(二):词法分析