Python 里 str 和 bytes 到底差在哪:拆开 UTF-8 变长编码、PEP 393 内存布局和 surrogateescape
一句话结论:str 是 Unicode 码点序列,bytes 是 0–255 的整数序列。两者之间那道 .encode() / .decode(),是一次有规则的换算,不是「加个 b 前缀」。
本文所有数字来自本机 CPython 3.13.14 实测。
一、同一个字符,长度可以是 1、2、3、4
for ch in ['A', 'é', '中', '😀', '衡']:
e = ch.encode('utf-8')
print(f'{ch!r} U+{ord(ch):04X} {e.hex(" "):<12} len(str)={len(ch)} len(bytes)={len(e)}')
实测输出:
| 字符 | 码点 | UTF-8 字节 | len(str) |
len(bytes) |
|---|---|---|---|---|
A |
U+0041 | 41 |
1 | 1 |
é |
U+00E9 | c3 a9 |
1 | 2 |
中 |
U+4E2D | e4 b8 ad |
1 | 3 |
衡 |
U+8861 | e8 a1 a1 |
1 | 3 |
😀 |
U+1F600 | f0 9f 98 80 |
1 | 4 |
UTF-8 是变长编码:ASCII 一字节,拉丁扩展两字节,常用汉字三字节,辅助平面(emoji)四字节。
所以 len(s) 数的是码点个数,len(s.encode()) 数的是字节数——这是中文场景下最常见的 off-by-N 来源:按字符数截断没问题,按字节数截断可能把一个汉字切在半路。
二、索引语义完全不同
s = '中A'
print(len(s), s[0], s[1]) # 2 中 A
b = s.encode('utf-8')
print(len(b), b[0], b[1]) # 4 228 184
print(list('中'.encode('utf-8'))) # [228, 184, 173]
str[0] 给的是一个字符,bytes[0] 给的是一个 int(228),不是 b'\xe4'。想取单字节得切片 b[0:1]。
'中'.encode() 那三个字节 228/184/173,是 U+4E2D 按 UTF-8 规则拆进三个字节模板的结果,不是乱码,也不是「三个字符」。
三、内存:PEP 393 让字符串按「最宽字符」选宽度
CPython 3.3 起,同一个 str 对象会根据内容里最大的码点,选 1/2/4 字节每字符的紧凑表示。本机实测 100 个字符重复:
| 内容 | sys.getsizeof |
说明 |
|---|---|---|
'a' * 100 |
141 | 1 字节/字符(ASCII) |
'é' * 100 |
157 | 1 字节/字符(Latin-1) |
'中' * 100 |
258 | 2 字节/字符(UCS-2) |
'😀' * 100 |
460 | 4 字节/字符(UCS-4) |
关键在最后两行:一个 emoji 混进去,整串都会升到 4 字节/字符。所以「字符串占多少内存」不能只看长度,要看内容里最宽的那个码点。
这也解释了为什么 str 没有 C 里那种「定长 char 数组」的直觉——Python 早就不是那样存的了。
四、解码失败怎么办:四种 errors 实测
拿一段非法字节 b'abc\xff\xfe' 试:
| errors | 结果 | 能否原样 round-trip |
|---|---|---|
strict(默认) |
UnicodeDecodeError: invalid start byte |
— |
replace |
'abc\ufffd\ufffd' |
❌ 信息已丢 |
ignore |
'abc' |
❌ 信息已丢 |
surrogateescape |
'abc\udcff\udcfe' |
✅ 编码回去字节完全相同 |
raw = b'abc\xff\xfe'
out = raw.decode('utf-8', errors='surrogateescape')
print(repr(out)) # 'abc\udcff\udcfe'
print(out.encode('utf-8', errors='surrogateescape') == raw) # True
surrogateescape 把每个无法解码的字节映射进 U+DC80–U+DCFF 这段代理区,编码时再原样还原。这是 CPython 处理命令行参数、文件路径、环境变量的默认策略——Linux 上文件名允许任意非斜杠字节,不能因为不是合法 UTF-8 就打不开。
用它的场合:需要「读进来、原样写回去」,中途不解释内容。别用它的场合:拿这段字符串去写 JSON、进数据库或拼 URL,代理码点会让下游直接报错。
五、BOM:三个字节就能让解析错一位
bom = '中'.encode('utf-8-sig')
print(bom.hex(' ')) # ef bb bf e4 b8 ad
print(repr(bom.decode('utf-8'))) # '\ufeff中' ← 开头多了个零宽字符
print(repr(bom.decode('utf-8-sig')))# '中'
UTF-8 本身不需要 BOM,但 Windows 工具链(记事本、部分 Excel 导出的 CSV)习惯加。用 utf-8 读带 BOM 的文件,第一列字段名会变成 '\ufeff姓名',然后 df['姓名'] 直接 KeyError。
读别人给的文件,稳妥写法是先试 utf-8-sig;写出去给别人用,写纯 utf-8。
六、看起来一样的字符串,可能并不相等
import unicodedata
nfd = unicodedata.normalize('NFD', 'é')
nfc = unicodedata.normalize('NFC', nfd)
print(len(nfd), len(nfc), nfd == nfc) # 2 1 False
print(nfd.encode().hex(' '), nfc.encode().hex(' '))
# 65 cc 81 | c3 a9
'é' 有两种合法表示:一个组合码点 U+00E9,或 e + 组合重音 U+0301。NFD 拆开、NFC 合成。视觉上一模一样,== 却是 False,哈希也不同。
从 macOS(Finder/HFS+ 倾向 NFD)拷来的文件名,和用户手输的 NFC 字符串比,就会踩这个坑。做比较、去重、建索引之前先 unicodedata.normalize('NFC', s)。
七、可变性:str 与 bytes 都不可变,bytearray 可变
s = '中A'
s[0] = 'B' # TypeError: 'str' object does not support item assignment
ba = bytearray(b'ab'); ba[0] = 99
print(bytes(ba)) # b'cb'
所以循环里 s += 'x' 在 CPython 有原地优化(引用计数为 1 时会 resize),但别把它当通用保证;拼大量片段还是 ''.join(parts)。逐字节改协议报文用 bytearray,别反复 b = b + chunk。
八、快速判断与性能
'abc'.isascii(), '中文'.isascii(), '😀'.isascii() # True False False
len('中文'.encode()), len('😀'.encode()) # 6 4
str.isascii() 是 O(n) 但不分配内存,比 s.encode('ascii') 加 try/except 便宜。
本机 2000 次编码 '中文abc' * 1000(3000 字符 → 9000 字节)耗时 0.0175s。编码本身很快,瓶颈通常在 IO 和反复的小块拼接。
本机环境:sys.getdefaultencoding() = 'utf-8'、locale.getpreferredencoding(False) = 'utf-8'、sys.stdout.encoding = 'utf-8'。注意 Windows 上不同版本/终端可能仍是 cp936/gbk——显式写 encoding='utf-8' 永远比依赖默认值安全。
九、六条排错清单
UnicodeDecodeError—— 先确认文件真实编码,别一律errors='ignore'糊过去;丢失的字节找不回来。- 按字节截断中文出乱码 —— 用
len(s)管字符数、用len(s.encode())管字节数,别混。 - CSV/JSON 首字段带
\ufeff—— 用utf-8-sig读。 - 文件名明明看着一样却匹配不上 —— 做 NFC 归一化。
- 拿到
bytes却当str用(b[0]是 int、不能.startswith('中'))—— 先明确边界:IO 边界 decode,内部统一用 str,出去再 encode。 - 字符串里混了代理码点(
\udcff)导致下游崩 —— 检查是不是用了surrogateescape,且没在出口还原。
十、完整可运行示例
import sys, unicodedata
def describe(s):
b = s.encode('utf-8')
return (f'{s!r}: codepoints={len(s)} bytes={len(b)} '
f'hex={b.hex(" ")} ascii={s.isascii()} sizeof={sys.getsizeof(s)}')
for s in ['A', 'é', '中', '😀', '中文abc' * 10]:
print(describe(s))
# 非法字节:唯一能 round-trip 的策略
raw = b'abc\xff\xfe'
out = raw.decode('utf-8', errors='surrogateescape')
assert out.encode('utf-8', errors='surrogateescape') == raw
# BOM
assert '中'.encode('utf-8-sig').decode('utf-8-sig') == '中'
assert '中'.encode('utf-8-sig').decode('utf-8').startswith('\ufeff')
# 等价性:先归一化再比较
nfd = unicodedata.normalize('NFD', 'é')
nfc = unicodedata.normalize('NFC', nfd)
assert nfd != nfc and unicodedata.normalize('NFC', nfd) == nfc
# 可变字节缓冲
buf = bytearray()
for chunk in [b'abc', '中'.encode('utf-8')]:
buf += chunk
print(bytes(buf))
小结
str 和 bytes 的分工其实很清楚:文本在内存里用 str,二进制在 IO 边界用 bytes,中间那道 encode/decode 显式写清楚编码。
九成问题都出在「隐式转换」上——依赖默认编码、靠猜 BOM、用 ignore 吞掉错误、拿字节数当字符数。把这四处改成显式,中文、emoji、跨平台文件名基本就不会再出事了。
实测环境:CPython 3.13.14(Windows),脚本 d57-probe.py。getsizeof 数值随内容与 Python 版本变化,量级关系稳定。

浙公网安备 33010602011771号