编译期求值与 constexpr/comptime

系统讲解编译期求值:C++ constexpr 与 consteval、Rust const fn、Zig comptime 三者的语义差异,编译器如何在语义分析阶段内置一个带资源预算的受限解释器,以及步数限制、常量折叠、诊断质量与工程实践中的取舍与陷阱。

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++11constexpr 函数体仅一条 return,无循环与局部变量
C++14允许局部变量、循环、if、多条语句
C++17if constexpr、constexpr lambda、static_assert 单参
C++20consteval、constinit、constexpr 虚函数与 try、常量求值中 new/delete 配对
C++23constexpr 中的 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) 的一个解释器上,常见架构:

  1. 前端把源码降为某种可执行 IR(C++ 用 Clang 的 AST + 常量求值器 Expr::Evaluate,Rust 用 MIR,Zig 用 AST + 自举的 comptime 解释器);
  2. 求值器以步进方式执行 IR,维护一个求值栈与常量内存模型;
  3. 每次求值有资源预算:步数、内存、递归深度、求值对象大小,超预算即报错;
  4. 结果回填:把求出的常量替换回 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++ constexprRust const fnZig comptime
求值对象AST + 常量表达式MIRAST / 自举解释器
类型是否一等值否(需模板)否(需 const 泛型)是
资源预算-fconstexpr-steps内建步数上限编译期无显式步数限制
运行期可复用是(同一函数两用)是(const fn 可当普通函数)是(普通函数)
反射无(靠模板特化)无(靠 macro/const)内建 @typeInfo
典型诊断note: 链式定位MIR 求值栈@compileError 自定义

6. 工程实践与陷阱

  • 编译时间:把重计算搬到编译期等于把成本转嫁给构建。用 constexpr 生成大表时,优先选择「简单公式 + 运行期首用缓存」而不是「编译期算全表」。
  • 可调试性:编译期求值出的常量在调试器里看不到中间状态,出问题时只能靠 static_assert 分段断言。
  • 跨平台一致:constexpr 里若用了浮点,不同目标可能给不同结果;需要严格一致时应改用定点或整数运算。
  • 误用为运行期优化:constexpr 函数在运行期调用时并不会自动变成常量,别把它当成「优化提示」。
  • 递归 vs 循环:C++11 时代只能递归导致栈深度爆掉,现代语言都鼓励循环写法,递归仅用于确实递归定义的结构。

一句话:编译期求值的本质是「把一部分运行期工作前置到构建期」,收益是零运行期成本与更强静态检查,成本是编译时间与更受限的调试手段。它取代的是宏系统里最脏的那部分工作,但取代不了运行期才能真正决定的东西。

延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「compiler」更多文章

  1. 浮点语义与快速数学优化
  2. 查询式编译器与增量类型检查:Salsa 架构
  3. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI