RustaceanのためのRustの書き方
このスクラップ is 何?
Jon Gjengset "Rust for Rustaceans: Idiomatic Programming for Experienced Developers", No Starch Press(2021) の個人的な読書メモ。ボクは出版社サイト(下記リンク)より購入したPDF版で読んでいます。
Table of Contents
このスクラップの見出し。個人的な関心事ごとにまとめているので、節立ては必ずしも原著と一致しないことをご留意ください。
Chap 1. Foundations
メモリ管理のメンタルモデル
Rustのメモリ管理は大別して2種類のメンタルモデルで考えることができる。
-
High-level model
- 変数を単に「値に付けられた名前」として見なし、コード上でいつ初期化・ムーブ・消費されるかを考える
- コード上で変数の使用箇所を辿る線のことを本書では「データフロー」と呼んでいる
- → 所有権、ライフタイムの理解に有用
- 変数を単に「値に付けられた名前」として見なし、コード上でいつ初期化・ムーブ・消費されるかを考える
-
Low-level model
- 変数を「値のスロット」と見なし、そのスロットがどうなっているか(値が入っているか、参照されているか、など)を考える
- → unsafeコード、生ポインタの理解に有用
本書の内容を理解する上ではこれらhigh/low-levelどちらの考え方もできると複雑なコードを読み解くのに良いとのこと。今は「ふーんそうなんだ」くらいに思っておく。
3種類のメモリ領域
- stack
- heap
- static
可変参照
Q. ある変数が所有権を持っているときと可変参照であるときの違いは何?
A. 値をdropする責任があるかどうか。所有権を持っている状態ではスコープ離脱時に値をdropする必要があるが、可変参照では値をdropしない。
⚠️ 注意:可変参照がある状態で値をmoveするとき
サンプルコード:
fn replace_with_84(s: &mut Box<i32>) {
// let was = *s ← ①
let was = std::mem::take(s);
*s = was;
let mut r = Box::new(84);
std::mem::swap(s, &mut r);
assert_ne!(*r, 84);
}
// 関数呼び出し側スコープ
let mut s = Box::new(42);
replace_with_84(&mut s);
ここで
-
std::mem::take<T>(dest: &mut T) -> T
:dest
の参照先のT
型の中身(値)を取り出して代わりにDefault
の値に詰め替える[1] -
std::mem::swap<T>(x: &mut T, y: &mut T)
: 2つの可変参照が指す先の値を入れ替える[2]
である。
上記のサンプルコードでは、関数replace_with_84
にBox<i32>
型の可変参照s
を渡しているが、引数s
の所有権は依然として関数呼び出し側スコープの変数s
が持っているため、①のように関数内で参照外しをして所有権を関数内の変数にmoveすることはできない。
そこで関数内で可変参照先の値を取得する回避策として、ここではstd::mem::take
を使ってs
の参照先をDefault
値に詰め替えている。こうすることで、関数呼び出し側スコープがdropすべき値を変数s
に持たせ続けたまま関数内で値の書き換えを実現している。
内部可変性
Rustの型には内部可変性 (interior mutability) を提供しているものがあり、それらはざっくり2種類に分けられる。
- 可変参照を通じて値を変更させるもの
- e.g.
Mutex
,RefCell
. ... - 裏では
UnsafeCell
で実装されている -
shared XOR mutable メカニズムに準拠する
- 1つの可変参照と複数の共有された不変参照は同時には存在できない(排他的、XOR)という仕組み[1]
- コード上である変数に対する1つの可変参照と他の不変参照/可変参照のデータフローが並行して存在していると、それらはshared XOR mutableを満たさないコンフリクトと見なされる
- e.g.
- 参照を持たず内部の値のみを変更させるもの
- e.g.
std::cell::Cell
,std::sync::atomic
, ... - これらの方にはそもそも参照を得る関数が定義されていない[2]
- その代わり、内部の値を直接読み書きするメソッドが提供されている
- e.g.
-
Rustでのshared XOR mutableを説明している例: https://speakerdeck.com/qnighy/rust-imperative-language-2-dot-0?slide=35 ↩︎
ライフタイム
Rustの変数のライフタイムはしばしば「スコープと対応している」と説明される。大抵の場合はこの理解で十分だが、正確には
のことであり、それは必ずしもスコープと一致している必要はない(大抵の場合は一致するが)。
例1. コード上で連続していなくてもライフタイムが続く場合
let mut x = Box::new(42);
let r = &x; // ① ライフタイム 'a の始まり
if rand() > 0.5 {
*x = 84; // ② 可変操作
} else {
println!("{}", r); // ③ ライフタイム 'a を持つ参照の使用
}
// ④: ライフタイム 'a の終わり
上記コードで、変数x
への参照であるr
のライフタイムを考える。注目すべきはif-else節で、③で参照r
にアクセスしているにも関わらず②の時点で&mut x
の可変性を要求する代入操作をしている。
これは一見、不変参照がスコープに存在する状態で可変操作を行っているため借用チェッカに怒られそうだが、実際は無事コンパイルが成功する。なぜならコンパイラの静的解析によってif
とelse
の中身は独立である(i.e. ③の行が実行されるときに②が実行されることはない)ことが分かっているので、参照のデータフローはコンフリクトしていないからである。
試しにこの状態で④の位置やif節の②の後にr
を使用するようなコードを追加すると、参照ライフタイムのコンフリクトが発生しコンパイルが通らなくなる。
例2. ループで穴が空いていてもライフタイムが続く場合
let mut x = Box::new(42);
let mut z = &x; // ① 'a
for i in 0..100 {
println!("{}", z); // ② 'a
x = Box::new(i); // ③
z = &x; // ④
}
println!("{}", z); // ⑤ 'a
今度は上記コードで、変数x
の参照z
のライフタイムを考える。forループの中の②までは参照z
のライフタイムは有効だが、次の③で参照先のx
が上書き(これがシャドーイング?)されることでz
のライフタイムはここで途切れることとなる。続く残りのforループ内の④で初期化されている参照z
はもはや単に同じ名前なだけの全く別の変数である。
ここでは毎ループの③、④で参照z
のライフタイム'a
は一旦途切れはする(=穴が空いている)ものの、最後の⑤でアクセスされるまでライフタイム上でのコンフリクトは発生していない。よってこのコードもコンパイルが成功する。
以上の例1, 2で見てきたように、複雑なコードでライフタイムを読み解くときにはライフタイム = スコープと覚えるよりも データフローでコンフリクトが起きていないか という考え方をすると良さそう。