📚 背景知識(読んでから問題へ)
Rustはガベージコレクタ(GC)を持たないのに、C/C++のような手動free()も要求しません。その根幹にあるのが所有権システムです。所有権には3つの原則があります。
- Rustの各値は所有者(owner)と呼ばれる変数をちょうど1つだけ持つ
- 所有者がスコープを抜けると、値は自動的にドロップ(drop)される
- 値を別の変数に代入したり関数に渡したりすると、多くの場合所有権が移動(ムーブ)する
なぜムーブが必要か
Stringのようなヒープ確保を伴う型は、内部的には「スタック上のポインタ・長さ・キャパシティ」と「ヒープ上の実データ」に分かれています。もしlet s2 = s1;のような代入で単純にこの構造体をビットコピーしただけだと、s1とs2の両方が同じヒープ領域を指すことになります。スコープを抜けるとき、両方の変数がそれぞれdropを呼び出そうとすれば、同じメモリを2回解放してしまう二重解放(double free)が発生します。
Rustはこれを防ぐために、代入時に「所有権をs1からs2へ完全に移動し、s1を無効化する」という設計を選びました。これがムーブセマンティクスです。無効化されたs1を使おうとするコードは、実行前にコンパイルエラーとして検出されます。
Copyトレイト:ムーブされない型
i32やf64、bool、charのようにスタックのみに収まる単純な型は、Copyトレイトを実装しています。これらの型では代入時にムーブではなく値のビット単位コピーが行われ、コピー元の変数もそのまま使い続けられます。ヒープを持たないためコピーのコストが極めて低く、二重解放の懸念もないためです。
Cloneトレイト:明示的なディープコピー
StringやVec<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);
}
次の問いに答えてください。
- どの行でコンパイルエラーが発生するか。なぜ
s1とs2のブロックはエラーになり、n1とn2のブロックはエラーにならないのかを、所有権とCopyトレイトの観点から説明してください - 発生するコンパイルエラーのエラーコード(
E0xxx)と、おおまかなメッセージ内容を予想してください - このコードを2通りの方法で修正してください
- 方法A:
.clone()を使ってディープコピーする - 方法B: 所有権を移動させず、参照(
&)を使って借用する
- 方法A:
- 追加の実装課題: 次の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は以降「無効化」されますi32はCopyトレイトを実装しているため、let n2 = n1;はビットコピーとして扱われ、n1もn2も両方有効なままです- エラーコードは「ムーブされた値の借用」を意味するものです
- 方法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 解説
let s2 = s1;だけがエラーになるのかlet s1 = String::from("hello");
let s2 = s1; // s1の所有権がs2へムーブする
StringはCopyを実装していません。したがってこの代入はムーブとして扱われ、コンパイラは以降s1を「無効な変数」としてマークします。一方i32はCopyを実装しているため、let n2 = n1;は単純なビットコピーであり、n1とn2はそれぞれ独立した値として両方有効です。
元のコードのまま
println!("s1 = {}, s2 = {}", s1, s2);を実行しようとすると、error[E0382]: borrow of moved value: s1というエラーになります(詳細は「コンパイルエラーが出た場合」セクション参照)。
.clone()で修正するlet s2 = s1.clone();
.clone()はヒープ上のデータを新しく確保し直し、完全に独立したコピーを作ります。これによりs1はムーブされず、両方の変数が有効なまま使えます。ただしヒープ確保とデータコピーのコストが発生する点に注意してください。
&)で借用するlet s2b = &s1b;
&s1bは「所有権を移動させずに、値への参照だけを渡す」操作です。所有者は引き続きs1bのままなので、ムーブは発生せず、s1bもs2b(s1bへの参照)も両方有効です。コピーのコストなしに、元の値を安全に共有できます。
fn takes_ownership(s: String) { ... } // 呼び出すと引数の所有権が関数側へ移動する
fn calculate_length(s: &String) -> usize { ... } // 参照を渡すだけで所有権は移動しない
takes_ownership(s3)を呼んだ後、s3はムーブ済みで無効化されるため、以降s3を使おうとするとコンパイルエラーになります。一方calculate_length(&s4)は参照を渡しているだけなので、呼び出し後もs4は引き続き有効です。
💡 設計思想・なぜこう書くのか
.clone()という目に見える形で明示させる」ことで、パフォーマンスの見通しをコードから読み取れるようにしています。これはゼロコスト抽象化の思想の一部です——「使わない機能にコストを払わない」だけでなく「使うコストは常にコードから見える」という透明性も重視されています。🛑 コンパイルエラーが出た場合
元のコード(修正前)をそのままコンパイルすると、次のようなエラーが出ます。
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("s1 = {}, s2 = {}", s1, s2); // ここでs1を使おうとする
}
読み方:
error[E0382]: 「ムーブ済みの値を借用しようとした」エラー。所有権システム特有のエラーコードですmove occurs because ... does not implement the Copy trait:StringがCopyを実装していないため、代入がムーブとして扱われたことを説明しています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のような提案も積極的に提示してくれます。
🌐 他言語との比較
| 観点 | Rust | Go | Java | C++ | 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)(借用規則(同時可変参照の禁止)の意味)