rustを学んで⑥
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); // エラー!
}
| 特徴 | Box | Vec |
|---|---|---|
| 用途 | 単一の値をヒープに置く | 可変長コレクション |
| サイズ変更 | できない(固定) | できる(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。でも、正確には:
- 関数が
&strを要求している &を付けることで、Deref型強制が働く条件を満たす- Rustが自動で
&MyBox<String>→&String→&strと変換 - 関数が呼べる
重要なルール: 参照(&)がついている時だけ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/JS | Rust | |
|---|---|---|---|
| 片付け | 手動 | 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)
- リソースの解放を保証
- 「ドロップ時の処理」をカスタマイズ
学習の姿勢
完璧を目指さず、「今はこれでいいや」で先に進む。 壁にぶつかったら戻ってくる。 その繰り返しで理解が深まる。
まだスタートラインにも立っていないが、確実に前に進んでいると思いたい。長い人生なので、少しずつの理解でいいので「なぜ?」を大切にして行きたいと思っている。