正则表达式,PEG 以及 parser combinator
2022-02-28
上次年终总结中提到,cora 接下来计划的其中一个方向,考虑像 babashka 那样子做成日常的脚本来使用。 如果是当作日常脚本使用,其中很重要的一块是文本处理能力。而说到文本处理,对于正则表达式的支持首先就出现在了脑海里。所以这一篇的话题就讨论正则表达式,PEG 以及 parser combinator。
正则当然可以用一些三方库,但是作为一个完善语言的库的过程,所以我想自己撸一些东西。
正经的正则的实现可以参考 russ cox 写过一系列关于正则表达式的文章,做法是编译成 NFA 或者 DFA。 也有一些极简的取巧做法是像《代码之美》的书里面,有一章专门讲这个话题,如果是 toy 级别的实现,当然也可以这么干。但是其实老实说,我并不太喜欢正则,所以找一找正则表达式的替代品。
然后就回想起 PEG(Parsing Expression Grammars) 了。我记得之前读过 Janet 的一篇博客,是实现 PEG 的,当时觉得特别精妙!所以这个周末我又翻出来拜读了一遍。
把里面的例子 port 成 cora 代码之后,大概就是这样一个函数:
(func match-peg
['! x] text => (let pos (match-peg x text)
(if (> pos 0)
0
1))
['+ x y] text => (let pos (match-peg x text)
(if (= pos 0)
(match-peg y text)
pos))
['* x y] text => (let pos1 (match-peg x text)
(if (> pos1 0)
(let pos2 (match-peg y (string/slice text pos1))
(if (> pos2 0)
(+ pos1 pos2)
0))
0))
peg text => (if (string/has-prefix? text peg)
(string/length peg)
0))
match-peg
接受一个 PEG 的 pattern,以及输入的字符串,返回匹配到的位置。如果没匹配上,则返回 0。 pattern 有几种基本操作:或,且,非。这里分别是用的 '+
'*
'!
表示的,可以组合起来。
比如说想要匹配数字,这个 PEG 可以写成用"或"处理 0~9 的每一个:
(set ' ['+ "0" ['+ "1" ['+ "2" ['+ "3"
['+ "4" ['+ "5" ['+ "6" ['+ "7" ['+ "8" "9"]]]]]]]]])
然后日期中的年是一个四位数字,就可以用顺序的串起来 4 个 digits:
(set ' ['* ['* ['* ]]])
如果我们想要匹配 "2019-06-10" 这样的日期,完整的 PEG 语法就类似这样子:
(set ' ['+ "0" ['+ "1" ['+ "2" ['+ "3"
['+ "4" ['+ "5" ['+ "6" ['+ "7" ['+ "8" "9"]]]]]]]]])
(set ' ['* ['* ['* ]]])
(set ' ['* ])
(set ' )
(set ' ['* ['* "-" ['* ['* "-" ]]]])
然后就可以执行:
(match-peg "2019-06-10")
demo 的代码在这里。
如果是像 cora 这种 lisp 语言,弄 PEG 的库,可以利用其代码即数据的能力,去做 DSL。
我突然又联想到,其它的语言,做 PEG 会怎么处理。然后去翻了一下 Go 的。发现 Go 的版本是用了代码生成,像 peg 或者 pigeon 都是。可以理解,毕竟 Go 的 DSL 能力是很弱的。但是我还是不太喜欢像 parser generator 这种形式额外引入一套语法,使用起来有学习成本,而且 parser generator 的东西,调试起来特别不方便。
然后又联想到,PEG 其实跟另外一个东西很像 – parser combinator。
两者的区别是:PEG 实现中,第一个参数是语法规则,第二个参数是输入文本。内部会有解释器根据语法规则,去匹配输入。 而 parser combinator 中,没有解释器了,语法规则和解释器融合到了一起,变成了函数。
我尝试了一下 parser combinator 用 Go 的实现做,首先是定义一个接口:
type Parser interface {
Parse(input string) (bool, string)
}
然后可以定义一些基础的 parser,比如专门解析字符串常量的 parser:
type Literal string
func (s Literal) Parse(input string) (bool, string) {
if len(input) >= len(s) && input[:len(s)] == string(s) {
return true, input[len(s):]
}
return false, input
}
接下来是核心部分,组合子。比如说 OR 组合子接受多个 Parser,返回一个新的 Parser。效果是其中某一个 Parser 能解析输入,则处理成功。
func OR(ps ...Parser) Parser {
return orC(ps)
}
type orC []Parser
func (c orC) Parse(input string) (bool, string) {
for _, p := range c {
succ, remain := p.Parse(input)
if succ {
return succ, remain
}
}
return false, input
}
SEQ 组合子接受多个 parser,用它们依次按顺序去解析文本。第一个成功后,再用第二个解析剩下的文本。如果中间有失败了,就失败了。 关键点是,通过组合子,能够可以把一些基础的 parser 组合起来,变成处理复杂语法的 parser。还是上面的处理 iso date 的例子,可以这么弄:
func TestISODate(t *testing.T) {
var tmp [10]Byte
for i := '0'; i < '9'; i++ {
tmp[i-'0'] = Byte(i)
}
digits := OR(tmp[0], tmp[1], tmp[2], tmp[3], tmp[4], tmp[5], tmp[6], tmp[7], tmp[8], tmp[9])
year := SEQ(digits, digits, digits, digits)
month := SEQ(digits, digits)
day := SEQ(digits, digits)
xx := Byte('-')
date := SEQ(year, xx, month, xx, day)
input := "2021-10-23"
succ, remain := date.Parse(input)
require.True(t, succ)
require.Equal(t, remain, "")
}
Byte 是最基础 parser,通过 OR 组合之后变成可以处理 digits 的 parser,而四个 digits 通过 SEQ 组合之后可以处理 year 格式解析,year / month / day 都定义出来之后,用 SEQ 组合子串到一起就处理 date 格式了。
parser combinator 的组合能力,跟 PEG 的组合能力是一样强大的。都是从最基本的 or, sequence, not 等组合起来。
我试着写了一点代码来解析 Go 的 benchmark 时打印的像这样 "Benchmark FuncnameXXX 23132 ns/op 823032 bytes/op 41 allocs/op" 的文本。
type BenchResult struct {
OP Number
Byte Number
Alloc Number
}
func (r *BenchResult) Parse(input string) (bool, string) {
WS := WhiteSpace{}
Benchmark := Literal("Benchmark")
Funcname := Funcname{}
NSOP := Literal("ns/op")
BytesOP := Literal("bytes/op")
AllocsOP := Literal("allocs/op")
pattern := SEQ(Benchmark, WS, Funcname,
WS, &r.OP, WS, NSOP,
WS, &r.Byte, WS, BytesOP,
WS, &r.Alloc, WS, AllocsOP)
return pattern.Parse(input)
}
它可以把字符串解析出来,并且把结果(需要的几个字段)存到结构体里面去。
感觉可以。demo 代码在这里。其实关于保存 parser 出来的结果,是有一些技巧性的...不展开了。
结论是:不想用正则的情况下,如果语言对 DSL 的支持比较友好,用 PEG 挺好的。 否则,用 parser combinator 可能会更方便。