四、分组与捕获:括号、非捕获分组、反向引用

正则里头的括号,看着简单,用好了能解决大问题。我早年处理日志解析时,就吃过没用好分组的亏——写了一长串正则,结果匹配出来一堆零散碎片,根本没法直接用。后来才明白,括号不只是用来"括起来"的,它背后是一套完整的分组与捕获机制。

说白了,分组就是把多个字符打包成一个单元。捕获呢?就是把这个单元匹配到的内容记下来,留着后面用。你想想看,要是没有分组,你想重复匹配一个"ab",还得写成 (ab){3} 吗?嗯,实际上你也可以写 ababab,但那样太死板了。分组让正则活了起来。

4.1 捕获分组——最基础的括号

先看最常见的用法:用圆括号 () 括起来的部分,就是一个捕获分组。每当你用一对括号,正则引擎就会把匹配到的内容存到一个编号的"寄存器"里。

// 提取日期中的年、月、日
String date = "2024-03-15";
Pattern pattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher matcher = pattern.matcher(date);

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
}

分组编号是怎么定的?从左到右,按左括号出现顺序排。0号是完整匹配,1、2、3...依次排下去。有个容易忽略的细节——分组嵌套时,也是按左括号出现的顺序编号。

表达式 分组编号 匹配内容
(\d{4})-(\d{2})-(\d{2}) 1:年, 2:月, 3:日 2024-03-15
((\d{2})(\d{2}))-(\d{2}) 1:前四位, 2:前两位, 3:后两位, 4:最后两位 2403-15
(a)(b)(c) 1:a, 2:b, 3:c abc
小提示: 大多数语言里,分组编号从1开始。0号是整个匹配,不算在分组里。写代码时容易把 group(0) 当成第一个分组,我刚开始就犯过这错。

4.2 非捕获分组——只要分组,不要捕获

有时候你只想把几个字符当成一个单元来处理,并不想捕获它们。比如你想匹配 "abc" 或 "def" 重复三次,写成 (?:abc|def){3}。如果用普通括号 (abc|def){3},会额外捕获一个分组,占用编号,还影响性能。

非捕获分组的语法是 (?:pattern)。它在分组功能上和普通括号完全一样,唯一的区别就是——不记录匹配内容。

// 对比:捕获分组 vs 非捕获分组
String text = "abcabc";
Pattern capPat = Pattern.compile("(abc){2}");
Matcher capMat = capPat.matcher(text);
// group(1) = "abc"

Pattern nonCapPat = Pattern.compile("(?:abc){2}");
Matcher nonCapMat = nonCapPat.matcher(text);
// 没有分组1,调用 group(1) 会抛异常
核心区别: 非捕获分组不占用编号,不消耗内存,运行更快。如果你只是需要"把一组字符当成一个整体",别犹豫,用 (?:)。

我在项目中见过有人写了一长串正则,里面有十多个普通分组,其中一半其实只是用来做重复匹配的。那些分组根本不需要捕获,纯属浪费资源。嗯,这种情况用非捕获分组就清爽多了。

4.3 反向引用——让正则"记住"前面匹配了什么

反向引用是分组机制里最强大的特性之一。它允许你在同一个正则表达式中引用前面某个捕获分组匹配到的内容。语法是 \1、\2... 对应第1、第2个捕获分组。

为什么会需要这个?举个例子:你要匹配一个 HTML 标签,开始标签是 <div>,结束标签必须是 </div>。你不能写死标签名,得用反向引用。

// 匹配任意成对出现的 HTML 标签
String html = "<div>内容</div><p>段落</p>";
Pattern pattern = Pattern.compile("<(\\w+)>.*?</\\1>");
Matcher matcher = pattern.matcher(html);

while (matcher.find()) {
    System.out.println(matcher.group());
    // 输出: <div>内容</div>
    // 输出: <p>段落</p>
}

看到 \\1 了吗?它引用了第一个分组 (\\w+) 匹配到的标签名。如果第一个分组匹配到 "div",那 \\1 就必须也是 "div"。这就保证了开始和结束标签的名称一致。

避坑指南: 我曾经在替换操作里用反引用翻过车。在 Java 的 replaceAll 里,反向引用要写成 $1 而不是 \1。不同语言的语法不一样,JavaScript 用 $1,Python 用 \1,PHP 用 \1 或 $1……用之前一定查文档。

4.4 命名分组——给编号起个名字

编号分组虽然简单,但可读性差。特别是分组多了以后,你根本记不住 group(5) 对应的是哪个字段。我建议在正则超过3个分组时,改用命名分组。

命名分组的语法是 (?<name>pattern),有些语言也支持 (?'name'pattern)。通过名字而不是编号来引用,代码清晰得多。

// 命名分组示例:提取用户信息
String userInfo = "name:张三,age:28,city:北京";
Pattern pattern = Pattern.compile(
    "name:(?<name>\\w+),age:(?<age>\\d+),city:(?<city>\\w+)"
);
Matcher matcher = pattern.matcher(userInfo);

if (matcher.find()) {
    System.out.println(matcher.group("name")); // 张三
    System.out.println(matcher.group("age"));  // 28
    System.out.println(matcher.group("city")); // 北京
}

// 反向引用时也用名字:\k<name>
String repeat = "<h1>标题</h1>";
Pattern p2 = Pattern.compile("<(?<tag>\\w+)>.*?</\\k<tag>>");
类型 语法 反向引用 适用场景
捕获分组 (pattern) \1 简单场景,分组少
非捕获分组 (?:pattern) 不支持 仅需分组,无需捕获
命名分组 (?<name>pattern) \k<name> 分组多,需要可读性
个人习惯: 只要正则里超过3对括号,我一定用命名分组。不是为了炫技,而是三个月后回头改代码时,能一眼看懂自己在干什么。编号分组?嗯,到那时你肯定得重新数一遍。

4.5 分组与"或"逻辑的配合

分组最常见的搭档就是"或"操作符 |。分组限定了"或"的范围,否则 | 会把整个表达式拆成两半。

// 不加括号:匹配 "abc" 或 "def"
String s1 = "abcdef";
Pattern p1 = Pattern.compile("abc|def");   // 匹配 "abc" 或 "def"

// 加括号:匹配 "abc" 或 "def" 重复三次
Pattern p2 = Pattern.compile("(?:abc|def){3}"); // 匹配 "abcabcabc" 或 "defdefdef" 或 "abcdefabc" 等

// 命名分组里也能用或
Pattern p3 = Pattern.compile("(?<type>img|script|link)");

你想想看,没有分组时,| 的优先级最低,它会把它左边和右边整个表达式当成两个分支。加上括号,才能精确控制"或"的作用范围。

经验之谈: 我见过一个生产事故,同事用 ^select|from|where$ 想匹配SQL关键字,结果匹配了所有包含"select"或"from"或"where"的行,完全不是他要的效果。正确的写法是 ^(select|from|where)$。括号的位置,差之毫厘,谬以千里。

4.6 知识体系总览

下面这张图把本章的核心知识点串在一起。你可以看到三种分组方式各自的位置,以及它们如何通向反向引用和实际应用。

分组与捕获核心机制 正则中的圆括号 () 捕获分组 (pattern) → 编号 1,2,3 非捕获分组 (?:pattern) → 不捕获 命名分组 (?<name>pattern) → 名字 反向引用 编号引用: \1 \2 …… | 命名引用: \k<name> 实际应用: 数据提取 · 标签匹配 · 格式校验 💡 核心原则: 需要捕获就用 (),只想分组就用 (?:),分组多就用命名分组提升可读性。

4.7 实战:数据清洗中的分组应用

讲个真实场景。我曾经需要从一个混乱的日志文件里提取所有接口调用记录。每条日志长这样:

2024-01-17 14:23:45 [INFO] 用户:zhangsan 调用:/api/order/list 耗时:342ms 状态:200
2024-01-17 14:23:47 [ERROR] 用户:lisi 调用:/api/order/create 耗时:1021ms 状态:500

用分组来解析:

String logPattern = "(?<date>\\d{4}-\\d{2}-\\d{2}) " +
                    "(?<time>\\d{2}:\\d{2}:\\d{2}) " +
                    "\\[(?<level>INFO|ERROR)\\] " +
                    "用户:(?<user>\\w+) " +
                    "调用:(?<api>/\\S+) " +
                    "耗时:(?<duration>\\d+)ms " +
                    "状态:(?<status>\\d+)";

Pattern pattern = Pattern.compile(logPattern);
Matcher matcher = pattern.matcher(logContent);

while (matcher.find()) {
    String api = matcher.group("api");
    String duration = matcher.group("duration");
    String status = matcher.group("status");
    // 处理提取到的数据
}

用了命名分组后,代码可读性直接拉满。每组数据都有名字,后续处理也不用记编号。我后来把这个模式用在了好几个项目里,效果都很好。

4.8 本章小结

分组与捕获是正则从"字符串匹配工具"升级为"结构化数据解析器"的关键。我把这章的核心要点列出来:

  • 捕获分组 ():按编号存储匹配内容,适合简单场景。
  • 非捕获分组 (?:):只分组不捕获,性能更好,适合重复和或逻辑。
  • 命名分组 (?<name>):用名字替代编号,可读性最佳。
  • 反向引用 \1 / \k<name>:引用前面捕获的内容,实现标签匹配、重复检测等高级功能。
一句话总结: 括号不只是"括起来",它是正则的"变量声明"。用好分组,你的正则就从"一把梭"变成了"结构化编程"。