來源: ApacheCN『JavaScript 編程精解 中文第三版』翻譯項目原文:Project: A Programming Languagejavascript
譯者:飛龍html
協議:CC BY-NC-SA 4.0java
自豪地採用谷歌翻譯git
部分參考了《JavaScript 編程精解(第 2 版)》github
肯定編程語言中的表達式含義的求值器只是另外一個程序。正則表達式
Hal Abelson 和 Gerald Sussman,《計算機程序的構造和解釋》apache
構建你本身的編程語言不只簡單(只要你的要求不要過高就好),並且對人富有啓發。編程
但願經過本章的介紹,你能發現構建本身的編程語言其實並非什麼難事。我常常感到某些人的想法聰明無比,並且十分複雜,以致於我都不能徹底理解。不過通過一段時間的閱讀和實驗,我就發現它們其實也並無想象中那麼複雜。數組
咱們將創造一門名爲 Egg 的編程語言。這是一門小巧而簡單的語言,可是足夠強大到能描述你所能想到的任何計算。它容許基於函數的簡單抽象。數據結構
程序設計語言中最直觀的部分就是語法(syntax)或符號。解析器是一種程序,負責讀入文本片斷(包含程序的文本),併產生一系列與程序結構對應的數據結構。若文本不是一個合法程序,解析器應該指出錯誤。
咱們的語言語法簡單,並且具備一致性。Egg 中一切都是表達式。表達式能夠是綁定名稱、數字,或應用(application)。不只函數調用屬於應用,並且if
和while
之類的語言構造也屬於應用。
爲了確保解析器的簡單性,Egg 中的字符串不支持反斜槓轉義符之類的元素。字符串只是簡單的字符序列(不包括雙引號),並使用雙引號包圍起來。數值是數字序列。綁定名由任何非空白字符組成,而且在語法中不具備特殊含義。
應用的書寫方式與 JavaScript 中同樣,也是在一個表達式後添加一對括號,括號中能夠包含任意數量的參數,參數之間使用逗號分隔。
do(define(x, 10), if(>(x, 5), print("large"), print("small")))
Egg 語言的一致性體如今:JavaScript 中的全部運算符(好比>
)在 Egg 中都是綁定,可是能夠像其餘函數同樣調用。因爲語法中沒有語句塊的概念,所以咱們須要使用do
結構來表示多個表達式的序列。
解析器的數據結構用於描述由表達式對象組成的程序,每一個對象都包含一個表示表達式類型的type
屬性,除此之外還有其餘描述對象內容的屬性。
類型爲"value"
的表達式表示字符串和數字。它們的value
屬性包含對應的字符串和數字值。類型爲"word"
的表達式用於標識符(名稱)。這類對象以字符串形式將標識符名稱保存在name
屬性中。最後,類型爲"apply"
的表達式表示應用。該類型的對象有一個operator
屬性,指向其操做的表達式,還有一個args
屬性,持有參數表達式的數組。
上面代碼中> (x, 5)
這部分能夠表達成以下形式:
{ type: "apply", operator: {type: "word", name: ">"}, args: [ {type: "word", name: "x"}, {type: "value", value: 5} ] }
咱們將這樣一個數據結構稱爲表達式樹。若是你將對象想象成點,將對象之間的鏈接想象成點之間的線,這個數據結構將會變成樹形。表達式中還會包含其餘表達式,被包含的表達式接着又會包含更多表達式,這相似於樹的分支重複分裂的方式。
咱們將這個解析器與咱們第 9 章中編寫的配置文件格式解析器進行對比,第 9 章中的解析器結構很簡單:將輸入文件劃分紅行,並逐行處理。並且每一行只有幾種簡單的語法形式。
咱們必須使用不一樣方法來解決這裏的問題。Egg 中並無表達式按行分隔,並且表達式之間還有遞歸結構。應用表達式包含其餘表達式。
所幸咱們可使用遞歸的方式編寫一個解析器函數,並優雅地解決該問題,這反映了語言自身就是遞歸的。
咱們定義了一個函數parseExpression
,該函數接受一個字符串,並返回一個對象,包含了字符串起始位置處的表達式與解析表達式後剩餘的字符串。當解析子表達式時(好比應用的參數),能夠再次調用該函數,返回參數表達式和剩餘字符串。剩餘的字符串能夠包含更多參數,也有能夠是一個表示參數列表結束的右括號。
這裏給出部分解析器代碼。
function parseExpression(program) { program = skipSpace(program); let match, expr; if (match = /^"([^"]*)"/.exec(program)) { expr = {type: "value", value: match[1]}; } else if (match = /^\d+\b/.exec(program)) { expr = {type: "value", value: Number(match[0])}; } else if (match = /^[^\s(),"]+/.exec(program)) { expr = {type: "word", name: match[0]}; } else { throw new SyntaxError("Unexpected syntax: " + program); } return parseApply(expr, program.slice(match[0].length)); } function skipSpace(string) { let first = string.search(/\S/); if (first == -1) return ""; return string.slice(first); }
因爲 Egg 和 JavaScript 同樣,容許其元素之間有任意數量的空白,因此咱們必須在程序字符串的開始處重複刪除空白。 這就是skipSpace
函數能提供的幫助。
跳過開頭的全部空格後,parseExpression
使用三個正則表達式來檢測 Egg 支持的三種原子的元素:字符串、數值和單詞。解析器根據不一樣的匹配結果構造不一樣的數據類型。若是這三種形式都沒法與輸入匹配,那麼輸入就是一個非法表達式,解析器就會拋出異常。咱們使用SyntaxError
而不是Error
做爲異常構造器,這是另外一種標準錯誤類型,由於它更具體 - 它也是在嘗試運行無效的 JavaScript 程序時,拋出的錯誤類型。
接下來,咱們從程序字符串中刪去匹配的部分,將剩餘的字符串和表達式對象一塊兒傳遞給parseApply
函數。該函數檢查表達式是不是一個應用,若是是應用則解析帶括號的參數列表。
function parseApply(expr, program) { program = skipSpace(program); if (program[0] != "(") { return {expr: expr, rest: program}; } program = skipSpace(program.slice(1)); expr = {type: "apply", operator: expr, args: []}; while (program[0] != ")") { let arg = parseExpression(program); expr.args.push(arg.expr); program = skipSpace(arg.rest); if (program[0] == ",") { program = skipSpace(program.slice(1)); } else if (program[0] != ")") { throw new SyntaxError("Expected ',' or ')'"); } } return parseApply(expr, program.slice(1)); }
若是程序中的下一個字符不是左圓括號,說明當前表達式不是一個應用,parseApply會返回該表達式。
不然,該函數跳過左圓括號,爲應用表達式建立語法樹。接着遞歸調用parseExpression
解析每一個參數,直到遇到右圓括號爲止。此處經過parseApply
和parseExpression
互相調用,實現函數間接遞歸調用。
由於咱們可使用一個應用來操做另外一個應用表達式(好比multiplier(2)(1)
),因此parseApply
解析完一個應用後必須再次調用自身檢查是否還有另外一對圓括號。
這就是咱們解析 Egg 所需的所有代碼。咱們使用parse
函數來包裝parseExpression
,在解析完表達式以後驗證輸入是否到達結尾(一個 Egg 程序是一個表達式),遇到輸入結尾後會返回整個程序對應的數據結構。
function parse(program) { let {expr, rest} = parseExpression(program); if (skipSpace(result.rest).length > 0) { throw new SyntaxError("Unexpected text after program"); } return expr; } console.log(parse("+(a, 10)")); // → {type: "apply", // operator: {type: "word", name: "+"}, // args: [{type: "word", name: "a"}, // {type: "value", value: 10}]}
程序能夠正常工做了!當表達式解析失敗時,解析函數不會輸出任何有用的信息,也不會存儲出錯的行號與列號,而這些信息都有助於以後的錯誤報告。但考慮到咱們的目的,這門語言目前已經足夠優秀了。
在有了一個程序的語法樹以後,咱們該作什麼呢?固然是執行程序了!而這就是求值器的功能。咱們將語法樹和做用域對象傳遞給求值器,執行器就會求解語法樹中的表達式,而後返回整個過程的結果。
const specialForms = Object.create(null); function evaluate(expr, scope) { if (expr.type == "value") { return expr.value; } else if (expr.type == "word") { if (expr.name in scope) { return scope[expr.name]; } else { throw new ReferenceError( `Undefined binding: ${expr.name}`); } } else if (expr.type == "apply") { let {operator, args} = expr; if (operator.type == "word" && operator.name in specialForms) { return specialForms[operator.name](expr.args, scope); } else { let op = evaluate(operator, scope); if (typeof op == "function") { return op(...args.map(arg => evaluate(arg, scope))); } else { throw new TypeError("Applying a non-function."); } } } }
求值器爲每一種表達式類型都提供了相應的處理邏輯。字面值表達式產生自身的值(例如,表達式100
的求值爲數值100
)。對於綁定而言,咱們必須檢查程序中是否實際定義了該綁定,若是已經定義,則獲取綁定的值。
應用則更爲複雜。若應用有特殊形式(好比if
),咱們不會求解任何表達式,而是將表達式參數和環境傳遞給處理這種形式的函數。若是是普通調用,咱們求解運算符,驗證其是不是函數,並使用求值後的參數調用函數。
咱們使用通常的 JavaScript 函數來表示 Egg 的函數。在定義特殊格式fun
時,咱們再回過頭來看這個問題。
evaluate
的遞歸結構相似於解析器的結構。二者都反映了語言自身的結構。咱們也能夠將解析器和求值器集成到一塊兒,在解析的同時求解表達式,但將其分離爲兩個階段使得程序更易於理解。
這就是解釋 Egg 所需的所有代碼。這段代碼很是簡單,但若是不定義一些特殊的格式,或向環境中添加一些有用的值,你沒法使用該語言完成不少工做。
specialForms
對象用於定義 Egg 中的特殊語法。該對象將單詞和求解這種形式的函數關聯起來。目前該對象爲空,如今讓咱們添加if
。
specialForms.if = (args, scope) => { if (args.length != 3) { throw new SyntaxError("Wrong number of args to if"); } else if (evaluate(args[0], scope) !== false) { return evaluate(args[1], scope); } else { return evaluate(args[2], scope); } };
Egg 的if
語句須要三個參數。Egg 會求解第一個參數,若結果不是false
,則求解第二個參數,不然求解第三個參數。相較於 JavaScript 中的if
語句,Egg 的if
形式更相似於 JavaScript 中的?:
運算符。這是一條表達式,而非語句,它會產生一個值,即第二個或第三個參數的結果。
Egg 和 JavaScript 在處理條件值時也有些差別。Egg 不會將 0 或空字符串做爲假,只有當值確實爲false
時,測試結果才爲假。
咱們之因此須要將if
表達爲特殊形式,而非普通函數,是由於函數的全部參數須要在函數調用前求值完畢,而if
則只應該根據第一個參數的值,肯定求解第二個仍是第三個參數。while
的形式也是相似的。
specialForms.while = (args, scope) => { if (args.length != 2) { throw new SyntaxError("Wrong number of args to while"); } while (evaluate(args[0], scope) !== false) { evaluate(args[1], scope); } // Since undefined does not exist in Egg, we return false, // for lack of a meaningful result. return false; };
另外一個基本的積木是do
,會自頂向下執行其全部參數。整個do
表達式的值是最後一個參數的值。
specialForms.do = (args, scope) => { let value = false; for (let arg of args) { value = evaluate(arg, scope); } };
咱們還須要建立名爲define
的形式,來建立綁定對綁定賦值。define
的第一個參數是一個單詞,第二個參數是一個會產生值的表達式,並將第二個參數的計算結果賦值給第一個參數。因爲define
也是個表達式,所以必須返回一個值。咱們則規定define
應該將咱們賦予綁定的值返回(就像 JavaScript 中的=
運算符同樣)。
specialForms.define = (args, scope) => { if (args.length != 2 || args[0].type != "word") { throw new SyntaxError("Incorrect use of define"); } let value = evaluate(args[1], scope); scope[args[0].name] = value; return value; };
evaluate
所接受的做用域是一個對象,它的名稱對應綁定名稱,它的值對應這些綁定所綁定的值。 咱們定義一個對象來表示全局做用域。
咱們須要先定義布爾綁定才能使用以前定義的if
語句。因爲只有兩個布爾值,所以咱們不須要爲其定義特殊語法。咱們簡單地將true
、false
兩個名稱與其值綁定便可。
const topEnv = Object.create(null); topScope.true = true; topScope.false = false;
咱們如今能夠求解一個簡單的表達式來對布爾值求反。
let prog = parse(`if(true, false, true)`); console.log(evaluate(prog, topScope)); // → false
爲了提供基本的算術和比較運算符,咱們也添加一些函數值到做用域中。爲了確保代碼短小,咱們在循環中使用 Function
來合成一批運算符,而不是分別定義全部運算符。
for (let op of ["+", "-", "*", "/", "==", "<", ">"]) { topScope[op] = Function("a, b", `return a ${op} b;`); }
輸出也是一個實用的功能,所以咱們將console.log
包裝在一個函數中,並稱之爲print
。
topScope.print = value => { console.log(value); return value; };
這樣一來咱們就有足夠的基本工具來編寫簡單的程序了。下面的函數提供了一個便利的方式來編寫並運行程序。它建立一個新的環境對象,並解析執行咱們賦予它的單個程序。
function run(program) { return evaluate(parse(program), Object.create(topScope)); }
咱們將使用對象原型鏈來表示嵌套的做用域,以便程序能夠在不改變頂級做用域的狀況下,向其局部做用域添加綁定。
run(` do(define(total, 0), define(count, 1), while(<(count, 11), do(define(total, +(total, count)), define(count, +(count, 1)))), print(total)) `); // → 55
咱們以前已經屢次看到過這個程序,該程序計算數字 1 到 10 的和,只不過這裏使用 Egg 語言表達。很明顯,相較於實現一樣功能的 JavaScript 代碼,這個程序並不優雅,但對於一個不足 150 行代碼的程序來講已經很不錯了。
每一個功能強大的編程語言都應該具備函數這個特性。
幸運的是咱們能夠很容易地添加一個fun
語言構造,fun
將最後一個參數看成函數體,將以前的全部名稱用做函數參數。
specialForms.fun = (args, scope) => { if (!args.length) { throw new SyntaxError("Functions need a body"); let body = args[args.length - 1]; let params = args.slice(0, args.length - 1).map(expr => { if (expr.type != "word") { throw new SyntaxError("Parameter names must be words"); } return expr.name; }); return function() { if (arguments.length != argNames.length) { throw new TypeError("Wrong number of arguments"); } let localScope = Object.create(scope); for (let i = 0; i < arguments.length; i++) { localScope[params[i]] = arguments[i]; } return evaluate(body, localScope); }; };
Egg 中的函數能夠得到它們本身的局部做用域。 fun
形式產生的函數建立這個局部做用域,並將參數綁定添加到它。 而後求解此範圍內的函數體並返回結果。
run(` do(define(plusOne, fun(a, +(a, 1))), print(plusOne(10))) `); // → 11 run(` do(define(pow, fun(base, exp, if(==(exp, 0), 1, *(base, pow(base, -(exp, 1)))))), print(pow(2, 10))) `); // → 1024
咱們構建的是一個解釋器。在求值期間,解釋器直接做用域由解析器產生的程序的表示。
編譯是在解析和運行程序之間添加的另外一個步驟:經過事先完成儘量多的工做,將程序轉換成一些能夠高效求值的東西。例如,在設計良好的編程語言中,使用每一個綁定時綁定引用的內存地址都是明確的,而不須要在程序運行時進行動態計算。這樣能夠省去每次訪問綁定時搜索綁定的時間,只須要直接去預先定義好的內存位置獲取綁定便可。
通常狀況下,編譯會將程序轉換成機器碼(計算機處理能夠執行的原始格式)。但一些將程序轉換成不一樣表現形式的過程也被認爲是編譯。
咱們能夠爲 Egg 編寫一個可供選擇的求值策略,首先使用Function
,調用 JavaScript 編譯器編譯代碼,將 Egg 程序轉換成 JavaScript 程序,接着執行編譯結果。若能正確實現該功能,可使得 Egg 運行的很是快,並且實現這種編譯器確實很是簡單。
若是讀者對該話題感興趣,願意花費一些時間在這上面,建議你嘗試實現一個編譯器做爲練習。
咱們定義if
和while
的時候,你可能注意到他們封裝得或多或少和 JavaScript 自身的if
、while
有點像。一樣的,Egg 中的值也就是 JavaScript 中的值。
若是讀者比較一下兩種 Egg 的實現方式,一種是基於 JavaScrip t之上,另外一種是直接使用機器提供的功能構建程序設計語言,會發現第二種方案須要大量工做才能完成,並且很是複雜。無論怎麼說,本章的內容就是想讓讀者對編程語言的運行方式有一個基本的瞭解。
當須要完成一些任務時,相比於本身完成全部工做,藉助於別人提供的功能是一種更高效的方式。雖然在本章中咱們編寫的語言就像玩具同樣,十分簡單,並且不管在什麼狀況下這門語言都沒法與 JavaScript 相提並論。但在某些應用場景中,編寫一門微型語言能夠幫助咱們更好地完成工做。
這些語言不須要像傳統的程序設計語言。例如,若 JavaScript 沒有正則表達式,你能夠爲正則表達式編寫本身的解析器和求值器。
或者想象一下你在構建一個巨大的機械恐龍,須要編程實現恐龍的行爲。JavaScript 可能不是實現該功能的最高效方式,你能夠選擇一種語言做爲替代,以下所示:
behavior walk perform when destination ahead actions move left-foot move right-foot behavior attack perform when Godzilla in-view actions fire laser-eyes launch arm-rockets
這一般被稱爲領域特定語言(Domain-specific Language),一種爲表達極爲有限的知識領域而量身定製的語言。它能夠準確描述其領域中須要表達的事物,而沒有多餘元素。這種語言比通用語言更具表現力。
在 Egg 中支持數組須要將如下三個函數添加到頂級做用域:array(...values)
用於構造一個包含參數值的數組,length(array)
用於獲取數組長度,element(array, n)
用於獲取數組中的第n
個元素。
// Modify these definitions... topEnv.array = "..."; topEnv.length = "..."; topEnv.element = "..."; run(` do(define(sum, fun(array, do(define(i, 0), define(sum, 0), while(<(i, length(array)), do(define(sum, +(sum, element(array, i))), define(i, +(i, 1)))), sum))), print(sum(array(1, 2, 3)))) `); // → 6
咱們定義fun
的方式容許函數引用其周圍環境,就像 JavaScript 函數同樣,函數體可使用在定義該函數時能夠訪問的全部局部綁定。
下面的程序展現了該特性:函數f返回一個函數,該函數將其參數和f的參數相加,這意味着爲了使用綁定a,該函數須要可以訪問f中的局部做用域。
run(` do(define(f, fun(a, fun(b, +(a, b)))), print(f(4)(5))) `); // → 9
回顧一下fun形式的定義,解釋一下該機制的工做原理。
若是咱們能夠在 Egg 中編寫註釋就太好了。例如,不管什麼時候,只要出現了井號(#
),咱們都將該行剩餘部分當成註釋,並忽略之,就相似於 JavaScript 中的//
。
解析器並不須要爲支持該特性進行大幅修改。咱們只須要修改skipSpace
來像跳過空白符號同樣跳過註釋便可,此時調用skipSpace
時不只會跳過空白符號,還會跳過註釋。修改代碼,實現這樣的功能。
// This is the old skipSpace. Modify it... function skipSpace(string) { let first = string.search(/\S/); if (first == -1) return ""; return string.slice(first); } console.log(parse("# hello\nx")); // → {type: "word", name: "x"} console.log(parse("a # one\n # two\n()")); // → {type: "apply", // operator: {type: "word", name: "a"}, // args: []}
目前綁定賦值的惟一方法是define
。該語言構造能夠同時實現定義綁定和將一個新的值賦予已存在的綁定。
這種歧義性引起了一個問題。當你嘗試爲一個非局部綁定賦予新值時,你最後會定義一個局部綁定並替換掉原來的同名綁定。一些語言的工做方式正和這種設計同樣,可是我老是認爲這是一種笨拙的做用域處理方式。
添加一個相似於define
的特殊形式set
,該語句會賦予一個綁定新值,若綁定不存在於內部做用域,則更新其外部做用域相應綁定的值。若綁定沒有定義,則拋出ReferenceError
(另外一個標準錯誤類型)。
咱們目前採起的技術是使用簡單的對象來表示做用域對象,處理目前的任務很是方便,此時咱們須要更進一步。你可使用Object.getPrototypeOf
函數來獲取對象原型。同時也要記住,咱們的做用域對象並未繼承Object.prototype
,所以若想調用hasOwnProperty
,須要使用下面這個略顯複雜的表達式。
Object.prototype.hasOwnProperty.call(scope, name);
specialForms.set = function(args, env) { // Your code here. }; run(` do(define(x, 4), define(setx, fun(val, set(x, val))), setx(50), print(x)) `); // → 50 run(`set(quux, true)`); // → Some kind of ReferenceError