Box・Rc・Arc の違い — Rust のスマートポインタ、いつ何を使うか

読了 5分

基礎講座の締めくくりで「スマートポインタは The Book へ」と預けておいた空白を、この記事で回収します。所有権規則(所有者は 1 つ、スコープの終わりで解放)は大半のコードに十分ですが、ときどきその規則だけでは表現できない構造が現れます。所有者が複数必要だったり、サイズがコンパイル時に分からなかったり、不変の参照越しに変更が必要だったりする場合です。標準ライブラリのスマートポインタがその脱出口で、種類は実質 4 つで整理できます。

Box — 値をヒープに置く #

Box は最も単純なスマートポインタで、値をヒープに置いてその所有権を持つポインタです。所有者は相変わらず 1 つなので所有権規則はそのままで、変わるのは値の置き場所だけです。必要な状況は 3 つです。

src/main.rs
// 1. 再帰型: サイズを確定させるため
enum Tree {
    Leaf(i32),
    Node(Box<Tree>, Box<Tree>), // Box なしではサイズ無限 → コンパイルエラー E0072
}

// 2. dyn トレイト: サイズの違う型たちを 1 つの型として
let handlers: Vec<Box<dyn Fn(&str)>> = vec![/* ... */];

// 3. 大きな値の移動: コピーの代わりにポインタだけ移動
let big = Box::new([0u8; 1_000_000]);

再帰型は「自分自身を含む構造体のサイズは無限」という問題をポインタのサイズに固定して解決し、Box<dyn Trait> は基礎第 8 回で見た動的ディスパッチのあの形です。3 つに共通するのは、所有者は 1 つのままということです。共有が必要なら次へ進みます。

Rc — 所有者を複数に (単一スレッド) #

src/main.rs
use std::rc::Rc;

let shared = Rc::new(String::from("共有設定"));
let a = Rc::clone(&shared); // データのコピーではなくカウント +1
let b = Rc::clone(&shared);
println!("所有者数: {}", Rc::strong_count(&shared)); // 3

Rc(reference counting)は「この値を参照する所有者の数」を数え、カウントが 0 になった瞬間に解放します。所有者は 1 つという規則の公式な例外で、グラフやツリーで 1 つのノードを複数の親が指す構造のように、誰が最後まで使うかをコンパイル時に決められない共有が用途です。Rc::clone はデータをコピーせずカウントを上げるだけなので、安い操作です。2 つの限界を覚えておく必要があります。第一に、得られるのは共有された不変アクセスです(変更は下の RefCell との組み合わせで)。第二に、互いが互いを指す循環参照はカウントが 0 になれず、メモリが解放されません。循環が生まれる設計(子→親の逆参照など)では、カウントを数えない Weak を使います。

Arc — スレッドをまたぐ Rc #

Rc のカウント増減はアトミックではないので、コンパイラがスレッド間の移動自体を拒否します。マルチスレッドの共有には、カウントをアトミック演算で処理する Arc(atomic Rc)を使います。

src/main.rs
use std::sync::Arc;
use std::thread;

let config = Arc::new(String::from("共有設定"));
let for_worker = Arc::clone(&config);
thread::spawn(move || println!("{for_worker}"));

アトミック演算のコストがあるので、単一スレッドでは Rc、スレッドをまたぐなら Arc という区分がそのまま選択基準です。間違えて選んでもコンパイラが「Rc はスレッド間を移動できない」と止めてくれるので、暗記より理解が必要な区分です。

RefCell — 借用検査を実行時に先送りする #

Rc がくれたのは共有の不変アクセスでした。共有しながら変更まで必要なら、借用規則の検査をコンパイル時から実行時へ移す RefCell の出番です。

src/main.rs
use std::cell::RefCell;

let counter = RefCell::new(0);
*counter.borrow_mut() += 1;   // 実行時に可変借用を検査
let now = counter.borrow();   // 実行時に不変借用を検査

規則そのもの(可変 1 つ、または不変複数)は同じで、破るとコンパイルエラーの代わりに実行時パニックになります。コンパイラが証明できないパターンを開発者が責任を持って開く扉なので、「とりあえず RefCell で包む」ではなく、設計で解決できないときの最後の手段に置くのが健全な順序です。

実務のイディオム — 組み合わせの 2 つの形 #

4 つを組み合わせると、実務でよく見る 2 つの形が出てきます。

  • Rc<RefCell<T>> — 単一スレッドで「複数が所有して変更もする」状態。GUI のウィジェットツリー、インタープリタの環境オブジェクトのような構造の定石です。
  • Arc<Mutex<T>> — マルチスレッド版。RefCell の席をロックである Mutex が代わります。ただし実践第 5 回で見たとおり、並列集計のように分けて合わせられる問題にこの形を使うと、ロック競合で遅くなります。構造で共有をなくせるならそちらが先、Arc<Mutex<T>> は共有状態が本当に本質であるときだけ、が順序です。

選択の手順 #

  1. 所有者は 1 つで足りるか? → ただの値。ヒープ配置・再帰・dyn が必要なときだけ Box。
  2. 所有者が複数必要か? → 単一スレッドは Rc、マルチスレッドは Arc。
  3. 共有しながら変更も必要か? → Rc<RefCell<T>> / Arc<Mutex<T>>。ただし、構造の変更で共有をなくせないかを先に検討。

まとめ #

  • Box は置き場所だけヒープに移した単独所有です。再帰型、dyn トレイト、大きな値の移動の 3 つの状況が用途です。
  • Rc は参照カウントで所有者を複数にする単一スレッドの道具です。共有は不変で、循環参照は Weak で断ちます。
  • Arc はカウントをアトミック演算に変えたマルチスレッド版です。選び間違えてもコンパイラが境界を守ってくれます。
  • RefCell は借用検査を実行時パニックへ先送りする最後の手段です。設計で解決できないときだけ開きます。
  • 組み合わせのイディオムは Rc<RefCell<T>>(単一スレッド)と Arc<Mutex<T>>(マルチスレッド)です。ただし共有自体をなくす構造(fold・reduce)が可能なら、そちらが先です。
X