Introduction
广义地说,我们可以将解析定义为试图获取一团模糊的数据,例如字符串、数据、用户输入,甚至URL请求,并将其转换为更特定于域的第一类数据类型,例如用户模型。如果这样说的话,你甚至可以想象解析是一个从“模糊的数据块”到“结构良好的数据”的函数,因此函数编程可能有很多关于这个主题的内容。但是,这个函数一开始很难实现,因为我们可能需要做很多工作才能从中提取出有意义的数据。
解析器组合器的目标是将这个问题分解为许多非常特定的解析器,这些解析器只做一项工作,而且做得很好。然后,它提供了所有高级函数,允许我们将许多解析器粘合在一起,得到能够处理越来越复杂数据的大型解析器。它使我们能够专注于单个随机单元,然后通过有趣的方式将它们粘合在一起,构建出许多复杂的随机生成器。这种解决问题的方式在函数式编程中不断出现,而且非常强大。
Simple parsers in Swift/Foundation
事实证明,解析是很重要的,Swift和Foundation有一整套解析字符串的方法。
其中一些与类型上的初始化器一样简单,例如,Int上有一个初始化器,它接受一个字符串:
Int("42") // 42
Int("42-") // nil
Double上的初始化器也有类似的作用。
Double("42") // 42.0
Double("42.32435") // 42.32435
甚至Bool也有这样一个初始化器。
Bool("true") // true
Bool("false") // false
Bool("f") // nil
许多基础类型带有这些初始化器解析器,包括UUID、URL,甚至URLComponents。
import Foundation
UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF") // UUID
UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEE") // nil
UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEZ") // nil
URL(string: "https://www.pointfree.co") // URL
URL(string: "^https://www.pointfree.co") // nil
let components = URLComponents(string: "https://www.pointfree.co?ref=twitter")
components?.queryItems // [{name "ref", value "twitter"}]
所有这些初始化器都只是函数,它们接收模糊的字符串并返回第一类值,无论是整数、Double、UUID还是URL。
然后有一些高级分析器做很多更复杂的事情,比如Foundation的DateFormatter。现在,这个名称会让您相信它负责将日期格式化为字符串,但它的作用正好相反:它可以将字符串解析为日期值:
let df = DateFormatter()
df.timeStyle = .none
df.dateStyle = .short
df.date(from: "1/29/17") // Date
和我们的其他格式化程序一样,它返回一个可选的日期,因为解析可能会失败。
df.date(from: "-1/29/17") // nil
DateFormatter只是格式化程序类型的整个类层次结构中的一个,可以将字符串解析为其他类型。有NumberFormatter、ByteCountFormatter、PersonNameComponentsFormatter等等。
More advanced parsing in Foundation
到目前为止,这些示例都是特定领域的,也就是说,您无法使用这些函数解析Swift一无所知的自定义格式。对于这种情况,Foundation还提供了一些其他工具。
正则表达式就是这样一种工具。正则表达式本身就是一种语言,用字符串描述解析字符串的方法。它可以用来“capture”复杂正则表达式中需要特定数据类型的字符串部分。
例如,下面是一个从字符串中解析电子邮件地址的正则表达式:
let emailRegexp = try NSRegularExpression(pattern: #"\S+@\S+"#)
let emailString = "You're logged in as blob@pointfree.co"
let emailRange = emailString.startIndex..<emailString.endIndex
let match = emailRegexp.firstMatch(
in: emailString,
range: NSRange(emailRange, in: emailString)
)!
emailString[Range(match.range(at: 0), in: emailString)!]
// "blob@pointfree.co"
正则表达式确实很强大,但是有一个非常简洁的符号来匹配事物。它们的解析能力也非常有限。解析越复杂,表达式就越神秘。
该API在Swift中仍然相当笨拙,需要进行大量Range - NSRange来回转换。
另一种工具叫做Scanner。它是一个通用的解析工具,允许您从字符串开始解析各种类型。
let scanner = Scanner("42 Hello World")
var int = 0
scanner.scanInt(&int) // true
int // 42
要使用它,您必须创建一个可变变量来传递给scanner,如果解析成功,scanner将使用解析的值更新该变量,并且scan方法将返回一个布尔值,指示扫描是否成功。
这是一个非常古老的API,如NSRegularExpression,早在Swift的优秀功能(如optionals和代数数据类型)出现之前就已经出现了。这个API可以改进很多,但至少很高兴知道苹果给了我们一些解析工具,而且在幕后它可以做一些非常强大的事情。
这是Swift和Foundation的一些解析工具的简要概述。它们可能非常强大,并且服务于许多用例,但它们有一些严重的缺点:
-
其中许多是特定于域的,例如初始化器和格式化程序类。它们在这些域中工作得非常好,但它们不能通用化,以允许您解析自己的格式。
-
对于更通用的解析器,如正则表达式和scanner,它们无法组合。没有办法将它们拼接在一起并且使用两个小型解析器来完成一件事。这使得我们无法将复杂的解析器重构为更简单的解析器,并可能在完全不同的解析器之间进行一些代码重用。
-
最后,所有这些API都非常古老,它们都没有像泛型那样利用Swift特性。
Parsing from scratch
既然我们看到解析对苹果来说非常重要,可以给我们提供许多解决方案,那么让我们自己来尝试一下解析,这样我们就可以看到它有多么困难和微妙。让我们尝试解析一种相对简单的字符串格式:纬度和经度坐标。有一种常见的格式,即纬度/经度坐标以度数表示,同时还有一个基本方向,用于描述坐标位于赤道和本初子午线的哪一侧。
例如,这里是纽约布鲁克林的经纬度:
// 40.6782° N, 73.9442° W
我们想将这个松散的字符串解析为更加计算机友好的内容,比如说下面的结构:
struct Coordinate {
let latitude: Double
let longitude: Double
}
让我们试着手工将字符串解析到这个结构中。这意味着我们将尝试从头开始实现此功能:
func parseLatLong(_ string: String) -> Coordinate? {
}
这将返回一个可选的字符串,因为我们可能无法解析该字符串。
我们将通过执行大量不同的字符串操作来实现这一点。例如,我们可以从拆分空格上的字符串开始,这样就可以得到坐标的所有部分:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
}
parts应保持纬度坐标,然后是其基本方向,然后是经度坐标,然后是其基本方向。如果我们得到的parts比我们预期的多或少,我们可能应该提前退出并返回nil,因为这意味着我们得到了一些不好的数据:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
}
通过检查,让我们将parts提取到变量中:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
let lat = parts[0]
let latCard = parts[1]
let long = parts[2]
let longCard = parts[3]
}
我们可以很容易地将lat和long变量转换为Double,只需删除度数符号:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
let lat = Double(parts[0].dropLast())
let latCard = parts[1]
let long = Double(parts[2].dropLast())
let longCard = parts[3]
}
现在这种double 解释实际上是失败的,所以我想我们必须做一些额外的防护:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
guard
let lat = Double(parts[0].dropLast()),
let long = Double(parts[2].dropLast())
else { return nil }
let latCard = parts[1]
let longCard = parts[3]
}
我们的解析很接近成功了,但我们必须以某种方式结合基本方向。基本方向只是确定坐标是正还是负,因此添加该逻辑实际上非常简单:
剩下要做的就是把坐标乘以它们的符号:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
guard
let lat = Double(parts[0].dropLast()),
let long = Double(parts[2].dropLast())
else { return nil }
let latCardinal = parts[1]
let longCardinal = parts[3]
let latSign = latCardinal == "N" ? 1.0 : -1
let longSign = longCardinal == "E" ? 1.0 : -1
return Coordinate(latitude: lat * latSign, longitude: long * longSign)
}
让我们来试一试:
print(parseLatLong("40.6782° N, 73.9442° W"))
// Coordinate(latitude: -40.6782, longitude: -73.9442))
Addressing edge cases
那是不对的。我们试图分析北纬40.6782°,但由于某种原因,结果是-40.6782。为什么?
看起来我忘了N后面的“,”,所以我们的等式检查**parts[1] == "N``"**总是会失败,因此符号将为-1。我们可以去掉逗号来解决这个问题。
let latCardinal = parts[1].dropLast()
这修复了我们正在进行的当前解析。
print(parseLatLong("40.6782° N, 73.9442° W"))
// Coordinate(latitude: 40.6782, longitude: -73.9442))
不过,这仍然不太正确。如果我们给出一个无效的方向,我们仍然会得到一个坐标。
print(parseLatLong("40.6782° X, 73.9442° W"))
// Coordinate(latitude: -40.6782, longitude: -73.9442))
我们可能只想识别何时使用了N/S/E/W字符,如果提供了其他字符,则识别失败。看来我们需要加强这一逻辑:
func parseLatLong(_ string: String) -> Coordinate? {
let parts = string.split(separator: " ")
guard parts.count == 4 else { return nil }
guard
let lat = Double(parts[0].dropLast()),
let long = Double(parts[2].dropLast())
else { return nil }
let latCard = parts[1].dropLast()
guard latCard == "N" || latCardinal == "S" else { return nil }
let longCard = parts[3]
guard longCard == "E" || longCardinal == "W" else { return nil }
let latSign = latCard == "N" ? 1.0 : -1
let longSign = longCard == "E" ? 1.0 : -1
return Coordinate(latitude: lat * latSign, longitude: long * longSign)
}
现在我们的解析器完全失败了。
print(parseLatLong("40.6782° X, 73.9442° W"))
// nil
现在这个函数变得相当复杂,但仍然不太正确。看看这个:
print(parseLatLong("40.6782% N- 73.9442% W"))
// Coordinate(latitude: 40.6782, longitude: -73.9442))
我们将度符号改为百分比,逗号改为减号,但不知何故,它仍然可以解析。
我们可能应该检查该符号,如果它不匹配,解析就会失败。因此,这个解析器还有更多的工作要做,它的复杂性将继续增长。
但这还不是最糟糕的部分。最糟糕的是,这个解析函数是一个一次性的、特别的解决方案,用于解析非常特定的纬度/经度坐标格式。在本例之外,主体内部没有可重用的内容。
我们想提出一个解析的解决方案,使构建小型的、可重用的解析器变得容易,这样就可以将它们拼接在一起,形成一个大型的、复杂的解析器。事实上,如果我们甚至可以将所有的小解析放在一个共享库中,这样我们就可以在许多不同的解析情况下重用它们,那就太好了。
References
Scanner
NSScanner
Ledger Mac App: Parsing Techniques
Parse, don’t validate