前言
「字符集」和「字符编码」这两个词被混用得太久了,久到很多人以为它们是一回事。实际上它们是两层:字符集管的是一张「字符到编号」的表,编码管的是「编号到字节」的转换规则。UTF-8 和 UTF-16 用的是同一个字符集,却产生完全不同的字节序列,就是因为它们处在第二层。
搞不清这两层,遇到乱码时就只能瞎试编码,试到对为止。搞清楚之后,你会知道乱码几乎是在某个环节用错了编码,而定位这个环节是有方法的。
本文讲清五件事:字符集与编码的分工、码位与字节序列的区别、ASCII / GBK / Unicode 三者的关系、UTF-8 为什么是变长的,以及 Python 3 里字符串与字节串的分界。文末特别给一张 Python 2 与 Python 3 的对照,因为编码是两代 Python 差异最大的地方。本机无 Python 解释器,代码为逐行推演。
一、字符集与编码:两层,别混
- 字符集(character set):一个字符集合,加上「每个字符对应哪个编号」的映射。这个编号叫码位(code point)。
- 字符编码(character encoding):把码位翻译成实际字节序列的规则。
用官方 Unicode HOWTO 的例子:字符「♞」(BLACK CHESS KNIGHT)的码位是 U+265E,也就是十进制 9822。这个 9822 是码位,不是字节。至于它落到文件里是几个字节、每个字节是什么,由编码决定——UTF-8 下是三个字节,UTF-16 下是另外两个字节。同一个码位,不同编码,不同字节。
| 概念 | 回答的问题 | 例子 |
|---|
| 字符集 | 有哪些字符?各自编号多少? | Unicode:U+265E 是「♞」 |
| 编码 | 编号怎么写成一串字节? | UTF-8:E2 99 9E |
| 字形(glyph) | 屏幕上长什么样? | 由字体决定 |
注意最后一行:码位不等于字形。同一个码位在不同字体里长得可能完全不同,但它是同一个字符。反过来,有些字符看起来一样却不是同一个码位(后面的文章会讲这个陷阱)。
二、码位、码元与字节序列
这三个词经常连着出现,一层层往下:
- 码位(code point):字符的编号,Unicode 里范围是
0到0x10FFFF(十进制 1,114,111)。 - 码元(code unit):编码把码位切成的基本单位。UTF-8 的码元是 8 位,UTF-16 的码元是 16 位,UTF-32 的码元是 32 位。
- 字节序列:码元最终写进文件/网络时的那串字节。
关键理解:「一个字符占几个字节」这个问题,离开编码就没法回答。同一个「中」字,UTF-8 里 3 字节,GBK 里 2 字节,UTF-32 里 4 字节。
Python 里可以用两个内置函数观察码位:
# 适用于 Python 3.8+
print(ord("A")) # 65 字符 -> 码位
print(chr(65)) # 'A' 码位 -> 字符
print(hex(ord("♞"))) # 0x265e 和 HOWTO 里的例子一致ord()接受一个长度为 1 的字符串,返回它的码位整数;chr()反过来。这两个函数操作的是码位层面,完全不涉及字节。
三、ASCII、GBK、Unicode 的关系
它们不是同一层的替代品,历史关系是「后一层包含并取代前一层」:
| 名称 | 类型 | 覆盖范围 | 与字节的关系 |
|---|
| ASCII | 字符集 + 编码合一 | 128 个字符 | 1 字符 = 1 字节 |
| Latin-1(ISO-8859-1) | 字符集 + 编码合一 | 256 个字符 | 1 字符 = 1 字节 |
| GB2312 / GBK / GB18030 | 中文编码 | 简中字符,GBK 是 GB2312 的扩展,GB18030 再扩大 | 变长(中文多 2 字节) |
| Unicode | 字符集 | 全球字符 | 不含字节规则 |
| UTF-8 / UTF-16 / UTF-32 | Unicode 的编码 | 全部 Unicode 码位 | 1~4 字节 / 2~4 / 固定 4 |
几个要点:
- ASCII 既是字符集又是编码,因为它的字符和字节一一对应,两层合一。Latin-1(
latin-1)同样如此,官方文档指出latin-1、iso_8859_1、8859是同一编码的不同名字。 - GBK 是中文编码,它是 GB2312 的扩展,字节层面和 Unicode 无关。GB18030 是更全的后继标准。一个 GBK 字节流用 UTF-8 去解,必然乱码或报错,反过来也一样。
- Unicode 是字符集,不是编码。常说的「存成 Unicode」是含糊说法,实际必须指定 UTF-8 或 UTF-16 之类的具体编码。
- 只要一个字节流全是 ASCII,它在 UTF-8 下也是合法的。这是 UTF-8 的一个便利性质:ASCII 文本天然就是合法的 UTF-8 文本。
四、为什么 UTF-8 是变长的
固定宽度(比如直接用 4 字节表示每个码位)的问题很直观:英语文本里绝大多数字符的码位都小于 128,用 4 字节存一个 ASCII 字符,浪费极大。官方 HOWTO 里明确算过这笔账:同样的字符串,用 32 位定长表示要 24 字节,而 ASCII 只要 6 字节——磁盘和带宽是 4 倍浪费。
UTF-8 的规则(来自官方 HOWTO):
- 码位小于 128 时,用一个字节表示,值与码位相同。
- 码位大于等于 128 时,用两、三或四个字节表示。
这套设计带来几个便利:
- 能表示任意 Unicode 码位。
- ASCII 部分是合法的 UTF-8,老的 ASCII 数据和 UTF-8 天然兼容。
- 不会出现意外的零字节(
U+0000除外),所以能安全穿过 C 字符串函数。 - 字节序无关,不像 UTF-16 那样需要 BOM(字节序标记)来区分大小端。
代价是:UTF-8 是变长的,所以「字符串长度」和「字节数」不是一回事。Python 3 的len(s)数的是码位数,而len(s.encode("utf-8"))数的是字节数:
# 适用于 Python 3.8+
s = "中文abc"
print(len(s)) # 5 5 个字符
print(len(s.encode("utf-8"))) # 11 3+3+1+1+1 个字节一个额外细节:Python 3 的len()数的是码位,也不等于用户眼中的「字符数」。带组合符号的字符(如「é」写成e加一个组合重音符)算两个码位,但看起来是一个字符。这个问题留给 Unicode 文本处理的文章展开。
五、Python 3 里字符串与字节串的分界
Python 3 最基本的一条分界:str是文本(码位序列),bytes是字节(8 位序列)。两者之间必须显式转换:
| 方向 | 方法 | 说明 |
|---|
str→bytes | s.encode("utf-8") | 编码 |
bytes→str | b.decode("utf-8") | 解码 |
| 编码名 | "utf-8"、"gbk"、"ascii"等 | 大小写不敏感,-和_等价 |
# 适用于 Python 3.8+
text = "中文"
data = text.encode("utf-8") # bytes
print(data) # b'\xe4\xb8\xad\xe6\x96\x87'
back = data.decode("utf-8") # str
print(back == text) # Trueb"\xe4\xb8\xad..."里的\xe4是一个字节的十六进制写法。注意bytes对象的每个元素是 0~255 的整数,data[0]得到的是整数228,不是字符。
5.1 Python 2 的对照(必看)
Python 2.7 已于 2020 年 1 月 1 日停止维护(PEP 373 里写明了这个日期)。编码正是两代 Python 差异最大的地方:
| 项目 | Python 2 | Python 3 |
|---|
str是什么 | 字节串 | 文本(码位序列) |
| 文本类型 | unicode | str |
| 字节类型 | str | bytes |
u"..."前缀 | 必需(否则是字节串) | 无意义(str已是文本) |
| 默认编码 | ASCII,隐式ascii编解码 | UTF-8 |
| 源码默认编码 | ASCII,需# -*- coding: utf-8 -*- | UTF-8(PEP 3120) |
Python 2 里最坑的是隐式编解码:当字节串和unicode混用运算时,解释器会偷偷用 ASCII 去解码字节串,一旦遇到非 ASCII 字节就抛UnicodeDecodeError。Python 3 取消了这种隐式转换,逼你显式encode/decode,虽然啰嗦,但把乱码问题从「运行时随机炸」变成了「写代码时就得想清楚」。
常见坑点
- 把 Unicode 当成一种编码
❌ 「这个文件是 Unicode 格式的」,然后试图decode("unicode")。
✅ Unicode 是字符集,必须指明utf-8/utf-16之类的具体编码。
- 混用
len()和字节数
❌ 用len(s)去算网络包该申请多大缓冲区,结果中文被截半。
✅ 字节相关问题一律用len(s.encode(...))。
- 用错编码硬解字节流
❌ GBK 的字节流拿去decode("utf-8"),报UnicodeDecodeError。
✅ 先确定字节流真实编码,再用对应编码解码。
- 默认编码不一致导致跨平台差异
❌open("f.txt")不写encoding,在 Windows 上按 GBK、在 Linux 上按 UTF-8,结果不同。
✅ 始终显式写open("f.txt", encoding="utf-8")。
- 以为 ASCII 能存中文
❌"中".encode("ascii")直接抛UnicodeEncodeError。
✅ 需要存中文就用utf-8,不要用ascii。
- 照抄 Python 2 的写法
❌ 写u"中文"或依赖隐式编解码。
✅ Python 3 里str已是文本,不需要u前缀;编解码必须显式。
- UTF-16 忽略字节序
❌ 用utf-16编出来的字节流,被另一端按固定大小端解读,出现「�」。
✅ 需要跨系统交换时用utf-16(带 BOM)或明确utf-16-le/utf-16-be。
总结
| 概念 | 一句话 |
|---|
| 字符集 | 字符到码位的表,如 Unicode |
| 编码 | 码位到字节的规则,如 UTF-8 |
| 码位 | 0 ~ 0x10FFFF 的整数 |
| UTF-8 | 变长,ASCII 兼容,字节序无关 |
| Python 3 | str是文本,bytes是字节,转换必须显式 |
| Python 2 | str是字节串,unicode才是文本,已停止维护 |
字符集和编码分属两层,把它们分开看,乱码就不再玄学:不是「编码错了」,而是「在某个环节用了另一层该用的东西」。UTF-8 之所以成为事实标准,是因为它用变长换来了 ASCII 兼容和字节序无关;代价是长度概念要随时分清是码位还是字节。这两点想明白,后面的编码问题排查会顺很多。