Day 011 — ライフタイム省略規則 — ルール3が生む E0623 の落とし穴

2026-08-08 🔵 中級者 / Phase 2 デバッグ・コードレビュー ライフタイム省略規則

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

Day 010では3つの省略ルールを整理し、longest(x: &str, y: &str) -> &strcombine(a, b, c) のように「入力ライフタイム位置が複数あり、&self もない」場合はどのルールも適用できず E0106: missing lifetime specifier になることを見ました。

今日はその逆——ルール3(&self/&mut self があれば戻り値は self 由来とみなす)が「機械的に適用されてしまう」ことで起きる、もう一種類の落とし穴を扱います。

⚠️
複数の入力ライフタイム位置があっても、そのうちの1つが &self/&mut self なら、コンパイラは関数の中身を一切読まずに「戻り値は self 由来」と決定します。もし実際の実装が self ではなく他の引数を返しているなら、その引数のライフタイムは戻り値の型(self 由来と決まったライフタイム)と噛み合わず、コンパイルエラーになります。

これは省略規則が「賢い推論」ではなく「シグネチャの形だけを見る決定的アルゴリズム」であることの裏返しです——中身がどうであれ、形が一致すればルール3が問答無用で適用されます。

📝 問題

以下の Greeter 構造体のメソッド pick_greeting は、self.name と引数 other のうち長い方の文字列スライスを返すことを意図しています。しかしこのコードはコンパイルエラーになります。

struct Greeter {
    name: String,
}

impl Greeter {
    fn pick_greeting(&self, other: &str) -> &str {
        if self.name.len() > other.len() {
            &self.name
        } else {
            other
        }
    }
}

fn main() {
    let g = Greeter { name: String::from("Alice") };
    let short = String::from("Bo");
    println!("{}", g.pick_greeting(&short));
}
  1. 予測課題: このコードをコンパイルするとどのエラーコードが出るか予測してください。また、3つの省略ルールをこのシグネチャ fn pick_greeting(&self, other: &str) -> &str に順番に当てはめ、「なぜルール3が適用されるのか」「ルール3が適用された結果、コンパイラは内部でこのシグネチャをどう解釈しているか」を <'a> 付きの形で書き下してください
  2. 原因説明課題: ルール3が決めた戻り値のライフタイムと、関数の中身が実際に返そうとしている other のライフタイムが、なぜ噛み合わないのかを説明してください
  3. 修正課題: このコードをコンパイルが通るように修正してください。修正方法は1つではありません。少なくとも2種類の異なる修正方針(シグネチャの変更方針が異なるもの)を考え、それぞれのAPI設計上のトレードオフ(呼び出し側にどんな制約が生まれるか)を比較してください
  4. 実装課題: 上記のうち、あなたが最も良いと判断した修正方針でコードを完成させ、main 関数と #[cfg(test)] のテストで self.name が返るケース・other が返るケースの両方を検証してください

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • エラーコードは Day 010 の E0106 とは別の種類です。「ライフタイム注釈が足りない」のではなく、「ライフタイム注釈(省略により暗黙に決まったもの)と、実際に返している値の型が矛盾している」というエラーです
  • まず fn pick_greeting(&self, other: &str) -> &str に省略ルールを機械的に適用し、<'a> を使った完全な形に書き下すところから始めてください
ヒント2 — アプローチ
  • ルール1: &selfother: &str はそれぞれ独立したライフタイム('a'b)を持ちます
  • ルール3: 入力ライフタイム位置は2つですが、その1つが &self なので、戻り値のライフタイムは self のライフタイム('a)に固定されます。つまりコンパイラは内部でこう解釈します: fn pick_greeting<'a, 'b>(&'a self, other: &'b str) -> &'a str
  • ここで問題です。関数の中身の else 分岐は other(型は &'b str)を返していますが、シグネチャ上の戻り値は &'a str です。'b'a は無関係な2つのライフタイムなので、コンパイラは「otherself と同じかそれ以上長く生きる」ことを証明できません
  • 修正方針としては、(A) other にも明示的に self と同じライフタイム 'a を要求する、(B) 戻り値を借用ではなく所有型(String)に変える、の大きく2系統が考えられます
ヒント3 — コード骨格
// 方針A: other を self と同じライフタイムに縛る
impl Greeter {
    fn pick_greeting<'a>(&'a self, other: &'a str) -> &'a str {
        if self.name.len() > other.len() { &self.name } else { other }
    }
}

// 方針B: 所有権を持つ String を返し、ライフタイムの絡みを断つ
impl Greeter {
    fn pick_greeting_owned(&self, other: &str) -> String {
        if self.name.len() > other.len() { self.name.clone() } else { other.to_string() }
    }
}

模範解答

// 方針A: 呼び出し側に「other は self 以上に長生きする参照であること」を要求する。
// ゼロコピーで返せる代わりに、呼び出し側のライフタイム設計に制約を課す。
struct Greeter {
    name: String,
}

impl Greeter {
    fn pick_greeting<'a>(&'a self, other: &'a str) -> &'a str {
        if self.name.len() > other.len() {
            &self.name
        } else {
            other
        }
    }
}

fn main() {
    let g = Greeter { name: String::from("Alice") };
    let short = String::from("Bo");
    let long = String::from("Bartholomew");

    println!("{}", g.pick_greeting(&short)); // "Alice" (self.name の方が長い)
    println!("{}", g.pick_greeting(&long));  // "Bartholomew" (other の方が長い)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn returns_self_name_when_longer() {
        let g = Greeter { name: String::from("Alice") };
        let other = String::from("Bo");
        assert_eq!(g.pick_greeting(&other), "Alice");
    }

    #[test]
    fn returns_other_when_longer() {
        let g = Greeter { name: String::from("Al") };
        let other = String::from("Bartholomew");
        assert_eq!(g.pick_greeting(&other), "Bartholomew");
    }

    #[test]
    fn returns_other_when_equal_length() {
        // self.name.len() > other.len() が false になる境界ケース(等しい場合は other 側)
        let g = Greeter { name: String::from("Al") };
        let other = String::from("Bo");
        assert_eq!(g.pick_greeting(&other), "Bo");
    }
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
Alice
Bartholomew

$ cargo test
running 3 tests
test tests::returns_self_name_when_longer ... ok
test tests::returns_other_when_longer ... ok
test tests::returns_other_when_equal_length ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🪜 Step-by-Step 解説

1
省略ルールをシグネチャに機械的に適用する
fn pick_greeting(&self, other: &str) -> &str を1つずつ検証します。
  • ルール1: 省略された各引数のライフタイムに、独立した名前を割り当てる。&self'aother: &str'b
  • ルール2: 入力ライフタイム位置が「ちょうど1つ」なら、それを戻り値に流用する。今回は 'a'b の2つあるため不成立
  • ルール3: 入力ライフタイム位置の1つが &self/&mut self なら、戻り値は self のライフタイムに固定する。今回は &self があるため成立。結果、戻り値は 'a に確定する
最終的にコンパイラが解釈する完全な形は次の通りです。
fn pick_greeting<'a, 'b>(&'a self, other: &'b str) -> &'a str
2
関数の中身とシグネチャの矛盾を確認する
シグネチャは「戻り値は 'aself のライフタイム)」と約束していますが、else 分岐の中身は other(型は &'b str)を返しています。コンパイラは 'b'a と同じか、それ以上長く生きるという保証を一切持っていません('a'b は無関係な独立変数として導入されただけだからです)。そのため型チェッカーは「&'b str 型の値を &'a str 型の戻り値として返そうとしている」という不整合を検出し、コンパイルを拒否します。出るエラーは error[E0623]: lifetime mismatch です(詳細は後述)。
3
修正方針を比較する
方針A(ライフタイムを統一する): other にも self と同じ 'a を要求する。
fn pick_greeting<'a>(&'a self, other: &'a str) -> &'a str
これは「otherself(=Greeter インスタンス)が生きている間、少なくとも同じだけ生き続ける参照でなければならない」という制約を呼び出し側に課します。ゼロコピーで値を返せる利点がありますが、呼び出し側で「self より寿命の短い一時的な &str」を渡すコードは書けなくなります。

方針B(所有権を持つ型を返す): String を返すことで、参照同士のライフタイム関係を断ち切る。
fn pick_greeting_owned(&self, other: &str) -> String {
    if self.name.len() > other.len() { self.name.clone() } else { other.to_string() }
}
呼び出し側のライフタイム制約は一切なくなりますが、呼ばれるたびに clone() または to_string() によるヒープ確保とコピーが発生します。パフォーマンスが重要な経路で頻繁に呼ばれるなら方針Aが望ましく、呼び出し側の柔軟性を優先するなら方針Bが望ましい、というトレードオフです。

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

📌
経験則の固定化と、その代償: 省略ルール3は「メソッドが返す参照は大抵の場合 self 由来だろう」という統計的な経験則をコンパイラの決定的アルゴリズムとして固定化したものです。しかし今回のように実際には self 由来ではない値を返すメソッドを書くと、このルールは「間違った仮定」を機械的に適用し、コンパイルエラーという形で矛盾を突きつけます。
⚠️
シグネチャだけで安全性を保証する原則: もしコンパイラが「よしなに」other の実際の使われ方を見て戻り値のライフタイムを推測してしまうと、シグネチャだけを見て安全性を判断できなくなります(関数の実装が変わるたびに、呼び出し側の借用チェック結果が変わりかねない)。Rustは常に「関数のシグネチャだけで、呼び出し側の安全性を静的に保証する」ことを最優先するため、ルールで解決できない場合は必ず人間に明示させるという設計を貫いています。今回のエラーは、まさにその境界にプログラマが気づかずに踏み込んだ結果です。

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

元のコード(fn pick_greeting(&self, other: &str) -> &str)をコンパイルすると、次のようなエラーが出ます。

error[E0623]: lifetime mismatch --> src/main.rs:6:13 | 6 | fn pick_greeting(&self, other: &str) -> &str { | ----- ----- | | | this parameter and the return type are declared with different lifetimes... ... 9 | other | ^^^^^ ...but data from `other` is returned here

読み方:

  • error[E0623]: Day 010で見た E0106(ライフタイム注釈そのものが足りない)とは異なり、「省略ルールにより暗黙に決まったライフタイム同士が矛盾している」ことを示すエラーです
  • this parameter and the return type are declared with different lifetimes: other(省略ルール1で割り当てられた無名のライフタイム)と、戻り値の型(省略ルール3で self 由来に固定されたライフタイム)が異なる、というメッセージです
  • but data from `other` is returned here: 実際に返している値(other)の出どころを指し示しています。エラーメッセージが「シグネチャ上の約束」と「実装の中身」のズレを具体的に教えてくれています

修正方法: other にも明示的に self と同じライフタイムパラメータを与え、コンパイラの「暗黙の仮定」と実装の中身を一致させます。

fn pick_greeting<'a>(&'a self, other: &'a str) -> &'a str {
    if self.name.len() > other.len() { &self.name } else { other }
}

🌐 他言語との比較

観点RustGoJavaC++TypeScript
「関数のシグネチャの形」から戻り値の出どころを機械的に決める仕組み省略ルール3により、&self があれば戻り値は自動的に self 由来と解釈される。中身と矛盾すればコンパイルエラー該当なし(GCが参照の生存期間を管理するため、そもそも出どころを型システムが追跡しない)該当なし該当なし(参照/ポインタを返す関数の生存期間はプログラマの責任で、コンパイラは検査しない)該当なし
「意図した引数と違うものを返す」バグの検出タイミングコンパイル時(E0623)に静的検出実行時に問題が起きるまで気づかないことが多い(GCがあるため即座のクラッシュにはなりにくいが、意図しない参照共有によるバグは残る)同上実行時のダングリング参照・未定義動作になりうる(一時オブジェクトへの参照を返すなど)。コンパイラは警告止まりで防げないケースが多い同上(そもそも参照の生存期間という概念がない)

C++で最も近い危険なパターンは「一時オブジェクトへの参照を返す」バグです。C++コンパイラは一部のケースで警告(-Wreturn-local-addr等)を出せますが、検出は完全ではなく、見逃すと未定義動作(ダングリング参照)に直結します。Rustでは省略ルール3によって「self 由来という前提」がシグネチャレベルで固定されるため、その前提と矛盾する実装は必ずコンパイルエラーとして止まります。C++が実行時(あるいは検出漏れ)に頼るのに対し、Rustは型システムのレベルでこの種のバグのクラス全体を排除しています。

🏆 実務での使いどころ

  • APIの意図をシグネチャで伝える: pick_greeting<'a>(&'a self, other: &'a str) -> &'a str というシグネチャを見ただけで、呼び出し側は「戻り値は selfother のどちらか、または両方に由来しうる(self と同じかそれより短い寿命)」と読み取れます。ドキュメントを読まずともシグネチャから契約が伝わるのがRustの強みです
  • リファクタリング時の安全網: メソッドの実装を変更して「self 由来だったはずの戻り値を、別の引数由来に変える」ようなリファクタリングをした際、省略ルール3が固定した戻り値のライフタイムと矛盾すればその場でコンパイルエラーになります。C++やGoであれば実行時までバグに気づけない変更が、Rustでは修正漏れとしてビルド段階で捕捉されます
  • 借用中心のパーサ・トークナイザ設計: 文字列スライスを複数の引数から受け取り、条件によってどちらかを返すような関数(トークナイザの先読み処理など)は実務でも頻出パターンで、今回のような E0623 に遭遇しやすい代表例です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
E0623E0106(Day 010)を同じ「ライフタイム注釈が足りないエラー」だと混同するどちらも「ライフタイム」という言葉を含むエラーメッセージのためE0106 は「省略ルールのどれも適用できず、ライフタイムを解決する情報が足りない」エラー。E0623 は「省略ルール(今回はルール3)が暗黙に決めたライフタイムと、実装の中身が矛盾している」エラーで、原因が異なる
&self があるメソッドなら、どんな引数を返しても自動で通るはず」と思い込むルール3が「self優先」という名前から、self以外の値も柔軟に扱ってくれると誤解しやすいためルール3は「戻り値の型を self のライフタイムに固定する」だけで、実装が実際に何を返すかは一切考慮しない。self 以外を返す実装がある場合は、そのライフタイムも明示的に self と同じにする(今回の方針A)か、借用をやめる(方針B)必要がある
コンパイルエラーが出たら、とりあえず 'static を付けて逃げようとする「ライフタイムエラー = 'static にすれば消える」という誤った経験則が広まりがちなため'static を付けると「プログラム全体の実行中ずっと有効な参照」という強い制約になり、通常の借用(&short のようなローカル変数への参照)では逆に別のエラーが出るか、無理に 'static な値しか渡せなくなり実用性を失う。今回のようなケースでは、ライフタイムを統一する(方針A)か所有型に変える(方針B)が正攻法

🚀 次のステップ

  • 発展: 今回の Greeter に3つ目の引数(例: other2: &str)を追加し、self.name / other / other2 のいずれかを返す設計にした場合、省略ルールはどう振る舞うか(ルール3はまだ成立するか)を自分で検証してみてください
  • 次回予告: Day 012ではトレイト境界とwhere句を扱います。ジェネリクスに制約を課す書き方を学び、「どんな型でも受け付ける」設計から「特定の能力を持つ型だけを受け付ける」設計への発展を扱います

🎯 自己評価

自分の回答

気づき・メモ