Rustにおける内部可変性:コンパイル時安全と実行時柔軟性の調和

内部可変性が必要となる理由

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:書き込み中)
  • dataUnsafeCell経由でラップされた実データ

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は軽量なランタイムチェックを伴うが、単一スレッド環境では非常に効率的である。

設計上の教訓

  1. 内部可変性は例外処理の手段:普遍的な設計ではなく、特定の共有パターンのために使うべき。
  2. panicの責任は開発者にある:実行時エラーはバグの兆候であり、テストで網羅すべき。
  3. 型システムの力を弱める可能性がある:過度な使用は静的保証の恩恵を損なう。

つまり、「コンパイル時に解決できるなら、そちらを選ぶ」ことが基本原則である。

タグ: rust refcell interior-mutability unsafe-cell rc

6月14日 17:01 投稿