C++ / a working model

15 / 80   ·   C++11   ·   约 8 分钟

mutable:逻辑只读对象中的可变实现状态

先记住这句话

mutable 允许特定非静态数据成员在所属对象为 const 时仍被修改,适合缓存、互斥量等实现细节。它不会自动提供同步,也不应掩盖逻辑状态变化;lambda 的 mutable 则控制按值捕获副本的可修改性。

本篇内容
  1. 物理变化与逻辑变化
  2. 缓存要有完整失效策略
  3. lambda 中的同名关键字
  4. 运行示例
  5. 动手练习

物理变化与逻辑变化

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 允许修改各自副本,不会让它们共享一个计数器。若需要共享状态,应明确设计对象所有权及必要的并发同步。

继续查证

标准草案链接会随工作草案更新;本文版本标记对应示例最低要求,不表示草案中的所有新规则都适用于旧标准。

回到目录