前言
对于大多数前端开发者来说JavaScript可谓是我们最熟悉的编程语言了,它十分强大可是有些语言特性却十分难以理解,例如闭包和this绑定等概念往往会让初学者摸不着头脑。网上有很多诸如《你看完这篇还不懂this绑定就来砍我》之类的文章来为大家传道解惑。可是在我看来这些文章大多流于表面,你读了很多可能还是会被面试官问倒。 那么如何才能彻彻底底理解这些语言特性,从而在面试的时候立于不败之地呢?在我看来要想真的理解一样东西,最好的途径就是实现这样东西,这也是西方程序员非常喜欢说的learning by implementing。例如,你想更好地理解React,那么最好的办法就是你自己动手实现一个React。因此为了更好地理解JavaScript的语言特性,我就自己动手实现了一个叫做Simple的JavaScript语言解释器,这个解释器十分简单,它基于TypeScript实现了JavaScript语法的子集,主要包括下面这些功能:
- 基本数据类型
- 复杂数据类型object, array和function
- 变量定义
- 数学运算
- 逻辑运算
- if条件判断
- while,for循环
- 函数式编程
- 闭包
- this绑定
本系列文章正是笔者在实现完Simple语言解释器后写的整理性文章,它会包括下面这些部分:
- 项目介绍和词法分析(本文)
- 语法分析
- 执行JavaScript代码
虽然Simple的实现和V8引擎(或者其它JavaScript引擎)没什么关系,你也不能通过本系列文章来理解它们的源码,可是看完本系列文章后你将会有下面这些收获:
- 加深对JavaScript语言的理解(this和闭包等)
- 掌握编译原理的基础知识
- 知道什么是DSL以及如何实现内部DSL来提高研发效率(Simple的语法分析是基于内部DSL的)
Simple解释器的源代码已经开源在github上面了,地址是https://github.com/XiaocongDong/simple,我还开发了一个简易的代码编辑器供大家把玩,地址是https://superseany.com/opensource/simple/build/,大家可以在这个编辑器里面编写和运行JavaScript代码,并且可以看到JavaScript代码生成的单词(Token)和语法树(AST)。
接着就让我们进入本系列文章的第一部分 - 项目介绍和词法分析的内容。
项目介绍
编译器 vs 解释器
在开始了解Simple的实现原理之前,我们先来搞清楚两个基本的编译原理概念:编译器(Compiler) vs 解释器(Interpreter)。
编译器
编译器可以理解成语言的转换器,它会把源文件从一种形式的代码转换成另外一种形式的代码,它只是负责转换代码,不会真正执行代码的逻辑。在开发前端项目的过程中,我们用到的代码打包器Webpack其实就是一个JavaScript编译器,它只会打包我们的代码而不会执行它们。
解释器
解释器顾名思义就是会对我们的代码进行解释执行,它和编译器不一样,它不会对源代码进行转换(最起码不会输出中间文件),而是边解释边执行源代码的逻辑。
Simple解释器
由于Simple不会对编写的JavaScript代码进行中间代码转换,它只会解释并且执行代码的逻辑,所以它是一个不折不扣的JavaScript语言解释器。
Simple的架构设计
我们编写的代码其实就是保存在计算机硬盘上面的字符串文本,而实现语言解释器的本质其实就是教会计算机如何才能理解并执行这些文本代码
。那么计算机如何才能理解我们写的东西呢?考虑到大多数编程语言都是用英语进行编码的,我们不妨先来看一下人是如何理解一个英语句子的,看能不能受到一些启发。
人理解英语句子的过程
Put a pencil on the table。我相信大家肯定都知道这句话是什么意思,可是你是否有思考过你是如何理解这句话的呢?或者更进一步,你能不能将你理解这句话的过程拆分成一个个单独的步骤?
我相信大多数人在理解上面这句话的过程中都会经历这些阶段:
- 切割单词,理解每个单词的意思:句子是由单词组成的,我们要理解句子的意思首先就要知道每个单词的意思。Put a pencil on the table这个句子每个单词的意思分别是:
- put: 动词,放置。
- a: 不定冠词,一个。
- pencil: 名词,铅笔。
- on: 介词,在…上面。
- the: 定冠词,这张。
- table: 名词,桌子。
- 单词切割完后,我们就会根据英语语法规则划分句子的结构:在理解完句子每个单词的意思后,我们接着就会根据英语的语法规则来对句子进行结构的划分,例如对于上面这个句子,我们会这样进行划分:
- 因为句子第一个单词是动词put,而且动词后面跟的是不定冠词修饰的名词,所以这个句子应该是个动词 + 名词的祈使句,因此这句话的前半句的意思就是叫某人放(put)一支(a)铅笔(pencil)。
- 前半句解释完后,我们再看一下这个句子的后半句。后半句的开头是一个介词(on)然后接着一个定冠词修饰的名词(the table),所以它是用来修饰句子前半句的结构为介词 + 名词的状语,表示铅笔是放在这个桌子上的。
- 划分和理解完句子的结构后,我们自然也明白了这个句子的意思,那就是:将铅笔放在这张桌子上面。
计算机如何理解代码
知道了我们是如何理解一个英语句子后,我们再来思考一下如何让计算机来理解我们的代码。我们都知道计算机科学的很多知识都是对现实世界的建模。举个例子,我们熟知的数据结构Queue对应的就是我们日常生活中经常会排的队,而一些设计模式,例如Visitor,Listener等都是对现实生活情景的建模。在计算机科学里面研究编程语言的学科叫做编译原理,那么编译原理的一些基本概念是如何和我们上面说到的人类理解句子的步骤一一对应起来的呢?
上面说到我们理解一个句子的第一步是切割单词然后理解每个单词的意思,这一个步骤其实对应的就是编译原理中的词法分析(Lexical Analysis)。词法分析顾名思义就是在单词层面对代码进行解释,它主要会将代码字符串划分为一个个独立的单词(token)。
在理解完每个单词的意思后我们会根据英语语法规则划分句子的结构,这个步骤对应的编译原理的概念是语法分析(Syntax Analysis/Parser)。语法分析的过程会将词法分析生成的单词串根据定义的语法规则生成一颗抽象语法树(AST)。生成的抽象语法树最后就会被一些运行时(runtime)执行。
综上所述,一个语言解释器的软件架构大体是这样的:
上面其实也就是Simple的软件架构,接着让我们来看一下词法分析的具体实现。
词法分析
前面已经说过,所谓的词法分析就是将文件的代码以单词(token)为单位切割成一个个独立的单元。这里要注意的是编译原理的单词和英文里面的单词不是等同的概念,在编译原理里面,除了let
,for
和while
等用字母连接起来的字符串是单词,一些诸如=
,==
,&&
和+
等非字母连接起来的字符串也是合法的单词。对于Simple解释器来说,下面都是一些合法的单词:
- 关键字:let,const,break,continue,if,else,while,function,true,false,for,undefined,null,new,return
- 标识符:主要是一些开发者定义的变量名字,例如arr,server,result等
- 字面量:字面量包括数字字面量(number)和字符串字面量(string),Simple解释器只支持单引号字符串,例如’this is a string literal’
- 算术和逻辑运算符号:+,-,++,–,*,/,&&,||,>,>=,<,<=,==
- 赋值运算符:=,+=,-=
- 特殊符号:[,],{,},.,:,(,)
这里要注意的是词法分析阶段不会保留源代码中所有的字符,一些无用的信息例如空格,换行和代码注释等都会在这个阶段被去掉。下面是一个词法分析的效果图:
对于词法分析,大概有以下两种实现:
正则表达式
这个方法可能是大多数开发者都会想到的做法。由于Simple解释器没有使用这种做法,所以这里只会简单介绍一下流程,总体来说,它包含以下这些步骤:
- 为各个单词类型定义对应的正则表达式,例如数字字面量的正则表达式是
/[0-9][0-9]*/
(不考虑浮点数的情况),简单赋值运算符的正则表达式是/=/
,等于运算符的正则表达式是/==/
。 - 将各个单词类型的正则表达式按照词法优先级顺序依次和代码字符串进行match操作,如果某个单词类型的正则表达式有命中,就将对应的子字符串提取出来,然后从刚才命中的字符串最后的位置开始继续执行match操作,如此循环反复直到所有字符串都match完毕为止。这里有一个十分重要的点是不同的单词类型是有词法优先级顺序的,例如等于运算符
==
的优先级要比=
的优先级要高,因为如果开发者写了两个等号,想表达的肯定是等于判断,而不是两个赋值符号。
基于有限状态机
由于所有的正则表达式都可以转化为与其对应的有限状态机,所以词法分析同样也可以使用有限状态机来实现。那么什么是有限状态机呢?
有限状态机的英文名称是Finite State Machine(FSM),它有下面这些特点:
- 它的状态是有限的
- 它同一个时刻只能有一个状态,也就是当前状态
- 在接收到外界的数据后,有限状态机会根据当前状态以及接收到的数据计算出下一个状态并转换到该状态
我们熟悉的红绿灯其实就是一个有限状态机的例子。红绿灯只能有三种颜色,分别是红色,绿色和黄色,所以它的状态集是有限的。由于红绿灯在某一个时刻只能有一种颜色(试想下红绿灯同时是红色和绿色会怎样:)),因此它当前的状态是唯一的。最后红绿灯会根据当前的状态(颜色)和输入(过了多少时间)转换成下一个状态,例如红灯过了60秒就会变黄灯而不能变绿灯。
从上面的定义我们知道一个有限状态机最重要的是下面这三个要素:
- 状态集
- 当前状态
- 不同状态之间如何扭转
知道了什么是有限状态机和它的三要素之后,接着让我们来看一个使用简易有限状态机来做词法分析的例子。我们要设计的有限状态机可以识别下面类型的单词:
- identifier(标识符)
- number(数字字面量,不包含浮点数)
- string(字符串字面量,单引号包起来的)
- 加号(+)
- 加号赋值运算符(+=)
我们先来为这个有限状态机定义一下上面提到的状态机三要素:
- 状态集:状态集应该包含状态机在接收到任何输入后出现的
所有状态
,对于上面的状态机会有下面的状态:- initial:初始状态
- number:当状态机识别到数字字面量时会处于这个状态
- start string literal:当状态机接收到第一个单引号的时候并且没有接收到第二个单引号前(字符串还没结束)都是处于这个状态
- string literal:当状态机识别到字符串字面量时会处于这个状态
- identifier:当状态机识别到标识符会处于这个状态
- plus:当状态机识别到加号会处于这个状态
- plus assign:当前状态机识别到加号赋值运算符会处于这个状态
- 当前状态:该有限状态机的当前状态可以是上面定义的任意一个状态
- 不同状态之间如何扭转:当状态机处于某一个状态时,它只可以扭转到某些特定的状态。举个例子,如果状态机现在处于
start string literal
状态,它只可以维持当前状态或者转换到string literal
状态。在当前输入不能让状态机进行状态扭转时,会有两种情况,第一种情况是当前状态是一个可终止的状态,也就是说当前状态机已经知道生成一个token需要的所有信息了,这个时候状态机会输出当前状态表示的单词类型,输出上一个单词后,状态机会重置为初始状态接着再重新处理刚才的输入;如果当前状态是个非终止状态的话,也就是说当前状态机还没有足够的信息输出一个单词,这个时候状态机会报错。在当前这个例子中,可终止状态有number
,string literal
和identifier
,而非终止状态有start string literal
。下面是这个状态机的状态扭转图:
这里要注意的是状态机除了要存储当前的状态信息外,还要保留现在还没输出为单词的字符,也就是说要有一个buffer
变量来存储遇到的字符输入。例如遇到+
后,buffer
会变成+
,后面再遇到=
,buffer
会变为+=
,最后+=
被输出,buffer<