第 1 章 正则表达式到底是什么?------先建立直觉
1.1 一个生活化的类比
想象你在一个巨大的图书馆里找书。如果只知道书名里的几个字,你不会逐本翻完所有书,而是会用"检索规则":
- "书名以《C++ 开头"
- "书名包含 网络 两个字"
- "书名以 入门 结尾"
正则表达式(Regular Expression,简称 regex / regexp)就是这种"检索规则"的标准化写法。它用一串特殊字符组成的"模式(pattern)",描述"什么样的文本算命中",然后让引擎去文本里找。
它就像文本世界的"模糊匹配搜索模板":
| 生活中的说法 | 正则写法 | 含义 |
|---|---|---|
| "以 C++ 开头" | ^C\+\+ | 从行首开始匹配 C++ |
| "包含任意数字" | \d | 匹配 0-9 中任意一个数字 |
| "连续 3 个字母" | a-z{3} | 匹配小写字母连续出现 3 次 |
| "以 入门 结尾" | 入门$ | 匹配到行尾的"入门" |
1.2 正则能做什么(能力地图)
- 匹配(Match):判断一段文本是否符合某个格式(如"这是不是一个合法邮箱")。
- 查找(Search):在一大段文本里找出所有满足条件的位置(如"找出日志里所有 IP 地址")。
- 提取(Extract):把命中部分的子片段抓出来(如"从 URL 里提取域名")。
- 替换(Replace):把命中部分替换成别的内容(如"把手机号中间四位打码")。
- 切分(Split):按模式把文本切成多段。
1.3 正则的两个本质概念(务必先记住)
- 模式(pattern):你写的正则字符串,如 \d{4}-\d{2}-\d{2}(匹配日期)。
- 引擎(engine) :真正拿着模式去扫描文本的程序。C++ 里最常见引擎有三个:std::regex(标准库自带)、RE2(Google 出品)、PCRE2(Perl 兼容)。第 2 章对比。
类比:模式是"菜谱",引擎是"厨师"。同样的菜谱,不同的厨师做出来速度、能力都不一样------这正是后面性能差异的根源。
第 2 章 C++ 正则库全景:std::regex / RE2 / PCRE2 怎么选?
2.1 三大引擎一句话介绍
| 引擎 | 出处 | 一句话定位 | 最突出的优点 | 最突出的缺点 |
|---|---|---|---|---|
| std::regex | C++ 标准库(C++11 引入) | 零依赖、随手可用 | 不用装任何东西,跨平台 | 各编译器实现差异大,性能普遍较慢,部分语法支持不全 |
| RE2 | Google 开源 | 安全优先的高性能引擎 | 线性时间匹配,永不回溯爆炸,适合不可信输入 | 不支持反向引用、环视等"高级回溯特性" |
| PCRE2 | Perl 兼容正则社区 | 功能最全、兼容 Perl | 支持几乎全部语法(回溯、环视、递归),带 JIT 加速 | 存在灾难性回溯风险,需小心写模式 |
2.2 怎么选(决策树)
你的正则需要反向引用 / 环视 / 递归匹配 吗?
├─ 需要 → PCRE2(或 Boost.Regex)
├─ 不需要,但输入来自用户 / 网络 / 日志等不可信数据?
│ ├─ 是 → 优先 RE2(防 ReDoS 攻击)
│ └─ 否 → 看性能要求:
│ ├─ 高吞吐、频繁匹配 → RE2
│ └─ 低频、图省事、不想引第三方库 → std::regex
2.3 std::regex 的"三套实现"问题(重要!)
std::regex 是标准规定接口、编译器各自实现的。同一个正则,在三个主流编译器下行为、性能差异很大:
| 编译器/标准库 | 实现来源 | 已知特点 |
|---|---|---|
| GCC(libstdc++) | 自研 | 速度慢(历史包袱),部分语法支持不全(如 \d 在 ECMAScript 模式下早期版本不支持,新版已修复) |
| Clang(libc++) | 自研 | 支持较好,性能一般 |
| MSVC(Windows) | 自研 | 支持较好,性能一般 |
⚠️ 易错点:同一份代码在 Linux 上编译运行正常,换到 Windows 可能行为不同------因为底层的"厨师"换了。写跨平台代码时,正则功能请按"三者的公共子集"来写,或者直接用 RE2/PCRE2 这类库消除差异。
2.4 本篇的路线图
- 第 3~6 章:用 std::regex 学会正则的核心用法(零依赖、立刻能跑)。
- 第 7~8 章:深入引擎原理,理解"为什么有的正则会卡死"。
- 第 9~10 章:接入 RE2 和 PCRE2,在生产环境选对工具。
第 3 章 快速上手:你的第一个 std::regex 程序
3.1 第 1 步:包含头文件
正则功能在 <regex> 头文件里。在代码开头加上:
cpp
#include <regex> // 正则表达式的全部功能都在这
#include <string> // std::string 字符串
#include <iostream>// 控制台输出
3.2 第 2 步:写一个最简单的匹配程序
目标:判断字符串 "hello123" 里是否包含数字。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
// 1) 待搜索的文本
std::string text = "hello123";
// 2) 构造正则对象:\d 表示"任意一个数字"
// 注意:C++ 字符串里反斜杠要写成 \\,所以正则里的 \d 要写成 "\\d"
std::regex pattern("\\d");
// 3) regex_search:在 text 中搜索是否有子串能匹配 pattern
bool found = std::regex_search(text, pattern);
// 4) 输出结果
if (found) {
std::cout << "找到了数字!" << std::endl;
} else {
std::cout << "没有数字" << std::endl;
}
return 0;
}
编译运行(Linux/macOS,g++ 编译器):
bash
g++ -std=c++17 demo.cpp -o demo
./demo
# 输出:找到了数字!
Windows 用 MSVC:直接新建控制台程序粘贴代码,F5 运行即可。
3.3 第 3 步:把匹配到的内容"抓出来"
光知道"有没有"不够,我们还想知道匹配到了什么、在哪个位置。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "订单号是 AB12345,请核对。";
// 模式:AB 后面跟 5 个数字
std::regex pattern("AB\\d{5}");
// std::smatch:存放匹配结果的"容器",s 表示 string 版本
std::smatch match_result;
if (std::regex_search(text, match_result, pattern)) {
// match_result[0] 就是整个匹配到的字符串
std::cout << "匹配到: " << match_result[0] << std::endl;
// position() 返回匹配开始的位置(下标从 0 开始)
std::cout << "起始位置: " << match_result.position() << std::endl;
}
return 0;
}
输出:
匹配到: AB12345 起始位置: 5
类比:std::smatch 像一个"快递包裹",0 是整件包裹,后面的 1、2...是里面分装的小盒子(捕获组,第 6 章细讲)。
3.4 第 4 步:掌握最常用的 4 个函数(先混个脸熟)
| 函数 | 干什么的 | 类比 |
|---|---|---|
| std::regex_match | 整个字符串完全符合模式才算成功 | "身份证号是不是 18 位" |
| std::regex_search | 字符串里任意位置有符合模式的片段就算成功 | "这段话里有没有数字" |
| std::regex_replace | 把所有匹配到的片段替换成新内容 | "把手机号打码" |
| std::regex_iterator | 遍历所有匹配片段 | "把日志里每个 IP 都找出来" |
⚠️ 易错点(新手 90% 会踩):regex_match 是"全文必须完全匹配",regex_search 是"包含即可"。很多人想用 match 找子串,结果永远返回 false,还以为正则写错了。第 5 章详细演示。
第 4 章 正则语法速览:字符、量词、分组、锚点
4.1 字符匹配:匹配"单个字符"的基本单元
| 写法 | 含义 | 示例 | 能匹配 |
|---|---|---|---|
| a | 普通字符,匹配它自己 | a | "a" |
| . | 任意一个字符(换行除外) | c.t | "cat"、"cut"、"c+t" |
| \d | 任意一个数字(0-9) | \d\d | "42"、"07" |
| \D | 任意一个非数字 | \D | "a"、"%" |
| \w | 任意一个"单词字符"(字母、数字、下划线) | \w\w | "ab"、"_9" |
| \W | 任意一个非单词字符 | \W | " "、"-"、"。" |
| \s | 任意一个空白字符(空格、Tab、换行) | a\sb | "a b" |
| \S | 任意一个非空白字符 | \S | "ab" |
| abc | 字符集合:a、b、c 中任意一个 | aeiou | "a"、"e" |
| a-z | 字符范围:a 到 z 任意一个 | 0-9 | "5" |
| \^abc | 取反:不是 a、b、c 的任意字符 | \^0-9 | "x" |
类比:\d 相当于"数字通配符",0-9 是"明确列出 0 到 9"。\d 是 0-9 的便捷缩写。
4.2 量词:控制"重复几次"
| 写法 | 含义 | 示例 | 能匹配 |
|---|---|---|---|
| * | 0 次或多次 | ab*c | "ac"、"abc"、"abbc" |
| + | 1 次或多次 | ab+c | "abc"、"abbc"(不能匹配 "ac") |
| ? | 0 次或 1 次 | ab?c | "ac"、"abc" |
| {n} | 恰好 n 次 | \d{4} | "2026" |
| {n,} | 至少 n 次 | \d{2,} | "12"、"12345" |
| {n,m} | n 到 m 次 | \d{2,4} | "12"、"1234" |
贪婪 vs 懒惰(重点):
- 默认是贪婪(greedy):尽量多匹配。\d+ 匹配 "12345" 时会吃掉全部 5 个数字。
- 在量词后加 ? 变成懒惰(lazy):尽量少匹配。\d+? 匹配 "12345" 时只吃 1 个数字。
cpp
#include <regex>
#include <iostream>
int main() {
std::string text = "12345";
std::smatch m;
// 贪婪:\d+ 会尽可能多吃,结果整串 "12345"
std::regex greedy("\\d+");
std::regex_search(text, m, greedy);
std::cout << "贪婪: [" << m[0] << "]" << std::endl;
// 懒惰:\d+? 只吃最少,结果只有 "1"
std::regex lazy("\\d+?");
std::regex_search(text, m, lazy);
std::cout << "懒惰: [" << m[0] << "]" << std::endl;
return 0;
}
输出:
贪婪: [12345] 懒惰: [1]
⚠️ 易错点 :想匹配"引号里的内容"时,".*" 贪婪会一路吃到最后一个 引号;要用 ".*?" 才能吃到第一个引号就停。例:对 "a" and "b",".*" 匹配整个 "a" and "b",".*?" 只匹配 "a"。
4.3 分组与捕获:用小括号"打包"
| 写法 | 含义 | 示例 |
|---|---|---|
| (abc) | 捕获组:把 abc 打包,可整体用量词,也可提取 | (ab)+ 匹配 "abab" |
| (?:abc) | 非捕获组:只打包不提取(省内存) | 同上,但不占捕获编号 |
| \1 | 反向引用:引用第 1 个捕获组匹配的内容 | (\w)\1 匹配 "aa"、"zz" |
4.4 锚点与边界:锁定位置
| 写法 | 含义 | 示例 |
|---|---|---|
| ^ | 字符串/行开头 | ^hello 只匹配开头的 hello |
| $ | 字符串/行结尾 | world$ 只匹配结尾的 world |
| \b | 单词边界(单词字符与非单词字符的分界) | \bcat\b 匹配 "a cat" 里的 cat,不匹配 "category" 里的 cat |
| \B | 非单词边界 | \Bcat\B 匹配 "concatenate" 里的 cat |
4.5 转义:特殊字符要"加反斜杠"
想匹配字面上的 .、*、(、\ 等特殊字符时,前面加 \:
cpp
// 想匹配 "3.14" 里的点号:点号是特殊字符,必须写成 \.
std::regex pattern("3\\.14"); // C++ 字符串里 \\ 表示一个反斜杠
| 想匹配的字面字符 | 正则写法 | C++ 字符串写法 |
|---|---|---|
| . | \. | "\\." |
| * | \* | "\\*" |
| ( | \( | "\\(" |
| \ | \\ | "\\\\" |
⚠️ 易错点(C++ 特有,必考) :C++ 字符串里反斜杠本身需要转义,所以正则里的每个 \,在 C++ 源码里都要写两个 。正则 \d → C++ 写 "\\d";正则 \. → C++ 写 "\\."。忘了双写是新手最常见的编译/运行错误。C++11 起可以用原始字符串字面量 R"(...)" 少写一半反斜杠:
cpp
// 普通字符串写法(容易看花眼)
std::regex p1("\\d{4}-\\d{2}-\\d{2}");
// 原始字符串写法(推荐!反斜杠不用双写)
std::regex p2(R"(\d{4}-\d{2}-\d{2})"); // 和 p1 完全等价
4.6 语法风格:std::regex 默认用 ECMAScript
std::regex 支持多种语法风格,默认是 ECMAScript(和 JavaScript 正则最接近)。构造时可用第二个参数切换:
cpp
// 默认 ECMAScript
std::regex r1("\\d+");
// 显式指定 ECMAScript
std::regex r2("\\d+", std::regex::ECMAScript);
// POSIX 扩展语法(不同语法的 \d 等写法不同,一般用不到)
std::regex r3("[[:digit:]]+", std::regex::extended);
| 语法风格常量 | 说明 |
|---|---|
| std::regex::ECMAScript(默认) | 与 JavaScript 风格一致,功能最全 |
| std::regex::basic / extended | POSIX 风格,\d 这类缩写不支持,要用 \[:digit:] |
| std::regex::awk / grep / egrep | 兼容经典 Unix 工具 |
⚠️ 易错点:POSIX 风格不支持 \d、\w、\s 缩写,要用 \[:digit:]、\[:alpha:] 等"字符类"写法。如果非要用 \d 却选了 extended,会编译出错或匹配失败。
第 5 章 四大核心 API 实战:match / search / replace / iterator
5.1 std::regex_match:全文严格匹配(格式校验)
使用场景:校验"整个输入是否符合格式"------身份证、手机号、版本号、日期。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
// 要校验的字符串
std::string id = "110101200001011234";
// 18 位身份证:17 位数字 + 最后一位数字或 X
std::regex id_pattern(R"(\d{17}[\dXx])");
// regex_match:整个字符串必须完全匹配
if (std::regex_match(id, id_pattern)) {
std::cout << "身份证格式合法" << std::endl;
} else {
std::cout << "身份证格式不合法" << std::endl;
}
// 反例:字符串里混了别的字符,match 会失败
std::string bad = "身份证号是110101200001011234"; // 前面多了汉字
if (std::regex_match(bad, id_pattern)) {
std::cout << "(意外)匹配成功" << std::endl;
} else {
std::cout << "正确:多字导致全文匹配失败" << std::endl;
}
return 0;
}
5.2 std::regex_search:局部搜索(找片段)
使用场景:在文本中找"是否包含"、找"第一处匹配"。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string log = "2026-08-17 10:23:45 [ERROR] connection timeout to 192.168.1.10:8080";
// 找 IPv4 地址:4 组 1~3 位数字,用点分隔
std::regex ip_pattern(R"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})");
std::smatch m;
if (std::regex_search(log, m, ip_pattern)) {
std::cout << "找到 IP: " << m[0] << std::endl; // 192.168.1.10
std::cout << "起始位置: " << m.position() << std::endl; // 输出下标
}
return 0;
}
💡 提示:std::smatch 是 std::match_results<std::string::const_iterator> 的类型别名;如果操作的是 std::wstring(宽字符串),用 std::wsmatch。
5.3 std::regex_replace:批量替换
使用场景:数据脱敏、格式统一、清洗脏数据。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "联系 13812345678 或 13987654321,我们会尽快回复。";
// 手机号:1 开头 + 10 位数字
// 替换成 "****",实现脱敏
std::regex phone(R"(1\d{10})");
std::string result = std::regex_replace(text, phone, "****");
std::cout << result << std::endl;
// 输出:联系 **** 或 ****,我们会尽快回复。
return 0;
}
用 $1 保留捕获组做"部分保留"的替换(只打码中间四位):
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "我的手机是 13812345678";
// 分组:(\d{3}) 前 3 位,(\d{4}) 中间 4 位,(\d{4}) 后 4 位
// 替换串里 $1、$2、$3 分别引用第 1、2、3 个捕获组
std::regex phone(R"((\d{3})(\d{4})(\d{4}))");
std::string result = std::regex_replace(text, phone, "$1****$3");
std::cout << result << std::endl;
// 输出:我的手机是 138****5678
return 0;
}
⚠️ 易错点:替换字符串里的 是特殊符号,& 表示"整个匹配到的内容",1\~9 表示捕获组。如果想输出字面 ,要写成 $。
5.4 std::regex_iterator 与 std::regex_token_iterator:遍历所有匹配
使用场景:一次性取出所有 IP、所有数字、所有日期。
写法 A:std::sregex_iterator(推荐,直观)
cpp
#include <regex>
#include <string>
#include <iostream>
#include <vector>
int main() {
std::string log = "尝试连接 10.0.0.1,失败后连接 10.0.0.2,最后 172.16.5.9 成功。";
std::regex ip_pattern(R"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})");
// 构造迭代器:begin 指向第一处匹配,end 是"结束哨兵"
auto begin = std::sregex_iterator(log.begin(), log.end(), ip_pattern);
auto end = std::sregex_iterator(); // 默认构造 = 结束位置
std::vector<std::string> ips;
for (auto it = begin; it != end; ++it) {
// *it 是 smatch,[0] 是完整匹配串
ips.push_back((*it)[0].str());
}
std::cout << "共找到 " << ips.size() << " 个 IP:" << std::endl;
for (const auto& ip : ips) {
std::cout << " " << ip << std::endl;
}
return 0;
}
写法 B:std::sregex_token_iterator(想只取"捕获组内容"时很方便)
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
// 场景:从 "name=alice&age=25&city=beijing" 中提取所有 value
std::string query = "name=alice&age=25&city=beijing";
// 只提取捕获组 1(等号后面的部分)
std::regex kv(R"(=(\w+))");
// 第二个参数 -1 表示"返回不匹配的部分",1 表示"返回第 1 个捕获组"
auto begin = std::sregex_token_iterator(query.begin(), query.end(), kv, 1);
auto end = std::sregex_token_iterator();
std::cout << "提取到的 value:";
for (auto it = begin; it != end; ++it) {
std::cout << " " << *it;
}
std::cout << std::endl;
// 输出:提取到的 value: alice 25 beijing
return 0;
}
5.5 四种 API 选择速查
| 需求 | 用哪个 | 返回什么 |
|---|---|---|
| 整个字符串是否符合格式 | regex_match | bool(或通过 smatch 拿分组) |
| 是否包含 / 第一处匹配在哪 | regex_search | bool + smatch(位置、内容) |
| 把所有命中替换掉 | regex_replace | 新字符串 |
| 把每处命中都取出来 | regex_iterator | 逐段遍历 |
| 只要每处命中的某个分组 | regex_token_iterator | 逐段遍历(可指定分组) |
第 6 章 捕获组进阶:反向引用与命名分组
6.1 捕获组是什么?为什么要用?
正则里用小括号 () 包起来的部分叫捕获组(capturing group)。它有两个作用:
- 整体打包:让量词作用于"一组字符"而不是单个字符。例如 (ab)+ 匹配 "abab"。
- 单独提取:引擎会把每个括号里匹配到的内容单独存一份,供后续使用。
类比:捕获组像"快递包裹里的分装袋"。m0 是整个包裹,m1 是第 1 个分装袋,m2 是第 2 个......编号从 1 开始,按左括号出现的顺序从左到右编号。
6.2 提取多组内容:解析日期
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "会议定于 2026-08-17 下午 3 点召开。";
// 三个捕获组:年、月、日
std::regex date_pattern(R"((\d{4})-(\d{2})-(\d{2}))");
std::smatch m;
if (std::regex_search(text, m, date_pattern)) {
std::cout << "完整匹配: " << m[0] << std::endl; // 2026-08-17
std::cout << "年: " << m[1] << std::endl; // 2026
std::cout << "月: " << m[2] << std::endl; // 08
std::cout << "日: " << m[3] << std::endl; // 17
}
return 0;
}
6.3 反向引用:匹配"重复出现的内容"
反向引用(backreference) 用 \1、\2 表示"这里必须和第 1、2 个捕获组刚才匹配到的内容一模一样"。
典型场景:找出成对重复的单词(打字重复错误)。
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "this is is a test test case";
// (\w+) 匹配一个单词;\s+ 匹配中间空白;\1 要求再出现同一个单词
std::regex dup_pattern(R"((\w+)\s+\1)");
auto begin = std::sregex_iterator(text.begin(), text.end(), dup_pattern);
auto end = std::sregex_iterator();
std::cout << "重复单词:" << std::endl;
for (auto it = begin; it != end; ++it) {
// 完整匹配是 "is is" / "test test",第 1 组是单词本身
std::cout << " " << (*it).str() << " -> 重复的是: " << (*it)[1] << std::endl;
}
return 0;
}
输出:
重复单词: is is -> 重复的是: is test test -> 重复的是: test
⚠️ 易错点(重要!) :反向引用是"回溯型引擎"的特性,RE2 不支持 。如果你把上面这段代码换成 RE2,会得到编译错误:"invalid escape sequence" 或 "backreferences not supported"。这是第 8 章引擎原理的核心区别,先记住结论:要反向引用 → PCRE2/std::regex;要防卡死 → RE2。
6.4 命名分组:给捕获组起名字(C++ 标准库不支持!)
| 库 | 命名分组写法 | 引用方式 |
|---|---|---|
| PCRE2 | (?<year>\d{4}) 或 (?'year'\d{4}) | \k<year> |
| RE2 | (?P<year>\d{4}) | (?P=year) |
| std::regex | 不支持(只能用编号 m1、m2...) | --- |
⚠️ 易错点 :std::regex 没有命名分组语法。网上很多用 (?<name>...) 的 JavaScript 教程,直接抄进 C++ 会编译失败。需要命名分组请用 PCRE2 或 RE2。
6.5 非捕获组:(?:...) 只打包不提取
当小括号只是为了"整体加量词",不需要提取内容时,用 (?:...) 能省掉编号、略微提升性能、避免编号混乱。
cpp
// 需求:匹配 "abc" 或 "xyz" 连续出现 2 次
// 写法 A:用捕获组 (abc|xyz) 也能跑,但白白占用了 m[1]
std::regex a(R"((abc|xyz){2})");
// 写法 B:用非捕获组,不占编号(推荐)
std::regex b(R"((?:abc|xyz){2})");
第 7 章 性能陷阱:灾难性回溯(ReDoS)是怎么把程序卡死的
7.1 一个"看起来没问题"的卡死现场
下面这段代码,输入只有 30 个字符,但运行时间可能超过几秒甚至永远跑不完:
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
// 模式:若干 a 后面跟若干 a,再跟一个 b
// 匹配目标:"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"(30 个 a,后面没有 b)
std::regex pattern(R"(^(a+)+$)");
std::string evil(30, 'a'); // 30 个 a,故意不匹配(没有 b)
// 这句话可能要跑几十秒甚至几分钟!
bool ok = std::regex_match(evil, pattern);
std::cout << std::boolalpha << ok << std::endl;
return 0;
}
把 30 改成 40,时间会暴涨到指数级 。这就是著名的灾难性回溯(Catastrophic Backtracking) ,也叫 ReDoS(Regular expression Denial of Service,正则拒绝服务)。
7.2 为什么会卡死?------三分钟看懂回溯
回溯(backtracking) 是回溯型引擎的"试错机制":
类比:你在一个岔路很多的迷宫里找出口。走一条路发现不通,就退回上一个岔路口换一条路再试。如果岔路口套岔路口,试错的组合数会爆炸。
^(a+)+$ 匹配 30 个 a 且没有 b 时,引擎的行为:
- 外层 (a+)+ 可以理解为"把 a 分成若干段",比如 aaa...、aaaa...、aaaa...------有 2^30 种分法。
- 每种分法都要试到末尾,发现没有 b 才失败,然后退回换下一种分法。
- 试完所有分法 ≈ 10 亿种组合 → 程序卡死。
嵌套量词 + 匹配失败 = 灾难。凡是出现 (x+)+、(x*)*、(x+)* 这类"量词套量词"的结构,配合无法匹配的输入,就可能指数爆炸。
7.3 经典危险模式清单
| 危险模式 | 危险原因 | 攻击输入示例 |
|---|---|---|
| ^(a+)+$ | 外层 + 套内层 + | 一串 a 后跟别的字符 |
| `^(a | a)+$` | 多分支 + 量词 |
| ^(\w+\s?)*$ | 量词套量词 | 长串无空格的字符 |
| ^(.*,)*\d+$ | 点号星号嵌套 | 大量逗号+结尾非数字 |
7.4 如何防御
- 模式层面:消除"量词套量词"。(a+)+ 改成 a+(大多数场景等价)。
- 输入层面:限制输入长度;对不可信输入设置超时。
- 引擎层面 :用 RE2(线性时间,天然免疫回溯爆炸),这是最彻底的方案。
- 工具层面:上线前用 ReDoS 检测工具(如 safe-regex、vuln-regex-detector)扫描模式。
⚠️ 易错点:std::regex 无法设置"匹配超时",一旦遇到灾难性回溯就是真·卡死。生产环境如果正则处理用户输入,强烈建议换成 RE2(第 9 章)。
第 8 章 引擎原理深度解析:NFA / DFA / 回溯 / RE2 的线性时间魔法
本章是本篇的"深度"核心。如果你只想用库,可以跳到第 9 章;如果想理解"为什么 RE2 安全、PCRE2 功能全、std::regex 慢",请务必读完。
8.1 正则引擎的两种基本流派
所有正则引擎本质上分两类:
| 流派 | 代表 | 匹配方式 | 时间复杂度 | 支持反向引用/环视 |
|---|---|---|---|---|
| 回溯型(Backtracking NFA) | PCRE、Perl、Java、Python re、std::regex(多数实现) | 深度优先试错,一条路走到黑再回头 | 最坏指数级 | ✅ 支持 |
| 自动机型(DFA / NFA 模拟) | RE2、grep、awk 部分实现、Rust regex | 同时跟踪所有可能状态,一次扫描推进 | 线性 O(n) | ❌ 不支持 |
8.2 从正则到自动机:模式是如何"变成机器"的
要理解自动机型引擎,先认识两个概念:
NFA(非确定有限自动机,Nondeterministic Finite Automaton)
类比:一张"地铁换乘图"。从起点站出发,每到一站,如果当前字符满足条件就前进;NFA 的"非确定"体现在:同一时刻可能有多条路线同时可选。
把正则 a(b|c)d 翻译成 NFA:
start --a--> [1] --b--> [2] --d--> [accept] \--c--> [3] --d--/
DFA(确定有限自动机,Deterministic Finite Automaton)
类比:把"地铁换乘图"提前改造成"每条路只有一个方向的单向高架",每站只有一条确定的路可走。DFA 每个状态读一个字符只产生一个确定的下一个状态,所以匹配速度极快,但状态可能很多(最坏指数级膨胀)。
8.3 Thompson 构造法:正则 → NFA 的机械翻译
1968 年 Ken Thompson(Unix 之父)提出:任何正则都能机械地翻译成 NFA,只需 6 种基本构件拼装:
| 正则片段 | NFA 构件 |
|---|---|
| 单个字符 a | 两个状态,中间一条标着 a 的边 |
| 连接 ab | 两个子 NFA 首尾相接 |
| 并集 `a | b` |
| 星号 a* | 加一条"跳过"边形成环 |
| 加号 a+ | a 的星号外面再强制走一次 a |
| 问号 a? | 加一条"跳过"边 |
这套构造法的意义:任何正则都能在 O(长度) 时间内变成 NFA,之后在 NFA 上做匹配就有章可循了。
8.4 三种引擎的工作方式对比
① 回溯型引擎(PCRE/Java/Python re/std::regex 多数实现)
- 用深度优先方式在 NFA 上"探路":优先沿第一条边走,走到死胡同就回头试另一条。
- 优点:天然支持反向引用(\1)、环视((?=...))、懒惰量词等"需要记录试错路径"的高级特性。
- 缺点:路径组合指数增长 → 灾难性回溯。
② 传统 DFA 引擎(老式 grep 等)
- 先用"子集构造法"把 NFA 变成 DFA(一次性预处理,状态爆炸是它的代价),然后每个字符只查一次表。
- 优点:匹配本身极快、稳定线性。
- 缺点:预处理可能消耗大量内存;DFA 状态一多就爆。
③ NFA 模拟引擎(RE2 采用)------兼顾两者
RE2 的巧妙之处:不把 NFA 变成 DFA,而是"同时模拟 NFA 上的所有可能状态"。
类比:回溯引擎是"一个人带一个笔记本,走一条路记一条笔记,走不通翻回去改笔记";RE2 是"带了一支军队,每个岔路口分一队人,各走各的,最后看有没有队伍到达终点"。军队人数 = NFA 状态数,是固定的,不会因为输入变长而爆炸。
实现手段叫 Pike VM(Rob Pike 发明的虚拟机):
- 引擎维护一个状态列表,初始只含起始状态。
- 每读一个字符,就计算"当前所有可达状态在吃下这个字符后,能到哪些新状态",得到新的状态列表。
- 一个字符只做一次"全体状态转移",所以总代价 = 输入长度 × 状态数 = O(n × m) ,m 是正则长度,可视为常量,即 线性时间。
输入: a b c d
状态集: {start} → {1} → {2} → {3} → {accept}
(每步并行推进所有可能状态,不会回头重试)
8.5 为什么 RE2 不支持反向引用和环视?
- 反向引用 要求"记住之前匹配的具体文本 再回去比对",每个候选文本都产生新的路径,状态数随输入爆炸------破坏了"状态数固定"的线性保证。
- 环视((?=...)、(?<=...))要求"在不消耗字符的情况下向前/向后试探",同样需要回溯试错。
- RE2 的设计哲学:宁可牺牲这些功能,也要保证最坏情况线性时间。因为它的目标场景是"处理不可信的、海量的输入"(Google 内部每天处理万亿级请求)。
8.6 性能对比实测思路
如果你想亲自验证三种引擎的差距,可以写一个基准程序:对同一段文本(如 1MB 日志)重复匹配 1000 次,分别统计 std::regex、RE2、PCRE2(可开 JIT)的耗时。经验结论大致如下(具体数值取决于编译器与机器):
| 引擎 | 简单模式(\d+) | 复杂模式((a+)+ 类) | 危险输入下的表现 |
|---|---|---|---|
| std::regex(libstdc++) | 慢(数倍于 RE2) | 慢 | 可能卡死 |
| PCRE2 | 较快(JIT 后极快) | 快 | 可能卡死 |
| RE2 | 快 | 快 | 稳定线性,永不卡死 |
8.7 引擎原理总结表
| 维度 | 回溯型(PCRE2/std::regex) | NFA 模拟型(RE2) |
|---|---|---|
| 匹配策略 | 深度优先 + 回溯试错 | 并行模拟所有状态 |
| 最坏时间复杂度 | 指数级 | 线性 |
| 反向引用 | ✅ | ❌ |
| 环视(lookaround) | ✅ | ❌ |
| 懒惰量词 | ✅ | ✅(RE2 支持,语义等价实现) |
| 内存占用 | 取决于回溯深度 | 与 NFA 状态数成正比(可控) |
| 典型用途 | 复杂文本处理、编辑器 | 海量日志、不可信输入、安全过滤 |
第 9 章 RE2 实战:如何接入并写出高性能匹配代码
9.1 第 1 步:安装 RE2
RE2 是 Google 开源 C++ 库(GitHub: google/re2,BSD 许可)。安装方式:
bash
# Ubuntu / Debian
sudo apt install libre2-dev
# macOS(Homebrew)
brew install re2
# Windows:用 vcpkg
vcpkg install re2
# 或 vcpkg install re2:x64-windows
CMake 接入:
find_package(re2 REQUIRED)
target_link_libraries(your_target PRIVATE re2::re2)
9.2 第 2 步:基本匹配(compile + match)
RE2 的用法核心:先 RE2::FullMatch(全文匹配)或 RE2::PartialMatch(部分匹配)。
cpp
#include <re2/re2.h> // RE2 头文件
#include <string>
#include <iostream>
int main() {
std::string text = "订单 AB12345 已发货";
// RE2 模式:AB 后跟 5 位数字
re2::RE2 pattern(R"(AB\d{5})");
// PartialMatch:查找 text 中是否有匹配片段(类似 regex_search)
if (RE2::PartialMatch(text, pattern)) {
std::cout << "找到订单号模式" << std::endl;
}
// 提取捕获组:把匹配到的数字部分取出来
std::string order_no;
re2::RE2 extract(R"(AB(\d{5}))");
if (RE2::PartialMatch(text, extract, &order_no)) {
// 第 1 个捕获组内容会写入 order_no
std::cout << "订单号 = " << order_no << std::endl;
}
return 0;
}
💡 说明:RE2 里 PartialMatch 对应 std::regex 的 regex_search,FullMatch 对应 regex_match。
9.3 第 3 步:批量匹配(RE2::Set)
一个常见需求:一次拿几百个模式,看文本命中哪些。RE2 提供 RE2::Set 批量优化:
cpp
#include <re2/re2.h>
#include <string>
#include <iostream>
#include <vector>
int main() {
// 1) 创建 Set,指定匹配方式(RE2::UNANCHORED = 部分匹配)
re2::RE2::Set set(re2::RE2::UNANCHORED);
// 2) 注册多个模式,返回各自的索引
int idx_ip = set.Add(R"(\d+\.\d+\.\d+\.\d+)", nullptr);
int idx_mail = set.Add(R"(\w+@\w+\.\w+)", nullptr);
int idx_url = set.Add(R"(https?://\S+)", nullptr);
// 3) 编译(编译失败返回 false)
if (!set.Compile()) {
std::cerr << "编译失败" << std::endl;
return 1;
}
// 4) 一次性匹配整段文本,命中的模式索引写入 matches
std::string log = "访问 https://example.com 的用户 ip 是 10.0.0.8";
std::vector<int> matches;
if (set.Match(log, &matches)) {
std::cout << "命中 " << matches.size() << " 条规则:" << std::endl;
for (int idx : matches) {
if (idx == idx_ip) std::cout << " - IP 规则" << std::endl;
if (idx == idx_mail) std::cout << " - 邮箱规则" << std::endl;
if (idx == idx_url) std::cout << " - URL 规则" << std::endl;
}
}
return 0;
}
类比:RE2::Set 像"一次刷一整张安检清单"------所有模式合并成一台机器,扫描一遍文本同时检查所有规则,比逐个模式匹配快得多。
9.4 第 4 步:常用安全参数
RE2 默认就带保护,还可以显式设置上限:
cpp
#include <re2/re2.h>
#include <iostream>
int main() {
re2::RE2::Options opt;
// 1) 限制内存使用(默认 8MB):防止恶意超长正则耗尽内存
opt.set_max_mem(64 * 1024 * 1024); // 64MB
// 2) 限制"一个字符可匹配的字节数",防御 UTF-8 边界攻击
opt.set_one_line(false);
// 3) 设置日志级别,匹配错误时静默(不刷屏)
re2::RE2::Options::Logging log_opt;
opt.set_log_errors(false);
re2::RE2 pattern(R"((a+)+b)", opt);
if (!pattern.ok()) {
std::cerr << "模式编译失败: " << pattern.error() << std::endl;
return 1;
}
std::cout << "模式编译成功" << std::endl;
return 0;
}
9.5 RE2 与 std::regex API 对照表
| 功能 | std::regex | RE2 |
|---|---|---|
| 构造 | std::regex p(s) | re2::RE2 p(s) |
| 全文匹配 | regex_match | RE2::FullMatch |
| 部分匹配 | regex_search | RE2::PartialMatch |
| 遍历所有命中 | sregex_iterator | re2::StringPiece + 循环 PartialMatch(用 Rewrite 或手写偏移推进) |
| 批量替换 | regex_replace | RE2::GlobalReplace / RE2::Replace |
| 批量多模式 | 手动循环 | RE2::Set |
批量替换示例:
cpp
#include <re2/re2.h>
#include <string>
#include <iostream>
int main() {
std::string text = "ip: 10.0.0.1, ip: 172.16.0.9";
// GlobalReplace:替换所有命中;\\d 在 RE2 的替换串里也是 \\d 形式
re2::RE2::GlobalReplace(&text, R"(\d+\.\d+\.\d+\.\d+)", "[IP]");
std::cout << text << std::endl;
// 输出:ip: [IP], ip: [IP]
return 0;
}
第 10 章 PCRE2 与 JIT:需要完整回溯能力时的选择
10.1 PCRE2 是什么
PCRE2(Perl Compatible Regular Expressions, version 2) 是兼容 Perl 正则语法的库,功能最全:反向引用、环视、递归、条件分支、原子组、占有量词都有。很多著名软件(PHP、R、Apache、PostgreSQL)都在用它。
10.2 什么时候需要 PCRE2
- 你需要反向引用(如"找重复单词")。
- 你需要环视((?<=...) 后向断言、(?=...) 前向断言)。
- 你需要递归匹配(如匹配成对括号 \((?:\^()|(?R))*\))。
- 你确认输入可信 / 已限制长度 / 模式经过审查。
10.3 PCRE2 的 JIT(Just-In-Time 编译)
PCRE2 能把正则编译成机器码(JIT),匹配速度比解释执行快数倍。启用方式:
cpp
#include <pcre2.h>
#include <string>
#include <iostream>
int main() {
// 1) 编译模式(UTF 模式可加 PCRE2_UTF)
int errcode = 0;
PCRE2_SIZE erroffset = 0;
std::string pattern_str = R"(\d{4}-\d{2}-\d{2})";
pcre2_code* re = pcre2_compile(
reinterpret_cast<PCRE2_SPTR>(pattern_str.c_str()),
pattern_str.size(),
0, // 选项:0 表示默认
&errcode, &erroffset, nullptr);
if (re == nullptr) {
std::cerr << "编译失败,错误码 " << errcode << std::endl;
return 1;
}
// 2) 启用 JIT(可选,大幅提速)
int jit_ret = pcre2_jit_compile(re, PCRE2_JIT_COMPLETE);
if (jit_ret != 0) {
std::cout << "JIT 不可用,退回解释执行" << std::endl;
}
// 3) 创建匹配上下文
pcre2_match_data* md = pcre2_match_data_create_from_pattern(re, nullptr);
// 4) 执行匹配
std::string text = "今天日期 2026-08-17";
int rc = pcre2_match(re,
reinterpret_cast<PCRE2_SPTR>(text.c_str()),
text.size(), 0, 0, md, nullptr);
if (rc >= 0) {
// 取第 0 组(完整匹配)的起止位置
PCRE2_SIZE* ovector = pcre2_get_ovector_pointer(md);
std::cout << "匹配成功,长度 " << (ovector[1] - ovector[0]) << std::endl;
} else {
std::cout << "未匹配(返回码 " << rc << ")" << std::endl;
}
pcre2_match_data_free(md);
pcre2_code_free(re);
return 0;
}
⚠️ 使用 PCRE2 一定要意识到:它和 std::regex 一样是回溯型引擎,同样存在灾难性回溯风险。JIT 只是加速,不改变算法复杂度。第 7 章的 ^(a+)+$ 在 PCRE2 下同样会卡死。
10.4 三个引擎终极对比表
| 特性 | std::regex | RE2 | PCRE2 |
|---|---|---|---|
| 需要安装 | ❌(标准库自带) | ✅ | ✅ |
| 跨平台行为一致性 | ❌(三套实现差异) | ✅ | ✅ |
| 反向引用 \1 | ✅ | ❌ | ✅ |
| 环视 (?=...) | ✅(部分实现) | ❌ | ✅ |
| 命名分组 | ❌ | ✅ (?P<name>) | ✅ (?<name>) |
| 最坏时间 | 指数 | 线性 | 指数 |
| 抗 ReDoS | ❌ | ✅ | ❌(可配超时) |
| JIT 加速 | ❌ | ❌(本身已快) | ✅ |
| 典型场景 | 快速原型、简单校验 | 生产环境高吞吐 / 不可信输入 | 复杂语法、文本编辑器 |
💡 如果既想要 RE2 的安全,又需要个别回溯特性,RE2 官方文档的建议是:先尽量用 RE2 能表达的模式;确需回溯特性时再引入 PCRE2,并对输入长度和匹配时间做双重限制。
第 11 章 实战案例:日志解析 / 输入校验 / 数据脱敏 / 批量提取
本章四个案例都基于 std::regex(零依赖可直接编译),第 11.5 节给出"生产环境替换成 RE2"的等价写法提示。
11.1 案例一:日志解析------提取访问日志里的 IP、时间、状态码
cpp
#include <regex>
#include <string>
#include <iostream>
#include <fstream>
int main() {
// 模拟一行 Nginx/通用访问日志
std::string line = "192.168.1.55 - - [17/Aug/2026:10:23:45 +0800] \"GET /api/user HTTP/1.1\" 200 1024";
// 分组依次是:IP、日期时间、请求方法、路径、协议、状态码、字节数
std::regex log_pattern(
R"((\d+\.\d+\.\d+\.\d+).*?\[([^\]]+)\].*?\"(\w+)\s+(\S+)\s+([^\"]+)\"\s+(\d{3})\s+(\d+))");
std::smatch m;
if (std::regex_search(line, m, log_pattern)) {
std::cout << "IP: " << m[1] << std::endl;
std::cout << "时间: " << m[2] << std::endl;
std::cout << "方法: " << m[3] << std::endl;
std::cout << "路径: " << m[4] << std::endl;
std::cout << "协议: " << m[5] << std::endl;
std::cout << "状态码: " << m[6] << std::endl;
std::cout << "字节数: " << m[7] << std::endl;
}
return 0;
}
💡 技巧:日志行字段间用 .*?(懒惰匹配)跳过分隔内容,既直观又不易误伤;但注意大量 .*? 会稍慢,生产环境可换成更精确的字符类(如 \^\\s+)。
11.2 案例二:输入校验------邮箱 / URL / 手机号
cpp
#include <regex>
#include <string>
#include <iostream>
// 简单但实用的校验函数封装
bool is_valid_email(const std::string& s) {
// 简化版邮箱:用户名@域名(不追求 100% 严谨,够日常校验用)
static const std::regex email(R"(^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$)");
return std::regex_match(s, email);
}
bool is_valid_url(const std::string& s) {
// http/https + 域名 + 可选路径
static const std::regex url(R"(^https?://[\w.-]+(:\d+)?(/\S*)?$)");
return std::regex_match(s, url);
}
bool is_valid_phone(const std::string& s) {
// 中国大陆手机号:1 开头,第二位 3-9,共 11 位
static const std::regex phone(R"(^1[3-9]\d{9}$)");
return std::regex_match(s, phone);
}
int main() {
std::cout << std::boolalpha;
std::cout << "a@b.com -> " << is_valid_email("a@b.com") << std::endl;
std::cout << "a@b -> " << is_valid_email("a@b") << std::endl;
std::cout << "https://x.com/a -> " << is_valid_url("https://x.com/a") << std::endl;
std::cout << "13812345678 -> " << is_valid_phone("13812345678") << std::endl;
std::cout << "23812345678 -> " << is_valid_phone("23812345678") << std::endl;
return 0;
}
输出:
a@b.com -> true a@b -> false https://x.com/a -> true 13812345678 -> true 23812345678 -> false
⚠️ 说明:生产环境请把校验正则放在 static const 里复用(避免每次构造对象),如上面代码所示;并在高并发路径优先考虑 RE2。
11.3 案例三:数据脱敏------身份证、手机号、卡号打码
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text =
"用户张三 身份证 110101199003071234,手机 13812345678,"
"银行卡 6222021234567890123。";
// 身份证:保留前 4 后 4
text = std::regex_replace(text, std::regex(R"(\d{17}[\dXx])"), "$&");
// 用更精确方式:分组打码
text = std::regex_replace(text,
std::regex(R"((\d{6})\d{8}(\d{4}))"), // 身份证:前6 + 中间8 + 后4
"$1********$2");
// 手机号:保留前 3 后 4
text = std::regex_replace(text,
std::regex(R"((\d{3})\d{4}(\d{4}))"),
"$1****$2");
// 银行卡:保留前 4 后 4
text = std::regex_replace(text,
std::regex(R"((\d{4})\d{11}(\d{4}))"),
"$1***********$2");
std::cout << text << std::endl;
// 输出:用户张三 身份证 110101********1234,手机 138****5678,银行卡 6222***********0123。
return 0;
}
⚠️ 易错点 :手机号正则 (\d{3})\d{4}(\d{4}) 会误伤身份证前 11 位 (身份证也是数字串)。脱敏的稳妥顺序:先处理更长/更特殊的类型(身份证、银行卡),再处理手机号;更严谨的做法是先分词再逐词判断。
11.4 案例四:批量提取------从文本中取出所有数字、所有日期
cpp
#include <regex>
#include <string>
#include <iostream>
#include <vector>
int main() {
std::string text = "价格 12.5 元,数量 3 件,日期 2026-08-17 发货,积分 8888。";
// 1) 提取所有整数
std::regex int_re(R"(\b\d+\b)");
std::vector<std::string> ints;
for (auto it = std::sregex_iterator(text.begin(), text.end(), int_re);
it != std::sregex_iterator(); ++it) {
ints.push_back((*it)[0].str());
}
std::cout << "整数:";
for (auto& s : ints) std::cout << " " << s;
std::cout << std::endl;
// 2) 提取所有日期
std::regex date_re(R"(\d{4}-\d{2}-\d{2})");
std::vector<std::string> dates;
for (auto it = std::sregex_iterator(text.begin(), text.end(), date_re);
it != std::sregex_iterator(); ++it) {
dates.push_back((*it)[0].str());
}
std::cout << "日期:";
for (auto& s : dates) std::cout << " " << s;
std::cout << std::endl;
return 0;
}
输出:
整数: 12 5 3 2026 08 17 8888 日期: 2026-08-17
💡 注意:\b\d+\b 会把 2026-08-17 拆成 2026、08、17 三个整数------这正是"按整词提取"的正常语义。想跳过日期里的数字,需要更复杂的模式(如排除日期结构),实际业务按需调整。
11.5 生产环境等价写法(std::regex → RE2)
把案例四改成 RE2 只需改三处:头文件 #include <re2/re2.h>、用 RE2::PartialMatch 循环、用 StringPiece 推进偏移:
cpp
#include <re2/re2.h>
#include <string>
#include <iostream>
#include <vector>
int main() {
std::string text = "价格 12.5 元,数量 3 件,日期 2026-08-17 发货,积分 8888。";
re2::RE2 int_re(R"(\b\d+\b)");
std::vector<std::string> ints;
re2::StringPiece input(text);
std::string matched;
// 循环:每次 PartialMatch 找到一个,然后把输入前移
while (RE2::FindAndConsume(&input, int_re, &matched)) {
ints.push_back(matched);
}
std::cout << "整数:";
for (auto& s : ints) std::cout << " " << s;
std::cout << std::endl;
return 0;
}
RE2::FindAndConsume 的语义:找到匹配后自动把输入指针推进到匹配末尾,天然适合"逐个取出所有命中"。RE2 还有 Consume(要求从当前位置开始匹配)和 FindAndConsume(任意位置找)两种。
第 12 章 易错点大全(踩坑合集)
| 易错点 | 错误示例 | 正确做法 | 后果 | |
|---|---|---|---|---|
| 1 | 忘记反斜杠双写 | std::regex("\\d") 其实是对的,错的是写 std::regex("\d") | 用原始字符串 R"(\d)" | 编译警告/未定义行为 |
| 2 | regex_match 和 regex_search 混用 | 用 match 找子串 | 全文校验用 match,找片段用 search | 永远 false,莫名失败 |
| 3 | 贪婪匹配吃到不该吃的 | ".*" 匹配多个引号段 | ".*?" 懒惰匹配 | 替换/提取结果错误 |
| 4 | 量词套量词 | (a+)+、(\w+\s?)* | 化简模式;或换 RE2 | 灾难性回溯卡死 |
| 5 | 用 POSIX 语法却期望 \d | std::regex("\\d+", std::regex::extended) | 默认 ECMAScript,或用 \[:digit:] | 匹配失败 |
| 6 | 以为 std::regex 支持命名分组 | (?<name>...) | 用编号 m1;或换 RE2/PCRE2 | 编译错误 |
| 7 | 每次循环都重新构造 regex | 在 for 里 std::regex r(...) | 提到循环外 / static const | 性能浪费 |
| 8 | 忽略空匹配死循环 | 用 iterator 匹配可空模式 a* 遍历 | 检查匹配长度,长度为 0 时手动推进迭代器 | 无限循环/重复结果 |
| 9 | 用 \d 匹配中文数字 | \d 只匹配 0-9 | 按业务补充 0-90-9一二三四五六七八九十 | 漏匹配 |
| 10 | 替换串里 $ 理解错 | 想输出 却写 | 写 $$ 表示字面 ;& 是整个匹配 | 输出内容错误 |
| 11 | 跨平台只测一个编译器 | 只测 Linux 就上线 | 至少在 GCC/Clang/MSVC 各测一遍,或统一用 RE2 | 行为不一致 |
| 12 | 用正则解析 HTML/JSON | 用 .* 匹配嵌套标签 | 换专门解析器(如 simdjson、html parser) | 规则永远写不对 |
12.1 空匹配死循环详解(易错点 8 的演示)
cpp
#include <regex>
#include <string>
#include <iostream>
int main() {
std::string text = "abc";
std::regex pattern(R"(a*)"); // 可匹配空串的模式
auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
auto end = std::sregex_iterator();
int count = 0;
for (auto it = begin; it != end; ++it) {
std::cout << "命中: [" << (*it)[0] << "] 长度=" << (*it)[0].length() << std::endl;
++count;
if (count > 10) { // 防呆:避免真死循环
std::cout << "(已截断输出)" << std::endl;
break;
}
}
return 0;
}
输出会包含大量长度为 0 的空命中(在位置 1、2、3 处各匹配一次空串)。实际业务里若用"迭代器 + 空模式",容易产出重复结果。处理办法:遍历时若 length() == 0,则手动把起始位置 +1 再继续。
第 13 章 FAQ 速查表
| 问题 | 一句话答案 |
|---|---|
| 正则表达式是什么? | 用特殊字符描述"什么样的文本算命中"的检索模板 |
| C++ 里怎么用正则? | #include <regex>,构造 std::regex,调 regex_match / regex_search / regex_replace / regex_iterator |
| regex_match 和 regex_search 区别? | match 要求全文完全匹配;search 只要求包含匹配片段 |
| \d 在 C++ 里怎么写? | "\\d"(双写反斜杠),推荐原始字符串 R"(\d)" |
| 贪婪和懒惰量词怎么选? | 默认贪婪尽量多吃;量词后加 ? 变懒惰尽量少吃;取引号内容用懒惰 |
| 怎么提取匹配的部分? | 用 std::smatch,m0 整个匹配,m1 起是捕获组 |
| 反向引用能用吗? | std::regex / PCRE2 可以,RE2 不行 |
| std::regex 支持命名分组吗? | 不支持,用编号或换 RE2/PCRE2 |
| 为什么我的正则一匹配就卡死? | 大概率是灾难性回溯(如 (a+)+),换 RE2 或简化模式 |
| RE2 和 PCRE2 选哪个? | 防卡死/高吞吐选 RE2;需要反向引用/环视选 PCRE2 |
| RE2 怎么安装? | apt libre2-dev / brew re2 / vcpkg re2 |
| 匹配性能太慢怎么办? | 复用 regex 对象、简化模式、换 RE2、必要时 PCRE2 JIT |
| 能用正则解析 HTML 吗? | 不建议,嵌套结构请用专门解析器 |
| 跨平台正则行为不一样? | std::regex 三套实现有差异,生产环境建议统一 RE2/PCRE2 |
| 正则校验邮箱要写多复杂? | 日常够用即可(见 11.2),过度追求 RFC 完整会让模式难维护 |
第 14 章 总结与延伸阅读
14.1 核心结论(三句话带走)
- 会用:std::regex 零依赖、开箱即用,掌握 match / search / replace / iterator 四件套 + 捕获组,就能覆盖 80% 日常需求。
- 会用对:全文校验用 regex_match,找片段用 regex_search,遍历用迭代器;反斜杠记得双写;避免量词套量词。
- 会选型 :生产环境、高吞吐、不可信输入 → RE2 (线性时间,永不回溯爆炸);需要反向引用/环视 → PCRE2(加 JIT,控制好输入与超时)。
14.2 与已写 C++ 主题的关系
- 与《simdjson 高性能 JSON 解析库》互补:simdjson 解决"JSON 文本解析",正则解决"任意文本模式匹配",两者可组合使用(先用正则切出 JSON 片段,再交给 simdjson)。
- 与《fmt 开源格式化库》互补:fmt 负责"格式化输出",正则负责"输入/中间文本的模式处理"。
- 与《C++ 多线程并发编程实战》互补:正则匹配天然适合并行------大日志分片后多线程各跑 RE2,RE2::Set 还能一次匹配多规则。
14.3 延伸阅读
| 主题 | 建议资料 |
|---|---|
| RE2 官方 | GitHub google/re2 README 与 re2/syntax 文档 |
| PCRE2 官方 | pcre2pattern(3) 手册页 |
| 正则性能分析 | Russ Cox 的三篇经典文章《Regular Expression Matching Can Be Simple And Fast》 |
| ReDoS 研究 | OWASP ReDoS 条目;vuln-regex-detector 工具 |
| C++ 标准库 | cppreference.com 的 <regex> 章节 |
14.4 附:全部示例代码统一编译命令
bash
# 纯 std::regex 示例(第 3~7、11~12 章)
g++ -std=c++17 -O2 demo.cpp -o demo
# RE2 示例(第 9、11.5 章,需先安装)
g++ -std=c++17 -O2 re2_demo.cpp -o re2_demo -lre2
# PCRE2 示例(第 10 章,需先安装)
g++ -std=c++17 -O2 pcre2_demo.cpp -o pcre2_demo -lpcre2-8