JS Parser Combinator (解析器组合子)

前言

偶然看到以前写的一份代码,注意有一段尘封的代码,被我遗忘了。这段代码是一个简单的解析器,当时是为了解析日志而做的。最初解析日志时,我只是简单的正则加上分割,写着写着,我想,能不能用一个简单的方案做个解析器,这样可以解析多种日志。于是就有了这段代码,后来日志解析完了,没有解析其它日志就给忘了。再次看到这段代码,用非常简单易读的代码就实现了一个解析器,觉得非常值得分享。

通过本文读者将了解到什么是解析器组合子以及它是怎么来的。

思路

文本解析不是一件简单的事情,如果让我们解析一个 JSON 或者一个表达式,我们应该会感觉到有一点困难。当面对困难任务时,我们可以从最简单的情形开始。最简单的文本解析情形应该是解析单个字符了吧。

现假设我们要解析一个字符 a,那么我们解析器应该会是什么样呢?我想,大概是这样:

function parseA(input) {
  if (input.startsWith('a')) return 'a';
  throw Error(`unexpected character '${input[0]}'`);
}

这段代码正确地完成了我们的解析任务,没有任何问题。但是我们并不满足,我们想解析字符 b,于是我们又写了一个函数:

function parseB(input) { /* ... */ }

很好,parseB 也完成了我们期望的功能。但是我们还不满足,我们想解析更多的字符,cde 等等。如果我们挨个给这些字符写一个解析器,那将无穷无尽。另外,如果我们能解析 abc 这三个字符,自然而然地,我们会觉得应该也要可以解析字符串 abc

基于以上问题,我们可以总结出:

  1. 我们需要一个工厂函数来给我们创建不同的解析器
  2. 我们的解析器要能够组合

如果把上面的总结翻译成代码就是:假设 Parser T 表示能解析出 T 类型的解析器

那么,工厂函数就大概是这样:

x -> Parser x

而解析器的组合就是这样:

(Parser a, Parser b) -> Parser [a, b]

那上面提到的 Parser T 到底是什么呢?简单地说它是一个把字符串转换为 T 的过程:

String -> T

这个转换过程有多种实现方式,比如面向对象的实现可能就是一个有 parse 方法的接口:

interface Parser<T> {
  parse(input: string): T
};

而我们将采用 JS 来实现,在 JS 中我们可以采取更简单的实现--函数!

type Parser<T> = (input: string) => T;

于是,我们的 parseAparseB 这些函数就可以样写了:

function char(ch) {
  return input => {
    if (input.startsWith(ch)) return ch;
    throw Error(`unexpected character '${input[0]}'`);
  };
}

const parseA = char('a');
const parseB = char('b');

显然上面的解析器可能解析出正确的结果,但是很可惜的是它们不满足我们上面总结出来的 “可以组合” 这个条件。可组合的函数要求前面函数的输出可以喂到下个函数的输入中,显然它们并不是这样的。以 parseAparseB 为例,parseA 输出的是字符 a,而 parseB 期望的是输入字符串至少是以 'b' 开头的。要解决这个问题,我们需要把解析器的返回值做一些修改,使它可以包含下一个解析器的输入。其实,下个解析器的输入就是上个解析器解析成功后剩余的输入字符串。

type Parser<T> =
  (input: string) => [T, string];
//                    |     |  
//                    |     `--- remaining input
//                    `--------- result

现在我们的解析器就可以改写成这样:

function char(ch) {
  return input => {
    if (input.startsWith(ch))
      return [ch, input.substr(ch.length)];
    throw new Error(/* ... */);
  };
}

function compose(parserA, parserB) {
  return input => {
    const [a, rest1] = parserA(input);
    const [b, rest2] = parserB(rest1);
    return [[a, b], rest2];
  };
}

const chA = char('a');
const chB = char('b');
const parseAB = compose(chA, chB);
parseAB('abcd'); // [['a', 'b'], 'cd']

目前为止,一切都很棒,但我们可以继续优化。上面的 compose 函数只能把两个解析器组合成一个解析器,我们可以推广 compose 函数,让它可以接受任意数量的解析器。分析一下 compose 函数组合出来的新解析器我们可以发现,新解析器的效果相当于组合前各解析器按顺序解析。所以我们把推广后的 compose 函数重命名为 seq

function seq(...parsers) {
  return input => 
	parsers.reduce(
	  ([xs, src], parser) => {
	    const [x, rest] = parser(src);
	    return [[...xs, x], rest];
	  },
	  [[], input]
	);
}

玩玩看,我们来解析 hello 这个字符串:

const parseHello = seq(
  char('h'),
  char('e'),
  char('l'),
  char('l'),
  char('o')
);

parseHello('hello!');
// [['h', 'e', 'l', 'l', 'o'], '!']

非常好,如预期般工作,我们应该止步于此吗?观察一下我们解析 “hello” 的过程,我们可以发现:

  1. 有点繁琐。如果要解析长字符串很麻烦
  2. parseHello 应该要返回字符串 “hello” 才合理,现在返回的是字符列表

针对第一个问题,我们可以再为字符串创建一个工厂函数:

function str(what) {
  return seq(...what.split('').map(char));
}

虽然这样也可以,但有更好的方式:

function str(v) {
  return input => {
    if (input.startsWith(v))
      return [v, input.substr(v.length)];
    throw new Error(/* ... */);
  };
}

我们甚至有更激进的做法!JS 原生支持正则表达式,如果我们的工厂函数也接受正则表达式,那可以节省不少构造解析器的步骤。

function tok(p) {
  if (p instanceof RegExp) {
    p = new RegExp(p, 'y');
    return input => {
      p.lastIndex = 0;
      const m = p.exec(input);
      if (!m) throw new Error(/* ... */);
      return [m[0], input.substr(m[0].length)];
    };
  }
  return input => {
    if (input.startsWith(p))
      return [p, input.substr(p.length)];
    throw new Error(/* ... */);
  };
}

现在,parseHello 就可以写成 parseHello = tok('hello') 了。代码简洁了不少,也正确返回了字符串 “hello”。但是,返回值类型问题还没有彻底解决,比如以下解析器:

const number = tok(/\d+/);

如果我们想让 number 函数返回 number 类型的结果该怎么办呢?

目前为止我们的解析器本质上是字符串解析器,也就是 Parser String。我们想要得到一个 number 类型的结果,用代码表述就是:

Parser String -> Parser Number

一般化后的形式就是:

Parser A -> Parser B

不难看出,这实际上是某种映射!我们可以添加一个 map 函数来表达这种映射:

function map(parser, fn) {
  return input => {
    const [r, rest] = parser(input);
    return [fn(r), rest];
  };
}

map 函数相当于一个变形器,能够把 Parser A 变形为 Parser B,只要你告诉它应该怎么把 A 变形为 B! 有了它我们就可以彻底解决返回值类型问题了,现在我们可以真正地解析数字:

const number = map(tok(/\d+/), Number);
number('2025'); // [2025, '']

可以看到我们得到了预期结果。

我们的解析器框架已经初具规模,是时候尝试一下稍微复杂的解析任务了。我们来试试解析一下加减法表达式:

const Int = map(tok(/\d+/), Number);
const Expr = map(
  seq(Int, tok(/\+|-/), Int),
  ([a, op, b]) => {
    if (op === '+') return a + b;
    if (op === '-') return a - b;
    throw new Error(`unknown op '${op}'`);
  }
);

Expr('1+2'); // [3, ''] 🎉

完美!我们可以正确解析表达式并进行计算,而且代码很简洁。

更多组合子!

这节的标题中出现了一个陌生的词语--“组合子”,究竟什么是组合子呢?前面的 seqmap 就是组合子,它们可以把多个解析器组合起来形成更复杂的解析器,在我们的实现里,组合子以函数的形式存在。

前面我们已经提到,seq 表示顺序解析,可现实的解析任务中我们会遇到各式各样的结构,比如重复、可选等等。我们可以给这些常见的结构编写相应的组合子来方便解析,以下列出了一些这样的组合子:

  • opt 表示 “可选” 解析,相当于正则表达式中的 ?
  • many 表示 “重复”,相当于正则表达式中的 *
  • any 表示 “或者”,相当于正则表达式中的 |

以上组合子代码逻辑非常简单,这里不再列出它们的代码。

解析 JSON

有了越来越多的这些组合子,我们可以完成越来越复杂的解析,接下来我们来挑战一下 JSON 解析。不过在开始之前我们还需要再添加一个组合子。JSON 中包含一种常见结构,值得我们专门为它写一个组合子--分隔符分隔的列表。

function many_sep(sep, item) {
  // (item (sep item)*)?
  return map(
	opt(seq(item, many(seq(sep, item)))),
	items => {
	  if (!items) return [];
	  const [x, xs] = items;
	  return [x, ...xs.map(([, it]) => it)];
	}
  );
}

有了它,我们就能解析 JSON 中的数组和键值对列表了,以下是 JSON 解析的代码:

const ws = tok(/\s*/);
const Sep = seq(tok(','), ws);
const Str = map(
  tok(/"([^\\"]|\\[\\\/bfrtn"]|\\u[0-9a-fA-F]{4})*"/),
  JSON.parse // !! 偷个懒
); 
const Num = map(
  tok(/-?(0|[1-9]\d*)(\.\d+)?([eE][\+\-]?\d+)?/),
  Number
);
// Elems = Val (',' Val)*
const Elems = many_sep(Sep, src => Val(src));
// KV = Str ws ':' ws Val
const KV = map(
  seq(Str, ws, tok(':'), ws, src => Val(src)),
  xs => [xs[0], xs[4]]
);
// KVs = KV (',' KV)*
const KVs = many_sep(Sep, KV);
// Val = Str | Num | 'true' | 'false' | 'null'
//     | '[' ws Elems ws ']' | '{' ws KVs ws '}'
const Val = any(
  Str, Num,
  map(tok('true'), () => true)
  map(tok('false'), () => false),
  map(tok('null'), () => null),
  map(
    // '[' ws Elems ws ']'
    seq(tok('['), ws, Elems, ws,tok(']')),
    xs => xs[2]
  ),
  map(
    // '{' ws KVs ws '}'
    seq(tok('{'), ws, KVs, ws, tok('}')),
    xs => Object.fromEntries(xs[2])
  )
);

Val('{"name": "Alice", "luckyNumbers": [8, 9], "married": true}');
// [{name: "Alice", luckyNumbers: [8, 9], married: true}, ''] 🎉

总结

本文从一个简单的解析任务开始,不断地提出问题并解决问题,不断地抽象,引导读者发现了解析器组合子,并最终完成了一个相对困难的解析任务。从这个过程中我们不难发现,解析器组合子的出现是非常自然而然的。讲到这里我们可以正式地解释一下什么是解析器组合子了。

解析器组合子

我们可以分两部分理解解析器组合子:

  • 解析器:一个接受某种输入(通常是字符串)并将它转换为某种结果的对象或者函数
  • 组合子:可以组合多个解析器从而形成更复杂解析器的对象或者函数

“解析器组合子” 能够把多个小的、简单的解析函数组合成具有复杂功能的解析器,它们能让我们构建出的解析器更加模块化,也更具有复用性,是非常强大的工具。

彩蛋 1 -- TS 类型注解

type Parser<T> = (src: string) => [T, string];
type ResultOf<T extends Parser<any>> = T extends Parser<infer U> ? U : never;
type Seq<T extends Parser<any>[]> = Parser<{ [K in keyof T]: ResultOf<T[K]> }>;
type Any<T extends Parser<any>[]> = Parser<T extends Parser<infer U>[] ? U : never>;

function map<A, B>(parser: Parser<A>, fn: (a: A) => B): Parser<B>;
function tok<T extends string>(str: T): Parser<T>;
function tok(pattern: RegExp): Parser<string>;
function opt<T>(parser: Parser<T>): Parser<T|undefined>;
function alt<T extends Parser<any>[]>(...opts: readonly [...T]): Any<T>;
function seq<T extends Parser<any>[]>(...steps: readonly [...T]): Seq<T>;
function many<T>(item: Parser<T>): Parser<T[]>;

彩蛋 2 -- 进阶版表达式求值

进阶版本的表达式解析与求值,支持加、减、乘、除和括号:

// Factor = '(' ws Exp ws ')' | Num
const Factor = any([
    seq([tok('('), ws, src => Exp(src), ws, tok(')')], vals => vals[2]),
    Num,
]);

// Term = Factor (ws '*|/' ws Factor)*
const Term = seq([Factor, many(seq([ws, tok(/\*|\//), ws, Factor]))], ([head, tail]) => {
    return tail.reduce((acc, x) => {
        if (x[1] === '*') return acc * x[3];
        if (x[1] === '/') return acc / x[3];
        return acc;
    }, head);
});

// Exp = Term (ws '+|-' ws Term)*
const Exp = seq([Term, many(seq([ws, tok(/\+|-/), ws, Term]))], ([head, tail]) => {
    return tail.reduce((acc, x) => {
        if (x[1] === '+') return acc + x[3];
        if (x[1] === '-') return acc - x[3];
        return acc;
    }, head);
});

console.log(Exp('( 1 + 2 * 5 ) * 3')); // [33, ""]
posted @ 2023-03-26 17:58  1bite  阅读(192)  评论(0)    收藏  举报