Day 4: スマートポインタ実践 - 解説
目次
- スマートポインタの本質
- 設計原則
- メンタルモデル
- ビジュアル図解
- 重要なポイント
- セルフチェック質問
- 所有権管理: データの所有権を明確化
- 自動メモリ管理: RAII (Resource Acquisition Is Initialization) パターン
- 型安全性: コンパイル時の安全性保証
スマートポインタの本質
スマートポインタとは何か
スマートポインタは、ポインタとして振る舞いながら、追加のメタデータと機能を持つ型です。通常のポインタと異なり、以下の特徴を持ちます:
通常のポインタとの比較
// C言語: 生ポインタ(危険)
int* ptr = malloc(sizeof(int));
*ptr = 42;
// free を忘れるとメモリリーク!
// Rust: Box(安全)
let ptr = Box::new(42);
// スコープを抜けると自動的に解放される
Rustの優位性:
- コンパイル時に所有権を検証
- ダブルフリーの防止
- Use-after-freeの防止
なぜスマートポインタが必要か
手動メモリ管理の主な問題を解決するためです:
問題1: メモリリーク
// C言語: メモリリーク
struct Node* create_node() {
struct Node* node = malloc(sizeof(struct Node));
node->value = 42;
return node; // freeを忘れたら?
}
// Rust: 自動解放
fn create_node() -> Box<Node> {
Box::new(Node { value: 42 })
// 自動的にメモリ解放
}
問題2: ダブルフリー
// C言語: ダブルフリー(危険)
free(ptr);
free(ptr); // クラッシュ!
// Rust: コンパイルエラー
let ptr = Box::new(42);
drop(ptr);
// drop(ptr); // コンパイルエラー!値は既にムーブされている
問題3: Use-after-free
// C言語: Use-after-free
free(ptr);
printf("%d", *ptr); // 未定義動作!
// Rust: コンパイルエラー
let ptr = Box::new(42);
drop(ptr);
// println!("{}", *ptr); // コンパイルエラー!
設計原則
RAII (Resource Acquisition Is Initialization)
スマートポインタはRAII原則に基づいています:
// リソースの取得 = 初期化
let file = File::open("data.txt")?;
// スコープを抜けると自動的に解放
// Dropトレイトが呼ばれる
メモリ以外のリソースにも適用:
- ファイルハンドル
- ネットワーク接続
- データベース接続
- ロック
Deref トレイト: スマートポインタの核心
Derefトレイトにより、スマートポインタは通常のポインタのように振る舞います:
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// 使用例
let x = MyBox(5);
assert_eq!(5, *x); // Derefで自動変換
Deref強制(Deref Coercion):
fn hello(name: &str) {
println!("Hello, {}!", name);
}
let m = MyBox(String::from("Rust"));
hello(&m); // &MyBox<String> -> &String -> &str
Drop トレイト: 自動クリーンアップ
Dropトレイトにより、スコープを抜ける際に自動的にクリーンアップが行われます:
struct CustomPointer {
data: String,
}
impl Drop for CustomPointer {
fn drop(&mut self) {
println!("Dropping CustomPointer with data `{}`!", self.data);
}
}
fn main() {
let _c = CustomPointer {
data: String::from("data"),
};
println!("CustomPointer created.");
}
// スコープを抜けるとdropが自動実行
// 出力:
// CustomPointer created.
// Dropping CustomPointer with data `data`!
Interior Mutability(内部可変性)
RefCellは「内部可変性」パターンを実現します:
// 通常の借用ルール(コンパイル時チェック)
let mut x = 5;
let y = &mut x; // OK
// let z = &mut x; // エラー!
// 内部可変性(実行時チェック)
let x = RefCell::new(5);
let y = x.borrow_mut(); // OK
// let z = x.borrow_mut(); // パニック!(実行時)
トレードオフ:
- 利点: 柔軟性の向上
- 欠点: 実行時オーバーヘッド
メンタルモデル
Box: ヒープへの所有権
Boxを「ヒープ上のデータへの唯一の所有権」として理解します。
// スタック ヒープ
// ┌────┐ ┌─────────┐
// │ x │─────────>│ 42 │
// └────┘ └─────────┘
let x = Box::new(42);
// xがドロップされると、ヒープメモリも解放
使用例:
- 大きなデータのスタック溢れ防止
- 再帰的なデータ構造
- トレイトオブジェクト
Rc: 共有所有権のグラフ
Rcを「参照カウント付きのグラフノード」として理解します。
// ┌──────────────┐
// │ Rc<Vec> │
// │ count: 3 │
// │ [1, 2, 3] │
// └──────────────┘
// ↑ ↑ ↑
// │ │ │
// a b c
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a);
let c = Rc::clone(&a);
ライフサイクル:
Rc::new(): カウント = 1Rc::clone(): カウント +1drop(): カウント -1- カウント = 0: メモリ解放
Arc: スレッド安全な共有
Arcを「アトミック操作による安全な共有」として理解します。
use std::sync::Arc;
use std::thread;
let data = Arc::new(vec![1, 2, 3]);
// スレッド1 スレッド2
// ↓ ↓
// └───→ Arc ←────┘
// ↓
// [1, 2, 3]
let data1 = Arc::clone(&data);
thread::spawn(move || {
println!("{:?}", data1);
});
アトミック操作:
- カウンタの増減がスレッド安全
- データ競合の防止
RefCell: 実行時借用チェック
RefCellを「実行時に動的にチェックする借用」として理解します。
// コンパイル時 実行時
// ↓ ↓
// RefCell<T> borrow() / borrow_mut()
// ↓
// 実行時チェック
// ↓
// OK / パニック
let data = RefCell::new(5);
{
let r1 = data.borrow(); // OK: カウント +1
let r2 = data.borrow(); // OK: カウント +1
} // カウント = 0
{
let mut w = data.borrow_mut(); // OK: 排他的借用
*w += 1;
}
ビジュアル図解
Box のメモリレイアウト
関数スタック
┌─────────────────────────────┐
│ main() │
│ ┌─────────────────────────┐ │
│ │ list: Box<List> │ │
│ │ ┌─────┐ │ │
│ │ │ ptr │─┐ │ │
│ │ └─────┘ │ │ │
│ └─────────┼───────────────┘ │
└───────────┼─────────────────┘
│
↓
ヒープ
┌───────────────────────────────┐
│ Cons(1, Box<List>) │
│ ┌───┬─────┐ │
│ │ 1 │ ptr │─┐ │
│ └───┴─────┘ │ │
│ ↓ │
│ Cons(2, Box<List>) │
│ ┌───┬─────┐ │
│ │ 2 │ ptr │─┐ │
│ └───┴─────┘ │ │
│ ↓ │
│ Cons(3, Box<Nil>) │
│ ┌───┬─────┐ │
│ │ 3 │ Nil │ │
│ └───┴─────┘ │
└───────────────────────────────┘
特徴:
- 各ノードがヒープに配置
- Box がドロップされると、再帰的にすべて解放
Rc の参照カウント
初期状態: count = 1
┌────────────────┐
│ Rc<Vec> │
│ count: 1 │
│ [1, 2, 3] │
└────────────────┘
↑
│
a
Rc::clone後: count = 2
┌────────────────┐
│ Rc<Vec> │
│ count: 2 │
│ [1, 2, 3] │
└────────────────┘
↑ ↑
│ │
a b
さらにclone: count = 3
┌────────────────┐
│ Rc<Vec> │
│ count: 3 │
│ [1, 2, 3] │
└────────────────┘
↑ ↑ ↑
│ │ │
a b c
bドロップ: count = 2
┌────────────────┐
│ Rc<Vec> │
│ count: 2 │
│ [1, 2, 3] │
└────────────────┘
↑ ↑
│ │
a c
すべてドロップ: count = 0 → メモリ解放
Rc> のグラフ構造
グラフ構造の例(双方向):
Node 1 Node 2
┌─────────────────────┐ ┌─────────────────────┐
│ value: 1 │ │ value: 2 │
│ neighbors: │ │ neighbors: │
│ RefCell<Vec<Rc>> │ │ RefCell<Vec<Rc>> │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ [Rc→Node2] ──┼──┼───┼──>│ │ │
│ └──────────────┘ │ │ └──────────────┘ │
└─────────────────────┘ └─────────────────────┘
↑ │
│ │
└─────────────────────────┘
Rc→Node1
参照カウント:
- Node 1: 2 (自身 + Node2の隣接リスト)
- Node 2: 2 (自身 + Node1の隣接リスト)
RefCell の借用チェック
RefCell内部の状態機械:
初期状態: BorrowState::Unused
┌────────────────────┐
│ RefCell<i32> │
│ state: Unused │
│ value: 5 │
└────────────────────┘
borrow() → BorrowState::Shared(1)
┌────────────────────┐
│ RefCell<i32> │
│ state: Shared(1) │ ← 不変参照の数
│ value: 5 │
└────────────────────┘
さらにborrow() → Shared(2)
┌────────────────────┐
│ RefCell<i32> │
│ state: Shared(2) │
│ value: 5 │
└────────────────────┘
すべて解放 → Unused
┌────────────────────┐
│ RefCell<i32> │
│ state: Unused │
│ value: 5 │
└────────────────────┘
borrow_mut() → BorrowState::Exclusive
┌────────────────────┐
│ RefCell<i32> │
│ state: Exclusive │ ← 可変参照
│ value: 6 │ ← 変更可能
└────────────────────┘
重要なポイント
1. スマートポインタは型システムの一部
// これらは異なる型
let box_ptr: Box<i32> = Box::new(5);
let rc_ptr: Rc<i32> = Rc::new(5);
let arc_ptr: Arc<i32> = Arc::new(5);
// コンパイラが型の違いを強制
// box_ptr = rc_ptr; // エラー!型が異なる
意味:
- 所有権の意味論が型に現れる
- コンパイル時に正しい使い方を強制
2. ゼロコスト抽象化(一部例外あり)
// Box: ほぼゼロコスト
let x = Box::new(42);
// アセンブリ: 単なるポインタアクセス
// Rc/Arc: カウンタのオーバーヘッド
let x = Rc::new(42);
let y = Rc::clone(&x); // カウンタ +1 (小さなコスト)
コスト比較:
| 型 | アロケーション | クローン | ドロップ |
|---|---|---|---|
| Box |
malloc相当 | 不可 | free相当 |
| Rc |
malloc + カウンタ | カウンタ+1 | カウンタ-1 |
| Arc |
malloc + アトミック | アトミック+1 | アトミック-1 |
3. 内部可変性のトレードオフ
// コンパイル時チェック(高速、安全)
let mut x = 5;
let y = &mut x;
// 実行時チェック(柔軟、小コスト)
let x = RefCell::new(5);
let y = x.borrow_mut();
選択基準:
- コンパイル時に関係が確定 → 通常の借用
- 実行時に動的に決まる → RefCell
4. 循環参照に注意
use std::rc::{Rc, Weak};
// 悪い例: 循環参照でメモリリーク
struct Node {
parent: Rc<Node>, // ❌
children: Vec<Rc<Node>>,
}
// 良い例: Weakで循環を断ち切る
struct Node {
parent: Weak<Node>, // ✅
children: Vec<Rc<Node>>,
}
ルール:
- 親→子:
Rc(強い参照) - 子→親:
Weak(弱い参照)
セルフチェック質問
質問1: Box の必要性
Q: 次のコードでBoxが必要な理由は?
enum List {
Cons(i32, Box<List>),
Nil,
}
答えを見る
A: 再帰的な型のサイズを確定させるため。
理由: Boxなしの場合:
enum List {
Cons(i32, List), // エラー!
Nil,
}
// Listのサイズ = i32のサイズ + Listのサイズ + ...
// → 無限サイズ!
Boxありの場合:
enum List {
Cons(i32, Box<List>), // OK
Nil,
}
// Listのサイズ = max(i32 + ポインタサイズ, 0)
// → 確定サイズ!
メモリレイアウト:
List = 最大(
Cons(4 bytes + 8 bytes), // i32 + Box
Nil(0 bytes)
) = 12 bytes (+ padding)
質問2: Rc vs Arc
Q: いつRcを使い、いつArcを使うべきですか?
答えを見る
A:
- Rc: シングルスレッドの共有所有権
- Arc: マルチスレッドの共有所有権
判断基準:
use std::thread;
// Rc: シングルスレッド専用
let data = Rc::new(vec![1, 2, 3]);
// thread::spawn(move || println!("{:?}", data)); // エラー!
// Arc: スレッド間で共有可能
let data = Arc::new(vec![1, 2, 3]);
thread::spawn(move || println!("{:?}", data)); // OK
パフォーマンス:
- Rc: 高速(非アトミック操作)
- Arc: やや遅い(アトミック操作)
ルール: > スレッドを使わないなら Rc、スレッドで共有するなら Arc
質問3: RefCell のパニック
Q: 次のコードはパニックしますか?
let data = RefCell::new(5);
let r1 = data.borrow();
let r2 = data.borrow();
drop(r1);
let mut w = data.borrow_mut();
答えを見る
A: いいえ、パニックしません。
理由:
let data = RefCell::new(5);
let r1 = data.borrow(); // Shared(1)
let r2 = data.borrow(); // Shared(2)
drop(r1); // Shared(1)
// ここでr2がまだ存在する
// let mut w = data.borrow_mut(); // パニック!
修正:
let data = RefCell::new(5);
{
let r1 = data.borrow();
let r2 = data.borrow();
} // r1, r2 がドロップ
let mut w = data.borrow_mut(); // OK
ルール: > すべての不変参照がドロップされた後でのみ、可変参照を取得可能
質問4: 循環参照の検出
Q: 次のコードでメモリリークは発生しますか?
use std::rc::Rc;
use std::cell::RefCell;
struct Node {
value: i32,
next: RefCell<Option<Rc<Node>>>,
}
let node1 = Rc::new(Node { value: 1, next: RefCell::new(None) });
let node2 = Rc::new(Node { value: 2, next: RefCell::new(None) });
*node1.next.borrow_mut() = Some(Rc::clone(&node2));
*node2.next.borrow_mut() = Some(Rc::clone(&node1));
答えを見る
A: はい、メモリリークが発生します。
理由:
node1 ───> Node(1)
↑ │
│ ↓
│ next: Some(Rc→node2)
│
└─────────┐
node2 │
↓ │
Node(2) │
│ │
↓ │
next: Some(Rc→node1)
参照カウント:
- node1: 2 (自身 + node2のnext)
- node2: 2 (自身 + node1のnext)
問題:
- node1とnode2がスコープを抜けてもカウントは1のまま
- 永遠に解放されない!
解決策:
use std::rc::{Rc, Weak};
struct Node {
value: i32,
next: RefCell<Option<Weak<Node>>>, // Weakを使う
}
質問5: スマートポインタの選択
Q: 次の状況でどのスマートポインタを使うべきですか?
- DOMツリー(親子双方向参照)
- 設定ファイルの共有(読み取り専用、複数スレッド)
- キャッシュ(複数所有者、時々変更、シングルスレッド)
答えを見る
A:
- DOMツリー:
Rc+> Weak
struct Node {
parent: RefCell<Weak<Node>>, // 弱参照で循環回避
children: RefCell<Vec<Rc<Node>>>, // 強参照
}
- 設定ファイル:
Arc
let config = Arc::new(load_config());
// 複数スレッドで共有
- キャッシュ:
Rc>>
let cache = Rc::new(RefCell::new(HashMap::new()));
// 複数の所有者、時々変更
判断フローチャート:
複数所有者?
├─ No → Box<T>
└─ Yes
├─ マルチスレッド?
│ ├─ No → Rc<T>
│ └─ Yes → Arc<T>
└─ 変更必要?
├─ No → そのまま
└─ Yes
├─ シングルスレッド → RefCell<T>
└─ マルチスレッド → Mutex<T> or RwLock<T>
まとめ
スマートポインタの解説を通じて、以下の概念を学びました:
- 本質: 所有権を管理する型
- 設計原則: RAII、Deref、Drop、内部可変性
- メンタルモデル: 所有権のグラフ、参照カウント、実行時チェック
- ビジュアル: メモリレイアウト、参照関係
- 重要ポイント: 型の選択、パフォーマンス、循環参照
これらの理解を基に、実践的な課題に取り組み、Rustの高度なメモリ管理パターンを習得してください。スマートポインタをマスターすることで、安全で効率的なRustコードが書けるようになります。