Rustのメモリ安全性とライフタイムについて

2023/10/22に公開

参照エラーの種類

  1. 二重開放: 同じ参照が複数回開放される
  2. 未開放: 参照が開放されない
  3. ダングリング: 開放された参照が使われる

Rustはコンパイル時にこれらのエラーを検出してsegmentation faultを起こさないようにしてくれます。
それぞれのエラーに対して以下の仕組みを使って検出しています。

  1. 二重開放: 所有権
  2. 未開放: Resource Acquisition Is Initialization (RAII)
  3. ダングリング: ライフタイム

それぞれ簡単に説明しつつ、3. ライフタイムについて詳しく説明します。

また、最後にライフタイムを扱うためのアノテーションについて説明します。

1. 二重開放

二重開放って?

同じ参照が複数回開放されることです。
例えば、以下のコードが実行されるとsが使用するメモリーが二重開放されます。

fn main() {
    if true {
        let s = String::from("hello");
        let v = s;
        println!("{}", s);
    }
}

なぜなら、svは同じメモリーを指しておりif文のブロックを抜けると同時に開放されるからです。
なので上記のコードはコンパイルできません。

所有権

Rustでは所有権という仕組みを使って上記をコンパイル時に検出してくれます。

所有権って?

所有権とは、変数の格納先が常に一つであるという制約です。

解決策

上記の例ではsが保持する所有権がvに移動しているので、sは所有権を失っています。
その上でsをプリントしようとしているのでコンパイルエラーになります。
それを理解した上でvをプリントするようにすればコンパイルが通ります。

fn main() {
    if true {
        let s = String::from("hello");
        let v = s;
-        println!("{}", s);
+        println!("{}", v);
    }
}

2. 未開放

未開放って?

参照が開放されずメモリを占有し続けることです。

これを防ぐためにRustではRAIIという仕組みを使っています。

RAIIって?

Resource Acquisition Is Initializationの略で、リソースの取得と破棄を紐付けるという制約です。具体的には、リソースの取得と破棄は同じスコープで行われるという制約です。

fn main() {
    if true {
        let s = String::from("hello");
    }
    println!("{}", s); // error, s is not in scope
}

細かい話ですがAcquisition Is Initializationというだけあって、リソースの取得は変数代入時ではなく宣言時に行われます。
例えば、以下のコードではsが文字列スライスになることをlet sの時点でコンパイラーは認識しておりその時点でポインター分のメモリが確保されます。

fn main() {
    let s;
    {
        let s = String::from("hello");
    }
    println!("{}", s);
}

3. ダングリング

ダングリングって?

開放された参照が使われることです。開放された参照には別の値が入っている可能性があるので、それを使うと予期しない挙動をする可能性があります。

fn make_dangling() -> &String {
    let s = String::from("hello");
    &s
}

fn main() {
    let v = make_dangling();
}

上記の例ではmake_danglingの返り値がmainで使われていますが、make_danglingのスコープを抜けるとsは開放されます。なのでvが指すメモリーは開放されたメモリーを指している可能性があります。なのでコンパイルエラーになります。これを検知するためにRustではライフタイムという仕組みを使っています。

ライフタイム

ライフタイムって?

参照が有効な期間です。参照はスコープを抜けると開放されるので、そのスコープを抜けるまでの期間がライフタイムです。可変参照は一つのスコープに一つしか存在できないので、可変参照のライフタイムはスコープと同じです。不変参照は複数存在できるので、不変参照のライフタイムは参照の中で一番短いスコープと同じです。

例えば、上記のコードではsがライフタイムを超えてvに参照されているのでコンパイルエラーになります。

解決策

make_danglingの返り値の参照ではなく、実体であるStringを返すようにすればsのライフタイムを気にする必要がなくなります。

上記のコードの解決としては以下の様になります。

- fn make_dangling() -> &String {
+ fn make_dangling() -> String {
    let s = String::from("hello");
    s
}

fn main() {
    let v = make_dangling();
}

この様な場合は簡単ですが、参照を返す必要がある場合はどうすればいいでしょうか?
それにはライフタイムアノテーションという仕組みを使います。

4.ライフタイムアノテーション

ライフタイムアノテーションって?

参照に対して、その参照が有効な期間を指定するものです。

どういうときに使うの?

コンパイラが参照のライフタイムを推論できないときに使います。

アノテーションが必要なケース

以下の関数は実行できそうですがコンパイルできません。

fn get_longer(s1: &str, s2: &str) -> &str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

これは返り値が参照で、そのライフタイムがs1とs2のライフタイムのどちらと一致するか実行時までわからないからです。
この場合、関数内でライフタイムが終わることは無いので問題なさそうですが、コンパイラは返り値のライフタイムをコンパイル時に(実行無しで)決定しなければなりません。
そうしないとコンパイルの段階で二重開放が起きる可能性のあるコードを拒否できません。

ライフタイムを指定する

ライフタイムを指定するにはジェネリクスと同じ様に指定します。

- fn get_longer(s1: &str, s2: &str) -> &str {
+ fn get_longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

これでコンパイルが通ります。

解説

'a

ライフタイムパラメータです。これを指定することで参照に対して最低でもライフタイムaの間有効であることをコンパイラに伝えます。

返り値のライフタイムは?

s1s2のライフタイムの短い方が採用されます。

なんで短い方?

実際のライフタイムより短い方を採用する分には安全だからです。
長い方を採用してコンパイルすると実行時に短い方が返り値になる可能性があり、想定してないダングリングポインターを産む可能性があります。

Discussion