原文:Parser Combinators are Easy
假设,我们在处理一种全新的 Point 数据格式。 然而,设计 Point 数据格式的程序员今天爆发了“创造力”,并设计了一个复杂的传输字符串:
const input = '.:{([2 3]][[6 2]][[1 2])][([1 4]][[2 1])][([6 9])}:.'
这显然太丑了,不能接受。不过,你只能通过这个程序员拿到各种 Point 数据,而且 Point 数据本身看起来还是完整的,没啥问题,所以你必须撸起袖子加油干。
我不知道你会怎么做,但我在这种场合(直到现在!)总是叹息一声,然后开始写正则表达式。或者开始用字符串来操作。 确实会很丑陋,但能起作用。 可以先用正则获取 group 拿到点的数组集合 ,然后要么使用另一个正则表达式,要么使用字符串拆分和迭代器,来获取其内容。 这个过程会很无趣,并且最后回过头一看,根本无法辨认 regex(除非你真的很懂正则表达式)。
可是等等! 还有一个办法! 比你想的还要容易!
看到例子中的字符串,我们立即能明白,这是一个点列表。 棘手的部分,是如何让计算机明白。 使用语法分析组合子(Parser Combinator),我们可以做到这一点! 语法分析组合子可以支持定义小型解析器,然后可以组合这些解析器,来解析任何东西,比如从字符串到编程语言。因为使用了像 monadic LL(infinity) 这样的短语,和某些语言中的一些看起来很复杂的语法,语法分析组合子猛一看很复杂。但它实际上非常简单,而且用起来很有趣。 如果模块尽可能小,那么,每个小模块都可以重复使用。 通过这种方式,我们可以使用一些可理解的代码块,来告诉 JavaScript 我们要做什么。
我使用 Parsimmon 库来做例子。但还有许多其他的 JS 库。其他的语言也有这方面的库。
使用 Parsimmon,我们创建了一种“语言”,其中包含很多小解析器(parser),小解析器由更小的解析器组成。 下面是一个基础例子:
// index.js
const P = require('Parsimmon')
const CrazyPointParser = P.createLanguage({
Num: () => P.regexp(/[0-9]+/).map(Number)
})
我们一看这个这个代码,我们就知道,它要解析一串数字。 这是非常基本的模块,我们用 regexp 抓取,匹配指定范围内的一次或多次字符。 这个正则表达式,比上面的巨型 Point 用到的,要小多了。 每个解析器拿到的值,我们都可以做 map 映射转换。上面这段代码,我们把字符串转化成 JavaScript Number。
可以用下面这段代码做测试:
let a = '23'
try {
console.log(CrazyPointParser.Num.tryParse(a))
} catch (err) {
console.log('Oops! ' + err)
}
运行 node index.js 应该输出 23 - 而不是 '23'。 我们已经解析了一个数字! 现在我们可以在更大的解析器中,使用这个小解析器。 下一个要看的自然是点 - [8 76]。 用空格隔开的两个数字。
const CrazyPointParser = P.createLanguage({
Num: () => P.regexp(/[0-9]+/).map(Number),
Point: (r) => P.seq(P.string('['), r.Num, P.string(' '), r.Num, P.string(']')).map(([_open, x, _space, y, _close]) => [x, y])
})
P.seq() 组合器,是用来将多个组合器按顺序链接在一起,以进行匹配。 这里,我们作为参数传递的 r 是规则 rules 的缩写,它可以引用该语言中定义的其他组合器。 然后我们只使用 P.string() 组合器来精确匹配分隔符,并用我们的 r.Num 组合器来处理识别和转换数字。 然后在 map 中,我们传递了匹配每个部分的数组作为入参。 我们忽略 P.string() 组合器返回的括号和空格,只返回 Num 组合器为我们处理的值。 将测试代码改为:
let b = '[78 3]'
try {
console.log(CrazyPointParser.Point.tryParse(b))
} catch (err) {
console.log('Oops! ' + err)
}
执行以上代码会返回 [78, 3]。 现在,这些点被进一步分组为不同大小的集合,并且(莫名其妙地)由字符串 '][' 分隔。 我们可以为该分隔符,创建一个小型解析器,然后利用 sepBy() 组合器来处理这些集合:
const CrazyPointParser = P.createLanguage({
// ...
Sep: () => P.string(']['),
PointSet: (r) => P.seq(P.string('('), r.Point.sepBy(r.Sep), P.string(')')).map(([_open, points, _close]) => points)
})
我们不需要在 Sep 解析器中包含 map 部分, 我们只想按原样返回匹配,然后将其丢弃。 在我们的 PointSet 解析器中,r.Point.seqBy(r.Sep) 使用分隔符 r.Seq分割,然后返回零个或多个点的数组集合,并删除分隔符本身。 试试看:
let c = '([2 3]][[6 2]][[1 2])'
try {
console.log(CrazyPointParser.PointSet.tryParse(c))
} catch (err) {
console.log('Oops! ' + err)
}
这里将输出 [ [ 2, 3 ], [ 6, 2 ], [ 1, 2 ] ]。 我们快要实现了! 需求中的完整的字符串只是一堆 PointSet,由相同的分隔符分隔,两端都有一些华丽的装饰字符:
const CrazyPointParser = P.createLanguage({
// ...
PointSetArray: (r) => P.seq(P.string('.:{'), r.PointSet.sepBy(r.Sep), P.string('}:.')).map(([_open, pointSets, _close]) => pointSets)
})
仅此而已! 我们的解析器现在可以成功解析整个输入字符串,只需几行。 下面是完整的代码:
const P = require('Parsimmon')
const input = '.:{([2 3]][[6 2]][[1 2])][([1 4]][[2 1])][([6 9])}:.'
const CrazyPointParser = P.createLanguage({
Num: () => P.regexp(/[0-9]+/).map(Number),
Sep: () => P.string(']['),
Point: (r) => P.seq(P.string('['), r.Num, P.string(' '), r.Num, P.string(']')).map(([_open, x, _space, y, _close]) => [x, y]),
PointSet: (r) => P.seq(P.string('('), r.Point.sepBy(r.Sep), P.string(')')).map(([_open, points, _close]) => points),
PointSetArray: (r) => P.seq(P.string('.:{'), r.PointSet.sepBy(r.Sep), P.string('}:.')).map(([_open, pointSets, _close]) => pointSets)
})
try {
console.log(CrazyPointParser.PointSetArray.tryParse(input))
} catch (err) {
console.log('Oops! ' + err)
}
输出:
$ node index.js
[ [ [ 2, 3 ], [ 6, 2 ], [ 1, 2 ] ],
[ [ 1, 4 ], [ 2, 1 ] ],
[ [ 6, 9 ] ] ]
我们甚至可以玩的更炫一些,只需将我们的 Point 组合器替换为:
Point: (r) => P.seq(P.string('['), r.Num, P.string(' '), r.Num, P.string(']')).map(([_open, x, _space, y, _close]) => {
return {
x: x,
y: y,
};
}),
现在我们可以看到:
$ node index.js
[ [ { x: 2, y: 3 }, { x: 6, y: 2 }, { x: 1, y: 2 } ],
[ { x: 1, y: 4 }, { x: 2, y: 1 } ],
[ { x: 6, y: 9 } ] ]
这个解析器很容易搞明白,也很容易完全换掉其子解析器。每个解析器都互相独立。
很多语言中,都有实现语法分析组合子的库。这里有一个 Rust 语言的例子,说明 PointSet 如何使用 combine,假设我们已经定义了 sep() 和 point() 解析器:
fn point_set<I>() -> impl Parser<Input = I, Output = Vec<Point>>
where
I: Stream<Item = char>,
I::Error: ParseError<I::Item, I::Range, I::Position>,
{
(char('('), sep_by(point(), sep()), char(')')).map(|(_, points, _)| points)
}
撇开语法不谈,它是一样的。组合任意数量的任意小解析器来解析您想要的任何格式。 对于 Rust,还有 nom 使用宏(macro)而不是特质(trait),但归根结底,它们都是一样的好用。
有最喜欢的解析器组合器库吗? 让我知道吧!