[Rust] ライフタイムは参照の生存期間ではなく、所有権の有効期限である!?
本記事はUzabase Advent Calendar 2023の二日目の記事です。
業務でRustの所有権まわりについて同僚に説明しているうちに悟りを得たのでここで共有します。
ライフタイム関連のエラー(taskをspawnするときとか)で悩むことがある方にとって少しでも参考になれば幸いです。
'staticについて考えてみた
Rustのライフタイムには、'staticというものがあります。
'staticの説明として「プログラムが終了まで生存する」という表現が使われることが多いようですが、若干のミスリーディングを感じています。
私の言葉で説明するのであれば、'staticは「所有権が無期限」ということです。
さらにざっくり言うのであれば、'staticは「ライフタイムを考慮する必要がない」ということです。
この主張(?)について納得していただけるように、短くまとめてみました。
また、主題についてもこの説明のなかで触れていきます。
現代法における"所有"の比喩を使うとわかりやすい
Rustの所有権を理解するときは、現代法における"所有"の比喩を使うとわかりやすいです(Rustを学び始めたときに知りたかった……)。"所有権"という名前からしてそういう比喩だと思うのですが、この当たり前なことに最近気づいて、あのとき難解だとおもっていた公式ドキュメントなどの所有権の説明が実はわかりやすいものだったことがわかりました。
現代人は法律における"所有"の概念を感覚もしくは知識として持っていると思うので、それを比喩に使うとわかりやすいです。自分は完全に新しい概念としてRustの所有権を理解しようとしていたので難しさを感じていました。
例えば、Rustでは以下のような用語や操作がありますが、比喩を使ってカッコ内のように言い換えるとわかりやすいです。
- move (所有権を譲る)
- borrow (所有権の一部を借用する)
- drop (所有権の行使もしくは所有者の責任として物を破棄する)
- forget (所有権を持っていることを忘れる)
ただし、Rustにおける所有権には以下のような制約があることを覚えておくことが重要です。
- 所有権は常にただ一人が持つ(共同所有はできない)
- ただ一人にしか可変で貸与できず、その間は他者による一切の借用ができない
このあたりについては後日別の記事で詳しく書けたらと思います。タイトルは「ゴミ問題とRustの所有権」の予定です。
「プログラムが終了まで生存する」
さて、'staticの話に戻ります。
まずは「プログラムが終了するまで生存する」ものの例を挙げてみます。
例えば文字列リテラルの値はコンパイル時にバイナリにそのまま含まれるため必然的にプログラムが終了するまで生存します。
let s: &'static str = "hello";
また、static変数はプログラムが終了するまで生存します。
static hello: Lazy<String> = Lazy::new(|| "hello".to_string());
fn main() {
let s: &'static String = &*hello;
}
T: 'static が意味すること
次に以下の2つの関数を見てください。違いがわかりますか?
fn needs_static1<T>(_: &'static T) {}
fn needs_static2<T: 'static>(_: T) {}
実はneeds_static2は参照だけでなく、Ownedな値も引数に取れます。
static HELLO: Lazy<String> = Lazy::new(|| "hello".to_string());
needs_static1(&*HELLO as &str);
needs_static2(&*HELLO as &str);
needs_static1("hello".to_string()); // compile error
needs_static2("hello".to_string());
needs_static1("hello".to_string())は当然コンパイルエラーになりますが、needs_static2のほうはコンパイルできますね。
これは実は参照でないStringのようなOwnedな値は暗黙に'staticライフタイムを持っているということです。
あれ、ライフタイムって参照の話じゃなかったっけ?
参照を所有する
以下のコードでは、なんとbは「aの参照」の所有権をもっています。
let a = 1;
let b = &a;
そのことは実は当然の事実であることが、以下のようなコードを書いてみるとわかります。
let a = 1;
let b = &a;
let c = &mut b;
let d = &mut b; // cannot borrow `b` as mutable more than once at a time
つまり所有と参照は独立したものではなく、すべての値は誰かに所有されることができて、単にその値の種類としてOwnedな値とアドレス値(参照)とがあるということです。
ライフタイムが必要なのは参照だけではない
参照でなければライフタイムのことを考える必要がないというのは実は間違いだったりします。
struct AppContext<'a> {
repository: &'a mut Repository,
}
このstructの値を扱う際は'staticでない参照を扱うときと全く同様のケアが必要になります。
また以下のコードがコンパイルできることからAppContext<'a>という型をもつ値は'aというライフタイムで扱われます。
fn use_app_context<'a>(context: AppContext<'a>) {
assert_lifetime::<'a, _>(context);
fn assert_lifetime<'a, T: 'a>(_: T) {}
}
整理も兼ねて以下のようになります。
-
AppContext<'a>という型をもつ値は、'aというライフタイムで扱われます。 -
Repositoryという型をもつ値は、'staticというライフタイムで扱われます。 -
&'a mut Repositoryという型をもつ値は、'aというライフタイムで扱われます。
ライフタイムが必ずしも参照だけに関係するものではないことがわかります。
余談: 完全に参照と関係のないライフタイムの例
PhantomDataを使えばデータ構造に対して内容と関係のないライフタイムを持たせることができます。
一見、何でもないライフタイムを導入してもなにも嬉しくなさそうですが、応用例がないわけではないです。
例えば、ライフタイムがinvariantになるようにうまくPhantomDataを使うことで一種のブランド型を作ることができます。
ライフタイムはなにを表しているのか
Stringという型の値が持つライフタイムは'staticです。
また、所有権を持っている人ならいつでもdropできるので、その値はプログラムが終了するまで生存するわけではありません。
つまり'staticだけど、プログラムが終了するまで生存しないことが多々あるということになります。
'staticの説明として「プログラムが終了まで生存する」という表現が使われることが多いようですが、若干のミスリーディングを感じます。
それが上のように書いた理由です。
これを踏まえるとライフタイムの解釈のひとつとして「所有権の有効期限」という解釈ができます。
Stringのような'staticライフタイムをもつ型の値を所有する場合、その所有時間に制限はありません。プログラムが終わるまで所有しても構いませんし、途中でdropしても構いません。
一方でAppContext<'a>のような型の値を所有する場合、その所有時間は'aというライフタイムに制限されます。'aライフタイムが終わる前にdropすることはできますが、'aライフタイムを超えて所有し続けることはできません。
&'a strのような通常の参照の所有についても同様です。
なぜ所有権の期間が制限されているのかというと、そのリソースが期間限定のリソースに依存しているからと捉えることができます。期間限定で借りている土地の上に家を建ててその家を所有しているみたいなイメージですね。
もっと簡単に考えてみる
'staticは特別なライフタイムであり、
しかしそれは「プログラムが終了するまで生存する」ということを意味するのではなく、
それを扱うコードにおいて「ライフタイムを考慮する必要がない」ということを意味している。
なぜならライフタイムを考慮する必要がないとコンパイラが判断して'staticをつけてくれているからである。
と考えるのもありではないかと思います。
StringやUserIdやHashMap<UserId, Vec<Post>>のような型が軒並み'staticと扱われていることによって、我々はライフタイムを全く考慮せずにアプリケーションを書くことができるのです。
まとめ
- Rustのライフタイムは参照の生存期間ではなく、所有権の有効期限であると考えるとわかりやすい
- 実はみんな
'staticだった -
'staticは必ずしも「プログラムが終了するまで生存する」ということを意味するわけではない
参考になったら幸いです。何かあれば(何かなくても)コメントをいただけると嬉しいです。
Discussion