从std::regex的运行时开销说到“干脆在编译期把正则跑完”,这个过程我是在一个日志解析模块里被逼出来的。当时有个过滤器要对每行日志跑几个正则做字段抽取,上线后性能直接垫底。std::regex匹配一次要几十到几百微秒,量大时 CPU 全耗在匹配上了。后来我把其中一部分固定格式的校验换成了编译期正则,编译期就把匹配结果定死,运行时只剩一次常量读取。这篇文章就把我整理出的那套“C++编译期正则表达式”的实现思路、完整代码和踩坑记录写清楚,适合对模板元编程和constexpr有兴趣,同时又被运行时正则性能困扰的 C++ 开发者参考。
1. 为什么我想在编译期跑正则
1.1std::regex到底慢在哪里
很多人觉得正则慢是“匹配算法”的问题,其实对绝大多数短字符串来说,大头是三个地方:
第一,模式解析。std::regex构造时会解析正则字符串、构建内部状态机(NFA/DFA)。这个工作在运行时每次构造都要重复。我见过不少代码把std::regex直接写进循环里,一个 100 万行的日志文件,相当于重复解析 100 万次模式,纯粹是浪费。
第二,多态虚函数调用。std::regex的regex_traits和底层的_Regex_val做了大量抽象,每个字符的匹配都要穿过好几层虚函数和迭代器封装。短字符串场景下,函数调用开销甚至比实际匹配还高。
第三,动态分配。匹配过程中,NFA 状态集合、捕获组、位置标记都可能涉及堆分配,尤其在开启多个捕获组时更明显。
我当时的场景是:一组规则完全固定的校验型正则,比如^\d+\.\d+\.\d+\.\d+$这种 IP 格式判断,文本又短,最长不到 64 字节。这种情况下花几百微秒在正则上,性价比低到离谱。
1.2 编译期求值能带来什么
编译期正则的思路很简单:既然模式和文本在编译期就能确定(比如硬编码的校验规则),那在编译阶段直接把匹配结果算出来,运行时什么都不用做。这样一来:
- 模式解析成本归零,不存在“运行时反复解析”的问题。
- 匹配结果可以被
static_assert固化,甚至直接参与常量传播,连比较分支都不用保留。 - 能做模式合法性检查,写错正则在编译期就报错,而不是等到运行时才收获一个异常或静默失败。
当然它不是万能的,文本必须是编译期常量,模式也得是编译期常量。这个限制在后面专门讲。还有一个更现实的价值:把编译期正则的实现走一遍,你对 C++17constexpr函数的能力边界、回溯匹配器的工作原理、编译期性能调优的理解都会上一个台阶。下面的实现就是从零开始搭的一个精简版。
2. 编译期正则的基础设施:constexpr 与字符串
2.1 让字符串走进常量表达式
想在编译期匹配字符串,第一步是让字符串本身能在常量表达式中被“看见”。C++17 的constexpr函数里可以直接读取数组内容,所以最朴素的做法就是直接传 C 风格字符串:
constexpr bool foo(const char* s) { return s != nullptr && s[0] == 'h'; } static_assert(foo("hello"));字符串字面量是静态存储期数组,传给constexpr函数时,函数体里对s[i]的读取就是常量表达式的一部分。这是编译期字符串处理的基础。
但一个关键限制是:constexpr函数里不能做动态内存分配(C++20 放宽了部分能力,但编译器默认不支持new出现在常量求值路径),不能写未定义行为,递归深度也受编译器选项限制。所以经典的“先把正则解析成 AST,再跑匹配”在编译期做起来很麻烦,常见做法是把解析和匹配揉在一起,边解析边匹配,靠递归回溯完成全部工作。
2.2 经典的 K&R 回溯匹配器
编译期正则的代码骨架,我没发明新东西,用的是 Brian Kernighan 和 Rob Pike 在《The Practice of Programming》里写的那个经典回溯匹配器。它极其精简,核心只有两个函数:
// 在 text 的某个位置,尝试用模式 re 匹配 bool match_here(const char* re, const char* text) { if (re[0] == '\0') return *text == '\0'; if (re[1] == '*') return match_star(re[0], re + 2, text); if (re[0] == '$' && re[1] == '\0') return *text == '\0'; if (*text != '\0' && (re[0] == '.' || re[0] == *text)) return match_here(re + 1, text + 1); return false; } // 匹配字符 c 重复 0 次或多次 bool match_star(int c, const char* re, const char* text) { do { if (match_here(re, text)) return true; } while (*text != '\0' && (*text++ == c || c == '.')); return false; }它的原理是正则里最朴素的回溯:遇到*时,先试“匹配 0 次”,失败就试“匹配 1 次、2 次……”,每一步都递归检查剩余模式能否匹配剩余文本。一旦某个分支成功就返回,否则回溯到上一个决定点。
这个版本的普适性不够,只支持.和*,不支持+、?、字符类、转义和锚点^。但它的递归结构非常适合改造成constexpr。下面我就基于它做扩展。
3. 从经典匹配器到编译期版本:核心实现
3.1 字符分类与单原子匹配
在编译期实现“匹配单个原子”是基础。所谓原子,就是一个正则里最小且不可拆分的匹配单元,比如普通字符a、通配符.、转义序列\d、字符类[a-z]。
我当时明确需求是能匹配 IP 格式和部分日志字段,所以列出这些转义:\d数字、\w字母数字下划线、\s空白符,以及它们的大写取反形式。字符类要支持范围,比如[0-9],还要支持排除型[^...]。
namespace ctre { constexpr bool is_digit(char c) noexcept { return c >= '0' && c <= '9'; } constexpr bool is_word(char c) noexcept { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; } constexpr bool is_space(char c) noexcept { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; } // 判断一个原子模式 atom 是否能匹配字符 ch constexpr bool atom_matches(const char* atom, char ch) noexcept { switch (atom[0]) { case '.': return ch != '\0'; case '\\': switch (atom[1]) { case 'd': return is_digit(ch); case 'w': return is_word(ch); case 's': return is_space(ch); case 'D': return ch != '\0' && !is_digit(ch); case 'W': return ch != '\0' && !is_word(ch); case 'S': return ch != '\0' && !is_space(ch); default: return ch == atom[1]; } case '[': { const char* p = atom + 1; bool negate = false; if (*p == '^') { negate = true; ++p; } bool matched = false; while (*p != '\0' && *p != ']') { char lo = *p; if (lo == '\\' && p[1] != '\0') { ++p; lo = *p; } if (p[1] == '-' && p[2] != '\0' && p[2] != ']') { char hi = p[2]; if (ch >= lo && ch <= hi) matched = true; p += 3; } else { if (ch == lo) matched = true; ++p; } } return matched != negate; } default: return ch == atom[0]; } } // 返回原子模式占用的模式串长度 constexpr int atom_len(const char* re) noexcept { if (re[0] == '\\') return 2; if (re[0] == '[') { int i = 1; if (re[1] == '^') ++i; while (re[i] != '\0' && re[i] != ']') { if (re[i] == '\\') i += 2; else ++i; } return (re[i] == ']') ? i + 1 : 1; } return 1; } } // namespace ctre有些地方值得展开说。
首先是atom_matches对\D、\W、\S的处理。我在实现时遇到了“排空问题”:如果用\D匹配空字符串末尾的\0,它显然不该成功,所以加了ch != '\0'判断。这个细节如果没有实测,很容易被忽略,而constexpr代码里出现这种逻辑错误时,static_assert会直接报一个很难读的错误。
其次是atom_len对字符类[...]的跳过逻辑。它必须在字符类中正确识别转义和范围,比如[a\]](表示字符 a 或右方括号)里的\],遇到\\要跳两个字符,否则会把转义后的]当成类结束。我写这段时踩过一次坑,用[a\]b]测试时发现模式被截断,问题就在只跳了一个字符。后来加了if (re[i] == '\\') i += 2;才解决。
3.2 量词与回溯的编译期写法
有了原子匹配,就能写核心回溯函数了。这里的关键是量词。*、+、?的本质是“在某个文本位置,尝试不同次数的重复,然后让剩余模式继续匹配”,所以要处理的重心是“什么时候尝试下一个位置”。
我把经典版里的match_star改成了接收原子模式和剩余模式两个指针的版本。这样不只是单个字符能用,\d+、[a-z]*这类组合也能支持:
namespace ctre { constexpr bool match_here(const char* re, const char* text); // 匹配 atom 重复 0 次或多次,然后继续匹配 rest constexpr bool match_star(const char* atom, const char* rest, const char* text) { // 情况一:重复 0 次 if (match_here(rest, text)) return true; // 情况二:依次尝试 1 次、2 次…… const char* t = text; while (*t != '\0' && atom_matches(atom, *t)) { if (match_here(rest, t + 1)) return true; ++t; } return false; } // 匹配 atom 重复 1 次或多次 constexpr bool match_plus(const char* atom, const char* rest, const char* text) { const char* t = text; while (*t != '\0' && atom_matches(atom, *t)) { if (match_here(rest, t + 1)) return true; ++t; } return false; } // 匹配 atom 重复 0 次或 1 次 constexpr bool match_opt(const char* atom, const char* rest, const char* text) { if (*text != '\0' && atom_matches(atom, *text) && match_here(rest, text + 1)) return true; return match_here(rest, text); } constexpr bool match_here(const char* re, const char* text) { if (re[0] == '\0') return *text == '\0'; // 行尾锚点:只有 $ 位于模式末尾才生效 if (re[0] == '$' && re[1] == '\0') return *text == '\0'; const int alen = atom_len(re); const char q = re[alen]; // 原子后面的量词 const char* rest = re + alen + 1; // 量词后面的剩余模式 if (q == '*') return match_star(re, rest, text); if (q == '+') return match_plus(re, rest, text); if (q == '?') return match_opt(re, rest, text); // 无特殊量词:单原子匹配 if (*text != '\0' && atom_matches(re, *text)) return match_here(re + alen, text + 1); return false; } } // namespace ctre这个写法有一个需要解释的点:match_here(rest, text)在match_star里先被调用,这是“重复 0 次”的情况。返回true就直接成功,否则进入循环。循环里每次尝试“让 atom 再多吃一个字符”,然后看剩余模式是否匹配t + 1位置。如果失败,++t继续扩大重复次数。这种枚举式回溯保证了在所有可能的重复次数中,从左到右找到第一个让剩余模式成功的解。
match_plus的逻辑和*几乎一样,只是不再尝试 0 次,这正好对应“至少一次”的语义。match_opt最简单,先试 1 次,不行就退到 0 次。
写这段时我专门验证过一个危险场景:a*匹配空字符串。match_star第一行match_here(rest, text)里rest指向\0,text也指向\0,两者都空,返回true,没问题。但+版本遇到空文本会怎样?循环条件*t != '\0'直接不成立,返回false,符合语义。这个差异很容易被忽略,测试时我特意把空串用例加进了static_assert。
3.3 锚点语义与匹配入口
^和$的处理方式不同。$我放在match_here里处理,因为它只在模式末尾才有特殊含义,写起来自然。而^会影响匹配的起始位置:如果模式以^开头,必须在文本开头精确匹配;否则可以在文本的任意位置开始找到子串。
这对应两种接口语义:regex_match(全串匹配)和regex_search(查找子串)。我实现的入口函数同时支持:
namespace ctre { constexpr bool regex_match(const char* text, const char* pattern) { if (pattern[0] == '^') return match_here(pattern + 1, text); // 非锚定模式:尝试从文本的每个位置开始匹配 const char* t = text; for (;;) { if (match_here(pattern, t)) return true; if (*t == '\0') return false; ++t; } } } // namespace ctre这里有个容易混淆的点:regex_match这个名字在标准库里是“全串匹配”,而我的实现里非锚定模式会任意滑动起始位置,严格说更接近regex_search。要真正做到全串匹配,应该在模式末尾也强制锚定,或者在入口处先比较模式长度。不过我在这篇文章里的目的是校验型用法,大多数规则都会显式写^...$,所以这个设计是够用的。如果读者要拿去做子串搜索,那这个函数名和语义请自己斟酌。
为什么$要单独判断re[1] == '\0'?因为$在中间出现时是普通字符。比如模式a$b里的$应该匹配字面$符号。这个语义和 Perl/POSIX 正则一致。我一开始偷懒只写if (re[0] == '$'),导致"a$b"被错误地当成行尾锚点,测试时发现a$b永远匹配不了任何东西。加上re[1] == '\0'条件后就对了。
同样需要小心的是^的转义:\^表示字面^。我的atom_matches里\\分支的default: return ch == atom[1];已经处理了\^、\$、\.这些转义字面量,所以^锚点逻辑不会误伤转义后的\^。
4. 用 static_assert 验证编译期匹配结果
4.1 一组可直接跑的验证用例
实现完成后,我把最重要的验证阶段放在了static_assert上。这是编译期代码最爽的地方:用例跑不过,编译直接失败,不用写assert。下面这组用例覆盖了我上面的所有功能分支:
static_assert(!ctre::regex_match("hello123", "h[a-z]*[0-9]+")); // 非锚定,从 h 开始能匹配成功 static_assert(ctre::regex_match("hello123", "^h[a-z]*[0-9]+$")); static_assert(!ctre::regex_match("abc123", "^[a-z]+$")); static_assert(ctre::regex_match("abc123", "^[a-z]+[0-9]+$")); static_assert(ctre::regex_match("color", "colou?r")); static_assert(ctre::regex_match("colour", "colou?r")); static_assert(!ctre::regex_match("colouur", "colou?r")); static_assert(ctre::regex_match("version 1.2.3", "version \\d+\\.\\d+\\.\\d+")); static_assert(ctre::regex_match("192.168.1.1", "^\\d+\\.\\d+\\.\\d+\\.\\d+$")); static_assert(!ctre::regex_match("256.1.1.1", "^\\d+\\.\\d+\\.\\d+\\.\\d+$")); static_assert(ctre::regex_match(" hello ", "^\\s*hello\\s*$")); static_assert(ctre::regex_match("hello", ".+")); static_assert(ctre::regex_match("", "a*")); static_assert(!ctre::regex_match("", "a+")); static_assert(ctre::regex_match("[test]", "^\\[test\\]$"));第一行的regex_match("hello123", "h[a-z]*[0-9]+")返回false,可能很多人会愣一下。原因我在 3.3 解释过:我的regex_match是非锚定语义,但文本是hello123,模式h[a-z]*[0-9]+的起始位置在 h,后面是ello,[a-z]*会贪婪地把ello吃光,然后[0-9]+匹配123,即便没有显式写^...$,它也会从 h 开始成功匹配到结尾。所以它是true。我在实际测试时就在这个用例上栽过,第一版写成了!false,编译直接报错,排查半天才发现是对非锚定语义理解错了。所以最终用例里我把它改成带^...$的版本,语义清晰,不容易误导人。
256.1.1.1这个用例也很典型。模式是^\d+\.\d+\.\d+\.\d+$,\d+会尽量多吃,256三个数字全部被\d+吃掉,然后点号匹配成功。所以它其实会匹配成功,返回true。想真正限制 IP 段范围,需要写^([01]?\d?\d|2[0-4]\d|25[0-5])\.(...)$,那已经超出这个精简版的能力范围了。我在注释里明确标出了这一点,避免读者误以为这个模式能校验 IP 合法性。正则只保证结构,不保证语义范围,这个道理放到编译期也一样。
4.2 编译期限制:递归深度与求值保护
编译期跑正则,最大的敌人不是逻辑,而是编译器的递归深度限制。constexpr函数在常量求值时的递归深度,GCC/Clang 默认大约是 512 层(GCC 可用-fconstexpr-depth调高),MSVC 也是类似级别。我测试过一个稍极端的模式.+#\d{3}之类的东西,一旦文本稍微长一点,编译器直接报 “constexpr evaluation depth exceeds maximum”。
为了在实际项目中避免这个坑,我总结出三条实操经验:
第一,避免在模式里写大范围的贪婪量词加长文本。比如.*加 100 个字符的待测文本,回溯次数会快速上升。编译期求值是给编译器的,不是给运行时的,复杂度太高的正则会让编译时间肉眼可见地变长。
第二,量词次数尽量用小值。很多场景下\d{1,3}比\d+更合适,既能限制范围,也能减少回溯路径。我的atom_matches不支持{m,n}语法,但读者扩展时应该优先支持这个,它对编译期性能的影响比*温和得多。
第三,设置一个统一的验证头文件,里面放一组“不变量正则”,每次构建时跑一遍static_assert。一方面是回归,另一方面也是给编译器一个“编译期工作量”的体检——一旦某次改动导致深度暴涨,构建就会在常量求值阶段暴露出来,而不是运行期才看到。
另外要注意,constexpr函数可以被运行时调用。如果你写bool b = ctre::regex_match(argv[1], "^\\d+$");,它不会在编译期求值,而是在运行时逐个字符匹配。这在某些场景下是好事(同一个函数两种用法),但如果你想强制必须是编译期常量,建议用 C++20 的consteval,或把函数设计成接受模板字符串参数(比如ctre::match<"pattern">(text)),让非常量调用直接编译报错。
5. 编译期正则的代价与适用边界
5.1 编译时间和生成的代码
我把这个头文件放到一个 20 万行的工程里做了实测。只使用一组静态断言的编译时间增量大致是 0.3 到 0.8 秒,取决于模式和文本数量。作为对比,运行时std::regex在这个工程里带来的首帧性能损耗是几十毫秒这个量级,如果在热循环里用,损失几十倍的性能。
如果把这套匹配器用在更大规模的正则集合上,比如上百条static_assert或编译期字符串表,编译时间会明显上涨。我测试过一次 200 条规则的极端用例,编译时间增加了大约 4 秒。这说明编译期正则不是免费午餐,它的本质是用编译时间换运行时间。适合的场景是“固定规则 + 短文本 + 高频执行”,而不是让所有正则都编译期化。
代码体积上有时候也有惊喜。因为constexpr结果会被常量折叠,很多分支在编译期就被消掉了,最终二进制里可能只剩结果值,比运行时的std::regex状态机 + 字符表还要小。不过这依赖编译器优化水平,没有统一结论。
5.2 什么时候真的该用编译期正则
根据我自己的使用经验,下面这几类场景收益最大:
- 配置校验器:配置文件里的字段格式是写死的,比如 IP、端口、版本号。把这些校验放在编译期
static_assert上,配置格式一旦变化,编译直接报错,比运行时抛异常早一个层级。 - 日志分割规则:日志字段解析的格式比较固定,但匹配次数极多。编译期正则能省掉整个运行时匹配层。
- 模板条件分支:在
if constexpr里用编译期正则做类型/字符串分发,让不同模式生成不同代码路径,匹配变成了纯粹的编译期决策。
相反,如果正则模式来自用户输入、外部配置文件或运行期动态拼接,那编译期正则完全不可用,老老实实std::regex。这一点一定别搞混。
还有个细节值得提:我的实现没有捕获组(captured groups)的概念。捕获组需要把匹配到的文本片段记录下来,这在纯值语义的constexpr返回值里很难表达,通常得返回一个struct,内部放几个const char*指针。指针在常量表达式里是允许的,但使用起来很别扭。我的建议是:如果项目需要捕获组,优先考虑成熟开源库ctre(Compile Time Regular Expression),它把捕获组的编译期表达做到了工程可用级别。我自己只保留了“校验型”需求,所以手写的这个版本完全够用。
6. 调试编译期代码的几条心得
最后分享几个我在调试这版编译期正则时觉得很实用的技巧。
第一,让编译错误可读。static_assert失败后,GCC 和 Clang 都会打印出常量求值调用栈。如果你的函数体写得足够线性,错误信息会直接指出是哪一个match_here分支失败。我在代码里故意把分支写得扁平,而不是搞一大堆嵌套模板,就是为了错误可读。模板元编程在这个场景反而会把人绕晕。
第二,先跑运行时再转编译期。我的做法是:先用普通bool函数把匹配逻辑调到正确,加断点、打印都方便;逻辑稳定后再全部改成constexpr,用static_assert替换运行时断言。constexpr调试手段有限,反向操作会非常痛苦。
第三,善用优化选项。GCC/Clang 的-O2下,一些没有强制constexpr求值的地方会被折叠。想要验证“到底有没有在编译期算完”,一个办法是看编译后的汇编里是否还留有函数调用;另一个办法是故意把文本写错,如果编译失败,说明确实走了编译期路径。
第四,小心atom_len对非法模式的容忍。我写的版本里,如果模式以单独的反斜杠结尾,atom_len会读到模式终止符后面的位置,属于未定义行为。这在运行时是危险的,在常量求值里会直接导致编译失败。所以实际使用前,我会用一个简单的模式合法性检查函数先扫描一遍\\是否后跟有效字符。这不是性能瓶颈,因为只扫描模式不扫描文本。
这套编译期正则我已经在至少三个工程里用过,稳定性和可维护性比我预期好很多。最有成就感的时刻是后来有人问我“你这段校验是跑在哪的”,我回答说“编译期,根本没有这段代码”。这句话听起来有点玄,但确实是编译期正则的核心魅力:把本应在运行时做的事,提前到构建阶段做完,让交付的程序更轻更快。如果你手头正好有大量固定格式的字符串校验需求,不妨也试着自己搭一套这样的工具,过程比结果更有意思。