Rust入門者とライフタイム注釈との出会い
多くのRust入門書で、ライフタイム注釈の説明が出てくるのはかなり後半です。なので、Rust入門者とライフタイム注釈との出会いは往々にして実際にコードを書いている際、ということになりがちです。「コンパイラが急に謎のアポストロフィを要求してきて、言われた通りに直したら動いたけど、一体何をしたのか分からないし、コードが禍々しいアポストロフィだらけになってしまった……。」あたりが典型例でしょうか。
この記事の対象読者
必要なことは全てRust公式チュートリアルの10.3章に書いてありますので、まずはそちらを参照して下さい。その上で、
- 読んだが理解できない
- なぜこんな仕組みが必要なのかモヤモヤする(なぜ必要なのか分からない)
- コンパイラに指摘される前に気付ける気がしない(いつ必要なのか分からない)
としたら、この記事がお役に立てるかもしれません。
なぜ注釈が必要なのか
ライフタイム注釈がなぜ必要なのか。それを実感するために、架空のプログラミング言語Lust(Lifetime annotation-less rUST)を使って思考実験をしてみましょう。Lustはライフタイム注釈に関する文法がすっぽり抜け落ちている以外は、完全にRustと同一の文法を持つ言語とします。
例えばLustで以下のシンプルな2つの関数、func1
とfunc2
を定義してみましょう。どちらも2つの文字列参照を受け取り、printして、片側の引数だけを返り値として返すの関数です。func1
とfunc2
の違いは、第一引数と第二引数のどちらを返すか、だけです。
// 2つの文字列参照を受け取り、printして、第一引数だけを返す関数
fn func1(x: &str, y: &str) -> &str {
println!("{x} {y}");
x
}
// 2つの文字列参照を受け取り、printして、第二引数だけを返す関数
fn func2(x: &str, y: &str) -> &str {
println!("{x} {y}");
y
}
そして、main関数からfunc1
とfunc2
を以下のように呼んでみます。どうなるでしょうか。
fn main() {
let s1: &str;
let s2: &str;
let hello = "Hello".to_string();
{
let world = "World!".to_string();
s1 = func1(&hello, &world); // <-- (1)
s2 = func2(&hello, &world); // <-- (2)
} // <-- (3)
println!("{}", s1); // <-- (4)
println!("{}", s2); // <-- (5)
}
func1
の方は問題ないでしょう。(1)の箇所でHello World!
がprintされたあとにs1
へとhello
の参照が代入され、(4)の箇所で再度Hello
とprintされるはずです。
func2
の方は問題があります。(2)の箇所でHello
とWorld!
がprintされたあとs2
へとworld
への参照が代入されますが、(3)の箇所でworld
の実体はスコープから外れ、破棄されてしまいます。にもかかわらず(5)でworld
の実体を参照するs2
を使おうとするため、コンパイルエラーになるでしょう。
これはまずい事態でしょうか?「別に問題ないのでは」と思う人もいるかもしれません。「確かにこの例でのfunc2
の使われ方には問題がある。でも(5)の部分で問題が生じるのは明白なのだからコンパイルエラーが出るはずだし、それを見てコードを修正すれば良いじゃないか。ライフタイム注釈なんてものが出る幕は無い。」と。
いや、コンパイラはそれで良いかもしれませんが1、我々人類にとってはそうとも言えないのです。エラーの原因を考えてみてください。(4)と(5)の箇所でのエラーの有無を生んだのは、func1
とfunc2
がそれぞれ第一引数か第二引数のどちらを返すか、でした。つまり関数内部の事情です。
これの意味するところは、あなたはこの問題に対処するために関数内部のコードを読まなければならない、ということです。このサンプルくらい単純なら良いですが、もし他人が書いた複雑な関数だったらどうでしょうか。ある日crates.ioで見つけただけのライブラリでこんなエラーが起きたら?
Lustの世界にもdocs.lsがあるとして、func1
とfunc2
の定義はきっと以下のように記されているでしょう。両者の関数シグネチャは完全に同じで、挙動の違いを使用者が予見することは不可能です。
// Lustの場合
pub fn func1(x: &str, y: &str) -> &str
pub fn func2(x: &str, y: &str) -> &str
そもそも静的な型システムとはなんのためにあるんでしたっけ。答えは複数あるでしょうが、最大のメリットの一つが「安全性」です。fn(x: &str) -> String {}
という関数があったとして、引数xとして&str型を与えてString型を受け取るのなら(実行時に逃れられないpanicやロジックの正誤はともかく)プログラムそのものは正常にコンパイルされることが保証されている。これが静的型の良い所だったはずです。型情報自体が関数の仕様書、あるいは関数の実装者と使用者の契約書と言っても良いでしょう。
関数func2
において、使用者は契約どおりに&strを投げて&strを受けとったのに、そのせいでコンパイルエラーが返ってきたのです。Lustの型システムは崩壊しています。
対してRustではどうでしょうか?関数の定義は以下のようになります。ライフタイム注釈の登場です。
// Rustの場合
pub fn<'a,'b> func1(x: &'a str, y: &'b str) -> &'a str
pub fn<'a,'b> func2(x: &'a str, y: &'b str) -> &'b str
注釈記法の具体的な読み方は次章で説明しますが、まずはfunc1
とfunc2
の関数表現が異なることに注目してください。func1
には「xの参照先は返値よりも先に死んではならない」ことが表現されています。func2
では「yの参照先は返値よりも先に死んではならない」ことが表現されています。よって、最初の例で使い方を守らなかったのは関数の使用者の責任であり、どのように間違え、どのように直すべきなのか、明確です。ライフタイム注釈がなんのためにあるのか。(パーサーにも大いにメリットはありますが)何よりも我々人類のため、実装者が設計意図を表明し、使用者がその意図を汲み取る、静的な型システムを成立させるためだったということです。
[2024/06/01]コメント指摘により、C++との比較を削除
ライフタイム注釈の読み方
では、先ほどのライフタイム注釈の読み方について見ていきましょう。fn
キーワードによる関数定義時に引数と返値に同じ名前のライフタイム注釈がある場合、その引数の参照先はその返値よりも先に死んではいけません。
例えば、
fn hoge<'a>(x: &'a str) -> &'a str {}
であれば、「xの参照先はこの返値(&'a str)よりも先に死んではならない」と読みます2。関数名hoge
のあとの<'a>
は、'a
がライフタイム注釈であることを定義しています。要は'b
でも'aaa
でも好きに名付けて使って良いのですが3、あらかじめ<>
内で定義しておく必要があります。
次に、
fn hoge<'a>(x: &'a str, y: &'a str) -> &'a str {}
であれば、「xの参照先はこの返値(&'a str)よりも先に死んではならないし、かつyの参照先はこの返値(&'a str)よりも先に死んではならない」と読みます。x
とy
は同じライフタイム注釈('a
)で表されていますが、これによってxとyの寿命関係が制限されることはありません。fn
におけるライフタイム注釈は、あくまでも返値から見た引数の参照先の寿命の下限を規定するものです。
次はどうでしょうか。
fn hoge<'a,'b>(x: &'a str, y: &'b str) -> &'a str {}
これは「xの参照先はこの返値(&'a str)よりも先に死んではならない」と読みます。異なるライフタイム注釈('a
と'b
)で表される引数と返値の間には何の寿命制限も生まれません。
関数名のあとにいちいち<'a,'b>
のように定義することは必要なのか?と思った人もいるかもしれません。これはジェネリクスの記法に準拠したものです。そもそもジェネリクスにおいてなぜこのような記述が必要かは別記事に書きました。もっとも、ライフタイム注釈における予約語はごく少数しかない('static
など)ので省略できる気もしますが、記法の統一性を重視したのではないかと思います。
改めて、前章に戻ってfunc1
とfunc2
の関数表現を読んでみて下さい。あるいは、Rust公式チュートリアルの10.3章のlongest
関数の表現を読んでみて下さい。今なら関数実装者の意図を読み取れるでしょうか?
いつ注釈が必要なのか
前章では、関数の使用者の立場からライフタイム注釈の読み方を見ました。では、関数の実装者はいつライフタイム注釈をつけなければならないのでしょうか?前章で述べた通り、関数定義におけるライフタイム注釈は関数の使用者にその関数の正しい使い方を告知するものでした。よって「ライフタイム注釈が無ければその関数の使用者がを誤った使い方をする可能性があるかどうか」がライフタイム注釈の要不要を見分けるポイントになります。
fn
キーワードによる関数定義の典型的なパターンを順番に見ていくことにしましょう。ただし、以下では簡単のために静的ライフタイム'static
(プログラム実行中全域に渡って生きていることを表す特殊なライフタイム注釈)が絡まないパターンのみを想定します。T
は参照を内部に含まない何らかの型を表します。
-
fn hoge(x: T) -> T {}
引数にも返値にも借用型が無いパターン。これまでずっと述べてきたとおり、ライフタイム注釈が規定するのは借用の寿命の話なので、明らかにライフタイム注釈は不要です。 -
fn hoge(x: &T) -> T {}
引数にだけ借用型があるパターン。最初に述べた通り、ライフタイム注釈は返値の寿命から見た引数の寿命を規定するものなので、('static 絡みで無い限り)ライフタイム注釈は不要です。 -
fn hoge(x: T) -> &T {}
返値にだけ借用型があるパターン。引数に与えてもいないのに返値に参照があるということは、('static 絡みで無い限り)関数内部の変数の参照を返しているとしか考えられませんが、関数から出た瞬間に関数内部の変数は破棄されるのでコンパイルできません。 -
fn hoge(x: &T) -> &T {}
返値と引数に一つずつ借用型があるパターン。上記3.と同じ理由で、返値は必ず引数をそのまま返していることになります('static 絡みで無い限り)。よって、引数xの参照先は返値よりも先に死んではいけません。だからライフタイム注釈をつける.....、と言いたいところですが、逆にこの形であればそれ以外のパターンがありません。よって使用者が迷う余地は無い、ということで、ライフタイム注釈は省略可能になっています(ごく初期のRust4では必要だったようですが)。 -
fn hoge(x: &T) -> (&T, &T) {}
入力が1つ、出力が2つのパターン。上記3.と同じ理由で('static 絡みで無い限り)返値はどちらも引数をそのまま返す形になります。よって上記4.と同じ形になり、ライフタイム注釈は省略可能です。 -
fn hoge(x: &T, y: &T) -> &T {}
入力に複数借用型があるパターン。前章で述べたfunc1
やfunc2
のパターンですね。解説したとおり、ライフタイム注釈が必要です。返値がxなのかyなのか、あるいはどちらの可能性もあるのかによって注釈のつけ方が変わります。
ライフタイム注釈が必須となるケースは案外少ないですね。型内部に参照が含まれている場合・匿名ライフタイムが必要なる場合などはもう少し形が複雑になりますが、「いつ注釈が必要になるのか?」の判断方法自体はシンプルだと実感してもらえたでしょうか。
おわりに
今回はfn
キーワード関連に絞ってライフタイム注釈について説明をしました。ライフタイム注釈はfn
キーワードのほかにstruct
やimpl
キーワード等でも登場しますが、基本的な考え方は同じです。
「型」が担う重要な仕事の一つとして、メモリ空間をどのように使うかを規定することが挙げられます。それに対して、オブジェクトの寿命という時間軸情報が型表現に含まれている時空間的な型システムがRustの面白いところだと思います。