Skip to content

consteval 与 constinit:编译期保证的新工具

引言

前两章我们一直在讨论 constexpr——这个"可能"在编译期求值的关键字。"可能"这两个字既是它的优势,也是它的软肋。当你声明一个 constexpr 函数时,你表达了"这个函数可以在编译期求值"的意图,但编译器并不保证它一定会这么做。

需要注意的是,现代编译器(开启优化时)相当智能——即使你把返回值赋给了一个非 constexpr 变量,只要参数是常量且函数调用足够简单,编译器仍然可能在编译期求值。但在某些复杂场景下,或者编译器优化被禁用时(如 -O0),constexpr 函数确实可能退化为运行时调用。这种不确定性正是 consteval 要解决的问题。

这种"弹性"在大多数时候是好事,但有些场景下你确实需要硬性保证:这个函数必须、一定、绝对要在编译期执行完。比如编译期哈希、编译期配置校验——如果这些东西退化为运行时计算,你可能在代码审查时不会注意到这个问题,而只有在性能分析或运行错误时才发现。consteval 通过编译期强制检查,让这类问题在编译阶段就被暴露出来。

C++20 引入了两个新关键字来解决这个问题:consteval 声明的函数(称为"立即函数")必须在编译期求值,而 constinit 则保证静态变量在编译期完成初始化。它们不是 constexpr 的替代品,而是精细化的补充工具。

第一步——consteval:强制编译期求值

consteval 与 constexpr 的核心区别

consteval 声明的函数叫做"立即函数"(immediate function)。它的语义非常直接:任何对这个函数的调用都必须产生一个编译期常量。如果编译器发现某个调用上下文无法在编译期完成求值,直接报错。

cpp
consteval int square(int x)
{
    return x * x;
}

// OK:参数是常量,上下文是 constexpr 变量初始化
constexpr int kResult = square(8);  // 编译通过,kResult == 64

// OK:参数是常量字面量
int arr[square(5)];  // OK,square(5) == 25,数组大小

// 错误!参数来自运行时
int runtime_val = 42;
// int bad = square(runtime_val);  // 编译错误:不是常量表达式

对比一下 constexpr 版本:

cpp
constexpr int square_maybe(int x)
{
    return x * x;
}

int runtime_val = 42;
int ok = square_maybe(runtime_val);  // OK!退化为运行时调用

区别一目了然:constexpr 函数面对运行时参数会"妥协",自动退化为运行时执行;consteval 函数面对运行时参数会"拒绝",直接导致编译失败。你可以把 consteval 理解为"加上了编译期强制担保的 constexpr"。

consteval 的适用场景

consteval 最适合的场景是那些"在运行时执行没有任何意义甚至会引入风险"的计算。

第一个典型场景是编译期 ID 和哈希生成。在协议处理、命令分派中,经常需要把字符串映射为整数 ID。如果字符串到 ID 的哈希计算在运行时执行,既浪费了 CPU 又丧失了编译期冲突检测的能力。

cpp
#include <cstdint>
#include <cstddef>

consteval std::uint32_t fnv1a32(const char* str, std::size_t len)
{
    std::uint32_t hash = 0x811c9dc5u;
    for (std::size_t i = 0; i < len; ++i) {
        hash ^= static_cast<std::uint8_t>(str[i]);
        hash *= 0x01000193u;
    }
    return hash;
}

template <std::size_t N>
consteval std::uint32_t command_id(const char (&s)[N])
{
    return fnv1a32(s, N - 1);
}

// 所有 ID 都在编译期生成,没有任何运行时开销
constexpr auto kIdStart = command_id("START");
constexpr auto kIdStop  = command_id("STOP");
constexpr auto kIdReset = command_id("RESET");

// 编译期验证:确保没有哈希冲突
static_assert(kIdStart != kIdStop);
static_assert(kIdStart != kIdReset);
static_assert(kIdStop != kIdReset);

第二个典型场景是编译期配置校验和约束检查。当你需要确保某个配置值满足特定约束时,用 consteval 可以强制在编译期完成校验,杜绝运行时才发现配置错误的可能性。

cpp
consteval int validate_buffer_size(int size)
{
    // 如果约束不满足,直接编译错误
    return size > 0 && size <= 4096 && (size & (size - 1)) == 0
        ? size
        : throw "Buffer size must be a power of 2 between 1 and 4096";
    // 在 consteval 上下文中,throw 会导致编译错误
}

constexpr int kBufferSize = validate_buffer_size(1024);  // OK
// constexpr int kBadSize = validate_buffer_size(1000);  // 编译错误!不是 2 的幂

第三个场景是编译期类型标签和元数据。当你需要在类型系统中嵌入编译期信息(比如外设描述、协议字段定义)时,consteval 可以确保这些元数据不会意外地变成运行时对象。

cpp
struct PeripheralTag {
    const char* name;
    std::uint32_t base_address;
    std::uint32_t clock_mask;

    consteval PeripheralTag(const char* n, std::uint32_t addr, std::uint32_t clk)
        : name(n), base_address(addr), clock_mask(clk) {}
};

consteval PeripheralTag make_usart1_tag()
{
    return PeripheralTag{"USART1", 0x40013800, 0x00004000};
}

constexpr auto kUsart1Tag = make_usart1_tag();
static_assert(kUsart1Tag.base_address == 0x40013800);

consteval 的传播规则

consteval 有一个需要特别注意的传播行为:如果一个 consteval 函数在另一个函数中被调用,那个外层函数也必须是 consteval 的(或者调用本身处于常量求值上下文中)。

cpp
consteval int forced_compile_time(int x) { return x * x; }

// 错误!constexpr 函数中调用 consteval 函数,
// 但该调用的结果不是常量表达式
constexpr int wrapper(int x)
{
    // return forced_compile_time(x);  // 编译错误
    return x * x;  // 需要自己实现逻辑
}

// OK:consteval 函数中可以调用 consteval 函数
consteval int double_square(int x)
{
    return forced_compile_time(x) * 2;
}

constexpr auto kVal = double_square(3);  // OK,kVal == 18

C++23(DR20,P2564R3)进一步调整了传播规则:如果一个 consteval 函数在 constexpr 函数中被调用,只要该 constexpr 函数的调用最终处于常量求值上下文中,就不再报错。这让 constevalconstexpr 的组合使用更加灵活。

if consteval:编译期/运行期分派

C++23 引入了 if consteval(也叫 if !consteval),允许函数根据当前是否处于常量求值上下文来选择不同的代码路径。

cpp
#include <cstdio>
#include <cstddef>

constexpr std::size_t compute_hash(const char* str, std::size_t len)
{
    if consteval {
        // 编译期路径:使用纯 constexpr 的算法
        std::size_t hash = 0xcbf29ce484222325ull;
        for (std::size_t i = 0; i < len; ++i) {
            hash ^= static_cast<std::size_t>(str[i]);
            hash *= 0x100000001b3ull;
        }
        return hash;
    } else {
        // 运行时路径:可以使用其他实现策略
        std::size_t hash = 0xcbf29ce484222325ull;
        for (std::size_t i = 0; i < len; ++i) {
            hash ^= static_cast<std::size_t>(str[i]);
            hash *= 0x100000001b3ull;
        }
        // 运行时路径中,如果编译器支持内联 SIMD 指令,
        // 可能会自动向量化这段循环;也可以显式调用 SIMD 库
        return hash;
    }
}

constexpr auto kCompileTimeHash = compute_hash("test", 4);  // 走编译期路径

if constevalif constexpr 是不同的东西。if constexpr 根据模板参数在编译期选择分支,而 if consteval 根据当前是否处于常量求值上下文来选择。后者更适合在同一个函数中为编译期和运行时提供不同的实现策略。

第二步——constinit:解决静态初始化问题

静态初始化顺序灾难

在讨论 constinit 之前,我们需要先理解它要解决的问题。C++ 中,具有静态存储期的对象(全局变量、static 类成员变量等)的初始化分两个阶段:

第一阶段是静态初始化(static initialization),包括零初始化和常量初始化(constant initialization)。这些发生在程序加载阶段,甚至在 main 函数开始之前,它们的顺序是确定的——零初始化先于常量初始化。

第二阶段是动态初始化(dynamic initialization),需要运行时代码的参与。问题在于,不同翻译单元之间的动态初始化顺序是未定义的。如果你有两个文件 a.cppb.cpp,各自有一个全局对象,且 a.cpp 中的对象初始化依赖于 b.cpp 中对象的值,那么你就可能遇到"静态初始化顺序灾难"(Static Initialization Order Fiasco,简称 SIOF)。

cpp
// a.cpp
#include <vector>
std::vector<int> g_data{1, 2, 3};  // 动态初始化:调用 vector 的构造函数

// b.cpp
extern std::vector<int> g_data;
int g_first_element = g_data[0];  // 可能读到未初始化的 g_data!

这种 bug 的可怕之处在于它"看运气"——在某些链接顺序下正常,换个链接顺序就炸了,而且只在程序启动时出问题,调试极其困难。

constinit 的语义

constinit 的语义简洁有力:它应用于具有静态或线程存储期的变量声明,断言该变量必须进行常量初始化。如果编译器发现这个变量需要动态初始化,直接编译报错。

cpp
#include <array>

// OK:std::array 的聚合初始化是常量初始化
constinit std::array<int, 4> g_table = {1, 2, 3, 4};

// OK:用 constexpr 函数的返回值初始化
constexpr int compute_value() { return 42; }
constinit int g_value = compute_value();

// 错误!get_runtime_value 不是常量表达式,需要动态初始化
// int get_runtime_value();
// constinit int g_bad = get_runtime_value();  // 编译错误

constinit vs constexpr:微妙但关键的差异

constinitconstexpr 都涉及编译期,但它们关注的维度不同。constexpr 变量要求值在编译期确定且对象本身是 const 的——你不能修改它。constinit 变量也要求初始值在编译期确定,但对象本身可以修改。

cpp
constexpr int kConstVal = 42;        // 编译期值 + 不可修改
// kConstVal = 100;                  // 错误!constexpr 变量是 const 的

constinit int gMutableVal = 42;      // 编译期初始化 + 可修改
gMutableVal = 100;                   // OK!运行时可以改值

这个区别看似不大,但在实际工程中非常有用。比如一个全局的配置缓冲区,你希望它的初始值在编译期就定好(避免 SIOF),但程序运行过程中需要更新它的内容。constinit 正好满足这个需求。

值得注意的是,constinit 不能和 constexpr 同时使用——它们是互斥的。constexpr 变量隐含了常量初始化的保证(以及 const 语义),所以再加 constinit 是多余的。

constinit 与 thread_local

constinit 有一个非常实用的附带效果:当它应用于 thread_local 变量时,可以消除运行时的线程安全检查开销。

cpp
// 没有 constinit:每次访问都需要检查线程局部存储是否已初始化
thread_local int tl_counter = 42;

// 有 constinit:编译器知道初始化在加载时就完成了,
// 不需要运行时守卫变量(guard variable)
constinit thread_local int tl_fast_counter = 42;

普通的 thread_local 变量在首次访问时需要检查是否已经初始化,这通常涉及一个隐藏的守卫变量(guard variable)和可能的原子操作。加上 constinit 后,编译器知道这个变量在程序加载时就已经有了确定的初始值,理论上可以优化掉运行时检查。不过实际性能提升取决于具体编译器实现——在 GCC 15.2 上测试(-O2),优化幅度有限(约 5%),但在某些编译器或场景下可能会有更明显的改善。

extern 声明中的 constinit

constinit 可以用在非初始化声明(比如 extern 声明)中,用来告诉编译器"这个变量已经在别处用 constinit 声明了,它不需要运行时初始化检查"。

cpp
// header.h
extern constinit int g_shared_value;  // 告诉使用者:这是常量初始化的

// source.cpp
#include "header.h"
constinit int g_shared_value = 100;   // 实际定义

这在大型项目中特别有用——头文件中的 extern constinit 声明就是一种"编译期文档",告诉使用者这个全局变量的初始化行为是确定的。

第三步——三关键字对比与选择策略

理解了三个关键字的语义之后,我们现在来做一个清晰的对比。

特性constexprconstevalconstinit
适用对象变量、函数函数、构造函数静态/线程存储期变量
编译期保证"可以"在编译期求值"必须"在编译期求值初始化必须是常量初始化
运行时行为可退化为运行时调用不允许运行时调用变量可在运行时修改
可变性不可修改(隐式 constN/A可修改
解决的问题编译期计算的灵活性强制编译期求值避免 SIOF

选择策略用一句话概括:如果值永远不会变,用 constexpr 变量;如果函数必须在编译期执行,用 consteval;如果全局变量需要在编译期初始化但运行时可修改,用 constinit。对于函数,默认用 constexpr(它最灵活),只在确实需要强制编译期求值时才升级为 consteval

常见组合模式

在实际项目中,这三个关键字经常组合使用。

模式一是 consteval 函数生成 constexpr 值。consteval 函数的调用结果天然是常量表达式,所以可以用 constexpr 变量来接收。

cpp
consteval std::uint32_t hash_string(const char* s)
{
    std::uint32_t h = 0x811c9dc5u;
    while (*s) {
        h ^= static_cast<std::uint8_t>(*s++);
        h *= 0x01000193u;
    }
    return h;
}

constexpr auto kHashStart = hash_string("START");  // 编译期强制求值
constexpr auto kHashStop  = hash_string("STOP");

模式二是 constexpr 函数配合 constinit 全局状态。函数本身不强制编译期求值,但当它用于初始化 constinit 变量时,编译器会强制在编译期执行它。

cpp
constexpr int lookup_value(int index)
{
    constexpr int kTable[] = {10, 20, 30, 40, 50};
    return index >= 0 && index < 5 ? kTable[index] : 0;
}

constinit int g_first = lookup_value(0);   // 编译期求值
constinit int g_third = lookup_value(2);   // 编译期求值

模式三是 consteval 用于编译期校验。在校验逻辑上使用 consteval 确保它在编译期执行,配合 throw 来产生编译错误。

cpp
consteval bool check_config(int baud_rate, int data_bits)
{
    if (baud_rate <= 0 || baud_rate > 4000000) return false;
    if (data_bits < 5 || data_bits > 9) return false;
    return true;
}

// 用 static_assert + consteval 函数做编译期配置校验
static_assert(check_config(115200, 8), "Invalid UART config");
// static_assert(check_config(0, 8));  // 编译错误:校验不通过

常见陷阱

consteval 函数的地址不能在运行时使用

你不能在运行时获取 consteval 函数的函数指针并调用它。consteval 函数的地址可以在编译期使用(比如在 consteval 上下文中传递),但它不能"逃逸"到运行时。如果在非常量求值上下文中尝试获取 consteval 函数的地址,会导致编译错误。这是因为 consteval 函数没有运行时实体——它们在编译期就被完全展开并内联了。

constinit 不意味着 const

这一点容易搞混。constinit 只是说初始化是常量初始化,对象本身不一定是 const 的。如果你需要一个既在编译期初始化又不可修改的全局变量,应该用 constexpr(而不是 constinit const,虽然后者也能工作)。

consteval 与模板的交互

consteval 可以用于函数模板,但要注意:如果模板实例化后不能满足 consteval 的要求(比如内部调用了非 constexpr 的函数),编译器会报错。这与 constexpr 函数模板不同——constexpr 模板只需要至少有一组参数能在编译期工作就行,而 consteval 要求所有调用都必须在编译期完成。

小结

C++20 的 constevalconstinit 是对 constexpr 体系的精准补充。consteval 填补了"我想强制编译期求值"这个需求空白,而 constinit 解决了 C++ 长期以来的静态初始化顺序问题。三者各有分工:constexpr 提供灵活性,consteval 提供强制性,constinit 提供初始化安全。理解它们之间的精确差异并合理选择,是写出高质量编译期计算代码的关键。

下一章我们将进入实战,综合运用这些知识来实现编译期查表、字符串处理和状态机设计。

参考资源

基于 VitePress 构建