Day 003 — 所有権の基礎(ムーブセマンティクス)

2026-07-31 🟢 初心者 / Phase 1 概念理解 所有権の基礎(ムーブセマンティクス)

📚 背景知識(読んでから問題へ)

Rustはガベージコレクタ(GC)を持たないのに、C/C++のような手動free()も要求しません。その根幹にあるのが所有権システムです。所有権には3つの原則があります。

  1. Rustの各値は所有者(owner)と呼ばれる変数をちょうど1つだけ持つ
  2. 所有者がスコープを抜けると、値は自動的にドロップ(drop)される
  3. 値を別の変数に代入したり関数に渡したりすると、多くの場合所有権が移動(ムーブ)する

なぜムーブが必要か

Stringのようなヒープ確保を伴う型は、内部的には「スタック上のポインタ・長さ・キャパシティ」と「ヒープ上の実データ」に分かれています。もしlet s2 = s1;のような代入で単純にこの構造体をビットコピーしただけだと、s1s2の両方が同じヒープ領域を指すことになります。スコープを抜けるとき、両方の変数がそれぞれdropを呼び出そうとすれば、同じメモリを2回解放してしまう二重解放(double free)が発生します。

Rustはこれを防ぐために、代入時に「所有権をs1からs2へ完全に移動し、s1を無効化する」という設計を選びました。これがムーブセマンティクスです。無効化されたs1を使おうとするコードは、実行前にコンパイルエラーとして検出されます。

Copyトレイト:ムーブされない型

i32f64boolcharのようにスタックのみに収まる単純な型は、Copyトレイトを実装しています。これらの型では代入時にムーブではなく値のビット単位コピーが行われ、コピー元の変数もそのまま使い続けられます。ヒープを持たないためコピーのコストが極めて低く、二重解放の懸念もないためです。

Cloneトレイト:明示的なディープコピー

StringVec<T>のようなヒープデータを複製したい場合は、.clone()メソッドを明示的に呼び出します。Rustが「暗黙のコピー」を許さず.clone()を明示させるのは、コストが見えないコピーをコード上に隠さないという設計思想の表れです。

他言語との違い(ざっくり)

  • Go/Java/TypeScript: 参照型の代入は「参照のコピー」で、元の変数も引き続き同じオブジェクトを指せます(エイリアシング自由)。生存期間はGCが管理します。
  • C++: デフォルトはコピーコンストラクタによる暗黙のディープコピー(気づかずに高コストな処理が走ることがある)。安いムーブをしたい場合はstd::move()で明示的にキャストする必要があります。
  • Rust: C++と正反対です。デフォルトは安いムーブで、高コストなコピー(clone)は明示しなければなりません。

📝 問題

以下のコードにはコンパイルエラーが発生する箇所があります。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}, s2 = {}", s1, s2);

    let n1 = 5;
    let n2 = n1;
    println!("n1 = {}, n2 = {}", n1, n2);
}

次の問いに答えてください。

  1. どの行でコンパイルエラーが発生するか。なぜs1s2のブロックはエラーになり、n1n2のブロックはエラーにならないのかを、所有権とCopyトレイトの観点から説明してください
  2. 発生するコンパイルエラーのエラーコード(E0xxx)と、おおまかなメッセージ内容を予想してください
  3. このコードを2通りの方法で修正してください
    • 方法A: .clone()を使ってディープコピーする
    • 方法B: 所有権を移動させず、参照(&)を使って借用する
  4. 追加の実装課題: 次の2つの関数を実装し、mainから呼び出して挙動を確認してください
    • fn takes_ownership(s: String): 引数のStringの所有権を受け取り、内容を出力するだけの関数(呼び出し後、呼び出し元の変数が使えなくなることを確認する)
    • fn calculate_length(s: &String) -> usize: 引数を借用し、文字列の長さを返す関数(呼び出し後も呼び出し元の変数が引き続き使えることを確認する)

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • 所有権のルールを思い出してください。「1つの値の所有者は常に1人」です。let s2 = s1;の直後、s1という変数はまだ有効でしょうか?
  • i32はスタックに収まるサイズが固定された単純な型です。このような型に対してRustが用意している特別なトレイトがあります
ヒント2 — アプローチ
  • StringはCopyトレイトを実装していません。したがってlet s2 = s1;ムーブとして扱われ、s1は以降「無効化」されます
  • i32Copyトレイトを実装しているため、let n2 = n1;ビットコピーとして扱われ、n1n2も両方有効なままです
  • エラーコードは「ムーブされた値の借用」を意味するものです
  • 方法Bでは、関数シグネチャを&String(または&str)に変える必要があります
ヒント3 — コード骨格
fn takes_ownership(s: String) {
    println!("所有権を受け取った: {}", s);
    // ここでsはスコープを抜けてdropされる
}

fn calculate_length(s: &String) -> usize {
    s.len()
    // sは借用なので、関数を抜けてもdropされない(所有権を持っていないため)
}

fn main() {
    // --- 修正前の問題を2通りで直す ---
    let s1 = String::from("hello");
    let s2 = s1.clone(); // 方法A: ディープコピー
    println!("s1 = {}, s2 = {}", s1, s2);

    let n1 = 5;
    let n2 = n1; // i32はCopyなのでそのままでOK
    println!("n1 = {}, n2 = {}", n1, n2);

    // --- 追加課題 ---
    let s3 = String::from("world");
    takes_ownership(s3);
    // ここで s3 を使おうとすると...?

    let s4 = String::from("rust");
    let len = calculate_length(&s4);
    // s4 はここでまだ使える
    println!("'{}' の長さ: {}", s4, len);
}

模範解答

fn takes_ownership(s: String) {
    println!("所有権を受け取った: {}", s);
    // ここでsはスコープを抜けてdropされる(ヒープメモリが解放される)
}

fn calculate_length(s: &String) -> usize {
    s.len()
    // sは&Stringという「借用」であり所有権を持たないため、
    // 関数を抜けてもdropは発生しない(呼び出し元がs4の所有者のまま)
}

fn main() {
    // --- 元のコードが持っていた問題の修正 ---

    // 方法A: clone()でディープコピーする(s1もs2も独立して使える)
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);

    // i32はCopyトレイトを実装しているため、そのままで問題なし
    let n1 = 5;
    let n2 = n1;
    println!("n1 = {}, n2 = {}", n1, n2);

    println!("---");

    // 方法B: 参照(&)で借用する版(別の変数ペアで確認)
    let s1b = String::from("hello");
    let s2b = &s1b; // ムーブではなく借用
    println!("s1b = {}, s2b = {}", s1b, s2b);

    println!("---");

    // --- 追加実装課題 ---

    // takes_ownership: 呼び出すとs3の所有権がtakes_ownershipに移動する
    let s3 = String::from("world");
    takes_ownership(s3);
    // この後 println!("{}", s3) と書くとコンパイルエラーになる
    // (s3は既にムーブ済みで無効化されているため、あえて書かない)

    // calculate_length: 借用なので呼び出し後もs4を使い続けられる
    let s4 = String::from("rust");
    let len = calculate_length(&s4);
    println!("'{}' の長さ: {}", s4, len);
}
▶ 実行結果を見る(cargo run)
s1 = hello, s2 = hello
n1 = 5, n2 = 5
---
s1b = hello, s2b = hello
---
所有権を受け取った: world
'rust' の長さ: 4

🪜 Step-by-Step 解説

1
なぜlet s2 = s1;だけがエラーになるのか
let s1 = String::from("hello");
let s2 = s1; // s1の所有権がs2へムーブする
StringCopyを実装していません。したがってこの代入はムーブとして扱われ、コンパイラは以降s1を「無効な変数」としてマークします。一方i32Copyを実装しているため、let n2 = n1;は単純なビットコピーであり、n1n2はそれぞれ独立した値として両方有効です。
2
予想されるコンパイルエラー
元のコードのままprintln!("s1 = {}, s2 = {}", s1, s2);を実行しようとすると、error[E0382]: borrow of moved value: s1というエラーになります(詳細は「コンパイルエラーが出た場合」セクション参照)。
3
方法A — .clone()で修正する
let s2 = s1.clone();
.clone()はヒープ上のデータを新しく確保し直し、完全に独立したコピーを作ります。これによりs1はムーブされず、両方の変数が有効なまま使えます。ただしヒープ確保とデータコピーのコストが発生する点に注意してください。
4
方法B — 参照(&)で借用する
let s2b = &s1b;
&s1bは「所有権を移動させずに、値への参照だけを渡す」操作です。所有者は引き続きs1bのままなので、ムーブは発生せず、s1bs2bs1bへの参照)も両方有効です。コピーのコストなしに、元の値を安全に共有できます。
5
所有権を受け取る関数 vs 借用する関数
fn takes_ownership(s: String) { ... }   // 呼び出すと引数の所有権が関数側へ移動する
fn calculate_length(s: &String) -> usize { ... } // 参照を渡すだけで所有権は移動しない
takes_ownership(s3)を呼んだ後、s3はムーブ済みで無効化されるため、以降s3を使おうとするとコンパイルエラーになります。一方calculate_length(&s4)は参照を渡しているだけなので、呼び出し後もs4は引き続き有効です。

💡 設計思想・なぜこう書くのか

📌
ムーブを既定にした理由: 値の代入や関数呼び出しは非常に頻繁に発生する操作です。もしこれらが常にディープコピーだったら(C++のデフォルト挙動のように)、意図せず高コストな処理が随所で走ることになります。Rustは「頻繁に起きる操作(所有権の移動)を安価にし、たまにしか使わない高コストな操作(ディープコピー)を.clone()という目に見える形で明示させる」ことで、パフォーマンスの見通しをコードから読み取れるようにしています。これはゼロコスト抽象化の思想の一部です——「使わない機能にコストを払わない」だけでなく「使うコストは常にコードから見える」という透明性も重視されています。
📌
コンパイル時に所有権エラーを検出する理由: 二重解放やuse-after-freeはC/C++で長年悩まされてきた実行時バグの典型です。Rustはこれを実行時のチェック(GCやリファレンスカウントの実行時オーバーヘッド)ではなく、借用チェッカーによる静的解析で解決しています。実行時コストゼロで安全性を保証できるのは、この所有権ルールをコンパイラが型システムの一部として厳密に追跡しているからです。

🛑 コンパイルエラーが出た場合

元のコード(修正前)をそのままコンパイルすると、次のようなエラーが出ます。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {}, s2 = {}", s1, s2); // ここでs1を使おうとする
}
error[E0382]: borrow of moved value: `s1` --> src/main.rs:4:38 | 2 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 3 | let s2 = s1; | -- value moved here 4 | println!("s1 = {}, s2 = {}", s1, s2); | ^^ value borrowed here after move

読み方:

  • error[E0382]: 「ムーブ済みの値を借用しようとした」エラー。所有権システム特有のエラーコードです
  • move occurs because ... does not implement the Copy trait: StringCopyを実装していないため、代入がムーブとして扱われたことを説明しています
  • value moved here: let s2 = s1;の時点で所有権がs2へ移動したことを示す位置
  • value borrowed here after move: ムーブ済みのs1を、その後のprintln!でまだ使おうとしていることを指摘

修正方法: 「模範解答」セクションの方法A(.clone())または方法B(&による借用)のどちらかを適用します。rustcのエラーメッセージはhelp: consider cloning the value if the performance cost is acceptableのような提案も積極的に提示してくれます。

🌐 他言語との比較

観点RustGoJavaC++TypeScript
デフォルトの代入挙動ムーブ(Copy型のみ例外的にコピー)値型はコピー、参照型(slice/map等)は参照コピー参照型は常に参照コピー(GCが管理)コピーコンストラクタによる暗黙のディープコピー参照コピー(GCが管理)
元の変数は代入後も使えるかムーブなら使えない(コンパイルエラー)型によって異なるが基本的に使える使える(同じオブジェクトを指す)使える(コピーされているため)使える(同じオブジェクトを指す)
安価な所有権移動をしたい場合既定の挙動そのもの(何もしなくてよい)該当概念なし該当概念なしstd::move()で明示的にキャスト該当概念なし
明示的なディープコピー.clone()型ごとに手動実装clone()(浅いコピーになりがち)コピーコンストラクタ(暗黙または= default手動でのディープコピー実装が必要
二重解放バグの可能性コンパイル時に排除されるGCが管理するため発生しないGCが管理するため発生しないプログラマの責任(バグの温床になりやすい)GCが管理するため発生しない

Rustが際立つのは、GC言語のような実行時の安全網なしに、C++と同等の実行時コストで二重解放を防いでいる点です。C++はstd::move()という「明示的に安く済ませる」手段を後付けで用意しましたが、Rustは最初から「安いムーブが既定、高いコピーが明示」という逆の優先順位を型システムに組み込んでいます。

🏆 実務での使いどころ

  • Builderパターンでのself消費: fn build(self) -> Widgetのようにselfを値で受け取る設計は、「このメソッドを呼んだ後はBuilderを再利用させない」という意図をAPIシグネチャそのもので強制します。誤って同じBuilderを2回使うバグをコンパイル時に防げます
  • 大きなデータ構造の受け渡し: 巨大なVec<T>Stringを関数間で受け渡す際、所有権をムーブするだけならヒープデータのコピーは発生しません。「参照で十分か、所有権を渡す必要があるか」を関数シグネチャ設計時に意識することが、パフォーマンスと安全性の両立につながります
  • APIシグネチャが伝える意図: fn process(data: String)(消費する)とfn inspect(data: &String)(覗くだけ)はシグネチャを見ただけで呼び出し元への影響が分かります。これはコードレビューやAPI設計において強力なドキュメントになります
  • 不要な.clone()の濫用に注意: 借用チェッカーのエラーに困って安易に.clone()を貼り付けるのはRust初学者が陥りやすいアンチパターンです。パフォーマンスクリティカルな箇所では「本当にコピーが必要か、借用で済まないか」を都度検討する必要があります

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
代入は常に値がコピーされると思うGo/Java/TypeScript等、多くの言語で値型の代入がコピーとして振る舞う経験があるためRustではCopyトレイトを実装していない型(String, Vec<T>等)の代入はデフォルトでムーブになる。コピーが必要なら.clone()を明示する
ムーブ後の変数はC++のムーブ済みオブジェクトのように「有効だが中身が空」の状態で使えると思うC++のstd::move()後のオブジェクトは技術的にはまだ「有効だが未規定の状態」であり、破棄や再代入は可能なためRustではムーブされた変数はコンパイラによって完全に無効化され、再代入するまで一切使用できない。実行時ではなくコンパイル時にこの制約が強制される
関数に値を渡すと自動的に参照渡しになると思うGo/Javaなどでオブジェクトを関数に渡すと(参照型として)暗黙的に参照が渡される感覚があるためRustでは引数の型が値型(Stringなど)であれば、関数呼び出し時に所有権がムーブされる。参照を渡したい場合は呼び出し側で明示的に&を付ける必要がある
.clone()は常に安全でコストを気にしなくてよいと思うメソッド1つ呼ぶだけで解決するので「便利な魔法」に見えるため.clone()はヒープデータのディープコピーであり、データサイズに比例したメモリ確保とコピーのコストが発生する。ホットパスでの濫用はパフォーマンス低下の原因になる

🚀 次のステップ

  • 発展: 今回は&による借用を「所有権を移動しない値の共有」として軽く触れましたが、&mutによる可変借用や「同時に複数の可変参照を持てない」という借用規則の詳細はまだ扱っていません。&s1bのような不変参照が複数同時に存在できることを実際にコードで試してみると、次回の理解がスムーズになります
  • 次回予告: Day 004 — 借用と参照(& と &mut)(借用規則(同時可変参照の禁止)の意味)

🎯 自己評価

自分の回答

気づき・メモ