前言
会写s.upper()、s.split()不等于会处理 Unicode 字符串。真正难的场景是这些:两个看起来一字不差的字符串,==却返回False;对一批中文按「拼音顺序」排,sorted()排出来的完全不是那个顺序;从不同系统拿到的同一条数据,一个带 BOM 一个不带,拼接后多了个看不见的字符。
这些问题的根子在于:Unicode 里同一个「看得见的字符」,可以有多种码位表示法。本文讲四组工具——ord/chr、unicodedata的属性查询、规范化(NFC / NFD)、以及宽度与排序——它们就是应付这类问题的基本手段。
前置说明:本机无 Python 解释器,示例为逐行推演,未运行。所引用的unicodedata函数名与行为均对照 Python 官方unicodedata文档。
版本前提也要讲清:Python 2.7 已于 2020 年 1 月 1 日停止维护。Python 2 里str是字节串、unicode才是文本,写文本要加u"..."前缀,ord()对字节串返回的是字节值而非码位——这些在 Python 3 里都变了:str统一为文本,u"..."前缀不再有意义。本文全部按 Python 3 表述。
一、ord 与 chr:在码位层面读写
# 适用于 Python 3.8+
print(ord("A")) # 65
print(chr(65)) # 'A'
print(hex(ord("中"))) # 0x4e2d
print(ord("中")) # 20013ord(c)接受一个长度为 1 的字符串,返回它的码位整数。chr(i)接受一个整数,返回对应的单字符字符串。
两者是一对逆运算。注意ord的参数必须是长度恰好为 1的字符串——传"ab"会抛TypeError。另外len("中") == 1,因为 Python 3 的len()数的是码位,不是字节;想知道字节数得len("中".encode("utf-8"))(结果为 3)。
二、unicodedata:查询字符属性
unicodedata是标准库模块,提供对 Unicode 字符数据库(UCD)的查询。常用函数:
| 函数 | 返回 | 例子 |
|---|
name(chr) | 字符的官方名字 | name("A")为'LATIN CAPITAL LETTER A' |
category(chr) | 双字母的通用类别 | category("A")为'Lu' |
east_asian_width(chr) | 东亚宽度 | east_asian_width("中")为'W' |
decomposition(chr) | 分解映射(字符串) | decomposition("Ã")为'0041 0303' |
combining(chr) | 组合类(整数) | 无组合类时为0 |
numeric(chr) | 数值(浮点) | numeric("½")为0.5 |
normalize(form, unistr) | 规范化后的字符串 | 见下一节 |
is_normalized(form, unistr) | 是否已是该规范形 | 需要 Python 3.8+ |
category()返回的两个字母含义很实用:第一个字母是大类(L字母、N数字、P标点、Z分隔符、C其他……),第二个是子类(u小写、l大写、d十进制数字……)。所以category("A")是'Lu',读作「Letter, uppercase」。
# 适用于 Python 3.8+
import unicodedata as ud
print(ud.name("A")) # LATIN CAPITAL LETTER A
print(ud.category("A")) # Lu
print(ud.category("中")) # Lo —— Letter, other
print(ud.decomposition("Ã")) # 0041 0303decomposition("Ã")返回'0041 0303':说明「Ã」可以分解成 U+0041(A)加 U+0303(组合波浪号)。这正是下一节要讲的「一个字符、多种表示」的来源。
三、规范化:NFC 与 NFD
规范化(normalization)就是把这些等价的表示法统一成一种标准形式。unicodedata.normalize(form, unistr)的form有四个合法值,官方文档的定义是:
| 形式 | 全称 | 做什么 |
|---|
| NFD | Normalization Form D | 规范分解:把预组合字符拆成基字符 + 组合字符 |
| NFC | Normalization Form C | 先规范分解,再把可组合的重新组合 |
| NFKD | Normalization Form KD | 兼容分解:连兼容字符(如罗马数字、全角形式)也替换成等价物 |
| NFKC | Normalization Form KC | 先兼容分解,再规范组合 |
关键区别在「规范」与「兼容」:
- 规范等价(NFC / NFD 关心的):两个表示就是同一个字符,只是写法不同,规范后必然相等。
- 兼容等价(NFKC / NFKD 关心的):两个字符在某些用途下可以互换,但语义上未必相同。官方文档举的例子是罗马数字「Ⅰ」(U+2160)和字母「I」(U+0049)——它们看起来一样,但罗马数字是「为了兼容旧字符集(如 GB2312)」才保留的独立字符。用 NFKC 会把「Ⅰ」变成「I」,这会改变语义,要谨慎。
# 适用于 Python 3.8+
import unicodedata as ud
s1 = "é" # 预组合:单个码位 U+00E9
s2 = "é" # 分解形式:e 加组合重音符 U+0301
print(s1 == s2) # False —— 看起来一样,却不相等
print(len(s1), len(s2)) # 1 2 —— 长度也不同
print(ud.normalize("NFC", s1) == ud.normalize("NFC", s2)) # True
print(ud.normalize("NFD", s1) == ud.normalize("NFD", s2)) # True这就是「看起来一样但不相等」的经典陷阱:要先统一规范形,再比较。同一份数据在不同系统、不同输入法、不同复制来源下,可能是 NFC 也可能是 NFD。
is_normalized(form, unistr)(3.8+)用来判断一个字符串是否已经是某个规范形,可以在做昂贵的normalize之前先探测:
# 适用于 Python 3.8+
if not ud.is_normalized("NFC", s):
s = ud.normalize("NFC", s)四、中文排序与东亚宽度
4.1 默认排序按码位
Python 的sorted()对字符串比较时,用的是码位顺序。对中文来说,这意味着它既不按拼音、也不按笔画,而是按 Unicode 表里的编号:
# 适用于 Python 3.8+
words = ["中", "啊", "兵"]
print([hex(ord(w)) for w in words]) # ['0x4e2d', '0x554a', '0x5175']
print(sorted(words)) # ['中', '兵', '啊'] —— 码位顺序按码位,中(0x4E2D)<兵(0x5175)<啊(0x554A)。如果按拼音,顺序应该是「啊(a) < 兵(bing) < 中(zhong)」,两者对不上。想要拼音排序,标准库本身不提供,需要引入第三方拼音库或自建映射表(具体用法以该库官方文档为准)。
标准库里有locale.strxfrm/locale.strcoll可以做区域相关的比较,但它们依赖locale.setlocale设置的全局区域,行为随平台和系统区域变化,且设置 locale 会影响整个进程,不是线程安全的。用它之前要清楚这些限制。
4.2 东亚宽度:对齐和截断都要用
east_asian_width()返回字符在东亚语境下的显示宽度分类(对应 Unicode 标准的 East_Asian_Width 属性):
| 值 | 含义 |
|---|
"W" | Wide,宽(如中日韩汉字) |
"F" | Fullwidth,全角(如全角标点 U+FF01) |
"H" | Halfwidth,半角(如半角片假名) |
"Na" | Narrow,窄(如 ASCII) |
"A" | Ambiguous,歧义(在东亚语境下按宽算) |
"N" | Neutral,中立(如一般符号) |
# 适用于 Python 3.8+
import unicodedata as ud
print(ud.east_asian_width("中")) # 'W'
print(ud.east_asian_width("A")) # 'Na'
print(ud.east_asian_width(",")) # 'W' —— 全角逗号按东亚宽度算宽用处很直接:在等宽终端里排表格,或者按「显示宽度」截断字符串时,汉字要按 2 个字符宽度算,ASCII 按 1 个。如果按len()截断,一个汉字占一个len却占两个显示位,表格会歪。注意"A"(Ambiguous)这一类的处理是有语境依赖的,需要你自己决定按 1 还是按 2 算。
常见坑点
- 直接比较两个「看起来一样」的字符串
❌"é" == "é"返回False,还以为数据有问题。
✅ 双方都unicodedata.normalize("NFC", s)之后再比。
- 用
len()当「可见字符数」
❌ 用len(s)去截断显示文本,遇到组合字符会把一个字符截半。
✅ 需要「用户感知的字符数」时要考虑组合字符,长度问题用east_asian_width/ 规范化配合处理。
- 不加区分地用 NFKC
❌ 对全文做 NFKC,把「Ⅰ」「①」「f」这类兼容字符全改掉,语义丢失。
✅ 只在明确需要兼容化(如搜索索引)时用 NFKC;一般比较用 NFC。
- 以为
sorted()会按拼音排中文
❌sorted(["中","啊","兵"])期待「啊兵中」。
✅ 默认是码位顺序;拼音排序需第三方库或自建映射。
ord传了多字符字符串
❌ord("ab")抛TypeError。
✅ord只接受长度为 1 的字符串。
- 把
str长度当字节数
❌ 用len(s)估算缓冲区大小,中文被截半。
✅ 字节问题用len(s.encode("utf-8"))。
- 忘了 BOM 也会变成一个字符
❌ 用utf-8解码带 BOM 的文本,开头多一个不可见的 U+FEFF。
✅ 用utf-8-sig解码,它会跳过 BOM。
- 依赖
is_normalized的版本
❌ 在 Python 3.7 上调用unicodedata.is_normalized,报AttributeError。
✅ 它需要 Python 3.8 及以上;更早版本只能比较normalize前后的结果。
总结
| 工具 | 用途 | 关键点 |
|---|
ord/chr | 码位与字符互转 | 只处理长度 1 的字符串 |
unicodedata.category | 判断字符大类 | 两个字母,如Lu |
unicodedata.normalize | 统一表示法 | NFC / NFD 是规范,NFKC / NFKD 是兼容 |
unicodedata.east_asian_width | 算显示宽度 | 汉字是W,ASCII 是Na |
sorted | 排序 | 默认按码位,不是拼音 |
处理 Unicode 字符串的核心,是承认「同一个字符可以有多种表示」。一旦接受这点,比较前先规范化、截断时看显示宽度、排序时别指望拼音,这些做法就都顺理成章了。反过来,把str当成「一个字符就是一个位置」的朴素数组,迟早会在某个带组合字符或 BOM 的输入上栽跟头。