[Rustのはじめ方] Part11: ライフタイムとその明示

Rustの安全性を支える重要な概念 🛡️

所有権、借用に続いて、Rustのメモリ安全性を保証するためのもう一つの重要な仕組み、「ライフタイム」について学びましょう。ライフタイムは、参照が常に有効なデータを指していることをコンパイラが検証するための仕組みです。

ライフタイムとは?なぜ必要?

Rustでは、参照はそれが指すデータよりも長く生存してはいけません。もし参照が、すでに解放されたメモリ領域を指してしまうと、「ダングリングポインタ」となり、未定義動作を引き起こす可能性があります。😱

他の言語ではこれは実行時エラーになることが多いですが、Rustはコンパイル時にこれを検出し、未然に防ぎます。そのために使われるのがライフタイムです。

ライフタイムは、参照が有効である「期間」や「スコープ」を抽象化したものです。コンパイラは、全ての参照にライフタイムを割り当て、参照とその参照先のデータのライフタイムを比較することで、ダングリングポインタが発生しないことを保証します。

ポイント: ライフタイムは、参照が有効な範囲をコンパイラに伝えるためのものです。これにより、実行時ではなくコンパイル時にメモリ安全性をチェックできます。

ライフタイムの基本と省略規則

多くの場合、ライフタイムはコンパイラによって推論されるため、明示的に記述する必要はありません。これをライフタイム省略規則 (Lifetime Elision Rules) と呼びます。

例えば、関数の引数として参照を受け取り、その参照を返すような単純なケースでは、コンパイラが自動的にライフタイムを判断してくれます。

以下はライフタイム省略が適用される主な規則です:

  1. 各入力参照パラメータは、それぞれ固有のライフタイムパラメータを得ます。
  2. 入力ライフタイムパラメータが1つだけの場合、そのライフタイムが出力参照パラメータすべてに割り当てられます。
  3. 入力ライフタイムパラメータが複数あり、そのうちの1つが &self または &mut self の場合(つまりメソッドの場合)、self のライフタイムが出力参照パラメータすべてに割り当てられます。

これらの規則により、多くの一般的なケースでライフタイムを手動で記述する手間が省けます。✨

// この関数はライフタイム省略規則により、明示的な指定が不要
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

ライフタイムの明示が必要な場合

ライフタイム省略規則でカバーできない、より複雑なケースでは、開発者が明示的にライフタイムを指定する必要があります。コンパイラが参照間の関係を自動的に判断できない場合です。

ライフタイムは、アポストロフィ (') に続けて短い名前(通常は小文字、例えば 'a, 'b')で表されます。これをライフタイムパラメータと呼びます。

例えば、2つの文字列スライスを受け取り、常に最初の引数のスライスの一部を返す関数を考えてみましょう。しかし、もし常に一番「長い」文字列スライスを返したい場合はどうでしょうか?

// コンパイルエラー例: 戻り値の参照がどちらの入力参照に由来するかわからない
// fn longest(x: &str, y: &str) -> &str {
//     if x.len() > y.len() {
//         x
//     } else {
//         y // この場合、yのライフタイムが戻り値に関連づけられるべきだが...
//         // コンパイラはどちらのライフタイムを使えば安全か判断できない
//     }
// }

上記のコードはコンパイルエラーになります。戻り値の参照 (&str) が、入力 xy のどちらのライフタイムに関連付いているのか、コンパイラには判断できないためです。x を返す場合も y を返す場合もあるため、戻り値の参照が常に有効であることを保証するには、xy の両方が有効な期間だけ、戻り値も有効である必要があります。

これを解決するには、ライフタイムパラメータを使って、入力参照と出力参照の関係を明示します。

// ライフタイムパラメータ 'a を使って関係を明示
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        // longest関数は string1 と string2 のうち短い方のライフタイム 'a に制約される
        // ここでは string2 のスコープが短いため、'a は string2 のライフタイムになる
        result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result); // ここまではOK: result ('a) と string2 ('a) はまだ有効
    } // ここで string2 はスコープを抜け、ライフタイム 'a が終了する

    // println!("The longest string is {}", result); // エラー: `string2` does not live long enough
                                                   // result のライフタイム 'a はすでに終了しているため、ここでは使えない
}

<'a> はライフタイムパラメータ 'a を宣言します。そして、x: &'a str, y: &'a str, -> &'a str により、「入力参照 xy、そして出力参照は、少なくとも同じライフタイム 'a の期間は有効である」ことをコンパイラに伝えます。

重要なのは、longest 関数が返す参照のライフタイムは、xy のうち短い方のライフタイムと同じになるということです。これにより、返された参照がダングリングポインタになることを防ぎます。上の例では、result のライフタイムは string2 のライフタイムと同じ(短い方)になるため、string2 がスコープを抜けた後では result を使うことができません。

ライフタイム境界

複数のライフタイムパラメータがあり、一方が他方よりも長く生存することを保証したい場合、ライフタイム境界を使用します。例えば 'a: 'b と書くと、「ライフタイム 'a は少なくともライフタイム 'b と同じ期間だけ有効である」ことを意味します。これはジェネリクスと組み合わせて使われることが多いです。

// 例: Tが参照を含む場合、その参照は'b以上生きる必要がある
 use std::fmt::Debug;

 fn print_refs<'a, 'b, T>(x: &'a T, y: &'b T)
 where
     T: Debug + 'b, // T内の参照(もしあれば)は少なくとも'bの間有効でなければならない
     'a: 'b, // かつ、ライフタイム'a はライフタイム'b よりも長いか等しい必要がある
 {
     println!("x is {:?}, y is {:?}", x, y);
 }

この例では、型 T が持つ可能性のある参照が少なくとも 'b の期間は有効であること、そしてライフタイム 'a がライフタイム 'b よりも長い(または等しい)ことを要求しています。

構造体とライフタイム

構造体が参照をフィールドとして持つ場合、その構造体の定義にもライフタイムパラメータを指定する必要があります。これにより、構造体のインスタンスが、そのフィールド内の参照よりも長く生存しないことを保証します。

struct ImportantExcerpt<'a> {
    part: &'a str, // この参照はライフタイム 'a を持つ
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");

    // i のインスタンスは 'a のライフタイムを持つ
    // first_sentence (参照元 novel の一部) がスコープ外に出る前に i が使われる必要がある
    let i = ImportantExcerpt {
        part: first_sentence,
    };

    println!("Excerpt part: {}", i.part);
} // novel がスコープを抜けるので、first_sentence も i もここでは無効になる

この例では、ImportantExcerpt 構造体は文字列スライス part を保持します。ライフタイムパラメータ 'a は、ImportantExcerpt のインスタンスが参照 part よりも長く生存できないことを示します。つまり、ImportantExcerpt インスタンスが有効な期間は、part が指すデータが有効な期間によって制約されます。

構造体にメソッドを実装する場合も、ライフタイム省略規則が適用されますが、必要に応じて明示的な指定も可能です。

// ImportantExcerpt の実装ブロックにもライフタイムパラメータが必要
impl<'a> ImportantExcerpt<'a> {
    // 第1省略規則が適用され、ライフタイム指定不要 (&self に固有のライフタイムが割り当てられる)
    fn level(&self) -> i32 {
        3
    }

    // 第3省略規則が適用され、ライフタイム指定不要
    // &self と announcement で入力ライフタイムが2つあるが、
    // 1つが &self なので、そのライフタイムが出力に適用される
    fn announce_and_return_part(&self, announcement: &str) -> &'a str {
    // fn announce_and_return_part(&self, announcement: &str) -> &str { // 省略可能
        println!("Attention please: {}", announcement);
        self.part // self.part のライフタイム ('a) が返る
    }
}

特別なライフタイム: ‘static

特別なライフタイムとして 'static があります。これは、参照がプログラムの実行期間全体にわたって有効であることを示します。言い換えると、その参照が指すデータは決して解放されない(またはプログラム終了まで存在する)ということです。

主に2つのケースで使われます:

  • 文字列リテラル: 文字列リテラル ("hello" など) はプログラムのバイナリに直接埋め込まれるため、プログラム実行中は常に有効です。そのため、型は &'static str となります。
  • ‘static 境界: ジェネリクスやトレイトオブジェクトで、型パラメータが 'static な参照のみを含む(または参照を含まない)ことを要求する場合に使われます (T: 'static)。これは、例えば別スレッドにデータを安全に渡したり、グローバルな状態として保持したりする際に重要になります。データ自身がプログラム終了まで生存するか、あるいは所有されるデータ(参照ではない)であることを意味します。
let s: &'static str = "I have a static lifetime."; // 文字列リテラル

// 'static 境界の例 (スレッド)
use std::thread;

fn main() {
    let data = vec![1, 2, 3];
    // 下記はコンパイルエラー: クロージャは 'static である必要があるが、
    // data は main 関数のスコープに束縛されており 'static ではない参照をキャプチャしてしまうため
    // let handle = thread::spawn(|| {
    //     println!("Data: {:?}", data);
    // });

    // move キーワードで所有権をクロージャに移す -> data 自体がスレッドに渡される
    let handle_move = thread::spawn(move || {
        // data は 'static である必要はなく、所有権が移っているのでOK
        println!("Moved Data: {:?}", data);
    });
    handle_move.join().unwrap();

    // あるいは、データが 'static であることを保証する
    let static_data: &'static [i32] = &[10, 20, 30]; // これは &'static なスライス (データはバイナリに埋め込まれる)
    let handle_static = thread::spawn(|| {
         // static_data は 'static なので、参照をキャプチャしてもOK
         println!("Static Data: {:?}", static_data);
    });
    handle_static.join().unwrap();
}
注意: すべての参照を 'static にしようとするのは、多くの場合間違いです。これは非常に強い制約であり、通常はより短い、適切なライフタイムが存在します。必要な箇所でのみ使用し、基本的にはコンパイラにライフタイムを推論させるか、具体的なライフタイムパラメータ ('a など) を指定しましょう。

まとめ

ライフタイムは、Rustのメモリ安全性を支える根幹的な機能です。参照が常に有効なデータを指すことをコンパイル時に保証することで、ダングリングポインタのような危険なバグを防ぎます。💪

最初は少し抽象的で難しく感じるかもしれませんが、ライフタイム省略規則のおかげで、多くの一般的なコードではライフタイムを明示的に記述する必要はありません。コンパイラがライフタイムを判断できない場合にのみ、明示的な指定が求められます。

コンパイラのエラーメッセージは非常に親切で、ライフタイム指定が必要な箇所や、どの参照間の関係を明確にすべきかを教えてくれます。エラーメッセージを注意深く読み、コンパイラと対話するように修正していくことで、ライフタイムへの理解が深まります。焦らず、一つずつ解決していきましょう!😊

これで「所有権と借用」、そして「ライフタイム」というRustの核心的な概念を学びました。次は、これらの知識を活かして、スライスや文字列のより詳細な扱い方について見ていきましょう!