15 / 80 · C++11 · 约 8 分钟
mutable:逻辑只读对象中的可变实现状态
mutable 允许特定非静态数据成员在所属对象为 const 时仍被修改,适合缓存、互斥量等实现细节。它不会自动提供同步,也不应掩盖逻辑状态变化;lambda 的 mutable 则控制按值捕获副本的可修改性。
物理变化与逻辑变化
const 成员函数通常不能通过 this 修改普通数据成员,但某些变化不应改变对象对外表达的值。例如查询时填充一个可重算缓存,返回结果仍由原始数据决定;此时 mutable 可明确标记“允许在只读查询里更新的实现状态”。
mutable 只能用于符合要求的非静态数据成员,不能直接修饰引用成员或顶层 const 成员。它并不是去除整个对象的 const,而是给某一个成员设定有边界的例外。是否属于逻辑状态仍要由接口设计判断,编译器无法替你决定缓存是否合理。
缓存要有完整失效策略
示例保存一个固定的小整数,第一次调用 doubled 时计算并标记缓存有效,第二次查询复用结果。对象的原始值不可被公开接口修改,所以不需要额外的失效分支;如果之后添加 set_value,就必须同时使缓存失效。
const 查询也可能被多个线程同时调用。两个线程无同步地更新 mutable 缓存仍会产生数据竞争。可以用 mutable mutex 保护查询中的缓存,或预先计算、使用其他经过证明的同步方案;mutable 本身既不是锁也不是原子操作。
lambda 中的同名关键字
对按值捕获的 lambda,mutable 使调用运算符不再默认是 const,于是可以修改闭包对象里保存的捕获副本。它不会把捕获方式改成引用,也不会让外部原变量随副本改变。示例递增副本后检查外部变量仍然是原值。
按引用捕获时,能否修改目标取决于目标对象本身的限定及访问方式,不需要为了写入非 const 被引用对象而加 mutable。复制一个 mutable 闭包还会复制它的状态,两个闭包随后可能各自演进;返回闭包时要特别区分独立状态和共享引用。
容易答错的地方
- 为所有成员加 mutable 会掏空 const 接口的约束,应该只标记确实不改变逻辑值的实现细节。
- 缓存失效遗漏与线程竞争是两种独立问题,修复其中一个不意味着另一个也被解决。
运行一个例子
最低标准 C++11 · 完整程序 · 下载 .cpp
#include <cassert>
#include <iostream>
class Sample {
const int value_ = 3;
mutable bool ready_ = false;
mutable int cached_ = 0;
public:
int doubled() const {
if (!ready_) {
cached_ = value_ * 2;
ready_ = true;
}
return cached_;
}
};
int main() {
const Sample sample;
assert(sample.doubled() == 6);
assert(sample.doubled() == 6);
int original = 4;
auto next = [original]() mutable { return ++original; };
const int captured_value = next();
assert(captured_value == 5 && original == 4);
std::cout << sample.doubled() << ' ' << captured_value << ' '
<< original << '\n';
}
在本地编译
g++ -std=c++11 -Wall -Wextra -Wpedantic -pthread basics-mutable.cpp -o example && ./example预期结果
6 5 4
CHECK YOUR UNDERSTANDING
合上答案,试着解释。
auto f = [n]() mutable { return ++n; }; auto g = f;,初始外部 n 为 0。依次调用 f()、f()、g() 的结果是什么?
查看参考答案
依次得到 1、2、1,外部 n 仍为 0。f 先持有一个值为零的捕获副本,g 在调用发生前又复制了这一闭包状态;两者内部副本独立。mutable 允许修改各自副本,不会让它们共享一个计数器。若需要共享状态,应明确设计对象所有权及必要的并发同步。
继续查证
标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。