實現JavaScript語言直譯器(三)

進擊的大蔥發表於2022-03-08

前言

上篇文章我為大家介紹了語法解析的一些基本概念,以及如何通過自定義的DSL語言實現Simple語言直譯器的語法樹解析。在本篇也是這個系列最後一篇文章中我將為大家介紹Simple直譯器是如何執行生成的語法樹的。

evaluate函式和作用域

前面在介紹語法解析相關知識的時候有出現過evaluate函式,其實基本每一個AST節點都會有一個對應的evaluate函式,這個函式的作用就是告訴Simple直譯器如何執行當前AST節點。因此Simple直譯器執行程式碼的過程就是:從根節點開始執行當前節點的evaluate函式然後遞迴地執行子節點evalute函式的過程

我們知道JavaScript程式碼執行的時候有一個概念叫做作用域,當我們訪問一個變數的時候,會先看看當前作用域有沒有定義這個變數,如果沒有就會沿著作用域鏈向上一直尋找到全域性作用域,如果作用域鏈上都沒有該變數的定義的話就會丟擲一個Uncaught ReferenceError: xx is not defined的錯誤。在實現Simple語言直譯器的時候,我參照了JavaScript作用域的概念實現了一個叫做Environment的類,我們來看看Evironment類的實現:

// lib/runtime/Environment.ts

// Environment類就是Simple語言的作用域
class Environment {
  // parent指向當前作用域的父級作用域
  private parent: Environment = null
  // values物件會以key-value的形式儲存當前作用域變數的引用和值
  // 例如values = {a: 10},代表當前作用域有一個變數a,它的值是10
  protected values: Object = {}

  // 當前作用域有新的變數定義的時候會呼叫create函式進行值的設定
  // 例如執行 let a = 10 時,會呼叫env.create('a', 10)
  create(key: string, value: any) {
    if(this.values.hasOwnProperty(key)) {
      throw new Error(`${key} has been initialized`)
    }
    this.values[key] = value
  }

  // 如果某個變數被重新賦值,Simple會沿著當前作用域鏈進行尋找,找到最近的符合條件的作用域,然後在該作用域上進行重新賦值
  update(key: string, value: any) {
    const matchedEnvironment = this.getEnvironmentWithKey(key)
    if (!matchedEnvironment) {
      throw new Error(`Uncaught ReferenceError: ${key} hasn't been defined`)
    }
    matchedEnvironment.values = {
      ...matchedEnvironment.values,
      [key]: value
    }
  }

  // 在作用域鏈上尋找某個變數,如果沒有找到就丟擲Uncaught ReferenceError的錯誤
  get(key: string) {
    const matchedEnvironment = this.getEnvironmentWithKey(key)
    if (!matchedEnvironment) {
      throw new Error(`Uncaught ReferenceError: ${key} is not defined`)
    }

    return matchedEnvironment.values[key]
  }

  // 沿著作用域鏈向上尋找某個變數的值,如果沒有找到就返回null
  private getEnvironmentWithKey(key: string): Environment {
    if(this.values.hasOwnProperty(key)) {
      return this
    }
  
    let currentEnvironment = this.parent
    while(currentEnvironment) {
      if (currentEnvironment.values.hasOwnProperty(key)) {
        return currentEnvironment
      }
      currentEnvironment = currentEnvironment.parent
    }

    return null
  }
}

從上面的程式碼以及註釋可以看出,所謂的作用域鏈其實就是由Environment例項組成的單向連結串列。解析某個變數值的時候會沿著這個作用域鏈進行尋找,如果沒有找到該變數的定義就會報錯。接著我們以for迴圈執行的過程來看一下具體過程是怎麼樣的:

被執行的程式碼:

for(let i = 0; i < 10; i++) {
  console.log(i);
};

ForStatement程式碼的執行過程:

// lib/ast/node/ForStatement.ts
class ForStatement extends Node {
  ...

  // evaluate函式會接受一個作用域物件,這個物件代表當前AST節點的執行作用域
  evaluate(env: Environment): any {
    // 上面for迴圈括號裡面的內容是在一個獨立的作用域裡面的,所以需要基於父級節點傳遞過來的作用域新建一個作用域,取名為bridgeEnvironment
    const bridgeEnvironment = new Environment(env)
    // if括號內的變數初始化(let i = 0)會在這個作用域裡面進行
    this.init.evaluate(bridgeEnvironment)

    // 如果當前作用域沒有被break語句退出 && return語句返回 && 測試表示式(i < 10)是真值,for迴圈就會繼續執行,否則for迴圈中斷
    while(!runtime.isBreak && !runtime.isReturn && this.test.evaluate(bridgeEnvironment)) {
      // 因為for迴圈體(console.log(i))是一個新的作用域,所以要基於當前的brigeEnvironment新建一個子作用域
      const executionEnvironment = new Environment(bridgeEnvironment)
      this.body.evaluate(executionEnvironment)
      // 迴圈變數的更新(i++)會在brigeEnvironment裡面執行
      this.update.evaluate(bridgeEnvironment)
    }
  }
}

閉包和this繫結

在理解了evalute函式的一般執行過程後,我們再來看看閉包是如何實現的。我們都知道JavaScript是詞法作用域,也就是說一個函式的作用域鏈在這個函式被定義的時候就決定了。我們通過函式宣告節點FunctionDeclaration的evaluate函式的程式碼來看一下Simple語言的閉包是如何實現的:

// lib/ast/node/FunctionDeclaration.ts
class FunctionDeclaration extends Node {
  ...

  // 當函式宣告語句被執行的時候,這個evaluate函式會被執行,傳進來的物件就是當前的執行作用域
  evaluate(env: Environment): any {
    // 生成一個新的FunctionDeclaration物件,因為同一個函式可能被多次定義(例如這個函式被巢狀定義在某個父級函式的時候)
    const func = new FunctionDeclaration()
    // 函式複製
    func.loc = this.loc
    func.id = this.id
    func.params = [...this.params]
    func.body = this.body
    
    // 函式被宣告的時候會通過parentEnv屬性記錄下當前的執行作用域,這就是閉包了!!!
    func.parentEnv = env

    // 將函式註冊到當前的執行作用域上面,該函式就可以被遞迴呼叫了
    env.create(this.id.name, func)
  }
  ...
}

從上面的程式碼可以看出,要實現Simple語言的閉包,其實只需要在函式宣告的時候記錄一下當前作用域(parentEnv)就可以了

接著我們再來看一下函式執行的時候是如何判斷this繫結的是哪個物件的:

// lib/ast/node/FunctionDeclaration.ts
class FunctionDeclaration extends Node {
  ...

  // 函式執行的時候,如果存在呼叫函式的例項,該例項會被當做引數傳進來,例如a.test(),a就是test的這個引數
  call(args: Array<any>, callerInstance?: any): any {
    // 函式執行時傳進來的引數如果少於宣告的引數會報錯
    if (this.params.length !== args.length) {
      throw new Error('function declared parameters are not matched with arguments')
    }

    // 這是實現閉包的重點,函式執行時的父級作用域是之前函式被定義的時候記錄下來的父級作用域!!
    const callEnvironment = new Environment(this.parentEnv)
    
    // 函式引數進行初始化
    for (let i = 0; i < args.length; i++) {
      const argument = args[i]
      const param = this.params[i]

      callEnvironment.create(param.name, argument)
    }
    // 建立函式的arguments物件
    callEnvironment.create('arguments', args)

    // 如果當前函式有呼叫例項,那麼這個函式的this將會是呼叫例項
    if (callerInstance) {
      callEnvironment.create('this', callerInstance)
    } else {
      // 如果函式沒有呼叫例項,就會沿著函式的作用域鏈就行尋找,直到全域性的process(node)或者window(browser)物件
      callEnvironment.create('this', this.parentEnv.getRootEnv().get('process'))
    }

    // 函式體的執行
    this.body.evaluate(callEnvironment)
  }
}

上面的程式碼大概給大家介紹了Simple語言的this是如何繫結的,實際上JavaScript的實現可能和這個有比較大的出入,這裡只是給大家一個參考而已。

總結

在本篇文章中我給大家介紹了Simple直譯器是如何執行程式碼的,其中包括閉包和this繫結的內容,由於篇幅限制這裡忽略了很多內容,例如for和while迴圈的break語句是如何退出的,函式的return語句是如何將值傳遞給父級函式的,大家如果感興趣可以看一下我的原始碼:
https://github.com/XiaocongDo...

最後希望大家經過這三篇系列文章的學習可以對編譯原理和JavaScript一些比較難懂的語言特性有一定的瞭解,也希望後面我可以繼續給大家帶來優質的內容來讓我們共同進步。

個人技術動態

文章首發於我的部落格平臺

歡迎關注公眾號進擊的大蔥一起學習成長

wechat_qr.jpg

相關文章