1. 一次编译报错引出的三个关键字:const、volatile、mutable 到底各管什么
前段时间同事甩过来一段代码,编译报错指向一个成员函数,他笃定地说“我这个函数一行成员都没改,凭什么不让我编译”。我扫了一眼函数签名,末尾少了个const;再往下翻,函数体里又在给一个缓存字段赋值。问题一次牵出两个关键字:const决定谁能调这个函数,mutable决定函数体内哪块状态可以破例。同一周我又在另一份老代码里看到volatile被当成线程间通信的开关用,注释写着“加 volatile 编译器就不会乱序了”。这三件事凑在一起,基本覆盖了const、volatile、mutable在日常工程里最常见的误解。
const是“只读承诺”,约束的是通过这个名字能不能改,而不是这块内存物理上是不是只读;volatile是“别优化我”,要求编译器每次访问都真实落到内存,但它跟线程安全没有半点关系;mutable是给const承诺开的一个受控后门,专门用来表达“逻辑上没变、物理上变了”的成员。搞清楚这三者的分工,很多诡异的编译错误和运行期问题会瞬间变得可解释。
这篇内容适合已经能写 C++ 但被这几个关键字绕晕的人,也适合从 C 或 Java 转过来的朋友——C 里的const、Java 里的final跟 C++ 的const语义差异比想象中大得多。我会从存储布局讲到接口契约,从汇编层面看编译器究竟做了什么,最后给一张排错速查表。所有代码都可以直接编译复现,编译环境基于常见的 GCC/Clang 工具链,标准选 C++20。
1.1 先把职责边界摆清楚
理解这三个关键字最快的办法,是问自己三个问题:谁有权修改?修改发生在什么阶段?编译器能不能假设“这个值不会变”?
const回答的是“访问路径上有没有写权限”。它是一层类型系统层面的约束,编译器据此做优化,也据此拒绝你的赋值语句。它跟“这块内存在不在只读段”没有必然关系。
volatile回答的是“这次访问能不能被省略或合并”。它假设这块内存会被程序之外的力量改变,比如硬件寄存器、信号处理函数。编译器必须老老实实每次都去读。
mutable回答的是“在只读上下文里,哪些成员仍然允许改”。它是const成员函数的例外条款,用来支持缓存、计数器、锁这类“外部不可见”的状态。
| 关键字 | 约束对象 | 生效阶段 | 编译器行为 | 典型误用 |
|---|---|---|---|---|
const | 类型/访问路径 | 编译期为主 | 可常量折叠、可做只读优化、拒绝写操作 | 以为const变量一定在只读段 |
volatile | 访问动作 | 编译期 | 禁止缓存到寄存器、禁止删除冗余读 | 当成线程同步原语 |
mutable | 成员变量 | 编译期 | 允许在const成员函数中赋值 | 拿它隐藏真正的状态变更 |
这张表建议先记住,后面每一节都是围绕它展开的细节。很多人栽跟头,是因为把行与行之间的语义搞混了:const管的是“能不能写”,volatile管的是“读的时候要不要真的去读”,两者正交,完全可以叠加成const volatile——比如一个只读的硬件状态寄存器,既不允许你写,又必须每次真实读取。
2. const 约束的到底是什么:从存储布局到接口契约
2.1 const 对象真的躺在只读段里吗
刚接触 C++ 的人容易形成一种直觉:加了const的变量会被放进只读段,所以改它才会崩溃。这个直觉只对了一小半。
对于函数内的const局部变量,它通常在栈上分配——前提是你取了它的地址或者编译器判断需要给它一块可寻址的空间。如果不取地址,编译器在-O2下很可能直接把值折叠进指令,连栈空间都省了。这种情况下你用const_cast拿到指针去写,行为是未定义的,但不一定会立刻崩,因为那块内存可能压根不存在。
对于命名空间作用域的const变量,情况更有意思。C++ 标准规定,命名空间作用域的const变量默认具有内部链接(internal linkage),也就是说每个包含该头文件的翻译单元都会得到自己的一份独立副本。这跟extern const的行为完全不同。这个设计有历史原因——它让头文件里定义常量不会引发重复定义错误——但副作用是,两个翻译单元里取同一个const全局变量的地址,拿到的指针值可能不一样。我在一个跨模块的配置系统里被这个坑过一次:某模块用&Config::kDefaultTimeout做键去注册回调,另一个模块用同样的表达式查表,查不到,因为地址不同。修复办法是把定义改成inline constexpr(C++17 起支持内联变量),或者显式加extern只在一处定义。
提示:头文件里定义全局常量,C++17 之后优先用
inline constexpr,它能同时保证唯一性和编译期可求值,比裸const加extern的组合干净得多。
至于“放进只读段”,那是链接器层面的安排。const全局变量通常被放进.rodata,运行时该段被标记为只读页,写入会触发段错误。但这属于实现细节,标准并没有承诺这一点。你写的代码应该建立在语言语义上,而不是建立在“它一定在只读页”这个假设上。
2.2 指针声明读法:顶层 const 与底层 const 的分水岭
const int*、int* const、const int* const,这三个声明让无数人头皮发麻。我用的读法是从右往左念,const修饰它左边的东西;如果左边什么都没有,就修饰右边。
const int* p:p是指针,指向const int。指针本身可以改指向,指向的内容不能改。这叫底层 const(low-level const)。int* const p:p是常量指针,指向int。指针本身不能改指向,内容可以改。这叫顶层 const(top-level const)。const int* const p:两者都有。
区分顶层和底层不是学术游戏,它直接影响函数重载、模板推导和拷贝语义。顶层const在函数参数按值传递时会被忽略——void f(const int x)和void f(int x)是同一个函数签名,你不能靠它重载。而底层const会参与类型,const char*和char*是不同的类型,前者不能隐式转成后者(后者能转前者)。
还有一个高频困惑点:const迭代器不等于const_iterator。const std::vector<int>::iterator it表示迭代器本身不能改(不能++it),但它指向的元素可以改;std::vector<int>::const_iterator it表示指向的元素不能改。标准库容器同时提供begin()和cbegin(),就是为了让你在非 const 容器上也能拿到只读视图。我见过有人在const成员函数里写return data_.begin();,编译报错后百思不得其解,其实就是该用cbegin()。
2.3 const 成员函数是一份接口契约,不是编译器魔法
给成员函数加const,本质是在向调用方承诺:这个调用不会改变对象的可观察状态。编译器做的是把函数体内的this指针类型从X*变成const X*,于是所有非mutable的成员都变成只读。
这份契约的价值在于它能传导。一个const对象只能调用const成员函数,一个const引用参数只能调用const成员函数,一层层往上,最终让“这个函数不会改我”这件事在类型系统里被强制检查。这比写注释可靠一万倍。
但契约里有个关键词叫“可观察状态”,也就是逻辑常量性(logical constness),而不是位常量性(bitwise constness)。标准只要求位常量性——对象占用的那些字节不能被改。但工程上我们真正想表达的是逻辑常量性:对外看起来没变,内部维护个缓存、打个日志、加个锁,都不算违约。mutable就是为这个落差准备的,第 4 节会细讲。
还有一个常被忽略的点:const成员函数返回内部成员的非 const 引用,等于把后门开给了调用方,契约当场失效。正确做法是返回const引用,或者返回值。我在代码评审里对这条卡得很严,因为它会让const从“编译期保证”退化成“看调用方自觉”。
另外补充一个偏门但实用的技巧:const成员函数可以和非const版本构成重载,编译器根据对象的 cv 限定选择。标准库的operator[]就是典型:
T& operator[](std::size_t i) { return data_[i]; } const T& operator[](std::size_t i) const { return data_[i]; }这两个函数体通常一模一样,但少了任何一个,const对象就用不了下标,或者非 const 对象拿不到可写引用。
2.4 const 与 constexpr 的分工,别混着用
const说的是“运行期不许改”,constexpr说的是“编译期就能算出来”。前者是运行期概念,后者是编译期概念。一个const变量完全可能直到运行期才确定值,比如从配置文件读出来的超时时间;一个constexpr变量必须在编译期就有确定值。
const int a = read_from_config(); // 合法,运行期只读 constexpr int b = read_from_config(); // 非法,除非 read_from_config 是 constexprconstexpr函数的要求随着标准演进在放宽,C++14 允许局部变量和循环,C++20 允许constexpr虚函数和try块。但核心不变:给定编译期常量参数,它必须能算出编译期结果。
实际选择上,我的经验是:能constexpr就constexpr,不能就const,两者都不行才用普通变量。constexpr能带来更强的优化空间,也能用在需要常量表达式的地方,比如数组大小、模板非类型参数、switch的case标签。早期 C 里写const int n = 5; int arr[n];是变长数组,C++ 里这个写法合法恰恰是因为n是常量表达式,而不是因为它是const。
还有一条老规矩值得提:不要给返回值加const。const std::string make();这种写法在 C++11 之前被视为良好风格,因为它能阻止make() = "x"这种无意义赋值。但 C++11 引入移动语义后,返回const值会让接收方拿不到移动构造的机会,白白多一次拷贝。C++17 有保证的复制消除之后影响变小,但依然是坏习惯,该去掉就去掉。
3. volatile 被误解最深的十年:它不是同步,是“别优化我”
3.1 标准给 volatile 的语义只有一句话
标准对volatile的表述可以概括成:对volatile对象的访问属于可观察副作用,编译器不能省略、不能合并、不能把它缓存到寄存器里跨多次访问复用。仅此而已。
它没有承诺原子性,没有承诺内存序,没有承诺跨线程可见性,也不建立 happens-before 关系。C++11 之后这些语义由std::atomic和内存序模型负责,volatile跟它们完全不在一个层面上。
我见过最典型的翻车写法是这样的:
volatile int g_counter = 0; void worker() { for (int i = 0; i < 100000; ++i) { ++g_counter; // 以为这样就线程安全了 } }++g_counter在汇编层面是“读—改—写”三步,volatile只保证这三步都真实发生、顺序不被编译器打乱,但两个线程完全可能同时读到旧值,各自加一再写回,丢失一次更新。想让它正确,要么用std::atomic<int>,要么加锁。std::atomic的fetch_add在多数平台上会编译成一条lock前缀指令,这才是真正的原子读改写。
用一个生活化的类比:volatile像是要求你每次查余额都必须亲自去 ATM 机上看,不许记在脑子里;但它没解决“两个人同时取钱”的问题。ATM 机本身不排队,你还是会透支。排队这件事是互斥锁的职责。
3.2 真正需要 volatile 的三类场景
volatile不是没用,它的用武之地很窄但很硬。
第一类:内存映射的硬件寄存器。嵌入式开发里,外设寄存器被映射到特定地址,读它会清标志位,写它会触发动作。如果编译器认为“你连续读了两次同一个地址,第二次是冗余的”而优化掉一次,硬件行为就丢了。
volatile std::uint32_t* const status_reg = reinterpret_cast<volatile std::uint32_t*>(0x40001000); while ((*status_reg & 0x1) == 0) { // 必须每次真实读取,直到硬件置位 }这里的volatile一个都不能少:指针指向的对象是volatile,保证解引用真实发生;指针本身是const,防止不小心改指向。
第二类:信号处理函数与主流程共享的标志。信号处理函数里修改的变量,主循环里轮询检查,编译器不知道这两者之间的关系,可能把主循环里的读提到循环外。
volatile std::sig_atomic_t g_stop = 0; void on_signal(int) { g_stop = 1; } int main() { while (!g_stop) { /* 干活 */ } }标准明确推荐用std::sig_atomic_t,它在信号处理上下文里能保证单次读写不被打断。注意是“单次读写”,不是“读改写”。
第三类:setjmp/longjmp之间被修改的局部变量。这条比较冷门。如果局部变量在setjmp之后被修改,且没加volatile,longjmp跳回来之后它的值是不确定的,因为可能只存在寄存器里而被恢复覆盖。绝大多数现代 C++ 代码不用这套机制,但读老代码时会遇到。
除了这三类,我在业务代码里基本见不到volatile的正当用途。顺带一提,C++20 弃用了一批volatile的用法,包括volatile修饰的函数参数、返回类型、结构化绑定,以及volatile对象的++、--和复合赋值。原因是这些写法语义混乱、容易误用,标准委员会选择了收紧。新代码里再写volatile int x; x += 1;会收到弃用警告。
3.3 拿 volatile 做线程同步为什么会翻车
除了原子性问题,还有一个更隐蔽的坑:编译器重排与 CPU 重排。
volatile只约束编译器对volatile访问之间的相对顺序——在同一个线程内,两次volatile访问不会被交换。但它不约束非volatile访问与volatile访问之间的顺序,也不对 CPU 层面的乱序执行施加任何限制。现代多核处理器有自己的存储缓冲和失效队列,指令真正生效的顺序可能和程序顺序不同。
一个经典的双标志握手场景:
int g_data = 0; volatile bool g_ready = false; // 线程 A g_data = 42; g_ready = true; // 线程 B while (!g_ready) {} int v = g_data; // 可能读到 0线程 B 看到g_ready == true不代表能看到g_data == 42,因为两条写操作之间没有建立 happens-before。正确写法是用std::atomic<bool>配合 release/acquire 语义,或者干脆用互斥量。
std::atomic<bool> g_ready{false}; // 线程 A g_data = 42; g_ready.store(true, std::memory_order_release); // 线程 B while (!g_ready.load(std::memory_order_acquire)) {} int v = g_data; // 保证看到 42release 保证它之前的写操作对 acquire 之后的读可见,这才是我真正想要的效果。volatile在这件事上什么都不提供。
我的判断标准很简单:涉及数据竞争,一律std::atomic或锁;涉及外部硬件或信号,才考虑volatile。两者永远不会互相替代。
4. mutable 的合理出口与滥用红线
4.1 缓存、惰性求值与统计计数
mutable存在的前提,是const成员函数承诺的“不变”指的是逻辑状态,而不是每一个字节。缓存是最典型的例子:一个解析器对象,第一次调用ast()时解析,之后复用结果;从调用方视角看,对象的语义状态没变,只是内部多了个缓存。
class Parser { public: const Ast& ast() const { if (!cache_) { cache_ = parse(); } return *cache_; } private: Ast parse() const; // 纯函数,不依赖外部可变状态 mutable std::optional<Ast> cache_; // 逻辑上不属于对象身份 };这里mutable用得干净,因为它只影响性能和内存,不影响任何可观察的语义。判断标准是:如果把这个缓存删掉,程序的行为(除耗时和内存外)应当完全一致。如果删掉之后行为变了,说明你藏了真正的状态变更,那是滥用。
但上面这段代码在并发环境下会炸。两个线程同时调ast(),都发现缓存为空,都去解析,然后同时写cache_,这是数据竞争。修法有两种:加mutable std::mutex,或者用std::call_once配合mutable std::once_flag。std::once_flag本身就是设计成可mutable的,因为它在const上下文里的修改是受控的、线程安全的。
class Parser { public: const Ast& ast() const { std::call_once(init_, [this] { cache_ = parse(); }); return cache_; } private: mutable std::once_flag init_; mutable Ast cache_; };统计计数是另一类正当用法。比如一个对象想知道自己的size()被调了多少次,这个计数显然不该影响对象对外的逻辑语义,但又必须在const成员函数里自增。
4.2 互斥量与调试钩子
把std::mutex声明成mutable几乎是并发类的标配。一个const成员函数要读内部数据,为了线程安全得先加锁,而lock()是非 const 操作,不加mutable就编译不过。
class Registry { public: std::size_t size() const { std::lock_guard<std::mutex> lk(mtx_); return items_.size(); } private: mutable std::mutex mtx_; std::vector<Item> items_; };这个用法完全正当,因为互斥量不属于对象对外的语义状态——没有哪个调用方会关心内部锁是锁定还是释放状态。它只是实现同步的工具。
调试钩子同理。有些代码会在const成员函数里往日志缓冲区追加一条追踪记录,这个缓冲区声明成mutable是合理的,前提是它不参与任何业务判断。一旦它开始影响返回值或者参与相等比较,性质就变了,从“可观察副作用”变成了“真实状态”。
4.3 lambda 里的 mutable 是同一套逻辑
mutable在 lambda 表达式里出现频率很高,很多人第一次见到时没意识到它跟类成员里的mutable是同一个概念的延伸。
lambda 的operator()默认是const的,也就是说按值捕获的变量在函数体里是只读的。加上mutable之后,operator()去掉const限定,捕获的副本就可以修改了。
int base = 10; auto f = [base]() mutable { base += 1; // 不加 mutable 编译不过 return base; }; f(); // 返回 11 f(); // 返回 12,因为它改的是自己的副本这里的base是 lambda 对象的一个成员,mutable允许在默认const的调用运算符里改它。注意它改的是拷贝,外面那个base完全没动。很多人在这里踩坑,以为mutable能让 lambda 修改外部变量,实际上要修改外部变量得用引用捕获[&base],那样根本不需要mutable。
放到标准库语境里看,函数对象经常被const引用传递——比如std::for_each接收的谓词就是按值传递后以非 const 使用的,但std::set的比较器、std::priority_queue的比较器等场景会以const方式使用。如果你的函数对象内部需要维护状态(比如计数),就得把成员声明成mutable。这就是同一个问题在不同语法位置的重现。
4.4 滥用的代价:破坏 const 契约
mutable最大的风险,是让const从“编译期可验证的保证”退化成“看作者自觉”。一旦团队里有人拿mutable藏真正的状态变更,所有基于const的推理都会失效——包括编译器的优化、并发代码的正确性论证、以及阅读者的心智模型。
有三个信号提示你滥用了:
信号一,mutable成员参与相等比较或哈希。如果一个对象被放进std::unordered_set作为键,而它的mutable成员在const成员函数里被改了,哈希值就变了,容器的不变量被破坏,查找会失效。这类 bug 极难排查,因为从代码上看每一步都“合法”。
信号二,两个const成员函数调用顺序不同,最终结果不同。正常情况下const成员函数应该是可交换的,如果a.f(); a.g();和a.g(); a.f();结果不一样,说明对象内部有隐藏状态在被修改,这个状态就不该藏在mutable后面。
信号三,需要给mutable成员加锁才能保证基本正确性,而锁又带来新的死锁风险。缓存加锁升级到最后往往变成自己实现一套同步,这时候不如重新设计:把缓存挪到对象外面,用一个独立的缓存管理器持有。
我个人的红线是:mutable成员不得出现在任何影响可观察行为的路径上。缓存、计数器、锁、日志缓冲区可以;业务字段一律不行,如果确实需要改,那这个函数就不该是const。
5. 动手验证:把编译器的行为摊开看
5.1 编译环境与选项准备
光看文字容易似懂非懂,最好的办法是把编译器生成的东西打出来看。我用一份单文件工程演示,工具链用常见的 GCC 或 Clang,标准统一-std=c++20,优化级别用-O2,汇编输出选 Intel 语法,可读性好一些。
g++ -std=c++20 -O2 -masm=intel -S demo.cpp -o demo.s如果机器上是 Clang,把g++换成clang++即可,-masm=intel两边都支持。看汇编的时候重点盯三件事:常量有没有被折叠成立即数、volatile访问有没有被合并、写只读对象的代码有没有被优化掉。这三件事分别对应const、volatile和未定义行为。
5.2 用汇编看 const 折叠与 volatile 强制访存
先看const的常量折叠:
int folded() { const int k = 42; return k * 2; } int not_folded(int x) { const int k = x * 2; return k * 2; }-O2下folded()的汇编大概只有两行,直接把 84 放进返回寄存器,栈上根本不分配k。not_folded则会老老实实算x * 4。这说明编译器对待const局部变量的态度完全取决于它能不能在编译期确定值,跟“这块内存在哪”无关。
再看volatile:
volatile int flag = 0; bool wait_flag() { while (flag == 0) { } return flag != 0; }汇编里会出现一个带条件跳转的循环,每次迭代都有一次真实的内存加载。把volatile去掉再编译,-O2下整个函数可能被优化成一次加载或者干脆返回常量,因为编译器认为循环里没有任何可观察副作用。
这里有个细节值得留意:volatile保证的是“每次访问都真实发生”,但它不保证多个volatile访问之间不会插入其他指令,也不保证 CPU 执行时不会乱序。前面第 3.3 节讲的就是这件事。
5.3 const_cast 写只读对象的未定义行为复现
这段代码我建议你在本地跑一次,亲自感受一下“未定义行为”到底长什么样。
#include <cstdio> int main() { const int k = 7; int* p = const_cast<int*>(&k); *p = 9; std::printf("k=%d *p=%d\n", k, *p); }在-O2下,常见输出是k=7 *p=9——同一个地址,两个值。原因很简单,编译器把k当成编译期常量,printf里直接用立即数 7,而*p走的是真实内存读。这说明const_cast去掉const后写入一个真正const的对象是未定义行为,标准允许编译器做出任何反应,包括“看起来一切正常”。
如果你把k改成非const变量再用const引用绑定,const_cast后写入就是合法的:
int x = 7; const int& r = x; const_cast<int&>(r) = 9; // 合法,x 本身不是 const这条规则的实用意义在于:const_cast只能用来去掉“通过某个路径加上去的”const,不能用来去掉对象本身的const。很多老接口设计成接收const char*但内部又要改,就靠const_cast转回去,这种做法能工作的前提是调用方传进来的确实不是真正const的对象。
还有一条更隐蔽的坑:把const对象存进std::vector之类的容器。std::vector<const T>在标准里是未定义行为(分配器不支持const类型),但因为容器本身要求元素可移动、可赋值,const元素根本没法满足。实践中更常见的是std::map<Key, const Value>,某些实现能编译通过但一赋值就炸。这类问题编译器一般会给比较明确的报错,看到const相关的模板错误,先怀疑是不是往容器里塞了const元素。
6. 排错速查:编译期与运行期的典型症状
6.1 编译报错对照表
这几个关键字的报错信息经常绕圈子,我把高频的几类整理成表,遇到时可以直接对着找。
| 报错关键词 | 最可能的原因 | 处理方式 |
|---|---|---|
passing 'const X' as 'this' argument discards qualifiers | 在const对象上调了非const成员函数 | 给成员函数补const,或确认该对象是否真该是const |
assignment of read-only member | const成员函数里给普通成员赋值 | 确认是逻辑常量性则加mutable,否则重构函数 |
invalid conversion from 'const char*' to 'char*' | 字符串字面量是const,往非 const 指针塞 | 用const char*接,或拷贝一份到std::string |
binding reference of type 'X&' to 'const X' discards qualifiers | 非 const 引用绑定 const 对象 | 改用const X& |
redefinition of 'xxx'且涉及 const 全局变量 | 头文件里定义了非inline的const全局变量 | 改成inline constexpr或在头文件里只声明 |
no matching function for call且候选里有 const 版本 | const成员函数和非 const 版本并存但对象 cv 不匹配 | 检查对象本身是否被声明为const |
volatile相关弃用警告 | C++20 弃用了部分volatile用法 | 改用std::atomic或去掉volatile |
表里第一条是出现频率最高的。有个快速判断法:看报错里discards qualifiers的位置,如果指向this,那就是成员函数的const缺失;如果指向参数,那就是引用类型的 const 不匹配。
6.2 运行期诡异现象的定位思路
编译期报错好歹有明确指向,运行期的问题才是真折磨。我按症状列几条排查路径。
症状一:多线程下计数偏小、标志偶尔丢失。先查是不是用了volatile做同步。是的话换成std::atomic,用fetch_add、load/store,并且明确内存序。如果本来就是普通变量,那就是彻底的数据竞争,加锁或者改原子。
症状二:const对象被改了,但只在某些编译选项下出问题。大概率是const_cast写了真正const的对象,触发了未定义行为。排查办法是把const对象放到只读段(全局const),如果一写就段错误,就锁定位置了。修复方向是去掉这条const_cast路径,从设计上分离读写接口。
症状三:缓存导致的返回值不一致。一个mutable缓存字段在const成员函数里被更新,但更新逻辑依赖了外部可变状态,导致同一个对象两次调用返回不同结果。这类问题的特征是“偶发、不可复现、跟调用时序有关”。排查时把所有mutable成员列出来,逐个检查它的更新是否只依赖对象自身的不可变状态。
症状四:std::set或std::unordered_set里的元素“消失”了。检查元素类型里有没有mutable成员参与比较或哈希。有的话,紧张的是容器的不变量被破坏了,解决办法要么把该成员移出比较范围,要么干脆别让它mutable。
症状五:volatile变量在-O0下正常,-O2下出问题。这说明有另一处访问没有被volatile覆盖。常见情况是声明了volatile的指针,但通过另一个非volatile的别名访问同一块内存。volatile是跟着访问路径走的,不是跟着内存走的,每一条访问路径都得标上。
6.3 我个人的几条使用准则
写到最后,把我在实际项目里固定下来的几条习惯分享出来,都是踩过坑之后形成的。
准则一:const默认加上,需要改的时候再摘。局部变量、成员函数、引用参数,一律先写const。摘掉const的成本远低于补上const的成本,因为后者要改调用链上的所有签名。
准则二:volatile只出现在硬件访问和信号处理里。业务代码里见到volatile,我基本会当成一个待处理的信号,多半是有人想用它解决并发问题但用错了。项目里可以加一条静态检查规则,禁止在业务模块里出现volatile,需要的地方走专门的外设访问层。
准则三:mutable成员必须写注释说明为什么它不影响逻辑状态。这条看起来啰嗦,但它是防止滥用的最有效手段。写不出这句注释,说明你自己也说不清它为什么该是mutable。
准则四:const成员函数不要返回内部非 const 引用。返回const引用或者值,把写权限收回到类内部。如果确实需要暴露可写视图,提供一个显式的非const接口,名字里带Mutable之类的后缀,让调用方一眼看出这里有副作用。
准则五:涉及并发,先想数据竞争,再想优化。std::atomic的默认内存序是seq_cst,最严格也最慢,但先写对再谈快。等 profile 出热点再考虑降级到acquire/release,而且降级必须配注释说明为什么这个位置可以放松。我见过太多为了“性能”把内存序改成relaxed最后导致偶发丢失更新的案例,排查成本远超那点性能收益。
准则六:跨翻译单元的常量用inline constexpr。这条能一次性规避内部链接导致的地址不一致问题,也能保证常量表达式可用。C++17 之前的项目用extern const在头文件声明、在单个源文件定义,效果类似但更啰嗦。
还有一条偏经验的观察:const用得好不好,基本能看出一个人对接口设计的理解深度。把const当成“编译器逼我写的装饰”的人,接口通常暴露过多的可变状态;把const当成契约一部分的人,接口边界会清晰很多,调用方也更难误用。这个差别在小项目里不明显,一旦代码量上去、并发参与进来,就是 bug 率的分水岭。