你的第一个正则:字面量与元字符的直觉
只有两种字符的世界
打开任何一个正则引擎,你会发现整个语言只有两类词汇:字面量(literal,表示"就匹配这个字符本身")和元字符(metacharacter,表示"这是一条规则指令")。字母 a 就是字面量,匹配一个 a;而 . 是元字符,匹配"任意一个字符"。学正则的过程,本质上就是认识那十几个元字符,其余的字符全是老实的字面量。
本书先带你认识五个出场率最高的元字符:. \ * + ?。掌握了它们,你已经能解决不少真实问题。
从 Ctrl+F 到正则:两代搜索的差距
在正式学语法前,先看清楚正则到底比"普通搜索"强在哪。同一个任务,两代工具的表现差距如下:
| 任务 | Ctrl/F 普通搜索 | 正则 |
|---|---|---|
| 找出文本里所有"以 ms 结尾的耗时数字" | 只能搜 ms,数字部分要肉眼逐个抄 |
\d+(?=ms) 一次抓全 |
| 查"字段顺序相反"的行(name=xx,age=xx) | 搜不到"两个未知值" | name=(\w+),age=(\d+) |
| 把几百行日志里的日期格式统一换掉 | 逐行手工改 | 一次替换全部完成 |
| 确认"文本里有多少个手机号" | 数不过来,还容易漏 | findall 直接返回列表 |
核心差距一句话:普通搜索只能搜"写死的字面量",正则搜的是"一类字符串的形状"。两个例子感受一下:
import re
log = "耗时 120ms、350ms、7ms"
# Ctrl+F 搜 "ms" 只能告诉你"存在",抓不出数字本身
print(re.findall(r"\d+(?=ms)", log))
# 输出:['120', '350', '7']
再看"字段对调"——普通搜索根本无法表达"两个未知的值":
import re
rows = "name=张三,age=28"
print(re.sub(r"name=(\w+),age=(\d+)", r"age=\2,name=\1", rows))
# 输出:age=28,name=张三
\w+ 和 \d+ 分别代表"一串字"和"一串数"这两个未知的形状,括号把它们暂存起来,替换串里再按位置取回——这种"描述形状 + 捕获 + 重组"三步曲,就是本章往后所有内容的骨架。
先认识五个元字符
.:占一个坑的"任意字符"
. 匹配除换行符以外的任意单个字符(绝大多数方言如此)。它最常用的姿势是配合量词做"模糊占位":
import re
# 找出 2024 年 3 月任意一天的日志行前缀
logs = ["2024-03-05 ERROR db timeout", "2024-04-05 ERROR disk full"]
print([l[:10] for l in logs if re.match(r"2024-03-..", l)])
# 输出:['2024-03-05']
2024-03-.. 中两个 . 分别吃掉日子的两位数字。如果你要匹配的真是小数点本身,必须转义为 \.——这就是 \ 的职责。
\:把元字符打回字面量
反斜杠是"开关":放在元字符前,取消其特殊含义(\. 匹配真正的点);放在普通字母前,则赋予其特殊含义(\d 变成"任意数字")。一个符号,两个方向。
import re
text = "售价 399.00 元,折扣 0.85"
print(re.findall(r"\d+\.\d+", text))
# 输出:['399.00', '0.85']
如果不转义,\d+.\d+ 中的 . 也能吞掉小数点,碰上 399,00 这类文本会误匹配——但结果常常"看起来也对",这种隐患最难排查。
*、+、?:三个量词,三种执念
三者都表示"前面那个东西出现几次",区别在于底线和上限:
*:出现 0 次或多次,可有可无,多多益善;+:出现 1 次或多次,至少来一次;?:出现 0 次或 1 次,最多一次,可选项。
import re
samples = ["用户 老王", "用户 老王", "用户 老张"]
# 「用户」与名字之间可能有任意多个空格
print([re.search(r"用户\s*(\S+)", s).group(1) for s in samples])
# 输出:['老王', '老王', '老张']
\s* 用 * 表示"空白可有可无";若改成 \s+,则强制要求至少有一个空格,用户老王(没空格)就匹配不上了。量词只作用于紧挨着它的前一个单元:ab* 是"a 加若干个 b",不是"若干个 ab"。想让量词管住一串字符,用括号分组:(ab)*。
五元字符速查
| 元字符 | 含义 | 一句话直觉 |
|---|---|---|
. |
任意单字符(除换行) | 占一个坑 |
\ |
转义或赋予特殊义 | 开关 |
* |
前一单元出现 0 次及以上 | 有没有都行 |
+ |
前一单元出现 1 次及以上 | 至少来一个 |
? |
前一单元出现 0 或 1 次 | 可有可无,仅一个 |
把这五行抄在便签上,前两周够用了。
一个完整的小实战:清洗手机号显示格式
产品要求把文本里的手机号统一打成 138****8000 的脱敏格式。先用 + 定位连续数字,再用分组与替换完成变形(分组语法第二章细讲,这里先感受工作流):
import re
text = "有问题联系 13812348000,备用号 15900992107。"
def mask(m):
return m.group(0)[:3] + "****" + m.group(0)[7:]
print(re.sub(r"1[3-9]\d{9}", mask, text))
# 输出:有问题联系 138****8000,备用号 159****2107。
读一遍这条正则:1 字面量开头,[3-9] 限定第二位(字符组下一章展开),\d{9} 补齐 9 位数字,总共 11 位——正好是中国大陆手机号的形状。先观察数据的真实形状,再用符号描述它,这个流程适用于你将来写的每一条正则。
避坑提醒:
?除了"可选"还有第二重身份——跟在*、+、{n,m}后面时,它把量词从"贪婪"切换成"懒惰"(尽可能少匹配)。初学者看到\d+?常以为是笔误。现在只需记住这个组合存在,第三章的量词篇会彻底讲清两种模式的行为差异。
在编辑器里跑你的第一条正则
打开 VS Code,Ctrl+H 打开替换面板,点亮搜索框右侧的 .* 图标(正则开关),试试把项目里所有 var count = ...; 的 var 换成 let:搜索 var (count) =,替换为 let $1 =。当你看到 $1 神奇地还原了括号里捕获的内容时,恭喜,你已经摸到了"分组与替换"的大门——那是下一章的主角。
在 HBuilderX 里做同样的操作也有完整的图文说明,见本站《HBuilderX 效率技巧与提效手册》:HBuilderX 效率技巧。
五个元字符的自测
合上书回答三个问题:ab* 匹配什么?\. 和 . 差在哪?用户\s+名字 为什么抓不到"用户名字"?
第一题最考验"量词只管前一个单元"的理解:
import re
print(re.findall(r"ab*", "a ab abbb"))
# 输出:['a', 'ab', 'abbb']
ab* 的意思是"a 加任意个 b",所以单独的 a 也命中——三个结果长度递增,正好展示了 b 的出现次数 0、1、3。第二题:\. 只匹配小数点本身,. 什么都吃;在"版本 2.1 与 3.5"里两者结果恰好一样,但换成"版本 2x1"就分出高下。第三题:\s+ 要求至少一个空白,没有空格的"用户名字"自然落网,这正是量词下限的意义。
import re
print(re.findall(r"\d\.\d", "版本 2.1 与 3.5"))
# 输出:['2.1', '3.5']
三道题全对,说明你已经建立了"符号逐位阅读"的直觉——这是写复杂正则前最重要的地基。答错的题目,回到对应小节重读一遍,比继续往下赶进度更划算。
字面量优先:一个反直觉的写作原则
新手写正则常见的失误是"上来就堆元字符",而老手的习惯恰恰相反:能用字面量的地方绝不用元字符。要匹配 HTTP/1.1,直接写 HTTP/1.1 就好,不需要 . 的参与;要匹配 [订单] 这种带方括号的文本,才需要转义成 \[订单\]。
这条原则背后的逻辑是"最小特殊化":元字符每多用一个,模式的不确定性就多一分,出错的搜索面就大一分。审读一条看不懂的正则时,先找出其中的字面量部分——它们是整条正则的"锚",围绕锚去理解元字符的意图,往往一读就通。
另一个实用建议是给正则写"人话翻译":1[3-9]\d{9} 翻译过来是"1 开头、第二位 3 到 9、再接 9 位数字"。写完任何一条正则,强制自己念一遍翻译,念不通的地方就是写错的地方。这个习惯在下一章的字符组里会反复用到——字符组尤其需要"念"才能写对。
最后留一道课后小菜:在编辑器里打开任意一份你项目的配置文件,找出一类重复出现的字段行,写出匹配它的正则并在替换预览里确认零误伤。这个练习只花三分钟,却是把本章五个元字符装进肌肉记忆的捷径——正则的直觉,从来都是在真实文件上练出来的。
参考资源
- MDN 对元字符与转义的权威说明:MDN 正则指南·入门
- Python re 模块语法总表:re — Regular expression operations
本章由 WorkBuddy 原创撰写,示例均经实际验证。
