Open8

読書メモ『Effective Rust』

ose20ose20

このスクラップについて

『Effective Rust』の読書メモ。本の内容をまとめたり再構成したり、感想をまとめたりする。

ose20ose20

前書き

  • この本には「~してはいけない」という項目がない
    • なぜなら本当にしてはいけないならコンパイラがエラーにするべきだから
    • これは非常に重要な視点
      • 現実的にはRustにはそういう面(そう書いちゃいけない)はあるだろうけど、かなり少ないはず
      • 人間はそういう言語を使うべき
        • プラクティスで回避しないといけないものは、実際の現場では回避されない
          • みんながみんなプログラミングが好きじゃない、勉強する時間もない
          • 常にコスパを求められ時間に追われる労働者は、長期的な視点に立った学習への投資はされにくい
          • だからコンパイラ、言語仕様でいい書き方を強制すべきである
            • そしてそれは実行前に行われるべきである
              • フィードバックループを高速に回すため
              • なので表現力があり厳格な型システムおよびその検査器がいる
ose20ose20

1章 型

項目1: データ構造を表現するために型システムを用いよう

  • enum型を特に推している
    • enum型は自分でデータの型を定義できる
      • とり得る値の全てを列挙することでMECEによる分析ができる、それを使った場合分けなどで認知負荷が減る
        • match式によるパターンマッチではコンパイラが網羅性検査をする
          • 代数的データ型があるなら当然その機能も欲しい
      • 明示的に名前をつけることで他の型を区別することができ、間違って別の値を入れることができなくなる
        • コンパイルエラーになるため
        • newtypeパターンと一部効能が被る
          • newtypeパターンは既存の型でラップするので、構造が同じ(例えば2値しかないならboolをラップするなど)既存の型がある場合はnewtypeパターンを使えばいい
  • 代数的データ型には不変条件を埋め込むことができる
    • 例えば、Rustでは上の例を下の例にすることでプログラムの普遍条件をプログラムの中にエンコードすることができる
      • 上の例は、DisplayProps として不適なものまで DisplayProps 型の値として扱うことができてしまう
        • プログラマは、DisplayProps という型の値が来た時に、常にそれが valid なものなのかどうかについて認知を割かないといけない
          • テストもその分大変だろう
            • 人が書いたコードならなおさら
      • 下の例は、代数的データ型を活用することによって、不適な値はそもそも作れないようになっている
        • プログラマは、不適な値がまざっていることを一切考えなくて良くなる
pub struct DisplayProps {
  pub x: u32,
  pub y: u32,
  pub monochrome: bool,
  // `fg_color` must be (0, 0, 0) if `monochrome` is true. `monochrome`が真なら`fg_color`は(0, 0, 0)でなければならない
  pub fg_color: RgbColor,
}
# x, y は共通してもっていて、モノクロかそうでないかで構造が変わる
pub struct DisplayProps {
  pub x: u32,
  pub y: u32,
  pub color: Color,
}

# モノクロかそうでないかという排他的な状態は enum で表現できる
pub enum Color {
  Monochrome,
  Foreground(RgbColor),
}

struct RgbColor {
  pub r: u8,
  pub g: u8,
  pub b: u8,
}

  • Option<T>Result<T, E>など、Rustのコアの型ではこのenumの恩恵をふんだんに受けている

項目2: 型システムを用いて共通の挙動を表現しよう

  • マーカートレイトは、静的検査がほぼ効かないトレイト
    • StableSortというトレイトで安定ソートの振る舞いを定義したいとする
      • それが安定ソートであるということを型システムで検証するのはかなり高度でより複雑な型システムがいる
      • 型システムによる検証はあきらめけるけれど、モジュールの提供者と利用者間の約束事としてそれを実装することにした
        • 提供者はこれが安定ソートでもあるということをマーカートレイト(ここではStableSort)を実装することで宣言する
          • この正しさをコンパイラが検証することはできない
          • 利用者は、提供者のその宣言を信じてStableSortを実装しているトレイトを利用する

項目3: OptionとResultに対してはmatchを用いずに変換しよう

  • OptionResult間(型パラメータが違うもの同士も含めて)の変換は大体メソッドが提供されているので探してみるといい
  • as_ref()メソッドのように、&Container<T>Container<&T>を行き来する処理も典型なのでメソッドがすでに提供されている可能性が高い、使いこなそう

項目4: 標準のError型を使おう

  • DebugとDisplayを実装すればErrorになれる
  • ?は対象のエラー型(Result<T,E>E)に自動で変換を試みるので、Fromトレイトを実装しておくとなお良い
  • anyhowthiserrorがなぜ便利なのか、どんな問題を解決してるのか
  • アプリケーションならanyhowでいい
    • 利用される環境が予測できないライブラリではenumを用いてネストしたエラーを定義した方がいい
      • 情報をたくさんもちたいため

項目5: 型変換を理解しよう

  • 3種類ある
    • FromIntoトレイトによるもの
    • asを用いた明示的なキャスト
    • 暗黙に行われる自動型変換(coercion)
  • Fromを実装していれば自動的にIntoも導出されるようになっているが、逆はない
  • asではなくできるだけfrom/intoをつかいたい
    • 前者は後者ではできない型変換も許容するが、これはよくないと筆者は判断している
    • Clippyに強制されるのもあり
  • asによる明示キャストは自動型変換の真のスーパーセットになっている
    • 自動型変換
      • 可変参照から不変参照
      • 参照から生ポインタ
      • 変数をキャプチャしていないクロージャから関数ポインタ
      • 配列からスライス
      • ある型からその型が実装しているトレイトのトレイトオブジェクト
      • 生存期間が長いものから短いもの

項目6: newtypeパターンを活用しよう

  • 型エイリアスはただのドキュメンテーションでしかなく、コンパイラに区別させられない
  • SomeNewType(T)はほとんどTであるにもかかわらず、Tが実装しているトレイトを手動でSomeNewType(T)にも実装し直さないといけない

項目7: 複雑な型にはビルダを使おう

  • 想定する(ビルドしたい型)は、Option<T>じゃない必須のフィールドと、Option<T>の必須じゃないフィールドがあるやつ
    • HogeBuilder(T)という別の型を用意する
    • newメソッドでは必須のフィールドをもらう
    • あとはmut selfをもらってselfを返すメソッドでオプションフィールドを追加する
    • 最後に呼ぶ用のbuild(self) -> Tメソッドを用意する

項目8: 参照型とポインタ型に慣れよう

  • 参照は最もよく使われるポインタ
    • 安全に使うための制約がいくつもある
  • Derefは一つの実装につき1つの参照解決先(Target)をもたないといけない(自動でDerefするのでその変換先が一意に定まらないといけない)
    • AsRefAsMutは自動変換がないのでパラメタ化できる
  • ファットポインタ
    • スライスとトレイトオブジェクト
  • Borrowトレイト
    • 借用を一般化
    • &self -> T の変換をI/Fとして提供する
  • ToOwnedトレイト
    • Cloneの一般化

項目9: 明示的なループの代わりにイテレータ変換を使用することを検討しよう

  • 明示的なループの方がいい場合もある
    • ループボディが大きい場合など
      • ただこのばあいはいくつかの宣言的なI/Fに切り出した方がいいかも
ose20ose20

2章 トレイト

項目10: 標準トレイトに習熟しよう

  • Copyトレイトはマーカートレイト
    • メモリのビット単位のコピーで新しいアイテムが作れることを意味する
    • 代入の操作的な意味を根本的に変える
    • 実装しない方がいい/してはいけない場合の例
      • 型が大きい場合
        • copyは高速に行われることを暗黙に期待されている

項目11: RAIIパターンにはDropトレイトを実装しよう

  • Resource Acquisition Is Initializationという名前から初期化時に意識がいきそうだけど、ミスリードだと思う
    • ある値の生存期間と、それに紐づくデータ、資源の生存期間を一致させるというのが本質
    • つまりある値がスコープから外れる時に、それに紐づく資源も一緒に解放するようにしよう
      • ロックとか最たる例
      • だからDropトレイトが必要になってる来るんですね
      • 他の例
        • OSの資源にアクセスするデータ
          • ファイルディスクリプタdc

項目12: ジェネリクスとトレイトオブジェクトのトレードオフを理解しよう

項目13: デフォルト実装を用いて、実装しなければならないトレイトメソッドを最小限にしよう

ose20ose20

3章 さまざまなコンセプト

項目14: 生存期間を理解しよう

  • プログラミング言語理論ではスコープには馴染みが深いので、概念自体は全然抵抗がない
    • 自分はこのライフタイムありの体系を研究していたこともある(修士)
  • ヒープとスタック
    • スタック
      • 現在実行中の関数に関する状態を保持するため
        • 関数の引数
        • 関数内のローカル変数
        • 関数内で一時的に計算された値
        • 関数の呼び出し元コード内のリターンすべきアドレス
      • スタックの値は短命で、こいつがもういないのにポインタは残ってるみたいなのがよくおこる問題
        • C言語はローカル変数のポインタを返してしまうことは普通にできる
        • このスタックとヒープの部分は抽象化の難しいところ
          • 微妙に隠してるんだけど、ちゃんと使うためにはしらないといけない
  • 一時変数のスコープの話で出てきた以下の例
    • return_refへの引数を& Itemにするとコンパイルできるのなんでだろう
      • mutかどうかに関わらず一時変数はつくられるよね
      • 原則通りだとどっちもアウトになるはずだけど、mutつけない方はコンパイラがアドホックにライフタイムの延長をしていそう
struct Item {
    content: u32,
}

fn return_ref(item: &Item) -> &Item {
    item
}

fn main() {
    let r: &Item = return_ref(&mut Item { content: 42 });
    println!("Item content: {}", r.content);
}
  • 'staticライフタイム

    • プログラム実行中は常に同じアドレスに存在し、移動しないことを保証する
    • const'staticライフタイムに昇格できるが、注意点が2つ
      1. Dropを実装しているとできない
      2. 常に変わらないことが保証されるのは「値」だけ
      • コンパイラはconst値を使う場所ごとにコピーを作れる
        • 複数のアドレスにコピーが作られる
  • ヒープ上に確保され、解放されない値も'staticの定義を満たす

    • Box<T>は違う
      • ドロップされないことを保証できないから
      • ただしBox::leakを使うとできる
        • ああ、型もhoge -> &'static mut Tなんだろうね
        • Box<T>Tへの可変参照に変換するもの
        • 誰も所有者がいなくなるので解放もされない
  • ヒープに関して

    • 全てのアイテムには所有者がいるというのがポイント
      • その所有者のライフタイムと一致する
    • 所有者がヒープにいる場合もある
      • 連鎖を辿っていけばスタックに帰着される
  • よくでてくる<'_>について

    • これはユーザーの定義した型が、ライフタイムに関連づけられていることを示す手軽な手段として使われている
    • Rustのシグネチャにはライフタイムを省略していい場合があるけど、ユーザー定義型からそれを外すとライフタイムの必要な参照を持っていることがわかりにくくなる

項目15: 借用チェッカを理解しよう

  • 参照の生存期間にはノンレキシカル生存期間機能がある
    • その変数が定義されたブロックじゃなくて、最後に使用した時点までに縮められる
  • 相互接続されたデータ構造にはスマートポインタを積極的に使おう
    • RcRefCellと組み合わせることが多い
      RefCellは可変参照なしで内部状態を変更できるが、借用チェックを実行時に後退させてしまう
    • Mutexはマルチスレッド環境での内部可変性を実現する

項目16: unsafeコードを書かないようにしよう

  • unsafeが必要な場合、それを実現している権威のあるクレートを使うのがいい
    • こういうスタンスだからか、unsafeについては全然説明してない

項目17: 状態共有並列実行には気を付けよう

項目18: Don't panic

項目19: リフレクションを避けよう

  • そもそもできないので守るのは簡単
  • 同じ依存ライブラリの互換性のない複数バージョンを1つのプログラムをロードしたい場合に、1つのライブラリは1つだけしかリンクできないという制約を回避するためにリフレクションを用いる場合がある
    • RustはCargoが同じライブラリの複数バージョンを扱えるので必要ない

項目20: 過剰な最適化の誘惑を退けよう

  • 最適化は必要になったら使えばいい
  • .clone()とかはコストを明示できるのがえらい
  • スマートポインタがんがんつかっていけ
ose20ose20

4章 依存ライブラリ

項目21: セマンティックバージョン(SemVer)を理解しよう

  • Cargoでは1.2.3と指定した場合、1.3.0も受け入れられる

項目22: 可視範囲を最小化しよう

項目23: ワイルドカードインポートを避けよう

項目24: APIに型が登場する依存ライブラリは再エクスポートしよう

  • Cargoが、依存グラフの中に、互換性のない複数バージョンの同一クレートを持てることによる発生する問題を回避するプラクティス
    • 以下のような依存経路を考える
      • src -> libA(ver 0.8)
      • src -> libB
        • libA(0.7)
    • このとき、libBの中でlibAをpub useすることで、
      • 「このライブラリ(libB)の中で使うlibA」をsrcから参照できるようになる
    • libBのI/FにlibAが露出する場合に有効

項目25: 依存グラフを管理しよう

  • 依存グラフのチェックをするツール
    • cargo-udeps
      • 使ってないものを検知してくれる
    • cargo-deny
      • 推移依存関係にあるさまざまな潜在的問題を検出する
        • 既知のセキュリティ問題
        • 受け入れ不能なライセンスで提供されているライブラリ

項目26: 忍び寄るフィーチャに注意しよう

  • まず、Rustコンパイラにはフィーチャーオプションによって制御できる条件付きコンパイルがある
    • Cargoのフィーチャー機能は、これをラップしてるだけ
    • フィーチャー機能は Additive だというのが罠になりうる
ose20ose20

5章 ツール

項目27: パブリックインターフェイスのドキュメントを書こう

  • こーどからわかることをドキュメントに書かない
    • 特にせっかくRustを使ってるんだから、セマンティクスを型として表現してコードに埋め込むべき

項目28: 分別をもってマクロを使おう

  • Rustのマクロは型、識別子、式などのプログラムの断片を抽象化する
  • 宣言的マクロと手続きマクロがある
  • 手続きマクロ
    • マクロの引数以外のソースコードをいじれる
    • 柔軟性高いかつコンパイル時に安全性を検証する
      • リフレクションに対する制限の緩緩的な側面も持つ
    • 3つの分類
      • 関数的マクロ
        • 引数を用いて呼び出す
        • 引数は1つ
          • 字句解析したトークン列の直感でいい
          • だから自分で構文解析をする必要がある
      • 属性マクロ
        • プログラムのなんらかの文法要素に付与
        • 関数全体を包むために用いられたりする
      • deriveマクロ
        • データ構造の定義に付与
        • 入力トークンを置き換えるのではなく、追加する
        • へるぱ属性を宣言することができる

項目29: Clippyに耳を傾けよう

項目30: ユニットテスト以上のものを書こう

型システムで表現されていることをドキュメントに「書かない」ようにアドバイスした。同様に、型システムで保証されていることをテストする必要はない。enum型の変数に、許可されているヴァリアントのリストにない値が入ることがあったら、ユニットテストに失敗するどころでない大問題だ。

  • その通りすぎる
    • 表現力の豊かな型システムを使えば普遍条件をエンコードできるのでテストが軽くなるんだよね
  • 結合テスト
    • tests/に置かれる
    • クレートの公開APIしか使えない

項目31: ツールのエコシステムを活用しよう

項目32: CIシステムを設定しよう

  • rust-toolchain.tomlを使ってCIビルドで用いるバージョンを固定しよう
ose20ose20

6章 標準Rustの向こうへ

項目33: ライブラリコードをno_std互換にすることを検討しよう

  • no_std互換にするための、クレートの利用方法などが書いてある
    • 他のクレートのソースコードを読んだ時にも遭遇するかもしれない

項目34: FFI境界を通過するものを制御しよう

  • Foreign Function Interfaceの名前に反して、連携できるのは関数だけじゃない
  • 他言語との相互運用のデフォルトのターゲットはC言語
  • FFIコードは自動的にunsafe
  • extern "C"
    • 関数の定義を外部のC言語のライブラリが提供することを示す
    • この中の関数はno_mangleとなる
  • Beginner's guide to Linkers

項目35: 手動でFFIマッピングを書かずにbindgenを用いよう

  • bindgen
    • 基本的な機能は、Cのヘッダファイルをパースして対応するRustの宣言を生成する