上个月帮同事看一段代码,一个const std::string& getName() const;的接口,被他在实现里改成了返回std::string并且顺手把末尾的const删掉了。删掉之后,整个工程里二十多处调用点全炸了,报错信息从模板深处一层层冒出来,长得像瀑布。他盯着屏幕问我:不就少个 const 吗,编译器为什么这么大反应。这个问题其实问到了点子上——const在 C++ 里从来不是"给变量加个只读标记"这么简单的东西,它是类型系统里参与重载决议、参与指针转换规则、参与对象生命周期管理的一等公民。你把 const 加在哪儿、漏在哪儿,编译器都会当成类型层面的差异来处理,而不是当成风格问题。
这篇东西就是把这几年我在实际项目里对const的理解整理一遍,从最基础的"它锁住了什么"讲起,重点落在四个最容易出问题的地方:指针声明里的顶层/底层 const、函数参数和返回值上的 const、类成员函数末尾的 const,以及它跟智能指针、标准库、其他语言里的同名关键字之间的差异。文章里的代码片段都可以直接编译验证,遇到容易踩的地方我会把坑的位置单独标出来。适合已经能写 C++ 但对 const 总有点"凭感觉加"的读者,也适合正在做代码审查、想把规范讲清楚的人。
1. const 锁住的不是"值",而是"这个入口的权限"
1.1 从一份被改坏的配置说起:const 的第一性理解
很多人对const的直觉是"这个变量的值不能变",这个说法在日常写代码时够用,但一旦牵扯到指针和类,立刻就失效了。更准确的表述是:const是一个类型修饰符,它给类型附加了"通过这个入口不能修改对象"的约束。注意关键词是"入口",而不是"对象"。
同一个对象可以同时有多种入口。假设我有一份全局配置对象Config cfg;,我可以声明一个const Config& readOnly = cfg;,也可以声明一个Config& writable = cfg;。这两个引用指向的是同一块内存,同一个对象,但通过readOnly这个入口能调用的成员函数集合和通过writable能调用的集合是不一样的。const约束的是路径,而不是那块内存本身。所以后来有人用const_cast从readOnly里把 const 拔掉去改cfg,编译能过,也"看起来"没崩,但这属于未定义行为——因为cfg本身不是 const,改它其实是合法的,只有当你把一个真正以const定义的对象的 const 拔掉再写,才是 UB。这个区别在很多面试题里会被反复拿出来当陷阱。
我一般用一句话概括给新人:const是"谁在什么位置看到了什么权限"的描述,不是"这个内存被冻结了"。这句话在后面的顶层/底层 const 部分会反复用到。
1.2 const 对象、const 引用与常量折叠:它到底占不占内存
const int kMax = 100;这样的定义,编译器在多数情况下根本不会给它分配存储空间,用到的地方直接换成字面量 100,这就是所谓的常量折叠(constant folding)。所以你在调试器里对着kMax打断点,可能会发现变量列表里根本没有它,或者在优化构建下它的地址每次都不一样。这是正常现象,不是编译器出 bug。
什么时候它会真的占内存?两种情况:取了它的地址,或者把它绑定到引用上。比如const int* p = &kMax;或者const int& r = kMax;,编译器就必须给它安排一个实际位置了。这个细节有个很实际的后果:如果你在一个头文件里写const int kMax = 100;,每个包含它的翻译单元都有一份独立的常量,链接器不会报重复定义。因为 C++ 里命名空间作用域的const变量默认是内部链接(internal linkage),相当于隐式static。这一点跟 C 正好相反——在 C 里,文件作用域的const对象是外部链接,多个.c文件都定义就会冲突。我当年从 C 转到 C++ 写嵌入式代码的时候,就被这个差异坑过一次,头文件里放了一堆const数组,链接时找不到符号(因为每个单元各有一份,指向的是不同地址),排查了半天才反应过来链接行为不同。
正确的做法其实很统一:头文件里放constexpr或者inline const(C++17 起支持inline变量),让所有翻译单元共享同一份实体。inline const std::string kDefaultName = "guest";是我现在写头文件常量的标准写法。
引用的 const 还有一层:const int& r = 42;这种"把一个临时对象绑定到 const 左值引用"是合法的,编译器会把临时对象的生命周期延长到引用的生命周期结束。但如果你写的是int& r = 42;,直接编译不过。这解释了为什么"接受临时对象"这件事基本只有 const 引用能干,普通引用干不了。反过来说,这也给了一个隐蔽的坑:函数返回const T&,如果返回的是一个函数内临时对象,引用延长规则不管用,调用方拿到的是悬垂引用。这个坑后面第 3 节会单独说。
1.3 const、constexpr、consteval、constinit:四个词的分工
这四个词经常被混着用,我把它们的分工列成表格更清楚:
| 关键字 | 引入版本 | 核心含义 | 典型场景 |
|---|---|---|---|
const | C++98 | 类型修饰,通过此入口不可修改 | 只读参数、const 成员函数 |
constexpr | C++11 | 可用于常量表达式,编译期可求值 | 编译期数组长度、常量函数 |
consteval | C++20 | 立即函数,必须编译期求值 | 编译期校验、编译期字符串处理 |
constinit | C++20 | 保证静态初始化,不保证只读 | 避免静态初始化顺序问题 |
需要强调一点:constexpr变量隐含const,但const变量不一定是constexpr。比如const int n = rand();完全合法,它不是常量表达式,只是运行期只读。反过来constexpr int n = rand();直接编译不过。constinit比较容易误解,它跟只读没关系,它只保证变量在程序启动阶段完成初始化,不引入运行期的动态初始化,从而避免"静态初始化顺序"带来的随机崩溃。我们之前有个项目有一批全局查表用的数组,改成constinit之后,之前偶发在启动阶段读空数据的崩溃就消失了。
至于const成员变量在类里的初始化,只能走构造函数初始化列表或者默认成员初始化器(C++11 起),不能在构造函数体里赋值。原因很简单:进入函数体时对象已经构造完成,const 成员已经没有"赋值"的资格了。这个限制经常让第一次写 const 成员的人卡住。
2. 顶层 const 与底层 const:指针声明里那个最容易读错的 const
2.1 从右往左读:一次读对三种写法的口诀
指针声明里 const 的位置决定了它修饰的是指针本身还是指针指向的对象,这是 C++ 里最经典的阅读障碍之一。我教人的办法是从变量名往右读,再往左读,也就是所谓"从右往左"读法:
int x = 1, y = 2; const int* p1 = &x; // p1 是"指向 const int 的指针" int const* p2 = &x; // 与 p1 完全等价(const 在 int 前后没区别) int* const p3 = &x; // p3 是"const 的指向 int 的指针" const int* const p4 = &x; // 指向 const int 的 const 指针 *p1 = 5; // 错:不能通过 p1 改对象 p1 = &y; // 对:指针本身可改 p3 = &y; // 错:指针本身不可改 *p3 = 5; // 对:指向的对象可改p1和p2携带的叫底层 const(low-level const),意思是"被指向的对象是 const"。p3携带的叫顶层 const(top-level const),意思是"指针这个对象本身是 const"。p4两个都带。对引用来说不存在顶层 const,因为引用一旦绑定就不能改指向,你写int& const r是非法的,只能写const int& r,它永远是底层 const。
我个人的记忆方式是:const紧贴着谁,就管谁。const int*里 const 贴着int,所以管的是一堆 int,指针本身不受管;int* const里 const 贴着*,管的是指针。这个技巧比背诵"从右往左读"更直观,写代码时也不容易写错。
2.2 为什么 int** 不能变成 const int**
这是我认为 const 规则里最优雅也最反直觉的一条。先看结论:
int* p = nullptr; const int** q = &p; // 编译错误 int* const* r = &p; // 编译通过很多人的第一反应是"加 const 应该是更安全的,为什么反而不允许"。原因是如果允许,会打开一个能修改真正 const 对象的漏洞:
// 假设第一行允许编译 const int c = 10; int* p = nullptr; const int** q = &p; *q = &c; // 现在 p 指向了 c,而 p 的静态类型是 int* *p = 20; // 通过 p 修改了 const 对象 c —— 完全合法,但 c 是只读的这个链条一旦打通,const的保证就彻底失效了。所以标准规定:在多级指针的限定转换中,如果你想在第 n 级增加 const,那么从第 n+1 级开始到最深层,必须原本就已经带了 const。int**转int* const*合法,是因为最深层(int那一层)没被加 const,只在第 2 级加了,而第 2 级已经是最后一层。想转到const int**就要在第 3 级加 const,但第 2 级还没带 const,所以拒绝。
顺带回答一个网上被问得很多的问题:"顶层 const 和底层 const 可以相互赋值吗?"得分开看。拷贝赋值时顶层 const 会被忽略,const int a = 1; int b = a;完全合法,因为拷贝出来的是值,新变量的 const 属性由接收方决定。底层 const 则不能被丢弃,const int* p; int* q = p;一定报错,除非你用const_cast,那就进入下一节的话题。另外,非 const 可以隐式转成 const,反过来不行,这正是"只读入口可以指向可写对象,可写入口不能指向只读对象"这条安全直觉在类型系统里的体现。
2.3 const_cast 的合法边界在哪里
const_cast是唯一能去掉 const 的转换。它有一个绝对的红线:如果被指向的对象本身是以const定义的真实只读对象,你把 const 拔掉再写,就是未定义行为。编译器可能把常量折叠进去,也可能把数据放进只读段,你写进去要么没效果,要么直接段错误。
它的合法用途其实只有一个场景:你手里有一个接口拿到了const修饰的入口,但你明确知道它背后指向的是非 const 对象。典型例子是在非 const 成员函数里复用 const 成员函数的实现(这个模式后面第 4 节会展开),或者调用一个老旧的、参数没有加 const 的第三方接口:
// 第三方库函数,参数本该是 const,但历史原因没加 void legacy_print(char* s); void wrapper(const std::string& s) { // 前提:legacy_print 承诺不会修改 s 的内容 legacy_print(const_cast<char*>(s.c_str())); }这里我加了一句注释说明前提。写const_cast的时候一定要在代码里写清楚为什么安全,因为下一个维护代码的人看到const_cast会本能地怀疑。我自己定的规矩是:const_cast出现的地方必须有注释,且整个项目里这种东西不应该超过个位数。如果一个模块里到处是const_cast,那说明 const 的接口设计从根上就错了,应该改设计而不是到处打补丁。
3. const 参数与返回值:写在签名上的承诺,以及不该写的地方
3.1 值参数上的 const:一个纯粹的实现细节
先看这段代码:
void process(int count) { count = normalize(count); // 先归一化 // ... } void process(const int count) { // 常见但没什么意义的写法 // count 只能读 }在值参数上加 const 的作用只有一个:防止函数体内不小心改了它。这本身没啥错,但它有个容易被忽略的性质——顶层 const 在函数类型里会被忽略。也就是说void f(int)和void f(const int)声明的是同一个函数,你不能靠加 const 值参数来重载。这就导致一个常见的不一致:头文件里声明写成void f(int),实现文件里写成void f(const int),编译能过,但看代码的人会困惑,甚至有的团队工具会报警告。
我的做法是:值参数只在定义(实现)里加 const,声明(头文件)里不加。这样既保护了函数体,又不会在读签名的时候产生误导。如果某个值参数你既不想改它、又希望读者一眼看出它是只读的,其实更推荐把它改成const&——但那会引入下一节讲的三个副作用,所以对 POD 类型(int、double、指针等)直接用值就行,别折腾。
顺带说一句const和volatile可以组合成const volatile,在嵌入式读硬件寄存器时很常见,含义是"我不能改它,但硬件可以改它"。这个组合不在本文主线里,但读驱动代码时遇到别懵。
3.2 const 引用参数:想避免拷贝,就要接受它的三个副作用
const T&作为参数是 C++ 中最常用的传参形式,它的好处是避免拷贝、能接受临时对象、能接受各种可隐式转换的类型。但代价是三个必须知道的行为:
第一,它绑定临时对象时,那个临时对象只在函数调用期间有效。函数返回之后再拿它的引用就是悬垂。所以你不能把一个const T&参数原样返回出去:
const std::string& bad(const std::string& s) { return s; // 危险:如果调用方传的是临时对象,返回的就是悬垂引用 } auto& r = bad("hello"); // 悬垂 std::string r2 = bad("hello"); // 侥幸能跑,但仍然是错的第二,const T&会做隐式转换。如果函数签名是void f(const std::string&),而你传的是"hello"或者0,编译器可能会构造一个临时std::string。这在大多数情况下是好事,但如果你重载了一堆函数,隐式转换会让重载决议变得难以预测。有个经典问题:void f(const std::string&)和void f(bool)同时存在,传nullptr或者0的时候会调用到f(bool),因为标准转换优先于用户定义转换。这类坑没有通用解法,只能靠少用重载、多用明确的类型。
第三,const T&参数在支持移动语义的场合会让移动失效。函数内部要拿这个参数去构造新对象时,const会让std::move(s)退化成拷贝:
void sink(const std::string& s) { owned_ = std::move(s); // 实际是拷贝,因为 s 是 const 引用 }所以现在越来越多的人用"按值传参 + 内部移动"的写法:void sink(std::string s) { owned_ = std::move(s); }。左值时传参那一次拷贝,右值时零拷贝,而且函数体里可以自由移动。这个写法在 C++11 之后被广泛推荐,代价是左值调用多一次移动(很便宜)。我在新代码里已经全面切到这个模式,除非参数明确只是读取、不会存下来。
3.3 返回 const 值是最容易被抄错的习惯
const std::string getName() const;这种写法在十几年前的代码规范里非常流行,理由是"防止调用方对返回值做奇怪的事情",比如getName() = "x"。但这在 C++11 之后基本成了反模式,因为返回 const 值会阻止移动:
const std::string makeName() { return "guest"; } auto a = makeName(); // a 是 std::string,从 const 返回值拷贝 std::string b = makeName(); // 同样是拷贝,不是移动auto推导会自动丢掉顶层 const,所以a的类型是std::string,但它初始化的来源是一个const std::string右值,右值引用绑不上 const,只能走拷贝构造。对于大对象,这个拷贝是实打实的开销。更糟的是,返回值加 const 会让一些泛型代码里的类型推导变得别扭,比如decltype(makeName())得到的是const std::string,再做std::move也无法真正移动。
现在我的规则很简单:返回值不加顶层 const。想防止getName() = "x"这种写法,可以在类设计层面把它变成返回引用,或者干脆让它无法赋值(返回 const 引用就能阻止对临时对象的赋值,但这也带来其他问题)。为了防一个几乎没人会犯的错误而牺牲移动语义,不划算。
3.4 返回 const 引用与 const 指针:悬垂和封装的取舍
返回const T&是成员函数里非常常见的模式,它避免拷贝,又保证调用方不能改内部状态。但它有两个隐患。
第一个是悬垂。如果返回的引用指向的是函数内部的临时对象,或者指向的成员在后续操作中被销毁了,调用方拿到的是野引用。最典型的错误是返回一个局部static的引用然后多线程下被改,或者返回vector里某个元素的引用然后容器被clear()了。我的经验是:返回const&的接口,必须在注释或者文档里说清楚"这个引用的有效期绑定于哪个对象的生命周期"。
第二个是封装被打破。返回内部成员的const&会暴露内部的存储结构,将来你把内部实现从std::string换成std::string_view或者string_view的包装,接口就不得不改。所以在公共 API 上,我更倾向于返回值(依赖 RVO 和移动,成本很低),只在性能敏感的循环内部访问器上返回const&。
指针这边同理。const T*表示"指向只读对象的指针",接收方不能改对象,但可以改指针指向别处。注意一个细节:函数返回const T*时,调用方拿到的是一个值(指针本身是值),所以顶层 const 在这里也没什么意义,T* const作为返回类型同样是反模式。
4. const 成员函数:const 正确性的地基,和 mutable 这个正当出口
4.1 this 指针在 const 成员函数里变成了什么
成员函数末尾那个 const,作用是把成员函数内部的this指针类型从T*变成const T*。这就是为什么 const 成员函数里不能改成员变量的值,也不能调用同类里非 const 的成员函数——因为this已经是 const 了,通过它调用非 const 成员函数等于丢弃底层 const。
class Counter { public: int value() const { return n_; } // 可以 void bump() { ++n_; } // 非 const int bad() const { bump(); // 错:const 成员函数里不能调非 const 成员函数 return n_; } private: int n_ = 0; };这里有个值得记住的推论:const 对象只能调用 const 成员函数。所以如果你写了一个bool operator==(const Counter&) const却忘了末尾的 const,那么当Counter对象是 const 的时候,连==都用不了,报错信息会非常难懂("no match for operator==",然后列出一长串候选)。这是新人最常遇到的 const 相关编译错误之一,也是我建议所有比较运算符、访问器一律加 const 的原因。
构造函数和析构函数不能加 const 限定。构造函数里对象还没成型,"const 对象"这个概念还不适用;析构函数如果加 const,delete this之类的操作就没法做了。静态成员函数也不能加 const,因为它没有this。
4.2 const 重载是怎么工作的,以及怎么避免写两份逻辑
同一个名字的成员函数可以同时存在 const 版本和非 const 版本,这叫 const 重载。调用时按对象的常量性选择:const 对象调用 const 版本,非 const 对象优先调用非 const 版本。
class Buffer { public: char& operator[](std::size_t i) { return const_cast<char&>(static_cast<const Buffer&>(*this)[i]); } const char& operator[](std::size_t i) const { if (i >= size_) throw std::out_of_range("index"); return data_[i]; } private: static constexpr std::size_t size_ = 1024; char data_[size_]{}; };这个写法是《Effective C++》里推的经典模式:把真正的逻辑写在 const 版本里,非 const 版本通过两次转型复用它。第一步static_cast<const Buffer&>(*this)把非 const 对象看成 const 对象,从而调用到 const 版本;第二步const_cast<char&>把返回值上的 const 去掉。两次转型的方向必须是"先加 const 再减 const",顺序反了会出问题。
为什么值得这么写?因为边界检查、断言、日志这些逻辑只写一份,不会两边不同步。我见过太多项目里 const 和非 const 版本各写一份,后来加了个边界检查只改了一边,const 路径上就留了个越界读的隐患,这种 bug 在测试里很难覆盖到。
需要提醒的是,operator[]的 const 版本返回const char&,这意味着const Buffer b; b[0] = 'x';会编译失败——这正是我们想要的。但如果返回类型写成char(按值),那就失去了保护,const 版本返回的值是拷贝,改它不影响原对象,虽然安全但语义变了。标准库的std::vector<bool>就是这样一个特例,它的 const 版本返回的也是bool而不是const bool&,因为底层是位压缩存储,没法返回引用。这是个历史包袱,知道就好。
4.3 mutable 不是后门,是给"逻辑常量"用的
有些操作不改变对象的"可观察状态",但确实需要写成员变量。这时候mutable是正当的。最典型的三类用途:
缓存和惰性求值。一个解析器类,第一次调用result()时才去解析,解析结果缓存起来。对使用者来说result()是只读操作,但对实现来说需要写缓存,所以缓存成员加mutable。
同步原语。在多线程代码里,const 成员函数如果只是读操作,通常也需要共享锁或者互斥量来保护,而加锁本身是写操作,所以互斥量成员加mutable。
class ConfigCache { public: const std::string& get(const std::string& key) const { std::lock_guard<std::mutex> lk(mtx_); auto it = cache_.find(key); if (it != cache_.end()) return it->second; return cache_.emplace(key, loadFromDisk(key)).first->second; } private: std::string loadFromDisk(const std::string& key) const; mutable std::mutex mtx_; mutable std::unordered_map<std::string, std::string> cache_; };这里有个实打实的坑要提醒:返回的是unordered_map里元素的引用。unordered_map是节点式容器,rehash 只会让迭代器失效,元素的引用和指针仍然有效,所以这个写法是安全的。但如果你把容器换成std::vector,插入导致扩容后之前返回的引用立刻悬垂。所以"在 const 成员函数里返回内部容器元素引用"这件事,一定要先确认容器的引用稳定性。这条经验我在 review 里反复提,因为从unordered_map换成vector时,编译器不会报任何错。
计数和统计。比如"被调用次数"这类只用于观测的计数器,加mutable是合理的。但这里要小心:mutable成员参与了对象状态的修改,如果多个线程同时调用同一个 const 对象的方法,mutable的计数器不加保护就是数据竞争。const 成员函数并不自动等于线程安全,这一点必须写进团队规范里,否则新人会以为const就是线程安全的意思。
注意:
mutable应该只用于"不影响对象逻辑状态"的成员。如果一个变量的修改会让两个内容相等的对象在operator==下表现得不一样,那它就不该是mutable。
4.4 容器与迭代器:const_iterator、cbegin 与 const 传播
标准容器的 const 成员函数返回的是const_iterator,这是 const 正确性在泛型代码里的延伸:
std::vector<int> v{1, 2, 3}; const std::vector<int>& cv = v; auto it1 = v.begin(); // iterator,可以 *it1 = 5; auto it2 = cv.begin(); // const_iterator,不能改元素 auto it3 = v.cbegin(); // 显式要 const_iterator // 泛型代码里更稳的写法 template <typename Container> void print(const Container& c) { for (auto it = c.begin(); it != c.end(); ++it) { /* 只读 */ } }cbegin()/cend()是 C++11 加的,好处是即使你手里是一个非 const 容器,也能明确地拿到只读迭代器。在泛型代码里,c.begin()的返回类型依赖c的常量性,写起来会绕,直接cbegin()更省心。不过要注意,cbegin()是const成员函数,返回的迭代器是只读的,如果你在遍历中要用迭代器去改元素,就不能用它。
还有一个与 const 相关但很少被提到的点:const对象在范围 for 循环里的行为。for (auto& x : cv)里的x类型是const int&,for (auto x : cv)是拷贝(丢掉 const)。如果你写for (auto&& x : cv),万能引用会推导成const int&,同样不能改。这些差异在模板代码里会放大,我一般建议在只读遍历时明确写const auto&,意图最清晰。
5. 让 const 正确性落到工程里:报错定位、智能指针与审查清单
5.1 一条编译错误的完整排查链路
先看一条真实报错:
error: passing 'const Foo' as 'this' argument discards qualifiers [-fpermissive] note: in call to 'void Foo::update()'看到discards qualifiers,意思就是"你想把一个带 const 的对象当不带 const 的对象用"。排查分三步走,我通常按这个顺序来。
第一步,找谁被 const 了。报错里那个const Foo是怎么来的?可能是函数参数是const Foo&,可能是成员函数末尾有 const 导致this是const Foo*,也可能是你拿到了一个const容器的元素引用。顺着报错里的note往上翻,找到变量声明。
第二步,找调用链。update()是非 const 成员函数,谁在调它?如果调用点本身就在一个 const 成员函数里,那么问题不是"这里要加 const",而是"这个函数到底该不该是 const"。
第三步,做设计判断。有两种修法:一是给update()加 const(如果它确实不改变逻辑状态,可能要给相关成员加 mutable);二是去掉调用路径上的 const(如果这个函数本来就是要改状态的)。选错方向会让 const 正确性一路崩坏。我见过的坏味道是:为了让编译通过,有人直接把 const 成员函数的末尾 const 删掉,然后连带删掉一整条调用链上的 const,最后整个模块的 const 全没了。改完不报错,但代码的可读性和安全性都降了一档。
我建议在团队里定一条规矩:删 const 要走评审,加 const 可以随手提交。因为加 const 最坏情况是编译报错让你继续补,而删 const 是一个不可见的倒退。
5.2 智能指针上的 const:顶层和底层的又一次分岔
智能指针把顶层/底层 const 的问题又演了一遍,因为shared_ptr<T>本身是一个类对象,它有一个"指向"的关系:
| 写法 | 能不能改指向 | 能不能改 T | 说明 |
|---|---|---|---|
std::shared_ptr<T> | 能 | 能 | 普通情况 |
const std::shared_ptr<T> | 不能 | 能 | 顶层 const,类似T* const |
std::shared_ptr<const T> | 能 | 不能 | 底层 const,类似const T* |
const std::shared_ptr<const T> | 不能 | 不能 | 两者都有 |
这里有个非常容易混的点:const std::shared_ptr<T>&作为函数参数,含义是"不能改这个引用指向哪个 shared_ptr,但可以通过它改 T 的内容"。很多人在参数上写const std::shared_ptr<T>&想表达"对象不可改",结果发现调用方还是能改 T,就把参数写成std::shared_ptr<const T>——这才是真正的只读。我现在写接口时会把这两个含义分得很清楚:要表达"这个对象只读",参数用const T&或者std::shared_ptr<const T>;要表达"我只借用这个引用,不改它的指向",并且需要用到 shared_ptr 本身的操作(拷贝、观察引用计数),才用const std::shared_ptr<T>&。
引用计数本身是怎么实现的?它在控制块里有一个mutable的原子计数,所以即使你对一个const std::shared_ptr<T>做拷贝,引用计数也能往上加。这是mutable在标准库里的一个非常正当的用法,跟我们前面讲的缓存是同一类需求:从使用者视角看,拷贝一个指针是"只读"操作,但内部要改计数。
weak_ptr有个限制:不能直接从weak_ptr<T>转出shared_ptr<const T>。如果你需要只读访问,通常的做法是在lock()之后用std::const_pointer_cast转成shared_ptr<const T>,或者在设计接口时就一路用shared_ptr<const T>存。这个转换本身是安全的(加 const 不会破坏任何保证),但如果反过来const_pointer_cast<T>去掉 const 就要非常小心,跟const_cast一样的红线。
5.3 和标准库/第三方库打交道时的 const 摩擦
标准库的接口设计是 const 正确性的教科书,但也有些让人不舒服的摩擦点。
最典型的是catch (const std::exception& e)。为什么 catch 要加 const 引用?std::exception::what()是const noexcept的,加了 const 引用之后依然能调用,而且能绑到任何派生类异常(值传递会切片)。这里有个细节:throw出来的异常对象,即使你 catch 到的是const引用,修改它也是没意义的(catch 里的修改不会传播出去),所以加 const 更安全。
另一个摩擦点是老旧的 C 接口。很多库函数的参数本该是const char*但写成了char*,或者回调函数签名里没加 const,逼着调用方做const_cast。我处理这类问题的经验是:在项目内部包一层适配器,把 const_cast 集中在一个文件里,并且在适配器里加静态断言或者注释说明契约。这样上层代码保持干净的 const 正确性,脏活只在一处。
还有模板库泛型代码里的类型推导,比如:
const std::string s = "demo"; auto x = s; // std::string,顶层 const 被丢弃 decltype(auto) y = s; // const std::string& decltype(s) z = "other"; // const std::stringauto和decltype在 const 上的差异经常导致模板代码里的类型不匹配。我的经验是在泛型代码里尽量显式写出 const,比如const auto&,别让推导规则来决定常量性,否则换一个编译器版本或者换一个类型,行为可能就变了。
5.4 一份可以贴在 Code Review 里的检查清单
- 只读参数用
const T&(大对象)或值传递(小对象),不要用const T值参数污染声明。 - 访问器、比较运算符、
hash一律加 const,否则 const 对象用不了。 - 返回值不加顶层 const,避免阻止移动。
- 返回
const&或指针时,注释里写明生命周期约束。 const_cast出现处必须有注释,且只用于"底层对象本身非 const"的场景。mutable只用于缓存、锁、观测计数,并且保证并发安全。- 智能指针参数分清
shared_ptr<const T>(对象只读)和const shared_ptr<T>&(引用只读)。 - 删 const 的操作要走评审,别在修编译错误时顺手删。
6. 别的语言里的 const:JavaScript、Vue 与 Rust 分别在约束什么
6.1 JS 的 const 约束的是绑定,不是内容
前端代码里const满天飞,但它的语义跟 C++ 的const完全不是一回事。JavaScript 里const只保证绑定(binding)不能被重新赋值:
const obj = { a: 1 }; obj.a = 2; // 完全合法,内容可以改 obj = { a: 3 }; // 报错,绑定不能改 const arr = [1, 2]; arr.push(3); // 合法 arr = []; // 报错想真正禁止修改内容,得用Object.freeze(obj)。
const frozen = Object.freeze({ a: 1 }); frozen.a = 2; // 静默失败(严格模式下抛 TypeError) console.log(frozen.a); // 仍然是 1而且Object.freeze是浅冻结,嵌套对象还是能改的。我看到过好几次前端事故就是"以为 const 能保护配置对象",结果某个地方的代码给config.headers加了个字段,影响了所有共用这个对象的地方。区别在于:C++ 的 const 是编译期由类型系统强制的,前端这类运行期的冻结是运行期检查,而且有静默失败的坑(非严格模式不报错)。如果同时写过 C++ 和 JS,一定要在脑子里把这两个 const 分开,别把 JS 的习惯带回去。
6.2 Vue 里 const props = defineProps 之后到底发生了什么
const props = defineProps({...})这行代码里,const起的作用是"props这个变量不能被重新赋值"。就这么简单,它跟"props 的内容只读"没有直接关系。
真正让 props 只读的是 Vue 内部的处理:props 对象在传入子组件时会被包装成浅层只读的响应式代理,你在子组件里写props.foo = 1时,开发模式会给出警告,生产模式下赋值不生效(或者在某些情况下会同步到父组件,这取决于你传的是引用还是值)。所以准确的说法是:const管住了变量绑定,Vue 的响应式系统管住了对象属性。两者的职责是分开的,只是写法上碰巧看起来像一体的。
另外两个细节值得一提。第一,defineProps的返回值必须赋给一个变量才能用,而这个变量在<script setup>里没法重新赋值,所以用const是自然而然的。第二,如果 props 里传的是对象或数组,父组件那边改了内容,子组件这边会看到新内容,因为只读只是"不能重新赋值属性指向的对象",不是深拷贝。所以子组件里如果要对 props 里的对象做变换,要自己先拷贝一份,别原地改——原地改会污染父组件的数据,这是 Vue 项目里非常常见的一类玄学 bug。
6.3 Rust 的 const fn 与 C++ constexpr 的相似与差异
Rust 里也有const,语义偏向"编译期常量",跟 C++ 的constexpr更接近。而const fn是可以在编译期求值的函数,它在 Rust 1.31 稳定下来(也就是 2018 年末的版本),之后能力一直在扩展,早期连if都不支持,后来逐步支持了循环、匹配等。它跟 C++ 的constexpr函数思路是一致的:同一个函数既能编译期求值,也能运行期调用,具体在哪求值由调用上下文决定。C++20 又加了consteval,强制必须在编译期求值,相当于补上了"只允许编译期执行"的那一档;Rust 这边对应的是const上下文的约束和宏系统,机制不太一样。
写 C++ 的人转去看 Rust,最容易犯的错是以为const就是 C++ 的const。Rust 的let x = 5;默认就是不可变的,要可变得写mut,而且变量的不可变性是默认值而不是附加修饰。这个设计方向的差异,和 C++ 里"默认可变、按需加 const"是反过来的。C++ 的const正确性是靠开发者自觉一点点加上去的,所以才会出现"加 const 容易、删 const 也容易"的现状。
写到这里,我的体会是:const的知识点本身都不难,难的是它在工程里的一致性。一个模块只要有一处该加没加,后面的人就会顺着一路不加,最后 const 只剩下个装饰作用;反过来,一开始把值参数、引用参数、返回值、成员函数这四处的规则定清楚,后面的代码自然就会往正确方向长。我们团队现在的做法很简单,就在新人入职的第一份文档里放上面那份检查清单,新模块从第一天起就按这个写,三个月后回头看,const 相关的编译报错几乎绝迹了。真正花时间的从来不是理解 const 本身,而是在一个已经有几万行不规范代码的项目里,怎么一点点把它加回来还不把别人正在开发的代码搞崩——我的建议是从新代码开始,别急着批量改旧代码,让正确性随着新功能的推进自然扩散。