len("ab你好") 等于 4——一个字母算一个,一个汉字也算一个。这个答案本身没错,但它只是表面:Python 的文本世界里其实并存着好几套"账",len 数的是码点,编码后数的是字节,用户眼睛看到的是字素,而从 Java 过来的人还得先把 Java 的 UTF-16 计数习惯拆掉。不搞清这几套账的边界,截断、限长、传输时会接连踩坑。
本文按"len 与码点 → 字节账本 → 字素陷阱 → 平台默认编码 → 安全截断写法"的顺序展开,把这团概念一次理顺。
1、len 数的是码点
Python 3 的 str 是码点(code point)序列——Unicode 给每个字符编的号码。字母 a 是一个码点,汉字 你 也是一个码点,计数上一视同仁。基础操作,示例如下:
s = "ab你好"
print(len(s)) # 4:len 数的是码点,不分中英文
print(s[0], s[2]) # a 你:下标与切片同样按码点
print(ord("你")) # 20320:你 的码点,习惯写作 U+4F60
print(chr(0x4F60)) # 你:码点反向得到字符
解读:ord 与 chr 是码点与整数之间的双向换算,s[2] 取到的是完整的汉字——下标、切片、len 全部工作在同一套码点账上,s[:30000] 截的就是 3 万个码点。内部实现上,Python 3.3 起(PEP 393)按内容自动选择每码点 1、2 或 4 字节的紧凑存储,纯 ASCII 字符串只占 1 字节每码点。
Java 开发者在这里要换脑子:Java 的 length() 数的是 UTF-16 码元,不是码点。对照如下:
String a = "你";
String b = "😀";
System.out.println(a.length()); // 1
System.out.println(b.length()); // 2:超出 BMP,UTF-16 里占一对代理项
解读:常用汉字在 UTF-16 里占 1 个码元,和 Python 一致;但 emoji 这类"增补平面"字符要占 2 个码元。同一字符串在两门语言里的账面差异如下:
| 内容 | Java length() | Python len() |
|---|---|---|
| 常用汉字"你" | 1 | 1 |
| 单个 emoji | 2 | 1 |
| 家庭 emoji(4 人与连接符) | 11 | 7 |
从表里可以看出,Python 的 len 比 Java 的 length() 更接近直觉:一个码点就是一,不分平面。但这套账依然不等于"用户看到的字符数",第 3 节展开。
2、字节数是另一套账
字符占多少字节由编码决定,与 len 无关。str 编码成 bytes 后,长度才以字节计,示例如下:
s = "ab你"
b = s.encode("utf-8") # str 编码成 bytes
print(len(s), len(b)) # 3 5:码点账 3,字节账 5
print(b) # b'ab\xe4\xbd\xa0':你 占 3 个字节
print(b.decode("utf-8")) # bytes 解回 str
解读:encode 与 decode 在 str 与 bytes 两个类型之间往返,bytes 的 len 就是字节数。UTF-8 是最常见的编码,各类字符的账单如下:
| 字符类别 | 举例 | UTF-8 字节数 |
|---|---|---|
| ASCII 字母数字与半角符号 | a 1 |
1 |
| 带附加符号的拉丁字母 | é | 2 |
| 常用汉字与多数中日韩文字 | 你 | 3 |
| emoji 与生僻字 | 😀 | 4 |
从表里可以直接算出实际影响:同样 3 万字符的限额,全英文内容约 3 万字节,全中文内容约 9 万字节——如果对端按字节限长(数据库字段、部分 API),中文内容会先撞墙。更系统的阐述见官方 Unicode HOWTO。
3、码点不等于字素
len 的账再准,也有对不上的时刻:用户感知的一个"字",可能由多个码点拼成。这类"一个字"的标准名称是字素(grapheme cluster),两个经典案例如下:
import unicodedata
s = "e" + chr(0x301) # 字母 e 加组合尖音符,两个码点
print(len(s)) # 2
print(len(unicodedata.normalize("NFC", s))) # 1:归一化后合成单码点
family = "👨👩👧👦" # 零宽连接符拼出的家庭 emoji
print(len(family)) # 7:4 个人物码点加 3 个连接符
解读:é 有两种写法——单码点的合成式,和"字母加组合符"的分解式,肉眼看一模一样,len 却差一倍;比较相等前先做 NFC 归一化是标准对策,细节见 unicodedata 文档。家庭 emoji 更极端:屏幕上一个 glyph,账面上 7 个码点,中间的零宽连接符不可见——复制粘贴这串字符时连接符一旦丢失,一个字素就散成了 4 个。
字素级切分标准库没有提供,需要第三方库(如 regex 模块的 \X 转义)。日常结论:len 在含 emoji 或拼音声调的文本上只保证"码点账"正确,别把它当"用户字符数"用。
4、平台默认编码的坑
码点与字节的换算靠编码,而"不写编码名时用什么"是另一类坑。此前的 Python 版本里,open() 不指定 encoding 时跟随操作系统区域设置——中文 Windows 上默认是 GBK,同一份代码在 Linux 与 Windows 上读写同一文件可能得到不同结果。显式指定是唯一稳妥的写法:
open("data.txt", "w", encoding="utf-8") # 写文件显式指定编码
open("data.txt", encoding="utf-8") # 读文件同理
解读:显式 encoding="utf-8" 一劳永逸。同源的坑还有控制台输出:往 GBK 编码的终端 print("😀") 会抛 UnicodeEncodeError——emoji 在 GBK 里没有对应字节,这不是代码错,是终端的账本里没这个字符。
这个问题正在被官方收口:PEP 686 已让 Python 3.15 起默认启用 UTF-8 模式,文件读写不再跟随系统区域。但在 3.15 之前的版本上,"永远显式写 encoding"仍然是必须养成的习惯。
5、安全截断的写法
把几套账合起来,就回到工程里最常见的操作:截断限长。按码点与按字节是两种不同的截法:
def clip_chars(s, limit):
"""按码点截断,超长补省略号"""
return s[:limit - 1] + "…" if len(s) > limit else s
def clip_bytes(s, limit):
"""按 UTF-8 字节截断,丢弃被切坏的尾部序列"""
return s.encode("utf-8")[:limit].decode("utf-8", "ignore")
解读:clip_chars 服务于"限字符数"的场合,切片按码点走,不会切碎一个汉字;clip_bytes 服务于"限字节数"的场合——字节切割可能把一个多字节字符拦腰截断,decode 时用 errors="ignore" 把残缺的尾部序列丢掉。两种截法都对付不了字素:切片可能把组合音符与家庭 emoji 切散,要在字素边界截断就得借助第三方字素库。
选截法之前先问限额到底是什么:对方说"3 万"时,是 3 万码点还是 3 万字节,答案决定用哪一个函数。
6、总结
回顾三套账:len 与切片数的是码点,一个汉字一个码点,比 Java 的 UTF-16 码元计数更贴近直觉;字节多少由编码决定,UTF-8 下汉字 3 字节、emoji 4 字节,字节限额与字符限额差数倍;用户感知的字素可能由多个码点拼成,emoji 与带声调字母是重灾区,NFC 归一化能合并"同形不同构",字素切分要靠第三方库。对于日常处理中英混排文本的开发者,建议把三条立成规矩:len 只读作码点数,跨系统传输与存储前按字节核对限额,所有文件读写显式写上 encoding="utf-8"——这三条能挡掉绝大多数字符问题。

