先看一个很多人答不上来的问题:
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 函数
constexpr变量一定是常量表达式- 必须用常量表达式初始化,否则编译报错
- 隐含
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;
}
两个原因叠加:
- C++11 不允许定义局部变量
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 到底怎么选,以及它为什么能让模板代码瘦一半。