1. 为什么把计算搬到编译期
一句话总结: 编译期求值(Compile-Time Evaluation)让编译器在语义分析阶段直接执行一部分源码,把结果固化成常量,从而同时换取运行时性能与类型安全。
编译期求值的收益有三层。第一层是性能:const N = 1024 * 1024 里的乘法不必留到运行期,static LUT = build_table() 把整张查找表在编译期算完,运行期零初始化成本。第二层是安全:把数组下标、位宽、格式串校验放到编译期,越界与格式不匹配变成编译错误而非运行时崩溃。第三层是表达力:允许用普通函数、循环、条件分支去生成类型与代码,替代一部分宏系统的职责。
// 编译期完成的整表计算:运行期零成本
constexpr auto table = [] {
std::array<int, 256> t{};
for (int i = 0; i < 256; ++i) t[i] = i * i;
return t;
}();
代价是编译器要内置一个受限解释器,还得处理一个根本矛盾:编译期求值不能图灵完备到可以写死循环,否则一个恶意或手滑的程序就能让编译器永不终止。各语言的解法不同,这正是理解 constexpr 家族的关键。
2. C++ constexpr:逐步放宽的二十年
C++11 引入 constexpr 时限制极严:函数体只能是一条 return,不能有循环、局部变量、if。C++14 放开局部变量与循环,C++17 引入 if constexpr 与 constexpr lambda,C++20 允许 constexpr 函数里有 try、dynamic_cast、虚函数调用(在常量上下文中被求值时仍需满足限制),C++23 又补上 constexpr 的 static 局部变量与 goto。
// C++11: 只能递归,因为没有循环
constexpr int fact11(int n) { return n <= 1 ? 1 : n * fact11(n - 1); }
// C++14 起: 可以写循环与局部变量
constexpr int fact14(int n) {
int r = 1;
for (int i = 2; i <= n; ++i) r *= i;
return r;
}
关键区分是**「能不能出现在常量上下文」与「是不是一定在常量上下文求值」**:
| 关键字 | 语义 | 求值时机 |
|---|---|---|
constexpr 变量 | 必须在编译期求值 | 编译期,否则报错 |
constexpr 函数 | 可以在编译期或运行期求值 | 取决于调用点 |
consteval(C++20) | 必须在编译期求值 | 任何调用都在编译期 |
constinit(C++20) | 静态初始化必须在编译期 | 保证无动态初始化顺序问题 |
consteval 也叫「立即函数(immediate function)」,它堵住了「同一个函数有时编译期、有时运行期」带来的歧义,是写编译期 API 的推荐工具:
consteval int checked_width(int bits) {
if (bits <= 0 || bits > 64) throw "bad width"; // 编译期抛错
return bits;
}
template <int W> struct Bits { static constexpr int width = W; };
using Word = Bits<checked_width(32)>;
2.1 常量上下文与「不允许的操作」
C++ 标准用 constant expression 规则框定哪些操作可在编译期进行。禁止项包括:reinterpret_cast(除少数场景)、访问非 constexpr 的静态存储、new/delete(C++20 起允许在常量求值中配对使用并全部释放)、未定义行为、读取未初始化值、越界访问。一旦在常量上下文里触发未定义行为,编译器必须诊断而不是「静默产生随机常量」。
constexpr int bad() {
int a[3] = {1, 2, 3};
return a[5]; // 编译错误: 常量求值中越界
}
各标准版本对常量求值能力的开放节奏,可以当作一张兼容性地图:
| 标准 | 关键变化 |
|---|---|
| C++11 | constexpr 函数体仅一条 return,无循环与局部变量 |
| C++14 | 允许局部变量、循环、if、多条语句 |
| C++17 | if constexpr、constexpr lambda、static_assert 单参 |
| C++20 | consteval、constinit、constexpr 虚函数与 try、常量求值中 new/delete 配对 |
| C++23 | constexpr 中的 static 局部变量、goto、部分 <cmath> 函数 |
2.2 求值顺序与 ODR 约束
constexpr 函数的「双重身份」带来一个易错点:同一函数在编译期与运行期必须给出相同结果,否则违反 ODR(One Definition Rule)。如果函数里读了全局可变状态或依赖平台相关的浮点行为,编译器可能在一处折叠、在另一处留到运行期,产生不一致。
int counter = 0;
constexpr int f() { return ++counter; } // 常量上下文里不允许: 修改静态存储
// 即使运行期能编译,在 constexpr 上下文里也会被拒绝
因此 constexpr 函数应保持纯函数语义:只依赖参数与真正的编译期常量,不读全局可变状态,不做平台相关的浮点优化。
3. Rust 的 const fn 与 const 求值
Rust 的策略更保守:const fn 里能用的语言子集被显式列举,编译器(rustc_const_eval)实现了一个 MIR 解释器(Miri 的兄弟),直接在 MIR(Mid-level IR) 上求值。
const fn fib(n: u32) -> u64 {
let (mut a, mut b) = (0u64, 1u64);
let mut i = 0;
while i < n {
let t = a + b;
a = b;
b = t;
i += 1;
}
a
}
const F40: u64 = fib(40); // 编译期算完
限制与坑:
const fn不能调用普通函数,只能调用其他const fn或用const上下文允许的内建操作;- 不能分配堆内存(
Box、Vec在 const 上下文长期不可用,const分配器是逐步开放的); - 浮点运算在 const 求值里的行为需与运行期一致,Rust 明确要求「同一表达式在 const 与运行期得到相同结果」,因此
const fn里不能做会因平台而异的浮点优化; - 递归深度与循环次数有上限,超限报
const evaluation is taking a long time。
# 打开 const 求值诊断
RUSTFLAGS="-Z const-eval-check-recursion-limit" cargo build
cargo +nightly build -Zunpretty=mir # 查看 MIR,确认哪些被折叠
Rust 还把常量传播与 const fn 打通:const_generics 允许泛型参数是 const N: usize,数组长度、类型维度都能是编译期计算的结果。
struct Matrix<const R: usize, const C: usize>([[f64; C]; R]);
const fn area(r: usize, c: usize) -> usize { r * c }
type M = Matrix<{ area(4, 8) }, 8>; // 类型维度由 const 求值得出
3.1 MIR 解释器与 Miri
rustc_const_eval 直接在 MIR 上解释执行,这套解释器与 Miri 共享核心:Miri 把同一套求值引擎用在运行期,加上对未定义行为的严格检查(越界、悬垂指针、数据竞争、无效位模式),成为 Rust 生态里查 UB 的利器。
# 用 Miri 检查测试里是否触发 UB
rustup component add miri
cargo +nightly miri test
因为 const fn 与运行期走的是同一个解释器,两者语义高度一致:const fn 里能过的检查,Miri 在运行期也会做;反之,在 const 求值里被拒绝的操作,通常意味着它无法被安全地解释执行。
4. Zig comptime:类型也是一等值
Zig 走得更远:它没有宏系统,也没有单独的「编译期类型」。comptime 是一种求值上下文,任何在编译期已知的值都能参与,类型本身也是编译期的值,于是泛型就是对类型做普通函数调用。
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
pub fn init(buf: []T) @This() { return .{ .items = buf, .len = 0 }; }
};
}
const IntList = List(i32); // 在编译期调用函数得到一个类型
comptime 的三种用法:
comptime x: T参数:强制该参数在编译期求值,于是函数可以返回type;comptime块:块内代码在编译期执行;inline for/inline while:在编译期展开循环,用于遍历struct字段或 tuple 元素。
const std = @import("std");
fn sumFields(v: anytype) i64 {
var total: i64 = 0;
inline for (std.meta.fields(@TypeOf(v))) |f| {
total += @field(v, f.name); // 编译期展开,无运行期反射
}
return total;
}
Zig 的取舍很直接:没有运行期反射,一切元编程都在编译期完成,生成的代码与手写等价;代价是编译期执行的是完整语言,@compileLog、循环展开失控都可能让编译时间爆炸。
4.1 编译期断言与日志
Zig 用内建函数表达编译期诊断,语义比 static_assert 更灵活:
fn assertAligned(comptime T: type) void {
if (@alignOf(T) < 8) {
@compileError("type must be 8-byte aligned");
}
}
comptime {
@compileLog(@sizeOf(struct { a: u32, b: u64 })); // 编译期打印,故意报错以中断
@compileLog(@typeName(List(u8))); // 打印 "List(u8)"
}
@compileError 直接终止编译并给出自定义信息,@compileLog 打印值后强制编译失败——这是一种「调试即报错」的设计,保证日志不会被遗忘在正式代码里。
5. 实现机制:编译器里的那个解释器
无论语言怎么设计,编译期求值的实现都落在语义分析阶段(Semantic Analysis) 的一个解释器上,常见架构:
- 前端把源码降为某种可执行 IR(C++ 用 Clang 的 AST + 常量求值器
Expr::Evaluate,Rust 用 MIR,Zig 用 AST + 自举的 comptime 解释器); - 求值器以步进方式执行 IR,维护一个求值栈与常量内存模型;
- 每次求值有资源预算:步数、内存、递归深度、求值对象大小,超预算即报错;
- 结果回填:把求出的常量替换回 IR,作为后续优化的输入。
# 一个极简的常量求值器骨架
class ConstEval:
def __init__(self, budget=1_000_000):
self.steps = 0
self.budget = budget
def tick(self):
self.steps += 1
if self.steps > self.budget:
raise ConstEvalError("evaluation step limit exceeded")
def eval(self, node, env):
self.tick()
if node.kind == "lit":
return node.value
if node.kind == "binop":
a, b = self.eval(node.lhs, env), self.eval(node.rhs, env)
return self.apply(node.op, a, b)
if node.kind == "call":
fn = env[node.name]
return self.eval(fn.body, {**env, **dict(zip(fn.params, [self.eval(x, env) for x in node.args]))})
raise ConstEvalError(f"not allowed in const context: {node.kind}")
5.1 步数限制与诊断
预算机制决定了编译器的可用性。Clang 默认的 constexpr 步数限制是 -fconstexpr-steps(默认约 1,048,576),求值深度限制是 -fconstexpr-depth(默认 512):
clang++ -fconstexpr-steps=10000000 -fconstexpr-depth=2048 heavy.cpp
调大能通过更重的编译期计算,但也意味着一个 bug 会让编译卡更久。诊断质量同样重要:好的编译器会指出「在哪一步、哪个操作、为什么不允许」,而不是笼统地报「不是常量表达式」。
error: constexpr variable 'T' must be initialized by a constant expression
note: non-constexpr function 'malloc' cannot be used in a constant expression
note: in call to 'build_table()' at line 42
5.2 与常量折叠、内联的关系
编译期求值不是孤立 pass,它与中端优化交织:
- 常量折叠(Constant Folding) 在 IR 层面处理「操作数全是常量」的表达式,是求值器的下游;
- 常量传播(Constant Propagation) 把求值结果沿 def-use 链扩散;
- 函数内联 可能把跨函数的常量上下文打通,让更多表达式变常量。
# 观察 LLVM 折叠掉多少指令
clang++ -O2 -S -emit-llvm k.cpp -o - | opt -passes=instcombine -S
5.3 三语言横向对比
| 维度 | C++ constexpr | Rust const fn | Zig comptime |
|---|---|---|---|
| 求值对象 | AST + 常量表达式 | MIR | AST / 自举解释器 |
| 类型是否一等值 | 否(需模板) | 否(需 const 泛型) | 是 |
| 资源预算 | -fconstexpr-steps | 内建步数上限 | 编译期无显式步数限制 |
| 运行期可复用 | 是(同一函数两用) | 是(const fn 可当普通函数) | 是(普通函数) |
| 反射 | 无(靠模板特化) | 无(靠 macro/const) | 内建 @typeInfo |
| 典型诊断 | note: 链式定位 | MIR 求值栈 | @compileError 自定义 |
6. 工程实践与陷阱
- 编译时间:把重计算搬到编译期等于把成本转嫁给构建。用
constexpr生成大表时,优先选择「简单公式 + 运行期首用缓存」而不是「编译期算全表」。 - 可调试性:编译期求值出的常量在调试器里看不到中间状态,出问题时只能靠
static_assert分段断言。 - 跨平台一致:
constexpr里若用了浮点,不同目标可能给不同结果;需要严格一致时应改用定点或整数运算。 - 误用为运行期优化:
constexpr函数在运行期调用时并不会自动变成常量,别把它当成「优化提示」。 - 递归 vs 循环:C++11 时代只能递归导致栈深度爆掉,现代语言都鼓励循环写法,递归仅用于确实递归定义的结构。
一句话:编译期求值的本质是「把一部分运行期工作前置到构建期」,收益是零运行期成本与更强静态检查,成本是编译时间与更受限的调试手段。它取代的是宏系统里最脏的那部分工作,但取代不了运行期才能真正决定的东西。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。