Swift 4 中的字符串

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

這個系列中其餘文章:git

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

本文節選自咱們的新書《高級 Swift 編程》「字符串」這一章。《高級 Swift 編程》新版本已根據 Swift 4 的新特性修訂補充,新版現已上市。程序員

全部的現代編程語言都有對 Unicode 編碼字符串的支持,但這一般只意味着它們的原生字符串類型能夠存儲 Unicode 編碼的數據——並不意味着全部像獲取字符串長度這樣簡單的操做都會獲得「合情合理」的輸出結果。github

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

Swift 爲了字符串的實現支持 Unicode 作出了巨大的努力。Swift 中的 String(字符串)是一系列 Character 值(字符)的集合。這裏的 Character 指的是人們視爲單個字母的可讀文本,不管這個字母是由多少個 Unicode 編碼字符組成。所以,全部對於 Collection(集合)的操做(好比 count 或者 prefix(5))也一樣是按照用戶所理解的字母來操做的。算法

這樣的設計在正確性上無可挑剔,但這是有代價的,主要是人們對它不熟悉。若是你習慣了熟練操做其餘編程語言裏字符串的整數索引,Swift 的設計會讓你以爲笨重不堪,讓你感受到奇怪。爲何 str[999] 不能得到字符串第一千個字符?爲何 str[idx+1] 不能得到下一個字符?爲何不能用相似 "a"..."z" 的方式遍歷一個範圍的 Character(字符)?express

同時,這樣的設計對代碼性能也有必定的影響:String 不支持隨意獲取。換句話說,得到一個任意字符不是 O(1) 的操做——當字符寬度是個變量的時候,字符串只有查看過前面全部字符以後,纔會知道第 n 個字符儲存在哪裏。編程

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

Unicode:拋棄固定寬度

原本事情很簡單。ASCII編碼 的字符串用 0 到 127 之間的一系列整數表示。若是使用 8 比特的二進制數組合表示字符,甚至還多餘一個比特!因爲每一個字符的長度固定,因此 ASCII 編碼的字符串是能夠隨機獲取的。windows

可是,若是不是英語而是其餘國家的語言的話,其中的一些字符 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)。

相關文章
相關標籤/搜索