跳到主要内容

文本处理、正则表达式与 Unicode

本节目标

查询 Python 文本与字节边界、Unicode 规范化、正则匹配和输入复杂度。

文本处理先确定文本和字节的边界,再确定 Unicode 等价性与模式匹配的范围。以下以 strcodecsUnicode HOWTOunicodedatare正则 HOWTO 的 Python 3.14 文档为准。文件流里的编码边界见文件、路径、流、临时文件与目录操作,序列化数据格式见结构化数据、序列化、压缩与 SQLite

strbytes 与文本边界

str 表示 Unicode 文本,bytes 表示 0 到 255 的字节序列;二者只能通过选定编码的 encode()decode() 转换。网络、文件和协议入口负责把字节解码为文本,出口再编码,业务逻辑不应猜测隐式转换。

payload = "Café".encode("utf-8")
text = payload.decode("utf-8")

编码名、错误策略和字节来源共同构成 API 边界,bytes 不是“另一种字符串”。把 UTF-8 字节当作可直接与 str 拼接的文本,或把任意字节猜作默认编码,会得到类型错误或错误数据。

编解码器与错误处理

编解码器把明确的字符集名称映射为编码和解码规则,errors="strict" 会将无效数据报告为 UnicodeError。互操作接口默认显式使用 UTF-8 与严格错误处理;只有业务已规定无效数据如何恢复时,替代策略才有明确语义。

text = b"Python\xe2\x98\x83".decode("utf-8", errors="strict")

replaceignore 是有损恢复,不是验证成功;它们会改变后续比较、签名或审计的输入。不要把错误处理选项当成清洗不可信数据的安全边界,错误字节的来源仍须处理。

码点、组合字符与用户感知字符

Python str 由 Unicode 码点组成,组合字符可与前一基字符共同显示为一个用户感知的字形簇。len(str) 计数的是码点,不是用户感知的字形簇。因此存储、索引和协议可以按码点 API 操作,面向用户的截断、光标和长度限制却需要能识别字形簇的界面层规则。

combining = "e\u0301"
code_points = len(combining)

一个视觉字符可有多个码点,反过来字形簇也不是 Python re 的通用匹配单位。把 len() 的结果写成屏幕上的“字符数”,或在任意码点位置切断文本,都会破坏用户感知的文本边界。

Unicode 规范化与 unicodedata

同一用户可见文本可能有预组合和组合序列等不同码点表示;unicodedata.normalize() 按 NFC、NFD、NFKC 或 NFKD 产生指定形式。规范化形式是业务合同:先把约定写入数据接口,再在受控入口统一处理,比较、索引、去重和签名的参与方才不会在不同层反复、无规则地变换文本。

import unicodedata

normalized = unicodedata.normalize("NFC", "Cafe\u0301")

NFKC/NFKD 会执行兼容等价转换,可能改变标识符或展示语义;规范化也不授予身份、权限或同形字符安全性。不能仅因两个值视觉相似就当作同一业务值。

text_unicode_report.py
import re
import unicodedata


source = "Cafe\u0301 版本 3.14"
normalized = unicodedata.normalize("NFC", source)
pattern = re.compile(r"(?P<name>Café)\s+版本\s+(?P<version>\d+\.\d+)")
match = pattern.fullmatch(normalized)
assert match is not None
rewritten = pattern.sub(r"\g<name> / Python \g<version>", normalized)
roundtrip = normalized.encode("utf-8").decode("utf-8") == normalized

print(f"normalized={normalized}")
print(f"name={match.group('name')}")
print(f"version={match.group('version')}")
print(f"rewritten={rewritten}")
print(f"utf8-roundtrip={roundtrip}")
normalized=Café 版本 3.14
name=Café
version=3.14
rewritten=Café / Python 3.14
utf8-roundtrip=True

报告先把分解的 Cafe\u0301 规范化为 NFC,再用命名组完整匹配、替换并验证 UTF-8 往返;它的固定输出不证明其他输入自动符合业务的规范化合同。

字符串拆分、拼接与格式化

split() 按分隔规则产生字段,join() 将一组字符串连接,f-string 或 format() 将值格式化为展示文本。批量拼接宜用 join();解析前则要定义分隔符和字段数量,不能反过来把展示格式充当机器协议。

fields = "python,3.14".split(",")
label = " / ".join(fields)

split() 不理解 CSV 引号、转义或嵌套结构,格式化输出也不保留原值类型。把用户字段直接插入日志、模板或下游语言而未定义转义和边界,会将显示操作误用为序列化 API。

正则模式与原始字符串

正则有自身的元字符与转义,Python 字符串字面量也先处理反斜杠;原始字符串只降低 Python 字符串层的转义负担。原始字符串不会关闭正则表达式自己的转义规则。通常可以用 r"..." 编写模式,但哪些字符按字面量匹配仍须由正则语义明确。

import re

digits = re.compile(r"\d+\.\d+")

这里 \d 是正则数字类,\. 才是字面点;原始字符串也不能以单个反斜杠结束。不要把 Python 转义和正则转义混成一层,或把未经审查的文本直接拼接为模式。

matchsearchfullmatch

match() 只从字符串开头尝试,search() 寻找任意位置的第一个匹配,fullmatch() 要求整个字符串都匹配。完整字段验证应选择 fullmatch();前缀匹配和子串查找才分别对应 match()search()

import re

valid = re.fullmatch(r"[A-Z]{2}-\d{3}", "PY-314") is not None

API 名称限定的是匹配范围,不是输入可信度或正则性能保证。用 search() 验证完整令牌会接受夹带垃圾的文本;用 match() 替代全字段验证也会遗漏尾部内容。

分组、命名组与反向引用

捕获组保存匹配片段,命名组通过稳定名称访问而不是依赖编号,模式内部可用反向引用约束同一内容。业务字段使用命名组能避开位置编号漂移,相应组名也应作为解析结果的 API 合同维护。

import re

match = re.fullmatch(r"(?P<name>\w+):(?P<value>\d+)", "level:3")
value = match.group("value") if match else None

新增括号会改变位置组编号,非捕获组 (?:...) 不会;反向引用匹配的是已捕获的文本,不是字段验证或授权。不要在未检查 match is not None 时读取组,也不要让脆弱的数字编号跨模块传播。

替换、切分与 flags

sub() 用替换模板或函数生成新文本,split() 以匹配处切分,flags 改变大小写、多行和点号等匹配语义。为避免组引用歧义,替换模板使用 \g<name>IGNORECASEMULTILINE 等 flags 也属于模式合同,不能留作调用处的偶然选项。

import re

rewritten = re.sub(r"(?P<word>python)", r"[\g<word>]", "python", flags=re.I)

\1 与后续数字相邻时可能含义不清,空匹配也会影响切分与重复替换结果。不要在未限定 count、flags 或输入结构时把替换结果当作稳定的字段解析。

正则复杂度与 Unicode 边界

某些回溯模式在特定不匹配输入上会消耗大量时间,且 Unicode 的大小写、边界和可见字符不总与 ASCII 直觉一致。Python 的 re 没有通用超时参数;不可信的模式或输入必须设置资源上限。具体措施包括限制输入长度、采用受审查的简单模式,必要时移入隔离进程;结构化格式优先交给专用解析器。

if len(user_text) > 1_000:
raise ValueError("input too long")

锚点、贪婪量词或 re.I 不是 DoS 防护,也不保证跨语言文本规则相同。不要向不可信调用方开放任意模式或无限输入,更不要把一次本机快速匹配当作复杂度证明。