
上个月帮同事看一段代码一个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 constC17 起支持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四个词的分工这四个词经常被混着用我把它们的分工列成表格更清楚关键字引入版本核心含义典型场景constC98类型修饰通过此入口不可修改只读参数、const 成员函数constexprC11可用于常量表达式编译期可求值编译期数组长度、常量函数constevalC20立即函数必须编译期求值编译期校验、编译期字符串处理constinitC20保证静态初始化不保证只读避免静态初始化顺序问题需要强调一点constexpr变量隐含const但const变量不一定是constexpr。比如const int n rand();完全合法它不是常量表达式只是运行期只读。反过来constexpr int n rand();直接编译不过。constinit比较容易误解它跟只读没关系它只保证变量在程序启动阶段完成初始化不引入运行期的动态初始化从而避免静态初始化顺序带来的随机崩溃。我们之前有个项目有一批全局查表用的数组改成constinit之后之前偶发在启动阶段读空数据的崩溃就消失了。至于const成员变量在类里的初始化只能走构造函数初始化列表或者默认成员初始化器C11 起不能在构造函数体里赋值。原因很简单进入函数体时对象已经构造完成const 成员已经没有赋值的资格了。这个限制经常让第一次写 const 成员的人卡住。2. 顶层 const 与底层 const指针声明里那个最容易读错的 const2.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携带的叫底层 constlow-level const意思是被指向的对象是 const。p3携带的叫顶层 consttop-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那么从第 n1 级开始到最深层必须原本就已经带了 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_castchar*(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); }。左值时传参那一次拷贝右值时零拷贝而且函数体里可以自由移动。这个写法在 C11 之后被广泛推荐代价是左值调用多一次移动很便宜。我在新代码里已经全面切到这个模式除非参数明确只是读取、不会存下来。3.3 返回 const 值是最容易被抄错的习惯const std::string getName() const;这种写法在十几年前的代码规范里非常流行理由是防止调用方对返回值做奇怪的事情比如getName() x。但这在 C11 之后基本成了反模式因为返回 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 对象这个概念还不适用析构函数如果加 constdelete this之类的操作就没法做了。静态成员函数也不能加 const因为它没有this。4.2 const 重载是怎么工作的以及怎么避免写两份逻辑同一个名字的成员函数可以同时存在 const 版本和非 const 版本这叫 const 重载。调用时按对象的常量性选择const 对象调用 const 版本非 const 对象优先调用非 const 版本。class Buffer { public: char operator[](std::size_t i) { return const_castchar(static_castconst 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_castconst Buffer(*this)把非 const 对象看成 const 对象从而调用到 const 版本第二步const_castchar把返回值上的 const 去掉。两次转型的方向必须是先加 const 再减 const顺序反了会出问题。为什么值得这么写因为边界检查、断言、日志这些逻辑只写一份不会两边不同步。我见过太多项目里 const 和非 const 版本各写一份后来加了个边界检查只改了一边const 路径上就留了个越界读的隐患这种 bug 在测试里很难覆盖到。需要提醒的是operator[]的 const 版本返回const char这意味着const Buffer b; b[0] x;会编译失败——这正是我们想要的。但如果返回类型写成char按值那就失去了保护const 版本返回的值是拷贝改它不影响原对象虽然安全但语义变了。标准库的std::vectorbool就是这样一个特例它的 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_guardstd::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_mapstd::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::vectorint v{1, 2, 3}; const std::vectorint 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()是 C11 加的好处是即使你手里是一个非 const 容器也能明确地拿到只读迭代器。在泛型代码里c.begin()的返回类型依赖c的常量性写起来会绕直接cbegin()更省心。不过要注意cbegin()是const成员函数返回的迭代器是只读的如果你在遍历中要用迭代器去改元素就不能用它。还有一个与 const 相关但很少被提到的点const对象在范围 for 循环里的行为。for (auto x : cv)里的x类型是const intfor (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_ptrT本身是一个类对象它有一个指向的关系写法能不能改指向能不能改 T说明std::shared_ptrT能能普通情况const std::shared_ptrT不能能顶层 const类似T* conststd::shared_ptrconst T能不能底层 const类似const T*const std::shared_ptrconst T不能不能两者都有这里有个非常容易混的点const std::shared_ptrT作为函数参数含义是不能改这个引用指向哪个 shared_ptr但可以通过它改 T 的内容。很多人在参数上写const std::shared_ptrT想表达对象不可改结果发现调用方还是能改 T就把参数写成std::shared_ptrconst T——这才是真正的只读。我现在写接口时会把这两个含义分得很清楚要表达这个对象只读参数用const T或者std::shared_ptrconst T要表达我只借用这个引用不改它的指向并且需要用到 shared_ptr 本身的操作拷贝、观察引用计数才用const std::shared_ptrT。引用计数本身是怎么实现的它在控制块里有一个mutable的原子计数所以即使你对一个const std::shared_ptrT做拷贝引用计数也能往上加。这是mutable在标准库里的一个非常正当的用法跟我们前面讲的缓存是同一类需求从使用者视角看拷贝一个指针是只读操作但内部要改计数。weak_ptr有个限制不能直接从weak_ptrT转出shared_ptrconst T。如果你需要只读访问通常的做法是在lock()之后用std::const_pointer_cast转成shared_ptrconst T或者在设计接口时就一路用shared_ptrconst T存。这个转换本身是安全的加 const 不会破坏任何保证但如果反过来const_pointer_castT去掉 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_ptrconst T对象只读和const shared_ptrT引用只读。删 const 的操作要走评审别在修编译错误时顺手删。6. 别的语言里的 constJavaScript、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函数思路是一致的同一个函数既能编译期求值也能运行期调用具体在哪求值由调用上下文决定。C20 又加了consteval强制必须在编译期求值相当于补上了只允许编译期执行的那一档Rust 这边对应的是const上下文的约束和宏系统机制不太一样。写 C 的人转去看 Rust最容易犯的错是以为const就是 C 的const。Rust 的let x 5;默认就是不可变的要可变得写mut而且变量的不可变性是默认值而不是附加修饰。这个设计方向的差异和 C 里默认可变、按需加 const是反过来的。C 的const正确性是靠开发者自觉一点点加上去的所以才会出现加 const 容易、删 const 也容易的现状。写到这里我的体会是const的知识点本身都不难难的是它在工程里的一致性。一个模块只要有一处该加没加后面的人就会顺着一路不加最后 const 只剩下个装饰作用反过来一开始把值参数、引用参数、返回值、成员函数这四处的规则定清楚后面的代码自然就会往正确方向长。我们团队现在的做法很简单就在新人入职的第一份文档里放上面那份检查清单新模块从第一天起就按这个写三个月后回头看const 相关的编译报错几乎绝迹了。真正花时间的从来不是理解 const 本身而是在一个已经有几万行不规范代码的项目里怎么一点点把它加回来还不把别人正在开发的代码搞崩——我的建议是从新代码开始别急着批量改旧代码让正确性随着新功能的推进自然扩散。