正则表达式这东西,Python新手基本都会遇到。学的时候觉得"不就是匹配字符串吗",等真的动手写才发现,要么匹配不到,要么匹配多了,那个*和+看起来差不多,用起来结果完全不一样。我自己当年学的时候也在这个地方卡了很久,翻了不少教程,大部分都是把语法列一遍然后给几个例子就完了,真正遇到问题还是不知道怎么排查。
所以这篇东西,我打算用比较实际的方式来写。不搞那种冗长的语法手册,而是围绕Python正则最核心的、新手最容易卡住的知识点和场景,把每个关键点讲透,配实际的代码和踩坑记录。无论你是刚装好Python想找个方向练习,还是已经在写爬虫遇到了文本提取的需求,这篇文章都能帮你少走弯路。
1. 先别急着写代码:正则表达式到底在解决什么问题
1.1 从生活场景理解"模式匹配"
在没有正则表达式的时候,你想从一个字符串里找出所有的手机号,只能这样写:
text = "联系人:张三 13812345678,李四 13987654321" # 你只能用切片和判断去硬找这个字符串短还好说,如果是一整页网页源码,几百KB的文本,用Python原生字符串方法去处理,代码会膨胀到没法看。而正则表达式做的事情就是:你描述一个"模式",它帮你在文本里把所有符合这个模式的内容都找出来。就像你在一个巨大的图书馆里找所有书脊上有"Python"字样的书,不需要一本一本翻,只需要一个检索规则。
这个概念理解到位了,后面所有语法都是在为"描述模式"服务的。
1.2 Python里正则的两个基本角色:字符与元字符
正则表达式里的字符分两种。一种是普通字符,比如你写一个字母a,它就只能匹配文本里的a;另一种是元字符,比如\d、\w、*、+、[,它们有特殊的含义。新手最容易困惑的就是这两种角色的区别:为什么\d能匹配数字但不是数字?为什么[本身不能被直接匹配?
我的建议是,先记住最核心的几组元字符,其他的用到再查:
| 元字符 | 含义 | 等价说明 |
|---|---|---|
\d | 匹配一个数字 | 等价于[0-9] |
\w | 匹配字母、数字、下划线 | 等价于[a-zA-Z0-9_] |
\s | 匹配空白字符 | 空格、制表符、换行 |
. | 匹配除换行以外的任意字符 | 注意是"任意" |
* | 前面的字符出现0次或多次 | 贪婪匹配 |
+ | 前面的字符出现1次或多次 | 贪婪匹配 |
? | 前面的字符出现0次或1次 | 还用于关闭贪婪 |
[] | 字符集,匹配括号里的任意一个字符 | 注意是"一个" |
^ | 匹配字符串开头 | 在[]里表示取反 |
$ | 匹配字符串结尾 | 注意和\Z的区别 |
看到一个正则表达式的时候,先用眼睛把元字符找出来,剩下的就是普通字符。元字符决定"匹配规则",普通字符决定"匹配对象",这个区分一旦清楚了,阅读正则表达式的能力会立刻提升。
1.3 新手最常见的误区:拿正则当字符串函数用
很多新手学正则的时候会有一个错觉,觉得正则能搞定一切字符串处理,结果把简单问题复杂化。实际上,Python自带的字符串方法已经覆盖了很多常见需求:
text = "hello world" # 判断开头结尾,用 startswith/endswith 就行,没必要用正则 print(text.startswith("hello")) # 替换固定文本,用 str.replace 更直观什么时候用正则?一个简单的判断标准:当你要匹配的内容本身是"一类东西"而不是"一个具体东西"的时候。比如"匹配以数字开头的行"、"匹配所有邮箱地址",这类需求字符串原生方法做不了,才轮到正则上场。
2. Python正则的核心API:掌握这五个就够用
2.1 match、search、findall的分工差异
Python的re模块提供了几个常用函数,新手容易搞混的是match和search,以及findall的返回类型。
re.match只从字符串的开头位置开始匹配。如果开头就不符合,哪怕中间有符合的内容也返回None。re.search则扫描整个字符串,找到第一个符合模式的位置就停下。
import re text = "我的电话是13812345678,赶紧联系" # match 从头开始匹配,text 开头是"我的",不是数字,所以返回 None result_match = re.match(r"\d{11}", text) print(result_match) # None # search 会扫描整个字符串,找到第一个连续11位数字 result_search = re.search(r"\d{11}", text) print(result_search.group()) # 13812345678findall和前面两个有本质区别:它返回的是所有匹配结果组成的列表,而不是一个match对象。如果正则里有分组(用小括号括起来的部分),findall的行为会再变一下,这个问题后面专门讲,这里先记住:findall是提取数据的利器,但不适合拿来判断"有没有匹配"。
2.2 sub、split:不光是查找,还要会替换和切分
正则不只是用来"找"的,re.sub用来替换所有匹配的内容,re.split用来按模式切分字符串。这两个函数的数据处理能力被很多人低估了。
import re text = "2024-01-15 和 2024/02/20 是两个日期" # 把各种格式的日期分隔符统一成 - result = re.sub(r"[/.]", "-", text) print(result) # 2024-01-15 和 2024-02-20 是两个日期 # 按多个标点切分句子 sentence = "Python很棒;但是正则有点难,需要多练。" parts = re.split(r"[;,。]", sentence) print(parts) # ['Python很棒', '但是正则有点难', '需要多练', '']re.sub的高级用法是传一个函数作为替换内容,这样可以对匹配到的内容做动态处理。比如想把文本里所有数字都乘以2:
import re def double(match): return str(int(match.group()) * 2) text = "今年是2024年,我买了3本书" result = re.sub(r"\d+", double, text) print(result) # 今年是4048年,我买了6本书这个技巧在处理日志分析、数据清洗时非常实用,建议直接记住。
2.3 两个小细节:re.IGNORECASE与re.VERBOSE
新手容易忽略的是编译标志。re.IGNORECASE(简写re.I)让正则忽略大小写,这个很好理解。重点说re.VERBOSE(简写re.X),它允许你在正则里写注释和换行,大幅提升可读性:
import re # 没有 VERBOSE 的正则,一长串看着就头疼 pattern1 = r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$" # 加了 VERBOSE 之后,可以像写普通代码一样排版 pattern2 = re.compile(r""" ^(\d{4}) # 年份:4位数字 [-/] # 分隔符:- 或 / (\d{1,2}) # 月份:1到2位数字 [-/] # 分隔符 (\d{1,2}) # 日期:1到2位数字 $ """, re.VERBOSE)写复杂的正则时,我会默认加上re.VERBOSE。否则隔一个月回头看自己写的一长串正则会非常痛苦。这个东西不是锦上添花,是刚需。
3. 从零写一个手机号码正则:实操拆解与进阶案例
3.1 13位数字手机号码正则表达式的完整推导
很多新手直接在搜索引擎搜"13位数字手机号码正则表达式怎么写",搜出来就复制粘贴。但如果不理解每一步的推导过程,换个场景你就废了。我们一步步拆:
第一步,确定边界。手机号是11位,不是13位。搜"13位数字手机号码正则"的人,往往混淆了"以13开头"这个条件。比如号码是13812345678,你说它是"13开头的号码",不代表号码只有13位。所以先明确:需求是匹配以13开头的11位手机号。
第二步,分析结构。中国手机号的规律是:第一位是1,第二位目前是3到9,后面9位任意数字。那么正则就是:
import re pattern = r"^1[3-9]\d{9}$"这个正则拆开看:
^和$:确保匹配的是整个字符串,而不是一段文本里恰好有11位数字。1:第一位必须是数字1。[3-9]:第二位的取值范围是3到9,注意这是一个字符位。\d{9}:后面9个数字。{9}表示前面的\d正好出现9次。
验证一下:
import re pattern = r"^1[3-9]\d{9}$" phones = ["13812345678", "19876543210", "12812345678", "123456789012"] for p in phones: result = re.match(pattern, p) print(p, "->", "匹配" if result else "不匹配")注意我这里是用了re.match,它本身就是从开头匹配,所以^可以省略。但如果你用的是re.search,^必须带。
还有一点,实际业务里手机号通常出现在一堆文字中间,比如"请回电13812345678"。这时候^和$就失灵了,因为手机号不在开头也不在结尾。正确做法是给手机号加边界:在前面用(?<!\d)表示"前面不能是数字",后面用(?!\d)表示"后面不能是数字":
import re pattern = r"(?<!\d)1[3-9]\d{9}(?!\d)" text = "请回电13812345678,或者13987654321也行,注意不是123456789012345" print(re.findall(pattern, text)) # 输出:['13812345678', '13987654321']这个负向断言(?<!\d)和(?!\d)在提取场景里几乎必用,因为不加的话,13位以上的长数字串会被提取出错误的子串。
3.2 进阶案例一:从爬虫HTML中提取邮箱链接
新手学正则有一个普遍动力是为了爬虫。爬虫的第一步往往是从HTML里提取信息。虽然专业的爬虫框架有更成熟的解析方案,但正则始终是轻量级的首选工具。看这个场景:从一堆HTML源码中提取邮箱地址。
import re html = """ <div> <p>联系邮箱:support@example.com</p> <p>商务合作:business@test.com.cn</p> <p>广告联系:ad@sub.domain.co</p> </div> """ pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" emails = re.findall(pattern, html) print(emails)这个正则的结构:[a-zA-Z0-9._%+-]+是邮箱用户名部分,@是固定分隔符,[a-zA-Z0-9.-]+是域名部分(点号用反斜杠转义了,因为在正则里点号是任意字符的通配符),最后\.[a-zA-Z]{2,}是顶级域名,至少2个字母。
这个例子我用了findall直接取结果列表,没有判断匹配对象的步骤,是提取数据的标准姿势。
3.3 进阶案例二:格式化日期字符串
数据清洗时经常遇到日期格式混乱的问题:有2024-01-15,有2024/1/5,有2024.01.05,需要统一成一种格式。
import re text = "日期1: 2024-01-15, 日期2: 2024/2/20, 日期3: 2024.03.05" pattern = r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})" def reformat(match): year, month, day = match.groups() return f"{year}年{int(month)}月{int(day)}日" result = re.sub(pattern, reformat, text) print(result) # 日期1: 2024年1月15日, 日期2: 2024年2月20日, 日期3: 2024年3月5日这里的核心是分组():把年份、月份、日期分别捕获,然后在函数里重新组合。如果你只需要提取而不需要重排,match.group(1)、match.group(2)这种方式也足够直观。
3.4 进阶案例三:统计文章中出现次数最多的关键词
正则配合collections.Counter可以实现简单的词频统计。这个案例结合了"python"和"正则表达式"两个热搜词的场景,也是新手练习的常用方向。
import re from collections import Counter article = """ Python是一种编程语言,python非常适合数据分析。 学习Python的过程中,正则表达式是必须掌握的技能之一。 正则表达式虽然刚开始学起来有点难,但掌握了它,处理文本会非常高效。 """ # 提取所有中英文单词(简单版本,只匹配连续字母和中文字符) words = re.findall(r"[a-zA-Z]+|[\u4e00-\u9fff]+", article.lower()) # 统计词频 counter = Counter(words) print(counter.most_common(5))这段代码里的[\u4e00-\u9fff]是中文的Unicode范围,\u4e00是"一"字所在编码区间的起点,\u9fff是终点。新手在中英文混排的场景里很容易在这里卡住,后面第4部分会专门讲中文匹配。
4. 新手踩坑实录:这些坑我全都踩过
4.1 贪婪匹配与懒惰匹配:为什么结果比预期多
*和+默认是贪婪的,意思是它们会尽可能匹配多的内容。比如你有一个HTML标签<b>加粗</b>,想提取加粗两个字:
import re text = "<b>加粗</b> 和 <b>加粗2</b>" # 错误示范:贪婪匹配 result = re.findall(r"<b>(.*)</b>", text) print(result) # ['加粗</b> 和 <b>加粗2']为什么会这样?因为.*会一直往右吞字符,直到最后一个</b>出现才停下来。它不会在第一个</b>就停止。解法是加?把贪婪变成懒惰:
result = re.findall(r"<b>(.*?)</b>", text) print(result) # ['加粗', '加粗2'](.*?)里的?表示:前面的*匹配到的内容尽可能少,够用就行。这个区别在一切有开始标记和结束标记的提取任务中都会遇到。我的经验是,在写.*的时候养成反射性加?的习惯,除非你明确要贪婪匹配。
4.2 转义地狱:为什么写[\d\.]还是不对
正则里有特殊含义的字符,比如.、*、(、),如果你想匹配它们的本来面目,需要在前面加反斜杠\。新手很容易在字符集[]里犯迷糊,以为里面也需要全部转义。比如想匹配一个数字或者一个点号,写成[\d\.]。
实际上点号在字符集[]内部就是普通字符,不需要转义。[\d.]和[\d\.]的效果一样。但如果你不转义也能用,很多教程还是倾向于让你转义,因为万一你以后把这个表达式挪到字符集外面,忘了转义就会出错。
真正麻烦的是在Python字符串里写正则时的双重转义。注意这两行的区别:
# 正则里的 \d 表示数字,Python字符串里要写成 "\\d" pattern1 = "\\d+" # 正确 pattern2 = "\d+" # 错误,Python 会把 \d 当作普通字符 d,带一个警告更推荐的做法是使用原始字符串,也就是在字符串前面加一个r:
pattern = r"\d+" # 推荐加了r之后,反斜杠就原样传给正则引擎,不用再纠结转义了。这是Python正则最重要的一个写法习惯。我看到很多新手的错误代码,一半以上都和忘记加r有关。
4.3 中文匹配:Unicode范围怎么记
正则匹配中文,正统写法是用Unicode编码范围[\u4e00-\u9fff]。
import re text = "我最近在学Python的re模块,感觉re真的很好用" # 提取所有中文字符 chinese_chars = re.findall(r"[\u4e00-\u9fff]", text) print(chinese_chars) # ['我', '最', '近', '在', '学', '的', '模', '块', '感', '觉', '真', '的', '很', '好', '用']这里的\u4e00对应"一"字,\u9fff对应这个区间的末尾。这个范围覆盖了绝大多数常用汉字,但生僻字可能在范围之外。如果只是做一般的中文文本处理,这个范围完全够用。
另外注意re模块在没有指定re.UNICODE标志时,处理中文字符的边界可能和你的直觉不同。比如\w在默认情况下匹配中文字符(Python3里\w会匹配Unicode的字母数字),但\b词的边界对中文不友好。所以判断一个中文字符是否存在,最好明确用[\u4e00-\u9fff]。
4.4 性能陷阱:灾难性回溯与re.compile
正则的性能问题新手可能体会不深,但一旦开始处理大数据量的文本,就会踩到。最典型的场景是"嵌套量词",比如(a+)+b这种写法。当文本里全是一长串a而没有b时,正则引擎会尝试无数种匹配路径,导致程序卡死。这个现象叫"灾难性回溯"。
import re # 这个表达式在匹配长字符串时会很慢 bad_pattern = r"(a+)+$" # 实际测试里,几十个a后面没有b,就足以卡顿解决方案有两个:一是重写正则,避免嵌套量词;二是给量词加一个上限,比如{1,10},限制尝试次数。
另外一个小优化是re.compile的使用。如果一个正则表达式要在循环里用很多次,每次直接re.findall(pattern, text)会重复编译模式,消耗额外时间。提前编译一次:
import re pattern = re.compile(r"(\d{4})-(\d{2})-(\d{2})") for line in large_file: match = pattern.search(line) # 复用编译后的模式 # 处理逻辑对于新手阶段,re.compile的性能提升可能感觉不到,但这是一个好的习惯。更重要的是,编译后可以给它起一个有意义的名字,代码可读性会好很多。
5. 正则的边界:什么时候千万别用正则
5.1 经典反例:解析HTML/JSON
正则表达式很强,但它不是万能锤。HTML和JSON这种有嵌套结构的文本,理论上就不适合用正则解析。比如你要匹配一个<div>里面的所有内容,如果<div>里面还嵌套了<div>,正则的(.*?)只会匹配到第一个闭合标签就停了,无法正确判断层级。
举个现实的例子:有人用正则去爬网页标题。看起来是提取<title>(.*?)</title>,确实绝大多数情况都能成功。但一旦遇到<title>标签属性里有特殊字符,或者标题里有</title>这种字符串(虽然罕见但不是不可能),正则解法就直接崩了。这种场景,正确做法是用BeautifulSoup这类DOM解析器。
同样的道理适用于JSON。JSON是严格嵌套的结构化格式,直接用json.loads就能解析成Python字典。如果非要写一个正则去提取嵌套的JSON字段,最后一定会写出一个没人维护得动的怪物。
一个简单的判断标准:你要处理的内容如果本身有"层级结构",就别用正则;如果只是一串扁平的文本,正则高效又方便。
5.2 正则能力自查清单
经过上面的学习,你可以拿这个清单自查一下:
- [ ] 看到
\d、\w、\s、.、*、+、?、[]能立刻说出含义 - [ ] 能区分
re.match、re.search、re.findall的差异 - [ ] 知道
findall在正则包含分组时返回的是组而不是完整匹配 - [ ] 能写出提取11位手机号的正则,并处理数字边界
- [ ] 知道
(.*?)和(.*)的区别 - [ ] 知道为什么写正则时建议加
r前缀 - [ ] 知道用
[\u4e00-\u9fff]匹配中文字符 - [ ] 能说出至少一个"不该用正则"的场景
每一项背后对应的都是一个实际的坑。如果自查发现哪一项不确定,回到前面的章节再读一遍对应的部分,或者打开Python交互环境敲一敲。
5.3 新手练习路径:从环境搭建到每日一练
最后聊一下练习环境。很多新手卡在"正则表达式"之前,其实是卡在"Python还没装好"或者"写完代码不知道在哪跑"。如果你还没装Python,建议直接去Python官网下载最新稳定版,安装时注意勾选"Add Python to PATH"。编辑器方面,我推荐用VSCode,装好Python扩展,用起来比较轻量。不建议新手一开始就折腾复杂的IDE,反而被工具分散了注意力。
练正则不需要独立环境,直接在Python交互式shell里就能跑。我自己当年构建了一套简单的练法,分享给你:
- 定义一组测试文本,包含正常数据和干扰数据。比如练习手机号提取时,文本里既有正确手机号,也有座机号、乱写的13位数字,确保你的正则是"准"的。
- 随时用
re.findall打印结果,验证预期。正则这东西,"写出来"和"写对了"完全是两回事,必须以输出为准。 - 把每天写爬虫、处理文本时遇到的提取需求,先想一想要不要用正则、怎么写,再去查语法。
学习正则最好的产出是"整理自己的常用正则片段"。比如手机号、邮箱、URL、日期、IP地址,这些高频模式完全可以沉淀成自己的工具函数库。以后写爬虫、做数据分析清洗,直接调用,效率翻倍。
我个人在实际操作中的体会是,正则表达式的学习曲线不是线性的,它更像是在爬台阶:刚学会\d和*的时候觉得很简单,一用起来发现匹配多了;学会懒惰匹配和分组之后又觉得进阶了,接着就碰到中文和性能问题。每上一个台阶,你都能处理更复杂的文本场景。坚持把每一个"卡住"的问题弄明白,正则就会从玄学变成顺手工具。别怕慢,这东西实战几次就熟了。