ライフタイム注釈 'a は寿命を決めていない — 制約の宣言と NLL
TL;DR
'aは寿命を決めたり延ばしたりしない。複数の参照のライフタイム同士の関係を記述するだけ。'aはジェネリックパラメータ。Tと同じで、呼び出しごとにコンパイラが具体的な領域を当てはめる。- 因果の向きは「
'aが寿命を決める」ではなく「実データの寿命が'aの上限を決める」。 - NLL により、借用はスコープの
}ではなく最終使用地点で終わる。'aはブロックではなく領域。
このドキュメントの射程
&'a str の 'a が何を表しているのか。「寿命に名前を付けている」という直感がどこまで正しく、どこからずれるかを整理する。
原因
'a を「この参照はここまで生きる、という指定」と読むと、実際の借用チェッカの動きとずれる。
The Book の表現はこうである。ライフタイム注釈は参照がどれだけ生きるかを変えるわけではなく、複数の参照のライフタイム同士の関係を、ライフタイム自体には影響を与えずに記述するものである。さらに、シグネチャにライフタイムパラメータを書くとき、渡された値や返される値のライフタイムを変えているのではなく、その制約を満たさない値は借用チェッカが拒否すべきだと指定している。
つまり 'a は「ここまで生きろ」という指示ではなく、「この参照とこの参照はこういう関係になっていなければコンパイルを通さない」という関係の宣言である。実際の寿命はスコープと制御フローが決めており、'a はそれを検査するための名前でしかない。
解決
'a はジェネリックパラメータ
The Book は、longest 関数は x と y が実際どれだけ生きるかを知る必要はなく、そのシグネチャを満たす何らかのスコープが 'a に代入できることだけ分かればよい、と述べている。
fn first<'a>(s: &'a str) -> &'a str { &s[..1] }
let long = String::from("hello");
{
let short = String::from("world");
let a = first(&long); // ここの 'a = long に収まる領域
let b = first(&short); // ここの 'a = short に収まる領域(別物)
}
同じ 'a という名前でも中身は呼び出しごとに違う。固定の寿命にラベルを貼っているのではなく、「'a という穴を開けておくのでコンパイラが呼び出しごとに埋める」に近い。
因果の向き
コンパイラがやっているのは「実データが生きている領域 ⊇ 'a を満たす 'a が存在するか」の探索で、存在しなければ does not live long enough になる。
したがって「'a が終わったから参照が死ぬ」ではなく「参照が有効な範囲までしか 'a を取れない」が正しい順序。結果として使える範囲が 'a で頭打ちになるので、体感としては前者のように見える。
'a はスコープではない(NLL)
Rust 2018 以降は NLL(Non-Lexical Lifetimes)により、借用はスコープの } ではなく最後に使われた地点で終わる。
let mut v = vec![1, 2, 3];
let r = &v[0];
println!("{r}"); // r の最終使用。ここで借用が終わる
v.push(4); // OK(NLL 以前はエラー)
'a は「ブロック」ではなく、制御フローグラフ上で参照が実際に使われている領域である。「'a が終わったら終わり」の「終わり」は } ではなく最終使用地点。
注意点として、NLL が切るのはコンパイル時のプロパティである借用であり、参照を保持している変数自体はレキシカルスコープの末尾でドロップされる。そのため借用を持つ型が Drop を実装していると、drop 時に借用が使われうるため NLL が効かなくなる。
構造体で制約が重くなる理由
関数なら呼び出しごとに新しい 'a が入るので柔軟だが、構造体のインスタンスは構築時に 'a が具体的な領域に固定され、それを背負ったまま動く。フィールドが生き続けるので NLL の「最終使用で終わる」も効きにくく、構造体が生きている間ずっと元データが借用されたままになる。
なお関数シグネチャにはライフタイム省略規則があるが、構造体フィールドは省略できない。コンパイラは各関数の本体をシグネチャだけを頼りに独立して検査するため、制約が型に書かれている必要があるからである。
まとめ
'a は寿命の指定ではなく関係の宣言であり、実データの寿命が 'a の上限を決める。'a の実体はレキシカルスコープではなく、NLL によって最終使用地点までの領域である。