今天咱們將學習開發一個編譯器,可是呢,這個編譯器並非說什麼都能都編譯,它只是一個超級小的編譯器,主要用於說明編譯器的一些基本的原理。node
咱們這個編譯器能夠將相似於lisp語言的函數調用編譯成相似於C語言的函數調用。若是你對lisp語言和C語言這二者都不熟悉,不要緊,什麼語言其實無所謂,但接下來仍是會給你一個快速的介紹。git
若是咱們有兩個函數分別是add和subtract,若是用它們來計算下面的表達式:github
2 + 2 4 - 2 2 + (4 - 2)
那麼在lisp語言中它可能長這樣子:express
(add 2 2) // 2 + 2 (subtract 4 2) // 4 - 2 (add 2 (subtract 4 2)) // 2 + (4 - 2)
而在C語言中它長這個樣子:數組
add(2, 2) subtract(4, 2) add(2, subtract(4, 2))
至關簡單吧?函數
好吧,這是由於這僅僅只是咱們這個編譯器所須要處理的情形。 這既不是list語言的完整語法,也不是C語言的完整語法。 但這點語法已經足以用來演示現代編譯器所作的大部分工做。學習
大部分編譯器所作的工做均可以分解爲三個主要的步鄹: 解析、轉換和代碼生成。this
解析一般分爲兩個階段:詞法分析和句法分析。spa
對於下面的語法:code
(add 2 (subtract 4 2))
標記可能長下面這個樣子:
[ { 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: ')' }, ]
而後它對應的抽象語法樹(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)拿過來對它作必定的改變。這種改變多種多樣,能夠是在同一種語言中進行改變,也能夠直接將抽象語法樹轉換成另一種徹底不一樣的新語言。
讓咱們來看看咱們將如何轉換一個抽象語法樹(AST)。
你可能已經注意到,咱們的抽象語法樹裏面有一些很是相似的元素。 這些元素對象有一個type屬性。 這每個對象元素都被稱爲一個AST節點。 這些節點上定義的屬性用於描述AST樹上的一個獨立部分。
咱們能夠爲數字字面量(NumberLiteral)創建一個節點:
{ type: 'NumberLiteral', value: '2', }
或者是爲調用表達式(CallExpression)建立一個節點:
{ type: 'CallExpression', name: 'subtract', params: [...nested nodes go here...], }
當轉換AST樹的時候,咱們可能須要對它進行add、remove、replace等操做。 咱們能夠增長新節點,刪除節點或者咱們徹底能夠將AST樹擱一邊不理,而後基於它建立一個全新的AST。
因爲咱們這個編譯器的目標是將lisp語言轉換成C語言,因此咱們會聚焦建立一個專門用於目標語言(在這裏是C語言)的全新AST。
爲了瀏覽全部這些節點,咱們須要可以遍歷它們。 這個遍歷過程是對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,咱們須要像這樣走:
若是咱們直接操做這個AST而不是建立一個單獨的AST,咱們可能須要在這裏引入各類抽象概念。 可是咱們正在嘗試作的事情,只須要訪問樹中的每一個節點就足夠了。
使用「訪問」這個詞的緣由是由於這個詞可以很好的表達如何在對象結構上操做元素。
這裏最基本的思路就是咱們建立一個訪問者對象,這個對象擁有一些方法,這些方法能夠接受不一樣的節點類型。
好比下面這樣:
var visitor = { NumberLiteral() {}, CallExpression() {}, };
當咱們遍歷AST的時候,一旦咱們碰到一個與指定類型相匹配的節點,咱們就會調用訪問者對象上的方法。
爲了讓這個函數比較好用,咱們給它傳遞了該節點以及它的父節點:
var visitor = { NumberLiteral(node, parent) {}, CallExpression(node, parent) {}, };
然而,這裏也會有可能出如今退出時調用東西。 想象一下咱們前面提到的樹結構:
- Program - CallExpression - NumberLiteral - CallExpression - NumberLiteral - NumberLiteral
當咱們往下遍歷的時候,咱們會遇到最終的分支。 當咱們訪問完全部的分支後咱們退出。 因此向下遍歷樹,咱們進入節點,而後向上回溯的時候咱們退出節點。
-> Program (enter) -> CallExpression (enter) -> Number Literal (enter) <- Number Literal (exit) -> Call Expression (enter) -> Number Literal (enter) <- Number Literal (exit) -> Number Literal (enter) <- Number Literal (exit) <- CallExpression (exit) <- CallExpression (exit) <- Program (exit)
爲了支持這種方式,咱們的訪問者對象須要改爲下面這個樣子:
var visitor = { NumberLiteral: { enter(node, parent) {}, exit(node, parent) {}, } };
編譯器的最後一步是代碼生成。有時候編譯器在這一步會重複作一些轉換步鄹作過的事情。 可是對代碼生成而言,它所作的大部分工做就是將咱們的AST樹stringify一下輸出,也就是轉換成字符串輸出。
代碼生成有多種工做方式,有一些編譯器會重複利用前面生成的標記,另外一些編譯器會建立代碼的單獨表示,以便線性地打印節點,可是據我說知,大多數編譯器的策略是使用咱們剛剛建立的那個AST,這是咱們將要關注的。
實際上,咱們的代碼生成器將知道如何打印AST的全部不一樣節點類型,而且它將遞歸地調用本身來打印嵌套節點,直到將全部內容打印成一長串代碼。
而就是這樣! 這就是編譯器的全部差別部分。
如今不是說每一個編譯器看起來都和我在這裏描述的徹底同樣。 編譯器有許多不一樣的用途,他們可能須要比我詳細的更多的步驟。
可是如今您應該對大多數編譯器的輪廓有一個整體的高層次的概念。
如今我已經解釋了全部這些,你應該能夠寫好本身的編譯器了是吧?
只是在開玩笑的啦,我會在這裏繼續提供幫助,因此咱們開始吧!
前面說了,整個編譯器大概能夠分爲三步:解析、轉換、代碼生成。而解析又能夠分紅兩步:詞法解析和句法解析。因此一共須要四個函數就能夠實現了。咱們來分別看一下:
咱們將從編譯器的第一步——解析——開始,利用tokenizer函數進行詞法分析。
咱們將把字符串代碼拆分紅由標記組成的數組:
(add 2 (subtract 4 2)) => [{ type: 'paren', value: '(' }, ...]
咱們的tokenizer接收一個代碼字符串, 而後接下來作兩個事情:
function tokenizer(input) { // 一個current變量,相似於遊標,用於跟蹤咱們在代碼字符串中的位置 let current = 0; // 以及一個tockens數組,用於將咱們分解的標記存放其中 let tokens = []; // 咱們建立一個while循環,在這裏面咱們設置咱們的current變量,這個變量會隨着循環的深刻而不斷增長 // // 這麼作是由於tockens可能會是任意長度 while (current < input.length) { // 咱們還會存儲變量current所在位置的字符 let char = input[current]; // 咱們首先要檢查的是左括弧,這個將會在稍後用於CallExpression,可是此時咱們只關心左括弧字符 // // 咱們檢查看看有沒有左括弧: if (char === '(') { // 若是有,則創建一個對象,其type屬性是paren,值爲左括弧, 而後咱們將這個對象加入tokens數組 tokens.push({ type: 'paren', value: '(', }); // 接着咱們增長current變量,也就是移動遊標 current++; // 而後進行下一輪循環. continue; } // 接着咱們檢查右括弧,咱們按照前面的套路來作:檢查右括弧,新增一個標記,增長current, 進行下一輪循環 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遊標 while (NUMBERS.test(char)) { value += char; char = input[++current]; } // 這以後咱們將建立數字標記並加入tokens數組 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]; // 建立類型爲string的標記,並放進tockens數組 tokens.push({ type: 'string', value }); continue; } // 最後一種類型的標記是name標記,這是一串字符而不是數字,也就是lisp語法中的函數名 // // (add 2 4) // ^^^ // name 標記 // let LETTERS = /[a-z]/i; if (LETTERS.test(char)) { let value = ''; // 同理,咱們遍歷,並將它們拼接起來 while (LETTERS.test(char)) { value += char; char = input[++current]; } // 而且建立一個類型爲name的標記,存儲於tokens數組 tokens.push({ type: 'name', value }); continue; } // 最後,若是咱們到這裏尚未匹配一個字符, 咱們將拋出一個錯誤而後退出 throw new TypeError('I dont know what this character is: ' + char); } // 在tokenizer函數的末尾咱們將tokens數組返回 return tokens; }
舉個例子,對於(add 123 456)
這段lisp語言代碼,tokenizer化以後獲得的結果以下:
句法解析的目標就是將tokens數組轉換成AST。也就是下面的過程:
[{ type: 'paren', value: '(' }, ...] => { type: 'Program', body: [...] }
因此,咱們定義一個parse函數,接收咱們的tokens數組做爲參數:
function parser(tokens) { // 一樣咱們維持一個current變量用做遊標 let current = 0; // 可是此次咱們使用遞歸而不是while循環,因此咱們定義了walk函數 function walk() { // 在walk函數內部,咱們首先拿到tokens數組中current索引處存放的標記 let token = tokens[current]; // 咱們將把每種類型的標記以另一種結構關係存儲,以體現句法關係 // 首先從數字token開始 // // 咱們檢查看有沒有數字token if (token.type === 'number') { // 若是有,咱們移動遊標 current++; // 而且咱們會返回一個叫作「NumberLiteral」的新的AST節點而且將它的value屬性設置爲咱們標記對象的value屬性 return { type: 'NumberLiteral', value: token.value, }; } // 若是咱們有string類型的標記,咱們會和數字類型相似,建立一個叫作「StringLiteral」的AST節點 if (token.type === 'string') { //一樣移動遊標 current++; return { type: 'StringLiteral', value: token.value, }; } // 接下來咱們查找CallExpressions. 咱們是經過左括弧來開始這個過程的 if ( token.type === 'paren' && token.value === '(' ) { // 咱們將忽略左括弧,由於在AST裏面,AST就是有句法關係的,因此咱們不關心左括弧自己了 token = tokens[++current]; // 咱們建立一個叫作CallExpression的基礎節點,而且將節點的名字設置爲當前標記的value屬性, // 由於左括弧標記的下一個標記就是函數名字 let node = { type: 'CallExpression', name: token.value, params: [], }; // 咱們移動遊標,忽略掉name標記,由於函數名已經存起在CallExpression中了 token = tokens[++current]; // 而後如今咱們遍歷每個標記,找到CallExpression的參數直至遇到右括弧 // // 如今,這裏就是遞歸出場的地方了,爲了不陷入無限的嵌套節點解析,咱們採用遞歸的方式來搞定這個事情 // // 爲了更好的解釋這個東西,咱們以咱們的Lisp代碼舉例,你能夠看到,add的參數是一個數字以及一個嵌套的CallExpression, // 這個嵌套的函數調用包含它本身的數字參數 // // (add 2 (subtract 4 2)) // // 你特能夠從它的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: ')' }, <<< 右括弧 // ] // // 咱們將依賴於嵌套的walk函數來增長咱們的遊標 // 因此咱們建立一個while循環,這個while循環將一直進行直到遇到一個類型是paren的標記而且這個標記的值是一個右括弧 while ( (token.type !== 'paren') || (token.type === 'paren' && token.value !== ')') ) { // 咱們將調用walk函數,這個函數將返回一個節點, 咱們將把這個返回的節點放到當前節點的params // 數組中存儲起來,這樣嵌套關係再AST裏面就體現出來了 node.params.push(walk()); token = tokens[current]; } // 最後,咱們須要最後一次移動遊標用於忽略右括弧 current++; // 而且返回節點 return node; } // 一樣,若是咱們沒有識別出標記的類型,咱們也會拋出一個錯誤 throw new TypeError(token.type); } // 如今walk函數已經定義好了, 咱們須要定義咱們的AST樹了,這個AST樹有一個「Program」根節點: let ast = { type: 'Program', body: [], }; // 而後咱們要啓動咱們的walk函數, 將AST節點放入根節點的body數組裏面 // // 咱們在循環裏面作這個是由於,咱們可能會遇到連着的多個函數調用,好比說像這樣的: // // (add 2 2) // (subtract 4 2) //啓動walk while (current < tokens.length) { ast.body.push(walk()); } // 在解析函數的最後,咱們將返回生成的AST. return ast; }
任然之前面的例子舉例,咱們解析後獲得的AST以下:
如今咱們已經有了咱們的AST,咱們想要一個訪問者能夠訪問不一樣的節點,不管什麼時候匹配到對應的節點類型的時候,咱們均可以調用訪問者上的方法。
因此咱們定義一個旅行者函數,這個函數接收兩個參數,第一個參數爲AST樹,第二個參數是一個訪問者。這個訪問者須要實現不一樣類型的AST節點須要調用的一些方法:
traverse(ast, { Program: { enter(node, parent) { // ... }, exit(node, parent) { // ... }, }, CallExpression: { enter(node, parent) { // ... }, exit(node, parent) { // ... }, }, NumberLiteral: { enter(node, parent) { // ... }, exit(node, parent) { // ... }, }, });
所以,咱們的旅行者函數的實現以下,它接收AST和一個訪問者做爲參數,而且在裏面還定義了兩個方法:
function traverser(ast, visitor) { // 定義一個traverseArray函數,能夠是咱們迭代一個數組,而後調用咱們稍後定義的traverseNode函數 function traverseArray(array, parent) { array.forEach(child => { traverseNode(child, parent); }); } // traverseNode函數接收一個AST節點以及它的父節點,因此它也能夠傳遞給咱們的訪問者函數 function traverseNode(node, parent) { // 咱們首先檢查訪問者匹配類型的方法 let methods = visitor[node.type]; // 若是該AST節點類型存在enter方法,咱們將以當前node及其父節點做爲參數調用該方法 if (methods && methods.enter) { methods.enter(node, parent); } // 接下來咱們會根據節點類型來把事情劃分開來 switch (node.type) { // 首先咱們從頂級節點Program開始,因爲該頂級節點有一個叫作body的屬性,這個屬性中是一個AST節點組成的數組 // 咱們將調用traverseArray函數來遞歸它 // // (記住traverseArray函數會反過來調用traverseNode函數,因此咱們讓這個AST被遞歸的訪問) case 'Program': traverseArray(node.body, node); break; // 接下來咱們對CallExpression節點作一樣的事情,而且訪問它們的參數 case 'CallExpression': traverseArray(node.params, node); break; // 對於數字節點以及字符串節點,他們沒有任何的子節點,因此咱們直接break. case 'NumberLiteral': case 'StringLiteral': break; // 而且再一次,若是沒有識別出對應的節點類型,就拋出錯誤 default: throw new TypeError(node.type); } // 若是訪問者上有exit方法,咱們將以該節點和它的父節點做爲參數調用exit方法 if (methods && methods.exit) { methods.exit(node, parent); } } // 最後,咱們啓動traverser,這是經過調用traverseNode實現的,而且traverseNode第二個參數是null,由於定級節點自己就沒有父節點. traverseNode(ast, null); }
前面咱們已經寫好了traverser函數,而traverser函數對節點的主要操做都是經過它的第二個參數,也就是訪問者來完成的,在上面,咱們並無定義訪問者的具體實現,只是定義了enter和exit兩個接口,實際上這兩個接口所作的事情就是轉換步鄹真正乾的事情。爲此咱們定義transformer函數。
transformer函數接收AST,將它傳遞給traverser函數,而且transformer函數內部還爲traverser函數提供訪問者。最終transformer函數返回一個新建的AST。
好比之前面那個例子爲例,獲得的AST和轉換後的AST以下:
---------------------------------------------------------------------------- Original AST | Transformed AST ---------------------------------------------------------------------------- { | { type: 'Program', | type: 'Program', body: [{ | body: [{ type: 'CallExpression', | type: 'ExpressionStatement', name: 'add', | expression: { params: [{ | type: 'CallExpression', type: 'NumberLiteral', | callee: { value: '2' | type: 'Identifier', }, { | name: 'add' type: 'CallExpression', | }, name: 'subtract', | arguments: [{ params: [{ | type: 'NumberLiteral', type: 'NumberLiteral', | value: '2' value: '4' | }, { }, { | type: 'CallExpression', type: 'NumberLiteral', | callee: { value: '2' | type: 'Identifier', }] | name: 'subtract' }] | }, }] | arguments: [{ } | type: 'NumberLiteral', | value: '4' -------------------------------- | }, { | type: 'NumberLiteral', | value: '2' | }] | } | } | }] | } ----------------------------------------------------------------------------
因此咱們的transformer函數的具體實現以下:
function transformer(ast) { // 咱們將建立一個新的AST(即newAst),它和咱們原來的AST相似,有一個Program根節點 let newAst = { type: 'Program', body: [], }; // 接下來,咱們會作一些取巧的操做,咱們在父節點上定義一個\_context屬性, // 咱們會將節點放入父節點的\_context屬性中 // 一般你會有更好的抽象(也許會複雜些),可是在這裏咱們這樣作使得事情變得相對簡單 // // 你僅僅須要記住的是,context是一個從老AST到新AST的引用 ast._context = newAst.body; // 咱們以老ast和一個訪問者做爲參數調用traverser函數 traverser(ast, { // 第一個訪問者的屬性是用來處理NumberLiteral的 NumberLiteral: { // 在enter方法中會對節點進行訪問. enter(node, parent) { // 在這裏面咱們會建立一個新的AST節點,這個節點任然以NumberLiteral命名 // 咱們會將這個節點放入該節點父親的\_context屬性中 parent._context.push({ type: 'NumberLiteral', value: node.value, }); }, }, // 接下來是StringLiteral StringLiteral: { enter(node, parent) { parent._context.push({ type: 'StringLiteral', value: node.value, }); }, }, // 接下來是CallExpression CallExpression: { enter(node, parent) { // 咱們建立一個新的節點CallExpression,它有一個嵌套的標識符 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節點 // 這麼作是由於頂級CallExpression節點實際上就是statement // 也就是說,若是某個CallExpression節點的父節點不是CallExpression節點 // 那麼這個CallExpression節點應該就是函數聲明 expression = { type: 'ExpressionStatement', expression: expression, }; } // 最後咱們將這個新的CallExpression(可能被ExpressionStatement包裹) // 放入parent._context parent._context.push(expression); }, } }); // 在transformer函數的最後,咱們把咱們剛建立的新AST返回 return newAst; }
咱們一樣之前面的例子來看一下新建立AST長什麼樣子:
如今讓咱們進入咱們的最後一個步鄹:代碼生成。咱們的代碼生成函數會遞歸的調用本身用來打印它的節點到一個很大的字符串。也就是完成由newAST到代碼的過程:
newAst => generator => output
function codeGenerator(node) { // 咱們會根據節點的type類型來將事情分別處理 switch (node.type) { // 若是咱們有一個Program節點,咱們將遍歷body中的每個節點而且對每個節點遞調用codeGenerator // 函數,而且將它們的結果用一個換行符鏈接起來 case 'Program': return node.body.map(codeGenerator) .join('\n'); // 對於ExpressionStatement節點,咱們將在節點的expression節點上調用 // codeGenerator函數,而後咱們會加上一個分號(即;) case 'ExpressionStatement': return ( codeGenerator(node.expression) + ';' // << (...because we like to code the *correct* way) ); // 對於CallExpression節點,咱們會打印callee並開始一個作括弧 // 咱們會遍歷該節點的arguments屬性,而後對每一個屬性調用codeGenerator方法, // 將他們的結果用逗號分隔,最後在後面加一個右括弧 case 'CallExpression': return ( codeGenerator(node.callee) + '(' + node.arguments.map(codeGenerator) .join(', ') + ')' ); // 對於標識符,咱們將返回節點的名字 case 'Identifier': return node.name; // 對於NumberLiteral節點,咱們返回它的value屬性 case 'NumberLiteral': return node.value; // 對於StringLiteral節點,咱們用引號將它的value屬性值包裹起來 case 'StringLiteral': return '"' + node.value + '"'; // 若是沒有識別節點,咱們將拋出錯誤 default: throw new TypeError(node.type); } }
一樣以上面例子舉例,它的輸出結果如圖:
如今,編譯器的三大步鄹的代碼都已經實現了,咱們如今開始實現編譯器,它的方式就是將三個步鄹連接起來,能夠將這幾個步鄹描述以下:
1. input => tokenizer => tokens 2. tokens => parser => ast 3. ast => transformer => newAst 4. newAst => generator => output
咱們的編譯器代碼以下:
function compiler(input) { let tokens = tokenizer(input); let ast = parser(tokens); let newAst = transformer(ast); let output = codeGenerator(newAst); // and simply return the output! return output; }
最後做爲一個模塊,咱們但願別人去使用它,由於咱們的每一個函數都是相對獨立的一個功能模塊,因此咱們將這裏面的每一個函數都導出:
module.exports = { tokenizer, parser, traverser, transformer, codeGenerator, compiler, };
書把手系列還包括:手把手教你實現一個簡單的Promise,手把手教你實現一個簡單的MVC模式,手把手教你實現一個簡單的MVP模式,手把手教你實現一個簡單的MVVM模式。