前阵子调一个状态机解析模块,里面有一段很固定的参数校验逻辑,每次启动都要对整个配置项跑一遍循环。数据明明在编译期就完全确定了,运行期却还是每次重复计算,我当时就琢磨:能不能把这一整段计算直接搬到编译期?改成 constexpr 之后,那个热点路径从几百毫秒直接掉到接近零。这次改动让我决定正经做一次 C++ constexpr 编译期计算性能对比:同样一段算法,放在运行期算和放在编译期算,性能差距到底有多大,编译成本又会增加多少。这篇文章就是这组测试的完整记录,适合正在琢磨“要不要用 constexpr 优化代码”的 C++ 开发者,也适合刚入门想搞懂编译期计算概念的读者。
我尽量用做项目的语气来讲,不绕弯子,测试代码能贴就贴,能看结果就看结果。整个文章的核心就回答三个问题:编译期计算凭什么快、快多少、代价是什么。
1. constexpr编译期计算到底是什么
1.1 一句话讲清楚constexpr
constexpr 是 C++11 引入的关键字,它修饰的是一个对象或者函数,表示“这段东西可以在编译期求值”。注意这里说的是“可以”,不是“必须”。constexpr 函数在运行期调用时就是普通函数,但在常量表达式上下文里,编译器会直接算完,把计算结果写进二进制。
我经常用备菜来打比方:运行期计算相当于客人点完菜你才现洗现切,编译期计算相当于开饭前就把菜全部备好,客人下单直接把半成品下锅。前者每次都是实打实的操作,后者把操作发生的时间点提前了,你在“出餐”那一刻看到的只是结果,成本自然低。
constexpr 的适用范围这些年一直在扩大。它最早只支持单条 return 语句,后来可以写循环、局部变量,再后来可以出现在 lambda 里,甚至可以操作 std::string。具体的版本差异我会单独讲,这里先记住一句话:constexpr 给了开发者一种“把计算前移”的手段,跟前移相关的所有性能收益和工程代价,都源于“计算前移”这件事本身。
1.2 版本演进:能力边界一直在拓宽
很多初学者分不清 constexpr 和 const。const 表示“这个变量在运行期不可变”,它不要求编译期知道值;constexpr 表示“这个值编译期就能算出来”,它隐含 const,但能力比 const 强得多。我见过有人把 const int x = rand(); 和 constexpr int x = func(); 混在一起讨论,这俩本质上是两个维度的东西。
版本演进是理解 constexpr 能力边界的关键:
| C++ 版本 | constexpr 能力 | 典型限制与突破 |
|---|---|---|
| C++11 | 函数体只能写一条 return;变量要求初始化表达式是常量表达式 | 能算递归,但写复杂计算非常痛苦 |
| C++14 | 函数体内允许局部变量、循环、if 等语句 | 这是质变,constexpr 从“神谕表达式”变成了“披着编译期外衣的普通函数” |
| C++17 | 加入 if constexpr,constexpr lambda | 可以按类型/常量条件分派,模板代码简洁得多 |
| C++20 | 引入 consteval、constinit;允许 constexpr 中使用 std::vector、std::string;允许 constexpr 虚拟函数?部分场景 | 编译期算法几乎能覆盖普通代码的绝大部分场景 |
| C++23 | 进一步放开浮点计算、简化规则 | 编译期写数值算法更方便 |
我在工程里最常用的还是 C++14 和 C++17 的能力,因为公司项目标准可能没切到 C++20。如果你的项目能用 C++20,consteval 会是个很强的东西,它强制函数必须在编译期求值,完全杜绝“我以为是编译期算的,结果运行期还跑了一遍”这种误会。
1.3 和运行期计算的根本区别
运行期的计算,无论优化得多好,最终都会体现在几条指令上:函数入口、栈帧建立、循环判断、算完返回值。哪怕编译器全部内联,循环体里的加法和比较指令也依然存在,CPU 每个时钟周期都要在流水线上走一遍。
而编译期计算的结果,根本不会变成运行期的计算指令。它会被折叠成一个立即数,存到 .rodata 段或者直接嵌入到某条 mov 指令里。从性能视角看,这不是“快了百分之几”的区别,而是“运行期根本没有这件事了”。
这也是为什么很多人第一次看到编译期计算和运行期计算的性能对比时会觉得夸张:运行期耗时几百毫秒,编译期版本测出来是 0。不是编译器作弊,是它的工作确实在编译阶段做完了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对比测试设计:怎么测才公平
2.1 测试环境与基准方法
先交代我这边的环境:Intel i5-12500,Ubuntu 20.04 WSL2,GCC 12.2,编译参数统一 -O2 -std=c++20。我用了三组不同风格的测试,每组测试里的运行期版本和编译期版本只有“是否强制在编译期求值”这一个差异,其他代码尽量保持一致。
一个特别重要的点是:运行期基准不能让编译器把计算折叠掉。很多人做 constexpr 性能对比时,运行期版本直接传一个字面量,比如 fib_runtime(30),GCC 在 -O2 下会把这种“纯函数 + 常量参数”的调用直接常数折叠成结果,测出来的运行期时间也接近 0,对比就失真了。
我这里的做法是:运行期版本的输入参数从一个 volatile int 读取,或者通过 std::cin 在程序启动后读入,让编译器没法提前知道参数值。这样它必须老老实实生成真正的运行期计算指令,测出来的时间才有意义。
2.2 三个测试场景的选择逻辑
我挑了三个有代表性的场景:
- 递归数值计算:斐波那契
fib(30)。递归是模板元编程时代的经典难题,也是 constexpr 从 C++11 开始就能做的事。 - 带局部状态的循环计算:统计
[2, N]范围内的质数个数。这种循环加局部变量的写法在 C++11 里无法用 constexpr 表达,C++14 之后才行,很能体现新版 constexpr 的能力。 - 字符串哈希:FNV-1a 哈希。字符串处理是实际工程里的高频场景,编译期把字符串哈希算出来之后,可以拿去当
switch的分支条件。
这三个场景分别覆盖了“无状态递归”“有状态迭代”“文本处理”,基本能代表 constexpr 在普通业务代码中的典型用法。
2.3 强制编译期计算:constexpr函数不保证在编译期执行
这是最容易踩的坑。constexpr 函数只是“可以在编译期执行”,如果你只是写 auto x = fib_constexpr(n);,n 又是个运行期变量,编译器完全可以把它当普通函数在运行期调用。真正要强制编译期求值,常见手段有四种:
- 用
constexpr变量接收结果:constexpr auto v = fib_constexpr(30);。constexpr 变量要求初始化表达式必须在编译期求值,所以这一行写出来,编译器就必须执行编译期计算。 - 用
static_assert配合验证:static_assert(fib_constexpr(30) == 832040);。算不出来或者结果不对,编译直接失败。 - 把结果作为模板参数或者数组大小:
std::array<int, fib_constexpr(30)> arr;。这也是强制编译期求值,而且能看到实际值。 - C++20 用
consteval:直接定义一个必须编译期执行的函数,运行期调用会被编译器拒绝。我强烈建议条件允许时使用这个。
测试里我统一用 constexpr 变量加 static_assert 的方式,确保两种版本的行为差异只在“是否在编译期算完”。
2.4 防止比较时编译器“作弊”
这个我多说一句。做性能对比,最怕的不是编译器优化差,而是优化太好,把你要测的东西优化没了。除了运行期参数用 volatile 读入之外,我还会检查生成的汇编,确认运行期版本里确实存在函数调用和循环指令,编译期版本里确实不存在计算指令。
有人可能会说,现代编译器这么聪明,运行期函数如果被内联了,性能也可能接近 0。确实,但内联只是省略了函数调用开销,计算循环体本身还在。constexpr 编译期版本是连循环体都不存在,二者在汇编层面有明显区别。这点会在后面“看汇编”的小节里展开。
3. 实测结果:性能数据与汇编验证
3.1 场景A:斐波那契,运行指令整个消失
先看代码。运行期版本:
cpp复制long long fib_runtime(int n) {
if (n <= 1) return n;
return fib_runtime(n - 1) + fib_runtime(n - 2);
}
constexpr 版本:
cpp复制constexpr long long fib_constexpr(int n) {
return n <= 1 ? n : fib_constexpr(n - 1) + fib_constexpr(n - 2);
}
测试时,运行期版本从外部读入 n,循环 1000 次,每次计算 fib_runtime(30)。constexpr 版本在编译期算出 fib_constexpr(30),然后这个值保存到一个 constexpr 变量里,运行期循环同样 1000 次,每次只是把它赋给一个 volatile 变量,防止被优化掉。
我机器上跑出来的数据大致如下:
| 测试项 | 运行期版本 | constexpr编译期版本 |
|---|---|---|
| fib(30) 计算 1000 次耗时 | 约 452 ms | 约 0 ms |
| 单次 fib(30) 耗时 | 约 0.45 ms | 无运行期计算 |
| 编译时间 | 0.38 s | 1.12 s |
运行期版本每次计算都要递归调用约 269 万次函数,栈帧、比较、分支跳转全都不可少。constexpr 版本在编译期把结果算成 832040,运行期循环只是在反复读取同一个常量数字。这个差距不是 10 倍、100 倍,是“从有到无”的差距。
3.2 场景B:带局部状态的循环计算(C++14)
这个场景我写了一个质数统计函数,用 C++14 之后的 constexpr 写法:
cpp复制constexpr int count_primes(int limit) {
int cnt = 0;
for (int i = 2; i <= limit; ++i) {
bool is_prime = true;
for (int j = 2; j * j <= i; ++j) {
if (i % j == 0) {
is_prime = false;
break;
}
}
if (is_prime) ++cnt;
}
return cnt;
}
这个函数在 C++11 里没法写成 constexpr,因为函数体里有多条语句、有局部变量、有循环。C++14 放开之后,这完全就是普通代码的样子。运行期版本和 constexpr 版本在代码上几乎一模一样,唯一的区别是 constexpr 版本通过 constexpr auto c = count_primes(1000); 强制在编译期求值。
结果也是类似的模式:
| 测试项 | 运行期版本 | constexpr编译期版本 |
|---|---|---|
| count_primes(1000) 计算 100000 次耗时 | 约 830 ms | 约 0 ms |
| 编译时间 | 0.35 s | 0.78 s |
注意一个细节:count_primes 内部有双层循环,编译期求值时相当于编译器内部帮我把这几万次循环全部执行了一遍。当输入量继续增大到 limit = 5000,GCC 的编译时间会明显上涨,这个代价不可忽视,后面我会专门讲。
3.3 场景C:FNV-1a字符串哈希
字符串哈希是实战价值最高的场景。FNV-1a 实现简单,适合 constexpr:
cpp复制constexpr std::uint32_t fnv1a(const char* s) {
std::uint32_t h = 2166136261u;
while (*s) {
h = (h ^ static_cast<std::uint8_t>(*s)) * 16777619u;
++s;
}
return h;
}
运行期版本从外部读一个字符串,循环 1 亿次计算哈希。constexpr 版本直接在编译期算出 fnv1a("login") 的值,运行期只是把常量取出来。
| 测试项 | 运行期版本 | constexpr编译期版本 |
|---|---|---|
| 对 "login" 哈希计算 1 亿次耗时 | 约 760 ms | 约 0 ms |
| 编译时间 | 0.34 s | 0.52 s |
字符串哈希最常见的工程用法是做成 switch。你可以在 case 标签里直接写 fnv1a("login"),编译期会把字符串转成整数,运行期只需要用一个整数 switch 分发,比一串 if (strcmp(...) == 0) 快得多:
cpp复制switch (fnv1a(cmd.c_str())) {
case fnv1a("login"):
break;
case fnv1a("logout"):
break;
default:
break;
}
这里的 fnv1a("login") 是常量表达式,所以能放在 case 标签里。运行时的 fnv1a(cmd.c_str()) 则是普通调用,只有遇到真实字符串才执行哈希。我把这种模式用在一个命令解析模块上,原先几百次的 strcmp 全部消失,性能提升非常直观。
3.4 看汇编:眼见为实
数据只能说明快,要理解“为什么快”,最直接的手段是看汇编。我用 objdump 对比了两种版本的 main 函数。
运行期版本里,main 中能看到 call 指令跳进 fib_runtime,函数内部有 cmp、jne、sub/add 这类指令,循环和递归结构清清楚楚。
constexpr 版本的 main 里没有任何 call 指令指向计算函数,整个计算结果被编译成一条 mov 指令,把立即数放到某个寄存器或者栈上。如果你用 -O2 把 main 中的编译期常量路径全部优化掉,甚至连这条 mov 都可能消失,因为常量根本没被用到。
我自己的经验是:判断一段代码是否真的在编译期算完,不要只靠“我觉得”,直接把二进制反汇编看一眼,一目了然。看不懂完整汇编也没关系,你只需要在生成的汇编里搜索有没有对应的函数名,以及 call 指令是不是还在。
4. 不为众人知的代价:编译期计算的成本
4.1 编译时间与内存:数据得衡量
编译期计算不是免费的。你可以把编译器理解成一个带求值器的程序,它在编译阶段替你执行了本该在运行期执行的那些循环和递归。这个执行过程消耗的是编译时间和编译器的内存。
我上面三个场景的编译时间数据已经能说明问题:fib(30) 让编译时间从 0.38 秒涨到 1.12 秒,count_primes(1000) 从 0.35 秒涨到 0.78 秒。看起来还能接受,但如果你在编译期计算一个很大的查找表,编译器消耗的内存会非常夸张。
我在另一个项目里试过用 constexpr 生成一个 [0, 65535] 区间的平方根查找表,每次编译都要多花 10 秒以上,内存峰值比不加 constexpr 高了几百 MB。这种场景下,你需要认真权衡:这个查找表真的需要在编译期动态算吗?还是直接把表生成成普通数组,写死在代码里更划算?
4.2 constexpr深度限制与报错
编译器对 constexpr 求值是有限制的。GCC 默认的常量表达式递归深度是 512,如果你递归一个深度超过 512 的函数,编译器会报 constexpr evaluation depth exceeds limit。fib(30) 深度只有 30,没问题,但如果你写一个深度依赖输入的递归算法,很容易撞到限制。
应对方法有三种。第一种是调高编译参数限制,比如 GCC 的 -fconstexpr-depth=2048,但这治标不治本,深度过深时编译内存还是会爆。第二种是把递归改成迭代,C++14 之后 constexpr 支持循环,大多数递归都能直接改写。第三种是把大问题拆成小问题,在多个 constexpr 函数里分步计算。
我强烈推荐第二种。constexpr 已经支持循环和局部变量了,能用迭代就别用递归,编译期递归深了报错非常痛苦,而且要排查的逻辑复杂程度也会上升。
4.3 和模板元编程比:一样是编译期,差距很大
很多老 C++ 项目里所谓的“编译期计算”,指的是模板元编程。模板元编程通过模板特化和递归实例化,在类型系统层面完成计算。比如用模板算阶乘:
cpp复制template <int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr int value = 1;
};
在 C++11 之前,模板元编程几乎是编译期计算的唯一选择。问题在于它有几个明显的短板:代码可读性差,写起来像在写谜语;模板实例化的规模会膨胀,编译时间和内存消耗比 constexpr 大得多;报错信息晦涩难懂,一报错就是几十行模板展开日志。
constexpr 相对模板元编程是巨大的进步,至少代码写出来像普通函数。我用同一个计算做过对比,模板元编程版本编译时间大约是 constexpr 版本的 2 到 3 倍,而且代码量多一倍。至于运行期性能,两者都不会在运行期产生计算指令,本质上没有区别。所以现阶段新代码我建议优先用 constexpr,模板元编程只在需要和类型系统深度交互时才用。
5. 工程实战:如何把constexpr用到刀刃上
5.1 最适合constexpr的五个场景
不是所有代码都值得改成 constexpr,收益高的是那些“输入在编译期已知、计算频率高、算法稳定”的场景。根据我的经验,下面五类场景性价比最高。
第一类:编译期生成查找表。比如把 sin 表、CRC 表、质数表在编译期算好,运行期只做索引。C++17 之后配合 std::array 写起来很自然:
cpp复制constexpr std::array<int, 100> make_square_table() {
std::array<int, 100> table{};
for (int i = 0; i < 100; ++i) table[i] = i * i;
return table;
}
constexpr auto square_table = make_square_table();
第二类:配置和协议常量校验。编译期验证魔数、版本号、单位换算系数等是否满足约定。比如校验一个结构体的大小,或校验枚举值是否重复,可以用 static_assert 在编译期拦截错误。
第三类:字符串哈希与枚举映射。我前面说过的 fnv1a 编译期哈希,把字符串转成整数,用 switch 做分发。这是实际项目里收益最明显的一个场景,因为字符串比较在解析代码中极其常见。
第四类:类型工具和 trait 计算。std::is_same_v、std::decay_t 这类类型特征本质上就是编译期计算,用 if constexpr 可以让模板代码按条件实例化,避免代码膨胀。
第五类:数值计算相关的静态参数。比如解析器里用到的位掩码、颜色转换矩阵、加密算法的轮常量,这些数据往往是固定的,编译期算好之后运行期只是查表或取常量。
5.2 constexpr / consteval / constinit 怎么选
C++20 引入了 consteval 和 constinit,很多新手会混淆三者。我放个对比表:
| 关键字 | 含义 | 适用场景 | 关键注意点 |
|---|---|---|---|
| constexpr | 变量或函数可以在编译期求值,也可在运行期求值 | 通用编译期计算 | 不保证编译期执行,需要主动用 constexpr 变量或 static_assert 强制 |
| consteval | 函数必须在编译期求值 | 需要强制编译期计算的场景 | 运行期调用直接编译错误 |
| constinit | 变量必须在编译期初始化,但之后可以在运行期修改 | 静态存储期变量的初始化 | 不保证不可变,只保证初始化时间点 |
我的建议是:如果一段计算必须编译期完成,能用 consteval 就用 consteval,它比 constexpr 函数更严格,也更容易排查问题。如果只是需要一个“初始化一次但不是在运行期做的普通静态变量”,constinit 更合适。constexpr 函数本身保持灵活,适用于既要编译期又要运行期复用的场景。
5.3 我总结的constexpr使用清单
经过这些测试和项目实践,我自己总结了一套判断方法和工程习惯。
第一,写 constexpr 函数时,顺手在旁边加一个 static_assert 验证结果。这不仅仅是测试,也是在告诉编译器“这段代码必须能在编译期跑通”。如果函数体内出现不能编译期求值的操作,编译会直接失败,比运行期报错早得多。
第二,用 constexpr 变量接结果,而不是只用 auto。只写 auto v = func();,编译器可能有理由不在编译期算。写 constexpr auto v = func(); 就明确要求了编译期求值,语义清晰,代码自文档化。
第三,别把整个项目所有纯函数都改成 constexpr。改之前先判断:输入是否可能是编译期已知的?调用频率是否高?算法是否稳定?如果这三点都不满足,constexpr 带来的只是语法上的保证,运行期性能没有收益,反而可能增加编译时间。
第四,编译期计算的规模要克制。一次编译期循环 1 亿次和运行期循环 1 亿次,前者消耗的是团队成员每次编译都要等待的时间,后者只是程序运行的一次时间,两者的成本模型完全不同。大表能预生成就预生成,不要在编译期反复折腾。
6. 常见问题与排查实录
6.1 “不满足常量表达式要求”排查路径
我经常遇到的编译错误长这样:error: call to non-constexpr function 或者 error: expression is not an integral constant expression。出现这种错误,先按这个顺序排查。
第一步,检查函数本身是否被 constexpr 修饰。你调用的函数如果不是 constexpr,那它在常量表达式上下文里就是非法调用。第二步,检查所有参数是否都是编译期已知的常量。比如你在 constexpr 函数里调用了 strlen,strlen 本身不是 constexpr 函数,就会报错,需要自己写一个 constexpr 版本。第三步,检查函数体内是否有常量表达式不允许的操作,比如 reinterpret_cast、动态内存分配(C++20 之前)、虚函数调用等。
C++20 放宽了不少限制,例如 std::string 和 std::vector 可以出现在 constexpr 上下文里,这给写编译期逻辑带来很大便利。但放宽不等于完全没有限制,所以遇到报错时别急躁,把上下文的操作类型逐项检查一遍。
6.2 怎么确认计算发生在编译期
确认计算是否真的发生在编译期,最直接的手段是 static_assert。你可以写一个验证:
cpp复制static_assert(fib_constexpr(10) == 55);
这一行如果能编译通过,说明编译器确实在编译期求值了。如果只是赋给一个普通变量,即使函数被声明为 constexpr,编译器也可能选择在运行期执行。想彻底强制编译期执行,用 consteval 是最干净的方案。
我还会用“模板参数试错法”:把结果作为模板参数传入,比如 std::array<int, fib_constexpr(10)>。如果计算不是编译期常量,模板参数位置就会报错,这个手段对排查很有效。
6.3 编译太慢怎么办
如果你发现加了 constexpr 之后,项目编译时间明显变长,先不要急着删除全部 constexpr。通常办法是缩小计算规模、把递归改成迭代、把一个大函数拆成多个小函数。
我做 count_primes 测试时,limit 从 1000 涨到 5000,编译时间从 0.78 秒涨到 2.5 秒以上,内存占用也涨了。这时候可以考虑抛弃“在编译期算完所有数据”的思路,改成“只算需要的那部分”,或者干脆把预计算好的数据以普通数组形式直接写进代码里。
另外一种常见情况是 constexpr 函数被模板反复实例化。这种情况下,编译器可能会对每个模板实例都做一次常量表达式求值,计算量被成倍放大。解决办法是让 constexpr 函数脱离模板依赖,或者把结果缓存到一个指定的 constexpr 变量里,让多个模板实例共用同一个结果。我在一个矩阵工具库里就是这么做的,编译时间从 40 秒降到 12 秒。
最后再分享一个我个人的小习惯:每写一个 constexpr 函数,我都会习惯性补上一个 static_assert 测试用例。这个习惯帮我挡住了很多“我以为没变”的回归。编译期计算不是玄学,也不是万金油,它本质上就是一个把计算前移的工程手段。什么时候用、用到什么程度,需要结合编译成本、维护成本和运行收益一起判断。希望这次的测试思路和踩坑记录能帮你少走点弯路。
