Skip to content

필사 모드: Rustのスマートポインタ:Box・Rc・Arc・RefCellと内部可変性

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — スマートポインタとは何か

Rustの所有権システムは強力ですが、時に厳しすぎると感じます。値の所有者はただ一つ、借用は規則どおり、サイズはコンパイル時に確定。この規則だけでは、再帰的なデータ構造や、複数の場所で共有される状態を表現するのが難しくなります。その隙間を埋めるのが**スマートポインタ(smart pointer)**です。

スマートポインタは単にアドレスを保持する生ポインタではありません。データを指しながら、追加のメタデータと振る舞いを併せ持つデータ構造です。多くは何らかの値を所有し、スコープを抜けるときにその値を片付ける責任まで負います。実は、あなたが毎日使う StringVec<T> も、ヒープメモリを所有・管理する点ですでにスマートポインタです。

この記事では、標準ライブラリの中核となるスマートポインタ五つ、BoxRcArcRefCellMutex を順に見ていきます。それぞれがどんな問題を解くために存在するのか、いつ手を伸ばすべきか、そして誤用するとどんな罠にはまるのかを、例で学びましょう。

Box — 値をヒープに置く

もっとも単純なスマートポインタが Box<T> です。やることは名前のとおりで、値をスタックではなく**ヒープ(heap)**に置き、そのヒープアドレスを保持するポインタをスタックに置きます。所有者は依然として一つだけなので、規則は単純なままです。

fn main() {
    let boxed: Box<i32> = Box::new(5);
    println!("{}", boxed); // 5 — Derefのおかげで値のように使える
    // boxedがスコープを抜けるとヒープメモリも自動的に解放される
}

整数一つをヒープに置くこと自体には、ほとんど利点がありません。Box が輝くのは特定の状況です。

一つ目は再帰型です。Rustは型のサイズをコンパイル時に知る必要がありますが、自分自身を直接含む型はサイズが無限大になってしまいます。次のコードはコンパイルできません。

// コンパイルエラー: recursive type has infinite size
enum List {
    Cons(i32, List),
    Nil,
}

List の中にまた List が丸ごと入るので、サイズを確定できません。ここに Box を挟むと、内側の値はヒープにあり、スタックにはポインタ(サイズ固定の値)だけが残るので、サイズが確定します。

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    // これでサイズが確定してコンパイルできる
}

二つ目は大きな値を移動するときです。大きな構造体をムーブするとスタック上でバイトを丸ごとコピーする必要がありますが、Box で包めばヒープに置いてポインタだけを移動できます。

三つ目はトレイトオブジェクトです。異なる具体型を一つのトレイトとして扱うにはサイズが分からないので、Box<dyn Trait> の形でヒープに置き、ポインタで扱います。この話はDerefの節でまた続けます。

Rc — 所有権を分け合う

Box は所有者が一つという前提の上にあります。ところが、一つのデータを複数の場所で共有して所有したいときがあります。グラフの一つのノードを複数のノードが指したり、複数のデータ構造が同じ設定値を共有したりする場合のように。そこで使うのが Rc<T>、**参照カウント(reference counting)**ポインタです。

Rc は値とともに「いまこの値を何人が所有しているか」を数えるカウンタを持ちます。Rc::clone を呼ぶとデータを複製するのではなくカウンタを1増やすだけで、所有者が一つスコープを抜けるとカウンタが1減ります。カウンタが0になった瞬間に値が実際に解放されます。

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("shared config"));
    println!("count = {}", Rc::strong_count(&a)); // 1

    let b = Rc::clone(&a); // データのコピーではなくカウントを+1するだけ
    println!("count = {}", Rc::strong_count(&a)); // 2

    {
        let c = Rc::clone(&a);
        println!("count = {}", Rc::strong_count(&a)); // 3
    } // ここでcが消えてカウント-1

    println!("count = {}", Rc::strong_count(&a)); // 2
}

慣例として a.clone() ではなく Rc::clone(&a) と書きます。値全体を深くコピーする通常の clone と区別するためです。この呼び出しは値をそのままにしてカウンタだけを操作するので、コストは非常に安いです。

一つ重要な制約があります。Rc単一スレッド専用です。カウンタをアトミックに更新しないため、複数のスレッドが同時にカウントを操作すると値が壊れる恐れがあります。そのため RcSend でも Sync でもなく、スレッド境界を越えようとするとコンパイラが止めてくれます。複数スレッドで共有するには、次の Arc を使う必要があります。

Arc — スレッドをまたぐ共有所有

Arc<T>Atomic Reference Counted の略で、Rc とAPIは事実上同じですが、カウンタをアトミック演算で更新します。おかげで複数のスレッドで安全に共有できます。

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);
    let mut handles = vec![];

    for i in 0..3 {
        let data = Arc::clone(&data); // 各スレッドに渡す所有権を一つずつ
        let handle = thread::spawn(move || {
            let sum: i32 = data.iter().sum();
            println!("thread {i}: sum = {sum}");
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}

では、なぜ常に Arc を使わないのでしょうか。アトミック演算は通常の整数演算より高価です。単一スレッドでのみ共有するなら、そのコストを払う理由がないので Rc の方が速いです。必要なときだけ Arc、それ以外は Rc が原則です。コンパイラがスレッド安全性を強制するので、必要なのに Rc を使えばそもそもコンパイルできず、間違える余地も少ないです。

いずれにせよ、Rc でも Arc でも共有される値は既定で不変です。複数の所有者がいる中で、その一つが値を変更すると借用規則(共有参照がある間は変更不可)が破れるからです。ところが実際には、共有しながら値を変えたいことがよくあります。ここで内部可変性が登場します。

RefCellと内部可変性 — 規則を実行時へ移す

Rustの借用規則は通常コンパイル時に検査されます。「不変参照は複数、または可変参照は一つ、両方同時は不可」。ところが RefCell<T> はこの検査を実行時へ遅らせます。 これが**内部可変性(interior mutability)**です。外側からは不変参照しか持っていなくても、内側の値を変更できるようにするパターンです。

RefCellborrow() で不変参照を、borrow_mut() で可変参照を貸します。規則違反をコンパイラではなく実行時に検査し、違反すればコンパイルエラーではなく**パニック(panic)**が起きます。

use std::cell::RefCell;

fn main() {
    let cell = RefCell::new(5);

    {
        let mut m = cell.borrow_mut(); // 可変借用
        *m += 10;
    } // mはここで返却される

    println!("{}", cell.borrow()); // 15

    // 規則違反の例 — 実行中にパニックする
    let _a = cell.borrow_mut();
    let _b = cell.borrow_mut(); // panic: already borrowed
}

コンパイラが捕まえてくれるものを、なぜわざわざ実行時へ遅らせるのでしょうか。コンパイル時検査は安全ですが保守的で、実際には安全なのに拒否するコードがあります。RefCell は「規則を守っていると分かっているのに、コンパイラを説得しづらい」状況の脱出口になります。ただしその代償として、安全性の検査が実行時へ移り、規則を破るとパニックとして現れます。

RefCell の真価は Rc と組み合わせると出ます。Rc<RefCell<T>> は「複数の所有者で共有しながら値も変えられる」よくあるパターンです。

use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let shared = Rc::new(RefCell::new(vec![1, 2, 3]));

    let clone = Rc::clone(&shared);
    clone.borrow_mut().push(4); // 共有された値を変更

    println!("{:?}", shared.borrow()); // [1, 2, 3, 4]
}

注意点が一つ。RefCell もまた単一スレッド専用です。複数スレッドで内部可変性が必要なら、次の Mutex へ進む必要があります。

Mutex — スレッド安全な内部可変性

複数のスレッドで共有する値を変更するには Mutex<T>(相互排他、mutual exclusion)を使います。Mutex は一度に一つのスレッドだけが値にアクセスできるよう**ロック(lock)**で守ります。lock() を呼ぶとロックを得るまで待ち、得られると値にアクセスできるガードを返します。ガードがスコープを抜けるとロックは自動的に解放されます。

Rc<RefCell<T>> のスレッド安全版は Arc<Mutex<T>> です。

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // ロック取得
            *num += 1;
        }); // ガードが消えてロック解放
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("result = {}", *counter.lock().unwrap()); // 10
}

Arc で複数のスレッドに所有権を分け、Mutex で値の変更を直列化します。この組み合わせが、Rustでスレッド間の共有可変状態を扱う標準的な慣用句です。

読み取りが圧倒的に多い状況なら RwLock<T> もあります。読み取りロックは複数のスレッドが同時に持てて、書き込みロックだけが排他的です。最後に、ミューテックスには**デッドロック(deadlock)**という罠があります。二つのスレッドが互いの持つロックを待つと永遠に止まります。ロック取得の順序を一貫させるのが基本的な防御策です。

Deref強制 — スマートポインタを値のように

先の例で Box<i32>println! にそのまま渡しても整数のように出力されました。これを可能にするのが Deref トレイトと**Deref強制(deref coercion)**です。

Deref を実装した型は * 参照外し演算子で内側の値にアクセスできます。さらにコンパイラは、必要なときにこの変換を自動で挿入します。たとえば &Box<String>&str を受け取る関数に渡すと、コンパイラが Box<String>Stringstr の順に参照外しを連鎖させてくれます。

fn greet(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let boxed = Box::new(String::from("Rust"));
    greet(&boxed); // &Box<String>が&strへ自動変換される
}

この自動変換のおかげで、スマートポインタをまるで内側の値であるかのように自然に使えます。だから先に触れたトレイトオブジェクト Box<dyn Trait>Vec<Box<dyn Trait>> も、大した構文上の負担なく扱えます。参照外し一回でトレイトのメソッドを直接呼べるからです。

Rc循環参照とWeak — メモリリークを防ぐ

Rustはメモリ安全性で有名ですが、Rc を誤用するとメモリリークが起きることがあります。二つの Rc が互いを指す循環参照を作ると、両方の値の参照カウントが互いのせいで永遠に0まで下がりません。すると両方とも解放されずメモリに残ります。

親子ツリーを考えてみましょう。親は子を所有(Rc)し、子も親を知る必要があるからと親を Rc で指すと循環が生まれます。親→子、子→親の双方向に強い参照がかかり、カウントが決して0になりません。

解決策は Weak<T>弱参照です。Rc::downgrade で作る Weak は値を指しはしますが、所有権を主張しません。 つまり強参照カウント(strong_count)を増やさないので、循環を断ち切ります。所有は一方向(親→子)だけ強く、逆方向(子→親)は弱くかけるのが原則です。

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,      // 親は弱く(所有しない)
    children: RefCell<Vec<Rc<Node>>>, // 子は強く(所有する)
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    // 子が親を弱く指す — 循環を作らない
    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    // 弱参照はupgradeで取り出す(消えていればNone)
    if let Some(parent) = leaf.parent.borrow().upgrade() {
        println!("leaf's parent value = {}", parent.value); // 5
    }
}

Weak が指す値はすでに解放されている可能性があるので、値にアクセスするには upgrade()Option<Rc<T>> を受け取り、生きているか確認します。生きていれば Some、すでに消えていれば None です。この一枚の確認が、循環なしで双方向の参照を安全に扱う鍵です。

何をいつ使うか — まとめ

ここまで見たスマートポインタを選ぶ基準を凝縮すると、こうなります。

  • Box<T>:値をヒープに一つの所有者で置きたいとき。再帰型、大きな値の移動、トレイトオブジェクト。
  • Rc<T>:単一スレッドで一つの値を複数の所有者が共有するとき。読み取り専用の共有。
  • Arc<T>:複数スレッドで共有所有が必要なとき。Rc のスレッド安全版。
  • RefCell<T>:単一スレッドで不変参照の裏で値を変えたいとき(内部可変性)。多くは Rc<RefCell<T>>
  • Mutex<T> / RwLock<T>:複数スレッドで共有可変状態が必要なとき。多くは Arc<Mutex<T>>
  • Weak<T>Rc/Arc の循環参照を断ち切ってリークを防ぐとき。所有しない逆方向の参照。

核心の感覚は三つの軸です。所有者が一つか複数か(Box対Rc/Arc)、単一スレッドか多スレッドか(Rc/RefCell対Arc/Mutex)、そして不変か内部可変が必要か(ただのRc対Rc/RefCell)。この三つの問いに答えれば、たいてい正解は一つに絞られます。

おわりに

スマートポインタはRustの所有権規則を回避する裏口ではありません。むしろその規則をより広い状況へ拡張する道具です。Box はサイズと位置の制約を、RcArc は単一所有の制約を、RefCellMutex は不変共有の制約を、それぞれ安全に解きほぐします。そしてその安全のために、Weak という安全装置まで備えています。

最初は Rc<RefCell<T>> のような入れ子が見慣れなく感じますが、各層がどんな問題を解くのか分かれば、むしろ意図がくっきり読めます。必要な最小限の道具だけを選んで使う節度が、Rustで安全かつ柔軟なデータ構造を作る道です。

参考資料

현재 단락 (1/166)

Rustの所有権システムは強力ですが、時に厳しすぎると感じます。値の所有者はただ一つ、借用は規則どおり、サイズはコンパイル時に確定。この規則だけでは、再帰的なデータ構造や、複数の場所で共有される状態を...

작성 글자: 0원문 글자: 8,099작성 단락: 0/166