大师网-带你快速走向大师之路 解决你在学习过程中的疑惑,带你快速进入大师之门。节省时间,提升效率

Reactjs+BootStrap开发自制编程语言Monkey的编译器:词法解析1

我们先看一句简单的代码:

let x = y + 5;

编译器在解析这条语句前,它需要做一项分析工作,它会把上面的语句各个要素进行分类如下:

1:let
2: x , y
3:=
4:+,
5:5
6:;

也就说 编译器把一句代码中的不同元素分成了六组,第一组是由关键字'let'组成的集合;第二组是三个字符串或是字符的集合;第三组由等于号'='组成;第四组是一个个特殊符号'+'组成的集合;第五组是由数字‘5’组成的集合;第六组是符号';'独自组成的一个集合;为了区分不同的集合,我们为每一个集合赋予一个不同的值,第一组赋值0,第二组赋值1,依次类推,第六组赋值5。直接赋与数值不利于人的理解,于是我们可以用编程中常量定义的方法,用不同的常量来对应不同的值,例如:

const  LET = 0;
const  IDENTIFIER = 1;
const  EQUAL_SIGN = 2;
const  PLUST_SIGN = 3;
const  INTEGER = 4;
const  SEMICOLON = 5;

经过分类后,上面的代码语句在编译器的眼里就变成了:

LET IDENTIFIER EQUAL_SIGN IDENTIFIER PLUS_SIGN INTEGER SEMICOLON

于是我们完成了对代码编译时的第一步抽象。分类的一个原则是,所有关键字自己单独成为一类,后面我们要看到的关键字例如 if else 他们会自己成为一类,所有表示变量的字符串,例如x, y, monkey, 等全部被划入IDENTIFIER一类,所有的特殊符号,例如':','-','*','/'等,都自己形成一类,所有的数字字符串例如'5', '123',等全部划入INTEGER这类。因此经过第一层处理后,编译器看到的再也不是具体的字符,而是代码中不同元素所对应的分类。

然而仅仅向上面那样分类,那很多信息就丢失了,这样编译器在后面就不能就顺利的解释执行或是代码生成,因此除了分类后,我们还必须附带上必要信息,例如对于分类IDENTIFIER, 我们还需要附带上它对应的字符串,对于分类INTEGER,我们还需要附带上它对应的数值,最好还是要附带上该元素所在的行号,这样以便于输出错误信息或者开发调试器。于是上面的解析再次增强为:
{type: LET, literal: "let", lineNumber: 0} {type: EQUAL_SIGN, literal:"=", lineNumber: 0} {type: IDENTIFIER, literal: "x", lineNumber: 0} {type: EQUAL_SIGN, literal:"=", lineNumber: 0} {type: IDENTIFIER, literal:"y"} {type: PLUS_SIGN, literal: "+", lineNumber: 0} {type: INTEGER, literal:"5", lineNumber: 0} {type: SIMICOLON, literal: ";", lineNumber: 0}

编译器把代码转变为上面这种结构的过程,就叫词法解析。其中类似{type: LET, literal: "let", lineNumber: 0} 这种结构体呢,我们就叫Token.我们在src/目录下新建一个组件文件叫MonkeyLexer.js,它将专门用来实现词法解析的功能,然后先在该文件中添加Token对象的定义:

class Token {
    constructor(type, literal, lineNumber) {
        this.type = type
        this.literal = literal
        this.lineNumber = lineNumber    
    }

    type() {
        return this.type
    }

    literal() {
        return this.literal
    }

    lineNumber() {
        return this.lineNumber
    }
}

class MonkeyLexer {
    constructor(sourceCode) {
        this.sourceCode = sourceCode
    }
}

export default MonkeyLexer

类MonkeyLexer将负责把源代码解析成一系列Token的组合。词法解析的基本办法是,先把字符一个个读出来,判断一下读到的单个字符是否是特殊符号,例如';', '+'等,如果是,那么直接生成对应的Token对象,如果不是,那么就把字符攒起来,直到遇到空格,回车换行为止,接着判断一下攒起来的字符串是关键字,还是变量,还是整形数值,根据不同情况生成不同Token对象。我们看看解析算法的代码是如何实现的:

class MonkeyLexer {
    constructor(sourceCode) {
        this.initTokenType()
        this.sourceCode = sourceCode
        this.poistion = 0
        this.readPosition = 0
        this.lineCount = 0
        this.ch = ''
    }

    initTokenType() {
        this.ILLEGAL = -2
        this.EOF = -1
        this.LET = 0
        this.IDENTIFIER = 1
        this.EQUAL_SIGN = 2
        this.PLUS_SIGN = 3
        this.INTEGER = 4
        this.SEMICOLON = 5
    }
    ....
    
}

MonkeyLexer 是词法解析器,在他的初始化构造函数constructor中,它调用initTokenType函数,先为不同的元素分类给定一个唯一整数以便加以区分。接着我们需要一个函数,以便把字符从代码字符串中一个个读出来,这个函数实现如下:

class MonkeyLexer {
    ....
    readChar() {
        if (this.readPosition >= this.sourceCode.length) {
            this.ch = 0
        } else {
            this.ch = this.sourceCode[this.readPosition]
        }

        this.poistion = this.readPosition
        this.readPosition++
    }
    
    skipWhiteSpaceAndNewLine() {
        /*
        忽略空格
        */
        while (this.ch === ' ' || this.ch === '\t' 
            || this.ch === '\n') {
            if (this.ch === '\t' || this.ch === '\n') {
                this.lineCount++;
            }
            this.readChar()
        }
    }
....

}

readChar() 从代码字符串中逐个读取字符,每读取一个字符,让readPosition加一,每次读取时,代码总是从readPoisition指向的位置开始读取。skipWhiteSpaceAndNewLine函数的作用是,判断读取的字符是不是空格,如果是空格,那么就忽略当前读取的字符,继续读取后续字符,如果字符是回车换行,那么把表示当前行号的变量lineCount加1,然后继续往后读取,直到读取到不是空格,或是回车换行字符为止。当读取到有效字符之后,我们要根据字符的含义把它归类,例如当读取到的字符是';'时,就创建一个类型为SEMICOLON的Token对象,具体代码实现如下:

class MonkeyLexer {
    ....
    nextToken () {
        var tok
        this.skipWhiteSpaceAndNewLine() 
        var lineCount = this.lineCount

        switch (this.ch) {
            case '=':
            tok = new Token(this.EQUAL_SIGN, "=", lineCount)
            break
            case ';':
            tok = new Token(this.SEMICOLON, ";", lineCount)
            break;
            case '+':
            tok = new Token(this.PLUS_SIGN, "+", lineCount)
            break;
            case 0:
            tok = new Token(this.EOF, "", lineCount)
            break;
            
            default:
            var res = this.readIdentifier()
            if (res !== false) {
                tok = new Token(this.IDENTIFIER, res, lineCount)
            } else {
                res = this.readNumber()
                if (res !== false) {
                    tok = new Token(this.INTEGER, res, lineCount)
                }
            }

            if (res === false) {
                tok = undefined
            }

        }

        this.readChar()
        return tok
    }
    ....
}

nextToken函数在执行时,先通过调用skipWhiteSpaceAndNewLine,滤掉空格以及回车换行等特殊字符,一旦独到有效字符后,进入switch部分,如果当前的字符是特殊字符,例如';','=','+'等,由于这些字符各自属于单独一个分类,因此分别给他们创建里一个Token对象,如果读到的是普通英文字符或者是数字字符,那么就进入default代表的代码处。

当代码连续读入的字符是普通英文字符或是数字字符时,词法解析器会把这些字符凑成一个字符串,假设读入的代码是:

five = 123;

那么解析器读入上面语句时,首先它会连续读入5个字符: f, i, v, e,然后把他们组合成一个字符串"five",接着为该字符串生成一个分类为IDENTIFIER的Token对象,当解析器读入'='后面的内容时,它会把后面的数字字符分别读入,也就是分别读取'1','2','3'三个字符,然后把这三个字符组合成字符串"123",最后给这个字符串创建一个类型为INTEGER的Token对象。这些工作分别由函数readIdentifier() 和 函数 readNumber()来实现,我们看看他们的代码:

isLetter(ch) {
        return ('a' <= ch && ch <= 'z') || 
               ('A' <= ch && ch <= 'Z') ||
               (ch === '_')
    }

    readIdentifier() {
        var identifier = ""
        while (this.isLetter(this.ch)) {
            identifier += this.ch
            this.readChar()
        }

        if (identifier.length > 0) {
            return identifier
        } else {
            return false
        }
    }

    isDigit(ch) {
        return '0' <= ch && ch <= '9'
    }

    readNumber() {
        var number = ""
        while (this.isDigit(this.ch)) {
            number += this.ch
            this.readChar()
        }

        if (number.length > 0) {
            return number
        } else {
            return false
        }
    }

readIdentifier 在执行时,先调用isLetter来判断当前读入的字符是否是字母,如果是,那么它就把所有字符集合起来,形成一个字符串。readNumber在执行时,它先判断当前读入的字符是否是数字,如果是,它就把所有数字字符集合起来,形成一个数字组成的字符串。在nextToken的switch语句部分,如果逻辑进入default部分,那么函数会调用readIdentifier()看看当前是否读到了一个由字母组合成的字符串,如果是,那么就创建一个类型为IDENTIFIER的Token对象,如果不是由字母组成的字符串,那么就接着调用readNumber看看当前内容是不是全是由数字组成的字符串,如果是,那么就创建一个类型为INTEGER的Token对象,如果不是,那说明当前读到了词法解析器无法理解的字符,因此返回一个undefined对象。

更详细的讲解和代码调试演示过程,请点击链接

到目前为止,我们的词法解析部分已经基本成型了,现在就看如何调用起MonkeyLexer这个组件,以便用来分析在页面文本框中输入的代码。要想运行MonkeyLexer这个组件,我们需要把页面文本框中的内容得到,然后传入到该组件中。

回到MonkeyCompilerIDE.js文件,页面加载时,该文件里的MonkeyCompilerIDE.render 函数会被调用,以便用于渲染页面。render在执行时返回了一个JSX对象,其中有一个控件是这样的:

<bootstrap.FormControl 
 componentClass = "textarea" 
 style={textAreaStyle}
 placeholder="Enter your code" />

上面这个控件的作用就是在页面上创建出一个输入文本框。当用户在文本框上输入内容后,点击下面的红色按钮,我们如何得到框内的文本内容呢?要想实现这个功能,我们必须要获得控件的实例对象,把上面的控件代码做如下修改:

              <bootstrap.FormControl 
               componentClass = "textarea" 
               style={textAreaStyle}
               inputRef = {
                 (ref) => {this._textAreaControl = ref}
               }
               placeholder="Enter your code" />

注意看,我们增加了部分代码如下:

inputRef = {
              (ref) => {this._textAreaControl = ref}
}

inputRef是Reactjs给我们提供的指令,如果一个控件,如果它要想在页面上绘制或是创建内容的话,它必须实现一个render()接口,render()接口会被reactjs框架调用,于是组件就可以在render中去绘制页面,那么render()是如何被reactjs调用的呢?当一个组件被放入到"<>",这两个尖括号中时,reactjs解析到后就会自动把尖括号里面的组件对象得到,然后调用它的reander函数。例如上面代码中,夹在尖括号中的组件叫bootstrap.FormControl, 那么reactjs在解析到上面代码时,会自动调用bootstrap.FormControl.render(),于是一个输入文本框就会显示到页面上了。

如果要想把尖括号包围起来的组件对象获取到,就得依靠inputRef指令,就像我们上面做的那样,当reactjs解读尖括号中的组件时,如果发现其中包含inputRef指令,那么他就会执行后面大括号里面的代码,上面代码中,ref变量就是reactjs框架传给我们的组件对象,其中this指向的是MonkeyCompilerIDE这个组件对象本身,this._textAreaControl = ref 它的意识是,在MonkeyCompilerIDE这个对象内部创建一个名为_textAreaControl的成员变量,然后把ref指向的控件对象赋值给它,这样我们就可以获得文本框控件的实例对象,有了实例对象,我们通过访问它的value属性就可以获得文本框内的文本了。

接下来我们需要关注的是如何响应底层按钮的点击。在JSX中,对应按钮的组件是:

<bootstrap.Button bsStyle="danger">
         Lexing             
</bootstrap.Button>

上面的代码经过reactjs解析后会在页面上绘制出底部那个红色的按钮,其中bsStyle="danger" 称之为组件的属性,是用来从将信息从外部传入组件内部的,后面我们会详细讲解这个特性。如何响应按钮的点击时间呢?如下:

<bootstrap.Button onClick={this.onLexingClick.bind(this)} 
 bsStyle="danger">
     Lexing            
</bootstrap.Button>

我们增加对onClick事件的捕捉,一旦用户点击按钮后,onClick事件被触发,它会调用我们自己实现的onLexingClick函数,这里一定要使用bind把onLexingClick绑定,要不然被调用时,this指针不指向MonkeyCompilerIDE组件。我们再看看响应函数的实现:

onLexingClick () {
        this.lexer = new MonkeyLexer(this._textAreaControl.value)
        this.lexer.lexing()
    }

我们先通过new 构建一个MonkeyLexer实例,this._textAreaControl.value对应文本框中输入的代码内容,并把创建的实例赋值给当前组件的lexer成员变量,最后调用MonkeyLexer导出的lexing函数开始词法解析流程。

上面代码完成后,加载页面,在文本框中输入几句代码,点击按钮进行词法解析,结果如下:

[图片上传失败...(image-c9c351-1510286811495)]

我在左边输出了两条语句:

let five = 5;
let six = 6;

右边控制台输出了词法解析的结果,其中变量"five"形成的Token对象中,分类为1,对应我们的代码,它就是IDENTIFIER, 第二行的数字6,它对应的Token中,分类值为4,对应到代码中是NUMBER,并且它所在的行号是1,从这两处结果看,词法解析的结果基本正确。但有个问题就是let, 它对应的Token中分类是1,对应的就是IDENTIFIER, 这是有问题的,前面我们说过,let是关键字,它必须对应自己的分类,因此词法解析在这里出了点问题,下一节,我们再处理它。

更详细的讲解和代码调试演示过程,请点击链接

如果点击后视频还没有,那表明视频还在云课堂的审查过程中,敬请期待。

更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号:


这里写图片描述
这里写图片描述