正则表达式这东西,刚接触 Python 的时候我把它当洪水猛兽,一堆反斜杠加问号括号,看着就头大。后来写得多了才发现,它本质上就是一套描述文本模式的迷你语言,你告诉 Python “我要找长什么样的字符串”,它帮你从一堆乱七八糟的文本里精准捞出来。re模块就是 Python 标准库里负责这件事的工具,不用装任何第三方包,开箱即用。这篇内容我打算把自己这些年用re的经验完整摊开讲一遍,从最基础的匹配到分组、断言、替换、编译优化,再到实际项目里踩过的坑,都会涉及。不管你是刚学 Python 想搞懂re.match和re.search到底差在哪,还是已经写过一些正则但总在边界情况上翻车,应该都能从里面找到对你有用的东西。核心关键词就一个:Python 的 re 正则表达式,围绕它把该讲透的地方都讲透。
1. 先搞清楚 re 模块到底在解决什么问题
1.1 正则表达式不是 Python 专属,但 re 模块有它的脾气
正则表达式这套语法最早能追溯到上世纪的计算理论,后来在各类编程语言和文本工具里遍地开花。Python 的re模块遵循的是 Perl 风格的正则语法,大部分你在其他语言里写的正则拿过来能直接用,但细节上有差异,这个后面会专门说。理解re的第一步,是明白它处理的对象永远是字符串,输入一个模式(pattern)和一个目标字符串,输出匹配结果或者做替换、切分。它不关心你的数据是 JSON、日志、HTML 还是纯文本,只要你能把想找的东西描述成一个模式,它就能干活。
我见过不少新手把正则当成万能锤子,什么文本处理都想用正则解决,结果写出又长又难维护的表达式。这里先给个判断标准:结构简单、格式相对固定的文本抽取用正则很合适,比如从日志行里抠时间戳、从配置里提取键值对、校验用户输入的邮箱手机号格式。但如果是要解析嵌套的 HTML 或者复杂的 JSON,老老实实用专门的解析库,正则在这类场景下会把你折磨到怀疑人生。这个边界感很重要,是我踩过坑之后才建立起来的认知。
1.2 一个最小可运行例子先跑通
在深入语法之前,先让代码跑起来,建立手感。下面这段是最基础的用法:
import re text = "订单编号 A2024,金额 399 元,下单时间 2024-05-12" pattern = r"\d+" result = re.findall(pattern, text) print(result) # ['2024', '399', '2024', '05', '12']这里有几个点值得说。第一,模式字符串前面加了r,表示原始字符串,这样反斜杠不会被 Python 字符串本身转义,\d才能原样传给正则引擎。强烈建议所有正则模式都用原始字符串,这是铁律,能省掉大量莫名其妙的 bug。第二,\d匹配任意一个数字字符,+表示前面的元素出现一次或多次,合起来就是“连续的一串数字”。第三,findall返回所有不重叠的匹配结果列表。
跑通这个例子,你就已经迈进门了。接下来要做的,是把这套语法系统地拆开看。
1.3 re 模块的核心函数全景
re模块提供的函数不算多,但每个都有明确的使用场景,混用会导致结果不符合预期。我整理了一张对照表,建议先有个整体印象:
| 函数 | 作用 | 返回结果 | 典型场景 |
|---|---|---|---|
re.match | 从字符串开头尝试匹配 | Match 对象或 None | 校验整串格式 |
re.search | 扫描整个字符串找第一个匹配 | Match 对象或 None | 查找是否存在某模式 |
re.findall | 找出所有匹配 | 字符串列表(或元组列表) | 批量抽取数据 |
re.finditer | 找出所有匹配 | Match 对象迭代器 | 需要匹配位置信息 |
re.sub | 替换匹配内容 | 替换后的新字符串 | 清洗、脱敏、格式化 |
re.split | 按模式切分 | 字符串列表 | 复杂分隔符切分 |
re.compile | 预编译模式 | Pattern 对象 | 重复使用同一模式 |
这张表我建议存下来,写代码时拿不准用哪个就瞄一眼。最容易搞混的是match和search,下面单独拎出来讲。
2. 匹配函数的选择逻辑:match、search、findall 到底怎么选
2.1 match 和 search 的本质区别
很多人第一次用re.match会困惑:明明字符串里有这个内容,为什么返回 None?原因在于match只从字符串的第 0 个位置开始尝试,开头对不上就直接失败,不会往后找。而search会扫描整个字符串,找到第一个能匹配的位置就返回。
import re text = "用户ID: 10086" print(re.match(r"\d+", text)) # None,因为开头是"用"不是数字 print(re.search(r"\d+", text)) # <Match object; span=(5, 10), match='10086'>这个差异在实际开发里非常关键。如果你要校验“整个字符串是否符合某个格式”,比如手机号必须是 11 位数字,那用match配合$锚定结尾更合适;如果你只是想知道“这段文本里有没有出现某个东西”,用search。我自己早期的习惯是能用search就用search,因为它更宽容,不容易因为开头多一个空格就翻车。
提示:
re.match等价于在模式前面隐式加了^,但re.search不会。如果你需要从开头匹配又用search,记得手动加^。
2.2 findall 的返回值陷阱
findall用起来最爽,但它有个容易被忽略的行为:当模式里含有分组(括号)时,返回值会变成分组内容的列表,而不是整个匹配。这个坑我踩过不止一次。
import re text = "姓名: 张三, 年龄: 28; 姓名: 李四, 年龄: 32" # 没有分组,返回完整匹配 print(re.findall(r"姓名: \w+", text)) # ['姓名: 张三', '姓名: 李四'] # 有一个分组,只返回分组内容 print(re.findall(r"姓名: (\w+)", text)) # ['张三', '李四'] # 有多个分组,返回元组列表 print(re.findall(r"姓名: (\w+), 年龄: (\d+)", text)) # [('张三', '28'), ('李四', '32')]理解这个行为后,你反而能利用它:想要完整匹配就别加括号,想要抽取特定字段就加括号。但如果你既想要完整匹配又需要分组,那就得用finditer配合group()方法,这个后面讲 Match 对象时会说。
2.3 finditer 与 Match 对象的完整用法
finditer返回的是迭代器,每个元素是 Match 对象,信息量比findall大得多。Match 对象上常用的方法有group()、groups()、start()、end()、span(),分别取匹配文本、所有分组、起始位置、结束位置、位置区间。
import re text = "2024-05-12 下单,2024-06-01 发货" pattern = re.compile(r"(\d{4})-(\d{2})-(\d{2})") for m in pattern.finditer(text): print(f"完整: {m.group(0)}, 年: {m.group(1)}, 位置: {m.span()}") # 完整: 2024-05-12, 年: 2024, 位置: (0, 10) # 完整: 2024-06-01, 年: 2024, 位置: (14, 24)group(0)永远代表整个匹配,group(1)开始对应各个分组。span()返回的区间是左闭右开,这个和 Python 切片规则一致,方便你直接拿去做字符串截取。需要定位匹配位置做高亮或者替换时,finditer是首选。
3. 正则语法核心要素逐个击破
3.1 字符类:方括号里的世界
字符类用方括号[]表示,匹配其中任意一个字符。[abc]匹配 a、b、c 中任意一个,[a-z]匹配任意小写字母,[0-9a-zA-Z_]匹配数字字母下划线。方括号里有个特殊规则:^放在开头表示取反,[^0-9]匹配任意非数字字符。
import re print(re.findall(r"[aeiou]", "hello world")) # ['e', 'o', 'o'] print(re.findall(r"[^aeiou\s]", "hello world")) # ['h', 'l', 'l', 'w', 'r', 'l', 'd']方括号里的元字符大部分会失去特殊含义,比如.在方括号里就是普通点号,*也是普通星号。但]、\、^(非开头位置)、-(非区间位置)仍需注意转义或位置安排。我一般习惯把-放在方括号最前面或最后面,避免被误解析成区间。
3.2 预定义字符类:省事的快捷方式
常用的字符集合 Python 都给了简写,用起来比方括号清爽:
| 简写 | 含义 | 等价写法 |
|---|---|---|
\d | 数字 | [0-9] |
\D | 非数字 | [^0-9] |
\w | 单词字符(字母数字下划线) | [a-zA-Z0-9_] |
\W | 非单词字符 | [^a-zA-Z0-9_] |
\s | 空白字符(空格、制表、换行等) | [ \t\n\r\f\v] |
\S | 非空白字符 | [^ \t\n\r\f\v] |
. | 任意字符(默认不含换行) | 需配合re.DOTALL才含换行 |
这里要特别提醒\w的行为:在 Python 3 的默认模式下,\w会匹配 Unicode 单词字符,也就是说中文、日文等也会被算进去。如果你只想匹配 ASCII 的字母数字下划线,需要加re.ASCII标志。这个细节在处理中英文混合文本时经常引发意外,我一开始就吃过亏,以为\w+只抓英文,结果把中文也一起抓了。
3.3 量词:控制出现次数
量词决定前面那个元素出现几次,是正则表达力的核心:
*:0 次或多次+:1 次或多次?:0 次或 1 次{n}:恰好 n 次{n,}:至少 n 次{n,m}:n 到 m 次
量词默认是贪婪的,也就是尽可能多匹配。在量词后面加?变成非贪婪(懒惰),尽可能少匹配。这个区别在处理 HTML 标签、引号内容时极其重要。
import re html = "<div>内容一</div><div>内容二</div>" print(re.findall(r"<div>.*</div>", html)) # ['<div>内容一</div><div>内容二</div>'] 贪婪,一口吞到底 print(re.findall(r"<div>.*?</div>", html)) # ['<div>内容一</div>', '<div>内容二</div>'] 非贪婪,逐个匹配贪婪和非贪婪的选择逻辑很简单:当你需要匹配的内容有明确的结束边界,且边界字符可能在内容中重复出现时,用非贪婪。但非贪婪也不是万能药,有时候性能反而更差,后面性能优化那节会细说。
3.4 锚点与边界:定位匹配位置
锚点不消耗字符,只声明位置:
^:字符串开头(多行模式下是每行开头)$:字符串结尾(多行模式下是每行结尾)\b:单词边界\B:非单词边界
\b这个锚点特别实用,用来做整词匹配。比如你想在文本里找单词 “cat”,但不想匹配到 “category” 里的 cat:
import re text = "cat category concatenate cat." print(re.findall(r"\bcat\b", text)) # ['cat', 'cat']\b的本质是“一边是单词字符、另一边不是单词字符”的位置。理解这个定义,你就能预判它在各种边界情况下的行为,而不是死记硬背。
3.5 分组与捕获:括号的多种玩法
圆括号()在正则里有三重身份,这是很多人容易混淆的地方:
第一重:分组,把多个字符当成一个整体,方便加量词。(ab)+匹配 “ab”、“abab” 等。
第二重:捕获,匹配到的分组内容会被单独保存,可以通过group(n)取出。这是最常用的功能。
第三重:非捕获分组(?:...),只分组不捕获,不占用分组编号。当你只需要分组加量词、不需要提取内容时,用非捕获分组能减少不必要的开销,也让分组编号更清晰。
import re text = "2024-05-12" # 捕获分组 m = re.search(r"(\d{4})-(\d{2})-(\d{2})", text) print(m.groups()) # ('2024', '05', '12') # 非捕获分组,groups() 为空 m2 = re.search(r"(?:\d{4})-(?:\d{2})-(?:\d{2})", text) print(m2.groups()) # ()还有命名分组(?P<name>...),给分组起名字,通过group('name')取值。当分组多了以后,用编号容易记混,命名分组可读性高很多:
import re m = re.search(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})", "2024-05-12") print(m.group('year'), m.group('month'), m.group('day')) # 2024 05 12命名分组在re.sub的回调替换里也能用,后面会演示。
3.6 反向引用:匹配重复内容
反向引用\1、\2引用前面第 n 个分组匹配到的内容,用来匹配“重复出现的相同文本”。经典场景是找重复单词:
import re text = "this is is a test test case" print(re.findall(r"\b(\w+)\s+\1\b", text)) # ['is', 'test']\1表示“和第一个分组匹配到的内容完全一样”。这个功能在数据清洗里很有用,比如检测重复的字段、找出格式异常的重复片段。注意反向引用引用的是匹配结果而不是模式本身,这是它和普通分组的关键区别。
4. 编译、标志位与性能优化
4.1 什么时候该用 re.compile
re模块的函数每次调用都会在内部编译模式,如果你在循环里反复用同一个模式,编译开销会累积。re.compile把模式预编译成 Pattern 对象,之后复用,能省下这部分开销。
import re # 不推荐:循环里反复编译 for line in lines: if re.search(r"\d{4}-\d{2}-\d{2}", line): ... # 推荐:预编译一次 date_pattern = re.compile(r"\d{4}-\d{2}-\d{2}") for line in lines: if date_pattern.search(line): ...不过要说清楚:Python 内部对最近使用的模式有缓存机制,简单脚本里不预编译也不会慢到哪去。真正需要预编译的场景是:同一模式在循环或高频函数里反复使用。我一般的习惯是,只要一个模式在代码里出现超过一次,就提出来编译成模块级常量,既提升性能又方便统一维护。
4.2 常用标志位详解
标志位通过flags参数传入,多个标志用|组合:
| 标志 | 作用 | 使用场景 |
|---|---|---|
re.IGNORECASE/re.I | 忽略大小写 | 用户输入校验 |
re.MULTILINE/re.M | ^$匹配每行首尾 | 多行文本逐行处理 |
re.DOTALL/re.S | .匹配包括换行 | 跨行匹配 |
re.VERBOSE/re.X | 允许模式里写注释和空白 | 复杂正则的可读性 |
re.ASCII/re.A | \w\d等只匹配 ASCII | 避免中文被\w匹配 |
re.VERBOSE这个标志值得单独说,它能让你的复杂正则变得可读。开启后,模式里的空白会被忽略(除非在字符类里或转义),#后面可以写注释:
import re pattern = re.compile(r""" (?P<year>\d{4}) # 年份 - (?P<month>\d{2}) # 月份 - (?P<day>\d{2}) # 日期 """, re.VERBOSE) m = pattern.search("日期: 2024-05-12") print(m.groupdict()) # {'year': '2024', 'month': '05', 'day': '12'}复杂正则用这种方式写,维护成本直线下降。我现在的习惯是,只要模式超过 30 个字符或者包含 3 个以上分组,就上re.VERBOSE。
4.3 性能优化的几个实操手段
正则写不好会拖慢程序,尤其是处理大文本时。几个我验证过有效的优化手段:
第一,能用字符串方法就别用正则。判断前缀用str.startswith,判断包含用in,这些比正则快一个数量级。正则的编译和状态机开销是实打实的。
第二,避免灾难性回溯。嵌套量词如(a+)+在匹配失败时会产生指数级回溯。解决办法是尽量让量词作用在明确的字符类上,避免嵌套不确定的量词。
第三,优先用具体字符类代替.。.要检查所有字符,[0-9]只需检查数字,后者更快也更精确。
第四,合理使用锚点。在模式开头加^或\b能让引擎快速排除不可能的位置,减少无效尝试。
import re import time text = "x" * 10000 + "2024-05-12" # 慢:没有锚点,每个位置都尝试 start = time.time() re.search(r"\d{4}-\d{2}-\d{2}", text) print(f"无锚点: {time.time() - start:.6f}s") # 快:有明确边界 start = time.time() re.search(r"\b\d{4}-\d{2}-\d{2}\b", text) print(f"有锚点: {time.time() - start:.6f}s")实际差距取决于文本结构,但方向是对的:给引擎更多排除信息,它就能更快找到目标。
5. 替换、切分与实战场景拆解
5.1 re.sub 的进阶用法
re.sub基础用法是re.sub(pattern, repl, string),把匹配内容替换成repl。repl可以是字符串,也可以是函数。字符串里可以用\1、\g<name>引用分组:
import re # 日期格式转换:2024-05-12 -> 2024/05/12 text = "下单日期 2024-05-12,发货日期 2024-06-01" result = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1/\2/\3", text) print(result) # 下单日期 2024/05/12,发货日期 2024/06/01当替换逻辑需要计算时,传函数更灵活。函数接收 Match 对象,返回替换字符串:
import re def mask_phone(m): phone = m.group(0) return phone[:3] + "****" + phone[-4:] text = "联系电话 13812345678,备用 13987654321" result = re.sub(r"1[3-9]\d{9}", mask_phone, text) print(result) # 联系电话 138****5678,备用 139****4321这个手机号脱敏的例子在实际项目里非常常见,用函数替换比写死格式灵活得多。re.sub还有个count参数控制最多替换几次,默认全部替换。
5.2 re.split 处理复杂分隔符
str.split只能按固定字符串切分,遇到“逗号、分号、空格都可能作为分隔符”的情况就无能为力了。re.split按模式切分,轻松搞定:
import re text = "苹果,香蕉;橙子 葡萄|西瓜" print(re.split(r"[,; |]", text)) # ['苹果', '香蕉', '橙子', '葡萄', '西瓜']如果分隔符本身有规律但长度不定,比如多个连续空格,用r"\s+"一次切干净。注意re.split如果模式里含捕获分组,分组内容也会出现在结果列表里,这个行为和findall类似,用的时候留意。
5.3 实战:日志行解析
拿一个贴近真实工作的场景收尾这一节。假设有一批应用日志,格式如下:
2024-05-12 10:23:45 [INFO] user=1001 action=login ip=192.168.1.10 2024-05-12 10:24:01 [ERROR] user=1002 action=pay error=timeout要提取时间、级别、用户、动作,可以这样写:
import re log_pattern = re.compile(r""" (?P<time>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}) # 时间 \s\[(?P<level>\w+)\]\s # 级别 user=(?P<user>\d+)\s # 用户 action=(?P<action>\w+) # 动作 """, re.VERBOSE) logs = [ "2024-05-12 10:23:45 [INFO] user=1001 action=login ip=192.168.1.10", "2024-05-12 10:24:01 [ERROR] user=1002 action=pay error=timeout", ] for line in logs: m = log_pattern.search(line) if m: print(m.groupdict()) # {'time': '2024-05-12 10:23:45', 'level': 'INFO', 'user': '1001', 'action': 'login'} # {'time': '2024-05-12 10:24:01', 'level': 'ERROR', 'user': '1002', 'action': 'pay'}用命名分组加re.VERBOSE,模式一目了然,后续加字段也方便。这种写法我在多个日志处理脚本里复用,比一行挤在一起的正则可维护太多。
6. 常见问题排查与避坑实录
6.1 高频问题速查表
下面这张表是我这些年被问得最多、自己也踩得最多的正则问题,按现象、原因、解决整理:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
match返回 None 但明明有内容 | match只从开头匹配 | 改用search或加.*前缀 |
findall返回的不是完整匹配 | 模式里有捕获分组 | 用非捕获分组(?:...)或改finditer |
\w把中文也匹配了 | Python 3 默认 Unicode 模式 | 加re.ASCII标志 |
.匹配不到换行 | 默认.不含换行 | 加re.DOTALL标志 |
| 贪婪匹配吞掉太多内容 | 量词默认贪婪 | 量词后加?变非贪婪 |
模式里\d报错或行为异常 | 没用原始字符串 | 模式前加r |
| 大文本匹配极慢 | 灾难性回溯或缺少锚点 | 简化量词嵌套、加边界锚点 |
| 替换时分组引用不生效 | 替换串没用原始字符串 | 替换串也加r前缀 |
6.2 几个真实踩坑案例
坑一:原始字符串漏加导致转义混乱。早期我写re.search("\d+", text),Python 先把\d当字符串转义处理,虽然\d恰好不是合法转义序列所以没报错,但遇到\b(退格符)、\n(换行)这类就出大问题。\b在字符串里是退格,在正则里是单词边界,不加r前缀行为完全不同。这个坑我建议用工具检查,或者养成肌肉记忆:看到re.就条件反射加r。
坑二:贪婪匹配在提取引号内容时翻车。文本他说"你好"然后说"再见",想提取两个引号内的内容,用r'"(.*)"'会匹配到你好"然后说"再见,因为贪婪。改成r'"(.*?)"'才对。这个案例我拿来给新人讲过很多次,非贪婪的?是解决这类问题的标准答案。
坑三:re.match校验格式时忘了锚定结尾。用re.match(r"\d{11}", phone)校验手机号,输入138123456789(12 位)居然也通过了,因为match只要求开头匹配,后面多出来的不管。正确写法是re.fullmatch(r"\d{11}", phone)或者re.match(r"\d{11}$", phone)。re.fullmatch是 Python 3.4 之后加的,专门做整串匹配,比手动加$更不容易漏。
坑四:字符类里忘记转义连字符。写[a-z-0-9]想匹配字母、连字符、数字,结果z-0被解析成一个区间,行为完全不对。连字符要么放最前[-a-z0-9],要么放最后[a-z0-9-],要么转义[a-z\-0-9]。
6.3 调试正则的实用技巧
正则写错了很难肉眼定位,几个我常用的调试手段:
第一,用在线工具可视化。把模式和测试文本贴进去,能直观看到匹配过程和分组结构。这类工具很多,选一个顺手的就行。
第二,分步构建。复杂正则不要一次写完,先写一小段验证,再逐步加内容。比如先验证时间部分\d{4}-\d{2}-\d{2},通过后再拼级别、用户等字段。
第三,用re.DEBUG标志。编译时加这个标志,Python 会打印出模式的解析树,能看出引擎是怎么理解你的模式的:
import re re.compile(r"\d{4}-\d{2}", re.DEBUG) # 输出会显示 LITERAL、MAX_REPEAT 等内部结构第四,写单元测试。把边界用例都覆盖到,包括空字符串、超长字符串、特殊字符、中英文混合。正则的边界情况太多,靠手动测试容易漏。
注意:
re.DEBUG的输出是给引擎看的内部表示,不是给人读的,但排查“为什么这个模式不按我想的匹配”时,它能告诉你引擎的真实理解,比瞎猜高效。
7. 进阶话题:断言、条件与 Unicode 处理
7.1 前后向断言:不消耗字符的匹配
断言(lookaround)用来声明“某个位置的前面或后面必须满足什么条件”,但本身不消耗字符。四种形式:
(?=...):正向先行断言,后面必须匹配(?!...):负向先行断言,后面必须不匹配(?<=...):正向后行断言,前面必须匹配(?<!...):负向后行断言,前面必须不匹配
经典场景是密码强度校验:至少 8 位,必须含数字和字母,但不能含特殊字符。用断言可以一次性表达:
import re pattern = re.compile(r"^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$") print(bool(pattern.match("abc12345"))) # True print(bool(pattern.match("abcdefgh"))) # False,没有数字 print(bool(pattern.match("12345678"))) # False,没有字母(?=.*[A-Za-z])的意思是“从当前位置往后看,任意位置要出现字母”,(?=.*\d)同理。两个断言叠加,就实现了“同时包含”的约束。这种写法比写多个if判断简洁,但可读性对新手不友好,团队里用的话记得加注释。
后行断言有个限制:Python 的re模块要求后行断言里的模式必须是固定长度的,不能用*、+这类不定量词。这是re模块的已知限制,遇到需要变长后行断言的场景,只能换思路或者用第三方库。
7.2 Unicode 与中文处理
Python 3 的字符串默认是 Unicode,re模块对中文的支持总体不错,但有几个点要注意。前面提过\w会匹配中文,\d在默认模式下也会匹配全角数字和其他文字的数字字符。如果你的业务逻辑只认 ASCII 数字,务必加re.ASCII。
处理中文时,[\u4e00-\u9fa5]是常用的“基本汉字”区间,但要注意它不覆盖所有汉字(比如扩展区的生僻字)。更稳妥的做法是用\w配合re.ASCII的反向思路,或者直接用str.isalpha()判断。我一般处理中文文本时,能用字符串方法判断的就不硬套正则,正则留给结构化的模式匹配。
7.3 条件匹配与注释
正则还支持条件匹配(?(id)yes|no),根据某个分组是否匹配成功来决定走哪个分支。这个语法用得少,但在解析可选字段时有用。另外(?#comment)可以在模式里写注释,不过有了re.VERBOSE之后这个语法基本被替代了。这两个特性了解即可,实际项目里我几乎没主动用过,维护成本高于收益。
8. 我个人的使用心得与建议
写到这里,把该讲的语法和场景基本覆盖了。最后分享几条纯个人经验,都是踩坑换来的。
正则不是越长越牛。我见过一个两百多字符的正则,作者自己三个月后都看不懂。复杂模式拆成多个简单模式分步处理,或者用re.VERBOSE加注释,维护成本低得多。代码是给人看的,顺便给机器执行。
测试用例要覆盖边界。空字符串、纯空格、超长输入、特殊字符、中英文混合、格式刚好差一位,这些都要测。正则的 bug 往往藏在你想不到的边界上,我吃过太多次亏。
性能问题先测量再优化。不要凭感觉觉得某个正则慢就大改,先用timeit测一下。很多时候瓶颈不在正则本身,而在数据处理的其他环节。真遇到慢的,优先检查有没有灾难性回溯和缺失锚点。
能用内置方法就用内置方法。str.startswith、str.endswith、in、str.replace、str.split这些在简单场景下比正则快且可读。正则的定位是处理“有结构但格式不固定”的文本,别让它干本该字符串方法干的活。
模式统一管理。项目里所有正则模式集中放在一个模块或者配置里,命名清晰,加注释说明用途和示例。散落在各处的正则字符串是维护噩梦,改一个格式要全局搜索,还容易漏。
这套东西我用了好几年,从最初看到正则就头疼,到现在能比较从容地拆解各种文本处理需求,核心就是多写多测。语法就那么些,真正拉开差距的是对边界情况的理解和调试能力。遇到搞不定的模式,先拆小、先测、先可视化,基本都能解决。