💤

rustのライフタイムをちゃんと理解する

2024/09/03に公開

ライフタイム難しいですよね。なので書きます。

ライフタイムとはなんなのか

ライフタイムとは参照が有効なスコープのことです。参照はデータが実際に書き込まれているメモリへのアドレスとなるはずなので、参照が有効であるとはデータが書き込まれているメモリが解放されるまでの期間となります。

rustの場合はメモリの解放のタイミングは、そのデータを生成した関数やブロックを抜けたときかと思います。

ライフタイムはなんのためにあるのか

ダングリング参照を発生させないためにあるらしいです。「だんぐりんぐ」という耳慣れない謎ワードが出てきましたね。言葉の意味はよくわかりませんが、とにかくすごい問題です。rustの公式ドキュメントの文脈から考えると「解放済みのメモリにアクセする行為」がダングリング参照なのかと思います。

だんぐりんぐ参照ってヌルポのこと?

ヌルポとはnullな値、あるいはnullが格納されている変数に対して何かしらの操作を行うことで発生するエラーです。ダングリング参照はこのnullな参照へのアクセスとは似て非なるものです。nullへのアクセスは参照先のアドレス自体がそもそも存在しないような状況ですが、ダングリング参照の場合は解放済みのメモリへのアドレスにアクセスできてるような状況です。C言語ではちょこちょこやらかしてしまう類の問題ではあるのですが、rubyやpythonのようなLL言語(げんごげんご、、)はもちろんgoのようないけてる言語では参照がどこからもたどれなくなった時に自動でメモリが解放されるので、通常は解放済みのメモリへのアクセスできないのでダングリング参照の問題に直面する機会はほぼないように思います。そのためC言語のような低水準の言語の経験が少ない人にとってはちょっとダングリング参照は理解しづらいものかもしれません。なのでc言語でダングリング参照してみましょう。

下記の例はスタック上に確保されたメモリ領域へのダングリング参照の例です。
get_pointer関数ではローカル変数iへのポインタを返していますが、iは関数の終了と同時に解放される領域に存在しています。しかし、iのポインタを返したことによって呼び出し元のmain関数では解放済みのメモリにアクセスできてしまいます。実際の出力結果としてはget_pointerで格納している値3にmain関数からアクセスできてしまっています。これがダングリング参照の一例です。
なんら問題ないようにも思いますが、後にwrite_pointerを呼び出されたことによって値が上書きされてしまっています。このようにダングリング参照をされてしまうと意図せず値が書き換わった状態のデータにアクセスできてしまったりもします。なんだが脆弱性への扉が開かれているような気持ちですね。

#include <stdio.h>

int* get_pointer() {
    int i = 3;
    return &i;
}

void write_pointer() {
    int i = 10;
}

int main() {
    int* i = get_pointer();
    printf("after get_pointer > %d\n", *i);
    write_pointer();
    printf("after write_pointer > %d\n", *i);
    return 0;
}
$ gcc main.c
$ ./a.out
after get_pointer > 3
after write_pointer > 10

もう一つのダングリング参照の例としてヒープに作成されたデータの場合をみてみましょう。 mallocによってヒープに整数型の値を確保できる領域を作成しており、その領域に値3を書き込んでいます。その後にメモリをfree関数によって解放しています。しかし、解放した領域のポインタを持ったままのためこの領域にもアクセスすることができます。

何が起こったのかはさっぱりですがfreeした後の値は全く異なる値が上書きされてしまっていることがわかります。

#include <stdio.h>
#include <stdlib.h>

int main() {
    int* i = malloc(sizeof(int));
    *i = 3;
    printf("before free > %d\n", *i);
    free(i);
    printf("after free > %d\n", *i);
    return 0;
}
$ gcc main.c
$ ./a.out
before free > 3
after free > -784826320

rustでは上記のような解放済みのメモリ領域にアクセスできないようにするために、コンパイルの段階でダングリング参照が起きる可能性のあるコードをエラーにしてくれます。その仕組みがライフタイムのようです。

ライフタイムはいつ必要になるのか

ライフタイム自体はスコープなので通常は他のプログラミング言語と同様にほとんど意識することはないです。しかし、構造体のメンバや関数の戻り値に参照を利用する時に突如明示的にライフタイムを設定することを要求されてしまいます。

ライフタイムの設定方法には下記の 'a'bように注釈を与える必要があります。

struct X <'a> {
    i: &'a i32,
}

fn func1<'a>() -> &'a i32 {
    todo!()
}

fn func2<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    todo!()
}

fn func3<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
    todo!()
}

fn func4<'a, 'b>(x: &'a i32) -> &'static i32 {
    todo!()
}

注釈として使う文字はなんでも良いですが、'staticについては特別な意味があります。'staticな参照とはプログラムが終了するまでメモリが解放されない値への参照となります。速い話が定数化された参照ですね。グローバルな場所に宣言することもできるし、リテラルから直接参照を得るようなコードを書くとコンパイラがいい感じに勝手にstaticな参照として扱ってくれるようです。

static x : &i32 = &1;

fn main() {
    let y = &2; // 整数値2へのstaticな参照になる
}

構造体のライフタイムチェック

先ほどの例では構造体Xの注釈は「構造体Xのメンバiが格納する&i32型の指す値は、少なくとも構造体Xと同等かそれ以上に長いライフタイムを持つ」という意味合いの設定になります。
この注釈によって構造体のメモリが解放されるより前にメンバの持つ参照先のメモリが解放されることを防ぐことができます。具体的には以下のようなコードを書くとエラーになります

// 構造体Xのメンバiが格納する&i32型の指す値は、少なくとも構造体Xと同等かそれ以上に長いライフタイムを持つ
struct X <'a> {
    i: &'a i32,
}

fn main() {
    let mut x = X{i: &1};
    {
        let i = 10; // iはこのブロック内で作られたデータ
        x.i = &i; // x.iの参照先のスコープはブロック内部なのでXより短い
    }
    println!("{}", x.i) // x.iのもつ参照のライフタイムは注釈と矛盾するためエラー
}

ところで、私は「構造体自身よりも短いライフタイムを与えられないというルールになっているなら注釈の設定を強制する意味はないのでは?」と思いました。しかし、構造体のメンバに参照を与える場合はstaticな参照を格納したいケースもあります。それを考えると明示的にstaticなのかそうでないのかの注釈は必要になると考えられます。

関数の戻り値のライフタイムチェック

先ほどの例の各関数の注釈はそれぞれ以下のような意味合いになります。

  • func1の&i32型の戻り値は少なくともfunc1のスコープよりも長いライフタイムを持つ
  • func2の&i32型の戻り値は少なくともxが格納する参照の指すデータと同等かそれ以上に長いライフタイムを持つ
  • func3の&i32型の戻り値は少なくともxとyが格納する参照の指すデータと同等かそれ以上に長いライフタイムを持つ
  • func4の&i32型の戻り値はstaticな(プログラムが終了するまで解放されない)参照となる

関数では戻り値に指定されている値がこれらのライフタイムの設定に違反しないようにチェックされます。注意点としては指定された注釈と同等のライフタイムではなく、それよりも長ければ問題ないという点です。つまり引数に指定された参照と同じ注釈を戻り値に付けた場合でもstaticな参照を返すことが可能と言うことになります。

より理解しやすいように先ほどの関数に実装を記述して、ライフタイムチェックが通る場合と通らない場合の例を作成しました。

// OK: staticなライフタイムを持つ参照を返しているのでチェックは通る
fn func1<'a>() -> &'a i32 {
    &3
}

// Error: iはfunc1と同様のスコープのためエラー
fn func1<'a>() -> &'a i32 {
    let i = 3;
    &i
}
// OK: xのライフタイムかそれ以上になれば良いのでチェックは通る
fn func2<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    x
}

// Error: xとyは異なるライフタイムを持っていて、かつ、戻り値はxのライフタイムを基準にするためエラーになる
fn func2<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    y
}
// Ok: xとyはx <= yまたは y <= xなライフタイムを持つ可能性があるが関数的には同等のライフタイムを持つと解釈できるためどちらでも返せる
fn func3<'a>(x: &'a i32, y: &'a i32) -> &'a i32{
    if *x == 3 {
        x
    } else {
        y
    }
}
// Error: &3とするとプログラム中は解放されないstaticな参照となるためチェックは通る
fn func4<'a, 'b>(x: &'a i32) -> &'static i32 {
    &3
}

// Error: 関数的にはxはstaticな参照よりも短いライフタイムになる可能性があるのでエラーになる
fn func4<'a, 'b>(x: &'a i32) -> &'static i32 {
    x
}

上記の例のエラーになるコードについて勘違いが起きやすい(と私が勝手に思っている)点があります。それは、関数に実際に入力される値のライフタイムが何であるかは考慮されない点です。rustのコンパイラは非常に高機能で実装の詳細もそれなりに考慮してチェックしてくれますが、関数単体のライフタイムチェックに呼び出し元の実装の詳細まではチェックされません。より具体的に言うとfunc3において以下のようなコードを実装した場合、xyは当然異なるライフタイムを持つことになります。しかし、func3はそのことを一切考慮せず、自身の定義の上ではxyは同じライフタイムを持つと解釈するのでライフタイムチェックは通ります。

fn main() {
    let x = 1;
    {
        let y = 2;
        println!("{}", func3(&x, &y)); // OK: main関数視点ではxとyはライフタイムが異なるがfunc3に影響はない
    }
}

// func3視点ではxもyも同じライフタイムを持つ
// 呼び出し元で実際に入力される値は考慮しない
fn func3<'a>(x: &'a i32, y: &'a i32) -> &'a i32{
    if *x == 3 {
        x
    } else {
        y
    }
}

もう1点func2の例を例にコードを書きました。今度は呼び出し元では全く同じライフタイムの値を渡しています。しかし、func2ではxyは異なるライフタイムと定義されているためyを返そうとすれば当然エラーになります。

fn main() {
    let a = 1;
    println!("{}", func3(&a, &a));
}

fn func2<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    y // Error: 実引数で同じライフタイムの参照を渡したとしてもfunc2的には別のライフタイムなのでエラー
}

さらにfunc4の例を書きました。今度は実引数にstaticなライフタイムの参照を与えています。func4自体は戻り値の参照はstaticなライフタイムを持つ値を返すことを期待しており、func4とその呼び出し元の実装を考慮すればstaticな参照が返ることになります。しかし、呼び出し元の実装の詳細は考慮されないためこれもまたエラーになります。

fn main() {
    println!("{}", func4(&10));
}

fn func4<'a, 'b>(x: &'a i32) -> &'static i32 {
    a // Error: 実装上は&10が返ることになるけどfunc4的にはそんなの関係ねぇ
}

呼び出した関数の戻り値のライフタイムチェック

先ほどは関数に設定されたライフタイムを元に関数自身の実装に対するライフタイムチェックがどのように行われるのかを説明しました。今度は逆に関数を呼ぶ側に対してはどのようなライフタイムチェックが行われるのかを見ていきましょう。先ほどは関数のライフタイムチェックは呼び出し元の値の影響を受けないと言いましたが、呼び出し元の関数自身のライフタイムチェックにおいては呼び出される側のライフタイムの設定が影響を与えます。ただし、呼び出される関数の実装の詳細までは考慮されず、あくまで関数の引数と戻り値に設定された注釈を元にチェックが行われます。

例えばfunc3で以下のようにstaticなライフタイムの値を返す実装をした場合でも、呼び出し元ではそのことは一切考慮されません。また、呼び出し元のライフタイムチェックにおいて重要な点としては返される参照のライフタイムは呼び出し元の実引数を含め、返しうる参照のライフタイムのうち最も短いライフタイムが採用されます。func3の注釈から考えられる戻り値がとりうるライフタイムはi1のライフタイム、または、i2のライフタイムまたはstaticなライフタイムとなります。今回の場合はyに渡されるi2のライフタイムが最も短いため、i2のライフタイムを基準にライフタイムチェックが行われます。この場合、戻り値を格納したret_valuei2のライフタイムを超えてアクセスされているためライフタイムチェックではエラーが発生します。

fn func3<'a>(x: &'a i32, y: &'a i32) -> &'a i32{
    &3
}

fn main() {
    let mut ret_value;
    let i1 = 1;
    {
        let i2 = 2;
        ret_value = func3(&i1, &i2); // 実際にfunc3で返るのはstaticな参照
    }
    // Error: 実装的にはダングリング参照は発生しないが、func3の定義的には戻り値の参照は引数の参照と同等となると解釈され、かつ、
    // 最も短いライフタイムを持つi2の参照が渡されているためエラーになる。
    println!("{}", *ret_value)
}

また先ほど戻り値のとりうるライフタイムの長さについて触れましたが、func1の場合は引数に参照を持たないため注釈が 'a であっても実際に返せる参照はstaticなライフタイムを持つ参照のみとなります。そのため以下のようなコードではライフタイムチェックは成功します。

fn main() {
    let mut x : &i32;
    {
        x = func();
    }
    println!("{}", x); // OK: 引数なしで参照を返す関数の場合、戻り値はstaticな参照のはずなので問題なく通る。
}

fn func1<'a>() -> &'a i32 {
    &1
}

このように関数を呼び出す側は、呼び出される側とは少し異なったライフタイムチェックが行われるので注意しましょう。

ライフタイムの注釈の推論

実のところライフタイムの注釈はある程度推論されるようになっており、一部のライフタイム注釈は自動で設定されるようになっています。 ただこのライフタイム注釈の推論があるせいで思っても見ないところでコンパイルエラーに悩まされたりしちゃいます。 なので次はどう言う時にどんなライフタイム注釈の推論が行われているのかを見ていきましょう。

引数が&self、または、&mut selfのみの場合

メソッドに引数で自分自身の参照をとる場合は暗黙で戻り値の参照はselfと同じ注釈が与えられます。
具体的には以下のコードは

fn method1(&self) -> &i32
fn method2(&mut self) -> &i32

以下のように解釈されます。

fn method1<'a>(&'a self) -> &'a i32
fn method2<'a>(mut&'a self) -> &'a i32

引数の参照が1つのみの場合

関数の引数が1つだけ、かつ、その引数が参照である場合は戻り値の参照はその引数と同じ注釈が与えられます。
具体的には以下のコードは

fn func(x &i32) -> &i32

以下のように解釈されます。

fn func<'a>(x: &'a i32) -> &'a i32

逆に引数の参照が2つ以上になった場合は推論されず明示的に注釈を記述する必要があります。

fn func(x: &i32, y: &i32) -> &i32 // Error

引数が1つで、かつ、引数の型が参照を持つ型で、かつ、単一のライフタイム注釈を持つ場合

引数が1つの場合の特殊なルールとして、参照を持っている構造体やタプルなどが与えられている場合はその型が持っている参照と同じ注釈が与えられます。
具体的には以下のコードは

struct X <'a> {
    i: &'a i32,
}

fn func(x: X) -> &i32

以下のように解釈されます。

struct X <'a> {
    i: &'a i32,
}

fn func<'a>(x: X<'a>) -> &'a i32

この推論はrustの公式ドキュメントにかかれてなさげ?だったのですが、以下のようなコードを書くとエラーになるので間違い無いかと思います。

struct X <'a> {
        i: &'a i32,
}

fn func(x: X) -> &i32 {
    &1
}

fn main() {
    let i1 : &i32;
    {
        let i2 = 10;
        let x = X{ i: &i2 };
        i1 = func(x);
    }
    println!("{}", i1); // Error: i1に格納される参照はi2のライフタイムと同様と解釈されるためエラーになる
}

もちろん十分なライフタイムを持つ値ならエラーになりません。

struct X <'a> {
        i: &'a i32,
}

fn func(x: X) -> &i32 {
    &1
}

fn main() {
    let i1 : &i32;
    {
        let x = X{ i: &10 }; // staticなライフタイムを持つ値
        i1 = func(x);
    }
    println!("{}", i1); // OK: 問題なく通る。
}

Xが複数のライフタイム注釈を持つなら推論されずにエラーになります。

struct X <'a, 'b> {
        i: &'a i32,
        s: &'b str,
}

fn func(x: X) -> &i32 { // 戻り値型のライフタイムが推論できない
    todo!()
}

引数が&self、または、&mut selfで、かつ、第二引数以降に参照を持つ場合

メソッドが自分自身の参照を第一引数にとる場合は第二引数以降が参照だとしても戻り値型はselfと同様のライフタイム注釈を与えられます。

具体的には以下のコードは

fn method1(&self, i: &i32) -> &i32
fn method2(&mut self, i: &i32) -> &i32

以下のように解釈されます。

fn method1<'a>(&'a self, i: &i32) -> &'a i32
fn method2<'a>(&mut self, i: &i32) -> &i32

そのため以下のコードはエラーになります。

struct X {
    i: i32,
}

impl X {
    fn method<'a>(&'a self, i: &i32) -> &'a i32 {
        &10
    }
}

fn main() {
    let i : &i32;
    {
        let x = X{i: 1};
        i = x.method(&3);
    }
    println!("{}", i); // Error: iはxと同様のライフタイムとなるためエラーになる
}

また第二引数以降の参照には注釈がつかない(と言うより暗黙で別のselfと別のライフタイム注釈が付与される?)ので実引数にxよりも短いライフタイムの値を与えても問題なくライフタイムチェックは通ります。

struct X {
    i: i32,
}

impl X {
    fn method<'a>(&'a self, i: &i32) -> &'a i32 {
        &10
    }
}


fn main() {
    let i : &i32;
    let x = X{i: 1};
    {
        let i2 = 3;
        i = x.method(&i2);
    }
    println!("{}", i); // OK: iはxのライフタイムを使うので通る
}

ちなみに以下のように登場する参照全てにライフタイム注釈をつけなくてもライフタイムチェックは通ります。

fn func<'a>(i: &i32) -> &'a i32 {
    todo!()
}

複数のライフタイムが出てくる複雑な例を紐解く

ここまで見ていると「とりあえず注釈を推論できなかった時は適当に全部の参照に同じ注釈を付けとけば問題ないんじゃない?」と思われたかもしれません(少なくとも私は思いましたよ)。大概の場合は問題ないと思いますが、少し複雑なことをやろうと思うと複数のライフタイムが必要なケースが出てきたりするようです。下記のリンクにそのような状況が記載されています。
https://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/book/second-edition/ch19-02-advanced-lifetimes.html

具体的には以下のようなコードの場合は単一のライフタイム注釈を使うとエラーになってしまいます。

struct Context<'a>(&'a str);

struct Parser<'a> {
        context: &'a Context<'a>,
}

impl<'a> Parser<'a> {
    fn parse(&self) -> Result<(), &str> {
        Err(&self.context.0[1..])
    }
}

fn parse_context(context: Context) -> Result<(), &str> {
        Parser { context: &context }.parse()
}

そしてその対応として次のように複数のライフタイム注釈を与えるとライフタイムチェックが通ります(記事だとこの対応でも不十分と書かれていますが、私の手元のrust 1.80.1だと通ります。バージョン上がって書きやすくなったってことですかね)。

struct Context<'s>(&'s str);

struct Parser<'c, 's> {
context: &'c Context<'s>,
}

impl<'c, 's> Parser<'c, 's> {
    fn parse(&self) -> Result<(), &'s str> {
        Err(&self.context.0[1..])
    }
}

fn parse_context(context: Context) -> Result<(), &str> {
    Parser { context: &context }.parse()
}

では元のコードは何がダメだったのでしょうか?今まで説明した内容に照らし合わせると、parseメソッドの戻り値の型が推論によってParser構造体と暗黙で同じライフタイムが割り当てられていることがわかります。つまり以下のようになっています。

// 引数が&self、または、&mut selfのみの場合はそれと同じ注釈が戻り値型に与えられるルール
impl<'a> Parser<'a> {
    fn parse(&'a self) -> Result<(), &'a str> {
        Err(&self.context.0[1..])
    }
}

このコード自体は問題ないです。Contextの持つ &strContextと同じライフタイム注釈が与えられており、さらにParserContextも同じ注釈が与えられているため、 &strは少なくともParserと同等かそれ以上のライフタイムを持つことになっています。そのため &strから作られた別の文字列もまたParserと同等かそれ以上のライフタイムを持っています。すなわちparseメソッドはライフタイムチェックが通ることがわかります。

ところがどっこいparse_contextでは以下のようなライフタイム注釈が設定されることになります。

// 引数が1つで、かつ、引数の型が参照を持つ型で、かつ、単一のライフタイム注釈を持つ場合はそれと同じ注釈が戻り値型に与えられるルール
fn parse_context<'a>(context: Context<'a>) -> Result<(), &'a str> {
    Parser { context: &context }.parse()
}

そのため、parse_contextの戻り値は少なくともContextと同等かそれ以上のライフタイムが与えられる必要があります。そして実装の詳細を見るとparse_contextの戻り値はparseメソッドの戻り値をそのまま返しています。実装の詳細的には得られる文字列はContextと同等以上のライフタイムを持っているはずなので一見すると問題ないように思えます。しかし、「呼び出した関数の戻り値のライフタイムチェック」で説明した通り、呼び出した関数はその関数の実装の詳細がどおあれ、とりうるライフタイムの中で一番短いライフタイムを使ってライフタイムチェックを行います。そのため、parse_contextにおいてはParserのライフタイムを利用してライフタイムチェックが行われることになります。そしてParserparse_contextのスコープ内でライフタイムを終了してしまうためparserによって得られた値はparse_contextと同じライフタイムと見なされるためエラーになってしまうのです。

そこで次のようにParserにもう1つ別のライフタイムを与えて、parseの戻り値はParserの持つContextの持つ &strと同等かそれ以上のライフタイムであることを明示することによって、parse_contextの戻り値にparseメソッドの戻り値が使えるようにしたのです。より具体的に言うなら、parse_context内部で生成したParserではなく、第一引数のContextのライフタイムを基準にするように変更したと言うことになります。

struct Context<'s>(&'s str);

struct Parser<'c, 's> {
context: &'c Context<'s>,
}

impl<'c, 's> Parser<'c, 's> {
    fn parse(&self) -> Result<(), &'s str> {
        Err(&self.context.0[1..])
    }
}

非常にややこしいですが、このように複雑な参照の取扱が必要になった場合には必要に応じて複数のライフタイム注釈を設定する必要があることがわかります。実用レベルで考えるとvisitorパターンみたいなことをしたい場合は必要になるって感じですかねぇ(知らんけど)。

まとめ

めちゃくちゃ長くなって駄文が続いた感はありますが、以上が私が把握している範囲でのライフタイムのお話でした。rustの一番特徴的な機能なのに非常にややこしく理解が困難ですね。しかし、ここを乗り切れば割と普通に書けちゃうんじゃない?って気はするので「rust使いたいのに難しくてわからない」という方の手助けになればこれ幸い。

Discussion