Day 1: 所有権マスター - 解説
所有権システムの本質
なぜRustは所有権を選んだのか
Rustの目標は「安全性」と「パフォーマンス」の両立です。伝統的に、この2つは トレードオフの関係にありました。
| 言語 | メモリ安全 | 高パフォーマンス | 予測可能性 | ランタイムコスト |
|---|---|---|---|---|
| C/C++ | x | o | o | なし |
| Java/Go | o | x | x | GC(5-15%) |
| Rust | o | o | o | なし |
所有権システムにより、コンパイル時にメモリ安全性を保証できます。これは、実行時のオーバーヘッドがゼロであることを意味します。
設計原則
Rustの所有権システムは、3つの重要な設計原則に基づいています:
1. RAII (Resource Acquisition Is Initialization)
C++から借りた概念で、リソースの取得と解放をスコープに紐づけます。
{
let file = File::open("data.txt")?; // リソース取得
// ファイルを使った処理
} // スコープを抜ける → 自動的にファイルがクローズされる
利点:
- リソースリークの防止
- 例外安全性
- コードが読みやすい(いつ解放されるかが明確)
2. Zero-cost Abstractions(ゼロコスト抽象化)
高レベルな抽象化が、低レベルなコードと同等のパフォーマンスを持つ。
// 高レベル: イテレータチェーン
let sum: i32 = vec![1, 2, 3, 4, 5]
.iter()
.map(|x| x * 2)
.filter(|x| x > &5)
.sum();
// コンパイル後は、手書きのループと同じ機械語に
// 実行時オーバーヘッドなし
3. Fearless Concurrency(恐れのない並行性)
所有権システムにより、データ競合がコンパイル時に防がれる。
use std::thread;
let data = vec![1, 2, 3];
// このコードはコンパイルエラー
// thread::spawn(|| {
// println!("{:?}", data); // dataの所有権が不明確
// });
// 正しい方法: 所有権を移動
thread::spawn(move || {
println!("{:?}", data); // 所有権が明確
});
所有権の深い理解
スタックとヒープ
メモリは大きく2つの領域に分かれます。所有権を理解するには、この違いを知る必要があります。
スタック
特徴:
- LIFO (Last In, First Out)
- 固定サイズのデータ
- 高速(メモリアドレスの計算が簡単)
- 関数のローカル変数
スタックのメモリレイアウト:
+------------------+ <- スタックポインタ(SP)
| main のローカル |
+------------------+
| func1 のローカル |
+------------------+
| func2 のローカル | <- 関数呼び出しで積まれる
+------------------+
スタックに格納される型:
let x: i32 = 5; // 4バイト、スタック
let y: f64 = 3.14; // 8バイト、スタック
let point = (10, 20); // 8バイト、スタック
let array = [1, 2, 3]; // 12バイト、スタック
ヒープ
特徴:
- 動的サイズのデータ
- 遅い(メモリアロケータが空き領域を探す)
- プログラムの実行中にサイズが変わる
- 明示的な管理が必要(RustではOwnershipで自動化)
ヒープのメモリレイアウト:
+------------------+
| 空き領域 |
+------------------+
| String: "hello" | <- アロケータが確保
+------------------+
| Vec: [1,2,3,4,5] |
+------------------+
| 空き領域 |
+------------------+
ヒープに格納される型:
let s: String = String::from("hello"); // ヒープ
let v: Vec<i32> = vec![1, 2, 3]; // ヒープ
let b: Box<i32> = Box::new(5); // ヒープ
スタックとヒープの複合型
let s = String::from("hello");
メモリレイアウト:
スタック(固定サイズ部分):
+----------+
| ptr | -----> ヒープ: ['h']['e']['l']['l']['o']
| len: 5 |
| cap: 5 |
+----------+
- ptr: ヒープへのポインタ(8バイト、64bitシステム)
- len: 現在の長さ(8バイト)
- cap: 確保済み容量(8バイト)
合計: 24バイト(スタック)+ 5バイト(ヒープ)
Move の仕組み
Moveが起こるとき
let s1 = String::from("hello");
let s2 = s1; // Move発生
Move前:
スタック:
+----------+
| s1: ptr | -----> ヒープ: ['h']['e']['l']['l']['o']
| len: 5 |
| cap: 5 |
+----------+
Move後:
スタック:
+----------+
| s1: 無効 | (コンパイラが使用を禁止)
+----------+
| s2: ptr | -----> ヒープ: ['h']['e']['l']['l']['o']
| len: 5 | (同じヒープ領域を指す)
| cap: 5 |
+----------+
なぜMoveが必要か
もしMoveがなかったら:
// もしもMoveがなかったら...(疑似コード)
let s1 = String::from("hello");
let s2 = s1; // s1とs2が同じヒープを指す
// スコープを抜けるとき
// s1が破棄 → ヒープが解放される
// s2が破棄 → 既に解放済みのヒープを再度解放(Double Free!)
Double Freeの危険性:
- プログラムクラッシュ
- セキュリティ脆弱性(任意のコード実行)
- 予測不能な動作
Rustの解決策は、所有者を1つに限定することです。
Copy の仕組み
Copyが起こるとき
let x = 5;
let y = x; // Copy発生(xはまだ有効)
メモリレイアウト:
スタック:
+----------+
| x: 5 | (4バイト)
+----------+
| y: 5 | (4バイト、独立したコピー)
+----------+
なぜCopyが安全か
- スタック上の固定サイズデータのみ
- ビット単位のコピーが安価
- ヒープへのポインタを含まない
- 二重解放の問題が起こらない
Copy可能な型の判定方法
// Copy可能: すべてのフィールドがCopy
#[derive(Copy, Clone)]
struct Point {
x: i32, // i32はCopy
y: i32, // i32はCopy
}
// Copy不可能: 1つでもCopyでないフィールドがある
struct Rectangle {
top_left: Point, // PointはCopy
label: String, // StringはCopyでない!
}
メンタルモデル:所有権の直感的理解
所有権 = 責任
所有権を「責任」として考えると理解しやすいです。
実世界のアナロジー
本の貸し借り:
// あなたが本を持っている
let book = String::from("Rust Programming");
// 友人に本を渡す(所有権の移動)
let friend_has_book = book;
// あなたはもう本を読めない
// println!("{}", book); // エラー!本はもうない
// 友人だけが本を読める
println!("{}", friend_has_book);
本のコピー:
// 雑誌を持っている(軽い)
let magazine = 5; // ページ番号として想像
// 誰かにページ番号を教える(コピー)
let someone_else = magazine;
// 両方が独立してページ番号を知っている
println!("私: {}, 相手: {}", magazine, someone_else);
Move vs Borrow vs Clone
| 操作 | 構文 | 元の値 | 新しい値 | メモリコスト |
|---|---|---|---|---|
| Move | `let b = a;` | 使えない | 使える | なし |
| Borrow | `let b = &a;` | 使える | 読み取り専用 | なし |
| Clone | `let b = a.clone();` | 使える | 使える | あり(ヒープコピー) |
決定木:どれを使うべきか
データを使いたい
|
+-- 元の値も後で使う?
|
Yes --> 値を変更する?
| |
| Yes --> 可変借用 (&mut)
| No --> 不変借用 (&)
|
No --> 値を変更する?
|
Yes --> Move (所有権移動)
No --> Move または Borrow
ビジュアル図解:メモリレイアウト
例1: Stringのライフサイクル
fn main() {
let s = String::from("hello"); // (1) 作成
do_something(s); // (2) Move
// (4) 関数終了、sは存在しない
}
fn do_something(text: String) { // (3) 受け取る
println!("{}", text);
// (4) textが破棄される(ヒープも解放)
}
メモリの変化:
(1) main で作成:
スタック (main):
+----------+
| s: ptr | -----> ヒープ: ['h']['e']['l']['l']['o']
| len: 5 |
| cap: 5 |
+----------+
(2) 関数呼び出し:
スタック (main):
+----------+
| s: 無効 |
+----------+
スタック (do_something):
+----------+
| text:ptr | -----> ヒープ: ['h']['e']['l']['l']['o']
| len: 5 | (同じヒープ領域)
| cap: 5 |
+----------+
(3) 関数終了:
ヒープが解放される
すべてのスタックフレームがクリア
例2: Vecの成長
let mut v = Vec::new(); // (1) 空のVec
v.push(1); // (2) 1つ追加
v.push(2); // (3) 2つ目追加
v.push(3); // (4) 3つ目追加
// 容量が足りなくなると自動的に再割り当て
メモリの変化:
(1) 空のVec:
スタック:
+----------+
| v: ptr | -> null
| len: 0 |
| cap: 0 |
+----------+
(2) push(1):
スタック:
+----------+
| v: ptr | -----> ヒープ: [1][未使用][未使用][未使用]
| len: 1 | (容量4で確保)
| cap: 4 |
+----------+
(3) push(2):
スタック:
+----------+
| v: ptr | -----> ヒープ: [1][2][未使用][未使用]
| len: 2 |
| cap: 4 |
+----------+
(4) capacity超過時(例: 5個目追加):
古いヒープ: [1][2][3][4] (破棄される)
↓
新しいヒープ: [1][2][3][4][5][未][未][未] (容量8で再確保)
よくある間違いとその理由
1. 所有権移動後の使用
let s = String::from("hello");
let t = s;
println!("{}", s); // エラー!
エラーメッセージ:
error[E0382]: borrow of moved value: `s`
--> src/main.rs:4:20
|
2 | let s = String::from("hello");
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 | let t = s;
| - value moved here
4 | println!("{}", s);
| ^ value borrowed here after move
なぜエラーか:
- sの所有権がtに移動
- sはもう有効な値を指していない
- 無効なメモリへのアクセスを防ぐため
2. 関数に渡した後の使用
fn consume(s: String) {
println!("{}", s);
}
let s = String::from("hello");
consume(s);
println!("{}", s); // エラー!
なぜエラーか:
consume(s)で所有権が関数に移動- 関数終了時に
sが破棄される - mainの
sはもう無効
3. 部分的なMove
struct Person {
name: String,
age: u32,
}
let person = Person {
name: String::from("Alice"),
age: 30,
};
let name = person.name; // nameだけMove
println!("{}", person.age); // OK: ageはCopy
// println!("{}", person.name); // エラー!nameは移動済み
ルール: 構造体の一部がMoveされると、構造体全体としては使えなくなる。
所有権のベストプラクティス
1. 借用を優先する
// ❌ 所有権を取るのは重い
fn process_data(data: Vec<i32>) -> Vec<i32> {
// 処理
data // 返す必要がある
}
// ✅ 借用で十分な場合が多い
fn process_data(data: &[i32]) -> Vec<i32> {
// 処理
data.iter().map(|&x| x * 2).collect()
}
理由:
- 呼び出し側が柔軟に使える
- 関数の責任範囲が明確
- 意図しない変更を防ぐ
2. clone() は最後の手段
// ❌ 安易なclone
fn get_config() -> Config {
GLOBAL_CONFIG.clone() // 毎回コピー
}
// ✅ 参照を返す
fn get_config() -> &'static Config {
&GLOBAL_CONFIG // コピーなし
}
// ✅ または Arc で共有
fn get_config() -> Arc<Config> {
Arc::clone(&GLOBAL_CONFIG) // 参照カウントのみ
}
3. 小さい型はCopyを実装
// ✅ 小さい構造体はCopyに
#[derive(Copy, Clone)]
struct Point {
x: f64,
y: f64,
}
// ✅ IDやハンドルもCopyに
#[derive(Copy, Clone)]
struct UserId(u64);
条件:
- 16バイト以下(目安)
- ヒープを使わない
- Copyが自然に感じられる型
4. 関数設計の指針
// 読み取り専用 → 不変参照
fn calculate_total(items: &[Item]) -> f64 { }
// 変更が必要 → 可変参照
fn sort_items(items: &mut [Item]) { }
// 所有権が必要 → 値を取る
fn consume_and_save(data: Vec<u8>) -> Result<(), Error> { }
// 所有権を返す → ビルダーパターン
fn add_header(mut request: Request, key: &str, value: &str) -> Request {
request.headers.insert(key, value);
request
}
パフォーマンスへの影響
ベンチマーク: Clone vs Borrow
use std::time::Instant;
fn benchmark() {
let data = "a".repeat(1_000_000); // 1MBの文字列
// Clone: ~5ms
let start = Instant::now();
let cloned = data.clone();
println!("Clone: {:?}", start.elapsed());
// Borrow: ~0.001ms
let start = Instant::now();
let borrowed = &data;
println!("Borrow: {:?}", start.elapsed());
}
メモリ使用量
use std::mem;
fn memory_usage() {
let s = String::from("hello");
// Stringのサイズ(スタック部分)
println!("String size: {}", mem::size_of_val(&s)); // 24バイト
// 参照のサイズ
let r = &s;
println!("&String size: {}", mem::size_of_val(&r)); // 8バイト
// ヒープの実際のデータ
println!("Heap size: {}", s.len()); // 5バイト
}
セルフチェック質問
レベル1: 基礎
- CopyとMoveの違いは?
- clone()とCopyの違いは?
レベル2: 応用
- このコードはコンパイルできるか?
let s1 = String::from("hello");
let s2 = s1;
let s3 = s1;
- 答え: No。s2への代入でs1が無効になるため、s3への代入でエラー。
- このコードはコンパイルできるか?
let x = vec![1, 2, 3];
let y = x;
println!("{}", x.len());
- 答え: No。yへの代入でxが無効になるため、x.len()でエラー。
- このコードはコンパイルできるか?
let x = 5;
let y = x;
println!("{}", x);
- 答え: Yes。i32はCopyなので、xは有効。
- 関数シグネチャを設計せよ
レベル3: 設計
fn read_data(data: &[u8]) -> usize
- データを変更: fn modify_data(data: &mut Vec)
- データを消費: fn process_data(data: Vec) -> Result<(), Error> - 構造体を設計せよ
// 小さい構造体: Copy
#[derive(Copy, Clone)]
struct Point { x: i32, y: i32 }
// 大きい構造体: Cloneのみ
#[derive(Clone)]
struct Config { host: String, port: u16 }
次のステップ
Day 2では「借用」を学びます。所有権を持たずにデータにアクセスする方法を習得しましょう。
学習ポイント:
- 不変借用(
&T) - 可変借用(
&mut T) - 借用のルール(排他制御)
- ライフタイムの基礎
- 借用チェッカーのエラー解決
所有権をマスターしたあなたは、Rustの最大の山を越えました。借用はその延長線上にあり、より柔軟なメモリ管理を可能にします。