カテゴリー: BackEnd

Rust入門してみた その5 ライフタイム

はじめに

Rustには、借用した値の参照の寿命を意味する「ライフタイム」という概念があり、この仕組みによって破棄された所有に対する参照をしてしまうことがないように、コンパイル時にチェックします。
多くの場合では、ライフタイムをさほど意識する必要はなさそうですが、複数のライフタイムを返す関数を作成する場合など、ライフタイムを意識せざるを得ない場合もあるため、ライフタイムの概念について抑えておきたいと思います。

ライフタイムと借用チェッカー

Rustのコンパイラには借用チェッカーがあり、スコープを抜けて破棄された参照にアクセスしようとしていないか、コンパイル時にチェックします。
例えば、以下のコードはコンパイルエラーになります。

fn main() {
    {
        let r;                // ---------+-- 'a
                              //          |
        {                     //          |
            let x = 5;        // -+-- 'b  |
            r = &x;           //  |       |
        }                     // -+       |
                              //          |
        println!("r: {}", r); //          |
    }                         // ---------+
}

Rustでは、所有はスコープ内でのみ有効であり、スコープを抜けると破棄されます。このコードでは、内側のスコープで宣言されたxへの参照を、外側のスコープのrに入れようとします。しかし、xは内側のスコープを抜けると破棄されてしまうため、rはその後使用できなくなってしまいます。
このような状況を「ダングリング参照」と呼びます。

この、借用できる有効範囲こそがライフタイムのことです。上記のコードでは、’a と ‘b のライフタイムがあるとして、改めてソースコードを見てみます。
そうすると、 ‘a より ‘b のほうがライフタイムが短いのに、 ‘a は ‘b への参照を持ってしまっています。

このコードを修正すると、次のようになります。

fn main() {
    {
        let x = 5;            // ----------+-- 'b
                              //           |
        let r = &x;           // --+-- 'a  |
                              //   |       |
        println!("r: {}", r); //   |       |
                              // --+       |
    }                         // ----------+
}

このように、Rustで借用をする場合には、常にライフタイムを意識して、破棄された所有への参照が残らないようにする必要があります。

コンパイラにライフタイムを教える

借用チェッカーはある程度のライフタイムはコードから推論してくれますが、中には分からないものもあります。そうした場合には、ライフタイムをコード上で教える必要があります。
例えば、次のコードを見てください。

fn main() {
    let text = String::from("hello world.");
    let first_sentence;

    {
        let default_val = String::from("default");
        first_sentence = slice_by_first_sentence(text.as_str(), default_val.as_str());
    }
    print!("{}", first_sentence)
}

fn slice_by_first_sentence(text: &str, default_val: &str) -> &str {
    for (i, c) in text.chars().enumerate() {
        if c == '.' {
            return &text[..i];
        }
    }
    default_val
}

sliece_by_first_sentence()は第1引数textのなかから最初の1文を探して、見つかればそこまでの文字列スライスを返します。(文字列スライスは、Stringの一部への参照です。)
見つからなければ、第2引数defaultをそのまま返します。
(ちなみに引数のライフタイムを「入力ライフタイム」、戻り値のライフタイムは「出力ライフタイム」と称されます。)

ここで、main関数を見てみましょう。text, first_sentenceは外側のスコープにいますが、default_valは内側のスコープにします。そして、default_valは内側のスコープを抜けたタイミングで破棄されます。
しかし、slice_by_first_sentenceの戻り値として、第2引数に渡したdefault_valの参照がそのまま返ってくる可能性があり、その場合はやはりダングリング参照が起きてしまいます。

ですが、このようなケースは借用チェッカーでは検知できず、別のコンパイルエラーとなります。
なぜなら、 slice_by_first_sentnce の実際の戻り値が、textなのかdefault_valなのか、コンパイラには分からないからです。
こうした場合には、次のようにライフタイム注釈をします。

fn slice_by_first_sentence<'a>(text: &'a str, default_val: &'a str) -> &'a str {
    for (i, c) in text.chars().enumerate() {
        if c == '.' {
            return &text[..i];
        }
    }
    default_val
}

ライフタイム注釈の書き方はちょっと特殊で 'aのように書きます。
上記のコードは、textとdefaultの両方ともがとある 'aというライフタイムであり、戻り値もまた 'aライフタイムであることをコンパイラに教えています。

ちなみに <'a>というようにジェネリクスに準じたシンタックスであることから想像が付くかもしれませんが、このライフタイム注釈はジェネリックなものです。
そのためtextやdefault_valの実際のライフタイムはこの関数の関心ごとではなく、渡されてきたそれぞれの参照のライフタイムのうち、どのライフタイムを返すか、ということが注釈できていれば良く、textのライフタイムにもなり得るし、default_valのライフタイムにもなり得るということです。

ただし、main()関数からの視点で見てみると、実際には 'aはtextもしくはdefault_valのうち、ライフタイムが短い方に等しいライフタイムになります。
そのため、ライフタイム注釈を書いた状態でビルドしてみると、借用チェッカーにより想定したエラーが出ます。

error[E0597]: `default_val` does not live long enough
 --> src/main.rs:7:65
  |
7 |         first_sentence = slice_by_first_sentence(text.as_str(), default_val.as_str());
  |                                                                 ^^^^^^^^^^^^^^^^^^^^ borrowed value does not live long enough
8 |     }
  |     - `default_val` dropped here while still borrowed
9 |     print!("{}", first_sentence)
  |                  -------------- borrow later used here

最後に、main関数を修正して、ビルドが通る状態にします。

fn main() {
    let text = String::from("hello world.");
    let first_sentence;

    let default_val = String::from("default");
    first_sentence = slice_by_first_sentence(text.as_str(), default_val.as_str());

    print!("{}", first_sentence)
}

ライフタイムの指定が不要なケース

先程の関数を少し変更してみました。

fn slice_by_first_sentence<'a>(text: &'a str, separator: &str) -> &'a str {
    for (i, c) in text.chars().enumerate() {
        if c == separator.chars().next().unwrap() {
            return &text[..i];
        }
    }
    text[..]
}

引数default_valの代わりに、separatorとし、文章の区切り文字を引数で渡せるようにしました。また、区切り文字が見つからなかった場合は、渡されたtext全体を1文とみなし、そのまま返すようにしました。(つまり、必ずtextの参照が返ります)
このような場合、sepratorにライフタイム注釈は不要です。なぜなら、separatorがreturnされることがなく、渡ってきた引数textのライフタイムを気にする必要がないためです。

ライフタイムの省略

ところで、これまでの入門編コードでは関数やメソッドで、引数で借用するコードを書いてきましたが、ライフタイム注釈は一切書いてきませんでした。
それには、ライフタイムを省略できる規則があり、その規則通りであればライフタイムはコンパイル時に推論されます。
詳細は、以下のドキュメントが参考になります。

https://doc.rust-jp.rs/book-ja/ch10-03-lifetime-syntax.html#%E3%83%A9%E3%82%A4%E3%83%95%E3%82%BF%E3%82%A4%E3%83%A0%E7%9C%81%E7%95%A5

さいごに

基本的には、ダングリング参照が起きないようなコーディングをした上で、ライフタイム注釈は必要な場面でのみ使えば良いということが分かりました。
Rust特有の概念だと思いますが、基本的にはコンパイラがチェックしてくれるので、コンパイルエラーになった場合には、適宜対応していけば良さそうです。

おすすめ書籍

カイザー

シェア
執筆者:
カイザー
タグ: Rust

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

4週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前