怎样编写简朴的parser(实践篇)

上一篇(《怎样编写简朴的parser(基本篇)》)中引见了编写一个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规范的定义也很清楚:
《怎样编写简朴的parser(实践篇)》

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

参考

几篇能够参考的原文,引荐大伙看看:

规范以及文献:

    原文作者:weeast
    原文地址: https://segmentfault.com/a/1190000018067223
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞