正则表达式入门
正则表达式(Regular Expression,简称 regex 或 regexp)是一种用于匹配字符串模式的工具。它本质上是一门专门描述文本规则的微型编程语言,几乎所有现代编程语言都支持正则表达式。
什么是正则表达式
假设你需要在一篇文章中找出所有的邮箱地址,或者验证用户输入的手机号格式是否正确。这些任务如果用手动方式逐个检查,既繁琐又容易出错。正则表达式就是为解决这类问题而生的——它让你用简洁的语法描述文本模式,然后由程序自动完成匹配工作。
正则表达式的核心思想是:用特定的字符组合来描述一类字符串的共同特征。例如,中国手机号码 这个模式可以匹配所有以 1 开头、第二位是 3-9 的数字、总共 11 位的号码。
正则表达式的应用场景
正则表达式几乎出现在所有需要文本处理的场景中。了解它的典型应用,有助于你理解为什么要学习这门"微型语言"。
数据验证
这是正则表达式最常见的用途。当用户在表单中输入数据时,你需要验证格式是否正确:
- 邮箱验证:检查用户输入是否符合邮箱格式
- 手机号验证:确认手机号是否符合特定国家/地区的格式
- 密码强度检查:验证密码是否包含大小写字母、数字、特殊字符
- 身份证号验证:检查身份证号的格式和校验码
- IP 地址验证:验证 IPv4 或 IPv6 地址格式
// 示例:验证中国手机号
/^1[3-9]\d{9}$/.test("13812345678"); // true
/^1[3-9]\d{9}$/.test("12345678901"); // false(第二位不在3-9)
数据提取
从大量文本中提取符合特定模式的信息:
- 提取网页链接:从 HTML 中获取所有 URL
- 提取邮箱地址:从文档中找出所有邮箱
- 提取日志信息:解析服务器日志,提取 IP、时间、请求路径等
- 提取代码结构:从源代码中提取函数名、变量名等
import re
# 示例:从文本中提取所有邮箱
text = "联系我们: [email protected] 或 [email protected]"
emails = re.findall(r'[\w.-]+@[\w.-]+\.\w+', text)
print(emails) # ['[email protected]', '[email protected]']
数据替换与转换
将文本从一种格式转换为另一种格式:
- 敏感信息脱敏:隐藏手机号、身份证号的中间部分
- 格式转换:将日期从 YYYY-MM-DD 转换为 DD/MM/YYYY
- 命名风格转换:驼峰命名与下划线命名互转
- 文本清洗:去除 HTML 标签、多余空白等
// 示例:手机号脱敏
"13812345678".replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
// 结果: "138****5678"
文本搜索与高亮
在编辑器、搜索引擎中查找特定模式:
- 关键词高亮:在搜索结果中高亮显示匹配的关键词
- 代码编辑器:语法高亮、括号匹配
- IDE 搜索:支持正则表达式的全局搜索替换
数据解析
解析结构化或半结构化的文本数据:
- 解析 CSV/TSV:处理逗号或制表符分隔的数据
- 解析配置文件:读取 key=value 格式的配置
- 解析日志文件:提取日志中的结构化信息
- 解析 URL 参数:从 URL 中提取查询参数
import re
# 示例:解析 URL 查询参数
url = "https://example.com?name=张三&age=25"
params = dict(re.findall(r'[?&](\w+)=([^&]*)', url))
print(params) # {'name': '张三', 'age': '25'}
爬虫与数据采集
网页爬虫中广泛使用正则表达式:
- 提取页面链接:获取页面中的所有 URL
- 提取特定内容:从网页中抓取价格、标题、评论等
- 清洗数据:去除 HTML 标签、脚本代码等
正则表达式不能做什么
正则表达式很强大,但它不是万能的。以下场景不适合用正则表达式:
- 解析嵌套结构:如 HTML/XML 的深层嵌套(应使用专用解析器)
- 复杂语法分析:如解析完整的编程语言代码(应使用语法分析器)
- 数学表达式计算:正则表达式无法进行数学运算
- 平衡括号匹配:标准正则表达式无法完美处理任意深度的括号嵌套
能用简单字符串方法解决的问题,就不要用正则表达式。正则表达式虽然强大,但过度使用会使代码难以阅读和维护。
正则表达式的历史
正则表达式的概念源于理论计算机科学。1951年,数学家史蒂芬·科尔·克莱尼(Stephen Cole Kleene)在他的论文《神经网事件的表示》中首次提出了"正则集合"(Regular Sets)的数学概念,用一种称为"正则语言"的形式来描述神经网络的输入输出模式。
1968年,Unix 操作系统的创始人肯·汤普森(Ken Thompson)将这一数学理论应用到计算机编程中。他在 QED 文本编辑器中实现了第一个实用的正则表达式引擎,随后又将这个功能移植到 ed 编辑器和 grep 工具中。
正则表达式的普及得益于 Perl 语言的推动。1987年,拉里·沃尔(Larry Wall)在创建 Perl 语言时,将正则表达式作为语言的一等公民,极大地扩展了正则表达式的功能。Perl 的正则表达式实现成为后来许多编程语言模仿的标准,JavaScript、Python、Java 等语言的正则表达式语法都深受 Perl 的影响。
"正则"(Regular)一词来自数学中的"正则语言"(Regular Language),指的是可以用有限自动机识别的一类形式语言。尽管现代正则表达式已经超越了数学上的"正则"定义,但这个名称一直沿用至今。
第一个正则表达式
让我们从一个简单的例子开始。假设你想判断一个字符串是否包含 "hello":
// JavaScript 示例
/hello/.test("hello world"); // true
/hello/.test("hi there"); // false
# Python 示例
import re
re.search(r"hello", "hello world") # 匹配成功
re.search(r"hello", "hi there") # None
这里的 hello 就是一个正则表达式,它匹配字符串中连续出现的 h-e-l-l-o 这五个字母。这种直接匹配字符的方式称为字面量匹配。
创建正则表达式
不同编程语言创建正则表达式的方式略有不同:
JavaScript
// 方式一:正则表达式字面量(推荐用于固定模式)
const regex1 = /abc/gi;
// 方式二:RegExp 构造函数(用于动态构建模式)
const regex2 = new RegExp("abc", "gi");
字面量方式在脚本加载时就完成编译,性能更好;构造函数方式适合需要动态构建模式的场景。
Python
import re
# 方式一:使用原始字符串(推荐)
pattern1 = re.compile(r"abc", re.IGNORECASE)
# 方式二:直接使用模块函数
match = re.search(r"abc", "abcdef", re.IGNORECASE)
Python 中推荐使用原始字符串(r"..."),这样可以避免反斜杠的转义问题。普通字符串中,反斜杠需要写成 \\,而原始字符串中只需要写一个 \。
字面量匹配
最简单的正则表达式就是字面量匹配——直接匹配指定的字符序列:
/cat/.test("The cat sat"); // true
/cat/.test("category"); // true(包含 "cat")
/cat/.test("dog"); // false
字面量匹配区分大小写。如果要忽略大小写,需要使用标志:
/cat/i.test("CAT"); // true(i 标志忽略大小写)
特殊字符与转义
正则表达式中有一些字符具有特殊含义,称为元字符:
. ^ $ * + ? { } [ ] \ | ( )
这些字符在正则表达式中有特殊功能。如果你想匹配这些字符本身,需要使用反斜杠进行转义:
// 匹配字符串 "a*b"
/a\*b/.test("a*b"); // true
// 匹配字符串 "price: $10"
/price: \$10/.test("price: $10"); // true
// 匹配字面的点号(而不是"任意字符")
/3\.14/.test("3.14"); // true
/3.14/.test("3X14"); // true(. 匹配任意字符,这不是我们想要的)
# 匹配字符串 "a.b"
import re
re.search(r"a\.b", "a.b") # 匹配成功
在字符类 [...] 中,大部分元字符会失去特殊含义,变成普通字符。例如 [.*+] 匹配的就是点号、星号或加号本身。
控制字符转义
正则表达式支持以下控制字符的转义序列:
| 转义序列 | 含义 | Unicode |
|---|---|---|
\t | 水平制表符 | U+0009 |
\n | 换行符 | U+000A |
\r | 回车符 | U+000D |
\v | 垂直制表符 | U+000B |
\f | 换页符 | U+000C |
\0 | NUL 字符 | U+0000 |
[\b] | 退格符(仅在字符类内) | U+0008 |
// 匹配制表符
/\t/.test("a\tb"); // true
// 匹配换行符
/\n/.test("a\nb"); // true
// 匹配回车换行(Windows 风格)
/\r\n/.test("a\r\nb"); // true
// 注意:\b 在字符类外是单词边界,不是退格符
/\b/.test(" word "); // true(单词边界)
/[\b]/.test("\b"); // true(退格符)
十六进制和 Unicode 转义
| 转义序列 | 含义 | 示例 |
|---|---|---|
`\xHH` | 两位十六进制字符 | `\x41` 匹配 'A' |
`\uHHHH` | 四位 Unicode 字符 | `\u4e2d` 匹配 '中' |
`\u\{H...\}` | Unicode 代码点(需 u 标志) | `\u\{1F600\}` 匹配 '😀' |
// 十六进制转义
/\x41/.test("A"); // true(\x41 = 65 = 'A')
/\x4e2d/.test("中"); // false(\x 只接受两位)
// Unicode 转义(四位)
/\u4e2d/.test("中"); // true
// Unicode 代码点转义(需要 u 标志)
/\u\{1F600\}/u.test("😀"); // true
/\u\{1F600\}/.test("😀"); // false(没有 u 标志)
控制字符转义
使用 \cX 匹配控制字符,其中 X 是 A-Z 的字母:
// \cM 匹配 Ctrl+M(回车符)
/\cM/.test("\r"); // true
// \cJ 匹配 Ctrl+J(换行符)
/\cJ/.test("\n"); // true
Python 特有转义
Python 还支持以下特殊转义:
| 转义序列 | 含义 |
|---|---|
\A | 字符串开头(不受 MULTILINE 影响) |
\Z | 字符串结尾(匹配换行符之前的位置) |
\z | 字符串绝对结尾(Python 3.14+) |
import re
# \A 和 ^ 的区别
text = "line1\nline2"
re.findall(r'^\w+', text, re.M) # ['line1', 'line2']
re.findall(r'\A\w+', text, re.M) # ['line1']
# \Z 和 $ 的区别
text = "hello\n"
re.search(r'o$', text, re.M) # 匹配成功($ 匹配换行符之前)
re.search(r'o\Z', text) # 匹配成功(\Z 也匹配换行符之前)
字符类
字符类(Character Class)允许你定义一个字符集合,匹配其中的任意一个字符。字符类用方括号 [] 表示。
基本用法
// 匹配元音字母中的任意一个
/[aeiou]/.test("apple"); // true,匹配到 'a'
/[aeiou]/.test("xyz"); // false,没有元音字母
// 匹配数字中的任意一个
/[0123456789]/.test("abc5def"); // true,匹配到 '5'
重要理解:字符类匹配的是单个字符,而不是整个集合。[aeiou] 匹配 a、e、i、o、u 中任意一个字符,而不是 "aeiou" 这个字符串。
字符类内的特殊规则
字符类内部有一些特殊的规则:
规则一:大多数元字符失去特殊含义
在字符类内部,除了 ]、\、^、- 之外,其他特殊字符都变成普通字符:
// 在字符类内,. 就是字面的点号
/[a.c]/.test("abc"); // true,匹配 'a' 或 '.' 或 'c'
/[a.c]/.test("a1c"); // false,'1' 不在集合中
// 其他例子
/[.*+?]/.test("a*b"); // true,匹配 '*'
/[()*$]/.test("a(b"); // true,匹配 '('
规则二:^ 在开头表示否定
// ^ 在开头:否定字符类,匹配不在集合中的字符
/[^abc]/.test("a"); // false,'a' 在排除范围内
/[^abc]/.test("d"); // true,'d' 不在排除范围内
// ^ 不在开头:匹配字面的 ^
/[a^b]/.test("^"); // true
规则三:- 表示范围
// - 在中间:表示范围
/[a-z]/.test("m"); // true
/[0-9]/.test("5"); // true
/[A-Za-z]/.test("G"); // true
// - 在开头或结尾:匹配字面的 -
/[-abc]/.test("-"); // true
/[abc-]/.test("-"); // true
预定义字符类
为了简化常用模式,正则表达式提供了预定义的字符类:
| 字符类 | 含义 | 等价形式 |
|---|---|---|
. | 任意字符(默认不含换行) | - |
\d | 数字 | [0-9] |
\D | 非数字 | [^0-9] |
\w | 单词字符(字母、数字、下划线) | [a-zA-Z0-9_] |
\W | 非单词字符 | [^a-zA-Z0-9_] |
\s | 空白字符(空格、制表符、换行等) | [ \t\n\r\f\v] |
\S | 非空白字符 | [^ \t\n\r\f\v] |
// \d - 数字
/\d/.test("5"); // true
/\d/.test("a"); // false
/\d\d\d/.test("123"); // true,三个连续数字
// \w - 单词字符
/\w/.test("a"); // true
/\w/.test("_"); // true
/\w/.test("-"); // false
// \s - 空白字符
/\s/.test(" "); // true,空格
/\s/.test("\t"); // true,制表符
/\s/.test("\n"); // true,换行符
量词
量词(Quantifier)用于指定前面元素出现的次数。
基本量词
| 量词 | 含义 | 示例 |
|---|---|---|
* | 零次或多次 | a* 匹配 ""、"a"、"aaa" |
+ | 一次或多次 | a+ 匹配 "a"、"aaa",不匹配 "" |
? | 零次或一次 | colou?r 匹配 "color" 或 "colour" |
// * 零次或多次
/ca*t/.test("ct"); // true(零个 a)
/ca*t/.test("cat"); // true(一个 a)
/ca*t/.test("caaat"); // true(三个 a)
// + 一次或多次
/ca+t/.test("ct"); // false(至少需要一个 a)
/ca+t/.test("cat"); // true
// ? 零次或一次
/colou?r/.test("color"); // true
/colou?r/.test("colour"); // true
精确量词
使用花括号 {} 可以精确控制匹配次数:
// 恰好 n 次
/\d{4}/.test("2024"); // true,4 位数字
/\d{4}/.test("24"); // false
// 至少 n 次
/\d{2,}/.test("1"); // false
/\d{2,}/.test("123456"); // true
// n 到 m 次
/\d{2,4}/.test("12"); // true
/\d{2,4}/.test("1234"); // true
// 实际应用:匹配中国手机号
/^1[3-9]\d{9}$/.test("13812345678"); // true
贪婪与非贪婪
贪婪匹配是量词的默认行为,会尽可能多地匹配字符:
const html = "<div>内容1</div><div>内容2</div>";
// 贪婪匹配:.* 匹配尽可能多
html.match(/<div>.*<\/div>/);
// ["<div>内容1</div><div>内容2</div>"]
// .* 匹配了 "内容1</div><div>内容2"
在量词后加 ? 变成非贪婪匹配,会尽可能少地匹配:
// 非贪婪匹配:.*? 匹配尽可能少
html.match(/<div>.*?<\/div>/);
// ["<div>内容1</div>"]
| 贪婪量词 | 非贪婪量词 | 行为差异 |
|---|---|---|
* | *? | 尽可能多 vs 尽可能少 |
+ | +? | 尽可能多 vs 尽可能少 |
? | ?? | 优先匹配 vs 优先不匹配 |
{n,m} | {n,m}? | 尽可能多 vs 尽可能少 |
位置锚点
锚点用于匹配字符串中的特定位置,而不是具体字符:
| 锚点 | 含义 |
|---|---|
^ | 字符串开头 |
$ | 字符串结尾 |
\b | 单词边界 |
\B | 非单词边界 |
// ^ 匹配开头
/^hello/.test("hello world"); // true
/^hello/.test("say hello"); // false
// $ 匹配结尾
/world$/.test("hello world"); // true
/world$/.test("world peace"); // false
// ^ 和 $ 组合进行全匹配
/^hello$/.test("hello"); // true
/^hello$/.test("hello world"); // false
单词边界
单词边界 \b 匹配单词字符(\w)和非单词字符之间的位置:
/\bcat\b/.test("the cat sat"); // true,cat 是独立单词
/\bcat\b/.test("concatenate"); // false,cat 是单词的一部分
// 实际应用:替换单词
"the cat sat".replace(/\bcat\b/g, "dog"); // "the dog sat"
"concatenate".replace(/\bcat\b/g, "dog"); // "concatenate"(不变)
选择结构
竖线 | 表示"或"的关系:
/(cat|dog|bird)/.test("I have a cat"); // true
/(cat|dog|bird)/.test("I have a fish"); // false
// 结合分组使用
/(大|小)(红|白)猫/.test("大红猫"); // true
/(大|小)(红|白)猫/.test("小白猫"); // true
选择结构的优先级较低,通常需要配合分组使用:
// 错误:这会匹配 "gray" 或 "ay"
/gr|ay/.test("ay"); // true(不是我们想要的)
// 正确:使用分组
/gr(a|e)y/.test("gray"); // true
/gr(a|e)y/.test("grey"); // true
分组基础
使用圆括号 () 可以将正则表达式的一部分组合成一个单元:
分组与量词
// 没有分组:只有 b 重复
/ab+/.test("abbb"); // true,匹配 "abbb"
/ab+/.test("abab"); // false
// 使用分组:(ab) 成为一个整体
/(ab)+/.test("abab"); // true,匹配 "abab"
捕获与提取
分组还会"捕获"匹配的内容,可以后续提取:
const date = "2024-03-15";
const match = date.match(/(\d{4})-(\d{2})-(\d{2})/);
console.log(match[0]); // "2024-03-15" - 完整匹配
console.log(match[1]); // "2024" - 第一个分组
console.log(match[2]); // "03" - 第二个分组
console.log(match[3]); // "15" - 第三个分组
分组按照左括号出现的位置从左到右编号,编号从 1 开始(0 是整个匹配)。
import re
date = "2024-03-15"
match = re.search(r'(\d{4})-(\d{2})-(\d{2})', date)
print(match.group(0)) # "2024-03-15"
print(match.group(1)) # "2024"
print(match.group(2)) # "03"
print(match.group(3)) # "15"
print(match.groups()) # ('2024', '03', '15')
关于分组的更多内容(非捕获分组、命名分组、反向引用等),请参阅 分组与捕获 章节。
标志(修饰符)
标志用于修改正则表达式的匹配行为:
| 标志 | 含义 | JavaScript | Python |
|---|---|---|---|
i | 忽略大小写 | /abc/i | re.I |
g | 全局匹配 | /abc/g | - |
m | 多行模式 | /abc/m | re.M |
s | dotAll(. 匹配换行) | /abc/s | re.S |
u | Unicode 模式 | /abc/u | 默认 |
// i: 忽略大小写
/hello/i.test("HELLO"); // true
// g: 全局匹配(找到所有匹配)
"hello hello".match(/hello/g); // ["hello", "hello"]
// m: 多行模式(^ 和 $ 匹配每行开头和结尾)
/^hello$/m.test("world\nhello"); // true
// s: dotAll 模式(. 匹配换行符)
/hello.world/s.test("hello\nworld"); // true
import re
# 忽略大小写
re.search(r"hello", "HELLO", re.IGNORECASE)
# 多行模式
re.search(r"^hello$", "world\nhello", re.MULTILINE)
# dotAll 模式
re.search(r"hello.world", "hello\nworld", re.DOTALL)
正则表达式的工作原理
理解正则表达式的工作原理,能帮助你写出更高效的表达式。
NFA 引擎
大多数编程语言(JavaScript、Python、Java、Ruby、PHP 等)的正则引擎采用 NFA(非确定性有限自动机) 模型。这种引擎有一个重要特点:匹配顺序取决于正则表达式的编写顺序。
这意味着同样的匹配目标,不同的正则写法可能导致截然不同的性能表现。
回溯机制
当正则引擎遇到量词或选择结构时,它会保存一个"回溯点"。如果后续匹配失败,引擎会回到这个点,尝试其他匹配路径。
// 理解回溯:贪婪匹配的工作过程
const text = "abbbc";
const pattern = /a.*c/;
/*
匹配过程:
1. a 匹配 'a'
2. .* 贪婪地匹配剩余所有字符:"bbbc"
3. c 需要匹配字符,但字符串已到末尾,失败
4. 触发回溯:.* "吐出" 一个字符,现在匹配 "bbb"
5. c 尝试匹配 'c',成功!
结果:匹配成功,.* 最终匹配的是 "bbb"
*/
回溯灾难
当正则表达式包含嵌套的量词时,回溯次数可能呈指数级增长:
// 危险模式:嵌套量词
const dangerous = /(a+)+b/;
// 对于字符串 "aaaa...aac"(不匹配)
// 组合数量约为 2^n,n 是 a 的个数
// 可能导致程序卡死
在生产环境中,永远不要使用嵌套量词模式如 (a+)+。这类模式在匹配失败时会产生指数级的回溯次数,可能导致服务器拒绝服务。
安全的替代写法:
// 移除嵌套
const safe = /a+b/;
关于性能优化的更多内容,请参阅 性能优化与最佳实践 章节。
实践练习
尝试写出匹配以下模式的正则表达式:
- 匹配邮箱地址:包含
@符号,前面是用户名,后面是域名 - 匹配手机号:11 位数字,以 1 开头,第二位是 3-9
- 匹配日期格式:YYYY-MM-DD 格式
点击查看参考答案
// 1. 简化版邮箱
/^[\w.-]+@[\w.-]+\.\w{2,}$/
// 2. 中国手机号
/^1[3-9]\d{9}$/
// 3. 日期格式
/^\d{4}-\d{2}-\d{2}$/
小结
本章介绍了正则表达式的基础知识:
- 创建方式:字面量和构造函数
- 字面量匹配:直接匹配指定的字符序列
- 转义:使用
\匹配特殊字符本身 - 字符类:
[]定义字符集合,\d、\w、\s等预定义类 - 量词:
*、+、?、{n,m}控制匹配次数 - 贪婪与非贪婪:默认贪婪,加
?变非贪婪 - 锚点:
^、$、\b匹配位置 - 分组:
()组合和捕获 - 选择:
|表示"或" - 标志:
i、g、m等修改匹配行为
掌握这些基础概念后,你已经可以写出实用的正则表达式了。接下来的章节将深入讲解:
- 分组与捕获:非捕获分组、命名分组、反向引用
- 零宽断言:先行断言和后行断言
- 正则表达式进阶:原子组、条件匹配等高级特性
- 实战案例:数据验证、提取、处理等实际应用
- 性能优化与最佳实践:避免回溯灾难,写出高效的正则