代理对的来历与算法
UTF-16 的编码单位是 16 位,只能表达 0-0xFFFF 共 65536 个值,而 Unicode 码位要到 0x10FFFF。解决办法是把 U+10000 以上的码位减去 0x10000 得到 20 位值,前 10 位加到 0xD800 形成高位代理,后 10 位加到 0xDC00 形成低位代理,两个码元合起来表示一个字符。因此代理区 D800-DFFF 本身不是合法字符,单独出现即非法。字符串按码元计数时,U+10000 以上字符会被算作长度 2。
基本信息
| 编码单位 | 16 位码元(code unit) |
|---|---|
| 高位代理区间 | U+D800-U+DBFF |
| 低位代理区间 | U+DC00-U+DFFF |
| 覆盖范围 | 基本平面 1 个码元,增补平面 2 个码元 |
| 典型语言 | Java 的 char / String、.NET 的 char、JavaScript 字符串 |
| 长度陷阱 | 按码元计的长度会把一个增补平面字符算成 2 |
典型使用场景
- emoji 相关逻辑:判断字符串里是否含 emoji、做长度限制与截断时必须按码点或字形簇而非码元
- Java/.NET 字符串遍历:用 char 循环会拆散代理对,需要用码点遍历接口
- 前后端交互:JavaScript 侧按 length 计算的长度与后端按码点计算的口径不一致,容易触发校验失败
- 数据校验:检测孤立代理者(lone surrogate),它是损坏数据的典型形态
- 跨编码转换:UTF-16 与 UTF-8 互转时代理对必须成对处理,否则产出非法序列
代理对的观察与处理
代理对的观察与处理
python -c "s='\U0001F600'; print(len(s), [hex(ord(c)) for c in s])" # 单个码点,Python3 按码点计数
python -c "print([hex(ord(c)) for c in '\U0001F600'.encode('utf-16-le').decode('utf-16-le')])"
# Java:按码元遍历会拆开代理对,正确做法是按码点遍历
# for (int i = 0; i < s.length(); ) { int cp = s.codePointAt(i); i += Character.charCount(cp); }
python -c "print('\U0001F600'.encode('utf-16-le'))" # 小端下 4 字节:3d d8 00 de
python -c "s='\ud83d'; print(repr(s.encode('utf-8','surrogatepass')))" # 孤立代理者在严格模式下无法编码码元与码点口径对照
| 口径 | emoji 的计数 | 说明 |
|---|---|---|
| 字节数(UTF-8) | 4 | 存储与带宽估算用 |
| 码元数(UTF-16) | 2 | Java length、JS length 的结果 |
| 码点数 | 1 | Python3 的 len,最接近字符语义 |
| 字形簇数 | 1 或更多 | 带肤色、ZWJ 组合的 emoji 可能由多个码点组成 |
孤立代理者(lone surrogate)是数据损坏的信号:它常出现在按 UTF-16 码元截断字符串、或用错误编码往返转换之后。严格编码器(如 UTF-8 严格模式)会直接拒绝,宽松实现会替换成 U+FFFD,两者行为差异会掩盖问题,排查时应先用严格模式验证。
长度校验是代理对最容易出问题的地方:前端按 JS 的 length 限制 10 个字符,后端按 Python 的 len 也限制 10,同一个含 emoji 的输入可能一边通过一边失败。接口文档应写明按字节、码点还是字形簇计数,并在两端使用同一口径。
代理区码位不会被直接分配给字符(U+D800-U+DFFF 永远保留),所以任何声称「这个字符就是 U+D83D」的数据都是损坏或人为构造的,可用于校验过滤与安全检测。