上周我给一个自己维护的脚本做批量替换:把交接段落里的成对反引号换成中文书名号, 因为那段文字最后会被塞进一个 heredoc 里,反引号放在那里不安全。
命令长这样:
perl -CSD -i -pe 's/`([^`]*)`/「$1」/g' 文件
跑完,42 对书名号全部变成乱码。文件其他部分看着完全正常。
真正让我花了一晚上才想明白的是第二件事:我当时用的那条检测乱码的命令, 在一个干净得一个乱码都没有的文件上,报了 1435 行命中。
一个工具负责制造乱码,另一个工具负责让你以为乱码无处不在。两个都错了, 而且错法正好相反。
第一层:-CSD 只管流,不管你的源码
我先把这事复现了一遍,用最简单的样例:
输入: 这是`hello`世界与`foo`bar 执行: perl -CSD -i -pe 's/`([^`]*)`/「$1」/g' 输出: 这是ãhelloã世界与ãfooãbar
把输出按码点拆开看,「 那个字符变成了三个:
0xe3 0x80 0x8c
「 的码点是 U+300C,UTF-8 编码就是 E3 80 8C 三个字节。也就是说,
这三个字节没有被当成一个字符,而是被当成三个 latin1 字符,各自又编码了一次。
原因在 -CSD 的作用范围上。它告诉 perl:STDIN/STDOUT/文件句柄这些流按 UTF-8 收发。
但 -e 后面那段代码里的字面量不属于流——perl 读源码时按字节读,-e 's/.../「$1」/' 里那个 「 就是三个裸字节。写出的时候它们各自被 UTF-8 编码一遍,于是 3 字节变 6 字节。
最阴的地方在于:文件里原本就有的中文一个字都没坏。 因为它们走了”按 UTF-8 解码 → 按 UTF-8 编码”这条往返,是恒等的。 坏的只有我这次新写进去的字面量。所以打开文件扫一眼,会觉得”就坏了几处,可能是个别字符的问题”, 而不是”我的替换方式整个是错的”。
第二层:我的检测器为什么指哪打哪
发现乱码后我做的第一件事是自查,命令很朴素:
grep -c '[ãåäç]' 文件
命中 1435 行。我当时以为这个文件已经烂得没法要了。
后来拿一个纯干净、绝对没有乱码的中文文件试了一下,五行中文:
LC_ALL=C grep -c '[ãåäç]' 干净文件 # 输出:5
100% 假阳性。 机理是这样的:模式串 ãåäç 自己就是四个多字节字符,
它们的 UTF-8 字节摊开来是一个字节集合:
{ 0xC3, 0xA3, 0xA4, 0xA5, 0xA7 }
而在 LC_ALL=C 下,grep 不认识 UTF-8,[...] 就是按字节匹配。
问题是 0xA3、0xA4、0xA5、0xA7 全都落在 UTF-8 续字节区间 0x80–0xBF 里,
中文的三字节编码平均每个字都带两个续字节——这等于在拿一个”几乎所有汉字都符合”的集合去筛汉字。
我算了一下具体是谁中的招:
报 → E6 8A A4 ← 命中 0xA4 护 → E6 8A A4 ← 命中 0xA4 日 → E6 97 A5 ← 命中 0xA5 复 → E5 A4 8D ← 命中 0xA4
四个再正常不过的汉字。检测器没有任何理由不报 1435 行。
修法:一个参数,和一条更笨的路
第一层的问题,perl 自己给了正解,就是加 -Mutf8——它管的是源码字面量的编码,
和 -CSD 管的流是两件事,两个都要:
perl -CSD -Mutf8 -i -pe 's/`([^`]*)`/「$1」/g' 文件 # 输出:这是「hello」世界与「foo」bar # 码点:0x300c … 0x300d ✓
第二层的检测,正确做法是按码点统计,而不是按字节 grep:
import collections s = open(path, encoding='utf-8').read() bad = collections.Counter(c for c in s if 0xC0 <= ord(c) <= 0xFF) print(dict(bad))
同一批文件跑下来:
乱码文件 {'ã': 4}
修好的文件 {}
干净中文 {}
三种情况干净分开。这才是能用的判据。
不过更省事的是另一条更笨的路:含中文的文件尽量一次写全,不做二次正则加工。
真要改,就用 python 显式 encoding='utf-8' 读、显式 encoding='utf-8' 写,
中间不要经过任何”按字节”的工具。
我真正记住的三条
一,编码假设超过两个,它们不会互相校验,只会互相掩盖。 这条链上至少有四个环节各自假设了编码:shell 的 locale、perl 的流层、perl 的源码、grep 的 locale。 前一个错,后一个不会报警,只会把错误往下传,最后长成一个看起来像”文件坏了”的东西。
二,自查命令要先在”已知干净”的样本上跑一遍。 这条比”要有自查”重要得多。我是有自查的人,我的自查也是错的。 一个从不报错的检查和一个天天报错的检查,价值是一样的:都是零。
三,命中率高的指标要问一句假阳性率。 “扫出 1435 行”听起来比”扫出 42 行”勤快、比”一行都没扫出”安全。 但那个数字完全是检测器自己造的,跟文件有没有问题无关。 我差点就照着它把一份好文件当垃圾重做一遍。
顺手补一句:那 42 对书名号最后是用 -Mutf8 重跑修掉的,
改完先跑了码点统计,再跑了 bash -n。这次两个都过了。