你的第一个正则:字面量与元字符的直觉

只有两种字符的世界

打开任何一个正则引擎,你会发现整个语言只有两类词汇:字面量(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 位数字"。写完任何一条正则,强制自己念一遍翻译,念不通的地方就是写错的地方。这个习惯在下一章的字符组里会反复用到——字符组尤其需要"念"才能写对。

最后留一道课后小菜:在编辑器里打开任意一份你项目的配置文件,找出一类重复出现的字段行,写出匹配它的正则并在替换预览里确认零误伤。这个练习只花三分钟,却是把本章五个元字符装进肌肉记忆的捷径——正则的直觉,从来都是在真实文件上练出来的。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。