字素簇:一个字符到底多长
Basic_Emoji 与 RGI 序列 两页拆过 👨👩👧 是五个 code point。换个更极端的例子 🤦🏼♂️(扶额男、浅肤色):Rust 数出长度 17,JS、Java 与 C# 数出 7,Python 3 数出 5,Swift 只数出
1。四个答案都对,差别在按哪一层数——UTF-8 字节、UTF-16 码元、code point,以及最上面那层 grapheme cluster(字素簇)。只有最后一层对应人眼感知的「一个字符」。
1 · 四种长度
🤦🏼♂️ 的五个码点是 U+1F926(扶额)、U+1F3FC(浅肤色)、U+200D(ZWJ)、U+2642(男性符号)、U+FE0F(VS16)。往下摊:前两个是补充平面字符,各占一对 UTF-16 代理对,其余三个各占一个码元,合计 7 个码元;换成
UTF-8,那两个补充平面字符各占四字节,另外三个各占三字节,合计 17 字节。往上收:五个码点合成一个字素簇,于是是 1。
各语言的默认长度取的正是不同层:Rust 的 .len() 是 UTF-8 字节,JS 的 .length 与 Java、C# 是 UTF-16 码元,Python 3 的 len() 是 code point,Swift 的 .count 与 Elixir 是字素簇。所以「字符串长度」这个问题本身有歧义,得先问是哪一层。
2 · 切成字素簇
浏览器原生的 Intl.Segmenter 以 granularity: 'grapheme' 把文本切成字素簇。切出来的每一段就是用户眼里的一个字符,段内可能裹着若干码点:肤色序列、ZWJ 序列、区域指示符对、组合记号,都会被收进同一段。
3 · UAX #29 的分类与规则表
Intl.Segmenter 内部并不神秘,切分规则全写在 UAX #29 里,两步:先给每个 code point 标一个 Grapheme_Cluster_Break 属性值,再用一张固定的规则表,只看相邻两个码点的属性值(外加少量跨字符状态),判断此处是断开还是黏连。
| 属性值 | 含义 |
|---|---|
Extend |
组合记号、肤色修饰符、变体选择符,黏住前一格 |
ZWJ |
U+200D,粘合 emoji 序列 |
RI |
区域指示符,两两成对拼一面旗 |
L V T LV LVT |
韩文谚文音节的拼合零件 |
Prepend |
极少数前置格式符,黏住后一格 |
Control |
控制符与行分隔符,两侧必断 |
Other |
普通字符,默认各自成簇 |
规则表本身只有十来条,核心几条如下(完整实现在系列根的 regex.ts):
function shouldBreak(a, b, st) {
if (a.cat === 'CR' && b.cat === 'LF') return false; // GB3 \r\n 不拆
if (/^(CR|LF|Control)$/.test(a.cat)) return true; // GB4 控制符后必断
if (/^(CR|LF|Control)$/.test(b.cat)) return true; // GB5 控制符前必断
if (a.cat === 'L' && /^(L|V|LV|LVT)$/.test(b.cat)) return false; // GB6 ┐
if (/^(LV|V)$/.test(a.cat) && /^(V|T)$/.test(b.cat)) return false; // GB7 ├ 谚文拼合
if (/^(LVT|T)$/.test(a.cat) && b.cat === 'T') return false; // GB8 ┘
if (b.cat === 'Extend' || b.cat === 'ZWJ') return false; // GB9 记号 / ZWJ 黏前
if (b.cat === 'SpacingMark') return false; // GB9a 占位记号黏前
if (a.cat === 'Prepend') return false; // GB9b 前置符黏后
if (st.gb11 && b.pict) return false; // GB11 emoji ZWJ 序列
if (a.cat === 'RI' && b.cat === 'RI' && st.riCount % 2) return false; // GB12 旗成对
return true; // GB999 其余一律断
}
警示 · 预设里的 क्षि(Devanagari 连写)会让两边对不上:手写引擎切成 2 段,Intl.Segmenter 切成 1 段。差在 GB9c——它管的是 Indic 连写,依赖 Indic_Conjunct_Break 属性,而 JS 的 \p{…} 查不到这一栏(写
\p{Indic_Conjunct_Break=Linker} 直接抛 Invalid property name),所以本页的简化引擎没有实现它。这条规则并非一直都在:对照 UAX #29 的两个修订版,GB9c 在 Unicode 15.1 的第 43 版里首次出现,15.0 的第 41 版全文找不到它。切分规则会随 Unicode
版本增补,照着某一年的规则手搓,迟早与跟着版本走的 ICU 分家。
4 · 字符串反转
反转是最容易暴露层次错误的操作。按码元反转会把代理对拆散,产出孤儿代理,屏幕上是乱码;按码点反转保住了单个码点,但 ZWJ 序列、肤色修饰符会跟错基字符,🤦🏼♂️ 会散成几个不相干的符号;只有按字素簇反转才保住每个「字符」完整。
注 · 前面 emoji 几页拆过的 ZWJ 序列、肤色修饰符、VS16 与区域指示符旗,本质都是「多个 code point 组成一个字素簇」的具体形态,字素簇是这一现象的统称。切分规则随版本变,所以限长、截断、反转这类操作应交给跟着 Unicode 版本更新的实现(Intl.Segmenter、ICU、Swift
标准库),而不是自己按码点凑。
5 · 参考文献
- Unicode. UAX #29: Unicode Text Segmentation. 字素簇、词、句三种边界的规范:
Grapheme_Cluster_Break属性值定义与 GB1–GB999 规则表,本页的手写引擎照它实现。unicode.org - Unicode. UAX #29(第 41 版 / Unicode 15.0). 与最新版对照可见
GB9c尚未出现,是本页版本差异一节的依据。unicode.org - MDN. Intl.Segmenter.
granularity的三个取值与segment()返回的迭代结果结构。developer.mozilla.org - Henri Sivonen. It's Not Wrong that "🤦🏼♂️".length == 7. 那组 17 / 7 / 5 / 1 的出处,逐层解释各语言为何给出不同长度。hsivonen.fi