C++编译期正则表达式:用constexpr把匹配提前到编译期

发布时间:2026/10/10 9:14:27
C++编译期正则表达式:用constexpr把匹配提前到编译期 从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固化甚至直接参与常量传播连比较分支都不用保留。能做模式合法性检查写错正则在编译期就报错而不是等到运行时才收获一个异常或静默失败。当然它不是万能的文本必须是编译期常量模式也得是编译期常量。这个限制在后面专门讲。还有一个更现实的价值把编译期正则的实现走一遍你对 C17constexpr函数的能力边界、回溯匹配器的工作原理、编译期性能调优的理解都会上一个台阶。下面的实现就是从零开始搭的一个精简版。2. 编译期正则的基础设施constexpr 与字符串2.1 让字符串走进常量表达式想在编译期匹配字符串第一步是让字符串本身能在常量表达式中被“看见”。C17 的constexpr函数里可以直接读取数组内容所以最朴素的做法就是直接传 C 风格字符串constexpr bool foo(const char* s) { return s ! nullptr s[0] h; } static_assert(foo(hello));字符串字面量是静态存储期数组传给constexpr函数时函数体里对s[i]的读取就是常量表达式的一部分。这是编译期字符串处理的基础。但一个关键限制是constexpr函数里不能做动态内存分配C20 放宽了部分能力但编译器默认不支持new出现在常量求值路径不能写未定义行为递归深度也受编译器选项限制。所以经典的“先把正则解析成 AST再跑匹配”在编译期做起来很麻烦常见做法是把解析和匹配揉在一起边解析边匹配靠递归回溯完成全部工作。2.2 经典的 KR 回溯匹配器编译期正则的代码骨架我没发明新东西用的是 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指向\0text也指向\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$);它不会在编译期求值而是在运行时逐个字符匹配。这在某些场景下是好事同一个函数两种用法但如果你想强制必须是编译期常量建议用 C20 的consteval或把函数设计成接受模板字符串参数比如ctre::matchpattern(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*指针。指针在常量表达式里是允许的但使用起来很别扭。我的建议是如果项目需要捕获组优先考虑成熟开源库ctreCompile Time Regular Expression它把捕获组的编译期表达做到了工程可用级别。我自己只保留了“校验型”需求所以手写的这个版本完全够用。6. 调试编译期代码的几条心得最后分享几个我在调试这版编译期正则时觉得很实用的技巧。第一让编译错误可读。static_assert失败后GCC 和 Clang 都会打印出常量求值调用栈。如果你的函数体写得足够线性错误信息会直接指出是哪一个match_here分支失败。我在代码里故意把分支写得扁平而不是搞一大堆嵌套模板就是为了错误可读。模板元编程在这个场景反而会把人绕晕。第二先跑运行时再转编译期。我的做法是先用普通bool函数把匹配逻辑调到正确加断点、打印都方便逻辑稳定后再全部改成constexpr用static_assert替换运行时断言。constexpr调试手段有限反向操作会非常痛苦。第三善用优化选项。GCC/Clang 的-O2下一些没有强制constexpr求值的地方会被折叠。想要验证“到底有没有在编译期算完”一个办法是看编译后的汇编里是否还留有函数调用另一个办法是故意把文本写错如果编译失败说明确实走了编译期路径。第四小心atom_len对非法模式的容忍。我写的版本里如果模式以单独的反斜杠结尾atom_len会读到模式终止符后面的位置属于未定义行为。这在运行时是危险的在常量求值里会直接导致编译失败。所以实际使用前我会用一个简单的模式合法性检查函数先扫描一遍\\是否后跟有效字符。这不是性能瓶颈因为只扫描模式不扫描文本。这套编译期正则我已经在至少三个工程里用过稳定性和可维护性比我预期好很多。最有成就感的时刻是后来有人问我“你这段校验是跑在哪的”我回答说“编译期根本没有这段代码”。这句话听起来有点玄但确实是编译期正则的核心魅力把本应在运行时做的事提前到构建阶段做完让交付的程序更轻更快。如果你手头正好有大量固定格式的字符串校验需求不妨也试着自己搭一套这样的工具过程比结果更有意思。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询