The Rust Programming Language 日本語版 を読んで補足を交えつつ書いたメモ
Rustのモチベーション
-
メモリ管理を以下のどちらでもないより良い方法で行いたい
- プログラマーが自前で書く like C, C++
- 大変
- ミスが頻発する
- GCで開放する like Java, Go etc
- Stop the Worldがある
- なぜDiscordはGoからRustへ移行するのか - MISONLN41’s Blog
- → 言語仕様自体にメモリ管理のロジックを組み込み、1の世界でプログラマーが自分で書いていたメモリ管理を勝手にやってくれる&そのルールに違反するコードはコンパイル時にエラーにする
- プログラマーが自前で書く like C, C++
-
とにかくCやC++のような実行効率の良さ・実行速度の速さを求めている
- ex. ゼロコスト抽象化
-
メモリ管理の機能によって、並行・並列処理の安全性をコンパイル時に保証できる
-
モダンな文法
-
モダンなパッケージマネージャ等の開発エコシステム
-
Binaryを出力できる
Rustの重要なコンセプト
1. メモリ管理
メモリ管理の前提知識
-
スタックとヒープの理解
-
スタックは
- 関数毎に確保されるメモリ領域
- サイズが決まっている・不変なデータのみ格納できる
- first in, last outで処理される
- データが配置される場所と取得方法が決まっているので高速
- 関数が終了したらスタックごと消えるので、データの生存期間は短い
-
ヒープは
- まだらにランダムにどこにでもデータが配置されるメモリ領域
- サイズが可変のデータも格納できる
- データがどこに配置されるか分からず、ランダムアクセスで取得するので低速
- プロセス実行中にずっとメモリ上に展開されるため、データの生存期間は長い
-
関数内でサイズ可変なデータを扱う場合は、可変データ自体はヒープに格納し、そのポインタ(メモリアドレスの1番目)をスタックに格納することになる
-
Javaなどの従来の言語は明確にデータサイズ不変以外のデータはヒープにとりあえず置いているイメージだった
-
Rustはできるだけ高速を目指すために厳密にスタックにデータを入れる
- できるだけスタックを使う
- ex. Sized trait
-
何をスタックに入れていて、何がヒープに入るかを明確に意識してコードを書く必要がある
メモリ管理において防ぎたいこと
以下の問題を防ぐために所有権という概念が導入されている。
各問題とそれに対応するための言語仕様を記述する。
- メモリの開放忘れ
- メモリが無駄になってしまう
- Rustは変数や関数などのデータがそのスコープを抜けたタイミングでメモリ解放を行うdrop関数が必ず自動的に呼ばれるようになっている
- メモリの2重解放
- memory corruptionにつながり、脆弱性を生む可能性がある
- あるヒープデータの参照が複数ある場合に、複数の参照をメモリ解放しようとしてしまうと、メモリの二重解放が起きてしまう
- そのため、Rustでは「ムーブ」という機能でヒープデータに対する参照(所有権)は1つしか持てないようにしている
- ちなみに不変サイズのデータはそもそもヒープではなくスタックに存在するデータであるため、ムーブはされない (デフォルトでコピーされる)
- ヒープデータに対する参照が一つしか持てない場合、一度別の変数や関数に渡したデータはその後の処理で使えないため、コードを書くのが困難になる。
- そのため、「ヒープデータに対する参照 = これはスタックのデータ」 に対する参照 (つまりヒープデータの参照の参照) を作成して利用する
- これが「借用」と呼ばれる
- Data Racing
- スタックの参照(借用)を複数作成した場合に、
- 複数の参照からWriteが行われると書き込みが衝突してしまう
- 片方の参照でWriteが行われるともう一方の参照のデータが変わってしまう
- という問題を防ぐ必要がある
- 可変な参照 (Writableな参照)、不変な参照 (Read onlyな参照) には以下のようなルールがある
- 可変な参照は1つ以上作成できない
- 可変な参照と不変な参照を同時には作成できない
- 不変な参照は複数作成できる
- スタックの参照(借用)を複数作成した場合に、
- メモリの早期解放、ダングリングポインタ
- 必要な変数のメモリを開放してしまうことによって、無効な変数ができてしまう
- 参照が存在する変数を開放してしまうことによって、その参照がNullPointer(ダングリングポインタ)になってしまう
- 参照が存在するが元のデータを開放してしまうことがないように「ライフタイム」という機能がある
- 条件分岐などにより、関数の返り値のライフタイムが一意に決まらない場合はライフタイム注釈を付けて、どの引数のライフタイムと同じライフタイムなのかを記述する
2. ゼロコスト抽象化
- classやinterfaceやgenericsなどプログラムを抽象化して記述するのは現代のプログラミングでは当たり前になっている
- 抽象化されたプログラムを実行する際、普通に実行した場合は抽象化されている部分のコード (ex. genericsで書かれたコード) を実行するには、実行時に抽象化部分の参照先を探しに行かなくてはならない
- JVMなどではvirtual method tableという場所にこの参照を保持しており、vTableに探しに行く動作を動的ディスパッチと呼ぶ
- 動的ディスパッチはvTableに探しに行く分、遅い(コストがかかる)
- 一方で参照を探しに行くことなしに実行できるコード、その実行を静的ディスパッチと呼ぶ
- 動的ディスパッチを静的ディスパッチにする手法としてメソッドのインライン展開がある
- コンパイル時に参照先の実際の実装コードをそのままコピペして持ってくる
- ex. genericsであればTに入りうる型分のmethodを全部書くイメージ
- Rustでは動的ディスパッチをできるだけ使わないように、徹底的にメソッドのインライン展開を使って静的ディスパッチにしている
- これをゼロコスト(動的ディスパッチをしないのでコストがかからない)抽象化という
- ただし、Rustにおいてもトレイトオブジェクト(後述)は動的ディスパッチになる
- トレイトオブジェクトを利用した型の場合は、そのトレイトがどのstructに実装されているかをコンパイラが把握しないため
- GenericsはTが何かをコンパイル時に決める必要があるため静的ディスパッチが可能、トレイトオブジェクト(要はinterface)の場合は、実行時にならないとそこに「そのinterfaceを満たすどんな型」が入ってくるかがわからないため動的ディスパッチになる
- 後から導入された impl Trait という「静的ディスパッチになるトレイトオブジェクト」の記法がある
文法
構造体
-
メソッド
- selfを受け取るもの
-
関連関数
- selfを受け取らないもの
-
メソッドはselfの受け取り方で性質が決まる
- readonlyで良い場合は&self
- writableが必要な場合は&mut self
- メソッドが呼び出された後、呼び出し元で元の構造体が利用できなくなっても良い場合は selfで所有権も受け取る
Enum
-
enumに状態を持てる
- TSの判別共用体のようにenumをpropertyに持つ構造体を複数作成する必要がなくなる
-
enumにメソッドを実装できる
-
Rustにはnullはなく、Option型を使う
- Option型のデータをOption型のまま演算でき、最後にnoneの場合のハンドリングをすれば良くなる
-
match式は
- Exaustive checkがデフォルトで入っている
- matchした場合にその中の値を取り出す書き方がシンプル
- 1つのパターンとの一致しか判定しない場合はmatchは冗長なのでif letを使っても良い
パッケージ、クレート、モジュール
- パッケージ: クレートをビルドし、テストし、共有することができるCargoの機能
- 1 Cargo.toml ファイル
- クレート: ライブラリか実行可能ファイルを生成する、木構造をしたモジュール群
- モジュール と use: これを使うことで、パスの構成、スコープ、公開するか否かを決定可能
- パス: 要素(例えば構造体や関数やモジュール)に名前をつける方法
コレクション
- Vec
- String
- HashMap<K, V>
エラー処理
-
Resultとpanic! でエラーハンドリングする
-
Result、Option、Futureなどはシンプルにはmatch式で条件分岐して処理を書く
-
しかし、それだとネストが深くなりすぎる問題があるため以下のような演算子を使う
- ResultとOptionは?
- map()
- and_then()
トレイト
-
interface と何が違う?
- イメージは基本同じ
- 違うのは、既存の型にimplしてメソッドを後から追加できる
- tsの場合のinterfaceは、型を宣言する際にimplementsをかかなくてはいけないため、既存の型(Number型など)に対してはimplementsできない
-
トレイトオブジェクト?
- トレイトを型コンテキストに書いたもの
トレイトそのものは「型」ではないのですが、あたかも型のように関数の返却値やベクターの要素の型指定に使おうというのが「トレイトオ ブジェクト」と呼ばれるものです。
- 型理論的には
- Genericsが全称型(全てのTに対してその型が存在する)
- interface = トレイトオブジェクトは存在型 (存在するか分からないがあるTに対してその型が成立する)
- interfaceは定義されても、実際にそのinterfaceを満たす型があるかは分からない
- https://qiita.com/sparklingbaby/items/fa80df035cff85eb0960
- 関数の引数や返り値にトレイトオブジェクトを書く場合には
impl
を付けることで静的ディスパッチにすることができる - 型引数にトレイトオブジェクトを渡す場合にトレイトオブジェクトの前に
dyn
を付けて、型ではなくトレイトオブジェクトだと明示する文法がある
-
トレイト境界?
- genericな型に対して制約をかけるもの
- TSでいう型コンテキストのextends
ライフタイム
-
条件分岐などにより、関数の返り値のライフタイムが一意に決まらない場合はライフタイム注釈を付けて、どの引数のライフタイムと同じライフタイムなのかを記述する
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
use std::fmt::Display; fn longest_with_an_announcement<'a, T>( x: &'a str, y: &'a str, ann: T, ) -> &'a str where T: Display, { println!("Announcement! {}", ann); if x.len() > y.len() { x } else { y } } 複数の引数と同じライフタイムだよという記述をする場合は、 複数の引数のうち、短い方のライフタイムと同じになる
-
呼び出し元に返す返り値が借用の形で返される場合は、その返り値のライフタイムが引数のどれと同じかを指定する必要がある
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
pub fn search(query: &str, contents: &str) -> Vec<&str> { let mut results = Vec::new(); for line in contents.lines() { if line.contains(query) { results.push(line); } } results } ↑のコンパイルエラー = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents` help: consider introducing a named lifetime parameter 正解は↓ pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { let mut results = Vec::new(); for line in contents.lines() { if line.contains(query) { results.push(line); } } results }
-
static ライフタイム
‘static
をつけると「プログラムの全期間生き残る」ライフタイム
-
ライフタイム境界
クロージャ
- クロージャが関数の外の変数を使用する場合は
move
を記載して所有権を移転する
|
|
スマートポインタ
-
通常のポインタ型に追加でメタデータとメソッドを持ったデータ型
-
ポインタを実現しているトレイト
Deref
トレイト- 参照外し演算子
*
を持つ interface - 参照外し演算子
*
の振る舞いをカスタマイズできる
- 参照外し演算子
Drop
トレイト- スコープを抜けた時にメモリを解放してくれる
drop
を持つ interface
- スコープを抜けた時にメモリを解放してくれる
-
具体的なスマートポインタの例
Box<T>
型- スタックではなくヒープにデータを格納するようにする
- 使い所
- データの所有権を移したいが、データサイズが大きく、スタックに入れたままだとデータがコピーされてしまうのが嫌な場合
- コンパイル時にはサイズを知ることができない型(ex. List自体の型定義などによく見られる再起型) を扱う場合
- スタックには固定長のデータしか入れられないためヒープに入れる必要があるため
- etc
Rc<T>
型 (Reference Counting型)- 参照カウント式のポインタ、現在参照されている数を状態として持つ
- 使い所
- あるデータが複数の所有者を持つことは通常の参照では不可能だが、それを実現したい場合に使う
- ex. グラフデータ構造 (複数の辺が同じノードを指す場合があるため、指す辺がなくならない限り、ノードのデータが開放されてはいけない)
- ヒープにプログラムの複数箇所で読む何らかのデータを確保したいけれど、 コンパイル時にはどの部分が最後にデータを使用し終わるか決定できない時に使う
- どの部分が最後に終わるかわかっている場合は、 単にその部分をデータの所有者にして、コンパイル時に強制される普通の所有権で良い
- あるデータが複数の所有者を持つことは通常の参照では不可能だが、それを実現したい場合に使う
RefCell<T>
型-
内部可変性を持つ
-
内部可変性パターン: データへのReadonly参照がある場合でもデータを可変化できるようにするデザインパターン
- 通常はReadonly参照がある場合にWritable参照は作れない
-
データ構造内で
unsafe
を使用して、 可変性と借用を支配するRustの通常の規則を捻じ曲げて実現する -
ルールを無視しているのでコンパイラが保証できないが、借用規則に実行時に従うことが保証できる時、 内部可変性パターンを使用した型を使用できる
- コンパイル時ではなく実行時に借用規則を強制する
-
使い所
借用規則を実行時に代わりに精査する利点は、コンパイル時の精査では許容されない特定のメモリ安全な筋書きが許容されることです。 Rustコンパイラのような静的解析は、本質的に保守的です。コードの特性には、コードを解析するだけでは検知できないものもあります: 最も有名な例は停止性問題であり、この本の範疇を超えていますが、調べると面白い話題です。
- 停止性問題: 静的型のコンパイラは実行時にバグを引き起こす可能性のある性質を見つけることができる。ただそのプログラムが有限時間内で実行が完了するかを文字列を解析しただけでは判断することができないという問題
-
- その他にも大量にスマートポインタ型は存在する
-
注意点
Rc<T>
とRefCell<T>
を使用するということはRustのコンパイラルールを破ることになるため、普通に危険- もし循環参照になるような複数の参照を持ってしまうとメモリリークが起きてしまう
並行処理
当初、Rustチームは、メモリ安全性を保証することと、並行性問題を回避することは、 異なる方法で解決すべき別々の課題だと考えていました。時間とともに、チームは、所有権と型システムは、 メモリ安全性と並行性問題を管理する役に立つ一連の強力な道具であることを発見しました。 所有権と型チェックを活用することで、多くの並行性エラーは、実行時エラーではなくコンパイル時エラーになります。
- 並行性におけるRustのスゴいところは「並行処理におけるデータ競合や無効な参照を型システムによってコンパイル時に検知できる」という点
スレッドの生成
thread::spawn(クロージャ)
でスレッドの生成thread::spawn()
の返り値handle
を取って、handle.join()
でhandle
が表すスレッドの終了を待つthread::spawn(クロージャ)
のクロージャに外側のスコープの変数を渡したい場合も通常のクロージャと同様にmove
で所有権を強制的にクロージャに奪わせる必要がある
スレッド間メッセージング ~チャンネル~
mpsc::channel()
でチャンネルを生成する- mpsc = multiple producer, single consumer
- Rustの標準ライブラリがチャンネルを実装している方法は、送信側の転送機(tx)は複数、受信側の受信機(rx)は1つというスタイル
|
|
- 別のスレッドにチャンネル経由でsendされた値はmoveされたことになる
- これによって複数のスレッドが同じ値にアクセスしうる状態をなくす
- 上記のコードでtxを
clone()
すれば複数の転送機を利用できる
状態共有並行性
-
1つのメモリに格納されている状態に複数のスレッドからアクセスする (共有する) こと
-
Mutex = Mutual Exclusion (相互排他)
- いつ何時も1つのスレッドにしかアクセスを許可しないという意味
- Mutexにアクセスするスレッドは、アクセス時にロックを取る必要がある
- また、アクセスが終わったらロックを解除する必要もある
-
Rustの所有権規則があれば、このロックとアンロックを忘れることがなくなる
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); *num = 6; } println!("m = {:?}", m); } ポイント 1. Mutexからデータを取り出す場合はlock()を呼ばなくては型エラーになる 2. lock()はロックを得られるまで待つ。もし失敗したらそれは異常なのでpanic確定なのでunwrapする 3. lock().unwrap()の結果として可変な参照が得られる 4. MutexはスマートポインタでDropを実装しており、スコープを出るときに自動的にアンロックされる
-
複数のスレッドで1つの値にアクセスしようとした時、その値の所有権を複数のスレッドが持っていることになってしまう
- これは所有権ルールに基づき、不可能
- 以下のコードはエラーになる
|
|
-
スマートポインタの章で出てきたRcを使って複数の所有権を許可しようとしても、Rcはロックなどの機構が実装されておらずスレッド間で共有するには安全ではないため、使えない
-
そこで利用するのが Arc (= Atomic RC)
- 複数スレッドで並行な状況で安全に利用できるRc
なぜ全ての基本型がアトミックでなく、標準ライブラリの型も標準で
Arc<T>
を使って実装されていないのか疑問に思う可能性があります。 その理由は、スレッド安全性が、本当に必要な時だけ支払いたいパフォーマンスの犠牲とともに得られるものだからです。 シ -
Mutexは内部可変性を持つスマートポインタ
- 上記のサンプルコードでも
let counter = …
で定義されるcounterはimmutableなのにスレッドからアクセスした場合にはmutableな参照になっている - RefCellと同様に内部を可変化するスマートポインタなのである
- 上記のサンプルコードでも
-
Mutexの注意点
- デッドロックを引き起こすコードはコンパイルで検知できない
- Rcがお互いを参照しあって循環参照になり、メモリリークを引き起こしてしまう可能性がある。そしてそれをコンパイルで検知できないのと同じ
- デッドロックを引き起こすコードはコンパイルで検知できない
-
Send トレイト
Send
を実装した型の所有権をスレッド間で転送できる
-
Sync トレイト
Sync
を実装した型は、複数のスレッドから参照されても安全であることを示唆します。 言い換えると、&T
(T
への参照)がSend
なら、型T
はSync
であり、参照が他のスレッドに安全に送信できる
https://www.kanejaku.org/posts/2018/12/rust-closure-types/
オブジェクト指向での書き方
-
Rustには継承は存在せず、継承でやりたいことはトレイトで行う
-
構造体間でメソッドを共通化したい場合
- トレイトを定義して、structに対してtraitをimplすることで、traitに記述してあるメソッドが実装される
- traitを型コンテキストで使うとトレイトを実装している型のインスタンス という型になるトレイトオブジェクトとなる
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
pub trait Draw { fn draw(&self); } pub struct Screen { pub components: Vec<Box<Draw>>, } pub struct Button { pub width: u32, pub height: u32, pub label: String, } impl Draw for Button { fn draw(&self) { // code to actually draw a button // 実際にボタンを描画するコード } } → Screen型とButton型は多態になる
-
この章で説明されているトレイトの使い方はTSのinterfaceと同じと理解
マクロ
-
マクロとは
-
= メタプログラミング
-
コードを生成・編集するコード
-
リフレクションとは実行タイミングが違う
リフレクションとは、実行時に型名や関数の中身などを取得する機能のことです。 言語によって提供されていたりいなかったりしますが、実行時にメタデータがないと取得できないので、 RustやC++のようなアセンブリコードに翻訳され、パフォーマンスを要求される高級言語では、提供されないのが一般的と思われます。
-
-
マクロと関数の違い
- シグニチャ
- 関数は事前に引数の数と返り値の型を宣言する必要がある
- マクロは可変長の引数を取れる
- 実行タイミング
- マクロはコンパイル前に実行され、コードが展開される
- pros: コンパイル前なので型にトレイトを実装するなど可
- 関数はコンパイル後、実行時に実行される
- マクロはコンパイル前に実行され、コードが展開される
- シグニチャ
-
マクロの分類
-
宣言的マクロ
-
macro_rules! を利用するマクロ
- 以下のようにコードをinputにとり、パターンマッチでマッチしたコードを置き換えるコードを書く
- パターンマッチで宣言的に記述するから「宣言的マクロ」と呼ぶ
1 2 3 4 5 6 7 8 9 10 11 12
#[macro_export] macro_rules! vec { ( $( $x:expr ),* ) => { { let mut temp_vec = Vec::new(); $( temp_vec.push($x); )* temp_vec } }; }
-
-
手続き的マクロ
- 以下のようにTokenStreamを受け取りTokenStreamを返す関数として書く
- 内部では手続き的にInputのTokenStreamを使ってoutputのコードを作成していくイメージ
1 2 3 4 5
use proc_macro; #[some_attribute] pub fn some_name(input: TokenStream) -> TokenStream { }
-
カスタム
#[derive]
マクロ- 構造体とenumに対してコードを生成するマクロ
1 2 3 4 5 6 7 8 9 10 11
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; let gen = quote! { impl HelloMacro for #name { fn hello_macro() { println!("Hello, Macro! My name is {}!", stringify!(#name)); } } }; gen.into() }
-
属性風のマクロ
- 書き方はderiveマクロと同じ
- deriveマクロは構造体とenumにしか使えないが、属性風マクロは関数などの他の要素にも利用できる
1 2 3 4 5 6 7
こんなマクロがあったら... #[proc_macro_attribute] pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {} こんな風に利用される #[route(GET, "/")] fn index() {}
-
関数風のマクロ
- 特に制限なくTokenStreamを引数にとってTokenStreamを返す関数のように書けるマクロ
1 2 3 4 5 6 7 8 9
こんなマクロがあったら... #[proc_macro] pub fn sql(input: TokenStream) -> TokenStream { inputに入ってくる生SQLをparseしてvalidationする } こんな風に利用される let sql = sql!(SELECT * FROM posts WHERE id=1);
-