C++ constexpr 与编译期编程实战:从 constexpr 容器到 consteval

constexpr 把计算从运行期搬到编译期。本文梳理 constexpr、consteval、constinit 的语义分工,用 static_assert 与编译期 FNV/CRC32 哈希演示常量求值,讲解 C++20 constexpr 容器与 if consteval。

在 C++11 之前,编译期计算只有一条路:模板元编程。用递归的类模板、偏特化和 ::value 把计算编码进类型系统,写出的代码晦涩、报错信息冗长、编译内存开销巨大。C++11 引入 constexpr 打开了另一扇门,允许用「看起来像运行时代码」的写法做编译期求值;C++14 放开多语句函数体,C++17 引入 if constexpr 与 constexpr lambda,C++20 让 std::vector 与 std::string 成为字面量类型,C++23 补上 if consteval。这条演进线的终点是:绝大多数元编程需求,都可以退化成普通函数。

一、三种限定符的语义分工

1.1 constexpr、consteval 与 constinit

三者名字相近,职责完全不同,混淆它们是实际项目中最常见的错误来源。

限定符标准作用对象语义
constexprC++11变量 / 函数 / 构造函数 / lambda变量:必须是编译期常量;函数:允许编译期求值,也允许运行期调用
constevalC++20函数 / lambda立即函数,每一次调用都必须在编译期完成,不允许运行期调用
constinitC++20变量强制静态初始化,消除静态初始化顺序问题,但变量本身可被运行期修改

一句话记忆:constexpr 是「可以」,consteval 是「必须」,constinit 是「初始化时机」。

#include <array>
#include <cstdint>

// constexpr 函数:既能编译期求值,也能运行期调用
constexpr std::uint32_t fib(std::uint32_t n) {
    std::uint32_t a = 0, b = 1;
    for (std::uint32_t i = 0; i < n; ++i) {
        std::uint32_t t = a + b;
        a = b;
        b = t;
    }
    return a;
}

// constexpr 变量:必须在编译期确定
constexpr std::uint32_t kFib10 = fib(10);   // 55

// constinit 变量:静态初始化,但非 const
constinit std::uint32_t g_counter = fib(5); // 初始化在编译期完成,运行期可改

int main() {
    std::uint32_t runtime_n = 20;
    auto v = fib(runtime_n);   // 合法:constexpr 函数也能运行期调用
    return static_cast<int>(v);
}

1.2 consteval 的强制语义

consteval 函数一旦被调用,编译器必须在其调用点完成求值,否则直接报错。这带来两个好处:一是消除了「误以为编译期求值、实际在运行期求值」的性能陷阱;二是可以在函数体内用 throw 表达编译期诊断。

consteval std::uint32_t compile_time_only(std::uint32_t n) {
    return n * 2;
}

constexpr std::uint32_t kOk = compile_time_only(21);   // 42,合法

std::uint32_t bad(std::uint32_t n) {
    // return compile_time_only(n);   // 错误:n 不是常量表达式,立即函数无法运行期调用
    return n;
}

注意 constexpr 变量初始化时若调用 constexpr 函数,只要实参是常量表达式,求值同样发生在编译期——constexpr 与 consteval 的差别在于是否允许退化为运行期,而不在于「是否真的在编译期算了」。

二、常量表达式求值与 static_assert

2.1 什么算常量表达式

常量表达式(constant expression)的核心约束可以概括为:不读未初始化的对象、不调用非 constexpr 函数、不触碰 volatile、不做 UB。指针是常量表达式的一部分,但只有指向具有静态存储期对象或字符串字面量的指针才能进入 constexpr 变量。

constexpr int arr[4] = {1, 2, 3, 4};

constexpr int* p = const_cast<int*>(arr);  // 合法:指向静态存储期对象
// constexpr int* q = &arr[5];             // 错误:越界取址,编译期报错

编译期越界是 constexpr 最实用的能力之一:数组下标越界在运行期是 UB,在编译期求值中则必然被诊断。

2.2 static_assert 的用法与消息设计

static_assert 有单参数(C++17 起)与双参数两种形式。C++17 允许省略消息字符串,此时编译器会打印出条件表达式本身,配合 constexpr 函数名往往已足够可读。

#include <type_traits>

template <typename T>
struct Serializer {
    static_assert(std::is_trivially_copyable_v<T>,
                  "Serializer 要求 T 是 trivially copyable 类型,"
                  "若 T 含虚函数或非平凡拷贝构造函数请改用显式序列化");
};

// C++17 起可省略消息
static_assert(sizeof(void*) == 8);

把 static_assert 放进 if constexpr 的未选分支是无效的——未选分支在实例化时被丢弃,其中的 static_assert 不会触发。要做「按条件报错」,应把 static_assert 依赖的表达式写成常量表达式,或在函数体内用 consteval 校验。

三、编译期字符串解析与哈希

3.1 FNV-1a 编译期实现

FNV-1a 是最适合编译期实现的非加密哈希:一次异或、一次乘法,无需查表。C++17 起 std::string_view 的成员函数全部 constexpr,可以直接遍历。

#include <cstdint>
#include <string_view>

constexpr std::uint64_t fnv1a_64(std::string_view sv) {
    std::uint64_t hash = 14695981039346656037ULL;   // FNV offset basis
    for (unsigned char c : sv) {
        hash ^= c;
        hash *= 1099511628211ULL;                    // FNV prime
    }
    return hash;
}

// 编译期求值:kHashHello 是常量
constexpr std::uint64_t kHashHello = fnv1a_64("hello");
static_assert(kHashHello == 0xa430d84680aabd0bULL, "FNV-1a 常量校验");

注意 for (unsigned char c : sv) 这一步:若写成 for (char c : sv),在 char 为有符号的平台上,非 ASCII 字节会先符号扩展再转 uint64_t,产生与 8 位无符号语义不同的结果。编译期哈希必须与运行期实现逐位一致,这类符号性问题是最隐蔽的坑。

3.2 CRC32 编译期查表

CRC32 依赖一张 256 项的查找表。用 constexpr 函数在编译期生成整张表,运行期只做查表与移位,既保留了查表法的性能,又避免了手工维护常量数组。

#include <array>
#include <cstdint>
#include <cstddef>

constexpr std::array<std::uint32_t, 256> make_crc32_table() {
    std::array<std::uint32_t, 256> table{};
    for (std::size_t i = 0; i < table.size(); ++i) {
        std::uint32_t c = static_cast<std::uint32_t>(i);
        for (int k = 0; k < 8; ++k) {
            c = (c & 1u) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
        }
        table[i] = c;
    }
    return table;
}

constexpr auto kCrc32Table = make_crc32_table();

constexpr std::uint32_t crc32(std::string_view sv) {
    std::uint32_t crc = 0xFFFFFFFFu;
    for (unsigned char ch : sv) {
        crc = kCrc32Table[(crc ^ ch) & 0xFFu] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFFu;
}

static_assert(crc32("123456789") == 0xCBF43926u, "CRC32 标准测试向量");

0xCBF43926 是 CRC-32/ISO-HDLC 对字符串 "123456789" 的标准校验值,可以直接用作 static_assert 的自检向量。这类「用已知测试向量做编译期断言」的做法,能在编译阶段就锁死哈希实现的正确性。

3.3 编译期字符串字面量类型

若要把字符串本身作为模板参数,需要自定义字面量类型并借助 C++20 的类 NTTP(非类型模板参数):把 const char (&)[N] 存进一个 std::array<char, N> 成员,并声明 consteval 构造函数与推导指引,就能写出以字符串为模板参数的接口,把配置键名编码进类型从而在编译期完成查表与去重。

四、constexpr 容器

4.1 std::array 与 std::string_view

std::array 自 C++14 起绝大部分接口为 constexpr,std::string_view 自 C++17 起全 constexpr。二者构成编译期数据处理的主力,且零运行时开销。

#include <array>
#include <string_view>
#include <algorithm>

constexpr std::array<int, 6> kRaw = {5, 3, 9, 1, 7, 2};

constexpr std::array<int, 6> sorted_copy() {
    std::array<int, 6> out = kRaw;
    // C++20 起 std::sort 为 constexpr
    std::sort(out.begin(), out.end());
    return out;
}

constexpr auto kSorted = sorted_copy();
static_assert(kSorted[0] == 1 && kSorted[5] == 9, "编译期排序");

std::sort 自 C++20 起成为 constexpr,这是编译期算法的分水岭:此前想在编译期排序只能手写插入排序或归并排序,此后标准算法可以直接用于常量求值。

4.2 C++20 的 constexpr std::vector 与 std::string

C++20 把 std::vector 与 std::string 纳入字面量类型范畴:它们可以在 constexpr 函数中构造、修改、析构,只要不把分配出的指针泄漏到常量表达式之外。关键限制是同一常量求值过程中的分配与释放必须配对,因此 constexpr std::vector 不能作为 constexpr 变量的类型。

#include <vector>
#include <string>
#include <numeric>

// 合法:vector 是局部量,求值结束时被销毁
constexpr int sum_of_squares(int n) {
    std::vector<int> v;
    v.reserve(static_cast<std::size_t>(n));
    for (int i = 1; i <= n; ++i) v.push_back(i);
    int s = 0;
    for (int x : v) s += x * x;
    return s;
}

static_assert(sum_of_squares(10) == 385, "1..10 的平方和");

// 合法:string 同理
constexpr std::size_t count_vowels(std::string s) {
    std::size_t n = 0;
    for (char c : s) {
        switch (c) {
            case 'a': case 'e': case 'i': case 'o': case 'u': ++n; break;
            default: break;
        }
    }
    return n;
}

static_assert(count_vowels("compile time") == 4);

// 非法:constexpr 变量不能持有运行期才释放的堆内存
// constexpr std::vector<int> bad = {1, 2, 3};

编译器(GCC 12+、Clang 15+、MSVC 19.29+)在常量求值中使用了「编译期堆」来承载这些临时分配,代价是编译内存上升。若在编译期构造了上万元素的 std::vector,编译时间与内存都会显著增长。

五、编译期格式校验

5.1 编译期校验的思路

编译期校验的本质是:把「运行期才会暴露的格式错误」提前到编译期,让非法输入根本无法构造出程序。典型场景包括格式化字符串占位符数量与实参数量是否匹配、状态机转移表是否有非法边、配置文件字段名是否拼写正确。校验函数通常声明为 consteval,扫描一遍格式串,统计 {} 占位符数量并检查括号配对。

5.2 consteval 校验函数与错误信息设计

consteval 函数在常量求值中抛出异常,编译器会把异常信息作为诊断输出。利用这一点可以构造出「信息量足够」的编译期报错。

#include <stdexcept>
#include <string_view>

consteval std::size_t count_placeholders(std::string_view fmt) {
    std::size_t n = 0;
    for (std::size_t i = 0; i < fmt.size(); ++i) {
        if (fmt[i] == '{') {
            if (i + 1 >= fmt.size() || fmt[i + 1] != '}')
                throw std::logic_error("格式串中的 '{' 没有匹配的 '}'");
            ++n;
            ++i;
        } else if (fmt[i] == '}') {
            throw std::logic_error("格式串中出现孤立的 '}'");
        }
    }
    return n;
}

template <typename... Args>
consteval bool check_arity(std::string_view fmt, Args&&...) {
    if (count_placeholders(fmt) != sizeof...(Args))
        throw std::logic_error("格式串占位符数量与实参数量不一致");
    return true;
}

static_assert(check_arity("x={} y={}", 1, 2));   // 合法
// static_assert(check_arity("x={} y={}", 1));   // 编译期报错,附带异常消息

抛出的 std::logic_error 在常量求值中不会真的构造对象,但编译器会把消息文本打印到诊断中。GCC 与 Clang 都能给出包含该字符串的错误信息,这比传统的 static_assert 更容易表达「哪里错了」。

六、consteval 与 if consteval

C++23 引入的 if consteval 让同一个函数可以根据「是否处于常量求值」走不同分支。它的典型用途是:编译期用精确但昂贵的算法,运行期用平台优化的实现。

#include <cstring>

constexpr std::size_t length(const char* s) {
    if consteval {
        // 常量求值分支:手写循环,完全可移植
        std::size_t n = 0;
        while (s[n] != '\0') ++n;
        return n;
    } else {
        // 运行期分支:交给 libc 的 SIMD 实现
        return std::strlen(s);
    }
}

static_assert(length("compile-time") == 12);

关键点:if consteval 的非活动分支在常量求值中被丢弃,因此即便运行期分支调用了非 constexpr 的 std::strlen,常量求值依然合法。与之相对,普通 if 的两个分支都必须能通过常量求值检查。

std::is_constant_evaluated() 是 C++20 提供的等价机制,但它写在 if 条件中时两个分支仍会被实例化,语义不如 if consteval 干净。

七、编译时间与内存成本

编译期计算不是免费的,代价主要体现在三处:

  • 常量求值步数上限:GCC 默认 -fconstexpr-ops-limit=33554432,Clang 默认 -fconstexpr-steps=1048576。超限会报 constexpr evaluation hit maximum step limit。可用 -fconstexpr-ops-limit=N 或 -fconstexpr-steps=N 放宽。
  • 编译期堆内存:constexpr std::vector 的每个临时分配都占用编译器内存。GCC 有 -fconstexpr-loop-limit 限制循环次数,Clang 的 -fconstexpr-depth 控制递归深度。
  • 重复求值:constexpr 函数若在多个翻译单元中被同一组常量实参调用,每个 TU 都要独立求值一遍。把它包进 constexpr 变量(具名常量)可以复用结果。
# 度量编译期计算的成本
clang++ -std=c++23 -ftime-trace -c constexpr_heavy.cpp -o /dev/null
# 生成 constexpr_heavy.json,拖进 chrome://tracing 查看 EvaluateAsConstantExpr 耗时

# 放宽步数上限(谨慎:会显著增加编译内存)
g++ -std=c++23 -fconstexpr-ops-limit=1073741824 -c constexpr_heavy.cpp -o /dev/null

实测经验:编译期生成一张 4096 项的表(如 CRC 表、三角函数表)成本可忽略;编译期解析并哈希上千个字符串字面量时,编译时间会明显上升;编译期排序上万元素则可能让单个 TU 的编译时间增加数秒。

八、与模板元编程的取舍

模板元编程(TMP)与 constexpr 编程的边界,可以用三条准则划分。

优先用 constexpr 函数。可读性、可调试性、报错信息三者全面优于 TMP。传统 TMP 的 template <int N> struct Factorial { static constexpr int value = N * Factorial<N-1>::value; }; 完全可以写成 constexpr 循环。

需要「按类型分派」时用 if constexpr 或 Concept。if constexpr 把编译期分支写进函数体,避免了特化爆炸;Concept 则把约束表达为可读的谓词。

只有「需要类型作为结果」时才回到 TMP。类型列表、类型级映射、变参包的展开结果这类以类型为输出物的场景,constexpr 函数无能为力,必须依赖模板。C++20 的 Concept 与 requires 表达式已经能覆盖大部分原本需要 SFINAE 的场景。

以编译期阶乘为例,传统 TMP 需要主模板加 FactorialTmp<0> 全特化,实参非法时会展开出一整棵实例化栈;改写成 constexpr int factorial(int n) 后循环累乘即可,static_assert(factorial(10) == 3628800) 报错信息只有一行。

相关阅读

  • https://plumephp.com/cpp-metaprogramming/ — 模板元编程的经典手法与类型列表
  • https://plumephp.com/cpp-modern-17-20-23/ — if constexpr、Concept 与模块的语言背景
  • https://plumephp.com/cpp-compiletime-reflection-serialization/ — 编译期反射驱动的结构体序列化

延伸阅读

  • https://plumephp.com/cpp-templates-generics/ — 模板参数推导与偏特化的推导规则
  • https://plumephp.com/cpp-build-speed-optimization/ — 编译时间度量与 -ftime-trace 的完整用法
  • https://plumephp.com/cpp-performance-optimization/ — 编译期计算如何消除运行期分支与查表开销

文末完整示例

// 完整可运行示例:编译期哈希、CRC32、容器与 consteval 校验
// 编译:g++ -std=c++23 -O2 -o constexpr_demo constexpr_demo.cpp
//   或:clang++ -std=c++23 -O2 -o constexpr_demo constexpr_demo.cpp

#include <array>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>

// ====== 1. 编译期 FNV-1a ======
constexpr std::uint64_t fnv1a_64(std::string_view sv) {
    std::uint64_t hash = 14695981039346656037ULL;
    for (unsigned char c : sv) {
        hash ^= c;
        hash *= 1099511628211ULL;
    }
    return hash;
}

static_assert(fnv1a_64("hello") == 0xa430d84680aabd0bULL,
              "FNV-1a 标准向量校验失败");

// ====== 2. C++20 constexpr 容器 ======
constexpr int sum_of_squares(int n) {
    std::vector<int> v;
    v.reserve(static_cast<std::size_t>(n));
    for (int i = 1; i <= n; ++i) v.push_back(i);
    int s = 0;
    for (int x : v) s += x * x;
    return s;
}

static_assert(sum_of_squares(10) == 385);

// ====== 3. if consteval ======
constexpr std::size_t length(const char* s) {
    if consteval {
        std::size_t n = 0;
        while (s[n] != '\0') ++n;
        return n;
    } else {
        std::size_t n = 0;
        while (s[n] != '\0') ++n;   // 运行期可换成 SIMD 实现
        return n;
    }
}

static_assert(length("compile-time") == 12);

// 编译期 CRC32 查表与 consteval 格式校验示例见正文第三、五节

int main() {
    std::cout << "FNV-1a(\"hello\") = 0x" << std::hex << fnv1a_64("hello")
              << std::dec << "\n";
    std::cout << "1..10 平方和 = " << sum_of_squares(10) << "\n";
    std::cout << "length(\"compile-time\") = " << length("compile-time")
              << "\n";
    return 0;
}

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer