引言
第二章留下一个承诺:「码点如何变成字节,是第五章的系统话题」——本篇把它兑现。字符串方法(第一篇)与切片(第二篇)都在 str 内部打转,而本篇要回答三个更底层的问题:Unicode 与编码是什么关系;str 与 bytes 两个类型怎么互转、常见编码 utf-8 / gbk / ascii 各自什么脾气;以及 len() 到底数的是什么。
这不是书斋问题。文件读写(第七章)、网络传输(第二十一章)、数据库存取(后续章节)的每一处「文本进出」,本质上都是 str 与 bytes 的转换。编码与解码的错位是生产环境最难排查的 bug 之一:有时它干脆报错(UnicodeDecodeError),更多时候它静默地把”你好”变成”ÄãºÃ”这种乱码,而程序运行得分毫不差。把本篇的模型记牢,你就具备了定位这类事故的系统框架。
概念与原理
码点是「字符目录」,编码是「打包方案」
Unicode 是一张字符目录:给世界上绝大多数书写系统的每个字符分配一个唯一编号,叫码点(code point),写作 U+XXXX(十六进制)。比如「中」是 U+4E2D,「A」是 U+0041。码点只是编号,与「怎么存进内存、磁盘、网络」无关;**编码(encoding)**才定义「用哪些字节位模式表示哪个码点」。
同一个码点在不同编码下字节数不同:UTF-8 是变长编码(1 到 4 字节),设计上向后兼容 ASCII,互联网事实标准;GBK 是中文常用编码,一个汉字固定 2 字节;ASCII 只有 0 到 127 共 128 个码位,一个字节一个字符。「中」在三种编码下的落地形态,本机实测如下:
| 字符 | 码点 | UTF-8 字节(hex) | GBK 字节(hex) |
|---|---|---|---|
| 中 | U+4E2D | E4 B8 AD(3 字节) | D6 D0(2 字节) |
| A | U+0041 | 41(1 字节) | 41(1 字节) |
注意「兼容」的含义:ASCII 字符在 UTF-8 与 GBK 里都是原来的单个字节 41,写同一份字节流,两边读出同一个 A;而「中」的两种字节序列完全不同——这就是编码错位事故的根源。
str 与 bytes:两个世界,一座桥
Python 3 严格分离两个概念:str 是码点序列(承载人类语义,长度用码点数衡量);bytes 是不可变的字节序列(承载机器语义,元素是 0 到 255 的整数)。桥梁是一对方法:str.encode(编码) 把码点打包成字节,bytes.decode(编码) 把字节拆回码点。这两步都是「解释」:编码是你给字符选打包方案,解码是你给字节选拆包方案——选错了,要么报错,要么出乱码。
BOM:文件头部的「编码自述」
**BOM(Byte Order Mark,字节序标记)**原本是 UTF-16 用来声明字节序的:码点 U+FEFF 放在文本最前,读方据此判断是 大端在前还是小端在前。UTF-8 没有字节序问题(一个字节一个字节排),但借它来表明「本文件是 UTF-8」:开头的 EF BB BF 三字节成为一个约定俗成的标记。Excel 导出的 CSV 常带 BOM,Python 用 utf-8-sig 编解码器读写,自动处理。
组合字符与 Unicode 规范化
同一个「字」可能对应不同的码点序列:é(U+00E9)是预组合的单个码点;而 e 后面跟一个组合尖音符 U+0301,是两个码点,屏幕上看几乎一样。**Unicode 规范化(normalization)**把这些等价形式统一:NFC 尽量合并为单码点,NFD 尽量分解;NFKC / NFKD 再做兼容性分解(比如把 ② 变成 2、罗马数字 Ⅳ 变成 IV)。macOS 文件系统存储文件名倾向 NFD,Linux/Windows 倾向 NFC——跨平台交换文件时这会产生「看着同名却对不上」的摩擦,比较与去重之前先规范化。
为什么 Python 3 强制 str 与 bytes 分离
这不是洁癖,而是历史的教训。Python 2 时代,str 承载字节、unicode 承载文本,两者在比较与拼接时会悄悄发生隐式转换(默认按 ASCII 解释):数据一旦超出 ASCII——中文、emoji 都会触发——要么在运行深处抛 UnicodeDecodeError,要么静默解出乱码,而报错位置常常远离真正出问题的数据源头,难以排查。Python 3 取消了全部隐式转换:str 与 bytes 之间必须显式 encode / decode。代价是每次进出文本通道都要写明编码,换来的是「错误集中在转换点上、可复现、可测试」——这正是把编码事故从「上线后的深夜告警」变成「本地就能断言」的关键设计。
len() 数的到底是什么
len(s) 返回码点个数,不是字节数,也不是屏幕显示宽度。emoji「👍」是 1 个码点(U+1F44D),但 UTF-8 编码占 4 字节;家庭 emoji「👨👩👧👦」由 4 个 emoji 和 3 个零宽连接符(ZWJ,U+200D)共 7 个码点组成,UTF-8 占 25 字节。做「字数限制」校验时,先问清楚业务口径是码点、字节还是显示宽度——三者对同一句话给出的数字可以差好几倍。
操作与实现
码点与字符互查:ord / chr
print(hex(ord("中"))) # 0x4e2d:字符 → 码点
print(chr(0x4e2d)) # 中:码点 → 字符
print(hex(ord("你"))) # 0x4f60
print(chr(0x1F44D)) # 👍:码点超过基本平面也能直接构造
0x4e2d
中
0x4f60
👍
ord 与 chr 是双向映射,第二章讲过的 \u4e2d 转义本质就是「把码点写进源代码」;chr(0x1F44D) 则演示了码点空间远超 16 位。
互转与常见编码对照
text = "中国"
data = text.encode("utf-8")
print(data) # b'\xe4\xb8\xad\xe5\x9b\xbd'
print(len(text), len(data)) # 2 6:2 个码点 vs 6 个字节
print(data.decode("utf-8")) # 中国:原样往返
print(text.encode("utf-8").hex()) # e4b8ade59bbd
print(text.encode("gbk").hex()) # d6d0b9fa:同样两个字,4 字节
print(text.encode("utf-16").hex()) # fffe2d4efd56:UTF-16 带 BOM,共 7 字节
print(b"\xd6\xd0".decode("gbk")) # 中:按 GBK 拆包
print("abc".encode("ascii")) # b'abc':ASCII 字符任何编码都一致
print("Python".isascii(), "中".isascii()) # True False:isascii 判断纯 ASCII
b'\xe4\xb8\xad\xe5\x9b\xbd'
2 6
中国
e4b8ade59bbd
d6d0b9fa
fffe2d4efd56
中
b'abc'
True False
b"\xd6\xd0" 同一字节流,按 GBK 解出「中」,按 UTF-8 解就会失败——这正是错位事故的微观模型。isascii() 是 3.7 加入的快捷判断,可用于「这行文本能否安全走纯 ASCII 通道」的预检。
失败与容错:errors 三档策略
try:
b"\xd6\xd0".decode("utf-8")
except UnicodeDecodeError as err:
print(err.reason, err.start, err.end) # invalid continuation byte 0 1
print(repr(b"\xd6\xd0".decode("utf-8", errors="replace"))) # '��'
try:
"中".encode("ascii")
except UnicodeEncodeError as err:
print(err.reason) # ordinal not in range(128)
print("中".encode("ascii", errors="replace")) # b'?'
print("中".encode("ascii", errors="ignore")) # b''
invalid continuation byte 0 1
'��'
ordinal not in range(128)
b'?'
b''
默认策略 strict 是「出错即抛」;replace 在不可编码的码点处放问号、在不可解码的字节处放替换符(replacement character,U+FFFD,显示为 �);ignore 直接丢弃。三个策略各有适用场景:落盘前预检用 strict,清洗脏数据用 replace,剥除噪声用 ignore——但 ignore 会造成信息丢失,慎用。
BOM 的读写现场
print("表头".encode("utf-8").hex()) # e8a1a8e5a4b4:无 BOM
print("表头".encode("utf-8-sig").hex()) # efbbbfe8a1a8e5a4b4:自动加 BOM
plain = b"\xef\xbb\xbf\xe4\xb8\xad"
print(repr(plain.decode("utf-8"))) # '\ufeff中':BOM 混进第一个字符
print(repr(plain.decode("utf-8-sig"))) # '中':sig 自动剥离 BOM
print(hex(ord("\ufeff"))) # 0xfeff:BOM 对应的码点
e8a1a8e5a4b4
efbbbfe8a1a8e5a4b4
'\ufeff中'
'中'
0xfeff
用普通 utf-8 读带 BOM 的文件,第一个字符会是 U+FEFF——它不可见、打印不出来,却在 startswith、CSV 表头比对时制造诡异失败;用 utf-8-sig 读写即可自动处理。顺带说清 BOM 在 UTF-16 里的本职:U+FEFF 在小端序(LE)下落地为 FF FE,在大端序(BE)下落地为 FE FF,读方凭头两个字节判断字节序——本机实测 "中国".encode("utf-16").hex() 以 fffe 开头(本机是小端)。utf-16-le / utf-16-be 两个变种则不写 BOM、固定字节序,适合自己管序的协议。
Unicode 规范化:等价形式归一
import unicodedata
cafe_single = "\u00e9" # é:预组合,1 个码点
cafe_split = "e\u0301" # e + 组合尖音符:2 个码点
print(cafe_single == cafe_split) # False:=== 比较码点序列
print(len(cafe_single), len(cafe_split)) # 1 2
print(unicodedata.normalize("NFC", cafe_split)) # é:合并回单码点
print(unicodedata.normalize("NFC", cafe_split) == cafe_single) # True
print(len(unicodedata.normalize("NFD", cafe_single))) # 2:拆成两个码点
print(unicodedata.normalize("NFKC", "②")) # 2:兼容分解合并
print(unicodedata.normalize("NFKD", "\u2163")) # IV:罗马数字兼容分解
False
1 2
é
True
2
2
IV
判等、字典键、数据库唯一约束都按码点序列走,所以「看起来一样」的两串文本可能不相等;统一在入口处做一次 NFC(或按业务需要的其他形式)再存,是去重类系统的标准姿势。
len() 谜题:emoji 与组合字符
print(len("👍")) # 1:一个码点
print(len("👍".encode("utf-8"))) # 4:UTF-8 四个字节
family = "\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466"
print(len(family)) # 7:4 个 emoji + 3 个零宽连接符
print(len(family.encode("utf-8"))) # 25:字节数
print("你".encode("utf-8").hex()) # e4bda0
1
4
7
25
e4bda0
这组数字是「码点 / 字节 / 显示宽度」三套口径的分水岭:len(family) 为 7、字节数为 25,而屏幕上一个家庭 emoji 只占约两个字符的宽度——三个数字,三种答案。短信按字节计费、表单按显示宽度限长、数据库按字符计数,各有所指,对齐口径才能对齐预期。
工程合成:编码事故现场
# 模拟旧系统按 GBK 落盘的日志,读出时选错编码
log = "访问用户:李雷,状态:成功"
disk = log.encode("gbk") # 按 GBK 打包落盘
print(disk.decode("gbk")) # 访问用户:李雷,状态:成功:编码对得上
try:
disk.decode("utf-8") # 按 UTF-8 拆包:第一个字节就失败
except UnicodeDecodeError as err:
print(err.reason, err.start, err.end) # invalid start byte 0 1
sample = "你好".encode("gbk").decode("cp1252") # 某些解码器不会报错
print(repr(sample)) # 'ÄãºÃ':静默乱码,比报错更难排查
访问用户:李雷,状态:成功
invalid start byte 0 1
'ÄãºÃ'
「访」在 GBK 里的首字节 B7 不是合法的 UTF-8 起始字节,解码器在位置 0 就抛错——报错信息带着起止位置(start 0、end 1),可定位到字节。同一种事故还有另一种表现:乱码——「你好」的 GBK 字节流用 cp1252 拆包「成功解出」‘ÄãºÃ’,语法合法、语义全错,程序不报任何异常。乱码之所以更危险,正是因为无异常可查:排查铁律是文本异常时先问「这份字节当初是用什么编码写的」,再决定用什么编码读。第八章的日志解析将再次用到这个判断。
易错点与陷阱
陷阱一:把 len() 当成字节数或字数
len("中") 是 1 不是 3,len("👍") 是 1 不是 4——码点、字节、显示宽度三个口径对同一文本给出不同数字。数据库「限 10 字符」通常指码点或字符单元,短信按字节,前端文本框按显示宽度;混用口径的校验会在 emoji、中文、组合字符上依次翻车。先定口径,再写校验。
陷阱二:encode/decode 不报错不代表正确
解码「成功」只说明字节位模式符合目标编码的语法,不说明语义正确。「你好」的 GBK 字节流用 cp1252 解出来是 ‘ÄãºÃ’——语法完全合法,没有异常。判断文本的真实编码不能靠「解不解得出来」,要靠源头约定、BOM、或抽样验证关键字符;看到替换符 � 时,说明某处已经发生过 replace 策略下的数据损坏。
陷阱三:BOM 悄悄混进第一个字符
带 BOM 的 UTF-8 文件用普通 utf-8 读取时,第一个码点是 U+FEFF:不可见、不报错,但 startswith、表头比对、CSV 解析全部错位。Excel 导出的 CSV 与 Windows 记事本的「另存为 UTF-8」都可能产 BOM。统一用 utf-8-sig 读写,或者读取后显式做一次 lstrip(“\ufeff”)。
陷阱四:组合字符不相等
"\u00e9" == "e\u0301" 是 False,两个都显示成 é。坑位集中在:字典去重漏掉看似重复的键、数据库唯一索引冲突、macOS 与 Linux 之间文件名对不上。对策是把规范化做成数据入口的统一步骤:存之前 normalize 一次(常用 NFC),比较前也先 normalize。
陷阱五:写文件时忘掉编码参数
open() 默认编码跟随系统 locale(Windows 中文系统常见 GBK 系、Linux 默认 UTF-8),同一份代码在不同机器上写出的字节可能不同。第七章讲文件时会给全规则,这里先立原则:涉及文件与网络的编码一律显式指定,不依赖环境默认;Python 3.15 起(PEP 686)UTF-8 Mode 将默认开启,届时环境差异进一步收敛,但显式指定的好习惯不变。
陷阱六:以为 encode → decode 往返必然无损
同一种编码、strict 策略下,往返是严格可逆的——这正是无损的本义。但中途只要经历过 replace 或 ignore,信息就已经丢失:「中」以 errors="replace" 编成 ascii 得到 b’?’,再 decode 永远回不到「中」。凡是带容错策略编过的数据,都不该再当作原文对待;需要完整保真的场景(落库、签名、比对),编码与解码全程禁止任何容错策略。
小结
Unicode 是码点目录,编码是把码点变成字节的规则:UTF-8 变长且兼容 ASCII,一个中文汉字 3 字节;GBK 中文 2 字节;ASCII 只覆盖 128 个码位。str 与 bytes 通过 encode / decode 互转,strict / replace / ignore 三档策略对应报错、替换与丢弃。BOM(U+FEFF,UTF-8 落地为 EF BB BF)用 utf-8-sig 自动处理;组合字符与预组合字符之间靠 NFC / NFD 等规范化统一。最后记住:len() 数的是码点,不是字节数——emoji 家庭 7 码点 25 字节,三个口径三个答案。
练习与思考题
- 用 ord / chr 验证「你」的码点(U+4F60)与其 UTF-8 编码 e4 bd a0、GBK 编码 c4 e3 的对应关系,解释为什么两种编码字节数不同。
- 写出以下表达式的值并运行验证:
len("🐍")、len("🐍".encode("utf-8"))、"Python".isascii()、"你好".encode("gbk").hex()。 - 思考题:UTF-8 为什么设计成变长且兼容 ASCII?如果用定长 3 字节编所有码点(像 UTF-32 思路)会损失什么?从存储与兼容两个角度回答。
- 动手题:构造一个在 UTF-8 下解码不报错但内容全乱的字节序列(提示:用其他编码打包再按 UTF-8 拆),并把它的报错版与乱码版装进同一个脚本演示。