Swift 4 中的字串

SwiftGG翻譯組發表於2018-08-09

原文連結:swift.gg/2018/08/09/…
作者:Ole Begemann
譯者:東莞大唐和尚
校對:pmst,Firecrest
定稿:CMB

這個系列中其他文章:

  1. Swift 1 中的字串
  2. Swift 3 中的字串
  3. Swift 4 中的字串(本文)

本文節選自我們的新書《高階 Swift 程式設計》「字串」這一章。《高階 Swift 程式設計》新版本已根據 Swift 4 的新特性修訂補充,新版現已上市。

所有的現代程式語言都有對 Unicode 編碼字串的支援,但這通常只意味著它們的原生字串型別可以儲存 Unicode 編碼的資料——並不意味著所有像獲取字串長度這樣簡單的操作都會得到「合情合理」的輸出結果。

實際上,大多數語言,以及用這些語言編寫的大多數字符串操作程式碼,都表現出對Unicode固有複雜性的某種程度的否定。這可能會導致一些令人不開心的錯誤

Swift 為了字串的實現支援 Unicode 做出了巨大的努力。Swift 中的 String(字串)是一系列 Character 值(字元)的集合。這裡的 Character 指的是人們視為單個字母的可讀文字,無論這個字母是由多少個 Unicode 編碼字元組成。因此,所有對於 Collection(集合)的操作(比如 count 或者 prefix(5))也同樣是按照使用者所理解的字母來操作的。

這樣的設計在正確性上無可挑剔,但這是有代價的,主要是人們對它不熟悉。如果你習慣了熟練操作其他程式語言裡字串的整數索引,Swift 的設計會讓你覺得笨重不堪,讓你感覺到奇怪。為什麼 str[999] 不能獲得字串第一千個字元?為什麼 str[idx+1] 不能獲得下一個字元?為什麼不能用類似 "a"..."z" 的方式遍歷一個範圍的 Character(字元)?

同時,這樣的設計對程式碼效能也有一定的影響:String 不支援隨意獲取。換句話說,獲得一個任意字元不是 O(1) 的操作——當字元寬度是個變數的時候,字串只有檢視過前面所有字元之後,才會知道第 n 個字元儲存在哪裡。

在本章中,我們一起來詳細討論一下 Swift 中字串的設計,以及一些獲得功能和效能最優的技巧。不過,首先我們要先來學習一下 Unicode 編碼的專業知識。

Unicode:拋棄固定寬度

本來事情很簡單。ASCII編碼 的字串用 0 到 127 之間的一系列整數表示。如果使用 8 位元的二進位制陣列合表示字元,甚至還多餘一個位元!由於每個字元的長度固定,所以 ASCII 編碼的字串是可以隨機獲取的。

但是,如果不是英語而是其他國家的語言的話,其中的一些字元 ASCII 編碼是不夠的(其實即使是說英語的英國也有一個"£"符號)。這些語言中的特殊字元大多數都需要超過 7 位元的編碼。在 ISO 8859 標準中,就用多出來的那個位元定義了 16 種超出 ASCII 編碼範圍的編碼,比如第一部分(ISO8859-1)包括了幾種西歐語言的編碼,第五部分包括了對西裡爾字母語言的編碼。

但這樣的做法其實還有侷限。如果你想根據 ISO8859 標準,用土耳其語寫古希臘語的話,你就不走運了,因為你要麼得選擇第七部分(拉丁語/希臘語)或者第九部分(土耳其語)。而且,總的來說 8 個位元的編碼空間無法涵蓋多種語言。例如,第六部分(拉丁語/阿拉伯語)就不包含同樣使用阿拉伯字母的烏爾都語和波斯語中的很多字元。同時,越南語雖然使用的也是拉丁字母,但是有很多變音組合,這種情況只有替換掉一些原有 ASCII 編碼的字母才可能儲存到 8 個位元的空間裡。而且,這種方法不適用其他很多東亞語言。

當固定長度編碼空間不足以容納更多字元時,你要做一個選擇:要麼提高儲存空間,要麼採用變長編碼。起先,Unicode 被定義為 2 位元組固定寬度的格式,現在我們稱之為 UCS-2。彼時夢想尚未照進現實,後來人們發現,要實現大部分的功能,不僅 2 位元組不夠,甚至4個位元組都遠遠不夠。

所以到了今天,Unicode 編碼的寬度是可變的,這種可變有兩個不同的含義:一是說 Unicode 標量可能由若干個程式碼塊組成;一是說字元可能由若干個標量組成。

Unicode 編碼的資料可以用多種不同寬度的 程式碼單元(code unit 來表示,最常見的是 8 位元(UTF-8)和 16(UTF-16)位元。UTF-8 編碼的一大優勢是它向後相容 8 位元的 ACSCII 編碼,這也是它取代 ASCII 成為網際網路上最受歡迎的編碼的一大原因。在 Swift 裡面用 UInt16UInt8 的數值代表UTC-16和UTF-8的程式碼單元(別名分別是 Unicode.UTF16.CodeUnitUnicode.UTF8.CodeUnit)。

一個 程式碼點(code point) 指的是 Unicode 編碼空間中一個單一的值,可能的範圍是 00x10FFFF (換算成十進位制就是 1114111)。現在已使用的程式碼點大約只有 137000 個,所以還有很多空間可以儲存各種 emoji。如果你使用的是 UTF-32 編碼,那麼一個程式碼點就是一個程式碼塊;如果使用的是 UTF-8 編碼,一個程式碼點可能有 1 到 4 個程式碼塊組成。最初的 256 個 Unicode 編碼的程式碼點對應著 Latin-1 中的字母。

Unicode 標量 跟程式碼點基本一樣,但是也有一點不一樣。除開 0xD800-0xDFFF 中間的 2048 個代理程式碼點(surrogate code points)之外,他們都是一樣的。這 2048 個代理程式碼點是 UTF-16 中用作表示配對的字首或尾綴編碼。標量在 Swift 中用 \u{xxxx} 表示,xxxx 代表十進位制的數字。所以歐元符號在Swift裡可以表示為 "€""\u{20AC}"。與之對應的 Swift 型別是 Unicode.Scalar,一個 UInt32 數值的封裝。

為了用一個程式碼單元代表一個 Unicode scalar,你需要一個 21 位元的編碼機制(通常會達到 32 位元,比如 UTF-32),但是即便這樣你也無法得到一個固定寬度的編碼:最終表示字元的時候,Unicode 仍然是一個寬度可變的編碼格式。螢幕上顯示的一個字元,也就是使用者通常認為的一個字元,可能需要多個 scalar 組合而成。Unicode 編碼裡把這種使用者理解的字元稱之為 (擴充套件)字位集 (extended grapheme cluster)。

標量組成字位集的規則決定了如何分詞。例如,如果你按了一下鍵盤上的退格鍵,你覺得你的文字編輯器就應該刪除掉一個字位集,即使那個“字元”是由多個 Unicode scalars 組成,且每個 scalar 在計算機記憶體上還由數量不等的程式碼塊組成的。Swift中用 Character 型別代表字位集。Character 型別可以由任意數量的 Scalars 組成,只要它們形成一個使用者看到的字元。在下一部分,我們會看到幾個這樣的例子。

字位集和規範對等(Canonical Equivalence)

組合符號

這裡有一個快速瞭解 String 型別如何處理 Unicode 編碼資料的方法:寫 “é” 的兩種不同方法。Unicode 編碼中定義為 U+00E9Latin small letter e with acute(拉丁字母小寫 e 加重音符號),單一值。但是你也可以寫一個正常的 小寫 e,再跟上一個 U+0301combining acute accent(重音符號)。在這兩種情況中,顯示的都是 é,使用者當然會認為這兩個 “résumé” 無論使用什麼方式打出來的,肯定是相等的,長度也都是 6 個字元。這就是 Unicode 編碼規範中所說的 規範對等(Canonically Equivalent)

而且,在 Swift 語言裡,程式碼行為和使用者預期是一致的:

let single = "Pok\u{00E9}mon"
let double = "Poke\u{0301}mon"
複製程式碼

它們顯示也是完全一致的:

(single, double) // → ("Pokémon", "Pokémon")
複製程式碼

它們的字元數也是一樣的:

single.count // → 7
double.count // → 7
複製程式碼

因此,比較起來,它們也是相等的:

single == double // → true
複製程式碼

只有當你通過底層的顯示方式檢視的時候,才能看到它們的不同之處:

single.utf16.count // → 7
double.utf16.count // → 8
複製程式碼

這一點和 Foundation 中的 NSString 對比一下:在 NSString 中,兩個字串是不相等的,它們的 length (很多程式設計師都用這個方法來確定字串顯示在螢幕上的長度)也是不同的。

import Foundation

let nssingle = single as NSString
nssingle.length // → 7
let nsdouble = double as NSString
nsdouble.length // → 8
nssingle == nsdouble // → false
複製程式碼

這裡,== 是定義為比較兩個 NSObject

extension NSObject: Equatable {
    static func ==(lhs: NSObject, rhs: NSObject) -> Bool {
        return lhs.isEqual(rhs)
    }
}
複製程式碼

NSString 中,這個操作會比較兩個 UTF-16 程式碼塊。很多其他語言裡面的字串 API 也是這樣的。如果你想做的是一個規範比較(cannonical comparison),你必須用 NSString.compare(_:) 。沒聽說過這個方法?將來遇到一些找不出來的 bug ,以及一些怒氣衝衝的國外使用者的時候,夠你受的。

當然,只比較程式碼單元有一個很大的優點是:速度快!在 Swift 裡,你也可以通過 utf16 檢視來實現這一點:

single.utf16.elementsEqual(double.utf16) // → false
複製程式碼

為什麼 Unicode 編碼要支援同一字元的多種展現方式呢?因為 Latin-1 中已經有了類似 é 和 ñ 這樣的字母,只有靈活的組合方式才能讓長度可變的 Unicode 程式碼點相容 Latin-1。

雖然使用起來會有一些麻煩,但是它使得兩種編碼之間的轉換變得簡單快速。

而且拋棄變音形式也沒有什麼用,因為這種組合不僅僅只是兩個兩個的,有時候甚至是多種變音符號組合。例如,約魯巴語中有一個字元是 ọ́ ,可以用三種不同方式寫出來:一個 ó 加一點,一個 ọ 加一個重音,或者一個 o 加一個重音和一點。而且,對最後一種方式來說,兩個變音符號的順序無關緊要!所以,下面幾種形式的寫法都是相等的:

let chars: [Character] = [
    "\u{1ECD}\u{300}",      // ọ́
    "\u{F2}\u{323}",        // ọ́
    "\u{6F}\u{323}\u{300}", // ọ́
    "\u{6F}\u{300}\u{323}"  // ọ́
]
let allEqual = chars.dropFirst()
    .all(matching: { $0 == chars.first }) // → true
複製程式碼

all(matching:) 方法用來檢測條件是否對序列中的所有元素都為真:

extension Sequence {
    func all(matching predicate: (Element) throws -> Bool) rethrows -> Bool {
        for element in self {
            if try !predicate(element) {
                return false
            }
        }
        return true
    }
}
複製程式碼

其實,一些變音符號可以加無窮個。這一點,網上流傳很廣 的一個顏文字表現得很好:

let zalgo = "s̼̐͗͜o̠̦̤ͯͥ̒ͫ́ͅo̺̪͖̗̽ͩ̃͟ͅn̢͔͖͇͇͉̫̰ͪ͑"

zalgo.count // → 4
zalgo.utf16.count // → 36
複製程式碼

上面的例子中,zalgo.count 返回值是 4(正確的),而 zalgo.utf16.count 返回值是 36。如果你的程式碼連網上的顏文字都無法正確處理,那它有什麼好的?

Unicode 編碼的字位分割規則甚至在你處理純 ASCII 編碼的字元的時候也有影響,回車 CR 和 換行 LF 這一個字元對在 Windows 系統上通常表示新開一行,但它們其實只是一個字位:

// CR+LF is a single Character
let crlf = "\r\n"
crlf.count // → 1
複製程式碼

Emoji

許多其他程式語言處理包含 emoji 的字串的時候會讓人意外。許多 emoji 的 Unicode 標量無法儲存在一個 UTF-16 的程式碼單元裡面。有些語言(例如 Java 或者 C#)把字串當做 UTF-16 程式碼塊的集合,這些語言定義"?"為兩個 “字元” 的長度。Swift 處理上述情況更為合理:

let oneEmoji = "?" // U+1F602
oneEmoji.count // → 1
複製程式碼

注意,重要的是字串如何展現給程式的,不是字串在記憶體中是如何儲存的。對於非 ASCII 的字串,Swift 內部用的是 UTF-16 的編碼,這只是內部的實現細節。公共 API 還是基於字位集(grapheme cluster)的。

有些 emoji 由多個標量組成。emoji 中的國旗是由兩個對應 ISO 國家程式碼的地區識別符號號(reginal indicator symbols)組成的。Swift 裡將一個國旗視為一個 Character

let flags = "????"
flags.count // → 2
複製程式碼

要檢查一個字串由幾個 Unicode 標量組成,需要使用 unicodeScalars 檢視。這裡,我們將 scalar 的值格式化為十進位制的數字,這是程式碼點的普遍格式:

flags.unicodeScalars.map {
    "U+\(String($0.value, radix: 16, uppercase: true))"
}
// → ["U+1F1E7", "U+1F1F7", "U+1F1F3", "U+1F1FF"]
複製程式碼

膚色是由一個基礎的角色符號(例如?)加上一個膚色修飾符(例如?)組成的,Swift 裡是這麼處理的:

let skinTone = "??" // ? + ?
skinTone.count // → 1
複製程式碼

這次我們用 Foundation API 裡面的 ICU string transform 把 Unicode 標量轉換成官方的 Unicode 名稱:

extension StringTransform {
    static let toUnicodeName = StringTransform(rawValue: "Any-Name")
}

extension Unicode.Scalar {
    /// The scalar’s Unicode name, e.g. "LATIN CAPITAL LETTER A".
    var unicodeName: String {
        // Force-unwrapping is safe because this transform always succeeds
        let name = String(self).applyingTransform(.toUnicodeName,
            reverse: false)!

        // The string transform returns the name wrapped in "\\N{...}". Remove those.
        let prefixPattern = "\\N{"
        let suffixPattern = "}"
        let prefixLength = name.hasPrefix(prefixPattern) ? prefixPattern.count : 0
        let suffixLength = name.hasSuffix(suffixPattern) ? suffixPattern.count : 0
        return String(name.dropFirst(prefixLength).dropLast(suffixLength))
    }
}

skinTone.unicodeScalars.map { $0.unicodeName }
// → ["GIRL", "EMOJI MODIFIER FITZPATRICK TYPE-4"]
複製程式碼

這段程式碼裡面最重要的是對 applyingTransform(.toUnicodeName,...) 的呼叫。其他的程式碼只是把轉換方法返回的名字清理了一下,移除了括號。這段程式碼很保守:先是檢查了字串是否符合期望的格式,然後計算了從頭到尾的字元數。如果將來轉換方法返回的名字格式發生了變化,最好輸出原字串,而不是移除多餘字元後的字串。

注意我們是如何使用標準的集合(Collection)方法 dropFirstdroplast 進行移除操作的。如果你想對字串進行操作,但是又不想對字串進行手動索引,這就是一個很好的例子。這個方法同樣也很高效,因為 dropFisrtdropLast 方法返回的是 Substring 值,它們只是原字串的一部分。在我們最後一步建立一個新的 String 字串,賦值為這個 substring 之前,它是不佔用新的記憶體的。關於這一點,我們在這一章的後面還有很多東西會涉及到。

Emoji 裡面對家庭和夫妻的表示(例如 ?‍?‍?‍? 和 ?‍❤️‍?)是 Unicode 編碼標準面臨的又一個挑戰。由於性別以及人數的可能組合太多,為每種可能的組合都做一個程式碼點肯定會有問題。再加上每個人物角色的膚色的問題,這樣做幾乎不可行。Unicode 編碼是這樣解決這個問題的,它將這種 emoji 定義為一系列由零寬度連線符(zero-width joiner)聯絡起來的 emoji 。這樣下來,這個家庭 ?‍?‍?‍? emoji 其實就是 man ? + ZWJ + woman ? + ZWJ + girl ? + ZWJ + boy ?。而零寬度連線符的作用就是讓作業系統知道這個 emoji 應該只是一個字素。

我們可以驗證一下到底是不是這樣:

let family1 = "?‍?‍?‍?"
let family2 = "?\u{200D}?\u{200D}?\u{200D}?"
family1 == family2 // → true
複製程式碼

在 Swift 裡,這樣一個 emoji 也同樣被認為是一個字元 Character

family1.count // → 1
family2.count // → 1
複製程式碼

2016年新引入的職業型別 emoji 也是這種情況。例如女性消防隊員 ?‍? 就是 woman ? + ZWJ + fire engine ?。男性醫生就是 man ? + ZWJ + staff of aesculapius ⚕(譯者注:阿斯克勒庇厄斯,是古希臘神話中的醫神,一條蛇繞著一個柱子指醫療相關職業)。

將這些一系列零寬度連線符連線起來的 emoji 渲染為一個字素是作業系統的工作。2017年,Apple 的作業系統表示支援 Unicode 編碼標準下的 RGI 系列(“recommended for general interchange”)。如果沒有字位可以正確表示這個序列,那文字渲染系統會回退,顯示為每個單個的字素。

注意這裡又可能會導致一個理解偏差,即使用者所認為的字元和 Swift 所認為的字位集之間的偏差。我們上面所有的例子都是擔心程式語言會把字元數多了,但這裡正好相反。舉例來說,上面那個家庭的 emoji 裡面涉及到的膚色 emoji 還未被收錄到 RGI 集合裡面。但儘管大多數作業系統都把這系列 emoji 渲染成多個字素,但 Swift 仍舊只把它們看做一個字元,因為 Unicode 編碼的分詞規則和渲染無關:

// Family with skin tones is rendered as multiple glyphs
// on most platforms in 2017
let family3 = "??\u{200D}??\u{200D}??\u{200D}??" // → "??‍??‍??‍??"
// But Swift still counts it as a single Character
family3.count // → 1
複製程式碼

Windows 系統已經可以把這些 emoji 渲染為一個字素了,其他作業系統廠家肯定也會盡快支援。但是,有一點是不變的:無論一個字串的 API 如何精心設計,都無法完美支援每一個細小的案例,因為文字太複雜了。

過去 Swift 很難跟得上 Unicode 編碼標準改變的步伐。Swift 3 渲染膚色和零寬度連線符系列 emoji 是錯誤的,因為當時的分詞演算法是根據上一個版本的 Unicode 編碼標準。自 Swift 4 起,Swift 開始啟用作業系統的 ICU 庫。因此,只要使用者更新他們的作業系統,你的程式就會採用最新的 Unicode 編碼標準。硬幣的另一面是,你開發中看到的和使用者看到的東西可能是不一樣的。

程式語言如果全面考慮 Unicode 編碼複雜性的話,在處理文字的時候會引發很多問題。上面這麼多例子我們只是談及其中的一個問題:字串的長度。如果一個程式語言不是按字素集處理字串,而這個字串又包含很多字元序列的話,這時候一個簡簡單單的反序輸出字串的操作會變得多麼複雜。

這不是個新問題,但是 emoji 的流行使得糟糕的文字處理方法造成的問題更容易浮出表面,即使你的使用者群大部分是說英語的。而且,錯誤的級別也大大提升:十年前,弄錯一個變音符號的字母可能只會造成 1 個字元數的誤差,現在如果弄錯了 emoji 的話很可能就是 10 個字元數的誤差。例如,一個四人家庭的 emoji 在 UTF-16 編碼下是 11 個字元,在 UTF-8 編碼下就是 25 個字元了:

family1.count // → 1
family1.utf16.count // → 11
family1.utf8.count // → 25
複製程式碼

也不是說其他程式語言就完全沒有符合 Unicode 編碼標準的 API,大部分還是有的。例如,NSString 就有一個 enumerateSubstrings 的方法可以按照字位集遍歷一個字串。但是預設設定很重要,而 Swift 的原則就是預設情況下,就按正確的方式來做。而且如果你需要低一個抽象級別去看,String 也提供不同的檢視,然你可以直接從 Unicode 標量或者程式碼塊的級別操作。下面的內容裡我們還會涉及到這一點。

字串和集合

我們已經看到,String 是一個 Character 值的集合。在 Swift 語言發展的前三年裡,String 這個類在遵守還是不遵守 Collection 集合協議這個問題上左右搖擺了幾次。堅持不要遵守集合協議的人認為,如果遵守的話,程式設計師會認為所有通用的集合處理演算法用在字串上是絕對安全的,也絕對符合 Unicode 編碼標準的,但是顯然有一些特例存在。

舉一個簡單的例子,兩個集合相加,得到的新的集合的長度肯定是兩個子集合長度的和。但是在字串中,如果第一個字串的字尾和第二個字串的字首形成了一個字位集,長度就會有變化了:

let flagLetterJ = "?"
let flagLetterP = "?"
let flag = flagLetterJ + flagLetterP // → "??"
flag.count // → 1
flag.count == flagLetterJ.count + flagLetterP.count // → false
複製程式碼

出於這種考慮,在 Swift 2 和 Swift 3 中,String 並沒有被算作一個集合。這個特性是作為 String 的一個 characters 檢視存在的,和其他幾個集合檢視一樣:unicodeScalarsutf8 和 utf16。選擇一個特定的檢視,就相當於讓程式設計師轉換到另一種“處理集合”的模式,相應的,程式設計師就必須考慮到這種模式下可能產生的問題。

但是,在實際應用中,這個改變提升了學習成本,降低了可用性;單單為了保證在那些極端個例中的正確性(其實在真實應用中很少遇到,除非你寫的是個文字編輯器的應用)做出這樣的改變太不值得了。因此,在 Swift 4 中,String 再次成了一個集合。characters 檢視還在,但是隻是為了向後相容 Swift 3。

雙向獲取,而非任意獲取

然而,String不是一個可以任意獲取的集合,原因的話,上一部分的幾個例子已經展現的很清楚。一個字元到底是第幾個字元取決於它前面有多少個 Unicode scalar,這樣的情況下,根本不可能實現任意獲取。由於這個原因,Swift 裡面的字串遵守雙向獲取(BidirectionalCollection)規則。可以從字串的兩頭數,程式碼會根據相鄰字元的組成,跳過正確數量的位元組。但是,每次訪問只能上移或者下移一個字元。

在寫處理字串的程式碼的時候,要考慮到這種方式的操作對程式碼效能的影響。那些依靠任意獲取來保證程式碼效能的演算法對 Unicode 編碼的字串並不合適。我們看一個例子,我們要獲取一個字串所有 prefix 的列表。我們只需要得到一個從零到字串長度的一系列整數,然後根據每個長度的整數在字串中找到對應長度的 prefix:

extension String {
    var allPrefixes1: [Substring] {
        return (0...self.count).map(self.prefix)
    }
}

let hello = "Hello"
hello.allPrefixes1 // → ["", "H", "He", "Hel", "Hell", "Hello"]
複製程式碼

儘管這段程式碼看起來很簡單,但是執行效能很低。它先是遍歷了字串一次,計算出字串的長度,這還 OK。但是每次對 prefix 進行 n+1 的呼叫都是一次 O(n) 操作,因為 prefix 方法需要從字串的開頭往後找出所需數量的字元。而在一個線性運算裡進行另一個線性運算就意味著演算法已經成了 O(n2) ——隨著字串長度的增加,演算法所需的時間是呈指數級增長的。

如果可能的話,一個高效能的演算法應該是遍歷字串一次,然後通過對字串索引的操作得到想要的子字串。下面是相同演算法的另一個版本:

extension String {
    var allPrefixes2: [Substring] {
        return [""] + self.indices.map { index in self[...index] }
    }
}

hello.allPrefixes2 // → ["", "H", "He", "Hel", "Hell", "Hello"]
複製程式碼

這段程式碼只需要遍歷字串一次,得到字串的索引(indices)集合。一旦完成之後,之後再 map 內的操作就只是 O(1)。整個演算法也只是 O(n)

範圍可替換,不可變

String 還遵從於 RangeReplaceableCollection (範圍可替換)的集合操作。也就是說,你可以先按字串索引的形式定義出一個範圍,然後通過呼叫 replaceSubrange (替換子範圍)方法,替換掉字串中的一些字元。這裡有一個例子。替換的字串可以有不同的長度,甚至還可以是空的(這時候就相當於呼叫 removeSubrange 方法了):

var greeting = "Hello, world!"
if let comma = greeting.index(of: ",") {
    greeting[..<comma] // → "Hello"
    greeting.replaceSubrange(comma..., with: " again.")
}
greeting // → "Hello again."
複製程式碼

同樣,這裡也要注意一個問題,如果替換的字串和原字串中相鄰的字元形成了新的字位集,那結果可能就會有點出人意料了。

字串無法提供的一個類集合特性是:MutableCollection。該協議給集合除 get 之外,新增了一個通過下標進行單一元素 set 的特性。這並不是說字串是不可變的——我們上面已經看到了,有好幾種變化的方法。你無法完成的是使用下標操作符替換其中的一個字元。許多人直覺認為用下標操作符替換一個字元是即時發生的,就像陣列 Array 裡面的替換一樣。但是,因為字串裡的字元長度是不定的,所以替換一個字元的時間和字串的長度呈線性關係:替換一個元素的寬度會把其他所有元素在記憶體中的位置重新洗牌。而且,替換元素索引後面的元素索引在洗牌之後都變了,這也是跟人們的直覺相違背的。出於這些原因,你必須使用 replaceSubrange 進行替換,即使你變化只是一個元素。

字串索引

大多數程式語言都是用整數作為字串的下標,例如 str[5] 就會返回 str 的第六個“字元”(無論這個語言定義的“字元”是什麼)。Swift 卻不允許這樣。為什麼呢?原因可能你已經聽了很多遍了:下標應該是使用固定時間的(無論是直覺上,還是根據集合協議),但是查詢第 n 個“字元”的操作必須查詢它前面所有的位元組。

字串索引(String.Index 是字串及其檢視使用的索引型別。它是個不透明值(opaque value,內部使用的值,開發者一般不直接使用),本質上儲存的是從字串開頭算起的位元組偏移量。如果你想計算第 n 個字元的索引,它還是一個 O(n) 的操作,而且你還是必須從字串的開頭開始算起,但是一旦你有了一個正確的索引之後,對這個字串進行下標操作就只需要 O(1) 次了。關鍵是,找到現有索引後面的元素的索引的操作也會變得很快,因為你只需要從已有索引位元組後面開始算起了——沒有必要從字串開頭開始了。這也是為什麼有序(向前或是向後)訪問字串裡的字元效率很高的原因。

字串索引操作的依據跟你在其他集合裡使用的所有 API 一樣。因為我們最常用的集合:陣列,使用的是整數索引,我們通常使用簡單的算術來操作,所以有一點很容易忘記: index(after:) 方法返回的是下一個字元的索引:

let s = "abcdef"
let second = s.index(after: s.startIndex)
s[second] // → "b"
複製程式碼

使用 index(_:offsetBy:)方法,你可以通過一次操作,自動地訪問多個字元,

// Advance 4 more characters
let sixth = s.index(second, offsetBy: 4)
s[sixth] // → "f"
複製程式碼

如果可能超出字串末尾,你可以加一個 limitedBy: 引數。如果在訪問到目標索引之前到達了字串的末尾,這個方法會返回一個 nil 值。

let safeIdx = s.index(s.startIndex, offsetBy: 400, limitedBy: s.endIndex)
safeIdx // → nil
複製程式碼

比起簡單的整數索引,這無疑使用了更多的程式碼。**這是 Swift 故意的。**如果 Swift 允許對字串進行整數索引,那不小心寫出效能爛到爆的程式碼(比如在一個迴圈中使用整數的下標操作)的誘惑太大了。

然而,對一個習慣於處理固定寬度字元的人來說,剛開始使用 Swift 處理字串會有些挑戰——沒有了整數索引怎麼搞?而且確實,一些看起來簡單的任務處理起來還得大動干戈,比如提取字串的前四個字元:

s[..<s.index(s.startIndex, offsetBy: 4)] // → "abcd"
複製程式碼

不過謝天謝地,你可以使用集合的介面來獲取字串,這意味著許多適用於陣列的方法同樣也適用於字串。比如上面那個例子,如果使用 prefix 方法就簡單得多了:

s.prefix(4) // → "abcd"
複製程式碼

(注意,上面的幾個方法返回的都是子字串 Substring,你可以使用一個 String.init 把它轉換為字串。關於這一部分,我們下一部分會講更多。)

沒有整數索引,迴圈訪問字串裡的字元也很簡單,用 for 迴圈。如果你想按順序排列,使用 enumerated()

for (i, c) in s.enumerated() {
    print("\(i): \(c)")
}
複製程式碼

或者如果你想找到一個特定的字元,你可以使用 index(of:):

var hello = "Hello!"
if let idx = hello.index(of: "!") {
    hello.insert(contentsOf: ", world", at: idx)
}
hello // → "Hello, world!"
複製程式碼

insert(contentsOf:at:) 方法可以在指定索引前插入相同型別的另一個集合(比如說字串裡的字元)。並不一定是另一個字串,你可以很容易地把一個字元的陣列插入到一個字串裡。

子字串

和其他的集合一樣,字串有一個特定的切片型別或者說子序列型別(SubSequence):子字串(Substring)。子字串就像是一個陣列切片(ArraySlice):它是原字串的一個檢視,起始索引和結束索引不同。子字串共享原字串的文字儲存空間。這是一個很大的優勢,對一個字串進行切片操作不佔用記憶體空間。在下面的例子中,建立firstWord變數不佔用記憶體:

let sentence = "The quick brown fox jumped over the lazy dog."
let firstSpace = sentence.index(of: " ") ?? sentence.endIndex
let firstWord = sentence[..<firstSpace] // → "The"
type(of: firstWord) // → Substring.Type
複製程式碼

切片操作不佔用記憶體意義重大,特別是在一個迴圈中,比如你要通過迴圈訪問整個字串(可能會很長)來提取其中的字元。比如在文字中找到一個單詞使用的次數,比如解析一個 CSV 檔案。這裡有一個非常有用的字串處理操作:split。splitCollection 集合中定義的一個方法,它會返回一個子序列的陣列(即 [Substring] )。它最常見的變種就像是這樣:

extension Collection where Element: Equatable {
    public func split(separator: Element, maxSplits: Int = Int.max,
        omittingEmptySubsequences: Bool = true) -> [SubSequence]
}
複製程式碼

你可以這樣使用:

let poem = """
    Over the wintry
    forest, winds howl in rage
    with no leaves to blow.
    """
let lines = poem.split(separator: "\n")
// → ["Over the wintry", "forest, winds howl in rage", "with no leaves to blow."]
type(of: lines) // → Array<Substring>.Type
複製程式碼

這個跟 String 繼承自 NSStringcomponents(separatedBy:) 方法的功能類似,你還可以用一些額外設定比如是否拋棄空的元件。而且在這個操作中,所有輸入字串都沒有建立新的複製。因為還有其他split方法的變種可以完成操作,除了比較字元以外,split 還可以完成更多的事情。下面這個例子是文字換行演算法的一個原始的實現,最後的程式碼計算了行的長度:

extension String {
    func wrapped(after: Int = 70) -> String {
        var i = 0
        let lines = self.split(omittingEmptySubsequences: false) {
            character in
            switch character {
            case "\n", " " where i >= after:
                i = 0
                return true
            default:
                i += 1
                return false
            }
        }
        return lines.joined(separator: "\n")
    }
}

sentence.wrapped(after: 15)
// → "The quick brown\nfox jumped over\nthe lazy dog."
複製程式碼

或者,考慮寫另外一個版本,可以拿到一個包含多個分隔符的序列:

extension Collection where Element: Equatable {
    func split<S: Sequence>(separators: S) -> [SubSequence]
        where Element == S.Element
    {
        return split { separators.contains($0) }
    }
}
複製程式碼

這樣的話,你還可以這麼寫:

"Hello, world!".split(separators: ",! ") // → ["Hello", "world"]
複製程式碼

字串協議 StringProtocol

SubstringString 幾乎有著相同的介面,因為兩種型別都遵守一個共同的字串協議(StringProtocol)。因為幾乎所有的字串API 都是在 StringProtocol 中定義的,所以操作 Substring 跟操作 String 沒有什麼大的區別。但是,在有些情況下,你還必須把子字串轉換為字串的型別;就像所有的切片(slice)一樣,子字串只是為了短時間內的儲存,為了防止一次操作定義太多個複製。如果操作結束之後,你還想保留結果,將資料傳到另一個子系統裡,你應該建立一個新的字串。你可以用一個 Substring 的值初始化一個 String,就像我們在這個例子中做的:

func lastWord(in input: String) -> String? {
    // Process the input, working on substrings
    let words = input.split(separators: [",", " "])
    guard let lastWord = words.last else { return nil }
    // Convert to String for return
    return String(lastWord)
}

lastWord(in: "one, two, three, four, five") // → "five"
複製程式碼

不建議子字串長期儲存背後的原因是子字串一直關聯著原字串。即使一個超長字串的子字串只有一個字元,只要子字串還在使用,那原先的字串就還會在記憶體裡,即使原字串的生命週期已經結束。因此,長期儲存子字串可能導致記憶體洩漏,因為有時候原字串已經無法訪問了,但是還在佔用記憶體。

操作過程中使用子字串,操作結束的時候才建立新的字串,通過這種方式,我們把佔用記憶體的動作推遲到了最後一刻,而且保證了我們只會建立必要的字串。在上面的例子當中,我們把整個字串(可能會很長)分成了一個個的子字串,但是在最後只是建立了一個很短的字串。(例子中的演算法可能效率不是那麼高,暫時忽略一下;從後先前找到第一個分隔符可能是個更好的方法。)

遇到只接受 Substring 型別的方法,但是你想傳遞一個 String 的型別,這種情況很少見(大部分的方法都接受 String 型別或者接受所有符合字串協議的型別),但是如果你確實需要傳遞一個 String 的型別,最便捷的方法是使用範圍操作符:...(range operator),不限定範圍:

// 子字串和原字串的起始和結束的索引完全一致 
let substring = sentence[...]
複製程式碼

Substring 型別是 Swift 4 中的新特性。在 Swift 3 中,String.CharacterView 是自己獨有的切片型別(slice type)。這麼做的優勢是使用者只需要瞭解一種型別,但這也意味這如果儲存一個子字串,整個原字串也會佔據記憶體,即使它正常情況下應該已經被釋放了。Swift 4 損失了一點便捷,換來的是的方便的切片操作和可預測的記憶體使用。

要求 SubstringString 的轉換必須明確寫出,Swift 團隊認為這沒那麼煩人。如果實際應用中大家都覺得問題很大,他們也會考慮直接在編譯器中寫一個 SubstringString 之間的模糊子型別關係(implicit subtype relationship),就像 IntOptional<Int> 的子型別一樣。這樣你就可以隨意傳遞 Substring 型別,編譯器會幫你完成型別轉換。


你可能會傾向於充分利用字串協議,把你所有的 API 寫成接受所有遵守字串協議的例項,而不是僅僅接受 String 字串。但 Swift 團隊的建議是,別這樣

總的來說,我們建議繼續使用字串變數。 使用字串變數,大多數的 API 都會比把它們寫成通用型別(這個操作本身就有一些代價)更加簡潔清晰,使用者在必要的時候進行一些轉換並不需要花費很大的精力。

一些 API 極有可能和子字串一起使用,同時無法泛化到適用於整個序列 Sequence 或集合 Collection 的級別,這些 API 可以不受這條規則的限制。一個例子就是標準庫中的 joined 方法。Swift 4 中,針對遵守字串協議的元素組成的序列(Sequence)新增了一個過載(overload):

extension Sequence where Element: StringProtocol {
    /// 兩個元素中間加上一個特定分隔符後
    /// 合併序列中所有元素,返回一個新的字串
    /// Returns a new string by concatenating the elements of the sequence,
    /// adding the given separator between each element.
    public func joined(separator: String = "") -> String
}
複製程式碼

這樣,你就可以直接對一個子字串的陣列呼叫 joined 方法了,沒必要遍歷一次陣列並且把每個子字串轉換為新的字串。這樣,一切都很方便快速。

數值型別初始器(number type initializer)可以將字串轉換為一個數字。在 Swift 4 中,它也接受遵守字串協議的值。如果你要處理一個子字串的陣列的話,這個方法很順手:

let commaSeparatedNumbers = "1,2,3,4,5"
let numbers = commaSeparatedNumbers
    .split(separator: ",").flatMap { Int($0) }
// → [1, 2, 3, 4, 5]
複製程式碼

由於子字串的生命週期很短,所以不建議方法的返回值是子字串,除非是序列 Sequence 或集合 Collection 的一些返回切片的 API。如果你寫了一個類似的方法,只對字串有意義,那讓它的返回值是子字串,好讓讀者明白這個方法並不會產生複製,不會佔用記憶體。建立新字串的方法需要佔用記憶體,比如 uppercased(),這類的方法應該返回 String 字串型別的值。

如果你想為字串型別擴充套件新的功能, 好的辦法是將擴充套件放在字串協議 StringProtocol 上,保證 API 在字串和子字串層面的一致性。字元權協議的設計初衷就是替換原先在字串基礎上做的擴充套件功能。如果你想把現有的擴充套件從字串轉移到字串協議上,你要做的唯一改變就是,把傳遞 Self 給只接受具體 String 值的 API替換為 String(Self)

需要記住的一點是,從 Swift 4 開始,如果你有一些自定義的字串型別,不建議遵守字串協議StringProtocol。官方文件明確警告:

不要做任何新的遵守字串協議 StringProtocol 的宣告。只有標準庫裡的 StringSubstring 是有效的遵守型別。

允許開發者寫自己的字串型別(比如有特殊的儲存優化或效能優化)是終極目標,但是現階段協議的設計還沒有最終確定,所以現在就啟用它可能會導致你的程式碼在 Swift 5裡無法正常執行。

… <SNIP> <內容有刪減>…

總結

Swift 語言裡的字串跟其他所有的主流程式語言裡的字串差異很大。當你習慣於把字串當做程式碼塊的陣列後,你得花點時間轉化思維,習慣 Swift 的處理方法:它把遵守 Unicode 編碼標準放在簡潔前面。

總的來講,我們認為 Swift 的選擇是正確的。Unicode 編碼文字比其他程式語言所認為的要複雜得多。長遠來看,處理你可能寫出來的 bug 的時間肯定比學習新的索引方式(忘記整數索引)所需的時間多。

我們已經習慣於任意獲取“字元”,以至於我們都忘了其實這個特性在真正的字串處理的程式碼裡很少用到。我們希望通過這一章裡的例子可以說服大家,對於大多數常規的操作,簡單的按序遍歷也完全 OK。強迫你清楚地寫出你想在哪個層面(字位集,Unicode scalar,UTF-16 程式碼塊,UTF-8 程式碼塊)處理字串是另一項安全措施;讀你程式碼的人會對你心存感激的。

2016年7月,Chris Lattner 談到了 Swift 語言字串處理的目標,他最後是這麼說的:

我們的目標是在字串處理上超越 Perl。

當然 Swift 4 還沒有實現這個目標——很多想要的特性還沒實現,包括把 Foundation 庫中的諸多字串 API 轉移到標準庫,正規表示式的自然語言支援,字串格式化和解析 API,更強大的字串插入功能。好訊息是 Swift 團隊已經表示 會在將來解決所有這些問題


如果喜歡本文的話,請考慮購買全書。謝謝!

全書中第一張是本文的兩本。討論了其他的一些問題,包括如何使用以及什麼時候使用字串的程式碼塊檢視,如何和 Foundation裡的處理字串的 API(例如 NSRegularExpression 或者 NSAttributedString) 配合處理。貼別是後面這個問題很難,而且很容易犯錯。除此之外還討論了其他標準庫裡面機遇字串的 API,例如文字輸出流(TextOutputStream)或自定義字串轉換(CustomStringConvertible)。

相關文章