C++ constexpr 与编译期计算:把成本提前到编译期
本文要回答的问题
- constexpr 到底是什么?它是"编译器优化"还是"语言保证"?
- 怎么证明一个值确实在编译期算完了?什么语境强制编译期求值?
- 编译期计算会拖慢编译吗?代价有多大?
- C++11 到 C++20,constexpr 能力经历了怎样的松绑?
- constexpr 和上一讲的模板实例化,是什么关系?
一、constexpr:两种身份的常量
constexpr 从 C++11 出生,自带双重身份,很多人只记住了其中一半:
- constexpr 变量:要求在编译期求值,且一旦求值就是只读常量。
constexpr int n = 40 + 2;里的40 + 2在编译期就算完,n是名副其实的编译期常量,可以拿来开数组、当模板实参、写case标签。 - constexpr 函数:一份"有机会在编译期执行"的函数。它既能当常量表达式用(编译期求值),也能当普通函数用(运行期调用),由调用它的语境决定。这是它和宏、和普通函数都不同的地方——同一份代码,两种活法。
判断规则一句话:出现在要求常量的语境里,必须编译期算出来;出现在普通语境里,编译器可以折叠成常量(通常它也会折叠),也可以留到运行期。 前者是语言保证,不是优化——int arr[fact(5)]; 里 fact(5) 必须编译期算完,算不出来就编译报错;后者才是编译器"顺手"的优化。
constexpr unsigned fact(unsigned n) { return n <= 1 ? 1 : n * fact(n - 1); }
constexpr unsigned f5 = fact(5); // 强制编译期:constexpr 变量初始化
int arr[fact(5)]; // 强制编译期:数组大小
std::array<int, fact(6)> slot; // 强制编译期:模板实参
enum { MAX = fact(4) }; // 强制编译期:枚举值
static_assert(fact(5) == 120, ""); // 强制编译期:断言必须可求值
std::printf("%u", fact(5)); // 普通语境:编译器通常折叠,但非语言保证
上图把 constexpr 函数的两种语境画清楚了:左侧是强制编译期的常量表达式语境——static_assert、数组大小、模板实参、枚举、constexpr 变量初始化,碰到任何一个,编译器都必须当场求值,求不出来就编译失败;右侧是普通语境——printf 里的 fact(5) 这种,编译器通常顺手折叠成常量,但语言并不保证,它有权留到运行期。
上面五处"强制"是最常见的常量表达式语境。理解"强制"这两个字,是理解 constexpr 的钥匙:constexpr 的价值不在于"编译器好心帮我算",而在于语言规定有些地方必须用编译期常量,constexpr 让程序员的普通函数也能供应这种常量。
二、怎么证明它在编译期算完了:实测
空口无凭。用两个可验证的证据,让"编译期算完"这件事落地。实验代码与完整输出在公开仓库 demos/cpp-expert/constexpr-compile-time/。
证据一:static_assert。 任何一步算错,编译直接失败——这意味着求值必然发生在编译期,否则编译器无从断言:
static_assert(fact(5) == 120, "5! must be 120");
static_assert(FACT.v[12] == 479001600, "12! must be 479001600");程序能编译通过,就说明 fact(5)、FACT.v[12] 这些值在编译期算对了。
证据二:-O2 反汇编。 把编译期算好的值打印出来,看 -O2 下的汇编,printf 的每个参数都是立即数:
movl $120, %esi ← 5! 直接写死
movl $.LC0, %edi
xorl %eax, %eax
call printf
movl $479001600, %esi ← 12! 直接写死
movl $720, %esi ← slot.size() 编译期就是 720
movl $24, %esi ← MAX_ITEMS 直接写死
call printf运行期只有 printf 调用,没有任何 fact 函数调用、没有任何乘法。运行输出也是这套值:
5! = 120
12! = 479001600
7! = 5040
slot.size() = 720
MAX_ITEMS = 24slot.size() 是 720——std::array<int, fact(6)> 这个 720 元素数组的大小,在编译期由 fact(6) 算定。这就是 constexpr 的日常用法:把"查表""常量计算"提前到编译期,运行期直接拿现成的。
三、能力演进:C++11 → C++20 的三次松绑
constexpr 诞生时非常"面瘫":C++11 的 constexpr 函数只能有一个 return 语句,不能有局部变量、不能有循环、不能有 if。上一节的 fact 用三元运算符和递归硬凑,就是那个年代的写法——写起来像受刑。
C++14 放宽(relaxed constexpr):允许局部变量、if、for/while 循环。写阶乘终于可以像正常人一样写:
// C++14 起
constexpr unsigned fact14(unsigned n) {
unsigned r = 1;
for (unsigned i = 2; i <= n; ++i) r *= i;
return r;
}C++17 进一步允许在 constexpr 里用 lambda;C++20 再加两把新刀:consteval(只允许编译期调用,杜绝"编译期或运行期"的二义性)和 constinit(保证静态存储期变量在编译期初始化,防止动态初始化顺序问题)。到 C++20,std::vector/std::string 也能在 constexpr 语境下用(部分限制),编译期计算的空间被彻底打开。
| 标准 | constexpr 能力 | 意义 |
|---|---|---|
| C++11 | 单 return、无局部变量/循环 | 出生:只能写纯递归/三元表达式 |
| C++14 | 局部变量、if、循环 | 放宽:正常写法即可 |
| C++17 | lambda、if constexpr | 与模板协同更顺手 |
| C++20 | consteval、constinit、部分容器 | 强制编译期、消除初始化顺序坑 |
if constexpr(C++17)值得一提,它把 constexpr 的能力从"算数值"扩展到"选代码":if constexpr (sizeof(T) > 8) 在编译期判定真假并丢弃另一边分支,是模板代码里最常用的编译期决策之一,与 模板实例化机制 直接相关。
四、编译期的价值:查表、配置、安全
编译期计算买到的,是运行期零成本。最经典的三类应用:
- 查表:编译期算好查找表(正弦表、CRC 表、转换表),程序启动即用,不用运行时初始化。上一节的
FACT表就是最小例子。 - 静态配置与安全:编译期校验大小关系(
static_assert(sizeof(int) == 4))、计算缓冲区大小、生成类型无关的元信息。把错误从运行期"崩溃"提前到编译期"报错",是 C++ 最值钱的特性之一——能编译期查出来的问题,绝不拖到线上。 - 元编程地基:
std::integral_constant、std::ratio、编译期字符串哈希、std::array大小的来源——现代模板库的地基几乎全是 constexpr 常量。
和运行期计算对比,账是这样:
| 维度 | 编译期计算(constexpr) | 运行期计算 |
|---|---|---|
| 何时执行 | 编译时 | 每次运行 |
| 运行期成本 | 零(值已定死) | 每次调用都算 |
| 出错时机 | 编译报错 | 运行期才暴露 |
| 灵活性 | 依赖常量表达式语境 | 任意 |
| 编译时间 | 增加(通常轻微) | 无影响 |
一句话:编译期计算是用"编译时多花一点时间"换"运行期零成本 + 更早的出错反馈"。这两个交换,正好是性能敏感的 C++ 项目最想要的。
五、编译期成本有多大:实测
编译期计算不是免费的午餐。实测数据(make ctime,g++ 4.8.5,-std=c++11):
== main.cpp(浅:fact 表 + 若干强制语境) ==
real 0m0.086s
== deep.cpp(深:fib(32) 编译期求值,递归无记忆化) ==
real 0m0.082s意外的结论:深递归几乎没增加编译时间。 把深度一路加到 fib(44)(理论上亿次级递归调用),编译仍是约 0.08s——GCC 的 constexpr 求值器自带记忆化缓存,同一子问题不会重复算两遍。所以"constexpr 会让编译变慢"这个印象,对纯数值计算来说基本不成立。
真正让编译慢的,是模板实例化组合爆炸,而不是 constexpr 数值求值。上一讲 模板实例化机制 的 -ftime-report 里,实例化占了墙钟 28%、内存 19%;本篇 make ftime 的明细也能佐证,constexpr 求值被计入"template instantiation"阶段:
template instantiation : 0.03 (38%) usr 0.02 (29%) sys 0.04 (25%) wall 4092 kB (20%) ggc
TOTAL : 0.08 0.07 0.16 20788 kB结论需要修正成一句更准的话:编译期数值计算本身很便宜(编译器有缓存),代价高的是模板组合数量的指数增长。这解释了为什么 C++20 以后 constexpr 能力大幅放开,项目编译时间却仍主要受模板制约。
六、constexpr 的边界:什么不能做
即使到 C++20,constexpr 也不是万能的。常被踩的边界:
- I/O:
printf、文件读写、网络——任何有副作用的 I/O 都不行。constexpr 函数里不能输出、不能读文件。 - 动态内存:C++20 前不能
new;C++20 起 constexprnew有限支持(编译期分配必须编译期释放,不能泄漏出编译期)。 - 虚函数调用:C++20 前不能;C++20 起 constexpr 虚函数可用(编译期对象可以走虚派发),但动态类型必须编译期可知。
- 未定义行为:在 constexpr 里踩 UB(如整数溢出在 C++20 前、越界访问)直接编译报错——这是 constexpr 的"副作用",也是它的优点:编译期计算帮你把 UB 拦在编译门外。
- 求值深度:
-fconstexpr-depth(默认 512)限制递归深度,深到顶会编译报错,可调大但意味着更慢。
边界本身就在提示用途:constexpr 适合"纯函数式"的计算——给定输入,输出确定,无副作用。这也和函数式编程的纯度要求殊途同归。
七、constexpr 与模板实例化:两种编译期机制的配合
上一讲讲模板实例化,这一讲讲 constexpr,二者是编译期的两台机器,分工明确:模板实例化负责"复制代码",constexpr 负责"算出数值"。 但它们的配合远比分工精彩:
- constexpr 的值喂给模板:
std::array<int, fact(6)>里fact(6)算出的 720 成为非类型模板实参,模板实例化的"输入"由编译期计算提供。 - 模板的递归逼出 constexpr:编译期计算在模板元编程里往往以递归函数形式出现(经典如
std::integral_constant),C++14 放宽后才渐渐能用循环写。 - 实例化触发求值:模板实例化时,非类型实参必须是常量表达式——
FibAt<32>::value那种enum { value = fib(N) }写法,就是在实例化过程中强制fib(32)编译期求值。
一句话概括关系:constexpr 是"算",模板是"造";先算出来的常量,再去驱动模板造出对应的代码。这也是为什么 constexpr 和模板经常成对出现、总被一起提。
八、与本站性能主线衔接
constexpr 是"零成本抽象"的另一根支柱。模板实例化消灭了类型分派的运行期开销,constexpr 则消灭了常量计算的运行期开销——凡能编译期定案的东西,都不该让运行期再算一遍。这在性能剖析里有个直接推论:剖析时如果看到热点循环里有"每次都在算同一个值"的代码,检查它能不能提前到编译期或至少提到循环外——很多看似烧 CPU 的"常量计算",其实只是没告诉编译器它是个常量。
但也要记得编译期那头的账:constexpr 用多了,编译时间和内存会涨(虽然实测数值计算本身不贵);模板组合才是编译时间的真凶。性能优化永远是"运行期 vs 编译期"的权衡,constexpr 给了你一把把成本从运行期挪到编译期的扳手,用不用、用多少,得看项目。
一句话总结
constexpr 是"有机会在编译期执行"的代码,遇到常量表达式语境(数组大小、模板实参、static_assert、枚举、constexpr 变量)时语言保证它必须在编译期算完——static_assert 与 -O2 反汇编的立即数都是铁证;算数值本身几乎不拖慢编译(编译器有缓存),代价的大头在模板实例化组合,而它换来的运行期零成本与"编译期报错代替运行期崩溃",正是 C++ 性能代码的底气。
上一篇:模板实例化机制 下一篇:RTTI 与 typeid