乐趣区

关于前端:编译原理实战入门用-JavaScript-写一个简单的四则运算编译器修订版

编译器是一个程序,作用是 将一门语言翻译成另一门语言

例如 babel 就是一个编译器,它将 es6 版本的 js 翻译成 es5 版本的 js。从这个角度来看,将英语翻译成中文的翻译软件也属于编译器。

个别的程序,CPU 是无奈间接执行的,因为 CPU 只能辨认机器指令。所以要想执行一个程序,首先要将高级语言编写的程序翻译为汇编代码(Java 还多了一个步骤,将高级语言翻译成字节码),再将汇编代码翻译为机器指令,这样 CPU 能力辨认并执行。

因为汇编语言和机器语言一一对应,并且汇编语言更具备可读性。所以计算机原理的教材在解说机器指令时个别会用汇编语言来代替机器语言解说。

本文所要写的四则运算编译器须要将 1 + 1 这样的四则运算表达式翻译成机器指令并执行。具体过程请看示例:

// CPU 无奈辨认
10 + 5

// 翻译成汇编语言
push 10
push 5
add

// 最初翻译为机器指令,汇编代码和机器指令一一对应
// 机器指令由 1 和 0 组成,以下指令非实在指令,只做演示用
0011101001010101
1101010011100101
0010100111100001

四则运算编译器,尽管说性能很简略,只能编译四则运算表达式。然而编译原理的前端局部简直都有波及:词法剖析、语法分析。另外还有编译原理后端局部的代码生成。不论是简略的、简单的编译器,编译步骤是差不多的,只是简单的编译器实现上会更艰难。

可能有人会问,学会编译原理有什么益处

我认为对编译过程外部原理的把握将会使你成为更好的高级程序员。另外在这援用一下知乎网友 - 随心所往的答复,更加具体:

  1. 能够更加容易的了解在一个语言种哪些写法是等价的,哪些是有差别的
  2. 能够更加主观的比拟不同语言的差别
  3. 更不容易被某个特定语言的鼓吹者忽悠
  4. 学习新的语言是效率也会更高
  5. 其实从语言 a 转换到语言 b 是一个通用的需要,学好编译原理解决此类需要时会更加熟能生巧

好了,上面让咱们看一下如何写一个四则运算编译器。

词法剖析

程序其实就是保留在文本文件中的一系列字符,词法剖析的作用是将这一系列字符依照某种规定分解成一个个字元(token,也称为终结符),疏忽空格和正文。

示例:

// 程序代码
10 + 5 + 6

// 词法剖析后失去的 token
10
+
5
+
6

终结符

终结符就是语言中用到的根本元素, 它不能再被合成。

四则运算中的终结符包含符号和整数常量(暂不反对一元操作符和浮点运算)。

  1. 符号+ - * / ()
  2. 整数常量:12、1000、111…

词法剖析代码实现

function lexicalAnalysis(expression) {const symbol = ['(', ')', '+', '-', '*', '/']
    const re = /\d/
    const tokens = []
    const chars = expression.trim().split('')
    let token = ''
    chars.forEach(c => {if (re.test(c)) {token += c} else if (c == ' ' && token) {tokens.push(token)
            token = ''
        } else if (symbol.includes(c)) {if (token) {tokens.push(token)
                token = ''
            } 

            tokens.push(c)
        }
    })

    if (token) {tokens.push(token)
    }

    return tokens
}

console.log(lexicalAnalysis('100    +   23   +    34 * 10 / 2')) 
// ["100", "+", "23", "+", "34", "*", "10", "/", "2"]

四则运算的语法规定(语法规定是分层的)

  1. x*,示意 x 呈现零次或屡次
  2. x | y,示意 x 或 y 将呈现
  3. () 圆括号,用于语言构词的分组

以下规定从左往右看,示意右边的表达式还能持续往下细分成左边的表达式,始终细分到不可再分为止。

  • expression: addExpression
  • addExpression: mulExpression (op mulExpression)*
  • mulExpression: term (op term)*
  • term: ‘(‘ expression ‘)’ | integerConstant
  • op: + - * /

addExpression 对应 + - 表达式,mulExpression 对应 * / 表达式。

如果你看不太懂以上的规定,那就先放下,持续往下看。看看怎么用代码实现语法分析。

语法分析

对输出的文本依照语法规定进行剖析并确定其语法结构的一种过程,称为语法分析。

个别语法分析的输入为形象语法树(AST)或语法分析树(parse tree)。但因为四则运算比较简单,所以这里采取的计划是即时地进行代码生成和错误报告,这样就不须要在内存中保留整个程序结构。

先来看看怎么剖析一个四则运算表达式 1 + 2 * 3

首先匹配的是 expression,因为目前 expression 往下分只有一种可能,即 addExpression,所以合成为 addExpression
顺次类推,接下来的程序为 mulExpressionterm1(integerConstant)、+(op)、mulExpressionterm2(integerConstant)、*(op)、mulExpressionterm3(integerConstant)。

如下图所示:

这里可能会有人有疑难,为什么一个表达式搞得这么简单,expression 上面有 addExpressionaddExpression 上面还有 mulExpression
其实这里是为了思考运算符优先级而设的,mulExpraddExpr 表达式运算级要高。

1 + 2 * 3
compileExpression
   | compileAddExpr
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 1
   |  |  |_
   |  | matches '+'
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 2
   |  |  | matches '*'
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 3
   |  |  |_ compileOp('*')                      *
   |  |_ compileOp('+')                         +
   |_

有很多算法可用来构建语法分析树,这里只讲两种算法。

递归降落分析法

递归降落分析法,也称为自顶向下分析法。依照语法规定一步步递归地剖析 token 流,如果遇到非终结符,则持续往下剖析,直到终结符为止。

LL(0)分析法

递归降落分析法是简略高效的算法,LL(0)在此基础上多了一个步骤,当第一个 token 不足以确定元素类型时,对下一个字元采取“提前查看”,有可能会解决这种不确定性。

以上是对这两种算法的简介,具体实现请看下方的代码实现。

表达式代码生成

咱们通常用的四则运算表达式是中断表达式,然而对于计算机来说中断表达式不便于计算。所以在代码生成阶段,要将中断表达式转换为后缀表达式。

后缀表达式

后缀表达式,又称逆波兰式,指的是不蕴含括号,运算符放在两个运算对象的前面,所有的计算按运算符呈现的程序,严格从左向右进行(不再思考运算符的优先规定)。

示例:

中断表达式:5 + 5 转换为后缀表达式:5 5 +,而后再依据后缀表达式生成代码。

// 5 + 5 转换为 5 5 + 再生成代码
push 5
push 5
add

代码实现

编译原理的理论知识像天书,常常让人看得云里雾里,但真正动手做起来,你会发现,其实还挺简略的。

如果下面的理论知识看不太懂,没关系,先看代码实现,而后再和理论知识联合起来看。

留神:这里须要引入方才的词法剖析代码。

// 汇编代码生成器
function AssemblyWriter() {this.output = ''}

AssemblyWriter.prototype = {writePush(digit) {this.output += `push ${digit}\r\n`
    },

    writeOP(op) {this.output += op + '\r\n'},

    // 输入汇编代码
    outputStr() {return this.output}
}

// 语法分析器
function Parser(tokens, writer) {
    this.writer = writer
    this.tokens = tokens
    // tokens 数组索引
    this.i = -1
    this.opMap1 = {
        '+': 'add',
        '-': 'sub',
    }

    this.opMap2 = {
        '/': 'div',
        '*': 'mul'
    }

    this.init()}

Parser.prototype = {init() {this.compileExpression()
    },

    compileExpression() {this.compileAddExpr()
    },

    compileAddExpr() {this.compileMultExpr()
        while (true) {this.getNextToken()
            if (this.opMap1[this.token]) {let op = this.opMap1[this.token]
                this.compileMultExpr()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 + - 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileMultExpr() {this.compileTerm()
        while (true) {this.getNextToken()
            if (this.opMap2[this.token]) {let op = this.opMap2[this.token]
                this.compileTerm()
                this.writer.writeOP(op)
            } else {
                // 没有匹配上相应的操作符 这里为没有匹配上 * / 
                // 将 token 索引后退一位
                this.i--
                break
            }
        }
    },

    compileTerm() {this.getNextToken()
        if (this.token == '(') {this.compileExpression()
            this.getNextToken()
            if (this.token != ')') {throw '短少右括号:)'
            }
        } else if (/^\d+$/.test(this.token)) {this.writer.writePush(this.token)
        } else {throw '谬误的 token:第' + (this.i + 1) + '个 token (' + this.token + ')'
        }
    },

    getNextToken() {this.token = this.tokens[++this.i]
    },

    getInstructions() {return this.writer.outputStr()
    }
}

const tokens = lexicalAnalysis('100+10*10')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
console.log(instructions) // 输入生成的汇编代码
/*
push 100
push 10
push 10
mul
add
*/

模仿执行

当初来模仿一下 CPU 执行机器指令的状况,因为汇编代码和机器指令一一对应,所以咱们能够创立一个间接执行汇编代码的模拟器。
在创立模拟器前,先来解说一下相干指令的操作。

在内存中,栈的特点是只能在同一端进行插入和删除的操作,即只有 push 和 pop 两种操作。

push

push 指令的作用是将一个操作数推入栈中。

pop

pop 指令的作用是将一个操作数弹出栈。

add

add 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,而后执行 a + b,再将后果 push 到栈中。

sub

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,而后执行 a – b,再将后果 push 到栈中。

mul

mul 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,而后执行 a * b,再将后果 push 到栈中。

div

sub 指令的作用是执行两次 pop 操作,弹出两个操作数 a 和 b,而后执行 a / b,再将后果 push 到栈中。

四则运算的所有指令曾经解说结束了,是不是感觉很简略?

代码实现

留神:须要引入词法剖析和语法分析的代码

function CpuEmulator(instructions) {this.ins = instructions.split('\r\n')
    this.memory = []
    this.re = /^(push)\s\w+/
    this.execute()}

CpuEmulator.prototype = {execute() {
        this.ins.forEach(i => {switch (i) {
                case 'add':
                    this.add()
                    break
                case 'sub':
                    this.sub()
                    break
                case 'mul':
                    this.mul()
                    break
                case 'div':
                    this.div()
                    break                
                default:
                    if (this.re.test(i)) {this.push(i.split(' ')[1])
                    }
            }
        })
    },

    add() {const b = this.pop()
        const a = this.pop()
        this.memory.push(a + b)
    },

    sub() {const b = this.pop()
        const a = this.pop()
        this.memory.push(a - b)
    },

    mul() {const b = this.pop()
        const a = this.pop()
        this.memory.push(a * b)
    },

    div() {const b = this.pop()
        const a = this.pop()
        // 不反对浮点运算,所以在这要取整
        this.memory.push(Math.floor(a / b))
    },

    push(x) {this.memory.push(parseInt(x))
    },

    pop() {return this.memory.pop()
    },

    getResult() {return this.memory[0]
    }
}

const tokens = lexicalAnalysis('(100+  10)*  10-100/  10      +8*  (4+2)')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
const emulator = new CpuEmulator(instructions)
console.log(emulator.getResult()) // 1138

一个简略的四则运算编译器曾经实现了。咱们再来写一个测试函数跑一跑,看看运行后果是否和咱们期待的一样:

function assert(expression, result) {const tokens = lexicalAnalysis(expression)
    const writer = new AssemblyWriter()
    const parser = new Parser(tokens, writer)
    const instructions = parser.getInstructions()
    const emulator = new CpuEmulator(instructions)
    return emulator.getResult() == result}

console.log(assert('1 + 2 + 3', 6)) // true
console.log(assert('1 + 2 * 3', 7)) // true
console.log(assert('10 / 2 * 3', 15)) // true
console.log(assert('(10 + 10) / 2', 10)) // true

测试全副正确。另外附上残缺的源码,倡议没看懂的同学再看多两遍。

更上一层楼

对于工业级编译器来说,这个四则运算编译器属于玩具中的玩具。然而人不可能一口吃成个瘦子,所以学习编译原理最好采取循序渐进的形式去学习。上面来介绍一个高级一点的编译器,这个编译器能够编译一个 Jack 语言(类 Java 语言),它的语法大略是这样的:

class Generate {
    field String str;
    static String str1;
    constructor Generate new(String s) {
        let str = s;
        return this;
    }

    method String getString() {return str;}
}

class Main {function void main() {
        var Generate str;
        let str = Generate.new("this is a test");
        do Output.printString(str.getString());
        return;
    }
}

下面代码的输入后果为:this is a test

想不想实现这样的一个编译器?

这个编译器出自一本书《计算机系统因素》,它从第 6 章开始,始终到第 11 章解说了汇编编译器(将汇编语言转换为机器语言)、VM 编译器(将相似于字节码的 VM 语言翻译成汇编语言)、Jack 语言编译器(将高级语言 Jack 翻译成 VM 语言)。每一章都有具体的知识点解说和试验,只有你一步一步跟着做试验,就能最终实现这样的一个编译器。

如果编译器写完了,最初机器语言在哪执行呢?

这本书曾经为你思考好了,它从第 1 章到第 5 章,一共五章的内容。教你从逻辑门开始,逐渐组建出算术逻辑单元 ALU、CPU、内存,最终搭建出一个古代计算机。而后让你用编译器编译进去的程序运行在这台计算机之上。

另外,这本书的第 12 章会教你写操作系统的各种库函数,例如 Math 库(蕴含各种数学运算)、Keyboard 库(按下键盘是怎么输入到屏幕上的)、内存治理等等。

想看一看全书共 12 章的试验做完之后是怎么样的吗?我这里提供几张这台模仿计算机运行程序的 DEMO GIF,供大家参考参考。

这几张图中的右上角是“计算机”的屏幕,其余局部是“计算机”的堆栈区和指令区。

这本书的所有试验我都曾经做完了(每天花 3 小时,两个月就能做完),答案放在我的 github 上,有趣味的话能够看看。

参考资料

  • 《计算机系统因素》
退出移动版