实现JavaScript语言解释器(二)

前言

在上一篇文章中我为你们介绍了Simpe项目的一些背景知识以及如何使用有限状态机来实现词法解析,在本篇文章中我将会为你们介绍语法分析的相关内容,而且经过设计一门内部DSL语言来实现Simple语言的语法解析。html

什么是语法解析

词法解析事后,字符串的代码会被解析生成一系列Token串,例以下面是代码let a = 'HelloWorld';的词法解析输出:前端

[
  {
    "type": "LET",
    "value": "let",
    "range": {
      "start": {
        "line": 1,
        "column": 1
      },
      "end": {
        "line": 1,
        "column": 3
      }
    }
  },
  {
    "type": "IDENTIFIER",
    "value": "a",
    "range": {
      "start": {
        "line": 1,
        "column": 5
      },
      "end": {
        "line": 1,
        "column": 5
      }
    }
  },
  {
    "type": "ASSIGN",
    "value": "=",
    "range": {
      "start": {
        "line": 1,
        "column": 7
      },
      "end": {
        "line": 1,
        "column": 7
      }
    }
  },
  {
    "type": "STRING_LITERAL",
    "value": "'HelloWorld'",
    "range": {
      "start": {
        "line": 1,
        "column": 9
      },
      "end": {
        "line": 1,
        "column": 20
      }
    }
  },
  {
    "type": "SEMICOLON",
    "value": ";",
    "range": {
      "start": {
        "line": 1,
        "column": 21
      },
      "end": {
        "line": 1,
        "column": 21
      }
    }
  }
]

语法解析(Syntax Analysis)阶段,Simple解释器会根据定义的语法规则来分析单词之间的组合关系,从而输出一棵抽象语法树Abstract Syntax Tree),这也就咱们常听到的AST了。那么为何说这棵语法树是抽象的呢?这是由于在语法解析阶段一些诸如分号和左右括号等用来组织代码用的token会被去掉,所以生成的语法树没有包含词法解析阶段生成的全部token信息,因此它是抽象的。在语法解析阶段,若是Simple解释器发现输入的Token字符串不能经过既定的语法规则来解析,就会抛出一个语法错误(Syntax Error),例如赋值语句没有右表达式的时候就会抛出Syntax Errornode

从上面的描述能够看出,词法解析阶段的重点是分离单词,而语法解析阶段最重要的是根据既定的语法规则组合单词。那么对于Simple解释器来讲,它的语法规则又是什么呢?jquery

Simple语言的语法

咱们前面说到Simple语言实际上是JavaScript的一个子集,因此Simple的语法也是JavaScript语法的一个子集。那么Simple的语法规则都有哪些呢?在进入到使用专业的术语表达Simple语法规则以前,咱们能够先用中文来表达一下Simple的语法规则:程序员

  • 变量定义:let, const或者var后面接一个identifier,而后是可选的等号初始化表达式:express

    let a;
    // 或者
    let a = 10;
  • if条件判断:if关键字后面加上由左右括号包裹起来的条件,条件能够是任意的表达式语句,接着会跟上花括号括起来的语句块。if语句块后面能够选择性地跟上另一个else语句块或者else if语句块:编程

    if (isBoss) {
      console.log('niu bi');
    } else {
      console.log('bu niu bi');
    };
  • while循环:while关键字后面加上由左右括号包裹起来的条件,条件能够是任意的表达式语句,接着是由花括号包裹起来的循环体:json

    while(isAlive) {
      console.log('coding');
    };

    ...数组

细心的你可能发如今上面的例子中全部语句都是以分号;结尾的,这是由于为了简化语法解析的流程,Simple解释器强制要求每一个表达式都要以分号结尾,这样咱们才能够将重点放在掌握语言的实现原理而不是拘泥于JavaScript灵活的语法规则上。浏览器

上面咱们使用了最直白的中文表达了Simple语言的一小部分语法规则,在实际工程里面咱们确定不能这么干,咱们通常会使用巴克斯范式(BNF)或者扩展巴克斯范式(EBNF)来定义编程语言的语法规则

BNF

咱们先来看一个变量定义的巴科斯范式例子:
bnf.png

在上面的巴科斯范式中,每条规则都是由左右两部分组成的。在规则的左边是一个非终结符,而右边是终结符非终结符的组合。非终结符表示这个符号还能够继续细分,例如varModifier这个非终结符能够被解析为letconstvar这三个字符的其中一个,而终结符表示这个符号不能继续细分了,它通常是一个字符串,例如ifwhile(或者)等。不管是终结符仍是非终结符咱们均可以统一将其叫作模式(pattern)

在BNF的规则中,除了模式符号,还有下面这些表示这些模式出现次数的符号,下面是一些咱们在Simple语言实现中用到的符号:

符号 做用
[pattern] 是option的意思,它表示括号里的模式出现0次或者一次,例如变量初始化的时候后面的等号会出现零次或者1次,由于初始值是可选的
pattern1 pattern2 是or的意思,它表示模式1或者模式2被匹配,例如变量定义的时候可使用letconst或者var
{ pattern } 是repeat的意思, 表示模式至少重复零次,例如if语句后面能够跟上0个或者多个else if

要实现Simple语言上面这些规则就够用了,若是你想了解更多关于BNF或者EBNF的内容,能够自行查阅相关的资料。

如何实现语法解析

在咱们编写完属于咱们语言的BNF规则以后,可使用Yacc或者Antlr等开源工具来将咱们的BNF定义转化成词法解析和语法解析的客户端代码。在实现Simple语言的过程当中,为了更好地学习语法解析的原理,我没有直接使用这些工具,而是经过编写一门灵活的用来定义语法规则的领域专用语言(DSL)来定义Simple语言的语法规则。可能不少同窗不知道什么是DSL,不要着急,这就为你们解释什么是DSL。

DSL的定义

身为程序员,我相信你们都或多或少据说过DSL这个概念,即便你没听过,你也确定用过。在了解DSL定义以前咱们先来看一下都有哪些经常使用的DSL:

  • HTML
  • CSS
  • XML
  • JSX
  • Markdown
  • RegExp
  • JQuery
  • Gulp
    ...

我相信做为一个程序员特别是前端程序员,你们必定不会对上面的DSL感到陌生。DSL的全称是Domain-Specific Language,翻译过来就是领域特定语言,和JavaScrpt等通用编程语言(GPL - General-Purpose Language)最大的区别就是:DSL是为特定领域编写的,而GPL能够用来解决不一样领域的问题。举个例子,HTML是一门DSL,由于它只能用来定义网页的结构。而JavaScript是一门GPL,所以它能够用来解决不少通用的问题,例如编写各式各样的客户端程序和服务端程序。正是因为DSL只须要关心当前领域的问题,因此它不须要图灵完备,这也意味着它能够更加接近人类的思惟方式,让一些不是专门编写程序的人也能够参与到DSL的编写中(设计师也能够编写HTML代码)。

DSL的分类

DSL被分红了两大类,一类是内部DSL,一类是外部DSL。

内部DSL

内部DSL是创建在某个宿主语言(一般是一门GPL,例如JavaScript)之上的特殊DSL,它具备下面这些特色:

  • 和宿主语言共享编译与调试等基础设施,对那些会使用宿主语言的开发者来讲,使用该宿主语言编写的DSL的门槛会很低,并且内部DSL能够很容易就集成到宿主语言的应用里面去,它的使用方法就像引用一个外部依赖同样简单,宿主欢迎只须要安装就能够了。
  • 它能够视为使用宿主语言对特定任务(特定领域)的一个封装,使用者能够很容易使用这层封装编写出可读性很高的代码。例如JQuery就是一门内部DSL,它里面封装了不少对页面DOM操做的函数,因为它的功能颇有局限性,因此它能够封装出更加符合人们直觉的API,并且它编写的代码的可读性会比直接使用浏览器原生的native browser APIS要高不少。

下面是一个分别使用浏览器原生API和使用JQuery API来实现一样任务的例子:
native.png
jquery.png

外部DSL

和内部DSL不一样,外部DSL没有依赖的宿主环境,它是一门独立的语言,例如HTML和CSS等。由于外部DSL是彻底独立的语言,因此它具备下面这些特色:

  • 不能享用现有语言的编译和调试等工具,若有须要要本身实现,成本很高
  • 若是你是语言的实现者,须要本身设计和实现一门全新的语言,对本身的要求很高。若是你是语言的学习者就须要学习一门新的语言,比内部DSL具备更高的学习成本。并且若是语言的设计者自身水平不够,他们弄出来的DSL一旦被用在了项目里面,后面可能会成为阻碍项目发展的一个大坑
  • 一样也是因为外部DSL没有宿主语言环境的约束,因此它不会受任何现有语言的束缚,所以它能够针对当前须要解决的领域问题来定义更加灵活的语法规则,和内部DSL相比它有更小的来自于宿主语言的语言噪声

下面是一个外部DSL的例子 - Mustache
mustache.png

Simple语言的语法解析DSL

前面说到了内部DSL和外部DSL的一些特色和区别,因为咱们的语法解析逻辑要和以前介绍的词法解析逻辑串联起来,因此我在这里就选择了宿主环境是TypeScript的内部DSL来实现

DSL的设计

如何从头开始设计一门内部DSL呢?咱们须要从要解决的领域特定问题出发,对于Simple语言它就是:将Simple语言的BNF语法规则使用TypeScipt表达出来。在上面BNF的介绍中,咱们知道BNF主要有三种规则:optionrepeator。每一个规则之间能够相互组合和嵌套,等等,互相组合和嵌套?你想到了什么JavaScript语法能够表达这种场景?没错就是函数的链式调用

对于程序员来讲最清晰的解释应该是直接看代码了,因此咱们能够来看一下Simple语言语法解析的代码部分。和词法解析相似,Simple的语法规则放在lib/config/Parser这个文件中,下面是这个文件的示例内容:

// rule函数会生成一个根据定义的语法规则解析Token串从而生成AST节点的Parser实例,这个函数会接收一个用来生成对应AST节点的AST类,全部的AST节点类定义都放在lib/ast/node这个文件夹下
const ifStatement = rule(IfStatement)
ifStatement
  // if语句使用if字符串做为开头
  .separator(TOKEN_TYPE.IF)
  // if字符串后面会有一个左括号
  .separator(TOKEN_TYPE.LEFT_PAREN)
  // 括号里面是一个执行结果为布尔值的binaryExpression
  .ast(binaryExpression)
  // 右括号
  .separator(TOKEN_TYPE.RIGHT_PAREN)
  // if条件成立后的执行块
  .ast(blockStatement)
  // 后面的内容是可选的
  .option(
    rule().or(
      // else if语句
      rule().separator(TOKEN_TYPE.ELSE).ast(ifStatement),
      // else语句
      rule().separator(TOKEN_TYPE.ELSE).ast(blockStatement)
    )
  )

上面就是Simple的if表达式定义了,因为使用了DSL进行封装,ifStatement的语法规则很是通俗易懂,并且十分灵活。试想一下假如咱们忽然要改变ifStatement的语法规则:不容许if后面加else if。要知足这个改变咱们只须要将rule().separator(TOKEN_TYPE.ELSE).ast(ifStatement)这个规则去掉就能够了。接着就让咱们深刻到上面代码的各个函数和变量的定义中去:

rule函数

这个函数是一个用来生成对应AST节点Parser的工厂函数,它会接收一个AST节点的构造函数做为参数,而后返回一个对应的Parser类实例。

// lib/ast/parser/rule
const rule = (NodeClass?: new () => Node): Parser => {
  return new Parser(NodeClass)
}
Parser类

Parser类是整个Simple语法解析的核心。它经过函数链式调用的方法定义当前AST节点的语法规则,在语法解析阶段根据定义的语法规则消耗词法解析阶段生成的Token串,若是语法规则匹配它会生成对应AST节点,不然Token串的光标会重置为规则开始匹配的位置(回溯)从而让父节点的Parser实例使用下一个语法规则进行匹配,当父节点没有任何一个语法规则知足条件时,会抛出Syntax Error。下面是Parser类的各个函数的介绍:

方法 做用
.separator(TOKEN) 定义一个终结符语法规则,该终结符不会做为当前AST节点的子节点,例如if表达式的if字符串
.token(TOKEN) 定义一个终结符语法规则,该终结符会被做为当前AST节点的子节点,例如算术表达式中的运算符(+,-,*,/)
.option(parser) 定义一个可选的非终结符规则,非终结符规则都是一个子Parser实例,例如上面if表达式定义中的else if子表达式
.repeat(parser) 定义一个出现0次或者屡次的非终结符规则,例如数组里面的元素多是0个或者多个
.or(...parser TOKEN) or里面的规则有且出现一个,例如变量定义的时候多是var,let或者const
.expression(parser, operatorsConfig) 特殊的用来表示算术运算的规则
.parse(tokenBuffer) 这个函数会接收词法解析阶段生成的tokenBuffer串做为输入,而后使用当前Parser实例的语法规则来消耗TokenBuffer串的内容,若是有彻底匹配就会根据当前Parser节点的AST构造函数生成对应的AST节点,不然会将TokenBuffer重置为当前节点规则开始匹配的起始位置(setCursor)而后返回到父级节点
AST节点类的定义

Simple语言全部的AST节点定义都放在lib/ast/node这个文件夹底下。对于每一种类型的AST节点,这个文件夹下都会有其对应的AST节点类。例如赋值表达式节点的定义是AssignmentExpression类,if语句的定义是IfStatement类等等。这些节点类都有一个统一的基类Node,Node定义了全部节点都会有的节点类型属性(type),节点生成规则create函数,以及当前节点在代码执行阶段的计算规则evaluate函数。下面是示例代码:

// lib/ast/node/Node
class Node {
  // 节点类型
  type: NODE_TYPE
  // 节点的起始位置信息,方便产生语法错误时给开发者进行定位
  loc: {
    start: ILocation,
    end: ILocation
  } = {
    start: null,
    end: null
  }

  // 节点的生成规则,当前节点会根据其子节点的内容生成
  create(children: Array<Node>): Node {
    if (children.length === 1) {
      return children[0]
    } else {
      return this
    }
  }

  // 节点的运算规则,节点在运算时会传进当前的环境变量,每一个节点都须要实现本身的运算规则,下一篇文章会详细展开
  evaluate(env?: Environment): any {
    throw new Error('Child Class must implement its evaluate method')
  }
}

如今咱们来看一下IfStatement这个AST节点类的定义

class IfStatement extends Node {
  // 该节点的类型是if statement
  type: NODE_TYPE = NODE_TYPE.IF_STATEMENT
  // if的判断条件,必须是是一个BinaryExpression节点
  test: BinaryExpression = null
  // if条件成立的条件下的执行语句,是一个BlockStatement节点
  consequent: BlockStatement = null
  // else的执行语句
  alternate: IfStatement|BlockStatement = null

  // Parser会解析出if语句的全部children节点信息来构造当前的IfStatement节点,children节点的内容和定义由lib/config/Parser文件定义
  create(children: Array<Node>): Node {
    this.test = children[0] as BinaryExpression
    this.consequent = children[1] as BlockStatement
    this.alternate = children[2] as IfStatement|BlockStatement
    return this
  }

  evaluate(env: Environment): any {
    // 后面文章会讲
  }
}

AST

介绍完Parser类和AST节点类后你如今就能够看懂lib/config/Parser的语法规则定义了,这个文件里面包含了Simple全部语法规则的定义,其中包括根节点的定义:

// 列举了全部可能的statement
statement
  .or(
    breakStatement,
    returnStatement,
    expressionStatement,
    variableStatement,
    assignmentExpression,
    whileStatement,
    ifStatement,
    forStatement,
    functionDeclaration,
  )
const statementList = rule(StatementList)
  .repeat(
    rule()
      .ast(statement)
      .throw('statement must end with semi colon')
      .separator(TOKEN_TYPE.SEMI_COLON)

// 一个程序其实就是不少statement的组合
const program = statementList

最后就是将上一章的词法解析和语法解析串联起来,代码在lib/parser这个文件里面:

// tokenBuffer是词法解析的结果
const parse = (tokenBuffer: TokenBuffer): Node => {
  // parser是lib/config/Parser的根节点(program节点),rootNode对应的就是抽象语法树AST
  const rootNode = parser.parse(tokenBuffer)

  if (!tokenBuffer.isEmpty()) {
    // 若是到最后还有没有被解析完的Token就代表编写的代码有语法错误,须要报错给开发者
    const firstToken = tokenBuffer.peek()
    throw new SyntaxError(`unrecognized token ${firstToken.value}`, firstToken.range.start)
  }

  return rootNode
}

咱们来看一下rootNode的具体内容,假如开发者写了如下的代码:

console.log("Hello World");

会生成下面的AST:

{
  "loc": {
    "start": {
      "line": 1,
      "column": 1
    },
    "end": {
      "line": 1,
      "column": 26
    }
  },
  "type": "STATEMENT_LIST",
  "statements": [
    {
      "loc": {
        "start": {
          "line": 1,
          "column": 1
        },
        "end": {
          "line": 1,
          "column": 26
        }
      },
      "type": "EXPRESSION_STATEMENT",
      "expression": {
        "loc": {
          "start": {
            "line": 1,
            "column": 1
          },
          "end": {
            "line": 1,
            "column": 26
          }
        },
        "type": "CALL_EXPRESSION",
        "callee": {
          "loc": {
            "start": {
              "line": 1,
              "column": 1
            },
            "end": {
              "line": 1,
              "column": 11
            }
          },
          "type": "MEMBER_EXPRESSION",
          "object": {
            "loc": {
              "start": {
                "line": 1,
                "column": 1
              },
              "end": {
                "line": 1,
                "column": 7
              }
            },
            "type": "IDENTIFIER",
            "name": "console"
          },
          "property": {
            "loc": {
              "start": {
                "line": 1,
                "column": 9
              },
              "end": {
                "line": 1,
                "column": 11
              }
            },
            "type": "IDENTIFIER",
            "name": "log"
          }
        },
        "arguments": [
          {
            "loc": {
              "start": {
                "line": 1,
                "column": 13
              },
              "end": {
                "line": 1,
                "column": 25
              }
            },
            "type": "STRING_LITERAL",
            "value": "Hello World"
          }
        ]
      }
    }
  ]
}

小结

在本篇文章中我介绍了什么是语法解析,以及给你们入门了领域专用语言的一些基本知识,最后讲解了Simple语言是如何利用内部DSL来实现其语法解析机制的。

在下一篇文章中我将会为你们介绍Simple语言的运行时是如何实现的,会包括闭包如何实现以及this绑定等内容,你们敬请期待!

我的技术动态

文章首发于个人博客平台

欢迎关注公众号进击的大葱一块儿学习成长

wechat_qr.jpg

相关文章
相关标签/搜索