實現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

相關文章
相關標籤/搜索