如何用javascript(typescript)写一个解析组合子库(parser combinator)

黑色的影子

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<A>(value: A, state: ParserState): ParserResult<A>{
  return {
    type: SUCCESS,
    value,
    state: state
  }
}

function mismatch<A>(state: ParserState): ParserResult<A>{
  return {
    type: MISMATCH,
    state: state,
  }
}

上面的state保存了我们parser执行的上下文信息,在我们text函数构造出的函数中,如果匹配成功,我们只是简单的返回,但在实际的场景中,我们的parser执行函数会继续地向前看,所以我们需要保存一些上下文信息,对应的类型如下:

type UserState = object

interface ParserState{
  position: number;
  expectedTokens: string[];  // 错误提示时可以用
  userState: UserState;
}

type ParserResult<A> =  
  | { 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<string> {
  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<A> {
  (input: string, state: ParserState): ParserResult<A>;
}

const initialState: ParserState = {
  position: 0,
  expectedTokens: [],
  userState: {},
};

class Parser<A> {
  public _parse: ParserFun<A>;

  constructor(parse: ParserFun<A>) {
    this._parse = parse;
  }
  
  parse(input: string, userState = {}) {
    return this._parse(input, { ...initialState, userState });
  }
}

现在我们只有text函数能构造出基本的匹配函数,且只能匹配字符,为了扩充我们的能力,我们写一个匹配正则的函数:

function regex(re: RegExp, expected?: string | string[]):   Parser<string>{
  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<A> {
  public _parse: ParserFn<A>;
  constructor(parse: ParserFn<A>){
    this._parse = parse
  }
  parse(input: string, userState = {}){
    return this._parse(input, { ...initialState, userState })
  }  
  map<B>(fn: (x: A) => B): Parser<B>{
    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:B): Parser<B>{
    return this.map(_ => b)
  }
}  

如果我们想执行多个匹配函数,并处理他们的结果呢?

export function apply<TS extends any[], R>(f: (...args: TS) => R, ...ps: ParserMap<TS>){
  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, B>(a: Parser<A>, b: Parser<B>): Parser<A>{
  return apply((firstArg, secondArg) => firstArg, a, b)
}

const token = word => first(text(word), regex(/\s*/))

让我们把上面的模式做的更通用一些:

type MaybeParser = string | RegExp | Parser<string>

function first<A, B>(a: Parser<A>, b: Parser<B>): Parser<A>{
  return apply((firstArg, secondArg) => firstArg, a, b)
}

function second<A, B>(a: Parser<A>, b: Parser<B>): Parser<B>{
  return apply((firstArg, secondArg) => secondArg, a, b)
}
function liftP(a: MaybeParser): Parser<string>{
  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<A>(...ps: Parser<A>[]): Parser<A>{
  return new Parser((input: string, state: ParserState) => {
    let pResult: ParserResult<A>;
    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<A> {
  public _parse: ParserFn<A>;
  constructor(parse: ParserFn<A>){
    this._parse = parse
  }
  parse(input: string, userState = {}){
    return this._parse(input, { ...initialState, userState })
  } 
  orElse(p: Parser<A>): Parser<A>{
    return oneOf(this, p)
  }  
}  

function pure<A>(value: A): Parser<A>{
  return new Parser((input, state) => {
    return success(value, state)
  })
}

function lazy<A>(getP: () => Parser<A>){
  let p: null | Parser<A> = null
  return new Parser((input, state) => {
    if(p == null) p = getP()
    return p._parse(input, state)
  })
}

function many<A>(p: Parser<A>): Parser<A[]>{
  const manyP: Parser<A[]> = apply((p, list) => [p, ...list], p, lazy(() => manyP).orElse(pure(EMPTYARRAY)))
  return manyP.orElse(pure(EMPTYARRAY))
}

这里的lazy函数,可以帮助你使用还未声明的变量,当然其实many函数的构造方法有多种,你也可以这样:

const EMPTYARRAY: any[] = []

class Parser<A> {
  public _parse: ParserFn<A>;
  constructor(parse: ParserFn<A>){
    this._parse = parse
  }
  parse(input: string, userState = {}){
    return this._parse(input, { ...initialState, userState })
  } 
  orElse(p: Parser<A>): Parser<A>{
    return oneOf(this, p)
  }  
  chain<B>(fn: (x: A) => Parser<B>): Parser<B>{
    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<A>(value: A): Parser<A>{
  return new Parser((input, state) => {
    return success(value, state)
  })
}

function many<A>(p: Parser<A>): Parser<A[]>{
  const manyP: Parser<A[]> = 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<JValue> = lazy(() => oneOf<JValue>(jNumber, jString, jBoolean, jNull, jObject, jArray))

最后让我们实现object类型和array类型:

class Parser<A> {
  public _parse: ParserFn<A>;
  constructor(parse: ParserFn<A>){
    this._parse = parse
  }
  parse(input: string, userState = {}){
    return this._parse(input, { ...initialState, userState })
  }  
  sepBy<B>(parser: Parser<B>): Parser<A[]>{
    const suffixes = many(second(parser, this))
    return oneOf(apply((x, xs) => [x, ...xs], this, suffixes), pure(EMPTYARRAY))
  }
  skip<B>(junk: Parser<B>): Parser<A>{
    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<A>(parser: Parser<A>, 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<A> {
  skip<B>(junk: Parser<B>): Parser<A>{
    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、我们的错误信息,显然是不够友好的。在生产环境中,当我们匹配失败时,我们应显示期待的字符串,实际的字符串,匹配到的位置。

而这两点就作为进一步实践,留给读者去完成了。

参考:

阅读 277

我的前端学习
一个前端学习者的分享
890 声望
69 粉丝
0 条评论
你知道吗?

890 声望
69 粉丝
宣传栏