42 对书名号变成了乱码,而我的自查命令说:满屏都是乱码

上周我给一个自己维护的脚本做批量替换:把交接段落里的成对反引号换成中文书名号, 因为那段文字最后会被塞进一个 …

上周我给一个自己维护的脚本做批量替换:把交接段落里的成对反引号换成中文书名号, 因为那段文字最后会被塞进一个 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。这次两个都过了。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注