constexpr 是 C++ 里最能体现「版本差异」的关键字:同一个函数,C++11 下连个 for 循环都不能写,C++20 下却能在里面 push_back 到 std::vector、调用 std::sort。这篇按标准版本把这条演进线捋直,重点是搞清楚编译期与运行期的边界到底在哪 :constexpr 的含义是「可以被编译期求值」,不是「必须」。
需要 C++20:这篇标了
// verify: std=c++20的示例必须用-std=c++20编译;标 C++17 的示例是工作环境下的基线写法。
1. 引子:C++11 的 constexpr 连循环都不给
一个求和的 constexpr 函数,在 C++11 下是这样:
cpp
// verify: std=c++11
// 片段(无 main,不进校验):C++11 的 constexpr 函数体只能是一条 return 语句
constexpr int sum_to(int n) {
int s = 0;
for (int i = 1; i <= n; ++i) {
s += i;
}
return s;
}
// gcc-13.2.0 实测报错:
// error: body of 'constexpr' function 'constexpr int sum_to(int)' not a return-statement
int s = 0; 是局部变量,for 是循环。两样在 C++11 的 constexpr 函数里都不许出现,所以只能写成递归:
cpp
// 片段(无 main,不进校验):C++11 能接受的写法 ------ 把循环展开成递归
constexpr int sum_to(int n) {
return n <= 0 ? 0 : n + sum_to(n - 1);
}
递归深度受 constexpr 求值步数限制,写起来也别扭。C++14 把这两条限制一起去掉 ,constexpr 函数体跟普通函数体一样,可以有局部变量、循环、多分支、if:
cpp
// 片段(无 main,不进校验):C++14 起合法,也是现在该写的形态
constexpr int sum_to(int n) {
int s = 0;
for (int i = 1; i <= n; ++i) {
s += i;
}
return s;
}
这一条(P0292 之前就有的 C++14 放宽)是后面所有能力的起点:函数体能写的东西越多,能在编译期算的东西就越多。
2. 演进表:C++11 → C++20
| 标准 | 新增的 constexpr 能力 | 影响的写法 | 特性测试宏 |
|---|---|---|---|
| C++11 | 引入 constexpr 变量与函数;函数体只能是单条 return(外加 using / static_assert) |
constexpr int k = 42;、递归形态的 constexpr 函数 |
__cpp_constexpr = 200704 |
| C++14 | 函数体放宽 :局部变量、循环、多分支、多次 return;constexpr 成员函数不再是隐式 const |
终于能写循环版 constexpr,如 sum_to |
__cpp_constexpr = 201304 |
| C++17 | if constexpr 编译期分支;constexpr lambda (捕获的 lambda 也可 constexpr);constexpr 静态数据成员隐式 inline |
模板里按类型分叉;把 lambda 直接当编译期函数的参数 | __cpp_constexpr = 201603 |
| C++20 | 常量求值里允许 new / delete (即 constexpr std::vector / std::string);大部分 <algorithm> 与 <numeric> 变 constexpr ;constexpr 虚函数与 try;新增 consteval、constinit |
编译期建表、编译期排序、编译期字符串拼接 | __cpp_constexpr = 201907(gcc-13.2.0 实测报 202002)、__cpp_consteval = 201811、__cpp_constinit = 201907 |
三个关键节点值得单独记:
- C++17 的
if constexpr解决的是模板里的「按类型分叉」,被丢弃的分支不参与实例化------它和constexpr变量/函数是两套机制,别混为一谈(《if constexpr:C++17 编译期分支,为什么它能取代 SFINAE》)有完整说明。 - C++20 的
constexpr容器 是这条线上最大的一步:std::vector、std::string能用在常量求值里,意味着编译期可以「先用动态容器倒腾,再输出成静态结果」。 - C++20 的
<algorithm>constexpr 化 让std::sort、std::is_sorted、std::accumulate全都能在编译期跑。下面这段是真跑过的:
cpp
// verify: std=c++20
// 片段(无 main,不进校验):C++20 起 std::sort 可以在编译期执行
#include <algorithm>
#include <array>
#include <numeric>
constexpr std::array<int, 6> make_sorted() {
std::array<int, 6> a{5, 3, 1, 4, 2, 0};
std::sort(a.begin(), a.end()); // C++20:编译期排序
return a;
}
constexpr auto kSorted = make_sorted();
static_assert(kSorted[0] == 0 && kSorted[5] == 5);
static_assert(std::accumulate(kSorted.begin(), kSorted.end(), 0) == 15);
3. 编译期与运行期的边界:constexpr 是「可以」不是「必须」
这是全文最该记住的一句话。constexpr 函数同时是一个普通函数:如果所有实参都是常量表达式,求值可以发生在编译期;否则它就在运行期老老实实执行一遍。两类用法可以共用同一份代码:
text
同一个 constexpr 函数的两条路(以 fib 为例)
constexpr long long fib(int n) { ...循环... }
│
┌────────────────────┴────────────────────┐
│ │
常量表达式上下文 普通运行期调用
· static_assert(fib(10) == 55) · fib(i),i 是运行期变量
· std::array<int, fib(10)> a; · 每次调用都真执行一遍
· constexpr long long k = fib(20);
│ │
▼ ▼
编译期求值(常量折叠) 运行期求值(普通调用)
· 结果直接进目标码 / 只读段 · 指令正常执行
· 运行期 0 开销 · 可以用运行期的数据
· 能当模板参数、数组长度、case 标签 · 不能当模板参数
· 不能用运行期的值当实参 · 不能用 static_assert 校验
cpp
// constexpr_both.cpp --- 编译: g++ -std=c++17 -Wall -O2 constexpr_both.cpp -o cb
#include <array>
#include <cstdio>
constexpr long long fib(int n) { // 加了 constexpr 只是「允许」编译期求值
long long a = 0;
long long b = 1;
for (int i = 0; i < n; ++i) {
const long long next = a + b;
a = b;
b = next;
}
return a;
}
// 用法①:当编译期常量断言
static_assert(fib(10) == 55, "fib(10) 应该是 55");
static_assert(fib(20) == 6765, "fib(20) 应该是 6765");
// 用法②:当数组长度 / 模板参数
constexpr auto kFib15 = fib(15);
const std::array<long long, static_cast<std::size_t>(fib(10))> kArr{};
int main() {
std::printf("编译期 fib(10) = %lld\n", fib(10));
// 用法③:同一个函数,拿运行期的值调用,照样合法
for (int i = 30; i <= 32; ++i) {
std::printf("运行期 fib(%d) = %lld\n", i, fib(i));
}
std::printf("数组长度 = %zu, kFib15 = %lld\n", kArr.size(), kFib15);
}
text
编译期 fib(10) = 55
运行期 fib(30) = 832040
运行期 fib(31) = 1346269
运行期 fib(32) = 2178309
数组长度 = 55, kFib15 = 610
一个 fib 顶三种活:编译期断言、模板参数、运行期计算。这就是 constexpr 与 consteval 的分水岭:前者是「可以」,后者是「必须」。
4. consteval 与 constinit:C++20 的两个新关键字
| 关键字 | 约束的是什么 | 运行期还能用吗 | 变量是否需要 const |
典型用途 |
|---|---|---|---|---|
constexpr |
变量必须是常量表达式初值;函数允许编译期求值 | 函数可以,变量不行 | 变量隐式 const |
通用编译期常量与常量函数 |
consteval(C++20) |
函数必须编译期求值(immediate function) | 不行,一律编译期展开 | 不适用(只修饰函数) | 保证「零运行期开销」的元编程入口 |
constinit(C++20) |
静态存储期变量的初始化必须是静态初始化,禁止运行期动态初始化 | 变量可以随便改 | 不需要,变量仍是可写的 | 消灭「静态初始化顺序问题」(SIOF) |
cpp
// verify: std=c++20
// constexpr_consteval.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_consteval.cpp -o cc
#include <cstdio>
constexpr int sq(int x) { return x * x; } // 允许编译期,也允许运行期
consteval int sq_now(int x) { return x * x; } // 必须编译期
constinit int g_ready = sq(6); // 静态初始化;g_ready 本身仍是可写变量
int main() {
constexpr int a = sq(5); // 编译期
int n = 5;
const int b = sq(n); // 运行期调用同一个函数,合法
g_ready += 1; // constinit 不禁止写 ------ 它只管初始化时机
std::printf("a=%d b=%d\n", a, b);
std::printf("consteval sq_now(7) = %d\n", sq_now(7));
std::printf("constinit g_ready = %d\n", g_ready);
std::printf("__cpp_constexpr = %ldL\n", __cpp_constexpr);
std::printf("__cpp_consteval = %ldL\n", __cpp_consteval);
std::printf("__cpp_constinit = %ldL\n", __cpp_constinit);
}
text
a=25 b=25
consteval sq_now(7) = 49
constinit g_ready = 37
__cpp_constexpr = 202002L
__cpp_consteval = 201811L
__cpp_constinit = 201907L
sq_now 的实参是字面量 7,所以这里合法。一旦把运行期的值喂给 consteval 函数,编译器当场拒绝:
cpp
// 片段(无 main,不进校验):反例,不要这么写 ------ consteval 不接受运行期实参
consteval int sq_now(int x) { return x * x; }
int f(int n) {
return sq_now(n); // ✗ gcc-13.2.0 实测:error: 'n' is not a constant expression
}
constinit 的战场完全不同:它解决的是静态初始化顺序问题 (Static Initialization Order Fiasco)。两个翻译单元里的全局对象,如果初始化互相依赖,谁先谁后是未指定的。把初始化强制成「静态初始化」(编译期就能算出初值),就根本没有顺序问题。注意它和 constexpr 的分工:constexpr 变量既要求常量初值,又自带 const;constinit 只要前者,变量照样可写。
5. C++20 的 constexpr 容器:编译期建一张查找表
C++20 允许在常量求值里 new / delete,于是 std::vector 进了编译期。但有一条硬规则必须记住:
常量求值期间分配的内存,必须在这次求值结束前释放------分配可以「短暂」,不能「逃逸」。
所以这段代码编不过:
cpp
// 片段(无 main,不进校验):反例,不要这么写 ------ 分配跟着返回值逃逸出常量求值
#include <vector>
constexpr std::vector<int> make_table_bad() {
std::vector<int> t{1, 2, 3};
return t; // ✗ 分配活过了这次常量求值
}
constexpr auto kBad = make_table_bad();
// gcc-13.2.0 实测:
// error: 'make_table_bad()' is not a constant expression because it refers to
// a result of 'operator new'
正确的姿势是:函数内部用 std::vector / std::string 尽情倒腾,最后把结果拷进 std::array 返回 。std::array 没有堆分配,才能成为真正的编译期常量对象。
cpp
// verify: std=c++20
// constexpr_table.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_table.cpp -o table
#include <array>
#include <cstdio>
#include <vector>
namespace {
constexpr int kLimit = 1000;
// 纯函数(无副作用)才能在常量求值里用
constexpr bool is_prime(int n) {
if (n < 2) return false;
for (int d = 2; d * d <= n; ++d) {
if (n % d == 0) return false;
}
return true;
}
// 内部用 vector 写埃氏筛(短暂分配,求值结束前销毁),返回 array 让它能当常量
constexpr std::array<bool, kLimit + 1> make_prime_table() {
std::vector<bool> tmp(static_cast<std::size_t>(kLimit) + 1, true);
tmp[0] = false;
tmp[1] = false;
for (int n = 2; n * n <= kLimit; ++n) {
if (!tmp[static_cast<std::size_t>(n)]) continue;
for (int m = n * n; m <= kLimit; m += n) {
tmp[static_cast<std::size_t>(m)] = false;
}
}
std::array<bool, kLimit + 1> out{};
for (int n = 0; n <= kLimit; ++n) {
out[static_cast<std::size_t>(n)] = tmp[static_cast<std::size_t>(n)];
}
return out;
}
constexpr auto kPrimeTable = make_prime_table(); // 编译期算好,运行期只是一张常量表
long long g_trial = 0; // 运行期试除计数
bool is_prime_rt(int n) { // 有副作用 → 只能运行期用
if (n < 2) return false;
for (int d = 2; d * d <= n; ++d) {
++g_trial;
if (n % d == 0) return false;
}
return true;
}
} // namespace
int main() {
static_assert(kPrimeTable[997], "997 是素数");
static_assert(!kPrimeTable[1000], "1000 不是素数");
// 方案 A:查编译期的表 ------ 运行期一次除法都不做
g_trial = 0;
int hitsA = 0;
for (int n = 2; n <= kLimit; ++n) {
if (kPrimeTable[static_cast<std::size_t>(n)]) ++hitsA;
}
const long long trialA = g_trial;
// 方案 B:每个数现算 ------ 每次都要试除
g_trial = 0;
int hitsB = 0;
for (int n = 2; n <= kLimit; ++n) {
if (is_prime_rt(n)) ++hitsB;
}
const long long trialB = g_trial;
std::printf("表大小 = %zu 字节(常量对象,可直接放进只读段)\n", sizeof(kPrimeTable));
std::printf("方案 A 查表 : 命中 %d 个素数, 运行期试除 %lld 次\n", hitsA, trialA);
std::printf("方案 B 现算 : 命中 %d 个素数, 运行期试除 %lld 次\n", hitsB, trialB);
}
text
表大小 = 1001 字节(常量对象,可直接放进只读段)
方案 A 查表 : 命中 168 个素数, 运行期试除 0 次
方案 B 现算 : 命中 168 个素数, 运行期试除 5288 次
两边命中数都是 168(π(1000) = 168,正确性由 static_assert 兜底),但方案 A 运行期一次试除都没做,方案 B 做了 5288 次整数除法。这就是「编译期算好」的收益量化:整数除法在 x86 上是几十个周期的指令,5288 次足够拉出肉眼可见的差距;而表的制造成本被彻底移到了编译期。
数字来自 gcc-13.2.0
-O2的实测输出;换编译器/优化级别,试除次数不变(它只取决于算法),耗时变化另论。
6. 收益与代价:什么时候值得往编译期搬
编译期计算不是免费的午餐,它把开销从运行期搬到了编译期。而编译期的成本由写代码的人承担。务实地说:
| 维度 | 编译期算(constexpr / consteval) |
运行期算 |
|---|---|---|
| 运行期开销 | 0(结果已是常量) | 每次执行都要算 |
| 数据位置 | 常量对象,可放只读段(.rodata) |
通常在堆或栈上 |
| 能否当模板参数 / 数组长度 | 能 | 不能 |
| 启动期成本 | 0(常量初始化,不参与动态初始化) | 有;全局对象还要防 SIOF |
| 编译时间 | 变长(求值步数越多越慢) | 无影响 |
| 调试难度 | 高:编译期求值不能打断点,static_assert 只给一句消息 |
低:正常单步 |
| 代码可读性 | 下降:为了「能常量求值」常要迁就写法(例如必须有纯函数、不能用运行期数据) | 好写 |
| 适用场景 | 真正的热路径、需要编译期常量的场合(表、掩码、类型计算) | 其余全部情况 |
结论很简单:先看这个计算有没有必要进编译期 。判断标准有两条:它的结果是不是「编译期常量」(要当模板参数、数组长度、case 标签,就必须进),或者它是不是在热路径上被反复调用(进编译期等于把它从运行期账本上划掉)。除此之外,把逻辑留在运行期通常更好写、更好调;毕竟 Core Guidelines 的 Per.4 就是「别为了聪明而聪明」。
7. 完整示例:把编译期的表接上运行期的逻辑
cpp
// verify: std=c++20
// constexpr_full.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_full.cpp -o full
#include <algorithm>
#include <array>
#include <cstdio>
#include <numeric>
#include <vector>
namespace {
constexpr int kMaxN = 64;
// ① 同一个函数:编译期当模板参数用,运行期照常调用
constexpr long long square(long long v) { return v * v; }
// ② 编译期建表,返回 std::array ------ 结果是一个真正的常量对象
constexpr std::array<long long, kMaxN + 1> make_squares() {
std::array<long long, kMaxN + 1> out{};
for (int i = 0; i <= kMaxN; ++i) {
out[static_cast<std::size_t>(i)] = square(i);
}
return out;
}
// ③ C++20:常量求值里用 std::vector ------ 分配只要不逃逸就合法
constexpr int count_primes() {
std::vector<int> tmp;
for (int n = 2; n <= kMaxN; ++n) {
bool p = true;
for (int d = 2; d * d <= n; ++d) {
if (n % d == 0) { p = false; break; }
}
if (p) tmp.push_back(n);
}
return static_cast<int>(tmp.size()); // 临时 vector 在求值内销毁
}
constexpr auto kSquares = make_squares();
constexpr int kPrimeCount = count_primes();
constexpr long long kSum = std::accumulate(kSquares.begin(), kSquares.end(), 0LL);
// ④ C++20 起 <algorithm> / <numeric> 也能在编译期跑
static_assert(std::is_sorted(kSquares.begin(), kSquares.end()), "平方表应单调递增");
static_assert(kSquares[64] == 4096, "64^2 = 4096");
static_assert(kSum == 89440, "0..64 的平方和是 89440");
static_assert(kPrimeCount == 18, "1..64 里有 18 个素数");
static_assert(square(9) == 81, "编译期调用同一个 square");
} // namespace
int main() {
std::printf("0..8 的平方: ");
for (int i = 0; i <= 8; ++i) {
std::printf("%lld ", kSquares[static_cast<std::size_t>(i)]);
}
std::printf("...\n");
std::printf("平方和 = %lld(static_assert 已校验)\n", kSum);
std::printf("1..%d 的素数个数 = %d(编译期算出)\n", kMaxN, kPrimeCount);
long long side = 7; // 运行期的值
std::printf("编译期 square(9) = %lld,运行期 square(%lld) = %lld\n",
square(9), side, square(side));
}
text
0..8 的平方: 0 1 4 9 16 25 36 49 64 ...
平方和 = 89440(static_assert 已校验)
1..64 的素数个数 = 18(编译期算出)
编译期 square(9) = 81,运行期 square(7) = 49
这个程序把三条线并在一起:square 是「两种用法都行」的 constexpr 函数;make_squares 产出的 kSquares 是编译期常量对象;count_primes 内部用了 std::vector(C++20 的常量求值分配)。整段代码没有裸 new / delete,所有变量声明即初始化,能 constexpr 的都加了 constexpr,输出用 '\n' 不用 std::endl。符合 Core Guidelines 的 Con.1 与 ES.20。
8. 延伸阅读
- constexpr specifier --- cppreference ------ 每个标准版本放宽了什么,这一页按版本列得最清楚,遇到「为什么这行 C++17 能过、C++11 不行」就来查它
- consteval specifier --- cppreference ------ immediate function 的准确定义,含「调用链传染」的规则
- constinit specifier --- cppreference ------ 静态初始化与 SIOF 的关系,读之前建议先看初始化顺序那一节
- P0784R7: More constexpr containers --- open-std.org ------
constexpr std::vector的提案原文,「分配必须短暂」这条规则的动机和边界都在这里 - P0202R3: Add constexpr modifiers to functions in <algorithm> and <utility> --- open-std.org ------
<algorithm>constexpr 化的来源 - C++ Core Guidelines: Per.4 / Per.11 ------ 「别为聪明而聪明」与「能搬到编译期就搬」的原始表述
本知识库内的相关篇目:
- 《Concepts 把模板错误拦在编译期:C++20 约束的四种写法与重载偏序》 ------ C++20 的 Concepts(概念)把「模板参数必须满足什么」从注释和 SFINAE 技巧变成能编译
- 《C++20 核心新特性总览:四大特性 + gcc 13 实测清单》 ------ C++20 是继 C++11 之后最大的一次改动。
- 《if / switch 初始化语句:C++17 把变量作用域关进笼子》 ------ C++17 给 if 和 switch 加了 init-statement
9. 一句话总结
constexpr 的演进就是一条不断拆限制的路:C++11 只能写单条 return,C++14 放开循环与局部变量,C++17 加上 if constexpr 和 constexpr lambda,C++20 让 std::vector / std::string / 大部分 <algorithm> 都能进常量求值,同时补上 consteval(必须编译期)和 constinit(只保证静态初始化)。 用的时候只要抓住一句话:constexpr 函数是「可以 在编译期求值」,不是「必须」------同一份代码既能在 static_assert 里当常量、当模板参数,也能拿运行期的值照常调用。C++20 里用容器建编译期查找表的硬规则是「分配不能逃逸 」:内部可以 push_back,最后必须把结果拷进 std::array 这类无分配的类型才能变成常量。收益是运行期开销归零、数据进只读段、结果能当模板参数;代价是编译时间、调试难度和可读性------只在真正的热路径或必须用编译期常量的场合才往编译期搬。