AIGC标识 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' 永远比依赖默认值安全。

九、六条排错清单

  1. UnicodeDecodeError —— 先确认文件真实编码,别一律 errors='ignore' 糊过去;丢失的字节找不回来。
  2. 按字节截断中文出乱码 —— 用 len(s) 管字符数、用 len(s.encode()) 管字节数,别混。
  3. CSV/JSON 首字段带 \ufeff —— 用 utf-8-sig 读。
  4. 文件名明明看着一样却匹配不上 —— 做 NFC 归一化。
  5. 拿到 bytes 却当 str 用(b[0] 是 int、不能 .startswith('中'))—— 先明确边界:IO 边界 decode,内部统一用 str,出去再 encode。
  6. 字符串里混了代理码点(\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 版本变化,量级关系稳定。

posted @ 2026-11-11 10:21  小小张说故事  阅读(0)  评论(0)    收藏  举报