解析器和解析器组合器?这是什么?
解析器和解析器组合器是将某一类型的流解析成另一种类型的一种方法。例如,可能从一个包含数学表达式(例如 2 + 3
)的字符串开始。我们希望从该字符串到代表令牌的数组的转换,然后可以进一步处理该表达式本身或进行语法高亮显示,或用于其他目的。
在这里,我无法公正地介绍这个主题。请查阅 维基百科 的标准文章,以及您喜欢的搜索引擎将提供更多信息。
对于已了解一些的来说,Parsimonious 解析器的类型签名如下所示
typealias Parser<C: Collection, T> = (Context<C>) throws -> T
您会注意到类型签名中包含 Collection
。Parsimonious 不是一个流解析器。这允许更轻松地进行回溯,但代价是不支持解析 极大 数量的数据。对于绝大多数使用情况,这不会成为问题。
术语
再次看看解析器的签名。它唯一的参数是 Context<C: Collection>
。我们称解析器 消费 集合。如果该集合的元素是类型化的,我们称解析器 消费 该类型集合。返回类型是 T
,但是对于解析器我们说解析器 匹配 T
。
组合器是一个高阶函数,它返回一个解析器。以下是一个例子
func combinator<C: Collection, T>(_ parsers: Parser<C, T>...) -> Parser<C, [T]>
组合器 消费 集合。它 返回 一个解析器。它 匹配 T
的数组。(技术上,它返回一个匹配 T
的数组的解析器。但说得太冗长了,从上下文可以看出,如果我们省略一些内容,含义是非常明显的。)
使用 parse
函数开始解析
func parse<C: Collection, T>(_ input: C, with parser: Parser<C, T>) throws -> T
由解析器 消费 的集合称为 解析输入 或简称为 输入。这个术语也用于解析器本身,但指的是给定解析器尝试解析时 原始 解析输入中未消费的剩余部分。
为什么 Parsimonious?
原因一:字符测试
Parsimonious 对解析文本采取了一种略微新颖的方法。大多数解析器组合框架都具有内置函数,例如 whitespace
或 newline
。相反,Parsimonious 有 字符测试 的概念。
这比解释更容易演示,但它很容易使用。让我们匹配至少一个字符的序列,该序列不包含空白或标点符号。
many1S(all: !\Character.isWhitespace, !\Character.isPunctuation)
让我们暂时解析这个解析器。语法后面的 S
后缀告诉我们这个解析器解析字符串并匹配字符串。(稍后详细介绍。)语法后面的 all:
告诉我们所有测试都必须返回 true
,解析器才能匹配。在括号内部,有两个“否定”的键。使用 !
运算符与键一起使用,反转键暗示的测试。
那么,什么是字符测试?
- 任何
KeyPath<Character, Bool>
- 任何
Character
- 任何
String
(这自动意味着“一个”中的任何一个) - 一个
ExplicitCharacterTest
的实例 - 任何实现
CharacterTest
协议的类型
此外,可以组合测试的 test(any:)
和 test(all:)
函数,它们本身返回一个字符测试,可以组合测试到令人头晕目眩的程度
char(all: !\Character.isWhitespace, !"07", test(any: \Character.isLetter, \Character.isDigit))
上面的解析器匹配单个字符,该字符不可能是空白,也不可能是“0”或“7”。此外,它必须是字母或数字。所以它将匹配“9”,但不匹配“0”。它将匹配“x”,但不匹配“ ”。
使用这种方法,可以创建非常复杂的解析器。
原因二:字符串!
在 Swift 中,String
不仅仅是 Character
实例的数组。它是一个更复杂的类型,处理 Unicode 标准中的许多复杂性。
原始 Parsimonious 解析器和组合器对字符串或字符一无所知。它们只是解析某种“某个”或其数组的集合,并匹配该“某个”或其数组。例如,组合器 many1
匹配给定解析器的一个或多个实例。
let digits = many1("0123456789")
给定“289”作为解析输入,解析器 digits
匹配字符实例的数组,因此匹配将是 ["2", "8", "9"]
。因为这种情况通常不方便,Parsimonious 具有一套解析器和组合器,它匹配字符串而不是字符数组。
处理字符串并匹配相同字符串的解析器类型是Parser<String, String>
。这种类型非常常见,因此有一个别名:ParserS
。除了ParserS
之外,还有manyS
、many1S
等等。这里的S
后缀表示我们在处理字符串,而不是字符数组。
let digits = many1S("0123456789")
上述解析器将给出"289"而不是["2", "8", "9"]
,这通常要方便得多。
原因3:回溯
由于Parsimonious是一个集合解析器而不是流解析器,回溯很容易。实际上,任何未成功匹配的解析器都会自动回溯。这使得解析器更容易编写和思考。
let escape: Character = "\\"
let quote: Character = "\""
let quotation = char(quote) *> manyS(char(all: !escape, !quote) | (char(escape) *> char(any: escape, quote))) <* char(quote)
quotation
解析器匹配引号字符串。引号本身可以用反斜杠转义,并且反斜杠本身也必须用反斜杠进行转义。只有引号内的文本作为此解析器的结果返回。
如果此解析器的任何部分失败,它将回溯到开始处并抛出ParseError
。此ParseError
是否终止解析取决于此解析器发生时的上下文。
let quoteOrInteger = quotation | many1S("0123456789") | fail("Expected a quotation or integer")
|
组合器尝试左边的解析器。如果它失败,它吞掉错误并尝试右边的解析器。如果那也失败,那么|
就失败,错误会向上传播。由于最后一次失败可能没有太大帮助,我们使用fail
组合器(总是失败)来产生更友好的错误。
原因4:简单地编写复杂的解析器
让我们再次看一下解析器的类型签名
public typealias Parser<C: Collection, T> = (Context<C>) throws -> T
注意throws
关键字?这使得Parsimonious“Swift化”并简化了复杂解析器的编写。我们不必检查返回类型的不变仪式,可以使用更自然、更易于阅读的Swift语法
func parseDeclaration(_ context: Context<String>) throws -> String {
return try context.transact {
try context <- string("DECLARE")
try context <- many1S(\Character.isWhitespace)
let decl = try context <- many1S(\Character.isLetter)
try context <- many1S(\Character.isWhitespace)
return decl
}
}
transact
方法是一个助手,它将上下文的索引返回到解析器失败时的起始位置。你在编写自己的解析器时应该始终使用这个方法。
通过使用try
代替某种Result
类型,我们不必不断检查Result
。这使得解析器更容易编写和更容易理解。
当然,使用运算符编写这个特定的解析器要容易得多,而且只要可能,你应该这样始终进行,因为它为你处理了所有这些细节。
let ws = many1S(\Character.isWhitespace)
let parseDeclaration = string("DECLARE") *> (many1S(\Character.isLetter) <~> ws)
技巧与窍门
查看单元测试!
单元测试实现了功能齐全的JSON解析器和相当不错的CSV解析器。解析的内容很多。
不仅仅是字符串!
Parsimonious可以解析实现了Collection
接口的任何东西。它不一定是字符串。如果你想把解析分成几个阶段,其中第一阶段是简单的标记化,这很有用。你可以使用Parsimonious将标记组合成更大的结构。
位置
在标记化时,知道原始Collection
中匹配的标记出现的位置通常很有用。可以使用position
组合器来实现这一点。
enum Token {
case openParens
case closeParens
case sep
case integer(Int)
}
let openParens = char("(")
let closeParens = char(")")
let sep = char(",")
let integer = many1S("0123456789")
let tokens = [openParens, closeParens, sep, integer].map(position)
let token = or(tokens)
注意单个标记解析器是在没有position
的情况下定义的,之后使用高阶函数添加了它。这使得解析器更具可重用性。
这样做你得到的是什么?上面的示例中,解析器现在是匹配Position<String, Token>
,它有有用的startIndex
、endIndex
和value
属性,而不是匹配Token
。
预览
组合器peek
可以在不消耗任何底层Collection
的情况下进行匹配。这允许你前瞻即将出现的内容。它的一个良好示例是在单元测试中的CSV解析器中。考虑生成Decimal
的解析器。
let dec = toDecimal <%> many1S(digits) + char(".") + many1S(digits)
这是一个非常好的解析器,但在CSV文件中,在某种情况下可能会引起错误匹配。考虑
bob,62.135.157.128,4.9
查看62.135.157.128
。 62.135
高兴地解析为十进制数,但CSV解析器对剩余的.157.128
不知所措。它以错误结束。因此,我们需要教我们的解析器在十进制值的情况下,它需要后面跟着分隔符、行尾或文件尾。
func delimited<T>(_ parser: @escaping Parser<String, T>) -> Parser<String, T> {
return surround(parser, with: ows) <* peek(char(sep) | eol | eofS)
}
let dec = toDecimal <%> delimited(many1S(digits) + char(".") + many1S(digits))
delimited
组合器返回一个说我们的传入解析器期望被可选空白(ows
)包围并由分隔符字符后跟的解析器。
我们的CSV文件中的单个"item"匹配如下
let str = CSValue.string <%> (delimited(quotation) | unquotation)
let item = delimited(dec) | delimited(int) | str
使用peek
,IP地址无法解析为十进制数,因此尝试下一个可能,整数字符串。它也失败了,直到最终我们找到了一个成功的解析器:unquotation
。
peek
组合符几乎总是与<*
组合符一起使用,它必须匹配其左右解析器,但丢弃右解析器的值。
实用运算符
自定义运算符对于使解析器组合框架可操作和可读性至关重要。Parsimonious对内置的|
、&
和+
运算符进行了重载。由于Parsimonious中这些运算符的参数是解析器,因此不存在与这些运算符的常规用法冲突的可能性。自定义运算符包括<*
、*>
、<*>
、<%>
、<=>
、<?>
和<-
。所有提到的运算符都是中缀运算符。此外,还有两个后缀运算符,即+
和*
。
使用自定义和重载运算符完全是可选的。每个Parsimonious运算符都有一个相应的普通函数。
让我们列举几个运算符。其余部分请参阅文档和单元测试。
最常用的运算符可能是|
(or
)。此运算符尝试其左边解析器。如果它不匹配,它将尝试其右边解析器。如果两者都失败,将抛出右侧解析器的ParseError
。
// Matches "foo" or "bar" or fails.
string("foo") | string("bar")
<*
(first
)运算符在左右两边解析器都成功时成功,但它仅匹配左边解析器。可以将它视为“跟随”的意思。
string("foo") <* many1S(\Character.isWhitespace)
这个只在“foo”后面至少有一个空白字符时才匹配“foo”字符串。空白匹配被丢弃,但必须成功。
<*
的逆运算当然是*>
(second
),意为“ precedes by”
many1S(\Character.isWhitespace) *> string("foo")
此外,还有<*>
(surround
),其含义现在应该是显而易见的
string("foo") <*> many1S(\Character.isWhitespace)
上述解析器仅匹配两边都至少有一个空白字符的字符串“foo”。
运算符+
仅用于字符串解析器(即ParserS
)。它将其左右两边匹配的结果组合在一起。
string("foo") + string("bar")
这必须首先匹配字符串“foo”,然后匹配字符串“bar”。返回的匹配字符串是“foobar”。与string("foo") & string("bar")
相比,它返回数组["foo", "bar"]
。
作为后缀运算符,+
与many1
相同,但其参数必须始终是解析器,而不是字符测试。
let foobar = (string("foo") | string("bar")) <*> manyS(\Character.isWhitespace)
foobar+
给定解析输入“foofoo barfoo bar bar ”,foobar+
的匹配将是["foo", "foo", "bar", "foo", "bar", "bar"]
。
任意状态
在解析过程中使用的Context
类型允许设置和检索任意状态
context["count"] = 1
context["string"] = "ok"
由于解析开始的parse
方法不允许直接访问Context
对象,设置初始状态的最简单方法是通过创建一个执行此操作的“root”解析器
enum Datum {
case integer(Int)
case string(String)
}
func datum(_ context: Context<String>) throws -> Datum {
func integer(_ context: Context<String>) throws -> Datum {
return try context.transact {
let s = try context <- many1S("0123456789")
guard let i = Int(s) else {
throw ParseError(message: "'\(s)' out of range for integer.", context: context)
}
context["integers"] = (context["integers"]! as! Int) + 1
return .integer(i)
}
}
let string = Datum.string <%> quotation
let datum = try context <- integer | string | fail("Expected to match an integer or a quoted string.")
return datum
}
// Here is our root parser.
func data(_ context: Context<String>) throws -> ([Datum], Int) {
let ws = manyS(\Character.isWhitespace)
context["integers"] = 0
let data = try context <- (many(datum, sepBy: char(",") <*> ws) <*> ws) <* eof
return (data, context["integers"]! as! Int)
}
let s = "\"ok\", 79, 44, \"never\", 8 "
let (result, integerCount) = try! parse(s, with: data)
integerCount
的值是 3。这是一个非常牵强、几乎愚蠢的例子,但它演示了能够做到什么。