如何編寫簡單的parser(實踐篇)

上一篇(《如何編寫簡單的parser(基礎篇)》)中介紹了編寫一個parser所需具有的基礎知識,接下來,咱們要動手實踐一個簡單的parser,既然是「簡單」的parser,那麼,咱們就要爲這個parser劃定範圍,不然,完整的JavaScript語言parser的複雜度就不是那麼簡單的了。javascript

劃定範圍

基於可以編寫簡單實用的JavaScript程序具有基礎語法的解釋能力這兩點考慮,咱們將parser的規則範圍劃分以下:html

  • 聲明:變量聲明 & 函數聲明
  • 賦值:賦值操做 (& 左表達式)
  • 加減乘除:加減操做 & 乘除操做
  • 條件判斷:if語句

若是用一句話來劃分的話,即一個能解析包括聲明、賦值、加減乘除、條件判斷的解析器。java

功能劃分

基於上一篇中介紹的JavaScript語言由詞組(token)組成表達式(expression),由表達式組成語句(statement)的模式,咱們將parser劃分爲——負責解析詞法的TokenSteam模塊,負責解析表達式和語句的Parser,另外,負責記錄讀取代碼位置的InputSteam模塊。node

這裏,有兩點須要進行說明:git

  • 因爲咱們這裏包含的expression解析類型和statement的解析類型都很少,因此,咱們使用一個parser模塊來統一解析,可是在如babel-parser這類完整的parser中,是將expression和statement拆開進行解析的,這裏的邏輯僅供參考;
  • 另外,這裏對詞法的解析是逐字進行解析,並無使用正則表達式進行匹配解析,由於在完整度高的parser中,使用正則匹配詞法會提升總體的複雜度。

InputSteam

InputSteam負責讀取和記錄當前代碼的位置,並把讀取到的代碼交給TokenSteam處理,其意義在於,當傳遞給TokenSteam的代碼須要進行判讀猜想時,可以記錄當前讀取的位置,並在接下來的操做彙總回滾到以前的讀取位置,也能在發生語法錯誤時,準確指出錯誤發生在代碼段的第幾行第幾個字符。github

該模塊是功能最簡潔的模塊,咱們只需建立一個相似「流」的對象便可,其中主要包含如下幾個方法:正則表達式

  • peek() —— 閱讀下一個代碼,可是不會將當前讀取位置遷移,主要用於存在不肯定性狀況下的判讀;
  • next() —— 閱讀下一個代碼,並移動讀取位置到下一個代碼,主要用於肯定性的語法讀取;
  • eof() —— 判斷是否到當前代碼的結束部分;
  • croak(msg) —— 拋出讀取代碼的錯誤。

接下來,咱們看一下這幾個方法的實現:express

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劃定詞法解析的範圍:segmentfault

  • 變量聲明 & 函數聲明:包含了變量、「var」關鍵字、「function」關鍵字、「{}」符號、「()」符號、「,」符號的識別;
  • 賦值操做:包含了「=」操做符的識別;
  • 加減操做 & 乘除操做:包含了「+」、「-」、「*」、「/」操做符的識別;
  • if語句:包含了「if」關鍵字的識別;
  • 字面量(畢竟沒有字面量也沒辦法賦值):包括了數字字面量和字符串字面量。

接下來,TokenSteam主要使用InputSteam讀取並判讀代碼,將代碼段解析爲符合ECMAScript標準的詞組流,返回的詞組流大體以下:babel

{ 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標準的定義也很清晰:
clipboard.png

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_atomparse_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

參考

幾篇能夠參考的原文,推薦大夥看看:

標準以及文獻:

相關文章
相關標籤/搜索