本章是 《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
类型包含了更多的信息:
- 解析成功时,返回一个
Char
类型的解析结果; - 反之,返回关于解析失败的提示信息。关于错误提示的部分,我们在后文会花大量的精力去设计。
解析器需要被调用。这里再为其创建一个 run
函数:
def run[A](parser: Parser[A])(input : String) : Either[ParserError,A]
我们的输出可以是 ADT 树,或者是 Map
类型,再或者其它的什么类型,不妨统一泛化为 A
类型。解析器要么失败,从而返回 ParserError
,要么就返回正确提取出的结果。
你应该已经注意到了,目前没有任何关于 Parser
和 ParserError
类型的定义,它们作为参数类型,与我们平时标记的 E
,V
,T
没什么不同。它们的形式被现在被架空了。
看,在代数设计中,函数操作的数据类型已经没有那么重要了。一旦它们支持必要的法则和函数,甚至无需暴露其底层的数据格式。有这么一个观点认为,类型的含义是由它和其它类型的 关系 决定的,而非内在形式 ( 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) 。举个简单的例子:现在有两个谓词 "下雨" ,"吃饭" 和两个主语 "人","天"。显然 "吃饭" 必须和 "人" 组合在一起才能表达正确的语义。
也就是说,给定两个解析器 p1
和 p2
,p2
解析器的正确性还要依赖于前者给定的 上下文。具体要怎么做呢?假定第一个解析器是 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
实现 map
和 map2
组合子。除此之外,上下文无关可以看作是上下文有关的一种特殊情况,因此 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)))
基于最小原语集的拓展
到目前位置,我们手头上有以下几个原语级别的解析器及其组合子:
string(s)
:识别字符串字面量的解析器。regex(r)
:识别正则表达式的解析器。many
:识别重复模式的解析器。succeed(a)
:总是返回一个值a
的解析器。p flatMap f
:通过f
串化两个解析器,可以处理上下文有关文法,也可以处理上下文无关文法,我们在本章只讨论后者。p1 | p2
:在两个执行器中选择其一执行。
另一方面,我们理论上可以用 '0' | '1' | ... | '9'
去识别一个数字,但这样表达起来效率不是很高。一个不错的想法是将 正则表达式 引入进来:
implicit def regex(r : Regex) : Parser[String] = ???
这样一来,我们就可以基于 regex
去表达诸如 whitespace
,double
,quoted
等更丰富的语义了,它们在后文用于解析 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))
而 double
,quoted
解析器又是通过一些高级组合子构造出来的。见:
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
最多选择一条匹配前缀的分支并尝试后续的解析。如果这条唯一的分支失败了,明智的做法是尽快短路退出,避免在其它必定失败的分支上浪费时间。这一点是本小节围绕 |
做各种 ( 复杂 ) 设计的动机。
为此我们提出了两种状态:
- 提交 ( commit ):或称有效解析,指解析器至少成功解析了一个字符。
- 未提交 ( 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
:
- 当分析成功时,返回
A
类型的值以及成功处理的长度。 - 当分析失败时,返回记录了错误信息的
ParseError
,包括它是否为有效解析。
一旦着眼于具体实现,就会有越来越多的工具函数或者是工具类被添加进来。代数设计使我们在顶层设计时避免陷入各种细枝末节。
基础解析器
基于目前的 Parser[A]
外形,我们实现 string
,regex
,这两个核心解析器。判断解析成功的标志是:是否能够输入中截取到满足规则的字符串前缀。
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
的三个帮助函数:
remaining
方法自动根据偏移量offset
计算剩下的字符串输入。advanceBy
方法根据解析成功的长度重新计算offset
,并返回一个新的Location
拷贝。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
,|
,attempt
,succeed
这几个组合子了。见下方的实现:
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)
最后,重写 ParseError
的 toString
方法,以便给用户良好的信息提示。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 是如何组织这六种数据类型的:
- JSON 的根结构要么是一个对象,要么是一个数组,在此记作
(obj | array).eof
。 - 对象内部是由
key : value
形式组成的,其中key
是字符串字面量,它被""
引号包括;value
可以是任意数据类型,在此记作(literal | obj | array)
。 - 数组内部的对象也是由
(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))