📚 背景知識(読んでから問題へ)
Day 003では単純な変数のムーブ、Day 004ではVecの要素に対する部分ムーブが禁止されていること、Day 005ではTake & Returnパターンを学びました。今日はDay 004の最後で触れた予告——「構造体(struct)のフィールドは、実は部分的にムーブしてよい」という、Vecとは対照的なルールを掘り下げます。
Vecは部分ムーブ禁止、structは部分ムーブOK
Day 004で見たとおり、Vec<T>は「連続したメモリ領域に穴なくデータが並んでいる」という不変条件を持つため、fruits[i]のような動的インデックスの要素だけを抜き取ることはできませんでした(E0507)。
ところがstructは事情が異なります。
struct Config {
name: String,
path: String,
}
fn print_name(name: String) {
println!("name: {}", name);
}
fn main() {
let config = Config {
name: String::from("app.conf"),
path: String::from("/etc/app/app.conf"),
};
// フィールドアクセスで name だけをムーブする(= 部分ムーブ)
print_name(config.name);
// path はまだ有効。部分ムーブされたのは name だけ
println!("path still valid: {}", config.path);
}
config.nameだけを関数に渡しても、config.pathはそのまま使い続けられます。これはVecでは絶対に許されなかった操作です。
なぜこの違いが生まれるのか
Vecのインデックスiはforループの変数のように実行時にならないと値が決まらない動的な値です。コンパイラは「どの要素がムーブ済みか」を静的に追跡する手段を持たないため、安全のために部分ムーブそのものを禁止しています。
一方structのフィールドはname・pathのようにコンパイル時に個数も名前も固定されています。コンパイラは「config.nameだけがムーブ済みで、config.pathはまだ有効」という状態を型チェックの中で正確に追跡できます。だからこそ、フィールド単位の部分ムーブを安全に許可できるのです。
静的に追跡できるものは部分ムーブを許し、動的にしか分からないものは全体か借用かの二択に倒す——これがRustの一貫した設計判断です。
📝 問題
- 概念理解課題: 上記の
Configの例で、config.nameを関数に渡した後にconfig.pathは使えるのに、Day 004のfruits[i](Vec<String>の要素)は同じように部分的に取り出せなかったのはなぜですか。「コンパイル時に静的に追跡できるかどうか」という観点で説明してください - 実装課題: 以下を満たす
Config構造体と処理を実装してください- フィールド
name: String、path: Stringを持つConfigを定義する fn print_name(name: String)という、Stringの所有権を受け取って表示するだけの関数を実装するmain内でConfigのインスタンスを作り、print_name(config.name)でnameフィールドだけをムーブし、その後config.pathを(ムーブせずに)表示する
- フィールド
- デバッグ予測課題: 上記2の処理の直後に、もう一度
println!("{}", config.name);を書いてコンパイルしようとするとエラーになります。発生するエラーコード(E0xxx)と、おおまかなメッセージ内容を予想してください - 実装課題(メソッド版):
impl Configブロックにfn into_name_and_path(self) -> (String, String)というメソッドを実装してください。これはselfを消費して(name, path)のタプルを返します(=両方のフィールドを一度に取り出す、フィールド全体の完全な分解) - 発展の概念理解(コードは不要): もし
ConfigがDropトレイトを実装していたら、フィールド単位の部分ムーブ(問題2のような操作)は禁止されます。なぜだと思いますか?(ヒント:Dropはデストラクタです。構造体全体が破棄されるとき、Rustは全フィールドを正しい順序でDropする責任を負います)
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Vecのインデックスiはループ変数のように実行時に決まる値です。それに対してstructのフィールド名は、ソースコードを書いた時点で全て固定されています- コンパイラが「どこがムーブ済みか」を型チェックの中で正確に把握できるかどうかが、部分ムーブを許すかどうかの分かれ目です
Dropを実装した型は、スコープを抜けるときにdrop(&mut self)が呼ばれます。もし一部のフィールドだけ先にムーブされて空っぽになっていたら、dropはそのフィールドに対して何をすればよいでしょうか?
ヒント2 — アプローチ
- 実装課題は「背景知識」セクションのコード例をほぼそのまま
mainに書けば動きます - デバッグ予測課題のエラーは、Day 005で見た「ムーブ済みの値を使おうとした」系のエラーコードと同じ系統です。ただし今回は構造体の特定フィールドだけがムーブ済みという状態なので、メッセージ文言が少し異なります(
use of moved value: config.nameのように、フィールド名まで指定されます) into_name_and_pathはself.nameとself.pathをタプルにして返すだけです。self(所有権)を受け取っているので、両方のフィールドをムーブして構いません
ヒント3 — コード骨格
struct Config {
name: String,
path: String,
}
impl Config {
fn into_name_and_path(self) -> (String, String) {
// self.name と self.path をタプルにして返す
}
}
fn print_name(name: String) {
println!("name: {}", name);
}
fn main() {
let config = Config {
name: String::from("app.conf"),
path: String::from("/etc/app/app.conf"),
};
print_name(config.name); // name フィールドを部分ムーブ
println!("path still valid: {}", config.path); // path はまだ使える
// ここで println!("{}", config.name); を書くとどうなるか予想する(実際には書かない)
let config2 = Config {
name: String::from("db.conf"),
path: String::from("/etc/app/db.conf"),
};
let (n, p) = config2.into_name_and_path();
println!("{} @ {}", n, p);
}
✅ 模範解答
struct Config {
name: String,
path: String,
}
impl Config {
fn into_name_and_path(self) -> (String, String) {
(self.name, self.path)
}
}
fn print_name(name: String) {
println!("name: {}", name);
}
fn main() {
// --- 実装課題2: フィールド単位の部分ムーブ ---
let config = Config {
name: String::from("app.conf"),
path: String::from("/etc/app/app.conf"),
};
print_name(config.name); // config.name だけがムーブされる
println!("path still valid: {}", config.path); // config.path は無傷なのでそのまま使える
println!("---");
// --- 実装課題4: メソッドでself全体を消費する版 ---
let config2 = Config {
name: String::from("db.conf"),
path: String::from("/etc/app/db.conf"),
};
let (n, p) = config2.into_name_and_path(); // config2全体がこの呼び出しでムーブされる
println!("{} @ {}", n, p);
// config2 はここではもう使えない(into_name_and_pathがselfを消費したため)
// 一方 config は name フィールドだけがムーブ済みで、path はまだ使える状態のまま
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_into_name_and_path() {
let config = Config {
name: String::from("db.conf"),
path: String::from("/etc/app/db.conf"),
};
let (n, p) = config.into_name_and_path();
assert_eq!(n, "db.conf");
assert_eq!(p, "/etc/app/db.conf");
}
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
name: app.conf
path still valid: /etc/app/app.conf
---
db.conf @ /etc/app/db.conf
$ cargo test
running 1 test
test tests::test_into_name_and_path ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
🪜 Step-by-Step 解説
config.nameだけをムーブするprint_name(config.name);
config.nameというフィールドアクセス式をそのまま関数に渡すと、nameフィールドが持つStringの所有権が関数へムーブされます。この時点でコンパイラは「configのうちnameフィールドだけがムーブ済み」という状態を型チェックの内部で記録します。
config.pathはまだ有効println!("path still valid: {}", config.path);
pathフィールドは一度もムーブされていないため、引き続き有効です。コンパイラはconfigという変数全体を「無効」にするのではなく、フィールド単位で有効/無効を管理しているため、この行は問題なくコンパイルが通ります。
config.nameをもう一度使おうとするとエラーになる理由問題3で予測してもらった通り、
println!("{}", config.name);を追加するとコンパイルエラーになります。理由はDay 005で見たE0382と同じ系統——ムーブ済みの値を使おうとしたことです。ただし今回はconfig全体ではなくconfig.nameという特定のフィールドがムーブ済みである点が異なり、エラーメッセージにもconfig.nameというフィールド名がそのまま登場します(詳細は「コンパイルエラーが出た場合」セクション)。
into_name_and_path——selfを消費して両方取り出すfn into_name_and_path(self) -> (String, String) {
(self.name, self.path)
}
self(Config型そのもの、参照ではない)を引数に取ることで、このメソッドは呼び出されるとConfigインスタンス全体の所有権を受け取ります。メソッド内でself.nameとself.pathの両方をタプルに詰めて返すのは、「部分ムーブ」ではなく構造体全体の完全な分解です。呼び出し元のconfig2はこの呼び出し後、丸ごと無効になります(configのように一部フィールドだけ生き残る、ということはありません)。
💡 設計思想・なぜこう書くのか
structのフィールドは名前も個数もソースコード上に固定して書かれているため、コンパイラは「このフィールドはムーブ済み、あのフィールドはまだ有効」という表を静的に構築できます。一方Vecのインデックスは実行時の変数(for i in 0..fruits.len()のi)に依存するため、同じ表を静的に構築する手段がありません。この非対称性が、Day 004のVecの部分ムーブ禁止と、今日のstructの部分ムーブ許可という、一見矛盾するようで実は筋の通ったルールの違いを生んでいます。Drop実装型で部分ムーブが禁止される理由: 問題5で触れた通り、Dropを実装した型で部分ムーブが禁止されるのも同じ精密な追跡の裏返しです。Dropはスコープを抜けるときに構造体全体に対して一度だけ呼ばれる後片付け処理であり、「一部のフィールドだけ先に空っぽになった状態」でその後片付けを正しく実行する方法をコンパイラも言語仕様も定義していません。そのため、安全性を守るために部分ムーブそのものが禁止(E0509)されます。これも「静的に安全性を保証できないケースは禁止する」という同じ原則の適用です。🛑 コンパイルエラーが出た場合
問題2の処理の後に、もう一度config.nameを使おうとした場合のコードです。
fn main() {
let config = Config {
name: String::from("app.conf"),
path: String::from("/etc/app/app.conf"),
};
print_name(config.name);
println!("path still valid: {}", config.path);
println!("{}", config.name); // ← ここでエラー
}
読み方:
error[E0382]: Day 005で見たものと同じエラーコードです。「ムーブ済みの値を使おうとした」ことを示しますuse of moved value: config.name: 今回はDay 005のgreetingのような変数全体ではなく、config.nameというフィールド単位でムーブ済みと判定されています。コンパイラがフィールドレベルで有効/無効を追跡している証拠ですvalue moved here/value used here after move: それぞれ「ここでムーブが起きた(print_name(config.name))」「その後ここで使おうとした」を対応付けています
もしconfig.pathではなくconfig変数全体(例えばprintln!("{:?}", config)のようなDerive Debugでの丸ごと表示)を使おうとした場合は、少し文言が異なるエラーになります。
こちらは「configという値の一部(name)が部分ムーブされているため、config全体を借用できない」という意味です。config.pathのようにまだ有効なフィールドを個別に指定してアクセスする分には問題ない、という違いを押さえておくと混乱しません。
修正方法: ムーブ済みのフィールドをもう一度使いたい場合は、そもそも部分ムーブする前に.clone()しておく(let name = config.name.clone();)か、Day 005のTake & Returnパターンのように新しい値を作って再度Configを組み立て直す必要があります。
🌐 他言語との比較
| 観点 | Rust | Go | Java | C++ | TypeScript |
|---|---|---|---|---|---|
| 構造体の1フィールドだけ「渡す」操作 | フィールド単位の部分ムーブとしてコンパイラが静的に追跡。他のフィールドは無傷のまま使い続けられる | フィールドアクセスは常に値のコピー(または参照のコピー)。「ムーブして無効化」という概念自体が存在しない | フィールドはオブジェクトへの参照。渡しても元のオブジェクト・フィールドは常に有効(GCが管理) | std::move(obj.field)で部分的にムーブ可能だが、obj.fieldは「有効だが未規定の状態」として残り、コンパイラは再アクセスを防いでくれない | フィールドは参照のコピー。渡しても元のオブジェクトは常に有効(GCが管理) |
| コンパイラによる追跡 | フィールド単位で「ムーブ済みか」を静的に型チェックし、誤用をコンパイルエラーで防ぐ | 追跡不要(コピーなので誤用が起きない) | 追跡不要(GCが管理) | 追跡なし。std::move後のフィールドへのアクセスはプログラマの規律に依存し、バグの温床になりやすい | 追跡不要(GCが管理) |
Drop(デストラクタ)実装型での扱い | 部分ムーブ禁止(E0509)。デストラクタが全フィールドを一貫して後片付けできることを保証するため | 該当概念なし(GCにファイナライザはあるが所有権と無関係) | 該当概念なし(finalizeは非推奨・GC管理) | デストラクタは常に呼ばれるが、ムーブ済みフィールドに対する二重解放を防ぐのはプログラマの責任(ムーブ後のポインタをnullにする等) | 該当概念なし |
C++との対比が象徴的です。C++でもstd::move(obj.field)によってフィールド単位のムーブは可能ですが、その後obj.fieldへ誤ってアクセスしてしまうバグをコンパイラは検出してくれません。Rustはこの「フィールド単位の有効/無効」をコンパイル時の型チェックの一部として完全に追跡し、誤用を型エラーとして弾く点が根本的に異なります。
🏆 実務での使いどころ
- 設定・リクエストオブジェクトの分解:
ConfigやHttpRequestのような複数フィールドを持つ構造体から、特定のフィールドだけを別のコンポーネントに所有権ごと渡したい場面(例:headersだけをロギング処理に渡し、bodyは別のハンドラに渡す)で、部分ムーブは自然に活用されます selfを消費するBuilderやコンバータメソッド:into_name_and_pathのようにfn into_xxx(self) -> ...という命名規則(Rustの慣習でinto_接頭辞は所有権を消費する変換を意味します)は、標準ライブラリでもString::into_bytes(self) -> Vec<u8>など頻出します- デストラクタリング(
let Struct { a, b } = value;)との使い分け: 今日はフィールドアクセス(config.name)による部分ムーブを扱いましたが、パターンマッチによる分解(let Config { name, path } = config;)を使うと、一度に複数フィールドをそれぞれ独立した変数へムーブできます。どちらも同じ「フィールド単位の追跡」の上に成り立つ機能です Drop実装との設計トレードオフ判断: 独自にリソース管理(ファイルハンドル・コネクションなど)をする型にDropを実装すると、その型は部分ムーブができなくなります。「フィールドを個別に取り出したいAPIを設計したい」場合は、Dropを実装せずに済む設計(リソース解放を明示的なメソッド呼び出しに任せる等)を検討する判断材料になります
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
Vecの部分ムーブが禁止されているなら、structのフィールドも同様に禁止されていると思う | どちらも「複合型の一部を取り出す」という見た目上の操作が同じため | structのフィールドは名前・個数がコンパイル時に固定されているため部分ムーブが静的に安全に追跡でき、許可されている。Vecのインデックスは実行時にしか決まらないため禁止されている、という根本的な違いがある |
| フィールドを1つムーブすると、構造体全体(他のフィールドも含めて)が使えなくなると思う | 変数全体がムーブされるDay 003・005の感覚を構造体にもそのまま適用してしまうため | 実際にはムーブされていない他のフィールド(今日の例ではconfig.path)は引き続き有効。無効になるのはムーブされた特定のフィールドのみ |
Dropを実装していてもいなくても、部分ムーブの可否は変わらないと思う | 通常のフィールドアクセスの見た目が同じため、Drop実装の有無が挙動に影響するとは想像しにくい | Dropを実装した型は、スコープを抜けるときに構造体全体に対して一度だけデストラクタが呼ばれる。フィールドが部分的に空になっていると正しく後片付けできないため、コンパイラは部分ムーブ自体を禁止する(E0509) |
🚀 次のステップ
- 発展:
let Config { name, path } = config;というパターンマッチによる分解を試してみてください。フィールドアクセス(config.name)による部分ムーブと、分解パターンによる同時ムーブがどちらも同じ「フィールド単位の追跡」に基づいていることを確認できます - 次回予告: 所有権の基礎(ムーブセマンティクス)はここでいったん一区切りです。Day 007以降で借用と参照(& と &mut)へ本格的に進み、「所有権を移動させずにその場で書き換える」ための借用規則(同時可変参照の禁止)を学びます