24、正则表达式库:regex_match/regex_search/regex_replace、正则语法、迭代器与tokenizer、实战:日志格式提取

正则表达式,说白了就是一套“模式匹配”的规则语言。很多C++开发者一听到正则就头疼,觉得语法晦涩、性能堪忧。嗯,我刚开始也有这感觉。直到有一次接手一个日志分析模块,几千行日志要提取关键字段,手写字符串解析写到崩溃……后来用regex_replace三行代码搞定,从此真香。

C++11正式把正则表达式纳入了标准库,头文件<regex>。虽然它不像Python或Perl的正则那么“原生”那么快,但胜在跨平台、零依赖。我个人习惯是:小规模文本处理用STL regex,大规模日志分析还是上PCRE或RE2。但日常开发,STL regex完全够用。

24.1 核心函数:regex_match、regex_search、regex_replace

这三个函数是正则库的“三驾马车”。我经常看到新手把match和search搞混,其实区别很简单:

  • regex_match:整个字符串必须完全匹配模式。说白了就是“全字匹配”。
  • regex_search:只要字符串中有一部分匹配就行。它找到第一个匹配就返回。
  • regex_replace:匹配并替换,支持捕获组引用。

核心区别速记:match要“从头到尾”都对上,search只要“藏”在字符串里就行。

来看个例子。假设我们要验证一个字符串是不是合法的IPv4地址:

#include <iostream>
#include <regex>
#include <string>

int main() {
    std::string ip = "192.168.1.1";
    // 这个模式要求整个字符串都是IP格式
    std::regex ip_pattern(R"((\d{1,3}\.){3}\d{1,3})");

    if (std::regex_match(ip, ip_pattern)) {
        std::cout << "完全匹配:是IP地址" << std::endl;
    }

    std::string text = "服务器IP: 10.0.0.5, 端口: 8080";
    std::smatch match_result;
    // search只找子串,不要求全匹配
    if (std::regex_search(text, match_result, ip_pattern)) {
        std::cout << "找到IP: " << match_result.str() << std::endl;
    }

    // 替换所有IP为***.***.***.***
    std::string masked = std::regex_replace(text, ip_pattern, "***.***.***.***");
    std::cout << "脱敏后: " << masked << std::endl;

    return 0;
}

输出结果:

完全匹配:是IP地址
找到IP: 10.0.0.5
脱敏后:服务器IP: ***.***.***.***, 端口: 8080

我的小建议:如果只是判断“存不存在”,用regex_search;如果要提取整个字段,用regex_match配合捕获组。regex_replace的替换字符串里用$1、$2引用捕获组,这个语法和很多脚本语言一致。

24.2 正则语法速览

STL regex默认使用ECMAScript语法,和JavaScript、Java的正则基本一致。我整理了一份常用语法表,你写代码时可以直接翻:

语法 含义 示例
. 匹配任意单个字符(除换行) a.c 匹配 "abc"、"a1c"
* 前一个字符出现0次或多次 ab*c 匹配 "ac"、"abc"、"abbc"
+ 前一个字符出现1次或多次 ab+c 匹配 "abc",不匹配 "ac"
? 前一个字符出现0次或1次 ab?c 匹配 "ac"、"abc"
\d 数字,等价于[0-9] \d{3} 匹配三位数字
\w 单词字符(字母、数字、下划线) \w+ 匹配一个单词
() 捕获组 (\d+)-(\w+) 提取两部分
[] 字符集 [aeiou] 匹配任意元音
^ 行首 ^Hello 匹配行首的Hello
$ 行尾 world$ 匹配行尾的world

注意转义:在C++字符串里写反斜杠要加倍。比如\d要写成"\\d"。我个人强烈建议用原始字符串字面量R"(...)",这样反斜杠不用转义,可读性高很多。

24.3 迭代器与tokenizer

有时候我们需要遍历字符串中所有匹配的位置,而不是只找第一个。这时候std::sregex_iterator就派上用场了。它就像一个“扫描器”,每次迭代返回一个std::smatch对象。

我曾经在解析配置文件时用过它。一个文件里可能有几十个key=value对,用迭代器一行一行扫,比手动find快多了。

#include <iostream>
#include <regex>
#include <string>

int main() {
    std::string log = "2024-01-15 10:30:45 [ERROR] 连接超时\n"
                      "2024-01-15 10:31:02 [INFO] 重试第1次\n"
                      "2024-01-15 10:32:18 [WARN] 磁盘空间不足";

    // 匹配日志行中的时间、级别、消息
    std::regex line_pattern(R"((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \[(\w+)\] (.+))");

    // 用迭代器遍历所有匹配行
    auto begin = std::sregex_iterator(log.begin(), log.end(), line_pattern);
    auto end = std::sregex_iterator();

    for (auto it = begin; it != end; ++it) {
        std::smatch match = *it;
        std::cout << "时间: " << match[1].str()
                  << ", 级别: " << match[2].str()
                  << ", 消息: " << match[3].str()
                  << std::endl;
    }

    return 0;
}

输出:

时间: 2024-01-15 10:30:45, 级别: ERROR, 消息: 连接超时
时间: 2024-01-15 10:31:02, 级别: INFO, 消息: 重试第1次
时间: 2024-01-15 10:32:18, 级别: WARN, 消息: 磁盘空间不足

另外还有个std::regex_token_iterator,它更灵活。你可以指定要提取哪个捕获组,或者把匹配结果当分隔符来拆分字符串。说白了,它就是一个“正则版”的strtok。

#include <iostream>
#include <regex>
#include <string>

int main() {
    std::string csv = "张三,25,工程师;李四,30,设计师;王五,28,产品经理";
    // 用分号或逗号作为分隔符
    std::regex sep(R"([;,])");

    // tokenizer:遍历所有被分隔符分开的字段
    auto begin = std::regex_token_iterator<std::string::const_iterator>(
        csv.begin(), csv.end(), sep, -1);  // -1表示返回非匹配部分
    auto end = std::regex_token_iterator<std::string::const_iterator>();

    int count = 0;
    for (auto it = begin; it != end; ++it) {
        std::cout << "字段" << ++count << ": " << it->str() << std::endl;
    }

    return 0;
}

输出:

字段1: 张三
字段2: 25
字段3: 工程师
字段4: 李四
字段5: 30
字段6: 设计师
字段7: 王五
字段8: 28
字段9: 产品经理

性能提醒:regex_token_iterator的-1参数表示“返回分隔符之间的内容”,相当于split。但注意,如果字符串开头或结尾有分隔符,会返回空字符串。我在项目中遇到过这个坑,后来加了个前置检查才搞定。

24.4 实战:日志格式提取

好了,理论说完了,咱们来点实战。假设你有一个服务器日志文件,每行格式如下:

[2024-03-15 14:23:45] [INFO] [模块A] 用户登录成功, userId=10086, ip=192.168.1.10
[2024-03-15 14:24:01] [ERROR] [模块B] 数据库连接失败, errno=1040, db=user_db
[2024-03-15 14:25:30] [WARN] [模块C] 磁盘使用率超过80%, current=85%

需求:提取每条日志的时间、级别、模块、消息、以及键值对参数。咱们用正则一步到位:

#include <iostream>
#include <regex>
#include <string>
#include <vector>

struct LogEntry {
    std::string timestamp;
    std::string level;
    std::string module;
    std::string message;
    std::string params;  // 原始参数字符串
};

int main() {
    std::vector<std::string> logs = {
        "[2024-03-15 14:23:45] [INFO] [模块A] 用户登录成功, userId=10086, ip=192.168.1.10",
        "[2024-03-15 14:24:01] [ERROR] [模块B] 数据库连接失败, errno=1040, db=user_db",
        "[2024-03-15 14:25:30] [WARN] [模块C] 磁盘使用率超过80%, current=85%"
    };

    // 这个模式匹配整行日志
    std::regex log_pattern(
        R"(\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] )"   // 时间
        R"(\[(\w+)\] )"                                      // 级别
        R"(\[(\w+)\] )"                                      // 模块
        R"(([^,]+))"                                         // 消息(直到逗号或行尾)
        R"(,?\s*(.*))"                                       // 剩余参数(可选)
    );

    std::vector<LogEntry> entries;

    for (const auto& line : logs) {
        std::smatch match;
        if (std::regex_match(line, match, log_pattern)) {
            entries.push_back({
                match[1].str(),  // 时间
                match[2].str(),  // 级别
                match[3].str(),  // 模块
                match[4].str(),  // 消息
                match[5].str()   // 参数
            });
        }
    }

    // 打印提取结果
    for (const auto& entry : entries) {
        std::cout << "时间: " << entry.timestamp << "\n"
                  << "级别: " << entry.level << "\n"
                  << "模块: " << entry.module << "\n"
                  << "消息: " << entry.message << "\n"
                  << "参数: " << entry.params << "\n"
                  << "---" << std::endl;
    }

    return 0;
}

输出:

时间: 2024-03-15 14:23:45
级别: INFO
模块: 模块A
消息: 用户登录成功
参数: userId=10086, ip=192.168.1.10
---
时间: 2024-03-15 14:24:01
级别: ERROR
模块: 模块B
消息: 数据库连接失败
参数: errno=1040, db=user_db
---
时间: 2024-03-15 14:25:30
级别: WARN
模块: 模块C
消息: 磁盘使用率超过80%
参数: current=85%
---

我曾经踩过的坑:日志里的消息部分可能包含逗号,比如“磁盘使用率超过80%, current=85%”。如果直接用逗号分割,会把消息和参数搞混。所以我用([^,]+)匹配消息,只匹配到第一个逗号之前,剩下的全当参数。这样虽然参数里还带着逗号,但至少消息是干净的。如果你需要更精细的解析,可以再对参数字符串做一次正则拆分。

最后,咱们用一张图总结本章的知识体系:

正则表达式库核心知识体系 <regex> 标准库 regex_match regex_search regex_replace 全字匹配 子串查找 替换+捕获组 sregex_iterator regex_token_iterator 实战:日志格式提取

正则表达式库虽然强大,但也不是万能的。我个人建议:如果模式特别复杂(比如嵌套括号、递归匹配),或者数据量特别大(百万级行日志),还是考虑用专门的解析库。但日常的配置解析、日志提取、格式校验,STL regex完全能胜任。

嗯,这一章的内容就到这。记住三个核心函数、两种迭代器、以及那个日志提取的实战模式。下次你遇到字符串解析的需求,先想想能不能用正则三行搞定——说不定比你手写循环快得多。


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