上一篇中介紹了編寫一個parser所需具備的基礎知識,接下來,我們要動手實踐一個簡單的
parser,既然是“簡單”的parser,那麼,我們就要爲這個parser劃定範圍,否則,完整的JavaScript語言parser的複雜度就不是那麼簡單的了。
劃定範圍
基於能夠編寫簡單實用的JavaScript程序
和具備基礎語法的解釋能力
這兩點考慮,我們將parser的規則範圍劃分如下:
- 聲明:變量聲明 & 函數聲明
- 賦值:賦值操作 (& 左表達式)
- 加減乘除:加減操作 & 乘除操作
- 條件判斷:if語句
如果用一句話來劃分的話,即一個能解析包括聲明、賦值、加減乘除、條件判斷
的解析器。
功能劃分
基於上一篇中介紹的JavaScript語言由詞組(token)組成表達式(expression),由表達式組成語句(statement)的模式,我們將parser劃分爲——負責解析詞法的TokenSteam
模塊,負責解析表達式和語句的Parser
,另外,負責記錄讀取代碼位置的InputSteam
模塊。
這裏,有兩點需要進行說明:
- 由於我們這裏包含的expression解析類型和statement的解析類型都不多,所以,我們使用一個parser模塊來統一解析,但是在如babel-parser這類完整的parser中,是將expression和statement拆開進行解析的,這裏的邏輯僅供參考;
- 另外,這裏對詞法的解析是逐字進行解析,並沒有使用正則表達式進行匹配解析,因爲在完整度高的parser中,使用正則匹配詞法會提高整體的複雜度。
InputSteam
InputSteam負責讀取和記錄當前代碼的位置,並把讀取到的代碼交給TokenSteam處理,其意義在於,當傳遞給TokenSteam的代碼需要進行判讀猜測時,能夠記錄當前讀取的位置,並在接下來的操作彙總回滾到之前的讀取位置,也能在發生語法錯誤時,準確指出錯誤發生在代碼段的第幾行第幾個字符。
該模塊是功能最簡潔的模塊,我們只需創建一個類似“流”的對象即可,其中主要包含以下幾個方法:
-
peek()
—— 閱讀下一個代碼,但是不會將當前讀取位置遷移,主要用於存在不確定性情況下的判讀; -
next()
—— 閱讀下一個代碼,並移動讀取位置到下一個代碼,主要用於確定性的語法讀取; -
eof()
—— 判斷是否到當前代碼的結束部分; -
croak(msg)
—— 拋出讀取代碼的錯誤。
接下來,我們看一下這幾個方法的實現:
function InputStream(input) {
var pos = 0, line = 1, col = 0;
return {
next : next,
peek : peek,
eof : eof,
croak : croak,
};
function next() {
var ch = input.charAt(pos++);
if (ch == "\n") line++, col = 0; else col++;
return ch;
}
function peek() {
return input.charAt(pos);
}
function eof() {
return peek() == "";
}
function croak(msg) {
throw new Error(msg + " (" + line + ":" + col + ")");
}
}
TokenSteam
我們依據一開始劃定的規則範圍 —— 一個能解析包括聲明、賦值、加減乘除、條件判斷
的解析器,來給TokenSteam劃定詞法解析的範圍:
-
變量聲明 & 函數聲明
:包含了變量、“var”關鍵字、“function”關鍵字、“{}”符號、“()”符號、“,”符號的識別; -
賦值操作
:包含了“=”操作符的識別; -
加減操作 & 乘除操作
:包含了“+”、“-”、“*”、“/”操作符的識別; -
if語句
:包含了“if”關鍵字的識別; -
字面量(畢竟沒有字面量也沒辦法賦值)
:包括了數字字面量和字符串字面量。
接下來,TokenSteam主要使用InputSteam讀取並判讀代碼,將代碼段解析爲符合ECMAScript標準的詞組流,返回的詞組流大致如下:
{ type: "punc", value: "(" } // 符號,包含了()、{}、,
{ type: "num", value: 5 } // 數字字面量
{ type: "str", value: "Hello World!" } // 字符串字面量
{ type: "kw", value: "function" } // 關鍵字,包含了function、var、if
{ type: "var", value: "a" } // 標識符/變量
{ type: "op", value: "!=" } // 操作符,包含+、-、*、/、=
其中,不包含空白符和註釋,空白符用於分隔詞組,對於已經解析了的詞組流來說並無意義,至於註釋,在我們簡單的parser中,就不需要解析註釋來提高複雜度了。
有了需要判讀的詞組,我們只需根據ECMAScript標準的定義,進行適當的簡化,便能抽取出對應詞組需要的判讀規則,大致邏輯如下:
- 首先,跳過空白符;
- 如果input.eof()返回true,則結束判讀;
- 如果input.peek()返回是一個“"”,接下來,讀取一個字符串字面量;
- 如果input.peek()返回是一個數字,接下來,讀取一個數字字面量;
- 如果input.peek()返回是一個字母,接下來,讀取的可能是一個標識符,也可能是一個關鍵字;
- 如果input.peek()返回是標點符號中的一個,接下來,讀取一個標點符號;
- 如果input.peek()返回是操作符中的一個,接下來,讀取一個操作符;
- 如果沒有匹配以上的條件,則使用input.croak()拋出一個語法錯誤。
以上的,即是TokenSteam工作的主要邏輯了,我們只需不斷重複以上的判斷,即能成功將一段代碼,解析成爲詞組流了,將該邏輯整理爲代碼如下:
function read_next() {
read_while(is_whitespace);
if (input.eof()) return null;
var ch = input.peek();
if (ch == '"') return read_string();
if (is_digit(ch)) return read_number();
if (is_id_start(ch)) return read_ident();
if (is_punc(ch)) return {
type : "punc",
value : input.next()
};
if (is_op_char(ch)) return {
type : "op",
value : read_while(is_op_char)
};
input.croak("Can't handle character: " + ch);
}
主邏輯類似於一個分發器(dispatcher),識別了接下來可能的工作之後,便將工作分發給對應的處理函數如read_string、read_number等,處理完成後,便將返回結果吐出。
需要注意的是,我們並不需要一次將所有代碼全部解析完成,每次我們只需將一個詞組吐給parser模塊進行處理即可,以避免還沒有解析完詞組,就出現了parser的錯誤。
爲了使大家更清晰的明確詞法解析器的工作,我們列出數字字面量的解析邏輯如下:
// 使用正則來判讀數字
function is_digit(ch) {
return /[0-9]/i.test(ch);
}
// 讀取數字字面量
function read_number() {
var has_dot = false;
var number = read_while(function(ch){
if (ch == ".") {
if (has_dot) return false;
has_dot = true;
return true;
}
return is_digit(ch);
});
return { type: "num", value: parseFloat(number) };
}
其中read_while函數在主邏輯和數字字面量中都出現了,該函數主要負責讀取符合格則的一系列代碼,該函數的代碼如下:
function read_while(predicate) {
var str = "";
while (!input.eof() && predicate(input.peek()))
str += input.next();
return str;
}
最後,TokenSteam需要將解析的詞組吐給Parser模塊進行處理,我們通過next()方法,將讀取下一個詞組的功能暴露給parser模塊,另外,類似TokenSteam需要判讀下一個代碼的功能,parser模塊在解析表達式和語句的時候,也需要通過下一個詞組的類型來判讀解析表達式和語句的類型,我們將該方法也命名爲peek()。
function TokenStream(input) {
var current = null;
function peek() {
return current || (current = read_next());
}
function next() {
var tok = current;
current = null;
return tok || read_next();
}
function eof() {
return peek() == null;
}
// 主代碼邏輯
function read_next() {
//....
}
// ...
return {
next : next,
peek : peek,
eof : eof,
croak : input.croak
};
}
在next()函數中,需要注意的是,因爲有可能在之前的peek()判讀中,已經調用read_next()來進行判讀了,所以,需要用一個current變量來保存當前正在讀的詞組,以便在調用next()的時候,將其吐出。
Parser
最後,在Parser模塊中,我們對TokenSteam模塊讀取的詞組進行解析,這裏,我們先講一下最後Parser模塊輸出的內容,也就是上一篇當中講到的抽象語法樹(AST)
,這裏,我們依然參考babel-parser的AST語法標準,在該標準中,代碼段都是被包裹在Program節點中的(其實也是大部分AST標準的模式),這也爲我們Parser模塊的工作指明瞭方向,即自頂向下
的解析模式:
function parse_toplevel() {
var prog = [];
while (!input.eof()) {
prog.push(parse_statement());
}
return { type: "prog", prog: prog };
}
該parse_toplevel函數,即是Parser模塊的主邏輯了,邏輯也很簡單,代碼段既然是有語句(statements)組成的,那麼我們就不停地將詞組流解析爲語句即可。
parse_statement
和TokenSteam類似的是,parse_statement也是一個類似於分發器(dispatcher)
的函數,我們根據一個詞組來判讀接下來的工作:
function parse_statement() {
if(is_punc(";")) skip_punc(";");
else if (is_punc("{")) return parse_block();
else if (is_kw("var")) return parse_var_statement();
else if (is_kw("if")) return parse_if_statement();
else if (is_kw("function")) return parse_func_statement();
else if (is_kw("return")) return parse_ret_statement();
else return parse_expression();
}
當然,這樣的分發模式,也是隻限定於我們在最開始劃定的規則範圍,得益於規則範圍小的優勢,parse_statement函數的邏輯得以簡化,另外,雖然語句(statements)
是由表達式(expressions)
組成的,但是,表達式(expression)
依然能單獨存在於代碼塊中,所以,在parse_statement的最後,不符合所有語句條件的情況,我們還是以表達式進行解析。
parse_function
在語句的解析中,我們拿函數的的解析來作一個例子,依據AST標準的定義以及ECMAScript標準的定義,函數的解析規則變得很簡單:
function parse_function(isExpression) {
skip_kw("function");
return {
type: isExpression?"FunctionExpression":"FunctionDeclaration",
id: is_punc("(")?null:parse_identifier(),
params: delimited("(", ")", ",", parse_identifier),
body: parse_block()
};
}
對於函數的定義:
- 首先一定是以
關鍵字“function”
開頭; - 其後,若是匿名函數,則沒有函數名標識符,否則,則解析一個標識符;
- 接下來,則是函數的參數,包含在一對“
()
”中,以“,
”間隔; - 最後,即是函數的函數體。
在代碼中,解析參數的函數delimited
是依據傳入規則,在起始符與結束符之間,以間隔符隔斷的代碼段來進行解析的函數,其代碼如下:
function delimited(start, stop, separator, parser) {
var res = [], first = true;
skip_punc(start);
while (!input.eof()) {
if (is_punc(stop)) break;
if (first) first = false; else skip_punc(separator);
if (is_punc(stop)) break;
res.push(parser());
}
skip_punc(stop);
return res;
}
至於函數體的解析,就比較簡單了,因爲函數體即是多段語句,和程序體的解析是一致的,ECMAScript標準的定義也很清晰:
https://km.sankuai.com/api/fi...
function parse_block() {
var body = [];
skip_punc("{");
while (!is_punc("}")) {
var sts = parse_statement()
sts && body.push(sts);
}
skip_punc("}");
return {
type: "BlockStatement",
body: body
}
}
parse_atom & parse_expression
接下來,語句的解析能力具備了,該輪到解析表達式了,這部分,也是整個Parser比較難理解的一部分,這也是爲什麼將這部分放到最後的原因。因爲在解析表達式的時候,會遇到一些不確定
的過程,比如以下的代碼:
(function(a){return a;})(a)
當我們解析完成第一對“()
”中的函數表達式後,如果此時直接返回一個函數表達式,那麼後面的一對括號,則會被解析爲單獨的標識符。顯然這樣的解析模式是不符合
JavaScript語言的解析模式的,這時,往往我們需要在解析完一個表達式後,繼續往後進行嘗試性的解析。這一點,在parse_atom
和parse_expression
中都有所體現。
回到正題,parse_atom
也是一個分發器(dispatcher)
,主要負責表達式層面上的解析分發,主要邏輯如下:
function parse_atom() {
return maybe_call(function(){
if (is_punc("(")) {
input.next();
var exp = parse_expression();
skip_punc(")");
return exp;
}
if (is_kw("function")) return parse_function(true)
var tok = input.next();
if (tok.type == "var" || tok.type == "num" || tok.type == "str")
return tok;
unexpected();
});
}
該函數一開頭便是以一個猜測性的maybe_call函數開頭,正如上我們解釋的原因,maybe_call主要是對於調用表達式的一個猜測,一會我們在來看這個maybe_call的實現。parse_atom識別了位於“()”符號中的表達式、函數表達式、標識符、數字和字符串字面量,若都不符合以上要求,則會拋出一個語法錯誤。
parse_expression的實現,主要處理了我們在最開始規則中定義的加減乘除操作
的規則,具體實現如下:
function parse_expression() {
return maybe_call(function(){
return maybe_binary(parse_atom(), 0);
});
}
這裏又出現了一個maybe_binary
的函數,該函數主要處理了加減乘除
的操作,這裏看到maybe
開頭,便能知道,這裏也有不確定的判斷因素,所以,接下來,我們統一講一下這些maybe開頭的函數。
maybe_*
這些以maybe
開頭的函數,如我們以上講的,爲了處理表達式的不確定性
,需要向表達式後續的語法進行試探性的解析
。
maybe_call
函數的處理非常簡單,它接收一個用於解析當前表達式的函數,並對該表達式後續詞組進行判讀,如果後續詞組是一個“(
”符號詞組,那麼該表達式一定是一個調用表達式(CallExpression)
,那麼,我們就將其交給parse_call函數
來進行處理,這裏,我們又用到之前分隔解析的函數delimited
。
// 推測表達式是否爲調用表達式
function maybe_call(expr) {
expr = expr();
return is_punc("(") ? parse_call(expr) : expr;
}
// 解析調用表達式
function parse_call(func) {
return {
type: "call",
func: func,
args: delimited("(", ")", ",", parse_expression),
};
}
由於解析加、減、乘、除
操作時,涉及到不同操作符的優先級,不能使用正常的從左至右進行解析,使用了一種二元表達式
的模式進行解析,一個二元表達式包含了一個左值
,一個右值
,一個操作符
,其中,左右值可以爲其他的表達式,在後續的解析中,我們就能根據操作符的優先級
,來決定二元的樹狀結構,而二元的樹狀結構,就決定了操作的優先級,具體的優先級和maybe_binary
的代碼如下:
// 操作符的優先級,值越大,優先級越高
var PRECEDENCE = {
"=": 1,
"||": 2,
"&&": 3,
"<": 7, ">": 7, "<=": 7, ">=": 7, "==": 7, "!=": 7,
"+": 10, "-": 10,
"*": 20, "/": 20, "%": 20,
};
// 推測是否是二元表達式,即看該左值接下來是否是操作符
function maybe_binary(left, my_prec) {
var tok = is_op();
if (tok) {
var his_prec = PRECEDENCE[tok.value];
if (his_prec > my_prec) {
input.next();
return maybe_binary({
type : tok.value == "=" ? "assign" : "binary",
operator : tok.value,
left : left,
right : maybe_binary(parse_atom(), his_prec)
}, my_prec);
}
}
return left;
}
需要注意的是,maybe_binary
是一個遞歸
處理的函數,在返回之前,需要將當前的表達式以當前操作符的優先級進行二元表達式的解析,以便包含在另一個優先級較高的二元表達式中。
爲了讓大家更方便理解二元的樹狀結構如何決定優先級,這裏舉兩個例子:
// 表達式一
1+2*3
// 表達式二
1*2+3
這兩段加法乘法表達式使用上面的方法解析後,分別得到如下的AST:
// 表達式一
{
type : "binary",
operator : "+",
left : 1,
right : {
type: "binary",
operator: "*",
left: 2, // 這裏簡化了左右值的結構
right: 3
}
}
// 表達式二
{
type : "binary",
operator : "+",
left : {
type : "binary",
operator : "*",
left : 1,
right : 2
},
right : 3
}
可以看到,經過優先級的處理後,優先級較爲低的操作都被處理到了外層,而優先級高的部分,則被處理到了內部,如果你還感到迷惑的話,可以試着自己拿幾個表達式進行處理,然後一步一步的追蹤代碼的執行過程,便能明白了。
總結
其實,說到底,簡單的parser複雜度遠比完整版的parser低很多,如果想要更進一步的話,可以嘗試去閱讀babel-parser的源碼,相信,有了這兩篇文章的鋪墊,babel的源碼閱讀起來也會輕鬆不少。另外,在文章的最後,附上該篇文章的demo。
參考
幾篇可以參考的原文,推薦大夥看看:
- 《How to implement a programming language in JavaScript》(http://lisperator.net/pltut/)
- 《Parsing in JavaScript: Tools and Libraries》(https://tomassetti.me/parsing...)
標準以及文獻:
- 《ECMAScript® 2016 Language Specification》(http://www.ecma-international...)
- the core @babel/parser (babylon) AST node types(https://github.com/babel/babe...)