简介
正则表达式(Regular Expression,简称 regex 或 regexp)是用于定义搜索模式的字符序列。它是开发者工具箱中最强大的工具之一,能够用一个简洁的表达式完成文本的搜索、验证、提取和转换。
无论是在网页表单中验证电子邮件地址、从日志中提取数据,还是对数千个文件执行复杂的查找替换操作,正则表达式都能让你用一种几乎所有编程语言和主流文本编辑器都能理解的紧凑符号,精确表达你要查找的内容。
正则表达式的发展历史
正则表达式的历史可以追溯到理论计算机科学的基础:
- 1951 年 — 数学家 Stephen Kleene 在研究自动机理论时形式化了正则语言的概念,并引入了 Kleene 星(
*)符号。 - 1968 年 — Ken Thompson 在 QED 文本编辑器中实现了正则表达式,随后将其引入 Unix 工具(
grep、sed、awk),使正则表达式进入开发者的日常工作。 - 1986 年 — POSIX 标准化了两种风格:BRE(基本正则表达式)和 ERE(扩展正则表达式),确保了跨 Unix 系统的互操作性。
- 1997 年 — Philip Hazel 创建了 PCRE(Perl Compatible Regular Expressions)库,提供了 lookahead、lookbehind 和命名捕获等强大特性。
- 1999 年 — ECMAScript 3 标准化了 JavaScript 的
RegExp对象。 - 2015 年 — ES6 新增了
u(Unicode)和y(sticky)标志。 - 2018 年 — ES2018 新增了命名捕获组(
(?<name>...))和 lookbehind 断言。
POSIX vs PCRE vs JavaScript RegExp 对比
| 特性 | BRE/ERE (POSIX) | PCRE | JavaScript RegExp |
|---|---|---|---|
| Lookahead | ✗ | ✓ | ✓ |
| Lookbehind | ✗ | ✓ | ✓(ES2018+) |
| 命名捕获组 | ✗ | ✓ | ✓(ES2018+) |
| 非贪婪匹配 | ✗ | ✓ | ✓ |
| Unicode | 有限 | ✓ | ✓(需要 u 标志) |
| 反向引用 | ✓ | ✓ | ✓ |
核心语法参考
快速参考表
| 模式 | 含义 |
|---|---|
. |
除换行符外的任意字符 |
^ |
字符串开头 / 行首(配合 m 标志) |
$ |
字符串结尾 / 行尾(配合 m 标志) |
\d |
任意数字 [0-9] |
\D |
任意非数字 |
\w |
单词字符 [a-zA-Z0-9_] |
\W |
非单词字符 |
\s |
空白字符(空格、制表符、换行符等) |
\S |
非空白字符 |
[abc] |
字符类 — 匹配 a、b 或 c |
[^abc] |
否定字符类 — 匹配除 a、b、c 之外的字符 |
[a-z] |
字符范围 |
* |
0 次或多次(贪婪) |
+ |
1 次或多次(贪婪) |
? |
0 次或 1 次(贪婪) |
{n} |
恰好 n 次 |
{n,m} |
n 到 m 次(贪婪) |
*? +? ?? |
懒惰(非贪婪)等价形式 |
(abc) |
捕获组 |
(?:abc) |
非捕获组 |
(?<name>abc) |
命名捕获组 |
| |
选择 — 匹配左侧或右侧 |
(?=...) |
正向 lookahead |
(?!...) |
负向 lookahead |
(?<=...) |
正向 lookbehind |
(?<!...) |
负向 lookbehind |
字符类
字符类允许你匹配一组字符中的一个。[aeiou] 匹配任意单个元音字母,[a-zA-Z] 匹配任意字母,[^0-9] 匹配任意非数字字符。
常用简写字符类:
\d等价于[0-9]\w等价于[a-zA-Z0-9_]\s匹配空格、制表符(\t)、换行符(\n)、回车符(\r)等空白字符
量词:贪婪 vs 懒惰
默认情况下,量词是贪婪的——尽可能多地匹配字符。以 HTML 字符串 <b>粗体</b> 和 <i>斜体</i> 为例:
<.*> → 贪婪匹配,匹配从 <b> 到 </i> 的整个字符串
<.*?> → 懒惰匹配,依次匹配 <b>、</b>、<i>、</i>
在量词后添加 ? 可使其变为懒惰(非贪婪):在保证整体模式成功的前提下,尽可能少地匹配字符。
锚点
^匹配字符串开头(配合m标志时匹配行首)。$匹配字符串结尾(配合m标志时匹配行尾)。\b匹配单词边界——单词字符与非单词字符之间的位置。\B匹配非单词边界。
组与反向引用
捕获组 (...) 会捕获匹配到的文本,可以通过 \1、\2 等(反向引用)在后续模式中引用,或通过匹配结果数组访问。
非捕获组 (?:...) 用于分组子模式,但不创建捕获,效率更高,适合不需要捕获值的场景。
命名捕获组 (?<year>\d{4}) 允许通过名称(JavaScript 中为 match.groups.year)引用捕获,大幅提升模式的可读性。
Lookahead 与 Lookbehind
这些零宽断言匹配的是位置而非字符:
\d+(?= 元) → 仅当后面跟着"元"时,才匹配数字
\d+(?! 元) → 后面不跟"元"时,才匹配数字
(?<=\$)\d+ → 仅当前面是"$"时,才匹配数字
(?<!\$)\d+ → 前面不是"$"时,才匹配数字
Lookahead 和 lookbehind 不消耗字符,因此匹配结果不包含断言部分。
标志位(Flags)
| 标志 | 名称 | 作用 |
|---|---|---|
i |
忽略大小写 | [a-z] 同时匹配 [A-Z] |
g |
全局匹配 | 查找所有匹配项,而非仅第一个 |
m |
多行模式 | ^ 和 $ 匹配行边界 |
s |
dotAll | . 同时匹配换行符 |
u |
Unicode 模式 | 启用完整 Unicode 匹配,支持 \p{} |
y |
粘性匹配 | 仅从 lastIndex 位置开始匹配 |
x |
详细/扩展模式 | 允许空白和注释(仅 PCRE/Python) |
x(详细)标志对于记录复杂模式非常有价值:
import re
pattern = re.compile(r"""
^ # 字符串开头
(?P<year>\d{4}) # 4 位年份
-
(?P<month>0[1-9]|1[0-2]) # 月份 01–12
-
(?P<day>0[1-9]|[12]\d|3[01]) # 日期 01–31
$
""", re.VERBOSE)
常用模式与实际示例
电子邮件验证
^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
解析:
^[a-zA-Z0-9._%+-]+— 本地部分(字母、数字及特定特殊字符)@— 字面 @ 符号[a-zA-Z0-9.-]+— 域名\.[a-zA-Z]{2,}$— 2 个以上字母的顶级域名
URL 匹配
https?://(?:www\.)?[a-zA-Z0-9-]+(?:\.[a-zA-Z]{2,})+(?:/[^\s]*)?
ISO 日期(YYYY-MM-DD)
\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])\b
验证月份 01–12 和日期 01–31。注意:不会验证月份对应的最大天数(例如 2 月 30 日会通过验证)。
IPv4 地址
\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b
解析:
25[0-5]— 250–2552[0-4]\d— 200–249[01]?\d\d?— 0–199
美国电话号码
\+?1?\s?[\(]?\d{3}[\)]?[-.\s]?\d{3}[-.\s]?\d{4}
匹配 (555) 123-4567、555-123-4567、+1 555 123 4567 等格式。
十六进制颜色码
#(?:[0-9A-Fa-f]{3}){1,2}\b
同时匹配 3 位(#F00)和 6 位(#FF0000)十六进制颜色。
各编程语言中的正则表达式
JavaScript
// 字面量语法
const regex = /^hello\s+world$/im;
const match = "Hello World".match(regex);
// 构造函数语法(适合动态模式)
const term = "world";
const dynamic = new RegExp(`hello\\s+${term}`, "im");
// 全局替换
const result = "foo bar foo".replaceAll(/foo/g, "baz");
// 命名捕获(ES2018+)
const dateRegex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const { year, month, day } = "2024-03-15".match(dateRegex).groups;
Python
import re
# 预编译以便复用
pattern = re.compile(r'^hello\s+world$', re.IGNORECASE | re.MULTILINE)
match = pattern.match("Hello World")
# 查找所有匹配
dates = re.findall(r'\d{4}-\d{2}-\d{2}', text)
# 使用函数进行替换
result = re.sub(r'\b\d+\b', lambda m: str(int(m.group()) * 2), "1 加 2 等于 3")
# 命名捕获组
m = re.search(r'(?P<year>\d{4})-(?P<month>\d{2})', "2024-03")
print(m.group('year')) # 2024
Java
import java.util.regex.*;
Pattern p = Pattern.compile("^hello\\s+world$",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
Matcher m = p.matcher("Hello World");
boolean found = m.matches();
// 提取分组
Pattern datePattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher dm = datePattern.matcher("今天是 2024-03-15");
if (dm.find()) {
String year = dm.group(1);
}
Go
import "regexp"
re := regexp.MustCompile(`(?im)^hello\s+world$`)
match := re.FindString("Hello World")
// 查找所有子匹配
dateRe := regexp.MustCompile(`(\d{4})-(\d{2})-(\d{2})`)
all := dateRe.FindAllStringSubmatch(text, -1)
for _, m := range all {
year, month, day := m[1], m[2], m[3]
_ = year; _ = month; _ = day
}
性能与灾难性回溯
回溯的工作原理
大多数正则引擎基于 NFA(非确定性有限自动机)进行匹配,这意味着当某个路径失败时,引擎可以尝试多条路径。这种回溯机制使得 lookahead 和反向引用等特性成为可能,但也可能成为性能陷阱。
灾难性案例
考虑将模式 (a+)+ 应用于字符串 "aaaaaX":
- 外层
+尝试尽可能多地匹配组。 - 当引擎到达
X并失败时,它开始回溯,尝试以不同方式将a字符分配给组的各次重复。 - 对于长度为 n 的字符串,存在 2^(n-1) 种可能的分割方式,导致指数级时间复杂度。
(a+)+ 匹配 "aaaaaaaaaaaaaaaaaX" → 可能需要数秒甚至数分钟!
其他危险模式包括 (a|aa)+、(\w+\s*)+,以及任何嵌套量词作用于重叠字符类的情况。
如何避免
- 避免对相同字符集使用嵌套量词:将
(a+)+改为a+。 - 使用原子组
(?>...)或占有量词a++(PCRE)防止引擎回溯到已匹配的组中。 - 提高具体性:用排除分隔符的字符类替换
.*(例如,引号内的字符串用[^"]*)。 - 尽可能使用锚点,让引擎快速失败。
- 在生产代码中设置超时,尤其是处理不受信任的输入时。
如何阅读复杂的正则表达式
将 ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ 拆解:
^ → 锚点:字符串开头
[a-zA-Z0-9._%+-]+ → 一个或多个本地部分允许的字符
@ → 字面 @
[a-zA-Z0-9.-]+ → 一个或多个域名字符
\. → 字面点(已转义)
[a-zA-Z]{2,} → 顶级域名:2 个以上字母
$ → 锚点:字符串结尾
技巧: 使用本站的正则测试工具高亮显示每个捕获组,实时查看每个 token 匹配输入的哪个部分。从最简单的有效片段开始逐步构建模式,验证后再扩展。
最佳实践
- 编译一次,多次使用。 预编译模式(Python 的
re.compile()、Java 的Pattern.compile())比每次调用时重新解析效率高得多。 - 不需要捕获值时,优先使用非捕获组
(?:...)。 这既能表明意图,又能避免不必要的内存分配。 - 模式字符串使用原始字符串。 Python 中使用
r'\d+'而非'\\d+',避免双重转义。JavaScript 的字面量语法/\d+/自动处理此问题。 - 为捕获组命名。
(?<year>\d{4})比依赖\1等索引引用维护性高得多。 - 用边界情况测试: 空字符串、几乎匹配但不完全匹配的字符串、Unicode 字符和超长输入。
- 为复杂模式编写文档,Python/PCRE 中使用
x(详细)标志,或在代码中添加内联注释。 - 永远不要用正则表达式解析完整的 HTML 或 XML。 请使用专用解析器库。
- 在服务器端验证输入。 客户端正则验证可提升用户体验,但不能作为唯一的防线。
常见问题(FAQ)
Q:Python 中 match() 和 search() 有什么区别?
A:re.match() 只从字符串开头开始匹配;re.search() 扫描整个字符串查找匹配。如需要求模式匹配整个字符串,请使用 re.fullmatch()。
Q:字符类 [^abc] 中的 ^ 为什么含义不同?
A:在字符类中,^ 作为第一个字符时表示取反——匹配不在该集合中的任意字符。在字符类之外,^ 是字符串开头的锚点。
Q:可以用正则表达式解析 HTML 吗?
A:对于已知 HTML 结构中的简单、定义明确的提取,正则表达式可以胜任。但 HTML 并不是正则语言——它允许任意嵌套和可选的闭合标签。请使用专用 HTML 解析器(Python 中的 BeautifulSoup、JS 中的 DOMParser)进行健壮的解析。
Q:贪婪量词和占有量词有什么区别?
A:贪婪量词会回溯——先尝试最大匹配,必要时退让字符。占有量词(如 PCRE 中的 a++)不回溯——一旦匹配就锁定结果。这可以防止灾难性回溯,但也可能导致本来可以匹配的情况失败。
Q:如何匹配字面点 . 或圆括号 (?
A:使用反斜杠转义:\. 匹配字面点,\( 匹配字面左圆括号。
Q:正则表达式默认区分大小写吗?
A:是的。使用 i 标志(JavaScript 中的 /pattern/i,Python 中的 re.IGNORECASE)启用不区分大小写的匹配。
Q:\b 匹配什么?
A:\b 是零宽单词边界断言,匹配单词字符(\w)与非单词字符(\W)之间的位置。
Q:如何测试整个字符串是否与模式匹配?
A:使用 ^ 和 $ 锚定:^pattern$。Python 中也可以使用 re.fullmatch()。JavaScript 中使用 .test() 配合锚点,或检查 match()[0].length === input.length。
使用本站的正则测试工具,实时尝试本文中的每一个模式。粘贴任意模式,输入测试字符串,立即查看高亮显示的匹配结果。