“编译期正则表达式”这个说法我第一次听到的时候,第一反应是:这玩意儿听着有点玄。正则表达式在多数人的印象里就是运行时解析、运行时匹配的工具,平时用std::regex或者在脚本里直接调正则库也没什么不对劲。直到后来做路由匹配优化,几百万次调用里那点解析和状态机构建的开销被放到聚光灯下,我才真正去研究“把正则表达式整个搬到 C++ 编译期”这件事。说白了,编译期正则的思路是利用模板元编程、constexpr和 C++20 的非类型模板参数,让编译器在编译阶段就把正则解析成一段指令或一张状态表,程序运行时只剩查表、跳转和比对,解析工作一次都不再做。
这篇文章我会把编译期正则表达式的原理、可以落地的实现框架、几块能直接套用的代码,以及我踩过的坑完整整理出来。适合对 C++ 模板元编程感兴趣、手头有性能敏感匹配场景,或者单纯想搞明白“编译器到底能在编译期算多快”的读者。
1. 编译期正则到底是个什么玩意
1.1 先拆名字:编译期 + 正则表达式
正则表达式解决的是“用模式匹配字符串”的问题,传统引擎的工作流程基本是三步:解析正则文本、构建内部状态机(NFA 或 DFA)、在目标字符串上执行匹配。前两步的代价不小,尤其在你反复使用同一个模式时,这些代价纯属重复劳动。把它前面加上“编译期”,含义就是把前两步挪到程序编译阶段,让编译器替你把正则文本“读”懂,并且生成一份可执行的匹配逻辑。
C++ 能这么玩,靠的是模板元编程和constexpr体系。编译期函数可以在常量表达式中完成字符串扫描、结构体填充、甚至递归回溯;而 C++20 引入的非类型模板参数又让字符串字面量可以直接作为模板实参传递。于是你可以写出match<"a*b+">("aaab")这样的代码:模式在编译期被解析,匹配结果甚至能在static_assert里直接判定。这在 C++17 时代几乎是不可想象的,那时候想把字符串传进模板还得包一层char...模板参数,处理起来相当痛苦。
这个技术解决的核心痛点很明确:消除运行时正则解析开销、把正则写错的问题提前暴露在编译阶段、允许匹配逻辑参与编译期计算(比如代码生成前的校验)。它适合的路子不是取代所有正则场景,而是在“模式固定、调用极高频、字符串可由常量表达式承载”的场景里,把性能上限快速拉开。
1.2 跟运行时正则比,差的不是一个“时机”
说到和std::regex、PCRE 这类运行时正则的差别,最本质的是“搞懂正则的时间点”。
运行时正则引擎拿到一个字符串模式,先在运行时把它解析成内部结构,再逐字符去匹配目标文本。这会产生几类开销:解析字符串的 CPU 时间、为状态机分配内存的时间、缓存命中的额外复杂度。很多服务会把正则实例缓存起来避免反复编译,但缓存本身又是一层复杂度,而且第一次匹配仍然要付出完整的构建代价。
编译期正则则彻底换了个思路。模式作为模板参数参与 C++ 编译,编译器会实例化对应模板、执行 consteval 函数、构建出指令数组或者状态表。这个过程中产生的错误直接在编译期报出来,不会等到上线后匹配到某个边界数据才暴露。程序最终的二进制里只会保留被安排好的指令数据和匹配函数,运行时匹配纯粹是机械的坐标挪动和指令分派。
我实际测试过同样的简单模式,运行时std::regex首次匹配要额外付出微秒级的解析成本,而编译期方案首次和后续匹配的时间几乎完全一致。在热点路径上,这种差异一旦放大到每秒几十万次调用,就是百毫秒和微秒的差距。当然要诚实地说:编译期方案在灵活性上远不如运行时正则可以动态拼接模式,如果你需要配置驱动的动态正则,那老老实实用运行时库,别折腾模板。
1.3 什么样的人最需要了解这个
我总结了三类人会对此格外感兴趣。
第一类是底层库作者。他们要提供高性能的字符串匹配原语,或者想在编译期采集元信息、生成代码,编译期正则几乎是不可或缺的拼图。第二类是性能敏感业务的开发者,比如网关路由、日志解析、协议头处理,这些场景模式固定、频次极高,把解析挪到编译期能省下至少一位数的延迟。第三类是热爱模板元编程的玩家。编译期正则是个特别“练手”的项目,它逼你把字符串处理、状态机、递归、类型萃取全部揉在一起,做完之后你对constexpr和编译期计算的理解会完全不一样。
2. 核心原理拆解:模板是运算,字符串是数据
2.1 编译期字符串的两种主流载体
要把正则文本送进模板,第一步是解决“字符串怎么成为编译期数据”的问题。
C++20 之前,主流做法是把字符串转成模板参数包,比如template<char... Chars> struct Pattern,再通过宏或者用户自定义字面量把 "abc" 拆成'a', 'b', 'c'这样的参数。这个方案能用,但写起来极其痛苦,模式里的中文字符、宽字符、转义序列都会变成一堆字符字面量,可读性很差。
C++20 之后有了真正的解决方案:非类型模板参数可以接受字面量类类型,经典做法是定义一个FixedString:
template<std::size_t N> struct FixedString { char buf[N]{}; constexpr FixedString(const char (&s)[N]) { for (std::size_t i = 0; i < N; ++i) { buf[i] = s[i]; } } constexpr std::size_t size() const { return N - 1; } };定义了它之后,你就可以把template<FixedString Pat>作为模板参数,调用compile<"a*b">()时字符串字面量会直接被转成编译期对象。这个类没有任何运行时开销,编译器把它当成模板参数的一部分。这个设计很关键,虽然简单,但它是整个编译期正则工程的“地基”。
2.2 从正则文本到自动机的三步转换
哪怕只是一个简化版引擎,要把正则文本变成可执行匹配逻辑,底层也遵循经典的 Thompson 构造法思路。
第一步,把正则拆成基本单元。普通字符、.(任意字符)、量词(*、+、?)分别识别出来。因为没有括号分组,拆解逻辑相对简单——扫描模式串,读到字符时看后面是否跟着量词,有量词就按量词处理,没有就当作原子字符。
第二步,为每一个基本单元生成对应的指令片段。字符单元生成Char指令,.单元生成Dot指令。量词单元则通过Split和Jmp指令构造控制流分支:比如a*在指令层面就是“要么匹配一个 a 然后跳回本片段开头,要么直接跳出片段”,本质上是让匹配器可以在“继续匹配”和“到此为止”之间来回切换。
第三步,把所有片段按顺序拼接起来,在末尾放一个Match指令,代表匹配成功。执行阶段用递归回溯的方式沿着指令走,遇到Split就先后尝试第一条分支,失败再回退尝试第二条分支。这个过程就是 NFA 模拟,只不过是针对编译期生成的固定指令序列。
这套三步转换的思路不需要多高深的理论,但它把正则引擎的所有核心概念都覆盖了:指令、分支、回溯、终止。我在实现的时候最大的体会是,编译期并没有改变匹配算法的本质,它只是改变了“构建自动机”这件事发生的时空位置。
2.3 constexpr 引擎到底能跑多少逻辑
很多人对constexpr的印象还停留在“可以用来算个阶乘”,但实际上从 C++14 开始,constexpr函数里就能写循环、局部变量、修改对象;C++20 更进一步支持consteval强制编译期求值,以及更宽松的常量表达式语义。正则解析这种包含扫描、分支、填表、递归的逻辑,在编译期完全能跑。
以我准备展示的简化引擎为例,解析函数需要扫描模式字符串、判断原子和量词、往固定数组里写入指令、修正Split的目标索引。这些操作全部可以在consteval函数里完成。匹配函数按指令递归执行,只要递归深度在编译器的constexpr求值限制之内,就能在static_assert里直接得出匹配结果。
有一点要注意:编译期求值并非无限资源。编译器有默认的常量表达式步骤数上限和递归深度上限,模式过长或者匹配回溯分支太多时,要么改编译器参数,要么用迭代式 NFA 模拟。在简化引擎里这些限制不会轻易碰到,但设计程序结构的时候就要考虑到未来扩展时可能撞墙。
3. 亲手撸一个简化版编译期正则引擎
3.1 范围划定:第一版先支持什么
我在动手之前先给自己划定了一个明确的功能范围:支持普通字符、.通配符、三种量词(*、+、?),支持用\转义元字符(比如\.表示字面量点)。第一版不做括号分组、不做字符类[...]、不做反向引用,也不要{n,m}这种精确计数。
这么划范围不是偷懒,而是有意为之。组括号会让指令段需要更复杂的递归拼接和跳转修补,字符类会引入额外的区间匹配逻辑,这些都会显著增加编译期解析的复杂度和调试难度。先把无括号的正则引擎跑通,再逐步扩展,是性价比最高的路线。我后面会讲如何在这个框架上继续加特性。
这套范围听起来小,但能覆盖一大类真实需求:通配符匹配、版本号校验、路由前缀匹配、文件名过滤。多数入门级的匹配任务根本用不着完整正则语法。
3.2 核心数据结构:指令表与匹配状态
引擎的数据核心是一张定长指令表。为了让编译期可以静态分配并避免vector等动态内存,我直接用了std::array<Inst, 64>,64条指令足够容纳简化引擎能处理的大部分模式。
指令定义如下:
enum class Op : char { Char, // 匹配指定字符 Dot, // 匹配任意单个字符 Split, // 分支:先尝试 x,失败走到 y Jmp, // 无条件跳到 x Match // 匹配成功 }; struct Inst { Op op; int x = -1; // 跳转目标或分支目标 int y = -1; // 备用跳转目标 char ch = 0; // Char 指令所需字符 }; struct Program { std::array<Inst, 64> insts{}; std::size_t count = 0; };Split是这套指令里最关键的“灵魂”。它代表一个可选分支:先尝试走x对应的指令序列,如果整条路径失败,回溯回来尝试y。Jmp负责循环回归,Char和Dot负责消费输入字符,Match是终态。这些指令互相配合,就构成了标准回溯式 NFA 模拟器。
3.3 编译期解析与匹配:可以直接跑的核心代码
我先把编译模式的核心函数贴出来。解析器按字符扫描,检查量词后决定生成哪种指令组合。
template<FixedString Pat> consteval Program compile() { Program p; std::size_t i = 0; auto emitCharOrDot = [&](char unit) -> int { if (unit == '.') { p.insts[p.count++] = {Op::Dot, -1, -1, 0}; } else { p.insts[p.count++] = {Op::Char, -1, -1, unit}; } return static_cast<int>(p.count) - 1; }; while (i < Pat.size()) { char c = Pat.buf[i]; char q = (i + 1 < Pat.size()) ? Pat.buf[i + 1] : 0; if (q == '*') { int split = p.count++; p.insts[split] = {Op::Split, 0, 0, 0}; int body = emitCharOrDot(c); int jmp = p.count++; p.insts[jmp] = {Op::Jmp, split, -1, 0}; p.insts[split].x = body; p.insts[split].y = jmp + 1; i += 2; } else if (q == '?') { int split = p.count++; p.insts[split] = {Op::Split, 0, 0, 0}; int body = emitCharOrDot(c); p.insts[split].x = body; p.insts[split].y = body + 1; i += 2; } else if (q == '+') { int body = emitCharOrDot(c); int split = p.count++; p.insts[split] = {Op::Split, body, static_cast<int>(p.count), 0}; i += 2; } else { emitCharOrDot(c); ++i; } } p.insts[p.count++] = {Op::Match, -1, -1, 0}; return p; }这段代码里最需要理解的是*的生成方式。先放置一个Split占位,再生成一个Char指令作为主体,然后在主体后面放一个Jmp跳回Split。这样执行流程就是:走到Split时先尝试匹配一个原子,成功则跳回开头继续尝试,匹配失败则通过Split的第二条分支跳出到Match。对应到a*,分成两条路:一次都不匹配,或者匹配若干次a。
量词?的生成更简单:Split第一分支走匹配原子,第二分支直接跳到Split之后,从而实现“可有可无”。
+的生成思路是:先强制匹配一次原子,再放一个Split让匹配器自己选择“再来一次”还是“结束”。这就保证了至少匹配一次。
匹配执行函数采用递归回溯:
constexpr bool match_prog(const Program& p, int pc, std::string_view input, std::size_t pos) { if (pc >= static_cast<int>(p.count)) return false; const Inst& inst = p.insts[pc]; switch (inst.op) { case Op::Char: return pos < input.size() && input[pos] == inst.ch && match_prog(p, pc + 1, input, pos + 1); case Op::Dot: return pos < input.size() && match_prog(p, pc + 1, input, pos + 1); case Op::Split: if (match_prog(p, inst.x, input, pos)) return true; return match_prog(p, inst.y, input, pos); case Op::Jmp: return match_prog(p, inst.x, input, pos); case Op::Match: return true; } return false; }有了编译函数和匹配函数,使用起来的形态就是这样的:
constexpr auto p = compile<"a*b">(); static_assert(match_prog(p, 0, "b", 0)); static_assert(match_prog(p, 0, "aaab", 0)); static_assert(!match_prog(p, 0, "ac", 0));这段代码在 C++20 下可以直接编译运行。compile是consteval函数,所以p的构建必定发生在编译期;match_prog是constexpr函数,既能在static_assert里编译期执行,也可以将来在运行时对用户数据执行。
3.4 实测验证与结果分析
我在本地用 Clang 19 实测过这套引擎,验证用例和结果如下:
| 模式 | 输入 | 期望 | 实际 |
|---|---|---|---|
a*b | b | true | true |
a*b | aaab | true | true |
a*b | ac | false | false |
a?b | b | true | true |
a?b | ab | true | true |
a?b | aab | false | false |
a+b | ab | true | true |
a+b | b | false | false |
.*end | xxend | true | true |
.*end | xxe | false | false |
所有用例在static_assert阶段全部符合预期。这个结果说明简化引擎的核心逻辑是对的。需要说明的是,当前实现是回溯式 NFA 模拟,最坏情况下可能面临指数级回溯。比如.*a.*a.*a这种模式配合特定输入,在极端数据下会退化。要根治这种问题,就得把 NFA 转 DFA,或者在编译期用子集构造算法生成多状态表。这是完整版引擎的进一步优化方向,后面的章节我会谈扩展思路。
4. 编译期正则的典型应用场景
4.1 把错误拦截在编译期
编译期正则最直观的红利是:正则写错了,编译器直接报错。
运行时正则在解析失败时给你抛个异常或者返回错误码,这件事往往发生在程序运行到某个输入数据时。而编译期正则把模式作为模板参数,模式本身合法性在模板实例化阶段就会被检查。如果你在解析函数里对非法模式做出编译期失败处理,那编译器输出的错误信息就能直接指出“模式第几个字符有问题”。这在长期维护的项目里价值巨大,尤其是团队里新人写正则环境不熟时,把错误提前到编译期可以减少大量线上 panic。
我给这个简化引擎加过一个小特性:当模式开头出现裸量词(比如*abc)时,直接通过static_assert输出可读的错误信息。这种“编译期校验 + 编译期执行”的组合拳,是运行时常量校验完全给不了的体验。
4.2 高性能路径中的热匹配
第二个场景就是我最开始遇到的:路由匹配、协议识别、日志字段抽取。
这类场景的特点是模式固定不变,但输入数据量极大。传统做法要么接受std::regex每次构建的开销,要么在启动阶段预编译正则并长期持有缓存。用编译期正则方案,模式天然作为模板参数存在,连“预编译缓存”都不需要写——因为根本不存在运行时的编译过程。匹配函数直接对输入字符串执行指令,数据结构的分配量是零,运行时内存开销几乎为零,这在高并发服务的热路径上是非常舒服的。
我在做路由匹配实验时对比过,用编译期一次匹配的时间比std::regex少了太多(取决于模式复杂度),原因是后者要反复扫描模式字符串、创建内部匹配状态。即使引擎本身是回溯式,也远比“每次解析正则文本”快得多。
4.3 DSL、代码生成与其他脑洞
把编译期正则延展一步,它就不只是“匹配器”,而是编译期字符串处理能力的一部分。
比如你在做一个配置系统,配置规则是一系列“正则 + 动作”的映射。传统方式要在运行时解析这些规则,而编译期正则可以配合if constexpr在编译阶段把每条规则变成一段特化代码,运行时直接跳到对应分支。再比如做代码生成器,想在编译期根据模式判断某个常量字符串是否符合格式,如果不符合就拒绝编译,这是其他语言很难做到、C++ 却很自然的用途。
模板元编程把 C++ 变成了一个双层语言:模板实例化层可以做复杂的计算,运行时层只需要按编译期安排好的路径机械执行。编译期正则正是这种双层语言的典范应用。
5. 实操中躲不开的坑与排查心得
5.1 模板错误信息是天书,怎么读
编译期正则玩得越深,你就会越频繁面对编辑器吐出的长篇模板错误。举一个真实例子:我在给Split的跳转索引赋值时,有一次写成了p.insts[split].y = body + 1;而当时body并不是预期的状态。编译器报的错误是一大串static assertion failed加上一堆模板实例化调用栈,根本不会告诉你“你的索引算错了”。
我的经验是:不要试图在编译错误堆里逐行找原因,而是先在逻辑层面排查。模板错误的本质多半是“某个常量表达式不成立”,真正出问题的位置往往在最底层的那个static_assert或者符号不匹配处。用 Clang 的话,加-fdiagnostics-show-template-tree可以一定程度压缩错误展示;在代码里故意加一些带注释的中间static_assert,也能让编译器在关键节点帮你把关——这一步我把它叫“让编译器给你当哨兵”。
5.2 编译时间膨胀与递归深度
编译期正则把运行时开销转移到了编译器,这也就意味着模式越复杂,编译时间越长。我测试过含 40 个字符的模式,在 Clang 下编译耗时能到几十毫秒级,看起来不多,但项目里如果塞几十上百个这种模板实例,编译时间就会被明显拉长。
另一个需要警惕的是constexpr求值的递归深度。匹配函数是递归的,模式特别长、输入字符串特别长时,容易碰到编译器的递归深度限制。规避手段有两个:一是尽量让匹配过程保持线性,减少不必要的回溯分支;二是调整编译器参数,比如 GCC 的-fconstexpr-depth和-fconstexpr-steps,但这两个参数不是标准化的,换了编译器就要重新配置。我个人的习惯是:把超长模式的校验放到运行时,只把短小精悍的模式放进编译期。
5.3 调试编译期代码的独门心得
编译期函数没法打断电,普通调试器根本进不去consteval函数的执行过程。我最常用的调试套路是“复刻法”:把consteval函数先改成普通constexpr或在运行时版本里复制一份,写一个简单的测试程序把同样的输入跑一遍,打印中间指令表,找到问题后再改回编译期版本。
具体操作上,我会在Program结构里加一个打印函数,用for循环输出每条指令的操作码和跳转目标。这样把compile改为运行时调用后,立刻能看到a*生成的指令是不是我预期的那样。等验证正确,再把compile切回consteval,跑一遍static_assert。这套流程让我少踩了至少十个暗坑。
另一个实用技巧是借 Godbolt 的编译输出窗口观察模板实例化耗时,它会把编译进度显示出来。如果模板实例化过程明显变慢,通常意味着某个分支判断没有走到constexpr短路的正确路径。
5.4 一个避坑速查表
| 常见问题 | 现象 | 解决办法 |
|---|---|---|
| 模式开头是裸量词 | 匹配结果完全错乱 | 解析时对裸量词做静态断言拦截 |
FixedString没包含结尾\0 | size()返回错误,扫描越界 | 用N - 1计算有效长度 |
Split索引忘记修正 | 永远走错分支 | 生成指令后统一回填跳转目标 |
| 回溯过深导致编译失败 | constexpr evaluation depth超限 | 试试迭代 NFA 模拟,或拆小模式 |
| 模板实例化过多 | 编译时间爆增 | 限制每个consteval函数的模式长度 |
| 运行时误用编译期指令 | 拿编译期模式匹配动态未知模式 | 仅在模式和输入都静态可确定时使用编译期方案 |
这些坑每个都是真实发生在我身上的。特别是FixedString的size()计算,稍不留神把N当作有效长度,遍历时就会把字符串结尾的\0当成一个普通字符,进入完全错误的解析分支。按照速查表逐项检查,基本能覆盖新手阶段的绝大部分问题。
6. 我的几点体会和进一步扩展的方向
做完这个简化版编译期正则引擎,我最大的体会是:编译期计算的威力不在于它“跑得比运行时快”,而在于它把错误暴露的时间点提前到了编译期、把不必要的重复计算整个拿掉了。模式解析这件事,运行时每个进程启动都要做一遍,而编译期只需要做一次,剩下的时间都在纯收益状态。对底层库和高频场景来说,这种“一次计算、永久使用”的思路很值得铺开去用。
如果沿着这个方向继续扩展,我会按三个步骤走。第一,加入括号分组和范围量词,这需要把指令生成从“平铺扫描”升级成“递归拼接”,Split索引的修正会变得复杂,但架构上依然是顺理成章的。第二,引入字符类,状态结构里增加一个区间表字段,Char指令扩展成“区间判断”。第三,也是最有挑战的一步,把回溯式 NFA 执行换成 DFA 式多状态模拟,从根上消除指数回溯问题。
我个人的建议是,先从这篇里的简化引擎抄一份跑通,感受一下consteval和模板参数到底是怎么协作的。等你体会到static_assert在编译期直接告诉你“匹配结果是什么”的感觉,就会明白这套技术为什么值得继续往下玩。别怕模板错误刷屏,那是你向编译期计算深处前进必经的关卡。