同一个「1 到 100 里有几个偶数」,写成普通函数就要在运行时循环一遍;写成constexpr函数,答案在编译完成的瞬间就已经是常量50了,运行时一条循环都不会执行。编译期计算(compile-time computation)就是把这类工作从运行期搬到编译期。代价是编译变慢、二进制变大,换来的是运行时的计算时间、内存构造开销,以及一批「只有编译期常量才能用」的位置。
1. 引子:同一个循环为什么要写两遍
先看最朴素的对比,一个能编译期求值、一个只能运行期求值:
// constexpr_basics.cpp — 编译: g++ -std=c++17 -Wall -O2 constexpr_basics.cpp -o demo#include<cstddef>#include<cstdio>namespace{constexprintkBytesPerKiB=1024;// 编译期常量,进只读段constexprinttoBytes(intkib){// 能在编译期求值的函数returnkib*kBytesPerKiB;}inttoBytesAtRuntime(intkib){// 同样的公式,但只能在运行期调用returnkib*kBytesPerKiB;}}// namespaceintmain(){// ① static_assert 要求整个表达式在编译期算完static_assert(toBytes(4)==4096,"编译期就能验证");// ② constexpr 变量能当数组长度,运行期函数的返回值不行constexprstd::size_t kBufBytes=static_cast<std::size_t>(toBytes(8));intbuf[kBufBytes]={};std::printf("sizeof(buf) = %zu 字节\n",sizeof(buf));// ③ 结果一样,但一个在编译期算完,一个在运行期算std::printf("toBytes(4) = %d,toBytesAtRuntime(4) = %d\n",toBytes(4),toBytesAtRuntime(4));}sizeof(buf) = 32768 字节 toBytes(4) = 4096,toBytesAtRuntime(4) = 4096两个函数的函数体一模一样,区别只在constexpr这个关键字给了编译器一项授权:当所有实参都是常量表达式时,允许(并要求)在编译期把结果算出来。第 ① 行的static_assert是最硬的证据。它要求toBytes(4)在编译期就有值,否则这一行编译不过。第 ② 行是另一处只有编译期常量能站的位置:数组长度必须是常量表达式,toBytesAtRuntime(8)放进去会直接报错。
官方文档:constexpr 说明符 — cppreference、静态断言 static_assert — cppreference
2. 编译期和运行期,各自该干什么
编译期计算不是「让程序变快」的银弹,它只是重新划分了工作的时间归属:
把一份工作分给两个时间段: 源码 编译期:在编译它的机器上完成 运行期:用户机器上只剩这些 +-----------+ +---------------------------+ +-----------------------+ | constexpr | | constexpr evaluator | | one subscript access | | functions | | loops / branches all run | | O(1) lookup, no loop | | templates | | templates instantiated | | no heap allocation | | tables | | results become constants | | no init at startup | +-----------+ +---------------------------+ +-----------------------+ | | ^ | | 产出 | | +--> 常量值:直接写进机器码 ---------+ | +--> 数据表:放进 .rodata 只读段 ----+ | +--> 你付出:编译时间变长、二进制体积变大 运行期那一栏的三行合起来就是一句话:没有循环、没有堆分配、启动时没有初始化 —— 这些活全部被挪到编译期做完了这张图里最容易被忽略的是运行期那一栏。运行期分配和构造一块数据表,往往比计算本身更贵。它要走内存分配器、可能触发一次缺页、还要考虑线程安全(C++11 起局部static的初始化带同步开销)。编译期生成的数据表直接躺在只读段里,程序一启动就是现成的。
3. 查表法:编译期生成 std::array
把计算结果做成表,是编译期计算最经典的用法。质数表是个干净的入门例子。判定一个数是不是质数本身是要循环的:
// constexpr_table.cpp — 编译: g++ -std=c++17 -Wall -O2 constexpr_table.cpp -o demo#include<array>#include<cstddef>#include<cstdio>namespace{constexprstd::size_t kPrimeCount=10;constexprboolisPrime(intn){if(n<2){returnfalse;}for(intd=2;d*d<=n;++d){if(n%d==0){returnfalse;}}returntrue;}// 整张表在编译期算出来,运行期一次循环都不做constexprstd::array<int,kPrimeCount>makePrimeTable(){std::array<int,kPrimeCount>table{};std::size_t count=0;for(intn=2;count<kPrimeCount;++n){if(isPrime(n)){table[count]=n;++count;}}returntable;}constexprstd::array<int,kPrimeCount>kPrimes=makePrimeTable();// 这两行就是「表是编译期生成的」的硬证据:// 只要 makePrimeTable() 无法在编译期求值,它们直接编译不过static_assert(kPrimes[0]==2,"第 1 个质数是 2");static_assert(kPrimes[9]==29,"第 10 个质数是 29");}// namespaceintmain(){std::printf("编译期生成的质数表(%zu 项): ",kPrimes.size());for(intp:kPrimes){std::printf("%d ",p);}std::printf("\n");// 运行期只做一次下标访问:O(1)std::printf("查表 kPrimes[9] = %d\n",kPrimes[9]);// 对照:运行期现算同一个值,要把判定流程再走一遍intlast=0;std::size_t count=0;for(intn=2;count<kPrimeCount;++n){if(isPrime(n)){++count;last=n;}}std::printf("运行期现算 = %d\n",last);}编译期生成的质数表(10 项): 2 3 5 7 11 13 17 19 23 29 查表 kPrimes[9] = 29 运行期现算 = 29「表是编译期生成的」这件事怎么证明?static_assert就是证据。static_assert的实参必须是常量表达式,如果makePrimeTable()里用了任何不能在编译期执行的东西(比如std::printf、堆分配),kPrimes就不再是常量表达式,那两行static_assert会立刻编译失败。也就是说:这段代码能编译过,本身就是「表在编译期生成」的证明,不需要额外验证。
代价也要说清楚:constexpr函数在 C++14 才放开「循环和局部变量」的限制(C++11 里只能写单个return表达式),而std::array的operator[]在 C++17 才成为constexpr。所以这套写法在 C++17 上是顺的,在 C++11 里会很别扭。
官方文档:std::array — cppreference、常量表达式 — cppreference
4. 模板元编程 vs constexpr 函数
在constexpr出现之前,编译期计算靠的是模板递归实例化。同样算 10 的阶乘,两种写法:
// fact_compare.cpp — 编译: g++ -std=c++17 -Wall -O2 fact_compare.cpp -o demo#include<cstddef>#include<cstdio>namespace{// ① 模板元编程:每个 N 都是一次独立实例化,代码随 N 份数增长template<intN>structFactorial{staticconstexprlonglongvalue=N*Factorial<N-1>::value;};template<>structFactorial<0>{staticconstexprlonglongvalue=1;};// ② constexpr 函数:只有一份代码,求值发生在编译期constexprlonglongfactorial(intn){longlongresult=1;for(inti=2;i<=n;++i){result*=i;}returnresult;}}// namespaceintmain(){static_assert(Factorial<10>::value==3628800,"模板递归版");static_assert(factorial(10)==3628800,"constexpr 函数版");std::printf("Factorial<10>::value = %lld\n",Factorial<10>::value);std::printf("factorial(10) = %lld\n",factorial(10));std::printf("factorial(0) = %lld\n",factorial(0));std::printf("factorial(20) = %lld\n",factorial(20));}Factorial<10>::value = 3628800 factorial(10) = 3628800 factorial(0) = 1 factorial(20) = 2432902008176640000两者结果完全一致,但机制和代价差很远:
| 维度 | 模板元编程(递归实例化) | constexpr函数 |
|---|---|---|
| 代码实体数量 | 每个N一份独立实例化(Factorial<10>要展开 10 层) | 只有一份函数体 |
| 参数形式 | 必须是模板参数(编译期常量) | 普通函数参数,编译期/运行期都能用 |
| 写法 | 递归 + 全特化终止条件 | 普通循环、普通if |
| 编译时间 | 随实例化层数增长 | 单份代码求值,通常更省 |
| 报错可读性 | 出了错是一长串实例化栈 | 就是普通函数报错 |
| 必须用的场合 | 结果要当类型用(元编程算出的是类型) | 结果当值用 |
结论很简单:结果只要是「值」,就优先用constexpr函数;只有当计算结果需要是类型(比如根据条件挑一个类型、生成一个std::tuple的元素列表)时,才不得不回到模板元编程。这和 Core Guidelines 里「别耍聪明」的取向一致:模板递归在能算值的场景下只是历史包袱。
官方文档:模板特化 — cppreference、C++ Core Guidelines — Per.4 别假设复杂代码一定更快
5. 编译器本来就会做常量折叠
这里有个必须澄清的误解:写成普通函数,编译器也可能在编译期把它算掉。
const int n = 4 * 1024; → 编译器直接折叠成 4096,不会有乘法指令 for (int i = 0; i < 8; ++i) sum += 3;→ 开 -O2 后整个循环常被直接算成 24(常量折叠 + 循环展开) 也就是说:constexpr 不是「让编译器会算」的开关, 优化器本来就有常量折叠(constant folding)和常量传播(constant propagation)。 那 constexpr 到底给了什么? ① 保证性:不管你开不开优化、优化器这次心情如何,constexpr 必须编译期求值 ② 可用位置:常量表达式能去的地方,普通函数返回值去不了 —— · 数组长度 / std::array 的模板实参 · static_assert 的条件 · 模板的非类型参数 · switch 的 case 标签 · 位域的宽度、枚举初始化器 ③ 语义表达:函数签名里写了 constexpr,调用方一眼知道「这个函数没有副作用」第 ② 条才是constexpr真正的不可替代之处。看一个只有常量表达式能站的位置。switch的case标签:
// 反例,不要这么写:运行期函数的返回值不能当 case 标签,编译不过// int protocolId(int index) { return 0x1000 + index; }// switch (id) {// case protocolId(1): // ✗ case 标签必须是常量表达式// break;// default:// break;// }//// 正确写法:把函数标成 constexpr,返回值就成了常量表达式// constexpr int protocolId(int index) { return 0x1000 + index; }// switch (id) {// case protocolId(1): // ✓// break;// default:// break;// }官方文档:常量折叠与优化 — Compiler Explorer(把
int n = 4 * 1024;贴进去看汇编,会发现根本没有乘法指令)
6. 收益和代价,摊开算
| 维度 | 编译期做 | 运行期做 |
|---|---|---|
| 计算耗时 | 零(结果已经是常量) | 每次调用都要算 |
| 内存位置 | 只读段.rodata,多进程共享同一份物理页 | 每个进程一份,或在栈上临时构造 |
| 初始化开销 | 零:程序一启动就可用 | 需要构造 / 分配,局部static还带线程安全开销 |
| 二进制体积 | 变大(表越长、模板实例化越多越明显) | 不变 |
| 编译时间 | 变长(constexpr求值和模板实例化都是编译期成本) | 不变 |
| 灵活性 | 改一个常量就要重新编译,且必须是编译期已知 | 可以用运行期的输入(配置文件、命令行、环境变量) |
| 可读性 | 过度使用会让代码变成天书 | 普通循环,谁都看得懂 |
| 出错时机 | 编译期就报错,问题早暴露 | 运行期才暴露 |
务实的使用边界是:用在值固定、且算起来比查起来贵的场合:单位换算系数、协议常量、查找表(三角函数表、CRC 表、色域映射表)、编译期类型分派。反过来,一旦数据来自配置文件、依赖运行期输入、或者优化器已经能处理,就不值得为了constexpr把代码写成天书。Core Guidelines 的 Per.5 说得很直接:别为了效率把代码写复杂,先用最简单的写法。
官方文档:C++ Core Guidelines — Per.5 别用间接绕弯
7. C++20:constexpr 里能用容器和算法了
C++20 把std::vector、std::string的构造、析构、push_back以及一大批算法(std::sort、std::count_if、std::ranges系列)都变成了constexpr,于是编译期可以用「正常写法」而不是「元编程写法」:
// verify: std=c++20// constexpr_vector.cpp — 需要 C++20,编译: g++ -std=c++20 -Wall -O2 constexpr_vector.cpp -o demo#include<algorithm>#include<cstddef>#include<cstdio>#include<vector>// C++20 起 std::vector 可以出现在 constexpr 里(编译期分配与释放)constexprstd::size_tcountEvensUpTo(intlimit){std::vector<int>values;for(inti=1;i<=limit;++i){values.push_back(i);}returnstatic_cast<std::size_t>(std::count_if(values.begin(),values.end(),[](intx){returnx%2==0;}));}intmain(){constexprstd::size_t kEvens=countEvensUpTo(100);static_assert(kEvens==50,"1..100 里有 50 个偶数");std::printf("1..100 里的偶数个数 = %zu(编译期用 vector 算出来的)\n",kEvens);std::printf("同一函数运行期调用 = %zu\n",countEvensUpTo(1000));}1..100 里的偶数个数 = 50(编译期用 vector 算出来的) 同一函数运行期调用 = 500这段代码要在 C++20 下才能编译。C++17 里std::vector::push_back不是constexpr,countEvensUpTo(100)会被报「不能在常量表达式中使用」。而 C++20 里,编译期的那次堆分配由编译器在内部处理,只要在求值结束前把内存全部释放(values析构,或者push_back中途失败时清干净),就合法。同时注意最后一行:同一个constexpr函数在运行期照样能调用—— 它不是「只能编译期用」的东西,而是「可以编译期用」。
8. 完整示例:编译期生成的 CRC-32 表
CRC-32 是查找表最真实的用武之地:表有 256 项,每项都要跑 8 轮移位异或。传统 C 实现用「首次调用时懒初始化」加一个标志位,那意味着启动路径上多一次分支、以及一次可能被多线程竞争到的初始化;用编译期生成,这些问题一次性消失。
// crc32_table.cpp — 编译: g++ -std=c++17 -Wall -O2 crc32_table.cpp -o demo#include<array>#include<cstddef>#include<cstdint>#include<cstdio>#include<string_view>namespace{constexprstd::uint32_tkPolynomial=0xEDB88320U;// CRC-32 的反射多项式constexprstd::size_t kTableSize=256;constexprstd::array<std::uint32_t,kTableSize>makeCrcTable(){std::array<std::uint32_t,kTableSize>table{};for(std::size_t i=0;i<kTableSize;++i){std::uint32_tc=static_cast<std::uint32_t>(i);for(intbit=0;bit<8;++bit){c=(c&1U)!=0U?(kPolynomial^(c>>1)):(c>>1);}table[i]=c;}returntable;}// 256 * 8 次移位异或全部在编译期做完,表直接落在只读段constexprstd::array<std::uint32_t,kTableSize>kCrcTable=makeCrcTable();// 抽查两项,证明表确实是编译期生成的static_assert(kCrcTable[1]==0x77073096U,"CRC-32 表第 1 项");static_assert(kCrcTable[2]==0xEE0E612CU,"CRC-32 表第 2 项");constexprstd::uint32_tcrc32(std::string_view data){std::uint32_tcrc=0xFFFFFFFFU;for(charch:data){constautoindex=static_cast<std::size_t>((crc^static_cast<std::uint32_t>(ch))&0xFFU);crc=kCrcTable[index]^(crc>>8);}returncrc^0xFFFFFFFFU;}}// namespaceintmain(){constexprstd::string_view kMessage="hello, constexpr";constexprstd::uint32_tkDigest=crc32(kMessage);// 连校验和都在编译期算完std::printf("CRC32(\"%s\") = 0x%08X\n",kMessage.data(),kDigest);std::printf("表大小 = %zu 项,占 %zu 字节\n",kCrcTable.size(),sizeof(kCrcTable));std::printf("运行期重算同一串 = 0x%08X(与上面一致)\n",crc32(kMessage));}CRC32("hello, constexpr") = 0x8D4A647C 表大小 = 256 项,占 1024 字节 运行期重算同一串 = 0x8D4A647C(与上面一致)三行输出各有一个要点:
0x8D4A647C是编译期算出来的常量,constexpr std::uint32_t kDigest这个声明本身就是一道编译期约束 —— 算不出来就编不过。256 项,占 1024 字节:这张表在程序里存在.rodata段,多个进程可以共享同一份物理内存;换成运行期std::vector生成,每个进程都得各分 1KB 堆内存、各算 2048 次循环。- 第三行验证了同一个
crc32函数在编译期和运行期给出的答案完全一致—— 这是constexpr函数的基本契约:编译期求值必须和运行期求值语义相同。
实测输出里没有任何耗时数字:constexpr的收益是「运行期本来就没有这段代码」,用计时器量反而容易量到噪声。想看真实差别,去看生成的汇编(godbolt.org上kCrcTable会直接以数据形式出现,而运行期版本会有一段初始化循环)比测时间靠谱得多。
9. 延伸阅读
- constexpr 说明符 — cppreference:页面顶部那张「各标准版本允许在
constexpr里做什么」的清单(C++11 单表达式 → C++14 循环/局部变量 → C++20 容器与虚函数)值得完整读一遍 - 常量表达式 — cppreference:搞清楚「哪些位置必须是常量表达式」这份清单,就能自己判断某处能不能塞
constexpr - static_assert — cppreference:这篇用来证明「表是编译期生成的」的工具,注意 C++17 起消息参数变成可选的
- std::array — cppreference:
operator[]等成员在 C++17 才成为constexpr,跨标准版本时容易踩 - 模板特化 — cppreference:模板元编程递归的终止条件靠的就是全特化,这篇第 4 节的
Factorial<0>就是最小例子 - C++ Core Guidelines — Per.4 / Per.5:决定「这个
constexpr到底值不值得写」时的判断依据 - Compiler Explorer:验证常量折叠和「表被放进了只读段」的唯一可靠方式 —— 看汇编,别靠猜
本知识库内的相关篇目:
- 《C++ 模板实例化膨胀:为什么编译慢、二进制大,怎么治》 —— 模板每遇到一个不同的实参组合就生成一份独立机器码
- 《虚函数调用的真实开销:vptr、vtable 与间接跳转拆开算》 —— 把一次虚函数调用拆成「取 vptr → 查 vtable → 间接跳转」三步
- 《constexpr 能力扩张:从 C++11 到 C++20 的编译期计算》 —— constexpr 从 C++11 只能写单 return顺带把新特性与迁移这条线也铺一下。
10. 一句话总结
编译期计算是用编译时间和二进制体积去换运行期的计算时间、内存分配和初始化开销;constexpr的价值不在「让编译器会算」(优化器本来就会常量折叠),而在保证编译期求值、以及让结果能站到数组长度、static_assert、switch标签、模板实参这些位置;查表法(质数表、CRC 表)是它最实在的落地场景,而static_assert就是「表确实在编译期生成」的证明;结果只是「值」时优先constexpr函数而不是模板递归,C++20 起连std::vector和算法都能进constexpr。