Rust入門

【Rust入門】ライフタイムについて分かりやすく解説

【Rust入門】ライフタイムについて分かりやすく解説
naoki-hn

Rust のライフタイムの概念について初心者にも分かりやすく解説します。

Rust のライフタイム

Rust にはメモリの安全性を高める所有権(Ownership)と借用(Borrowing)という重要な特徴があります。所有権と借用の基本については「所有権と借用の基本を分かりやすく解説」を参考にしてください。

Rust では、変数や値はスコープによって生存期間が管理されており、スコープを外れる際に解放されます。Rust では解放のことを「ドロップ(drop)」と表現します。

Rust における借用では、不変参照や可変参照という種類の参照を作ることができますが、この参照は元の変数や値が有効な期間の中で使用しないと問題が生じます。参照が残っている状態で元のオブジェクトが解放されると、不正なメモリアクセスにつながってしまうからです。

Rust では、参照が有効な期間を「ライフタイム(lifetime)」と呼び、ライフタイムと借用チェッカーという仕組みによって、このような問題をコンパイル時に防ぎ、メモリ安全性を保証しています。

この記事では、ライフタイムの基本的な概念やライフタイム注釈の使い方を解説していきます。

スコープ・値の生存期間・ライフタイム

ライフタイムを勉強していると色々と混乱してしまうときがあります。変数や値が使える範囲のことを言っているのか、参照が有効な範囲ことを言っているのかが分からなくなり、私は最初にすごく混乱してしまいました。生存期間という表現が入門の書籍でもよく出てきますが、変数や値、参照とどちらのこととも取れそうな言葉であるからなのかもしれないと感じます。

まずは「Rust におけるライフタイムは、参照の有効期間に関わる概念である」ということを押さえておいてください。以下のプログラム例も参照しながら整理しておきましょう。

fn main() {  // === 「スコープ」の開始

    let s = String::from("Hello");  // --- s の「値の生存期間」の開始

    let r = &s;  // ~~~ 参照 r の「ライフタイム」開始
    println!("r: {}", r);  // ~~~ r の最後の使用で「ライフタイム」終了

    // r はもう無効だが、s はまだ有効
    println!("s: {}", s);

}   // --- s の「値の生存期間」の終了 (drop により解放される)
    // === 「スコープ」の終了
【実行結果】
r: Hello
s: Hello

スコープ

スコープ{} で囲まれた範囲のことであり、変数の有効期間はこの範囲により決まります。Rust の変数は、このスコープの中で有効です。

変数に対応するメモリ上の値は、変数の初期化(let s = String::from("Hello");)から始まり、変数がスコープを抜けるタイミングで drop(解放)されます。この期間は、変数ごとに厳密にはスコープより短い期間となるので区別して「値の生存期間」と呼ぶことにします。

なお、ここでメモリと言っているのは、基本型などが配置されるスタックや VecString が配置されるヒープの両方を含みます。

ライフタイム

ライフタイムは、参照の有効期間のことです。ライフタイムは、値の生存期間の範囲内に含まれている必要があります。参照が残っている状況で参照先の値が drop(解放)されてしまうと不正なメモリアクセスが発生してしまう恐れがあるからです。

なお、ライフタイムは、参照が生成されたタイミング(let r = &s)から始まり、参照が最後に使用されたタイミングで終了します。以前はスコープの最後まで参照のライフタイム(Lexical Lifetimes)でしたが、Rust 2018 で導入された NLL(Non-Lexical Lifetimes)の仕様により参照が最後に使用されたタイミングでライフタイムが終わるようになっています。

ライフタイム注釈(lifetime annotation)

Rust では、全ての参照にライフタイムが関連付けられており、通常はコンパイラが推論してくれるため明示的な指定は必要はありません。

ただし、関数で参照を返す場合などのライフタイムが複雑な状況では、ライフタイムに関してコンパイラに情報を伝えてあげる必要があります。ライフタイムの期間を表すには「ライフタイム注釈(lifetime annotation)」を使用します。

ライフタイム注釈の基本的な使い方

具体的に代表的な以下の例を使ってライフタイム注釈について見てみましょう。まずはコンパイルがうまくいかないコードの例から見ていきます。

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

上記の longest 関数は、2つの文字列の参照を受け取り長さの長い文字列の参照を返却する関数です。このままだとコンパイラは以下のようなエラーを出します。

error[E0106]: missing lifetime specifier
  |
1 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

Rust の素晴らしいところはコンパイルエラーで対策内容を含めて詳細を把握しやすい点です。エラーの内容を見ると、返却値の参照 &strxy のどちらに依存するライフタイムであるか分からないといっています。

具体的な対応策としては、コンパイルエラーが出してくれているように以下のようにライフタイムパラメータを指定することでコンパイルができるようになります。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("Long String");
    let s2 = String::from("Short");

    let result = longest(&s1, &s2);
    println!("{}", result);
}
【実行結果】
Long String

ライフタイムは関数等の後ろに <'a> のように記載します。これは1個のライフタイムを表し、ライフタイムの名前は a であることを示しています。ライフタイム注釈は、abc … が一般的に使用されます。ジェネリックの型パラメータに似ていますが「'」がつく点で異なります。

そして、対象とする変数に「&'a str」のように付与することで、ライフタイムの情報をコンパイラに伝えることができます。上記例では「関数の戻り値は、xy の値の生存期間の共通部分で有効である」ということを示しています。上記例でもう少し単純な言い方をすると xy の値の生存期間の短い方ということができます。

関数は xy のいずれかの参照を返却しますが、xy が参照する先の値の生存期間がどちらが長いかは関数の使い方に依存するため一意には分かりません。そのため共通部分(つまりは短い方)の期間が返却値のライフタイムとなります。

ライフタイムについて少し深堀り

ライフタイムの状況についてもう少し詳しく把握するために以下の例で確認していきましょう。以下のプログラムはライフタイム注釈に従って 2 つのコンパイルエラーが出ます。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("Long String");
    let result;
    
    // 異なるスコープ
    {
        let s2;

        // 以下は s2 の値の生存期間の前の借用 (エラーとなる)
        let ref_s2 = &s2;

        s2 = String::from("Short");  // s2 の値の生存期間が開始

        result = longest(&s1, &s2);  // &s1 と &s2 は一時的な借用
                                     // でライフタイムは終了
                                     // result のライフタイム開始

        println!("{}", result);
    } // s2 の値の生存期間終了
    
    // 以下は result のライフタイム外なのでエラーとなる
    println!("{}", result);
} 

コンパイラが表示するエラーは以下の 2 つです。

error[E0381]: used binding `s2` is possibly-uninitialized
   |
11 |         let s2;
   |             -- binding declared here but left uninitialized
...
14 |         let ref_s2 = &s2;
   |                      ^^^ `s2` used here but it is possibly-uninitialized
error[E0597]: `s2` does not live long enough
   |
15 |         let s2 = String::from("Short");  //
   |             -- binding `s2` declared here
16 |
17 |         result = longest(&s1, &s2);
   |                               ^^^ borrowed value does not live long enough
...
20 |     } // s2 の値の生存期間は終了
   |     - `s2` dropped here while still borrowed
...
23 |     println!("{}", result);
   |                    ------ borrow later used here

最初のエラーは「used binding s2 is possibly-uninitialized」となっているので s2 に値が設定される前に ref_s2 に参照を渡そうとしているためのエラーです。これは、s2 の値の生存期間前に参照を得ようとしているため、s2 のライフタイムが参照先の値の生存期間を外れているためルールに違反します。

2つ目のエラーは「s2 does not live long enough」となっています。参照である result のライフタイムは s1s2 の値の生存期間の共通部分で有効であるため、s2 の値の生存期間が終了した後に result を使用することはできないためです。

状況を簡単な図で整理してみるとより明確になります。

ライフタイムの説明

s2 の値の生存期間は s2 が初期化されたタイミングになるので その前に使おうとしている ref_s2 の行をコンパイラはエラーと判断します。

また、図で薄い黄色で書かれた s1s2 の値の生存期間の共通部分が 'a の範囲になり、longest 関数の返却値である &a' str はこの範囲に収まっている必要があります。この範囲に収まっている println については問題ありませんが、最終行の println'a の範囲外となるためコンパイラはエラーと判断します。

なお、&s1&s2 は一時的に関数の引数へ引き渡すための参照でありこの範囲で使用を終わります。このような借用を一時的な借用と言います。

ライフタイムの概念は少し複雑に思う部分ではありますが、根本の考え方としては「既に解放されているメモリにアクセスしないようにする」ことです。より複雑な状況もあると思いますが、各変数や参照しているメモリ上の値がどのようになっているかを想像しながらコードを見ていくと理解できるかなと思います。

まとめ

Rust のライフタイムの概念について解説しました。ライフタイムは、参照が有効な期間のことを言い、Rust は、ライフタイムと借用チェッカーという仕組みによってコンパイル時にメモリ安全性を保証しています。

この記事では「スコープ」「値の生存期間」「ライフタイム」といった内容について例を交えて紹介してきました。また、ライフタイムが複雑になるケースについて、ライフタイム注釈を使ってコンパイラに情報を伝える方法についても紹介しました。

ライフタイムは、Rust の各種概念の中でも複雑な概念の1つであり、私も学んだ際に混乱しました。ライフタイムの主な目的は無効なメモリ領域にアクセスしないようにコンパイラが事前にチェックするための仕組みだと考えて向き合うと少しずつ理解ができてくると思います。

ライフタイムは、Rust のメモリ安全性を支える重要な仕組みの1つですので理解を深めていってもらえればと思います。

ABOUT ME
ホッシー
ホッシー
システムエンジニア
はじめまして。当サイトをご覧いただきありがとうございます。
私は製造業のメーカーで、DX推進や業務システムの設計・開発・導入を担当しているシステムエンジニアです。これまでに転職も経験しており、以前は大手電機メーカーでシステム開発に携わっていました。

これまでの業務を通じてさまざまなプログラミング言語や技術に触れてきましたが、その中でもRustの設計思想に惹かれ、この言語についてもっと深く学びたい、そしてその魅力を発信していきたいと思い、このサイトを立ち上げました。

自身の学びを整理しつつ、同じようにRustに興味を持つ方のお役に立てるような情報を発信していければと思っています。どうぞよろしくお願いいたします。

※キャラクターデザイン:ゼイルン様
記事URLをコピーしました