在 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
三者名字相近,职责完全不同,混淆它们是实际项目中最常见的错误来源。
| 限定符 | 标准 | 作用对象 | 语义 |
|---|---|---|---|
constexpr | C++11 | 变量 / 函数 / 构造函数 / lambda | 变量:必须是编译期常量;函数:允许编译期求值,也允许运行期调用 |
consteval | C++20 | 函数 / lambda | 立即函数,每一次调用都必须在编译期完成,不允许运行期调用 |
constinit | C++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;
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。