跳到主要内容

正则表达式进阶

在掌握了正则表达式的基础知识后,本章将深入探讨更高级的特性,包括原子组、占有量词、条件匹配、Unicode 属性等。这些特性能够帮助你解决更复杂的文本处理问题。

原子组与占有量词

原子组和占有量词用于控制回溯行为,在防止回溯灾难和优化性能方面非常重要。

为什么需要控制回溯

普通量词在匹配失败时会回溯,尝试其他可能的匹配路径。但在某些情况下,回溯是不必要的,甚至会带来性能问题。

// 普通贪婪匹配的回溯过程
const text = "aaaaa";

// a+a 会匹配成功,但需要回溯:
// 1. a+ 匹配所有 5 个 'a'
// 2. 最后的 'a' 没有字符可匹配,失败
// 3. 回溯:a+ 放弃一个 'a',匹配 4 个
// 4. 最后的 'a' 匹配第 5 个 'a',成功
/a+a/.test(text); // true

原子组 (?>...)

原子组内的匹配一旦成功,就不会回溯。这意味着原子组内的量词会"锁定"它们的匹配,不会释放给组外的模式使用。

import re

# Python 3.11+ 支持原子组

# 没有原子组:会回溯
pattern1 = re.compile(r'\d+\d')
result = pattern1.match('123')
print(result) # 匹配成功
# 解释:\d+ 先匹配 "123",然后回溯一位,让最后的 \d 匹配 "3"

# 使用原子组:不回溯
pattern2 = re.compile(r'(?>\d+)\d')
result = pattern2.match('123')
print(result) # None
# 解释:(?>\d+) 匹配 "123" 后不会回溯,没有字符留给最后的 \d
// Java 支持原子组
import java.util.regex.*;

Pattern p1 = Pattern.compile("\\d+\\d");
Matcher m1 = p1.matcher("123");
System.out.println(m1.matches()); // true

Pattern p2 = Pattern.compile("(?>\\d+)\\d");
Matcher m2 = p2.matcher("123");
System.out.println(m2.matches()); // false

占有量词

占有量词是原子组的简化写法。在普通量词(*+?{m,n})后加 +,表示该量词匹配后不回溯。

普通量词占有量词等价的原子组
**+(?>.*)
+++(?>.+)
??+(?>.?)
{n}{n}+(?>.{n})
{n,}{n,}+(?>.{n,})
{n,m}{n,m}+(?>.{n,m})
import re

# Python 3.11+ 支持占有量词
text = "aaaaa"

# 普通贪婪匹配
r1 = re.compile(r'a+a')
print(r1.match(text)) # 匹配成功

# 占有量词
r2 = re.compile(r'a++a')
print(r2.match(text)) # None,a++ 匹配所有 5 个 'a' 后不回溯
// Java 支持占有量词
Pattern p1 = Pattern.compile("a+a");
Matcher m1 = p1.matcher("aaaaa");
System.out.println(m1.matches()); // true

Pattern p2 = Pattern.compile("a++a");
Matcher m2 = p2.matcher("aaaaa");
System.out.println(m2.matches()); // false

实际应用场景

场景一:防止回溯灾难

import re

# 危险模式:可能导致回溯灾难
dangerous = re.compile(r'(a+)+b')

# 使用占有量词优化(Python 3.11+)
# 如果字符串不匹配,会快速失败而不是指数级回溯
optimized = re.compile(r'(a++)b')

场景二:解析固定格式数据

import re

# 解析类似 "key=value" 的配置行
# 要求 key 不能包含等号

# 使用原子组确保 key 部分不会"吃掉"等号
pattern = re.compile(r'^(?>[^=]+)=(.*)$')

line = "name=value=with=equals"
match = pattern.match(line)
if match:
print(match.group(1)) # "name"
print(match.group(2)) # "value=with=equals"

各语言支持情况

特性JavaScriptPythonJavaPCREGo
原子组 (?>...)✅ (3.11+)
占有量词 *+ ++ ?+✅ (3.11+)
兼容性说明
  • JavaScript:不支持原子组和占有量词。如果需要类似功能,可以使用具体字符类替代 .*,如 [^X]*
  • Python:3.11 版本开始支持原子组和占有量词。
  • Java:完整支持原子组和占有量词。

条件匹配

条件匹配允许根据某个条件选择不同的匹配分支。这个功能在 Python 中通过 (?(id/name)yes-pattern|no-pattern) 语法实现。

基本语法

条件匹配的语法格式:

(?(id/name)yes-pattern|no-pattern)
  • id/name:分组编号或名称
  • yes-pattern:如果分组参与匹配,则使用此模式
  • no-pattern:如果分组未参与匹配,则使用此模式(可选)

使用场景

场景一:匹配可选引号包围的内容

import re

# 匹配带引号或不带引号的邮箱地址
# 如果有开头的 <,则必须匹配结尾的 >
# 如果没有 <,则直接匹配邮箱
pattern = re.compile(r'(<)?(\w+@\w+(?:\.\w+)+)(?(1)>|)')

# 测试
print(pattern.match('[email protected]')) # 匹配成功
print(pattern.match('<[email protected]>')) # 匹配成功
print(pattern.match('<[email protected]')) # None(有开头 < 但没有结尾 >)

解析这个模式:

  • (<)? - 可选的 <,这是第 1 组
  • (\w+@\w+(?:\.\w+)+) - 邮箱地址,这是第 2 组
  • (?(1)>|) - 条件匹配:如果第 1 组匹配了,则需要 >;否则匹配空

场景二:使用命名分组的条件匹配

import re

# 使用命名分组
pattern = re.compile(r'(?P<quote>["\'])?\w+(?(quote)(?P=quote)|)')

# 测试
print(pattern.match('"hello"')) # 匹配成功
print(pattern.match("'hello'")) # 匹配成功
print(pattern.match('hello')) # 匹配成功(无引号)
print(pattern.match('"hello')) # None(引号不配对)

场景三:根据前缀选择匹配模式

import re

# 匹配电话号码,根据是否有区号选择格式
# 如果有区号(分组1参与匹配),则需要分隔符
pattern = re.compile(r'(\(\d{3}\))?(\d{3})(?(1)-\d{4}|\d{4})')

# 测试
print(pattern.match('1234567')) # 匹配:1234567
print(pattern.match('(123)456-7890')) # 匹配:(123)456-7890
兼容性说明

条件匹配在 Python、PCRE、.NET 中支持,但 JavaScript 和 Java 不支持。在 JavaScript 中,通常需要使用多个正则表达式分别匹配,或使用编程逻辑判断。

Unicode 属性转义

Unicode 属性转义允许你基于 Unicode 字符属性进行匹配,这在处理国际化文本时非常有用。

JavaScript Unicode 属性

JavaScript 从 ES2018 开始支持 Unicode 属性转义,需要使用 uv 标志:

// \p{L} - 任何字母
/\p{L}+/u.test("Hello世界"); // true

// \p{N} - 任何数字
/\p{N}+/u.test("123١٢٣"); // true(包括阿拉伯数字)

// \p{Han} - 汉字
/\p{Han}+/u.test("你好世界"); // true

// \p{Emoji} - Emoji
/\p{Emoji}/u.test("😀"); // true

常用 Unicode 属性

属性含义示例
\p\{L\}字母A, 中, あ
\p\{Lu\}大写字母A, Z
\p\{Ll\}小写字母a, z
\p\{N\}数字0-9, ١٢٣
\p\{Nd\}十进制数字0-9
\p\{P\}标点符号. , !
\p\{Han\}汉字中, 文
\p\{Emoji\}Emoji😀, 👋

Python Unicode 支持

Python 默认使用 Unicode,但 \p{...} 语法需要使用第三方 regex 模块:

# 标准库 re 模块:使用 Unicode 范围
import re

# 匹配中文(基本汉字范围)
re.findall(r"[\u4e00-\u9fff]+", "Hello 世界!")
# ['世界']

# 使用 regex 模块获得更好的 Unicode 支持
# pip install regex
import regex
pattern = regex.compile(r'\p{Han}+')
pattern.findall("Hello 世界!") # ['世界']

实际应用

// 匹配任何语言的单词字符
const anyWord = /\p{L}+/gu;
anyWord.test("Hello世界こんにちは"); // true

// 匹配非 ASCII 数字
const nonAsciiDigit = /\p{Nd}+/gu;
nonAsciiDigit.test("١٢٣"); // true(阿拉伯数字)

// 匹配中文标点
const chinesePunc = /\p{P}/gu;
chinesePunc.test(",。!?"); // true

v 标志:Unicode Sets 模式

ES2024 引入了 v 标志(unicodeSets),这是 u 标志的升级版,提供了更强大的 Unicode 支持和字符类操作。注意:vu 标志不能同时使用。

字符串属性

v 标志支持 Unicode 字符串属性,可以匹配多码点组成的字符序列:

// 使用 u 标志 - 只能匹配单个码点
/\p\{Emoji\}/u.test("👨‍⚕️"); // false(多码点 Emoji 无法完整匹配)

// 使用 v 标志 - 可以匹配字符串属性
/\p\{RGI_Emoji\}/v.test("👨‍⚕️"); // true

集合运算

v 标志支持在字符类中进行集合运算:

差集运算(--

// 匹配希腊字母,但排除字母 Σ
/[\p\{Greek\}--Σ]/v.test("α"); // true
/[\p\{Greek\}--Σ]/v.test("Σ"); // false

// 匹配非 ASCII 数字
const nonAsciiDigits = /[\p\{Decimal_Number\}--[0-9]]/v;
nonAsciiDigits.test("६"); // true(天城文数字)
nonAsciiDigits.test("5"); // false

交集运算(&&

// 匹配既是希腊字母又是大写字母的字符
/[\p\{Greek\}&&\p\{Uppercase\}]/v.test("Ω"); // true
/[\p\{Greek\}&&\p\{Uppercase\}]/v.test("ω"); // false(小写)

字符串字面量语法

v 标志允许在字符类中使用字符串字面量:

// 匹配 "ab" 或 "cd" 或 "ef"
/[\q\{ab|cd|ef\}]/v.test("ab"); // true
/[\q\{ab|cd|ef\}]/v.test("a"); // false(单个字符不匹配)

浏览器兼容性

特性ChromeFirefoxSafariEdgeNode.js
v 标志112+116+17+112+20+

新标志详解

d 标志:匹配索引

ES2022 引入了 d 标志(hasIndices),它让匹配结果包含每个捕获组的起始和结束位置索引:

// 不使用 d 标志
const match1 = "hello world".match(/(world)/);
console.log(match1.index); // 6 - 只有完整匹配的起始位置

// 使用 d 标志
const match2 = "hello world".match(/(world)/d);
console.log(match2.indices); // [[6, 11], [6, 11]]
console.log(match2.indices[0]); // [6, 11] - 完整匹配的位置
console.log(match2.indices[1]); // [6, 11] - 第一个分组的位置

// 多个分组的情况
const date = "2024-03-15";
const regex = /(\d{4})-(\d{2})-(\d{2})/d;
const match = date.match(regex);

console.log(match.indices);
// [[0, 10], [0, 4], [5, 7], [8, 10]]
// [完整匹配, 第1组(年), 第2组(月), 第3组(日)]

// 命名分组的索引
const regex2 = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/d;
const match3 = "2024-03-15".match(regex2);
console.log(match3.indices.groups.year); // [0, 4]
console.log(match3.indices.groups.month); // [5, 7]
console.log(match3.indices.groups.day); // [8, 10]

实际应用场景:高亮搜索结果、提取文本位置、编辑器语法高亮等。

Modifier:内联标志

ES2024 引入了 Modifier 语法,允许在正则表达式内部临时启用或禁用特定标志:

// 只在特定部分启用 i 标志
const regex1 = /(?i:hello) world/;

regex1.test("hello world"); // true
regex1.test("HELLO world"); // true(hello 部分不区分大小写)
regex1.test("hello WORLD"); // false(world 部分区分大小写)

// 全局启用 i 标志,但在特定部分禁用
const regex2 = /hello (?-i:world)/i;

regex2.test("hello world"); // true
regex2.test("HELLO world"); // true
regex2.test("hello WORLD"); // false(world 部分区分大小写)

语法格式

(?flags1-flags2:pattern)
  • flags1:要启用的标志(可选)
  • -:分隔符
  • flags2:要禁用的标志(可选)

重要限制

  • 只能使用 ims 三个标志
  • flags1flags2 至少要有一个非空
  • 两个标志集合中不能有重复的标志

RegExp.escape()

RegExp.escape() 是 ES2025 正式引入的静态方法,用于安全地转义字符串中的正则表达式特殊字符:

// 传统方法:手动转义容易遗漏边界情况
const userInput = "a*b+c?d";
const regex1 = new RegExp(userInput.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));

// 使用 escape:安全且完整
const escaped = RegExp.escape(userInput);
// 结果类似: "a\\*b\\+c\\?d"(实际实现可能使用十六进制转义)
const regex2 = new RegExp(escaped);
regex2.test("a*b+c?d"); // true

// 实际应用:安全地使用用户输入构建正则表达式
function findExact(text, searchTerm) {
const escaped = RegExp.escape(searchTerm);
const regex = new RegExp(escaped, 'gi');
return text.match(regex);
}

findExact("The price is $100", "$100"); // ["$100"]

规范说明:根据 TC39 提案,RegExp.escape() 采用安全转义策略,对 ASCII 标点符号和空格等字符进行转义(可能使用 \xHH 十六进制格式),以确保生成的字符串在任何正则表达式上下文中都是安全的字面量。这比简单的 \ 转义更可靠,能处理各种边界情况。

// 示例输出(具体格式可能因实现而异)
RegExp.escape("(*.*)"); // "\\(\\*\\.\\*\\)"
RegExp.escape("[email protected]"); // 转义 @ 和 . 等字符

其他语言的对应方法

语言方法
Pythonre.escape()
JavaPattern.quote()
RubyRegexp.escape()
PHPpreg_quote()
C#Regex.Escape()
Goregexp.QuoteMeta()
Rustregex::escape()

递归模式与平衡组

某些正则引擎支持递归模式,用于匹配嵌套结构。

PCRE/Python regex 模块的递归模式

# 需要安装 regex 模块:pip install regex
import regex

# 匹配嵌套的括号
pattern = regex.compile(r"\((?:[^()]|(?R))*\)")

# 测试
pattern.match("(a)") # 匹配
pattern.match("(a(b)c)") # 匹配
pattern.match("(a(b(c)d)e)") # 匹配
pattern.match("(a(b)c") # 不匹配(不平衡)

JavaScript 有限层级嵌套

JavaScript 不支持递归,但可以匹配有限层级的嵌套:

// 匹配最多 3 层嵌套的括号
const nested3 = /\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\)/;

nested3.test("(a)"); // true
nested3.test("(a(b)c)"); // true
nested3.test("(a(b(c)d)e)"); // true
nested3.test("(a(b(c(d)e)f)g)"); // false(超出 3 层)

正则表达式引擎差异

了解不同正则引擎的差异,可以帮助你编写跨平台的正则表达式。

NFA vs DFA

NFA(非确定性有限自动机)

  • 大多数编程语言使用(JavaScript、Python、Java、Ruby、PHP 等)
  • 匹配顺序取决于正则表达式的编写顺序
  • 支持捕获分组、反向引用、零宽断言等高级特性
  • 性能高度依赖于正则表达式的写法

DFA(确定性有限自动机)

  • 某些专用工具使用(如 grep 的某些模式、awk)
  • 同时尝试所有可能的匹配路径
  • 匹配速度快,不会出现回溯灾难
  • 不支持捕获分组、反向引用等高级特性

特性支持对比

特性JavaScriptPythonJavaGoRust
命名分组ES2018+
后行断言ES2018+
原子组3.11+
占有量词3.11+
条件匹配
递归模式regex模块

编写跨平台正则表达式的建议

  1. 避免使用引擎特定特性:如果需要跨平台,避免使用原子组、条件匹配等
  2. 测试兼容性:在目标语言中测试正则表达式
  3. 简化复杂模式:用代码逻辑替代复杂的正则特性
  4. 使用标准语法:字符类、量词、分组等基本语法是通用的

小结

本章介绍了正则表达式的高级特性:

  • 原子组 (?>...):防止内部回溯
  • 占有量词 *+ ++:原子组的简化写法
  • 条件匹配 (?(id)yes|no):根据条件选择分支
  • Unicode 属性 \p{...}:基于 Unicode 属性匹配
  • v 标志:Unicode Sets 模式,支持集合运算
  • d 标志:获取匹配索引
  • Modifier:内联标志控制
  • RegExp.escape():安全转义特殊字符
  • 引擎差异:NFA vs DFA,跨平台注意事项

掌握这些高级特性后,你可以处理更复杂的文本处理任务。但请记住,正则表达式并非万能——对于过于复杂的模式,考虑使用解析器或其他工具可能更合适。

参考资源