Python 基础体系 · 第 7/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 字符串、bytes 与 Unicode:编码、解码和文本边界

Python 处理文本时,最容易混淆的不是语法,而是三个不同层次的问题:

  1. 文本是什么:字符、Unicode 码位、用户感知的字符。
  2. 内存中的值是什么strbytes,以及它们各自的元素和索引规则。
  3. 数据如何跨边界传输:编码、解码、文件、网络、终端和协议。

如果把这些层次混在一起,就会出现典型错误:把 bytes 当成文本拼接、把 str() 当成解码、用 len() 误判“字符数”,或在文件读写时依赖不稳定的默认编码。

本文以 Python 3.14 为范围,建立一条完整的数据流:

外部字节数据
    │
    │ decode(编码)
    ▼
Python str:Unicode 码位序列
    │
    │ 文本处理、匹配、规范化
    ▼
Python str
    │
    │ encode(编码)
    ▼
外部字节数据

其中,decodeencode 是明确的边界操作,而不是可以随意省略的“转换细节”。


一、先区分字符、码位、编码和字节

1. Unicode 是字符集合与编号体系

Unicode 为字符分配唯一的码位。码位通常写作 U+ 加十六进制数字,例如:

  • 拉丁字母 AU+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 表示一段不可变的字节数据。每个元素都是范围为 0255 的整数:

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 的 str
  • bytes[i] 返回整数
  • str[a:b] 返回 str
  • bytes[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 字节按另一套规则解释了。

这就是乱码不一定伴随异常的原因:错误编码有时仍然是语法上合法的编码。


五、错误处理策略:strictreplaceignore 和可逆性

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:拆分为基础字符和组合字符。
  • NFKCNFKD:还会处理兼容性等价,可能改变原始表示语义。

因此,规范化不是无条件的“清理”。例如,搜索匹配、用户名比较、数据库唯一性判断和密码处理,对规范化的要求可能不同。应先明确业务定义的等价关系。

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 码位;\xE4bytes 字面量中只生成一个字节,并不自动构成 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

诊断过程可以按数据边界倒推:

  1. 数据进入程序时是什么类型?
    • str:编码已经发生,问题可能在更早的边界。
    • bytes:还没有完成解码,必须确认编码。
  2. 原始字节是否符合预期编码?
    • 查看 hex(),不要只看终端显示。
  3. 是否使用了错误但仍然合法的编码?
    • 例如 UTF-8 字节按 Latin-1 解码。
  4. 是否在错误处理阶段丢失了信息?
    • 检查是否使用了 ignorereplace
  5. 是否把码位数误当作字节数或用户字符数?
    • 分别检查 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 确实能把每个 0x000xFF 字节映射到一个 Unicode 码位,因此几乎不会在单字节输入上报错。但它不能推断原始编码,也不能把任意字节恢复成正确文本。

它有时适合做“字节到码位的一一映射”,例如某些底层协议或遗留接口;这和识别自然语言文本的编码是两件事。

误解四:看到 UnicodeDecodeError 就改成 ignore

这会隐藏数据损坏:

clean = bad.decode("utf-8", errors="ignore")

错误变得不再可见,但文本内容已经改变。若目标是继续处理并保留诊断信息,可以考虑 backslashreplace,若目标是保证数据完整性,则使用默认的 strict 并记录原始字节。


十四、文本边界的核心原则

一个可靠的 Python 文本处理流程,应该明确标注每个边界的类型:

文件 / 网络 / 子进程
        │
        ▼
      bytes
        │  使用协议规定的编码 decode
        ▼
      str
        │  规范化、解析、匹配、业务处理
        ▼
      str
        │  使用目标协议规定的编码 encode
        ▼
      bytes

可以将原则压缩为四条:

  1. 内部文本处理使用 str,二进制处理使用 bytes
  2. 进入程序时尽早解码,离开程序时尽晚编码。
  3. 编码名称是协议的一部分,不应靠环境猜测。
  4. “长度”和“字符”必须说明计量单位。

Python 让 strbytes 和 I/O 流之间保持严格类型边界,正是为了让编码错误尽早暴露。真正需要掌握的不是某个“万能编码”,而是始终知道:当前数据处于码位层、字节层,还是用户感知文本层。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。