《Python编程入门》10.1 字符串处理与正则 re

字符串方法全景、切片与不可变、三种格式化的取舍与 f-string 调试写法、encode/decode 的编码错误处理,再到 re 模块的 match/search/fullmatch 区别、分组与命名分组、findall 与 finditer、贪婪与非贪婪、sub 回引,以及预编译缓存与一条日志解析实战。

本节目标:把字符串的方法、切片、格式化与编码讲成一张可查的地图,并系统掌握 re 模块的六个入口函数、分组、贪婪匹配与预编译缓存。
适用版本:Python 3.12+(实测 3.14.6)

10.1 字符串处理与正则 re

在 9.3 运行时校验与 Pydantic 入门 里,我们已经学会把外部数据挡在边界上做校验。可校验之后,数据往往还是「一大坨文本」:日志行、CSV 单元格、用户填写的地址。本节就把文本本身处理干净——先看字符串自带的方法,再上正则这把「模式手术刀」。

10.1.1 字符串方法全景

3.1 节讲过字符串不可变、len 数码位。这里补上最常用的方法,把它们当成一张随时可查的表:

方法作用示例 → 结果
strip / lstrip / rstrip去两端/左/右空白" a ".strip() → 'a'
split(sep)按分隔符切分"a-b".split("-") → ['a','b']
join(iter)用自身连接"-".join(['a','b']) → 'a-b'
replace(old, new)替换子串"aba".replace("a","o") → 'obo'
startswith / endswith前后缀判断"a.py".endswith(".py") → True
partition(sep)切成三段"k=v".partition("=") → ('k','=','v')
removeprefix / removesuffix去前后缀(3.9+)"a.py".removesuffix(".py") → 'a'
casefold大小写折叠"ß".casefold() → 'ss'

跑一遍验证:

s = "  Hello, Python World  "
print(repr(s.strip()))
print(repr("a-b-c".split("-")), repr("a  b   c".split()))
print(repr("abc".replace("b", "X")))
print(repr("key=value".partition("=")))
print(repr("https://x".removeprefix("https://")), repr("report.csv".removesuffix(".csv")))
'Hello, Python World'
['a', 'b', 'c'] ['a', 'b', 'c']
'aXc'
('key', '=', 'value')
'x' 'report'

两个易错点:split() 不带参数时按任意连续空白切分并丢弃空串,而 split(" ") 会把连续空格切成空串;大小写不敏感比较用 casefold() 而非 lower()——"ß".lower() 还是 'ß',"ß".casefold() 才得到 'ss'。

10.1.2 切片与不可变

字符串不可变,所有「修改」都是返回新对象。切片是最高频的操作,s[start:stop:step] 的三个参数都可省略:

s = "Python编程入门"
print(s[0], s[-1], s[2:6], s[:4], s[4:])
print(s[::2], s[::-1])
print(len(s), len(s.encode("utf-8")))
P 门 thon Pyth on编程入门
Pto编入 门入程编nohtyP
10 18

注意最后一行:len(s) 是 10(10 个码位),UTF-8 编码后是 18 字节——Python 六个字母各占 1 字节,编程入门 四个汉字各占 3 字节,6 + 4×3 = 18。永远用 len 数码位,不要用字节数估算长度。

切片的 step 为负时从右往左取,s[::-1] 就是反转字符串的惯用法;切片不会越界报错,s[100:200] 只是空串,比索引 s[100] 安全。

10.1.3 三种格式化与 f-string 调试写法

3.1 节已经展开过 f-string 的格式化语法,这里只做取舍对比。三种写法都能得到同样的结果:

name, age, pi = "Ada", 36, 3.14159
print("%s is %d, pi=%.2f" % (name, age, pi))
print("{} is {}, pi={:.2f}".format(name, age, pi))
print(f"{name} is {age}, pi={pi:.2f}")
Ada is 36, pi=3.14
Ada is 36, pi=3.14
Ada is 36, pi=3.14

选型建议:新代码一律用 f-string,因为它把变量名直接写在原位,可读性最好。需要把格式串存成变量、稍后再填值时才用 % 或 .format()。至于性能,本机微基准(timeit 20 万次)三者都在 0.04~0.05 秒量级,% 反而略快——差异在 10% 以内,不值得为此牺牲可读性。

f-string 在 3.8 起支持 = 自文档化写法,调试时极其省事——f"{expr=}" 会原样打印表达式文本加等号再加结果:

name, count, ratio = "Ada", 3, 0.8567
print(f"{name=} {count=} {ratio=}")
print(f"{ratio=:.1%}")
name='Ada' count=3 ratio=0.8567
ratio=85.7%

f"{ratio=:.1%}" 把「自文档」和「格式规格」叠加在一起:先输出 ratio=,再按 :.1% 输出 85.7%。排查变量值、写日志时比手打 "ratio=", ratio 干净得多。

10.1.4 encode / decode 与编码错误处理

字符串是 Unicode 码位序列,落盘或走网络前必须 encode 成字节,读回来再 decode:

text = "中文café"
b = text.encode("utf-8")
print(b)
print(len(text), len(b))
print(b.decode("utf-8"))
b'\xe4\xb8\xad\xe6\x96\x87caf\xc3\xa9'
6 11
中文café

6 个字符编码成 11 字节:「中文」各 3 字节,é 2 字节。编码失败时(如把中文塞进 ASCII)会抛 UnicodeEncodeError,可用 errors= 参数决定降级策略:

print("ignore:", "café".encode("ascii", errors="ignore"))
print("replace:", "café".encode("ascii", errors="replace"))
print("backslash:", "café".encode("ascii", errors="backslashreplace"))
ignore: b'caf'
replace: b'caf?'
backslash: b'caf\\xe9'

errors 取值有 strict(默认,抛异常)、ignore(丢弃)、replace(换成 ?)、backslashreplace、xmlcharrefreplace、namereplace 等。解码方向也常用 ignore / replace:

raw = b"\xe4\xb8\xad\xff"          # 最后一个字节不是合法 UTF-8
print("replace:", raw.decode("utf-8", errors="replace"))
print("ignore:", raw.decode("utf-8", errors="ignore"))
replace: 中�
ignore: 中

排查乱码的第一原则:先确认原始字节,再用 errors="replace" 试解码,看哪些字节被替换成 �,就能定位到出错位置。GBK 编码的中文用 UTF-8 解码必然失败,改用 gbk 才对——这正是下一节文件编码要展开的内容。

10.1.5 re 模块:match / search / fullmatch

正则解决的是「固定子串方法不够用」的场景。re 有六个入口函数,先记住三个返回 Match 或 None 的:

import re
print(re.match(r"\d+", "123abc"))     # 从开头匹配
print(re.match(r"\d+", "abc123"))     # 开头不是数字 → None
print(re.search(r"\d+", "abc123"))    # 任意位置查找
print(re.fullmatch(r"\d+", "123"))    # 必须整串匹配
print(re.fullmatch(r"\d+", "123abc")) # 尾部多余 → None
<re.Match object; span=(0, 3), match='123'>
None
<re.Match object; span=(3, 6), match='123'>
<re.Match object; span=(0, 3), match='123'>
None

三者的区别一句话说清:match 只管开头,search 全串找第一个,fullmatch 要求整串完全匹配。校验「用户输入是不是一个纯数字」要用 fullmatch,只写 match 会放过 "123abc"。

10.1.6 分组与命名分组

括号 () 把一部分模式圈成「分组」,匹配后可以单独取出:

m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "date: 2026-09-10")
print(m.group(0), "|", m.group(1), m.group(2), m.group(3))
print(m.groups(), m.span())
2026-09-10 | 2026 09 10
('2026', '09', '10') (6, 16)

group(0) 是整个匹配,group(1) 起按左括号顺序编号;groups() 返回全部捕获组元组;span() 给出起止下标。给分组起名能让代码更抗改动:

m = re.search(r"(?P<year>\d{4})-(?P<mon>\d{2})-(?P<day>\d{2})", "2026-09-10")
print(m.group("year"), m.groupdict())
2026 {'year': '2026', 'mon': '09', 'day': '10'}

(?P<name>...) 定义命名分组,groupdict() 直接把结果变成字典——字段顺序变了也不影响调用方;m["mon"] 是 m.group("mon") 的简写。

10.1.7 findall 与 finditer

findall 返回所有匹配的列表,finditer 返回 Match 迭代器:

print(re.findall(r"\d+", "a1b22c333"))
for m in re.finditer(r"(\w)(\d)", "a1b2"):
    print(m.group(0), m.groups(), m.start())
['1', '22', '333']
a1 ('a', '1') 0
b2 ('b', '2') 2

需要位置或分组信息时用 finditer;只要结果值用 findall。但 findall 在有捕获组时会变脸——它返回的是分组内容而非整个匹配:

print(re.findall(r"(\d)(\d)", "12 34"))    # 有分组 → 元组列表
print(re.findall(r"(?:\d)(\d)", "12 34"))  # 非捕获组 → 只留捕获组
[('1', '2'), ('3', '4')]
['2', '4']

(?:...) 是非捕获组:只分组、不占用编号、不进 findall 结果。只有一个捕获组时 findall 返回字符串列表,有多个时返回元组列表,这是初学最常踩的坑——不确定就用 finditer。

10.1.8 贪婪与非贪婪

量词 * + ? 默认贪婪:能吞多少吞多少。在量词后加 ? 变成非贪婪:能少吞就少吞。

s = "<b>bold</b> and <i>italic</i>"
print(re.findall(r"<.+>", s))
print(re.findall(r"<.+?>", s))
['<b>bold</b> and <i>italic</i>']
['<b>', '</b>', '<i>', '</i>']

贪婪的 <.+> 一路吃到最后一个 >,非贪婪的 <.+?> 每碰到一个 > 就收手。解析成对标签、抓引号之间的内容时,非贪婪几乎总是你想要的。但注意非贪婪不是万灵药——它只是「尽可能少」,遇到嵌套结构仍应改用专门的解析器。

10.1.9 sub 替换与反向引用

sub(pattern, repl, string) 把匹配替换成 repl,repl 里可用 \1 或 \g<name> 引用分组:

print(re.sub(r"\d+", "#", "a1b22c333"))
print(re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "2026-09-10"))
print(re.sub(r"(?P<a>\w+)@(?P<b>\w+)", r"\g<b>@\g<a>", "user@host"))
a#b#c#
10/09/2026
host@user

sub 还有 count 参数限制替换次数。\g<name> 的尖括号不能省——写 \gname 会被当成字面量。第二条把日期从 年-月-日 重排成 日/月/年,是分组回引的典型用法。

10.1.10 编译、缓存与一条实战

re 内部维护一个预编译缓存(默认 512 条),所以反复调用 re.search(pat, s) 不会每次重新编译。用私有的 re._cache / re._MAXCACHE 可以观察到它(仅供观察,不要在生产代码里依赖这两个私有名):

import re
re.purge()
print("after purge:", len(re._cache))
re.search(r"\d+", "a1")
re.search(r"[a-z]+", "a1")
print("keys:", list(re._cache))
print("limit:", re._MAXCACHE)
after purge: 0
keys: [(<class 'str'>, '\\d+', 0), (<class 'str'>, '[a-z]+', 0)]
limit: 512

缓存键是 (pattern 类型, 模式串, flags)。但缓存会被淘汰:模式种类多于一屏时(比如从配置文件动态加载几十条规则),早期模式会被挤出去、反复重编译。所以固定模式应当显式 re.compile 一次并复用——既不受缓存淘汰影响,也便于集中管理。

把本节串起来,解析一条日志:

LOG = re.compile(r"""
    ^
    (?P<ts>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})
    \s+ (?P<level>DEBUG|INFO|WARN|ERROR)
    \s+ (?P<logger>[\w.]+)
    \s+ (?P<msg>.+?)
    (?:\s+\((?P<file>[\w./]+):(?P<line>\d+)\))?
    $
""", re.VERBOSE)

for line in [
    "2026-09-10 10:00:01 INFO app.main server started",
    "2026-09-10 10:00:05 ERROR app.db connection refused (db.py:88)",
]:
    print(LOG.match(line).groupdict())
{'ts': '2026-09-10 10:00:01', 'level': 'INFO', 'logger': 'app.main', 'msg': 'server started', 'file': None, 'line': None}
{'ts': '2026-09-10 10:00:05', 'level': 'ERROR', 'logger': 'app.db', 'msg': 'connection refused', 'file': 'db.py', 'line': '88'}

这里用了三个技巧:re.VERBOSE 允许模式里写空白和换行、把正则排版成可读段落;(?P<msg>.+?) 非贪婪抓消息体;(?:...)? 可选的非捕获组抓尾部的位置信息。日志解析、字段提取这类「半结构化文本」正是正则最该出场的地方;而 CSV、JSON、HTML 有标准解析器,一律不要写正则。

更完整的正则语法(零宽断言、灾难性回溯防护、Unicode 规范化)见专题 Python 正则与文本处理进阶 。

小结

  • 字符串方法先查表:strip/split/join/replace/partition/removeprefix 覆盖大部分需求;大小写比较用 casefold()。
  • 字符串不可变,切片返回新对象且不越界报错;len 数码位,编码后字节数另算。
  • 格式化首选 f-string,f"{x=}" 是调试利器;三种格式化性能差异在 10% 以内,不必纠结。
  • encode/decode 用 errors= 控制失败降级;match/search/fullmatch 的区别是「只开头 / 任意位置 / 整串」。
  • 命名分组 + groupdict() 让结果变成字典;findall 遇捕获组会返回分组,不确定就用 finditer。
  • 量词默认贪婪,加 ? 变非贪婪;re.compile 一次复用比依赖缓存更稳。

字符串处理完,接下来要把文本落盘、从磁盘读回。下一节 10.2 文件 I/O、pathlib 与编码 会讲清 open 的每一个参数、pathlib 的现代路径操作,以及乱码到底从哪来。

阅读导航:上一节:9.3 运行时校验与 Pydantic 入门 · 下一节:10.2 文件 I/O、pathlib 与编码 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时