文本处理决策指南:编码、归一化、大小写与计数的 7 个判断点
同一段文本,两个工具给出不同结论,多数时候不是工具坏了,而是编码、归一化形式、行尾、locale 与计数口径没对齐。这篇按七个决定点给判据:什么情况必须转 NFC,比较键该用折叠还是小写,长度按码点还是按字素簇算,以及哪几步做完就再也回不去。
动手改文本之前:编码、归一化形式与行尾
文本在任何工具里出现时,都同时带着三个看不见的属性:字节按哪种编码解码成码位、码位是否已按某种形式归一化、行与行之间用哪个字符分隔。这三件事都不在内容里,却决定内容如何被切分和比较。改文本之前先把它们固定下来,是唯一不能跳的一步;跳过它,后面每处「看起来没问题」的修改都在猜。
编码这一层已有硬约束。要跨进程、跨机器、写进文件或数据库的文本,一律先转成 UTF-8 字节再谈其他,RFC 5198 把 UTF-8 加 NFC 定为网络交换的形式,RFC 8259 也要求 JSON 文本用 UTF-8 编码。反过来,凡是长度上限出现在密码框、昵称、备注这类输入上,限制必须按码点或字节算,不能按 JavaScript 的 string.length 算,它数的是 UTF-16 码元,一个 emoji 就占两个;NIST SP 800-63B 要求校验端至少接受 64 个字符、建议接受 Unicode,按字节截断两者都违背。
行尾是最容易被静默改写的一项:LF、CRLF 与单独的 CR 在文本视图里长得一样,只有看十六进制才分得清。git 的 core.autocrlf 会在检出时把 LF 换成 CRLF、提交时再换回去,于是同一份行级脚本在本地和 CI 上结论不同。判据是机器消费的行数据统一成 LF,只有 Windows 独占的控件才用 CRLF;混用时先统一再处理。代价是统一行尾会改写整个文件,diff 里全文件变红,这一步必须单独提交。
| 属性 | 常见默认值 | 出错时的表现 | 判断依据 |
|---|---|---|---|
| 编码 | 按本地编码读写 | 乱码或字符被替换成问号 | 看原始字节是否合法 |
| 归一化形式 | 不做任何归一化 | 显示相同却比较不等 | 比较前统一到 NFC |
| 行尾 | 跟随平台或 git 配置 | diff 全文件变更 | 十六进制看 0D 0A |
| 长度口径 | string.length | emoji 被算成两个字 | 按码点或字素簇重算 |
三个属性都不在文本内容里,却决定后面每一步的结果
大小写转换依赖语言:土耳其语 i、德语 ß、希腊语词尾 sigma
不带 locale 的大小写转换函数实现的是 Unicode 默认映射,不是英文规则。这套默认映射在三处会给出母语者不认的结果,恰好覆盖了大多数线上事故:土耳其语的带点与无点 i、德语的 ß、希腊语的词尾 sigma。
土耳其语里 i 与 I 并不配对。小写 i 的大写是 İ(U+0130,带点大写 I),大写的 I 的小写是 ı(U+0131,无点小写 i)。默认映射给的是英文答案,两者都错,只有带 locale 的转换函数并显式传 tr 才给对。反向更危险:把带点大写的城市名转小写,默认实现会产出 i 加组合点 U+0307,字符数从原样变多一位,任何按索引做的高亮、截断与搜索都会错位。
德语的 ß 会改变长度:转大写得到 SS,一个码点变两个码元,长度 1 变 2;正式的大写形式 ẞ(U+1E9E)自 Unicode 5.1 才有,老字体遇到它会退回 SS。希腊语的问题在方向性:Σ 转小写的结果取决于位置,词尾用 ς(U+03C2)、词中用 σ(U+03C3),所以大小写转换既不幂等也不可逆,ς 与 σ 转回大写都是 Σ,原始差异再也拿不回来。
判据按用途分三档:只用于展示、要符合用户语言,用带 locale 的转换函数并显式传语言标签;要构造比较键或去重键,用大小写折叠而不是小写,折叠能把 ß 折成 ss、把开尔文符号 K(U+212A)折成 k,小写做不到;既要稳定又要跨语言一致,那就别转换,直接比原串。代价是折叠会放大长度。文件系统另有一层:macOS 与 Windows 默认大小写不敏感却保留大小写,Linux 的 ext4 敏感,只差大小写的两个文件名在本地共存,推到 CI 就冲突。
| 场景 | 默认结果 | 正确结果 | 误用后果 |
|---|---|---|---|
| 土耳其语 i 转大写 | I | İ(U+0130) | 账号匹配失败 |
| 土耳其语 I 转小写 | i | ı(U+0131) | 搜索漏结果 |
| 德语 ß 转大写 | SS,长度加一 | ẞ(U+1E9E) | 按索引截断错位 |
| 希腊语 Σ 转小写 | 一律 σ | 词尾 ς、词中 σ | 往返转换丢信息 |
| 比较键构造 | 转小写 | 大小写折叠 | ß 与 ss 判成两条 |
同一套默认映射,在三种语言里分别错在结果、长度与可逆性上
Unicode 归一化:NFC、NFD 与「看起来相同但不相等」
同一个字可以有不止一种合法写法。é 可以是一个码点 U+00E9,也可以是 e 加组合尖音符 U+0065 U+0301,两者渲染一致、码点序列不同,逐字节比较直接判为不等。UAX #15 定义四种形式来处理这件事:NFC 做规范组合,NFD 做规范分解,NFKC 与 NFKD 在此之上再做兼容分解,把 ① 拆成 1、把连字 fi 拆成 fi、把全角 A 拆回 A。
判据由用途决定,不由喜好决定。存储与交换用 NFC,它通常更短,一个字往往一个码点,RFC 5198 与 RFC 8264 的 PRECIS 都把 NFC 写成前置条件,口令处理(RFC 8265 的 OpaqueString)也只做 NFC,不做大小写与宽度映射。要按基字符检索、排序或写正则,先转 NFD 把字母与重音拆开,匹配完再转回。要压掉从文档粘贴过来的全角与兼容字符,用 NFKC 做比较键,但只在比对那一瞬间用,绝不写回存储。
不可逆点就藏在这一层:NFKC 与 NFKD 会把 Ⅻ 变成 XII、把上标 ² 变成 2、把不换行空格 U+00A0 变成普通空格,差异一旦落库就找不回来。NFC 本身保信息,但会改变长度与偏移量,先算下标再归一化的流水线必然错位。还有三类差异归一化根本不处理:变体选择符 U+FE0F 的有无、零宽连接符拼出的 emoji 序列、汉字之间的异体与简繁对应,这些只能靠显式规则。
- 两侧都先归一化到同一形式,再比较;不要比较之后再补归一化
- 归一化只在数据入口做一次,不要在每个中间层反复做
- 存储层只允许 NFC,NFKC 只出现在比较键的瞬时计算里
- 比较失败时打印码位而不是打印字符串,肉眼分辨不出连接符与变体选择符
计数:码点、字素簇与显示宽度
这段文本有多少个字,至少有四个互不相等的答案:UTF-16 码元数、码点数、字素簇数与显示宽度。JavaScript 的 string.length 是第一个,中文常用字占 1,emoji 占 2,一个由四个 emoji 与三个零宽连接符拼成的家庭序列要占 11 个码元,却只是 7 个码点、1 个字素簇。码点数靠展开字符串来数,字素簇数才是用户眼里的字数,规则来自 UAX #29 的扩展字素簇,Intl.Segmenter 把粒度设成 grapheme 就是它。
判据按这个数字最后去哪来定。写进数据库字段长度、API 配额、加密前的字节上限,按码点或字节算,因为对端拿到的就是这两者。界面上的字数提示、剩余字符、光标移动与截断,按字素簇算,否则退格一次可能拆掉半个代理对,屏幕上出现方块。终端对齐、等宽表格列宽、进度条宽度,按显示宽度算,规则来自 UAX #11:东亚宽与全角字符占两列,组合标记占零列,emoji 通常两列,模糊宽度的字符随上下文变。
代价很具体。Intl.Segmenter 比读 length 慢一到两个数量级,对纯 ASCII 或纯中文正文没有收益,只在真正需要的地方用。按长度截断是最典型的不可逆操作:按码元截会把代理对切开,得到 U+FFFD;按码点截会切开连接序列,家庭 emoji 变成单人;按字素截看似安全,仍可能切掉变体选择符而让 emoji 失去彩色形态。三种截断都救不回来,只能留原文重来。
| 文本 | string.length | 码点数 | 字素簇数 |
|---|---|---|---|
| A | 1 | 1 | 1 |
| é(NFC,U+00E9) | 1 | 1 | 1 |
| e 加 U+0301 | 2 | 2 | 1 |
| 国旗 emoji(两个区域指示符) | 4 | 2 | 1 |
| 家庭 emoji(4 个加 3 个连接符) | 11 | 7 | 1 |
| 中文两个字 | 2 | 2 | 2 |
同一个显示结果,四个计数口径最多能差出十倍
按行拆分、排序与去重:「忽略大小写」掩盖了什么
按行处理的前提是先把行定义清楚。LF、CRLF 与单独的 CR 都算行分隔符,用换行符去拆一段 CRLF 文本,每行都会留下一个尾随的 CR,肉眼看不见,却让后面的比较与去重全部失败。末尾换行是第二个歧义:以换行结尾的两行文本,拆完得到三段,最后一段是空串,行数统计因此总差一;判据是数分隔符再单独处理末尾,或先统一行尾再拆。
不是所有文本都能按行拆。CSV 的引号字段里允许出现换行(RFC 4180),按行切会把一条记录劈成两条;格式化过的 JSON 同理。这两类要么用带引号状态的解析器,要么换成每行一条独立记录的 NDJSON,代价是牺牲缩进可读性。
排序与去重的问题都在比较键。默认排序按码元,于是 10 排在 9 前面,因为逐位比较时 1 小于 9;localeCompare 走 UTS #10 的排序规则,结果随 locale 与 ICU 版本变化,同一份数据在两台机器上顺序可能不同。要可复现的机器消费就用码点序并把环境固定成 LC_ALL=C,给人看的列表才用 locale 排序。
「忽略大小写」这一步掩盖了两件事。第一,折叠后相等不等于用户以为的相等:Foo、foo、FOO 被合并成一条,而 foo 加尾随空格、加不换行空格 U+00A0、加零宽空格 U+200B 依旧是三条不同记录。第二,合并会永久丢内容,保留哪一条、其余字段怎么并,取决于实现;先 trim 再落库则把原始空白差异直接抹掉。macOS 的 HFS+ 把文件名写成近似 NFD 的形式,两个来源的文件名可能码点不同而显示名相同。
| 操作 | 默认行为 | 隐藏代价 |
|---|---|---|
| 按换行符拆分 | CRLF 行尾残留 CR | 比较与去重全部失效 |
| 统计行数 | 拆完结果的长度 | 末尾换行导致多算一行 |
| 默认排序 | 按 UTF-16 码元逐位比 | 10 排在 9 前 |
localeCompare 排序 | 随 locale 与 ICU 版本变 | 跨机器顺序不一致 |
| 完全相等去重 | 只认原串一致 | 空格与零宽字符另算一条 |
| 折叠后去重 | 大小写合并 | 保留哪条不可控,内容可丢 |
每一步的默认值都合理,组合起来却会悄悄改掉数据集
文本差异比较:按行、按词还是按字符
比较粒度决定结果形状与代价。行级比较用 Myers 一类算法,复杂度是 O(ND),D 为编辑距离,最坏退到两个长度的乘积;git 默认用它,另有 histogram 与 patience 变体在变更密集时更稳。词级与字符级一般不做全局计算,而是在行级匹配之后对每个变更块再对齐一次,开销限于块内。
判据按对象选。代码、配置、日志用行级,行内再叠一层词级高亮;自然语言稿件用词级,词边界走 UAX #29 的分词规则;只有编号、拼写、条款这类短串才值得逐字符。中文多一条约束:词级依赖空格分词,中文没有空格,结果是退化成整段替换,此时按字素逐字比更诚实。
噪声是第二个决定因素。CRLF 与 LF 的差异会让每个变更块显示成全行修改;BOM 只差三个字节,却让整文件看起来重写;尾随空白同理。所以顺序是先规范化再比较:统一行尾、去掉尾随空白、必要时统一到 NFC。代价是规范化掉的差异从此不可见,要审计原始变更得在规范化之前留副本。
最深的误用是拿差异当补丁:把比较结果当成两份文本的全部区别并据此重建文件,等于假设未显示的内容完全相同,可被忽略的空白与零宽字符都会在重建时消失。反方向的坑是两侧分别为 NFC 与 NFD,屏幕上完全一样,工具却报差异,此时该查码位而不是怀疑工具。比较无序集合前先各自排序,comm 要求两侧用同一排序规则,否则公共行会漏掉。
| 粒度 | 适用场景 | 主要代价 |
|---|---|---|
| 行级 | 代码、配置、日志 | 长行变动只能整体显示 |
| 词级 | 稿件、文档修订 | 中文无空格时退化 |
| 字符级 | 短串、编号、条款 | 长文本开销剧增 |
| 先归一化再比较 | 跨来源、跨平台文本 | 被规范化掉的差异不可见 |
粒度选错会让结果要么看不见变化,要么全是噪声
信任一次文本变换之前的检查清单
把文本交给一次变换之前,先问三个问题,每一个都能否掉一半候选做法。输入的三件属性是否已经确定:字节来自哪种编码、现在处于哪种归一化形式、行尾是哪一个。变换是否幂等:同样输入再跑一次是否完全相同,还是像折叠那样逐步放大。失败时能否回到原始输入:手上有没有一份未被触碰的字节副本,还是只剩已经被替换成问号或 U+FFFD 的文本。
第三个问题最容易被跳过。分界线是能不能重跑:留着原始字节,任何一步都可重来;只留变换后的文本,一旦某步做了替换就没有回头路。所以流水线应当字节进、字节出,每一步只读原文、产出新值,而不是在原地连续改写同一份缓冲区,否则出错时连「哪一步坏掉的」都无法定位。
幂等性值得单独验。NFC 是幂等的,再做一次不变,NFC 与 NFD 交替则不是。全角转半角、去空白、去重、大小写折叠都不是幂等的:把 ß 折成 ss 之后再折一次仍是 ss,看似稳定,但把 ß 与 ss 合并去重后两条只剩一条,这一步不可撤销。可逆与幂等更不是一回事:Base64 可逆,可把已编码结果再编码一次会得到另一段文本,所以它不幂等;trim 不可逆,重复执行结果却不变。
- 入口处确认编码、归一化形式与行尾,各收敛一次并记录
- 始终保留一份原始字节副本,所有判断从它重放
- 一次只改一个变量,改完立刻比对长度与码位数量
- 长度变化要落在预期内,ß 转大写加一、NFC 通常减一,超出就停
- 存储层只写 NFC,NFKC 与折叠只用于瞬时比较键
- 文件名、去重键、排序键三处必须先归一化再比较
- 任何做了替换的步骤,后面紧跟一次不可逆性确认
常见问题
- 两个看起来一模一样的字符串为什么不相等?
- 规范等价不等于码点相同。é 可以是 U+00E9,也可以是 e 加 U+0301。比较前把两侧统一到 NFC,或直接比对码位。
- 不带参数的转大写为什么在土耳其语里出错?
- 它用的是 Unicode 默认映射,把 i 映射成 I。土耳其语要的是 İ(U+0130),必须用带 locale 的转换并显式传 tr。
- 字符串长度为什么和看到的字数不一样?
- length 数的是 UTF-16 码元,emoji 占两个;用户眼里的字是字素簇,要用
Intl.Segmenter按grapheme切分后再数。 - 去重时该不该忽略大小写?
- 看键的语义。邮箱、标识符可以折叠后比较;密码、路径、大小写敏感的键不能。折叠去重后仍要保留原始那一份。
- diff 显示整个文件都改了,是什么原因?
- 多半是行尾从 CRLF 变成 LF,或文件多了 BOM。先统一行尾与 BOM 再比较,能在比较前确认两侧字节同源更好。