跳到主要内容

正则表达式入门

正则表达式(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
\0NUL 字符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')

关于分组的更多内容(非捕获分组、命名分组、反向引用等),请参阅 分组与捕获 章节。

标志(修饰符)

标志用于修改正则表达式的匹配行为:

标志含义JavaScriptPython
i忽略大小写/abc/ire.I
g全局匹配/abc/g-
m多行模式/abc/mre.M
sdotAll(. 匹配换行)/abc/sre.S
uUnicode 模式/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/;

关于性能优化的更多内容,请参阅 性能优化与最佳实践 章节。

实践练习

尝试写出匹配以下模式的正则表达式:

  1. 匹配邮箱地址:包含 @ 符号,前面是用户名,后面是域名
  2. 匹配手机号:11 位数字,以 1 开头,第二位是 3-9
  3. 匹配日期格式: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 匹配位置
  • 分组() 组合和捕获
  • 选择| 表示"或"
  • 标志igm 等修改匹配行为

掌握这些基础概念后,你已经可以写出实用的正则表达式了。接下来的章节将深入讲解:

参考资源