第七章——字串(簡單的正規表示式匹配器)

bestswifter發表於2017-12-27

本文系閱讀閱讀原章節後總結概括得出。由於需要我進行一定的概括提煉,如有不當之處歡迎讀者斧正。如果你對內容有任何疑問,歡迎共同交流討論。

在本節中,我們會通過自己實現一個正規表示式匹配器來說明“字串切片依然是字串”這一特性的用途。首先定義一個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迴圈雖然醜,但也只能這樣解決問題。

相關文章