Python 处理中文编码:一个 65 字节的文件,为什么读出来是 38 个字符
Python 处理中文编码:一个 65 字节的文件,为什么读出来是 38 个字符
上周把一篇 Markdown 正文送进剪贴板,粘到网页编辑器后满屏都是「閰嶇疆鏂囦欢宸插浠斤細」。源文件本身没问题,用浏览器打开是正常中文。问题出在中转的那一步:一个 65 字节的 UTF-8 文件,被读成了 38 个字符。
正确读法是 29 个字符。多出来的 9 个不是凭空产生的,是 27 个双字节汉字被当成 54 个单字节重新配对的结果。
先量一下这 9 个字符是怎么多出来的
固定一个 65 字节的 UTF-8 探针文件,内容两行:
配置文件已备份:D:\备份\配置.json
第二行:中文
在 Windows PowerShell 5.1.19041.3803 下读同一个文件,只改一个参数:
$d = Get-Content $f -Raw # 不写 -Encoding
$u = Get-Content $f -Raw -Encoding UTF8
"$($d.Length) vs $($u.Length)"
$d.Contains([char]65533) # U+FFFD
$d.Contains([char]37197) # 「配」
输出是 38 vs 29、False、False。
这三行输出值得逐条看。字符数从 29 涨到 38,膨胀率 31%;文本里一个 U+FFFD 都没有;原文出现频率最高的「配」字,在错误结果里一次都不存在。也就是说,用「检查有没有乱码符号」来判断读取是否成功,在这个场景下会 100% 漏判——它看起来只是一段你从不认识的中文,而不是一段坏掉的中文。
剪贴板会把错误原样带走。Set-Clipboard -Value $d 之后再 Get-Clipboard -Raw,长度仍是 38,U+FFFD 仍是 0。粘贴到编辑器里,编辑器不会报错,读者只会觉得作者语文不好。
open() 的默认编码不是常量,是环境变量
Python 这边同样的坑,表现形式不同。同一个 open(p, "w"),在两台机器上写出的字节可以完全不一样:
import locale, os
print(locale.getpreferredencoding(False)) # 决定 open() 的默认编码
print(os.environ.get("PYTHONUTF8"))
本机实测两组结果:PYTHONUTF8=1 时输出 utf-8;把它关掉后输出 cp936。两种模式下执行同一句 open("x.txt", "w").write(text),落盘的是 49 字节、以 c5 e4 d6 c3 开头的 GBK 字节流,按 UTF-8 解码直接抛 invalid continuation byte at position 0。
49 这个数字本身还能再拆一层:这段文本 GBK 编码是 47 字节,多出的 2 字节来自换行符翻译——open() 默认 newline=None,写盘时把 \n 改写成 \r\n。同一段文本 UTF-8 是 65 字节,所以「文件突然变小了」在中文项目里不是优化,是编码换了,比值 1.38。
这就是「在我机器上是好的」的典型来源:代码里没有一行跟编码有关的字,行为却由启动环境决定。Python 从 3.10 起给了个开关,可以把这类隐式行为变成硬错误:
python -X warn_default_encoding -W error::EncodingWarning script.py
对没写 encoding 的 open(),直接抛 EncodingWarning: 'encoding' argument not specified;补上 encoding="utf-8" 后不再报。这个开关适合放进 CI 或提交前检查,比靠代码评审盯 open( 可靠。
U+FFFD 是墓碑,不是路标
解码失败时 errors="replace" 会塞一个 U+FFFD 进去,让程序继续跑。代价是信息当场销毁,且不可逆:
try:
"\ufffd".encode("gbk")
except UnicodeEncodeError as e:
print(e) # 'gbk' codec can't encode character '\ufffd'
print("\ufffd".encode("gbk", "replace")) # b'?'
写回 GBK 时它变成 ?。原文那个字是什么,从这一刻起没人知道。
字节层面更隐蔽。把 65 字节的 UTF-8 文本按偏移硬切,切口正好落在三字节汉字的中间:
| 切到第几字节 | 严格解码 | errors="replace" |
|---|---|---|
| 8 | UnicodeDecodeError: unexpected end of data, 0xe6 at 6, need 2 bytes |
'配置�',1 个 U+FFFD |
| 9 | 成功,配置文 |
0 个 U+FFFD |
| 10 | UnicodeDecodeError: unexpected end of data, 0xe4 at 9, need 1 bytes |
'配置文�',1 个 U+FFFD |
分页读取、按字节截断日志、read(1024) 循环,都可能踩在这条缝上。切在 8 还是 9,只差一个字节,一个静默丢字,一个完好无损。
顺带解释一个老梗。锟斤拷 不是随机乱码,它是两次错误叠加的产物:第一次解码失败产生 U+FFFD,第二次把 U+FFFD 按 UTF-8 编码成 ef bf bd,第三次再按 GBK 读——两个 U+FFFD 连在一起就是 ef bf bd ef bf bd,解出来正好是「锟斤拷」。同源的还有 烫烫烫(0xCC 填充)和 屯屯屯(0xCD 填充),都是 Debug 版堆栈里的填充字节被当成 GBK 念了出来。
三个字节的 BOM,一句很难懂的报错
UTF-8 文件可以带 BOM(ef bb bf),也可以不带。带不带都能被浏览器和编辑器正确显示,但 open(..., encoding="utf-8") 会把 BOM 当成一个正文字符 \ufeff:
s = open("bom.json", encoding="utf-8").read()
s.startswith("{") # False,首字符是 '\ufeff'
json.loads(s) # JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig)
报错信息其实已经给了答案。读入口一律用 utf-8-sig:没有 BOM 时它等价于 utf-8,有 BOM 时自动吞掉;写出时一律用 utf-8,不要给下游添麻烦。这条规则对 Python 和 PowerShell 都成立,成本是零。
用 try/except 猜编码,方向反了就是灾难
常见写法是「试着按 A 解,失败就按 B 解」。问题在于 GB18030 几乎不会失败——它是双字节加四字节的超集,任意两个字节凑一起基本都能凑出一个合法字符。实测把上面那段 UTF-8 字节按 GB18030 解码:成功,得到 38 个字符,样本 閰嶇疆鏂囦欢宸插,其中还夹着一个私用区字符 U+E62C,汉字占比 0.632。
0.632 这个数字意味着「汉字比例够高就算解码成功」这类启发式同样会放过它。真正的判据只有一个:这段字节严格按 UTF-8 解得开吗。解得开就是 UTF-8,解不开才轮到 GB18030,而且必须记一笔「低置信度」。
按这个顺序写体检函数,本机 17 个 Markdown 文件(含中文文件名)跑下来:17 个全部 utf-8,BOM 数量 0,汉字数 402–1683。没有一个落到 gb18030? 分支——这也说明,中文项目里真正需要兼容 GBK 的存量文件,比想象中少得多。
把约定写进代码,别写进记忆
落到工程上,只有四条,全都不需要额外依赖:
# 1. 读:显式指定,且容 BOM
text = open(path, "r", encoding="utf-8-sig").read()
# 2. 写:显式指定,不带 BOM
open(path, "w", encoding="utf-8", newline="\n").write(text)
# 3. 二进制判型,别猜
raw = open(path, "rb").read()
kind = "utf-8-bom" if raw[:3] == b"\xef\xbb\xbf" else "utf-8" if _try(raw) else "gb18030?"
# 4. 跨进程传递,用哈希验收而不是肉眼
import hashlib
hashlib.sha256(text.encode("utf-8")).hexdigest()
第 4 条是这次真正救命的。剪贴板、管道、临时文件这三个中转站都不保证编码,肉眼也分不清「閰嶇疆」和「配置」——它们都长得像正常中文。把源文件正文和目标端的字节各算一次 SHA256 对比,一致才算送达;不一致时长度差(38 vs 29)通常能第一时间指出问题在哪一段。
PowerShell 侧对应一句:Get-Content 和 Set-Clipboard 都要显式 -Encoding UTF8,Windows PowerShell 5.1 的默认值是系统 ANSI 代码页,不是 UTF-8,也不会因为你终端显示正常就改变。
结论
中文编码问题里最贵的不是乱码,是不报错的乱码:字符数变了、内容变了、没有任何异常,程序一路绿灯跑到底。判断一段处理链是否安全,标准不是「输出看起来对不对」,而是「每一个读写点是否显式声明了编码,以及有没有一条字节级的验收」。凡是靠默认值、靠肉眼、靠 errors="replace" 兜底的地方,都埋着一次不会被告知的静默丢字。
- 点赞
- 收藏
- 关注作者
评论(0)