constexpr 完全指南:从 const 的两种含义,到编译期计算的四次演进

先看一个很多人答不上来的问题:

复制代码
const    int a = 1;   // a 是常量表达式吗?
const    int d = c;   // c 是变量,d 是常量表达式吗?
constexpr int e = c;  // 换成 constexpr 呢?

答案:a 是,d 不是,e 直接编译错误。

const 说的是「我不改」,constexpr 说的是「编译期就知道」------这两件事根本不是一回事。

这篇从 const 的两种含义讲起,一路讲到 C++20 的 consteval / constinit,中间每一次标准演进都给可运行的例子。


一、前置知识:顶层 const 与底层 const

在讲 constexpr 之前,必须先把 const 讲清楚,否则后面的报错会看不懂。

指针涉及两个 const:指针本身是不是 const,它指向的对象是不是 const。

复制代码
int i = 0;

int* const p1 = &i;     // 顶层 const:p1 本身不能改(不能指向别处)
const int ci = 42;      // 顶层 const:ci 不能被改

const int* p2 = &ci;    // 底层 const:*p2 不能改,但 p2 可以指向别处
const int& r  = ci;     // 底层 const:引用的 const 永远是底层
位置 名称 含义
int* **const** p 顶层 const 指针自己不能变
**const** int* p 底层 const 指向的东西不能改
const int& r 底层 const 引用天然是底层

记住方位:* 右边的 const 是顶层,* 左边的是底层。

🔗 为什么先讲这个 :后面 constexpr int* p = &x; 报错的原因,就是「顶层 const 的指针不能指向底层 const 的对象」------权限不能被放大。


二、常量表达式:到底什么算

2.1 定义

常量表达式 :值不会改变、并且在编译过程中就能得到计算结果的表达式。

三种是常量表达式:

  • 字面值(42、3.14、"abc")
  • 用常量表达式初始化的 const 对象
  • constexpr 变量

2.2 五个例子,把边界试出来

复制代码
int size() { int n = 10; return n; }   // 注意:不是 constexpr 函数

int main() {
    const int a = 1;           // ✅ 常量表达式(const + 字面值初始化)
    const int b = a + 1;       // ✅ 常量表达式(const + 常量表达式初始化)

    int c = 1;                 // ❌ 不是(不是 const,值可能变)
    const int d = c;           // ❌ 不是(是 const,但初始化器不是常量表达式)
    const int e = size();      // ❌ 不是(size() 不是 constexpr 函数)

    int arr[a];                // ✅ 数组大小必须是常量表达式
    // int arr2[c];            // ❌ C++ 不支持变长数组(VLA 是 C99 的东西)
}

2.3 关键认知:const ≠ 常量表达式

复制代码
const int d = c;    // d 是「运行时初始化的 const」------它不可改,但编译期不知道它的值

这句话请记住:

const 只保证「运行期不可修改」,constexpr 才保证「编译期已知」。

这也回答开篇那个问题:d 不是常量表达式,所以它不能做数组大小、不能做模板参数、不能进 case 标签。


三、constexpr 变量

3.1 三条规则

复制代码
constexpr int a = 1;          // ✅ 必须用常量表达式初始化
// constexpr int b = c;       // ❌ 报错:c 不是常量表达式
// constexpr int d = size();  // ❌ 报错:size() 不是 constexpr 函数
  1. constexpr 变量一定是常量表达式
  2. 必须用常量表达式初始化,否则编译报错
  3. 隐含 const (所以 constexpr int a 之后不能再改)

3.2 constexpr 修饰指针 = 顶层 const

这是最容易错的一点:

复制代码
const int d = 1;

// constexpr int* p1 = &d;      // ❌ 报错
const int* p2 = &d;             // ✅ 底层 const
constexpr const int* p3 = &d;   // ✅

为什么 p1 报错?

constexpr int* p1 展开是 int* const p1(顶层 const ,指针本身不能改),而 &d 的类型是 const int*。

把「指向 const 的指针」赋给「指向非 const 的指针」,等于放大权限------C++ 不允许。

p3 写全了是 const int* const p3:指针本身 const + 指向 const,类型匹配,通过。

💡 一句话 :constexpr 修饰指针时,const 加在指针本身上,不会穿透到指向的对象。

3.3 常量表达式能用在哪

凡是「编译器需要提前知道值」的地方:

复制代码
int arr[10];                         // 数组大小
template <int N> struct Buffer {};   // 模板非类型参数
static_assert(N > 0);                // 静态断言
switch (x) { case 1: ... }           // case 标签
struct S { int a : 3; };             // 位域宽度

四、constexpr 函数(C++11 的严苛版)

4.1 C++11 的五条限制

限制 说明
参数和返回值必须是字面值类型 整型、浮点、指针、引用等
不能返回 void 必须有值
函数体只能有一条 return 不能有别的语句
不能定义局部变量、不能有循环/分支等控制流 想要分支只能用三目运算符
返回值必须是常量表达式 ---

于是 C++11 里的阶乘只能写成递归:

复制代码
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int fact5 = factorial(5);   // 编译期算出 120

4.2 核心认知:同一个函数,两种身份

复制代码
constexpr int func(int x) { return 10 + x; }

int main() {
    constexpr int N1 = func(10);   // ✅ 编译期求值
    int arr1[N1];

    int i = 10;
    // constexpr int N2 = func(i); // ❌ 报错:i 不是常量表达式
    int N3 = func(i);              // ✅ 编译期求值不了,就退化成普通函数调用
}

这就是 constexpr 的精髓:它不是「必须编译期算」,而是「能编译期算就算」。

  • 传入常量表达式 → 走编译期路径 → 结果直接变成常量
  • 传入运行时变量 → 走运行期路径 → 和普通函数没区别

⚠️ 所以课件里那句「constexpr 函数返回的不一定是常量表达式」是对的。 constexpr 修饰的是函数 ,不是每一次调用。

4.3 为什么这段编译不过

复制代码
constexpr int fxx(int x) {
    int i = x;              // ❌ C++11 不允许局部变量
    i++;
    cout << i << endl;      // ❌ cout 不是字面值类型操作,无法编译期求值
    return 10 + x;
}

两个原因叠加:

  1. C++11 不允许定义局部变量
  2. cout 是 IO 操作,永远不可能在编译期执行

注意:即使到了 C++14(允许局部变量),cout 依然过不了------因为编译期没有"控制台"这种东西。

4.4 一个隐藏特性:constexpr 函数默认 inline

复制代码
constexpr int size() { return 10; }

constexpr 函数隐式 inline(因为定义要出现在每个使用它的翻译单元里)。

这也是为什么 constexpr 函数通常写在头文件里。


五、constexpr 构造函数与成员函数

5.1 构造函数的三条要求

复制代码
class Date {
public:
    constexpr Date(int y, int m, int d)   // ✅
        : _year(y), _month(m), _day(d)    // 必须在初始化列表初始化所有成员
    {}

    constexpr int GetYear() const { return _year; }
private:
    int _year, _month, _day;
};
要求 说明
所有成员必须是字面值类型 不能有 std::string(C++20 前)
必须在初始化列表初始化所有成员 函数体必须为空
实参必须是常量表达式 constexpr Date d(x, 9, 8) 里 x 是变量 → 报错
析构函数必须是平凡的 不做任何实际清理
复制代码
int x = 2025;
// constexpr Date d0(x, 9, 8);   // ❌ x 不是常量表达式
constexpr Date d1(2025, 9, 8);   // ✅
constexpr int y = d1.GetYear();  // ✅ 编译期拿到 2025

Date d2(2025, 8, 11);            // ✅ 运行时构造
int z = d2.GetYear();            // ✅ 运行时调用

又是「同一个东西两种身份」------构造、成员函数都能这样。

5.2 成员函数的两个细节

① C++11 里 constexpr 成员函数隐式是 const 的

复制代码
constexpr int GetYear() { return _year; }   // C++11 里等价于加了 const

⚠️ 【知识补全】C++14 起这条规则取消了 ------constexpr 非静态成员函数不再隐式 const。 你可以在里面调用非 const 成员函数(只要它也能 constexpr)。

② C++20 之前不能是虚函数

复制代码
class Base {
    virtual constexpr int value() const { return 1; }   // ✅ C++20 才支持
};

C++20 引入了「编译期多态」 ------constexpr 虚函数,后续第六节会看到例子。

5.3 constexpr 模板函数:为什么"可能被忽略"

复制代码
template <typename T>
constexpr T Func(T t) { return t; }

constexpr int ret2 = Func(10);         // ✅ T=int,constexpr 生效
string ret1 = Func("111111");          // ⚠️ T=string,实例化后不满足常量表达式要求

C++11 规定:如果 constexpr 模板函数实例化后不满足常量表达式函数的要求,constexpr 会被自动忽略------退化成普通函数。

这是一种"优雅降级",不是报错。 但代价是:同一个模板,有的实例是 constexpr,有的不是,很容易踩坑。


六、四次演进:C++14 / 17 / 20

6.1 C++14:大幅放宽

五个放宽:

C++11 限制 C++14
只能一条 return ✅ 可以多条
不能有局部变量 ✅ 可以声明并初始化
不能有循环/分支 ✅ if / for / while / switch 都行
返回值只能是基本类型 ✅ 可以返回自定义类、std::array
--- ✅ 允许 void 返回
复制代码
// C++14 的阶乘:终于能写成循环了
constexpr int factorial(int n) {
    int res = 1;                    // ✅ 局部变量
    for (int i = 2; i <= n; ++i)    // ✅ 循环
        res *= i;
    return res;                     // ✅ 单条 return(也可以多条)
}

constexpr size_t stringLength(const char* s) {
    size_t len = 0;
    while (s[len] != '\0') ++len;   // ✅ 循环
    return len;
}
constexpr size_t len = stringLength("Hello");   // 编译期算出 5

还能返回复合类型:

复制代码
struct Point {
    constexpr Point(double x, double y) : x(x), y(y) {}
    double x, y;
};

constexpr Point midpoint(Point a, Point b) {   // ✅ 返回自定义类
    return Point((a.x + b.x) / 2, (a.y + b.y) / 2);
}

constexpr std::array<int, 5> createArray() {   // ✅ 返回 std::array
    std::array<int, 5> arr{};
    for (size_t i = 0; i < arr.size(); ++i)
        arr[i] = i * i;
    return arr;
}
constexpr auto a1 = createArray();             // 编译期生成 {0,1,4,9,16}

6.2 C++17:if constexpr 与 constexpr lambda

if constexpr ------编译期条件分支,没被选中的分支根本不编译:

复制代码
template <typename T>
auto get_value(T t) {
    if constexpr (std::is_pointer_v<T>)
        return *t;      // 只有 T 是指针时才实例化这段
    else
        return t;       // 否则只实例化这段
}

这是它和普通 if 的本质区别:

普通 if if constexpr
两个分支 都会编译 只编译选中的那个
用途 运行期分支 模板里丢弃非法分支(如 *t 在 T 非指针时非法)

constexpr lambda:

复制代码
constexpr int n = 10;
constexpr auto square = [n](int x) constexpr { return x * x * n; };
constexpr int result = square(5);      // 编译期算出 250

要求:捕获的必须是编译期常量,函数体要满足 constexpr 要求。

6.3 C++20:革命性增强

能力 说明
编译期 new / delete 但必须成对释放,否则不是常量表达式
标准库 constexpr 化 std::array、std::find、std::sort 等
try-catch 编译期异常,必须在编译期被捕获
union 活跃成员切换 编译期可以换 union 的活跃成员
mutable 成员可改 constexpr 成员函数里能改 mutable 成员
虚函数可以 constexpr 编译期多态

编译期动态内存:

复制代码
constexpr int dynamic_memory_example() {
    int* p = new int{42};      // ✅ C++20 编译期 new
    int v = *p;
    delete p;                  // ⚠️ 必须释放,否则不是常量表达式
    return v;
}
constexpr int v = dynamic_memory_example();   // 42

所以 std::vector / std::string 理论上能编译期用了------但正因为"必须成对释放",实际支持仍然有限。

编译期排序 + 查找:

复制代码
constexpr auto sort_example() {
    std::array<int, 5> arr{5, 3, 4, 1, 2};
    std::sort(arr.begin(), arr.end());     // ✅ C++20:编译期排序
    return arr;
}
constexpr auto sorted = sort_example();    // {1,2,3,4,5}

constexpr array<int, 10> a1 = {3,2,1,4,5};
constexpr auto it2 = find(a1.begin(), a1.end(), 4);
static_assert(*it2 == 4, "编译期查找");    // ✅

编译期异常:

复制代码
constexpr int safe_divide(int a, int b) {
    try {
        if (b == 0) throw "Division by zero";
        return a / b;
    } catch (...) {
        return 0;          // 必须在编译期捕获
    }
}
constexpr int v1 = safe_divide(10, 2);   // 5
// constexpr int v2 = safe_divide(10, 0); // 走 catch 返回 0?
// 注意:抛异常本身会让"常量表达式求值"失败,实际写起来很受限

mutable + 汇编对比(这个例子很妙):

复制代码
class A {
    mutable int _i;
    int _j;
public:
    constexpr A(int i, int j) : _i(i), _j(j) {}
    constexpr int Func1() const { ++_i; return _i + _j; }  // ✅ mutable 可改
    constexpr int Func2() const { return _i + _j; }
};

constexpr A aa(1, 1);
// constexpr int r1 = aa.Func1();  // ❌ 它修改了 _i,无法编译期求值
int r1 = aa.Func1();               // 运行时调用
constexpr int r2 = aa.Func2();     // ✅ 编译期

汇编层看得非常清楚:

复制代码
; Func1:真的调用了函数
lea  rcx, [aa]
call A::Func1
mov  dword ptr [r1], eax

; Func2:值直接内联成常量,连函数都没调
mov  dword ptr [r2], 2      ; ← constexpr 的零开销,一目了然

这就是 constexpr 最直观的价值:能编译期算的,连调用都不存在。

6.4 一张演进对照表

能力 C++11 C++14 C++17 C++20
constexpr 变量 ✅ ✅ ✅ ✅
函数体单 return ✅ 放宽 放宽 放宽
局部变量 / 循环 / 分支 ❌ ✅ ✅ ✅
返回复合类型 ❌ ✅ ✅ ✅
if constexpr ❌ ❌ ✅ ✅
constexpr lambda ❌ ❌ ✅ ✅
编译期 new/delete ❌ ❌ ❌ ✅
编译期 try-catch ❌ ❌ ❌ ✅
constexpr 虚函数 ❌ ❌ ❌ ✅
consteval / constinit ❌ ❌ ❌ ✅

七、C++20 的两个补全:consteval 与 constinit

7.1 consteval:立即函数

constexpr 的问题是「不确定」:

复制代码
constexpr int square(int x) { return x * x; }

int main() {
    constexpr int a = square(10);   // 编译期
    int b = square(5);              // 运行期!同一个函数
}

consteval 强制「必须在编译期求值」:

复制代码
consteval int square(int n) { return n * n; }

constexpr int a = square(10);   // ✅
int b = square(5);              // ❌ 编译错误!必须编译期可求值
constexpr consteval
编译期求值 能编译期就算(也可运行期) 必须编译期
非法时 退化成普通函数 编译报错

用途:写"只能编译期用"的接口,比如编译期校验字符串、编译期常量工厂。

7.2 constinit:解决静态初始化顺序问题

这是很多人没听过的点,但非常实用。

问题(SIOF,Static Initialization Order Fiasco):

复制代码
// a.cpp
int a = 42;

// b.cpp
extern int a;
int b = a;      // ⚠️ 危险!a 和 b 谁先初始化?标准未定义

两个文件的全局对象初始化顺序是未定义的,b 可能读到 a 的未初始化值(0)。

constinit 的解法:

复制代码
// a.cpp
constinit int a = 42;

// b.cpp
extern constinit int a;
constinit int b = a;      // ✅ 安全:a 保证在编译期就初始化好了
const constexpr constinit
编译期初始化 ❌ ✅ ✅ 必须
运行期可修改 ❌ ❌ ✅ 可以改
适用变量 任意 任意 只能静态/线程存储期
复制代码
constinit int global = compute_value();   // ✅ 全局变量
constinit int squared = square(5);        // ✅

int main() {
    // constinit int local = 10;   // ❌ 局部变量(自动存储期)不行
    squared = 30;                  // ✅ 之后可以改
}

一句话记住三者:

关键字 编译期初始化 之后能改吗
const 不一定 ❌
constexpr 必须 ❌
constinit 必须 ✅
consteval 函数必须编译期求值 ---

八、【知识补全】constexpr 在存储 / Infra 里到底怎么用

这是课件没讲、但对你最有用的一节。

8.1 编译期常量表(tcmalloc / mini-kv 直接用)

复制代码
class Allocator {
    static constexpr size_t kPageSize = 4096;
    static constexpr size_t kAlignment = 8;
    static constexpr size_t kMaxSmallSize = 256 * 1024;

    // 编译期校验:这些错误应该在编译时报,而不是运行时断
    static_assert(kPageSize % kAlignment == 0, "页大小必须对齐");
    static_assert(kMaxSmallSize % kPageSize == 0, "小对象上限必须是页的整数倍");
};

价值 :static_assert + constexpr 让「配置错误」在编译期就爆掉,而不是上线后靠日志发现。

8.2 编译期生成 size class 表

复制代码
constexpr std::array<size_t, N> make_size_classes() {
    std::array<size_t, N> t{};
    size_t sz = 8;
    for (size_t i = 0; i < N; ++i) {
        t[i] = sz;
        sz = (sz < 128) ? sz + 8 : sz * 2;   // 小对象线性、大对象倍增
    }
    return t;
}
constexpr auto kSizeClasses = make_size_classes();   // 编译期生成整张表

运行时零开销,表直接进 .rodata。

8.3 编译期哈希(避免运行时算字符串哈希)

复制代码
constexpr uint32_t fnv1a(const char* s, uint32_t h = 2166136261u) {
    return (*s == '\0') ? h : fnv1a(s + 1, (h ^ static_cast<uint8_t>(*s)) * 16777619u);
}
constexpr uint32_t kGetHash = fnv1a("GET");    // 编译期算好

命令分派时直接比常量,不用在运行期算字符串哈希。(这也是 mini-kv 协议层的一个优化点。)

8.4 编译期位掩码 / 标志位

复制代码
constexpr uint32_t bit(int n) { return 1u << n; }
constexpr uint32_t kFlagDirty  = bit(0);
constexpr uint32_t kFlagPinned = bit(1);
constexpr uint32_t kFlagFlushed = bit(2);
static_assert((kFlagDirty & kFlagPinned) == 0, "标志位不能重叠");

九、十个坑

# 坑 说明
1 以为 const 就是常量表达式 运行期初始化的 const 不是
2 constexpr int x = 变量; 直接编译错误,必须常量表达式
3 constexpr int* p = &const_obj; 顶层 const 不能指向底层 const(权限放大)
4 以为 constexpr 函数一定编译期执行 取决于调用上下文,传入变量就退化成运行期
5 在 constexpr 函数里用 cout 永远不可能编译期求值
6 以为 constexpr 成员函数有引用捕获的隐式 const C++14 起不再隐式 const
7 递归太深 编译器有深度限制(GCC 默认 -fconstexpr-depth=512)
8 编译期运算太多 会显著拖慢编译,大表要权衡
9 C++20 编译期 new 忘了 delete 不是常量表达式,报错
10 以为 constinit 等于 const constinit 变量可以改,只是初始化在编译期

十、面试题

Q1:const 和 constexpr 的区别? const 是「运行期不可修改」,constexpr 是「编译期已知且不可修改」。运行期初始化的 const 变量不是常量表达式。

Q2:constexpr 函数一定在编译期执行吗? 不一定。传常量 → 编译期;传变量 → 运行期。它是「能编译期算就算」。

Q3:为什么 constexpr 函数默认 inline? 因为它的定义必须对每个使用它的翻译单元可见(否则无法在编译期求值)。

Q4:constexpr 变量隐含 const 吗? 是。但如果是指针,const 加在指针本身(顶层 const),指向的对象不一定是 const。

Q5:if constexpr 和普通 if 的区别? 普通 if 两个分支都会编译;if constexpr 只编译选中的分支,可以丢弃非法分支。

Q6:consteval 和 constexpr 的区别? consteval 强制编译期求值,做不到就编译错误;constexpr 允许退化成运行期。

Q7:constinit 解决什么问题? 静态初始化顺序问题(SIOF)。跨编译单元的全局变量初始化顺序不确定,constinit 保证编译期初始化。

Q8:C++20 中 constexpr 能做哪些以前不能做的事? 编译期 new/delete(须成对释放)、编译期 try-catch、constexpr 虚函数(编译期多态)、mutable 成员修改、union 活跃成员切换。

Q9:constexpr 有什么代价? 编译时间变长;递归深度受限;错误信息更难读;过度使用会让代码晦涩。

Q10:为什么数组大小必须是常量表达式? 因为数组要在栈上分配固定空间,编译器必须在编译期知道大小。VLA 是 C99 特性,C++ 标准不支持。


十一、总结

复制代码
           ┌──────────────────────────────────────────┐
           │            constexpr 的三次认知升级        │
           └──────────────────────────────────────────┘
                                │
        ┌───────────────────────┼───────────────────────┐
        │                       │                       │
   【第一层】                【第二层】                【第三层】
   const ≠ constexpr        函数有两种身份            它是工具不是目的
        │                       │                       │
   编译期已知 vs 不可改      传常量→编译期            配置校验、查表、
        │                     传变量→运行期           位掩码、编译期哈希
        │                       │                       │
   "能不能当数组大小"       "为什么有时没生效"        "在 tcmalloc 里怎么用"

一句口诀:

「const 管住手,constexpr 管住时钟,consteval 管住必须在编译期,constinit 管住初始化顺序。」

关键字 一句话
const 我不能改(但可能是运行期才知道)
constexpr 编译期就知道(函数则「能算就算」)
consteval 必须在编译期算,否则不许编译
constinit 初始化必须在编译期,但之后能改

附:一张速查表

你想表达 用什么
运行期不变的量 const
编译期常量(数组大小/模板参数) constexpr
编译期算表 / 位掩码 / 哈希 constexpr + static_assert
只能编译期调用的函数 consteval
全局对象必须在编译期初始化但可改 constinit
模板里按类型选分支 if constexpr
编译期断言 static_assert

下一篇可以接着看:if constexpr 和 SFINAE 到底怎么选,以及它为什么能让模板代码瘦一半。

相关推荐
她的男孩1 小时前
租户 A 的验证码,却用了租户 B 的 AccessKey:Forge Admin 修跨租户短信串发
java·spring boot·saas
LabVIEW开发1 小时前
LabVIEW 局部变量与数据流:这道 CLAD 题为什么答案是 Undefined
java·开发语言·labview
文丑颜不良啊1 小时前
LeetCode1721~1740题目解析
java·leetcode
wangbing11251 小时前
监控网页项目常用英文缩写
java
麻瓜code1 小时前
双写一致性:为什么是“删缓存“,而不是“改缓存“
java·缓存·mybatis
小蒜学长1 小时前
基于SpringBoot+Vue的云南地区扶贫助农系统的设计与实现(代码+数据库+LW)
java·vue.js·spring boot·后端·乡村振兴
砚底藏山河1 小时前
量化回测可复现流水线CI化
java·python·ci/cd·金融
骑着蜗牛撵大象3271 小时前
Spring Boot 4.1 + Spring AI 2.0:RAG 供文、图谱供关系、MCP 供工具的 Java Agent 平台
java·spring boot·spring·微服务·知识图谱·rag·mcp
Bruce_Liuxiaowei1 小时前
前端校验登录态,等于没校验——一次应急广播终端高危漏洞的发现与报送
运维·前端·安全