Open6

RustaceanのためのRustの書き方

ピン留めされたアイテム
えとあるえとある

このスクラップ is 何?

Jon Gjengset "Rust for Rustaceans: Idiomatic Programming for Experienced Developers", No Starch Press(2021) の個人的な読書メモ。ボクは出版社サイト(下記リンク)より購入したPDF版で読んでいます。

https://nostarch.com/rust-rustaceans

Table of Contents

このスクラップの見出し。個人的な関心事ごとにまとめているので、節立ては必ずしも原著と一致しないことをご留意ください。

Chap 1. Foundations

えとあるえとある

メモリ管理のメンタルモデル

Rustのメモリ管理は大別して2種類のメンタルモデルで考えることができる。

  • High-level model
    • 変数を単に「値に付けられた名前」として見なし、コード上でいつ初期化・ムーブ・消費されるかを考える
      • コード上で変数の使用箇所を辿る線のことを本書では「データフロー」と呼んでいる
    • → 所有権、ライフタイムの理解に有用
  • Low-level model
    • 変数を「値のスロット」と見なし、そのスロットがどうなっているか(値が入っているか、参照されているか、など)を考える
    • → unsafeコード、生ポインタの理解に有用

本書の内容を理解する上ではこれらhigh/low-levelどちらの考え方もできると複雑なコードを読み解くのに良いとのこと。今は「ふーんそうなんだ」くらいに思っておく。

えとあるえとある

可変参照

Q. ある変数が所有権を持っているときと可変参照であるときの違いは何?

A. 値をdropする責任があるかどうか。所有権を持っている状態ではスコープ離脱時に値をdropする必要があるが、可変参照では値をdropしない。

⚠️ 注意:可変参照がある状態で値をmoveするとき

サンプルコード:

Listing 1-7
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_84Box<i32>型の可変参照sを渡しているが、引数sの所有権は依然として関数呼び出し側スコープの変数sが持っているため、①のように関数内で参照外しをして所有権を関数内の変数にmoveすることはできない。

そこで関数内で可変参照先の値を取得する回避策として、ここではstd::mem::takeを使ってsの参照先をDefault値に詰め替えている。こうすることで、関数呼び出し側スコープがdropすべき値を変数sに持たせ続けたまま関数内で値の書き換えを実現している。

脚注
  1. https://doc.rust-lang.org/stable/std/mem/fn.take.html ↩︎

  2. https://doc.rust-lang.org/stable/std/mem/fn.swap.html ↩︎

えとあるえとある

内部可変性

Rustの型には内部可変性 (interior mutability) を提供しているものがあり、それらはざっくり2種類に分けられる。

  • 可変参照を通じて値を変更させるもの
    • e.g. Mutex, RefCell. ...
    • 裏ではUnsafeCellで実装されている
    • shared XOR mutable メカニズムに準拠する
      • 1つの可変参照と複数の共有された不変参照は同時には存在できない(排他的、XOR)という仕組み[1]
      • コード上である変数に対する1つの可変参照と他の不変参照/可変参照のデータフローが並行して存在していると、それらはshared XOR mutableを満たさないコンフリクトと見なされる
  • 参照を持たず内部の値のみを変更させるもの
    • e.g. std::cell::Cell, std::sync::atomic, ...
    • これらの方にはそもそも参照を得る関数が定義されていない[2]
    • その代わり、内部の値を直接読み書きするメソッドが提供されている
脚注
  1. Rustでのshared XOR mutableを説明している例: https://speakerdeck.com/qnighy/rust-imperative-language-2-dot-0?slide=35 ↩︎

  2. https://qiita.com/mosh/items/c7d20811df218bb3188e#cell ↩︎

えとあるえとある

ライフタイム

Rustの変数のライフタイムはしばしば「スコープと対応している」と説明される。大抵の場合はこの理解で十分だが、正確には

\bold{ライフタイム} \equiv ある参照が有効でなければならないコード上の領域

のことであり、それは必ずしもスコープと一致している必要はない(大抵の場合は一致するが)。

例1. コード上で連続していなくてもライフタイムが続く場合

Listing 1-8
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の可変性を要求する代入操作をしている。

これは一見、不変参照がスコープに存在する状態で可変操作を行っているため借用チェッカに怒られそうだが、実際は無事コンパイルが成功する。なぜならコンパイラの静的解析によってifelseの中身は独立である(i.e. ③の行が実行されるときに②が実行されることはない)ことが分かっているので、参照のデータフローはコンフリクトしていないからである。

試しにこの状態で④の位置やif節の②の後にrを使用するようなコードを追加すると、参照ライフタイムのコンフリクトが発生しコンパイルが通らなくなる。

例2. ループで穴が空いていてもライフタイムが続く場合

Listing 1-9
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で見てきたように、複雑なコードでライフタイムを読み解くときにはライフタイム = スコープと覚えるよりも データフローでコンフリクトが起きていないか という考え方をすると良さそう。