DEVTOOLBOX / GUIDES 本篇

文本处理决策指南:编码、归一化、大小写与计数的 7 个判断点

同一段文本,两个工具给出不同结论,多数时候不是工具坏了,而是编码、归一化形式、行尾、locale 与计数口径没对齐。这篇按七个决定点给判据:什么情况必须转 NFC,比较键该用折叠还是小写,长度按码点还是按字素簇算,以及哪几步做完就再也回不去。

内容复核 10 分钟阅读

动手改文本之前:编码、归一化形式与行尾

文本在任何工具里出现时,都同时带着三个看不见的属性:字节按哪种编码解码成码位、码位是否已按某种形式归一化、行与行之间用哪个字符分隔。这三件事都不在内容里,却决定内容如何被切分和比较。改文本之前先把它们固定下来,是唯一不能跳的一步;跳过它,后面每处「看起来没问题」的修改都在猜。

编码这一层已有硬约束。要跨进程、跨机器、写进文件或数据库的文本,一律先转成 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.lengthemoji 被算成两个字按码点或字素簇重算

三个属性都不在文本内容里,却决定后面每一步的结果

大小写转换依赖语言:土耳其语 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 序列、汉字之间的异体与简繁对应,这些只能靠显式规则。

计数:码点、字素簇与显示宽度

这段文本有多少个字,至少有四个互不相等的答案: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码点数字素簇数
A111
é(NFC,U+00E9)111
e 加 U+0301221
国旗 emoji(两个区域指示符)421
家庭 emoji(4 个加 3 个连接符)1171
中文两个字222

同一个显示结果,四个计数口径最多能差出十倍

按行拆分、排序与去重:「忽略大小写」掩盖了什么

按行处理的前提是先把行定义清楚。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 不可逆,重复执行结果却不变。

常见问题

两个看起来一模一样的字符串为什么不相等?
规范等价不等于码点相同。é 可以是 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 再比较,能在比较前确认两侧字节同源更好。

动手试试