UTF-16 與代理對 — 字元數的陷阱
UTF-16 是以 16 位元為單位的編碼。BMP 之外的字元以代理對佔用 2 個碼元,於是 C# 與 JavaScript 的 length 不再等於肉眼所見的字元數。
「3 個字的名字填不進去」— 字元數數法的懸案
明明是「姓名最多 4 個字」的表單,「𠮷野家」這 3 個字卻填不進去。只有 2 個表情符號的貼文被算成「4 個字」。在 C# 裡看著 Length 截取字串,結果混進了字元的殘骸。— 這一章要談的是繼「位元組數」之後另一種數字的錯位:「字元數」的數法。
主角是 UTF-16。它是 Windows 內部以及 Java、C#、JavaScript 的字串型別採用的編碼,以 16 位元(2 個位元組)的碼元為基本單位。日文字元「あ」U+3042 恰好裝進 16 位元、佔 1 個單位 — 到這裡都很直觀。
但如第 2 章所見,Unicode 的號碼一路編到 U+10FFFF。16 位元能表示的 U+FFFF 以內的範圍稱為 BMP(基本多文種平面),而表情符號(U+1F600 一帶)或「𠮷」(U+20BB7)這類字元在 BMP 之外,1 個 16 位元裝不下。
代理對 — 用 2 個單位表示 1 個字元
UTF-16 的答案是:把 2 個碼元湊成一對來表示 1 個字元。步驟與 UTF-8 的模板非常相似。
D842 DFB7。D800〜DFFF 是單獨不成字元的保留區域,所以讀取方一眼就知道這是一對這種 2 個單位 1 組的組合稱為代理對(surrogate pair)。表情符號也是同一套機制:「😀」U+1F600 是 D83D DE00 的 2 個單位。
BMP 之內的「あ」佔 1 個單位,BMP 之外的「𠮷」佔 2 個單位。length 數的是這些「箱子的數量」,而不是肉眼所見的字元數。
小題 5-1 — UTF-16 與代理對
確認在 16 位元單位的世界裡,號碼很大的字元是如何表示的。
Q1. 關於 UTF-16 的說明,正確的是哪一個?
UTF-16 的基本單位是 16 位元。Unicode 早期「所有字元 2 個位元組就夠」的預估破滅後,用 2 個單位表示超過 U+FFFF 之字元的代理對才在後來被引入。「固定長度」只是侷限於 BMP 之內的過往樣貌,現在它是可變長度(1 或 2 個單位)。以 1 個位元組為單位且 ASCII 相容的是 UTF-8。
Q2. 「𠮷」(「吉」的異體字,日文稱「つちよし」)的碼位是 U+20BB7,超過了 U+FFFF。在 UTF-16 中它如何表示?
把 U+20BB7 減去 0x10000 得到 0x10BB7,以 20 個位元表示,再把高 10 位元載到高位代理(0xD800〜)、低 10 位元載到低位代理(0xDC00〜),就得到 D842 DFB7。D800〜DFFF 是為此用途保留、「單獨不成字元」的區域,兩兩成對才表示 1 個字元。表情符號(U+1F600 等)也全是同一套機制。
Q3. C# 的 string.Length 與 JavaScript 的 .length 回傳的是 UTF-16 的碼元數。「𠮷野家」(𠮷 = U+20BB7,野、家在 BMP 之內)的 length 是多少?
「𠮷」是代理對佔 2 個單位,「野」「家」各佔 1 個單位,所以 2 + 1 + 1 = 4。看起來是 3 個字,回傳的卻是 4 — 「length = 字元數」這個成見崩塌的瞬間。若用碼元來做字數限制檢查或截取前 N 個字元,就可能從代理對的正中間切開,產生壞掉的字元。
length 回傳的是「箱子的數量」
開頭的懸案在這裡解開。C# 的 string.Length 與 JavaScript 的 .length,回傳的都是 UTF-16 的碼元數 — 也就是把代理對的「箱子」算成 2 的值。
| 字串 | 肉眼所見 | length(碼元) | 原因 |
|---|---|---|---|
| あいう | 3 個字 | 3 | 全部在 BMP 之內,各佔 1 個單位 |
| 𠮷野家 | 3 個字 | 4 | 𠮷 佔 2 個單位 |
| 😀😀😀 | 3 個字 | 6 | 表情符號各佔 2 個單位 |
這不是處理系統的 bug,而是完全符合規格。危險的是把 length 解讀成「字元數」,拿去做字數限制檢查或截取前 N 個字元。一旦切點落在代理對的正中間,就會產生只剩高位代理的「壞掉的字元」,成為後續處理與資料庫錯誤的種子。
更進一步 — 組合字元與「肉眼所見的 1 個字」
其實「以碼位為單位數就是字元數」也還不是正解。家庭表情符號「👨👩👧」是把「男性」「女性」「女孩」3 個表情符號,用看不見的碼位零寬連接符(ZWJ)接起來的:碼位有 5 個,UTF-16 的碼元有 8 個,畫面上看到的卻是 1 個字。把日文的「が」表示成「か + 濁點」2 個碼位的組合字元也是同類。
對應人眼所見 1 個字的單位稱為字素叢集(grapheme cluster),要數它得用專用的 API(JavaScript 的 Intl.Segmenter 等)。總結來說,「字元數」至少有 4 種數法 — 位元組、碼元、碼位、字素 — 而能說出自己數的是哪一種,就是本章的終點。請把「𠮷野家」或家庭表情符號輸入第 3 章的模擬器,確認 4 個數字全部對不上。
小題 5-2 — length 的陷阱與組合字元
用表情符號來收尾,體會「數字元數」意想不到的困難。
Q4. 表情符號「😀」是 U+1F600,在 UTF-16 中佔 2 個碼元。在 JavaScript 中「😀😀😀」(3 個表情符號)的 .length 是多少?
2 個單位 × 3 個 = 6。抱著「最多 3 個表情符號 OK」的想法寫下 length <= 3 的輸入檢查,只放 2 個表情符號就會被擋下來。這是 SNS 的字數計算與資料庫字數限制中頻繁出現、很有現代感的陷阱。
Q5. 在 length(Length)與「肉眼所見的字元數」不一致的場合,正確的理解是哪一個?
length 是按照「UTF-16 的碼元數」這個明確的規格運作的。問題在於使用者把它擅自解讀成「字元數」。而且就算避開表情符號,「𠮷」之類的漢字或下一題的組合字元也會造成同樣的落差。意識到自己要數的單位(位元組、碼元、碼位、字素)再做選擇 — 這就是本章的結論。
Q6. 家庭表情符號「👨👩👧」是多個表情符號用零寬連接符(ZWJ)接起來的,JavaScript 的 .length 是 8。想正確數出「肉眼所見的 1 個字」時,合適的單位是哪一個?
👨👩👧 是「男性 + ZWJ + 女性 + ZWJ + 女孩」共 5 個碼位(其中 3 個表情符號各佔 2 個碼元),以碼元數是 8,以碼位數也是 5。對應人眼所見「1 個字」的單位是字素叢集,用它來數才終於得到 1。許多語言有專用 API(JavaScript 的 Intl.Segmenter 等),第 3 章模擬器的「肉眼所見的字元數」欄位也是以這個單位計算的。
本章帶走的重點
- UTF-16 以 16 位元的碼元為基本單位。BMP(U+FFFF 以內)佔 1 個單位,BMP 之外以代理對佔 2 個單位
- 「𠮷」U+20BB7 = D842 DFB7,「😀」U+1F600 = D83D DE00
- C# 與 JavaScript 的 length 是碼元數。「𠮷野家」是 4,「😀😀😀」是 6
- 肉眼所見的 1 個字 = 字素叢集。要區分位元組、碼元、碼位、字素這 4 種數法
編碼的機制到此全部到齊。下一章進入實務篇 — 一一收拾「宣告與實體的錯位」在現場引發的麻煩。