写 C++ 这些年,我发现自己面试别人时最喜欢问的题目里,十道有八道绕不开 const。这个关键字看着不起眼,却能在笔试里衍生出一连串追问:const int* p和int* const p有什么区别?const 成员函数为什么不能修改成员变量?constexpr又和 const 差在哪里?每次问完,能完全答利索的候选人真的不多。这篇文章不打算重复教科书,而是想把我实际写代码、读开源项目时对 const / constexpr 的理解、踩过的坑和工程取舍一次性理清楚。无论你是刚学 C++ 的入门者,还是准备校招想补八股,或者只是想把项目代码写得更稳,都值得泡杯茶慢慢看。
1. const 到底在锁定什么
很多教材喜欢把 const 解释成“只读”,但这个解释其实埋了雷。C++ 的 const 更准确的说法是“不变性承诺”:它告诉编译器,这个值在它的生命周期里不应该被修改,编译器可以基于这个承诺做优化,也会在你试图修改时给出编译错误。注意,“不应该”不等于“物理上不可能”——const_cast可以撬开这个承诺,但一旦真地去修改一个原本就是 const 的对象,结果就是未定义行为。这一点在实际工程里很容易被忽略。
1.1 先分清楚编译期常量和运行期常量
同样是 const,下面两个变量差别很大:
int x = 42; const int a = x; // 运行期才能确定的值 const int b = 42; // 编译期就知道是 42a 和 b 在外层看都是 const,但 b 属于“整型常量表达式”,可以用在数组大小、模板非类型参数、case 标签这些需要编译期值的地方;a 做不到。C++11 之前人们习惯用 const 强行顶替“编译期常量”的角色,const int SIZE = 100;这种写法在数组声明里确实能过,但它依赖的是这个具体初始化的字面量性质,而不是 const 本身的语义。一旦初始化式变成了函数调用,立刻就会报错。理解了这个区别,才能理解 constexpr 为什么要存在。
你可能还会遇到constexpr int c = 42;,这表示 c 不但是编译期常量,而且被整个表达式系统承认为常量表达式。后面再细说。
1.2 指针 const 的三种姿态
指针是新手最容易绕晕的地方,因为 const 既可以修饰指针自己,也可以修饰它指向的那个东西。三种组合长这样:
const int* p1; // 指向 const int,p1 可以改,*p1 不能改 int* const p2; // 指针本身 const,p2 不能改,*p2 可以改 const int* const p3; // 两者都 const我的记忆办法是“const 修饰左边最近的那个类型”,如果 const 在最左边,就把它挪到变量名左边的类型后面读。比如const int* p,const 修饰的是int,而不是p,所以指向的对象不能改。int* const p里的 const 紧挨着星号右边的 p,修饰的其实就是指针变量本身。读代码的时候用这个办法,基本不会再错。
还有一个细节经常出现在 C 风格的接口设计里:const char*和char const*完全等价,都是“指向 const char 的指针”。遇到了字符串字面量,比如"hello",它的类型是const char[6],所以拿它初始化指针时,正确写法是const char* s = "hello";,不能写char* s = "hello";,否则编译器会警告甚至报错。
1.3 函数参数和成员函数的 const 约定
给函数传引用时加 const,是我见过收益最高、最容易被忽略的写法。void parse(const std::string& input)这个签名一出现,所有人都知道 parse 不会篡改传入的字符串,同时避免了拷贝。如果你写void parse(std::string& input),其实是在暗示调用方“我会改它”,很容易误导使用者。
值传递的参数加不加 const,对外部调用方没有影响,只是函数内部不想误改这个局部副本。比如:
void print(const int n) { // 在函数体内,n 不可修改 std::cout << n << '\n'; }这种写法对调用方无感知,更多是给实现者自己加一道约束。
类成员函数后面的 const 是另一套逻辑:void func() const表示这个成员函数承诺不修改对象的“可见状态”。这不仅是一种约束,还影响着重载决议和常量对象能否调用。只有被 const 限定的成员函数才能作用在 const 对象上。如果一个 const 对象想调用某个成员函数,而这个成员函数没加 const,编译器会直接拒绝。
看个典型例子:
class Counter { public: int get() const; // const 成员 void inc(); // 非 const 成员 }; void demo(const Counter& c) { c.get(); // 可以 c.inc(); // 编译错误: 不能在一个 const 对象上调用非 const 成员 }在 const 函数里如果确实需要改某个成员,比如缓存计算结果、统计调用次数,可以把那个成员声明成 mutable。但必须克制,mutable 用多了,const 的语义会被稀释得跟纸一样,并发场景下还可能引入数据竞争。
2. constexpr:把计算挪到编译期
const 只能表达“只读”,不能表达“编译期可知”。数组大小、模板参数、枚举值这些位置需要的是常量表达式,单纯 const 不够用。C++11 引入 constexpr,就是在语法层面把这种能力固化下来:用 constexpr 修饰的变量和函数,至少具备在编译期求值的能力。constexpr 变量同时也是 const 的,所以它继承了 const 的许多语义,但反过来不成立。
2.1 为什么需要 constexpr
一个非常现实的场景:你想定义一个编译期可用的表,比如平方表。
const int n = 5; int table[n]; // 在局部作用域,一般没问题,因为 n 是编译期常量但如果是这样:
int number = someRuntimeFunc(); const int n = number; // n 是 const 的,但不是编译期常量 int table[n]; // 在标准 C++ 中这是非法的(VLAs 不是标准 C++)这时候你需要的不是“只读”,而是“在编译期就能知道”。constexpr 提供了这种保证。更关键的是,constexpr 函数可以把原本运行期循环算的东西搬到编译期,提高程序启动后的性能,还能用static_assert在编译期就把逻辑错误揪出来。
2.2 constexpr 函数的规则与求值时机
C++11 刚出 constexpr 函数时限制非常狠:函数体里只能有一条 return 语句,所以想算阶乘只能写成三目运算符递归。后来 C++14 放开了这些限制,循环、局部变量、if 分支都能用了,写起来舒服得多。下面这个例子在 C++14 及之后的标准下没有问题:
constexpr int factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); } int main() { constexpr int f5 = factorial(5); // 编译期就算出来 static_assert(f5 == 120, "factorial(5) should be 120"); int x = 6; int f6 = factorial(x); // 运行期调用,也没问题 }不需要把这个函数调用都当作编译期求值。constexpr 函数只是“具有了”编译期求值的能力:如果参数是编译期常量,而且结果被用在需要常量表达式的地方,编译器会在编译期求值;如果参数是运行期变量,就老老实实在运行期调用。也就是说,同一个函数既能在编译期跑,也能在运行期跑,这正是它强大的地方。
当然,要成为 constexpr 函数,约束也还是有的:不允许静态变量、不允许static存储期对象、不允许volatile、C++20 之前不允许 try/catch。参数类型和返回类型必须是“字面量类型”,也就是算术、指针、引用、枚举,以及满足条件的类类型。
2.3 constexpr 对象和构造函数
constexpr 不只修饰函数,也能修饰变量,还能修饰构造函数。一个 constexpr 构造函数允许你写出这样的代码:
struct Point { double x = 0.0; double y = 0.0; constexpr Point() = default; constexpr Point(double px, double py) : x(px), y(py) {} }; constexpr Point origin{}; constexpr Point p{1.0, 2.0}; // 编译期就存在的一个对象这样 p 就可以出现在常量表达式里,甚至可以拿去做模板参数。标准库里很多基础类型都在做类似的事,比如std::array的构造函数、std::pair的构造函数在 C++14/17 之后都有 constexpr 版本,这也是为什么泛型代码里能越来越放心地把算法标记成 constexpr。
2.4 试试编译期生成质数表
把上面这些串起来,可以做一个编译期生成素数表的例子。这段代码在 C++14 及以上可以编译:
#include <array> constexpr bool isPrime(int n) { if (n < 2) return false; for (int i = 2; i * i <= n; ++i) { if (n % i == 0) return false; } return true; } template <int N> constexpr std::array<int, N> makePrimes() { std::array<int, N> primes{}; int idx = 0; for (int v = 2; idx < N; ++v) { if (isPrime(v)) { primes[idx++] = v; } } return primes; } constexpr auto primes = makePrimes<100>(); static_assert(primes[0] == 2); static_assert(primes[1] == 3); static_assert(primes[2] == 5);运行期完全不需要再算,表已经躺在二进制里了。这种代码第一次写会觉得“绕”,但多写几次,你就会认同模板元编程里那句话:数据也可以是代码。
3. 项目里到底该用 const 还是 constexpr
记住一个判断框架,基本就能应付绝大多数情况:先问自己,这个值或函数结果在编译期就能确定吗?如果能,优先用 constexpr;如果不能,但你希望它不可被修改,就用 const。反之,如果只是想保护接口不被篡改,用 const 就够了,没必要为了用 constexpr 而去强行重构运行期依赖。
3.1 用一张表看透差异
| 维度 | const | constexpr |
|---|---|---|
| 本质 | 运行期或编译期的只读承诺 | 编译期求值(至少具备能力) |
| 能否修饰变量 | 能 | 能,但初始化必须是常量表达式 |
| 能否修饰函数 | 成员函数 | 普通函数和成员函数 |
| 能否用于数组大小 | 仅当初始化为编译期常量 | 可以 |
| 能否用于模板非类型参数 | 需要编译期常量 | 可以 |
| 能否用于 static_assert | 不能直接参与 | 可以 |
| 函数体是否允许循环/分支 | 与 const 无关 | C++14 起允许,C++11 仅一条 return |
constexpr 变量默认是 const 的,所以它同时继承了 const 的部分语义。你在代码里看到constexpr auto x = 3.14;时,严格说它也是 const。反过来,const int x = someFunc();是合法的,但不是 constexpr,因为它的初始值要到运行期才能确定。
3.2 典型工程场景
全局配置常量非常适合 constexpr。很多人习惯用宏:
#define MAX_CONN 1024一旦用到,就很容易踩宏的坑,比如MAX_CONN + 1在不同的上下文里有不同的解析优先级。换成:
constexpr int kMaxConn = 1024;既能做类型检查,又能在编译期参与模板参数或数组大小,还不会污染全局命名空间。
数学公式和物理常数也适合 constexpr 函数。比如算圆面积,直接声明一个 constexpr 函数,既可以在编译期把某个已知半径的面积算好,又可以在运行期接受用户输入的半径。
在泛型编程里,if constexpr(C++17 引入)几乎是必备武器:
template <typename T> auto to_string_impl(T v) { if constexpr (std::is_integral_v<T>) { return std::to_string(v); } else { return std::string(v); } }它能在编译器模板实例化阶段决定保留哪个分支、丢弃哪个分支,代码像写普通 if 一样简单,却能避免编译错误和无效实例化。
3.3 团队代码规范可以这样写
如果负责定规范,我个人会在评审 checklist 里加几条:
- 魔法数字一律抽出来,能写成 constexpr 就不要写成宏。
- 不修改参数的引用,一律加 const,比如
const std::string&、const std::vector<int>&。 - 成员函数只要逻辑上不修改对象,就加 const 修饰。这样常对象、常引用才能正常用。
- 数值型编译期常量,优先
constexpr而不是const,因为前者能明确表达“编译期可知”的意图。 - 模板代码里能用
if constexpr做编译期分流的,不要用 SFINAE 硬写标签分发。
这些规矩看着繁琐,但落地的收益非常直观:Code Review 时看到void process(std::string& s),你首先会想,它是不是要改 s;如果它其实不改,那就是接口设计有问题,尽早发现,避免后面别人误用。
4. 常见报错与疑难杂症
写 const / constexpr 时常见的报错和隐患,我整理成“症状-原因-解决”三件套,方便你排查。很多问题其实不是语法不懂,而是对“什么是编译期可知”理解不到位。
4.1 “表达式必须含有常量值”
这是新手最容易踩的坑。比如:
constexpr int n = test(); // test 不是 constexpr 函数或者:
int size = 100; constexpr int n = size; // size 不是常量表达式再或者:
int arr[size]; // size 是运行期变量,不是编译期常量报错信息往往会直接指出“表达式必须含有常量值”。解决方案也很简单:要么确认这个值确实能在编译期算出来,然后把参与计算的函数也标记成 constexpr;要么别把它当编译期常量用,该动态分配就用std::vector。
注意一个容易混淆的点:在局部作用域里,C 标准里的 VLA(变长数组)在一些编译器的扩展模式下是能过的,但这不是标准 C++。所以int arr[n]里 n 是运行期变量时,别指望它能在标准 C++ 下编译。
4.2 “函数调用不是常量表达式”
constexpr 函数被用在常量表达式上下文里,却报这个错,一般有几种情况:
- 参数本身不是编译期常量。
- 函数体内调用了非 constexpr 的函数,或者用了不允许的结构。
- C++11 下函数体不满足“只有一条 return”的限制。
- 类类型的构造函数不是 constexpr,或者成员不是字面量类型。
排查顺序:先从调用处看参数,再从函数体看实现,最后看编译标准。很多人把代码放过来报错,一查编译标准还是 C++98/03,那constexpr当然不认。Go 项目里尽量显式指定标准,CMake 里加set(CMAKE_CXX_STANDARD 17)或20,VSCode 下面的 tasks.json 也要对应加上-std=c++17/-std=c++20。
4.3 ODR-use 与跨编译单元问题
const 在命名空间作用域默认是内部链接,所以在头文件里定义const int kVersion = 3;,每个源文件都会有一份自己的拷贝,地址不一定相同。这在大多数情况下不影响使用,但如果你对它的地址有要求,比如绑定引用并比较地址,就会踩到 ODR(单一定义规则)相关的坑。
constexpr 变量在 C++17 之后默认是内联的,跨编译单元的地址唯一性有了保证。C++14 及以前没有这个特性,在头文件里定义constexpr int kX = 42;也会面临类似的 ODR 风险,尤其是取它的地址或绑定 const 引用时。所以如果项目还在 C++14 及以下,全局 constexpr 常量要么放在源文件里,要么用函数封装:
inline constexpr int kX() { return 42; }4.4 const 成员函数里的“暗度陈仓”
有些代码看起来加了 const,其实形同虚设:
class Data { std::vector<int> items; public: std::vector<int>& getItems() const { return items; } // 危险 };getItems 是 const 成员,但返回的是内部成员的非 const 引用,调用方可以随意修改 items,const 承诺被绕过了。这种接口非常危险,编译器不拦你,因为返回的是引用,修改发生在类外部。正确的写法是返回const std::vector<int>&;如果调用方需要修改,再提供一个非 const 版本的重载。
另外,const 成员函数并不等于线程安全。它只是“逻辑上”不修改对象,但如果类里有 mutable 成员,比如缓存指针、懒初始化数据,多个线程同时读这个 const 对象时,仍然可能因为写到这些 mutable 成员而发生数据竞争。写并发代码时,const 成员函数里的 mutable 要格外小心。
4.5 环境配置相关的杂症
VSCode 里写 C++,有时候constexpr标红,但不影响编译。这通常是 IntelliSense 的标准设置和实际编译器标准不一致导致的。在.vscode/c_cpp_properties.json里检查:
{ "configurations": [ { "name": "Linux", "cStandard": "c17", "cppStandard": "cpp17", "intelliSenseMode": "linux-gcc-x64" } ] }把cppStandard改成cpp17或cpp20,IntelliSense 就会认得if constexpr、std::array的 constexpr 构造函数这些新特性。
Windows 上还有一个高频报错,跟 const 无关,但搜索 C++ 时经常会看到:“Microsoft Visual C++ 14.0 or greater is required”。这是 Python 或 Node 原生模块在安装时找不到 MSVC 编译器。解决方式是装 Visual Studio Build Tools,勾选“使用 C++ 的桌面开发”工作负载,而不是装完整版 Visual Studio。顺便说一句,Visual C++ Redistributable 是运行库,装它只是解决缺少运行时 DLL 的问题,不能替代编译器安装。
4.6 问题速查表
| 症状 | 原因 | 解决 |
|---|---|---|
| 数组大小报错 | 用了运行期变量 | 换成 constexpr 或 std::vector |
| constexpr 函数总报错 | 编译标准太低 | 切到 C++14/17/20 |
| const 对象无法调成员函数 | 成员函数未加 const | 在函数声明尾部加 const |
| 返回内部引用却被外部改了 | const 成员返回非 const 引用 | 返回 const 引用,并重载非 const 版本 |
| constexpr 变量取地址有问题 | C++14 以前 ODR 风险 | 用 constexpr 函数封装或升级标准 |
| IntelliSense 标红但能编译 | 编辑器标准设置低于编译器 | 检查 cppStandard |
| MSVC 14.0 not installed | 缺少构建工具 | 安装 VS Build Tools + C++ 工作负载 |
最后说点掏心窝的话。我最早学 const 的时候,也是靠背“指针常量、常量指针”硬记,后来发现只要抓住“承诺”和“位置”两个词,很多问题都能自己推导出来。后来开始写模板库,才真正体会到 constexpr 的威力。记得有一次把一个用于生成查表的函数改成 constexpr,原本那套运行时单测被 static_assert 取代,跑起来稳多了。建议你在自己的小项目里也找几个纯函数试着重写成 constexpr,再把编译标准切到 C++17 或 C++20,这种“指尖上感受到编译器替你算好”的体验,比背十遍八股都管用。