4. 分组与捕获:子表达式分组、捕获匹配内容、使用regmatch_t结构体、提取子串

正则表达式里有个很实用的功能——分组与捕获。说白了,就是让你能从一段文本里,精准地掏出你想要的某一部分。比如从"2024-05-15"里单独拿出月份,或者从日志里提取IP地址。我个人觉得,这是正则从"能用"到"好用"的关键一步。

4.1 子表达式分组:用小括号圈出你的目标

在正则里,用小括号 () 包起来的部分,就是一个子表达式分组。它有两个作用:一是改变优先级(跟数学里的括号一样),二是让这部分内容可以被"捕获"到内存里。

举个例子,匹配日期格式 2024-05-15

#include <stdio.h>
#include <regex.h>

int main() {
    regex_t regex;
    // 三个分组:年、月、日
    const char *pattern = "([0-9]{4})-([0-9]{2})-([0-9]{2})";
    const char *text = "2024-05-15";

    if (regcomp(&regex, pattern, REG_EXTENDED) != 0) {
        printf("编译正则失败\n");
        return 1;
    }

    regmatch_t matches[4]; // 数组大小 = 分组数 + 1
    int ret = regexec(&regex, text, 4, matches, 0);
    if (ret == 0) {
        printf("匹配成功!\n");
    }

    regfree(&regex);
    return 0;
}

这里 ([0-9]{4}) 就是第一个分组,([0-9]{2}) 是第二个和第三个。我在项目中经常用这种模式去解析日志文件里的时间戳,比手写字符串切割靠谱多了。

小提示:分组编号从1开始,第0组是整个匹配的字符串。所以数组大小至少是"分组数+1"。

4.2 捕获匹配内容:regmatch_t 结构体详解

匹配完了,怎么把内容取出来?这就轮到 regmatch_t 结构体登场了。它的定义很简单:

typedef struct {
    regoff_t rm_so;  // 匹配子串的起始偏移(从0开始)
    regoff_t rm_eo;  // 匹配子串的结束偏移(不包含该位置)
} regmatch_t;

rm_sorm_eo 就像两个指针,告诉你每个分组在原始字符串里的起止位置。嗯,这里要注意:rm_eo 指向的是匹配结束的下一个字符,所以提取子串时长度是 rm_eo - rm_so

我曾经踩过一个坑:直接用 rm_eo 作为字符串结束位置去打印,结果把后面的字符也带出来了。后来才意识到,必须用 rm_eo - rm_so 计算长度。

4.3 提取子串:从匹配结果到可用字符串

有了偏移量,提取子串就很简单了。我们可以用 strncpy 或者直接手动拷贝:

#include <stdio.h>
#include <string.h>
#include <regex.h>

int main() {
    regex_t regex;
    const char *pattern = "([0-9]{4})-([0-9]{2})-([0-9]{2})";
    const char *text = "2024-05-15";

    regcomp(&regex, pattern, REG_EXTENDED);
    regmatch_t matches[4];

    if (regexec(&regex, text, 4, matches, 0) == 0) {
        char year[5] = {0};
        char month[3] = {0};
        char day[3] = {0};

        // 提取年:从 matches[1].rm_so 到 matches[1].rm_eo
        int len1 = matches[1].rm_eo - matches[1].rm_so;
        strncpy(year, text + matches[1].rm_so, len1);

        // 提取月
        int len2 = matches[2].rm_eo - matches[2].rm_so;
        strncpy(month, text + matches[2].rm_so, len2);

        // 提取日
        int len3 = matches[3].rm_eo - matches[3].rm_so;
        strncpy(day, text + matches[3].rm_so, len3);

        printf("年: %s, 月: %s, 日: %s\n", year, month, day);
    }

    regfree(&regex);
    return 0;
}

输出结果:

年: 2024, 月: 05, 日: 15

你想想看,如果不用分组,你得自己算偏移量、数位数,多麻烦。分组捕获一出,代码清晰多了。

注意:strncpy 不会自动添加 '\0',所以一定要手动初始化目标数组为全0,或者拷贝后手动加结束符。我刚开始学C时在这上面吃过不少亏。

4.4 多个匹配与循环提取

有时候文本里有多处匹配,比如从一行日志里提取多个IP地址。这时候可以用 regexec 配合偏移量循环处理:

#include <stdio.h>
#include <string.h>
#include <regex.h>

int main() {
    regex_t regex;
    const char *pattern = "([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})";
    const char *text = "访问来源: 192.168.1.1, 192.168.1.2, 10.0.0.1";

    regcomp(&regex, pattern, REG_EXTENDED);
    regmatch_t matches[2];

    const char *cursor = text;
    while (regexec(&regex, cursor, 2, matches, 0) == 0) {
        int len = matches[1].rm_eo - matches[1].rm_so;
        char ip[16] = {0};
        strncpy(ip, cursor + matches[1].rm_so, len);
        printf("提取到IP: %s\n", ip);

        // 移动游标到本次匹配结束位置,继续查找
        cursor += matches[1].rm_eo;
    }

    regfree(&regex);
    return 0;
}

输出:

提取到IP: 192.168.1.1
提取到IP: 192.168.1.2
提取到IP: 10.0.0.1

这里的关键是每次匹配后,把 cursor 移动到本次匹配的末尾,然后继续调用 regexec。我在处理网络设备日志时经常用这个技巧,一行日志里能抓出十几个IP。

4.5 本章知识体系

下面这张图总结了分组与捕获的核心流程:

分组与捕获核心流程 原始文本 "2024-05-15" 正则匹配 正则模式 ([0-9]{4})-([0-9]{2})-([0-9]{2}) regexec regmatch_t 数组 matches[0]: 0-10 (整串) matches[1]: 0-4 (年) matches[2]: 5-7 (月) 提取子串 strncpy + 偏移量计算 最终结果 年=2024, 月=05, 日=15 分组编号: 第0组:整个匹配 第1组:第一个括号 第2组:第二个括号 第3组:第三个括号

4.6 避坑指南

最后分享几个我实际工作中遇到的坑:

  • 数组越界: regmatch_t 数组大小一定要大于分组数。我见过有人只声明了 regmatch_t matches[1],结果匹配三个分组时直接崩了。
  • 空分组: 如果某个分组没匹配到内容,rm_so 会是 -1。提取前一定要判断一下。
  • 性能问题: 循环匹配大量文本时,每次调用 regexec 都有开销。如果文本很长,建议先按行分割再匹配。
  • 转义字符: C字符串里写正则时,反斜杠要双写。比如 \d 要写成 \\d,这个我刚开始写时经常忘。

核心要点:

  • 用小括号 () 创建分组,每个分组对应一个 regmatch_t
  • rm_sorm_eo 是提取子串的关键
  • 数组下标0存整个匹配,下标1开始存各个分组
  • 循环匹配时记得移动游标,避免死循环

分组与捕获是正则实战的基石。掌握了它,你就能从杂乱文本里精准提取信息。我个人觉得,这是C语言正则里最值得花时间搞懂的部分。多写几个例子,很快就能上手。


公众号:蓝海资料掘金营,微信deep3321