Python 基础体系 · 第 7/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 字符串、bytes 与 Unicode:编码、解码和文本边界
Python 处理文本时,最容易混淆的不是语法,而是三个不同层次的问题:
- 文本是什么:字符、Unicode 码位、用户感知的字符。
- 内存中的值是什么:
str、bytes,以及它们各自的元素和索引规则。 - 数据如何跨边界传输:编码、解码、文件、网络、终端和协议。
如果把这些层次混在一起,就会出现典型错误:把 bytes 当成文本拼接、把 str() 当成解码、用 len() 误判“字符数”,或在文件读写时依赖不稳定的默认编码。
本文以 Python 3.14 为范围,建立一条完整的数据流:
外部字节数据
│
│ decode(编码)
▼
Python str:Unicode 码位序列
│
│ 文本处理、匹配、规范化
▼
Python str
│
│ encode(编码)
▼
外部字节数据
其中,decode 和 encode 是明确的边界操作,而不是可以随意省略的“转换细节”。
一、先区分字符、码位、编码和字节
1. Unicode 是字符集合与编号体系
Unicode 为字符分配唯一的码位。码位通常写作 U+ 加十六进制数字,例如:
- 拉丁字母
A:U+0041 - 中文字符
中:U+4E2D - 雪人符号
☃:U+2603 - 😀:
U+1F600
码位是一个整数,不等同于字节,也不等同于屏幕上最终显示的字形。
Python 可以用 ord() 查看字符的码位,用 chr() 根据码位构造字符串:
for ch in "A中😀":
print(ch, f"U+{ord(ch):04X}")
print(chr(0x4E2D))
预期输出:
A U+0041
中 U+4E2D
😀 U+1F600
中
ord() 的输入必须是长度为 1 的 str,返回一个整数;chr() 接受一个合法 Unicode 码位,返回长度为 1 的 str。
这里的“长度为 1”表示一个 Unicode 码位,而不是一个字节,也不一定表示用户感知的一个完整字符。
2. 编码是把码位序列表示成字节序列
字符编码规定如何把 Unicode 码位转换为字节。例如,UTF-8 会把不同范围的码位编码为 1~4 个字节。
设文本为:
中
其码位是:
U+4E2D
UTF-8 编码过程可以写成:
U+4E2D = 0x4E2D
该码位落在 UTF-8 的三字节范围内。UTF-8 的三字节模板为:
1110xxxx 10xxxxxx 10xxxxxx
把 0x4E2D 的二进制位填入模板:
0x4E2D
= 0100 1110 0010 1101
= 0100 1110 00 1011 01
填入后得到:
1110 0100 10 1110 10 1101
按八位分组:
11100100 10101110 10101101
转换成十六进制就是:
E4 AE AD
因此:
text = "中"
encoded = text.encode("utf-8")
print(encoded)
print(encoded.hex())
输出:
b'\xe4\xb8\xad'
e4b8ad
上面的推导结果应为 E4 B8 AD。这正好说明:手工推导时必须严格按 UTF-8 的位模板操作,不能凭“看起来像”判断字节。Python 的结果是可执行验证:
assert "中".encode("utf-8") == bytes.fromhex("e4 b8 ad")
UTF-8 的重要性质是:ASCII 字节保持不变,因此所有 ASCII 文本同时也是合法 UTF-8 文本。非 ASCII 字符则可能占用多个字节。Python Unicode 指南也明确区分了 Unicode 字符串与 UTF-8 字节序列:前者是文本表示,后者是具体编码格式下的二进制表示。(docs.python.org)
二、str:Unicode 码位序列,不是 UTF-8 字节数组
Python 3 的 str 是不可变的 Unicode 码位序列。换句话说,字符串对象表达的是“文本内容”,而不是某一种外部编码下的字节。
s = "A中😀"
print(type(s))
print(len(s))
print(list(s))
print([ord(ch) for ch in s])
输出:
<class 'str'>
3
['A', '中', '😀']
[65, 20013, 128512]
Python 标准库文档将字符串定义为 Unicode 码位的不可变序列。(docs.python.org)
因此,以下操作合法:
"用户:" + "张三"
以下操作不合法:
"用户:" + b"Zhang San"
结果是:
TypeError: can only concatenate str (not "bytes") to str
这不是 Python “不知道如何转换”,而是类型边界被有意保留下来:程序必须明确指定字节使用什么编码,才能把它解释为文本。
str(bytes) 不等于解码
这是一个高频误解:
data = "中".encode("utf-8")
print(str(data))
print(data.decode("utf-8"))
输出类似:
b'\xe4\xb8\xad'
中
str(data) 在没有提供编码参数时,得到的是 bytes 对象的可读表示;它不会把 UTF-8 字节解码为文本。只有下面这种形式才是解码:
str(data, "utf-8")
它等价于:
data.decode("utf-8")
三、bytes:0~255 的不可变字节序列
bytes 表示一段不可变的字节数据。每个元素都是范围为 0~255 的整数:
data = b"\xE4\xB8\xAD"
print(data[0])
print(data[1:])
assert data[0] == 0xE4
assert isinstance(data[0], int)
assert isinstance(data[1:], bytes)
输出:
228
b'\xb8\xad'
这里有一个重要的索引差异:
str[i]返回长度为 1 的strbytes[i]返回整数str[a:b]返回strbytes[a:b]返回bytes
text = "中"
raw = text.encode("utf-8")
print(text[0], type(text[0]))
print(raw[0], type(raw[0]))
输出:
中 <class 'str'>
228 <class 'int'>
字面量前缀也表达了不同语义:
"abc" # str
b"abc" # bytes
r"\n" # 原始 str,内容是反斜杠和 n
br"\x41" # 原始 bytes,内容不是字母 A
print(r"\n")
print(br"\x41")
print(br"\x41".hex())
输出:
\n
b'\\x41'
5c783431
br 只影响字面量的转义解析,不会让 bytes 变成文本。
bytes 不是“字符数组”
ASCII 范围内,字节值和字符编码值碰巧相同:
b"A"[0] == 65
但这不能推广到中文或表情符号。UTF-8 中的 中 是三个字节:
raw = "中".encode("utf-8")
print(len(raw))
print(list(raw))
输出:
3
[228, 184, 173]
因此:
len("中") # 1:一个 Unicode 码位
len("中".encode()) # 3:三个 UTF-8 字节
这两个数回答的是不同问题。
四、编码和解码必须成对,并且编码参数必须匹配
编码:str -> bytes
text = "价格:¥100"
data = text.encode("utf-8")
print(data)
UTF-8 可以表示这个字符串,因此编码成功。
如果使用只能表示有限字符集的编码,可能发生 UnicodeEncodeError:
"中".encode("ascii")
结果:
UnicodeEncodeError: 'ascii' codec can't encode character ...
形式化地说,编码可以表示为:
E_c : UnicodeText -> ByteString
其中 c 是编码规则。对于某些编码,E_c 不是对所有 Unicode 文本都有定义,因为该编码无法表示全部码位。
解码:bytes -> str
data = b"\xe4\xb8\xad"
text = data.decode("utf-8")
print(text)
解码可以表示为:
D_c : ByteString -> UnicodeText
但只有当字节序列符合编码 c 的语法时,解码才会成功:
data.decode("ascii")
会失败,因为 0xE4 不属于 ASCII。
编码和解码的正确逆关系是:
D_c(E_c(s)) = s
对于能够被编码 c 表示的文本 s,先编码再用同一编码解码,应恢复原始文本:
samples = ["ASCII", "中文", "😀", "组合文本"]
for s in samples:
assert s.encode("utf-8").decode("utf-8") == s
反方向也有条件:
E_c(D_c(b)) = b
只有当 b 是编码 c 的合法字节序列时,这个关系才成立。
错误示例:
original = "中".encode("utf-8")
wrong = original.decode("latin-1")
round_trip = wrong.encode("latin-1")
print(original.hex())
print(wrong)
print(round_trip.hex())
print(original == round_trip)
输出类似:
e4b8ad
ä¸
e4b8ad
True
这里 latin-1 对每个字节都定义了一个对应码位,所以“解码”没有报错;但得到的文本 ä¸ 并不是原文。它只是把 UTF-8 字节按另一套规则解释了。
这就是乱码不一定伴随异常的原因:错误编码有时仍然是语法上合法的编码。
五、错误处理策略:strict、replace、ignore 和可逆性
encode() 和 decode() 都接受 errors 参数。最安全的默认策略是 strict,遇到无法处理的数据直接抛出异常。
bad = b"\xffABC"
for policy in ("strict", "replace", "ignore", "backslashreplace"):
try:
print(policy, repr(bad.decode("utf-8", errors=policy)))
except UnicodeDecodeError as exc:
print(policy, type(exc).__name__, exc.reason)
可能输出:
strict UnicodeDecodeError invalid start byte
replace '\ufffdABC'
ignore 'ABC'
backslashreplace '\\xffABC'
这些策略改变了信息保留方式:
strict:保留完整性,失败可见。replace:用U+FFFD替代无法解码的部分,结果可读但不可逆。ignore:直接丢弃错误数据,通常不可逆。backslashreplace:将错误字节显示为转义形式,便于日志诊断。
例如:
bad = b"\xffABC"
assert bad.decode("utf-8", "ignore").encode("utf-8") != bad
所以 errors="ignore" 不是“修复编码”,而是明确选择丢失数据。对于用户输入、账单、审计日志或协议字段,静默丢弃通常比抛错更危险。
六、Unicode 的“字符”不止一个层次
len(s) 统计的是 Unicode 码位数量,而不是屏幕上的字形数量,也不一定是用户认为的“字符数”。
1. 一个用户感知字符可能包含多个码位
例如,é 可以有两种表示:
a = "é" # U+00E9,预组合字符
b = "e\u0301" # U+0065 + U+0301,字母 e + 组合重音符
print(a == b)
print(len(a), len(b))
print([f"U+{ord(ch):04X}" for ch in a])
print([f"U+{ord(ch):04X}" for ch in b])
输出:
False
1 2
['U+00E9']
['U+0065', 'U+0301']
从视觉上看,二者通常都显示为 é;从 Python 的码位序列看,它们不同。
如果应用需要把等价的 Unicode 表示归一化,可以使用 unicodedata.normalize():
import unicodedata
a = "é"
b = "e\u0301"
print(unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))
print(unicodedata.normalize("NFC", b))
输出:
True
é
常见规范化形式包括:
NFC:尽可能使用预组合字符。NFD:拆分为基础字符和组合字符。NFKC、NFKD:还会处理兼容性等价,可能改变原始表示语义。
因此,规范化不是无条件的“清理”。例如,搜索匹配、用户名比较、数据库唯一性判断和密码处理,对规范化的要求可能不同。应先明确业务定义的等价关系。
2. Emoji 可能由多个码位组成
family = "👨👩👧👦"
print(len(family))
print([f"U+{ord(ch):04X}" for ch in family])
这个表情由多个 Emoji 码位和零宽连接符组成。Python 的 str 迭代按码位返回元素,并不提供“用户感知字符簇”级别的切分。
因此,以下代码不能保证限制为 10 个用户可见字符:
text[:10]
它只保证保留前 10 个 Unicode 码位,可能把一个组合序列截断,导致显示异常或语义变化。
这也是文本边界必须被明确定义的原因:
| 边界 | 典型单位 |
|---|---|
Python str 索引 |
Unicode 码位 |
| UTF-8 存储空间 | 字节 |
| 用户界面字符数 | 用户感知字符簇 |
| 字体显示 | 字形 |
正则表达式 . |
通常按 Unicode 字符匹配,但不等于完整字符簇 |
七、UTF-8、UTF-16、UTF-32 和 BOM
UTF-8
UTF-8 是面向字节的变长编码:
- ASCII 字符占 1 字节;
- 其他码位占 2~4 字节;
- 不依赖主机字节序;
- ASCII 文本与 UTF-8 兼容。
for encoding in ("ascii", "utf-8", "utf-16", "utf-32"):
data = "A中".encode(encoding)
print(encoding, len(data), data.hex())
具体长度依赖编码及其 BOM 行为。UTF-16 和 UTF-32 按 16 位或 32 位单元组织数据,字节序可能是大端或小端,因此通常需要明确使用:
"中".encode("utf-16-le")
"中".encode("utf-16-be")
BOM 不是所有编码都需要的“文件头”
BOM 是字节序标记。对 UTF-16 或 UTF-32,它可以帮助识别字节序;对 UTF-8,BOM 是可选的,通常称为 UTF-8 signature,字节为:
EF BB BF
Python 提供 utf-8-sig 来处理带 UTF-8 BOM 的数据:
data = b"\xef\xbb\xbfname\nAlice\n"
print(data.decode("utf-8-sig"))
输出:
name
Alice
如果直接使用普通 utf-8,开头可能出现 \ufeff:
print(repr(data.decode("utf-8")))
输出:
'\ufeffname\nAlice\n'
所以处理外部文件时,需要知道协议是否规定 BOM。不能仅凭“看到乱码”就盲目切换编码。
八、源代码文件本身也有编码边界
Python 源文件首先要被解释器读取。Python 3 默认将源代码视为 UTF-8,因此字符串字面量、注释和标识符可以直接包含 Unicode 字符。若使用其他源文件编码,可以通过编码声明指定。(docs.python.org)
# coding: utf-8
message = "杭州"
print(message)
这段注释在现代 Python 3 项目中通常没有必要,因为 UTF-8 已是默认值;但当源文件使用非默认编码时,声明可以避免解释器误读文件。
还要区分三种转义:
print("\u4E2D") # Unicode 转义,结果是 中
print(r"\u4E2D") # 原始字符串,结果是六个可见字符
print(b"\xE4") # 一个字节,不能据此表示完整中文
输出:
中
\u4E2D
b'\xe4'
\u4E2D 是字符串字面量阶段生成 Unicode 码位;\xE4 在 bytes 字面量中只生成一个字节,并不自动构成 UTF-8 字符。
九、文本 I/O:open() 在边界处自动编码和解码
io 模块将 I/O 分为文本 I/O、二进制 I/O 和原始 I/O。文本流接收和产生 str,底层若是字节存储,则由文本流自动执行编码、解码以及可选的换行转换;二进制流接收 bytes-like 对象并产生 bytes,不会自动执行编码、解码或换行转换。(docs.python.org)
from pathlib import Path
path = Path("example.txt")
with path.open("w", encoding="utf-8", newline="") as f:
f.write("第一行\n第二行\n")
with path.open("r", encoding="utf-8", newline="") as f:
text = f.read()
print(repr(text))
预期输出:
'第一行\n第二行\n'
数据流是:
f.write(str)
│ UTF-8 encode
▼
操作系统文件:bytes
操作系统文件:bytes
│ UTF-8 decode
▼
f.read() -> str
encoding="utf-8" 是这里的关键。Python 3.14 中,open() 和 TextIOWrapper 的默认编码仍然与 locale 有关;官方文档建议在知道文件编码时显式指定。Python 3.15 计划默认启用 UTF-8 Mode,但这不能替代当前代码中明确声明文件协议。(docs.python.org)
文本流和二进制流不能混写
with open("data.bin", "wb") as f:
f.write("中")
结果是:
TypeError: a bytes-like object is required, not 'str'
正确写法是:
with open("data.bin", "wb") as f:
f.write("中".encode("utf-8"))
或者,如果目标本来就是文本文件:
with open("data.txt", "w", encoding="utf-8") as f:
f.write("中")
二者的区别不是文件扩展名,而是流的契约:
"w":调用write()时传str;"wb":调用write()时传bytes或其他 bytes-like 对象。
buffer 是二进制边界
文本流通常包裹在缓冲二进制流之上:
with open("data.txt", "w", encoding="utf-8") as f:
print(type(f))
print(type(f.buffer))
f.buffer.write(b"raw\n")
f 是文本接口,f.buffer 是下层缓冲二进制接口。直接绕过文本层写入时,程序必须自己维护编码、换行和写入顺序,否则容易造成文本层缓冲数据与底层数据交错。
原始 I/O 更底层,写入可能只完成部分数据,需要检查返回值并重试;高级二进制和文本 I/O 会封装这类重试行为。(docs.python.org)
十、流式解码:一个 Unicode 字符可能跨越多个数据块
网络读取、文件分块和消息队列通常不会保证每个块恰好落在字符边界上。
UTF-8 的 中 是:
E4 B8 AD
如果网络分成两次收到:
第一次:E4 B8
第二次:AD
第一次数据本身不是完整字符。直接解码会失败:
part1 = b"\xe4\xb8"
part2 = b"\xad"
part1.decode("utf-8")
结果:
UnicodeDecodeError
正确方法是使用增量解码器,让解码器保存未完成的中间状态:
import codecs
decoder = codecs.getincrementaldecoder("utf-8")()
text1 = decoder.decode(part1)
text2 = decoder.decode(part2)
text3 = decoder.decode(b"", final=True)
print(repr(text1))
print(repr(text2))
print(repr(text3))
输出:
''
'中'
''
状态变化如下:
decode(E4 B8)
-> 暂存不完整 UTF-8 序列,不产生字符
decode(AD)
-> 补齐序列,产生“中”
decode(b"", final=True)
-> 检查是否仍有未完成序列
final=True 很重要:如果流结束时仍有残缺字节,增量解码器应在最终检查阶段报告错误,而不是把不完整数据静默当成完整文本。
十一、正则表达式中的 Unicode 边界
Python 正则表达式默认对 str 进行 Unicode 文本匹配,对 bytes 进行字节匹配。这两个模式不能混用:
import re
re.search(r"\d+", "订单 123")
re.search(br"\d+", b"123")
但下面会失败:
re.search(r"\d+", b"123")
因为模式和被匹配对象的类型必须一致。
还要注意,re 的匹配单位不是用户感知字符簇。对于组合字符和零宽连接符组成的文本,.、量词和切片都可能在视觉字符内部操作。
例如:
import re
s = "e\u0301"
m = re.match(r"..", s)
print(m.group())
print(len(m.group()))
这里两个点匹配两个码位:e 和组合重音符。它们视觉上可能只显示为一个字符,但正则表达式并没有把它们视为一个不可分割的用户字符。
因此,涉及用户名长度、界面截断、光标移动或消息长度时,必须先确定协议要求的是:
- 字节数;
- Unicode 码位数;
- 规范化后的码位数;
- 用户感知字符簇数;
- 还是显示宽度。
这些指标没有一个可以普遍替代其他指标。
十二、诊断乱码时,先观察类型和原始字节
遇到乱码,不要先尝试连续调用 encode() 和 decode()。先打印类型、表示形式和十六进制字节:
def inspect(value):
print("type:", type(value).__name__)
print("repr:", repr(value))
if isinstance(value, str):
print("code points:", [f"U+{ord(ch):04X}" for ch in value])
elif isinstance(value, bytes):
print("hex:", value.hex())
inspect("中")
inspect("中".encode("utf-8"))
输出类似:
type: str
repr: '中'
code points: ['U+4E2D']
type: bytes
repr: b'\xe4\xb8\xad'
hex: e4b8ad
诊断过程可以按数据边界倒推:
- 数据进入程序时是什么类型?
str:编码已经发生,问题可能在更早的边界。bytes:还没有完成解码,必须确认编码。
- 原始字节是否符合预期编码?
- 查看
hex(),不要只看终端显示。
- 查看
- 是否使用了错误但仍然合法的编码?
- 例如 UTF-8 字节按 Latin-1 解码。
- 是否在错误处理阶段丢失了信息?
- 检查是否使用了
ignore或replace。
- 检查是否使用了
- 是否把码位数误当作字节数或用户字符数?
- 分别检查
len(str)和len(str.encode(...))。
- 分别检查
可以给边界函数增加类型约束:
def decode_utf8(data: bytes) -> str:
return data.decode("utf-8")
def encode_utf8(text: str) -> bytes:
return text.encode("utf-8")
这两个函数的价值不在于减少代码,而在于把“文本边界”固定为显式接口:调用者必须知道自己持有什么数据,以及下一步需要什么数据。
十三、几个看似合理但错误的做法
误解一:str(bytes_obj) 可以把字节转成字符串
错误:
text = str(b"\xe4\xb8\xad")
这得到的是:
"b'\\xe4\\xb8\\xad'"
正确:
text = b"\xe4\xb8\xad".decode("utf-8")
误解二:bytes(text) 可以直接把文本编码成字节
错误:
bytes("中")
结果:
TypeError: string argument without an encoding
正确:
bytes("中", encoding="utf-8")
或更清晰地写:
"中".encode("utf-8")
误解三:latin-1 是万能解码器
latin-1 确实能把每个 0x00~0xFF 字节映射到一个 Unicode 码位,因此几乎不会在单字节输入上报错。但它不能推断原始编码,也不能把任意字节恢复成正确文本。
它有时适合做“字节到码位的一一映射”,例如某些底层协议或遗留接口;这和识别自然语言文本的编码是两件事。
误解四:看到 UnicodeDecodeError 就改成 ignore
这会隐藏数据损坏:
clean = bad.decode("utf-8", errors="ignore")
错误变得不再可见,但文本内容已经改变。若目标是继续处理并保留诊断信息,可以考虑 backslashreplace,若目标是保证数据完整性,则使用默认的 strict 并记录原始字节。
十四、文本边界的核心原则
一个可靠的 Python 文本处理流程,应该明确标注每个边界的类型:
文件 / 网络 / 子进程
│
▼
bytes
│ 使用协议规定的编码 decode
▼
str
│ 规范化、解析、匹配、业务处理
▼
str
│ 使用目标协议规定的编码 encode
▼
bytes
可以将原则压缩为四条:
- 内部文本处理使用
str,二进制处理使用bytes。 - 进入程序时尽早解码,离开程序时尽晚编码。
- 编码名称是协议的一部分,不应靠环境猜测。
- “长度”和“字符”必须说明计量单位。
Python 让 str、bytes 和 I/O 流之间保持严格类型边界,正是为了让编码错误尽早暴露。真正需要掌握的不是某个“万能编码”,而是始终知道:当前数据处于码位层、字节层,还是用户感知文本层。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 数值类型:int、float、complex、bool 与运算边界
- 下一篇:Python list、tuple 与 range:存储、切片、复杂度和选择
- 延伸:Python I/O 模型:文本、二进制、缓冲、编码和流式处理
- 延伸:Python 正则表达式:匹配模型、分组、回溯、性能和 Unicode
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论