第七章——字符串(简单的正则表达式匹配器)

本文系阅读阅读原章节后总结概括得出。由于需要我进行一定的概括提炼,如有不当之处欢迎读者斧正。如果你对内容有任何疑问,欢迎共同交流讨论。

在本节中,我们会通过自己实现一个正则表达式匹配器来说明“字符串切片依然是字符串”这一特性的用途。首先定义一个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可以匹配aabaca等,但是不能匹配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循环虽然丑,但也只能这样解决问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值