文字为什么会变成乱码
磁盘上并不存在所谓的纯文本。文件里装的是字节,而编码是这样一个约定:哪个字节、或者哪一组字节,代表哪个字符。普通文本文件并不记录这个约定,所以每一个打开它的程序都是在做假设。假设错了就出现乱码,而乱码的具体形状,是判断到底发生了什么的可靠线索。
乱码有几种值得认识的特征
最眼熟的一种,是一个带重音的字母变成两个。在 UTF-8 里,字母 é 存成 C3 和 A9 两个字节,而用西欧代码页逐字节读取的程序会把它们打印成 Ã 和 ©。任何 UTF-8 文件被当作旧的单字节编码来读,每个非英文字符都会这样变成两三个字符;一长串这样的结果连起来,就是中文或日文文本看上去像一堵拉丁碎片墙的原因。
另外三种记号的含义不同。文件开头出现 ,说明这是带字节顺序标记的 UTF-8 被当成了别的编码。带问号的黑色菱形是替换字符,是解码器在遇到那些在指定编码里不可能合法的字节时写下的。而本该是字母的位置上出现一个普通问号,通常意味着文本被转换成了一种放不下这个字母的编码。
其中两种特征意味着文字已经没了
é 是可以救回来的,因为原来的字节还在文件里,只是解释错了。用正确的编码去读同样这批字节,文字会原样回来。
替换字符和光秃秃的问号则不同:它们就是文件现在真正装着的内容。原始字节在转换的那一刻就被丢掉了,之后任何步骤都无法推断那个菱形原本是 é 还是 ü。这就是为什么修编码问题的第一条规矩是别再往原文件上保存——每用错误的编码保存一次,就把一个可逆的问题变成不可逆的问题。
双重编码,以及它为什么会反复发生
如果一个程序把 UTF-8 当作单字节编码读进来,再把结果按 UTF-8 存回去,这个错误就会呈现出一副无法挽回的样子:文件里如今真的装着 Ã 和 © 这两个字符,各自占着自己的两个字节。来上两遍,一个字母就变成四个字符,再来一遍就是八个。
它其实仍然可逆,因为只要知道经历了几轮,这个过程就能被精确地反过来做:每一轮先编码回那个单字节代码页,再按 UTF-8 解码。这种情形之所以常见,是因为它多半发生在流水线里——表单、数据库字段、导出环节——同一次转换又落在了已经转换过一次的数据上。
至今仍会遇到的旧编码
西文文本通常是 Windows-1252,这也是多数人说 Latin-1 时实际指的东西。这个差别很要紧:Windows-1252 把 Latin-1 空着的那段填上了弯引号、长破折号和省略号,所以一个漂亮的弯引号才会那么经常冒成 “。来自旧式东亚系统的文本会是 Shift_JIS、EUC-KR、Big5 或 GB18030,西里尔文则是 Windows-1251 或 KOI8-R。
在办公室里,最常见的源头是表格软件。在 Windows 上导出 CSV,至今仍倾向于按系统代码页而不是 UTF-8 来写,于是在制作它的那台机器上完美无缺的文件,到别处全是乱码;而在同一台机器上重新导入又一切正常,做这份文件的人因此根本看不见问题。
检测是猜测,文本越短越不可靠
UTF-8 能自我校验:它的多字节序列遵循严格的模式,所以能干净地按 UTF-8 解码的文件,几乎肯定就是 UTF-8。这是检测器唯一能给出的有把握的答案。
相比之下,任何单字节编码对所有可能的字节都成立,因此谁也排除不掉谁。检测器只能看哪些字母和字母组合在真实语言里更常见来做选择,这在一整段文字上管用,在一个人名或一个列标题上就会失败。对一段二十字节的字符串给出「Windows-1252,置信度低」的工具,是诚实,而不是没用。
文件本该在哪里声明自己
带声明的格式要好办得多。HTML 有 meta charset,XML 有它的声明,而任何通过 HTTP 传输的东西都可以在 Content-Type 头里写明字符集——并且这个头的优先级高于文档自己的说法,所以一个正确的页面配上错误的响应头,照样会乱。
UTF-8 文件开头的字节顺序标记是另一种声明,而且是一把双刃剑。对认识它的编辑器来说,它省去了猜测;对不认识它的工具来说,它就是破坏:报告「位置 0 处有意外字符」的 JSON 解析器、首行不再是 shebang 的脚本,或者第一个列名前粘着一个看不见的字符的 CSV。
怎样真正修好一个坏掉的文件
从字节出发,而不是从屏幕出发。用十六进制打开文件,看看带重音的字符占一个字节还是两个——单凭这一点,就能把旧编码文件和被读错的 UTF-8 文件区分开。然后用一个候选编码去解码,检查是否还原出真正的词句:判断标准是文字对不对,而不是它变没变。
等到能读了,把副本另存为 UTF-8,在确定之前别动原件。而且要在边界上修,而不是在中间:数据进来时解码,内部统一用一种编码,出去时把它声明清楚。反复出问题的,几乎都是在中途做转换的流水线——同一个错误被应用两次,走的正是这条路。