Friday Q&A 2015-11-06:為什么 Swift 中的 String API 如此難用?

作者:Mike Ash,原文鏈接,原文日期:2015-11-06
譯者:Cee;校對(duì):numbbbbb;定稿:numbbbbb

譯者注:可以結(jié)合 WWDC 2015 Session 227 - What's New in Internationalization 一起學(xué)習(xí)

歡迎來到本期因修改了很多次稿而推遲發(fā)布的周五問答。我發(fā)現(xiàn)很多人在使用 Swift 時(shí),都會(huì)抱怨 String API 很難用。它很難學(xué)習(xí)并且設(shè)計(jì)得晦澀難懂,大多數(shù)人希望它能采用其他語言的字符串(String) API 設(shè)計(jì)風(fēng)格。今天我就要來講一下為什么 Swift 中的 String API 會(huì)被設(shè)計(jì)成現(xiàn)在這樣(最起碼要解釋清楚我的看法),以及為什么我最終會(huì)認(rèn)為,就其基礎(chǔ)設(shè)計(jì)而言 Swift 中的 String API 是字符串 API 中設(shè)計(jì)得最好的。

什么是字符串?

在我們討論這點(diǎn)之前,首先需要建立一個(gè)基本的概念。我們總是把字符串想得很膚淺,很少有人能夠深入思考它的本質(zhì)。深思熟慮才能有助于我們理解接下來的內(nèi)容。

從概念上來說,什么字符串呢?從表面上看,字符串就是一段文本。"Hello, World" 是字符串;"/Users/mikeash""Robert'); DROP TABLE Students;--" 也是字符串。

(順道講一下,我認(rèn)為不應(yīng)該把這些不同的文本表述概念看作是同樣的字符串類型。人類可讀的文本、文件路徑、SQL 查詢語句,以及其他所有在概念上講并不相同的東西,在語言表示層面上都應(yīng)該被表示成不同的類型。我覺得這些概念上不同的字符串應(yīng)當(dāng)有不同的類型,這也能大幅減少 bug 數(shù)量。盡管我并沒有發(fā)現(xiàn)有哪個(gè)語言或者標(biāo)準(zhǔn)庫做到了這點(diǎn)。)

那么在底層,這些常見的「文本」概念又是怎么被表示的呢?唔,得看情況。有很多不同的解決方法。

在很多語言中,字符串是用于存放字節(jié)(bytes)的數(shù)組(array)。程序所要做的就是為這些字節(jié)賦值。這種字符串的表示方法在 C++ 中是 std::string 類型,Python 2、Go 和其他語言也是這樣。

C 語言對(duì)于字符串的表示就比較古怪和特殊。在 C 語言中,字符串是指向一串非零字節(jié)序列(sequence of non-zero bytes)的指針,以零字節(jié)位表示字符串的結(jié)束。基本的使其實(shí)和數(shù)組一樣,但是 C 語言中的字符串不能包含零字節(jié)位,并且諸如查詢字符串長度這樣的操作需要掃描內(nèi)存。

很多新語言把字符串定義成了一串 UCS-2 或者 UTF-16 碼元(code unit)的集合。Java、C# 還有 JavaScript 是其中的代表。同樣,在 Objective-C 中也使用了 Cocoa 和 NSString。這可能是一個(gè)歷史遺留問題。Unicode 在 1991 年被提出時(shí)(譯者注:1991 年 10 月發(fā)布 Unicode 1.0.0),當(dāng)時(shí)的系統(tǒng)都是 16 位。很多流行的編程語言在那個(gè)時(shí)代被設(shè)計(jì)出來,并且將 Unicode 作為字符串的構(gòu)成基礎(chǔ)。在 1996 年,Unicode 在 16 位系統(tǒng)上經(jīng)歷了爆發(fā)性的增長(譯者注:1996 年 7 月發(fā)布了 Unicode 2.0,字庫從 7161 個(gè)字元變成了 38950 個(gè)),這些語言再要改變字符串的編碼方式已為時(shí)已晚。這時(shí),由于 UTF-16 的編碼方式能夠?qū)⒏蟮臄?shù)字編碼為一組 16 位碼元的集合,因此將字符串視為 16 位碼元序列的基本想法就這樣延續(xù)了下來。

這種想法的一個(gè)變體就是將字符串定義成 UTF-8 碼元序列,其中組成的碼元是 8 位的。總體上來說和 UTF-16 的表示方法很接近,但是對(duì)于 ASCII 字符串來說,能夠有更加緊湊的表示空間,而且避免了在傳遞字符串進(jìn)入函數(shù)時(shí),由于這些函數(shù)只接受 C 語言風(fēng)格類型(也就是 UTF-8 字符串)而導(dǎo)致的轉(zhuǎn)換。

也有些語言將字符串表示為 Unicode 碼位(code point)指向的一段字符序列。Python 3 中就是這么實(shí)現(xiàn)的,在很多 C 語言實(shí)現(xiàn)中也提供了內(nèi)置的 wchar_t 類型。

簡短概括一下,一個(gè)字符串通常情況下會(huì)被當(dāng)做某些特定字符(character)的序列,其中字符通常是一個(gè)字節(jié),或者是一個(gè) UTF-16 碼元,又或者是一個(gè) Unicode 碼位。

問題

將字符串表示成一段連續(xù)「字符」的序列的確很方便。你可以把字符串看作是數(shù)組(array)(通常情況下就個(gè)數(shù)組),這樣就很容易獲得字符串的子串、從字符串頭部或者尾部取出部分元素、刪除字符串的某部分、獲取字符總數(shù),等等。

問題是我們身邊遍布著 Unicode,而 Unicode 會(huì)讓事情變得很復(fù)雜。簡單看一個(gè)字符串的例子,看一下它是怎么工作的:

aé∞??

每一個(gè) Unicode 碼位都有一串?dāng)?shù)字(寫作 U+nnnn)和一個(gè)供我們看得懂的命名(某種原因使用全大寫的英文字母表示),這樣我們更容易討論單個(gè)字符所表示的內(nèi)容。對(duì)于上面這個(gè)特定的字符串,它包括了:

  • U+0061 LATIN SMALL LETTER A
  • U+0065 LATIN SMALL LETTER E
  • U+0301 COMBINING ACUTE ACCENT
  • U+221E INFINITY
  • U+1D11E MUSICAL SYMBOL G CLEF

讓我們從字符串的中間移除一個(gè)「字符」。對(duì)于這個(gè)「字符」,我們嘗試用 UTF-8、UTF-16 和 Unicode 三種不同的字符編碼方式來講解。

首先將這個(gè)「字符」看作是一個(gè) UTF-8 字符單元。這個(gè)字符串在 UTF-8 下看上去長這樣:

61 65 cc 81 e2 88 9e f0 9d 84 9e
    -- -- ----- -------- -----------
    a  e    ′      ∞          ??

我們來移除第 3 個(gè)「字符」,即第三個(gè)字節(jié)(cc)。結(jié)果是:

61 65 81 e2 88 9e f0 9d 84 9e

這個(gè)字符串已經(jīng)不再是個(gè)合法的 UTF-8 字符串。UTF-8 的字符編碼有三類。對(duì)于那些 0xxxxxxx 表示的,即由 0 開頭的,會(huì)被表示為 ASCII 字符,單獨(dú)歸為第一類。那些看上去形如 11xxxxxx 的,表示一個(gè)多位序列,長度由第一個(gè) 0 的位置決定。第三類表示成 10xxxxxx,說明一個(gè)多位序列的剩余部分。cc(譯者注:即 11001100,劃分在第二類。其中第一個(gè) 0 出現(xiàn)在從 0 開始計(jì)數(shù)的第 2 位,故整個(gè)多位序列由兩個(gè)字節(jié)組成)表示了一個(gè)多位序列的開始,長度是兩個(gè)字節(jié),81(譯者注:即 10000001,劃分在第三類)表示了這個(gè)多位序列的尾部。如果移除了 cc,那么剩下的 81 將會(huì)被留在字符串中。所有 UTF-8 校驗(yàn)器都會(huì)拒絕識(shí)別這個(gè)字符串(譯者注:因?yàn)?81 并不是合法的 UTF-8 頭部字符,只有第一類和第二類的字符是合法的)。如果我們移除了從第三位之后的任意一個(gè)字符,這個(gè)問題依舊會(huì)發(fā)生。

那么如果是第二位呢?如果我們移除了這個(gè)字符,我們會(huì)得到:

61 cc 81 e2 88 9e f0 9d 84 9e
    -- ----- -------- -----------
    a    ′      ∞          ??

看上去這依然是個(gè)合法的 UTF-8 字符串,但是結(jié)果并不是我們所期待的那樣:

á∞??

對(duì)于人類來說,在這個(gè)字符串中的「第二個(gè)字符」應(yīng)該是「é」。但是第二位上的字符僅僅是不帶語調(diào)標(biāo)記的「e」。這個(gè)語調(diào)標(biāo)記被看作是一個(gè)「連接字符(combining character)」,被單獨(dú)添加到前面的字符上。移除第二個(gè)字符僅僅是移去了「e」,導(dǎo)致這個(gè)語調(diào)標(biāo)記連接到了「a」字符上。

那么如果移去首個(gè)字符呢?最終結(jié)果是我們所想要的那樣:

65 cc 81 e2 88 9e f0 9d 84 9e
    -- ----- -------- -----------
    e    ′      ∞          ??

讓我們再把這個(gè)字符串當(dāng)做 UTF-16 編碼來看。在 UTF-16 編碼下,這個(gè)字符串看上去長這個(gè)樣子:

0061 0065 0301 221e d834 dd1e
    ---- ---- ---- ---- ---------
     a    e    ′    ∞       ??

我們嘗試著移除第二個(gè)「字符」:

0061 0301 221e d834 dd1e
    ---- ---- ---- ---------
     a    ′    ∞       ??

和上面在 UTF-8 中出現(xiàn)的問題一樣,刪除了「e」,但是沒有刪除語調(diào)標(biāo)記,導(dǎo)致這個(gè)標(biāo)記附在了「a」上面。

那么如果刪除第五個(gè)字符呢?我們得到了如下的序列:

0061 0065 0301 221e dd1e

和不合法的 UTF-8 編碼是類似的問題,這個(gè)序列也不再是一個(gè)合法的 UTF-16 字符串。序列 d834 dd1e 形成了一組代理對(duì)(surrogate pair),指兩個(gè) 16 位的單元用于表示一個(gè)超過 16 位的碼位(譯者注:具體計(jì)算參考 Wiki)。而讓代理對(duì)中的一部分單獨(dú)出現(xiàn)在字符串中是非法的。在 UTF-8 中通常會(huì)出錯(cuò),而在 UTF-16 中這種狀態(tài)會(huì)被忽略。例如,Cocoa 會(huì)將這個(gè)字符串渲染成這樣:

aé∞?

(譯者注:即平時(shí)出現(xiàn)的亂碼現(xiàn)象。)

那么如果一個(gè)字符串被表示成一串 Unicode 碼位序列呢?字符串看上去是這樣的:

00061 00065 00301 0221E 1D11E
    ----- ----- ----- ----- -----
      a     e     ′     ∞     ??

對(duì)于這種表示方式,我們可以移除任意一個(gè)「字符」而不會(huì)導(dǎo)致產(chǎn)生一個(gè)非法的字符串。但是連接語調(diào)標(biāo)記的問題依然存在。移除第二個(gè)字符將會(huì)是這樣:

00061 00301 0221E 1D11E
    ----- ----- ----- -----
      a     ′     ∞     ??

即使使用這種表示方法,我們也無法確保結(jié)果的正確。

這些通常不是我們能夠簡單想到的問題。英語是鮮有的幾種僅使用 ASCII 字符就能表示的語言。你肯定不想把求職時(shí)的簡歷(Résumé)改成「Resume」吧!一旦超出 ASCII 字符集,這些荒謬的錯(cuò)誤就開始出現(xiàn)了。

字素簇(Grapheme Clusters)

Unicode 中有個(gè)概念叫做字素簇(Grapheme Clusters),本質(zhì)上就是閱讀時(shí)會(huì)被考慮成單個(gè)「字符」的最小單元。在大多數(shù)表示方法中,一個(gè)字素簇就等價(jià)于一個(gè)單獨(dú)的碼位,但是也有可能會(huì)表示成包括語調(diào)標(biāo)記的一部分內(nèi)容。如果我們將上面的例子表示成字素簇的方式,那么很顯然會(huì)是這樣的:

a é ∞ ??

移除任意一個(gè)作為字素簇的單元,留下的內(nèi)容都會(huì)被認(rèn)為是合情合理的。

注意到在這個(gè)例子中,并沒有任何的數(shù)字等值 (numeric equivalents) 存在。這是因?yàn)榕c UTF-8、UTF-16 或者普通的 Unicode 碼位不同,單個(gè)數(shù)字無法在一般情況下表示字素簇 (grapheme cluster) 。所謂字素簇,指的是一個(gè)或多個(gè)碼位的序列集合。一個(gè)字素簇通常會(huì)包含一個(gè)或兩個(gè)碼位,但是某些情況下(比如 Zalgo 中)字素簇中也可能會(huì)包含大量的碼位。例如下面這個(gè)字符串:

e?????????????

這一團(tuán)亂七八糟的字符串包括了 14 個(gè)不同的碼位:

+ U+0065
+ U+20DD
+ U+20DE
+ U+20DF
+ U+20E0
+ U+20E3
+ U+20E4
+ U+20E5
+ U+20E6
+ U+20E7
+ U+20EA
+ U+A670
+ U+A672
+ U+A671

所有的這些碼位單元都表示一個(gè)單獨(dú)的字素簇。

下面有個(gè)有趣的例子。有這樣一個(gè)包含瑞士國旗的字符串:

????

這個(gè)標(biāo)記實(shí)際上包括兩個(gè)碼位:U+1F1E8 U+1F1ED。這兩個(gè)碼位又表示什么意思呢?

+ U+1F1E8 REGIONAL INDICATOR SYMBOL LETTER C
+ U+1F1ED REGIONAL INDICATOR SYMBOL LETTER H

Unicode 包含了 26 個(gè)「Regional indicator symbol」而不是將地球上的所有國家的國旗作為單獨(dú)的碼位。將 C 和 H 的兩個(gè)標(biāo)識(shí)符合起來你就能得到瑞士的國旗。將 M 和 X 合起來會(huì)得到墨西哥國旗。每個(gè)國旗都是一個(gè)單獨(dú)的字符簇,但是由兩個(gè)碼位組成,即四個(gè) UTF-16 碼元或者八個(gè) UTF-8 字節(jié)。

字符串 API 實(shí)現(xiàn)方式

我們發(fā)現(xiàn)字符串有多種理解方法,也有多種表示「字符」的方式。將「字符」當(dāng)做一個(gè)字素簇可能最接近人們對(duì)于「字符」的理解,但是在代碼中操作字符串時(shí),要依據(jù)語言環(huán)境來判斷所謂「字符」的含義。當(dāng)在文本中移動(dòng)插入光標(biāo)時(shí),光標(biāo)經(jīng)過的字符就是指字素簇。當(dāng)為了保證文本滿足 140 字限制的推文時(shí),這里的字符就是 Unicode 碼位。當(dāng)字符串想要保存在限定長度是 80 個(gè)字符的數(shù)據(jù)庫表中時(shí),這里的字符就是個(gè) UTF-8 字節(jié)。

那么當(dāng)你在實(shí)現(xiàn)字符串時(shí),如何來平衡性能、內(nèi)存使用和簡潔代碼三者呢?

通常的回答是選擇一種標(biāo)準(zhǔn)化表示(canonical representation),之后在需要其他表示方法時(shí)進(jìn)行轉(zhuǎn)換。例如,NSString 使用 UTF-16 作為其標(biāo)準(zhǔn)化表示法。整個(gè) API 基于 UTF-16 建立。如果你想要處理 UTF-8 或者 Unicode 碼位,你需要將原始字符串轉(zhuǎn)化成 UTF-8 或者 UTF-32 表示然后再對(duì)結(jié)果進(jìn)行操作。這種處理方式更多是將字符串視為數(shù)據(jù)對(duì)象,而不是視為字符串本身,所以在轉(zhuǎn)換時(shí)并不是很方便。如果你要對(duì)字符簇進(jìn)行操作,還需要使用 rangeOfComposedCharacterSequencesForRange: 方法找到它們和其他字符的分界位置,這是一項(xiàng)非常枯燥的任務(wù)。

Swift 的 String 類型則采用了另外一種方法。在這里面沒有標(biāo)準(zhǔn)化的表示,而是為字符串的不同表示方式提供了視圖(view)。這樣無論處理哪種表示方式,你都能夠靈活自如地操作。

簡述 Swift 中的 String API

在舊版本中的 Swift 中,String 類遵循了 CollectionType 接口,將自己看做是 Character 元素的集合。在 Swift 2 中,這種表示已經(jīng)不復(fù)存在,String 類會(huì)根據(jù)使用的不同情況,展現(xiàn)出不同的表現(xiàn)方式。

這種表示方式還不是很完善,String 仍然有點(diǎn)傾向于 Character 集合的表示方式,它依舊提供了有點(diǎn)類似集合處理的接口:

    public typealias Index = String.CharacterView.Index
    public var startIndex: Index { get }
    public var endIndex: Index { get }
    public subscript (i: Index) -> Character { get }

你可以通過 String 的索引獲得單獨(dú)的 Character。注意,你并不能通過標(biāo)準(zhǔn)的 for in 語法遍歷整個(gè)字符串。

在 Swift 看來,一個(gè)「字符」究竟是什么?正如我們所見,有太多的可能性。Swift 中 String API 的實(shí)現(xiàn)基礎(chǔ)是將一個(gè)字素簇看作一個(gè)「字符」。這看上去是一個(gè)非常不錯(cuò)的選擇,因?yàn)檎缥覀兯姡@種方式符合人類在字符串中對(duì)于一個(gè)「字符」的定義。

不同的視圖在 String 類中作為屬性展現(xiàn)。例如,characters 屬性:

    public var characters: String.CharacterView { get }

CharacterViewCharacter 的一個(gè)集合:

    extension String.CharacterView : CollectionType {
        public struct Index ...
        public var startIndex: String.CharacterView.Index { get }
        public var endIndex: String.CharacterView.Index { get }
        public subscript (i: String.CharacterView.Index) -> Character { get }
    }

這看上去有點(diǎn)像 String 接口本身,除了它遵循 CollectionType 協(xié)議并且擁有所有 CollectionType 提供的方法外,它實(shí)現(xiàn)了劃分(slice)、遍歷(iterate)、映射(map)或者計(jì)數(shù)(count)方法。所以盡管下面的方法是不被允許的:

    for x in "abc" {}

但是這是行得通的:

    for x in "abc".characters {}

你可以使用構(gòu)造函數(shù)從 CharacterView 中獲得一個(gè)字符串:

    public init(_ characters: String.CharacterView)

你甚至可以從隨機(jī)序列中獲取 Character 作為一個(gè)字符串:

    public init<S : SequenceType where S.Generator.Element == Character>(_ characters: S)
    // 譯者注:現(xiàn)在是 public init(_ characters: String.CharacterView)

繼續(xù)我們的旅程,下一個(gè)是 UTF-32 字符視圖。Swift 把 UTF-32 碼元叫做「Unicode 標(biāo)量(unicode scalars)」(譯者注:參看 Unicode scalar values),因?yàn)?UTF-32 碼元與 Unicode 碼位是等同的。這個(gè)(簡化的)接口看上去是這樣的:

    public var unicodeScalars: String.UnicodeScalarView

    public struct UnicodeScalarView : CollectionType, _Reflectable, CustomStringConvertible, CustomDebugStringConvertible {
        public struct Index ...
        public var startIndex: String.UnicodeScalarView.Index { get }
        public var endIndex: String.UnicodeScalarView.Index { get }
        public subscript (position: String.UnicodeScalarView.Index) -> UnicodeScalar { get }
    }

類似于 CharacterView,在 UnicodeScalarView 內(nèi)部也有個(gè) String 的構(gòu)造函數(shù):

    public init(_ unicodeScalars: String.UnicodeScalarView)

不幸的是,UnicodeScalar 序列沒有實(shí)例化方法,所以在操作時(shí)需要做一點(diǎn)額外工作,例如,需要將這些字符轉(zhuǎn)換成數(shù)組,然后再將數(shù)組轉(zhuǎn)化成字符串。同時(shí),在 UnicodeScalarView 中也沒有接受 UnicodeScalar 序列作為參數(shù)的實(shí)例化方法。然而,Swift 提供了一個(gè)在尾部添加元素的函數(shù),所以你可以通過下面三步建立一個(gè) String

    var unicodeScalarsView = String.UnicodeScalarView()
    unicodeScalarsView.appendContentsOf(unicodeScalarsArray)
    let unicodeScalarsString = String(unicodeScalarsView)

接下來是 UTF-16 字符視圖,看上去和其他的也很類似:

    public var utf16: String.UTF16View { get }

    public struct UTF16View : CollectionType {
        public struct Index ...
        public var startIndex: String.UTF16View.Index { get }
        public var endIndex: String.UTF16View.Index { get }
        public subscript (i: String.UTF16View.Index) -> CodeUnit { get }
    }

在這個(gè)視圖中,String 的實(shí)例化方法又有細(xì)微的差別:

    public init?(_ utf16: String.UTF16View)

與其他的方法不同,這是一個(gè)可能會(huì)構(gòu)造失敗的構(gòu)造方法(譯者注:注意 init?)。任何 Character 或者 UnicodeScalar 的序列都是一個(gè)合法的 String,但是對(duì)于以 UTF-16 作為碼元的序列,可能無法將其轉(zhuǎn)化成一個(gè)合法的字符串。當(dāng)內(nèi)容非法時(shí),構(gòu)造方法將返回 nil

將任意一個(gè) UTF-16 碼元序列轉(zhuǎn)換成一個(gè) String 類型的字符串非常困難。UTF16View 沒有公共的構(gòu)造方法,并且只有很少幾個(gè)轉(zhuǎn)換方法。這個(gè)問題的解決方法就是使用全局 transcode 函數(shù),它已經(jīng)遵循 UnicodeCodecType 協(xié)議。UTF8UTF16UTF32 這三個(gè)類中分別實(shí)現(xiàn)了這個(gè)協(xié)議,通過 transcode 函數(shù)可以實(shí)現(xiàn)三者的互相轉(zhuǎn)化,雖然很不優(yōu)雅。對(duì)于輸入,函數(shù)接受一個(gè) GeneratorType 類型的參數(shù),中間通過一個(gè)用于產(chǎn)生輸出結(jié)果每一位的函數(shù)進(jìn)行轉(zhuǎn)化。這可將一個(gè) UTF16 字符串一點(diǎn)一點(diǎn)地轉(zhuǎn)化成 UTF32 類型字符串,接著再將每個(gè) UTF-32 碼元轉(zhuǎn)化成對(duì)應(yīng)的 UnicodeScalar,拼接到 String 中:

    var utf16String = ""
    transcode(UTF16.self, UTF32.self, utf16Array.generate(), { utf16String.append(UnicodeScalar($0)) }, stopOnError: true)
    // 譯者注:transcode 方法的幾個(gè)參數(shù):
    // 1. inputEncoding: InputEncoding.Type
    // 2. _ outputEncoding: OutputEncoding.Type
    // 3. _ input: Input
    // 4. _ output: (OutputEncoding.CodeUnit) -> ()
    // 5. stopOnError: Bool
    // 這里缺少 utf16Array,可以嘗試在第二行代碼前加入
    // let utf16Array = Array(String(count: 9999, repeatedValue: Character("X")).utf16)
    // 來測試結(jié)果

最后我們來看一下 UTF-8 字符視圖。實(shí)現(xiàn)方式和我們之前介紹的一樣:

    public var utf8: String.UTF8View { get }

    public struct UTF8View : CollectionType {
        /// A position in a `String.UTF8View`.
        public struct Index ...
        public var startIndex: String.UTF8View.Index { get }
        public var endIndex: String.UTF8View.Index { get }
        public subscript (position: String.UTF8View.Index) -> CodeUnit { get }
    }

另外在定義中也有一個(gè)構(gòu)造函數(shù)。和 UTF16View 一樣,這也是一個(gè)可能失敗的構(gòu)造方法,因?yàn)橛?UTF-8 碼元組成的序列也有可能是不合法的。

    public init?(_ utf8: String.UTF8View)

和前者類似,這兒也沒有一種簡便的方法將任意一個(gè) UTF-8 碼元組成的序列轉(zhuǎn)換成 String 類型。仍然可以使用 transcode 方法:

    var utf8String = ""
    transcode(UTF8.self, UTF32.self, utf8Array.generate(), { utf8String.append(UnicodeScalar($0)) }, stopOnError: true)
    // 譯者注:自行補(bǔ)充 utf8Array

因?yàn)槊看握{(diào)用 transcode 方法實(shí)在是太痛苦了,我將它們用在了這一對(duì)可能會(huì)構(gòu)造失敗的構(gòu)造函數(shù)中:

    extension String {
        init?<Seq: SequenceType where Seq.Generator.Element == UInt16>(utf16: Seq) {
            self.init()

            guard transcode(UTF16.self,
                            UTF32.self,
                            utf16.generate(),
                            { self.append(UnicodeScalar($0)) },
                            stopOnError: true)
                            == false else { return nil }
        }

        init?<Seq: SequenceType where Seq.Generator.Element == UInt8>(utf8: Seq) {
            self.init()

            guard transcode(UTF8.self,
                            UTF32.self,
                            utf8.generate(),
                            { self.append(UnicodeScalar($0)) },
                            stopOnError: true)
                            == false else { return nil }
        }
    }

通過這個(gè)構(gòu)造函數(shù),我們能將任意一個(gè) UTF-8 或 UTF-16 碼元組成的序列轉(zhuǎn)化成 String 類型的字符串:

    String(utf16: utf16Array)
    String(utf8: utf8Array)

索引

上面介紹的不同視圖都可用于索引 (Indexes)集合,但是它們并是數(shù)組。索引類型是一種非常詭異的自定義結(jié)構(gòu)體(struct)。這意味著你不能通過數(shù)字來讀取不同視圖中的內(nèi)容:

    // all errors
    string[2]
    string.characters[2]
    string.unicodeScalars[2]
    string.utf16[2]
    string.utf8[2]

不過你可以使用集合類型的 startIndex 或者是 endIndex 屬性,并且使用 successor() 或者 advancedBy() 方法來移動(dòng)到合適的位置:

    // these work
    string[string.startIndex.advancedBy(2)]
    string.characters[string.characters.startIndex.advancedBy(2)]
    string.unicodeScalars[string.unicodeScalars.startIndex.advancedBy(2)]
    string.utf16[string.utf16.startIndex.advancedBy(2)]
    string.utf8[string.utf8.startIndex.advancedBy(2)]

這并不是件有趣的事,我們想知道到底發(fā)生了什么?

還記得這些以標(biāo)準(zhǔn)化表示保存在字符串對(duì)象的視圖嗎?當(dāng)你使用了一個(gè)不符合標(biāo)準(zhǔn)化表示形式的視圖時(shí),存儲(chǔ)的數(shù)據(jù)并不能自動(dòng)轉(zhuǎn)化成你想要的形式。

回想一下上面所提到的,不同的編碼方式有不同的大小和長度。這也意味著無法簡單地判斷字符在不同視圖中對(duì)應(yīng)的位置,因?yàn)樗成涞降奈恢檬歉鶕?jù)保存的數(shù)據(jù)不同而不同的。考慮下面這個(gè)字符串:

A?工??

這個(gè) String 類型的字符串在 UTF-32 編碼下的標(biāo)準(zhǔn)化表示是幾個(gè) 32 位整型元素的集合:

0x00041 0x0018e 0x05de5 0x1f11e

我們再站在 UTF-8 編碼的視角上來看這些數(shù)據(jù)。理論上說,這些數(shù)據(jù)就是一組 8 位整型元素的序列:

0x41 0xc6 0x8e 0xe5 0xb7 0xa5 0xf0 0x9f 0x84 0x9e

下面是兩者的映射關(guān)系:

| 0x00041 |  0x0018e  |     0x05de5    |       0x1f11e       |
    |         |           |                |                     |
    |  0x41   | 0xc6 0x8e | 0xe5 0xb7 0xa5 | 0xf0 0x9f 0x84 0x9e |

如果需要獲取在 UTF-8 視圖下索引為 6 的值,那么必須去從 UTF-32 的序列中從頭開始去掃描,然后獲取所在位置所對(duì)應(yīng)的值。

顯然,這是可以做到的。Swift 提供了這種底層方法,但是長得并不好看:string.utf8[string.utf8.startIndex.advancedBy(6)]。為什么不能簡化這種表示,直接用一個(gè)整數(shù)來訪問索引呢?實(shí)際上 Swift 為了加強(qiáng)這種表示犧牲了簡潔性。在一個(gè) UTF8View 能提供 subscript(Int)(譯者注:即下標(biāo)索引) 方法的世界里,我們希望下面兩段代碼是等價(jià)的:

    for c in string.utf8 {
        ...
    }

    for i in 0..<string.utf8.count {
        let c = string.utf8[i]
        ...
    }

這看上去很相似,但是第二個(gè)會(huì)意外地更慢一些。第一個(gè)循環(huán)是一個(gè)線性時(shí)間的掃描,然而第二個(gè)循環(huán)需要對(duì)每次迭代做一次線性掃描,即需要用二次方項(xiàng)的時(shí)間來做迭代遍歷。對(duì)于一個(gè)長度為一百萬的字符串,第一個(gè)循環(huán)只需要 0.1 秒,而第二個(gè)循環(huán)需要 3 個(gè)小時(shí)(在我的 2013 年 MacBook Pro 上進(jìn)行的測試)。

我們再來看另外一個(gè)例子,從字符串中獲得最后一個(gè)字符:

    let lastCharacter = string.characters[string.characters.endIndex.predecessor()]

    let lastCharacter = string.characters[string.characters.count - 1]

第一個(gè)版本會(huì)更快一些。因?yàn)樗苯訌淖址淖詈箝_始,從最后一個(gè) Character 開始的地方從后往前搜索,然后獲取字符。第二個(gè)版本會(huì)掃描整個(gè)字符串……兩次!它首先得掃描整個(gè)字符串來獲取有多少個(gè) Character,接著再一次掃描特定序號(hào)的字符是什么。

類似這樣的 API 在 Swift 中只是有點(diǎn)不同、有點(diǎn)難寫。這些不同之處讓程序員們知道了視圖并不是數(shù)組,它們也沒有數(shù)組的行為。當(dāng)我們使用下標(biāo)索引時(shí),我們事實(shí)上假設(shè)了這種操作是一種效率很高的行為。如果 String 的視圖提供了這種索引,那其實(shí)和我們的主觀假設(shè)相反,只能寫出效率很低的代碼。

使用 String 類來寫代碼吧

在應(yīng)用層面上使用 String 類寫代碼意味著什么呢?

你可以使用頂層 API。舉個(gè)例子,如果你需要判斷一個(gè)字符串是否是以某個(gè)字符開頭的,那不需要對(duì)字符串索引然后獲取第一個(gè)字符并做比較。直接使用 hasPrefix 方法,它已經(jīng)為你準(zhǔn)備好了一切。不要害怕導(dǎo)入 Foundation 庫和使用 NSString 中的方法。當(dāng)你想移除 String 開頭和結(jié)尾多余的空格時(shí),不必手動(dòng)遍歷獲取這些字符,可以直接使用 stringByTrimmingCharactersInSet 方法。

如果你需要做一些字符層面的事情,那么就要想象一下,對(duì)于特定情況一個(gè)「字符」意味著什么。通常,正確答案是指一個(gè)字素簇,這在 Swift 中表示成 Character 類型,展現(xiàn)在 characters 視圖中。

無論你需要對(duì)文本做些什么事情,都要思考一下對(duì)文本從頭到尾線性掃描的事情。諸如計(jì)算有多少個(gè)字符、查找中間的字符這類操作會(huì)消耗線性的時(shí)間,所以你最好整理一下代碼,更加干凈利落地做這些線性時(shí)間掃描的操作。對(duì)于特定的視圖,取得開始和結(jié)束的下標(biāo)索引,在必要的時(shí)候使用 advancedBy() 或者其他類似的方法來移動(dòng)索引的位置。

總結(jié)

Swift 中的 String 類型采取了一種與眾不同的方法來處理字符串。其他很多語言會(huì)選擇一種標(biāo)準(zhǔn)化表示法,然后將轉(zhuǎn)換等操作留給程序員自己去處理。通常它們在「到底什么才是字符?」這種重要的問題上做出了妥協(xié),它們在處理字符串的時(shí)候,直接在編碼中加入一些「語法糖」來讓代碼更加易寫,然而這本質(zhì)上就會(huì)導(dǎo)致各種困難的發(fā)生。Swift 語法可能沒那么「甜」,相反則是在告訴你實(shí)際上會(huì)發(fā)生什么。對(duì)于程序員來說,這會(huì)比較困難,但其實(shí)也就只有這些困難。

String 的 API 中也有一些坑,但是我們可以使用一些其他的方法來讓操作稍微簡單一些。特別地,從 UTF-8 或 UTF-16 轉(zhuǎn)換成一個(gè) String 類型的數(shù)據(jù)是一件困難而又煩人的事。如果我們有一些能夠?qū)⑷我庖淮a元序列轉(zhuǎn)換成字符串的 UTF8ViewUTF16View 構(gòu)造方法,以及另外一些直接建立在這些視圖上的轉(zhuǎn)換方法,那么 Swift 中的 String 類型將變得更加友好。

今天就到這里了。希望下次還能給大家?guī)砀囿@喜。周五問答的主題是根據(jù)大家的想法產(chǎn)生的,所以記得給我們寫信來提出你想要聽的話題

本文由 SwiftGG 翻譯組翻譯,已經(jīng)獲得作者翻譯授權(quán),最新文章請?jiān)L問 http://swift.gg

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 我發(fā)現(xiàn)很多人在使用 Swift 時(shí),都會(huì)抱怨 String API 很難用。它很難學(xué)習(xí)并且設(shè)計(jì)得晦澀難懂,大多數(shù)人...
    HelloGeekBand閱讀 2,718評(píng)論 1 8
  • Swift學(xué)習(xí)有問必答群 : 313838956 ( mac版QQ有權(quán)限要求, 入群只能通過手機(jī)版 QQ申請...
    Guards翻譯組閱讀 6,657評(píng)論 9 13
  • 一個(gè)字符串 是一系列字符的集合,例如hello, world和albatross。Swift的字符串是String...
    BoomLee閱讀 2,423評(píng)論 0 3
  • 本人天生不愛做飯。在家做人女兒時(shí),有母親早晚操勞。婚后,天天上班,做飯全憑以前做大廚的公爹。偶而為之,飯不是多了就...
    南極雪北極冰閱讀 211評(píng)論 0 1
  • 昨天參加了林書宇導(dǎo)演的創(chuàng)作分享會(huì),聽他回顧了《海巡尖兵》、《九降風(fēng)》、《星空》、《百日告別》這一系列電影劇本寫...
    陳十五閱讀 653評(píng)論 2 2