第五章 字符串及正则表达式
字符串是程序里最常用的数据类型,没有之一。用户填的每一个字、读进来的每一行文件、拼出去的每一句提示,全是它。这一章讲三件事,字符串的常用操作、编码那些绕人的东西、以及正则表达式。
目录
sql
第五章 字符串及正则表达式
│
├── 1. 字符串常用操作 → 查找替换 / 大小写 / 分割合并 / 格式化
├── 2. 字符串编码转换 → 字符与字节 / 乱码成因 / 常见编码
├── 3. 正则表达式 → 元字符 / 字符类 / 数量词 / 分组 / 贪婪惰性
├── 4. re 模块 → 五个函数 / Match 对象 / 预编译 / 标志
├── 5. 实战 → 输入清洗 / 信息提取 / 日志分析 / 模板渲染 / 脱敏
└── 6. 小结 → 知识清单 + 避坑清单 + 5 道题 + 下一章预告
照旧,建个第五章目录放代码。这章的方法名多,正则的符号更多,光看记不住,得敲。
1. 字符串常用操作
1.1 先把底子说清楚,字符串的三个特性
字符串身上有三条铁律。
有序。每个字符都有自己的位置,下标从 0 开始,跟列表一模一样。
python
>>> s = "Python"
>>> s[0]
'P'
>>> s[-1]
'n'
>>> s[1:4]
'yth'
>>> s[::-1]
'nohtyP'
不可变 。这条最容易在刚上手时吃亏。上一章说过的都还适用,replace、upper、strip 这些方法,没有一个能改动原字符串,全部返回一个新的。
python
>>> s = "Python"
>>> s.upper()
'PYTHON'
>>> s
'Python'
>>> t = s.upper()
>>> t
'PYTHON'
把 s.upper() 单独写成一行,看着像执行了,其实返回值被丢掉了。所以我见过不少新手写下面这种代码,然后盯着眼前的输出发懵。
python
>>> text = " hello "
>>> text.strip()
'hello'
>>> text
' hello '
strip 返回的 'hello' 没人接,转身就被扔了。要留住就得赋值给变量,通常我们直接写回自己。
python
>>> text = " hello "
>>> text = text.strip()
>>> text
'hello'
写回自己的写法看着别扭,但 Python 里就这么处理,字符串的所有修改类方法都是这个套路。
可迭代 。for 循环能一个字符一个字符地走过去。
python
>>> for ch in "中国":
... print(ch)
中
国
注意这里是按字符走的,不是按字节。中文两个字就是两个字符,len 出来是 2。这点跟别的语言不一样,Python 3 里字符串天然是 Unicode,不用额外操心。
python
>>> len("中国")
2
>>> len("中国".encode("utf-8"))
6
字符数和字节数不是一回事,第二节会专门讲这个坑。
1.2 拼接与重复
加号连起来,乘号重复多少次。
python
>>> "Hello" + " " + "World"
'Hello World'
>>> "-" * 20
'--------------------'
>>> "重要的事情说三遍!" * 3
'重要的事情说三遍!重要的事情说三遍!重要的事情说三遍!'
减号不能用在字符串上,这是常被地问的一个操作,Python 直接不支持。
python
>>> "abc" - "c"
TypeError: unsupported operand type(s) for -: 'str' and 'str'
要把某段内容去掉,用 replace 或切片。
拼接大量字符串的时候有个讲究。像下面这种在循环里用 += 的写法,数据量小的时候没感觉,几十万次起来会明显慢。
python
result = ""
for i in range(100000):
result += str(i)
原因是每次 += 都要新建一个字符串再把旧的拷进去。数据量大时可以先把片段收进列表,最后用 join 一次性合并。join 在前面计算好了总长度,只分配一次内存。
python
parts = []
for i in range(100000):
parts.append(str(i))
result = "".join(parts)
两种写法结果一样,差别在效率。日常脚本几千条数据,怎么顺手怎么写,不用提前优化。
另外提醒一句,join 是字符串的方法,调用方式正好是反的。分隔符在前,列表在后。
python
>>> words = ["Python", "Java", "Go"]
>>> ", ".join(words)
'Python, Java, Go'
我刚开始学时总写成 words.join(", "),报错了好几回才记住。
1.3 查找
三个方法,各有分工。
find 返回下标,找不到返回 -1。
python
>>> s = "Hello, World!"
>>> s.find("o")
4
>>> s.find("z")
-1
index 同样返回下标,但找不到会抛异常。
python
>>> s.index("World")
7
>>> s.index("z")
ValueError: substring not found
选哪个看场景。如果不打算处理找不到的情况,index 更直白,异常会逼你去处理。如果在写判断逻辑,find 更顺手,因为它返回的是 -1 这种可以比较的值。
python
>>> if s.find("@") != -1:
... print("这像是邮箱")
count 数出现次数。
python
>>> s.count("o")
2
还有两个从两头查的版本,rfind、rindex。从右边找起,适合取下最后一段路径之类的场景。
python
>>> path = "/home/user/docs/report.pdf"
>>> path.rfind("/")
15
>>> path[path.rfind("/") + 1:]
'report.pdf'
用 in 判断存在与否是最省事的写法,可读性也最好,不用去记住 -1 这个约定。
python
>>> "World" in s
True
>>> "Python" in s
False
1.4 替换
replace 三个参数,旧的子串、新的子串、替换几次。第三个不写,默认全部替换。
python
>>> "a-b-c".replace("-", "/")
'a/b/c'
>>> "aaa".replace("a", "b", 2)
'bba'
>>> "aaa".replace("a", "")
''
把第三个参数留空让它全换,是最常用的形态。
删掉某一段就等于替换成空串,上面最后一行就是这个用法。
还有一对只在 Python 3.9 之后才有的方法,removeprefix 和 removesuffix。它们只处理开头或者结尾,比 replace 精确,也比切片好看。
python
>>> "TestCase".removeprefix("Test")
'Case'
>>> "report.pdf".removesuffix(".pdf")
'report'
这两条我挺常用的。以前处理前缀要写 s[len(prefix):] 或者用正则,现在一行搞定,读起来意思也清楚。
1.5 大小写
五个方法,一条比一条小众。
python
>>> s = "Hello, World!"
>>> s.upper()
'HELLO, WORLD!'
>>> s.lower()
'hello, world!'
>>> s.title()
'Hello, World!'
>>> s.capitalize()
'Hello, world!'
>>> s.swapcase()
'hELLO, wORLD!'
title 把每个单词首字母大写,capitalize 只把第一个单词的首字母大写,剩下的都压小写。两者在处理标题的时候结果差别挺明显。
swapcase 平时基本用不上,偶尔写点好玩的文本处理会碰到它。
判断用的 six 个方法也一并记了,它们全是返回布尔值。
python
>>> "abc".isalpha()
True
>>> "123".isdigit()
True
>>> "abc123".isalnum()
True
>>> " ".isspace()
True
>>> "Abc".istitle()
True
>>> "HELLO".isupper()
True
这些判断在处理用户输入时特别好用。想验证对方输入的是不是纯数字,"123".isdigit() 一句话搞定。
python
>>> age = input("请输入年龄 >> ")
请输入年龄 >> 十二
>>> age.isdigit()
False
比写过 try/except 去转换要省事。不过要注意一个反直觉的地方,全角数字也能通过 isdigit。
python
>>> "123".isdigit()
True
>>> "123".isdecimal()
True
>>> int("123")
123
让我有点意外的是,全角数字不仅能通过检查,int 还真能把它们转成 123。如果业务上只认半角,用 isdecimal 之外还得额外判断字符范围。
casefold 比 lower 更狠一点,它处理的是语言层面的等价。德语那个 ß 就是典型例子。
python
>>> "STRASSE".lower()
'strasse'
>>> "STRASSE".casefold()
'strasse'
>>> "ß".lower()
'ß'
>>> "ß".casefold()
'ss'
比较两个来自不同系统的字符串之前,用 casefold 比用 lower 稳。
1.6 去掉空白
strip 去两头,lstrip 去左边,rstrip 去右边。
python
>>> " hello ".strip()
'hello'
>>> " hello ".lstrip()
'hello '
>>> " hello ".rstrip()
' hello'
不传参数的时候,去掉的是所有空白字符,包含空格、制表符、换行符。这一条在处理文件行的时候太重要了,读进来的每一行末尾基本都挂着个 \n。
python
>>> line = "hello\n"
>>> line.strip()
'hello'
传了参数,就是按字符集去删。注意是字符集,不是子串,这个区别特别容易搞错。
python
>>> "xxxhelloxxx".strip("x")
'hello'
>>> "xyxhelloyxy".strip("xy")
'hello'
不过它们怎么排列不重要,只要挨着边就一直删到碰到别的字符为止。我一开始以为 strip("xy") 是删掉 xy 这个整体,看到结果愣了好一会儿。
python
>>> "xyxyhello".strip("xy")
'hello'
>>> "xyxyhello".removeprefix("xy")
'xyhello'
要删的是固定前缀,用 removeprefix 才准确。
1.7 对齐与填充
写命令行报表的时候这三兄弟出场率很高。
python
>>> "hi".center(10)
' hi '
>>> "hi".ljust(10)
'hi '
>>> "hi".rjust(10)
' hi'
>>> "hi".center(10, "-")
'----hi----'
>>> "hi".ljust(6, ".")
'hi....'
>>> "hi".rjust(6, ".").rjust(6, ".")
'....hi'
zfill 是给数字补零的,处理编号、日期、流水号时很好用。
python
>>> "42".zfill(5)
'00042'
>>> "-42".zfill(8)
'-0000042'
负号被算在宽度里,补的零跑到符号后面去,不会把负号挤掉。
不过中文在这一类方法里会出差错。中文字符在终端里占两个字符宽,而这些方法按字符数算,视觉上对不齐。
python
>>> "中国".ljust(10)
'中国 '
看着是行了,实际终端里 中国 占四个字符宽,加上六个空格一共十个字符宽,跟另一行的十个半角字符宽度一样。所以中英混排的对齐,得自己按显示宽度算,第三章那个小票就碰过这个问题。
1.8 分割与合并
split 按分隔符切开。不传分隔符的时候按任意空白切,连续空白当成一个处理。
python
>>> "a,b,c".split(",")
['a', 'b', 'c']
>>> "a b c d".split()
['a', 'b', 'c', 'd']
>>> "a-b-c".split("-", 1)
['a', 'b-c']
第二个参数是切几刀。给 1 表示只切一刀,凑两段出来。解析 key=value 之类的数据时很有用,右边的值里再有等号也不会被拆。
python
>>> "timeout=30s".split("=", 1)
['timeout', '30s']
rsplit 从右边开始切,取最后一段特别省事。
python
>>> "a-b-c-d".rsplit("-", 1)
['a-b-c', 'd']
按行切有专门的 splitlines,它能同时处理 \n、\r\n 以及别的行结束符。读文本文件的时候比 split("\n") 稳。
python
>>> "a\nb\r\nc".splitlines()
['a', 'b', 'c']
join 是 split 的反向动作。
python
>>> "-".join(["2026", "10", "07"])
'2026-10-07'
>>> "".join(["a", "b", "c"])
'abc'
有个点必须提醒,join 要求列表里全是字符串。混进数字就得手写转换,不然直接报错。
python
>>> "-".join([1, 2, 3])
TypeError: sequence item 0: expected str instance, int found
>>> "-".join(str(x) for x in [1, 2, 3])
'1-2-3'
第二种写法用生成器逐个转换,是常用的临时处理手段。
partition 返回一个三元组,分隔符左边、分隔符、分隔符右边。它对那些找不到分隔符的输入也很友好,不会报错,右边两个位置返回空串。
python
>>> "name=张三".partition("=")
('name', '=', '张三')
>>> "no_separator".partition("=")
('no_separator', '', '')
三个一起赋值,一眼能看懂要干什么。处理配置文件的时候比 split 干净。
python
>>> key, sep, value = "timeout=30".partition("=")
>>> key
'timeout'
>>> value
'30'
1.9 判断开头结尾
startswith 和 endswith 处理文件过滤、协议判断这类场景。
python
>>> "report.pdf".endswith(".pdf")
True
>>> "https://example.com".startswith("https")
True
它们还有个隐藏本事,传一个元组进去可以一次比多个。
python
>>> files = ["a.py", "b.txt", "c.py", "d.md"]
>>> [f for f in files if f.endswith((".py", ".md"))]
['a.py', 'c.py', 'd.md']
这写法比连着写三个 or 条件要优雅得多。注意参数是元组,写成列表会报错。
1.10 格式化,三种写法
这是很多人一边写一边查的部分。Python 攒了三套方案,各自的脾气不一样。
第一套,f-string。3.6 之后的主推写法。
python
>>> name = "张三"
>>> age = 20
>>> f"我叫{name},今年{age}岁"
'我叫张三,今年20岁'
变量直接写在大括号里,{ 和 } 会被当成占位符处理。可读性最好,我日常九成的场景用这个。
它还能在大括号里做运算、调方法。
python
>>> price, qty = 32.5, 3
>>> f"小计 {price * qty}"
'小计 97.5'
>>> f"{name.upper()} 你好"
'张三 你好'
数字格式的写法统一走 : 后面。
python
>>> f"{price:.2f}"
'32.50'
>>> f"{price:>8.2f}|"
' 32.50|'
>>> f"{0.2564:.2%}"
'25.64%'
>>> f"{1234567:,}"
'1,234,567'
>>> f"{42:0>5}"
'00042'
>>> f"{255:#x}"
'0xff'
>>> f"{255:b}"
'11111111'
这些格式符是通用的,能同时用在 f-string、format 和 % 上。
第二套,str.format。
python
>>> "{} 说 {}".format("张三", "你好")
'张三 说 你好'
>>> "{0} 的 {1},{1} 的 {0}".format("A", "B")
'A 的 B,B 的 A'
>>> "{name} 今年 {age}".format(name="李四", age=20)
'李四 今年 20'
带下标的写法能复用同一个参数,像 {1} 用两次这种,f-string 里做不到。 backed 一个好处是模板可以先定义好,到运行时再填值。
第三套,% 格式化。从 C 语言继承来的老家伙。
python
>>> "%s 今年 %d 岁" % ("张三", 20)
'张三 今年 20 岁'
>>> "%-8s|%8s|" % ("abc", "def")
'abc | def|'
老代码里到处都是这个写法,看懂就行,新写的建议用 f-string。
三套并存,总得有个取舍。绝大多数情况我用 f-string,简单直观。模板需要复用、或者要拖到运行时再填值的时候用 format。维护老项目就顺着原来的 % 写。有一条别破,同一个文件里别混三种,混起来读着难受。
有个库值得提一句,logging 模块内部用的就是 % 风格。所以写日志的时候别用 f-string,让 logger 自己去延迟渲染。
python
>>> import logging
>>> logging.warning("用户 %s 登录失败", "张三")
WARNING:root:用户 张三 登录失败
写成 logging.warning(f"用户 {name} 登录失败") 的话,即使日志级别被关掉,字符串也已经拼好了,白花力气。
1.11 字符串方法速查表
这一节的方法名实在太多,我整理成表,用到的时候翻一下。
| 类别 | 方法 | 说明 |
|---|---|---|
| 查找 | s.find(x) |
返回下标,找不到返回 -1 |
| 查找 | s.rfind(x) |
从右边找 |
| 查找 | s.index(x) |
返回下标,找不到抛异常 |
| 查找 | s.count(x) |
出现次数 |
| 查找 | x in s |
是否存在,推荐写法 |
| 替换 | s.replace(a, b) |
替换,第三个参数限制次数 |
| 替换 | s.removeprefix(p) |
去掉前缀,3.9+ |
| 替换 | s.removesuffix(p) |
去掉后缀,3.9+ |
| 大小写 | s.upper() |
全大写 |
| 大小写 | s.lower() |
全小写 |
| 大小写 | s.title() |
每个单词首字母大写 |
| 大小写 | s.capitalize() |
首单词首字母大写 |
| 大小写 | s.swapcase() |
大小写互换 |
| 大小写 | s.casefold() |
激进小写,比较前用 |
| 空白 | s.strip() |
去两头空白 |
| 空白 | s.lstrip() |
去左边 |
| 空白 | s.rstrip() |
去右边 |
| 对齐 | s.center(n, c) |
居中填充 |
| 对齐 | s.ljust(n, c) |
左对齐填充 |
| 对齐 | s.rjust(n, c) |
右对齐填充 |
| 对齐 | s.zfill(n) |
左边补零 |
| 分割 | s.split(sep) |
切分,第二个参数限次数 |
| 分割 | s.rsplit(sep, n) |
从右边切 |
| 分割 | s.splitlines() |
按行切,兼容各种换行符 |
| 分割 | s.partition(sep) |
切成三元组 |
| 合并 | sep.join(list) |
反向操作,注意调用方向 |
| 判断 | s.startswith(x) |
开头判断,支持元组 |
| 判断 | s.endswith(x) |
结尾判断,支持元组 |
| 判断 | s.isdigit() |
是否纯数字 |
| 判断 | s.isalpha() |
是否纯字母 |
| 判断 | s.isalnum() |
字母或数字 |
| 判断 | s.isspace() |
是否纯空白 |
| 格式化 | f"{x}" |
首选方案 |
| 格式化 | "{}".format(x) |
可复用下标 |
| 格式化 | "%s" % x |
老写法,兼容用 |
这张表看着吓人,实际高频的就十来个。split、join、strip、replace、find、f-string,这几个撑起了八成的日常使用。剩下的方法等真用上了再回来翻。
1.12 字符串的坑,四个
坑一,拿 str 当 list 用。 字符串没有 append,也不能按下标改值。
python
>>> s = "Python"
>>> s[0] = "J"
TypeError: 'str' object does not support item assignment
要改就得重建,最方便的做法是切片拼起来。
python
>>> s = "Python"
>>> "J" + s[1:]
'Jython'
坑二,修改类方法的返回值被丢掉。 这个前面提过,值得再说一遍。replace、strip、upper、lower 全都是返回新字符串,原字符串岿然不动。写这一类调用的时候,记得左边得有东西接着。
坑三,split 出空串。 字符串末尾正好是分隔符的时候,切出来的最后一个元素是空串。
python
>>> "a,b,c,".split(",")
['a', 'b', 'c', '']
处理外部数据时这条容易让人出错。稳妥的做法是先 strip 掉首尾的分隔符,或者用列表推导式过滤掉空值。
python
>>> [x for x in "a,b,c,".split(",") if x]
['a', 'b', 'c']
坑四,None 混进 join。 我在处理数据库查询结果的时候撞过好几次。
python
>>> ", ".join(["a", None, "b"])
TypeError: sequence item 1: expected str instance, NoneType found
数据库里的 NULL 读出来就是 None,直接塞进 join 会炸。稳妥写法是先转一遍,或者在取数的时候用 COALESCE 给默认值。
python
>>> ", ".join(str(x) for x in ["a", None, "b"] if x is not None)
'a, b'
2. 字符串编码转换
这一节我看书的时候跳过过一次,后来付出了代价。它不直观,但决定了你能不能把中文处理好。
先把三层概念分开。理清了后面就都顺了。
2.1 字符、码点、字节
scss
人类看到的 计算机存的 硬盘上落的
┌──────┐ ┌────────┐ ┌──────────┐
│ 中 │ 对应 │ U+4E2D │ 编码 │ E4 B8 AD │
│ (字) │ ──────→ │ (码点) │ ────→ │ (字节序列)│
└──────┘ └────────┘ └──────────┘
字符 给一个编号 按规则存成 01
Character Code Point Bytes
字符 是你屏幕上看到的那个方块。码点 是 Unicode 给每个字符分配的编号,全世界统一。字节是最终存在磁盘或者网络上传输的二进制数据。
编码干的事情就是把码点按某套规则转成字节,解码就是反过来。
同一个字符在不同编码规则下转出来的字节是不一样的。
python
>>> "中".encode("utf-8")
b'\xe4\xb8\xad'
>>> "中".encode("gbk")
b'\xd6\xd0'
>>> len("中".encode("utf-8"))
3
>>> len("中".encode("gbk"))
2
同一个「中」字,UTF-8 用三个字节,GBK 用两个。这就是乱码的根源,你可以往后面看。
2.2 ord 和 chr,两个方向
ord 把字符变成码点,chr 把码点变回字符。这一对是最直观的感受码点的方式。
python
>>> ord("A")
65
>>> ord("中")
20013
>>> ord("😀")
128512
>>> chr(65)
'A'
>>> chr(20013)
'中'
>>> chr(128512)
'😀'
注意 ord 一次只能接一个字符,传多了报错。
python
>>> ord("中国")
TypeError: ord() expected a character, but string of length 2 found
码点在 Python 里就写成 \u 后面跟四个十六进制位,超出的部分用 \U 加八位。
python
>>> "\u4e2d"
'中'
>>> "\u0041"
'A'
>>> print("\u4e2d\u6587")
中文
写配置文件或者数据源不支持 Unicode 的时候,这种转义串很常见。从别处拿到的 \u4e2d\u6587 这样的文本,可以用 unicode_escape 解回来。
python
>>> r"\u4f60\u597d"
'\\u4f60\\u597d'
>>> r"\u4f60\u597d".encode().decode("unicode_escape")
'你好'
2.3 encode 和 decode
这是本节唯一的两个动词,方向搞反了就等着报错。
python
字符串 str ──encode──→ 字节 bytes ──decode──→ 字符串 str
"你好" b'\xe4...' "你好"
encode 的参数指定编码规则,返回一个 bytes 对象。
python
>>> "你好".encode("utf-8")
b'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> type("你好".encode("utf-8"))
<class 'bytes'>
decode 把字节变回字符串,同样要指定规则。
python
>>> b'\xe4\xbd\xa0\xe5\xa5\xbd'.decode("utf-8")
'你好'
两边用同一套规则,往返无损。
python
>>> s = " café 中文 "
>>> s.encode("utf-8").decode("utf-8") == s
True
用错了规则,要么直接报错,要么出来一堆看不懂的符号。这就是乱碍。
python
>>> "中文".encode("gbk").decode("utf-8")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd6 in position 0: invalid continuation byte
上面这种还算好的,至少报错了。有时候用 gbk 去解 UTF-8 的字节,能解出来,结果是一串完全不相干的汉字,这才是真正难查的那些 bug 来源。
python
>>> "中文".encode("utf-8").decode("gbk", errors="replace")
'涓�鏂�'
>>> print("中文".encode("utf-8").decode("gbk", errors="replace"))
涓�鏂�
注意这里我传了 errors="replace",不然还是报错。加了这个参数,解不出来的部分会被替换成那个菱形问号 U+FFFD,程序不崩,但内容已经错了。这种能解却解错的场景,比直接报错难查得多。
errors 有几个常用取值。
| 取值 | 行为 | 场景 |
|---|---|---|
strict |
默认,遇到异常直接抛错 | 要求数据干净的内部处理 |
ignore |
悄悄跳过异常部分 | 不讲究的清洗,可能丢数据 |
replace |
用 ? 或 U+FFFD 占位 |
日志、展示用,能保住行数 |
backslashreplace |
转成 \xe4 这样的转义串 |
排查问题,保住原始字节信息 |
我排查乱码的时候习惯用 backslashreplace,它能把坏掉的字节原样暴露出来,一眼能看出问题在哪一段。
python
>>> "中文".encode("utf-8").decode("ascii", errors="backslashreplace")
'\\xe4\\xb8\\xad\\xe6\\x96\\x87'
排错的时候我常用这个组合,把字节原样摊开。看到 \xe4\xb8\xad 就能确认,这是 UTF-8 的「中」字,被当别的编码去解了。用 GBK 去解同一段字节的话会「半成功」,出来两个汉字夹着两个转义串,反而更迷惑。
python
>>> "中文".encode("utf-8").decode("gbk", errors="backslashreplace")
'涓\\xad鏂\\x87'
2.4 常见编码一览
| 编码 | 一个汉字 | 覆盖范围 | 现状 |
|---|---|---|---|
| ASCII | 不支持 | 英文字母数字标点 | 最早的,128 个字符 |
| GBK | 2 字节 | 汉字加常见符号 | 中文 Windows 默认,老系统的历史包袱 |
| GB18030 | 2 或 4 字节 | 汉字加少数民族文字 | GBK 的超集,国标要求 |
| UTF-8 | 3 或 4 字节 | 全世界所有字符 | 现在的通用选择 |
| UTF-16 | 2 或 4 字节 | 全世界所有字符 | Java 和 Windows API 内部使用 |
一个比较对象是 ASCII 编码。
python
>>> "Python".encode("ascii")
b'Python'
>>> "中文".encode("ascii")
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
它只认那 128 个字符,遇到中文就崩。这也是为什么 Python 2 年代,处理中文得在文件头写 # -*- coding: utf-8 -*-,第一章讲过这事。Python 3 默认就是 UTF-8,这一行声明已经没必要了。
UTF-8 的设计很聪明,它是变长的。英文一个字节,跟 ASCII 完全兼容,所以纯英文内容用 UTF-8 存不占额外空间。中文三个字节,常见 emoji 四个字节。
python
>>> len("abc".encode("utf-8"))
3
>>> len("中文".encode("utf-8"))
6
>>> len("😀".encode("utf-8"))
4
这种兼容英语又支持全世界文字的设计,是 UTF-8 成为通用标准的重要原因。
2.5 乱码是怎么产生的
画个图就清楚了。
bash
发送方 接收方
"你好"
│
├─ 用 UTF-8 编码
│ b'\xe4\xbd\xa0\xe5\xa5\xbd'
│
└─────── 网络/文件传输 ───────┐
│
├─ 用 GBK 解码 ✗
│ '浣犲ソ'
│
├─ 用 UTF-8 解码 ✓
│ '你好'
字节本身没有错,错的是解码时用的那套规则。文件或者网络传过来的是字节流,它自己不会声明自己是什么编码,得 receiver 猜或者约定。
所以处理外部数据有一条我自己在守的规矩,拿到字节的时候立刻用正确的编码 decode 成字符串,之后在程序里只在字符串层面打交道,输出的时候再根据目标 encode。
这条原则有个专门的说法叫 Unicode 三明治。
python
读入 ──decode──→ 内部全用 str ──encode──→ 写出
字节流 业务逻辑 字节流
中间的业务处理只用 str,不管编码。边界处才转换。这样的代码,编码问题基本就不存在了。
2.6 str 和 bytes 的分工
Python 3 把这两者分得很清,不像 Python 2 里那样可以随便混。
python
>>> type("你好")
<class 'str'>
>>> type(b"hello")
<class 'bytes'>
b 前缀的字面量就是字节串。它跟字符串是不能直接相加的。
python
>>> "hello" + b"world"
TypeError: can only concatenate str (not "bytes") to str
这个报错看着烦人,实际是在保护你。Python 2 允许这种混淆操作,代价是无数隐蔽的 bug。
bytes 对象的成员是整数,不是字符。
python
>>> b = b"ABC"
>>> b[0]
65
>>> list(b"ABC")
[65, 66, 67]
取出来是 ASCII 码值。要拿到字符,得再解一层。
python
>>> chr(b"ABC"[0])
'A'
两者都有 upper、split、replace 这些方法,但处理的单位不一样。字节串按字节处理,字符串按字符处理,别混着用。
2.7 文件读写的编码
日常最常见的编码痛点就在文件读写。open 有个 encoding 参数,默认跟着操作系统走。所以同一份代码在不同系统上跑,可能一台正常一台乱码。
python
# 先造个测试文件,好把例子跑起来
with open("data.txt", "w", encoding="utf-8") as f:
f.write("中文内容")
# 不推荐,依赖系统默认编码
with open("data.txt") as f:
content = f.read()
# 推荐,明确写出来
with open("data.txt", encoding="utf-8") as f:
content = f.read()
print(content)
我的习惯是永远显式写 encoding="utf-8"。多敲二十个字符,省掉后期排查的一整天。
Windows 上尤其要注意,中文系统的默认编码是 GBK。用 UTF-8 存的配置文件在 Python 里读出来乱码,多半就是没写这个参数。
python
# 写文件同样要指定
with open("out.txt", "w", encoding="utf-8") as f:
f.write("中文内容")
不写的话,Windows 上默认存成 GBK,Mac 和 Linux 上打开就乱码了。跨平台协作时这条一定会咬人。
2.8 几种常见的编码转换场景
Base64。 把任意二进制数据转成纯 ASCII 字符,方便在只能传文本的地方传二进制。图片的内嵌 data URI、某些接口的参数,用的都是它。
python
>>> import base64
>>> base64.b64encode("张三".encode("utf-8"))
b'5byg5LiJ'
>>> base64.b64decode("5byg5LiJ").decode("utf-8")
'张三'
注意 Base64 处理的是字节。先 encode 成字节再编码,解码之后也得再 decode 回字符串。
python
>>> base64.b64encode("张三")
TypeError: a bytes-like object is required, not 'str'
直接把字符串传给它,Python 会提醒你缺了一步。
URL 编码。 中文和特殊符号不能直接出现在 URL 里,得转成 %XX 的形式。
python
>>> import urllib.parse
>>> urllib.parse.quote("张三 Python")
'%E5%BC%A0%E4%B8%89%20Python'
>>> urllib.parse.unquote("%E5%BC%A0%E4%B8%89%20Python")
'张三 Python'
空格变成了 %20,中文每个字节一个 %XX。拼 URL 参数的时候记得走这个转换,手写容易漏。
JSON 里的中文。 json.dumps 默认会把中文转义成 \uXXXX,这是为了兼容性考虑。
python
>>> import json
>>> json.dumps({"name": "张三"})
'{"name": "\\u5f20\\u4e09"}'
>>> json.dumps({"name": "张三"}, ensure_ascii=False)
'{"name": "张三"}'
要写给人看的配置文件或者输出日志,加 ensure_ascii=False 可读性更好。要传给别的系统,保持默认更安全。
写文件时别忘了配合 encoding。
python
import json
data = {"name": "张三", "city": "北京"}
with open("data.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
print(open("data.json", encoding="utf-8").read())
这两个参数经常成对出现,我写 Python 这么久,这行代码敲过不下几百遍。
2.9 编码排错三板斧
碰到乱码不要慌,按这三步走基本都能定位。
第一步,确认它现在是什么类型。 假设出问题的对象叫 data。
python
>>> type(data)
<class 'bytes'>
如果是 bytes,说明还没解码或者解码错了。如果是 str,说明已经解过一轮,可能那一轮就错了。
第二步,把原始字节打出来看。
python
>>> print(repr(data))
b'\xe4\xb8\xad\xe6\x96\x87'
>>> data.hex()
'e4b8ade69687'
看十六进制比看乱码有用得多。UTF-8 的中文是三个字节一组,\xe4 开头的基本就是 UTF-8。GBK 是两个字节一组。
第三步,逐个尝试候选编码。
python
import re
data = b'\xe4\xb8\xad\xe6\x96\x87'
for enc in ["utf-8", "gbk", "gb18030", "big5"]:
try:
print(enc, "→", data.decode(enc))
except UnicodeDecodeError:
print(enc, "→ 解不动")
这个土办法在处理老数据时特别好使。试完一轮就能确定源文件到底用的什么编码。
3. 正则表达式
一堆符号挤在一起,第一眼像外星文。我第一次见是在别人的代码里,当时的反应是把这文件关了。
啃下来之后会发现值。它解决的那类问题,换成普通字符串方法写要几十行,还容易漏情况。
先看它能干什么。一段文本,把里面所有手机号找出来。
text
我的电话是 138-0013-8000,备用号码 13912345678,座机不用找
用字符串方法写,得判断位置、判断长度、判断每一位是不是数字,还得处理中间有没有横线。写成正则只要一行。
python
>>> import re
>>> text = "我的电话是 138-0013-8000,备用号码 13912345678"
>>> re.findall(r"1[3-9]\d-?\d{4}-?\d{4}", text)
['138-0013-8000', '13912345678']
1[3-9]\d-?\d{4}-?\d{4} 这一串就是正则表达式,它描述的是「以 1 开头,第二位是 3 到 9,第三位是任意数字,可能有一个横线,然后四位数字,可能再一个横线,再四位数字」这样一个模式。
所谓正则表达式,本质是一套用来描述字符串模式的小语言。普通字符串方法处理的是确定的字符串,正则处理的是一类符合某种规则的字符串。
3.1 先认识元字符
元字符是正则里那些有特殊含义的符号。它们是这套语言的地基。
| 元字符 | 含义 | 例子 | 能匹配 |
|---|---|---|---|
. |
任意一个字符,不含换行 | a.c |
abc a$c a c |
\d |
一个数字,等同 [0-9] |
\d\d |
42 07 |
\D |
一个非数字 | \D |
a - |
\w |
字母数字下划线 | \w+ |
abc a_1 |
\W |
非字母数字下划线 | \W |
- 空格 |
\s |
空白字符,含空格制表换行 | a\sb |
a b a\tb |
\S |
非空白字符 | \S+ |
hello |
^ |
行的开头 | ^abc |
行首的 abc |
$ |
行的结尾 | abc$ |
行尾的 abc |
\b |
单词边界 | \bcat\b |
cat,但 category 里的不算 |
[] |
字符集合,里面选一个 | [aeiou] |
任意一个元音字母 |
| ` | ` | 或者 | `cat |
() |
分组 | (ab)+ |
ab abab |
. 这个小点是最容易被误用的。它匹配的是任意一个字符,所以 a.c 能匹配 abc 也能匹配 a c。要匹配真正的句点,得转义写成 \.。
python
>>> re.findall(r"a.c", "abc a c a$c axc a\nc")
['abc', 'a c', 'a$c', 'axc']
>>> re.findall(r"a\.c", "abc a.c")
['a.c']
第一行里 a\nc 没能匹配,因为默认模式下 . 不包含换行符。加上 re.S 参数就能跨行,这在第四节讲标志的时候会再提。
3.2 字符类,方括号的四种写法
方括号表示从这个集合里任选一个字符。它有四种常见形态。
第一种,列举。 把所有候选写进去。
python
>>> re.findall(r"[aeiou]", "hello world")
['e', 'o', 'o']
第二种,范围。 用横线表示区间。
python
>>> re.findall(r"[a-z]+", "Hello World 2026")
['ello', 'orld']
>>> re.findall(r"[0-9]+", "订单 20261007")
['20261007']
>>> re.findall(r"[a-zA-Z0-9_]+", "user_name42 = 100")
['user_name42', '100']
注意 [a-z] 只匹配小写。[A-Za-z] 才同时接受大小写。这个坑很多人踩过,写了 [a-z]+ 然后发现大写的单词匹配不上。
第三种,取反。 方括号里第一个字符是 ^ 的时候,表示不要这些字符。
python
>>> re.findall(r"[^0-9]+", "订单2026号1007")
['订单', '号']
第四种,混合。 列举、范围、取反可以混着写。
python
>>> re.findall(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+", "me@example.com 或 x_y@sub.test.cn")
['me@example', 'x_y@sub']
这个飞天的结果说明了一件事,字符类 [a-zA-Z0-9_.+-] 里虽然有句点,但它代表「单个句点字符」而不是「任意字符」。域名部分的 [a-zA-Z0-9-]+ 匹配到 example 就停了,因为后面跟着的是句点,不在它的集合里。完整的邮箱正则要显式写出 \. 的部分,3.9 节有成品,这里只是演示字符类的写法。
匹配中文也有两种方式。可以用 Unicode 范围,也可以用「非 ASCII」这个思路。
python
>>> re.findall(r"[\u4e00-\u9fa5]+", "abc中文def")
['中文']
>>> re.findall(r"[^\x00-\x7f]+", "abc中文def,。")
['中文', ',。']
\u4e00-\u9fa5 是常用汉字的 Unicode 范围,\x00-\x7f 是所有 ASCII 字符。第二种写法更简单粗暴,把所有非 ASCII 字符都抓出来,包含中文标点。
同样要提醒一点,\w 在 Python 3 默认模式里是包含中文的。
python
>>> re.findall(r"\w+", "中文 abc_123")
['中文', 'abc_123']
这个设计挺贴心,处理中文文本的时候不用额外写 [\u4e00-\u9fa5]。但也意味着如果你想严格限制成英文单词,得明确写 [a-zA-Z]。
3.3 数量词,一个 but多个
数量词跟在字符或者分组后面,表示它重复几次。
| 写法 | 含义 | 相当于 |
|---|---|---|
* |
零次或多次 | {0,} |
+ |
一次或多次 | {1,} |
? |
零次或一次 | {0,1} |
{n} |
恰好 n 次 | |
{n,} |
至少 n 次 | |
{n,m} |
n 到 m 次 |
python
>>> re.findall(r"ab*c", "ac abc abbc abbbc")
['ac', 'abc', 'abbc', 'abbbc']
>>> re.findall(r"ab+c", "ac abc abbc")
['abc', 'abbc']
>>> re.findall(r"ab?c", "ac abc abbc")
['ac', 'abc']
* 和 + 的区别在乎 at least 一次。ab*c 能匹配 ac,因为 b 可以出现零次。ab+c 至少要有一个 b,所以 ac 被排除了。
这一条在验证用户输入的时候特别关键。想要求电话号码必填,就不能用 *,否则空字符串也能通过。
python
>>> bool(re.fullmatch(r"\d*", ""))
True
>>> bool(re.fullmatch(r"\d+", ""))
False
{n,m} 这种写死范围的用法,处理固定格式的数据时很顺手。
python
>>> re.findall(r"\d{4}-\d{2}-\d{2}", "日期 2026-10-07 和 2026-1-7")
['2026-10-07']
这里 2026-1-7 没被匹配,因为 \d{2} 要求恰好两位。{2} 是不讲情面的,少一个多一个都不行。
3.4 分组与捕获
圆括号有两个作用,把一段 pattern 当整体对待,以及把匹配到的内容单独取出来。
当整体。 让数量词作用在整组上。
python
>>> re.findall(r"(ab)+", "ab abab ababab")
['ab', 'ab', 'ab']
>>> re.findall(r"ab+", "ab abab ababab")
['ab', 'ab', 'ab', 'ab', 'ab', 'ab']
第一行定义了捕获组,(ab)+ 整体能吞掉 ababab,但 findall 在有捕获组的时候只返回组的内容,最后一次循环捕获到的是 ab。第二行没有组,返回的是每个完整匹配,ab+ 吃不掉连续的 ab,所以 ababab 被切成三段。
这是 findall 的一个脾气,文档里写了但容易被忽略。不需要取值的分组一律写 (?:...),能绕开这个陷阱。
取值。 用 groups() 拿到每个捕获组匹配到的内容。
python
>>> m = re.search(r"(\d{4})-(\d{2})-(\d{2})", "今天是 2026-10-07")
>>> m.groups()
('2026', '10', '07')
>>> m.group(1)
'2026'
>>> m.group(2, 3)
('10', '07')
group(0) 或者不带参数的 group() 返回整个匹配,group(1) 往后才是各个组。
日期重排这类需求用它特别方便。
python
>>> re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "2026-10-07")
'07/10/2026'
替换串里的 \1 \2 \3 分别引用第一、二、三个捕获组。这串写法让「把 2026-10-07 变成 07/10/2026」这样的事情一行搞定。
命名组。 组多了以后,\3 这种编号会看花眼。给组起个名字更清楚。
python
>>> m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", "2026-10-07")
>>> m.groupdict()
{'year': '2026', 'month': '10', 'day': '07'}
>>> m.group("year")
'2026'
>>> m["month"]
'10'
(P<名字>...) 是它的语法,groupdict() 一次性全取出来变成字典。处理结构化日期、日志行这种有多个字段的数据时,命名组能让代码可读性上一个台阶。
非捕获组。 只想分组不想取值的时候,用 (?:...)。
python
>>> re.findall(r"(ab)+", "ababab")
['ab']
>>> re.findall(r"(?:ab)+", "ababab")
['ababab']
两行的区别很说明问题,有捕获组时 findall 返回组的内容,用非捕获组则返回整个匹配。
我自己的习惯是,不需要取值的分组一律写 (?:...)。这样既避免了误会,性能上也略有好处。
3.5 锚点与边界
锚点匹配的是位置,不是字符。
| 锚点 | 含义 |
|---|---|
^ |
字符串开头,加了 re.M 之后是每行开头 |
$ |
字符串结尾,加了 re.M 之后是每行结尾 |
\b |
单词边界 |
\B |
非单词边界 |
\A |
字符串绝对开头,不受 re.M 影响 |
\Z |
字符串绝对结尾,不受 re.M 影响 |
^ 和 $ 在验证输入的时候几乎是必配的。
python
>>> bool(re.match(r"\d{6}", "100000"))
True
>>> bool(re.match(r"\d{6}", "100000abc"))
True
>>> bool(re.match(r"\d{6}$", "100000abc"))
False
第二行的问题看得出吧。match 只要求从开头匹配上就行,后面多出来什么它不管。加上 $ 才能锁死结尾。
做输入校验的时候,我习惯用 fullmatch 或者两端都加锚点,二选一,绝不留一头。
python
>>> bool(re.fullmatch(r"\d{6}", "100000abc"))
False
>>> bool(re.fullmatch(r"\d{6}", "100000"))
True
\b 匹配单词和非单词字符的交界处。
python
>>> re.findall(r"\bcat\b", "cat category concat")
['cat']
>>> re.findall(r"cat\b", "cat category concat")
['cat', 'cat']
第一条只取到了独立成词的那个 cat,category 和 concat 里的都不算。第二条放宽到只要结尾是单词边界,所以 concat 里的也匹配上了。
处理英文文本的时候差别很大,中文因为词汇之间没有空格,\b 基本派不上用场。
3.6 贪婪与惰性
这是正则里最重要的一个概念,理解它是从会用到精通的分水岭。
数量词默认贪婪,它会尽可能多地吃字符。
python
>>> re.findall(r"<.*>", "<a><b>")
['<a><b>']
<.*> 想要的是尖括号里的内容,但它从第一个 < 一路吃到了最后一个 >。因为 . 匹配任意字符,* 又想多吃,两者配合就贪过头了。
在数量词后面加一个 ?,把它变成惰性,也就是尽可能少地吃。
python
>>> re.findall(r"<.*?>", "<a><b>")
['<a>', '<b>']
结果变成我们想要的两个标签。每碰到第一个 >,它就停下来报告一次结果。
一样的道理体现在 \d+ 上。
python
>>> re.match(r"\d+", "123abc").group()
'123'
>>> re.match(r"\d+?", "123abc").group()
'1'
\d+ 吃掉全部三个数字,加问号之后只吃一个就收手。
三种数量词都有惰性版本。
| 贪婪 | 惰性 | 含义 |
|---|---|---|
* |
*? |
零次或多次,尽可能少 |
+ |
+? |
一次或多次,尽可能少 |
? |
?? |
零次或一次,尽可能少 |
{n,m} |
{n,m}? |
n 到 m 次,尽可能少 |
处理 HTML、引号里的文本、多行模板这类有明确起始和结束标记的内容,惰性匹配几乎总是正确的选择。
python
>>> re.findall(r'"(.*?)"', '他说 "你好",然后说 "再见"')
['你好', '再见']
改成贪婪版本的话,会从第一个引号一路吃到最后一个引号,中间那些全当成内容。
3.7 零宽断言
断言匹配的是条件,不消耗字符。听起来有点绕,看例子就明白了。
| 写法 | 名称 | 含义 |
|---|---|---|
(?=...) |
前瞻 | 后面得是这个 |
(?!...) |
负前瞻 | 后面不能是这个 |
(?<=...) |
后顾 | 前面得是这个 |
(?<!...) |
负后顾 | 前面不能是这个 |
提取后面紧跟着「元」的那些数字。
python
>>> re.findall(r"\d+(?=元)", "3元 5角 10元")
['3', '10']
\d+(?=元) 的意思是「一串数字,并且它后面得是元字」。那个「元」字参与了判断,但不出现在结果里,这就是「零宽」的意思。
反过来,不要后面跟着元的。
python
>>> re.findall(r"\d+(?!元)", "3元 5角 10元")
['5', '1']
结果里那个 1 可能会让人意外。它是 10 的十位数字,1 后面跟着 0 而不是「元」,所以也通过了检查。这类局部匹配是 (?!...) 的经典陷阱,用的时候要确认自己要的到底是整数还是每一位。
后顾用来限定前面的内容。
python
>>> re.findall(r"(?<=¥)\d+", "¥99 元 88")
['99']
只要货币符号后面的那串数字,另一个数字不被匹配。
密码强度校验是它的经典用途。「必须包含数字、必须包含字母」这种要求,用一条长正则写起来会很难看,拆成几条断言就清爽了。
python
import re
def check_password(pwd):
checks = [
(r".{8,}", "至少 8 位"),
(r"(?=.*[a-z])", "要有小写字母"),
(r"(?=.*[A-Z])", "要有大写字母"),
(r"(?=.*\d)", "要有数字"),
]
for pattern, reason in checks:
if not re.search(pattern, pwd):
return False, reason
return True, "强度合格"
print(check_password("Abc12345"))
print(check_password("abc12345"))
跑出来是这样。
text
(True, '强度合格')
(False, '要有大写字母')
每条断言独立检查一个条件,不满足就返回具体原因。比写一条二十几个字符的长正则,然后一个个 group 去判断舒服得多。
3.8 转义,以及为什么要用原始字符串
正则本身就一堆反斜杠,Python 的字符串里反斜杠又是转义符,两者叠在一起容易打架。
python
>>> pattern = "\\d+" # 普通字符串,\\ 表示一个反斜杠
>>> r"\d+" # 原始字符串,直接就是 \ 加 d
'\\d+'
>>> len("\\d+"), len(r"\d+")
(3, 3)
>>> re.match("\\d+", "123") is not None
True
>>> re.match(r"\d+", "123") is not None
True
\d 在 Python 普通字符串里不是合法转义序列,恰好被保留成了原样,所以两种写法都能跑。但像 \b 这种,\b 在 Python 字符串里是退格符,写普通字符串就读错了。
python
>>> len("\b"), len(r"\b")
(1, 2)
>>> "\bword\b"
'\x08word\x08'
>>> len("\bword\b")
6
\b 被解释成了退格符(ASCII 0x08),长度变成 1,整个串只剩六个字符,正则语法完全错乱。
所以写正则一律加 r 前缀,这是行业共识,也是我的硬性习惯。
python
pattern = r"\b\w+\b" # 推荐
pattern = "\\b\\w+\\b" # 能跑,但没必要折磨自己
另一个相关的函数是 re.escape。当你需要把用户输入的内容当成普通文本去搜索时,用它来自动转义所有特殊字符。
python
>>> re.escape("a.b*c")
'a\\.b\\*c'
>>> re.search(re.escape("file.txt"), "path/to/file.txt") is not None
True
>>> re.search("file.txt", "path/to/fileXtxt") is not None
True
最后一行说明了为什么需要转义。不加转义的时候,. 被当成任意字符,fileXtxt 也被匹配上了。这是处理用户搜索词时必须注意的一条,不转义的话就等于允许用户输入正则元字符。
3.9 常用正则速查
攒了一组我实际用过很多次的模式,直接拿去用或者改一改都行。
| 用途 | 正则表达式 |
|---|---|
| 手机号 | ^1[3-9]\d{9}$ |
| 邮箱 | ^[\w.+-]+@[\w-]+(\.[\w-]+)+$ |
| 邮政编码 | ^\d{6}$ |
| 日期 YYYY-MM-DD | ^\d{4}-\d{2}-\d{2}$ |
| IPv4 地址 | ^(\d{1,3}\.){3}\d{1,3}$ |
| 身份证 18 位 | ^\d{17}[\dXx]$ |
| 中文字符 | [\u4e00-\u9fa5] |
| 双引号内内容 | "(.*?)" |
| HTML 标签内容 | <(\w+)>(.*?)</\1> |
| 重复空行 | \n{3,} |
| 行首尾空白 | `^\s+ |
逐个验证一下。
python
>>> samples = {
... "手机": ("13812345678", r"^1[3-9]\d{9}$"),
... "邮箱": ("a.b@example.com", r"^[\w.+-]+@[\w-]+(\.[\w-]+)+$"),
... "邮编": ("100000", r"^\d{6}$"),
... "IP": ("192.168.1.1", r"^(\d{1,3}\.){3}\d{1,3}$"),
... "日期": ("2026-10-07", r"^\d{4}-\d{2}-\d{2}$"),
... }
>>> for name, (text, pat) in samples.items():
... print(name, bool(re.match(pat, text)))
手机 True
邮箱 True
邮编 True
IP True
日期 True
注意我用的是 match 而不是 fullmatch,但因为每条模式两端都带了 ^ 和 $,效果是一样的。
IPv4 那条严格来说只对格式负责,999.999.999.999 也能通过,因为它只检查了「一到三位数字」这个条件。要真正验证范围得拆开判断每一位。格式的活交给正则,数值范围的活交给代码,这是我一直遵循的分工。这种分工让正则保持可读,也让业务需求变化时改起来更容易。
4. 使用 re 模块实现正则表达式操作
前面讲的语法是通用的,几乎每门语言都一样。这一节讲的则是 Python 自己的那套接口。
re 模块提供两类入口,直接调用的函数,以及预编译之后的 Pattern 对象。先讲函数。
4.1 四个查找函数,别搞混
| 函数 | 从哪开始 | 找几个 | 返回什么 |
|---|---|---|---|
match |
必须从开头 | 第一个 | Match 对象或 None |
search |
任意位置 | 第一个 | Match 对象或 None |
fullmatch |
必须整串 | 第一个 | Match 对象或 None |
findall |
任意位置 | 全部 | 字符串列表 |
finditer |
任意位置 | 全部 | Match 对象迭代器 |
差别用一段代码看得最清楚。
python
>>> import re
>>> s = "abc123def456"
>>> re.match(r"\d+", s) # 开头是 abc,匹配不上
>>> re.search(r"\d+", s) # 从位置 3 找到了
<re.Match object; span=(3, 6), match='123'>
>>> re.findall(r"\d+", s) # 全部数字串
['123', '456']
>>> re.fullmatch(r"\d+", "123")
<re.Match object; span=(0, 3), match='123'>
match 返回 None,这是新手最容易困惑的一点。它只认开头,开头不对就放弃,不会继续往后找。
我自己分工挺固定的。做输入校验 用 fullmatch,它会把整个字符串锁死,最严格。做查找提取 用 search 或者 findall。match 基本不碰,它的脾气太容易让人会错意。
python
>>> bool(re.match(r"\d{6}", "100000abc"))
True
>>> bool(re.fullmatch(r"\d{6}", "100000abc"))
False
上面第一行是个经典事故。想验证六位邮编,用户输入 100000abc 也被判成了合法。fullmatch 才是做校验的正确选择。
4.2 Match 对象能给出什么
search 和 match 找到东西的时候,返回的是一个 Match 对象,它身上带着这次匹配的全部信息。
python
>>> m = re.search(r"(\d+)-(\d+)", "区间 10-20")
>>> m.group() # 整个匹配
'10-20'
>>> m.groups() # 所有捕获组
('10', '20')
>>> m.group(1) # 第一个组
'10'
>>> m.span(1) # 第一个组的位置
(3, 5)
>>> m.start(), m.end() # 整个匹配的位置
(3, 8)
>>> m.re.pattern # 用的哪条正则
'(\\d+)-(\\d+)'
>>> m.string # 在哪串里找的
'区间 10-20'
命名组可以直接用方括号取值,写起来比 group(1) 清楚。
python
>>> m = re.search(r"(?P<start>\d+)-(?P<end>\d+)", "区间 10-20")
>>> m["start"]
'10'
>>> m["end"]
'20'
用之前有个必要的动作,先判断有没有匹配到。没匹配上调用 group 会直接报错。
python
>>> m = re.search(r"\d+", "没有数字")
>>> m.group()
AttributeError: 'NoneType' object has no attribute 'group'
这个报错我见过太多次了。稳妥写法是先 if 判空。
python
>>> m = re.search(r"\d+", "没有数字")
>>> if m:
... print(m.group())
... else:
... print("没找到")
没找到
4.3 finditer,同时要内容和位置
findall 只给内容,finditer 给的是 Match 对象迭代器,内容和位置都在。
python
>>> for m in re.finditer(r"\d+", "a1 bb22 ccc333"):
... print(m.group(), m.span())
1 (1, 2)
22 (5, 7)
333 (11, 14)
需要下标的时候就得用它。做文本高亮、替换特定位置的内容、统计每段相隔多远,都靠它。
它比 findall 省内存。findall 要把所有结果先建成列表,finditer 找到一个给一个。处理单个大文件时差别明显。
4.4 替换,sub 和 subn
sub 三个必填参数,模式、替换串、目标串。
python
>>> re.sub(r"\s+", " ", "a b c")
'a b c'
>>> re.sub(r"-", "/", "2026-10-07")
'2026/10/07'
替换串里可以用 \1 引用分组,这个前面讲过。注意替换串也要写成原始字符串,不然 \1 会被 Python 提前吃掉。
python
>>> re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", "2026-10-07")
'07/10/2026'
>>> re.sub(r"(\d{4})-(\d{2})-(\d{2})", "\3/\2/\1", "2026-10-07")
'\x03/\x02/\x01'
第二行是我故意写的错误示范。不加 r 前缀,\3 被当成八进制转义,出来一堆控制字符。前面提过的那件事,这里算是第二次现身。
subn 多返回一个替换次数。
python
>>> re.subn(r"\d", "#", "a1b2c3")
('a#b#c#', 3)
想知道到底替换了几处的时候用它,比自己再去数一遍方便。
替换值还可以是个函数,这是 sub 最灵活的地方。
python
>>> def double(m):
... return str(int(m.group()) * 2)
>>> re.sub(r"\d+", double, "a1 b22")
'a2 b44'
函数接到的是 Match 对象,返回的必须是字符串。复杂的加工逻辑写在这里面就行,数值计算、查表替换、格式转换都好办。
python
>>> prices = {"苹果": 5, "香蕉": 3}
>>> re.sub(r"苹果|香蕉", lambda m: str(prices[m.group()]), "苹果和香蕉")
'5和3'
4.5 split,按模式切
re.split 比字符串的 split 强在可以按模式切,不用限定单一字符。
python
>>> re.split(r"[,;]", "a,b;c")
['a', 'b', 'c']
>>> re.split(r"\s+", "a b c")
['a', 'b', 'c']
>>> re.split(r"\d+", "a1b22c333d")
['a', 'b', 'c', 'd']
第二条很有用,按任意数量的空白切。字符串的 split() 不带参数时也有这个能力,但要指定具体分隔符的话,re.split 是唯一选择。
模式里带捕获组时,被切掉的分隔符会保留在结果里。
python
>>> re.split(r"([,;])", "a,b;c")
['a', ',', 'b', ';', 'c']
想在保留分隔符的同时知道它是什么,就靠这个特性。写分词器、简易的语法分析时会用到。
4.6 compile,什么时候该预编译
re 模块内部其实替你缓存了最近用过的模式,所以日常写脚本不预编译也没多大损失。但明确写出 compile 有两个好处。
一是能省掉重复解析的成本,尤其在循环里。
python
>>> import re, time
>>> text = "订单 SN20261007001 数量 3 金额 99.50,客户 13800138000"
>>> pattern = re.compile(r"\d+")
>>> pattern.findall(text)
['20261007001', '3', '99', '50', '13800138000']
我跑了二十万次循环对比了一下。
text
直接调 api 396.0 ms
预编译后 297.6 ms
省了约 24.9%
这两个数字不同机器差别挺大,别纠结具体值,看趋势就行。数据量大的时候省下的时间是可观的。
二是可读性。把模式集中定义在模块顶部,一眼能看出这个文件处理哪些格式。
python
import re
PHONE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
这种写法我在处理配置文件、日志解析这类场景里一直在用,改起来也只改一处。
预编译之后可以调 pattern.search(...)、pattern.findall(...),函数名跟直接调用时一样,只是少传了模式参数。
python
>>> pattern = re.compile(r"\d+")
>>> pattern.findall("a1b22")
['1', '22']
>>> pattern.subn("#", "a1b22")
('a#b#', 2)
4.7 标志,改默认行为
标志用来调整正则的匹配规则,常见的四个。
| 标志 | 全称 | 作用 |
|---|---|---|
re.I |
IGNORECASE | 忽略大小写 |
re.M |
MULTILINE | ^ $ 匹配每行首尾 |
re.S |
DOTALL | 让 . 也能匹配换行 |
re.X |
VERBOSE | 忽略模式里的空白,允许写注释 |
re.I 处理大小写混排的文本。
python
>>> re.findall(r"python", "Python PYTHON python")
['python']
>>> re.findall(r"python", "Python PYTHON python", re.I)
['Python', 'PYTHON', 'python']
re.M 是处理多行文本的利器。默认 ^ 只匹配整个字符串的开头。
python
>>> re.findall(r"^\w+", "a\nb\nc")
['a']
>>> re.findall(r"^\w+", "a\nb\nc", re.M)
['a', 'b', 'c']
逐行提取时用得上它。
re.S 让那个小点跨越换行。
python
>>> re.findall(r"a.b", "a\nb")
[]
>>> re.findall(r"a.b", "a\nb", re.S)
['a\nb']
提取 HTML 标签、多行日志块这类跨行内容,这个标志几乎是必配的。
re.X 让正则能写注释,对复杂模式的可读性提升巨大。
python
>>> pattern = re.compile(r"""
... (\d{4}) # 年
... - # 分隔符
... (\d{2}) # 月
... -
... (\d{2}) # 日
... """, re.X)
>>> pattern.search("今天是 2026-10-07").groups()
('2026', '10', '07')
换成挤在一行的 r"(\d{4})-(\d{2})-(\d{2})",意思完全一样,但半年后自己再看要多读几秒。
多个标志用竖线连起来。
python
>>> re.findall(r"^python$", "python\nPython", re.I | re.M)
['python', 'Python']
注意 re.X 模式下模式里的空格会被忽略。要表示真正的空格,得写成 \ 或者用 [ ]。这一条单独提醒一下,我自己踩过。
4.8 一个要命的性能陷阱,回溯灾难
这一节的内容看着偏门,但它能让你的服务直接卡死,值得花五分钟理解。
嵌套的数量词遇上不匹配的情况,会让引擎把所有可能的组合都试一遍。典型的写法是 (a+)+ 这种,量词套量词。
python
>>> import re, time
>>> pattern = re.compile(r"(a+)+$")
>>> pattern.match("a" * 20 + "b") is not None
False
看着人畜无害,性能表现是这个样子。
text
n=10 match=False 耗时 0.058 ms
n=14 match=False 耗时 0.727 ms
n=16 match=False 耗时 2.872 ms
n=18 match=False 耗时 11.667 ms
n=20 match=False 耗时 47.195 ms
n=22 match=False 耗时 193.826 ms
n=24 match=False 耗时 781.939 ms
n=26 match=False 耗时 3149.388 ms
每多两个字符,耗时翻四倍。二十六个字符三秒多,三十个字符就得按分钟算了。这就是所谓的灾难性回溯,行话叫 ReDoS。
触发条件是匹配失败 。字符串末尾放了个 b,导致 $ 永远满足不了,引擎只能把所有可能的切分方式试一遍。
text
输入 aaaab
第 1 种切法 (aaaa)(a) → 到了末尾发现是 b,失败
第 2 种切法 (aaa)(aa) → 失败
第 3 种切法 (aaa)(a)(a) → 失败
第 4 种切法 (aa)(aaa) → 失败
...
指数级的组合全部试完,才肯说一句"不匹配"
怎么避免,三条原则。
第一,别写嵌套量词。 (a+)+、(\w+\s?)+ 这类写法一律重写。上面那个例子本来就该写成 a+$,功能一样,复杂度变成线性。
第二,能用字符类就别用 .*。 .* 和 .+ 太能吃东西,容易跟别的部分打架。用 [^\n]*、[^"]* 这样更精确的表达,把搜索范围限制住。
python
>>> re.findall(r'"(.*?)"', '他说 "你好" 又说 "再见"')
['你好', '再见']
这个版本是安全的,因为用了惰性匹配。换成贪婪版本同样会吃过头。
第三,处理外部输入时留一手。 用户填的东西不可信。如果允许用户提交的内容参与正则匹配,加个超时或者长度上限。生产环境里我见过因为这个原因被拖垮的服务,排查起来特别费劲。
4.9 什么时候不该用正则
最后得说句不合时宜的。正则很强大,但有些场合用它反而糟糕,我自己在下面这几件事上基本不用它。
简单情况用字符串方法。 判断是不是以某个前缀开头,startswith 比 ^prefix 快得多,也清楚得多。
python
>>> "https://x.com".startswith("https") # 好
True
>>> bool(re.match(r"^https", "https://x.com")) # 多此一举
True
要解析的是 HTML 或 JSON。 千万别用正则。HTML 的嵌套结构超出了正则能描述的语法范畴,写出来的东西会在各种边界情况上崩。用 json.loads 解析 JSON,用 BeautifulSoup 或者 lxml 解析 HTML。
我见过有人用正则去剥 HTML 标签,写了三十多个字符的模式,然后发现对 <a href="x>y"> 这种写法就失效了。
模式复杂到自己都读不懂。 正则一旦超过十几个符号就该考虑拆分。用几条简单的模式分步处理,比一条长的更容易维护。re.X 就是为此准备的。
判断标准我自己的土办法是,写完这条正则,过三天还能不能看懂。看不懂就拆。
5. 实战
5.1 实战一,用户输入清洗
用户填什么进来都有可能,多空格、混入制表符、从 Excel 拷贝过来的奇怪字符。清洗是入库前的第一步。
python
# clean_input.py
import re
def clean_text(raw):
"""通用文本清洗,去首尾空白、压缩中间空白、去控制字符"""
text = raw.strip()
text = re.sub(r"[\x00-\x1f\x7f]", "", text)
text = re.sub(r"\s+", " ", text)
return text
def clean_phone(phone):
"""手机号清洗,去掉所有非数字字符"""
digits = re.sub(r"\D", "", phone)
if len(digits) == 11 and digits.startswith("1"):
return digits
if len(digits) == 13 and digits.startswith("86"):
return digits[2:]
return None
samples = [" 张 三 ", "李 四\t\n", " 王五\x0b\x0c "]
for raw in samples:
print(f"原样 {raw!r}")
print(f"清洗 {clean_text(raw)!r}")
phones = ["138 0013 8000", "138-0013-8000", "+86 13912345678", "12345"]
print()
for p in phones:
print(f"{p:<20} -> {clean_phone(p)}")
跑出来是这样。
text
原样 ' 张 三 '
清洗 '张 三'
原样 '李 四\t\n'
清洗 '李 四'
原样 ' 王五\x0b\x0c '
清洗 '王五'
138 0013 8000 -> 13800138000
138-0013-8000 -> 13800138000
+86 13912345678 -> 13912345678
12345 -> None
几个要点。clean_text 做了三件事,strip 去首尾,[\x00-\x1f\x7f] 匹配所有 ASCII 控制字符(包含制表符、垂直制表符、DEL),\s+ 把连续的空白压成一个空格。
顺序不能颠倒。先删控制字符再压缩空白,反过来会漏掉那些被 \s+ 当成普通空白处理掉的控制符。道理不复杂,控制符里有一部分本身就属于 \s 的成员,先压之后就没机会单独判断了。
clean_phone 的思路是先剥掉所有非数字,再按长度和前缀判断。\D 是 \d 的反面,代表非数字字符。处理 +86 前缀时剥完之后剩十三位,砍掉头两位正好。返回 None 表示这个号码不合法,调用方去决定怎么处理。
5.2 实战二,从文本里提取联系方式
把一段非结构化文本里的各种联系方式抠出来。
python
# extract_contacts.py
import re
doc = """
联系人信息如下
张经理 手机 13800138000 邮箱 zhang@example.com
李工 电话 021-88886666 邮箱 li.gong@sub.test.cn
客服 热线 400-123-4567
无效号码 12345678901
上传时间 2026-10-07
"""
PATTERNS = {
"手机号": r"(?<!\d)1[3-9]\d{9}(?!\d)",
"邮箱": r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+",
"座机": r"0\d{2,3}-\d{7,8}",
"客服热线": r"400-\d{3}-\d{4}",
"日期": r"\d{4}-\d{2}-\d{2}",
}
for name, pattern in PATTERNS.items():
print(name, re.findall(pattern, doc))
print()
for m in re.finditer(r"(?<!\d)1[3-9]\d{9}(?!\d)", doc):
print(f" {m.group()} 下标 {m.span()}")
输出是这样。
text
手机号 ['13800138000']
邮箱 ['zhang@example.com', 'li.gong@sub.test.cn']
座机 ['021-88886666']
客服热线 ['400-123-4567']
日期 ['2026-10-07']
13800138000 下标 (17, 28)
手机号那条我加了零宽断言 (?<!\d) 和 (?!\d)。它们的作用是限制这串数字左右不能再有别的数字。
这解决了一个实际问题。12345678901 这种十二位数字,如果不加限制,从第二位开始切出来的 2345678901 会被当成手机号。加了边界断言之后它被正确排除了,整串数字内部的每一位都被 (?<!\d) 挡住,找不到合法的起点。
另一件值得说的事,(?:\.[\w-]+)+ 这个非捕获组处理多段域名。写出来不太起眼,但它同时覆盖了 example.com 和 sub.test.cn 两种情况。换成捕获组的话 findall 只会返回最后一段,那就错了。
5.3 实战三,日志分析
运维日常最常见的活之一,从日志文件里统计状态码、找错误请求。
python
# log_analyzer.py
import re
from collections import Counter
LOG_TEXT = """192.168.1.10 - - [07/Oct/2026:09:01:12] "GET /api/user HTTP/1.1" 200 1024
192.168.1.11 - - [07/Oct/2026:09:02:33] "POST /api/order HTTP/1.1" 201 512
192.168.1.10 - - [07/Oct/2026:09:03:45] "GET /api/user HTTP/1.1" 200 1024
10.0.0.5 - - [07/Oct/2026:09:04:01] "GET /index.html HTTP/1.1" 304 0
192.168.1.12 - - [07/Oct/2026:09:05:22] "GET /api/order HTTP/1.1" 404 128
192.168.1.10 - - [07/Oct/2026:09:06:11] "DELETE /api/order HTTP/1.1" 500 256
10.0.0.5 - - [07/Oct/2026:09:07:44] "GET /index.html HTTP/1.1" 200 2048
192.168.1.11 - - [07/Oct/2026:09:08:09] "PUT /api/user HTTP/1.1" 200 768
"""
LOG_PATTERN = re.compile(
r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3})' # IP 地址
r' - - '
r'\[(?P<time>[^\]]+)\]' # 方括号里的时间
r' "(?P<method>[A-Z]+) '
r'(?P<path>[^ "]+) '
r'[^"]*"'
r' (?P<status>\d{3})'
r' (?P<size>\d+)'
)
records = []
for line in LOG_TEXT.strip().splitlines():
m = LOG_PATTERN.match(line)
if m:
records.append(m.groupdict())
else:
print("解不开的行", repr(line))
print(f"共解析 {len(records)} 条\n")
print("状态码分布")
for code, cnt in sorted(Counter(r["status"] for r in records).items()):
print(f" {code} {cnt} {'#' * cnt}")
print()
print("访问最多的 IP")
for ip, cnt in Counter(r["ip"] for r in records).most_common(3):
print(f" {ip:<16} {cnt} 次")
print()
print("接口调用排行")
for path, cnt in Counter(r["path"] for r in records).most_common():
print(f" {path:<16} {cnt} 次")
print()
print("错误请求")
for r in records:
if r["status"].startswith(("4", "5")):
print(f" {r['status']} {r['method']:<7}{r['path']} 来自 {r['ip']}")
真实输出。
text
共解析 8 条
状态码分布
200 4 ####
201 1 #
304 1 #
404 1 #
500 1 #
访问最多的 IP
192.168.1.10 3 次
192.168.1.11 2 次
10.0.0.5 2 次
接口调用排行
/api/user 3 次
/api/order 3 次
/index.html 2 次
错误请求
404 GET /api/order 来自 192.168.1.12
500 DELETE /api/order 来自 192.168.1.10
这条正则长得吓人,拆开看就清楚了。
text
(?P<ip>\d{1,3}(?:\.\d{1,3}){3}) IP,四段,每段一到三位
- - 固定的分隔符
\[(?P<time>[^\]]+)\] 方括号内,取到第一个右括号为止
"(?P<method>[A-Z]+) 请求方法,全大写字母
(?P<path>[^ "]+) 路径,取到空格或引号为止
[^"]*" 剩下的协议版本,不关心内容
(?P<status>\d{3}) 状态码,三位数字
(?P<size>\d+) 响应大小
几个值得学的地方。
[^\]]+ 这种「非某字符」的写法比 .*? 稳。它明确说了「一直读到右方括号」,不会因为字符串中间出现别的字符就跑偏。实际日志里时间戳格式五花八门,用这个写法一律通吃。
(?:\.\d{1,3}){3} 用非捕获组重复三次,配合前面的 \d{1,3} 凑出四段 IP。写成 (\d{1,3}\.){3}\d{1,3} 也能跑,但组多了容易和后面的命名组打架。
命名组配 groupdict() 一次变成字典,后面所有 r["ip"] 这种访问都带着语义。这是我最看重的部分,比 r[0] r[1] 的可读性高一个量级。写的时候多敲几个字符,改的时候能省一顿饭的时间。
最后那段错误处理,r["status"].startswith(("4", "5")) 用元组一次判断两种前缀,是这一章讲过的技巧,这里正好用上。
5.4 实战四,简易模板渲染
邮件模板、通知文案这类东西,把变量替换成实际值。用 re.sub 配一个函数是最优雅的写法。
python
# simple_render.py
import re
TEMPLATE = """尊敬的 {name}:
您的订单 {order_id} 已于 {date} 发货。
收货地址:{address}
"""
PLACEHOLDER = re.compile(r"\{(\w+)\}")
def render(template, values, strict=False):
"""把模板里的 {name} 换成 values 里的值"""
def replace(m):
key = m.group(1)
if key in values:
return str(values[key])
if strict:
raise KeyError(f"模板里用了未提供的变量 {key}")
return "[缺失]"
return PLACEHOLDER.sub(replace, template)
data = {
"name": "张三",
"order_id": "SN20261007001",
"date": "2026-10-07",
"address": "北京市朝阳区xxx路1号",
}
print(render(TEMPLATE, data))
print(render(TEMPLATE, {"name": "李四"}))
print("模板里的变量", PLACEHOLDER.findall(TEMPLATE))
try:
render(TEMPLATE, {"name": "李四"}, strict=True)
except KeyError as e:
print("严格模式报错", e)
跑出来的结果。
text
尊敬的 张三:
您的订单 SN20261007001 已于 2026-10-07 发货。
收货地址:北京市朝阳区xxx路1号
尊敬的 李四:
您的订单 [缺失] 已于 [缺失] 发货。
收货地址:[缺失]
模板里的变量 ['name', 'order_id', 'date', 'address']
严格模式报错 '模板里用了未提供的变量 order_id'
真正的机关在第二个参数。sub 这里接的不是字符串,而是一个函数。正则引擎每匹配到一个 {xxx},就把 Match 对象交给 replace 函数,用它的返回值作为替换内容。
这种写法比循环调用 str.replace 好在哪。一是只扫一遍文本,文本长了差别明显。二是能在函数里做任意判断,比如这里的 strict 分支。三是天然支持「模板里有哪些变量」的查询,findall 一把全出来。
\{(\w+)\} 这个模式要先转义花括号,因为它们在正则里是数量词语法。(\w+) 捕获变量名。
真正的模板引擎(Jinja2 之类)远比这复杂,支持循环、过滤、继承。但核心的替换机制就是这么回事,理解这个版本之后去看那些框架的源码会顺畅很多。
5.5 实战五,敏感信息脱敏
日志里、客服系统里,手机号身份证这些不能直接露出来。
python
# mask_info.py
import re
# 规则顺序有讲究,位数严格的那条要放前面
RULES = [
("身份证", re.compile(r"(?<!\d)(\d{6})(\d{8})(\d{3}[\dXx])(?!\d)"), r"\1********\3"),
("银行卡", re.compile(r"(?<!\d)(\d{4})(\d{11,15})(\d{4})(?!\d)"), r"\1 **** **** \3"),
("手机号", re.compile(r"(?<!\d)(1[3-9]\d)(\d{4})(\d{4})(?!\d)"), r"\1****\3"),
("邮箱", re.compile(r"([\w.+-]{2})[\w.+-]*(@[\w-]+(?:\.[\w-]+)+)"), r"\1***\2"),
]
text = """客户 张三,手机 13800138000,备用 13912345678
证件号 110101199003071234
银行卡 6222021234567890123
邮箱 zhangsan@example.com
联系请先核对上述信息。"""
masked = text
for name, pattern, repl in RULES:
masked, cnt = pattern.subn(repl, masked)
print(f"{name:<6} 匹配 {cnt} 处")
print()
print(masked)
真实输出。
text
身份证 匹配 1 处
银行卡 匹配 1 处
手机号 匹配 2 处
邮箱 匹配 1 处
客户 张三,手机 138****8000,备用 139****5678
证件号 110101********1234
银行卡 6222 **** **** 0123
邮箱 zh***@example.com
联系请先核对上述信息。
这个例子我第一版写错了,规则顺序反了。原来把银行卡放在身份证前面,结果那条十九位的卡号被身份证规则吃掉一半。
翻回去查了半天,问题出在我最早写的那条身份证模式上。(\d{6})(\d{8})(\d{4}[\dXx]) 四段加起来十九位,不是十八位,正好压在这张卡号的位数上。卡号同样是纯数字还更长,于是先被认领走了。修好之后改成 (\d{6})(\d{8})(\d{3}[\dXx]),六加八加四等于十八,对得上真实位数。
所以处理这类规则有个经验,位数限制严格的那条要先跑。身份证十八位是定死的,银行卡十六到十九位浮动,两者之间存在重叠。先处理最确定的,剩下的才不会互相干扰。
每条规则都用了 (?<!\d) 和 (?!\d) 把数字串夹在中间,避免从一串更长的数字里切出片段误判。这个技巧在 5.2 也用过,处理数字类匹配时算是标准动作。
\1****\3 这种替换串保留了前三后四,中间打码。这是国内脱敏的通行做法,既保护隐私又留够辨认度。
6. 小结
6.1 本章知识清单
| 小节 | 要点 | 自测题 |
|---|---|---|
| 字符串特性 | 有序、不可变、可迭代 | 能解释为什么 s[0] = "J" 会报错 |
| 查找替换 | find / index / replace 的差别 |
知道 find 找不到返回什么 |
| 分割合并 | split 切、join 合,调用方向相反 |
能写出 "-".join([...]) |
| 格式化 | f-string 优先,format 次之,日志用 % |
知道什么时候不该用 f-string |
| 编码 | 字符到码点到字节三层 | 能说清乱码产生的根本原因 |
| Unicode 三明治 | 读入解码,内部 str,写出编码 | 能在自己代码里落地这条原则 |
| 正则语法 | 元字符、字符类、数量词、分组、锚点 | 能读懂简单的正则 |
| 贪婪惰性 | 默认贪婪,加 ? 转惰性 |
知道 <.*> 和 <.*?> 的区别 |
| re 模块 | 四个查找函数加 sub split compile |
知道做校验该用哪个函数 |
| 转义 | 一律用原始字符串 | 知道 \b 不加 r 会怎样 |
6.2 避坑清单
这一章我踩过的坑,按被坑的频率排序。
修改方法忘了接返回值。 strip、replace、upper 全是返回新字符串。写了 s.strip() 单独一行,等于白写。
join 调用方向搞反。 分隔符在前列表在后,"-".join(parts),写反了会报类型错误。
split 末尾出空串。 分隔符结尾时会得到一个空字符串元素,用列表推导式过滤掉。
编码问题上依赖系统默认。 open 永远显式写 encoding="utf-8",这条能省掉绝大多数跨平台乱码。
str 和 bytes 混着运算。 Python 3 里两者不能直接相加,报错信息看着烦,实际是在救你。
正则不加 r 前缀。 \b 会被当成退格符,整个模式废掉。写正则一律加 r。
match 当成校验用。 它只管开头,后面多出来的不管。做校验用 fullmatch,或者两端加锚点。
findall 有捕获组时返回组内容。 不需要取值的分组写 (?:...)。
嵌套量词引发回溯灾难。 (a+)+ 这种写法在匹配失败时指数级变慢。
正则长度失控。 超过十来个符号就该拆,或者用 re.X 加注释。
6.3 课后练习
五道题,做完这章就算过关了。
第一题。 写个函数统计一段英文文本里每个单词出现的次数,返回前五个。提示是 lower 加 re.findall(r"\b\w+\b") 再加 Counter。
第二题。 把 "2026/10/07"、"2026.10.07"、"2026-10-07" 三种格式统一转成 "2026-10-07",用一次 re.sub 搞定。提示是 re.sub(r"[/.]", "-", text)。
第三题。 校验密码强度,要求八位以上、含大小写字母和数字,用零宽断言写。参考 3.7 节那个函数。
第四题。 写个函数把驼峰命名转成下划线命名,userName 变成 user_name。提示是 re.sub(r"(?<!^)([A-Z])", r"_\1", s).lower(),注意开头的字母前不要加下划线。
第五题。 读一个有中文的 UTF-8 文本文件,统计其中有多少个字符、多少个字节。提示是 open 加 encoding 参数,再用 len 和 encode。
6.4 下一章
第六章讲函数。到这一章为止写的所有代码都有个共同点,操作流程全部平铺在那儿,长一点的逻辑就没法拆开。函数是把代码装进盒子的技术,有了它才谈得上复用和组织。
前面几章铺垫的概念也会在函数这一章串起来。参数怎么传、返回值怎么接、变量的作用域怎么划,这些问题在函数里才第一次真正出现。
附录|字符串与正则速查卡
python
# 字符串常用
s.find(x) s.index(x) 查找,前者 -1 后者报错
s.count(x) x in s 计数、判断存在
s.replace(a, b) 替换
s.strip() s.split(sep) 去空白、切分
sep.join(list) 合并,注意方向
s.startswith(x) 开头判断,支持元组
s.upper() s.lower() 大小写
s.ljust(n) s.zfill(n) 对齐、补零
f"{x:.2f}" 格式化首选
# 编码
ord(c) chr(n) 字符与码点互转
s.encode("utf-8") 字符串转字节
b.decode("utf-8") 字节转字符串
open(f, encoding="utf-8") 永远显式写编码
# 正则语法
. \d \w \s 元字符
[a-z] [^0-9] 字符类、取反
* + ? {n,m} 数量词
*? +? {n,m}? 惰性版本
(...) (?:...) (?P<n>...) 捕获、非捕获、命名组
^ $ \b 锚点
(?=) (?!)(?<=) (?<!) 零宽断言
# re 模块
re.match(pattern, s) 只认开头
re.search(pattern, s) 找第一个
re.fullmatch(pattern, s) 整串校验,推荐
re.findall(pattern, s) 全部内容
re.finditer(pattern, s) 带位置的迭代器
re.sub(p, r, s) re.subn 替换
re.split(p, s) 按模式切
re.compile(p) 预编译
re.I re.M re.S re.X 四个标志
re.escape(s) 转义用户输入
这张卡跟前几章那几张放在一起,Python 基础部分的操作就凑齐了。前几张贴显示器边上的习惯我保持了很久,用到哪张翻哪张。