寫給小白的開源編譯器

削微寒發表於2022-05-18

不知道你是不是和我一樣,看到“編譯器”三個字的時候,就感覺非常高大上,同時心底會升起一絲絲“害怕”!

我始終認為編譯器是很複雜...很複雜的東西,不是我這種小白能懂的。而且一想到要學習編譯器的知識,腦海裡就浮現出那種 500 頁起的厚書。

一直到我發現 the-super-tiny-compiler 這個寶藏級的開源專案,它是一個僅 1000 行左右的迷你編譯器,其中註釋佔了程式碼量的 80%,實際程式碼只有 200 行!麻雀雖小但五臟俱全,完整地實現了編譯器所需基本功能,通過 程式碼+註釋+講解 讓你通過一個開源專案入門編譯器。

地址:https://github.com/jamiebuilds/the-super-tiny-compiler

中文:https://github.com/521xueweihan/OneFile/blob/main/src/javascript/the-super-tiny-compiler.js

下面我將從介紹 什麼是編譯器 開始,使用上述專案作為示例程式碼,更加細緻地講解編譯的過程,把編譯器入門的門檻再往下砍一砍。如果你之前沒有接觸過編譯器相關的知識,那這篇文章可以讓你對編譯器所做的事情,以及原理有一個初步的認識!

準備好變強了嗎?那我們開始吧!

一、什麼是編譯器

從概念上簡單講:

編譯器就是將“一種語言(通常為高階語言)”翻譯為“另一種語言(通常為低階語言)”的程式。

對於現代程式設計師來說我們最熟悉的 JavaScript、Java 這些都屬於高階語言,也就是便於我們程式設計者編寫、閱讀、理解、維護的語言,而低階語言就是計算機能直接解讀、執行的語言。

編譯器也可以理解成是這兩種語言之間的“橋樑”。編譯器存在的原因是因為計算機 CPU 執行數百萬個微小的操作,因為這些操作實在是太“微小”,你肯定不願意手動去編寫它們,於是就有了二進位制的出現,二進位制程式碼也被理解成為機器程式碼。很顯然,二進位制看上去並不好理解,而且編寫二進位制程式碼很麻煩,因此 CPU 架構支援把二進位制操作對映作為一種更容易閱讀的語言——組合語言。

雖然組合語言非常低階,但是它可以轉換為二進位制程式碼,這種轉換主要靠的是“彙編器”。因為組合語言仍然非常低階,對於追求高效的程式設計師來說是無法忍受的,所以又出現了更高階的語言,這也是大部分程式設計師使用且熟悉的程式語言,這些抽象的程式語言雖然不能直接轉化成機器操作,但是它比組合語言更好理解且更能夠被高效的使用。所以我們需要的其實就是能理解這些複雜語言並正確地轉換成低階程式碼的工具——編譯器。

我覺得對於初學者來說到這裡有個大致的瞭解就可以了。因為接下去要分析的這個例子非常簡單但是能覆蓋大多數場景,你會從最真實最直接的角度來直面這個“大敵”——編譯器。

二、“迷你”編譯器

下面我們就用 the-super-tiny-compiler 為示例程式碼,帶大家來簡單瞭解一下編譯器。

不同編譯器之間的不同階段可能存在差別,但基本都離不開這三個主要組成部分:解析、轉換和程式碼生成。

其實這個“迷你”編譯器開源專案的目的就是這些:

  • 證明現實世界的編譯器主要做的是什麼

  • 做一些足夠複雜的事情來證明構建編譯器的合理性

  • 用最簡單的程式碼來解釋編譯器的主要功能,使新手不會望而卻步

以上就解釋了這個開源專案存在的意義了,所以如果你對編譯器有很濃厚的興趣希望一學到底的,那肯定還是離不開大量的閱讀和鑽研啦,但是如果你希望對編譯器的功能有所瞭解,那這篇文章就別錯過啦!

現在我們就要對這個專案本身進行進一步的學習了,有些背景需要提前瞭解一下。這個專案主要是把 LISP 語言編譯成我們熟悉的 JavaScript 語言。

那為什麼要用 LISP 語言呢?

LISP 是具有悠久歷史的計算機程式語言家族,有獨特和完全用括號的字首符號表示法。起源於 1958 年,是現今第二悠久仍廣泛使用的高階程式語言。

首先 LISP 語言和我們熟悉的 C 語言和 JavaScript 語言很不一樣,雖然其他的語言也有強大的編譯器,但是相對於 LISP 語言要複雜得多。LISP 語言是一種超級簡單的解析語法,並且很容易被翻譯成其他語法,像這樣:

所以到這裡你應該知道我們要幹什麼了吧?那讓我們再深入地瞭解一下具體要怎麼進行“翻譯”(編譯)吧!

三、編譯過程

前面我們已經提過,大部分的編譯器都主要是在做三件事:

  1. 解析
  2. 轉換
  3. 程式碼生成

下面我們將分解 the-super-tiny-compiler 的程式碼,然後進行逐行解讀。

讓我們一起跟著程式碼,弄清楚上述三個階段具體做了哪些事情~

3.1 解析

解析通常分為兩個階段:詞法分析句法分析

  1. 詞法分析:獲取原始程式碼並通過一種稱為標記器(或詞法分析器 Tokenizer)的東西將其拆分為一種稱為標記(Token)的東西。標記是一個陣列,它描述了一個獨立的語法片段。這些片段可以是數字、標籤、標點符號、運算子等等。

  2. 語法分析:獲取之前生成的標記(Token),並把它們轉換成一種抽象的表示,這種抽象的表示描述了程式碼語句中的每一個片段以及它們之間的關係。這被稱為中間表示(intermediate representation)或抽象語法樹(Abstract Syntax Tree, 縮寫為AST)。AST 是一個深度巢狀的物件,用一種更容易處理的方式代表了程式碼本身,也能給我們更多資訊。

比如下面這個語法:

(add 2 (subtract 4 2))

拆成 Token 陣列就像這樣:

[
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        },
]

程式碼思路:

因為我們需要去解析字串,就需要有一個像指標/游標來幫我們辨認目前解析的位置是哪裡,所以會有一個 current 的變數,從 0 開始。而我們最終的目的是獲取一個 token 陣列,所以也先初始化一個空陣列 tokens

// `current` 變數就像一個指游標一樣讓我們可以在程式碼中追蹤我們的位置
let current = 0;
  
// `tokens` 陣列用來存我們的標記
let tokens = [];

既然要解析字串,自然少不了遍歷啦!這裡就用一個 while 迴圈來解析我們當前的字串。

// 在迴圈裡面我們可以將`current`變數增加為我們想要的值
while (current < input.length) {
  // 我們還將在 `input` 中儲存 `current` 字元
  let char = input[current];
}

如何獲取字串裡面的單個字元呢?答案是用像陣列那樣的中括號來獲取:

var char = str[0]

這裡新增一個知識點來咯!在 JavaScript 中 String 類的例項,是一個類陣列,從下面這個例子可以看出來:

可能之前你會用 charAt 來獲取字串的單個字元,因為它是在 String 型別上的一個方法:

這兩個方法都可以實現你想要的效果,但是也存在差別。下標不存在時 str[index] 會返回 undefined,而 str.charAt(index) 則會返回 ""(空字串):

隨著游標的移動和字串中字元的獲取,我們就可以來逐步解析當前字串了。

那解析也可以從這幾個方面來考慮,以 (add 2 (subtract 4 2)) 這個為例,我們會遇到這些:( 左括號、字串、空格、數字、) 右括號。對於不同的型別,就要用不同的 if 條件判斷分別處理:

  • 左右括號匹配代表一個整體,找到對應的括號只要做上標記就好
  • 空格代表有字元分割,不需要放到我們的 token 陣列裡,只需要跳到下一個非空格的字元繼續迴圈就好
// 檢查是否有一個左括號:
if (char === '(') {
  
  // 如果有,我們會存一個型別為 `paren` 的新標記到陣列,並將值設定為一個左括號。
  tokens.push({
    type: 'paren',
    value: '(',
  });
  
  // `current`自增
  current++;
  
  // 然後繼續進入下一次迴圈。
  continue;
}

// 接下去檢查右括號, 像上面一樣
if (char === ')') {
  tokens.push({
    type: 'paren',
    value: ')',
  });
  current++;
  continue;
}

// 接下去我們檢查空格,這對於我們來說就是為了知道字元的分割,但是並不需要儲存為標記。

// 所以我們來檢查是否有空格的存在,如果存在,就繼續下一次迴圈,做除了儲存到標記陣列之外的其他操作即可
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
  current++;
  continue;
}

字串和數字因為具有各自不同的含義,所以處理上面相對複雜一些。先從數字來入手,因為數字的長度不固定,所以要確保獲取到全部的數字字串呢,就要經過遍歷,從遇到第一個數字開始直到遇到一個不是數字的字元結束,並且要把這個數字存起來。

//   (add 123 456)
//        ^^^ ^^^
//        只有兩個單獨的標記
//
// 因此,當我們遇到序列中的第一個數字時,我們就開始了
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
  
  // 我們將建立一個`value`字串,並把字元推送給他
  let value = '';
  
  // 然後我們將遍歷序列中的每個字元,直到遇到一個不是數字的字元
  // 將每個作為數字的字元推到我們的 `value` 並隨著我們去增加 `current`
  // 這樣我們就能拿到一個完整的數字字串,例如上面的 123 和 456,而不是單獨的 1 2 3 4 5 6
  while (NUMBERS.test(char)) {
    value += char;
    char = input[++current];
  }
  
  // 接著我們把數字放到標記陣列中,用數字型別來描述區分它
  tokens.push({ type: 'number', value });
  
  // 繼續外層的下一次迴圈
  continue;
}

為了更適用於現實場景,這裡支援字串的運算,例如 (concat "foo" "bar") 這種形式的運算,那就要對 " 內部的字串再做一下解析,過程和數字類似,也需要遍歷,然後獲取全部的字串內容之後再存起來:

// 從檢查開頭的雙引號開始:
if (char === '"') {
  // 保留一個 `value` 變數來構建我們的字串標記。
  let value = '';
  
  // 我們將跳過編輯中開頭的雙引號
  char = input[++current];
  
  // 然後我們將遍歷每個字元,直到我們到達另一個雙引號
  while (char !== '"') {
    value += char;
    char = input[++current];
  }
  
  // 跳過相對應閉合的雙引號.
  char = input[++current];
  
  // 把我們的字串標記新增到標記陣列中
  tokens.push({ type: 'string', value });
  
  continue;
}

最後一種標記的型別是名稱。這是一個字母序列而不是數字,這是我們 lisp 語法中的函式名稱:

//   (add 2 4)
//    ^^^
//    名稱標記
//
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
  let value = '';

  // 同樣,我們遍歷所有,並將它們完整的存到`value`變數中
  while (LETTERS.test(char)) {
    value += char;
    char = input[++current];
  }

  // 並把這種名稱型別的標記存到標記陣列中,繼續迴圈
  tokens.push({ type: 'name', value });

  continue;
}

以上我們就能獲得一個 tokens 陣列了,下一步就是構建一個抽象語法樹(AST)可能看起來像這樣:

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2',
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4',
      }, {
        type: 'NumberLiteral',
        value: '2',
      }]
    }]
  }]
}

程式碼思路:

同樣我們也需要有一個游標/指標來幫我們辨認當前操作的物件是誰,然後預先建立我們的 AST 樹,他有一個根節點叫做 Program

let current = 0;

let ast = {
  type: 'Program',
  body: [],
};

再來看一眼我們之前獲得的 tokens 陣列:

[
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        },
]

你會發現對於 (add 2 (subtract 4 2)) 這種具有巢狀關係的字串,這個陣列非常“扁平”也無法明顯的表達巢狀關係,而我們的 AST 結構就能夠很清晰的表達巢狀的關係。對於上面的陣列來說,我們需要遍歷每一個標記,找出其中是 CallExpressionparams,直到遇到右括號結束,所以遞迴是最好的方法,所以我們建立一個叫 walk 的遞迴方法,這個方法返回一個 node 節點,並存入我們的 ast.body 的陣列中:

function walk() {
 // 在 walk 函式裡面,我們首先獲取`current`標記
 let token = tokens[current];
}

while (current < tokens.length) {
  ast.body.push(walk());
}

下面就來實現我們的 walk 方法。我們希望這個方法可以正確解析 tokens 陣列裡的資訊,首先就是要針對不同的型別 type 作區分:

首先先操作“值”,因為它是不會作為父節點的所以也是最簡單的。在上面我們已經瞭解了值可能是數字 (subtract 4 2) 也可能是字串 (concat "foo" "bar"),只要把值和型別匹配上就好:

// 首先先檢查一下是否有`number`標籤.
if (token.type === 'number') {

  // 如果找到一個,就增加`current`.
  current++;

  // 然後我們就能返回一個新的叫做`NumberLiteral`的 AST 節點,並賦值
  return {
    type: 'NumberLiteral',
    value: token.value,
  };
}

// 對於字串來說,也是和上面數字一樣的操作。新增一個`StringLiteral`節點
if (token.type === 'string') {
  current++;

  return {
    type: 'StringLiteral',
    value: token.value,
  };
}

接下去我們要尋找呼叫的表示式(CallExpressions)。每匹配一個左括號,就能在下一個得到表示式的名字,在沒有遇到右括號之前都經過遞迴把樹狀結構豐富起來,直到遇到右括號停止遞迴,直到迴圈結束。從程式碼上看更加直觀:

if (
  token.type === 'paren' &&
  token.value === '('
) {

  // 我們將增加`current`來跳過這個插入語,因為在 AST 樹中我們並不關心這個括號
  token = tokens[++current];

  // 我們建立一個型別為“CallExpression”的基本節點,並把當前標記的值設定到 name 欄位上
  // 因為左括號的下一個標記就是這個函式的名字
  let node = {
    type: 'CallExpression',
    name: token.value,
    params: [],
  };

  // 繼續增加`current`來跳過這個名稱標記
  token = tokens[++current];

  // 現在我們要遍歷每一個標記,找出其中是`CallExpression`的`params`,直到遇到右括號
  // 我們將依賴巢狀的`walk`函式來增加我們的`current`變數來超過任何巢狀的`CallExpression`
  // 所以我們建立一個`while`迴圈持續到遇到一個`type`是'paren'並且`value`是右括號的標記
  while (
    (token.type !== 'paren') ||
    (token.type === 'paren' && token.value !== ')')
  ) {
    // 我們把這個節點存到我們的`node.params`中去
    node.params.push(walk());
    token = tokens[current];
  }

  // 我們最後一次增加`current`變數來跳過右括號
  current++;

  // 返回node節點
  return node;
}

3.2 轉換

編譯器的下一個階段是轉換。要做的就是獲取 AST 之後再對其進行更改。它可以用相同的語言操作 AST,也可以將其翻譯成一種全新的語言。

那如何轉換 AST 呢?

你可能會注意到我們的 AST 中的元素看起來非常相似。這些元素都有 type 屬性,它們被稱為 AST 結點。這些節點含有若干屬性,可以用於描述 AST 的部分資訊。

// 我們可以有一個“NumberLiteral”的節點:
{
  type: 'NumberLiteral',
  value: '2',
}

// 或者可能是“CallExpression”的一個節點:
{
  type: 'CallExpression',
  name: 'subtract',
  params: [...巢狀節點放在這裡...],
}

對於轉換 AST 無非就是通過新增、刪除、替換屬性來操作節點,或者也可以新增節點、刪除節點,甚至我們可以在原有的 AST 結構保持不變的狀態下建立一個基於它的全新的 AST。

由於我們的目標是一種新的語言,所以我們將要專注於創造一個完全新的 AST 來配合這個特定的語言。

為了能夠訪問所有這些節點,我們需要遍歷它們,使用的是深度遍歷的方法。對於我們在上面獲取的那個 AST 遍歷流程應該是這樣的:

如果我們直接操作這個 AST 而不是創造一個單獨的 AST,我們很有可能會在這裡引入各種抽象。但是僅僅訪問樹中的每個節點對於我們來說想做和能做的事情已經很多了。

(使用訪問(visiting)這個詞是因為這是一種模式,代表在物件結構內對元素進行操作。)

所以我們現在建立一個訪問者物件(visitor),這個物件具有接受不同節點型別的方法:

var visitor = {
      NumberLiteral() {},
      CallExpression() {},
 };

當我們遍歷 AST 的時候,如果遇到了匹配 type 的結點,我們可以呼叫 visitor 中的方法。

一般情況下為了讓這些方法可用性更好,我們會把父結點也作為引數傳入。

var visitor = {
      NumberLiteral(node, parent) {},
      CallExpression(node, parent) {},
};

當然啦,對於深度遍歷的話我們都知道,往下遍歷到最深的自節點的時候還需要“往回走”,也就是我們所說的“退出”當前節點。你也可以這樣理解:向下走“進入”節點,向上走“退出”節點。

為了支援這點,我們的“訪問者”的最終形式應該像是這樣:

var visitor = {
    Program: {
      enter(node, parent) {},
      exit(node, parent) {},
    },
    NumberLiteral: {
      enter(node, parent) {},
      exit(node, parent) {},
    },
    CallExpression: {
       enter(node, parent) {},
       exit(node, parent) {},
    },
    ...
  };

遍歷

首先我們定義了一個接收一個 AST 和一個訪問者的遍歷器函式(traverser)。需要根據每個節點的型別來呼叫不同的訪問者的方法,所以我們定義一個 traverseNode 的方法,傳入當前的節點和它的父節點,從根節點開始,根節點沒有父節點,所以傳入 null 即可。

function traverser(ast, visitor) {
  // traverseNode 函式將接受兩個引數:node 節點和他的 parent 節點
  // 這樣他就可以將兩者都傳遞給我們的訪問者方法(visitor)
  function traverseNode(node, parent) {

    // 我們首先從匹配`type`開始,來檢測訪問者方法是否存在。訪問者方法就是(enter 和 exit)
    let methods = visitor[node.type];

    // 如果這個節點型別有`enter`方法,我們將呼叫這個函式,並且傳入當前節點和他的父節點
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    // 接下去我們將按當前節點型別來進行拆分,以便於對子節點陣列進行遍歷,處理到每一個子節點
    switch (node.type) {
      // 首先從最高的`Program`層開始。因為 Program 節點有一個名叫 body 的屬性,裡面包含了節點陣列
      // 我們呼叫`traverseArray`來向下遍歷它們
      //
      // 請記住,`traverseArray`會依次呼叫`traverseNode`,所以這棵樹將會被遞迴遍歷
      case 'Program':
        traverseArray(node.body, node);
        break;

      // 接下去我們對`CallExpression`做相同的事情,然後遍歷`params`屬性
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // 對於`NumberLiteral`和`StringLiteral`的情況,由於沒有子節點,所以直接 break 即可
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 接著,如果我們沒有匹配到上面的節點型別,就丟擲一個異常錯誤
      default:
        throw new TypeError(node.type);
    }

    // 如果這個節點型別裡面有一個`exit`方法,我們就呼叫它,並且傳入當前節點和他的父節點
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  // 呼叫`traverseNode`來啟動遍歷,傳入之前的 AST 樹,由於 AST 樹最開始
  // 的點沒有父節點,所以我們直接傳入 null 就好
  traverseNode(ast, null);
}

因為 ProgramCallExpression 兩種型別可能會含有子節點,所以對這些可能存在的子節點陣列需要做進一步的處理,所以建立了一個叫 traverseArray 的方法來進行迭代。

// traverseArray 函式來迭代陣列,並且呼叫 traverseNode 函式
function traverseArray(array, parent) {
  array.forEach(child => {
    traverseNode(child, parent);
  });
}

轉換

下一步就是把之前的 AST 樹進一步進行轉換變成我們所期望的那樣變成 JavaScript 的 AST 樹:

如果你對 JS 的 AST 的語法解析不是很熟悉的話,可以藉助線上工具網站 來幫助你知道大致要轉換成什麼樣子的 AST 樹,就可以在其他更復雜的場景進行應用啦~

我們建立一個像之前的 AST 樹一樣的新的 AST 樹,也有一個Program 節點:

function transformer(ast) {

  let newAst = {
    type: 'Program',
    body: [],
  };
  
  // 這裡有個 hack 技巧:這個上下文(context)屬性只是用來對比新舊 ast 的
  // 通常你會有比這個更好的抽象方法,但是為了我們的目標能實現,這個方法相對簡單些
  ast._context = newAst.body;
  
  // 在這裡呼叫遍歷器函式並傳入我們的舊的 AST 樹和訪問者方法(visitor)
  traverser(ast, {...}};
  
  // 在轉換器方法的最後,我們就能返回我們剛建立的新的 AST 樹了
  return newAst;
}

那我們再來完善我們的 visitor物件,對於不同型別的節點,可以定義它的 enterexit 方法(這裡因為只要訪問到節點並進行處理就可以了,所以用不到退出節點的方法:exit):

{
  // 第一個訪問者方法是 NumberLiteral 
  NumberLiteral: {
    enter(node, parent) {
      // 我們將建立一個也叫做`NumberLiteral`的新節點,並放到父節點的上下文中去
      parent._context.push({
        type: 'NumberLiteral',
        value: node.value,
      });
    },
  },
  
  // 接下去是 `StringLiteral`
  StringLiteral: {
    enter(node, parent) {
      parent._context.push({
        type: 'StringLiteral',
        value: node.value,
      });
    },
  },
  
  CallExpression: {...}
}

對於 CallExpression 會相對比較複雜一點,因為它可能含有巢狀的內容。

CallExpression: {
  enter(node, parent) {

    // 首先我們建立一個叫`CallExpression`的節點,它帶有表示巢狀的識別符號“Identifier”
    let expression = {
      type: 'CallExpression',
      callee: {
        type: 'Identifier',
        name: node.name,
      },
      arguments: [],
    };

    // 下面我們在原來的 `CallExpression` 結點上定義一個新的 context,
    // 它是 expression 中 arguments 這個陣列的引用,我們可以向其中放入引數。
    node._context = expression.arguments;

    // 之後我們將檢查父節點是否是`CallExpression`型別
    // 如果不是的話
    if (parent.type !== 'CallExpression') {

      // 我們將用`ExpressionStatement`來包裹`CallExpression`節點
      // 這麼做是因為單獨存在的 `CallExpressions` 在 JavaScript 中也可以被當做是宣告語句。
      //
      // 比如 `var a = foo()` 與 `foo()`,後者既可以當作表示式給某個變數賦值,
      // 也可以作為一個獨立的語句存在
      expression = {
        type: 'ExpressionStatement',
        expression: expression,
      };
    }

    // 最後我們把我們的`CallExpression`(可能有包裹)放到父節點的上下文中去
    parent._context.push(expression);
  },
}

3.3 程式碼生成

編譯器的最後一個階段是程式碼生成,這個階段做的事情有時候會和轉換(transformation)重疊,但是程式碼生成最主要的部分還是根據 AST 來輸出程式碼。程式碼生成有幾種不同的工作方式,有些編譯器將會重用之前生成的 token,有些會建立獨立的程式碼表示,以便於線性地輸出程式碼。但是接下來我們還是著重於使用之前生成好的 AST。

根據前面的這幾步驟,我們已經得到了我們新的 AST 樹:

接下來將呼叫程式碼生成器將遞迴的呼叫自己來列印樹的每一個節點,最後輸出一個字串。

function codeGenerator(node) {

  // 還是按照節點的型別來進行分解操作
  switch (node.type) {

    // 如果我們有`Program`節點,我們將對映`body`中的每個節點
    // 並且通過程式碼生成器來執行他們,用換行符將他們連線起來
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // 對於`ExpressionStatement`,我們將在巢狀表示式上呼叫程式碼生成器,並新增一個分號
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';' // << 這是因為保持程式碼的統一性(用正確的方式編寫程式碼)
      );

    // 對於`CallExpression`我們將列印`callee`, 新增一個左括號
    // 然後對映每一個`arguments`陣列的節點,並用程式碼生成器執行,每一個節點執行完之後加上逗號
    // 最後增加一個右括號
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // 對於`Identifier`直接返回`node`的名字就好.
    case 'Identifier':
      return node.name;

    // 對於`NumberLiteral`直接返回`node`的值就好.
    case 'NumberLiteral':
      return node.value;

    // 對於`StringLiteral`,在`node`的值的周圍新增雙引號.
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 如果沒有匹配到節點的型別,就丟擲異常
    default:
      throw new TypeError(node.type);
  }
}

經過程式碼生成之後我們就得到了這樣的 JS 字串:add(2, subtract(4, 2)); 也就代表我們的編譯過程是成功的!

四、結語

以上就是編寫一個 LISP 到 JS 編譯器的全過程,逐行中文註釋的完整程式碼地址:

https://github.com/521xueweihan/OneFile/blob/main/src/javascript/the-super-tiny-compiler.js

那麼,今天學到的東西哪裡會用到呢?眾所周知的 Vue 和 React 雖然寫法上有所區別,但是“殊途同歸”都是通過 AST 轉化的前端框架。這中間最重要的就是轉換 AST,它是非常“強大”且應用廣泛,比較常見的使用場景:

  • IDE 的錯誤提示、程式碼高亮,還可以幫助實現程式碼自動補全等功能
  • 常見的 Webpack 和 rollup 打包(壓縮)

AST 被廣泛應用在編譯器、IDE 和程式碼優化壓縮上,以及前端框架 Vue、React 等等。雖然我們並不會常常與 AST 直接打交道,但它總是無時無刻不陪伴著我們。

當然啦!看完文章不一定算真正瞭解了,所有學習過程都離不開動手實踐,或許實踐過程中你也會有不一樣的理解。實踐方法十分簡單:只需開啟瀏覽器的“開發者模式” ——> 進入控制檯(console)——> 複製/貼上程式碼,就可以直接執行看到結果了!

以上就是本文的所有內容 本文只能算粗略帶大家瞭解一下編譯器迷你的樣子,如果有不同的見解,歡迎評論區留言互動,共同進步呀!

相關文章