深入了解 Swift String 字串型別 讓你的程式跑得更快更好


字串型別 String 是每一個程式語言都會有的基本型別,也是所有工程師在牙牙學語的階段中,第一個會接觸到的資料型別(應該沒有人印 hello world 不是印字串、而是在螢幕印點陣的吧?)。雖然 String 在程式語言中非常基本,在大多數的情況下使用也不算太難,但是 String 也可以說是基礎型別中最複雜的一種了,背後除了多語系的顯示、轉換之外,還有記憶體、儲存空間最佳化、serialize/deserialize 等魔鬼,都藏在 String 這個幾乎每天都會用到的普通型別裡。

現在讓我們再把鏡頭拉回到 Swift 的開發現場,在開發 Swift 過程中,處理字串其實並沒有太直觀,你可能需要在不同 Swift 版本中,不斷改變你的字串處理語法;也會需要寫一長串呼叫,只為了取得 String 中的某一個 Character。相較於 Swift,許多高階語言像是 Python 或 Ruby,字串處理反而非常簡單,既可以直接遍歷字串 (Swift 曾經不行),也能夠直接用 subscript 語法取得 Character。為甚麼 Swift 會有這樣的差異?為甚麼 Swift 需要把字串 API 設計得這麼複雜?Swift 是怎樣做到完整支援 Unicode 的?怎樣操作 String 才能讓你的程式跑得又快又好(當然買一台新的 Mac Pro 一定是最佳解法)?在這篇文章裡,我們即將從 String 最底層的 Unicode 編碼,一路討論到 Swift 的字串 API,包括一些基本的編碼理論,還有簡單描述 Swift String 背後的實作。

在看完這篇文章後,你應該可以了解到:

  • Unicode 與 UTF-8, UTF-16 編碼
  • Swift 如何做到 Unicode-compliance (遵循 Unicode 規則)
  • Swift String API:index、comparison、Substring 等…
  • NSString 與 String 的關係
  • 即將在 Swift 5 出現的改變

這篇文章是基於目前 Swift 4.2 撰寫的,不過除了 String API 之外,其它的概念都是跨版本可以互通的!

Character encoding – 字元編碼

身為一個工程師,想必你已經對於 ASCII code 非常了解,也知道前人是有多麼困擾要怎樣使用多出來的128-bit,所謂知足常樂,在 640k 記憶體就足夠所有人使用的時代,大家可都是非常開心地生活在只有英文的世界。但很快地,個人電腦流行到全世界,128-bit 連某些拉丁語系的重音符號都無法定義,更不用說多到嚇人的中文字了。為了能夠讓世界上大部份的文字,都能夠順利地被量化,存進由 0 跟 1 構成的電腦系統裡,我們需要一套更為齊全的編碼 (Encoding) 系統,於是乎 Unicode 就誕生了。

在這邊,我們要先退一步,研究一下字元編碼的一些概念。因為我們的電腦資料都是由 0 跟 1 所組成,所以我們沒辦法直接把文字,像是英文字母或中文字,直接存進電腦裡,所以我們需要針對每個 Character (字元)做所謂的 Character Encoding (字元編碼),也就是,要把人類使用的文字,對應成某個數字(或者某組數字),使這些文字可以被儲存在電腦或是在數位的世界裡傳播。直覺上,我們只要準備一張夠大的表格,就可以把所有文字一個一個標上一個數字,這些文字就可以被適當地轉成數字,存進電腦裡。可以把這個大表格,想像成是一個立體的空間,空間內放滿了 Character,每一個 Character 都有對應的座標(一個或一組數字),稱為 Code Point。當我們要把文字存到電腦時,就找到文字對應的數字,把數字存到電腦裡。相反地,當我們從電腦要把文字讀出來時,我們會拿已經存好的數字,去找到對應的文字。

了解到 Character 跟 Code Point 的概念,也知道 Unicode 是目前最完善的編碼標準之後,我們接著要來研究,如何把 Unicode 編碼過的文字,存到電腦裡。首先,我們先準備一串文字(代表冬天到了就要準備吃泡麵了):

“🏂 costs ¥”

這串文字,在 Unicode 系統中,可以被編成如下表:

string-1

第一個 Character “🏂” 是一個滑雪板的 emoji,在 Unicode 裡面的 Code Point 是 U+1F3C2,換算成二進位的數字是 11111001111000010,總共有 17 位數。

現在我們想把它存到電腦裡,直覺上,是直接找一個 32-bit 的容器來裝這個數值,一個容器剛好可以擺上一個 Character,有幾個 Character 就找幾個容器來裝。這樣的做法有個問題,就是不管 Character 的大小,你都必需要用一個最大的 32-bit 的容器去裝它,比方說 “ ” 空白字元, 在 Unicode 中是二進位 100000,總共才 6-bit,如果我們使用 32-bit 的容器去裝它,就會有 26 bits 的空間被浪費掉。

另外一種做法,是我們也可以用 16-bit 的容器,遇到值大一點的 Character 時,就用兩個 16-bit 的容器來裝這 17-bit 的數值,遇到值小一點的 Character 時,就用一個 16-bit 的容器去裝,這樣我們可以比較充份地利用儲存空間而不會浪費。下面這張圖說明了 Unicode 編碼,與實際儲存方式之間的關係:

string-2

在現實生活中,因為相容性問題,還有上述原因,實際上我們不會直接儲存 Unicode 的 Code Point,而是會另外再把 Code Point 編碼成更適合的單位,再存入電腦。以上把數值轉成適合存到記憶體或硬碟的方法,被稱為 Character Encoding Form (字元編碼表),而被轉換後的,如上圖比較小的方塊,我們稱之為 Code Unit。而上面看到的,轉成 16-bit x 2 Code Unit 的編碼方式,就是常見的基本 UTF-16 編碼,大多的文字可以用一個 UTF-16 Code Unit 編完,剩下的則會利用兩個 Unit 來編碼,這個兩碼的 Unit 被稱作 Surrogate Pairs。而最下方轉成 8-bit Code Unit 的編碼方式,則是 UTF-8。UTF-8 跟 UTF-16 都沒有固定的編碼長度。

漫長的前情提終於要結束,接著 Swift 本人就要登場了!在下面的章節,我們可以了解到各種 Swift 為了 Unicode 所做的努力,還有在 Swift 裡操作 String,有哪些需要知道的基本知識。

Swift 與 Unicode

Swift 因為先天優良,生長的時代民智已開,Swift String 本身就已經完全符合各種 Unicode 的規範 (Unicode-compliance)。所以我們現在要來介紹,Swift String 是怎樣跟 Unicode 掛勾,實際上有哪些 Unicode 相關的 String API 可供使用。

String Literal

在 Swift 裡,你可以直接寫 Unicode 的 Code Point,來起始一個字串。像是:

或者是:

Unicode Scalar 與各種 Encoding View

另外,你也可以從一個 String ,轉換成 Unicode 的 Code Point:

這邊的 unicodeScalars 是一個由 Unicode.Scalar 組成的 Collection。一個 Unicode.Scalar 可以被視為是 Unicode 裡面的一個 Code Point ,但不包含 Surrogate Pairs 用的 Code Point。

Swift String 也支援直接取用不同編碼的 Code Unit,不同編碼使用的 Code Unit 數量都不一樣,可以回去對照上一章節的表格就可以理解了:

雖然大多數情況下,在 Unicode 標準裡,一個 Character 就是一個 Code Point,但 Unicode 也允許你用一個以上的 Code Point,去組成特別的 Character。像是 “e” 的 Code Point 是 U+65,如果把 U+65 跟代表重音符號的 U+301 放在一起,系統就會把這兩個 Code Point 放在一起看,顯示為 “é” :

一個或多個 Code Point 組成的單位,被稱之為 Grapheme Cluster。Grapheme Cluster 的長度不一,並且是可以一直疊加上去的:

或者是結合了圓圈 \u{20DD} 跟重音 \u{301} 的:

當然講到 Grapheme Cluster,一定不能缺席的就是 Zalgo 了:

上面的字印出來會長這樣:

這是一個非常有名玩壞(?) Grapheme Cluster 的經典例子,利用 Grapheme Cluster 組出看起來十分詭異的文字。你可以把它的 Unicode Code Point 全部印出來(這邊就不列出來充版面了 XD),會發現就是 “Zalgo” 這五個英文字,配上一堆修飾用的 Code Point。

Swift 目前支援的是 Extended Grapheme Clusters,有興趣的話可以參考 UNICODE TEXT SEGMENTATION

Swift 的 String API

在 Swift 4 中,String 其實也是一種 Collection,並且每個元素的 type 是 Character。Character 在 Swift 中,其實就是一個 Grapheme Cluster。如果我們針對上面幾個字串做 count :

會發現 count 的數目,不是 Code Point 的數量,而是人眼看到的 Character 的數量。實際上, zalgo 的 Code Point 有 83 個這麼多(消耗大量硬體資源做出酷炫的東西是人類進步的根源)!

這個驚人的特性,也說明了一個非常重要的觀念,在 Swift 中,計算 String 長度的時間複雜度,其實是 O(n),因為要解析所有的 Grapheme Cluster,你需要先遍歷過一整個 String ,才有辦法知道總共有幾個 cluster,也才能算出 Character 的數量。現在我終於知道為甚麼我在 LeetCode 上寫演算法總是過不了時間測試了。(事實證明 String.count 速度非常快,只是我演算法都暴力解)。

String Index

延續這個單一 Character 有不固定長度的 Code Point 的驚人認知,我們瞬間就了解到,在 Swift 裡,String 是無法像一般 Array 一樣,直接取用某個 index 底下的值,也就是 String 的元素 (Character) 是沒有辦法被 random access 的。這也就是為甚麼在 Swift 中, String 的 index 要這樣寫:

因為直接使用 Int 的 subscript 語法,如 snowboardCostsYen[8] ,暗示著 random access,而 Swift 在這裡不希望被誤解,所以維持了這樣複雜的寫法。而且,你應該也已經猜到, index 的取用的時間複雜度,也是 O(n) 😱。

在這裡,值得一題的是 index 的語法其實非常有彈性, offsetBy 除了可以擺正整數之外,也可以倒著數,擺上負數:

還有一些關於 String Index 的操作,像是如果要尋找字串中某個字元的位置,可以用:

如果要尋找某個子字串,則可以使用:

當然以上的時間複雜度都是華麗的 O(n) 。

更多關於 String Index 的設計緣由,可以參考 String Index Overhaul

String 的比較

回到 Character 的話題,一個肉眼看起來長得一樣的 Character ,可能會由一個到多個不同的 Code Point 組成,讓我們用 “é” 來做例子:

acute1 跟 acute2 印出來都是一個具有重音符號的 “é”,雖然 Code Point 不一樣,但是人眼看起來是一樣的,而 Swift 也判斷他們是相同的 Character,所以我們可以了解到,Swift String 的異同判斷,是針對人眼認知的 Character ,而不是任何一種編碼方式來判定的。

當然,這邊也有一些特例:

雖然兩個都長得一樣,但一個是英語的 A,另外一個是俄語的 “А”,在意義上兩者是不一樣的,所以 “==” 的判斷回傳 false 。

Substring

延續剛剛的例子,你會發現上面的 costSubstring 的型別,已經不是 String 了,而是 Substring。Swift 為了要加強 String 的效能,針對 String 做了許多特別的設定, Substring 就是其中一個因此而生的型別。 Substring 跟 String 一樣,都 conform StringProtocol,所以兩者操作是一模一樣的。特別的是,當你從某個 String ,透過 Range 取出 Substring 時,Swift 其實不會再創造一個新的 String,而是把這個 Substring 指向原本的記憶體位址,並且加上範圍標記,就像 ArraySlice 一樣。這個關係可以透過下圖來了解:

string-3

原本的 String 在記憶體中占用的空間,在上圖被標示為藍底方框。如果我們利用Range subscript 產生一個 Substring ,Swift 會拉出一個新的指標,指向原本的 String 的位址,標示 Substring 屬於 String 的那個範圍。利用這個技巧, Substring 的產生成本非常低,不用 allocate 新的記憶體,也不用 copy character,在速度跟空間上都非常有優勢。

到這邊,想必身為踩雷無數的 iOS 工程師,你看到兩個指標都指向同一個位址,心中一定警鈴大作了對吧(我是還好)!你猜的沒錯, Substring 這個指標,也同時擁有上面這個藍底的記憶體位址,用遠古的話來說,就是 Substring 的指標 retain 了這塊記憶體位址。也就是說,就算 String 的指標被移除了,只要 Substring 的指標還在,整個字串的記憶體空間也都會存在。所以在使用 Substring 的時候,請記得不要讓 Substring 存活太久,盡量讓 Substring 一執行完它的任務就釋放,才能避免多餘的記憶體位址被占用著。

swift-evolution 這裡可以找到更詳細的 Substring 設計理念。

String Performance

除了 Substring 之外,類似上面的技巧也被使用在一般的 String 上面。我們直接來看看 code ,假設我們有一個 string strA,我們另外新增一個 String 變數 strB ,把 strA assign 給 strB:

你會發現,雖然 String 是 value type,但是它們現在還是共享同一塊記憶體!這個設計,被稱為 Copy on Write ,如果你只是純粹複製這個變數到 strB ,但沒有對目標變數 strB 做任何修改,那 Swift 不會真的一個一個 copy 原本 strA 裡面的東西,而是會先把 strB 指向 strA 的記憶體位址,讓他們共享同一塊記憶體。現在我們對 strB 做點小小修改:

修改過後的 strB ,位址突然就不一樣了! Copy on Write 就是只有在寫入的時候,才會真正 Copy 資料。當然 String 的行為還是跟其它 Value Type 一樣,你應該要預期 assign、傳入參數等操作都會得到一個不一樣的 String ,這樣的認知可以大幅減少撰寫程式時的心理負擔,只是底層 Swift 比較偷懶,都要等到真的需要才會 Copy。

NSString 與 String

老一輩的工程師,都喜歡談論以前美好的過往,像是「我記得當初還要搬一堆卡片排隊等跑程式呢!」、或是「以前放滿一個大房間的電腦只能做九九乘法表」之類的。當然身為 iOS 系的工程師,就非得要來討論一下上古世代的 Objective-C 不可⋯⋯ 好,其實也沒有這麼老,甚至到目前為止,Objective-C 還是充斥著整個 Cocoa Framework,現在我們要討論的 NSString 就是一個例子。

目前仍然有許多 NSString 的 method 是還沒有(或者不會)被實作到 Swift String 上的,所以 Swift 目前的做法,是直接把 NSString 跟 String Bridging 起來,先讓兩個型別可以直接透過 “as” 直接轉換,並且提供接口讓 String 可以直接取用各種 NSString 的 method:

雖然 components(separatedBy:) 這個 method 是被實作在 NSString 裡面的,但我們仍然可以在 String 上面直接使用。要注意的是,這樣的 bridging 只有在 import Foundation 後才會有用。

NSString 預設的編碼邏輯也跟 String 不一樣,NSString 是從 UTF-16 的觀點來設計的,而 String 則是從 Unicode 的觀點來設計的。這個差異我們可以透過字串長度來看出來:

用人眼計數的話,真正的長度應該是 9 ,不過 NSString 的 length 並不是人眼能辨認的 Character 數量,而是 UTF-16 Code Unit 的數量,而從最前面我們的討論可以看得出來, emoji 的 Code Unit 是 2 ,所以才會有這樣數量的差異。但是!以上只說明了 String 的 API 設計邏輯是基於 Unicode ,如果你剛剛有專心看上面的章節(我是沒有),你應該會發現, Unicode 只能說是一個抽象的概念,通常不會被當成儲存用的編碼,真正存進電腦裡的編碼,需要是 UTF-8 或 UTF-16 這種有針對儲存空間做最佳化的編碼。就儲存上的編碼來說。目前在 Swift 4 中, String 跟 NSString 一樣,都是使用 UFT-16。除了 UTF-16 之外,String 偶而會使用 ASCII 當作底層的編碼,在 Swift 4.2 中, String 多了一個針對小字串的最佳化,條件是,如果 String 能夠被 ASCII 編碼,並且長度小於或等於 15 個 Code Unit,Swift 就不會為這個 String 特地 allocate 記憶體,而是直接存在 String 結構裡面。當然底層的實作就跟天橋下說書一樣,不太會直接影響到上層程式的設計,所以我們只要記得 String 的操作單位是基於 Unicode 標準,這樣就夠了。

再來,String 跟 NSString 可以互相 bridging 的這個特性,帶來了非常神奇的現象,你沒有辦法從宣告中,得知目前底層的資料型別,到底是 String 還是 NSString:

一開始我們宣告了一個 NSString 字串,然後把它 cast 成為一個 String ,這個時候其實 sStr 底層仍然是一個 NSString ,但如果我們在這個 NSString 上加一個普通的 String ,回傳的值就會是一個 String。大多數的時候,這樣的轉換其實是不太會被察覺的,但在大量運算的時候,仍然會有差異,普遍來說,String 的效能會比 NSString 要好,可以參考 objc.io 這個非常有趣的發現。

即將在 Swift 5 出現的改變

最後的最後,我們來看看,即將出現的 Swift 5 ,最近 merge 了一個非常大的改動:String’s ABI and UTF-8。從標題應該就可以猜到,沒錯,String 的儲存編碼,即將從 UTF-16 變成 UTF-8 了!UTF-8 的優勢非常明顯,就是它向下支援的能力相當好,以一篇全英文的文章為例,如果這篇文章可以正確地被 ASCII 編碼,那不管你用 ASCII 來編碼或者用 UTF-8 來編碼,兩者都會是一模一樣的。也就是說你不用修改任何既有的 ASCII 文件,它們已經被 UTF-8 支援了。

雖然 UTF-16 在這個區段的 Code Point 也跟前兩者一樣,但 UTF-16 的 Code Unit 大小不同於前兩者 (16 v.s 8),兩個 8-bit 的 Code Unit 接在一起有可能會在 UTF-16 下,被誤認為是另外一個 Code Point。

這個改動讓 Swift 跟 C 語言 (UTF-8) 的串接更有彈性,現在可以直接連接到 C 字串的記憶體空間,而不用 copy 或 allocate 。在空間利用上,大多數的情況, UTF-8 的空間利用度都比較好,因為很多文字都可以用 8-bit 就編碼完畢,加上 UTF-8 能夠從第一個 Code Unit 就知道後面有沒有接續的 Code Unit,所以速度上也會快上不少。當然少數(很不幸地講中文的我們就是少數)的情況下,UTF-8 解碼會比較花CPU 資源,因為幾乎所有的字元都由兩個以上的 Code Unit 組成,每個字元都要做邊界判斷。當然就目前的資料來看,就算是中文改成用 UTF-8 編碼,速度也會有所提升,因為記憶體空間的有效利用賺到了更多的效能,而 CPU 多吃的效能影響反而沒有這麼大。這些評估未來應該還會再更新,有興趣的話可以持續觀注 Swift ForumSwift Evolution

結論

看完這篇文章,你是不是發現,大多數都跟你平常開發不是特別有關係呢?(誤)Swift 在 String 的設計上下了非常多的功夫,讓 Unicode 的概念可以在 Swift 裡通行無阻,相較之下,隔壁棚的 Python 現在還不時可以聽到哀號聲。雖然現在編碼邏輯已經很好地被藏在 API 底下,不過從這一路上的設計過程,可以看得出來, Swift 在安全跟語法語意上,下了很大的功夫。像是 Substring 的出現,還有 Index 的 API,都可以看出來 Swift 在語法簡單跟語意清楚上,果斷地選擇了後者(所以我們要多寫很多 code)。不過這些 API 其實都一直在變動,String Collection 的傷痛還歷歷在目,基於 Swift 一直以來變動都不小的事實,了解到更多關於語法背後的邏輯,絕對還是有幫助的!更多 Swift 在 String 上的堅持,可以參見 StringManifesto

因為 String 系統真的非常龐大又複雜,文章可能會有解釋不清或者手誤的地方,都歡迎直接指正。也希望這篇文章能夠拋磚引玉,讓大神們分享更多關於 Swift String 或甚至語法的相關心得。

(🍻 Emoji 實作上還真不容易 XD)

其它參考資料


I’m ShihTing Huang(黃士庭). I brew iOS app, front-end web app, and of course, coffee and beer!

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This