イントロダクション
参加について
本書への貢献に興味がある方は、コントリビューションガイドラインをご確認ください。
お知らせ
- 2024-03-17: 本書のPDF版をこちらのリンクからダウンロードできるようになりました。
デザインパターン
ソフトウェア開発において、私たちはしばしば、それが現れる環境に関わらず類似性を共有する問題に遭遇します。実装の詳細は目の前のタスクを解決するために重要ですが、これらの特殊性から抽象化して、汎用的に適用可能な共通のプラクティスを見つけることができます。
デザインパターンは、エンジニアリングにおける再発する問題に対する、再利用可能でテスト済みのソリューションの集合です。デザインパターンは、ソフトウェアをよりモジュール化され、保守しやすく、拡張可能にします。さらに、これらのパターンは開発者にとって共通の言語を提供し、チームでの問題解決において効果的なコミュニケーションのための優れたツールとなります。
留意すべき点:各パターンには独自のトレードオフがあります。実装方法だけでなく、なぜ特定のパターンを選択するのかに焦点を当てることが重要です。1
Rustにおけるデザインパターン
Rustはオブジェクト指向ではなく、関数型要素、強力な型システム、借用チェッカーなど、すべての特性の組み合わせにより、Rustは独自のものとなっています。このため、Rustのデザインパターンは、従来のオブジェクト指向プログラミング言語とは異なります。だからこそ、私たちはこの本を書くことにしました。お読みいただければ幸いです!本書は3つの主要な章に分かれています:
- イディオム: コーディング時に従うべきガイドライン。これらはコミュニティの社会的規範です。正当な理由がない限り、これらを破るべきではありません。
- デザインパターン: コーディング時の一般的な問題を解決する方法。
- アンチパターン: コーディング時の一般的な問題を解決する方法。ただし、デザインパターンが利益をもたらすのに対し、アンチパターンはより多くの問題を生み出します。
https://web.archive.org/web/20240124025806/https://www.infoq.com/podcasts/software-architecture-hard-parts/
翻訳
mdbook-i18n-helperを利用しています。翻訳の追加と更新の方法については、 こちらのリポジトリをお読みください。
外部翻訳
翻訳を追加したい場合は、 メインリポジトリでissueを開いてください。
イディオム
イディオムとは、コミュニティで広く合意されている一般的なスタイル、ガイドライン、パターンのことです。慣用的なコードを書くことで、他の開発者が何が起きているのかをより理解しやすくなります。
結局のところ、コンピュータはコンパイラが生成する機械語にしか関心がありません。その代わり、ソースコードは主に開発者にとって有益なものです。この抽象化レイヤーがあるのですから、より読みやすくしてみてはいかがでしょうか?
KISSの原則を思い出してください:「シンプルにしておけ、馬鹿者(Keep It Simple, Stupid)」。この原則は「ほとんどのシステムは、複雑にするよりもシンプルに保つことで最もうまく機能する。したがって、シンプルさは設計における重要な目標であるべきであり、不必要な複雑さは避けるべきである」と主張しています。
コードは、コンピュータではなく人間が理解するためにある。
引数には borrowed 型を使う
説明
deref 型強制の対象となる型を使用することで、関数の引数にどの型を使うかを決める際にコードの柔軟性を高めることができます。この方法により、関数はより多くの入力型を受け入れることができるようになります。
これはスライス可能な型やファットポインタ型に限定されません。実際、所有型への借用よりも借用型を常に優先すべきです。たとえば、&String ではなく &str、&Vec<T> ではなく &[T]、&Box<T> ではなく &T を使用します。
borrowed 型を使用することで、所有型が既に間接参照の層を提供している場合に、間接参照の層を避けることができます。たとえば、String は間接参照の層を持っているため、&String は2層の間接参照を持つことになります。代わりに &str を使用することでこれを避けることができ、関数が呼び出されるたびに &String が &str に型強制されます。
例
この例では、関数の引数として &String を使用する場合と &str を使用する場合のいくつかの違いを説明しますが、この考え方は &Vec<T> と &[T] の使用、または &Box<T> と &T の使用にも同様に適用されます。
3つの連続した母音を含む単語かどうかを判定したい例を考えてみましょう。これを判定するために文字列を所有する必要はないので、参照を取ります。
コードは次のようになります:
fn three_vowels(word: &String) -> bool { let mut vowel_count = 0; for c in word.chars() { match c { 'a' | 'e' | 'i' | 'o' | 'u' => { vowel_count += 1; if vowel_count >= 3 { return true; } } _ => vowel_count = 0, } } false } fn main() { let ferris = "Ferris".to_string(); let curious = "Curious".to_string(); println!("{}: {}", ferris, three_vowels(&ferris)); println!("{}: {}", curious, three_vowels(&curious)); // This works fine, but the following two lines would fail: // println!("Ferris: {}", three_vowels("Ferris")); // println!("Curious: {}", three_vowels("Curious")); }
これは &String 型をパラメータとして渡しているため正常に動作します。最後の2行のコメントを外すと、例は失敗します。これは &str 型が &String 型に型強制されないためです。この問題は、引数の型を単純に変更することで修正できます。
たとえば、関数宣言を次のように変更すると:
fn three_vowels(word: &str) -> bool {
両方のバージョンがコンパイルされ、同じ出力が表示されます。
Ferris: false
Curious: true
しかし、それだけではありません!この話にはさらに続きがあります。おそらく、あなたは「そんなことは問題にならない、私は入力として &'static str を使うことはない」と考えるかもしれません("Ferris" を使用したときのように)。この特別な例を無視しても、&str を使用することで &String を使用するよりも柔軟性が高まることがわかるでしょう。
では、誰かから文章を与えられ、その文章内の単語のいずれかに3つの連続した母音が含まれているかどうかを判定したい例を考えてみましょう。すでに定義した関数を利用して、文章から各単語を入力すればよいでしょう。
この例は次のようになります:
fn three_vowels(word: &str) -> bool { let mut vowel_count = 0; for c in word.chars() { match c { 'a' | 'e' | 'i' | 'o' | 'u' => { vowel_count += 1; if vowel_count >= 3 { return true; } } _ => vowel_count = 0, } } false } fn main() { let sentence_string = "Once upon a time, there was a friendly curious crab named Ferris".to_string(); for word in sentence_string.split(' ') { if three_vowels(word) { println!("{word} has three consecutive vowels!"); } } }
引数の型を &str で宣言した関数を使ってこの例を実行すると、次のように出力されます:
curious has three consecutive vowels!
しかし、この例は引数の型を &String で宣言した関数では実行できません。これは、文字列スライスは &str であって &String ではないため、&String に変換するにはメモリ割り当てが必要になり、これは暗黙的には行われないからです。一方、String から &str への変換は安価で暗黙的に行われます。
参照
- Rust Language Reference on Type Coercions
Stringと&strの扱い方についての詳細な議論は、Herman J. Radtke III による このブログシリーズ (2015) を参照してください- Steve Klabnik のブログ投稿 ‘When should I use String vs &str?’
format! を使った文字列の連結
説明
push や push_str メソッドを可変な String に使用したり、+ 演算子を使用することで文字列を構築することができます。しかし、特にリテラル文字列と非リテラル文字列が混在する場合は、format! を使用する方が便利なことが多いです。
例
#![allow(unused)] fn main() { fn say_hello(name: &str) -> String { // We could construct the result string manually. // let mut result = "Hello ".to_owned(); // result.push_str(name); // result.push('!'); // result // But using format! is better. format!("Hello {name}!") } }
利点
format! を使用することは、通常、文字列を結合する最も簡潔で読みやすい方法です。
欠点
これは通常、文字列を結合する最も効率的な方法ではありません - 可変文字列に対する一連の push 操作が通常最も効率的です(特に文字列が予想されるサイズに事前に割り当てられている場合)。
コンストラクタ
説明
Rustには言語構造としてのコンストラクタはありません。代わりに、オブジェクトを作成するために関連関数であるnewを使用することが慣習となっています:
#![allow(unused)] fn main() { /// 秒単位の時間。 /// /// # Example /// /// ``` /// let s = Second::new(42); /// assert_eq!(42, s.value()); /// ``` pub struct Second { value: u64, } impl Second { // Constructs a new instance of [`Second`]. // Note this is an associated function - no self. pub fn new(value: u64) -> Self { Self { value } } /// Returns the value in seconds. pub fn value(&self) -> u64 { self.value } } }
デフォルトコンストラクタ
RustはDefaultトレイトを使用したデフォルトコンストラクタをサポートしています:
#![allow(unused)] fn main() { /// 秒単位の時間。 /// /// # Example /// /// ``` /// let s = Second::default(); /// assert_eq!(0, s.value()); /// ``` pub struct Second { value: u64, } impl Second { /// Returns the value in seconds. pub fn value(&self) -> u64 { self.value } } impl Default for Second { fn default() -> Self { Self { value: 0 } } } }
Defaultは、Secondのように全てのフィールドの型がDefaultを実装している場合、派生させることもできます:
#![allow(unused)] fn main() { /// 秒単位の時間。 /// /// # Example /// /// ``` /// let s = Second::default(); /// assert_eq!(0, s.value()); /// ``` #[derive(Default)] pub struct Second { value: u64, } impl Second { /// Returns the value in seconds. pub fn value(&self) -> u64 { self.value } } }
注意: 型がDefaultと空のnewコンストラクタの両方を実装することは一般的であり、期待されています。newはRustにおけるコンストラクタの慣習であり、ユーザーはそれが存在することを期待しているため、基本的なコンストラクタが引数を取らないことが妥当であれば、たとえdefaultと機能的に同一であっても、実装すべきです。
ヒント: Defaultを実装または派生させる利点は、Default実装が必要な場面で型を使用できるようになることです。最も顕著なのは、標準ライブラリの*or_default関数です。
参照
-
Defaultトレイトの詳細な説明についてはdefaultイディオムを参照してください。 -
複数の設定があるオブジェクトを構築する場合はビルダーパターンを参照してください。
-
Defaultとnewの両方を実装することについてはAPI Guidelines/C-COMMON-TRAITSを参照してください。
Default トレイト
説明
Rustの多くの型にはコンストラクタがあります。しかし、これは型に固有のものです。Rustは「new()メソッドを持つすべてのもの」を抽象化することはできません。これを可能にするためにDefaultトレイトが考案されました。これはコンテナやその他のジェネリック型で使用できます(例:Option::unwrap_or_default()を参照)。特に、一部のコンテナは該当する場合にすでにこれを実装しています。
Cow、Box、Arcのような単一要素のコンテナが、含まれる型がDefaultを実装している場合にDefaultを実装するだけでなく、すべてのフィールドがDefaultを実装している構造体に対して自動的に#[derive(Default)]できます。そのため、より多くの型がDefaultを実装するほど、より便利になります。
一方で、コンストラクタは複数の引数を取ることができますが、default()メソッドは引数を取りません。異なる名前を持つ複数のコンストラクタを持つことさえできますが、型ごとにDefaultの実装は1つしか持てません。
例
use std::{path::PathBuf, time::Duration}; // note that we can simply auto-derive Default here. #[derive(Default, Debug, PartialEq)] struct MyConfiguration { // Option defaults to None output: Option<PathBuf>, // Vecs default to empty vector search_path: Vec<PathBuf>, // Duration defaults to zero time timeout: Duration, // bool defaults to false check: bool, } impl MyConfiguration { // add setters here } fn main() { // construct a new instance with default values let mut conf = MyConfiguration::default(); // do something with conf here conf.check = true; println!("conf = {conf:#?}"); // partial initialization with default values, creates the same instance let conf1 = MyConfiguration { check: true, ..Default::default() }; assert_eq!(conf, conf1); }
参照
- コンストラクタイディオムは、「デフォルト」である場合とそうでない場合があるインスタンスを生成する別の方法です
Defaultドキュメント(実装者のリストについては下にスクロールしてください)Option::unwrap_or_default()derive(new)
コレクションはスマートポインタ
説明
Deref トレイトを使用して、コレクションをスマートポインタのように扱い、データの所有ビューと借用ビューを提供します。
例
use std::ops::Deref;
struct Vec<T> {
data: RawVec<T>,
//..
}
impl<T> Deref for Vec<T> {
type Target = [T];
fn deref(&self) -> &[T] {
//..
}
}
Vec<T> は T の所有コレクションであり、スライス(&[T])は T の借用コレクションです。Vec に Deref を実装することで、&Vec<T> から &[T] への暗黙的な参照外しが可能になり、自動参照外し検索に関係性が含まれます。Vec に実装されていると予想されるほとんどのメソッドは、代わりにスライスに実装されています。
また、String と &str も同様の関係を持っています。
動機
所有権と借用は Rust 言語の重要な側面です。データ構造は、良好なユーザーエクスペリエンスを提供するために、これらのセマンティクスを適切に考慮する必要があります。データを所有するデータ構造を実装する際、そのデータの借用ビューを提供することで、より柔軟な API が可能になります。
利点
ほとんどのメソッドは借用ビューに対してのみ実装でき、その後暗黙的に所有ビューでも利用可能になります。
クライアントにデータの借用または所有権の取得の選択肢を提供します。
欠点
参照外しを介してのみ利用可能なメソッドとトレイトは、境界チェック時に考慮されないため、このパターンを使用するデータ構造でのジェネリックプログラミングは複雑になる可能性があります(Borrow や AsRef トレイトなどを参照)。
議論
スマートポインタとコレクションは類似しています。スマートポインタは単一のオブジェクトを指し、コレクションは多数のオブジェクトを指します。型システムの観点からは、両者にほとんど違いはありません。コレクションが各データにアクセスする唯一の方法がコレクションを介する場合、およびコレクションがデータの削除に責任を持つ場合(共有所有権の場合でも、何らかの借用ビューが適切である場合があります)、コレクションはそのデータを所有します。コレクションがデータを所有する場合、通常、データの借用ビューを提供して複数回参照できるようにすることが有用です。
ほとんどのスマートポインタ(例:Foo<T>)は Deref<Target=T> を実装します。ただし、コレクションは通常、カスタム型に参照外しされます。[T] と str には言語サポートがありますが、一般的なケースでは必要ありません。Foo<T> は Deref<Target=Bar<T>> を実装でき、ここで Bar は動的サイズ型であり、&Bar<T> は Foo<T> のデータの借用ビューです。
一般的に、順序付きコレクションは Range に対して Index を実装し、スライス構文を提供します。ターゲットは借用ビューになります。
参照
デストラクタでのファイナライゼーション
説明
Rustはfinallyブロックに相当するもの、つまり関数がどのように終了してもコードが実行されることを保証する仕組みを提供していません。その代わりに、オブジェクトのデストラクタを使用して、終了前に実行する必要があるコードを実行できます。
例
fn baz() -> Result<(), ()> {
// some code
}
fn bar() -> Result<(), ()> {
// These don't need to be defined inside the function.
struct Foo;
// Implement a destructor for Foo.
impl Drop for Foo {
fn drop(&mut self) {
println!("exit");
}
}
// The dtor of _exit will run however the function `bar` is exited.
let _exit = Foo;
// Implicit return with `?` operator.
baz()?;
// Normal return.
Ok(())
}
動機
関数に複数のreturnポイントがある場合、終了時にコードを実行することが困難で繰り返しになり(したがってバグが発生しやすくなります)。これは、マクロによってreturnが暗黙的に行われる場合に特に当てはまります。よくあるケースは?演算子で、結果がErrの場合はreturnし、Okの場合は続行します。?は例外処理機構として使用されますが、Javaと異なり(Javaにはfinallyがあります)、通常のケースと例外的なケースの両方で実行されるコードをスケジュールする方法がありません。panicも関数を早期に終了させます。
利点
デストラクタ内のコードは(ほぼ)常に実行されます - panic、早期return等に対処できます。
欠点
デストラクタが実行されることは保証されていません。例えば、関数内に無限ループがある場合や、関数の実行が終了前にクラッシュした場合です。デストラクタは、すでにpanicしているスレッド内でpanicが発生した場合にも実行されません。したがって、ファイナライゼーションが絶対的に必要な場合、デストラクタをファイナライザーとして信頼することはできません。
このパターンは、気づきにくい暗黙的なコードを導入します。関数を読んでも、終了時に実行されるデストラクタの明確な兆候がありません。これはデバッグを難しくする可能性があります。
ファイナライゼーションのためだけにオブジェクトとDrop実装を必要とすることは、定型コードが多くなります。
議論
ファイナライザーとして使用されるオブジェクトをどのように正確に格納するかについては、いくつかの微妙な点があります。オブジェクトは関数の終わりまで生きていなければならず、その後破棄される必要があります。オブジェクトは常に値または一意に所有されたポインタ(例:Box<Foo>)でなければなりません。共有ポインタ(Rcなど)を使用すると、ファイナライザーは関数の生存期間を超えて生きることができます。同様の理由で、ファイナライザーは移動したり返したりしてはいけません。
ファイナライザーは変数に割り当てる必要があります。そうしないと、スコープから外れるときではなく、すぐに破棄されます。変数がファイナライザーとしてのみ使用される場合、変数名は_で始まる必要があります。そうしないと、コンパイラはファイナライザーが使用されていないと警告します。ただし、変数を接尾辞なしの_と呼ばないでください - その場合はすぐに破棄されます。
Rustでは、デストラクタはオブジェクトがスコープから外れるときに実行されます。これは、ブロックの終わりに到達した場合、早期returnがある場合、またはプログラムがpanicした場合に発生します。panicすると、Rustはスタックを巻き戻し、各スタックフレーム内の各オブジェクトのデストラクタを実行します。したがって、デストラクタは、呼び出されている関数内でpanicが発生した場合でも呼び出されます。
巻き戻し中にデストラクタがpanicした場合、取るべき良いアクションがないため、Rustはさらなるデストラクタを実行せずに、スレッドを直ちに中止します。これは、デストラクタが絶対的に実行されることが保証されていないことを意味します。また、デストラクタがpanicしないように特別な注意を払う必要があることも意味します。リソースが予期しない状態になる可能性があるためです。
参照
mem::{take(_), replace(_)} で変更された列挙型の所有値を保持する
説明
&mut MyEnum があり、(少なくとも)2つのバリアント、
A { name: String, x: u8 } と B { name: String } を持つとします。ここで、
x がゼロの場合は MyEnum::A を B に変更し、MyEnum::B はそのまま維持したいとします。
これは name をクローンすることなく実現できます。
例
#![allow(unused)] fn main() { use std::mem; enum MyEnum { A { name: String, x: u8 }, B { name: String }, } fn a_to_b(e: &mut MyEnum) { if let MyEnum::A { name, x: 0 } = e { // This takes out our `name` and puts in an empty String instead // (note that empty strings don't allocate). // Then, construct the new enum variant (which will // be assigned to `*e`). *e = MyEnum::B { name: mem::take(name), } } } }
これは、より多くのバリアントでも動作します:
#![allow(unused)] fn main() { use std::mem; enum MultiVariateEnum { A { name: String }, B { name: String }, C, D, } fn swizzle(e: &mut MultiVariateEnum) { use MultiVariateEnum::*; *e = match e { // Ownership rules do not allow taking `name` by value, but we cannot // take the value out of a mutable reference, unless we replace it: A { name } => B { name: mem::take(name), }, B { name } => A { name: mem::take(name), }, C => D, D => C, } } }
動機
列挙型を扱う際、列挙型の値を別のバリアントに変更したい場合があります。 これは通常、借用チェッカーを満足させるために2つのフェーズで行われます。最初のフェーズでは、既存の値を観察し、 その部分を見て次に何をすべきかを決定します。2番目のフェーズでは、 条件付きで値を変更する可能性があります(上記の例のように)。
借用チェッカーは、列挙型から name を取り出すことを許可しません(なぜなら、
何か がそこになければならないからです)。もちろん、name を .clone() して、そのクローンを
MyEnum::B に入れることもできますが、それは
借用チェッカーを満足させるためのクローン
アンチパターンの一例になります。いずれにせよ、可変借用だけで e を変更することで、
余分なアロケーションを避けることができます。
mem::take は、値を交換して、デフォルト値に置き換え、
以前の値を返すことを可能にします。String の場合、デフォルト値は空の
String であり、アロケーションを必要としません。結果として、元の
name を 所有値として 取得します。これを別の列挙型でラップできます。
注: mem::replace は非常に似ていますが、値を何に置き換えるかを指定できます。
mem::take 行と同等のものは
mem::replace(name, String::new()) です。
ただし、Option を使用していて、その値を
None に置き換えたい場合、Option の take() メソッドは、より短く、よりイディオマティックな
代替手段を提供します。
利点
見てください、アロケーションなしです!また、これを行っている間、インディ・ジョーンズのように感じるかもしれません。
欠点
これは少し冗長になります。繰り返し間違えると、借用チェッカーを嫌いになるでしょう。 コンパイラは二重ストアを最適化できない場合があり、アンセーフな言語で行うことと比較して パフォーマンスが低下する可能性があります。
さらに、取得する型は
Default トレイトを実装する必要があります。ただし、作業している型が
これを実装していない場合は、代わりに mem::replace を使用できます。
議論
このパターンは Rust においてのみ興味深いものです。GC のある言語では、デフォルトで 値への参照を取得し(GC が参照を追跡します)、C のような他の低レベル言語では、単に ポインタをエイリアスして、後で修正します。
しかし、Rust では、これを行うためにもう少し作業を行う必要があります。所有値は 1つの所有者しか持つことができないため、それを取り出すには、何かを戻す必要があります - インディ・ジョーンズのように、アーティファクトを砂の袋に置き換えます。
関連項目
これは、特定のケースにおいて 借用チェッカーを満足させるためのクローン アンチパターンを取り除きます。
スタック上での動的ディスパッチ
説明
複数の値に対して動的ディスパッチを行うことができますが、そのためには、異なる型のオブジェクトをバインドするために複数の変数を宣言する必要があります。必要に応じてライフタイムを延長するために、以下に示すように遅延条件付き初期化を使用できます:
例
use std::io; use std::fs; fn main() -> Result<(), Box<dyn std::error::Error>> { let arg = "-"; // We need to describe the type to get dynamic dispatch. let readable: &mut dyn io::Read = if arg == "-" { &mut io::stdin() } else { &mut fs::File::open(arg)? }; // Read from `readable` here. Ok(()) }
動機
Rustはデフォルトでコードを単相化します。これは、使用される型ごとにコードのコピーが生成され、独立して最適化されることを意味します。これによりホットパス上で非常に高速なコードが実現できますが、パフォーマンスが重要でない箇所ではコードが肥大化し、コンパイル時間とキャッシュ使用量のコストがかかります。
幸いにも、Rustでは動的ディスパッチを使用できますが、明示的に要求する必要があります。
利点
ヒープ上に何も割り当てる必要がありません。また、後で使用しないものを初期化する必要もなく、FileまたはStdinの両方で動作するように後続のコード全体を単相化する必要もありません。
欠点
Rust 1.79.0以前では、コードは遅延初期化を伴う2つのletバインディングが必要で、Boxベースのバージョンよりも多くの可動部分がありました:
// We still need to ascribe the type for dynamic dispatch.
let readable: Box<dyn io::Read> = if arg == "-" {
Box::new(io::stdin())
} else {
Box::new(fs::File::open(arg)?)
};
// Read from `readable` here.
幸いにも、この欠点は今では解消されました。やった!
議論
Rust 1.79.0以降、コンパイラは&または&mut内の一時的な値のライフタイムを、関数のスコープ内で可能な限り自動的に延長します。
これは、遅延初期化のために必要だったletバインディングにコンテンツを配置することを心配せずに、単に&mut値をここで使用できることを意味します(これはその変更前に使用されていた解決策でした)。
各値には依然として場所があり(たとえその場所が一時的であっても)、コンパイラは各値のサイズを把握しており、各借用値はそれから借用されたすべての参照よりも長生きします。
参照
- デストラクタでのファイナライゼーションおよびRAIIガードは、ライフタイムの厳密な制御から恩恵を受けることができます。
- (可変)参照の条件付きで埋められた
Option<&T>の場合、Option<T>を直接初期化し、その.as_ref()メソッドを使用してオプショナルな参照を取得できます。
FFIイディオム
FFIコードを書くこと自体が、それだけで一つのコース全体に値するテーマです。しかし、ここではunsafe Rustの初心者がつまずくことを避け、指針として役立ついくつかのイディオムを紹介します。
このセクションには、FFIを行う際に有用なイディオムが含まれています。
-
慣用的なエラー処理 - 整数コードとセンチネル戻り値(
NULLポインタなど)を使ったエラー処理 -
文字列の受け取り - 最小限のunsafeコードで実現する方法
-
文字列の受け渡し - FFI関数への文字列の渡し方
FFIにおけるエラーハンドリング
説明
C言語のような外部言語では、エラーはリターンコードで表現されます。しかし、Rustの型システムでは、より豊富なエラー情報を完全な型として捕捉し、伝播させることができます。
このベストプラクティスでは、さまざまな種類のエラーコードと、それらを使いやすい形で公開する方法を示します:
- フラットな列挙型は整数に変換し、コードとして返すべきです。
- 構造化された列挙型は、詳細情報として文字列エラーメッセージを持つ整数コードに変換すべきです。
- カスタムエラー型は、C表現を持つ「透過的」なものにすべきです。
コード例
フラットな列挙型
enum DatabaseError {
IsReadOnly = 1, // user attempted a write operation
IOError = 2, // user should read the C errno() for what it was
FileCorrupted = 3, // user should run a repair tool to recover it
}
impl From<DatabaseError> for libc::c_int {
fn from(e: DatabaseError) -> libc::c_int {
(e as i8).into()
}
}
構造化された列挙型
pub mod errors {
enum DatabaseError {
IsReadOnly,
IOError(std::io::Error),
FileCorrupted(String), // message describing the issue
}
impl From<DatabaseError> for libc::c_int {
fn from(e: DatabaseError) -> libc::c_int {
match e {
DatabaseError::IsReadOnly => 1,
DatabaseError::IOError(_) => 2,
DatabaseError::FileCorrupted(_) => 3,
}
}
}
}
pub mod c_api {
use super::errors::DatabaseError;
use core::ptr;
#[no_mangle]
pub extern "C" fn db_error_description(
e: Option<ptr::NonNull<DatabaseError>>,
) -> Option<ptr::NonNull<libc::c_char>> {
// SAFETY: we assume that the lifetime of `e` is greater than
// the current stack frame.
let error = unsafe { e?.as_ref() };
let error_str: String = match error {
DatabaseError::IsReadOnly => {
format!("cannot write to read-only database")
}
DatabaseError::IOError(e) => {
format!("I/O Error: {e}")
}
DatabaseError::FileCorrupted(s) => {
format!("File corrupted, run repair: {}", &s)
}
};
let error_bytes = error_str.as_bytes();
let c_error = unsafe {
// SAFETY: copying error_bytes to an allocated buffer with a '\0'
// byte at the end.
let buffer = ptr::NonNull::<u8>::new(libc::malloc(error_bytes.len() + 1).cast())?;
buffer
.as_ptr()
.copy_from_nonoverlapping(error_bytes.as_ptr(), error_bytes.len());
buffer.as_ptr().add(error_bytes.len()).write(0_u8);
buffer
};
Some(c_error.cast())
}
}
カスタムエラー型
struct ParseError {
expected: char,
line: u32,
ch: u16,
}
impl ParseError {
/* ... */
}
/* Create a second version which is exposed as a C structure */
#[repr(C)]
pub struct parse_error {
pub expected: libc::c_char,
pub line: u32,
pub ch: u16,
}
impl From<ParseError> for parse_error {
fn from(e: ParseError) -> parse_error {
let ParseError { expected, line, ch } = e;
parse_error { expected, line, ch }
}
}
利点
これにより、外部言語がエラー情報に明確にアクセスできるようになり、同時にRustコードのAPIを一切損なうことがありません。
欠点
記述量が多くなり、一部の型はC言語に簡単には変換できない場合があります。
文字列の受け入れ
説明
FFIを通じてポインタ経由で文字列を受け入れる場合、以下の2つの原則に従うべきです:
- 外部の文字列を直接コピーするのではなく、「借用」した状態に保つ。
- C形式の文字列からRustのネイティブ文字列への変換に関わる複雑さと
unsafeコードの量を最小限に抑える。
動機
Cで使用される文字列は、Rustで使用される文字列とは異なる振る舞いをします。具体的には:
- C文字列はnull終端であるのに対し、Rust文字列は長さを保持する
- C文字列は任意の非ゼロバイトを含むことができるが、Rust文字列はUTF-8でなければならない
- C文字列は
unsafeなポインタ操作を使用してアクセス・操作されるが、Rust文字列との対話は安全なメソッドを通じて行われる
Rust標準ライブラリには、RustのStringと&strに相当するC言語用の型としてCStringと&CStrが用意されており、これらによってC文字列とRust文字列間の変換に関わる複雑さとunsafeコードの多くを回避できます。
&CStr型は借用データを扱うことも可能にし、RustとC間の文字列受け渡しがゼロコスト操作になります。
コード例
pub mod unsafe_module {
// other module content
/// Log a message at the specified level.
///
/// # Safety
///
/// It is the caller's guarantee to ensure `msg`:
///
/// - is not a null pointer
/// - points to valid, initialized data
/// - points to memory ending in a null byte
/// - won't be mutated for the duration of this function call
#[no_mangle]
pub unsafe extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
let level: crate::LogLevel = match level { /* ... */ };
// SAFETY: The caller has already guaranteed this is okay (see the
// `# Safety` section of the doc-comment).
let msg_str: &str = match std::ffi::CStr::from_ptr(msg).to_str() {
Ok(s) => s,
Err(e) => {
crate::log_error("FFI string conversion failed");
return;
}
};
crate::log(msg_str, level);
}
}
利点
この例は以下を保証するように書かれています:
unsafeブロックができるだけ小さい。- 「追跡されていない」ライフタイムを持つポインタが「追跡された」共有参照になる
文字列が実際にコピーされる代替案を考えてみましょう:
pub mod unsafe_module {
// other module content
pub extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
// DO NOT USE THIS CODE.
// IT IS UGLY, VERBOSE, AND CONTAINS A SUBTLE BUG.
let level: crate::LogLevel = match level { /* ... */ };
let msg_len = unsafe { /* SAFETY: strlen is what it is, I guess? */
libc::strlen(msg)
};
let mut msg_data = Vec::with_capacity(msg_len + 1);
let msg_cstr: std::ffi::CString = unsafe {
// SAFETY: copying from a foreign pointer expected to live
// for the entire stack frame into owned memory
std::ptr::copy_nonoverlapping(msg, msg_data.as_mut(), msg_len);
msg_data.set_len(msg_len + 1);
std::ffi::CString::from_vec_with_nul(msg_data).unwrap()
}
let msg_str: String = unsafe {
match msg_cstr.into_string() {
Ok(s) => s,
Err(e) => {
crate::log_error("FFI string conversion failed");
return;
}
}
};
crate::log(&msg_str, level);
}
}
このコードは次の2つの点でオリジナルより劣っています:
unsafeコードがはるかに多く、さらに重要なことに、維持しなければならない不変条件が増える。- 広範な算術演算が必要なため、このバージョンにはRustの
未定義動作を引き起こすバグがある。
ここでのバグは、ポインタ演算における単純なミスです:文字列はコピーされましたが、そのmsg_lenバイト全てです。しかし、末尾のNUL終端子はコピーされませんでした。
その後、Vectorのサイズはゼロパディングされた文字列の長さに設定されました――末尾にゼロを追加できたはずのリサイズではなく。結果として、Vector内の最後のバイトは初期化されていないメモリになります。ブロックの最後でCStringが作成されるとき、Vectorの読み取りが未定義動作を引き起こします!
このような問題の多くと同様に、これは追跡が困難な問題です。文字列がUTF-8でないためにパニックすることもあれば、文字列の末尾に奇妙な文字が入ることもあれば、完全にクラッシュすることもあります。
欠点
なし?
文字列の受け渡し
説明
FFI関数に文字列を渡す際には、以下の4つの原則に従うべきです:
- 所有する文字列の寿命をできるだけ長くする。
- 変換時の
unsafeコードを最小化する。 - Cコードが文字列データを変更する可能性がある場合は、
CStringの代わりにVecを使用する。 - Foreign Function APIが必要としない限り、文字列の所有権を呼び出し先に移譲すべきではない。
動機
Rustには、CStringとCStr型によるC形式文字列への組み込みサポートがあります。しかし、Rust関数から外部関数呼び出しに送信される文字列には、さまざまなアプローチが存在します。
ベストプラクティスはシンプルです:unsafeコードを最小化する方法でCStringを使用することです。ただし、副次的な注意点として、オブジェクトは十分に長く生存しなければならないということがあります。つまり、ライフタイムを最大化する必要があります。さらに、ドキュメントでは、変更後にCStringを「往復」させることは未定義動作であると説明されているため、その場合には追加の作業が必要です。
コード例
pub mod unsafe_module {
// other module content
extern "C" {
fn seterr(message: *const libc::c_char);
fn geterr(buffer: *mut libc::c_char, size: libc::c_int) -> libc::c_int;
}
fn report_error_to_ffi<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
let c_err = std::ffi::CString::new(err.into())?;
unsafe {
// SAFETY: calling an FFI whose documentation says the pointer is
// const, so no modification should occur
seterr(c_err.as_ptr());
}
Ok(())
// The lifetime of c_err continues until here
}
fn get_error_from_ffi() -> Result<String, std::ffi::IntoStringError> {
let mut buffer = vec![0u8; 1024];
unsafe {
// SAFETY: calling an FFI whose documentation implies
// that the input need only live as long as the call
let written: usize = geterr(buffer.as_mut_ptr(), 1023).into();
buffer.truncate(written + 1);
}
std::ffi::CString::new(buffer).unwrap().into_string()
}
}
利点
この例は以下を確実にするように書かれています:
unsafeブロックを可能な限り小さくする。CStringが十分に長く生存する。- 型キャストのエラーは可能な限り常に伝播される。
よくある間違い(ドキュメントにも記載されているほど一般的)は、最初のブロックで変数を使用しないことです:
pub mod unsafe_module {
// other module content
fn report_error<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
unsafe {
// SAFETY: whoops, this contains a dangling pointer!
seterr(std::ffi::CString::new(err.into())?.as_ptr());
}
Ok(())
}
}
このコードはダングリングポインタを生成します。なぜなら、参照が作成された場合とは異なり、CStringのライフタイムはポインタの作成によって延長されないためです。
もう一つ頻繁に提起される問題は、1kのゼロベクタの初期化が「遅い」というものです。しかし、Rustの最近のバージョンでは、実際にその特定のマクロがzmallocへの呼び出しに最適化されており、これはオペレーティングシステムがゼロ化されたメモリを返す能力と同じ速さ(非常に高速)であることを意味します。
欠点
なし?
Optionに対する反復処理
説明
Optionは、0個または1個の要素を含むコンテナとして見ることができます。
特に、IntoIteratorトレイトを実装しているため、そのような型を必要とする
ジェネリックなコードで使用できます。
例
OptionはIntoIteratorを実装しているため、
.extend()の引数として使用できます:
#![allow(unused)] fn main() { let turing = Some("Turing"); let mut logicians = vec!["Curry", "Kleene", "Markov"]; logicians.extend(turing); // equivalent to if let Some(turing_inner) = turing { logicians.push(turing_inner); } }
既存のイテレータの末尾にOptionを追加する必要がある場合は、
.chain()に渡すことができます:
#![allow(unused)] fn main() { let turing = Some("Turing"); let logicians = vec!["Curry", "Kleene", "Markov"]; for logician in logicians.iter().chain(turing.iter()) { println!("{logician} is a logician"); } }
Optionが常にSomeである場合は、要素に対して
std::iter::onceを使用する方が
より慣用的であることに注意してください。
また、OptionはIntoIteratorを実装しているため、forループを使用して
反復処理することが可能です。これはif let Some(..)でマッチングするのと同等ですが、
ほとんどの場合、後者の方が望ましいでしょう。
参考
-
std::iter::onceは、 正確に1つの要素を生成するイテレータです。Some(foo).into_iter()よりも 読みやすい代替手段です。 -
Iterator::filter_mapは、Iterator::mapの バージョンで、Optionを返すマッピング関数に特化しています。 -
ref_sliceクレートは、Optionを0個または1個の要素を持つスライスに変換する関数を提供します。
クロージャに変数を渡す
説明
デフォルトでは、クロージャは環境を借用してキャプチャします。または、moveクロージャを使用して環境全体をムーブすることもできます。しかし、多くの場合、一部の変数だけをクロージャにムーブしたり、データのコピーを渡したり、参照で渡したり、その他の変換を行いたいことがあります。
そのためには、別のスコープで変数の再束縛を使用します。
例
以下のように使用します。
#![allow(unused)] fn main() { use std::rc::Rc; let num1 = Rc::new(1); let num2 = Rc::new(2); let num3 = Rc::new(3); let closure = { // `num1` is moved let num2 = num2.clone(); // `num2` is cloned let num3 = num3.as_ref(); // `num3` is borrowed move || { *num1 + *num2 + *num3; } }; }
以下の代わりに
#![allow(unused)] fn main() { use std::rc::Rc; let num1 = Rc::new(1); let num2 = Rc::new(2); let num3 = Rc::new(3); let num2_cloned = num2.clone(); let num3_borrowed = num3.as_ref(); let closure = move || { *num1 + *num2_cloned + *num3_borrowed; }; }
利点
コピーされたデータはクロージャの定義とともにグループ化されるため、その目的がより明確になります。また、クロージャで消費されない場合でも、すぐにドロップされます。
クロージャは、データがコピーされたかムーブされたかにかかわらず、周囲のコードと同じ変数名を使用します。
欠点
クロージャ本体の追加のインデント。
拡張性のための #[non_exhaustive] とプライベートフィールド
説明
ライブラリの作者が、後方互換性を壊すことなく、公開構造体に公開フィールドを追加したり、列挙型に新しいバリアントを追加したりしたい場合があります。
Rustはこの問題に対して2つの解決策を提供しています:
-
struct、enum、およびenumのバリアントに#[non_exhaustive]を使用する。#[non_exhaustive]が使用できるすべての場所に関する詳細なドキュメントについては、the docsを参照してください。 -
構造体にプライベートフィールドを追加して、直接インスタンス化されたりマッチされたりするのを防ぐことができます(代替案を参照)
例
#![allow(unused)] fn main() { mod a { // Public struct. #[non_exhaustive] pub struct S { pub foo: i32, } #[non_exhaustive] pub enum AdmitMoreVariants { VariantA, VariantB, #[non_exhaustive] VariantC { a: String, }, } } fn print_matched_variants(s: a::S) { // Because S is `#[non_exhaustive]`, it cannot be named here and // we must use `..` in the pattern. let a::S { foo: _, .. } = s; let some_enum = a::AdmitMoreVariants::VariantA; match some_enum { a::AdmitMoreVariants::VariantA => println!("it's an A"), a::AdmitMoreVariants::VariantB => println!("it's a b"), // .. required because this variant is non-exhaustive as well a::AdmitMoreVariants::VariantC { a, .. } => println!("it's a c"), // The wildcard match is required because more variants may be // added in the future _ => println!("it's a new variant"), } } }
代替案:構造体の プライベートフィールド
#[non_exhaustive] はクレートの境界を越えてのみ機能します。クレート内では、プライベートフィールドの方法を使用できます。
構造体にフィールドを追加することは、ほぼ後方互換性のある変更です。しかし、クライアントがパターンを使用して構造体インスタンスを分解する場合、構造体のすべてのフィールドに名前を付ける可能性があり、新しいフィールドを追加するとそのパターンが壊れます。クライアントがいくつかのフィールドに名前を付け、パターンで .. を使用する場合、別のフィールドを追加することは後方互換性があります。構造体のフィールドの少なくとも1つをプライベートにすることで、クライアントは後者の形式のパターンを使用せざるを得なくなり、構造体が将来にわたって安全であることが保証されます。
このアプローチの欠点は、他の方法では不要なフィールドを構造体に追加する必要がある場合があることです。() 型を使用すると実行時のオーバーヘッドがなく、フィールド名の前に _ を付けることで未使用フィールドの警告を回避できます。
#![allow(unused)] fn main() { pub struct S { pub a: i32, // Because `b` is private, you cannot match on `S` without using `..` and `S` // cannot be directly instantiated or matched against _b: (), } }
議論
struct において、#[non_exhaustive] は後方互換性のある方法で追加のフィールドを追加することを可能にします。また、すべてのフィールドが公開されている場合でも、クライアントが構造体コンストラクタを使用することを防ぎます。これは役立つかもしれませんが、追加のフィールドをコンパイラエラーとしてクライアントに見つけてもらうか、それとも静かに見過ごされる可能性があるものとして扱うか、検討する価値があります。
#[non_exhaustive] は列挙型のバリアントにも適用できます。#[non_exhaustive] バリアントは #[non_exhaustive] 構造体と同じように動作します。
これを意図的かつ慎重に使用してください:フィールドやバリアントを追加する際にメジャーバージョンをインクリメントすることが、多くの場合より良い選択肢です。#[non_exhaustive] は、ライブラリと同期せずに変更される可能性のある外部リソースをモデル化するシナリオでは適切かもしれませんが、汎用的なツールではありません。
デメリット
#[non_exhaustive] は、特に未知の列挙型バリアントを処理することを強制される場合、コードの人間工学を大幅に低下させる可能性があります。これは、メジャーバージョンをインクリメントせずにこのような進化が必要な場合にのみ使用すべきです。
#[non_exhaustive] が enum に適用されると、クライアントはワイルドカードバリアントを処理することを強制されます。この場合に適切なアクションがない場合、これは不自然なコードと、極めて稀な状況でのみ実行されるコードパスにつながる可能性があります。クライアントがこのシナリオで panic!() することを決定した場合、このエラーをコンパイル時に公開した方が良かったかもしれません。実際、#[non_exhaustive] はクライアントに「その他」のケースを処理することを強制します;このシナリオでは適切なアクションはほとんどありません。
参照
簡単なドキュメント初期化
説明
ドキュメントを書く際に構造体の初期化に大きな労力がかかる場合、構造体を引数として受け取るヘルパー関数で例をラップすると、より迅速に記述できます。
動機
複数または複雑なパラメータといくつかのメソッドを持つ構造体が存在することがあります。これらの各メソッドには例が必要です。
例えば:
struct Connection {
name: String,
stream: TcpStream,
}
impl Connection {
/// Sends a request over the connection.
///
/// # Example
/// ```no_run
/// # // Boilerplate are required to get an example working.
/// # let stream = TcpStream::connect("127.0.0.1:34254");
/// # let connection = Connection { name: "foo".to_owned(), stream };
/// # let request = Request::new("RequestId", RequestType::Get, "payload");
/// let response = connection.send_request(request);
/// assert!(response.is_ok());
/// ```
fn send_request(&self, request: Request) -> Result<Status, SendErr> {
// ...
}
/// Oh no, all that boilerplate needs to be repeated here!
fn check_status(&self) -> Status {
// ...
}
}
例
Connection と Request を作成するためのこのような定型文を全て記述する代わりに、それらを引数として受け取るラッピングヘルパー関数を作成する方が簡単です:
struct Connection {
name: String,
stream: TcpStream,
}
impl Connection {
/// Sends a request over the connection.
///
/// # Example
/// ```
/// # fn call_send(connection: Connection, request: Request) {
/// let response = connection.send_request(request);
/// assert!(response.is_ok());
/// # }
/// ```
fn send_request(&self, request: Request) {
// ...
}
}
注意 上記の例では、assert!(response.is_ok()); という行は、呼び出されることのない関数の内部にあるため、テスト中に実際には実行されません。
利点
これははるかに簡潔で、例の中の繰り返しコードを避けることができます。
欠点
例が関数内にあるため、コードはテストされません。ただし、cargo test の実行時にコンパイルできることは確認されます。そのため、このパターンは no_run が必要な場合に最も有用です。これを使用すれば、no_run を追加する必要はありません。
議論
アサーションが必要ない場合、このパターンはうまく機能します。
アサーションが必要な場合、代替案として #[doc(hidden)] で注釈されたヘルパーインスタンスを作成する公開メソッドを作成することができます(ユーザーには表示されません)。このメソッドはクレートの公開 API の一部であるため、rustdoc 内で呼び出すことができます。
一時的な可変性
説明
データを準備して処理する必要があることがよくありますが、その後はデータを検査するだけで変更することはありません。可変変数を不変として再定義することで、その意図を明示的に示すことができます。
これは、ネストされたブロック内でデータを処理するか、変数を再定義することで実現できます。
例
例えば、ベクタを使用する前にソートする必要があるとします。
ネストされたブロックを使用する場合:
let data = {
let mut data = get_vec();
data.sort();
data
};
// Here `data` is immutable.
変数の再束縛を使用する場合:
let mut data = get_vec();
data.sort();
let data = data;
// Here `data` is immutable.
利点
コンパイラが、ある時点以降に誤ってデータを変更しないことを保証します。
欠点
ネストされたブロックでは、ブロック本体に追加のインデントが必要になります。ブロックからデータを返すか、変数を再定義するために、もう1行必要になります。
エラー時に消費された引数を返す
説明
失敗する可能性のある関数が引数を消費(ムーブ)する場合、エラー内でその引数を返します。
例
pub fn send(value: String) -> Result<(), SendError> { println!("using {value} in a meaningful way"); // Simulate non-deterministic fallible action. use std::time::SystemTime; let period = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap(); if period.subsec_nanos() % 2 == 1 { Ok(()) } else { Err(SendError(value)) } } pub struct SendError(String); fn main() { let mut value = "imagine this is very long string".to_string(); let success = 's: { // Try to send value two times. for _ in 0..2 { value = match send(value) { Ok(()) => break 's true, Err(SendError(value)) => value, } } false }; println!("success: {success}"); }
動機
エラーが発生した場合、代替手段を試したり、非決定的な関数の場合は操作を再試行したりすることがあります。しかし、引数が常に消費される場合、呼び出しのたびにクローンを作成する必要があり、あまり効率的ではありません。
標準ライブラリでは、例えばString::from_utf8メソッドでこのアプローチを使用しています。有効なUTF-8を含まないベクタが与えられた場合、FromUtf8Errorが返されます。FromUtf8Error::into_bytesメソッドを使用して元のベクタを取り戻すことができます。
利点
可能な限り引数をムーブすることによる、より良いパフォーマンス。
欠点
エラー型がわずかに複雑になります。
デザインパターン
デザインパターンとは、 「ソフトウェア設計における特定の文脈で頻繁に発生する問題に対する、汎用的で再利用可能な解決策」です。デザインパターンは、プログラミング言語の文化を表現する優れた方法です。デザインパターンは言語固有のものであり、ある言語でパターンとされるものが、他の言語では言語機能により不要になったり、機能が欠けているために表現できなかったりすることがあります。
過度に使用すると、デザインパターンはプログラムに不要な複雑さを追加する可能性があります。 しかし、プログラミング言語に関する中級から上級レベルの知識を共有する優れた方法でもあります。
Rustにおけるデザインパターン
Rustには多くのユニークな機能があります。これらの機能は、問題のクラス全体を取り除くことで、大きな利益をもたらします。その中には、Rustに固有のパターンもあります。
YAGNI
YAGNIはYou Aren't Going to Need It(それは必要にならない)の頭字語です。これは、コードを書く際に適用すべき重要なソフトウェア設計の原則です。
私が書いた最高のコードは、書かなかったコードだ。
デザインパターンにYAGNIを適用すると、Rustの機能により多くのパターンを捨てられることがわかります。例えば、Rustではストラテジーパターンは必要ありません。なぜなら、単にトレイトを使えばよいからです。
振る舞いパターン
Wikipediaより:
オブジェクト間の共通の通信パターンを特定するデザインパターン。そうすることで、 これらのパターンは通信を実行する際の柔軟性を高める。
Command
説明
Commandパターンの基本的なアイデアは、アクションを独自のオブジェクトとして分離し、それらをパラメータとして渡すことです。
動機
オブジェクトとしてカプセル化された一連のアクションやトランザクションがあるとします。これらのアクションやコマンドを、異なる時間に何らかの順序で実行または呼び出したいと考えています。これらのコマンドは、イベントの結果としてトリガーされることもあります。例えば、ユーザーがボタンを押したときや、データパケットが到着したときなどです。さらに、これらのコマンドは元に戻すことができる可能性があります。これはエディタの操作などで役立ちます。システムがクラッシュした場合に後で変更を再適用できるように、実行されたコマンドのログを保存したい場合もあります。
例
2つのデータベース操作 create table と add field を定義します。これらの操作はそれぞれコマンドであり、コマンドを元に戻す方法を知っています。例えば、drop table と remove field です。ユーザーがデータベースマイグレーション操作を呼び出すと、各コマンドは定義された順序で実行され、ユーザーがロールバック操作を呼び出すと、コマンドのセット全体が逆順で呼び出されます。
アプローチ:トレイトオブジェクトの使用
2つの操作 execute と rollback でコマンドをカプセル化する共通のトレイトを定義します。すべてのコマンド structs はこのトレイトを実装する必要があります。
pub trait Migration { fn execute(&self) -> &str; fn rollback(&self) -> &str; } pub struct CreateTable; impl Migration for CreateTable { fn execute(&self) -> &str { "create table" } fn rollback(&self) -> &str { "drop table" } } pub struct AddField; impl Migration for AddField { fn execute(&self) -> &str { "add field" } fn rollback(&self) -> &str { "remove field" } } struct Schema { commands: Vec<Box<dyn Migration>>, } impl Schema { fn new() -> Self { Self { commands: vec![] } } fn add_migration(&mut self, cmd: Box<dyn Migration>) { self.commands.push(cmd); } fn execute(&self) -> Vec<&str> { self.commands.iter().map(|cmd| cmd.execute()).collect() } fn rollback(&self) -> Vec<&str> { self.commands .iter() .rev() // reverse iterator's direction .map(|cmd| cmd.rollback()) .collect() } } fn main() { let mut schema = Schema::new(); let cmd = Box::new(CreateTable); schema.add_migration(cmd); let cmd = Box::new(AddField); schema.add_migration(cmd); assert_eq!(vec!["create table", "add field"], schema.execute()); assert_eq!(vec!["remove field", "drop table"], schema.rollback()); }
アプローチ:関数ポインタの使用
各個別のコマンドを異なる関数として作成し、関数ポインタを保存して後で異なる時間にこれらの関数を呼び出すという別のアプローチに従うこともできます。関数ポインタは3つのトレイト Fn、FnMut、FnOnce をすべて実装しているため、関数ポインタの代わりにクロージャを渡して保存することもできます。
type FnPtr = fn() -> String; struct Command { execute: FnPtr, rollback: FnPtr, } struct Schema { commands: Vec<Command>, } impl Schema { fn new() -> Self { Self { commands: vec![] } } fn add_migration(&mut self, execute: FnPtr, rollback: FnPtr) { self.commands.push(Command { execute, rollback }); } fn execute(&self) -> Vec<String> { self.commands.iter().map(|cmd| (cmd.execute)()).collect() } fn rollback(&self) -> Vec<String> { self.commands .iter() .rev() .map(|cmd| (cmd.rollback)()) .collect() } } fn add_field() -> String { "add field".to_string() } fn remove_field() -> String { "remove field".to_string() } fn main() { let mut schema = Schema::new(); schema.add_migration(|| "create table".to_string(), || "drop table".to_string()); schema.add_migration(add_field, remove_field); assert_eq!(vec!["create table", "add field"], schema.execute()); assert_eq!(vec!["remove field", "drop table"], schema.rollback()); }
アプローチ:Fn トレイトオブジェクトの使用
最後に、共通のコマンドトレイトを定義する代わりに、Fn トレイトを実装する各コマンドをベクタに個別に保存することができます。
type Migration<'a> = Box<dyn Fn() -> &'a str>; struct Schema<'a> { executes: Vec<Migration<'a>>, rollbacks: Vec<Migration<'a>>, } impl<'a> Schema<'a> { fn new() -> Self { Self { executes: vec![], rollbacks: vec![], } } fn add_migration<E, R>(&mut self, execute: E, rollback: R) where E: Fn() -> &'a str + 'static, R: Fn() -> &'a str + 'static, { self.executes.push(Box::new(execute)); self.rollbacks.push(Box::new(rollback)); } fn execute(&self) -> Vec<&str> { self.executes.iter().map(|cmd| cmd()).collect() } fn rollback(&self) -> Vec<&str> { self.rollbacks.iter().rev().map(|cmd| cmd()).collect() } } fn add_field() -> &'static str { "add field" } fn remove_field() -> &'static str { "remove field" } fn main() { let mut schema = Schema::new(); schema.add_migration(|| "create table", || "drop table"); schema.add_migration(add_field, remove_field); assert_eq!(vec!["create table", "add field"], schema.execute()); assert_eq!(vec!["remove field", "drop table"], schema.rollback()); }
議論
コマンドが小さく、関数として定義されるか、クロージャとして渡される場合は、動的ディスパッチを利用しないため、関数ポインタを使用する方が望ましいかもしれません。しかし、コマンドが多数の関数と変数を持つ構造体全体であり、分離されたモジュールとして定義されている場合は、トレイトオブジェクトを使用する方が適しています。アプリケーションの例は actix にあり、ルートのハンドラ関数を登録する際にトレイトオブジェクトを使用しています。Fn トレイトオブジェクトを使用する場合、関数ポインタを使用した場合と同じ方法でコマンドを作成して使用できます。
パフォーマンスに関しては、パフォーマンスとコードのシンプルさおよび構成の間には常にトレードオフがあります。静的ディスパッチはより高速なパフォーマンスを提供し、動的ディスパッチはアプリケーションを構造化する際に柔軟性を提供します。
参照
Interpreter
説明
問題が頻繁に発生し、解決するために長く反復的な手順が必要な場合、問題のインスタンスを単純な言語で表現し、インタープリタオブジェクトがこの単純な言語で書かれた文を解釈することで問題を解決できます。
基本的に、あらゆる種類の問題に対して以下を定義します:
- ドメイン固有言語
- この言語の文法
- 問題インスタンスを解決するインタープリタ
動機
私たちの目標は、単純な数式を後置記法(または逆ポーランド記法)に変換することです。
簡単にするため、式は10個の数字 0, …, 9 と2つの演算子 +, - で構成されます。例えば、式 2 + 4 は 2 4 + に変換されます。
問題の文脈自由文法
タスクは中置式を後置式に変換することです。0, …, 9, +, - に対する中置式の集合の文脈自由文法を定義しましょう:
- 終端記号:
0,...,9,+,- - 非終端記号:
exp,term - 開始記号は
exp - そして以下が生成規則です
exp -> exp + term
exp -> exp - term
exp -> term
term -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
注意: この文法は、何をするかによってさらに変換する必要があります。例えば、左再帰を除去する必要があるかもしれません。詳細については、Compilers: Principles,Techniques, and Tools(ドラゴンブックとも呼ばれる)を参照してください。
解決策
単純に再帰下降パーサーを実装します。簡単にするため、式が構文的に間違っている場合(例えば、文法定義によると 2-34 や 2+5- は間違っています)、コードはパニックします。
pub struct Interpreter<'a> { it: std::str::Chars<'a>, } impl<'a> Interpreter<'a> { pub fn new(infix: &'a str) -> Self { Self { it: infix.chars() } } fn next_char(&mut self) -> Option<char> { self.it.next() } pub fn interpret(&mut self, out: &mut String) { self.term(out); while let Some(op) = self.next_char() { if op == '+' || op == '-' { self.term(out); out.push(op); } else { panic!("Unexpected symbol '{op}'"); } } } fn term(&mut self, out: &mut String) { match self.next_char() { Some(ch) if ch.is_digit(10) => out.push(ch), Some(ch) => panic!("Unexpected symbol '{ch}'"), None => panic!("Unexpected end of string"), } } } pub fn main() { let mut intr = Interpreter::new("2+3"); let mut postfix = String::new(); intr.interpret(&mut postfix); assert_eq!(postfix, "23+"); intr = Interpreter::new("1-2+3-4"); postfix.clear(); intr.interpret(&mut postfix); assert_eq!(postfix, "12-3+4-"); }
議論
Interpreterデザインパターンが形式言語の文法設計とこれらの文法のパーサーの実装に関するものだという誤った認識があるかもしれません。実際、このパターンは問題インスタンスをより具体的な方法で表現し、これらの問題インスタンスを解決する関数/クラス/構造体を実装することに関するものです。Rust言語には macro_rules! があり、特別な構文とこの構文をソースコードに展開する方法のルールを定義できます。
次の例では、n 次元ベクトルのユークリッド長を計算する単純な macro_rules! を作成します。norm!(x,1,2) と書く方が、x,1,2 を Vec にパックして長さを計算する関数を呼び出すよりも表現しやすく、効率的かもしれません。
macro_rules! norm { ($($element:expr),*) => { { let mut n = 0.0; $( n += ($element as f64)*($element as f64); )* n.sqrt() } }; } fn main() { let x = -3f64; let y = 4f64; assert_eq!(3f64, norm!(x)); assert_eq!(5f64, norm!(x, y)); assert_eq!(0f64, norm!(0, 0, 0)); assert_eq!(1f64, norm!(0.5, -0.5, 0.5, -0.5)); }
参照
Newtype
ある型を別の型と似たように振る舞わせたい、または型エイリアスだけでは不十分な場合にコンパイル時に特定の振る舞いを強制したい場合、どうすればよいでしょうか?
たとえば、セキュリティ上の理由(パスワードなど)で String にカスタムの Display 実装を作成したい場合などです。
このような場合、Newtype パターンを使用して 型安全性 と カプセル化 を提供できます。
説明
単一のフィールドを持つタプル構造体を使用して、型の不透明なラッパーを作成します。
これにより、型のエイリアス(type 項目)ではなく、新しい型が作成されます。
例
use std::fmt::Display; // Create Newtype Password to override the Display trait for String struct Password(String); impl Display for Password { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "****************") } } fn main() { let unsecured_password: String = "ThisIsMyPassword".to_string(); let secured_password: Password = Password(unsecured_password.clone()); println!("unsecured_password: {unsecured_password}"); println!("secured_password: {secured_password}"); }
unsecured_password: ThisIsMyPassword
secured_password: ****************
動機
newtype の主な動機は抽象化です。これにより、型間で実装の詳細を共有しながら、インターフェースを正確に制御できます。 API の一部として実装型を公開するのではなく、newtype を使用することで、後方互換性を保ちながら実装を変更できます。
Newtype は単位を区別するために使用できます。たとえば、f64 をラップして区別可能な Miles と Kilometres を提供します。
利点
ラップされた型とラッパー型は型互換性がありません(type を使用する場合とは異なります)。そのため、newtype のユーザーがラップされた型とラッパー型を「混同」することはありません。
Newtype はゼロコスト抽象化です - ランタイムオーバーヘッドはありません。
プライバシーシステムにより、ユーザーはラップされた型にアクセスできません(フィールドがプライベートの場合、デフォルトではプライベートです)。
欠点
newtype の欠点(特に型エイリアスと比較して)は、特別な言語サポートがないことです。つまり、大量の ボイラープレートが発生する可能性があります。 ラップされた型で公開したいすべてのメソッドに対して「パススルー」メソッドが必要であり、ラッパー型にも実装したいすべてのトレイトに対して impl が必要です。
議論
Newtype は Rust コードで非常に一般的です。抽象化や単位の表現が最も一般的な用途ですが、他の理由でも使用できます:
- 機能の制限(公開される関数や実装されるトレイトを減らす)
- コピーセマンティクスを持つ型をムーブセマンティクスにする
- より具体的な型を提供することによる抽象化、つまり内部型を隠す、例:
pub struct Foo(Bar<T1, T2>);
ここで、Bar は何らかの公開されたジェネリック型であり、T1 と T2 は内部型です。モジュールのユーザーは、Foo を Bar を使用して実装していることを知る必要はありませんが、ここで実際に隠しているのは型 T1 と T2、およびそれらが Bar でどのように使用されているかです。
参照
- Advanced Types in the book
- Newtypes in Haskell
- Type aliases
- derive_more, a crate for deriving many builtin traits on newtypes.
- The Newtype Pattern In Rust
ガードを用いたRAII
説明
RAIIは「Resource Acquisition is Initialisation(リソース取得は初期化である)」の略で、あまり良い名前とは言えません。このパターンの本質は、リソースの初期化がオブジェクトのコンストラクタで行われ、終了処理がデストラクタで行われることです。このパターンはRustでは、RAIIオブジェクトをリソースのガードとして使用し、型システムに依存してアクセスが常にガードオブジェクトによって仲介されることを保証することで拡張されています。
例
Mutexガードは、標準ライブラリからこのパターンの典型的な例です(これは実際の実装を簡略化したバージョンです):
use std::ops::Deref;
struct Foo {}
struct Mutex<T> {
// We keep a reference to our data: T here.
//..
}
struct MutexGuard<'a, T: 'a> {
data: &'a T,
//..
}
// Locking the mutex is explicit.
impl<T> Mutex<T> {
fn lock(&self) -> MutexGuard<T> {
// Lock the underlying OS mutex.
//..
// MutexGuard keeps a reference to self
MutexGuard {
data: self,
//..
}
}
}
// Destructor for unlocking the mutex.
impl<'a, T> Drop for MutexGuard<'a, T> {
fn drop(&mut self) {
// Unlock the underlying OS mutex.
//..
}
}
// Implementing Deref means we can treat MutexGuard like a pointer to T.
impl<'a, T> Deref for MutexGuard<'a, T> {
type Target = T;
fn deref(&self) -> &T {
self.data
}
}
fn baz(x: Mutex<Foo>) {
let xx = x.lock();
xx.foo(); // foo is a method on Foo.
// The borrow checker ensures we can't store a reference to the underlying
// Foo which will outlive the guard xx.
// x is unlocked when we exit this function and xx's destructor is executed.
}
動機
使用後にリソースを終了処理しなければならない場合、RAIIを使ってこの終了処理を行うことができます。終了処理後にそのリソースにアクセスすることがエラーである場合、このパターンを使ってそのようなエラーを防ぐことができます。
利点
リソースが終了処理されていない場合や、終了処理後にリソースが使用される場合のエラーを防ぎます。
議論
RAIIは、リソースが適切に解放または終了処理されることを保証するための有用なパターンです。Rustでは借用チェッカーを利用して、終了処理が行われた後にリソースを使用することから生じるエラーを静的に防ぐことができます。
借用チェッカーの中心的な目的は、データへの参照がそのデータよりも長生きしないことを保証することです。RAIIガードパターンが機能するのは、ガードオブジェクトが基礎となるリソースへの参照を含み、そのような参照のみを公開するためです。Rustは、ガードが基礎となるリソースよりも長生きできないこと、およびガードによって仲介されるリソースへの参照がガードよりも長生きできないことを保証します。これがどのように機能するかを理解するには、ライフタイム省略なしのderefのシグネチャを調べると役立ちます:
fn deref<'a>(&'a self) -> &'a T {
//..
}
リソースへの返される参照は、selfと同じライフタイム('a)を持ちます。したがって、借用チェッカーはTへの参照のライフタイムがselfのライフタイムよりも短いことを保証します。
Derefを実装することは、このパターンの核心部分ではなく、ガードオブジェクトの使用をより人間工学的にするだけであることに注意してください。ガードにgetメソッドを実装しても同様に機能します。
参照
RAIIはC++では一般的なパターンです: cppreference.com、 wikipedia。
スタイルガイドエントリ (現在はプレースホルダーのみです)。
Strategy (別名 Policy)
説明
Strategyデザインパターンは、関心の分離を可能にする技術です。また、依存性逆転の原則を通じてソフトウェアモジュールを疎結合にすることもできます。
Strategyパターンの基本的な考え方は、特定の問題を解決するアルゴリズムが与えられたとき、抽象レベルでアルゴリズムの骨格のみを定義し、具体的なアルゴリズムの実装を異なる部分に分離することです。
この方法により、アルゴリズムを使用するクライアントは特定の実装を選択できますが、一般的なアルゴリズムのワークフローは同じままです。言い換えれば、クラスの抽象仕様は派生クラスの具体的な実装に依存しませんが、具体的な実装は抽象仕様に従う必要があります。これが「依存性逆転」と呼ばれる理由です。
動機
毎月レポートを生成するプロジェクトに取り組んでいるとします。レポートをさまざまな形式(戦略)で生成する必要があります。例えば、JSONやプレーンテキスト形式などです。しかし、状況は時間とともに変化し、将来どのような要件が発生するかわかりません。例えば、まったく新しい形式でレポートを生成する必要があるかもしれませんし、既存の形式の1つを単に修正するだけかもしれません。
例
この例では、FormatterとReportが不変条件(または抽象)であり、TextとJsonが戦略構造体です。これらの戦略はFormatterトレイトを実装する必要があります。
use std::collections::HashMap; type Data = HashMap<String, u32>; trait Formatter { fn format(&self, data: &Data, buf: &mut String); } struct Report; impl Report { // Write should be used but we kept it as String to ignore error handling fn generate<T: Formatter>(g: T, s: &mut String) { // backend operations... let mut data = HashMap::new(); data.insert("one".to_string(), 1); data.insert("two".to_string(), 2); // generate report g.format(&data, s); } } struct Text; impl Formatter for Text { fn format(&self, data: &Data, buf: &mut String) { for (k, v) in data { let entry = format!("{k} {v}\n"); buf.push_str(&entry); } } } struct Json; impl Formatter for Json { fn format(&self, data: &Data, buf: &mut String) { buf.push('['); for (k, v) in data.into_iter() { let entry = format!(r#"{{"{}":"{}"}}"#, k, v); buf.push_str(&entry); buf.push(','); } if !data.is_empty() { buf.pop(); // remove extra , at the end } buf.push(']'); } } fn main() { let mut s = String::from(""); Report::generate(Text, &mut s); assert!(s.contains("one 1")); assert!(s.contains("two 2")); s.clear(); // reuse the same buffer Report::generate(Json, &mut s); assert!(s.contains(r#"{"one":"1"}"#)); assert!(s.contains(r#"{"two":"2"}"#)); }
利点
主な利点は関心の分離です。例えば、この場合、ReportはJsonとTextの具体的な実装について何も知りませんし、出力実装はデータがどのように前処理され、保存され、取得されるかを気にしません。彼らが知る必要があるのは、実装すべき特定のトレイトと、結果を処理する具体的なアルゴリズム実装を定義するそのメソッド、つまりFormatterとformat(...)だけです。
欠点
各戦略に対して少なくとも1つのモジュールを実装する必要があるため、戦略の数とともにモジュールの数も増加します。選択できる戦略が多数ある場合、ユーザーは戦略がどのように異なるかを知る必要があります。
議論
前の例では、すべての戦略が単一ファイルに実装されています。異なる戦略を提供する方法には次のものがあります:
- すべて1つのファイルに(この例で示したように、モジュールとして分離するのと同様)
- モジュールとして分離、例:
formatter::jsonモジュール、formatter::textモジュール - コンパイラの機能フラグを使用、例:
json機能、text機能 - クレートとして分離、例:
jsonクレート、textクレート
Serdeクレートは、Strategyパターンの良い例です。Serdeは、型に対してSerializeとDeserializeトレイトを手動で実装することで、シリアライゼーション動作の完全なカスタマイズを可能にします。例えば、serde_jsonとserde_cborは同様のメソッドを公開しているため、簡単に交換できます。これにより、ヘルパークレートserde_transcodeがより便利で人間工学的になります。
しかし、Rustでこのパターンを設計するためにトレイトを使用する必要はありません。
以下のおもちゃの例は、Rustのクロージャを使用したStrategyパターンのアイデアを示しています:
struct Adder; impl Adder { pub fn add<F>(x: u8, y: u8, f: F) -> u8 where F: Fn(u8, u8) -> u8, { f(x, y) } } fn main() { let arith_adder = |x, y| x + y; let bool_adder = |x, y| { if x == 1 || y == 1 { 1 } else { 0 } }; let custom_adder = |x, y| 2 * x + y; assert_eq!(9, Adder::add(4, 5, arith_adder)); assert_eq!(0, Adder::add(0, 0, bool_adder)); assert_eq!(5, Adder::add(1, 3, custom_adder)); }
実際、RustはすでにOptionsのmapメソッドでこのアイデアを使用しています:
fn main() { let val = Some("Rust"); let len_strategy = |s: &str| s.len(); assert_eq!(4, val.map(len_strategy).unwrap()); let first_byte_strategy = |s: &str| s.bytes().next().unwrap(); assert_eq!(82, val.map(first_byte_strategy).unwrap()); }
参照
- Strategy Pattern
- Dependency Injection
- Policy Based Design
- Implementing a TCP server for Space Applications in Rust using the Strategy Pattern
Visitor
説明
Visitorは、異種のオブジェクトコレクションに対して動作するアルゴリズムをカプセル化します。これにより、データ(またはその主要な動作)を変更することなく、同じデータに対して複数の異なるアルゴリズムを記述できます。
さらに、Visitorパターンは、オブジェクトコレクションの走査と各オブジェクトに対して実行される操作を分離することを可能にします。
例
// The data we will visit
mod ast {
pub enum Stmt {
Expr(Expr),
Let(Name, Expr),
}
pub struct Name {
value: String,
}
pub enum Expr {
IntLit(i64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
}
}
// The abstract visitor
mod visit {
use ast::*;
pub trait Visitor<T> {
fn visit_name(&mut self, n: &Name) -> T;
fn visit_stmt(&mut self, s: &Stmt) -> T;
fn visit_expr(&mut self, e: &Expr) -> T;
}
}
use ast::*;
use visit::*;
// An example concrete implementation - walks the AST interpreting it as code.
struct Interpreter;
impl Visitor<i64> for Interpreter {
fn visit_name(&mut self, n: &Name) -> i64 {
panic!()
}
fn visit_stmt(&mut self, s: &Stmt) -> i64 {
match *s {
Stmt::Expr(ref e) => self.visit_expr(e),
Stmt::Let(..) => unimplemented!(),
}
}
fn visit_expr(&mut self, e: &Expr) -> i64 {
match *e {
Expr::IntLit(n) => n,
Expr::Add(ref lhs, ref rhs) => self.visit_expr(lhs) + self.visit_expr(rhs),
Expr::Sub(ref lhs, ref rhs) => self.visit_expr(lhs) - self.visit_expr(rhs),
}
}
}
ASTデータを変更することなく、型チェッカーなどのさらなるVisitorを実装できます。
動機
Visitorパターンは、異種のデータにアルゴリズムを適用したい場合に便利です。データが同種である場合は、イテレータのようなパターンを使用できます。Visitorオブジェクトを使用すること(関数型アプローチではなく)により、Visitorがステートフルになり、ノード間で情報を伝達できるようになります。
議論
visit_*メソッドがvoidを返すこと(例のように戻り値を持たない)は一般的です。その場合、走査コードを分離し、アルゴリズム間で共有することが可能です(また、デフォルトのno-opメソッドを提供することもできます)。Rustでは、これを行う一般的な方法は、各データに対してwalk_*関数を提供することです。例えば、
pub fn walk_expr(visitor: &mut Visitor, e: &Expr) {
match *e {
Expr::IntLit(_) => {}
Expr::Add(ref lhs, ref rhs) => {
visitor.visit_expr(lhs);
visitor.visit_expr(rhs);
}
Expr::Sub(ref lhs, ref rhs) => {
visitor.visit_expr(lhs);
visitor.visit_expr(rhs);
}
}
}
他の言語(例えばJava)では、データに同じ役割を果たすacceptメソッドを持たせることが一般的です。
参照
Visitorパターンは、ほとんどのオブジェクト指向言語で一般的なパターンです。
foldパターンはVisitorと似ていますが、訪問されたデータ構造の新しいバージョンを生成します。
生成パターン
Wikipediaより:
オブジェクト生成メカニズムを扱うデザインパターンで、状況に適した方法でオブジェクトを生成しようとします。 オブジェクト生成の基本的な形式は、設計上の問題や設計の複雑性の増加をもたらす可能性があります。 生成デザインパターンは、このオブジェクト生成を何らかの方法で制御することで、この問題を解決します。
Builder
説明
ビルダーヘルパーへの呼び出しを使用してオブジェクトを構築します。
例
#![allow(unused)] fn main() { #[derive(Debug, PartialEq)] pub struct Foo { // Lots of complicated fields. bar: String, } impl Foo { // This method will help users to discover the builder pub fn builder() -> FooBuilder { FooBuilder::default() } } #[derive(Default)] pub struct FooBuilder { // Probably lots of optional fields. bar: String, } impl FooBuilder { pub fn new(/* ... */) -> FooBuilder { // Set the minimally required fields of Foo. FooBuilder { bar: String::from("X"), } } pub fn name(mut self, bar: String) -> FooBuilder { // Set the name on the builder itself, and return the builder by value. self.bar = bar; self } // If we can get away with not consuming the Builder here, that is an // advantage. It means we can use the FooBuilder as a template for constructing // many Foos. pub fn build(self) -> Foo { // Create a Foo from the FooBuilder, applying all settings in FooBuilder // to Foo. Foo { bar: self.bar } } } #[test] fn builder_test() { let foo = Foo { bar: String::from("Y"), }; let foo_from_builder: Foo = FooBuilder::new().name(String::from("Y")).build(); assert_eq!(foo, foo_from_builder); } }
動機
多数のコンストラクタが必要になる場合や、構築に副作用がある場合に有用です。
利点
構築用のメソッドを他のメソッドから分離します。
コンストラクタの増殖を防ぎます。
ワンライナーでの初期化にも、より複雑な構築にも使用できます。
欠点
構造体オブジェクトを直接作成したり、シンプルなコンストラクタ関数を使用するよりも複雑です。
議論
このパターンは、Rustにオーバーロードがないため、他の多くの言語よりもRust(そしてよりシンプルなオブジェクト)でより頻繁に見られます。特定の名前を持つメソッドは1つしか持てないため、複数のコンストラクタを持つことは、C++やJavaなどと比較してRustではあまり適していません。
このパターンは、ビルダーオブジェクトが単なるビルダーではなく、それ自体が有用である場合によく使用されます。例えば、
std::process::Command
は
Child(プロセス)
のビルダーです。このような場合、TとTBuilderの命名パターンは使用されません。
この例では、ビルダーを値で受け取り、値で返します。ビルダーを可変参照として受け取り、返す方が、より人間工学的(そしてより効率的)であることがよくあります。借用チェッカーはこれを自然に機能させます。このアプローチには、次のようなコードを書けるという利点があります
let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.build();
FooBuilder::new().a().b().build() スタイルと同様に。
参考
- Description in the style guide
- derive_builder, a crate for automatically implementing this pattern while avoiding the boilerplate.
- Constructor pattern for when construction is simpler.
- Builder pattern (wikipedia)
- Construction of complex values
Fold
説明
データのコレクション内の各アイテムに対してアルゴリズムを実行し、新しいアイテムを作成することで、全く新しいコレクションを作成します。
ここでの語源は私には不明確です。「fold」と「folder」という用語はRustコンパイラで使用されていますが、通常の意味でのfoldというよりもmapに近いように思えます。詳細については以下の議論を参照してください。
例
// The data we will fold, a simple AST.
mod ast {
pub enum Stmt {
Expr(Box<Expr>),
Let(Box<Name>, Box<Expr>),
}
pub struct Name {
value: String,
}
pub enum Expr {
IntLit(i64),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
}
}
// The abstract folder
mod fold {
use ast::*;
pub trait Folder {
// A leaf node just returns the node itself. In some cases, we can do this
// to inner nodes too.
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
// Create a new inner node by folding its children.
fn fold_stmt(&mut self, s: Box<Stmt>) -> Box<Stmt> {
match *s {
Stmt::Expr(e) => Box::new(Stmt::Expr(self.fold_expr(e))),
Stmt::Let(n, e) => Box::new(Stmt::Let(self.fold_name(n), self.fold_expr(e))),
}
}
fn fold_expr(&mut self, e: Box<Expr>) -> Box<Expr> { ... }
}
}
use fold::*;
use ast::*;
// An example concrete implementation - renames every name to 'foo'.
struct Renamer;
impl Folder for Renamer {
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
Box::new(Name { value: "foo".to_owned() })
}
// Use the default methods for the other nodes.
}
RenamerをASTに対して実行した結果は、古いASTと同一ですが、すべての名前がfooに変更された新しいASTです。実際のfolderは、構造体自体にノード間で保持される状態を持つ可能性があります。
folderは、あるデータ構造を別の(通常は類似した)データ構造にマッピングするように定義することもできます。例えば、ASTをHIRツリー(HIRは高レベル中間表現の略)に畳み込むことができます。
動機
データ構造内の各ノードに対して何らかの操作を実行してデータ構造をマッピングすることは一般的です。単純なデータ構造に対する単純な操作の場合、これはIterator::mapを使用して実行できます。より複雑な操作、おそらく以前のノードが後のノードの操作に影響を与える場合や、データ構造の反復が自明でない場合は、foldパターンを使用する方が適切です。
visitorパターンと同様に、foldパターンは、データ構造のトラバーサルを各ノードに対して実行される操作から分離することを可能にします。
議論
この方法でデータ構造をマッピングすることは、関数型言語では一般的です。オブジェクト指向言語では、データ構造をその場で変更する方がより一般的です。「関数型」アプローチはRustでは一般的で、主に不変性を好むためです。古いデータ構造を変更するのではなく、新しいデータ構造を使用することで、ほとんどの状況でコードについての推論が容易になります。
効率性と再利用性のトレードオフは、fold_*メソッドがノードをどのように受け入れるかを変更することで調整できます。
上記の例では、Boxポインタに対して操作を行っています。これらはデータを排他的に所有するため、データ構造の元のコピーを再利用することはできません。一方で、ノードが変更されていない場合、それを再利用することは非常に効率的です。
借用参照に対して操作を行う場合、元のデータ構造を再利用できます。ただし、変更されていない場合でもノードをクローンする必要があり、これは高コストになる可能性があります。
参照カウントポインタを使用すると、両方の長所が得られます - 元のデータ構造を再利用でき、変更されていないノードをクローンする必要がありません。ただし、使用が人間工学的でなく、データ構造が可変にできないことを意味します。
参考
イテレータにはfoldメソッドがありますが、これはデータ構造を新しいデータ構造ではなく値に畳み込みます。イテレータのmapは、このfoldパターンにより近いものです。
他の言語では、foldは通常、このパターンではなく、Rustのイテレータの意味で使用されます。一部の関数型言語には、データ構造上で柔軟なマップを実行するための強力な構造があります。
visitorパターンは、foldと密接に関連しています。両者は、データ構造を歩いて各ノードに対して操作を実行するという概念を共有しています。ただし、visitorは新しいデータ構造を作成せず、古いデータ構造を消費しません。
構造パターン
Wikipediaより:
エンティティ間の関係を実現する簡単な方法を特定することで、設計を容易にするデザインパターン。
独立した借用のための構造体の分解
説明
大きな構造体は、借用チェッカーで問題を引き起こすことがあります。フィールドは独立して借用できますが、構造体全体が一度に使用されてしまい、他の使用を妨げることがあります。解決策として、構造体をいくつかの小さな構造体に分解することが考えられます。その後、これらを元の構造体に合成します。こうすることで、各構造体を個別に借用でき、より柔軟な動作が可能になります。
このパターンは、他の面でもより良い設計につながることが多いです。この設計パターンを適用すると、より小さな機能単位が明らかになることがよくあります。
例
借用チェッカーが構造体の使用計画を阻む不自然な例を示します:
struct Database {
connection_string: String,
timeout: u32,
pool_size: u32,
}
fn print_database(database: &Database) {
println!("Connection string: {}", database.connection_string);
println!("Timeout: {}", database.timeout);
println!("Pool size: {}", database.pool_size);
}
fn main() {
let mut db = Database {
connection_string: "initial string".to_string(),
timeout: 30,
pool_size: 100,
};
let connection_string = &mut db.connection_string;
print_database(&db);
*connection_string = "new string".to_string();
}
コンパイラは次のエラーを出力します:
let connection_string = &mut db.connection_string;
------------------------- mutable borrow occurs here
print_database(&db);
^^^ immutable borrow occurs here
*connection_string = "new string".to_string();
------------------ mutable borrow later used here
この設計パターンを適用して、Database を3つの小さな構造体にリファクタリングすることで、借用チェックの問題を解決できます:
// Database は現在、ConnectionString、Timeout、PoolSize の3つの構造体で構成されています。 // より小さな構造体に分解しましょう #[derive(Debug, Clone)] struct ConnectionString(String); #[derive(Debug, Clone, Copy)] struct Timeout(u32); #[derive(Debug, Clone, Copy)] struct PoolSize(u32); // 次に、これらの小さな構造体を `Database` に合成します struct Database { connection_string: ConnectionString, timeout: Timeout, pool_size: PoolSize, } // print_database は、ConnectionString、Timeout、Poolsize 構造体を受け取るようになります fn print_database(connection_str: ConnectionString, timeout: Timeout, pool_size: PoolSize) { println!("Connection string: {connection_str:?}"); println!("Timeout: {timeout:?}"); println!("Pool size: {pool_size:?}"); } fn main() { // 3つの構造体で Database を初期化します let mut db = Database { connection_string: ConnectionString("localhost".to_string()), timeout: Timeout(30), pool_size: PoolSize(100), }; let connection_string = &mut db.connection_string; print_database(connection_string.clone(), db.timeout, db.pool_size); *connection_string = ConnectionString("new string".to_string()); }
動機
このパターンは、独立して借用したい多くのフィールドを持つ構造体がある場合に最も有用です。結果として、より柔軟な動作が得られます。
利点
構造体の分解により、借用チェッカーの制限を回避できます。また、多くの場合、より良い設計が生まれます。
欠点
コードが冗長になる可能性があります。また、小さな構造体が良い抽象化でない場合もあり、結果として設計が悪化することがあります。これはおそらく「コードの臭い」であり、プログラムを何らかの方法でリファクタリングすべきことを示しています。
議論
このパターンは、借用チェッカーを持たない言語では必要ないため、その意味で Rust に固有のものです。しかし、より小さな機能単位を作ることは、多くの場合、よりクリーンなコードにつながります。これは、言語に依存しない、広く認められたソフトウェアエンジニアリングの原則です。
このパターンは、Rust の借用チェッカーがフィールドを互いに独立して借用できることに依存しています。この例では、借用チェッカーは a.b と a.c が別々のものであり、独立して借用できることを認識しています。a 全体を借用しようとはしないため、このパターンは有用です。
小さなクレートを好む
説明
一つのことをうまく行う小さなクレートを好みましょう。
CargoとCrates.ioは、サードパーティライブラリの追加を容易にします。これはCやC++などと比べてはるかに簡単です。さらに、crates.io上のパッケージは公開後に編集や削除ができないため、現在動作しているビルドは将来も動作し続けるはずです。このツールの利点を活用し、より小さく、より細分化された依存関係を使用すべきです。
利点
- 小さなクレートは理解しやすく、よりモジュール化されたコードを促進します。
- クレートはプロジェクト間でコードを再利用できるようにします。例えば、
urlクレートはServoブラウザエンジンの一部として開発されましたが、その後プロジェクト外でも広く使用されるようになりました。 - Rustのコンパイル単位はクレートであるため、プロジェクトを複数のクレートに分割することで、より多くのコードを並列でビルドできるようになります。
欠点
- これは「依存関係地獄」につながる可能性があります。プロジェクトが同時に複数の競合するバージョンのクレートに依存する場合です。例えば、
urlクレートにはバージョン1.0と0.5の両方があります。url:1.0のUrlとurl:0.5のUrlは異なる型であるため、url:0.5を使用するHTTPクライアントは、url:1.0を使用するWebスクレイパーからのUrl値を受け入れません。 - crates.io上のパッケージはキュレーションされていません。クレートは不適切に書かれている可能性があり、役に立たないドキュメントを持っている可能性があり、または明らかに悪意のある可能性があります。
- コンパイラはデフォルトでリンク時最適化(LTO)を実行しないため、2つの小さなクレートは1つの大きなクレートよりも最適化されない可能性があります。
例
urlクレートは、URLを扱うためのツールを提供します。
num_cpusクレートは、マシン上のCPU数を照会する関数を提供します。
ref_sliceクレートは、&Tを&[T]に変換するための関数を提供します。(歴史的な例)
関連項目
小さなモジュールにunsafeを封じ込める
説明
unsafeなコードがある場合は、必要な不変条件を維持できる最小限のモジュールを作成し、unsafeの上に最小限の安全なインターフェースを構築します。これをより大きなモジュールに埋め込み、そのモジュールは安全なコードのみを含み、使いやすいインターフェースを提供します。なお、外側のモジュールはunsafeなコードを直接呼び出すunsafe関数やメソッドを含むことができます。ユーザーはこれを使用して速度上の利点を得ることができます。
利点
- 監査が必要なunsafeなコードを制限できる
- 内側のモジュールの保証に依存できるため、外側のモジュールの記述がはるかに簡単になる
欠点
- 適切なインターフェースを見つけるのが難しい場合がある
- 抽象化により非効率性が生じる可能性がある
例
toolshedcrateは、unsafeな操作をサブモジュールに含め、ユーザーに安全なインターフェースを提供しているstdのStringクラスは、内容が有効なUTF-8でなければならないという不変条件を追加したVec<u8>のラッパーである。Stringの操作はこの動作を保証する。ただし、ユーザーはunsafeメソッドを使用してStringを作成するオプションがあり、その場合は内容の妥当性を保証する責任がユーザーにある
参照
FFI パターン
FFIコードを書くことは、それ自体が1つのコース全体に相当します。しかし、ここには経験の浅いunsafe Rustユーザーのための指針となり、落とし穴を避けるのに役立ついくつかのイディオムがあります。
このセクションには、FFIを行う際に役立つ可能性のあるデザインパターンが含まれています。
-
オブジェクトベースAPI - メモリ安全性の特性が優れており、何が安全で何が安全でないかの明確な境界を持つ設計
-
型の統合とラッパー - 複数のRustの型を不透明な「オブジェクト」にグループ化する
オブジェクトベースAPI
説明
他言語に公開されるRust APIを設計する際、通常のRust API設計とは反対の重要な設計原則があります:
- すべてのカプセル化された型は、Rustが所有し、ユーザーが管理し、不透明であるべきです。
- すべてのトランザクショナルデータ型は、ユーザーが所有し、透過的であるべきです。
- すべてのライブラリの振る舞いは、カプセル化された型に作用する関数であるべきです。
- すべてのライブラリの振る舞いは、構造ではなく由来/ライフタイムに基づく型にカプセル化されるべきです。
動機
Rustには他言語へのFFIサポートが組み込まれています。これは、クレート作成者が異なるABIを通じてC互換APIを提供する方法を提供することで実現されています(この実践においては重要ではありませんが)。
よく設計されたRust FFIは、C API設計原則に従いつつ、可能な限りRust側の設計を妥協しないようにします。外部APIには3つの目標があります:
- ターゲット言語で使いやすくする。
- API がRust側の内部的な unsafe を可能な限り強制しないようにする。
- メモリ安全性違反とRustの
未定義動作の可能性を可能な限り小さく保つ。
Rustコードは、ある時点を超えて外部言語のメモリ安全性を信頼する必要があります。しかし、Rust側のすべてのunsafeコードは、バグの機会であり、未定義動作を悪化させる機会でもあります。
例えば、ポインタの由来が間違っている場合、無効なメモリアクセスによるセグメンテーション違反になる可能性があります。しかし、unsafeコードによって操作されると、完全なヒープ破壊になる可能性があります。
オブジェクトベースAPI設計により、良好なメモリ安全性特性を持つshimを記述でき、安全なものとunsafeなものの明確な境界を作ることができます。
コード例
POSIX標準は、DBMとして知られるファイルベースのデータベースにアクセスするためのAPIを定義しています。これは「オブジェクトベース」APIの優れた例です。
以下はCでの定義で、FFIに関わる方々には読みやすいはずです。下記の解説は、細かい点を見逃した方々の理解を助けるでしょう。
struct DBM;
typedef struct { void *dptr, size_t dsize } datum;
int dbm_clearerr(DBM *);
void dbm_close(DBM *);
int dbm_delete(DBM *, datum);
int dbm_error(DBM *);
datum dbm_fetch(DBM *, datum);
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
DBM *dbm_open(const char *, int, mode_t);
int dbm_store(DBM *, datum, datum, int);
このAPIは2つの型を定義しています:DBMとdatum。
DBM型は、上記で「カプセル化された」型と呼ばれました。これは内部状態を含むように設計されており、ライブラリの振る舞いのエントリーポイントとして機能します。
これはユーザーにとって完全に不透明であり、ユーザーはそのサイズやレイアウトを知らないため、DBMを自分で作成することはできません。代わりに、dbm_openを呼び出す必要があり、それはポインタのみを提供します。
これは、すべてのDBMがRustの意味でライブラリによって「所有」されていることを意味します。未知のサイズの内部状態は、ユーザーではなくライブラリによって制御されるメモリに保持されます。ユーザーはopenとcloseでそのライフサイクルを管理し、他の関数で操作を実行することしかできません。
datum型は、上記で「トランザクショナル」型と呼ばれました。これはライブラリとユーザー間の情報交換を容易にするように設計されています。
データベースは「非構造化データ」を格納するように設計されており、事前定義された長さや意味はありません。その結果、datumはRustスライスに相当するCの等価物です:バイトの塊と、その数のカウント。主な違いは型情報がないことで、これがvoidが示すものです。
このヘッダーはライブラリの視点から書かれていることに注意してください。ユーザーはおそらく既知のサイズを持つ何らかの型を使用しているでしょう。しかし、ライブラリは気にせず、Cのキャストルールにより、ポインタの背後にある任意の型はvoidにキャストできます。
前述のように、この型はユーザーにとって透過的です。しかし、この型はユーザーが所有します。これには、内部のポインタのため、微妙な影響があります。問題は、そのポインタが指すメモリを誰が所有するかです。
メモリ安全性のための最良の答えは「ユーザー」です。しかし、値を取得する場合など、ユーザーは正しく割り当てる方法を知りません(値の長さがわからないため)。この場合、ライブラリコードはユーザーがアクセスできるヒープ(Cライブラリのmallocやfreeなど)を使用し、Rustの意味で所有権を移転することが期待されます。
これはすべて推測に見えるかもしれませんが、これがCにおけるポインタの意味です。Rustと同じことを意味します:「ユーザー定義のライフタイム」。ライブラリのユーザーは、正しく使用するためにドキュメントを読んで理解する必要があります。とはいえ、ユーザーが間違えた場合の結果が少ないか大きいかという決定があります。それらを最小化することがこのベストプラクティスの目的であり、鍵は透過的なすべてのものの所有権を移転することです。
利点
これにより、ユーザーが守らなければならないメモリ安全性保証の数を比較的少数に最小化できます:
dbm_openによって返されないポインタで関数を呼び出さない(無効なアクセスまたは破損)。- closeの後にポインタで関数を呼び出さない(解放後の使用)。
- 任意の
datumのdptrはNULLであるか、宣伝された長さの有効なメモリスライスを指している必要があります。
さらに、多くのポインタ由来の問題を回避します。その理由を理解するために、代替案を詳しく検討しましょう:キーの反復。
Rustはイテレータでよく知られています。実装する際、プログラマーは所有者に制限されたライフタイムを持つ別の型を作成し、Iteratorトレイトを実装します。
以下はDBMに対するRustでの反復の実装です:
struct Dbm { ... }
impl Dbm {
/* ... */
pub fn keys<'it>(&'it self) -> DbmKeysIter<'it> { ... }
/* ... */
}
struct DbmKeysIter<'it> {
owner: &'it Dbm,
}
impl<'it> Iterator for DbmKeysIter<'it> { ... }
これはクリーンで、慣用的で、安全です。Rustの保証のおかげです。しかし、単純なAPI変換がどのように見えるか考えてみましょう:
#[no_mangle]
pub extern "C" fn dbm_iter_new(owner: *const Dbm) -> *mut DbmKeysIter {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
iter: *mut DbmKeysIter,
key_out: *const datum
) -> libc::c_int {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
// THIS API IS A BAD IDEA! For real applications, use object-based design instead.
}
このAPIは重要な情報を失います:イテレータのライフタイムは、それを所有するDbmオブジェクトのライフタイムを超えてはなりません。ライブラリのユーザーは、イテレータが反復するデータより長生きするような使い方をする可能性があり、初期化されていないメモリの読み取りを引き起こします。
Cで書かれたこの例には、後で説明するバグが含まれています:
int count_key_sizes(DBM *db) {
// DO NOT USE THIS FUNCTION. IT HAS A SUBTLE BUT SERIOUS BUG!
datum key;
int len = 0;
if (!dbm_iter_new(db)) {
dbm_close(db);
return -1;
}
int l;
while ((l = dbm_iter_next(owner, &key)) >= 0) { // an error is indicated by -1
free(key.dptr);
len += key.dsize;
if (l == 0) { // end of the iterator
dbm_close(owner);
}
}
if l >= 0 {
return -1;
} else {
return len;
}
}
このバグは古典的です。イテレータが反復終了マーカーを返したときに何が起こるかを示します:
- ループ条件が
lをゼロに設定し、0 >= 0であるためループに入ります。 - 長さが増加します。この場合はゼロだけ。
- if文が真になるため、データベースが閉じられます。ここにbreak文があるべきです。
- ループ条件が再度実行され、閉じられたオブジェクトに対して
next呼び出しが発生します。
このバグの最悪な部分は?Rust実装が注意深い場合、このコードはほとんどの場合動作します!Dbmオブジェクトのメモリがすぐに再利用されなければ、内部チェックがほぼ確実に失敗し、イテレータはエラーを示す-1を返します。しかし時折、セグメンテーション違反を引き起こし、さらに悪いことに、意味不明なメモリ破壊を引き起こします!
これのどれもRustでは回避できません。Rustの観点からは、それらのオブジェクトをヒープに置き、それらへのポインタを返し、それらのライフタイムの制御を放棄しました。Cコードは単に「きちんと振る舞う」必要があります。
プログラマーはAPIドキュメントを読んで理解する必要があります。Cではそれが当然と考える人もいますが、良いAPI設計はこのリスクを軽減できます。DBMのPOSIX APIは、イテレータの所有権を親と統合することでこれを実現しました:
datum dbm_firstkey(DBM *);
datum dbm_nextkey(DBM *);
したがって、すべてのライフタイムが結び付けられ、そのような安全性違反が防止されました。
欠点
しかし、この設計選択には多くの欠点もあり、それも考慮すべきです。
まず、API自体が表現力に欠けるようになります。POSIX DBMでは、オブジェクトごとに1つのイテレータしかなく、すべての呼び出しがその状態を変更します。これは安全ではありますが、ほぼすべての言語のイテレータよりもはるかに制限的です。おそらく他の関連オブジェクトでは、ライフタイムがあまり階層的でない場合、この制限は安全性よりもコストが高くなります。
次に、APIの部分の関係によっては、重要な設計努力が必要になる場合があります。より簡単な設計ポイントの多くには、他のパターンが関連付けられています:
-
ラッパー型の統合は、複数のRust型を不透明な「オブジェクト」にグループ化します
-
FFIエラー処理は、整数コードとセンチネル戻り値(
NULLポインタなど)を使用したエラー処理を説明します -
外部文字列の受け入れは、最小限のunsafeコードで文字列を受け入れることができ、FFIへの文字列の受け渡しよりも正しく実装しやすいです
しかし、すべてのAPIがこの方法で実行できるわけではありません。対象者が誰であるかは、プログラマーの最善の判断に委ねられています。
ラッパーへの型の統合
説明
このパターンは、メモリ安全性に関する露出面を最小限に抑えながら、複数の関連する型を優雅に処理できるように設計されています。
Rustのエイリアシング規則の基礎の一つはライフタイムです。これにより、型間のアクセスパターンの多くが、データ競合の安全性を含めてメモリ安全であることが保証されます。
しかし、Rust の型が他の言語にエクスポートされる場合、通常はポインタに変換されます。Rustにおいて、ポインタは「ユーザーがポインタの指す先のライフタイムを管理する」ことを意味します。メモリ安全性を回避するのはユーザーの責任です。
したがって、ユーザーコードに対してある程度の信頼が必要とされ、特に解放後使用(use-after-free)については、Rustは何もできません。しかし、APIの設計によっては、他の言語で書かれたコードに課す負担が高いものもあります。
最もリスクの低いAPIは「統合ラッパー」であり、オブジェクトに対するすべての可能な相互作用を「ラッパー型」に折りたたみながら、Rust APIをクリーンに保ちます。
コード例
これを理解するために、エクスポートするAPIの古典的な例を見てみましょう:コレクションの反復処理です。
そのAPIは次のようになります:
- イテレータは
first_keyで初期化されます。 next_keyへの各呼び出しでイテレータが進みます。- イテレータが最後にある場合、
next_keyへの呼び出しは何もしません。 - 上記のように、イテレータはコレクションに「ラップされます」(ネイティブなRust APIとは異なります)。
イテレータが nth() を効率的に実装している場合、各関数呼び出しに対して一時的なものにすることができます:
struct MySetWrapper {
myset: MySet,
iter_next: usize,
}
impl MySetWrapper {
pub fn first_key(&mut self) -> Option<&Key> {
self.iter_next = 0;
self.next_key()
}
pub fn next_key(&mut self) -> Option<&Key> {
if let Some(next) = self.myset.keys().nth(self.iter_next) {
self.iter_next += 1;
Some(next)
} else {
None
}
}
}
その結果、ラッパーはシンプルで、unsafe コードを含みません。
利点
これにより、型間のライフタイムに関する問題を回避し、APIをより安全に使用できるようになります。これが回避する利点と落とし穴の詳細については、オブジェクトベースのAPIを参照してください。
欠点
多くの場合、型をラップすることは非常に難しく、時にはRust APIの妥協点が物事を容易にすることがあります。
例として、nth() を効率的に実装していないイテレータを考えてみましょう。オブジェクトが内部で反復を処理するための特別なロジックを入れるか、外部関数API専用の効率的な異なるアクセスパターンをサポートする価値は間違いなくあります。
イテレータをラップしようとする試み(そして失敗)
任意の型のイテレータをAPIに正しくラップするには、ラッパーはCバージョンのコードが行うことを行う必要があります:イテレータのライフタイムを消去し、手動で管理します。
言うまでもなく、これは非常に難しいです。
これは一つの落とし穴の例です。
MySetWrapper の最初のバージョンは次のようになります:
struct MySetWrapper {
myset: MySet,
iter_next: usize,
// created from a transmuted Box<KeysIter + 'self>
iterator: Option<NonNull<KeysIter<'static>>>,
}
transmute を使用してライフタイムを延長し、ポインタでそれを隠すことで、すでに醜くなっています。しかし、さらに悪いことに:他の操作がRustの未定義動作を引き起こす可能性があります。
ラッパー内の MySet は、反復処理中に他の関数によって操作される可能性があることを考えてください。たとえば、反復処理中のキーに新しい値を格納するなどです。APIはこれを阻止せず、実際にいくつかの類似したCライブラリはこれを期待しています。
myset_store の単純な実装は次のようになります:
pub mod unsafe_module {
// other module content
pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
// DO NOT USE THIS CODE. IT IS UNSAFE TO DEMONSTRATE A PROBLEM.
let myset: &mut MySet = unsafe {
// SAFETY: whoops, UB occurs in here!
&mut (*myset).myset
};
/* ...check and cast key and value data... */
match myset.store(casted_key, casted_value) {
Ok(_) => 0,
Err(e) => e.into(),
}
}
}
この関数が呼び出されたときにイテレータが存在する場合、Rustのエイリアシング規則の一つに違反したことになります。Rustによれば、このブロック内の可変参照はオブジェクトへの排他的アクセスを持たなければなりません。イテレータが単に存在するだけであれば、それは排他的ではないため、未定義動作となります!1
これを避けるためには、可変参照が本当に排他的であることを保証する方法が必要です。それは基本的に、存在している間にイテレータの共有参照をクリアし、その後再構築することを意味します。ほとんどの場合、それでもCバージョンよりも効率が低くなります。
なぜCはこれをより効率的に行えるのか疑問に思う人もいるかもしれません。答えは、Cがズルをしているからです。Rustのエイリアシング規則が問題であり、Cは単にポインタに対してそれらを無視します。その代わりに、マニュアルで「スレッドセーフではない」と宣言されているコードを見ることが一般的です。実際、GNU Cライブラリには、並行動作専用の全語彙があります!
Rustは、安全性とCコードでは達成できない最適化の両方のために、すべてを常にメモリ安全にすることを好みます。特定のショートカットへのアクセスを拒否されることは、Rustプログラマーが支払う必要がある代償です。
頭を悩ませているCプログラマーのために、このコード中にイテレータが読み取られる必要はなく、UBが発生します。排他性規則は、イテレータの共有参照による不整合な観測を引き起こす可能性のあるコンパイラの最適化も可能にします(例:スタックスピルや効率のための命令の並べ替え)。これらの観測は、可変参照が作成された後のいつでも発生する可能性があります。
アンチパターン
アンチパターンとは、「通常は効果的でなく、非常に逆効果になるリスクがある繰り返し発生する問題」に対する解決策です。問題を解決する方法を知ることと同じくらい価値があるのは、問題を解決しない方法を知ることです。アンチパターンは、デザインパターンと比較して検討すべき優れた反例を提供します。アンチパターンはコードに限定されません。例えば、プロセスもアンチパターンになり得ます。
借用チェッカーを満たすためのクローン
説明
借用チェッカーは、以下のいずれかを保証することで、Rustユーザーが安全でないコードを開発するのを防ぎます:可変参照が1つだけ存在するか、または複数の不変参照が存在するかのいずれかです。記述されたコードがこれらの条件を満たさない場合、開発者が変数をクローンすることでコンパイラエラーを解決しようとすると、このアンチパターンが発生します。
例
#![allow(unused)] fn main() { // define any variable let mut x = 5; // Borrow `x` -- but clone it first let y = &mut (x.clone()); // without the x.clone() two lines prior, this line would fail on compile as // x has been borrowed // thanks to x.clone(), x was never borrowed, and this line will run. println!("{x}"); // perform some action on the borrow to prevent rust from optimizing this //out of existence *y += 1; }
動機
特に初心者にとって、借用チェッカーの混乱する問題を解決するためにこのパターンを使用することは魅力的です。しかし、重大な結果があります。.clone()を使用すると、データのコピーが作成されます。2つの間の変更は同期されません – まるで2つの完全に別々の変数が存在するかのようです。
特殊なケースがあります – Rc<T>はクローンを賢く処理するように設計されています。これは、データのコピーを正確に1つだけ内部で管理します。Rcで.clone()を呼び出すと、ソースのRcと同じデータを指す新しいRcインスタンスが生成され、参照カウントが増加します。Rcのスレッドセーフな対応物であるArcにも同じことが当てはまります。
一般的に、クローンは意図的に行われるべきであり、その結果を完全に理解している必要があります。借用チェッカーのエラーを消すためにクローンが使用されている場合、それはこのアンチパターンが使用されている可能性がある良い兆候です。
.clone()がアンチパターンの兆候であっても、以下のような場合には非効率的なコードを書くことは問題ありません:
- 開発者が所有権にまだ慣れていない場合
- コードに厳しい速度やメモリの制約がない場合(ハッカソンプロジェクトやプロトタイプなど)
- 借用チェッカーを満たすことが本当に複雑で、パフォーマンスよりも可読性を優先したい場合
不要なクローンが疑われる場合、クローンが必要かどうかを評価する前に、Rust Bookの所有権に関する章を完全に理解する必要があります。
また、プロジェクトで常にcargo clippyを実行してください。これにより、.clone()が不要な一部のケースが検出されます。
参照
mem::{take(_), replace(_)}to keep owned values in changed enumsRc<T>documentation, which handles .clone() intelligentlyArc<T>documentation, a thread-safe reference-counting pointer- Tricks with ownership in Rust
#![deny(warnings)]
説明
善意のあるクレート作成者は、自分のコードが警告なしでビルドされることを保証したいと考えています。そのため、クレートのルートに以下のアノテーションを付けます:
例
#![allow(unused)] #![deny(warnings)] fn main() { // All is well. }
利点
短く、何か問題があればビルドを停止します。
欠点
コンパイラが警告付きでビルドすることを禁止することで、クレート作成者はRustの有名な安定性からオプトアウトすることになります。時には新機能や古い誤機能が、物事の行い方の変更を必要とすることがあり、そのため一定の猶予期間中にwarnを出すlintが書かれ、その後denyに変更されます。
例えば、ある型が同じメソッドを持つ2つのimplを持つことができることが発見されました。これは悪い考えだと判断されましたが、移行をスムーズにするために、将来のリリースでハードエラーになる前に、この事実に遭遇した人に警告を与えるためにoverlapping-inherent-impls lintが導入されました。
また、時にはAPIが非推奨になることがあり、以前は警告がなかった場所で警告が発生するようになります。
これらすべてが相まって、何かが変更されるたびにビルドが壊れる可能性があります。
さらに、追加のlintを提供するクレート(例:rust-clippy)は、アノテーションを削除しない限り使用できなくなります。これは–cap-lintsによって緩和されます。--cap-lints=warnコマンドライン引数は、すべてのdeny lintエラーを警告に変えます。
代替案
この問題に取り組むには2つの方法があります。第一に、ビルド設定をコードから切り離すことができ、第二に、明示的に拒否したいlintを指定することができます。
以下のコマンドラインは、すべての警告をdenyに設定してビルドします:
RUSTFLAGS="-D warnings" cargo build
これは、コードの変更を必要とせずに、個々の開発者が実行できます(またはTravisのようなCIツールで設定できますが、何かが変更されたときにビルドが壊れる可能性があることを覚えておいてください)。
あるいは、コード内でdenyしたいlintを指定することもできます。以下は、(おそらく)安全に拒否できる警告lintのリストです(rustc 1.48.0時点):
#![deny(
bad_style,
const_err,
dead_code,
improper_ctypes,
non_shorthand_field_patterns,
no_mangle_generic_items,
overflowing_literals,
path_statements,
patterns_in_fns_without_body,
private_in_public,
unconditional_recursion,
unused,
unused_allocation,
unused_comparisons,
unused_parens,
while_true
)]
さらに、以下のallowされたlintをdenyするのも良いかもしれません:
#![deny(
missing_debug_implementations,
missing_docs,
trivial_casts,
trivial_numeric_casts,
unused_extern_crates,
unused_import_braces,
unused_qualifications,
unused_results
)]
missing-copy-implementationsをリストに追加したい人もいるかもしれません。
deprecated lintを明示的に追加しなかったことに注意してください。将来さらに非推奨のAPIが追加されることはほぼ確実だからです。
関連項目
- すべてのclippy lintのコレクション
- deprecate attribute のドキュメント
- システム上のlintのリストを見るには
rustc -W helpと入力してください。また、一般的なオプションのリストを見るにはrustc --helpと入力してください - rust-clippyは、より良いRustコードのためのlintのコレクションです
Deref ポリモーフィズム
説明
構造体間の継承をエミュレートするために Deref トレイトを誤用し、メソッドを再利用します。
例
Java などのオブジェクト指向言語で一般的な以下のパターンをエミュレートしたい場合があります:
class Foo {
void m() { ... }
}
class Bar extends Foo {}
public static void main(String[] args) {
Bar b = new Bar();
b.m();
}
これを実現するために、deref ポリモーフィズムアンチパターンを使用できます:
use std::ops::Deref; struct Foo {} impl Foo { fn m(&self) { //.. } } struct Bar { f: Foo, } impl Deref for Bar { type Target = Foo; fn deref(&self) -> &Foo { &self.f } } fn main() { let b = Bar { f: Foo {} }; b.m(); }
Rust には構造体の継承がありません。代わりにコンポジションを使用し、Bar に Foo のインスタンスを含めます(フィールドが値であるため、インライン格納されます。そのため、フィールドがある場合、メモリレイアウトは Java 版と同じになります(おそらく。確実にしたい場合は #[repr(C)] を使用すべきです))。
メソッド呼び出しを機能させるために、Foo をターゲットとして Bar に Deref を実装します(埋め込まれた Foo フィールドを返します)。これは、Bar を参照外し(例えば * を使用)すると Foo が得られることを意味します。これはかなり奇妙です。通常、参照外しは T への参照から T を与えますが、ここでは2つの無関係な型があります。しかし、ドット演算子は暗黙的な参照外しを行うため、メソッド呼び出しは Bar だけでなく Foo のメソッドも検索することを意味します。
利点
少しのボイラープレートを節約できます。例えば:
impl Bar {
fn m(&self) {
self.f.m()
}
}
欠点
最も重要なのは、これが驚くべきイディオムであることです - このコードを読む将来のプログラマーは、これが起こることを期待しません。なぜなら、意図された通り(およびドキュメント化された通りなど)に Deref トレイトを使用するのではなく、誤用しているからです。また、ここでのメカニズムが完全に暗黙的であるためでもあります。
このパターンは、Java や C++ の継承のような Foo と Bar の間にサブタイピングを導入しません。さらに、Foo によって実装されたトレイトは自動的に Bar に実装されないため、このパターンは境界チェックとジェネリックプログラミングに悪影響を与えます。
このパターンを使用すると、self に関してほとんどのオブジェクト指向言語とは微妙に異なるセマンティクスが与えられます。通常はサブクラスへの参照のままですが、このパターンではメソッドが定義されている「クラス」になります。
最後に、このパターンは単一継承のみをサポートし、インターフェース、クラスベースのプライバシー、その他の継承関連の機能の概念がありません。そのため、Java の継承などに慣れたプログラマーにとって微妙に驚くべき体験を与えます。
議論
唯一の良い代替案はありません。正確な状況に応じて、トレイトを使用して再実装するか、Foo にディスパッチするファサードメソッドを手動で記述する方が良い場合があります。Rust にこれに類似した継承メカニズムを追加する予定ですが、安定版 Rust に到達するまでにはかなりの時間がかかる可能性があります。詳細については、これらのブログ投稿とこのRFC issueを参照してください。
Deref トレイトは、カスタムポインタ型の実装用に設計されています。意図は、T へのポインタを T に変換することであり、異なる型間の変換ではありません。これがトレイト定義によって強制されていない(おそらくできない)のは残念なことです。
Rust は、明示的メカニズムと暗黙的メカニズムの間で慎重なバランスを取ろうとしており、型間の明示的な変換を好みます。ドット演算子での自動参照外しは、エルゴノミクスが暗黙的メカニズムを強く支持するケースですが、意図は、これが間接参照の度合いに限定され、任意の型間の変換ではないことです。
関連項目
- コレクションはスマートポインタのイディオム
- より少ないボイラープレートのための委譲クレート: delegate または ambassador
Derefトレイトのドキュメント
Rustの関数型的な使用法
Rustは命令型言語ですが、多くの関数型プログラミングのパラダイムに従っています。
コンピュータサイエンスにおいて、関数型プログラミングとは、関数を適用し組み合わせることでプログラムを構築するプログラミングパラダイムです。これは宣言的プログラミングパラダイムであり、関数定義は、プログラムの状態を変更する一連の命令型文ではなく、それぞれが値を返す式のツリーとなります。
プログラミングパラダイム
命令型プログラミングの経験がある人が関数型プログラムを理解する上で最も大きなハードルの一つは、考え方の転換です。命令型プログラムはどのようにやるかを記述するのに対し、宣言型プログラムは何をするかを記述します。 1から10までの数値を合計する例を見てみましょう。
命令型
#![allow(unused)] fn main() { let mut sum = 0; for i in 1..11 { sum += i; } println!("{sum}"); }
命令型プログラムでは、何が起こっているかを理解するためにコンパイラの役割を演じる必要があります。
ここでは、sumを0から始めます。次に、1から10までの範囲を反復処理します。ループを通過するたびに、範囲内の対応する値を加算します。
そして、それを出力します。
i | sum |
|---|---|
| 1 | 1 |
| 2 | 3 |
| 3 | 6 |
| 4 | 10 |
| 5 | 15 |
| 6 | 21 |
| 7 | 28 |
| 8 | 36 |
| 9 | 45 |
| 10 | 55 |
これは、私たちの多くがプログラミングを始める方法です。プログラムは一連のステップであることを学びます。
宣言型
#![allow(unused)] fn main() { println!("{}", (1..11).fold(0, |a, b| a + b)); }
わあ!これは本当に違いますね!何が起こっているのでしょうか?宣言型プログラムでは、どのようにやるかではなく、何をするかを記述することを思い出してください。foldは関数を
合成する関数です。この名前はHaskellの慣例から来ています。
ここでは、加算の関数(このクロージャ:|a, b| a + b)を1から10までの範囲と合成しています。0は開始点なので、最初はaが0です。bは範囲の最初の要素である1です。0 + 1 = 1が結果です。そして、a = 1、b = 2で再びfoldし、1 + 2 = 3が次の結果になります。このプロセスは範囲の最後の要素10に到達するまで続きます。
a | b | result |
|---|---|---|
| 0 | 1 | 1 |
| 1 | 2 | 3 |
| 3 | 3 | 6 |
| 6 | 4 | 10 |
| 10 | 5 | 15 |
| 15 | 6 | 21 |
| 21 | 7 | 28 |
| 28 | 8 | 36 |
| 36 | 9 | 45 |
| 45 | 10 | 55 |
ジェネリクスを型クラスとして使用する
説明
Rustの型システムは、命令型言語(JavaやC++など)よりも関数型言語(Haskellなど)のように設計されています。その結果、Rustは多くの種類のプログラミング問題を「静的型付け」の問題に変換できます。これは関数型言語を選択する最大のメリットの1つであり、Rustのコンパイル時保証の多くにとって重要です。
この考え方の重要な部分は、ジェネリック型の動作方法です。例えばC++やJavaでは、ジェネリック型はコンパイラのためのメタプログラミング構造です。C++のvector<int>とvector<char>は、vector型(テンプレートとして知られる)の同じボイラープレートコードを2つの異なる型で埋めた、単なる2つの異なるコピーです。
Rustでは、ジェネリック型パラメータは関数型言語で「型クラス制約」として知られるものを作成し、エンドユーザーによって埋められる各異なるパラメータは実際に型を変更します。言い換えれば、Vec<isize>とVec<char>は2つの異なる型であり、型システムのすべての部分によって区別されます。
これは**単相化(monomorphization)**と呼ばれ、**多相的(polymorphic)**なコードから異なる型が作成されます。この特別な動作では、implブロックでジェネリックパラメータを指定する必要があります。ジェネリック型の異なる値は異なる型を引き起こし、異なる型は異なるimplブロックを持つことができます。
オブジェクト指向言語では、クラスは親から動作を継承できます。しかし、これにより型クラスの特定のメンバーに追加の動作だけでなく、追加の振る舞いも付加できます。
最も近い同等物は、JavaScriptやPythonの実行時多相性です。これらの言語では、任意のコンストラクタによってオブジェクトに新しいメンバーを自由に追加できます。しかし、これらの言語とは異なり、Rustのすべての追加メソッドは使用時に型チェックできます。なぜなら、ジェネリクスが静的に定義されているからです。これにより、安全性を保ちながらより使いやすくなります。
例
一連の研究室マシン用のストレージサーバーを設計しているとします。関連するソフトウェアのため、サポートする必要がある2つの異なるプロトコルがあります: BOOTP(PXEネットワークブート用)とNFS(リモートマウントストレージ用)です。
目標は、Rustで書かれた1つのプログラムで両方を処理できるようにすることです。プロトコルハンドラを持ち、両方の種類のリクエストをリッスンします。メインアプリケーションロジックは、研究室管理者が実際のファイルのストレージとセキュリティコントロールを設定できるようにします。
研究室のマシンからのファイルリクエストには、どのプロトコルから来たかに関係なく、同じ基本情報が含まれています: 認証方法と取得するファイル名です。単純な実装は次のようになります:
enum AuthInfo {
Nfs(crate::nfs::AuthInfo),
Bootp(crate::bootp::AuthInfo),
}
struct FileDownloadRequest {
file_name: PathBuf,
authentication: AuthInfo,
}
この設計は十分にうまく機能するかもしれません。しかし今、プロトコル固有のメタデータを追加する必要があるとします。例えば、NFSでは、追加のセキュリティルールを適用するためにマウントポイントを特定したいとします。
現在の構造体が設計されている方法では、プロトコルの決定は実行時まで残されます。つまり、一方のプロトコルには適用され他方には適用されないメソッドでは、プログラマが実行時チェックを行う必要があります。
NFSマウントポイントを取得する方法は次のようになります:
struct FileDownloadRequest {
file_name: PathBuf,
authentication: AuthInfo,
mount_point: Option<PathBuf>,
}
impl FileDownloadRequest {
// ... other methods ...
/// Gets an NFS mount point if this is an NFS request. Otherwise,
/// return None.
pub fn mount_point(&self) -> Option<&Path> {
self.mount_point.as_ref()
}
}
mount_point()のすべての呼び出し元はNoneをチェックし、それを処理するコードを書かなければなりません。これは、特定のコードパスでNFSリクエストのみが使用されることを知っている場合でも当てはまります!
異なるリクエストタイプが混同された場合にコンパイル時エラーを引き起こす方が、はるかに最適です。結局のところ、ライブラリから使用する関数を含むユーザーコードの全体のパスは、リクエストがNFSリクエストかBOOTPリクエストかを知っているでしょう。
Rustでは、これは実際に可能です! 解決策は、APIを分割するためにジェネリック型を追加することです。
これは次のようになります:
use std::path::{Path, PathBuf}; mod nfs { #[derive(Clone)] pub(crate) struct AuthInfo(String); // NFS session management omitted } mod bootp { pub(crate) struct AuthInfo(); // no authentication in bootp } // private module, lest outside users invent their own protocol kinds! mod proto_trait { use super::{bootp, nfs}; use std::path::{Path, PathBuf}; pub(crate) trait ProtoKind { type AuthInfo; fn auth_info(&self) -> Self::AuthInfo; } pub struct Nfs { auth: nfs::AuthInfo, mount_point: PathBuf, } impl Nfs { pub(crate) fn mount_point(&self) -> &Path { &self.mount_point } } impl ProtoKind for Nfs { type AuthInfo = nfs::AuthInfo; fn auth_info(&self) -> Self::AuthInfo { self.auth.clone() } } pub struct Bootp(); // no additional metadata impl ProtoKind for Bootp { type AuthInfo = bootp::AuthInfo; fn auth_info(&self) -> Self::AuthInfo { bootp::AuthInfo() } } } use proto_trait::ProtoKind; // keep internal to prevent impls pub use proto_trait::{Bootp, Nfs}; // re-export so callers can see them struct FileDownloadRequest<P: ProtoKind> { file_name: PathBuf, protocol: P, } // all common API parts go into a generic impl block impl<P: ProtoKind> FileDownloadRequest<P> { fn file_path(&self) -> &Path { &self.file_name } fn auth_info(&self) -> P::AuthInfo { self.protocol.auth_info() } } // all protocol-specific impls go into their own block impl FileDownloadRequest<Nfs> { fn mount_point(&self) -> &Path { self.protocol.mount_point() } } fn main() { // your code here }
このアプローチでは、ユーザーが誤って間違った型を使用した場合:
fn main() {
let mut socket = crate::bootp::listen()?;
while let Some(request) = socket.next_request()? {
match request.mount_point().as_ref() {
"/secure" => socket.send("Access denied"),
_ => {} // continue on...
}
// Rest of the code here
}
}
構文エラーが発生します。型FileDownloadRequest<Bootp>はmount_point()を実装していません。FileDownloadRequest<Nfs>型のみが実装しています。そしてそれはもちろん、BOOTPモジュールではなくNFSモジュールによって作成されます!
利点
第一に、複数の状態に共通するフィールドを重複排除できます。共有されないフィールドをジェネリックにすることで、一度実装されます。
第二に、implブロックが状態ごとに分解されるため、読みやすくなります。すべての状態に共通するメソッドは1つのブロックに一度だけ記述され、1つの状態に固有のメソッドは別のブロックにあります。
これらの両方により、コード行数が減り、より良く整理されます。
欠点
現在、これはコンパイラでの単相化の実装方法により、バイナリのサイズを増加させます。将来、実装が改善できることを期待しています。
代替案
-
構築または部分的な初期化のために型が「分割API」を必要とするように見える場合は、代わりにビルダーパターンを検討してください。
-
型間でAPIが変わらず、動作のみが変わる場合は、代わりにストラテジーパターンを使用する方が良いです。
関連項目
このパターンは標準ライブラリ全体で使用されています:
Vec<u8>はStringからキャストできますが、他のすべての型のVec<T>はできません。1- イテレータはバイナリヒープにキャストできますが、
Ordトレイトを実装する型を含む場合のみです。2 to_stringメソッドは、型strのCowにのみ特殊化されました。3
また、API柔軟性を可能にするために、いくつかの人気のあるクレートでも使用されています:
-
組み込みデバイス用に使用される
embedded-halエコシステムは、このパターンを広範に使用しています。例えば、組み込みピンを制御するために使用されるデバイスレジスタの設定を静的に検証できます。ピンがモードに設定されると、Pin<MODE>構造体を返し、そのジェネリックがそのモードで使用可能な関数を決定します。これらの関数はPin自体にはありません。4 -
hyperHTTPクライアントライブラリは、異なるプラガブルリクエストのためにリッチなAPIを公開するためにこれを使用しています。異なるコネクタを持つクライアントは、それらに異なるメソッドと異なるトレイト実装を持ちますが、コアメソッドのセットは任意のコネクタに適用されます。5 -
「型状態」パターン – オブジェクトが内部状態や不変条件に基づいてAPIを獲得・喪失する – は、同じ基本概念と若干異なる手法を使用してRustで実装されています。6
関数型言語のオプティクス
オプティクスは、関数型言語で一般的なAPI設計の一種です。これは純粋関数型の概念であり、Rustではあまり使われていません。
しかし、この概念を探求することは、ビジターなど、RustのAPIにおける他のパターンを理解するのに役立つかもしれません。また、ニッチなユースケースもあります。
これはかなり大きなトピックであり、その能力を完全に理解するには言語設計に関する実際の書籍が必要です。しかし、Rustにおける適用可能性はずっとシンプルです。
この概念の関連部分を説明するために、Serde-APIを例として使用します。これは、単にAPIドキュメントから理解することが多くの人にとって困難なものだからです。
その過程で、オプティクスと呼ばれる異なる特定のパターンを取り上げます。これらは、アイソ(Iso)、ポリアイソ(Poly Iso)、*プリズム(Prism)*です。
APIの例: Serde
APIを読むだけでSerdeの動作を理解しようとするのは、特に初めての場合は困難です。新しいデータフォーマットを解析するライブラリによって実装されるDeserializerトレイトを考えてみましょう:
pub trait Deserializer<'de>: Sized {
type Error: Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
// remainder omitted
}
そして、ジェネリックで渡されるVisitorトレイトの定義は次のとおりです:
pub trait Visitor<'de>: Sized {
type Value;
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where
E: Error;
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error;
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error;
// remainder omitted
}
ここでは多くの型消去が行われており、複数レベルの関連型が行き来しています。
しかし、全体像は何でしょうか? なぜVisitorが呼び出し側が必要とする部分をストリーミングAPIで返すだけにしないのでしょうか? なぜこれらの追加部分が必要なのでしょうか?
これを理解する一つの方法は、オプティクスと呼ばれる関数型言語の概念を見ることです。
これは、Rustに共通するパターン(失敗、型変換など)を促進するように設計された、動作とプロパティの合成を行う方法です。1
Rust言語は、これらを直接的にサポートする機能が非常に貧弱です。しかし、それらは言語自体の設計に現れており、その概念はRustのAPIの一部を理解するのに役立ちます。その結果、これはRustが行う方法で概念を説明しようと試みます。
これは、これらのAPIが達成しようとしているもの、つまり合成可能性の特定のプロパティに光を当てるかもしれません。
基本的なオプティクス
アイソ(Iso)
アイソは、2つの型間の値変換器です。これは非常にシンプルですが、概念的に重要な構成要素です。
例として、ドキュメントの索引として使用されるカスタムハッシュテーブル構造があるとします。2 キー(単語)には文字列を使い、値(ファイルオフセットなど)にはインデックスのリストを使います。
主要な機能は、このフォーマットをディスクにシリアライズできることです。「手早く簡単な」アプローチは、JSON形式の文字列との相互変換を実装することです。(エラーは今のところ無視されます。後で処理されます。)
関数型言語ユーザーが期待する通常の形式で書くと:
case class ConcordanceSerDe {
serialize: Concordance -> String
deserialize: String -> Concordance
}
したがって、アイソは異なる型の値を変換する関数のペアです: serializeとdeserialize。
直接的な実装:
#![allow(unused)] fn main() { use std::collections::HashMap; struct Concordance { keys: HashMap<String, usize>, value_table: Vec<(usize, usize)>, } struct ConcordanceSerde {} impl ConcordanceSerde { fn serialize(value: Concordance) -> String { todo!() } // invalid concordances are empty fn deserialize(value: String) -> Concordance { todo!() } } }
これはかなり馬鹿げているように見えるかもしれません。Rustでは、この種の動作は通常トレイトで行われます。結局のところ、標準ライブラリにはFromStrとToStringがあります。
しかし、それが次の主題に繋がります: ポリアイソです。
ポリアイソ(Poly Isos)
前の例は、単に2つの固定型の値間の変換でした。次のブロックはジェネリクスでそれを拡張し、より興味深いものです。
ポリアイソは、操作を任意の型に対してジェネリックにしながら、単一の型を返すことを可能にします。
これにより、解析に近づきます。エラーケースを無視した基本的なパーサーが何をするか考えてみましょう。繰り返しますが、これはその通常の形式です:
case class Serde[T] {
deserialize(String) -> T
serialize(T) -> String
}
ここに最初のジェネリック、変換される型Tがあります。
Rustでは、これは標準ライブラリの2つのトレイトのペアで実装できます: FromStrとToString。Rustバージョンはエラーも処理します:
pub trait FromStr: Sized {
type Err;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
pub trait ToString {
fn to_string(&self) -> String;
}
アイソと異なり、ポリアイソは複数の型の適用を許可し、それらをジェネリックに返します。これは基本的な文字列パーサーに必要なものです。
一見、これはパーサーを書くための良い選択肢のように見えます。実際に見てみましょう:
use anyhow;
use std::str::FromStr;
struct TestStruct {
a: usize,
b: String,
}
impl FromStr for TestStruct {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<TestStruct, Self::Err> {
todo!()
}
}
impl ToString for TestStruct {
fn to_string(&self) -> String {
todo!()
}
}
fn main() {
let a = TestStruct {
a: 5,
b: "hello".to_string(),
};
println!("Our Test Struct as JSON: {}", a.to_string());
}
これはかなり論理的に見えます。しかし、これには2つの問題があります。
まず、to_stringはAPIユーザーに「これはJSONです」と示しません。すべての型がJSON表現に同意する必要があり、Rust標準ライブラリの多くの型は既にそうなっていません。これを使用するのは適していません。これは独自のトレイトで簡単に解決できます。
しかし、2つ目のより微妙な問題があります: スケーラビリティです。
すべての型が手動でto_stringを書く場合、これは機能します。しかし、型をシリアライズ可能にしたいすべての人が大量のコード、そして場合によっては異なるJSONライブラリを書かなければならない場合、それはすぐに混乱に陥ります!
答えはSerdeの2つの主要な革新の1つです: データシリアライゼーション言語に共通する構造でRustデータを表現する独立したデータモデルです。その結果、Rustのコード生成能力を使用して、Visitorと呼ばれる中間変換型を作成できます。
これは、通常の形式で(再び、簡潔さのためにエラー処理をスキップして)以下を意味します:
case class Serde[T] {
deserialize: Visitor[T] -> T
serialize: T -> Visitor[T]
}
case class Visitor[T] {
toJson: Visitor[T] -> String
fromJson: String -> Visitor[T]
}
結果は1つのポリアイソと1つのアイソです(それぞれ)。これらの両方はトレイトで実装できます:
#![allow(unused)] fn main() { trait Serde { type V; fn deserialize(visitor: Self::V) -> Self; fn serialize(self) -> Self::V; } trait Visitor { fn to_json(self) -> String; fn from_json(json: String) -> Self; } }
Rust構造を独立した形式に変換する統一されたルールセットがあるため、型Tに関連するVisitorを作成するコード生成を行うことさえ可能です:
#[derive(Default, Serde)] // the "Serde" derive creates the trait impl block
struct TestStruct {
a: usize,
b: String,
}
// user writes this macro to generate an associated visitor type
generate_visitor!(TestStruct);
しかし、実際にそのアプローチを試してみましょう。
fn main() {
let a = TestStruct { a: 5, b: "hello".to_string() };
let a_data = a.serialize().to_json();
println!("Our Test Struct as JSON: {a_data}");
let b = TestStruct::deserialize(
generated_visitor_for!(TestStruct)::from_json(a_data));
}
結局のところ、変換は対称的ではありませんでした! 理論上は対称的ですが、自動生成されたコードでは、Stringから完全に変換するために必要な実際の型の名前が隠されています。型名を取得するには、何らかのgenerated_visitor_for!マクロが必要になります。
不格好ですが、動作します…部屋の中の象に到達するまでは。
現在サポートされているフォーマットはJSONのみです。より多くのフォーマットをサポートするにはどうすればよいでしょうか?
現在の設計では、すべてのコード生成を完全に書き直し、新しいSerdeトレイトを作成する必要があります。これは非常にひどく、まったく拡張可能ではありません!
それを解決するには、より強力な何かが必要です。
プリズム(Prism)
フォーマットを考慮に入れるには、次のような通常の形式の何かが必要です:
case class Serde[T, F] {
serialize: T, F -> String
deserialize: String, F -> Result[T, Error]
}
この構造はプリズムと呼ばれます。これはポリアイソよりもジェネリクスで「1レベル高い」です(この場合、「交差する」型Fが鍵です)。
残念ながら、Visitorはトレイトであるため(各実装には独自のカスタムコードが必要です)、これにはRustがサポートしていない種類のジェネリック型境界が必要になります。
幸いなことに、以前のVisitor型がまだあります。Visitorは何をしているのでしょうか? それは、各データ構造が自身が解析される方法を定義できるようにしようとしています。
では、ジェネリックフォーマット用にもう1つのインターフェイスを追加できたらどうでしょうか? そうすれば、Visitorは単なる実装の詳細であり、2つのAPIを「橋渡し」することになります。
通常の形式で:
case class Serde[T] {
serialize: F -> String
deserialize F, String -> Result[T, Error]
}
case class VisitorForT {
build: F, String -> Result[T, Error]
decompose: F, T -> String
}
case class SerdeFormat[T, V] {
toString: T, V -> String
fromString: V, String -> Result[T, Error]
}
そして、どうでしょう、底部にトレイトとして実装できるポリアイソのペアがあります!
したがって、Serde APIがあります:
- シリアライズされる各型は、
Serdeクラスに相当するDeserializeまたはSerializeを実装します - これらは、
Visitorトレイトを実装する型(実際には2つ、各方向に1つ)を取得します。これは通常(常にではありませんが)deriveマクロによって生成されたコードを通じて行われます。これには、データ型とSerdeデータモデルのフォーマット間で構築または分解するロジックが含まれています。 Deserializerトレイトを実装する型は、Visitorによって「駆動」されながら、フォーマットに固有のすべての詳細を処理します。
この分割とRustの型消去は、実際には間接的にプリズムを達成するためのものです。
これはDeserializerトレイトで確認できます:
pub trait Deserializer<'de>: Sized {
type Error: Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>;
// remainder omitted
}
そしてビジター:
pub trait Visitor<'de>: Sized {
type Value;
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where
E: Error;
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error;
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error;
// remainder omitted
}
そして、マクロによって実装されるDeserializeトレイト:
pub trait Deserialize<'de>: Sized {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>;
}
これは抽象的だったので、具体的な例を見てみましょう。
実際のSerdeは、以前のstruct ConcordanceにJSONの一部をどのようにデシリアライズしますか?
- ユーザーはデータをデシリアライズするためにライブラリ関数を呼び出します。これによりJSON形式に基づいた
Deserializerが作成されます。 - 構造体のフィールドに基づいて、
Visitorが作成されます(これについては後で詳しく説明します)。これは、それを表現するために必要なジェネリックデータモデルの各型を作成する方法を知っています:Vec(リスト)、u64、String。 - デシリアライザーはアイテムを解析しながら
Visitorへの呼び出しを行います。 Visitorは、見つかったアイテムが期待されているかどうかを示し、そうでない場合はデシリアライゼーションが失敗したことを示すエラーを発生させます。
上記の非常にシンプルな構造では、期待されるパターンは次のようになります:
- マップ(Serdeの
HashMapまたはJSONの辞書に相当するもの)の訪問を開始します。 - “keys“という文字列キーを訪問します。
- マップ値の訪問を開始します。
- 各アイテムについて、文字列キーと整数値を訪問します。
- マップの終わりを訪問します。
- マップをデータ構造の
keysフィールドに格納します。 - “value_table“という文字列キーを訪問します。
- リスト値の訪問を開始します。
- 各アイテムについて、整数を訪問します。
- リストの終わりを訪問します。
- リストを
value_tableフィールドに格納します。 - マップの終わりを訪問します。
しかし、どの「観察」パターンが期待されるかを決定するのは何でしょうか?
関数型プログラミング言語は、カリー化を使用して型自体に基づいて各型のリフレクションを作成できます。Rustはそれをサポートしていないため、すべての単一の型は、そのフィールドとそのプロパティに基づいて独自のコードを書く必要があります。
Serdeは、deriveマクロでこの使いやすさの課題を解決します:
use serde::Deserialize;
#[derive(Deserialize)]
struct IdRecord {
name: String,
customer_id: String,
}
そのマクロは、単に構造体にDeserializeと呼ばれるトレイトを実装させるimplブロックを生成します。
これは、構造体自体を作成する方法を決定する関数です。コードは構造体のフィールドに基づいて生成されます。解析ライブラリが呼び出されたとき(この例ではJSON解析ライブラリ)、それはDeserializerを作成し、それをパラメータとしてType::deserializeを呼び出します。
deserializeコードはその後、Visitorを作成します。この呼び出しはDeserializerによって「屈折」されます。すべてがうまくいけば、最終的にそのVisitorは解析される型に対応する値を構築して返します。
完全な例については、Serdeドキュメントを参照してください。
その結果、デシリアライズされる型はAPIの「最上層」のみを実装し、ファイルフォーマットは「最下層」のみを実装する必要があります。ジェネリック型がそれらを橋渡しするため、各部分はエコシステムの残りの部分と「単に機能する」ことができます。
結論として、Rustのジェネリクスに影響を受けた型システムは、このAPI設計で示されているように、これらの概念に近づき、その力を使用することができます。しかし、そのジェネリクスのための橋を作成するために手続きマクロも必要になるかもしれません。
このトピックについてもっと学ぶことに興味がある方は、次のセクションをご確認ください。
参照
- lens-rs crate - これらの例よりもクリーンなインターフェイスを持つ、事前構築されたレンズ実装
- Serde自体 - 詳細を理解する必要なく、エンドユーザー(つまり構造体を定義する人)にとってこれらの概念を直感的にします
- luminance - 同様のAPI設計を使用するコンピュータグラフィックスを描画するためのクレート。異なるピクセルタイプのバッファのための完全なプリズムを作成するための手続きマクロを含み、ジェネリックのままです
- Scalaにおけるレンズに関する記事 - Scalaの専門知識がなくても非常に読みやすい
- 論文: Profunctor Optics: Modular Data Accessors
- Musli - 異なるアプローチで同様の構造を使用しようとするライブラリ。例えば、ビジターを廃止する
追加リソース
補足となる有用なコンテンツのコレクション
講演
- Design Patterns in Rust by Nicholas Cameron at the PDRust (2016)
- Writing Idiomatic Libraries in Rust by Pascal Hertleif at RustFest (2017)
- Rust Programming Techniques by Nicholas Cameron at LinuxConfAu (2018)
書籍(オンライン)
デザイン原則
一般的なデザイン原則の概要
SOLID
- 単一責任の原則 (SRP): クラスは単一の責任のみを持つべきです。つまり、ソフトウェア仕様の一部への変更のみが、そのクラスの仕様に影響を与えるべきです。
- オープン・クローズドの原則 (OCP): 「ソフトウェアエンティティは拡張に対して開いており、修正に対して閉じているべきです。」
- リスコフの置換原則 (LSP): 「プログラム内のオブジェクトは、そのサブタイプのインスタンスで置き換え可能であり、プログラムの正確性を変更しないべきです。」
- インターフェース分離の原則 (ISP): 「多くのクライアント固有のインターフェースは、1つの汎用インターフェースよりも優れています。」
- 依存性逆転の原則 (DIP): 「具象ではなく、抽象に依存すべきです。」
CRP(複合再利用の原則)または継承よりコンポジション
「クラスは、基底クラスや親クラスからの継承よりも、コンポジション(望ましい機能を実装する他のクラスのインスタンスを含めること)によって、ポリモーフィックな動作とコードの再利用を優先すべきであるという原則」 - Knoernschild, Kirk (2002). Java Design - Objects, UML, and Process
DRY (Don’t Repeat Yourself)
「すべての知識は、システム内で単一、明確、権威ある表現を持たなければならない」
KISS原則
ほとんどのシステムは、複雑にするよりもシンプルに保つことで最もうまく機能します。したがって、シンプルさは設計における主要な目標であるべきであり、不要な複雑さは避けるべきです。
デメテルの法則 (LoD)
特定のオブジェクトは、「情報隠蔽」の原則に従って、他のもの(そのサブコンポーネントを含む)の構造やプロパティについて、可能な限り少ない仮定をすべきです。
契約による設計 (DbC)
ソフトウェア設計者は、ソフトウェアコンポーネントの正式で正確かつ検証可能なインターフェース仕様を定義すべきです。これは、抽象データ型の通常の定義を、事前条件、事後条件、不変条件で拡張したものです。
カプセル化
データと、そのデータを操作するメソッドとのバンドリング、またはオブジェクトのコンポーネントの一部への直接アクセスの制限。カプセル化は、構造化されたデータオブジェクトの値や状態をクラス内に隠し、権限のない者による直接アクセスを防ぐために使用されます。
コマンド・クエリ分離 (CQS)
「関数は抽象的な副作用を生成すべきではない…コマンド(手続き)のみが副作用を生成することが許される。」 - Bertrand Meyer: Object-Oriented Software Construction
最小驚きの原則 (POLA)
システムのコンポーネントは、ほとんどのユーザーが期待する方法で動作すべきです。その動作はユーザーを驚かせたり、当惑させたりすべきではありません。
言語的モジュール単位
「モジュールは、使用される言語の構文単位に対応しなければならない。」 - Bertrand Meyer: Object-Oriented Software Construction
自己文書化
「モジュールの設計者は、モジュールに関するすべての情報をモジュール自体の一部にするよう努めるべきです。」 - Bertrand Meyer: Object-Oriented Software Construction
統一アクセス
「モジュールが提供するすべてのサービスは、それらがストレージを通じて実装されているか、計算を通じて実装されているかを明かさない統一された表記法を通じて利用可能であるべきです。」 - Bertrand Meyer: Object-Oriented Software Construction
単一選択
「ソフトウェアシステムが一連の選択肢をサポートする必要がある場合、システム内の1つのモジュールのみが、それらの完全なリストを知っているべきです。」 - Bertrand Meyer: Object-Oriented Software Construction
永続性クロージャ
「ストレージメカニズムがオブジェクトを格納するときは必ず、そのオブジェクトの依存物も一緒に格納しなければなりません。検索メカニズムが以前に格納されたオブジェクトを取り出すときは必ず、まだ取り出されていないそのオブジェクトの依存物も取り出さなければなりません。」 - Bertrand Meyer: Object-Oriented Software Construction