Swift 與 Objective-C

[Swift 基礎 #6] 為什麼字串不能使用 text[0]?

從擴充字素叢集出發,說明 Swift String 不接受整數索引的原因,並透過實際執行結果確認韓文組合字串的 Character、Unicode scalar 與 UTF-8 差異。

閱讀 5 分鐘
[Swift 基礎 #6] 為什麼字串不能使用 text[0]? 封面圖

從其他語言轉用 Swift 的開發者,最困惑的地方之一就是字串。text[0]不能用,text[2..<5]也不能用,還得建立獨立的索引型別,寫成text.index(text.startIndex, offsetBy: 2)。在 Python 裡,這原本只要一個字元就能完成。

這不是因為 Swift 團隊做不出這個 API,反而正好相反。它誠實揭露了「字串的第 n 個字元」本身並不簡單。其他語言選擇隱藏複雜性,偶爾回傳錯誤答案;Swift 則選擇即使不方便,也始終給出正確答案。這是 Swift 基礎系列第 6 篇,也是最後一篇;讓我們深入探討字串真正困難的原因。

將字串轉成數字時可能失敗的流程,會在Swift throws、try 與 Result 的選擇指南中連同錯誤模型一起說明。

「有幾個字元?」為什麼很難 — Unicode 的世界

從這個問題開始:「café」有幾個字元?看起來當然是 4 個,但對電腦而言有兩種答案。é 可以用一個預組合程式碼點(U+00E9)儲存,也可以用 e(U+0065)和組合重音符號(U+0301)兩個程式碼點疊合儲存。畫面看起來相同,內部程式碼點數卻是 1 和 2。

韓文讓這個問題更容易感受到。「一個韓文音節」可以是一個預組合音節程式碼點(U+AC01),也可以是 3 個字母的組合。如果你處理過 macOS 檔名,可能遇過以 NFD(Normalization Form D — 將文字拆解成字母單位儲存的 Unicode 正規化形式)儲存的韓文檔名,在其他系統上顯示成拆開的字母。同一個「字元」可能包含 1 個或 3 個程式碼點。

表情符號讓情況更複雜。家庭表情符號 👨‍👩‍👧‍👦 將 4 個人物表情符號以零寬連接符(ZWJ,Zero Width Joiner — 將表情符號結合成一個字元的不可見字元)串接起來,共有 7 個程式碼點。以 UTF-16 程式碼單元計算則是 11 個,但在人眼中仍是一個字元。

因此 Unicode 標準另外定義了「人所感知的一個字元」這個單位,稱為擴充字素叢集(extended grapheme cluster)。é、組合形式的韓文音節和家庭表情符號,各自都是一個字素叢集。

各語言的選擇 — 錯誤答案比較快,還是正確答案比較慢?

現在可以比較各語言如何處理「第 n 個字元」。

JavaScript 的"👨‍👩‍👧‍👦".length是 11,因為它計算 UTF-16 程式碼單元數。Java 也一樣。Python 3 的 len 會得到 7,因為它計算程式碼點數。這些結果都違反人類對「一個字元」的直覺。若天真地切割含有表情符號的字串,可能從表情符號中間切開,產生損壞的文字。

Swift 的"👨‍👩‍👧‍👦".count是 1,因為 Swift 的 Character 型別代表字素叢集,而不是程式碼點。不論 é 以哪種方式儲存,count 都相同;== 也會考慮正規化並回傳 true。它始終依照「人眼看到的字元」給出正確答案。

但這份精確性有代價。字素叢集是可變長度的,因此尋找第 n 個字元時,必須從前面開始逐一判定邊界並計數。這就是 Swift 字串沒有整數索引的原因。若提供看似能讓text[7]以 O(1) 完成的 API,實際上會掩蓋 O(n) 的成本。在迴圈中使用text[i]就會變成 O(n²),Swift 選擇不鼓勵這種用法。不透明的 String.Index 型別,則用型別揭示「這個位置是逐一計數後找到的結果」。哲學系列第 1 篇提到的「不隱藏成本」原則,在字串上也同樣適用。

相同字串用不同語言測量,結果也會不同
相同字串用不同語言測量,結果也會不同

實務工具箱 — 不使用索引處理字串

了解原理後進入實戰。即使沒有整數索引,大多數工作也能用更安全的工具完成。

前後截取使用 prefix 和 suffix。 text.prefix(10)會安全回傳前 10 個字元(以字素為準),超出範圍也不會當機,只回傳實際存在的部分。製作預覽文字時,不必計算 substring 索引。

**搜尋使用 range(of:) 和 firstIndex(of:)。**需要位置時,多半是「特定內容的位置」。直接將取得的 Range 或 Index 放入下標即可,不需要轉換成整數。

分割使用 split。 text.split(separator: ",")可取代手動巡覽索引。

**真的需要第 n 個字元時,使用 Array。**若字元層級的演算法確實需要隨機存取,標準做法是轉換成Array(text)。一次付出 O(n) 的轉換成本後,之後每次存取都是 O(1),遠比在每次迴圈呼叫 index(offsetBy:) 好。

**需要位元組時,明確指定檢視。**網路傳輸量或資料庫欄位限制等情況,需要的是「位元組數」而非「字元數」。Swift 透過text.utf8.counttext.utf16.counttext.unicodeScalars.count讓程式碼明確表達計算單位。順帶一提,UserDefaults 或舊 API 中的 NSString.length 以 UTF-16 為基準,可能與 Swift 的 count 不同。

還有一個陷阱:不同字串的索引不相容。把從 a 取得的 String.Index 用在 b 上,可能當機或得到錯誤結果。索引只屬於「那個字串在那個時刻」。修改字串後,最好視先前的索引為無效。

不使用整數索引也能解決大多數字串工作
不使用整數索引也能解決大多數字串工作

為什麼這對韓文開發者特別重要

製作韓文服務時,這些知識會從抽象概念變成實務問題。

暱稱字數限制就是典型例子。驗證「10 個字元以內」時,如果伺服器(其他語言)和用戶端(Swift)使用不同單位計算,就會出現用戶端通過、伺服器拒絕的錯誤。若伺服器限制 UTF-8 位元組數,韓文每個字元是 3 個位元組,因此 10 個字元就是 30 個位元組。團隊應先約定「計算什麼」,Swift 端再刻意選擇 count(字素)或 utf8.count(位元組)。

字母拆解也是問題。由於前述 NFD,外部系統傳入的韓文字串看起來相同,程式碼點卻可能不同。Swift 的 == 會考慮正規化,因此通常能直接運作;但若要把外部字串當作雜湊字典鍵,或傳給其他語言,明確使用 precomposedStringWithCanonicalMapping 正規化為 NFC(Normalization Form C — 將字母合併成預組合文字儲存的正規化形式)會更安全。

在搜尋與自動完成中實作初聲搜尋等功能時,反而需要將字素拆成字母單位,此時 unicodeScalars 檢視是起點。了解檢視概念後,這類需求就能整理成「巡覽另一個檢視」,而不是「需要特殊函式庫」。

實際執行確認的結果

在 Apple Swift 6.3.3 中,我們比較了預組合形式與由 3 個字母組成的組合形式한。組合字串有 3 個 Unicode scalar,但String.count會將它算作 1 個字元,兩種表示在正規等價性比較中相等。

string=characters:1,scalars:3,canonically-equal:true

確認這個結果後,實作字數限制時,我會在變數名稱中標示單位。若是characterLimit,使用count;若是傳輸大小,使用utf8.count,伺服器合約也寫上相同單位。若只用省略單位的length達成共識,韓文與表情符號很容易讓用戶端和伺服器不一致。

總結

  • 「第 n 個字元」之所以困難,是因為 Unicode 中一個字元(字素叢集)由數量可變的程式碼點組成。é、組合形式的韓文和 ZWJ 表情符號都是代表案例。
  • 其他語言的 length 可能計算程式碼單元或程式碼點,因而偏離直覺;Swift 的 count 計算字素,始終給出符合人類認知的答案。
  • 代價是隨機存取變成 O(n),因此 Swift 移除了整數索引,避免隱藏這項成本。
  • 實務上,prefix/suffix、range(of:)、split 可解決大多數問題;隨機存取使用 Array 轉換,位元組計算則明確使用 utf8 檢視。
  • 韓文服務的實務重點,是先約定字數驗證單位,並處理 NFC/NFD 正規化。

基礎系列第 6 篇到此結束。接下來是中級系列。第一個主題是 Swift 記憶體管理的核心:ARC(Automatic Reference Counting,自動參照計數)以及 weak 與 unowned 的選擇。這是閉包篇預告的循環參照故事主篇。

延伸閱讀

來源與驗證

  • Swift StringApple Developer Documentation · 官方文件 · 查核 2026年8月26日依據: String 的 Character 集合模型、Unicode 正規等價性、字串檢視與索引 API
  • The Swift Programming Language: Strings and CharactersSwift.org · 官方文件 · 查核 2026年8月26日依據: 擴充字素叢集、韓文組合表示法,以及 count 與整數索引的成本說明