资讯专栏INFORMATION COLUMN

如何编写简单的parser(实践篇)

shaonbean / 588人阅读

摘要:负责读取和记录当前代码的位置,并把读取到的代码交给处理,其意义在于,当传递给的代码需要进行判读猜测时,能够记录当前读取的位置,并在接下来的操作汇总回滚到之前的读取位置,也能在发生语法错误时,准确指出错误发生在代码段的第几行第几个字符。

上一篇(《如何编写简单的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 == "
") 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标准的定义也很清晰:

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。

参考

几篇可以参考的原文,推荐大伙看看:

《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...)

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/101455.html

相关文章

  • 如何编写简单parser(基础篇)

    摘要:在这里,词法解析器应用的规则即为词汇语法的定义,语法解释器应用的规则即为表达式语句声明和函数等的定义。如何编写简单的实践篇 什么是parser? 简单的说,parser的工作即是将代码片段转换成计算机可读的数据结构的过程。这个计算机可读的数据结构更专业的说法是抽象语法树(abstract syntax tree),简称AST。AST是代码片段具体语义的抽象表达,它不包含该段代码的所有细...

    Barry_Ng 评论0 收藏0
  • javascript 迁移 typescript 实践

    摘要:但是,从长远来看,尤其是多人协作的项目,还是很有必要的。第二参数为了某些场景下要大写强调,只需要传入即可自动将结果转成大写。这个有可能是业务上线了之后才发生,直接导致业务不可用。而这也被证明是个好的编码方式。 只是抱着尝试的心态对项目进行了迁移,体验了一番typeScript的强大,当然,习惯了JavaScript的灵活,弱类型,刚用上typeScript时会很不适应,犹如懒散惯了的人...

    niceforbear 评论0 收藏0
  • 如何用 ANTLR 4 实现自己脚本语言?

    摘要:是一个实现的词法语法分析生成程序,目前最新版本为,支持,,等语言,这里我们用来实现一个自己的脚本语言。在实现时,只要每个节点都做好自己的工作就可以了。不过,它是一个好的开始,可以让我们在此基础上,设计更完善易用的语言。 ANTLR 是一个 Java 实现的词法/语法分析生成程序,目前最新版本为 4.5.2,支持 Java,C#,JavaScript 等语言,这里我们用 ANTLR 4....

    scwang90 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<