constexpr、 consteval、 constinit
下面分三部分:constexpr 的作用、常见面试题、常见坑。代码没有实测,编译器限制的数值来自 GCC 文档,属于实现细节。
一、constexpr 究竟有什么用
它让一段计算可以在编译期完成,并且让计算结果能用在"必须是常量"的地方。 具体有四类用处:
| 用处 | 例子 |
|---|---|
| 1. 在要求常量表达式的地方使用计算结果 | 数组大小、模板实参、case 标签、static_assert、alignas、枚举值 |
| 2. 把运行时的开销挪到编译期 | 查找表、CRC 表、字符串哈希、正则或格式串的预处理(std::format 在编译期检查格式串,靠的就是这个) |
| 3. 编译期捕获 UB | 常量求值过程中如果遇到未定义行为(有符号溢出、数组越界、解引用空指针),编译器必须报错 |
| 4. 静态初始化安全 | constexpr 变量属于常量初始化,不参与动态初始化,因此不会遇到"静态初始化顺序"问题 |
constexpr std::array<uint32_t, 256>
constexpr auto crc_table = ; // 编译期算好,直接放进 .rodata
constexpr int
static_assert;
// static_assert(square(46341) > 0); // 编译错误:常量求值中发生有符号溢出
最后这个例子可以当测试手段用:对 constexpr 函数写 static_assert,编译器就会替你检查这组输入会不会触发 UB。
二、常见面试题
1. const 和 constexpr 的区别
const |
constexpr |
|
|---|---|---|
| 含义 | 只读 | 编译期常量 |
| 初始化 | 可以在运行时:const int n = rand(); |
必须用常量表达式初始化 |
| 能否当数组大小或模板实参 | 只有用常量初始化的整型 const 可以(历史遗留规则),const double 不行 |
可以 |
| 修饰函数 | 成员函数后面的 const 表示不修改对象 |
表示这个函数可以在编译期求值 |
2. constexpr 函数一定在编译期求值吗
不一定。 constexpr 函数的含义是"允许在编译期求值",不是"必须在编译期求值"。只有在必须得到常量的上下文里才保证编译期求值:
constexpr int
constexpr int a = ; // 保证编译期求值
int b = ; // 不保证:可能在运行时算,只是优化器通常会把它折叠成常量
int c = ; // 运行时求值,这完全合法
std::array<int, f> arr; // 保证编译期求值
要强制在编译期求值,可以把结果赋给 constexpr 变量,或者把函数声明成 consteval。
3. constexpr、consteval、constinit、if constexpr 各是什么
| 关键字 | 版本 | 保证什么 |
|---|---|---|
constexpr 变量 |
C++11 | 编译期初始化,并且隐含 const |
constexpr 函数 |
C++11 | 可以在编译期求值 |
consteval 函数 |
C++20 | 每次调用都必须在编译期求值,传入运行时的参数就是编译错误 |
constinit 变量 |
C++20 | 静态或线程存储期的变量在编译期完成初始化,但变量可以修改,用来解决静态初始化顺序问题 |
if constexpr |
C++17 | 条件在编译期求值;在模板里,不走的分支不会被实例化 |
if consteval |
C++23 | 判断当前是否处于常量求值中,编译期和运行时走不同的实现 |
constinit int counter = 0; // 编译期初始化,运行时可以 ++counter
constexpr int limit = 100; // 编译期初始化,不可修改
4. 各标准版本放宽了什么
| 版本 | constexpr 函数里能写什么 |
|---|---|
| C++11 | 基本只能有一条 return 语句;constexpr 成员函数隐式为 const |
| C++14 | 可以有局部变量、循环、多条语句;成员函数不再隐式 const |
| C++17 | lambda 满足条件时自动成为 constexpr;有了 if constexpr;constexpr 静态数据成员隐式 inline |
| C++20 | 虚函数、try/catch(throw 不能真正被求值)、编译期 new/delete、std::vector/std::string 可以在 constexpr 中使用;新增 consteval、constinit、std::is_constant_evaluated() |
| C++23 | if consteval;函数里可以出现非字面类型的变量和 goto,只要常量求值时不经过它们;constexpr 的 std::unique_ptr |
| C++26 | 常量求值中可以抛出和捕获异常、constexpr 的 placement new(我的记忆,可能随最终版本变化) |
5. constexpr 指针修饰的是谁
int g = 0;
constexpr int* p = &g; // p 本身是常量,等价于 int* const,*p 可以改
constexpr const int* q = &g; // 指向 const 的常量指针
// int x; constexpr int* r = &x; // 局部变量 x 的地址不是常量,报错
constexpr 只修饰顶层。取地址时,对象必须有静态存储期。
6. C++20 的编译期 new 有什么限制
分配是暂时的:在同一次常量求值里 new 出来的内存,必须在这次求值结束前 delete 掉,不能留到运行时。
constexpr int
static_assert;
// constexpr std::vector<int> v{1, 2, 3}; // 编译错误:分配的内存会留到运行时
想得到"编译期算好的动态数组",惯用法是在 constexpr 函数里用 vector 计算,最后把结果拷进 std::array 返回。
三、常见坑
坑 1:以为调用 constexpr 函数就一定在编译期算
auto x = ; // 可能在运行时算
constexpr auto y = ; // 这才保证在编译期
坑 2:函数内的 constexpr 局部数组,每次调用都可能在栈上重建
int
改成 static constexpr int table[],这样它只存在一份,放在 .rodata 里。同理,返回这个非 static 数组的指针或引用会悬空。
坑 3:C++17 之前,constexpr 静态成员被 ODR 使用会导致链接失败
;
int m = ; // std::max 按 const& 接收参数,这就是 ODR 使用
// C++14:undefined reference to `S::N',需要在 .cpp 里补一行 constexpr int S::N;
// C++17:constexpr 静态成员隐式 inline,这个问题消失
坑 4:头文件里命名空间作用域的 constexpr 变量,每个编译单元各有一份
const 和 constexpr 修饰的命名空间变量默认是内部链接。每个编译单元各有一份副本,取到的地址不同,大对象还会让代码膨胀。C++17 起在头文件里写 inline constexpr。
坑 5:if constexpr (std::is_constant_evaluated()) 永远为真
constexpr int
坑 6:constexpr 函数"悄悄地"不再是常量
- 模板实例化后,如果某个实例无法常量求值(比如调用了非 constexpr 函数),编译器不会报错,只是这个实例在编译期不可用。直到你在常量上下文里用它,才会报错。
- C++23 以前,一个非模板的 constexpr 函数如果任何输入都不可能常量求值,程序是 ill-formed, no diagnostic required(不要求编译器报错)。
所以写 constexpr 函数时,最好配一句 static_assert 真正在编译期调用一次,证明它确实能在编译期求值。
坑 7:编译期和运行时算出的浮点结果可能不同
编译期按标准语义计算。运行时的结果可能受 -ffast-math、x87 扩展精度、舍入模式的影响。<cmath> 函数直到 C++23/26 才部分变成 constexpr;在那之前,GCC 把很多数学内建函数当作 constexpr 处理,这是 GCC 的扩展,换编译器可能编译不过。
坑 8:只有常量求值才能抓到 UB
同一个函数,在编译期求值时遇到 UB 会报错,在运行时求值时就是悄无声息的 UB。常量求值只检查语言本身的 UB,库层面的前置条件(比如对空 vector 调用 front())不一定能被检查出来。
坑 9:编译期计算有上限,还会拖慢编译
GCC 默认的限制:
| 选项 | 限制 | 默认值 |
|---|---|---|
-fconstexpr-depth |
递归深度 | 512 |
-fconstexpr-loop-limit |
单个循环的迭代次数 | 262144 |
-fconstexpr-ops-limit |
总操作数 | 2³³ |
超过限制就是编译错误。在编译期生成大表会显著增加编译时间。
坑 10:constexpr std::string 这类全局变量能否编译取决于实现
短字符串走 SSO(小字符串优化)时不需要分配内存,在某些标准库实现上,constexpr std::string s = "hi"; 可能碰巧能编译;长字符串一定不能编译。不要依赖这种行为,编译期的字符串常量用 std::string_view 或者字符数组。
四、面试速答
- constexpr 有什么用? 让计算在编译期完成,结果能用在需要常量的地方,同时在编译期把 UB 检查出来。
- constexpr 函数一定在编译期执行吗? 不一定,只有在常量上下文中才保证;要强制就用
consteval,或者赋给constexpr变量。 - const 和 constexpr 的区别? const 只保证只读,初始化可以发生在运行时;constexpr 要求编译期常量。
- constinit 是做什么的? 保证静态变量在编译期初始化,但它仍然可以修改,用来解决静态初始化顺序问题。
- 有什么性能坑? 函数内的 constexpr 大数组要加
static;头文件里的 constexpr 变量要写inline constexpr。
1. constexpr/consteval 为什么能提高性能,常见吗
性能从哪里来
归根到底只有一件事:把计算从运行时挪到编译期,结果直接写进二进制文件。 由此带来四个具体收益:
| 收益 | 机制 |
|---|---|
| 运行时不再计算 | 结果成为指令里的立即数,或者放进 .rodata 的一张现成的表 |
| 启动时不执行初始化代码 | 全局变量属于常量初始化,初始值已经在 .data/.rodata 里,不产生构造代码,不影响启动时间 |
| 访问时不需要检查守卫 | 函数内的 static 变量如果是常量初始化,就不需要线程安全的初始化守卫(__cxa_guard_acquire),每次访问少一次检查 |
| 给优化器更多信息 | 编译器知道确切的值,可以进一步优化,比如除以常量变成乘法加移位、删掉走不到的分支 |
.rodata 里的数据是只读页,多个进程可以共享同一份物理内存,也不会触发写时复制。在嵌入式系统上,这意味着表放在 Flash 里,不占 RAM。
常见吗?比想象中少
- 简单的计算,不写 constexpr,
-O2也会做常量折叠。 比如int x = 3 * 4;,加不加 constexpr 生成的代码一样。constexpr 的价值在于保证编译期求值,并且能处理优化器不会展开的复杂计算,比如带循环的建表、解析字符串。 - 大多数业务代码里,写 constexpr 是为了"能用在常量表达式里"(数组大小、模板参数、
static_assert),而不是为了性能。 - 查表不一定比直接算快。 一张几百 KB 的表会挤占缓存,缓存未命中的代价可能超过直接计算。
所以,性能收益主要集中在库作者这一侧:库在编译期做完重活,使用者在运行时白得好处。
consteval 额外带来什么
- 保证不会悄悄退化到运行时。 比如你想让一个字符串字面量的哈希在编译期算好,用 constexpr 时,只要调用处不是常量上下文,它就可能在运行时计算,而且没有任何提示。consteval 在这种情况下直接编译报错。
- 可以做编译期校验。 在 consteval 函数里执行到
throw(或者任何不能常量求值的操作),就会变成编译错误。std::format在编译期检查格式串,就是这个原理。 - 二进制里不生成这个函数的代码,因为它不可能在运行时被调用。
哪些场景、哪些库在用
| 场景 | 库或例子 | 做了什么 |
|---|---|---|
| 格式串检查 | std::format(C++20)、{fmt} |
std::format_string 的构造函数是 consteval 的,格式串写错直接编译报错。{fmt} 的 FMT_COMPILE 还会把格式串在编译期展开成专用代码,运行时不再解析 |
| 正则表达式 | CTRE(compile-time regular expressions,Hana Dusíková) | 正则在编译期解析成状态机,性能比 std::regex 高出一个数量级以上 |
| 编译期哈希表 | frozen(frozen::unordered_map) |
在编译期构造完美哈希,运行时查找不分配内存 |
| 字符串 switch、字符串 ID | 游戏引擎里的 hashed string ID | 在编译期对字符串做 FNV 之类的哈希,运行时只比较整数 |
| 查找表 | CRC、三角函数表、UTF-8 解码表、协议解析表 | 表在编译期生成,放进 .rodata |
| 枚举反射 | magic_enum、nameof | 在编译期解析 __PRETTY_FUNCTION__ 得到枚举名,实现枚举转字符串 |
| 物理单位 | mp-units | 单位换算在编译期完成,运行时零开销 |
| 低延迟日志 | 一些高性能日志库 | 在编译期提取格式串的元信息,运行时只拷贝参数 |
| 标准库本身 | std::source_location::current()、chrono 字面量、std::string_view |
source_location::current() 是 consteval 的 |
| 静态反射(C++26) | P2996 | 整套设计建立在 consteval 之上 |
| 嵌入式 | 寄存器配置、外设参数计算 | 数据放在 Flash,不占 RAM,也不需要初始化代码 |
2. constinit 如何解决静态初始化顺序问题
先弄清问题从哪来
静态存储期变量(全局变量、命名空间变量、类的静态成员)的初始化分两个阶段:
| 阶段 | 内容 | 什么时候发生 | 顺序 |
|---|---|---|---|
| 静态初始化 | 零初始化,加上常量初始化 | 编译期就算好,初始值直接写在 .data/.bss 里,加载程序时就已经就位 |
不存在顺序问题 |
| 动态初始化 | 运行构造函数或其他代码 | 启动时,在 main 之前 |
同一个编译单元内按定义顺序;不同编译单元之间顺序不确定 |
静态初始化顺序问题:a.cpp 里某个变量的动态初始化用到了 b.cpp 里的变量,而后者还没来得及做动态初始化。这时读到的只是零初始化的值。
// b.cpp
int
int g_b = ; // 动态初始化:启动时才执行 compute()
// a.cpp
extern int g_b;
int g_a = g_b + 1; // 如果 a.cpp 先初始化,g_b 还是 0,g_a 就成了 1 而不是 43
constinit 做了什么
constinit 要求变量必须是常量初始化,做不到就编译报错:
// b.h
constexpr int
extern constinit int g_b; // 告诉所有使用者:这个变量是常量初始化的
// b.cpp
constinit int g_b = ; // 42 在编译期算好,写进 .data
// a.cpp
int g_a = g_b + 1; // 不管哪个编译单元先初始化,g_b 在加载时就已经是 42,g_a 一定是 43
需要准确理解三点:
- constinit 没有规定动态初始化的顺序,而是把这个变量从动态初始化里整个拿了出来。 它的值在加载阶段就已经就位,比任何动态初始化都早,所以不管别人按什么顺序初始化,读到的都是正确的值。它保护的是被依赖的一方。
- 它的本质是一个断言。 只要初始化表达式是常量,C++ 本来就会做常量初始化。问题在于没有任何保证:有人把
compute()改成非 constexpr,或者往构造函数里加了一句运行时逻辑,这个变量就会悄悄变成动态初始化,顺序问题随之重现,而编译器一声不吭。加了constinit以后,这种修改会直接导致编译失败。 - 它和 constexpr 的区别在于变量仍然可以修改。 constexpr 隐含
const,而全局计数器、全局std::mutex、全局注册表指针都需要在运行时修改,这类变量只能用 constinit:
constinit std::mutex g_mu; // std::mutex 有 constexpr 构造函数
constinit int g_counter = 0; // 编译期初始化,运行时可以 ++
constinit thread_local int t_depth = 0;
额外的性能收益:thread_local
跨编译单元访问一个带动态初始化的 thread_local 变量时,每次访问都要先调用一个包装函数,检查"这个线程里它初始化过没有"。在头文件里声明 extern constinit thread_local int t_depth; 之后,编译器知道它不需要动态初始化,就会直接访问 TLS,省掉这次调用。
局限
- 只适用于能常量初始化的类型:需要有 constexpr 构造函数,并且初始值是常量表达式。
std::map、存放长字符串的std::string、需要读配置文件的对象都做不到。 - 不解决析构顺序问题:带有非平凡析构函数的全局对象,在
exit时仍按逆序析构。别的析构函数如果在它析构之后还去用它,照样出错。
和其他方案对比
| 方案 | 做法 | 代价 |
|---|---|---|
constinit(C++20) |
编译期初始化,编译器强制检查 | 零开销;只适用于能常量初始化的类型 |
| 首次使用时构造(Meyers 单例) | T& get() { static T t; return t; } |
每次调用检查一次守卫;仍有析构顺序问题 |
| Nifty Counter(Schwarz 计数器) | 每个包含头文件的编译单元里放一个计数对象,第一个到的负责初始化 | 实现复杂;std::cout 就是这样初始化的(ios_base::Init) |
| 不用全局变量 | 显式创建对象,通过参数传递 | 需要改设计 |
排查工具:Clang 的 -Wglobal-constructors 能找出所有带动态初始化的全局变量;也可以用 readelf -S 看 .init_array 段里有多少项。
以上内容来自标准规则和实现知识,这次都没有实测。如果需要,可以在 dev 上写两个编译单元,用链接顺序复现初始化顺序问题,再对比加上 constinit 前后生成的 .init_array。
暂无评论,欢迎留下第一条评论。