正则表达式
把正则当成一门小语言来记:先是字符和数量,再是位置和分组,最后是"哪个引擎支持哪些语法"。第三部分最容易翻车——同一段表达式在 grep、Python 和 JavaScript 里的行为可能不一样。
基本构件
| 写法 | 匹配 | 备注 |
|---|---|---|
. | 任意一个字符 | 默认不含换行,除非开 s 标志 |
\. | 字面量点号 | 元字符要匹配自身就加反斜杠 |
[abc] | a、b、c 中任意一个 | 方括号内多数元字符失去特殊含义 |
[^abc] | 除这三个之外的字符 | ^ 在方括号内表示取反 |
[a-z0-9_] | 范围 | 范围按码位排序,[z-a] 是错的 |
\d \w \s | 数字 / 单词字符 / 空白 | 部分引擎中文也算 \w,见方言一节 |
\D \W \S | 上面三个的补集 | |
* + ? | 0+ / 1+ / 0 或 1 次 | |
{2,5} | 2 到 5 次 | {3} 恰好 3 次;{2,} 至少 2 次 |
| | 或 | 优先级最低,a|bc 是 a 或 bc |
^ $ | 行首 / 行尾 | 多行模式下是每行首尾 |
(…) | 分组并捕获 |
量词的三种模式
默认是贪婪的:在能满足整体匹配的前提下尽量多吃。加 ? 变懒惰(尽量少吃),加 + 变占有(吃完不吐)。用一段带引号的文本区分它们:
输入: "a" and "b"
".*" 贪婪 → "a" and "b" 一口吃到最后一个引号
".*?" 懒惰 → "a" 吃到最近的引号就停
".*+" 占有 → 匹配失败 吃光后不回退,找不到闭合引号
占有量词(*+、++、?+、{n,m}+)主要在 Java、PCRE、Python regex 模块里有,标准 re 不支持。它的价值是防止回溯,但通常用原子组或改写表达式更通用。
分组
| 写法 | 含义 | 取用 |
|---|---|---|
(abc) | 捕获组,编号按左括号顺序从 1 开始 | Python m.group(1),JS $1 |
(?:abc) | 只分组不捕获 | 减少无用分组、略微提速 |
(?P<name>abc) | 命名组(Python) | m.group('name') |
(?<name>abc) | 命名组(.NET / JS / PCRE) | $<name> |
(?=x) (?!x) | 正向 / 负向前瞻,不消耗字符 | |
(?<=x) (?<!x) | 正 / 负向后顾 | 多数引擎要求定长 |
反向引用 \1 表示"和第一组匹配到的内容完全相同",不是"再匹配一次第一组的模式"。找重复词很好用:
\b(\w+)\s+\1\b # 匹配 "the the" 这类连续重复的词
边界与位置
\b 单词边界(一边是 \w,一边不是)
\B 非单词边界
^ $ 行首 / 行尾
\A \z 整个字符串的开头 / 结尾(Python 里是 \A 和 \Z)
最容易忽略的是 \b 对中文的行为:中文不属于 \w,所以"中文\b边界"的判定和直觉不同。处理中文文本时,宁可显式写字符范围,也不要依赖 \b。
常见模式
下面这些可以直接用,但请先读每条的说明——正则能校验格式,不能校验语义。
IPv4 地址
\b(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}\b
四段各取 0–255,写法是按范围从大到小枚举:250–255、200–249、100–199、0–199 剩下的部分。[0-9]{1,3} 这种看着简单的写法会放过 999.1.1.1。
日期(YYYY-MM-DD)
^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$
它只保证月份 01–12、日期 01–31,不保证 2 月没有 30 号。真正的日期合法性交给语言自带的日期库去判断,不要试图用正则写完整个闰年规则。
时间(24 小时制)
^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$
十六进制颜色
#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b
六位写在三位前面,否则三位那一支会先吃掉前三个字符,把后面的内容留成垃圾。多选分支的顺序会影响结果,这是写交替时最常见的错误。
解析一行访问日志
^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) ([^"]*?)(?: HTTP/[\d.]+)?" (\d{3}) (\d+|-)
分别得到客户端地址、时间、方法、路径、状态码、字节数。[^\]]+ 这种"取到某个字符为止"的写法,比 .+? 更稳,也更不容易触发回溯。
去重连续空白 / 去掉行尾空白
[ \t]+ → 单空格
[ \t]+$
中文字符
[\u4e00-\u9fff] 基本汉字区
[^\x00-\x7f] 所有非 ASCII 字符(更宽松)
汉字在 Unicode 里分散在多个区段,基本区之外还有扩展 A–G。要"包含所有汉字"就得把多个区段拼起来,实际项目里通常用语言提供的 Unicode 属性,例如 \p{Script=Han}(需要引擎支持)。
性能:小心灾难性回溯
嵌套量词是主要来源。看这个表达式:
^(a+)+$
输入 aaaa…aaa(30 个 a)时它很快;输入 aaaa…aaab 时,引擎要尝试把 30 个 a 分成两组的所有可能组合,耗时随长度指数增长。这类表达式遇到用户输入就是一次拒绝服务。
三条实用原则:
- 不要嵌套量词,
(a+)+直接写成a+。 - 用更具体的字符类替代
.*,例如[^"]*代替.*。 - 对不可信输入限制长度,并给匹配设超时(语言层面通常没有,需要靠进程隔离)。
反过来,如果用的是 Go、RE2 这类基于自动机的引擎,就不存在回溯爆炸——代价是不支持反向引用和前后查找。选引擎时先想清楚要哪一边。
方言差异
| 特性 | POSIX ERE(grep -E) | PCRE / Python re | JavaScript | Go(RE2) |
|---|---|---|---|---|
\d \w \s | 标准里没有,GNU 部分支持 | 支持 | 支持 | 支持 |
懒惰量词 *? | 不支持 | 支持 | 支持 | 支持 |
| 前后查找 | 不支持 | 支持(后顾需定长) | 支持 | 不支持 |
| 反向引用 | 不支持 | 支持 | 支持 | 不支持 |
| 命名组 | 不支持 | (?P<n>…) | (?<n>…) | (?P<n>…) |
另外两个常被忽略的点:grep 默认按行匹配,. 永远碰不到换行;而 Python 的 re.match 只从开头匹配(等价于加了 ^),re.search 才是全串搜索。写 ^…$ 时如果字符串结尾有换行,Python 的 $ 允许在换行前匹配,用 \Z 更严格。
调试建议
- 先写测试串。列出 3 个应该匹配、3 个不该匹配的样本,再动手写表达式。没有样本的正则无法验证。
- 从宽到窄。先写出能匹配的最小表达式,再逐个加约束,每加一个就跑一次测试串。
- 别怕加空格。大部分语言支持
x标志,忽略空白并允许#注释,长表达式可读性会好很多。 - 分段验证。把表达式拆成几段分别测试,比盯着一个 200 字符的怪物猜哪里错了快得多。