小村軟體有限公司
第 5 章

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 的模板非常相似。

Step 1 — 減去 0x10000
U+20BB7 − 0x10000 = 0x10BB7。BMP 之外的號碼這樣一減,必定裝得進 20 個位元
Step 2 — 把 20 個位元分成 10 + 10
高 10 位元加上 0xD800 得到高位代理 D842,低 10 位元加上 0xDC00 得到低位代理 DFB7
Step 3 — 排上 2 個單位
「𠮷」 = D842 DFB7。D800〜DFFF 是單獨不成字元的保留區域,所以讀取方一眼就知道這是一對

這種 2 個單位 1 組的組合稱為代理對(surrogate pair)。表情符號也是同一套機制:「😀」U+1F600 是 D83D DE00 的 2 個單位。

顯示「あ」在 UTF-16 中是 1 個碼元、「𠮷」U+20BB7 是代理對 D842 DFB7 的 2 個碼元的圖

BMP 之內的「あ」佔 1 個單位,BMP 之外的「𠮷」佔 2 個單位。length 數的是這些「箱子的數量」,而不是肉眼所見的字元數。

小題 5-1 — UTF-16 與代理對

確認在 16 位元單位的世界裡,號碼很大的字元是如何表示的。

Q1. 關於 UTF-16 的說明,正確的是哪一個?

Q2. 「𠮷」(「吉」的異體字,日文稱「つちよし」)的碼位是 U+20BB7,超過了 U+FFFF。在 UTF-16 中它如何表示?

Q3. C# 的 string.Length 與 JavaScript 的 .length 回傳的是 UTF-16 的碼元數。「𠮷野家」(𠮷 = U+20BB7,野、家在 BMP 之內)的 length 是多少?

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 是多少?

Q5. 在 length(Length)與「肉眼所見的字元數」不一致的場合,正確的理解是哪一個?

Q6. 家庭表情符號「👨‍👩‍👧」是多個表情符號用零寬連接符(ZWJ)接起來的,JavaScript 的 .length 是 8。想正確數出「肉眼所見的 1 個字」時,合適的單位是哪一個?

本章帶走的重點

  • UTF-16 以 16 位元的碼元為基本單位。BMP(U+FFFF 以內)佔 1 個單位,BMP 之外以代理對佔 2 個單位
  • 「𠮷」U+20BB7 = D842 DFB7,「😀」U+1F600 = D83D DE00
  • C# 與 JavaScript 的 length 是碼元數。「𠮷野家」是 4,「😀😀😀」是 6
  • 肉眼所見的 1 個字 = 字素叢集。要區分位元組、碼元、碼位、字素這 4 種數法

編碼的機制到此全部到齊。下一章進入實務篇 — 一一收拾「宣告與實體的錯位」在現場引發的麻煩。