要解決上一節遇到的搜尋問題,我們需要深入了解Swift語言的陣列(Array)與字串(String)構造,特別是其「索引(Index)」的部分。
其實上節範例4-3b有一小段程式碼沒有解釋,就是與索引有關,在程式最後一段:
// 範例 4-3b 最後一段
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
這段的作用是讓索引在陣列中循環,如果索引已到達末尾,就返回開頭重新開始,否則就指向下一個索引。範例4-3a也有一段程式碼有同樣目的,但寫法更簡潔:
//範例 4-3a 最後一段
行次 = (行次 + 1) % 行數
4-3a的「行次」相當於4-3b的「索引」,利用除以「行數」所得餘數來控制在陣列中循環,這個方法簡單又方便,為什麼不用呢?這是因為Swift陣列的索引,並不一定總是連續整數,範例4-3b的方法雖然稍麻煩,卻100%通用。以下是進一步說明。
多數情況下,陣列索引的確是以 0 開始的連續整數,以範例4-3b為例,斷句分解後的「分解結果」就是一個這樣的字串陣列,如下圖:

這種情況下,索引是連續整數0, 1, 2, ...,用 索引 = (索引 + 1) % 分解結果.count 恰好可以讓索引在0到122之間循環。但Swift允許陣列的索引不一定是「連續」整數,也不一定從0開始,這個用餘數的方法就無效了,怎麼辦?
Swift所有陣列都會有一個屬性 startIndex 表示起始索引(在上圖為0),還有一個屬性 endIndex 表示索引結束,要注意的是,endIndex 並不是最後一個索引,而是「最後一個索引的下一個值」。例如上圖中,最後一個索引是122,整個陣列的索引範圍是0到122,但是 endIndex 卻是123!
這是Swift語言非常特殊的設計(說不上好壞),也是初學者最容易犯的致命錯誤之一,如果不小心用 endIndex 當作索引取值,會導致 “Index out of range" (索引超出範圍)的錯誤而導致App閃退!
所以,整個陣列的索引範圍,要寫成 startIndex ..< endIndex,也就是說:
分解結果.indices == [分解結果.startIndex ..< 分解結果.endIndex]
在Swift裡面,1...122 與 1..<123 是兩種不同的範圍類型,雖然都代表0到122的連續整數,前者(1...122)是我們過去常用的,類型名稱為 ClosedRange (封閉區間/封閉範圍),後者(1..<123)類型名稱才是 Range,表示以Swift來看,後者應該比較常用。
我們用的字串搜尋方法 .range(of:) 傳回的範圍類型是 Range,而不是 ClosedRange。所以在上節範例,用「分解結果」的每一句去本文的「顯示內容」中搜尋時,會得到該句字串的 Range,示意圖如下:

Range物件有兩個屬性,lowerBound與upperBound分別指定範圍的下限與上限,這裡的上下限是指字串索引的上下限,下限相當於範圍內(子字串)的起始索引,而上限upperBound則是範圍(子字串)的結束索引。
為什麼字串也有索引?字串也是陣列嗎?是的,在Swift內部設計,字串(String)是一個特別的陣列,陣列元素是字串個別的字元(Character)。
最特別的是,Swift 的字串索引並不是連續整數!所以我們只能透過 startIndex, endIndex 以及相關的索引函式來操作,如下圖:

要找到下一個索引,可以用字串物件方法 .index(after:),例如上圖 i = 本文.index(after: i) 會將 i 指定為下一個索引值。
我們可以用一段小程式來測試一下字串的索引值:
// Tested by Heman, 2022/04/04
import Foundation
let 西遊記第一回 = """
第一回 靈根育孕源流出 心性修持大道生
詩曰:
混沌未分天地亂,茫茫渺渺無人見。
自從盤古破鴻濛,開闢從茲清濁辨。
覆載群生仰至仁,發明萬物皆成善。
欲知造化會元功,須看西遊釋厄傳。
蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
"""
print(西遊記第一回.count)
print(西遊記第一回.startIndex)
print(西遊記第一回.endIndex)
let 本文 = 西遊記第一回
var i = 本文.startIndex
while i < 本文.endIndex {
print(本文[i], i)
i = 本文.index(after: i)
}
執行後控制台的輸出如下,可以看出「字串索引」並不是連續的整數:
642 //count: 一共642個「字元」,含中文字、標點符號、空格、換行
Index(_rawBits: 1) //startIndex: 第一個索引值
Index(_rawBits: 124780545) //endIndex: 最後一個索引值後面的結尾
第 Index(_rawBits: 1)
一 Index(_rawBits: 197377)
回 Index(_rawBits: 393985)
Index(_rawBits: 590081)
Index(_rawBits: 655617)
Index(_rawBits: 721153)
Index(_rawBits: 786689)
靈 Index(_rawBits: 852737)
根 Index(_rawBits: 1049345)
....
但字串的索引值到底是什麼?這裡的 _rawBits 又代表什麼?這個我們不用管,因為Apple並沒有公開內部細節(名稱若是以底線”_”開頭,如 _rawBits,就表示內部使用,細節不公開),我們只需透過索引的操作函式去使用即可。
了解字串索引之後,我們終於可以解決上節所遇到的問題,修正後的程式碼如下,只是多增加一個狀態變數「搜尋起點」與幾行程式碼:
// 4-3c 中文斷句 修正版 Substring
// Revised by Heman, 2022/04/03
import PlaygroundSupport
import SwiftUI
struct 小說: View {
var 本文: String
init(_ 字串參數: String) { 本文 = 字串參數 }
@State var 顯示內容 = AttributedString("")
@State var 分解結果: [String] = []
@State var 索引 = 0
@State var 搜尋起點 = AttributedString("").startIndex
let 定時器 = Timer.publish(every: 0.3, on: .main, in: .common).autoconnect()
var body: some View {
Text(顯示內容)
.font(.system(.title3))
.onAppear {
let 初步斷句 = 本文.components(separatedBy: .punctuationCharacters.union(.whitespacesAndNewlines))
分解結果 = 初步斷句.filter { 句子 in
句子 != ""
}
索引 = 分解結果.startIndex
// 搜尋起點 = AttributedString(本文).startIndex
}
.onReceive(定時器) { _ in
顯示內容 = AttributedString(本文)
let 搜尋範圍 = 搜尋起點 ..< 顯示內容.endIndex
if let 範圍 = 顯示內容[搜尋範圍].range(of: 分解結果[索引]) {
顯示內容[範圍].foregroundColor = .white
顯示內容[範圍].backgroundColor = .cyan
print(範圍.lowerBound, 範圍.upperBound)
搜尋起點 = 範圍.upperBound
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
搜尋起點 = 顯示內容.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
}
}
}
let 西遊記第一回 = """
第一回 靈根育孕源流出 心性修持大道生
詩曰:
混沌未分天地亂,茫茫渺渺無人見。
自從盤古破鴻濛,開闢從茲清濁辨。
覆載群生仰至仁,發明萬物皆成善。
欲知造化會元功,須看西遊釋厄傳。
蓋聞天地之數,有十二萬九千六百歲為一元。將一元分為十二會,乃子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每會該一萬八百歲。且就一日而論:子時得陽氣,而丑則雞鳴﹔寅不通光,而卯則日出﹔辰時食後,而巳則挨排﹔日午天中,而未則西蹉﹔申時晡,而日落酉,戌黃昏,而人定亥。譬於大數,若到戌會之終,則天地昏曚而萬物否矣。再去五千四百歲,交亥會之初,則當黑暗,而兩間人物俱無矣,故曰混沌。又五千四百歲,亥會將終,貞下起元,近子之會,而復逐漸開明。邵康節曰:「冬至子之半,天心無改移。一陽初動處,萬物未生時。」到此,天始有根。再五千四百歲,正當子會,輕清上騰,有日,有月,有星,有辰。日、月、星、辰,謂之四象。故曰,天開於子。又經五千四百歲,子會將終,近丑之會,而逐漸堅實。《易》曰:「大哉乾元!至哉坤元!萬物資生,乃順承天。」至此,地始凝結。再五千四百歲,正當丑會,重濁下凝,有水,有火,有山,有石,有土。水、火、山、石、土,謂之五形。故曰,地闢於丑。又經五千四百歲,丑會終而寅會之初,發生萬物。曆曰:「天氣下降,地氣上升﹔天地交合,群物皆生。」至此,天清地爽,陰陽交合。再五千四百歲,正當寅會,生人,生獸,生禽,正謂天地人,三才定位。故曰,人生於寅。
"""
PlaygroundPage.current.setLiveView(小說(西遊記第一回))
解決問題的關鍵,就在於索引範圍的操作:
let 搜尋範圍 = 搜尋起點 ..< 顯示內容.endIndex
if let 範圍 = 顯示內容[搜尋範圍].range(of: 分解結果[索引]) {
搜尋起點 = 範圍.upperBound
if 分解結果.index(after: 索引) == 分解結果.endIndex {
索引 = 分解結果.startIndex
搜尋起點 = 顯示內容.startIndex
} else {
索引 = 分解結果.index(after: 索引)
}
}
每次搜尋到子字串範圍後,就將「搜尋起點」指定為子字串範圍的upperBound(尾端之後),再將「搜尋範圍」設為「從 upperBound 到本文的 endIndex」,這樣前面搜尋過的子字串,就不會再被搜尋啦。
執行過程影片如下:
💡 註解
- 為什麼Swift陣列索引不一定是連續整數?這是一種通用化的做法,只保持陣列最基本的特性,也就是(1)元素由相同類型組成 (2)元素之間有前後次序關係,這樣可以保持最大彈性。其實陣列還有第3個特性,就是元素可以隨機存取(random access),比較技術性,不懂沒關係。
- 猜猜看空字串與空陣列的 startIndex, endIndex 是何值?可試試下面的小程式
//Tested by Heman, 2022/04/05
import Foundation
let a = ""
print(a.startIndex, a.endIndex)
print(a.first)
let b: [String] = []
print(b.startIndex, b.endIndex)
print(b.first) - 筆者常會記錯,將陣列第一個索引 startIndex 記為 firstIndex。陣列屬性的確有first,是指第一個元素,如測試範例的 a.first, b.first。
- 為什麼 print() 抓空字串的 endIndex 的元素(a.first)不會發生 "Index out of range" 的錯誤?