本文系阅读阅读原章节后总结概括得出。由于需要我进行一定的概括提炼,如有不当之处欢迎读者斧正。如果你对内容有任何疑问,欢迎共同交流讨论。
在本节中,我们会通过自己实现一个正则表达式匹配器来说明“字符串切片依然是字符串”这一特性的用途。首先定义一个Regex
结构体:
public struct Regex {
private let regexp: String
public init(_ regexp: String) {
self.regexp = regexp
}
}
复制代码
它由一个常量属性regexp
和初始化方法构成。regexp
字符串用于保存正则表达式。因为我们只是实现一个非常简单的匹配器,所以初始化函数中不会判断正则表达式是否有效(总是默认是有效的),也就不需要定义一个可失败构造器了。
为了简单起见,我们实现的匹配器只能匹配^
、$
以及*
、.
这四种元字符。为了方便读者理解下面的代码,首先解释一下这四种元字符的作用:
^
:匹配输入字符串的开始位置。比如^a
可以匹配所有首字母为a
的字符串,因此它可以匹配字符串abcd
,但不能匹配字符串dcba
$
:匹配输入字符串的结束位置。比如d$
可以匹配所有以字母d
结尾的字符串,因此它可以匹配字符串abcd
,但不能匹配字符串dcba
*
:匹配前面的子表达式任意次。比如t*a
可以匹配任意多个字母t
后面加上字母a
,因此它可以匹配abc
(0个t
),可以匹配ttabc
(2个t
),但是不能匹配ttba
。.
:匹配单个任意单个字符。比如.a
可以匹配aa
、ba
、ca
等,但是不能匹配ab
。
接下来,我们拓展Regex
结构体,实现match
函数。参数是待匹配的字符串,如果能匹配则返回true
:
extension Regex {
public func match(text: String) -> Bool {
//如果regex以^开头,只能从参数的第一个字符开始匹配
if regexp.characters.startsWith("^".characters) {
return Regex.matchHere(regexp.characters.dropFirst(), text.characters)
}
//依次从不同的位置开始,尝试匹配每一个子串
for var idx = text.startIndex; ; ++idx {
if Regex.matchHere(regexp.characters, text.characters[idx..<text.endIndex]) {
return true
}
if idx == text.endIndex {break}
}
return false
}
}
复制代码
整个函数的功能并不复杂,只是迭代了字符串的每一个可能子串并尝试匹配。主要的匹配逻辑在静态函数matchHere
中。注意,在这里我们已经处理了^
的匹配。接下来是matchHere
的实现:
extension Regex {
// 从text参数的开始位置匹配
private static func matchHere(regexp: String.CharacterView, _ text: String.CharacterView) -> Bool {
// 空的正则表达式可以匹配所有字符串
if regexp.isEmpty { return true }
// *之前的字符需要调用matchStar函数
if let c = regexp.first where regexp.dropFirst().first == "*" {
return Regex.matchStar(c, regexp.dropFirst().dropFirst(), text)
}
// 如果当前正则表达式中只有一个字符$,当前仅当text也为空时才能匹配
if regexp.first == "$" && regexp.dropFirst().isEmpty {
return text.isEmpty
}
// 如果第一个字符匹配,就从后面的字符开始,继续匹配
if let tc = text.first, let rc = regexp.first where rc == "." || tc == rc {
return matchHere(regexp.dropFirst(), text.dropFirst())
}
return false
}
}
复制代码
这个函数有四个if语句,分别处理四种情况。第一个if语句表示空正则表达式可以匹配所有字符串,第二个if语句处理*
的匹配,待会儿详细分析。第三个if语句处理$
元字符的匹配,最后一个则是正常的匹配逻辑。因为*
可以匹配0到多个字符,它被单独提取出来处理:
extension Regex {
private static func matchStar(c: Character,
_ regexp: String.CharacterView,
_ text: String.CharacterView) -> Bool {
var idx = text.startIndex
repeat { // *可以匹配前面的表达式0次或多次
if matchHere(regexp, text[idx..<text.endIndex]) {
return true
}
} while idx != text.endIndex && (text[idx++] == c || c == ".")
return false
}
}
复制代码
定义完了以后,我们来实际使用体验一下,匹配很简单:
print(Regex("^h..lo*!$").match("hellooooo!")) // 输出结果是:true
复制代码
这个正则表达式的含义如下,显然匹配结果是true
以字母h开头,第四个字母是l,随后有0到多个字母o,以!结尾
在实现匹配器的过程中,我们大量的使用了字符串切片,比如下标脚本和dropFirst
函数,以及可选类型和非可选类型直接的比较。比如这样的代码if regexp.first == "^"
,即使字符串为空字符串也可以正常运行。此时"".first
的值是nil
,而nil
与非可选类型变量的==
运算结果总是false
以上代码最丑陋的部分可能是for循环的使用(如果你读过此书的前面章节,你几乎不会看到for ;;
这种语法的出现)。导致代码很丑的原因是我们要遍历字符串的所有子串,包括最后的空字符串,否则就可以把idx != text.endIndex
写在for循环里了。要包括空字符串是因为确保Regex("$").match("abc")
的返回值为true
。如果字符串能像数组那样可以使用整数下标,我们就可以这样实现:
for idx in text.startIndex...text.endIndex {
if Regex.matchHere(regexp.characters, text.characters[idx..<text.endIndex]) {
return true
}
}
复制代码
此时,最后一次循环中,idx
的值是text.endIndex
,所以text.characters[idx..<text.endIndex]
是一个空字符串。不幸的是这样写会报错。
我们知道定义区间有两种方式:..<
表示右侧开区间,...
表示右侧闭区间。但对应到Range
类型其实只有一个,比如a..<b
对应到Range
类型被表示为Range(start: a, end: b)
,而a...b
会被转换成Range(start: a, end: b.successor())
。对于字符串来说,调用endIndex.successor()
会导致程序崩溃,而数组则不存在这样的问题:
let x = [1,2,3]
print(x.endIndex) // 输出结果:3
print(x.endIndex.successor()) // 输出结果:4
let s = "abc"
print(s.endIndex) // 输出结果:3
print(s.endIndex.successor()) // fatalError: can not increment endIndex
复制代码
数组使用的是整数下标,即使获取了endIndex
的后继也不会出大问题,但字符串下标就要复杂的多了。还是老原因,因为组成一个字符的代码点数量不确定,数组下标在增加时必须读取内存才能知道向后偏移多少。因此调用endIndex.successor()
就会导致访问到不该访问的内存。
因此,如果使用text.startIndex...text.endIndex
会导致程序立刻崩溃。之前的C语言风格的for循环虽然丑,但也只能这样解决问题。