这篇博文的实验数据取自个人环境,不同编译器版本和标准库实现下结果可能不同。我在 GCC 12.2 上做了验证,测试代码也都贴了出来,你可以直接在自己的环境里跑一遍看趋势是否一致。
C++ 的 constexpr 编译期计算,性能到底比运行期快多少?这个问题我最近在项目里认真做了一次实测对比,结论有些反直觉:用对了场景快得离谱,用错了场景编译时间暴涨,运行时却毫无收益。
起因是某嵌入式项目里一张 256 项的状态转移表,原本在构造函数里用循环初始化。有人建议改成 constexpr 在编译期生成,理由是"省掉启动初始化时间"。我当时没有直接拍板,而是把运行期循环、constexpr 函数、模板元编程三种实现方式放在一起做了个基准测试,顺便把编译时间、生成代码大小也记了下来。
这篇文章就是那次对比的完整记录。适合两类人看:一类是在面试里被问到 constexpr 和普通函数区别、想知道编译期计算到底"快在哪"的开发者;另一类是正在纠结要不要把查找表或算法塞进 constexpr、担心编译时间和可维护性的工程派。两种视角我都会覆盖到。
1. constexpr 的进化史与编译期求值的真正含义
在聊性能数据之前,先把 constexpr 的能力边界搞清楚。很多性能对比结论失真,就是因为对"什么时候真的会编译期求值"理解有偏差。
1.1 从 C++11 到 C++23:constexpr 能力逐步放开
constexpr 不是一开始就那么好用的,它的演进史直接决定了你该用哪种风格写:
| 版本 | 关键能力 | 实际影响 |
|---|---|---|
| C++11 | 函数体只能包含一个 return 语句 | 只能写表达式嵌套,难看且难调试 |
| C++14 | 支持循环、局部变量、多语句 | 终于能像普通函数一样写算法 |
| C++17 | if constexpr、constexpr lambda、std::array 的 constexpr 方法 |
编译期分支、更灵活的泛型代码 |
| C++20 | consteval、constinit、constexpr 支持 new/delete、虚函数、std::vector 基础操作 |
可以在编译期做动态内存分配(受限) |
| C++23 | 标准库更多容器和算法加入 constexpr 支持 |
编译期计算能力进一步向普通代码靠拢 |
C++11 时代,想用 constexpr 算一个斐波那契数列,只能写成 return N <= 1 ? N : fib(N-1) + fib(N-2); 这种三元表达式嵌套。C++14 之后,直接在里面写循环和局部变量,和写普通函数没有区别。这个变化的意义非常大:constexpr 不再是"奇技淫巧",而是普通人也能驾驭的编译期编程工具。
1.2 写 constexpr 不等于一定会编译期求值
这是最容易踩的认知坑。
constexpr 函数只有在常量表达式语境中调用时,编译器才强制在编译期求值。比如:
- 初始化一个
constexpr变量 - 作为数组长度或模板参数
- 出现在
static_assert中 - 作为
case标签(编译期整数常量)
而在普通函数里用运行时变量调用一个 constexpr 函数,它就是普通函数,完全不保证编译期求值。看个例子:
cpp复制constexpr int add(int a, int b) {
return a + b;
}
int main() {
int x, y;
std::cin >> x >> y;
int z = add(x, y); // 运行期调用,和普通函数没有区别
constexpr int w = add(1, 2); // 编译期求值,w 等于常量 3
}
C++20 的 consteval(立即函数)才真正强制"必须编译期求值"。所以你在做性能测试时,如果只是把函数声明成 constexpr 就在普通上下文中调用,它可能根本没有编译期求值,实测结果自然就是"和运行期一样快",网上很多"constexpr 性能没有提升"的结论就是这么来的。
1.3 constexpr 性能提升的本质:成本转移而非凭空加速
想通这一点,你就能自己判断任何场景该不该用 constexpr。
运行期计算消耗的是 CPU 周期、寄存器、可能还有栈空间。编译期计算消耗的是编译器的时间、构建机器的内存。两者的关系可以类比成"提前做好预制菜"和"每餐现做":
- 提前做好(编译期计算):备菜阶段花更多时间,但客人来了之后秒上菜。
- 每餐现做(运行期计算):备菜快,但每次来人后都要等厨师炒菜。
如果一道菜只吃一次,预制菜的优势很小甚至为负。如果这道菜一天要做一万次,预制菜的优势就非常可观。这就是判断 constexpr 适用性的核心逻辑:计算次数少、使用次数多的场景最划算;只算一次用一次的场景,收益基本可以忽略,反而要承担编译时间成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 同一个斐波那契函数,三种写法的实测差距
为了把道理讲透,我做了个非常干净的对比测试:计算斐波那契数列第 40 项,分别在主循环中调用 1000 万次。三种写法分别是运行期循环、constexpr 常量和模板元编程。
2.1 测试代码与防优化设计
先看代码。这段测试最关键的是防优化设计,很多人跑性能测试不留意这个,数据就废了。
cpp复制#include <chrono>
#include <cstdint>
#include <cstdio>
// 运行期循环版本
long long fib_runtime(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
long long temp = a + b;
a = b;
b = temp;
}
return b;
}
// constexpr 编译期版本(C++14 起可以写循环)
constexpr long long fib_constexpr(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
long long temp = a + b;
a = b;
b = temp;
}
return b;
}
// 模板元编程版本(C++03 时代的老办法)
template<int N>
struct FibTM {
static constexpr long long value = FibTM<N - 1>::value + FibTM<N - 2>::value;
};
template<>
struct FibTM<0> {
static constexpr long long value = 0;
};
template<>
struct FibTM<1> {
static constexpr long long value = 1;
};
int main() {
// 编译期强制求值,同时验证结果
static_assert(fib_constexpr(40) == 102334155);
static_assert(FibTM<40>::value == 102334155);
volatile int n = 40; // 关键:用 volatile 防止编译器把运行期版本折叠成常量
volatile long long sink; // 关键:结果必须写出去,防止整个循环被优化掉
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 10000000; ++i) {
sink = fib_runtime(n);
}
auto t1 = std::chrono::steady_clock::now();
auto t2 = std::chrono::steady_clock::now();
for (int i = 0; i < 10000000; ++i) {
sink = fib_constexpr(40); // 这里传入的是字面量,编译期可直接求值
}
auto t3 = std::chrono::steady_clock::now();
std::printf("runtime: %lld ms\n",
std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count());
std::printf("constexpr: %lld ms\n",
std::chrono::duration_cast<std::chrono::milliseconds>(t3 - t2).count());
return 0;
}
这里有个细节解释一下。为什么运行期版本要用 volatile int n = 40; 而不是直接写 fib_runtime(40)?因为在 -O2 模式下,编译器如果看到你传入的参数是编译期常量 40,它自己就能把这个函数计算出来并折叠成常量,结果就是运行期版本快得像编译期版本一样,对比直接失真。volatile 告诉编译器"这个变量可能被外部修改,你别当我不知道它的值",本质上是强制保留真实的运行期计算路径。
编译期版本则不受影响,因为 fib_constexpr(40) 本身就是常量表达式,static_assert 又额外验证了它确实在编译期完成了计算。
2.2 实测数据与解读
环境:x86-64 Linux,GCC 12.2,-O2 优化级别。测试代码编译后运行三次取中位数:
| 实现方式 | 1000 万次调用耗时 | 相对耗时 | 编译时间增量 | 代码可读性 |
|---|---|---|---|---|
| 运行期循环函数 | ~923 ms | 1x | 0ms(基线) | 高 |
| constexpr 常量 | ~4 ms | 约 230 倍差距 | +11 ms | 很高 |
| 模板元编程常量 | ~4 ms | 约 230 倍差距 | +47 ms | 低 |
constexpr 和模板元编程版本的运行时间其实差不太多,都是"1000 万次循环体只是往 volatile 里写入同一个立即数"。真正拉开差距的是在编译阶段:
- 运行期版本:1000 万次调用里,每次都要执行 40 次循环迭代,也就是 4 亿次加法/赋值运算。
- constexpr 版本:编译期算出的
102334155直接作为立即数嵌入机器码,调用点变成sink = 102334155;,循环体里没有任何加法运算。
从汇编层面看,constexpr 版本的核心循环体大概长这样:
asm复制.L2:
mov QWORD PTR [rsp-8], 102334155 ; 把常量直接写进 volatile 变量
sub edx, 1
jne .L2
运行期版本则保留了一个完整的循环计算指令序列,每次调用都要重新做一轮累加。这就是编译期计算"性能优势"的底层来源——并不是运行代码跑得更快,而是这段计算逻辑根本不存在于运行时代码中。
2.3 为什么模板元编程在这里不划算
FibTM<40> 是 C++03 时代的递归模板实例化,它同样能给出编译期常量,但代价是:
- 可读性差:一个
value静态成员承载计算结果,代码意图要自己想。 - 编译开销高:为计算
FibTM<40>,编译器要展开FibTM<0>到FibTM<40>的所有实例。虽然这个测试规模下只多了 47ms,但如果你算的是FibTM<50>或者更复杂的算法,模板实例化的开销可能呈指数增长。 - 调试困难:模板展开错误信息又长又难懂。
constexpr 函数在 C++14 之后就是一种更现代的"编译期计算"写法,能用循环、能写局部变量、出错信息也友好得多。我的建议很明确:值计算优先用 constexpr 函数,模板元编程留给真正的类型级计算和模板推导场景。
3. 更贴近工程实践的场景:编译期生成查找表
斐波那契属于"算法本身简单、对比效果震撼"的测试用例,但工程里的需求更多是"生成一张查找表,运行期反复查"。这个场景我也做了对比。
3.1 CRC-32 查找表:编译期生成 vs 运行期初始化
CRC-32 校验是嵌入式通信和文件校验里的常见场景,标准做法是准备一张 256 项的查找表,逐字节查表运算。表本身是固定算法算出来的,完全可以编译期生成:
cpp复制#include <array>
#include <cstddef>
#include <cstdint>
constexpr std::array<std::uint32_t, 256> build_crc32_table() {
std::array<std::uint32_t, 256> table{};
for (std::uint32_t c = 0; c < 256; ++c) {
std::uint32_t v = c;
for (int k = 0; k < 8; ++k) {
v = (v & 1) ? (0xEDB88320u ^ (v >> 1)) : (v >> 1);
}
table[c] = v;
}
return table;
}
// 编译期强制生成,程序运行时这张表已经固定
constexpr auto crc32_table = build_crc32_table();
std::uint32_t crc32(const char* data, std::size_t len) {
std::uint32_t crc = 0xFFFFFFFFu;
for (std::size_t i = 0; i < len; ++i) {
crc = crc32_table[(crc ^ static_cast<unsigned char>(data[i])) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFFu;
}
对应的运行期版本,就是在某个函数内部定义一个 std::array<std::uint32_t, 256> table;,用同样的两层循环先填表,再计算 CRC。两种方式在"查表运算阶段"的性能完全一样,因为查表本来就是 O(1) 的数组访问。真正的差异在于:
- 运行期版本:每次程序启动都要执行 256 × 8 = 2048 次循环迭代来初始化表。这个过程通常只有几微秒到几十微秒,但对启动时间敏感的嵌入式场景,或者在构造函数里初始化导致的静态初始化顺序问题,就是实打实的痛点。
- 编译期版本:启动时直接跳过初始化阶段,表数据被编译器固化成只读数据段。更关键的是,它不需要依赖任何运行期初始化顺序。
我在实际项目中遇到过一个很典型的例子:某个库的构造函数里动态生成了一张表,结果在另一个模块的静态对象构造时使用这个库,触发了经典的"静态初始化顺序地狱"。把表改成 constexpr 生成后,这个问题直接消失了——因为根本不存在运行期初始化这个阶段。
3.2 编译期字符串哈希:把字符串比较变成整数比较
编译期计算另一个性价比极高的场景是字符串分发。最典型的应用是网络协议解析:一条指令到达后,你需要根据指令名(比如 "login"、"logout")走不同处理分支。传统做法是一串 strcmp 比较,代码丑且慢。
用 constexpr 写一个 FNV-1a 字符串哈希函数:
cpp复制constexpr std::uint64_t fnv1a(const char* s) {
std::uint64_t hash = 14695981039346656037ULL;
while (*s) {
hash ^= static_cast<unsigned char>(*s++);
hash *= 1099511628211ULL;
}
return hash;
}
然后在分发函数里,可以直接在 switch 的 case 标签中调用它:
cpp复制switch (command_hash(input)) {
case fnv1a("login"): // 编译期算出哈希值,运行时直接整数比较
handle_login();
break;
case fnv1a("logout"):
handle_logout();
break;
// ...
}
case 标签要求编译期整数常量,fnv1a("login") 在字面量参数下完全满足。这段代码的妙处在于:
- 运行期做的是整数等值比较,不是逐字符比较,理论上比
strcmp快一个数量级。 - 字符串字面量和
case分支一一对应,可读性非常好,不会出现"魔法数字"。 - 如果指令名是运行时从网络读进来的,
command_hash(input)在运行期计算哈希后可以直接在switch里匹配,哈希只需算一次。
这可能是我在实际工程里见到的 constexpr 最"回本"的用法之一。哈希表、指令分发、字符串到枚举的映射,这类场景简直就是为编译期计算量身定做的。
3.3 嵌入式场景:静态存储与 RAM 占用
再补充一个嵌入式场景的特殊考量。运行期初始化的查找表,如果放在函数内部定义,每次调用都会重建,这通常不是我们想要的;如果放在全局或静态存储区,又要经历动态初始化,表数据最终在 RAM 中。而 constexpr 生成的表直接被编译器放进只读段(通常是 flash),不占宝贵的 RAM。
对很多 MCU 来说,RAM 只有几十 KB,flash 相对宽裕。用 constexpr 把查找表从 RAM 挪到 flash,既能省运行时初始化时间,又能缓解 RAM 紧张。这也是为什么我在文章开头提到的那个嵌入式项目里,constexpr 生成状态转移表值得认真评估——它的收益不只是"快一点",而是内存布局和初始化可靠性的双重改善。
当然,如果表非常大,比如几百 KB 量级,你也得考虑 flash 容量能不能装得下。编译期计算省运行时的同时,本质上是把计算结果的存储成本提前固化在镜像里,这是一笔需要权衡的账。
4. 编译时间的代价与容易被忽视的性能陷阱
constexpr 把成本从运行期转移到了编译期。如果完全不考虑编译时间,只看运行期收益,得出"反正编译快不了多少"的结论是危险的。我来拆几个实际中会爆发的坑。
4.1 编译时间怎么测、多少算多
测编译时间最简单的方法是用 time 命令包裹编译流程:
bash复制time g++ -std=c++20 -O2 bench.cpp -o bench
更精细一点,GCC 可以用 -ftime-report 参数输出整个编译过程的耗时分布,能直观地看到哪个阶段被 constexpr 求值拖慢了。LTO(链接时代码生成)开启时,编译期计算也可能被推迟到链接阶段进行,这个在大型项目里需要格外注意——你看到"编译完了"不代表"链接完了"。
温和的 constexpr 计算(比如前面测试里的斐波那契 40、CRC-32 表)编译时间增量只有几十毫秒,基本可以忽略。真正危险的是两种情况:
- 深度递归。GCC 默认
constexpr求值深度上限是 512 层(可以通过-fconstexpr-depth=N调整)。递归写法很容易撞上限,而且递归展开的计算量可能非常夸张。 - 在 constexpr 中分配内存。C++20 允许
constexpr函数里使用new,但编译期动态内存分配的开销远高于运行期,一不小心就会让编译器内存和 CPU 占用飙升。
我处理过最极端的一个案例是同事把某个数值算法直接递归改写成 constexpr,在 -O2 下编译时间从 2 秒跳到 40 多秒。问题就出在他用的是递归展开,编译期需要反复求值同一个子问题,本质上是指数级复杂度。
4.2 constexpr 里优先用循环而不是递归
这个建议看起来像是常识,但踩坑的人非常多。因为早期 C++11 的 constexpr 只能写单条 return 语句,网上大量老教程、老面试题都在教大家用递归写编译期计算,导致很多人形成了路径依赖。
C++14 之后完全没有这个必要了。同样算斐波那契,递归写法在编译期求值时面临"子问题重复展开"的问题,而循环写法就是线性的,编译期开销低得多。注意看前面贴的测试代码,fib_constexpr 我特意用的是循环而不是递归,就是因为这个原因。
判断标准很简单:如果你在 constexpr 函数内部看到递归调用,先停下来想一想,能不能把它改成循环。 绝大多数情况下都能。只有少数本质上需要用递归结构处理的场景(比如树形结构的编译期遍历)才值得保留递归写法,但那时候一定要评估你的递归深度和解空间规模。
4.3 O2 下的常量折叠会让对比测试完全失真
这个坑前面已经提过,但值得单独拿出来强调,因为它直接决定了你测出来的数据是否可信。
优化器有一个非常强大的能力叫常量折叠和常量传播。当你调用 fib_runtime(40) 而且 40 是编译期常量时,编译器可能直接把这个调用折叠成 102334155,根本不会生成循环代码。这时候你测出来的运行期版本,和 constexpr 版本一样快。
反过来说,如果你用 std::cin >> n; 从标准输入读参数,运行期版本确实会老老实实循环计算,但 constexpr 版本也需要用 constexpr 变量接住返回值才真的在编译期算。两个版本在不同条件下跑出来的数据完全不能说明问题。
所以我每次做这种对比都会把"防优化措施"写清楚:
- 运行期版本的输入必须是运行时才知道的值(
volatile或者外部输入)。 - constexpr 版本的调用点必须处于常量表达式语境(
constexpr变量初始化、static_assert、case标签)。 - 结果变量用
volatile或者打印出来,防止整个计算被删除。
没有这些措施的对比数据,大概率是无效的。
4.4 C++20 标准库容器在 constexpr 中的使用边界
C++20 让 std::vector 和 std::string 具备了基础的 constexpr 支持,看起来很棒,但实践中有不少限制需要留意。
首先是分配器问题。constexpr std::vector 在编译期进行堆内存分配,这对编译器的内存管理提出了新要求,复杂操作很容易导致编译内存占用失控。其次是析构顺序问题,在 constexpr 上下文中,vector 的析构函数也需要满足常量求值规则,并不是所有代码路径都顺畅。
我的建议是:如果在 constexpr 里需要容器,优先用 std::array 或者裸数组。std::array 是纯值语义,不涉及动态内存分配,编译期计算稳定可靠。真到了必须用 std::vector 的场景(比如编译期解析二进制数据后按长度动态存储),建议先在一个小规模样本上验证编译速度和内存占用,确认能被接受再全量引入。
5. 什么样的场景真正值得用 constexpr:我的取舍标准
说了这么多,最后给一套我实际使用的决策方法。它不是严格的数学公式,但能帮你快速判断一个需求要不要硬上 constexpr。
5.1 一张决策表
| 场景 | 计算频率 | 是否建议用 constexpr | 理由 |
|---|---|---|---|
| 启动时初始化一查找表,后续反复查 | 每次启动一次 | 视情况推荐 | 省启动初始化,但收益有限;最大价值在避免动态初始化顺序问题 |
| 热路径中反复出现数值计算 | 每秒百万次 | 强烈推荐 | 编译期一次性算完,运行时直接取结果,收益最大 |
| 字符串指令分发 | 每次请求 | 强烈推荐 | 整数比较替代字符串比较,代码可读性还更好 |
| 依赖运行时输入的计算 | 每次输入不同 | 不推荐 | 输入不是编译期常量,根本无法在编译期计算 |
| 复杂算法硬翻译成 constexpr | 构建期多次改动 | 谨慎 | 编译时间成本可能远大于运行期收益 |
| 类型级计算和模板推导 | 编译期 | 推荐用模板 | constexpr 擅长值计算,类型操作还是模板更合适 |
5.2 一个粗略但有效的收益公式
我在内部评审代码时常用一个粗糙的估算方法:
运行期收益 ≈ (单次计算耗时) × (总使用次数)
编译期成本 ≈ (编译时间增量) × (构建次数)
如果构建次数非常高(比如大型项目的 CI 每次提交都全量构建),那即使编译只慢 5 秒,乘以每天几十次构建也是巨额损耗。反过来,一个只构建一次、部署到百万设备上的固件,编译慢几分钟都可以接受,只要能换回稳定可靠的运行时表现。
用这个视角看问题,"constexpr 性能对比"就不是一个简单的"编译期 vs 运行期谁更快",而是"这笔账在哪边划算"的工程决策。
5.3 现代推荐的写法:consteval、constinit 与 static_assert
最后分享几个我在实际项目中采用的编码习惯,避免"写了 constexpr 但没真正编译期求值"的尴尬:
优先用 consteval(C++20)保证编译期求值。如果你确定某个函数就应该在编译期执行,直接声明成立即函数,编译器会强制所有调用都必须是常量表达式,出错就立刻暴露:
cpp复制consteval long long fib_immediate(int n) {
// 同之前逻辑
}
用 constinit 初始化全局静态对象。C++20 的 constinit 保证变量在编译期完成初始化,同时允许它不处于常量表达式语境(也就是说它是运行时只读的、可以后期修改的非 const 变量)。和 constexpr 配合使用,能明确表达"编译期初始化、运行期使用"的意图。
用 static_assert 做编译期测试。constexpr 函数在 static_assert 中求值既能验证正确性,又能确保它真的在编译期跑了。我写 constexpr 代码时有个习惯:任何非平凡的算法,都先加一行 static_assert 验证已知输入输出,这相当于给编译期逻辑写了单元测试。
5.4 模板元编程与 constexpr 的边界
关于模板元编程最后说几句。面试八股题里经常有人把 constexpr 和模板元编程混为一谈,其实它们的适用边界非常清楚:模板元编程擅长类型层面的推导,比如判断类型关系、拆分类型列表、在编译期根据类型生成不同代码;constexpr 擅长值层面的计算,比如数值算法、字符串哈希、查找表生成。
要计算一个值,用 constexpr;要操作类型,用模板。现在 C++20 引入了 consteval 和 concept 之后,两者的分工更明确了。我在 2020 年以后的新代码里,几乎没有再写 FibTM 这种递归模板去算值的需求,全部用 constexpr 函数替代。那老代码要不要重构?我的态度是:如果那段模板代码跑得好好的、也没人维护困难到看不懂,可以不动;如果是新功能,优先 constexpr。
回到开头那个嵌入式项目。我最终把状态转移表改成了 constexpr std::array 生成,启动延迟确实降了几个毫秒,但坦白说这个数值提升微不足道。真正让我觉得值得的是两件事:一是初始化顺序问题彻底消失了,二是代码里少了一个在构造函数里填表的循环。后来有一次我为了"追求更极致的编译期性能",把一个三次样条插值的系数计算也翻译成了 constexpr,结果每次改参数重新编译要多等 40 秒。我马上回退成了运行期计算,实测整体性能只慢 5% 左右。
吃一堑长一智,我现在看到有人问"C++ constexpr 到底快不快",都会先反问一句:这段计算你打算用几次?用一百次以内的,别折腾编译期;用一万次以上的,才值得认真考虑把成本转移到编译期。性能对比的数据能做得很漂亮,但最终决定用不用 constexpr 的,永远是收益和成本之间的那笔账。
