从写爬虫到处理日志再到解析用户输入正则表达式一直是Python里那根最趁手的“瑞士军刀”。但很多人用正则都卡在同一个阶段匹配倒是能匹配想提取结构化数据、做条件判断、处理边界情况就写不出来了。今天这期进阶内容重点拆解两个让你突破瓶颈的功能——分组Grouping和断言Assertion。这篇文章适合已经掌握基础元字符\d、\w、*、、?和re模块基本用法的读者。今天的内容会更贴近真实需求怎么从一段文本里精准挖出想要的部分怎么让一条正则同时满足多种规则怎么写出不会在复杂匹配时卡死的表达式。我会结合大量可复现的小例子把我平时处理数据时最常用的写法直接拿出来。1. 从一次真实的日志清洗说起正则为什么需要“进阶”先还原一个我前几天遇到的场景。线上服务打印了一堆日志格式大致是这样2024-11-03 14:22:31,892 INFO 用户 request_id8f3a2b91 操作下单 金额299.00 状态成功 2024-11-03 14:23:07,118 WARN 用户 request_id8f3a2b91 操作支付 金额299.00 状态超时重试 2024-11-03 14:24:02,403 ERROR 用户 request_id9c1d77e0 操作支付 金额499.00 状态失败我需要把所有金额字段提取出来做统计。基础写法可能是re.findall(r金额(\d\.\d), line)。这个能跑但有个很大的问题它只拿到了数字没有告诉我这笔金额对应的状态和操作。如果我要分析“失败订单的总金额”靠一条正则提取裸数字是办不到的。因为正则表达式默认只负责“匹配并返回匹配到的文本”而我们需要的是“匹配的同时把文本拆成有语义的结构”。1.1 基础正则的短板匹配到了但拿不到结构这就是基础正则最典型的短板——它擅长回答“这里有没有”但不太擅长回答“这里有啥分别是什么”。拿身份证号举例很多人写校验正则时第一反应是^\d{17}[\dXx]$这条正则可以判断一串字符是不是“长得像”身份证号。但如果要提取出生日期、性别、地区码难道要写三条正则分别去匹配显然不现实。分组语法就是专门解决这个问题的用括号把模式切成一个个逻辑单元每一个单元都能单独取出。1.2 分组与断言到底在解决什么问题分组解决的是**“怎么把匹配结果按结构拆开”断言解决的是“怎么在满足某些条件的位置匹配内容”**。前者跟数据提取强相关后者跟条件校验、边界处理强相关。这两样合在一起就能写出“既匹配、又提取、还能拦一道”的正则表达式。接下来的内容我不会一个语法一个语法地念API文档而是按实际使用频率和场景把这两块彻底讲透。2. 分组的花式玩法不只是加个括号那么简单先明确一个基础概念在正则里用圆括号()括起来的部分叫分组Group。分组按照左括号出现的顺序从1开始编号。re.search、re.match返回的匹配对象通过.group(0)拿整个匹配通过.group(1)、.group(2)依序拿每个分组。2.1 捕获分组与编号的底层规则看这个经典例子——解析日期但要分别拿到年、月、日import re text 活动开始时间2024-12-25 10:30 pattern r(\d{4})-(\d{2})-(\d{2}) m re.search(pattern, text) if m: print(m.group(0)) # 2024-12-25 print(m.group(1)) # 2024 print(m.group(2)) # 12 print(m.group(3)) # 25 print(m.groups()) # (2024, 12, 25)这里有一个大多数新手都会忽略的编号规则如果括号里面还套着括号编号按照左括号从左到右的出现顺序排。比如pattern r((\d{4})-(\d{2}))-(\d{2}) m re.search(pattern, text) print(m.groups()) # (2024-12, 2024, 12, 25)1号分组是外层的(\d{4})-(\d{2})2号和3号分别是里层的两个4号才是最后的(\d{2})。理解这个规则才能准确拿到你想要的组。2.2 命名分组给匹配结果起个名字分组多了以后用数字编号很容易乱——尤其是写完一条正则隔了一个月再回来看group(3)到底是什么这时候命名分组就非常有价值。语法是(?Pname...)Python里的写法是这个P大写的别记成小写。pattern r(?Pyear\d{4})-(?Pmonth\d{2})-(?Pday\d{2}) m re.search(pattern, text) print(m.group(year)) # 2024 print(m.group(month)) # 12 print(m.groupdict()) # {year: 2024, month: 12, day: 25}.groupdict()返回字典这对后续构造结构化数据特别方便。我在写爬虫解析时经常把一整条正则写成多个命名分组然后直接**groupdict()塞进数据表代码会非常清爽。命名分组也可以和数字编号混用同样按左括号顺序编号但实际工作中我建议要么全用命名要么全用数字混用是在给自己挖坑。2.3 非捕获分组省内存的“透明”括号还有一种情况括号只是用来表示“这些字符作为一个整体”并不需要单独提取结果。比如匹配“abc”出现两次写成(abc){2}这时abc这个组是会被捕获的。如果后面又引用了\1那没问题但如果压根不需要引用它捕获就是纯浪费——正则引擎需要额外记录捕获内容在大量文本循环匹配时会有性能损耗。不想要捕获结果就用非捕获分组(?:...)pattern r(?:abc){2} m re.search(pattern, abcabc) print(m.groups()) # () 空因为没有任何捕获组这个“透明括号”还有一个常见用途配合|做多选分支的限定。比如想匹配“apple”或“banana”后再跟一个数字写成apple|banana\d会出问题因为|的优先级很低它会把整个表达式劈成“apple”和“banana\d”两个分支。正确写法是(?:apple|banana)\d这样既能整体限定范围又不产生多余捕获。这个坑我在新手代码里见过无数次。3. 反向引用与替换让正则学会“回头看”分组不只是用来提取结果它还有一个反向的能力——在正则内部引用前面已经匹配到的内容。这就是反向引用。3.1 反向引用\1匹配重复结构反向引用的语法是\1、\2对应编号分组命名分组用(?Pname)来引用。它解决的核心问题是匹配的内容必须在同一次匹配中保持一致。举一个最经典的例子——查找连续重复的单词pattern r\b(\w)\s\1\b text the the book is is on the table print(re.findall(pattern, text)) # [the, is]\b是词边界(\w)捕获一个单词\s匹配空白\1要求后面出现跟第一个分组一模一样的单词。静态的正则模式做不到这种“动态回看”必须靠反向引用。反向引用的真实场景远不止查重。解析成对出现的HTML标签时非常管用比如匹配b内容/b这种结构和标签同名的场景html b加粗文字/b pattern r(\w).*?/\1 m re.search(pattern, html) print(m.group(0)) # b加粗文字/b只需要写一个(\w)捕获标签名闭合标签用\1引用就再也不用为每种标签各写一条正则了。3.2 re.sub中的组引用与替换回调在re.sub做字符串替换时分组引用更是高频操作。替换串里用\1、\2引用分组可以把“找到的内容”按结构重新拼装。举例把美国风格日期MM/DD/YYYY改成中国风格YYYY-MM-DDtext 今天是 12/25/2024 pattern r(\d{2})/(\d{2})/(\d{4}) result re.sub(pattern, r\3-\1-\2, text) print(result) # 今天是 2024-12-25注意这里re.sub的替换串里我写了r\3-\1-\2原样字符串里的反斜杠数字指的就是对应分组。如果你用的是re.subn它会额外返回替换了多少次。3.3 替换函数的高级用法示例如果替换逻辑比较复杂字符串模板表达不了可以把替换参数传成一个函数。函数接收匹配对象返回替换后的字符串text 订单金额299.00 元运费15.00 元 pattern r(\d\.\d{2}) def convert(m): amount float(m.group(1)) if amount 100: return f{amount * 0.9:.2f} # 模拟满100打9折 return m.group(0) print(re.sub(pattern, convert, text)) # 订单金额269.10 元运费15.00 元把整个逻辑放进替换函数里正则负责“找到”函数负责“计算”比在正则里硬拼复杂逻辑靠谱得多。我处理清洗数据时特别爱用这种组合因为很多清洗规则本质上是“先定位再按规则变换”正则是定位器函数才是变换器。4. 断言零宽度的边界艺术如果说分组是“拿到匹配内容的结构”那么断言就是“在不占字符的前提下规定匹配必须满足的上下文条件”。它的形象叫零宽断言Zero-Width Assertion——意思是断言本身不消费字符匹配成功时它只标记一个位置前进的“宽度”是0。4.1 零宽断言的核心机制理解“零宽”是理解断言的关键。普通字符匹配成功后会占用字符串的位置匹配位置随后移断言不同它只检查当前位置的左右两边是否满足条件满足则匹配位置原地不动继续用后续模式匹配。因此断言特别适合处理“内容是动态的但位置条件是固定的”这种场景。四个基本断言分别是断言语法名称作用(?...)正向前瞻右边必须能匹配...但不消费字符(?!...)负向前瞻右边必须不能匹配...(?...)正向后顾左边必须能匹配...(?!...)负向后顾左边必须不能匹配...分词界面的例子来理解\b就是内置断言它匹配单词边界但不是某个字符所以是零宽。自定义断言可以做更精细的“伪边界”。4.2 前瞻断言与负向前瞻先看一个非常现实的需求从一段文本里提取所有数字但这些数字必须紧接着单位“元”或者是价格格式。直接写\d会把所有数字都捞出来不是我们想要的。用正向前瞻可以做到“提取的内容不包括单位但要求单位必须存在”text 苹果8元一斤香蕉6元一斤一共买了5个苹果3根香蕉 pattern r\d(?元) print(re.findall(pattern, text)) # [8, 6](?元)检查\d匹配结束后下一个位置必须是“元”字。因为是零宽断言“元”本身不会被捕获进结果。负向前瞻相反要求后面不能跟什么。比如提取所有“后面不是百分号”的数字text 成功率98%失败2次 pattern r\d(?!%) print(re.findall(pattern, text)) # [98因为98后面是%不满足负前瞻但8后面是%也不满足先匹配9但9的右边是8不满足断言……需要更仔细看]这里我得啰嗦一句负向前瞻的实际执行和直觉有偏差上面这个例子容易踩坑。\d(?!%)匹配“98”时\d会尝试尽可能多吃字符首先吃掉“98”此时位置停在%前断言发现右边是%失败于是\d吐出1个字符变成匹配“9”此时位置在“8”前断言检查右边是“8”不是%通过——所以结果是[9, 2]。这个例子就是想告诉你断言和贪婪量词放一起时结果常出人意料实际用之前最好用Python跑一遍验证。4.3 后顾断言与python的固定宽度限制Python的re模块对后顾断言有个硬性限制(?...)里面必须是固定宽度的模式不能出现、*这类不定长度的量词。Python 3.11及以上版本开始支持、*等可变宽后顾但为了兼容性和稳定性我建议依然尽量写定宽的。regex第三方库则没有这个限制这点后面会提一嘴。定宽后顾的典型用法是提取货币符号后面的数字text 价格$29.99折扣价$19.99 pattern r(?\$)\d\.\d{2} print(re.findall(pattern, text)) # [29.99, 19.99]负向后顾用来排除某些前缀。比如提取“前面不是USD的金额数字”text USD 120CNY 80 pattern r(?!USD )\d # 注意USD后面有个空格这是定宽模式 print(re.findall(pattern, text)) # [80]前面的120被排除这里有个极易踩的坑后顾断言的模式必须放在字符串的最左边开始比对如果前面有其他字符它会以当前匹配位置为基准向左看不是搜索整个前面的字符串。理解方式后顾断言就是在匹配到当前位置时“回头”检查左侧固定长度的文本是否符合模式。5. 断言实战密码校验、数字格式与日志提取讲了这么多基础语法不落到真实场景里都是纸上谈兵。下面三个例子是我在实际工作中反复用到的也是面试题和业务需求里的常见变体。5.1 密码强度校验一口气写对需求密码长度为8到20位必须同时包含大写字母、小写字母和数字。用基础正则怎么写都别扭因为“必须同时包含”是一个全局条件不是从开头匹配到结尾就能判断的。断言恰恰擅长这种“多个独立条件同时满足”的校验。思路是利用前瞻断言做“并行检查”password Abc12345 pattern r^(?.*[A-Z])(?.*[a-z])(?.*\d)[A-Za-z\d]{8,20}$ m re.match(pattern, password) print(bool(m)) # True拆解一下这条正则^(?.*[A-Z])从字符串开头开始当前这个位置后面必须存在至少一个大写字母(?.*[a-z])同样的位置后面必须存在至少一个小写字母(?.*\d)同样的位置后面必须存在至少一个数字最后[A-Za-z\d]{8,20}$才是真正从头到尾消费字符的主体部分限定长度和字符集。因为断言是零宽的三次前瞻都从同一个起点检查但互不干扰、彼此独立。这就是用断言做“多条件叠加”的标准套路。将来遇到“不能包含连续三位相同字符”这种附加规则也可以再加一个负向前瞻进来。5.2 数字千分位格式化与文本清洗给一串长数字加上千分位分隔符比如1234567.891变成1,234,567.891这个需求在报表处理和前端展示时很常见。用断言处理这种“在某个位置插入字符”的任务特别顺手。核心思路是找到“数字中间需要插入逗号的位置”——一个数字的左边是数字、右边是数字但右边后面的数字数量恰好是3的倍数。不过反直觉的是标准正则写法是从后往前数的import re num 1234567.891 # 在整数部分每隔3位加逗号匹配整数部分里“右边恰好有3的倍数个数字”的位置 formatted re.sub(r(?\d)(?(\d{3})(?!\d)), ,, num) print(formatted) # 1,234,567.891分析一下为什么能成立(?\d)要求当前位置左边是数字确保这是数字串内部(?(\d{3})(?!\d))要求当前位置右边是若干个“3位数字组”(\d{3})且这个组结束后右边不再是数字(?!\d)这样只会匹配到整数部分、不影响小数部分。按这个写法1234567的“4”和“1”前面会被插入逗号因为右边剩余位数分别是3位和6位。这种“找位置插字符”的思路比先把数字翻转、切片、再拼接回去的做法简洁太多而且完全不需要写循环。5.3 用断言从日志中精准截取上下文回到文章开头那个日志场景。现在要提取所有“状态失败”的请求中对应的request_id。常规做法是先findall找到request_id(\w)再判断同一行里是否包含“失败”但这需要两步。用正向后顾和前瞻可以一条正则搞定“我要的是request_id但它后面必须出现失败状态”log 2024-11-03 14:22:31 INFO 用户 request_id8f3a2b91 操作下单 金额299.00 状态成功 2024-11-03 14:24:02 ERROR 用户 request_id9c1d77e0 操作支付 金额499.00 状态失败 pattern rrequest_id(\w)(?.*状态失败) print(re.findall(pattern, log)) # [9c1d77e0]同样如果要排除成功请求把前瞻改成负向前瞻(?!.*状态失败)即可。这里值得注意的是(?.*状态失败)中的.*是贪婪的它会一直向右找直到找到“状态失败”为止所以能跨过中间任意内容。这种“以远处条件为导向”的提取是处理扁平文本日志、配置、纯文本表格时的利器。6. 性能陷阱与可维护性进阶正则的两条命脉分组和断言功能强大但用不好会让正则又慢又难读。我见过不少线上事故一根正则把CPU跑满表面上问题出在“匹配不到”或“匹配太慢”根子上都是回溯失控。6.1 灾难性回溯分组与量词叠出的“死循环”正则引擎在遇到量词时默认是贪婪的——尽量多吃字符发现后面匹配不上再逐渐吐出来重新尝试这个过程叫回溯。当正则里出现多个量词叠加、且它们都可以伸缩时回溯次数会指数级增长。最典型的反面教材是这个pattern r(a)$这种“量词套量词”的结构遇到长串字符连续失败时回溯组合数量爆炸。比如对aaaaaaaaaaaaaaab做匹配引擎会尝试所有一种“切分a的方式”实际耗时可能是几秒甚至几分钟。避免灾难性回溯的几条经验嵌套量词(a)、(a*)*能不用就不用量词后面加?变成非贪婪比如.*?可以减少很多无效尝试能明确边界就写边界^、$、\b让引擎尽早失败对可能超长的目标文本设置长度上限比如.{0,100}而不是.*。Python里还有个简单的方法可以兜底用re模块时给匹配操作加个超时很难但可以在外部用信号或线程控制如果嫌麻烦regex第三方库提供regex.match(..., timeout...)对批量处理尤其实用。6.2 让长正则读得下去re.VERBOSE与编译复用进阶正则写久了一条正则动辄七八十个字符全是括号和反斜杠过两周自己都看不懂。这时有两个让代码“可活”的手段。第一个是用re.VERBOSE模式也叫re.X允许在正则里写空白和注释引擎会自动忽略它们pattern re.compile(r ^ # 字符串开头 (?.*[A-Z]) # 至少一个大写字母 (?.*[a-z]) # 至少一个小写字母 (?.*\d) # 至少一个数字 [A-Za-z\d]{8,20} # 主体8-20位字母数字 $ # 字符串结尾 , re.VERBOSE)分隔的注释让正则变成“可读的伪代码”。第二个是养成用re.compile的习惯把正则对象保存起来复用。尤其当你写爬虫或者做批量文本处理时同一个模式会被反复使用预编译可以明显减少耗时。我之前处理一个百万行级日志的任务把findall里的模式提出来预先compile整体时间大概快了三成。7. 把今天的知识点串成一个完整爬虫案例最后用一个稍微综合一点的例子收尾。假设你拿到一段商品信息文本需要提取“商品名、原价、折扣价、库存是否充足”四项数据data 商品无线机械键盘原价499.00现价399.00库存充足 商品USB-C扩展坞原价259.00现价199.00库存紧张 pattern re.compile(r 商品(?Pname[^]) 原价(?Poriginal\d\.\d{2}) 现价(?Pdiscount\d\.\d{2}) 库存(?Pstock充足|紧张) , re.VERBOSE) for m in pattern.finditer(data): print(m.groupdict())输出{name: 无线机械键盘, original: 499.00, discount: 399.00, stock: 充足} {name: USB-C扩展坞, original: 259.00, discount: 199.00, stock: 紧张}这个例子里组合了今天讲的所有核心能力命名分组做结构化提取finditer逐个处理匹配对象re.VERBOSE让长模式可读。数据字段间我用“”做了固定分隔所以直接匹配即可如果字段顺序可能变化就要配合断言按“键名定位值”。实际工作中多半还要再加一步把提取出的字符串统一转成数值类型、做异常处理这时groupdict()返回的字典是最好的中间数据结构。正则表达式学到分组和断言这个阶段才算真正开始解决实际问题。我个人体会是这两个特性最大的价值不是“写出更炫的表达式”而是让代码表达力上了一个台阶——以前要写五六行循环判断的逻辑现在一条正则加一个groupdict()就完成了。最后分享两个我在项目里的习惯复杂模式一定先用小样本文本打样验证跑通过再上全量数据命名分组的名字语义化哪怕模式长一点维护起来的心理负担也小得多。
阅读完成 · 觉得有帮助?