parser可以认为是将一段文本转换为ast的工具。
作为前端开发人员,我们的生态圈早已充斥着各种parser工具,通过本篇文章,希望你能学习到parser combinator的实现原理,更加了解函数式编程。在有必要的时候,轻松地通过parser combinator实现自己的parser。本篇文章在实现parser combinator库后,在末尾会通过使用这个库来实现一个json parser。
在开始之前,我们先说一下parser combinator相比parser generator的优势:
- 不用学习一门新的语言,如知名的parser generator: ANTLR,pegjs,nearley等,开发者首先要会BNF,然后还要学习各自的语法。而使用parser combinator,开发者可以使用他们最熟悉的主语言
- 因为parser generator是通过一门语言生成代码(即生成parser),所以不易debugger和类型检查。
- 更强大的表达能力,parser combinator拥有宿主语言的所有表达能力。
我们先写一个简单匹配的parser,直观感受下。需要说明的是,编译原理中的语法分析通常分为LL算法和LR算法,解析组合子本质上使用的就是LL算法,即通过向前看字符,自顶而下的分析,生成语法树。
function text(expected) {
return function textParser(input){
if (input.startsWith(expected)) {
return expected;
} else {
return console.error(`expect ${expected}`);
}
}
}
text这个函数可以构造出匹配字符串的函数,如果匹配成功就返回匹配到的字符,如果失败就打印出失败信息。
这是一个非常简单的parser构造函数,如果我们希望能匹配html,css,javascript中的任意字符应该怎么处理呢?
function oneOf(...ps){
return function (input, state){
let pResult = null
for(let p of ps){
pResult = p(input)
if(pResult) return pResult
continue
}
}
}
const matchLang = oneOf(text("html"), text("css"), text("javascript"))
const matched = matchLang("css") // css
这里我们通过oneOf函数和text函数即可构造出更强大的匹配函数。
我们整个解析组合子库的工作方式就是如上所示,通过一系列构造parser的函数组合,产生更强大的parser。
上面的只是开胃菜,希望大家有一个简单的了解,下面开始我们的构造之旅。
下面的代码为了更加健壮和更好的可读性,都是使用typescript书写,但类型不是很复杂,对typescript不熟悉的同学不用担心
如果你将上面的示例复制粘贴,并运行,你会发现打印出“expect html”这个字符串,这是因为我们的textParser中,如果匹配失败,就很简单地打印出了错误的信息。所以首先我们来定义parser执行成功和失败的函数:
const SUCCESS = Symbol('success')
const MISMATCH = Symbol('mismatch')
function success(value: A, state: ParserState): ParserResult{
return {
type: SUCCESS,
value,
state: state
}
}
function mismatch(state: ParserState): ParserResult{
return {
type: MISMATCH,
state: state,
}
}
上面的state保存了我们parser执行的上下文信息,在我们text函数构造出的函数中,如果匹配成功,我们只是简单的返回,但在实际的场景中,我们的parser执行函数会继续地向前看,所以我们需要保存一些上下文信息,对应的类型如下:
type UserState = object
interface ParserState{
position: number;
expectedTokens: string[]; // 错误提示时可以用
userState: UserState;
}
type ParserResult =
| { type: typeof SUCCESS, state: ParserState, value: A }
| { type: typeof MISMATCH, state: ParserState }
向前看我们需要移动向前看第一个字符的指针,对应的函数如下:
function advance(state: ParserState, length: number): ParserState{
return length === 0
? state
: {
...state,
position: state.position + length,
expectedTokens: []
}
}
同时,当匹配错误的时候,我们希望能展示期待匹配的字符:
function expect(state: ParserState, expected: string | string[]){
return {
...state,
expectedTokens: state.expectedTokens.concat(expected)
}
}
现在来改造我们的text函数:
function text(expected) {
return function textParser(input, state){
if (input.startsWith(expected)) {
return success(expected, advance(state, expected.length));
} else {
return mismatch(expect(state, expected))
}
}
}
为了使我们的api使用上更加地友好方便,我们再次改造text函数,让text函数返回一个parser对象:
function text(expected: string): Parser {
return new Parser(function textParser(input, state) {
if (input.startsWith(expected, state.position)) {
return success(expected, advance(state, expected.length));
} else {
return mismatch(expect(state, expected, false));
}
});
}
Parser是一个类,它的构造函数接受一个函数,在我们的text函数中就是实际执行匹配的函数,然后Parser有一个parse函数,这是开始parse时执行的函数。
interface ParserFun {
(input: string, state: ParserState): ParserResult;
}
const initialState: ParserState = {
position: 0,
expectedTokens: [],
userState: {},
};
class Parser {
public _parse: ParserFun;
constructor(parse: ParserFun) {
this._parse = parse;
}
parse(input: string, userState = {}) {
return this._parse(input, { ...initialState, userState });
}
}
现在我们只有text函数能构造出基本的匹配函数,且只能匹配字符,为了扩充我们的能力,我们写一个匹配正则的函数:
function regex(re: RegExp, expected?: string | string[]): Parser{
const anchoredRegex = new RegExp(`^${re.source}`)
return new Parser(function(input: string, state: ParserState){
const m = anchoredRegex.exec(input.slice(state.position))
if(m == null){
return mismatch(expect(state, re.source))
}
const matchedText = m[0]
return success(matchedText, advance(state, matchedText.length))
})
}
在实际匹配的过程中,我们往往需要将匹配到的字符串改为另外一个结构,我们添加map和mapTo函数:
export class Parser {
public _parse: ParserFn;
constructor(parse: ParserFn){
this._parse = parse
}
parse(input: string, userState = {}){
return this._parse(input, { ...initialState, userState })
}
map(fn: (x: A) => B): Parser{
return new Parser((input: string, state: ParserState) => {
const pResult = this._parse(input, state)
if(pResult.type !== SUCCESS) return pResult
return {
...pResult,
value: fn(pResult.value)
}
})
}
mapTo(b:B): Parser{
return this.map(_ => b)
}
}
如果我们想执行多个匹配函数,并处理他们的结果呢?
export function apply(f: (...args: TS) => R, ...ps: ParserMap){
return new Parser(function(input: string, state: ParserState) {
let results: TS = [] as any
for(let p of ps){
let pResult = p._parse(input, state)
if(pResult.type !== SUCCESS){
return pResult
}
results.push(pResult.value)
state = pResult.state
}
return success(f(...results), state)
})
}
看上面的代码可能有些抽象,我们来举一个实际的apply例子,假如我们想匹配一个字符串,但想忽略它后面的空白字符:
function first(a: Parser, b: Parser): Parser{
return apply((firstArg, secondArg) => firstArg, a, b)
}
const token = word => first(text(word), regex(/\s*/))
让我们把上面的模式做的更通用一些:
type MaybeParser = string | RegExp | Parser
function first(a: Parser, b: Parser): Parser{
return apply((firstArg, secondArg) => firstArg, a, b)
}
function second(a: Parser, b: Parser): Parser{
return apply((firstArg, secondArg) => secondArg, a, b)
}
function liftP(a: MaybeParser): Parser{
if(typeof a === "string") return text(a)
if(a instanceof RegExp) return regex(a)
return a
}
function lexeme(junk: MaybeParser) {
const junkP = liftP(junk)
return (p: MaybeParser) => first(liftP(p), junkP)
}
const token = lexeme(/\s*/)
假如我们想匹配布尔值,我们就可以这样用:
const jTrue = token("true").mapTo(true)
const jFalse = token("false").mapTo(false)
const jBoolean = oneOf(jTrue, jFalse)
我们把本篇开头提到的oneOf函数改造:
function oneOf(...ps: Parser[]): Parser{
return new Parser((input: string, state: ParserState) => {
let pResult: ParserResult;
let expected = state.expectedTokens
for(let p of ps){
pResult = p._parse(input, state)
if(pResult.type !== SUCCESS) continue
return pResult
}
return mismatch(expect(state, expected))
})
}
在正则表达式中,*表示匹配0次或多次,在词法分析和语法分析中,这种模式也是很常见的,让我们来实现这种模式。构造这种parser的函数,我们命名为many,这是我们本篇文章中相对比较难的一个函数。
要实现many,我们先分析下many有哪些情况:
- 一个都没匹配
- 匹配一个自己
- 匹配多个自己
通过前面first函数的构造经验,我们很容易想到使用apply函数,进行多个parser的串联匹配,并在其中做递归,但一个关键的点是,某一步匹配失败后,应该返回空数组,我们有下面的代码:
const EMPTYARRAY: any[] = []
class Parser {
public _parse: ParserFn;
constructor(parse: ParserFn){
this._parse = parse
}
parse(input: string, userState = {}){
return this._parse(input, { ...initialState, userState })
}
orElse(p: Parser): Parser{
return oneOf(this, p)
}
}
function pure(value: A): Parser{
return new Parser((input, state) => {
return success(value, state)
})
}
function lazy(getP: () => Parser){
let p: null | Parser = null
return new Parser((input, state) => {
if(p == null) p = getP()
return p._parse(input, state)
})
}
function many(p: Parser): Parser{
const manyP: Parser = apply((p, list) => [p, ...list], p, lazy(() => manyP).orElse(pure(EMPTYARRAY)))
return manyP.orElse(pure(EMPTYARRAY))
}
这里的lazy函数,可以帮助你使用还未声明的变量,当然其实many函数的构造方法有多种,你也可以这样:
const EMPTYARRAY: any[] = []
class Parser {
public _parse: ParserFn;
constructor(parse: ParserFn){
this._parse = parse
}
parse(input: string, userState = {}){
return this._parse(input, { ...initialState, userState })
}
orElse(p: Parser): Parser{
return oneOf(this, p)
}
chain(fn: (x: A) => Parser): Parser{
return new Parser((input: string, state: ParserState) => {
const pResult = this._parse(input, state)
if(pResult.type !== SUCCESS) return pResult
const p2 = fn(pResult.value)
return p2._parse(input, pResult.state)
})
}
}
function pure(value: A): Parser{
return new Parser((input, state) => {
return success(value, state)
})
}
function many(p: Parser): Parser{
const manyP: Parser = p.chain(x => oneOf(manyP, pure([])).map(xs => {
return [x].concat(xs)
})).orElse(pure(EMPTYARRAY))
return manyP
}
这里的代码主要是为了展示chain这个函数,chain这个函数很有用,它接受一个函数,函数接收当前parser执行之后的结果,产生第二个parser,然后执行第二个parser的parse,通过chain,我们可以动态地决定下一步的匹配方式。
其实通过上面的学习,可以发现我们的组合子库的框架已经搭建完成,如果想扩充它的能力,我们只需要通过扩充基础的函数,或者将函数间进行组合,就能产生更强大的parser。
现在让我们以json parser为例,进行一次实战吧。
首先我们来实现json的基本类型,根据标准https://www.json.org/json-en....,有如下代码:
function unquote(s: string) {
return s.substring(1, s.length - 1);
}
// 此处为了方便阅读学习,并没有完全按照标准去写匹配正则
const jNumber = token(/-?\d+(\.\d+)?/).map(x => +x)
const jString = token(/"(?:[^"|\"|\b|\f|\r|\n|\t])*"/).map(unquote)
const jTrue = token("true").mapTo(true)
const jFalse = token("false").mapTo(false)
const jBoolean = oneOf(jTrue, jFalse)
const jNull = token("null").mapTo(null)
然后我们来定义一个普通的json数据结构:
// 与上面的many函数一样,为了使用还未声明的变量,我们使用lazy函数
const jValue: Parser = lazy(() => oneOf(jNumber, jString, jBoolean, jNull, jObject, jArray))
最后让我们实现object类型和array类型:
class Parser {
public _parse: ParserFn;
constructor(parse: ParserFn){
this._parse = parse
}
parse(input: string, userState = {}){
return this._parse(input, { ...initialState, userState })
}
sepBy(parser: Parser): Parser{
const suffixes = many(second(parser, this))
return oneOf(apply((x, xs) => [x, ...xs], this, suffixes), pure(EMPTYARRAY))
}
skip(junk: Parser): Parser{
return first(this, junk)
}
... // 其他函数
}
const pair = apply((key, value) => [key, value], first(jString, token(":")) , jValue)
const comma = token(",")
const pairs = pair.sepBy(comma).map(pairs2Object)
const jObject = apply((leftBracket, obj, rightBracket) => obj, token('{'), pairs, token('}'))
const jArray = token("[").chain(() => jValue.sepBy(comma)).skip(token("]"))
json parser已经组合成功了,我们怎么使用它们呢?我们定义一个函数接收parser,执行这个parser的parse,如果成功就返回结果,如果失败就打印出错误信息:
export function parse(parser: Parser, input: string){
const res = parser.skip(eof).parse(input)
if(res.type === SUCCESS) return res.value
if(res.type === MISMATCH) {
return console.error(`position ${res.state.position}, expect ${res.state.expectedTokens}`)
}
}
其中skip(eof)是为了保证我们的parser匹配到了最后一个字符,如果匹配到了最后一个字符,还没匹配完成,就返回mismatch
class Parser {
skip(junk: Parser): Parser{
return first(this, junk)
}
// ...其他函数
}
const eof = new Parser(function eofParser(input: string, state: ParserState){
if(input.length > state.position){
return mismatch(expect(state, "EOF"))
}
return success(null, state)
})
从上面的代码可以看出,当我们有了一个解析组合子库后,实现一个json parser是很容易的。而且json parser中使用的很多函数,在匹配其他语言的时候,我们可以直接复用。
为了便于学习和消化,我们总结下上面出现的函数类型,上面大部分函数都是parser的构造器,对于这种函数,我们称为Constructor(与我们的构造函数不同)。而对于将一个Constrctor转换为另外一种Constructor的函数,我们称为Combinator。
我们最基本的匹配Constructor就是text函数和regex函数,在这两个函数的基础上通过各种combinator(oneOf,map,apply)等,使我们的parser更加的强大,同时这些单个函数也易于测试。
现在我们的解析组合子库,已经可以支持json parser,但它还有一些问题:
1、它的性能不好,如果读过我的另一篇文章回溯法和记忆法,你会发现我们现在的解析组合子库使用的就是回溯法,很容易做一些重复的计算。
2、我们的错误信息,显然是不够友好的。在生产环境中,当我们匹配失败时,我们应显示期待的字符串,实际的字符串,匹配到的位置。
而这两点就作为进一步实践,留给读者去完成了。
参考: