読書メモ: プログラミング言語 Rust 公式ガイド

Takami Torao Rust 1.38 #Rust
  • このエントリーをはてなブックマークに追加

The Rust Programming Language (日本語版) の邦訳版で ASCII DWANGO から出版されている「プログラミング言語 Rust 公式ガイド」の読書メモ。レベルとしては1つ2つの汎用プログラミング言語を使いこなすことができるプログラマ向けの入門書で、特に C/C++ と関数型言語を使える人ならすんなり読める内容だろう。

  1. 第一章 事始め
  2. 第二章 数当てゲームをプログラムする
  3. 第三章 一般的なプログラミングの概念
  4. 第四章 所有権を理解する
  5. 第五章 構造体を使用して関係のあるデータを構造化する
  6. 第六章 enum とパターンマッチング
  7. 第七章 モジュールを使用してコードを体系化し、再利用する
  8. 第八章 一般的なコレクション
    1. ベクター操作
    2. 文字列操作
    3. マップ操作
  9. 第九章 エラー処理 [under construction]
    1. panic! で回復不能なエラー
    2. Result で回復可能なエラー
  10. 第十章 ジェネリック型、トレイト、ライフタイム
  11. 第十一章 自動テストを書く
  12. 第十二章 入出力プロジェクト: コマンドラインプログラムを構築する
  13. 第十三章 関数型言語の機能: イテレーターとクロージャー
  14. 第十四章 Cargo と crates.io についてより詳しく
  15. 第十五章 スマートポインター
  16. 第十六章 恐れるな! 並行性
  17. 第十七章 Rust のオブジェクト指向プログラミング機能
  18. 第十八章 パターンとマッチング
  19. 第十九章 高度な機能
  20. 第二十章 最後のプロジェクト: マルチスレッドの Web サーバを構築する
  21. 参考文献

本書に書かれている言語構造や言語機能を手元で試すために Rust 版 REPL をインストールしておくと利便性が良い。

$ cargo install evcxr_repl
    Updating crates.io index
  Downloaded evcxr_repl v0.4.5
  ...

$ evcxr
Welcome to evcxr. For help, type :help
>> println!("hello, world")
hello, world
()
>>

第一章 事始め

Rust のインストールと hello, world プログラムのコンパイル、cargo を使用したビルド方法などの紹介。

  • Rust の安定性保証によりこの本に掲載されているサンプルコードは新しい Rust のバージョンになってもコンパイルでき続けることが保証されている。
  • Windows で Rust 開発コマンドを使用するには Visual Studio 2013 以降の C++ ビルドツールが必要。手っ取り早い方法は Visual Studio 2019 Community をインストールしてしまうこと。VS2019 のインストールが完了し再起動を行ったら Install Rust から rustup-init.exe をダウンロードして実行する。

    それ以外にも (Windows バイナリにはならないが) Windows Subsystem for Linux を使用して Linux と同じ方法でコンパイル環境を構築することができる。

  • rustup update で最新版へ更新できる。Rust と rustup のアンインストールは rustup self uninstall で行う。rustc --version, cargo --version でそれぞれバージョンが表示される。
  • hello, world プログラムのコンパイルと実行。
    > type hello_world.rs
    fn main(){
      println!("hello, world");
    }
    
    > rustc hello_world.rs
    
    > hello_world.exe
    hello, world 
  • Cargo は Rust のビルドシステム兼パッケージマネージャ。Scala の sbt や Ruby の gem に相当。プログラムが依存するライブラリを Cargo.toml に記述しておくと Cargo が暗黙的にダウンロードし参照できる状態で実行することができる。cargo new [project] で新しいプロジェクトディレクトリと Cargo.toml が生成される。
  • Cargo コマンドは build でビルド、run で実行。check はバイナリ生成の処理時間を省略してコンパイル可能かのみをチェックする。--release オプションを追加するとリリースビルドになる。

第二章 数当てゲームをプログラムする

標準入力から入力した数値で乱数を当てるゲームを使って依存ライブラリの追加方法や extern crate, use による名前解決、標準入出力の使い方、変数・関数の書き方などを一通り解説している。

  • (今のバージョンでは分からないが) 第1版の翻訳時にはソースコードのコメント以外の部分に日本語の文字を使用するとコンパイルに失敗することがあったとのこと。JDK 0.9 時代を思い出す。なのでサンプルコードで使用するメッセージなどの文字列リテラルは英語のままにしているとのこと。
  • prelude に定義されているいくつかの型だけは use を使用しなくても使うことができる。
  • let のみは不変の変数であるためスコープ中に値を書き換えることができない。可変の変数を使用したい場合は let mut を使用する。プログラムの安全性の観点からなるべく不変の変数を使用することが勧められるが、巨大なデータ構造の一部のみを書き換えるような場合はコピーを生成するより可変にして直接書き換えたほうがパフォーマンスが良い。
  • シャドーイング (shadowing) を使用して不変の変数を let で再定義することができる。これは内部的には同名の別の変数に代入していることと同じ。したがって型が変わっても問題ない。
  • 失敗する可能性のある関数などの結果には一般的に Result を返す設計を行う。これは Scala などの Either と同じ。OkErr の enum 列挙であり、Ok は成功したことを表し Err はその失敗理由などを保持している (ということは Err は個別の状態を持つことになり列挙値ではなく型ではないか?)。io::Result といった Result を派生させたライブラリ固有の定義も多く存在する (固有のエラー情報を追加するため?)
  • 標準の println!("x := {}", x){} でプレースホルダーを使用できる。
  • Cargo.toml に記述する [dependencies] のバージョン指定は「そのバージョンの互換性のある任意のバージョン」という意味を持つ。つまり "0.3.14" と指定したとき、実際には "0.3.21" が使われるかもしれないが "0.4.2" が使われることはない。Specifying dependencies from crates.io によればデフォルトのこの動作は "^0.3.14" と指定したのと等価。"~0.3.14" と指定すると "0.4.2" が使われる可能性がある。ワイルドカードとして "0.3.*" とすることもできる。
  • Cargo.lock は実際にその環境で使用している特定のバージョンを保存している。ほかの人の端末で上位バージョンが動作しなかった場合の調査に使用することができる。cargo update で最新版に更新可能。
  • 外部クレートはソース内で extern crate lib_name; とすると use のトップレベルで使用することができる。
  • 文字列の数値化は対象の整数型が推論できないとコンパイルエラーとなるため、型指定した変数に代入 let x = t.parse(); するか、t.parse::<u32>() のように型を明示したメソッドを呼び出す。
  • パターンマッチ match A { X => ... Y => ... }loop-break/continue 構文。

第三章 一般的なプログラミングの概念

Rust の言語機能に関するさわりを説明している。

  • const キーワードにより定数を宣言することができる。このとき型は必ず指定しなければならない。定数はどのようなスコープでも定義することができる。定数式しか設定することができない。つまり関数の呼び出し結果や実行時に評価される値は設定できない。命名規則はすべて大文字でアンダースコアによる単語区切り。
  • let で定義した変数であってもシャドーイングで再定義することができる。シャドーイングは型が変わっても良いため中間結果を一時保存するようなケースでも使える。例えば:
    let spaces = "     ";
    let spaces = spaces.len();
  • 整数型の中でも isize, usize はターゲットプラットフォームのポインタサイズを示す。ターゲットプラットフォームとはコンパイル環境か、コンパイル時に指定した実行プラットフォームの環境か、それとも実行環境ごとに 32/64bit 差異を同一バイナリで吸収してくれるものなのかは不明。
  • 整数型、浮動小数点型、論理値型、文字型が利用できる。複合型にタプル型、配列型が利用できる。配列は常に固定長の要素を持ちヒープではなくスタックに保存される。可変長を扱いたい場合はベクター型を使う。配列のインデックス外参照は実行時エラーを起こす。
  • 関数において引数の型宣言は必須。
  • 関数名で early return する場合は return を使用するが、一般的に関数の最後は return を付けず評価結果を返値とする。このために最後の式にはセミコロンを記述しない (セミコロンをつけると () を返すことになる)。
  • ブロック {...}if は式であり値を返すことができる。
    >> let x = 100;
    >> let y = if x > 100 { 101 } else { 99 };
    >> println!("x={}, y={}", x, y);
    x=100, y=99
    繰り返し命令 loop, while, for-in は通常の言語と同様。結果は Unit となる。
    >> let mut i = 0;
    >> loop { if i > 2 { break; } println!("i={}", i); i += 1 }
    i=0
    i=1
    i=2
    ()
    >> let mut i = 0;
    >> loop { if i > 2 { break; } println!("i={}", i); i += 1 }
    i=0
    i=1
    i=2
    ()
    >> let mut i = 0;
    >> while i <= 2 { println!("i={}", i); i += 1 }
    ()
    i=0
    i=1
    i=2
    >> for i in 0..3 { println!("i={}", i); }
    i=0
    i=1
    i=2
    () 

第四章 所有権を理解する

  • 所有権の規則
    • すべての値は「所有者」となる変数に対応している。
    • すべてのタイミングで所有者は 1 人。
    • 所有者の変数がスコープから外れたら値は破棄される。
  • Rust の文字列は文字列リテラル (&str; 文字配列のスライス) と String 型の 2 種類を使うことができる。String は可変宣言で Java の StringBuilder のように扱うことができる。
  • 実体の所有者がスコープを外れ値が破棄される前に drop() 関数が呼ばれる。これは C++ のデストラクタや Java のファイナライザのような言語機能。
  • スタックに配置された単純な整数などは代入によって新しい変数に値がコピーされる。構造体の代入は shallow copy され所有権が新しい変数に移り以前の変数は無効となる (ムーブ)。所有者が必ず 1 人となることで二重解放が発生しなくなる。
  • ディープコピーは clone() で提供されている。
  • Copy トレイトに適合していれば値のコピーの挙動となり、代入後も以前の変数を使い続けることができる。単純なスカラー値は Copy に適合している。構造体のようなメモリ確保が必要なものは Copy にはなっていない。
  • 関数の引数渡しでも代入と同様に所有権が移る。呼び出し元に所有権を維持したままにしたい場合は参照で渡すことができる。
  • 参照が生きている間はその実体の所有者も生きていなければならない。したがって関数内で作成した実態を参照として返すことはできない。この場合は実体のムーブとして返すことができる。
  • &mut とすると可変な参照となる。不変な参照はいくらでも作ることができるが、可変な参照はあるタイミングで一つしか作ることができない。これは不変を前提に参照している値が変化しないようにする措置。
  • ダングリングとは既に所有権が移り、別の所有者によって drop されてしまったポインタを持ち続けていること。Rust はダングリングが発生しないことを保証する。
  • スライスは実体そのものへの参照ではないが同等の参照規則が適用される。例えばスライスの不変参照のスコープ中に可変操作を行おうとするとコンパイルエラーが発生する。
  • 文字列リテラル &str はシステム的には文字配列のスライスの不変参照。文字列プールを参照しているような概念?
  • マルチバイト文字の中間をスライスで分離しようとすると落ちる(?)らしい。

構造体 struct とメソッド impl に関する使い方の簡単な説明。

  • 構造体は JSON の Object と似た書式で定義する。構築時にフィールドの初期化を行う。フィールド定義の末尾のコンマはあってもなくてもよい。
    >> struct Product{ name:String, price:u32 }
    >> let p = Product{ name:String::from("pencil"), price:130 };
    immutable の手段があるとはいえフィールドは常に丸見えか? 構築を隠ぺいするような方法はないのか?
  • フィールド名とローカル変数名が同じときにフィールド初期化の省略表記を使うことができる。
    fn f(name:String, price:u32) -> Product {
      Product { name, price }
    }
    ローカル変数名に依存した挙動は違和感あり。コンストラクタ的な関数を用意するときに多少楽かもしれないが。
  • あるインスタンスの一部のフィールドだけを書き換えた別のインスタンスを作成することができる。Scala での case class の copy() に相当。
    >> let p1 = Product{ name: String::from("pencil"), price: 130 };
    >> let p2 = Product{ name: String::from("eraser"), ..p1 };
  • 構造体のフィールド名はなくてもよい。この場合、名前付きのタプルのようになる。
    >> struct XY(f32, f32);
    >> let o = XY(0.0, 0.0);
  • トレイトの実装だけに使用するため、フィールドを持たない構造体を定義することができる。ユニット構造体と呼ばれる。
  • Debug トレイトを継承すると println!()"{:?}", "{:#?}" プレースホルダーがすべてのフィールドの出力に成形される。
    >> #[derive(Debug)] struct Product { name:String, price:u32 }
    >> let p = Product{ name:String::from("pencil"), price:130 };
    >> println!("product={:?}", p)
    product=Product { name: "pencil", price: 130 }:
    >> println!("product={:#?}", p);
    product=Product {
        name: "pencil",
        price: 130,
    }
  • 構造体に対するメソッドは impl [struct-name]{ ... } で定義する。インスタンスメソッドの第一引数は &self とし型は自明であるため省略できる。メソッド内で特定のフィールドを更新する必要がある場合は &mut self とする。
    >> #[derive(Debug)] struct Product { name:String, price:u32 }
    >> let p = Product{ name:String::from("pencil"), price:130 };
    >> impl Product { fn priceIncludesTax(&self) -> u32 { (self.price as f64 * 1.1) as u32 } }
    >> p.priceIncludesTax()
    143
    
    >> impl Product { fn raise(&mut self, diff:u32){ self.price += diff; } }
    >> let mut q = Product { name: String::from("eraser"), price: 150 };
    >> q.raise(20);
    >> q.price
    170
  • C/C++ でのメンバーアクセスは変数が実体かポインタかで .-> を分けていたが Rust では参照の場合は暗黙的に (*foo).bar() と等価と解釈される。
  • インスタンスを変数に取らないメソッドは関連関数と呼ばれる。C++ や Java における静的メソッドと等価。
    >> impl Product{ fn tax(price:u32) -> u32 { (price as f64 * 0.1) as u32 } }
    >> Product::tax(100)
    10
  • 1 つの構造体に複数の impl を定義することができる。Go や Julia と同じく構造的部分型の言語設計。

第六章 enum とパターンマッチング

  • Rust の enum は単なる定数値の列挙だけではなく代数データ型を定義するために使用することができる。列挙子として (定数のように使用できる) ユニット様構造体、タプル構造体、通常の構造体を使用することができる。
    #[derive(Debug)]
    enum Json {
      Null,
      String(String),
      Int(i32),
      Boolean(bool),
      Array(Vec<Json>),
      Object(Vec<(String,Json)>),
    }
    
    >> let s = Json::String(String::from("hello, world"));
    >> let i = Json::Int(1024);
    >> let arr = Json::Array(vec![Json::String(String::from("A")), Json::Int(256), Json::Null]);
    >> let obj = Json::Object(vec![(String::from("str"), Json::String(String::from("B"))), (String::from("int"), Json::Int(64))]);
    >> println!("s={:?}, i={:?}, arr={:?}, obj={:?}", s, i, arr, obj);
    s=String("hello, world"), i=Int(1024), arr=Array([String("A"), Int(256), Null]), obj=Object([("str", String("B")), ("int", Int(64))])
    #[derive(Debug)]
    enum Tree<T> {
      Leaf(T),
      Node{ left:Box<Tree<T>>, right:Box<Tree<T>> }
    }
  • 代数データ型を使用した Option<T>, Some(T), None がデフォルトで用意されている。目的や使い方は Scala や Java のそれと同じ。
  • 代数データ型のパターンマッチは Scala と同様に extractor としても機能する。包括的マッチが強制される。「その他」を表すパターン _ が使用できる。
    >> match Some(String::from("foo")) {
      Some(msg) => println!("msg={}", msg),
      None => println!("None")
    }
    msg=foo
    extractor で得られるのは参照? ライフタイムはどうなってる?
  • if let を用いてパターンマッチ代入を行うことができる。Scala では非包括的マッチでも記述できたが Rust ではコンパイルエラーとなるため if let を使う必要がある。
    >> if let Some(bar) = Some(String::from("foo")) { println!("{}", bar) }
    foo

第七章 モジュールを使用してコードを体系化し、再利用する

  • モジュールは関数や型定義を含む名前空間のこと。
    • mod キーワードでモジュールを作成する。
    • モジュールやそれに含まれる関数、型、定数は非公開。pub キーワードを使用して何を公開するかを選ぶことができる。
    • 名前空間を解決するために use キーワードを使用することができる。
  • クレートにはバイナリクレートライブラリクレートがあり、それぞれ src/ 下の main.rslib.rs のどちらをエントリポイントとして参照するかで異なる(cargo new で新規クレートを作成するときに --lib を指定するとライブラリクレートが作成される)。混在は可能だがバイナリクレートは extern crate を使用してライブラリクレートを参照しなければならない。ライブラリクレートのビルドは cargo build を使用する。

    $ cargo new foo --lib
    $ cat foo/src/lib.rs
    #[cfg(test)]
    mod tests {
        #[test]
        fn it_works() {
            assert_eq!(2 + 2, 4);
        }
    }

    バイナリクレートにモジュールは作成できるのか?

  • ライブラリの実装は lib.rs にすべてのコードを書くことができるが通常は mod を使ってモジュール分けをする。mod foo; と実体のないモジュールを定義したとき、コンパイラはサブモジュールの実装が foo.rsfoo/mod.rs に存在するものとして参照する。

    $ cat src/lib.rs
    pub mod a;
    pub mod b;
    
    $ cat src/a.rs
    pub const PI:f64 = 3.14159265359;
    pub fn circle_area(radius:f64) -> f64 {
        radius * radius * PI
    }
    
    $ cat src/b/mod.rs
    pub enum Shape {
        Point,
        Line,
        Circle,
        Rectangle,
    }
    
    pub mod c;
    
    $ cat src/b/c.rs
    pub fn rect_area(width:f64, height:f64) -> f64 {
        width * height
    }

    この構成だとサブモジュールに含まれるコードは一つのファイルに記述可能な範囲であることが望まれ、それより大きい場合は非公開のサブモジュールに分割して関数だけを公開するような構造を強いるだろう。本当?

  • 公開性の規則。
    • 要素が公開されている場合はどのモジュールからも利用が可能。
    • 要素が非公開の場合は直属の親か要素の兄弟からのみ利用が可能。
  • use は名前空間の解決に使用する。モジュールだけではなく enum にも使用できる。
  • トップレベルから絶対位置指定で名前空間を指定する場合は ::foo::bar のように記述する。相対位置指定で親を指定する場合は super::foo:bar のように記述する。名前空間内のすべてを参照する場合は foo::* のように記述が可能で *glab 演算子 と呼ぶ。

第八章 一般的なコレクション

ベクター操作

  • Rust ではジェネリクスを利用できる。可変長配列の役割となるベクター型 let Vec<i32> = Vec::new(); は要素となる値の型を指定する。ただし初期値を指定する場合は型推論が機能するため let v = vec!{1, 2, 3}; のように省略可能。
  • ベクターのようなコレクションがドロップされるタイミングでその要素もドロップされる (これは要素の所有権をコレクションが持っていることを暗に意味している)。コレクションの内容を変更するには mut 宣言をする必要がある。
  • ベクターの要素はインデックスで &v[i] と直接参照するか、Option を返す v.get(i) かが使用できる。直接参照で範囲外のインデックスを指定するとパニックを起こす。
  • ベクターから要素の列挙は for in で行う。要素の状態を変更する必要がある場合は for x in &mut v { ... } のように記述する。
  • 総称型に enum を使用することができる。この効果は Scala で sealed trait とそのサブクラスである case class, case object を使って設計するのと同じ。

文字列操作

  • 文字列は言語として str 型 (通常は借用の &str で使われる) と標準ライブラリで提供される Sring 型がある。
  • Rust の標準文字列は UTF-8 で扱うがシステム境界用の OSStringCString も用意されていてその限りではない。
  • + 演算子を用いて文字列の連結を行うことができる。ただし先頭以外は借用で渡す必要があり、連結後は先頭がムーブされる。
    >> let a = String::from("A");
    >> let b = String::from("B");
    >> let c = String::from("C");
    >> let abc = a + &b + &c;
    >> println!("{}", abc);
    ABC
    >> println!("{}", a);
                      ^
    cannot find value `a` in this scope
    help: a local variable with a similar name exists
    
    >> let a = String::from("A");
    >> let abc = a + &b + "C";
    >> println!("{}", abc);
    ABC
    または format!() を使用すればムーブや借用を意識する必要はない。
    >> let a = String::from("A");
    >> let abc = format!("{}{}{}", a, b, c);
    >> println!("{}", abc);
    ABC
  • String 型は単なる Vec<u8> のラッパー。従ってその len() は UTF-8 バイト数となり、マルチバイト文字に対しては文字数を意味しない。また [] を使った添え字アクセスもコンパイルエラーとなる。
    >> "ABC".len()
    3
    >> "あいう".len()
    9
    >> "あいう"[2]
       ^^^^^^^^ string indices are ranges of `usize`
    the type `str` cannot be indexed by `{integer}`
    help: the trait `std::slice::SliceIndex<str>` is not implemented for `{integer}`
    配列やスライスのインデックス参照は \(O(1)\) が期待されるが、文字列の場合は先頭からすべてを走査しなければならないため \(O(n)\) となる。
  • chars() で文字列挙、bytes() でバイト列挙を取得できる。
    >> "あいう".chars()
    Chars(['あ', 'い', 'う'])

マップ操作

  • デフォルトでは使用できないので use std::collection::HashMap; 宣言する必要がある。
    let mut m = HashMap::new();
    m.insert(String::from("A"), 10);
    println!("{:#?}", m);   // {"A": 10}
    またタプルの列挙に対して collect() で集約することでも生成することができる。
    >> (0..5).map(|x| (x.to_string(), x)).collect::<HashMap<String,i32>>();
    {"3": 3, "1": 1, "0": 0, "4": 4, "2": 2}
  • マップにキーや値を挿入したときの所有権の移動は代入操作と同じ。Copy トレイトを実装していれば値がコピーされるが、そうでなければムーブとなり所有権が移動する。
    >> let mut m:HashMap<String,i32> = HashMap::new();
    >> let a = String::from("A");
    >> m.insert(a, 1);
    >> a
       ^
    cannot find value `a` in this scope
    help: a local variable with a similar name exists
    >> m.insert(String::from("B"), 2);
    >> m.insert(String::from("C"), 3);
    >> m
    {"A": 1, "B": 2, "C": 3}
    
    >> m.get(&String::from("A"))
    Some(1)
    >> m.get(&String::from("X"))
    None
    
    >> m.entry(String::from("X")).or_insert(100);
    >> m
    {"A": 1, "B": 2, "C": 3, "X": 100}
  • デフォルトの HashMap は暗号論的ハッシュ関数を使用している。このハッシュアルゴリズムは BuildHasher トレイトを実装すれば変更することができる。

第九章 エラー処理

Rust のエラーの考え方は 1) 問題をユーザに報告し処理を再実行することのできる回復可能エラーと 2) バグの兆候として表れる回復不能エラーの 2 種類が存在する。Rust には例外が存在せず、回復可能なエラーを報告する Result と回復不能で処理を中止する panic! マクロを使用する。

panic! で回復不能なエラー

  • panic! マクロはスタックを巻き戻してプログラムを終了する。「スタックを巻き戻す」とは想定されるすべての drop() を実行して必要な終了手順とリソース開放を行った上でという意味? またマルチスレッド環境で panic! を実行するとどうなる?
    1. 単一のスレッドだけが中断されスレッドが巻き戻される。
    2. 全てのスレッドが中断されスレッドが巻き戻される。
    Cargo.tomlpanic = abort を設定すると panic! 時にスタックの巻き戻しは行わず即時異常終了するようになる。この場合、実行バイナリのサイズが少し小さくなる。リリースビルドのみ異常終了を有効にするには以下のように記述する:
    [profile.release]
    panic = 'abort'
  • RUST_BACKTRACE=1 cargo run のように実行時に環境変数 RUST_BACKTRACE を設定すると panic のバックトレースが出力される。ただし --release 付きでビルドすると表示のためのデバッグシンボルは削除される。

Result で回復可能なエラー

  • Either モナド。

第十章 ジェネリック型、トレイト、ライフタイム

第十一章 自動テストを書く

第十二章 入出力プロジェクト: コマンドラインプログラムを構築する

第十三章 関数型言語の機能: イテレーターとクロージャー

第十四章 Cargo と crates.io についてより詳しく

第十五章 スマートポインター

第十六章 恐れるな! 並行性

第十七章 Rust のオブジェクト指向プログラミング機能

第十八章 パターンとマッチング

第十九章 高度な機能

第二十章 最後のプロジェクト: マルチスレッドの Web サーバを構築する

参考文献

  1. Steve Klabnik and Carol Nichols (2018) The Rust Programming Language (Covers Rust 2018)