很多 C++ 开发者从入门到写了多年代码,都容易对「常量表达式」这个概念模棱两可。我们天天写 const 修饰变量,但很少有人能说清:哪些 const 是真正的编译期常量?哪些只是运行时的只读变量?为什么用 const 定义数组长度有时报错有时通过?为什么 const_cast 修改 const 变量,输出结果会违背直觉?
从 C++11 引入 constexpr 关键字开始,C++ 终于有了明确的「编译期常量」语义。本文结合代码实例,从底层原理到实战坑点,把常量表达式、const、constexpr 一次性讲透。
一、到底什么是常量表达式?
1.1 核心定义
常量表达式需要同时满足两个条件:
- 值不会发生改变
- 编译阶段就能计算出确定结果
简单来说:编译器在翻译代码时,就能 100% 确定它的最终值,不需要等待程序运行时计算。
1.2 最容易踩的误区:不是所有 const 都是常量表达式
const 只是语法层面的「只读约束」,它不天然等于「编译期常量」。一个 const 变量是不是常量表达式,完全取决于它的初始化来源。
我们直接看一组经典示例:
cpp
运行
// ✅ 字面量初始化,编译期确定 → 常量表达式
const int a = 1;
// ✅ 常量表达式运算结果 → 仍是常量表达式
const int b = a + 1;
// ❌ 普通变量初始化,运行时才能确定 → 不是常量表达式
int c = 1;
const int d = c;
// ❌ 普通函数返回值,运行时才执行 → 不是常量表达式
int size() { return 10; }
const int e = size();
最直观的验证方式,就是用它们定义数组长度。在 MSVC(Visual Studio)等严格遵循 C++ 标准的编译器中,不支持变长数组(VLA),数组大小必须是编译期常量表达式:
cpp
运行
int arr1[b]; // ✅ 编译通过,b 是常量表达式
int arr2[d]; // ❌ 编译报错,d 不是常量表达式
这也是很多人写代码时遇到「玄学报错」的根源。
二、const 的双重身份:编译期常量 vs 运行时只读
为什么同是 const,差异这么大?本质上 const 在 C++ 中承担了两种完全不同的语义:
- 编译期常量语义 :用字面量或常量表达式初始化的
const,编译器会触发常量折叠优化 ------ 代码中所有用到该变量的地方,会直接替换为对应的数值,变量甚至可能不占用运行时内存,或存放于只读内存段。 - 运行时只读语义 :用普通变量初始化的
const,只是语法层面禁止直接修改,本质还是栈上的普通变量,值要到运行时才确定。
这两种语义的差异,会直接导致一个经典的「诡异 Bug」------ 用 const_cast 修改 const 变量。
三、经典踩坑复盘:const_cast 修改 const,为什么输出没变?
我们先看一段很多人都跑过、结果却和直觉不符的代码:
cpp
运行
cpp
#include <iostream>
using namespace std;
int main()
{
// 情况1:常量表达式 const
const int i = 0;
cout << "修改前 i = " << i << endl;
int* ptr = const_cast<int*>(&i);
*ptr += 1; // 强行通过指针修改内存
cout << "修改后 i = " << i << endl;
cout << "指针读取值 = " << *ptr << endl;
// 情况2:运行时只读 const
int j = 0;
const int k = j;
cout << "\n修改前 k = " << k << endl;
ptr = const_cast<int*>(&k);
*ptr += 1;
cout << "修改后 k = " << k << endl;
cout << "指针读取值 = " << *ptr << endl;
return 0;
}
主流编译器开启优化后的运行结果:
plaintext
cpp
修改前 i = 0
修改后 i = 0
指针读取值 = 1
修改前 k = 0
修改后 k = 1
指针读取值 = 1
同样是修改 const 变量,为什么 i 的值没变,k 的值变了?
- 对于变量
i:它是常量表达式,编译器触发了常量折叠。cout << i在编译阶段就被替换成了cout << 0,运行时根本不会去读取i的内存。哪怕你修改了内存中的值,输出也不会变化。 - 对于变量
k:它只是运行时只读变量,没有常量折叠优化,每次读取都会访问内存,所以修改内存后值会同步变化。
⚠️ 重要警示 :修改原本就以 const 定义的常量对象,属于 C++ 标准明确规定的未定义行为。不同编译器、不同优化等级下结果完全不可预测,极端情况下还可能因内存只读保护触发程序崩溃,绝对不要在生产代码中这么写。
补充:如果给常量表达式加上
volatile关键字,会强制编译器每次都访问内存,禁止常量折叠,此时修改后输出会变化。但这依然属于未定义行为,仅可用于原理验证。
四、C++11 constexpr:给编译器的「强制常量承诺」
既然 const 的语义模糊,C++11 就引入了 constexpr 关键字,专门用来标记常量表达式,从语法层面消除歧义。
4.1 constexpr 修饰变量:编译期常量的强约束
constexpr 修饰的变量,有两条铁则:
- 它一定是常量表达式
- 它的初始化值必须是常量表达式,否则直接编译报错
cpp
运行
cpp
constexpr int aa = 1; // ✅ 字面量初始化
constexpr int bb = aa + 1; // ✅ 常量表达式运算
// constexpr int cc = c; // ❌ 编译报错!c 是普通变量
// constexpr int cc = size();// ❌ 编译报错!普通函数运行时求值
如果说 const 是「我尽量不修改」,那 constexpr 就是「我向编译器保证,这一定是编译期就能确定的常量」。编译器会帮你做强制检查,从根源上避免语义模糊。
4.2 constexpr 修饰指针:顶层 const 的本质
很多人容易搞混 constexpr 指针的语义,这里给出明确结论: constexpr 修饰指针时,作用的是「顶层 const」------ 也就是指针本身是常量,且指针指向的地址必须在编译期就能确定。
cpp
运行
int d = 10; // 局部栈变量
const int* p2 = &d; // ✅ 普通指针,指向 const int
// constexpr const int* p3 = &d; // ❌ 编译报错
为什么会报错?因为局部变量 d 存放在栈上,它的地址要到程序运行、函数调用时才会分配,编译期根本无法确定。
什么样的地址是编译期确定的?全局变量、静态变量,它们的地址在编译链接阶段就固定了:
cpp
运行
static int num = 10; // 静态存储期变量
constexpr const int* p = # // ✅ 合法
这个指针的完整类型等价于 const int* const p:指针本身不可修改,指向的内容也不可修改。
五、const 与 constexpr 核心对比
表格
| 特性 | const | constexpr |
|---|---|---|
| 是否一定是常量表达式 | 不一定,取决于初始化来源 | 强制为常量表达式 |
| 初始化要求 | 运行时、编译时值均可 | 必须是编译期常量表达式 |
| 能否做数组长度 | 仅常量表达式类 const 可以 | 全部可以 |
| 编译器优化 | 常量表达式会触发常量折叠 | 强制编译期求值、常量折叠 |
| 指针修饰范围 | 可自定义顶层 / 底层 const | 固定为顶层 const(指针本身不可改) |
六、工程最佳实践与避坑指南
- 能用 constexpr 就不用 const :如果确定值是编译期固定的常量(比如数组大小、固定配置参数、模板参数),优先用
constexpr,语义更清晰,编译器也能做更多优化。 - const 只用于运行时只读 :当变量的值要运行时才能确定,但初始化后不会修改时,使用
const。 - 绝对不要用 const_cast 修改原生 const 变量 :
const_cast仅适用于「原本非 const,仅被临时加上 const 修饰」的场景,比如对接缺少 const 修饰的旧版 API。 - 避免用 #define 定义常量 :宏常量没有类型检查、不受作用域约束,现代 C++ 应优先使用
const和constexpr。 - 用 static_assert 做编译期校验 :配合
constexpr可以在编译阶段验证常量的合法性,提前发现问题。
七、总结
最后我们用一句话梳理清楚三者的关系:
- 常量表达式是属性:描述一个值能否在编译期确定
const是语法约束 :表示「运行时不可修改」,一部分const变量具备常量表达式属性constexpr是强制标记:强制变量必须是常量表达式,由编译器做编译期检查
理解了常量表达式的本质,你就能避开 C++ 里绝大多数和 const、编译期常量相关的坑,写出更安全、性能更好的代码。