Scala:解析器组合子与 DSL

1,192 阅读14分钟

为了和 MySQL 数据库进行交互,我们的唯一方式是使用 SQL 语句,它是一个强大的,声明式编程的 领域特定语言 DSL 。尝到 "甜头" 的我们希望自己能够创造一门微型语言,让它能够对某类文件的解析,或者在特殊业务中发挥作用,提高开发效率。

比如,"创造" 一个诸如 where sth in aTuple 的句式快速完成对元组的检索。不过,这需要解析器(以及词法解析器)来将这些语句转换成本地程序能够理解的数据结构。对于学习者而言,这很难,因为你至少要成为精通《编译原理》 的专家。即便你是专家,这仍然是非常麻烦的一件事情。

Scala 提供了解析器组合子库,允许我们在 Scala 体系下实现一个内部的,简单的领域特定语言,或者称之内部 DSL。Scala 有一些特性可方便的支持内部 DSL 开发:函数柯里化、隐式转换、允许使用符号名称(这一点非常重要)、允许使用``空格替代对象调用的 . 符号等。

在阅读本章之前,你可能需要对 上下文无关文法 ( context-free grammar ) 有一个基本的认识。在简单介绍 Scala 解析器组合子的用法之后,我们会尝试制作一个支持将 JSON 格式的字符串转换为 Scala 对应数据结构的解析器。

绪论:上下文无关文法

产生式

一个产生式是由一个条件和动作组成的指令,即条件—活动规则:condition - action 。它通常用于表述具有因果关系的知识,其基本形式为 P → Q ,或者称为 if P then Q。

终结符

终结符是一个形式语言的基本符号,它不能被分解(或者替换成)更小的符号。比如给定两个产生式构成的文法:

S::=aSbS::=baS ::= aSb \\ S ::= ba

其中,::= 可以使用 -> 符号代替,它代表着 “相当于” 的含义。在这个文法中,S 可以被替换为 aSb ,或者是 ba,但是 a 和 b 却不能够再替代成其它的符号。因此,a 和 b 是终结符。

非终结符

非终结符,即表示可以被替代的符号。显然,在上述的文法中,S 是一个非终结符。在同一个文法下,一个符号不是终结符就是非终结符。

形式文法

形式文法,可以简单的理解为它是一个这样的元组:(N, Σ, P, S)。其中:

  1. N 代表着非终结符号集合。
  2. Σ 代表着终结符号集合。
  3. P 代表着一系列产生式规则。
  4. S 代表一个起始符号,其中 S 属于 N。

从直观的形式来看,形式文法是一系列产生式规约而形成的准则,或称是一系列 "公式",或者是 "模板" 。继续拿刚才的例子来说:

S::=aSbS::=baS ::= aSb \\ S ::= ba

显然,这个文法描述了这样的字符串集合:ba,abab,aababb 等。因为我们可以从初始符号 S 出发,通过类似这样的推导过程:S -> aSb -> aaSbb 得到上述的字符串。

形式文法的类型分为四种:无限制文法,上下文文法,上下文无关文法和正规文法。其中,划线部分为本章重点提及的文法。

Sent::=SVOS::={}V::={}O::={}Sent ::= SVO \\ S::= \{人 | 天\} \\ V::= \{吃 | 下\} \\ O::= \{雨 | 肉\} \\

其中,S 代表主语,V 代表谓语,O 代表宾语。根据刚才的判断方式,这是一个上下文无关文法,因为所有产生式左边只有一个单独的非终结符。

从初始符号 Sent 出发,我们可以得到以下的句子:

{人吃肉,天下雨,人吃雨,天下肉,......}

天下肉 为例,其推导过程为:

Sent -> SVO -> 天VO -> 天下O -> 天下肉

显然,由于上下文无关,在造句时完全不用考虑 "动宾搭配" 等因素,我们可以任意选择搭配的 S,V,O,而导致出现了一些语义错误的句子。在此时,我们必须要将此文法修正为上下文文法:

Sent::=SVOS::={}V::={人吃}V::={天下}O::={吃肉}O::={下雨}Sent ::= SVO \\ S::= \{人 | 天\} \\ 人V::= \{人吃\} \\ 天V::= \{天下\} \\ 吃O::= \{吃肉\} \\ 下O::= \{下雨\} \\

其中,人V,天V 都不再是单独的非终结符号。因为 V 和 O 的左侧都加了限定词,比如只有主语是 "人" 时,谓语才可以使用 “吃“。

以 "人吃饭" 为例,其推导过程为:

Sent -> SVO -> 人VO -> 人吃O -> 人吃饭

在第三步中,V 的上文是 "人" ,因此可以通过推导式第三条规则推出 人吃O

相关参考资料

算数表达式解析器

在介绍完了上下文无关文法之后,我们试着用它表达出四则运算表达式,形如:(2 * 3) + 44 * 2 + 1 ... 它的文法如下:

expr::=term{"+"term""term}.term::=factor{""factor""factor}.factor::=floatingPointNumber"("expr")"expr\quad::=\quad term\quad \{ "+"\quad term\quad|\quad"-"\quad term\}. \\ term\quad::=\quad factor\quad \{ "*"\quad factor\quad|\quad"-"\quad factor\}. \\ factor\quad::=\quad floatingPointNumber\quad|\quad "("\quad expr \quad")"

在这里,{} 内的内容是可重复的。内部的后备选项之间使用 | 符号分隔。另外,在本例中虽然没有提及,但是 [] 表示这是一个可选项。

注意,这个 表达式 (expr) 都是由多个 词 (term) 通过 * 或者 / 操作链接起来的。而词又是由多个因子 (factor) 通过 * 或者 / 操作链接起来的。而因子本身可以是一个浮点数 (floatingPointNumber) 或者是另一个被括号括起来的表达式。

"大因子" 之间使用 + / - 操作,"小因子" 之间使用 * 或 / 操作,这无形之间定义了操作符的运算优先级。

使用 Scala 工具实现你的表达式

算术表达式的解析器包含在一个继承自 JavaTokenParsers 特质的类中,这里的 Token 含义为 "符号",因为该工具给出了诸多基础的解析器:标识符,字符串字面量,以及数字等解析器。在这个例子中,floatingPointNumber 就是从该特质所提供的浮点数解析器。

给出文法的 Scala 工具实现:

import scala.util.parsing.combinator.JavaTokenParsersclass Arith extends JavaTokenParsers{
  def expr : Parser[Any] = term~rep("+"~term | "-" ~ term)
  def term  : Parser[Any] = factor~rep("*"~factor | "/"~factor)
  def factor : Parser[Any] = floatingPointNumber | "("~expr~")"
}

下面是一些相关说明:

  1. 每个产生式在 Scala 中都是一个方法,它的返回值是 Parser[Any] 。我们稍后会介绍它的具体含义是什么。
  2. 在文法中,符号和 expr, term, factor 之间使用空格来表示顺序的组合关系,犹如 " hello world " 这个句子中,各个单词使用空格来顺序衔接。而在 Scala 中,这个空格被替换成了 ~ 符号。为了使得代码和文法的视觉效果更加接近,在这里我们不在 ~ 的前后再加上空格。
  3. | 在 Scala 中是解析器组合子的其中一个。P | Q 表示首先尝试使用 P 解析器进行解析,如果失败,则使用解析器 Q
  4. 在 Scala 表述的文法中,可重复项使用 rep(...) 来表示,可选项使用 opt(...) 来表示,它们都属于解析器组合子。

有关于 ~ 符号

~ 是最常用的,用于顺序组合,的解析器组合子,比如 P~Q 代表着将 PQ 的解析结果装入到另一个同样命名为 ~ 的模板类当中并返回。因此,假设 P 的解析结果是 true ,而 Q 的解析结果是 ? ,那么 P~Q 将返回一个 ~("true","?") 。注意,这是一个 Parser 特质的内部模板类。当打印它时,会得到 (true~?)

// the operator formerly known as +++, ++, &, but now, behold the venerable ~
// it's short, light (looks like whitespace), has few overloaded meaning (thanks to the recent change from ~ to unary_~)
// and we love it! (or do we like `,` better?)/** A parser combinator for sequential composition.
     *
     * `p ~ q` succeeds if `p` succeeds and `q` succeeds on the input left over by `p`.
     *
     * @param q a parser that will be executed after `p` (this parser)
     *          succeeds -- evaluated at most once, and only when necessary.
     * @return a `Parser` that -- on success -- returns a `~` (like a `Pair`,
     *         but easier to pattern match on) that contains the result of `p` and
     *         that of `q`. The resulting parser fails if either `p` or `q` fails.
     */
@migration("The call-by-name argument is evaluated at most once per constructed Parser object, instead of on every need that arises during parsing.", "2.9.0")
def ~ [U](q: => Parser[U]): Parser[~[T, U]] = { lazy val p = q // lazy argument
                                               (for(a <- this; b <- p) yield new ~(a,b)).named("~")
                                              }

这个 for-yield 语句最终会编译成 this.flatMap( a => p.map(b => new ~(a,b))) ,最终返回一个 ~(a,b)。而 ~ 模板类的声明在此:

 /** A wrapper over sequence of matches.
   *
   *  Given `p1: Parser[A]` and `p2: Parser[B]`, a parser composed with
   *  `p1 ~ p2` will have type `Parser[~[A, B]]`. The successful result
   *  of the parser can be extracted from this case class.
   *
   *  It also enables pattern matching, so something like this is possible:
   *
   *  {{{
   *  def concat(p1: Parser[String], p2: Parser[String]): Parser[String] =
   *    p1 ~ p2 ^^ { case a ~ b => a + b }
   *  }}}
   */
case class ~[+a, +b](_1: a, _2: b) {
    override def toString = "("+ _1 +"~"+ _2 +")"
}

这样做的好处是如果你使用了 P~Q 的解析器组合子,那么你后续可以使用形式一致的偏函数 (或者理解为模式匹配) case p~q => (...) 提取出 PQ 各自的解析结果 p & q。这里涉及到另一个解析器组合子 ^^ ,我们在后续的文章中再提及它。

运行算数解析器

让另一个程序入口继承 Arith 类,并且在主函数中调用 parseAll 方法,将 expr 作为初始符号和你指定的数学表达式字符串传递进去:

object RunningTime extends Arith {
  def main(args: Array[String]): Unit = {
​
    println(parseAll(expr, "(3 + 2 ) * 2"))
​
  }
}

在解析完毕时,程序会输出 [1.13] ,它表示成功解析了从第 1 个字符到第 13 个字符之前的位置。实际上,这个字符串的长度为 12 ,因此换句话说就是整个表达式都被成功解析。

在解析后,控制台还会紧跟 parsed ,并打印解析结果(而它的用处并不大,我们可以先不去关心)。如果该表达式解析失败了,则会打印 failure: 并输出错误原因。

正则表达式解析器

floatingPointNumber 是由 JavaTokenParses 提供的按照 Java 格式识别浮点数的解析器。不过,有时候我们希望能够解析一个类似于 "0x11","0xAF" 的数字,或者有识别特定文本格式的需求。此时,我们可以使用更通用的 正则表达式解析器 (Regular Expression Parser)

下面的 MyParser 单例对象演示了如何制作一个能够解析电子邮箱格式的解析器:

object MyParser extends RegexParsers {
  def identEmail : Parser[String] = """\w+([-+.]\w+)*@\w+([-.]\w+)*.\w+([-.]\w+)*""".r
}

任何字符串后面加上 .r 方法,都将返回一个 Parser 解析器。同时字符串内容将视作正则表达式作为解析规则。下面试着在主函数中解析一个邮箱:

object RunningTime {
  def main(args: Array[String]): Unit = {
    println(MyParser.parseAll(MyParser.identEmail, "121@qq.com"))
  }
}

解析成功,则说明正则表达式正确,且解析器能够正常运行。该例中的 MyParser 继承自 RegexParsers 特质。Scala 的解析器组合子是按照特质的继承关系来有序组织的,它们被包含在 scala.util.parsing.combinator 当中。

Parser 是最顶层的特质,它定义了最通用的解析框架。下一层是 RegexParsers ,它要求提供正则表达式来让解析器工作。更具体的特质是 JavaTokenParsers ,它实现了对 Java 定义的词或语言符号进行识别的解析器。

JSON 解析器

在本章节中,我们尝试着制作一个 diy 的 JSON 解析器。首先给出一个 JSON 文本,并尝试着分析它的结构:

{
    "address book" : {
        "name" : "John",
        "address" : {
            "street" : "10 Market",
            "city" : "San Francisco",
            "zip" : 94244
        }
    },
    "phone numbers" : [
        "408 338-3238",
        "408 338-6892"
    ]
}

在 JSON 中,每个对象 (object) 都使用一个 {} 包括起来,内部包含了由 成员 (member) 组成的 成员集合 (members) ,成员之间使用 , 符号分隔。每个成员又是由 k:v 格式的键值对。其中 k 规定为字符串类型,v 是包含了各种数据结构的 值 (value) 。值可以包含了另一个独立的 对象 obj ,也可以包含一个 数组 arr 。其中,数组内部是由 值 value 包含的 值的集合 (values) 。除此之外,值 value 还包含了字符串值,浮点数值, “null”,"true","false"。

下面给出解析 JSON 的上下文无关文法:

value::=objarrstringLiteralfloatingPointNumber"null""true""false".obj::="{"[members]"}".arr::="{"[values]"}".members::=member{","member}.member::=stringLiteral":"value.values::=value{","value}value\quad::=\quad obj\quad|\quad arr \quad|\quad stringLiteral\quad|\quad floatingPointNumber\quad|\quad "null"\quad |\quad "true"\quad|\quad "false". \\ obj\quad::=\quad "\{"[members]"\}". \\ arr\quad::=\quad "\{"[values]"\}". \\ members\quad::=\quad member\quad\{"," member\}. \\ member\quad::=\quad stringLiteral\quad":"\quad value. \\ values\quad::=\quad value\quad\{","\quad value\}

完整的解析器代码块也在下文给出:其中,成员集合 (members)值的集合 (value) 在这里使用了 repsep 方法替代。如 repsep(member,",") 表示这是一个由 member 组成的,并且由 , 符号分隔的序列。对于 值的集合 (values) 同理。

class JsonParser extends JavaTokenParsers{
  def value : Parser[Any] = obj | arr | stringLiteral | floatingPointNumber | "null" | "true" | "false"
  def member : Parser[Any] = stringLiteral~":"~value
  def obj : Parser[Any] = "{"~repsep(member,",")~"}"
  def arr : Parser[Any] = "["~repsep(value,",")~"]"
}

由于 JSON 整体也可以看作是一个被 {} 包裹起来的,包含着一个大 obj 的 value,因此,在主函数中我们选取 value 作为初始符号传递并解析:

object RunningTime extends JsonParser {
  def main(args: Array[String]): Unit = {
​
    val jsonString : String =
      """
        |{
        |    "address book" : {
        |        "name" : "John",
        |        "address" : {
        |            "street" : "10 Market",
        |            "city" : "San Francisco",
        |            "zip" : 94244
        |        }
        |    },
        |    "phone numbers" : [
        |        "408 338-3238",
        |        "408 338-6892"
        |    ]
        |}
      """.stripMargin
​
    println(parseAll(value, jsonString))
  }
}

这段代码会运行成功,并且控制台会打印:

[16.7] parsed: (({~List((("address book"~:)~(({~List((("name"~:)~"John"), (("address"~:)~(({~List((("street"~:)~"10 Market"), (("city"~:)~"San Francisco"), (("zip"~:)~94244)))~}))))~})), (("phone numbers"~:)~(([~List("408 338-3238", "408 338-6892"))~]))))~})

解析器输出转换

虽然这个程序成功解析了 JSON,但是我们解析后没有任何后续操作,因此现在打印的字符串都来自 ~ 模板类的 toString 方法。它输出的内容都晦涩难懂,看起来没有任何实际意义,无论是对于程序员还是 Scala 程序,直接理解这个输出结果都不是一件容易的事情,现在是时候对它进行一些处理了。如果能够把 JSON 对象映射为某个 Scala 内部的数据格式,那么处理数据的效率就高得多。更自然的表示方式应当是这样:

  1. JSON 依赖 k-v 键值对存储信息,因此我们很自然地想到使用 Map 作为主体的容器。
  2. JSON 内的数组使用 Scala 内部的 List[Any] 类型去表示。
  3. JSON 字符串使用 Scala 的 String 类型去表示。
  4. JSON 数值使用 Scala 的 Double 类型去表示。
  5. ture, false, null 转换成 Scala 对应的数据结构,而不是 String

我们这里要引入另外一个解析器组合子:^^ 。该符号衔接在另一个解析器的后面,并尝试对前者的输出结果进行转型操作。设 P ^^ Q ,而 P 的返回值是 R,那么这步操作就可以理解为 Q(R)(前提是 P 得到了正确的解析结果)。下面尝试着创造一个将字符串转换成浮点数的解析器:

//尝试将解析器的解析结果转换为双精度浮点数。
def extractDouble :Parser[Double] = floatingPointNumber ^^ (_.toDouble)
​
//将解析的结果赋值给一个 Double 类型变量。
val d: Double = parseAll(extractDouble,"12.00").getOrElse(Double.NaN)
​
//输出结果
println(d)

现在使用它来升级我们 JSON 解析器的一部分,以 obj 为例子。我们的解析器最终会解析出类似 (String,List[(String,Any)],String)) 格式的结构(实际上是 JsonParser.this~[JsonParser.this~[String,List[(String,Any)]],String],笔者出于 “视觉效果” 做了省略)。因为两边的 String 类型分别对应着 {} 符号,而中间的 List[(String,Any)] 则对应着 JSON 的每一个 k-v 键值对组成的序列,其中 k 是 String 类型。

def obj: Parser[Map[String,Any]] = "{"~repsep(member, ",")~"}" ^^ {case "{"~ms~"}" => Map() ++ ms}

在这里我们使用偏函数,企图消除掉多余的 {} 花括号,将 repsep(member,",") 的结果赋值给 ms ,然后将 ms 添加到 Map 映射当中。

而实际上,我们可以使用 ~><~ 优化代码。比如以下写法:"{"~>repsep(member,",")<~"}"。其中, ~> 代表只保留右解析器组合子的操作结果(这样,解析结果 { 被抛弃掉了), <~ 同理。因此,上述代码可以变得更加紧凑:

def obj: Parser[Map[String,Any]] = "{" ~> repsep(member, ",") <~ "}" ^^ {Map() ++ _}

除此之外,我们希望能够将 JSON 中字符串 true , falsenull 转换成 Scala 中的对应数据结构。以其中一个为例,写法如下:

"true" => (_ => true)

现在,我们将 ^^ 投入并升级我们之前的 JSON 解析器:

import scala.util.parsing.combinator.JavaTokenParsersclass JsonParser extends JavaTokenParsers {
  def value: Parser[Any] =
      obj |
      arr |
      stringLiteral |
      floatingPointNumber ^^ (_.toDouble)|
      "null" ^^ (_ => null) |
      "true" ^^ (_ => true)|
      "false" ^^ (_ => false)
​
  // 通过偏函数提取解析的值。
  def member: Parser[(String,Any)] = stringLiteral~":"~value ^^ { case name~":"~value => (name,value)}
​
  def obj: Parser[Map[String,Any]] = "{"~>repsep(member, ",")<~"}" ^^ {Map() ++ _}
​
  def arr: Parser[List[Any]] = "["~>repsep(value, ",")<~"]"
}

重新使用该解析器解析之前的 JSON 字符串,我们将得到此结果:

[16.7] parsed: Map("address book" -> Map("name" -> "John", "address" -> Map("street" -> "10 Market", "city" -> "San Francisco", "zip" -> 94244.0)), "phone numbers" -> List("408 338-3238", "408 338-6892"))

显然,这样的结果更易于 Scala 程序去处理,并且对于开发人员来说也可以直观地检查实际的解析效果。

常用解析器组合子汇总

下面列出常用的解析器组合子:

组合子含义
"...".r正则表达式解析器
P~Q顺序组合 (regular expression)
P~>Q , P<~Q顺序组合,但是只保留右/左解析器结果。
P|Q备选项,可以多个备选项进行组合。
opt(P)可选择项 (option),解析成功则返回Some,否则为 None。
rep(P)重复项 (repetition)
repsep(P,s)P 和 s 交错的重复项,s 大部分情况下都是分隔符号。
P ^^ f将 P 的解析结果进行转换,可以使用 case 语法。