注意: 正確性は重視していない意訳なので、正確に知りたい人は原文を参照のこと
関連:
------
開始日: 2017-08-02
1. 要約
Rustの借用システムをノンレキシカルライフタイムをサポートするように拡張する このRFCが扱うもの:
より柔軟になる(ライフタイムの)領域をどう推論するか
エラーメッセージをどう調整するか
借用チェックを通すためだけに要求されていた、小さなコード変更群を行わなくてもよくするためのもの
このRFCが解決しない借用チェッカの制限もある:
詳しくは末尾の付録を見てね
2. 動機
2-1. ライフタイムとは何か?
借用チェッカの基本的なアイディア:
値が借用されている間は、更新されたり移動されたりしないようにする
ではどうやって値が借用されていることを知るのか?
アイディアはとても単純:
借用が作成されたら、コンパイラはその結果の参照にライフタイムを割り当てる このライフタイムは「参照が使用される(可能性がある)コードの範囲」に対応する
コンパイラは「参照が使用箇所全てを包含した中で、最小の範囲」をライフタイムとして推論する
Rustはライフタイムという用語を、とても限定的な意味で使っている。
通常の会話では、ライフタイムという単語は、二つの異なる(しかし似通った)意味で使用可能:
(1) 参照のライフタイム:
参照が使用される間の時間の範囲に対応するもの
(2) 値のライフタイム:
値が解放されるまでの時間の範囲に対応するもの
こちらの時間の範囲はとても重要なので、前者から区別するために値のスコープと呼称することにする
当然、ライフタイムとスコープは密接に関係している。特に、もしある値を参照したとして、その参照のライフタイムは、値のスコープよりも長生きすることはできない(解放後のメモリを指してしまうのを防止するために)。
両者の違いをよりちゃんと示すために、以下のような簡単な例を見てみる:
配列dataが破壊的に借用されている
借用結果の参照はcapitalize関数に渡される
capitalizeはこの参照を返り値で戻さない
この借用のライフタイムは、この呼び出しに閉じ込められる
dataのスコープはより長く、関数の本体のサフィックスに対応する
letから始まり、それを包含するスコープの終わりまで
code:rust
fn foo() {
capitalize(&mut data..); // | // ^~~~~~~~~~~~~~~~~~~~~~~~~ 'lifetime // |
data.push('d'); // |
data.push('e'); // |
data.push('f'); // |
} // <---------------------------------------+
fn capitalize(data: &mut char) { // do something
}
この例は、今日のRustのライフタイムは、スコープよりは若干柔軟であることも示している:
通常、スコープはブロックに対応する (正確にはletから、それを包含するブロックの終わりまで)
一方でライフタイムは、上の例のように、個別の式に対応させることも可能:
この例の借用の参照はcapitalizeの呼び出しに閉じ込められており、ブロックの残りの部分には影響しない
そのため、後続のdata.push呼び出しは合法となる
一つの文の中で使われるだけなら、今のライフタイムで十分。
ただし、複数の文を跨いだ参照を保持するようになると、問題が発生してくる:
この場合、コンパイラは、それらの文を包含する最も狭い範囲、を参照のライフタイムとして要求する
この範囲は通常はブロックと一致する
典型的には、これは本当に必要な範囲に比べて、かなり大きなものとなる (ので無駄にチェックが厳しくなる)
2-2. 問題ケース1: 一つの変数に割り当てられた参照群
参照が変数に割り当てられてしまうと、問題が発生しがち。
上の例を僅かに変更した次のコードを見てみる:
code:rust
fn bar() {
let slice = &mut data..; // <-+ 'lifetime // 訳注: 参照をローカル変数に格納するようになったのが変更点 capitalize(slice); // |
data.push('d'); // ERROR! // |
data.push('e'); // ERROR! // |
data.push('f'); // ERROR! // |
} // <------------------------------+
現在のコンパイラの挙動だと、参照を変数に割り当てると、そのライフタイムは変数のスコープと同じ長さになってしまう。
そのため、この例ではライフタイムがブロックの終端まで拡張されて、後続のdata.pushがエラーになるようになってしまった(迷惑)。
以下のようにslice変数を狭いブロックを押し込めることで回避は可能:
code:rust
fn bar() {
{
let slice = &mut data..; // <-+ 'lifetime capitalize(slice); // |
} // <------------------------------+
data.push('d'); // OK
data.push('e'); // OK
data.push('f'); // OK
}
新しいブロックを導入するこの方法は、ある種人為的であり、明白な解法ではない。
2-3. 問題ケース2: 条件付き制御フロー
参照が一つのmatch節(一般化するなら一つの制御フローパス)でのみ使用されている、というケースもよくある問題の一つ。
マップを用いた以下の例を見てみる(「キーが存在するなら値の処理、無いなら新規挿入」ということをmatchを使って実現したい):
code:rust
fn process_or_default() {
let mut map = ...;
let key = ...;
match map.get_mut(&key) { // -------------+ 'lifetime
Some(value) => process(value), // |
None => { // |
map.insert(key, V::default()); // |
// ^~~~~~ ERROR. // |
} // |
} // <------------------------------------+
}
今はこれはコンパイルできない。
note.icon 原文だと理由が書かれているけれど、これまでの説明と上のコードで自明なので省略
以下のようにNoneの場合の処理をmatchの外側に移動することで、問題の回避は可能:
code:rust
fn process_or_default1() {
let mut map = ...;
let key = ...;
match map.get_mut(&key) { // -------------+ 'lifetime
Some(value) => { // |
process(value); // |
return; // |
} // |
None => { // |
} // |
} // <------------------------------------+
map.insert(key, V::default());
}
回避できるとはいっても、こういったコード変更が必要になるのは望ましくはない。
2-4. 問題ケース3: 関数を跨いだ条件付き制御フロー
ケース2は比較的簡単に回避可能ではあったが、そう簡単には解決できない条件付き制御フローの変種もある。特に関数の外に参照を返したい場合には対応が難しくなる。
「キーに対応する値があるならその参照を、ないなら新規追加した値の参照を返す」といったことを行う、次のような関数を見てみる(説明のためにこのマップはentryAPIを備えていないものと仮定する):
code:rust
fn get_default<'r,K:Hash+Eq+Copy,V:Default>(map: &'r mut HashMap<K,V>,
key: K)
-> &'r mut V {
match map.get_mut(&key) { // -------------+ 'r
Some(value) => value, // |
None => { // |
map.insert(key, V::default()); // |
// ^~~~~~ ERROR // |
map.get_mut(&key).unwrap() // |
} // |
} // |
} // v
一見すると、これはケース2のコードに似ているように見えるが、実は全く異なっている:
Someブランチでvalueが呼び出し元に返されているのが差異の理由
valueはマップへの参照なので、これにより呼び出し側のある地点('rと等しい)まで借用が続くことになる
'rのライフタイムを分かりやすく記すために、get_defaultの呼び出し側のコードを見てみる:
(get_defaultの返り値の参照が使用されているコードの範囲が、'rのライフタイムとなる)
code:rust
fn caller() {
let mut map = HashMap::new();
...
{
let v = get_default(&mut map, key); // -+ 'r
// +-- get_default() -----------+ // |
// | match map.get_mut(&key) { | // |
// | Some(value) => value, | // |
// | None => { | // |
// | .. | // |
// | } | // |
// +----------------------------+ // |
process(v); // |
} // <--------------------------------------+
...
}
ケース2と同じ対処では上手くいかない:
code:rust
fn get_default1<'r,K:Hash+Eq+Copy,V:Default>(map: &'r mut HashMap<K,V>,
key: K)
-> &'r mut V {
match map.get_mut(&key) { // -------------+ 'r
Some(value) => return value, // |
None => { } // |
} // |
map.insert(key, V::default()); // |
// ^~~~~~ ERROR (still) |
map.get_mut(&key).unwrap() // |
} // v
以前はvalueのライフタイムがmatchに閉じていたが、今回のライフタイムは呼び出し元にまで拡張されている。
そのため、matchの後のcall呼び出しも、まだスコープ内として扱われてしまう。
この問題の回避策は若干複雑。
以下では、借用チェッカが「借用がスコープ内かどうか」を判定するために、関数の正確な制御フローを用いていることを利用している:
code:rust
fn get_default2<'r,K:Hash+Eq+Copy,V:Default>(map: &'r mut HashMap<K,V>,
key: K)
-> &'r mut V {
if map.contains(&key) {
// ^~~~~~~~~~~~~~~~~~ 'n
return match map.get_mut(&key) { // + 'r
Some(value) => value, // |
None => unreachable!() // |
}; // v
}
// ここに来たならmap.get_mutは決して呼び出されてはいない!
// (「呼び出されたけど、その結果の値はもう使われていない」ではなく、呼び出されてすらいない)
map.insert(key, V::default()); // OK now.
map.get_mut(&key).unwrap()
}
map.get_mutをifの中に移動したのが変更点:
get_mutの結果として得られる借用のライフタイム'rは、以前と同様に呼び出し元にまで続いている
ただしifの本体では、無条件に(必ず)returnしている
そのため借用チェッカは、借用がifブロックの外側に決して漏れないことが確認可能
=> ifの外側のmap.insertが呼び出し可能になる
この回避策は、他のものに比べてよりやっかい。
結果のコードは、ルックアップが増えることによって性能が劣化してしまっている。
実際にはentryAPIを使うのが良い:
code:rust
fn get_default3<'r,K:Hash+Eq,V:Default>(map: &'r mut HashMap<K,V>,
key: K)
-> &'r mut V {
map.entry(key)
.or_insert_with(|| V::default())
}
このコードは、元々のものよりも可読性が高く効率も良い(ルックアップが一回減る)ので、借用チェッカが改良されたとしても、このケースではentryAPIを使う方が良さそう。
(興味深いことに、entryAPIが最初に提案された際の動機の一つは、ここで取り上げた借用チェッカの回避すること、であった)
2-5. 問題ケース4: &mut参照を更新する
現在の借用チェッカは、&mutを格納する変数xが、その参照先*xが借用されている場合には、再代入されることを禁止している。
これはデータ構造を走査するようなループを書いている際によく問題になる。
リンクリスト&mut List<T>をVec<&mut T>に変換する、次のような関数を考えてみる:
code:rust
struct List<T> {
value: T,
next: Option<Box<List<T>>>,
}
fn to_refs<T>(mut list: &mut List<T>) -> Vec<&mut T> {
let mut result = vec![];
loop {
result.push(&mut list.value); // 訳注: listの参照先を借用
if let Some(n) = list.next.as_mut() { // 訳注: ここでも借用
list = n; // 訳注: ここで再代入
} else {
return result;
}
}
}
コンパイルすると、以下のエラーで失敗する(実際にはもっと沢山のエラーが出る):
code:rust
errorE0506: cannot assign to list because it is borrowed --> /Users/nmatsakis/tmp/x.rs:11:13
|
9 | result.push(&mut list.value);
| ---------- borrow of list occurs here
10 | if let Some(n) = list.next.as_mut() {
11 | list = n;
| ^^^^^^^^ assignment to borrowed list occurs here
list.value (正確には(*list).value)を借用しているのがエラーの原因。
現在の借用チェッカは、あるパスを借用する際には、そのパスの任意のプレフィックス部分に対して代入が行えない、ルールを強制する。
今回のケースでは、以下に対して代入が行えなくなる:
(*list).value
*list
list
結果としてはlist = nは禁止される。
このルールは、いくつのケースでは意味を持つ(例えば「listの型が&mut List<T>ではなくList<T>の場合、listを上書きすることはlist.valueを上書きすることに繋がる)が、今回のように破壊的な参照を走査するケースには当てはまらない。
例えば、以下のように&mut参照を一時変数の中に移動してしまえば良い(面倒):
code:rust
fn to_refs<T>(mut list: &mut List<T>) -> Vec<&mut T> {
let mut result = vec![];
loop {
let list1 = list; // 訳注: listの中身を、一時変数list1に移動
result.push(&mut list1.value);
if let Some(n) = list1.next.as_mut() {
list = n; // 訳注: list(に繋がるパス)自体はもう借用されていないので再代入可能
} else {
return result;
}
}
}
この問題自体は、ノンレキシカルライフタイムに直接関係するものではなく、借用チェッカが、パスが借用されている際に強制するルールが厳密過ぎるのと、借用された参照の間接性(note.icon 上のコードなら「listを更新しても、&mut list.valueには影響がない」ということ)を考慮していないことに起因する。
このRFCでは、これを解決するためのルールの調整について提案している。
2-6. 今回の解決策の大まかなアウトライン
このRFCは、ライフタイムのためのより柔軟なモデルを提案する:
今回は制御フローグラフによって定義されるライフタイムを提案
より具体的に言えば、コンパイラ内で内部的に使用されているMIRに基づいて、ライフタイムを導出する 直感的には、新しい提案では、関数内である参照が使用される(生きている)可能性がある範囲でのみ、その参照のライフタイムが維持される、ようになる (e.g., ケース2のような、参照が使用されていないmatch節ではライフタイムも切れる)。
ただし、ここまでで取り上げた全ての例で、適切に動作するようにするためには、ライフタイムを制御フローグラフの範囲に変更するだけではダメ:
サブタイピングチェックを行う際に、位置情報も考慮しなければならない
'aが'bよりも長生きする('a: 'b)なら、&'a ()型は&'b ()型のサブタイプ
'aは関数内のより広い範囲に対応している、ことを意味する
この提案によって、サブタイピングはある特定の地点Pによって確立されることが可能となる:
この場合、Pから到達可能な範囲でのみ、ライフタイム'aは、'bの範囲よりも長生きすればよい
------