rustを学んで⑥

·
#CLI#Rust#Terminal#rustlings

Rustの学習記録:スマートポインタとの格闘

今日はRustのスマートポインタについて学んだ。Box、Deref、Dropトレイトと順に進んでいったが、それぞれで疑問が湧いては解決する、という繰り返しだった。その過程を記録しておく。

Box:最初の疑問

疑問:「Boxはヒープにまだ大きさが決められていないもののアドレス、箱を用意する」という理解で合っている?

最初はこう理解していた。でも、実は少し違った。

正しい理解

Boxは「ヒープにデータを確保する」ためのスマートポインタ。

重要なのは:

  • Boxに格納するデータ自体のサイズはコンパイル時に決まっている必要がある
  • Boxそのもののサイズ(ポインタ)が常に一定なので、それを利用してサイズ問題を解決する
// これはコンパイルエラー
// let list: [i32] = [1, 2, 3]; // サイズ不定の配列スライスはスタックに置けない

// Boxを使うと、ポインタ(固定サイズ)がスタックに、
// データ本体はヒープに置かれる
let boxed_array: Box<[i32]> = Box::new([1, 2, 3]);

再帰的なデータ構造での必要性

Boxが本当に必要になるのは、再帰的なデータ構造を作る時だ。

// これはコンパイルエラー!
// enum List {
//     Cons(i32, List), // Listのサイズが無限になってしまう
//     Nil,
// }

// Boxを使うと解決
enum List {
    Cons(i32, Box<List>), // Boxはポインタサイズ(8バイト)で固定
    Nil,
}

fn main() {
    let list = List::Cons(1,
        Box::new(List::Cons(2,
            Box::new(List::Cons(3,
                Box::new(List::Nil))))));
}

疑問:「3までだよとBoxを使って明示的に示している」という理解で合っている?

正解。 List::Nilが「終わり」のマーカーで、そこまでが3要素だと明示している。

サイズ(バイト)での整理

Boxがなぜ必要かは、サイズの観点から理解できた。

// Boxなしだと...コンパイラの思考:
// List = i32(4バイト) + List(? バイト)
// List = i32(4バイト) + i32(4バイト) + List(? バイト)
// → 無限ループ!サイズが決められない!

// Boxありだと...
// List = max(
//   Cons: i32(4バイト) + Box(8バイト) = 12バイト,
//   Nil: 0バイト
// ) + タグ
// → 確定!約16バイト

メモリレイアウトのイメージ:

スタック(16バイト固定)        ヒープ
┌──────────────┐
│ List::Cons   │
│ ├─ 1 (4B)   │
│ └─ Box ──────┼─→ ┌──────────────┐
└──────────────┘   │ List::Cons   │
                   │ ├─ 2 (4B)   │
                   │ └─ Box ──────┼─→ ┌──────────────┐
                   └──────────────┘   │ List::Cons   │
                                      │ ├─ 3 (4B)   │
                                      │ └─ Box ──────┼─→ ┌──────────┐
                                      └──────────────┘   │List::Nil │
                                                         └──────────┘

BoxとVecの違い

ここで新たな疑問が生まれた。BoxとVecって何が違うの?

fn main() {
    // Box: 単一の値をヒープに置く
    let boxed_number: Box<i32> = Box::new(42);
    let boxed_array: Box<[i32; 5]> = Box::new([1, 2, 3, 4, 5]);
    
    // Vec: 可変長のコレクション(内部でヒープを使う)
    let mut vec_numbers: Vec<i32> = vec![1, 2, 3, 4, 5];
    vec_numbers.push(6); // 追加できる!
    
    // Boxは追加できない(固定サイズ)
    // boxed_array.push(6); // エラー!
}
特徴BoxVec
用途単一の値をヒープに置く可変長コレクション
サイズ変更できない(固定)できる(push/pop等)
メモリヒープに1つの値ヒープに動的配列
スタック上ポインタのみ(8バイト)ポインタ+長さ+容量(24バイト)

Derefトレイト:プロトタイプっぽい?

次にDerefトレイトを学んだ。

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

疑問:「これってJSのプロトタイプみたいな感じ?」

最初はそう思った。プロトタイプで機能を拡張していくような...

でも違った

  • JSプロトタイプ: 機能の継承・拡張
  • Rust Derefトレイト: *演算子の振る舞いを定義

Derefトレイトの本当の役割は参照外し時の動作Deref型強制だった。

参照外しの理解

「参照外しはその奥にある実体を見に行く」 という理解は完璧に正しかった。

fn main() {
    let x = 5;
    let y = Box::new(5);
    
    // yはポインタ(箱の住所)
    // *yで「その奥にある実体」を見に行く
    println!("{}", *y); // 5
}

Deref型強制の魔法

これが本当に便利な機能だった。

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

fn main() {
    let m = MyBox(String::from("Rust"));
    
    // これが動く理由:
    // MyBox<String> -> &String -> &str と自動変換!
    hello(&m);
}

疑問:「関数が&strで指定しているから」という理解でいい?

ここで大きな疑問が生まれた。「関数がfn hello(name: &str)こう&で指定しているから」くらいの理解しかなかったが、これで合っている?

Claudeへファクトチェックを依頼した結果:

実践的には「&strを要求してるから&を付ける」という理解でOK。でも、正確には:

  1. 関数が&strを要求している
  2. &を付けることで、Deref型強制が働く条件を満たす
  3. Rustが自動で&MyBox<String>&String&strと変換
  4. 関数が呼べる

重要なルール: 参照(&)がついている時だけDeref型強制が働く

fn main() {
    let m = MyBox::new(String::from("Rust"));
    
    hello(&m);  // ✅ 動く(Deref型強制)
    hello(m);   // ❌ エラー!(値そのものは変換されない)
}

実際の使用場面

実は、Derefトレイトを自分で実装することは滅多にない

ほとんどの場合:

  • Box、Rc、Arcなど、標準ライブラリのスマートポインタがすでに実装している
  • 自分でDerefを実装するのは、独自のスマートポインタを作る時だけ
fn main() {
    let boxed_vec = Box::new(vec![1, 2, 3, 4, 5]);
    
    // Derefのおかげで、Boxを剥がさずにVecのメソッドが使える
    println!("最初: {:?}", boxed_vec.first());
    println!("最後: {:?}", boxed_vec.last());
    println!("長さ: {}", boxed_vec.len());
}

今の段階での理解

  • Derefトレイトは自分で実装することは稀
  • Box、Rc、Arcなどが内部で実装している
  • おかげで*.が便利に使える
  • 「裏で動いている便利機能」として理解しておけばOK

実際のコードを書いていく中で「あ、これDerefが働いてるんだな」と気づく瞬間が来るはず。

Dropトレイト:コールスタックみたい?

最後にDropトレイトを学んだ。

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer { data: String::from("my stuff") };
    let d = CustomSmartPointer { data: String::from("other stuff") };
    println!("CustomSmartPointers created.");
}

実行結果:

CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

疑問:「コールスタックみたいな感じ?」

LIFO(Last In, First Out)という点では似ている。でも意味は違う。

  • コールスタック: 関数呼び出しの管理
  • Drop: 変数の片付け(作成の逆順)

理解が進んだ瞬間

「DB接続、メモリなどリソースの解放を保証」という説明で、理解が一気に進んだ。

最初は「関数終了後には自動的に終了している」と思っていたが、実際には終了しておらず、明示的に保証しなければならないのでは?と考えた。

でも、それは誤解だった

Rustでは関数終了後に自動的に片付けられる。

fn main() {
    let s = String::from("hello");
    
    // main終了
    // → sは自動的にメモリ解放される
    // → Dropトレイトが自動で呼ばれる
}

他の言語との比較:

C言語Python/JSRust
片付け手動GC(不定期)自動(即座)
保証❌ なし⚠️ いつかは片付く✅ 必ず片付く
タイミングfree()を呼んだ時GCが動いた時スコープを抜けた瞬間

Dropトレイトの本当の役割

「どう片付けるか」をカスタマイズするため。

struct FileHandle {
    filename: String,
}

impl Drop for FileHandle {
    fn drop(&mut self) {
        println!("ファイル {} を閉じます", self.filename);
        // 実際にはここでファイルを閉じる処理
    }
}

fn main() {
    let file = FileHandle { 
        filename: String::from("data.txt") 
    };
    
    println!("ファイル操作中...");
    
    // main終了 → 自動でdrop()が呼ばれる
    // → ファイルが確実に閉じられる
}

名前の由来

Drop = 「ドロップ時の処理」

この理解で腑に落ちた。

  • drop = 落とす、手放す、捨てる
  • 変数をdropする = 変数を手放す = メモリから解放する
スコープ内
┌─────────────┐
│ let x = ... │ ← 変数が存在
│             │
│ 処理...     │
│             │
└─────────────┘
      ↓ drop!(落ちる)
    片付け処理

Rustの用語は直感的だ:

  • drop = 落とす
  • move = 移動する
  • borrow = 借りる
  • clone = 複製する

全部、英語の意味通りに動く。

疑問:「でも、どうやって呼び出してるの?」

ここで重要な疑問が生まれた。Dropトレイトのdrop()メソッド、誰が、どうやって呼び出しているんだろう?

答え:Rustコンパイラが自動で呼び出している

私たちは一切呼び出していない。コンパイラが自動でコードを挿入している。

実際に何が起きているか

私たちが書いたコード:

fn main() {
    let c = CustomSmartPointer { data: String::from("my stuff") };
    println!("使用中");
}

コンパイラが実際に生成するコード(イメージ):

fn main() {
    let c = CustomSmartPointer { data: String::from("my stuff") };
    println!("使用中");
    
    // ← コンパイラがここに自動でdrop()呼び出しを挿入
    drop(c);
}

スコープごとに自動挿入

fn main() {
    let a = CustomSmartPointer { data: String::from("a") };
    
    {
        let b = CustomSmartPointer { data: String::from("b") };
        println!("内側のスコープ");
    } // ← ここでコンパイラが b.drop() を呼ぶ
    
    println!("外側のスコープ");
} // ← ここでコンパイラが a.drop() を呼ぶ

実行結果:

内側のスコープ
Dropping CustomSmartPointer with data `b`!
外側のスコープ
Dropping CustomSmartPointer with data `a`!

早期returnやpanicでも大丈夫

fn process() {
    let x = CustomSmartPointer { data: String::from("x") };
    let y = CustomSmartPointer { data: String::from("y") };
    
    if some_condition {
        return; // ← ここでreturnしても...
        // コンパイラが return の前に自動で
        // y.drop();
        // x.drop();
        // を挿入してくれる
    }
    
    println!("続き");
}

panicの場合も同様に、スタック巻き戻し時にコンパイラが自動でdrop()を呼んでくれる。

手動で呼びたい場合

直接x.drop()を呼ぶことはできない(コンパイルエラー)。

fn main() {
    let x = CustomSmartPointer { data: String::from("x") };
    
    x.drop(); // ❌ コンパイルエラー!
    // error: explicit use of destructor method
}

なぜダメかというと:

  • drop()が呼ばれた後も変数xは有効に見える
  • でも中身は破棄済み → 危険!
  • スコープ終了時にもう一度drop()が呼ばれる → 二重解放!

早く片付けたい時はstd::mem::drop()を使う:

fn main() {
    let x = CustomSmartPointer { data: String::from("x") };
    
    std::mem::drop(x); // ✅ これはOK
    // xの所有権がdrop関数に移動する
    // → もうxは使えない
}

std::mem::dropの実装は実は超シンプル:

pub fn drop<T>(_x: T) {
    // 何もしない!
    // _x がスコープを抜ける
    // → 自動で T::drop() が呼ばれる
}

所有権を奪うだけの関数だった。

まとめ:呼び出しの仕組み

  • 誰が呼び出す? → Rustコンパイラが自動で
  • いつ呼び出す? → 変数がスコープを抜ける時
  • どうやって呼び出す? → コンパイル時にコードを自動挿入
  • 私たちがすること → 何もしない(自動だから)
  • 早く片付けたい時std::mem::drop(x) を使う

Dropトレイトは「実装する」ものであって「呼び出す」ものではない。

実装 = 「片付け方を教える」
呼び出し = コンパイラが勝手にやる

今日の学び

Box

  • ヒープにデータを確保するスマートポインタ
  • ポインタサイズ(8バイト)は固定
  • 再帰的なデータ構造に必須
  • Vecとは用途が違う(単一の値 vs 可変長コレクション)

Deref

  • *演算子の振る舞いを定義
  • Deref型強制で自動的に型変換
  • 自分で実装することは稀
  • 「裏で動いている便利機能」

Drop

  • スコープを抜ける時の片付け処理
  • 逆順で自動的に呼ばれる(LIFO)
  • リソースの解放を保証
  • 「ドロップ時の処理」をカスタマイズ

学習の姿勢

完璧を目指さず、「今はこれでいいや」で先に進む。 壁にぶつかったら戻ってくる。 その繰り返しで理解が深まる。

まだスタートラインにも立っていないが、確実に前に進んでいると思いたい。長い人生なので、少しずつの理解でいいので「なぜ?」を大切にして行きたいと思っている。