C++ 里有三个"编译期求值"相关的关键字:constexprconstevalconstinit。名字里都带 const,但职责各不相同——一个承诺"编译期可算",一个强制"只能在编译期算",一个解决"静态变量别出初始化顺序问题"。这篇把三者一次性梳理清楚。

一张表先建立全局印象

关键字作用于求值时机能否修改
constexpr变量、函数必须可在编译期求值
consteval函数仅编译期,禁止运行期调用
constinit静态/线程存储期变量编译期初始化是(运行期可改)

记住一句话:constexpr 管"编译期可算",consteval 管"只能在编译期算",constinit 管"静态变量别出初始化顺序坑"。

constexpr:编译期就能算出来

constexpr(constant expression)要求对象或函数必须能在编译期求值。相比只承诺"运行期不可改"的 const,它把计算推到了编译期。constexpr 函数尤其特别——它能在编译期和运行期两种语境下复用同一份定义,这就是下面要讲的“双态求值”。

const int runtime_val = get_value();   // const, but value may be runtime-known
constexpr int compile_val = 42;        // must be known at compile time

关键:双态求值

constexpr 函数最强大的地方在于它是双态(dual-mode)的——同一个函数体,编译器会根据调用点上下文决定在编译期还是运行期执行:

  • 当所有实参都是常量表达式、且结果被用于需要常量表达式的语境(模板参数、static_assertconstexpr 变量初始化、数组大小等)时,函数在编译期求值;
  • 当实参包含运行期值,或调用点不需要常量结果时,函数退化为普通函数在运行期执行。
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

// 编译期求值:实参是常量,且用于需要常量的语境
constexpr int f5 = factorial(5);       // 120, folded at compile time
static_assert(f5 == 120);
int arr[factorial(3)];                 // array size needs compile-time value → 6

// 运行期求值:实参是运行期值
int n = 0;
std::cin >> n;
int f = factorial(n);                  // computed at runtime, like a normal call

// 边界情况:实参是常量,但调用点不要求常量结果
int g = factorial(5);                  // 实参是常量 5,但 g 是普通变量
                                       // 编译器“可以”在编译期算,但非强制
                                       // 实际通常在编译期算出再写入,但这不是保证

最后一种情况值得注意:constexpr 是“可以”在编译期算,不是“必须”。只有当实参是常量、且结果又被常量语境使用时,编译期求值才被强制;否则算在哪个阶段由实现决定(标准允许但不要求编译期折叠)。真正“必须在编译期算”是 consteval 的事。

这种双态带来一个巨大好处:一处定义,两处可用。你不需要像 C 那样写一份宏给编译期用、再写一份函数给运行期用,constexpr 函数自动适配两种调用语境:

// 这一份定义既服务于编译期,也服务于运行期
constexpr int hash_str(std::string_view s) {
    int h = 0;
    for (char c : s) h = h * 31 + c;
    return h;
}

// 编译期:用于 switch-case 标签、模板参数
constexpr int id = hash_str("login");
// 运行期:处理用户输入的命令名
int cmd_hash = hash_str(user_command);

正因为双态,constexpr 才能逐步“吃掉”越来越多原本只能运行期的逻辑。从 C++11 只能写 return 一条语句,到 C++14 允许循环和局部变量,到 C++20 放开 std::vector/std::string 等类型……标准一路放宽,本质就是让同一个函数在编译期语境里能干的事越来越多。这种放宽让你可以把运行期函数直接加个 constexpr 关键字,而不必为编译期另写一套——这是现代 C++ 把计算“上提”到编译期的核心机制。

constexpr 还能用在构造函数上,让类型在编译期构造:

struct Point {
    int x, y;
    constexpr Point(int x, int y) : x(x), y(y) {}
    constexpr int dot(Point o) const { return x * o.x + y * o.y; }
};

constexpr Point a(1, 2), b(3, 4);
constexpr int d = a.dot(b);            // 11, all at compile time

C++20 起放宽了 constexpr 的限制:可以用的语句越来越多,甚至能在 constexpr 函数里用 std::vectorstd::string(C++20/26 逐步支持)。这让大量原本只能运行期的逻辑得以"上提"到编译期。

consteval:只能在编译期算

consteval(C++20)是 constexpr 的严格版本——它强制函数只能在编译期求值,禁止运行期调用。

consteval int square(int n) { return n * n; }

constexpr int a = square(5);           // OK: compile time
int x = 5;
int b = square(x);                     // error: x is not a constant expression

什么时候用 consteval?当你不希望这段代码在运行期执行时——比如:

  • 编译期生成查找表、格式串校验、正则编译,避免运行期开销;
  • 强制某段逻辑必须由编译器验证,而不是偷偷退化成运行期调用。

一个典型场景是格式串检查。C++20 的 std::format 系列会校验格式串,但只有把校验放在 consteval 函数里,才能保证错误在编译期报出,运行期零成本:

// simplified idea behind std::format runtime-check elimination
consteval void check_fmt(std::string_view fmt) {
    // validate placeholders match argument count at compile time
}

template<typename... Args>
auto format_str(std::string_view fmt, Args&&... args) {
    check_fmt(fmt);                    // forced compile-time check
    return std::format(fmt, std::forward<Args>(args)...);
}

一句话区分:constexpr 是"可以在编译期算",consteval 是"只能在编译期算"。

constinit:编译期初始化,但能改

constinit(C++20)解决的是另一个问题:静态变量的初始化顺序

具有静态存储期的变量(全局变量、函数内 static 变量、类的 static 成员)的初始化分两种:

  • 常量初始化(constant initialization):编译期完成,零开销;
  • 动态初始化(dynamic initialization):运行期执行初始化代码。

跨翻译单元的动态初始化顺序是未定义的,这就是臭名昭著的"静态初始化顺序灾难"(SIOF):A 的初始化依赖 B,但 B 还没初始化。

constinit 强制变量进行常量初始化,杜绝动态初始化,从而消除 SIOF:

// header
constinit int g_counter = 0;           // compile-time init, but mutable

void inc() { ++g_counter; }            // OK: can modify at runtime

关键区别在于:constinit 不保证只读。它和 const 是正交的两个维度:

编译期初始化运行期只读
const不保证
constexpr
constinit
constinit const
constinit int a = 0;                   // compile-time init, mutable
constexpr int b = 0;                   // compile-time init, read-only
constinit const int c = 0;             // compile-time init, read-only

用法口诀:需要"静态变量在编译期就初始化好、避免初始化顺序坑,但运行期还想改它"——用 constinit

它们之间的关系

把这三个放在一起看,C++ 的"编译期求值"设计是分层的:

  • constexpr编译期可算的通用承诺,覆盖变量和函数,且能在编译期/运行期双态运行;
  • consteval 把"可算"收紧为"必须算",专供编译期专用逻辑,禁止运行期退化;
  • constinit 关注的是静态存储期的初始化时机,和只读无关,是 SIOF 的解药。

选择上有个简单的决策树:

  • 变量/函数编译期可算、运行期也能用 → constexpr
  • 函数只在编译期用,禁止运行期退化 → consteval
  • 静态变量怕初始化顺序、但运行期要改它 → constinit
// a class exercising all three
class Config {
public:
    constexpr Config(int id, std::string_view name) : id_(id), name_(name) {}

    [[nodiscard]] constexpr int id() const { return id_; }

    consteval static Config default_config() {
        return Config{0, "default"};
    }

private:
    int id_;
    std::string_view name_;
};

constinit const Config g_config = Config{1, "app"};   // compile-time init, read-only
constexpr Config c_config = Config{2, "lib"};         // compile-time, read-only

现代 C++ 的趋势是把尽可能多的计算和约束推到编译期——更早发现错误、更少运行期开销。constexpr/consteval/constinit 正是这条路线的核心工具。理解它们的分工,写出的代码会更安全、更高效,也更"有品味"。