跳到主要内容

分组与捕获

分组与捕获是正则表达式中最核心、最实用的特性之一。理解分组的工作原理,能让你更灵活地处理复杂的文本匹配和提取任务。本章将深入讲解分组的各种形式、捕获的工作机制,以及在不同编程语言中的应用。

为什么需要分组

在没有分组之前,量词只能修饰单个字符。例如 a+ 匹配一个或多个 a[abc]+ 匹配一个或多个 abc。但如果想要匹配一个或多个 abc 这样的完整字符串,就需要用到分组。

// 没有 grouped 的情况:只有 b 重复
/ab+/.test("abbb"); // true,匹配 "abbb"
/ab+/.test("abab"); // false,"ab" 没有整体重复

// 使用分组:(ab) 成为一个整体
/(ab)+/.test("abab"); // true,匹配 "abab"
/(ab)+/.test("abbb"); // false,"ab" 整体重复才能匹配

分组有两个核心作用:

  • 分组作用:将多个字符组合成一个单元,可以对其应用量词
  • 捕获作用:保存匹配的内容,供后续引用或提取

基本分组语法

捕获分组 (...)

最常用的分组形式是捕获分组,用圆括号 (...) 表示。括号内的内容会被"捕获"并保存,可以通过索引访问。

// 分组配合量词使用
const pattern1 = /(ab)+/; // 匹配一个或多个 "ab"
const pattern2 = /(abc){2,4}/; // 匹配 2 到 4 个 "abc"

// 分组配合选择结构使用
const pattern3 = /(cat|dog)/; // 匹配 "cat" 或 "dog"
const pattern4 = /gr(a|e)y/; // 匹配 "gray" 或 "grey"

分组编号规则

当正则表达式包含多个分组时,它们按照左括号出现的顺序进行编号:

const regex = /(\w+)-(\w+)-(\w+)/;
// ^^^^^ 第1组
// ^^^^^ 第2组
// ^^^^^ 第3组

const match = regex.exec("abc-def-ghi");
console.log(match[0]); // "abc-def-ghi" - 完整匹配
console.log(match[1]); // "abc" - 第1组
console.log(match[2]); // "def" - 第2组
console.log(match[3]); // "ghi" - 第3组

嵌套分组的编号也遵循同样的规则:

const nested = /((a)(b))/;
// ^^^^^^^ 第1组:(a)(b)
// ^^^ 第2组:(a)
// ^^^ 第3组:(b)

const m = nested.exec("ab");
console.log(m[1]); // "ab" - 第1组
console.log(m[2]); // "a" - 第2组
console.log(m[3]); // "b" - 第3组

索引 0 的含义

在匹配结果中,索引 0 始终是整个正则表达式的匹配结果,而索引 1、2、3... 才是各个捕获分组的内容:

import re

pattern = re.compile(r'(\d{4})-(\d{2})-(\d{2})')
match = pattern.match('2024-03-15')

print(match.group(0)) # "2024-03-15" - 完整匹配
print(match.group(1)) # "2024" - 第1组
print(match.group(2)) # "03" - 第2组
print(match.group(3)) # "15" - 第3组

# 获取所有分组
print(match.groups()) # ('2024', '03', '15')

非捕获分组 (?:...)

当你只需要分组的"分组功能",不需要"捕获功能"时,可以使用非捕获分组 (?:...)

语法与用法

// 捕获分组:会保存匹配内容
const capture = /(abc)(\d+)/;
const m1 = "abc123".match(capture);
console.log(m1.length); // 3(完整匹配 + 2个分组)
console.log(m1[1]); // "abc"
console.log(m1[2]); // "123"

// 非捕获分组:不保存匹配内容
const nonCapture = /(?:abc)(\d+)/;
const m2 = "abc123".match(nonCapture);
console.log(m2.length); // 2(完整匹配 + 1个分组)
console.log(m2[1]); // "123"(abc 没有被捕获)

为什么使用非捕获分组

1. 性能优化

非捕获分组不需要保存匹配结果,在处理大量文本时可以节省内存和 CPU 时间。

2. 简化分组编号

当正则表达式很复杂时,非捕获分组不会影响捕获分组的编号:

// 使用捕获分组:分组编号混乱
const url1 = /(https?:\/\/)?([\w.-]+)(\/.*)?/;
// 分组1: 协议,分组2: 域名,分组3: 路径

// 使用非捕获分组:编号更清晰
const url2 = /(?:https?:\/\/)?([\w.-]+)(?:\/.*)?/;
// 分组1: 域名(协议和路径不捕获)

3. 明确意图

使用非捕获分组可以告诉代码阅读者:这部分内容只是为了分组,不需要提取。

实际应用示例

// 匹配带可选区号的电话号码
const phone = /(?:\d{3}-)?\d{4}-\d{4}/;
// (?:\d{3}-)? 是非捕获分组,表示可选的区号

// 匹配十六进制颜色值
const hexColor = /#(?:[0-9a-fA-F]{3}){1,2}/;
// (?:[0-9a-fA-F]{3}){1,2} 匹配 3 位或 6 位十六进制

// 匹配 URL 的域名部分
const domain = /(?:https?:\/\/)?([\w.-]+)/;
// 只捕获域名,不捕获协议前缀

命名分组 (?<name>...)

命名分组允许你为分组指定一个有意义的名称,使代码更具可读性。这在处理复杂正则表达式时特别有用。

JavaScript 中的命名分组

JavaScript 从 ES2018 开始支持命名分组:

const date = "2024-03-15";
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = date.match(regex);

// 通过名称访问分组
console.log(match.groups.year); // "2024"
console.log(match.groups.month); // "03"
console.log(match.groups.day); // "15"

// 通过索引仍然可以访问
console.log(match[1]); // "2024"
console.log(match[2]); // "03"
console.log(match[3]); // "15"

// 检查 groups 对象
console.log(match.groups);
// { year: "2024", month: "03", day: "15" }

Python 中的命名分组

Python 使用 (?P<name>...) 语法:

import re

date = "2024-03-15"
pattern = re.compile(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})')
match = pattern.match(date)

# 通过名称访问
print(match.group('year')) # "2024"
print(match.group('month')) # "03"
print(match.group('day')) # "15"

# 通过索引访问
print(match.group(1)) # "2024"

# 获取所有命名分组
print(match.groupdict())
# {'year': '2024', 'month': '03', 'day': '15'}

在替换中使用命名分组

JavaScript

const date = "2024-03-15";
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;

// 使用 $<name> 语法引用命名分组
const result = date.replace(regex, "$<day>/$<month>/$<year>");
console.log(result); // "15/03/2024"

Python

import re

date = "2024-03-15"
pattern = re.compile(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})')

# 使用 \g<name> 语法引用命名分组
result = pattern.sub(r'\g<day>/\g<month>/\g<year>', date)
print(result) # "15/03/2024"

命名分组的优势

// 不使用命名分组:编号难以记忆
const logRegex = /^(\S+) (\S+) (\S+) \[([^\]]+)\] "(\S+) ([^ ]+) ([^"]+)" (\d+) (\d+)$/;
// 哪个编号代表什么?需要查文档或数括号

// 使用命名分组:一目了然
const logRegexNamed = /^(?P<ip>\S+) (?P<ident>\S+) (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>[^ ]+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\d+)$/;

// 代码更易读
if (match) {
console.log(`访问者 IP: ${match.groups.ip}`);
console.log(`请求路径: ${match.groups.path}`);
console.log(`状态码: ${match.groups.status}`);
}

兼容性说明

语言支持版本语法
JavaScriptES2018+(?<name>...)
Python3.6+(?P<name>...)
Java7+(?<name>...)
C#一直支持(?<name>...)
Go不支持-
Rust支持(?P<name>...)

反向引用

反向引用允许你在正则表达式中引用之前捕获分组匹配的内容。这是实现"重复模式匹配"的关键技术。

基本语法

在正则表达式中使用 \1\2 等引用对应编号的分组:

// 匹配重复的单词
const text = "the the cat sat on the the mat";
const duplicates = text.match(/\b(\w+)\s+\1\b/g);
console.log(duplicates); // ["the the", "the the"]

// 解析:
// \b 单词边界
// (\w+) 捕获一个或多个单词字符(第1组)
// \s+ 一个或多个空白
// \1 引用第1组匹配的内容(必须与第1组完全相同)
// \b 单词边界

匹配对称结构

反向引用常用于匹配对称的 HTML 标签、引号等:

// 匹配对称的 HTML 标签
const html = "<div>content</div><span>text</span><p>para</p>";
const tags = html.match(/<(\w+)>[^<]*<\/\1>/g);
console.log(tags);
// ["<div>content</div>", "<span>text</span>", "<p>para</p>"]

// 解析:
// <(\w+)> 匹配开始标签,捕获标签名
// [^<]* 匹配标签内容(不含 <)
// <\/\1> 匹配结束标签,\1 确保与开始标签名相同

匹配引号包围的内容

// 匹配单引号或双引号包围的内容
const text = 'He said "hello" and \'goodbye\'';
const quoted = text.match(/(['"])([^'"]*)\1/g);
console.log(quoted); // ['"hello"', "'goodbye'"]

// 解析:
// (['"]) 捕获引号(第1组)
// ([^'"]*) 捕获引号内容(第2组)
// \1 结束引号必须与开始引号相同

命名反向引用

命名分组可以通过名称引用:

JavaScript

// 使用 \k<name> 语法
const regex = /(?<quote>['"])(?<content>[^'"]*)\k<quote>/;
const match = '"hello"'.match(regex);
console.log(match.groups.content); // "hello"

Python

import re

# 使用 (?P=name) 语法
pattern = re.compile(r"(?P<quote>['\"])(?P<content>[^'\"]*)(?P=quote)")
match = pattern.search('"hello"')
print(match.group('content')) # hello

反向引用的工作原理

理解反向引用的工作原理有助于避免常见错误:

// 反向引用匹配的是"捕获的内容",而不是"捕获的模式"
const regex = /(\d+)\1/;

regex.test("1212"); // true,第1组匹配 "12",\1 也匹配 "12"
regex.test("1234"); // false,第1组匹配 "1234",但后面没有 "1234"
regex.test("1111"); // true,第1组匹配 "11",\1 也匹配 "11"

// 重要:分组没有匹配时,反向引用会失败
const regex2 = /(a)?b\1/;
regex2.test("aba"); // true,第1组匹配 "a",\1 也匹配 "a"
regex2.test("b"); // false,第1组没有匹配,\1 引用失败

分组在替换中的应用

捕获分组的一个主要用途是在替换操作中引用匹配的内容。

JavaScript 替换

// 使用 $1, $2 等引用分组
const date = "2024-03-15";
const result1 = date.replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
console.log(result1); // "15/03/2024"

// 交换姓名顺序
const name = "John Smith";
const result2 = name.replace(/(\w+) (\w+)/, "$2, $1");
console.log(result2); // "Smith, John"

// 格式化电话号码
const phone = "13812345678";
const result3 = phone.replace(/(\d{3})(\d{4})(\d{4})/, "$1-$2-$3");
console.log(result3); // "138-1234-5678"

使用替换函数

对于更复杂的替换逻辑,可以使用函数:

const text = "hello world";

// 函数接收匹配结果作为参数
const result = text.replace(/(\w+)/g, (match, word) => {
return word.toUpperCase();
});
console.log(result); // "HELLO WORLD"

// 带命名分组的替换函数
const date = "2024-03-15";
const result2 = date.replace(
/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/,
(match, year, month, day, offset, string, groups) => {
return `${groups.day}/${groups.month}/${groups.year}`;
}
);
console.log(result2); // "15/03/2024"

Python 替换

import re

# 使用 \1, \2 等引用分组
date = "2024-03-15"
result = re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', date)
print(result) # "15/03/2024"

# 使用命名分组
result2 = re.sub(
r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})',
r'\g<day>/\g<month>/\g<year>',
date
)
print(result2) # "15/03/2024"

# 使用替换函数
def upper_replacement(match):
return match.group(1).upper()

text = "hello world"
result3 = re.sub(r'(\w+)', upper_replacement, text)
print(result3) # "HELLO WORLD"

高级分组技巧

分组与量词的组合

// 可选分组
const optional = /(\d{3}-)?\d{4}-\d{4}/;
optional.test("138-1234-5678"); // true
optional.test("1234-5678"); // true

// 重复分组
const repeated = /(abc){2,3}/;
repeated.test("abcabc"); // true
repeated.test("abcabcabc"); // true
repeated.test("abc"); // false

// 分组内的选择
const choice = /(red|green|blue) apple/;
choice.test("red apple"); // true
choice.test("green apple"); // true
choice.test("yellow apple"); // false

嵌套分组

// 嵌套分组:URL 解析
const url = "https://www.example.com:8080/path/to/page?query=value";
const urlRegex = /^((https?):)?\/\/(([^:\/]+)(:(\d+))?)?(\/[^?]*)?(\?.*)?$/;

const match = url.match(urlRegex);
// match[1] = "https:" - 协议(含冒号)
// match[2] = "https" - 协议(不含冒号)
// match[3] = "www.example.com:8080" - 主机(含端口)
// match[4] = "www.example.com" - 主机(不含端口)
// match[5] = ":8080" - 端口(含冒号)
// match[6] = "8080" - 端口号
// match[7] = "/path/to/page" - 路径
// match[8] = "?query=value" - 查询字符串

分组与零宽断言的组合

// 提取特定上下文中的内容
const text = 'The price is $100 and €200';

// 提取 $ 后面的数字
const dollars = text.match(/(?<=\$)\d+/g);
console.log(dollars); // ["100"]

// 提取货币符号后面的金额
const amounts = text.match(/(?<=[\$])\d+/g);
console.log(amounts); // ["100", "200"]

条件匹配(Python)

Python 支持条件匹配语法 (?(id/name)yes|no)

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]')) # 匹配失败(引号不配对)

各语言的分组处理 API

JavaScript

const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const text = "2024-03-15";

// match() - 返回匹配数组
const match = text.match(regex);
console.log(match[1]); // "2024" - 索引访问
console.log(match.groups.year); // "2024" - 命名访问

// matchAll() - 返回所有匹配的迭代器
const text2 = "2024-03-15 and 2024-04-20";
for (const m of text2.matchAll(regex)) {
console.log(m.groups);
}

// exec() - 执行匹配,可循环获取结果
let result;
const regex2 = /\d+/g;
while ((result = regex2.exec("123 456 789")) !== null) {
console.log(result[0]);
}

Python

import re

pattern = re.compile(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})')
text = "2024-03-15"

# match() - 从开头匹配
match = pattern.match(text)
print(match.group(1)) # "2024" - 索引访问
print(match.group('year')) # "2024" - 命名访问
print(match.groups()) # ('2024', '03', '15')
print(match.groupdict()) # {'year': '2024', 'month': '03', 'day': '15'}

# findall() - 返回所有匹配
# 如果有分组,返回分组内容的列表
print(re.findall(r'(\d{4})-(\d{2})-(\d{2})', "2024-03-15 and 2024-04-20"))
# [('2024', '03', '15'), ('2024', '04', '20')]

# finditer() - 返回匹配对象的迭代器
for m in pattern.finditer("2024-03-15 and 2024-04-20"):
print(m.groupdict())

Java

import java.util.regex.*;

Pattern pattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher matcher = pattern.matcher("2024-03-15");

if (matcher.find()) {
System.out.println(matcher.group(0)); // "2024-03-15" - 完整匹配
System.out.println(matcher.group(1)); // "2024"
System.out.println(matcher.group(2)); // "03"
System.out.println(matcher.group(3)); // "15"
}

// 命名分组(Java 7+)
Pattern namedPattern = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})");
Matcher namedMatcher = namedPattern.matcher("2024-03-15");
if (namedMatcher.find()) {
System.out.println(namedMatcher.group("year")); // "2024"
System.out.println(namedMatcher.group("month")); // "03"
System.out.println(namedMatcher.group("day")); // "15"
}

常见问题与陷阱

1. 分组编号错误

// 问题:嵌套分组的编号容易搞混
const regex = /((a)(b))/;
// 正确理解:第1组是(ab),第2组是(a),第3组是(b)

// 解决方案:使用命名分组或注释
const regex2 = /(?<whole>(?<first>a)(?<second>b))/;

2. 非捕获分组的误用

// 问题:想捕获但没有捕获
const wrong = /(?:\d{4})-(\d{2})-(\d{2})/;
const match = "2024-03-15".match(wrong);
console.log(match[1]); // "03",不是 "2024"

// 解决方案:移除 ?: 或添加新的捕获分组
const correct = /(\d{4})-(\d{2})-(\d{2})/;

3. 反向引用失败

// 问题:分组可选时,反向引用可能失败
const regex = /(a)?b\1/;
console.log(regex.test("aba")); // true
console.log(regex.test("b")); // false,因为 \1 引用失败

// 解决方案:确保分组一定匹配,或使用条件逻辑

4. 分组数量过多

// 问题:分组太多难以维护
const complex = /^(\S+) (\S+) (\S+) \[([^\]]+)\] "(\S+) ([^ ]+) ([^"]+)" (\d+) (\d+)$/;

// 解决方案:使用命名分组
const better = /^(?P<ip>\S+) (?P<ident>\S+) (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<method>\S+) (?P<path>[^ ]+) (?P<protocol>[^"]+)" (?P<status>\d+) (?P<size>\d+)$/;

最佳实践

  1. 优先使用命名分组:提高代码可读性,减少编号错误
  2. 非必要不捕获:使用非捕获分组 (?:...) 提高性能
  3. 注释复杂模式:使用 verbose 模式或拆分变量
  4. 测试边界情况:确保分组可选或嵌套时的行为符合预期
  5. 避免过多分组:考虑拆分复杂的正则表达式

小结

分组与捕获是正则表达式的核心特性,本章介绍了:

  • 捕获分组 (...):保存匹配内容,支持后续引用
  • 非捕获分组 (?:...):只分组不捕获,提高性能
  • 命名分组 (?<name>...):使用名称而非编号,提高可读性
  • 反向引用 \1\k<name>:引用之前匹配的内容
  • 分组在替换中的应用:使用 $1\1 等在替换中引用分组

掌握分组与捕获,能够让你处理更复杂的文本匹配和提取任务,是正则表达式进阶的关键一步。