用25行JavaScript語句實現一個簡單的編譯器

2021-02-19 CSDN

點擊上方「CSDN」,選擇「置頂公眾號」

關鍵時刻,第一時間送達!

作者丨Minko Gechev

譯者丨夜風輕揚

譯者註:即使對於專業程式設計師來說,構造一個編譯器也是頗具挑戰性的任務,本文將會引導你抽絲剝繭,一探究竟!

我已經寫了幾篇與程式語言開發相關的文章,這讓我非常興奮!例如,在「關於 Angular 2 和 TypeScript 項目中的靜態代碼分析」[1]中,我研究了編譯器前端的基本知識,解釋了詞法分析、語法分析和抽象語法樹等各個階段。

最近我發表了「開發靜態類型程式語言[2] 」。本文展示了一個簡單的、靜態類型的函數式程式語言,它是受到了lambda微積分的啟發。我將編譯器開發的前端部分外包給了解析器生成器,並將注意力放在用於類型檢查的模塊的後端,以及用於代碼生成的模塊。

為什麼我需要這個?

你可能想「為什麼我需要知道如何開發編譯器?」,有幾個重要的原因:

你將更好地理解所使用的程式語言是如何工作的。這將使你能夠藉助該語言開發出更高效的程序。

經常的,為了不同的目的,你不得不重用下面所述的模塊(例如,對配置文件進行解析、對網絡消息進行解析等等)。

創建一個DSL。在你的項目中創建一個特定領域的語言可能非常方便,以便簡化任務,否則,使用通用程式語言,解決同樣問題,你可能需要花費更多的時間。

我們要討論的範圍是什麼?

在這篇博文中,我們將介紹從端到端的基礎知識。我們將用25行JavaScript代碼開發一個非常簡單的編譯器!我們的編譯器將會包含:

詞法分析模塊

語法分析模塊 

解析器將基於EBNF語法 

我們將使用遞歸下行解析算法來開發解析器

代碼生成器

我們將要探討的語言對於開發有實際意義的軟體程序並不是特別有用,但是它可以很容易地擴展成一個。

整個實現可以在我的GitHub介紹[3]中找到。

引入一種簡單的前綴語言

下面是我們語言中的一個示例表達式的樣子:

mul 3 sub 2 sum 1 3 4

在本文的最後,我們將能經過任何編譯器的典型階段,將這些表達式轉換為JavaScript語句。

為簡單起見,我們需要遵循一些規則:

我們只有函數:mul,sub,sum,div。

每個字符串標記都被空格隔開。

我們只支持自然數。

為了探究上述表達式背後的語義,我們定義一些JavaScript函數:

const mul = (...operands) => operands.reduce((a, c) => a * c, 1);

const sub = (...operands) => operands.reduce((a, c) => a - c);

const sum = (...operands) => operands.reduce((a, c) => a + c, 0);

mul接受多個操作數,並通過 =>操作符傳遞。函數只是將它們相乘,例如mul(2、3、4)==24。sub分別減去傳遞的參數,sum則是計算參數的總和。

上述的表達式可以轉換為下列的JavaScript表達式:

mul(3, sub(2, sum(1, 3, 4)))

或者

3 * (2 - (1 + 3 + 4))

現在,在我們了解了語義之後,讓我們從編譯器的前端開始吧!

注意:類似的前綴表達式可以通過基於堆棧的算法進行簡單的評估,但是在這種情況下,我們將關注概念而不是實現。

開發編譯器的前端

任何編譯器的前端通常都有詞法分析模塊[4]和語法分析模塊[5]。在這一節中,我們將在幾行JavaScript代碼中構建兩個模塊!

開發一個詞法分析器

詞法分析階段負責將程序的輸入字符串(或字符流)劃分為稱為標記的小塊。標記通常包含關於它們類型的信息(如果它們是數字、操作符、關鍵字、標識符等)、它們所代表程序的子串以及它們在程序中的位置。該位置通常用於報告用戶友好的錯誤,以防出現無效的語法結構。

例如下列的JavaScript程序:

if (foo) {

  bar();

}

一個示例的JavaScript詞彙分析器將生成輸出:

[

  {

    lexeme: 'if',

    type: 'keyword',

    position: {

      row: 0,

      col: 0

    }

  },

  {

    lexeme: '(',

    type: 'open_paran',

    position: {

      row: 0,

      col: 3

    }

  },

  {

    lexeme: 'foo',

    type: 'identifier',

    position: {

      row: 0,

      col: 4

    }

  },

  ...

]

我們將保持我們的詞法分析器儘可能簡單。事實上,我們可以通過一條 JavaScript語句實現它:

const lex = str => str.split(' ').map(s => s.trim()).filter(s => s.length);

在這裡,我們使用單一的空格來劃分字符串,然後將產生的子串映射成修理版本並且過濾掉空串。

針對一個表達式調用lexer將產生一個字符串數組:

lex('mul 3 sub 2 sum 1 3 4')

// ["mul", "3", "sub", "2", "sum", "1", "3", "4"]

這完全實現了我們的目標!

現在讓我們進入語法分析的階段!

開發一個語法分析器

語法分析器(通常稱為解析器)是一個編譯器的模塊,該編譯器從一個標記列表(或流)中生成一個抽象語法樹6。在這個過程中,語法分析器會對無效程序產生語法錯誤。

通常,解析器是基於語法[7]實現的。以下是我們語言的語法:

digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

num = digit+

op = sum | sub | mul | div

expr = num | op expr+

語法中包含有數字,這些數字組合在一起可以形成數字(num);有4個操作;一個表達式可以是一個數字,或者是操作,後面跟著一個或多個表達式。我們將把語法中的個體定義(例如num和op)作為規則。

這就是所謂的EBNF 語法[6]。稍微看一下語法,試著去理解它,然後完全忘記它!在解釋解析器之後,我們將回到語法,你將看到所有東西是如何連接在一起的!

如前所述,解析器是一種工具,它將標記的列錶轉換為AST。 

例如,對於我們的表達式:

mul 3 sub 2 sum 1 3 4

解析器將根據上面的語法生成下面的AST: 

讓我們來研究一下這個算法吧!

const Op = Symbol('op');

const Num = Symbol('num');

const parse = tokens => {

  let c = 0;

  const peek = () => tokens[c];

  const consume = () => tokens[c++];

  const parseNum = () => ({ val: parseInt(consume()), type: Num });

  const parseOp = () => {

    const node = { val: consume(), type: Op, expr: [] };

    while (peek()) node.expr.push(parseExpr());

    return node;

  };

  const parseExpr = () => /\d/.test(peek()) ? parseNum() : parseOp();

  return parseExpr();

};

壞消息是,有很多事情正在發生。好消息是,這是編譯器中最複雜的部分!

讓我們把代碼分成各個部分,然後一步步查看每一個步驟。

節點類型

const Op = Symbol('op');

const Num = Symbol('num');

首先,我們定義了在AST中將要使用的不同節點類型,我們只需要數字和操作。例如,數字節點42將會是這樣的:

{

  type: Num,

  val: 42

}

運算符sum,應用到2、 3、 4,將會是這樣的:

{

  type: Op,

  val: 'sum',

  expr: [{

    type: Num,

    va: 2

  }, {

    type: Num,

    va: 3

  }, {

    type: Num,

    va: 4

  }]

}

這是多麼簡單啊!

語法分析器

在聲明了節點類型之後,我們定義了一個名為parse的函數,該函數接受一個稱為標記的參數。在它的內部,我們定義了另外五個函數: 

peek-返回與標記元素關聯的本地變量c 的當前值。 

consum-返回與c本地變量和增量c的當前值相關聯的標記元素。 

parseNum-獲取當前的標記(即調用peek()),將其解析為一個自然數字,並返回一個新的數字標記。 

parseOp-我們會稍微研究一下。 

parseExpr - 檢查當前標記與正則表達式/\d/(即是一個數字)是否匹配,如果成功則調用parseNum,否則返回parseOp。

解析操作

parseOp可能是上面解析器中最複雜的函數。這是因為存在循環和間接遞歸的情況。這裡是它再一次的定義為了可以逐行對進行解釋:

const parseOp = () => {

  const node = { val: consume(), type: Op, expr: [] };

  while (peek()) node.expr.push(parseExpr());

  return node;

};

當peek()的返回值不是數值時, parseExpr會調用parseOp,我們知道這是一個運算符,所以我們創建一個新的操作節點。注意,我們不會執行任何進一步的驗證,但是,在實際的程式語言中,我們希望這樣做,如果遇到一個未知的標記時,會報告語法錯誤。 

無論如何,在節點聲明中,我們將「子表達式」的列表設置為空列表(也就是[]),把操作名設置為peek()的值,把節點類型設置為Op。隨後,通過將當前解析的表達式推到給定節點的子表達式列表中,我們循環遍歷所有標記。最後,我們返回該節點。

牢記 while (peek()) node.expr.push(parseExpr());執行一個間接遞歸。我們得到如下表達式:

sum sum 2

這將會: 

首先,調用parseExpr,結果會發現當期的標記(就是tokens[0])不是一個數(是sum),所以它會調用 parseOp。 

在parseOp創建一個操作節點後,並且由於調用consume(),c值增加。 

下一步,parseOp將會遍歷節點,對於tokens[c],這裡c對於1,將會調用parseExpr。 

parseExpr會發現當前的節點不是一個數,所以會調用 parseOp。 

parseOp會創建另外一個操作節點並且將c加1,並對所有的標記再來一次循環。 

parseOp 將會調用parseExpr,這時 c 不等於 2了。 

tokens[2] 是 「2」,parseExpr將會調用 parseNum,創立一個數值節點, 並將 c 變量加1。 

parseNum將會返回數節點並且推入到表達式數組的最後一個操作節點中,該節點通過最新一次的 parseOp 調用生成。 

最後一次的 parseOp調用將會返回操作節點,因為 peek()將會返回undefined( parseNum將 c加到 3,tokens[3] === undefined)。 

最後一次parseOp調用返回的節點將會返回給最外層的parseOp調用該調用也會返回操作節點。 

最後,parseExpr 將會返回根操作節點。

產生的AST如下所示:

{

  type: "Op",

  val: "sum",

  expr: [{

    type: "Op",

    val: "sum",

    expr: [{

      type: "Num",

      val: 2

    }]

  }]

}

最後一步是遍歷這棵樹並生成JavaScript!

遞歸下降解析

現在,讓我們將單獨的函數與上面定義的語法聯繫起來,看看為什麼語法有意義。讓我們來看看EBNF語法的規則:

digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

num = digit+

op = sum | sub | mul | div

expr = num | op expr+

現在它們更清晰一些了?expr看起來很像parseExpr,這裡我們或者解析出一個數或者是一個操作。類似的,op expr+看起來很像parseOp和num類似parseNum。實際上,解析器常常直接從語法中生成解析器,因為它們都與遞歸下降解析算法[8]有直接的聯繫。

事實上,我們剛剛開發了一個簡單的遞歸下降解析器!我們的解析器非常簡單(我們語法中只有4個產生式規則),但是你可以想像一個真實的程式語言的解析器是多麼複雜。

相較於編寫真正的解析器,觀察其簡化模型,開發一種語言的語法是非常方便的。解析器包含大量細節(例如,你的開發語言的許多語法結構),這與極其簡化和簡約的語法形成了鮮明的對比。

開發編譯器

在本文的這一部分中,我們將遍歷語言的AST並生成JavaScript。整個編譯器只有7行JavaScript代碼(字面上的!)

const transpile = ast => {

  const opMap = { sum: '+', mul: '*', sub: '-', div: '/' };

  const transpileNode = ast => ast.type === Num ? transpileNum(ast) : transpileOp(ast);

  const transpileNum = ast => ast.val;

  const transpileOp = ast => `(${ast.expr.map(transpileNode).join(' ' + opMap[ast.val] + ' ')})`;

  return transpileNode(ast);

};

讓我們來逐行探究一下實現的細節。

首先,我們定義了一個函數稱為transpile。它接受由解析器生成的AST作為參數。然後在opMap中,我們第一了數據操作和語言中操作的映射。基本上,sum 映射為+,mul映射為*,等等。

下一步,我們定義函數transpileNode,該函數接受AST節點。如果節點是是一個數,我們調用transpileNum函數,否則,調用transpileOp。

最後,我們定義兩個函數,來處理單個節點的轉譯: 

- transpileNum - 把一個數轉換成JavaScript 中的數 (簡單的直接返回這個數)。 

- 將操作轉換為JavaScript算術運算。注意這裡有一個間接的遞歸(transpileOp->transpileNode->transpileOp)。對於每個操作節點,我們都希望首先轉換其子表達式。我們通過調用 transpileNode 函數來做到這一點。 

在transpile函數體的最後一行,我們返回transpileNode的結果,形成這棵樹的根節點。

將一切都結合在一起

以下是我們如何將所有部分連接在一起的方法:

const program = 'mul 3 sub 2 sum 1 3 4';

transpile(parse(lex(program)));

// (3 * (2 - (1 + 3 + 4)))

我們調用 lex(program),生成標記列表,此後我們將這些標記傳遞給 parse函數,生成抽象語法樹(AST),最後,我們將AST轉譯成JavaScript! 

結論

本文詳細介紹了一種非常簡單的編譯器(或transpile)的開發,將前綴表達式編譯為JavaScript。雖然這只是解釋了編譯器開發的一些基本知識,但是我們介紹了一些非常重要的概念:

詞法分析

語法分析

原始碼生成

EBNF語法

遞歸下降解析

如果你有興趣做進一步的閱讀,我向你推薦:

開發靜態類型化程式語言

構造一個簡單的解釋器

編譯器:準則、技術和工具(第二版)

類型和程式語言

參考文獻

Static Code Analysis of Angular 2 and TypeScript Projects - https://mgv.io/ng-static-analysis.

Developing Statically Typed Programming Language - https://mgv.io/typed-lambda

Tiny Compiler - https://mgv.io/tiny-compiler

Lexical Analysis - https://mgv.io/wiki-lex

Syntax Analysis - https://mgv.io/wiki-parser

Abstract Syntax Tree - https://mgv.io/wiki-case-ast

EBNF grammar - https://mgv.io/wiki-ebnf

Recursive Descent Parsing - https://mgv.io/wiki-recursive-descent-parser

相關焦點

  • 實現一個簡單的編譯器
    編譯器如此神奇,那麼它到底是如何工作的呢?本文將簡單介紹編譯器的原理,並實現一個簡單的編譯器,使它能編譯我們自定義語法格式的原始碼。(文中使用的源碼都已上傳至 GitHub 以方便查看)。語法分析器語法分析器 的作用是構建 抽象語法樹,通俗的說 抽象語法樹 就是將源碼用樹狀結構來表示,每個節點都代表源碼中的一種結構;對於我們要實現的語法,其語法樹是很簡單的,如下:現在我們使用 Bison 生成 語法分析器 代碼,同樣 Bison 需要一個規則文件,我們的規則文件 syntactic.y 如下,限於篇幅,省略了某些部分,可以通過連結查看完整內容
  • javascript流程語句
    程序的三大流程控控制 1.我們的計算機在執行一個程序的時候,最基本的方式是一條語句接一條語句的執行。但不可能所有的問題都能用順序執行方式就能解決,總會有一些跳轉。2.採用結構化的程序設計,可以大大提高開發程序的速度、提高程序的可讀性、程序運行的速度和效率。
  • 3.1.1 JavaScript簡單if語句的使用
    if語句是最基本、最常用的條件判斷語句,通過判斷條件表達式的值來決定是否執行某一段語句,或者選擇執行哪一段語句。在實際編程中,if語句有多種寫法。簡單if語句的語法格式如下:if(表達式){語句組}參數:1)表達式:必選項,用於指定條件表達式。2)語句組:用於指定要執行的語句序列,可以是一條或多條語句。
  • 前端:用javascript實現一個轉盤小遊戲?
    本文轉載自【微信公眾號:趣談前端,ID:beautifulFront】經微信公眾號授權轉載,如需轉載與原文作者聯繫本文主要介紹如何使用原生javascript和Css3來實現一個在各大移動應用中經常出現的轉盤遊戲,由於改實現可以有不同方式,如果熟悉canvas的話也可以用canvas實現,本文採用js和css實現主要考慮到複雜度較小性能較好
  • 先有C語言還是先有的編譯器?這是一個雞與蛋的問題
    對於編譯器這種系統軟體,用C語言來編寫是很自然不過的,即使是像Python這樣的高級語言依然在底層依賴於C語言(舉Python的例子是因為Intel的黑客正在嘗試讓Python不需要作業系統就能運行——實際上是免去了BIOS上的一次性C代碼)。現在的學生,學過編譯原理後,只要有點編程能力的都可以實現一個功能簡單的類C語言編譯器。
  • JavaScript 實現 JSON 解析器
    操作AST[4]其中包括編譯器管道的概述,以及如何操作 AST,但是我還沒有詳細介紹如何實現解析器。這是因為在一篇文章中實現JavaScript編譯器對我來說是一項艱巨的任務。好吧,不用擔心。JSON 也是一種語言。它具有自己的語法,您可以從規範[5]中參考。編寫 JSON 解析器所需的知識和技術可以轉移到編寫 JS 解析器中。因此,讓我們開始編寫 JSON 解析器!理解語法如果您查看了規範頁面,會發現有2個圖。
  • C語言編譯器的來源
    現在的學生,學過編譯原理後,只要有點編程能力的都可以實現一個功能簡單的類C語言編譯器。可是問題來了,不知道你有沒有想過,大家都用C語言或基於C語言的語言來寫編譯器,那麼世界上第一個C語言編譯器又是怎麼編寫的呢?這不就是一個「雞和蛋」的問題嗎?
  • 教你幾招,輕鬆搞定javascript的條件判斷語句
    從這刻開始,咱們開始進入javascript的編程殿堂—條件語句,怎麼樣,很期待吧,下面,就讓我們一起開始吧!我們先來認識一下什麼是javascript的條件語句:javascript條件語句通常包括兩種:一是咱們經常要用到的if…else語句,一種是不常用的switch…case語句;其作用是判斷當一個條件滿足需求時執行什麼語句,不滿足的時候執行什麼語句。
  • 用javascript實現select的美化
    首頁 > 教程 > 關鍵詞 > 最新資訊 > 正文 用javascript實現select的美化
  • 3.2.4 JavaScript循環語句嵌套的應用
    如果在一個循環語句中包含其他的循環語句,我們稱為循環語句的嵌套。對於JavaScript中的while循環語句、do-while循環語句和for循環語句都是可以互相嵌套的。而且根據嵌套的層次,可以分為2層循環、3層循環等。
  • 第一個 C 語言編譯器是怎樣編寫的?
    當今幾乎所有的實用的編譯器/解釋器(以下統稱編譯器)都是用C語言編寫的,有一些語言比如Clojure,Jython等是基於JVM或者說是用Java實現的,IronPython等是基於.NET實現的,但是Java
  • 一行代碼證明編程能力,javascript程式語言中,經典語句精髓解析
    javascript程式語言中,經典語句精髓解析,一行代碼證明編程能力!程式設計師:十萬行代碼,證明編程基礎的掌握;之後,一行代碼證明編程的能力!1、if語句在javascript語言中,if條件語句是很常用到的。與其他程式語言相比,還是有差異的。
  • 3.1.5 JavaScript中switch語句的使用
    在JavaScript中使用if-else-if語句可以實現多路選擇功能,但其結構使程序看起來很不清晰,也不容易維護。而switch語句是典型的多路分支(多路選擇)語句,其作用與if-else-if語句基本相同,但switch語句比if-else-if語句更具有可讀性,它可以根據一個表達式的值在給定的多個選擇中選擇一個符合條件的分支來執行。而且switch語句允許在找不到一個匹配條件的情況下能執行默認的一個分支。
  • 3.1.2 JavaScript中if-else語句的使用
    JavaScript的if-else語句是if語句的標準形式,在if語句簡單形式的基礎上增加一個else從句,當表達式的值是false時執行else從句中的語句組。2)語句組1:用於指定要執行的語句序列,可以是一條或多條語句。當表達式的值為true時,執行該語句組。3)語句組2:用於指定要執行的語句序列,可以是一條或多條語句。當表達式的值為false時,執行該語句組。
  • JavaScript 條件語句if、switch-初級web前端工程師必學
    條件語句默認情況下,javascript解釋器依照語句的編寫順序依次執行。而javascript中的很多語句可以改變語句的默認執行順序。本文介紹可以改變語句默認執行順序的條件語句、循環語句和跳轉語句腳本的威力體現在它們可以根據人們給出的各種條件做出決策,javascript使用條件語句來做判斷,條件語句(conditianal statement)通過判斷表達式的值來決定執行還是跳過某些語句,包括if語句和switch語句if語句
  • JavaScript中的分號自動插入
    許多程式語言都有語句結束的概念。不過,用哪個符號結束以及結束語句的規則在各個語言之間存在差異。對於 JavaScript 來說,它在這方面要求非常寬鬆。在 JavaScript 中語句結束符是分號,不過也可以不寫。它是怎麼做到的?
  • JavaScript——ForEach語句和For…In語句的區別
    在這篇文章裡,我要簡單介紹一下JavaScript中的foreach語句和for...in語句。這篇文章寫給大家,是希望大家能了解一些新方法,而不要老是用for循環語句。For循環語句這裡給大家簡單快速地複習一下for循環:大家以前可能都用過for循環。它是JavaScript中最基本的循環語句,用途廣泛。
  • 實例教程,用python實現字節碼編譯器和解釋器
    對於一個語言來說,有兩個最重要功能,編譯器和解釋器。實現由原始碼到字節碼的轉化,然後才能執行。本文中蟲蟲以CPython 3.6位元組碼為實例,實現一個我們自己的字節碼編譯器和解釋器,以此來熟悉基本的編譯器工作原理(),當然如果想深入理論學習,建議大家去學習了《編譯原理》這本教材。
  • 第一個編譯器是怎麼來的~
    在C語言被用作系統程式語言之前,Tomphson也用過B語言編寫作業系統。 可見,在C語言實現以前,B語言已經可以投入實用了。因此第一個C語言編譯器的原型完全可能是用B語言或者混合B語言與PDP彙編語言編寫的。
  • 模擬男程式設計師的約會時間表,深度理解if語句與switch語句的差異
    沒有海誓山盟,沒有浪漫童話,有的,只是簡單的快樂。現在,用javascript程式語言來模擬該時間表,在簡單中深入理解if和switch流程制語句。圖B如圖B中,程式設計師用javascript程式語言中的if語句實現圖A中的事件提示。