📚 背景知識(読んでから問題へ)
Day 007では借用規則の基本——「複数の&T」または「1個だけの&mut T」のどちらか一方しか同時に存在できない——を学びました。今日はこの規則をコンパイラが実際にはどう判定しているかを掘り下げます。
NLL(Non-Lexical Lifetimes)——借用は「最後に使われた地点」で終わる
以下のコードを見てください。
fn main() {
let mut s = String::from("hello");
let r1 = &s;
println!("{}", r1); // r1 の最後の使用はここ
let r2 = &mut s; // r1 はもう使われないので OK
r2.push_str(", world");
println!("{}", r2);
}
r1(不変借用)とr2(可変借用)が同じsに対して存在しているように見えますが、これはコンパイルが通ります。理由は、Rust 2018 Edition以降に導入されたNLL(Non-Lexical Lifetimes)という仕組みにあります。
NLL以前(Rust 2015、〜2017年頃)の借用チェッカーは、借用の有効期間を変数のスコープ({}で囲まれた範囲)全体として扱っていました。つまりr1はmain関数の終わりまで「生きている」と判定され、その途中でr2を作ろうとするとエラーになっていました。
NLL導入後は、借用チェッカーが制御フローグラフを解析し、「その参照が実際に最後に使われる行はどこか」を追跡します。r1はprintln!("{}", r1)の行で最後に使われ、それ以降のコードでは一切参照されません。そのためr1の借用はその時点で終了し、次の行でr2(可変借用)を作ることが許されます。
| Rust 2015(レキシカルスコープ) | Rust 2018+(NLL) | |
|---|---|---|
| 借用の有効期間 | 変数が宣言された{}スコープの終わりまで | 参照が実際に最後に使われる行まで |
| 上記コードの結果 | コンパイルエラー | コンパイル成功 |
不変借用と可変借用が混在するとエラー(E0502)
NLLは「使われなくなった借用を早めに終わらせてくれる」だけであり、実際に借用が同時に生きている場合はやはりエラーになります。
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // r1 がまだ後で使われるため NG
println!("{}, {}", r1, r2);
}
このコードではr1はprintln!の中で使われるため「まだ生きている」と判定され、その状態でr2(可変借用)を作ろうとするのでE0502というエラーになります(Day 007のE0499は「可変×可変」、今日のE0502は「不変×可変」の衝突です)。
メソッド呼び出し越しだと「フィールド分割借用」が効かない
構造体のフィールドへの直接アクセスであれば、異なるフィールドは同時に借用できます(これをフィールド分割借用と呼びます)。
struct Counter { value: i32, history: Vec }
fn ok_example(c: &mut Counter) {
c.history.push(c.value); // OK: history と value は別フィールド
}
しかし、c.valueの代わりに「valueを返す&selfメソッド」を経由すると話が変わります。
impl Counter {
fn current(&self) -> i32 { self.value }
fn increment_bad(&mut self) {
self.history.push(self.current()); // これはエラーになる
}
}
self.current()を見たとき、「selfのどのフィールドを読むか」ではなく「currentのシグネチャ&selfが要求する通り、self全体を不変借用する」と機械的に判断します。フィールド分割借用はコンパイラがフィールドへの直接アクセスを見ている場合にのみ働く最適化であり、メソッド呼び出しの向こう側までは追跡してくれません。📝 問題
- 概念理解課題: NLL(Non-Lexical Lifetimes)とは何か、Rust 2015のレキシカルスコープ方式の借用チェッカーとの違いを説明してください
- デバッグ予測課題(成功パターン): 背景知識の「NLLで動く例」(
r1をprintln!した後にr2 = &mut sを作るコード)が実際にコンパイルできる理由を、r1の「最後の使用地点」という観点で説明してください - デバッグ予測課題(失敗パターン): 背景知識の「E0502が出る例」(
r1をprintln!の中でr2と一緒に使うコード)について、なぜエラーになるのか、r1がいつまで「生きている」と判定されるかを説明してください - 実装課題(フィールド分割借用の限界と回避): 以下の
increment_badはコンパイルエラーになります。currentの戻り値を一度ローカル変数に退避することでエラーを回避したincrementメソッドを実装し、Counterを3回incrementした後にvalueが3、historyがvec![0, 1, 2]になることをテストで確認してくださいstruct Counter { value: i32, history: Vec} impl Counter { fn current(&self) -> i32 { self.value } fn increment_bad(&mut self) { self.history.push(self.current()); // ← ここでエラー self.value += 1; } } - 発展の設計課題(コードは不要):
increment_badがエラーになるのに、c.history.push(c.value)(フィールドへの直接アクセス)はエラーにならない理由を、「コンパイラは何を単位に借用の衝突を判定しているか」という観点で説明してください。また、大規模な構造体のメソッドを設計するとき、この制約を踏まえてどのような書き方の工夫をすべきか考えてください
🔍 ヒント(段階的開示)
ヒント1 — 方向性
- NLLは「借用が生きている範囲を狭める」仕組みであり、「借用規則そのものを緩める」わけではありません。同時に生きている借用同士の衝突は相変わらず禁止されます
- 問題4のエラーは、
self.history.push(...)の呼び出しを組み立てる際に「引数の評価」と「レシーバの借用」の順序が絡んでいます。self.current()は&self(=self全体への不変借用)を要求することを思い出してください - 問題5は「コンパイラはフィールド単位で借用を追跡できるのは"直接アクセス"を見ているときだけ」という点がヒントです
ヒント2 — アプローチ
- 問題4は、
self.current()の結果をlet current = self.current();のように先に変数へ取り出してからself.history.push(current)を呼べば、selfへの不変借用はその1行で完結し、その後のself.historyへの可変借用と衝突しなくなります - テストは
Counter::new()のようなコンストラクタを用意し、3回increment()を呼んでvalueとhistoryをアサーションすると書きやすいです
ヒント3 — コード骨格
struct Counter {
value: i32,
history: Vec,
}
impl Counter {
fn new() -> Self {
Counter { value: 0, history: Vec::new() }
}
fn current(&self) -> i32 {
self.value
}
fn increment(&mut self) {
let current = self.current(); // ← ここで &self の借用は完了する
self.history.push(current); // ← ここでは &mut self.history だけが必要
self.value += 1;
}
}
fn main() {
let mut counter = Counter::new();
counter.increment();
counter.increment();
counter.increment();
println!("value = {}", counter.value);
println!("history = {:?}", counter.history);
}
✅ 模範解答
struct Counter {
value: i32,
history: Vec,
}
impl Counter {
fn new() -> Self {
Counter { value: 0, history: Vec::new() }
}
fn current(&self) -> i32 {
self.value
}
// 修正版: self.current() の呼び出し結果を先にローカル変数へ退避する。
// こうすることで self 全体への不変借用(current()呼び出し)は
// let current = self.current(); の行で完結・終了し、
// 次の self.history.push(current) では self.history への
// 可変借用だけが必要になるため衝突しない。
fn increment(&mut self) {
let current = self.current();
self.history.push(current);
self.value += 1;
}
}
fn main() {
let mut counter = Counter::new();
counter.increment();
counter.increment();
counter.increment();
println!("value = {}", counter.value);
println!("history = {:?}", counter.history);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_increment() {
let mut counter = Counter::new();
counter.increment();
counter.increment();
counter.increment();
assert_eq!(counter.value, 3);
assert_eq!(counter.history, vec![0, 1, 2]);
}
#[test]
fn test_current() {
let counter = Counter::new();
assert_eq!(counter.current(), 0);
}
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
value = 3
history = [0, 1, 2]
$ cargo test
running 2 tests
test tests::test_current ... ok
test tests::test_increment ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
🪜 Step-by-Step 解説
increment_badはエラーになるのかfn increment_bad(&mut self) {
self.history.push(self.current()); // ← ここでエラー
self.value += 1;
}
self.history.push(引数)を呼び出すには、レシーバとしてself.historyへの&mutが必要です。コンパイラはこの&mut self.historyを先に確保しようとします。その後、引数self.current()を評価しようとすると、currentのシグネチャfn current(&self) -> i32が要求する通りself全体への&(不変借用)が必要になります。しかしself.historyはすでにselfの一部として可変借用済みのため、「self全体への不変借用」と「self.historyへの可変借用」が同時に存在することになり、借用規則に違反します。
let current = self.current();で分離するfn increment(&mut self) {
let current = self.current(); // (1)
self.history.push(current); // (2)
self.value += 1;
}
(1)行ではself.current()の呼び出しのために&self(不変借用)が発生しますが、この借用はcurrentというi32の値を受け取った時点で完全に終了します(i32はCopyなので、借用ではなく値そのものがcurrentにコピーされます)。(2)行に進む頃にはselfへの不変借用はもう存在しないため、self.historyへの可変借用と衝突しません。
Day 007〜今日の背景知識で見た通り、NLLは「参照が最後に使われた地点」までしか借用を有効とみなしません。(1)行の
self.current()が返す&self由来の借用は、その行の式評価が終わった時点で使用が終わっているため、NLLのおかげで(2)行では新たな借用(&mut self.history)を問題なく作成できます。
💡 設計思想・なぜこう書くのか
&T」か「1個だけの&mut T」のどちらか一方のみ——はRust 2015から一貫して変わっていません。NLLが変えたのは、コンパイラが「その借用がいつ終わるか」をどれだけ賢く判定できるかという解析の精度だけです。安全性のルールを緩めることなく誤検知(本来安全なのに弾かれてしまうコード)を減らす、というアプローチは、Rustが「安全性を犠牲にせずに使い勝手を上げる」ことを重視する言語であることを象徴しています。increment_badのようなフィールド分割借用の限界は、Rustの借用チェッカーが関数のシグネチャだけを見て判断するという原則の裏返しです。コンパイラはcurrent関数の中身(実際にはself.valueしか読んでいない)を覗き込んで賢く判断してくれるわけではなく、あくまでfn current(&self) -> i32という型シグネチャだけを見て「self全体への不変借用が必要」と機械的に判定します。これは、関数の実装を変更してもシグネチャさえ変えなければ呼び出し側の借用チェックが壊れない——というモジュール性を保つためのトレードオフです。中身まで解析してしまうと、ライブラリの内部実装を1行変えただけで別のクレートのコンパイルが通ったり通らなくなったりする、という予測不能な事態が起こり得ます。🛑 コンパイルエラーが出た場合
問題4のincrement_badです。
fn increment_bad(&mut self) {
self.history.push(self.current()); // ← ここでエラー
self.value += 1;
}
読み方:
error[E0502]: 「不変借用したいのに、すでに可変借用が存在している(あるいはその逆)」ことを示すエラーコードです。Day 007のE0499(可変×可変の衝突)とは別系統のエラーですmutable borrow occurs here:self.history.push(...)を呼び出すためのレシーバとして、self.historyへの可変借用がここで発生していますimmutable borrow occurs here: 引数self.current()を評価するために、self全体への不変借用がここで発生していますmutable borrow later used by call: 先に確保した可変借用(push呼び出し)が、引数の評価を終えた後の実際の呼び出しでまだ使われる予定であるため、その間は不変借用と共存できません
修正方法: 問題4の模範解答の通り、self.current()の呼び出し結果をletで一度ローカル変数へ退避してからself.history.push(...)に渡します。こうすることで「selfへの不変借用」と「self.historyへの可変借用」が時間的に重ならなくなります。
🌐 他言語との比較
| 観点 | Rust | Go | Java | C++ | TypeScript |
|---|---|---|---|---|---|
| 参照の有効期間の判定方法 | コンパイラが制御フローを解析し、実際の最終使用地点まで(NLL)。手動管理不要 | ガベージコレクションが実際の参照の有無で解放タイミングを決める。借用の「衝突」という概念自体が存在しない | ガベージコレクションが到達可能性で解放。同様に借用衝突の概念はない | プログラマが手動でポインタ/参照の生存期間を管理する必要があり、ミスはダングリングポインタや二重解放につながる | ガベージコレクションに委ねる。借用衝突の概念はない |
| 「メソッド越しのフィールドアクセス」が制約になるか | なる。呼び出し先の中身を見ず、シグネチャ(&self/&mut self)だけで借用が判定される | ならない。ポインタレシーバのメソッドは自由に呼べ、データ競合の防止はsync.Mutexなどプログラマの責任 | ならない。オブジェクト参照は自由に共有され、synchronized等で自衛する | ならない。参照/ポインタは自由に共有でき、防止策は完全にプログラマ責任 | ならない。シングルスレッドモデルのため該当なし |
| 誤検知(本来安全なのに弾かれる)への対応 | NLLのような解析精度の改善で徐々に緩和。安全性のルール自体は変えない | 該当なし(そもそも借用の概念がない) | 該当なし | 該当なし | 該当なし |
Go・Java・C++・TypeScriptにはそもそも「借用が同時に生きているかどうか」をコンパイラが追跡する仕組みがないため、今日のようなエラーは発生し得ません(代わりに、実行時のデータ競合や、C++であればダングリングポインタといった形で問題が顕在化します)。Rustはこの追跡を型システムの一部として行うため、current()のような一見無害なヘルパーメソッドがシグネチャ由来の制約を生む、という独特の学習コストがありますが、それと引き換えにコンパイルが通ったコードにはデータ競合が存在しないという強い保証を得られます。
🏆 実務での使いどころ
- ゲッター/セッターの多い構造体設計:
structのフィールドを直接いじらず、get_x()/set_x()のようなメソッド越しにアクセスする設計は、今日学んだ「メソッド越しだとフィールド分割借用が効かない」制約に頻繁にぶつかります。Rustではpubフィールドを直接使うか、フィールドをまとめて借用する設計にする方が自然な場面が多くあります - ビルダーパターンやステートマシンの実装: 内部状態を読みながら別の内部状態を書き換えるロジック(今日の
Counterのようなパターン)は、状態管理を持つあらゆるサービスクラスに現れます。値をローカル変数に退避してから書き込む、という今日のテクニックは非常に頻出です - リファクタリング時の落とし穴の把握:
self.valueを直接読んでいたコードを、後からself.current()のようなヘルパーメソッド呼び出しに置き換えると、それまでコンパイルが通っていたコードが突然E0502で壊れることがあります。「シグネチャだけで借用が判定される」ことを知っていれば、このエラーに遭遇しても慌てず対処できます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
NLLは借用規則そのものを緩めた(複数の&mutが許されるようになった)と誤解する | 「NLLでコンパイルが通りやすくなった」という話を聞き、規則自体が変わったと早合点しやすい | NLLが変えたのは「借用がいつ終わるかの判定精度」のみ。同時に生きている借用同士が衝突するルールは一貫して変わっていない |
self.history.push(self.value)(フィールド直接アクセス)とself.history.push(self.current())(メソッド経由)が同じように動くと思い込む | どちらも「valueを読んでhistoryに積む」という見た目は同じ処理に見えるため | コンパイラはフィールドへの直接アクセスならフィールド単位で借用を分割できるが、メソッド呼び出しはシグネチャ(&self全体)でしか判断しないため、同じロジックでも借用チェックの結果が変わる |
E0502とE0499を同じエラーだと思い込む | どちらも「借用の衝突」というエラーメッセージの雰囲気が似ているため | E0499は「可変借用×可変借用」の衝突(Day 007)、E0502は「不変借用×可変借用」の衝突(今日)。原因も対処法も異なる別のエラー |
🚀 次のステップ
- 発展:
increment_badのエラーを、let current = ...を使わずに解決する別の方法(例:Counterのフィールドを2つの独立した構造体に分割する設計)も考えてみてください。「フィールドをどう分割するとメソッド境界を越えた借用衝突を避けられるか」はPhase 2で学ぶ設計パターンにもつながります - 次回予告: Day 009ではスライス型(
&str・&[T])へ進みます。今日までに学んだ「借用」の考え方が、StringやVecの全体ではなく一部分だけを指す参照としてどう応用されるかを見ていきます