1. 先从一个"编译报错"说起
一个特别经典的画面:新手在代码里写const int n = 5; int arr[n];,发现arr居然能编过,于是心里烙下一个结论——const就是"编译期常量"。等哪天需求变了,n来自一个函数返回值,代码变成const int n = getCount(); int arr[n];,编译器立刻翻脸:expression must have a constant value。这一刻很多人第一次意识到:const和"编译期能算出值"根本不是一回事。
我想先把两件事从根上掰开。
const本质是一个类型修饰符、一个访问约束,它表达的是"你不应该通过这个名字修改这个对象"。至于这个对象的值是什么时候确定的,它根本不管。const int x = rand();完全合法——对象就安安静静躺在运行期栈上,只是代码里不能拿x当左值去改写。constexpr才是在表达另一件事:"这个值必须在编译期就能确定,并且永远不可变。"所以它能放进数组大小、模板实参、static_assert、switch的case标签这些必须要求常量表达式的位置。
一个容易误导人的细节是:const int n = 5; int arr[n];之所以能通过,是因为 C++ 标准里保留了一条历史兼容规则——const 限定的整型对象(非 volatile),如果它的初始化器是整数常量表达式,那么这个对象本身也可以当整型常量表达式用。这条规则只对整型和枚举类型有效,换成const double d = 5.0; int arr[d];立刻报错。所以准确地说,不是"const 等于常量",而是"const int 加字面量初始化"恰好凑巧进入了常量表达式的通道。很多老代码、老教科书还在用这种写法,但读完这篇之后,你最好别再这么写了。
打个比方:
const像一个贴着"请勿触碰"标签的柜子。柜子可以放到运行期才打开,里面的东西也可以是程序运行过程中才放进去的。constexpr像施工图纸上写死的参数。拿到图纸那一刻就必须算出来,现场施工只能照着做。
这两者的差异不只是语法层面,背后是"类型系统的只读约束"与"求值时机要求"两个不同维度的东西。搞不清楚这个,后面看模板元编程、看现代 C++ 代码里的constexpr函数、看std::array的声明,全都会觉得云里雾里。
1.1 为什么我们总是把两者混为一谈
会混淆,很大程度是历史惯性。C++98 时代没有专门表达"编译期常量"的关键词,数组大小、模板参数需要常量的时候,大家普遍用const int写整型常量,还经常用enum { N = 5 };来绕开"类内const int初始化"的种种限制。enum当时几乎是模板元编程里唯一的"编译期整数常量"载体,模板特化、数组维度、位运算都靠它撑场面。
这种历史惯性一直延续到 C++11 引入constexpr之后很多年。我见过不少项目里,static const int和constexpr混着用,注释里全写着"常量",其实语义天差地别。先放一个最简单的对照:
const int a = 10; // 运行期只读对象,但 int 类型时偶尔也能当常量表达式用 constexpr int b = 10; // 明确要求编译期求值,b 是真正的编译期常量 const int c = std::rand(); // 合法,运行期才知道值,只是只读而已 constexpr int d = std::rand();// 编译错误:rand() 不是常量表达式到这一步,先把核心结论立住:凡是你需要"编译器在编译阶段就知道这个数"的场景,优先写constexpr;凡是"我不希望这个变量在这个作用域里被改"的场景,写const。后面所有内容,本质上都在细化这句话。
2. 历史源头:为什么 C++11 要在 const 之外再造一个 constexpr
了解一段历史,比死记规则有用得多。const是从 C 语言血脉里继承下来的,出身就带着"接口合约"的使命:告诉调用者"这个函数不会修改你的参数""这个指针指向的东西不允许改"。它跟"编译期求值"从来就不是绑定关系。
2.1 const 的本职工作:运行时契约
C++ 里的const主要干四件事:
- 修饰普通变量,表示"这么名字下不可写";
- 修饰函数参数,
const std::string& s表示"引用传递但只读"; - 修饰成员函数,
int size() const表示 this 指针是"指向 const 对象的指针"; - 修饰返回值,比如
const T&防止外部修改内部状态。
这些职责里没有一个要求"值在编译期就确定"。const约束的是代码行为,不是求值时机。正因为如此,函数形参可以是const int——形参本身就是一个运行期才确定的值;rand()的结果也可以赋给const int——对象只读,值照样是运行期算出来的。
但在 C++98 时代,模板元编程需要一个"编译期整数常量"。大家被迫用enum hack:
template <int N> struct Factorial { enum { value = N * Factorial<N - 1>::value }; }; template <> struct Factorial<0> { enum { value = 1 }; };enum没有对象实体、不会被 ODR-use(多数情况下)、天生就是编译期常量,所以它能当模板参数。但语法丑陋、可读性差,而且这个"用类型系统算算术"的姿势,跟普通函数差距太大。C++11 引入constexpr,本质上是给这些需求一个正规军:你有函数、有变量、有构造函数,都可以声明为"编译期可知",从此不用再靠enum和模板特化硬凑。
2.2 constexpr 的演进路线
constexpr 不是一次到位的,它经历了一条非常明显的放宽曲线:
| 版本 | constexpr 相关变化 |
|---|---|
| C++11 | 变量、函数、构造函数可加 constexpr;函数体只能包含一条 return 语句 |
| C++14 | 函数体内允许局部变量、循环、if/switch,仍禁止 goto、static 变量 |
| C++17 | lambda 可声明为 constexpr;constexpr 静态成员变量变相成为 inline(内联变量落地) |
| C++20 | 允许 constexpr 函数内出现 try 块;std::string、std::vector 的部分操作可 constexpr;consteval/constinit 加入 |
| C++23 | 进一步放宽:constexpr 函数内允许 static constexpr 局部变量;constexpr 相关边界继续软化 |
早期 constexpr 函数只能有一条return,充分说明当年委员会非常谨慎:编译期求值是一个大规模静态展开的过程,函数体太复杂,编译器负担会剧增。所以 C++11 把它压到"一行表达式"。后来实践表明,模板元编程社区急需更自然的递归和循环表达,C++14 才放开。
我自己的感觉是:constexpr的历史,就是"把原本依赖模板黑科技的编译期计算,逐步还给普通函数"的历史。C++20 之后,你甚至可以在编译期构造一个std::vector算数据,再把它交给运行时,这在 C++11 时代想都不敢想。
3. 六大高频场景逐一对照:const 和 constexpr 到底该用谁
很多人的困惑是:给我具体代码,我到底该写哪个?我不爱讲玄学,直接按场景来。
3.1 局部变量:看要不要参与编译期计算
void demo() { const int limit = compute(); // 运行期算,没问题,读代码的人知道 limit 不会变 constexpr int batch = 64; // 如果后续数组、模板、断言需要,这个才是正解 }局部变量只要不放进"要求常量表达式"的上下文,用const就够了。const让你和后来维护者都知道"这个变量只读",constexpr额外承诺"编译器知道它的值"。如果只是constexpr int batch = 64然后当普通变量用,也合法,但没必要。
一个反直觉点:const对象不一定比constexpr对象“更轻”——前者可能占用运行期栈空间,后者通常直接优化成立即数。所以真正的性能收益来自"编译期算完、运行期零成本用"。
3.2 函数形参与返回值:constexpr 函数是"双模函数"
形参本身绝对不可能是constexpr。函数一调用,形参的值就是运行期的,它不满足常量表达式要求。常看到新手写:
void f(constexpr int x) {} // 错误,constexpr 不能修饰形参正确姿势是形参用const表达"我不改它",然后函数整体加constexpr:
constexpr int twice(int x) { return x * 2; } int main() { constexpr int a = twice(10); // 编译期算,a 是常量 int n = 42; int b = twice(n); // 退化成普通函数,运行期算 return a + b; }这个"双模"特性特别像inline:函数本身具备编译期求值能力,但具体在哪个阶段执行,取决于实参是否常量表达式、以及上下文是否强制要求常量。这是第四章要重点展开的机制。
3.3 类成员变量:static 成员的经典困境
C++17 之前,类内静态常量整型可以这样写:
struct Config { static const int max_retry = 3; // 整型可以类内初始化 // static const double ratio = 0.5; // 错误,C++17 前 double 不行 };但如果你对max_retry取地址,或者它在程序里被 ODR-used,就需要在类外再定义一次,麻烦得很。C++17 落地 inline 变量之后,推荐写法变成:
struct Config { static constexpr int max_retry = 3; // 隐式 inline,不需要 .cpp 里再定义 static constexpr double ratio = 0.5; // 编译期常量,double 同样支持 };constexpr static成员变量自带 inline 属性,从 C++17 起就不用在类外补定义,这是团队代码里大量转向constexpr的现实原因之一。
3.4 数组大小与 std::array:必须编译期常量
int arr[n]里 n 必须是常量表达式。std::array<T, N>的 N 同样是模板非类型参数,必须是编译期常量。C++ 标准保留的 "const int 整型可兼容" 规则虽然让const int n = 5能过,但一旦这个 n 的来源变成函数参数、成员变量、(C++20 前的)非字面量类型,就会立刻翻车。
所以凡是"数组维度、容器容量、位宽"这种编译期架构层面的大小,一律写constexpr:
constexpr int kMaxEntries = 128; using Table = std::array<double, kMaxEntries>;3.5 模板非类型参数:NTTP 的标准答案
模板参数<N>必须编译期可知。constexpr是标准答案,但static constexpr也能用。C++20 还允许浮点、类类型作为非类型模板参数,前提是常量表达式。在这个场景里,const顶多靠整型兼容规则勉强凑合,但语义含混,代码审查时我会直接打回去。
template <int N> struct FixedBuffer { char data[N]; }; constexpr int kBufSize = 1024; FixedBuffer<kBufSize> buf; // OK3.6 静态存储期变量:constinit 解决初始化顺序问题
这里有第三个关键词constinit,它通常和constexpr一起被讨论。constinit保证静态存储期的变量在编译期初始化,但对象本身不一定是只读的,它解决的是"C++ 静态初始化顺序混乱"问题:
constinit int counter = compute(); // 编译期初始化,运行期可以改 counter // constinit int x = rand(); // 错误:不能保证编译期初始化constinit不要求不可变性,所以它和const是两回事;constexpr则隐式包含 const 语义,初始化必然编译期发生。如果你有一个运行期可能修改的全局计数变量,但希望避开初始化顺序问题,constinit就是工具。
我把六大场景的结论压成一个速查表:
| 使用位置 | const 的表现 | constexpr 的表现 | 实战推荐 |
|---|---|---|---|
| 局部变量 | 运行期只读 | 编译期常量 | 需要编译期值时用 constexpr,否则 const |
| 函数形参 | 接口只读合约 | 不合法 | 一律 const |
| 函数返回值 | 值类型上意义有限 | 双模:可在编译期/运行期求值 | 模板与算法里用 constexpr |
| 类静态成员 | C++17 前整型类内初始化麻烦 | 隐式 inline,简洁 | constexpr |
| 数组/array 容量 | int 类型可凑合,其他类型翻车 | 语义明确 | constexpr |
| 模板非类型参数 | 仅限整型且碰巧满足条件 | 标准答案 | constexpr |
| 静态存储期变量 | 只读但初始化时机不保证 | 既 const 又编译期初始化 | 需要可变则 constinit |
4. 深入 constexpr 函数:编译期准入规则与"惰性求值"机制
constexpr真正的威力在函数上。要把它用明白,必须理解“什么样的函数能成为 constexpr 函数”和“constexpr 函数什么时候真的在编译期执行”。
4.1 为什么不是所有函数都能加 constexpr
从 C++14 开始,一个函数要被标记为 constexpr,函数体通常只允许:实参类型和返回类型是字面量类型;函数体内不能有goto、不能有static或thread_local变量、不能是虚函数(C++20 虚函数也可 constexpr,这是个重大放宽)、不能进行未定义行为。C++20 进一步允许 try 块,但编译期求值中真正抛异常的路径仍然不被接受——try 块只是允许"析构和异常安全代码"在 constexpr 函数里存在。
为什么要有这么多条条框框?因为常量表达式求值是一个彻底的静态执行过程,编译器要把函数体按控制流逐步展开执行。如果里面混入 static 变量、线程局部存储、虚函数分派,就会引入"运行期状态",静态求值根本没办法保证确定性。goto会让控制流分析复杂度爆炸,早期干脆禁掉。
一个非常容易踩的坑是:C++23 之前,constexpr函数里不能用静态局部变量。例如:
constexpr int get_id() { static int counter = 0; // C++23 之前:错误 return ++counter; }这在常量表达式求值里会产生"跨调用状态",语义上说不通。C++23 的 P2644 才允许static constexpr局部变量,普通 static 仍然不行。
4.2 constexpr 函数是双模的:不是"调用它就会编译期执行"
这是新手最容易误解的一点。constexpr函数并不保证每次调用都发生在编译期。它只承诺:"如果实参是常量表达式,并且上下文需要常量表达式,那么编译器有能力在编译期把它算出来。"
用代码感受一下:
constexpr int square(int x) { return x * x; } constexpr int a = square(5); // 编译期求值:因为 a 本身是 constexpr int arr[square(3)]; // 编译期求值:数组维度需要常量表达式 int b = square(0); // 可能仍是编译期求值(优化),但标准不强制 int x = 4; int c = square(x); // 一定运行期求值:x 不是常量表达式注意最后一行的square(x):它调用的是同一个square函数,但完全没有"编译期魔法",行为和一个普通内联函数一致。这正是 constexpr 函数能兼顾"模板元编程工具"和"普通运行期函数"的原因。你在编译期和运行期用的是同一份逻辑,代码不会分裂成两套。
这带来一个特别大的好处:消除宏。以前想做"调用式编译期计算",只能用#define,宏没有类型、没有作用域、可能被多次求值。constexpr函数有类型、有作用域、参数量级清楚,还能被调试器识别。但凡有#define MAX(a,b) ((a)>(b)?(a):(b))这种代码,都值得认真考虑换成 constexpr 函数。
4.3 C++20 之后:consteval 和 constinit 让语义更清晰
consteval是"强制编译期求值"版本,只用于函数。如果一个函数用consteval声明,它不能被运行期调用,实参必须是常量表达式:
consteval int cube(int x) { return x * x * x; } constexpr int a = cube(3); // OK int n = 3; int b = cube(n); // 错误:consteval 函数必须在常量求值上下文中调用什么时候需要它?当函数内部依赖编译期才能完成的操作,或者你非常确定该函数的意义只存在于编译期。我一般用在"字符串哈希、编译期配置转换"这种场景,防止某次误调用把它拖进运行期。注意consteval和constexpr不能同时修饰一个函数。
再加上constinit,现代 C++ 的三件套可以这样总结:const管只读,constexpr管编译期常量,consteval强制编译期函数求值,constinit管静态变量的编译期初始化但不保证只读。它们分工完全不同,混着用才是问题根源。
5. 排查链路:我的 constexpr 代码为什么编译不过
前面讲原理,这部分分享实战排查思路。我见过太多人把 constexpr 报错截图发到群里,其实大部分问题就那几类。我把典型报错和一个完整排查过程放一起,照着走基本能解决。
5.1 常见编译错误全景
| 典型报错信息 | 大概率原因 | 排查方向 |
|---|---|---|
| initializer element is not constant | 用非常量表达式初始化全局/static 变量 | 检查初始化器是否调用非 constexpr 函数、访问运行期变量 |
| expression must have a constant value | 数组维度/模板参数/switch case 用了非常量表达式 | 变量加 constexpr,确认没有运行期依赖 |
| call to non-constexpr function 'foo' | constexpr 函数里调用了非 constexpr 函数 | 把被调用函数也改成 constexpr(如果允许) |
| constexpr variable cannot have non-literal type | 变量类型不是字面量类型 | 看看类型是否有非平凡析构/构造函数限制 |
| variable of non-literal type in constexpr function | constexpr 函数体内声明了非字面量类型的局部变量 | C++20 某些类型放宽,但自定义类型仍受限 |
| constexpr function never produces a constant expression | 函数没有任何实参组合能在编译期求值 | 检查是否依赖运行时设施,比如rand()、地址比较 |
一条经验:报错信息行号往往指向 constexpr 变量声明那一行,但真实病灶可能在初始化表达式深处。比如
constexpr int bad = get_number(); // get_number 不是 constexpr报错会直接打在这行,可问题在get_number是不是常量表达式。排查时先问三个问题:我在初始化什么?初始化器里调用了谁?被调用的那个函数/表达式是否满足常量表达式规则?
5.2 一个完整案例:编译期求斐波那契数列
假设我想写一个编译期算 Fibonacci 的函数,第一版:
constexpr int fib(int n) { if (n < 2) return n; return fib(n - 1) + fib(n - 2); } static_assert(fib(10) == 55);这在 C++14 以后没问题,因为函数体允许 if。但如果拿到 C++11 里就报错:“body of constexpr function not a return-statement”。旧标准里必须写成三元表达式:
constexpr int fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }接着我想用循环版本,C++14 之后也合法:
constexpr int fib_loop(int n) { if (n < 2) return n; int a = 0, b = 1; for (int i = 2; i <= n; ++i) { int tmp = a + b; a = b; b = tmp; } return b; } static_assert(fib_loop(20) == 6765);这段代码在 C++11 下会给出"变量 a 不是字面量?"之类的报错,因为函数体里连局部变量都不允许。很多人拿旧标准写新语法,报错后第一反应是"编译器坏了",其实是被语言版本卡住了。
接着我尝试在 constexpr 函数里用std::vector:
constexpr int sum_squares(int n) { std::vector<int> v; // 取决于工具链和标准,C++20 起允许 constexpr vector for (int i = 0; i < n; ++i) v.push_back(i * i); int total = 0; for (int x : v) total += x; return total; }C++17 需要报错“variable of non-literal type in constexpr function”,因为那时std::vector不是字面量类型。C++20 的工具链通常能过,但注意 flip side:编译期构造 vector 会显著增加编译负担,东西虽好别滥用。C++20 之前不能这么写不是语法问题,是库类型根本不具备 constexpr 能力。
再升级一个恶心的坑:在 constexpr 函数里用了static局部变量,C++23 之前不可能编过:
constexpr int counter() { static constexpr int seed = 1; // C++23 前:不能出现在 constexpr 函数体 return seed; }遇到这类报错,别急着魔改,先确认你用的标准版本。我的经验是:项目里-std=c++17的代码,就别想着用 C++20 的 constexpr vector;升级标准版本时要顺带把 constexpr 边界一起 review。
5.3 浮点、字符串、自定义类型的编译期求值边界
浮点常量表达式是允许的,但有个大坑:编译器在编译期求值时可能使用扩展精度,不同优化级别、不同平台计算结果可能不完全一致。如果你用 constexpr 浮点算完再跟某个字面量做static_assert,可能产生玄学报错。我的建议是:编译期浮点计算不要拿来跟精确小数做相等比较,用static_assert(std::abs(result - expected) < 1e-9)这类容差断言。
字符串和自定义类型,C++20 之后范围扩大很多。std::string的构造、比较、拼接在 C++20 起可以在常量表达式中使用;自定义类型的字面量要求是:拥有 constexpr 构造函数、平凡析构函数、成员类型都是字面量类型。C++20 放宽了很多(允许有析构函数?具体是 C++20 允许非平凡的 constexpr 析构?我记得 P0784 允许 constexpr 析构函数在 C++20)。如果报错提示"非字面量类型",先把类的析构函数、成员变量类型过一遍。
6. 结合搜索热度:"const 所有使用场景"12 位置速查与选择规则
网上搜"const 所有使用场景"的人特别多,因为 const 出现的姿势实在太多。我结合自己整理代码笔记的习惯,把 const 可能出现的 12 个位置一次性梳理出来,顺便在每个位置点明"这里要不要换成 constexpr"。
- 全局/命名空间作用域变量:
const int kGlobal = 5;只读对象。若值是字面量且需要编译期可见,写constexpr int kGlobal = 5;更好。 - 局部变量:见 3.1,按需选择。
- 类的静态数据成员:
static const int n = 5;或static constexpr int n = 5;。C++17 前 class 内整型静态常量可以初始化,但 ODR-use 麻烦;C++17 后 constexpr 直接内联。 - 类的非静态数据成员:
const int id;对象创建后该成员不可改(必须在构造函数初始化列表里初始化)。非静态成员不可能是 constexpr,因为每个对象的值是运行期构造的。 - 函数值传递形参:
void f(const int x)很少有意义,函数内本来就是 x 的副本。一般用不着。 - 指针的顶层/底层 const:
const int* p表示指向 const 对象,int* const p表示指针本身不可改,const int* const p两者都不可改。这是 C++ 里最容易被考倒但最常见的场景。 - 引用形参:
void f(const T& t)是现代 C++ 最常用的防拷贝只读传参姿势。这里 const 跟 constexpr 无关,形参不可能 constexpr。 - 成员函数尾部 const:
int size() const表示 this 是const T*,这个函数不会修改对象状态。它不是编译期相关,但它是 const 语义的核心。 - 函数返回值:
const T foo();对值类型基本没有意义(C++11 移动语义后更不建议),const T& foo();用于返回内部引用。若希望函数可编译期求值,用constexpr T foo();。 - const_iterator vs const iterator:
std::vector<int>::const_iterator it比const std::vector<int>::iterator it更常用,前者是"指向的内容不能改",后者是"迭代器本身不能改"。C++ 容器的 const 语义这里最容易混。 - 模板参数中的 const:
template <typename T> void f(const T& t)配合模板实参推导,能自动接收左值和右值。这不是编译期常量问题,是泛型接口设计。 - const_cast:
const_cast<T&>用来去掉 const 属性,是"我在明确打破只读约束"的逃生门。它和 constexpr 完全不搭,一般不推荐在真正 const 的对象上使用,否则是未定义行为。
把 12 个位置过完,你会发现绝大多数 const 场景属于"接口语义"——告诉读代码的人"这里不可改",跟编译期无关。只有少数几个位置(静态成员、数组大小、模板非类型参数、全局常量)才真正需要 constexpr。
给一个我当时总结给自己的选择规则,屡试不爽:
- 先问:编译器在编译阶段必须知道这个值吗?
- 是(数组大小、模板参数、static_assert、case 标签、constexpr 变量初始化),用
constexpr; - 否,再问:这个对象需要只读语义吗?是,用
const;都不需要,就普通变量。
- 是(数组大小、模板参数、static_assert、case 标签、constexpr 变量初始化),用
7. 最后再分享几个实战小技巧
这篇写到这里,核心概念和案例都过完了。最后分享三个我在实际项目里养成的习惯,希望能帮你少走弯路。
第一个习惯:代码审查时把所有"纯字面量初始化 + 只读"的命名常量,默认要求写成constexpr。比如const int kRetryCount = 3;这种如果你打开文件看到,直接改成constexpr往往没有任何副作用,还能让读代码的人瞬间知道这是个编译期常量。而const就留给真正的接口语义:函数参数、成员函数尾部、返回值引用。
第二个习惯:写完 constexpr 变量或函数后,随手加一行static_assert作为编译期校验。这既是测试,也是给编译器一个"必须在编译期求值"的强制上下文,等于把"我可能用错"的风险提前引爆:
constexpr int kMaxFrameSize = 4096; static_assert(kMaxFrameSize % 64 == 0, "size must align to cache line"); static_assert(fib(20) == 6765, "compile-time check");那句static_assert不仅仅是防错,更是在告诉后来者:"这个值是被编译期计算过的,别改成运行时变量糊弄我。"
第三个习惯:升级标准时,把 constexpr 边界一起过一遍。C++17 到 C++20 的变化里,constexpr 函数能用的库类型扩展、try 块、consteval/constinit 都是大重点。我见过一个项目从 C++17 升到 C++20 后,一堆std::vector在 constexpr 函数里突然能编过了,结果编译时间从 2 分钟涨到 10 分钟——因为原本被拒绝的代码现在真的在编译期跑了。能力变强不一定是免费的,编译期计算也要算成本。
还有一个 C++20 之后特别值得玩的新特性:std::is_constant_evaluated()。它可以让你在一个函数里根据"当前是不是编译期求值"选择不同实现,比如编译期用精确算法、运行期用快速近似算法。这种"双模函数"把 constexpr 的灵活度又往上推了一层,新手可以先跳过,但它确实是现代 C++ 编译期编程最有趣的角落。
老实说,const和constexpr的区别我在读标准之前也含糊了很多年。真正把它俩从"都是表示常量"的模糊认知里剥离开之后,再看模板代码、再读标准库源码,很多之前觉得"纯属编译器脾气"的报错都有了明确解释。希望这篇整理也能帮你把这一块彻底焊死。