下面分三部分:constexpr 的作用、常见面试题、常见坑。代码没有实测,编译器限制的数值来自 GCC 文档,属于实现细节。

一、constexpr 究竟有什么用

它让一段计算可以在编译期完成,并且让计算结果能用在"必须是常量"的地方。 具体有四类用处:

用处 例子
1. 在要求常量表达式的地方使用计算结果 数组大小、模板实参、case 标签、static_assertalignas、枚举值
2. 把运行时的开销挪到编译期 查找表、CRC 表、字符串哈希、正则或格式串的预处理(std::format 在编译期检查格式串,靠的就是这个)
3. 编译期捕获 UB 常量求值过程中如果遇到未定义行为(有符号溢出、数组越界、解引用空指针),编译器必须报错
4. 静态初始化安全 constexpr 变量属于常量初始化,不参与动态初始化,因此不会遇到"静态初始化顺序"问题
constexpr std::array<uint32_t, 256> make_crc_table() {
    std::array<uint32_t, 256> t{};
    for (uint32_t i = 0; i < 256; ++i) {
        uint32_t c = i;
        for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1;
        t[i] = c;
    }
    return t;
}
constexpr auto crc_table = make_crc_table();   // 编译期算好,直接放进 .rodata

constexpr int square(int x) { return x * x; }
static_assert(square(46340) == 2147395600);
// static_assert(square(46341) > 0);            // 编译错误:常量求值中发生有符号溢出

最后这个例子可以当测试手段用:对 constexpr 函数写 static_assert,编译器就会替你检查这组输入会不会触发 UB。

二、常见面试题

1. constconstexpr 的区别

const constexpr
含义 只读 编译期常量
初始化 可以在运行时:const int n = rand(); 必须用常量表达式初始化
能否当数组大小或模板实参 只有用常量初始化的整型 const 可以(历史遗留规则),const double 不行 可以
修饰函数 成员函数后面的 const 表示不修改对象 表示这个函数可以在编译期求值

2. constexpr 函数一定在编译期求值吗

不一定。 constexpr 函数的含义是"允许在编译期求值",不是"必须在编译期求值"。只有在必须得到常量的上下文里才保证编译期求值:

constexpr int f(int x) { return x * 2; }

constexpr int a = f(10);   // 保证编译期求值
int b = f(10);             // 不保证:可能在运行时算,只是优化器通常会把它折叠成常量
int c = f(argc);           // 运行时求值,这完全合法
std::array<int, f(3)> 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/catchthrow 不能真正被求值)、编译期 new/deletestd::vector/std::string 可以在 constexpr 中使用;新增 constevalconstinitstd::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 sum() {
    std::vector<int> v{1, 2, 3};   // OK:在编译期分配并释放
    return std::accumulate(v.begin(), v.end(), 0);
}
static_assert(sum() == 6);

// constexpr std::vector<int> v{1, 2, 3};   // 编译错误:分配的内存会留到运行时

想得到"编译期算好的动态数组",惯用法是在 constexpr 函数里用 vector 计算,最后把结果拷进 std::array 返回。

三、常见坑

坑 1:以为调用 constexpr 函数就一定在编译期算

auto x = fib(40);             // 可能在运行时算
constexpr auto y = fib(40);   // 这才保证在编译期

坑 2:函数内的 constexpr 局部数组,每次调用都可能在栈上重建

int lookup(int i) {
    constexpr int table[] = {1, 1, 2, 3, 5, 8, 13, 21 /* ... 上千个元素 */};
    return table[i];     // table 仍然是自动存储期,编译器可能每次都在栈上把它构造一遍
}

改成 static constexpr int table[],这样它只存在一份,放在 .rodata 里。同理,返回这个非 static 数组的指针或引用会悬空。

坑 3:C++17 之前,constexpr 静态成员被 ODR 使用会导致链接失败

struct S { static constexpr int N = 10; };
int m = std::max(S::N, 5);   // std::max 按 const& 接收参数,这就是 ODR 使用
// C++14:undefined reference to `S::N',需要在 .cpp 里补一行 constexpr int S::N;
// C++17:constexpr 静态成员隐式 inline,这个问题消失

坑 4:头文件里命名空间作用域的 constexpr 变量,每个编译单元各有一份

constconstexpr 修饰的命名空间变量默认是内部链接。每个编译单元各有一份副本,取到的地址不同,大对象还会让代码膨胀。C++17 起在头文件里写 inline constexpr

坑 5:if constexpr (std::is_constant_evaluated()) 永远为真

constexpr int f(int x) {
    if constexpr (std::is_constant_evaluated()) { /* 永远走这里 */ }
    // if constexpr 的条件本身就在常量求值中,所以永远是 true
    if (std::is_constant_evaluated()) { /* 正确写法 */ }
    if consteval { /* C++23,更不容易写错 */ }
}

坑 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 额外带来什么

  1. 保证不会悄悄退化到运行时。 比如你想让一个字符串字面量的哈希在编译期算好,用 constexpr 时,只要调用处不是常量上下文,它就可能在运行时计算,而且没有任何提示。consteval 在这种情况下直接编译报错。
  2. 可以做编译期校验。 在 consteval 函数里执行到 throw(或者任何不能常量求值的操作),就会变成编译错误。std::format 在编译期检查格式串,就是这个原理。
  3. 二进制里不生成这个函数的代码,因为它不可能在运行时被调用。

哪些场景、哪些库在用

场景 库或例子 做了什么
格式串检查 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 compute() { return 42; }
int g_b = compute();          // 动态初始化:启动时才执行 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 compute() { return 42; }
extern constinit int g_b;     // 告诉所有使用者:这个变量是常量初始化的

// b.cpp
constinit int g_b = compute();   // 42 在编译期算好,写进 .data

// a.cpp
int g_a = g_b + 1;            // 不管哪个编译单元先初始化,g_b 在加载时就已经是 42,g_a 一定是 43

需要准确理解三点:

  1. constinit 没有规定动态初始化的顺序,而是把这个变量从动态初始化里整个拿了出来。 它的值在加载阶段就已经就位,比任何动态初始化都早,所以不管别人按什么顺序初始化,读到的都是正确的值。它保护的是被依赖的一方
  2. 它的本质是一个断言。 只要初始化表达式是常量,C++ 本来就会做常量初始化。问题在于没有任何保证:有人把 compute() 改成非 constexpr,或者往构造函数里加了一句运行时逻辑,这个变量就会悄悄变成动态初始化,顺序问题随之重现,而编译器一声不吭。加了 constinit 以后,这种修改会直接导致编译失败。
  3. 它和 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