记得去年有个朋友发了段代码到群里,说“为什么 const 变量当数组长度还是编译不过”。那段代码大概是这样的:
int getSize() { return 10; } const int n = getSize(); int arr[n];他当时的疑问很有代表性:“n 不都已经是 const 了吗?只读变量为什么不能用?”我盯着这行代码想了一下,问题的关键其实不在 const 本身,而在于他把“只读”和“编译期常量”这两件事混成了一件事。C++ 初学者在 const 与 constexpr 上纠结,本质上就是因为这两个词都带着“const”,但在类型语义、求值时机、适用场景上完全是两套东西。
这篇笔记想把这些事讲透:const 到底在约束什么,constexpr 又是为了解决哪类问题出现的,以及在实际项目里到底什么时候用哪一个。适合刚学完 C++ 基本语法、想在代码里放心使用 const / constexpr 的读者,也适合准备把这部分知识整理成自己经验总结的开发者。
1. const 的本质:只读契约与类型系统的边界
1.1 只读不等于不可变,const 只是编译期的“承诺”
很多人把 const 理解成“这个值不能变”。这话作为入门记忆没问题,但太粗糙了,很容易在遇到指针、引用、成员函数时掉坑里。更准确的说法是:const 禁止的是通过这个名字进行写入操作,它并不保证对象在运行期绝对不可更改。
看个最简单的反例:
int x = 42; const int& ref = x; x = 100; // 合法,x 本身不是 const std::cout << ref; // 输出 100const 引用只是限制了“通过 ref 去修改 x”这条路,并没有给 x 上锁。类似地,const_cast 可以把 const 剥离掉,但如果原对象本身就是 const,修改它属于未定义行为;如果原对象本来不是 const,只是借了一个 const 引用给你,那用 const_cast 去掉只读语义后修改虽然危险,但技术上是被允许的。
我在实际工作中会把 const 理解为一种“接口契约”,而不是一把“安全锁”。它主要是在编译期帮我们守住边界:函数参数声明成 const 引用,调用方就知道你不会去动它的数据;成员函数声明成 const,外部就知道调用它不会改变对象状态。这些约束全部发生在编译阶段,运行期没有任何强制机制。
1.2 顶层 const 与底层 const:指针场景的识别方法
指针是 const 最容易让人晕的地方,因为一个声明里可能出现两个 const,含义还完全不同。
int a = 1; int b = 2; const int* p1 = &a; // 所指对象是 const,指针本身可变 int* const p2 = &a; // 指针本身是 const,所指对象可变 const int* const p3 = &a; // 两者都不可变区分方法很直接:const出现在*左边,修饰的是“所指对象”,这是底层 const;const出现在*右边,修饰的是“指针自身”,这是顶层 const。
所以:
p1 = &b; // 合法,p1 可以重新指向 *p1 = 10; // 非法,不能通过 p1 修改 p2 = &b; // 非法,p2 这个指针本身不能改 *p2 = 10; // 合法,可以通过 p2 修改 a面试和实际代码里最常出现的场景是函数参数。如果函数里只是想读取数据,通常写成const std::vector<int>&这种引用形式,它本质上就是一个底层 const:引用本身不能重新绑定,但通过引用不能修改容器内容。这时候把参数声明成const std::vector<int>&,既避免了拷贝开销,又表达了“我不动你的数据”这层意图。
1.3 const 成员函数、mutable 与 static:三条容易绕晕的边界
类内部的 const 有另一套规则。成员函数后面加 const,表示这个函数不会修改对象自身的非静态成员变量。这个承诺对调用方很有价值:一个 const 对象只能调用 const 成员函数。
class Counter { public: int getValue() const { ++cacheHits_; // 合法,cacheHits_ 是 mutable return value_; } private: int value_ = 0; mutable int cacheHits_ = 0; };mutable就是专门用来在 const 成员函数里开一个“后门”的。它适合缓存、计数、锁这种“逻辑上不影响对象状态,但物理上需要写”的成员。注意,这里说的是“逻辑上不影响”,如果某个成员被修改后会影响外部可观察行为,那就不应该用 mutable 掩盖过去。
另一个容易绕晕的点是:const 成员函数里可以修改 static 成员变量。原因是 static 成员变量属于类本身,不属于某个具体对象,修改它并不会破坏“这个对象没有变”的约定。这一点很多人面试时会答错,但理解之后其实挺顺的。
2. 从 const 到 constexpr:编译期常量为什么需要单独的语法
2.1 数组长度与模板参数:哪些场景必须编译期可求
理解 constexpr 之前,得先搞清楚一个问题:哪些地方非要“编译期就知道值”不可。
最典型的就是数组长度。C++ 标准层面没有支持 C 语言那种变长数组,所以在标准 C++ 里,数组边界必须是一个常量表达式。再看模板,template<int N> struct Config {};这种非类型模板参数,N 也必须在编译期确定。还有 switch 的 case 标签、static_assert的表达式、枚举的底层值,全都要求编译期可求。
这些场景的共同点是:它们在程序运行之前就必须被确定下来。编译器需要拿这些值去计算类型、分配静态存储、做编译期检查。如果这时候你只给一个“只读但运行期才知道”的值,编译器就没办法继续了。
2.2 C++03 时代的替代品:宏与 enum hack 的局限
在 C++11 引入 constexpr 之前,想在编译期弄一个常量颇费周折。老一辈开发者最常用的是宏:
#define MAX_SIZE 100宏的问题很直接:没有类型。MAX_SIZE只是预处理器层面的文本替换,它不知道自己是 int 还是别的什么。遇到重名、作用域、类型检查,宏都会带来麻烦。而且宏不遵守作用域规则,一个不留神就污染整个翻译单元。
另一个老办法是 enum hack,利用“枚举值必须是编译期整数常量”的特性:
struct Config { enum { MaxSize = 100 }; };这种方式能在编译期提供整数常量,比宏安全一些,但它只能表达整数,而且语义很绕。明明想要一个常量,却要借枚举的壳,代码可读性很差。constexpr 出现之后,这些方案基本都退居二线了。
2.3 constexpr 变量的引入与最小使用规则
constexpr 变量的核心价值一句话就能说清:它比 const 多了一层“编译期可求值”的硬保证。
constexpr int kMaxRetry = 5;这行代码的意思是:kMaxRetry不仅在运行期不能被修改,而且编译器必须能在编译期就把 5 算出来。如果初始化器不是常量表达式,比如调用了一个非 constexpr 函数,编译会直接报错。
一个容易忽略的细节是:constexpr 变量本身也是 const 的。也就是说constexpr int n = 5;隐含了const int n = 5;的特性,但它还额外保证了编译期可求。所以你会发现,几乎所有需要编译期常量的地方,第一选择都应该是 constexpr 而不是 const。
3. constexpr 函数:从“单行特工”到“正经函数”的演进史
3.1 C++11 的单 return 限制及其设计动机
constexpr 更强大的地方在函数。但 C++11 刚推出时,constexpr 函数的限制非常严格:函数体只能包含一条 return 语句,不能有局部变量,不能有循环,也不能有 if 分支,除非用三元运算符和递归绕过去。
constexpr int square(int x) { return x * x; } constexpr int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); }这种设计在当时是有道理的。编译器要在编译期对函数求值,函数体越简单,证明“它一定能被求值”就越容易。一条 return 语句加上递归,基本把函数变成了一个纯表达式,编译器只需要像展开数学公式一样处理。代价是写起来很不像正常代码,稍微复杂一点的逻辑就得用递归硬扛,读起来很痛苦。
3.2 C++14 解禁循环与局部变量后的日常写法
C++14 把限制放宽了,constexpr 函数里可以写局部变量、if、switch、循环,甚至可以在函数内部修改变量。这一下就让 constexpr 真正变得可用,写法跟普通函数几乎没有区别:
constexpr int sum(int n) { int result = 0; for (int i = 1; i <= n; ++i) { result += i; } return result; } static_assert(sum(10) == 55, "sum(10) should be 55");这比 C++11 的递归写法直观太多。static_assert是编译期检查,如果sum(10)不是 55,编译直接失败。能在 static_assert 里调用函数,就说明编译器确实在编译期执行了这个函数。
3.3 两栖函数:如何判定一次调用是编译期求值还是运行期回退
这里有个非常关键的认知:标记为 constexpr 的函数,并不代表“每次调用都会在编译期求值”。它更像一个“两栖”函数,既能在编译期跑,也能在运行期跑。
constexpr int square(int x) { return x * x; } constexpr int a = square(3); // 必须编译期求值,因为它用于常量表达式上下文 int b = square(3); // 不强制,但编译器很可能常量折叠成 9 int c = square(readValue()); // 只能在运行期求值规则可以理解为:如果调用被用在要求常量表达式的地方,比如 constexpr 变量初始化、模板参数、static_assert,那编译器必须在编译期求值;如果只是普通调用,实参又是运行期才知道的值,那它就退化成普通函数在运行期执行。理解了这一点,就不会再犯“加了 constexpr 就一定能提升性能”这种错误。
3.4 C++20 的新工具:consteval 与 constinit 的分工
constexpr 函数毕竟是“尽量编译期求值,不行就运行期跑”。如果你希望强制“只能编译期求值”,C++20 提供了 consteval:
consteval int doubleIt(int v) { return v * 2; } int a = doubleIt(10); // 合法consteval 函数的所有调用都必须发生在编译期,因此不能用运行期的变量去调用它。如果实参来自运行时输入,编译直接报错。它适合那些“本来就不应该在运行期存在”的计算。
C++20 还引入了 constinit,用来解决静态变量的初始化顺序问题。它的作用是保证一个静态存储期的变量在编译期完成初始化,但运行期可以修改:
constinit int globalCounter = 100; // 编译期初始化 // constinit int other = getRuntime(); // 编译错误:初始化器不是常量表达式注意 constinit 和 constexpr 不能同时修饰同一个变量,因为 constinit 不关心“只读”,只关心“初始化时机”。
4. 到底该用 const 还是 constexpr:项目中的选型与配合
4.1 选择依据:语义意图优先于性能推断
说了这么多规则,落到项目里到底怎么选?我的原则很简单:先想清楚这个值“应该什么时候确定”,再想“要不要只读”,最后才考虑性能。
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 函数参数不打算修改 | const T& | 表达接口契约,避免拷贝 |
| 成员函数不修改对象状态 | 成员函数尾加const | 接口自描述,允许 const 对象调用 |
| 运行期才能确定的值 | const或普通变量 | 编译期算不出来,用了 constexpr 反而报错 |
| 编译期必须可求的常量 | constexpr | 数组长度、模板参数、static_assert 都需要 |
| 类内共享的静态常量 | static constexpr(C++17 起) | 可作编译期常量,且是内联变量 |
性能不是首要因素。constexpr 确实能把一部分计算挪到编译期,但绝大多数业务代码里,真正值得挪到编译期的是那些又重又确定的计算,而不是随手给每个变量加个 constexpr。
4.2 常量在类设计中的配合:static、内联变量与模板
在类里面定义常量,C++ 各版本之间有不少历史包袱。C++17 之前,static constexpr成员变量在类内声明后,如果被 ODR 使用,比如取地址或绑定引用,就需要在类外再给一个定义,很麻烦。C++17 之后,static constexpr成员变量直接就是内联变量,不需要类外定义:
class Config { public: static constexpr int kMaxRetry = 5; static constexpr double kTimeoutSec = 3.0; };类内其他代码可以直接用Config::kMaxRetry当编译期常量,比如声明数组、做模板参数。与static const int相比,constexpr在类型和用途上都更通用,除了整数还能用于浮点数、指针、字面量类型对象。
const 成员函数和 constexpr 也能配合使用。一个 constexpr 成员函数同时满足“编译期可求值”和“不修改对象”这两个条件,它本质上也是一个 const 成员函数,所以可以在 const 对象上调用。
4.3 编译期计算的真实收益:从一个素数判断函数说起
constexpr 最吸引我的应用场景是编译期生成查找表。运行时查表 O(1),构建成本全部发生在编译期,线上代码几乎没有初始化开销。下面这个例子用 C++20 在编译期生成前 100 个素数:
#include <array> #include <cstddef> template <std::size_t N> constexpr std::array<int, N> makePrimes() { std::array<int, N> primes{}; int index = 0; for (int candidate = 2; index < N; ++candidate) { bool isPrime = true; for (int d = 2; d * d <= candidate; ++d) { if (candidate % d == 0) { isPrime = false; break; } } if (isPrime) { primes[index++] = candidate; } } return primes; } constexpr auto first100Primes = makePrimes<100>(); static_assert(first100Primes[0] == 2); static_assert(first100Primes[99] == 541);这里要提醒一下:std::array的operator[]是 C++20 才正式支持 constexpr 的,所以这个例子在 C++17 及更早版本不一定能编译。用这类“编译期做表”的技巧时,要确认当前编译环境的标准等级。
实际收益取决于表的构建成本。如果一个表在运行期要初始化,可能每次程序启动都要计算一遍,放进编译期之后启动速度确实会变快;但如果计算量很小,纯粹的 constexpr 并不会带来肉眼可见的差异,反而会增加编译时间。
5. 踩坑记录与几点个人体会
5.1 排查实例:为什么 const 变量就是不能做数组长度
回到开头那个问题。const int n = getSize();中的 n 是只读的,但它的值来自一个非 constexpr 函数的调用,编译器在编译期并不知道 n 等于几。数组长度要求的是“编译期常量表达式”,不是“只读变量”。
但有一个非常迷惑人的细节:const int n = 5;是可以做数组长度的。因为 C++ 标准规定,一个 const 整数类型变量,如果初始化器是常量表达式,那这个变量本身也可以作为常量表达式使用。所以你写了const int n = getSize();报错,而const int n = 5;不报错,这不是编译器抽风,而是标准语义本身就区分了这两种情况。
遇到这种报错,正确的处理不是去掉 const,而是判断这个值能不能在编译期确定。如果能,直接换成constexpr int n = ...;如果不能,说明这里本就不该用数组长度来表达需求,该考虑std::vector或者动态分配了。
5.2 排查实例:constexpr 函数没有“如约”在编译期执行
有一段时间我很迷信 constexpr,觉得函数加了 constexpr 就等于编译期优化。直到有一次我写了个复杂的解析函数,处理大量字符串,标记成 constexpr 之后跑起来性能并没有变好,才重新审视这个问题。
后来想明白了,那个函数的实参来自运行时配置,根本没法在编译期求值。constexpr 函数在这种情况下就退化成普通函数在运行期执行,不会因为标记了 constexpr 就强行常量折叠。想验证一个 constexpr 调用是否真的在编译期执行,最直接的办法是把它放进编译期上下文,比如static_assert或 constexpr 变量初始化,只要这样用不报错,编译期求值就是成立的。
如果你确实需要“强制编译期执行”,C++20 的 consteval 是更合适的选择,它把模糊空间彻底堵死了。
5.3 给初学者的三条落地建议
第一,别为性能到处加 constexpr。先把需求想清楚:这个值是不是必须在编译期知道?如果不是,const 表达只读意图就够了。第二,能用 constexpr 表达编译期常量时,优先 constexpr,因为它不仅自己是个常量,还能参与模板、数组、static_assert 这些编译期机制。第三,多在 const 成员函数、顶层与底层 const、constexpr 的求值时机这些容易混淆的地方写点小实验,编译不过就是理解有偏差,编译器是最好的老师。
我现在的习惯是:接口参数尽量用 const 引用表达契约,类里的静态常量优先 static constexpr,需要强制编译期求值的核心算法用 consteval 兜底。这么配合着用,代码既清晰,又能在关键路径上享受到编译期计算的红利。每次回头看最初那个 const 数组长度的报错,都会感慨这一小块语法背后其实藏着整个 C++ 的求值模型,弄懂了它,很多看似奇怪的编译错误就都有了答案。