基于代数式设计构建 JSON 语法解析器

965 阅读25分钟

本章是 《FP in Scala》第二部分的最后一章。我们曾基于函数式设计思想设计过基于性质的测试框架,以及并行编程库。见: 用 Scala 编写 Property-based Testing API - 掘金 (juejin.cn)Scala 纯函数式库设计:并行编程 - 掘金 (juejin.cn),本章会从头开始设计语法解析器,它是一种接收非结构化数据 ( 通常来说就是文本 ) ,并输出结构化数据 ( 比如 ADT 语法树 ) 的特殊程序。

我们会从分析 "a""b""c""d" 这类简单解析器开始逐步组合出可以 解析 JSON 格式 的复杂解析器;我们会使用类似 "指令链接" 的风格组合各种函数,且几乎不会携带任何命令式逻辑。

最重要的是审视我们的函数式设计,本章会在这里引入代数式设计的概念。当我们习惯了这种提炼通用模式的思维模式之后,下一站便是探索函数式设计的纯粹形式 —— Monad 。

代数式设计

在之前,我们首先会从设计目标中抽象出类型,然后再基于这些数据类型总结法则。而在本章中,我们先思考法则,再反过来决定这个类型的具体形式。

从一个简单的例子出发有利于我们忽略细节而关注于问题本身。比如,下面是一个仅识别某字符的解析器:

def char(c : Char) : Parser[Char] = ???

我们引入了 Parser表示解析的结果,它相比单纯的 Boolean 类型包含了更多的信息:

  1. 解析成功时,返回一个 Char 类型的解析结果;
  2. 反之,返回关于解析失败的提示信息。关于错误提示的部分,我们在后文会花大量的精力去设计。

解析器需要被调用。这里再为其创建一个 run 函数:

def run[A](parser: Parser[A])(input : String) : Either[ParserError,A]

我们的输出可以是 ADT 树,或者是 Map 类型,再或者其它的什么类型,不妨统一泛化为 A 类型。解析器要么失败,从而返回 ParserError,要么就返回正确提取出的结果。

你应该已经注意到了,目前没有任何关于 ParserParserError 类型的定义,它们作为参数类型,与我们平时标记的 EVT 没什么不同。它们的形式被现在被架空了。

看,在代数设计中,函数操作的数据类型已经没有那么重要了。一旦它们支持必要的法则和函数,甚至无需暴露其底层的数据格式。有这么一个观点认为,类型的含义是由它和其它类型的 关系 决定的,而非内在形式 ( internal representation )。这个视角常常和范畴论 ( category theory ) 结合。可以搜索:写给程序员的范畴论。见:Category Theory for Programmers: The Preface | Bartosz Milewski's Programming Cafe

char 函数一定满足下面的性质:

run(char(c))(c.toString) == Right(c)

进一步,如果能有一个更实用的,可解析字符串模式的解析器就好了,比如它可以验证 "aabb" 中存在子模式 "aa" 。我们再添加一个 string 函数:

def string(s : String) : Parser[String] = ???

同样,对于任何一个字符串 s,它也一定会满足:

run(string(s))(s) == Right(s)

我们期望解析器有一个 or 方法,比如 string("a").or(string("b")) 表示它是一个可以识别 "a""b" 的解析器。

def or(other : Parser[String]) : Parser[String] = ???

读到这里稍作整顿,然后基于 Scala 3 实现我们的第一个草图。在下文中,or 操作符被更加简洁的| 标识符替代了。我们目前尚未明确 Parser[A] 的具体形式 , 我们会在稍晚的时机确定它的真正形式,并且可能会让你感到一点惊讶。

现在的问题是如何去给解析器定义 | 方法呢?毕竟它现在只是一个虚构出来的类型。这里可以使用 Scala 2 的隐式类或者 Scala 3 extension 语法完成。

trait Parsers[Parser[+_]]:

  def run[A](parser: Parser[A])(input: String): Either[ParseError, A] = ???
  implicit def string(str: String): Parser[String] = ???

  extension [A](p: Parser[A])   
    def |[B >: A](p2: => Parser[B]): Parser[B] = ???

现在可以直接用 "aa" | "bb" 来表达解析字符串 "aa" 或者 "bb" 的解析器了。显而易见的是:只有解析器 "aa" 运行失败时,解析器 "bb" 才有执行的必要。因此这里的 p2 是一个传名调用。

构建代数

下面将不断抛出一些简单的问题来引入代数设计,比如从 "ababab" 这段文本中识别 "ab" 是否重复了三次,并将解析结果收集到列表内,如 List("ab","ab","ab")。第一个想法是构建一个 listOfN 函数:

def listOfN[A](n : Int, p : Parser[A]) : Parser[List[A]] = ???

listOfN 可以做进一步拆解。考虑更加简单的组合子 many,它仅负责识别重复的模式并提取结果,listOfN 到时候仅需要基于 many 检查重复次数就可以了:

extension [A](p: Parser[A])
  // ...
  def many: Parser[List[A]] = ???

引入 map 转换子

如果能再预留一个 A => B 的函数 f,用户就可以自行从收集的 List[A] 中提取想要的信息了。从这个角度出发,我们引入了更底层的 map 组合子:

extension [A](p: Parser[A])
  // ...
  def map[B](f: A => B): Parser[B] = p.flatMap(a => succeed(f(a)))

比如,某个解析器需要检测字符 "A" ( 或是其它什么内容 ) 重复次数,它可以表达成:

def numA = string("A").many.map(_.size)

另一个衍生的解析器是 succeed,它本质上是一个A => Parser[A] 的提升 ( lift ) 方法。

def succeed[A](a: A): Parser[A]

它的行为将满足类似:

run(succeed("always succeed"))("any") == Right("always succeed")

串行化解析与上下文解析

目前来看,解析器的组合方式只有 | 一种,显然是不够的。我们需要还一种能串行组合解析器的组合子 **

extension [A](p: Parser[A])
  // ...
  def **[B](p2 : Parser[B]) : Parser[(A,B)] = ???

比如,它应当满足下面的推导:

run(string("aa") ** string("ba"))("aabaccd") == Right("aaba")

事实上,** 组合子是由更底层的 map2 组合子抽象出来的。形式上它是这个样子:

extension [A](p: Parser[A])
  // ...
  def map2[B, C](p2: => Parser[B])(f: (A, B) => C): Parser[C] = ???

如果要实现解析 JSON 这样上下文无关的文档,那么到目前为止的 ** 组合子完全够用了。但是,如果要实现上下文有关文法,那么 ** 组合子还不能满足需求。

笔者在一年前的笔记中提到过上下文有关和无关文法的简要内容:Scala:解析器组合子与 DSL - 掘金 (juejin.cn) 。举个简单的例子:现在有两个谓词 "下雨" ,"吃饭" 和两个主语 "人","天"。显然 "吃饭" 必须和 "人" 组合在一起才能表达正确的语义。

也就是说,给定两个解析器 p1p2p2 解析器的正确性还要依赖于前者给定的 上下文。具体要怎么做呢?假定第一个解析器是 Parser[A] 类型,如果再引入一个 f : A => Parser[B] 函数,我们应当 等待上一个解析器运行成功之后,再应用 f 函数将剩下的部分送入下一个 Parser[B] 继续执行 —— 这个组合子就是 flatMap

extension [A](p: Parser[A])
  // ...
  def flatMap[B](f : A => Parser[B]) : Parser[B] = ???

flatMap 组合子的表达能力十分强大,可以结合提升函数 success 实现 mapmap2 组合子。除此之外,上下文无关可以看作是上下文有关的一种特殊情况,因此 flatMap 也可以去表达 ** 串化组合子。

extension [A](p: Parser[A])
  // ...
  def **[B](p2: => Parser[B]) : Parser[(A,B)] = p.flatMap(a => p2.map(b => (a,b)))

  // Scala 可以对任何实现了 `flatMap` 和 `map` 组合子的类型应用 for 推导式。
  // def map2[B,C](p2 : => Parser[B])(f : (A,B)=> C) : Parser[C] = for{a <- p; b <- p2} yield f(a,b)
  def def map2[B,C](p2 : => Parser[B])(f : (A,B)=> C) : Parser[C] = p.flatMap(a => p2.map(b => f(a,b)))

  // def map[B](f: A => B): Parser[B] = p.flatMap(f andThen succeed))
  def map[B](f: A => B): Parser[B] = p.flatMap(a => succeed(f(a)))

基于最小原语集的拓展

到目前位置,我们手头上有以下几个原语级别的解析器及其组合子:

  1. string(s):识别字符串字面量的解析器。
  2. regex(r):识别正则表达式的解析器。
  3. many:识别重复模式的解析器。
  4. succeed(a):总是返回一个值 a 的解析器。
  5. p flatMap f:通过 f 串化两个解析器,可以处理上下文有关文法,也可以处理上下文无关文法,我们在本章只讨论后者。
  6. p1 | p2:在两个执行器中选择其一执行。

另一方面,我们理论上可以用 '0' | '1' | ... | '9' 去识别一个数字,但这样表达起来效率不是很高。一个不错的想法是将 正则表达式 引入进来:

implicit def regex(r : Regex) : Parser[String] = ???

这样一来,我们就可以基于 regex 去表达诸如 whitespacedoublequoted 等更丰富的语义了,它们在后文用于解析 JSON 数值。

// 用于识别空格,回车,制表符等空白内容,使用它可以兼容一些写法格式。
def whitespace : Parser[String] = regex("\\s*".r)

// 将数值类型统一处理成 Double
def double: Parser[Double] = regex("[-+]?([0-9]*\\.)?[0-9]+([eE][-+]?[0-9]+)?".r).token.map(_.toDouble)

// 将 "aaa" 这种携带引号的字面量解析成字符串 aaa
def quoted: Parser[String] = string("\"") ~> regex((".*?" + Pattern.quote("\"")).r).token.map(_.dropRight(1))

doublequoted 解析器又是通过一些高级组合子构造出来的。见:

case class ParserOps[A](p: Parser[A]):
  // .. 忽略原语级别的组合子
  def ~>[B](p2: => Parser[B]): Parser[B] = p.flatMap(_ => p2.map(b => b))
  def <~[B](p2: => Parser[B]): Parser[A] = p.map2(p2)((a, _) => a)
  def eof: Parser[A] = p <~ regex("\\z".r)
  def token : Parser[A] = p <~ whitespace
  def as[B](b : B): Parser[B] = p.map(_ => b)
  def sep(separator: Parser[Any]): Parser[List[A]] =
    p.sep1(separator) | succeed(Nil)
  def sep1(separator: Parser[Any]): Parser[List[A]] =
    p.map2((separator ~> p).many)(_ :: _)

~><~ 是一对实用的高级组合子。以 string("[") ~> p1 为例,它表示解析的内容必须以 "[" 开头,并在解析之后提取其右半部分,<~ 同理;eof 组合子严格标记了解析的结束位置。如果文档后面仍有多余的内容,则判定解析失败。见下面的例子:

val nonStrictP = "[" ~> "hello,world" <~ "]"
val strictP = "[" ~> "hello,world" <~ "]".eof

val r1 = run(nonStrictP)("[hello,world]aaaa!") // success
val r2 = run(strictP)("[hello,world]aaaa!") // fail

as 用于将解析成功的结果投射成另一种输出结果,只不过它会丢弃成功原先解析后的结果。比如:

val p = "a".as("succeed")

// 如果解析成功了,忽略解析结果 "a",变换为 succeed 输出。
println(run(p)("a"))

token 则用于忽略多余的空格,回车,制表符等空白内容,仅提取重要的部分。它可以兼容用户的不规范输入。如:

val friendlyP = token(":") ** double

println(run(friendlyP)(": 1")) // ok
println(run(friendlyP)(":1"))  // ok

sep 和表示将一段文本按照某个分隔符进行切割,并将切割后的元素放到 List[A] 列表内,而 sep1 表示至少切割一次。从类型上看,它们可基于 many 推导出来。我们可以用 sep 组合子表述更加复杂的逻辑。比如:

// 可解析 "aa,bb,cc" 或 "bb,aa,cc" 等。
val p = ("aa" | "bb" | "cc").sep(",")

错误处理

直到现在,我们都完全没有讨论过解析失败时应当返回什么样的结果。但经验表明,我们往往会在程序调试上耗费更多的功夫。如果我们只定义了成功后的输出结果,然后便决定开始具体实现,那么必定会在错误提示和处理上做出草率的决定

错误提示

当解析出现错误时,程序最好明确地指出是哪个解析器发生了失败。下面的 label 方法用于给解析器作标记:

def label(msg : String)(p : Parser[A]) : Parser[A]

p 解析失败时,它会将 label保存的 msg 传递给上层的 ParserError,见下面的例子。

val p = string("a").label("checking 1 'a'") ** string("b").label("checking 1 'b'")

println(run(p)("cb"))  // 应当提示 checking 1 'a'
println(run(p)("ac"))  // 应当提示 checking 1 'b'

错误提示还应该包含错误发生的位置:行号 line,列号 col。为了保留这类解析结果,我们单独创建一个 Location 类型包装输入的字符串文本。offset 是由解析器处理后记录的偏移量,Location 可以自动它定位到最后一次解析成功的位置。

case class Location(input : String, offset : Int = 0):
  lazy val line: Int = input.slice(0, offset + 1).count { _ == '\n'} + 1
  // 定位到错误发生的最后一行,计算列偏移量。
  lazy val col: Int = input.slice(0, offset + 1).lastIndexOf('\n') match 
    case -1 => offset + 1
    case n => offset - n
  // TODO 

再做一点点完善。当解析出错时,除了 label 提示的错误之外,如果还能够提供一些上下文信息就完美了。比如:

val p = (string("a").label("checking 1 'a'") ** string("b").label("checking 1 'b'"))
  .scope("parsing 'ab'")

println(run(p)("cb"))  // 提示 when parsing 'ab'; checking 1 'a'
println(run(p)("ac"))  // 提示 when parsing 'ab'; checking 1 'b'

scope 的签名如下:

def scope[A](msg : String)(p : Parser[A]) : Parser[A] = ???

让我们解释地再具体一点。假定 ParseError 包装了一个描述了错误堆栈的 List[(Location, String)] 类型:

// Location 记录解析错误的位置,String 表示
case class ParseError(stack : List[(Location,String)]):
  // TODO 

如果某个具备标签 label 的解析器出现错误时,程序会创建一个新的 ParseError,并初始化一个栈收集 label 携带的信息。

/*stack:
1. checking 1 'a'
*/
val p = string("a").label("checking 1 'a'")

scope 不会创建新的 ParseError,它只会向原有的错误栈中补充信息。比如:

/*stack:
1. when parsing word;
2. checking 1 'a'
*/
val p = string("a").label("checking 1 'a'").scope("parsing word")

由于 ParseError 从抽象的代数设计中被具象了出来,因此可以将它从 Parsers 的类型参数中去掉了。

trait Parsers[Parser[+_]]:
 // YOUR CODE HERE

有效解析与回溯

还有一些性能上的话题可以挖掘,这影响到了我们后文的具体实现。比如下面的例子:

val p = "ab" | "bc" | "cd" | "de" | "ef" | ... | "yz"
println(run(p)("ad"))

这个解析器会像预想那样运行失败。且我们非常清楚:由于 | 连接的解析器之间 ( 我们在此处称之为 分支 (branch)) 不存在重复的前缀,因此 p 最多选择一条匹配前缀的分支并尝试后续的解析。如果这条唯一的分支失败了,明智的做法是尽快短路退出,避免在其它必定失败的分支上浪费时间。这一点是本小节围绕 | 做各种 ( 复杂 ) 设计的动机。

为此我们提出了两种状态:

  1. 提交 ( commit ):或称有效解析,指解析器至少成功解析了一个字符。
  2. 未提交 ( uncommit ):或称无效解析,指解析器一个字符都没有解析成功。

我们希望:程序一旦选择出一个可提交的分支,就会沿着这条分支一直解析,之前未提交的分支以及后续的分支都会被忽略。当然,这样的设计有一个缺陷,那就是其它潜在可行的分支会被意外地短路掉。比如:

val p = "ab" | "ac"
println(run(p)("ac"))

按照我们刚才的想法,这个运行结果将是 失败 的。原因是:两者的前缀均为 "a",但程序首先匹配到了 "ab" 分支,导致第二条分支 "ac" 被短路。为了避免出现违反直觉的结果,同时又尽可能少的执行分支检查,本文补充了一个帮助函数 attempt 作折衷,形如:

val p = attempt("ab") | "ac"

它的作用是:如果程序准备提交该分支并最终解析错误,则把该分支重置为未提交的状态,以尝试下一条可行的分支,我们可以把这个过程称之为回溯attempt 是一个 Parser[A] => Parser[A] 类型的装饰器:

def attempt[A](p : Parser[A]) : Parser[A] = ???

你也许会觉得:为了这一点短路的特性反而需要引入大量复杂的机制。笔者不会反对这种观点,这是设计上的取舍问题。

代数实现

为了避免在后面的实现过程中陷入混乱,不如先审视已经设计好的顶层 Parser 接口。我们在前文构想了各种解析器以及它们的组合子:

trait Parsers[Parser[+_]]:
  self =>
  def run[A](parser: Parser[A])(input: String): Either[ParseError, A] = ???

  implicit def string(str: String): Parser[String] = ???
  def regex(r: Regex): Parser[String] = ???
  // 由 regex 拓展的解析器
  def whitespace: Parser[String] = regex("\\s*".r)
  def double: Parser[Double] = regex("[-+]?([0-9]*\\.)?[0-9]+([eE][-+]?[0-9]+)?".r).token.map(_.toDouble)
  def quoted: Parser[String] = string("\"") ~> regex((".*?" + Pattern.quote("\"")).r).token.map(_.dropRight(1))
 
  def succeed[A](a: A): Parser[A] = ???
  def attempt[A](p : Parser[A]) : Parser[A] = ???
  extension [A](p: Parser[A])
    // 原语级组合子
    def |[B >: A](p2: => Parser[B]): Parser[B] = ???
    def flatMap[B](f: A => Parser[B]): Parser[B] = ???

    // 用于携带信息及上下文的组合子
    def scope(msg: String): Parser[A] = ???
    def label(msg: String): Parser[A] = ???
    
    // 基础组合子
    def map[B](f: A => B): Parser[B] = p.flatMap(a => succeed(f(a)))
    def many: Parser[List[A]] = p.map2(p.many)(_ :: _) | succeed(Nil)
    def product[B](p2 : Parser[B]) : Parser[(A,B)] = p ** p2
    def **[B](p2: Parser[B]): Parser[(A, B)] = p.flatMap(a => p2.map(b => (a, b)))
    def map2[B, C](p2: => Parser[B])(f: (A, B) => C): Parser[C] = p.flatMap(a => p2.map(b => f(a, b)))
    def many1: Parser[List[A]] = p.map2(p.many)(_ :: _)
    
    // 高级组合子
    def ~>[B](p2: => Parser[B]): Parser[B] = p.flatMap(_ => p2.map(b => b))
    def <~[B](p2: => Parser[B]): Parser[A] = p.map2(p2)((a, _) => a)
    def token : Parser[A] = p <~ whitespace
    def as[B](b : B): Parser[B] = p.map(_ => b)
    def eof: Parser[A] = p <~ regex("\\z".r)
    def sep(separator: Parser[Any]): Parser[List[A]] = p.sep1(separator) | succeed(Nil)
    def sep1(separator: Parser[Any]): Parser[List[A]] = p.map2((separator ~> p).many)(_ :: _)

从这份清单中清晰地看到,真正需要关注的只有那几个原语级别的内容。多亏了代数式设计,我们从一开始就避免了各种实现细节,从而聚焦到项目真正的重点。

那么,下面只需要再确定 Parser 的最终形式了。与其不假思索地创建一个 Parser[A] 的类型,不如先从代数中推理一些必要的信息再确定它的形式。首先,解析器依赖 run 函数运行:

def run[A](p : Parser[A])(input : String) : Either[ParserError,A]

run 函数表示解析器消费会一个 String,并产出一个 Either。既然如此,不妨将 Parser[A] 定义成 String => Either[ParserError,A] 的函数:

type Parser[A] = String => Either[ParseError,A]

在此之后,解析器的运行便不再需要依赖 run 帮助函数了,你可以把它当作是 Python 中的可调用对象 ( callable object ),只需要输入解析内容就可以驱动它得到解析结果。另一个重要的地方:解析器是无状态 ( stateless ) 的,意味着我们不需要用闭包或者定义类结构维护状态。

我们组合解析器,本质上是 组合解析行为

携带更多信息的输入与输出

先从 string 切入吧。我们给出第一版实现:

override implicit def string(str: String): Parser[String] = input => 
  if input startsWith str then Right(str)
  else Left(Location(input).toError(s"Excepted : $str but get $input"))

string 的实际代码要更复杂一些,但核心思路却不会有太大出入:如果能从输入中找到匹配的前缀,则表示解析成功。一旦失败,则将包含行,列号的位置信息Location 记录到 ParseError 返回。直接构建比较麻烦,因此我们在 Location 类额外引入了帮助函数 toError

case class Location(var input: String, offset: Int = 0):
  // loc, line ... 
  def toError(msg : String) : ParseError = ParseError(List((this,msg))) 

"abra" ** "cadabra" 这样的解析器串接要比预想中的复杂。假定输入是 "abraabada",那么在 "abra" 被成功解析之后,程序必须要把已经 消费 ( comsumed ) 过的子串裁掉,然后把剩下的部分送给 "cadabra" 去执行。

这样看来,我们需要使用 Location 替代原始的 String 类型作为输入输出。考虑到 Location 的创建细节对于用户来说是噪音,因此我们在此设置一个隐式转换屏蔽掉它:

given Conversion[String,Location] = s => Location(s)
type Parser[+A] = Location => Result[A]

enum Result[+A]:
  case Success(get : A, length : Int) extends Result[A]
  case Failure(get : ParseError,isCommitted: Boolean) extends Result[Nothing]
  // TODO

这里我们构建了信息量更大的 Result 代数类型来取代 Either

  1. 当分析成功时,返回 A 类型的值以及成功处理的长度。
  2. 当分析失败时,返回记录了错误信息的 ParseError,包括它是否为有效解析。

一旦着眼于具体实现,就会有越来越多的工具函数或者是工具类被添加进来。代数设计使我们在顶层设计时避免陷入各种细枝末节。

基础解析器

基于目前的 Parser[A] 外形,我们实现 stringregex,这两个核心解析器。判断解析成功的标志是:是否能够输入中截取到满足规则的字符串前缀。

object ParserImplement extends Parsers[ParserImplement.Parser] :
  // .. 省略 Result[A] 的定义
  override def attempt[A](p: Parser[A]): Parser[A] = l => p(l).uncommit
  override def succeed[A](a: A): Parser[A] = _ => Success(a, 0)
	
  given Conversion[String, Location] = s => Location(s)
  override implicit def string(str: String): Parser[String] = l =>
    // 寻找并截取最大匹配前缀
    def commonPrefix(s1: String, s2: String): String =
      val (short, long) = if s1.length <= s2.length then (s1, s2) else (s2, s1)
      val maxIndex: Int = short.length - 1
      @tailrec
      def endIndex(n: Int = 0): Int =
        if n <= maxIndex && short.charAt(n) == long.charAt(n) then endIndex(n + 1) else n - 1

      long.substring(0, endIndex() + 1)
    val commonStr: String = commonPrefix(l.remaining, str)
    // 检查这个最大匹配前缀是否满足 `str`。
    commonStr match
      case `str` => Success(str, str.length)
      // 记录解析失败时的偏移量。如果开头就解析失败了,则标记无效分析。
      case p => Failure(l.advanceBy(p.length).toError(s"expect '${str}'"), p.nonEmpty)

  // 对 regex 的处理则更直接,不满足模式就是无效分析。
  override def regex(r: Regex): Parser[String] = l =>
    r.findPrefixOf(l.remaining) match
      case None => Failure(l.toError(s"regex $r"), false)
      case Some(m) => Success(m, m.length)

  extension[A] (p: Parser[A])
    // ...

这里使用了来自 Location 的三个帮助函数:

  1. remaining 方法自动根据偏移量 offset 计算剩下的字符串输入。
  2. advanceBy 方法根据解析成功的长度重新计算 offset,并返回一个新的 Location 拷贝。
  3. toError 方法用于解析失败时将 Location 转换为携带错误报告的 ParseError

Location 完整的实现如下:

case class Location(var input: String, offset: Int = 0):
  // 提供行,列信息,如果有必要的话。
  lazy val line: Int = input.slice(0, offset + 1).count {_ == '\n'} + 1
  lazy val col: Int = input.slice(0, offset + 1).lastIndexOf('\n') match {
    case -1 => offset + 1
    case n => offset - n
  }
  def toError(msg: String): ParseError = ParseError(List((this, msg)))
  def advanceBy(n: Int): Location = copy(offset = offset + n)
  def remaining: String = input.substring(offset)

最小原语集实现

原语集剩下的部分只有 flatMap|attemptsucceed 这几个组合子了。见下方的实现:

object ParserImplement extends Parsers[ParserImplement.Parser] :

  type Parser[+A] = Location => Result[A]

  enum Result[+A]:
    self =>
    case Success(get: A, length: Int) extends Result[A]
    case Failure(get: ParseError, isCommitted: Boolean) extends Result[Nothing]

    // 将这些方法绑定到 Result 上,这样就可以通过类似指令链接的形式修改 Result 状态了。
    def advanceSuccess(n: Int): Result[A] = this match
      case Success(a, m) => Success(a, n + m)
      case _ => self

    def uncommit: Result[A] = this match
      case Failure(e, true) => Failure(e, false)
      case _ => self

    def addCommit(isCommitted: Boolean): Result[A] = this match
      case Failure(e, c) => Failure(e, c || isCommitted)
      case _ => self

  given Conversion[String, Location] = s => Location(s)
  override def attempt[A](p: Parser[A]): Parser[A] = l => p(l).uncommit

  // .. 省略了 string 和 regex

  override def succeed[A](a: A): Parser[A] = _ => Success(a, 0)
  def fail(msg: String): Parser[Nothing] = l => Failure(l.toError(msg), true)

  // 通过 extension / implicit 构造 Parser[A] 对象的 "方法"。
  extension[A] (p: Parser[A])
    override def many: Parser[List[A]] = l =>
      @tailrec
      def tryUntilFail(offset: Int = 0, buf: List[A] = Nil): Result[List[A]] =
        p(l.advanceBy(offset)) match
          case Success(repeat, n) =>
            tryUntilFail(offset + n, buf :+ repeat)
          case Failure(e, true) => Failure(e, true)
          case Failure(_, _) => Success(buf, offset)
	  // 不断提取重复的模式,直到首次失败为止。		
      tryUntilFail()

    override def |[B >: A](p2: => Parser[B]): Parser[B] = l =>
      p(l) match
        case Failure(_, false) => p2(l)
        case result => result

    override def flatMap[B](f: A => Parser[B]): Parser[B] = l =>
      p(l) match
        case Success(get, n) =>
          // 如果第一个解析器 p 运行成功了,则把它的运行结果 get 传到下一个解析器。
          // 同时,更新 input 的偏移量,使用 advanceBy(n) 函数完成。
          f(get)(l.advanceBy(n))
            .addCommit(n != 0) // 根据 n 设置是否为有效解析。
            .advanceSuccess(n) // 如果成功了,则累积偏移量。
        case fail: Failure => fail

前文已经提过,上下文无关分析是上下文相关分析的特殊形况,因此我们这里不需要特地实现 ** 串接组合子。

故障提示

回头看看 scope。当失败时,我们期望向 ParseError 原有的错误栈中压入新的消息。在 ParseError 中引入一个名为 push 的帮助方法做这件事情:

case class ParseError(stack : List[(Location,String)]):
  // ...
  def push(loc : Location, msg : String): ParseError = copy(stack = (loc,msg) :: stack)

注,copy 是 Scala 为样例类提供的值拷贝方法。有了它,我们可以实现解析器的 scope 方法:

extension[A] (p: Parser[A])
  // ...
  override def scope(msg: String): Parser[A] = l => p(l).mapError(_.push(l, msg))

mapError 接收 ParseError => ParseError 的变换,它被定义在 Result[+A] 上。如果解析是成功的,我们自然不需要应用 f 函数,即 mapError 什么也不会做。

enum Result[+A]:
  //...
  def mapError(f: ParseError => ParseError): Result[A] = this match
	case Failure(e, c) => Failure(f(e), c)
	case _ => self

由于我们对错误信息的压栈行为,使得栈上有了更多的信息供后续的分析过程使用。举个例子:

val p : Parser[String] = "a"
val q : Parser[String] = "b"

val P = scope(p ** scope(label(q)("except b"))("parsing 'b'"))("parsing 'ab'")
println(P("xb"))

如果 P 的解析过程发生了错误,则首先打印 "parsing 'ab'",然后优先打印 p 发生的错误 ( 默认输出 string 中定义的 ParseError 信息 ),然后打印 "parsing 'b'",最后是 "except b" 信息。这里的写法参考了 Scala 3 对 extension 的一些拓展。见最新的官方文档:Extension Methods (scala-lang.org) | Generic Extension。

我们也可以用 mapError 实现 label 方法:

extension[A] (p: Parser[A])
  // ...
  override def label(msg: String): Parser[A] = l => p(l).mapError(_.label(msg))

这里调用了定义在 ParseError 的同名 label 方法。我们用它修剪错误栈,丢掉内嵌范围的细节信息,仅仅使用栈底最近的位置:

case class ParseError(stack : List[(Location,String)]):
  def push(loc : Location, msg : String): ParseError = copy(stack = (loc,msg) :: stack)
  def label(s: String): ParseError =  ParseError(latestLoc.map((_,s)).toList)
  def latest: Option[(Location,String)] = stack.lastOption
  def latestLoc: Option[Location] = latest map (_._1)

最后,重写 ParseErrortoString 方法,以便给用户良好的信息提示。Location 提供了用于定位错误的行,列信息,除此之外,我们只需要将积累在堆栈内的信息逐个输出:

case class ParseError(stack : List[(Location,String)]):
  // push, label, latest, lastetLoc, ...
  override def toString: String =
    latestLoc match {
      case Some(location) =>
        s"""
           |When ${stack.map {case (_,s) => s}.mkString("; ")}
           |Occured in position: ${location.line},${location.col}
           |${location.input.split("\n")(location.line -1 )}
           |${" " * (location.col - 1) + "^"}
           |""".stripMargin
      case None => s"unknown error"
    }

编写 JSON 解析器

一旦通用的语法解析器准备完毕,在此基础上组合出 JSON 解析器就会相当容易。一段 JSON 可能包含 对象,它是使用 {} 包起来的,且用 , 分隔多个键值对序列。其中,键必须是字符串对象,但值可以是字符串,null,true,false,或其它数值字面量,也可以是数组,或者嵌套的对象。

我们现在创建解析后的 JSON 数据类型。这里使用的是 Scala 3 的枚举类:

enum JSON:
  case JNull
  case JNumber(get: Double)
  case JString(get: String)
  case JBool(get: Boolean)
  case JArray(get: IndexedSeq[JSON])
  case JObject(get: Map[String, JSON])

现在来梳理 JSON 是如何组织这六种数据类型的:

  1. JSON 的根结构要么是一个对象,要么是一个数组,在此记作 (obj | array).eof
  2. 对象内部是由 key : value 形式组成的,其中 key 是字符串字面量,它被 "" 引号包括;value 可以是任意数据类型,在此记作 (literal | obj | array)
  3. 数组内部的对象也是由 (literal | obj | array) 组成的。

见下面的实现。下面的代码没有字符串裁切的细节,也没有反复的命令式循环,只有直观的组合子表达:

object JSON:
  def jsonParser[Parser[+_]](P: Parsers[Parser]): Parser[JSON] =
    import P.*

    // 裁剪掉多余的空白,包括空格,回车,制表符等,对应 regex("\\s*".r)
    def token(s: String): Parser[String] = string(s).token

    def array: Parser[JSON] =
      scope {
        token("[") ~> value.sep(token(",")).map(vs => JArray(vs.toIndexedSeq)) <~ token("]")
      }("array")

    def obj: Parser[JSON] =
      scope {
        token("{") ~> keyval.sep(token(",")).map(kwargs => JObject(kwargs.toMap)) <~ token("}")
      }("object")

    // p1 ** p2 会将两个解析的结果包装成 (k,v) 返回。
    // 在这里,k 是 String 类型,v 则是 JSON 类型。
    def keyval: Parser[(String, JSON)] = quoted ** (token(":") ~> value)

    def literal: Parser[JSON] =
      token("null").as(JNull) |
        double.map(JNumber(_)) |
        quoted.map(JString(_)) |
        token("true").as(JBool(true)) |
        token("false").as(JBool(false))

    def value: Parser[JSON] = literal | obj | array

    // JSON 的最外层应该是一个 object 或 array。
    (whitespace ~> (obj | array)).eof

JSON 解析器显然也不关心 Parser 的形式是 Location => Result[A],又或者是别的什么样子,因为其他人也许会通过类 class 定义来给出截然不同的设计风格 ,这里 Parser 只是抽象的类型参数。Parsers[Parser] 顶级特质提供了各种抽象组合子来让 JSON 解析器表达自己语义,这就足够了。

演示

在测试程序中,我们做一些必要的导入,同时使用之前实现好的 ParserImplement 驱动 JSON 解析器执行。

import ParserImplement.{*, given}
import JSON.*
val jsonParser: Parser[JSON] = JSON.jsonParser(ParserImplement)

首先测试成功解析的情形。解析器支持用户输入 "不太规范" ( 指随意的换行,空格等 ) 的 JSON 字符串:

/*
Success(JObject(HashMap(Shares outstanding -> JNumber(8.38E9), Price -> JNumber(30.66), Company name -> JString(Microsoft Corporation), Related companies -> JArray(Vector(JString(HPQ), JString(IBM), JString(YHOO), JString(DELL), JString(GOOG))), Ticker -> JString(MSFT), Active -> JBool(true))),231)
*/

val jsonTxt = """
  {
    "Company name": "Microsoft Corporation",
    "Ticker": "MSFT",
    
    "Active"   : true,
    "Price" :30.66,
    "Shares outstanding" : 8.38e9,
    "Related companies" :[ "HPQ", "IBM", "YHOO", "DELL", "GOOG" ]
  }
  """

val jsonTxt2 ="""
      |{"name":"a",
      |
      |"age":12 }
      |""".stripMargin

println(jsonParser(jsonTxt))

当解析器在任意一处失败时,ParseError 会给出足够的信息让用户排查错误。比如:

/*
    Failure(
    When object; expect '}'
    Occured in position: 3,44
        "Company name": "Microsoft Corporation";
                                               ^
    ,true)
*/

val jsonTxt2 = """
  {
    "Company name": "Microsoft Corporation";
    "Ticker": "MSFT"
  }
  """
println(jsonParser(jsonTxt2))