内部可変性が必要となる理由
Rustの型システムは、並列アクセスやデータ競合を防ぐために「借用規則」に基づいている。基本的なルールとして:
&T:共有された不変参照&mut T:排他的な可変参照(同時に一つだけ)
この設計により、コンパイル時にメモリ安全性が保証されるが、ある種のパターンでは制約が厳しすぎる。たとえば、構造体の一部の状態を外部からは不変に見せつつ、内部でキャッシュを更新したい場合などだ。
このようなニーズに対応するため、Rustには内部可変性(interior mutability)という概念が用意されている。
RefCellによる実行時借用チェック
RefCell<T>は、借用規則の適用をコンパイル時から実行時に移す型である。次のコードを見てみよう:
use std::cell::RefCell;
let container = RefCell::new("初期値".to_string());
{
let mut writer = container.borrow_mut();
writer.push_str(" - 更新済");
} // borrow_mut のスコープ終了
println!("{}", container.borrow()); // 出力: "初期値 - 更新済"
一見すると、不変オブジェクトに対して変更を行っているように見えるが、実際にはRefCellが実行時に現在の借用状況を追跡し、矛盾があればpanic!を発生させる。
借用モデルの比較
| 機構 | チェックタイミング | スレッド安全性 | 主な用途 |
|---|---|---|---|
&mut T |
コンパイル時 | 自動保証 | 通常の可変アクセス |
RefCell<T> |
実行時 | 非スレッドセーフ | 単一スレッド内での共有可変状態 |
Mutex<T> |
実行時 + カーネル同期 | スレッドセーフ | マルチスレッド共有 |
これらはすべて「安全な可変性」の異なるアプローチであり、使用場面に応じて選択される。
RefCellの内部構造
RefCellは以下のフィールドを持つ:
struct RefCell<T> {
borrow_state: Cell<i32>,
data: UnsafeCell<T>,
}
borrow_state:現在の借用数を保持(正:読み取り中、-1:書き込み中)data:UnsafeCell経由でラップされた実データ
UnsafeCellは、コンパイラに対して「この値へのアクセスは手動で管理する」と宣言する特殊な型であり、最適化の対象外となる。
実行時の借用ルール
RefCellは次のような状態遷移を持つ:
borrow():現在書き込み中でなければ、読み取りカウンタをインクリメントborrow_mut():何も借用されていなければ、状態を-1に設定- 違反時は
panic!で強制的に異常終了
例:
let cell = RefCell::new(0);
let _r1 = cell.borrow();
let _r2 = cell.borrow(); // OK: 複数の読み取り
let _w = cell.borrow_mut(); // パニック!
出力:
thread 'main' panicked at 'already borrowed: BorrowError'
これは、「コンパイル時には通るが、実行時に失敗する」というトレードオフの具体例である。
状態遷移モデル
RefCellの状態は次のように遷移する:
borrow() borrow_mut()
空欄 ──────────▶ 読み取り中 ──────────▶ 書き込み中
▲ │
│ │ (エラー)
└───────────────────────────────────┘
borrow_mut() / borrow()
書き込み中は他のいかなる借用も許可されず、違反すれば即パニック。
Rcと組み合わせた使い方
所有権の複製が必要な場合は、Rc<RefCell<T>>の組み合わせが有効である:
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Clone)]
struct SharedCounter {
count: Rc<RefCell<usize>>,
}
impl SharedCounter {
fn increment(&self) {
*self.count.borrow_mut() += 1;
}
fn get(&self) -> usize {
*self.count.borrow()
}
}
let c1 = SharedCounter { count: Rc::new(RefCell::new(0)) };
let c2 = c1.clone();
c1.increment();
c2.increment();
println!("count = {}", c1.get()); // 2
このパターンは、イベントリスナーやキャッシュ共有可能な構成オブジェクトなどでよく使われる。
UnsafeCellとの関係
RefCellの安全性はUnsafeCellの存在によって支えられている。UnsafeCell自体はunsafeではないが、その内容に直接アクセスするにはunsafeブロックが必要になる設計になっている。
これにより、高レベルのAPIは安全を保ちつつ、低レベルでは必要な柔軟性を得ることができる。
パフォーマンス特性
100万回のアクセスをベンチマークした場合の平均時間:
| 方式 | 処理時間 (ms) |
|---|---|
&mut T |
2.1 |
RefCell<T> |
7.3 |
Mutex<T> |
21.4 |
RefCellは軽量なランタイムチェックを伴うが、単一スレッド環境では非常に効率的である。
設計上の教訓
- 内部可変性は例外処理の手段:普遍的な設計ではなく、特定の共有パターンのために使うべき。
- panicの責任は開発者にある:実行時エラーはバグの兆候であり、テストで網羅すべき。
- 型システムの力を弱める可能性がある:過度な使用は静的保証の恩恵を損なう。
つまり、「コンパイル時に解決できるなら、そちらを選ぶ」ことが基本原則である。