向量偏移|一份字串的三次量測
一個、七個、十一個,三把尺都對。
把 👨👩👧👦 放進支援這組 emoji 的螢幕,眼睛通常只接到一個圖像;換一把尺,程式卻會得到完全不同的答案。不是誰算錯了,而是「一個字」本來就有不只一種工程量法。
尺一|讀者
畫面先把接縫藏起來
Unicode 把通常應一起移動、一起選取的一段文字,稱為「延伸字素叢集」。白話一點,它是程式對「使用者大概會覺得這是一個字」所做的最佳近似。它不是字典裡唯一正確的字,也不保證每種語言、字型與操作情境都會得到同一答案。
實際造型依作業系統與字型而異;不支援這組合的環境可能顯示成分開的符號。
Unicode 的文字切分規則特別要求:emoji 的 ZWJ 組合不要在接縫中間斷開。因此,在預設延伸字素叢集的尺度上,這整串是 1。
尺二|Unicode
把圖像拆開,裡面站著七個碼位
「碼位」是 Unicode 分配給抽象文字符號的編號。這串家族 emoji 並不是一張塞進文字裡的小圖片;它由四個人物碼位與三個 U+200D 組成。U+200D 叫做零寬連接符號(Zero Width Joiner,ZWJ),自己不占一個可見圖形,卻告訴支援的顯示系統:把前後的 emoji 當成一組來顯示。
這一把尺不管最後畫成一個圖像還是七個分開的符號,只問底層有哪些 Unicode 編號,所以答案是 7。
尺三|JavaScript
十一不是字數,是儲存格數
ECMAScript 規格把字串定義成一連串 16 位元的值;在文字情境裡,每一格就是一個 UTF-16 碼元。JavaScript 的 .length 數的是這些格子,不是眼睛看到的圖像,也不是 Unicode 碼位。
各占 2 格 共 8 格
再加 3 個 ZWJ 11 個
UTF-16 碼元
const family = "👨👩👧👦";
family.length; // 11
[...family].length; // 7
[...new Intl.Segmenter("zh-Hant", {
granularity: "grapheme"
}).segment(family)].length; // 1四個人物碼位都位在基本多文種平面之外,各需要一組由兩個 UTF-16 碼元構成的代理對;三個 ZWJ 各占一格。於是 4 × 2 + 3 × 1,最後得到 11 個碼元。
尺四|產品
真正的 bug,常從沒說清楚要量什麼開始
如果一個介面把 .length 直接叫作「字數」,一個家族 emoji 可能瞬間吃掉十一格額度。若程式再依索引硬切字串,還可能把代理對或 ZWJ 組合切在中間,留下無法如預期顯示的碎片。
要限制資料儲存量,碼元或位元組也許才是你要的尺;要處理 Unicode 編號,就數碼位;要讓游標移動、刪除與讀者感受到的文字單位更接近,則應考慮延伸字素叢集。ECMAScript 的 Intl.Segmenter 提供了 grapheme 粒度,但 Unicode 也提醒:字素叢集仍是跨語言的通用近似,不是所有書寫系統的終極答案,實作仍要看語言與操作情境。
一個、七個、十一個沒有互相打架。它們只是各自回答:你正在數畫面、抽象符號,還是程式裡的 UTF-16 儲存格?
字串沒有偷偷變長。只是我們把三把尺疊在同一個「字」上,然後忘了替它們寫名字。