はじめに
参加
この本への貢献に興味がある場合は、 コントリビューションガイドラインを確認してください。
ニュース
- 2025-12-14: 新しいパターンが追加されました: 複雑な型境界を避けるためにカスタムトレイトを使用する
- 2024-03-17: PDF形式の本をダウンロードできるようになりました。
デザインパターン
ソフトウェア開発では、発生する環境が異なっていても、共通点を持つ問題にしばしば遭遇します。目の前の課題を解決するうえでは実装の詳細が重要ですが、個別の事情を抽象化することで、汎用的に適用できる共通のプラクティスを見つけられます。
デザインパターンは、エンジニアリングにおいて繰り返し発生する問題に対する、再利用可能で検証済みの解決策の集まりです。デザインパターンは、ソフトウェアをよりモジュール化し、保守しやすく、拡張しやすくします。さらに、これらのパターンは開発者に共通言語を提供するため、チームで問題解決を行う際の効果的なコミュニケーションに優れたツールとなります。
覚えておいてください: 各パターンには、それぞれ独自のトレードオフがあります。単にどのように実装するかだけでなく、なぜ特定のパターンを選ぶのかに焦点を当てることが重要です。1
Rustにおけるデザインパターン
Rust は従来の意味でのオブジェクト指向言語ではなく、関数型の要素、強力な型システム、借用チェッカーなどを組み合わせることで独自性を持っています。このため、Rustのデザインパターンは、他の従来のオブジェクト指向プログラミング言語とは異なります。それが、この本を書くことにした理由です。楽しんで読んでいただければ幸いです!この本は3つの主要な章に分かれています:
- イディオム: コーディング時に従うべきガイドラインです。これらはコミュニティで共有されている規範です。十分な理由がある場合にのみ、そこから逸脱すべきです。
- デザインパターン: コーディング時によくある問題を解決するための方法です。
- アンチパターン: コーディング時によくある問題に対する、一見すると解決策に見える方法です。ただし、デザインパターンが利益をもたらす一方で、アンチパターンはより多くの問題を生み出します。
翻訳
私たちは mdbook-i18n-helper を使用しています。 翻訳を追加および更新する方法については、 同リポジトリをお読みください。
外部翻訳
翻訳を追加したい場合は、 メインリポジトリで Issue を作成してください。
イディオム
イディオムとは、コミュニティによって概ね合意された、一般的に使用される スタイル、ガイドライン、パターンです。慣用的なコードを書くことで、 他の開発者は何が起きているのかをよりよく理解できます。
結局のところ、コンピューターが気にするのはコンパイラによって生成される 機械語コードだけです。一方で、ソースコードは主に開発者にとって有益です。 それなら、この抽象化レイヤーがあるのですから、もっと読みやすくしてはどうでしょうか?
KISS の原則を思い出してください: 「Keep It Simple, Stupid」です。これは「ほとんどのシステムは、複雑にするよりも 単純に保たれている場合に最もよく機能する。したがって、単純さは設計における重要な 目標であるべきであり、不要な複雑さは避けるべきである」と主張しています。
コードはコンピューターではなく、人間が理解するためにあります。
引数には借用型を使用する
説明
関数の引数に使用する引数型を決めるとき、Deref 型強制の対象を使用すると、コードの柔軟性を高めることができます。このようにすると、関数はより多くの入力型を受け付けるようになります。
これは、スライス化可能な型やファットポインター型に限りません。実際には、常に所有型を借用することよりも借用型を使用することを優先すべきです。たとえば、&String より &str、&Vec<T> より &[T]、&Box<T> より &T です。
借用型を使用すると、所有型がすでに間接参照の層を提供している場合に、間接参照の層を避けることができます。たとえば、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));
// これは問題なく動作しますが、次の 2 行は失敗します。
// 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" を使ったときのように)、と。この特殊な例を無視したとしても、&String を使用するよりも &str を使用するほうが柔軟性が高いことに気づくかもしれません。
では、誰かが私たちに文を与え、その文の中のいずれかの単語に 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 言語リファレンス
Stringと&strの扱い方に関するさらなる議論については、Herman J. Radtke III による このブログシリーズ(2015) を参照してください- Steve Klabnik のブログ記事「When should I use String vs &str?」
format! で文字列を連結する
説明
可変の String に対して push メソッドや push_str メソッドを使用するか、その + 演算子を使用して文字列を組み立てることができます。しかし、特にリテラル文字列と非リテラル文字列が混在する場合は、format! を使用するほうが便利なことがよくあります。
例
#![allow(unused)]
fn main() {
fn say_hello(name: &str) -> String {
// 結果の文字列を手動で構築することもできます。
// let mut result = "Hello ".to_owned();
// result.push_str(name);
// result.push('!');
// result
// しかし、format! を使用するほうが適しています。
format!("Hello {name}!")
}
}
利点
format! を使用することは、通常、文字列を結合するための最も簡潔で読みやすい方法です。
欠点
これは通常、文字列を結合するための最も効率的な方法ではありません。可変文字列に対する一連の push 操作が通常は最も効率的です(特に、文字列が想定されるサイズに事前に割り当てられている場合)。
コンストラクター
説明
Rust には、言語構成要素としてのコンストラクターはありません。代わりに、
オブジェクトを作成するには、関連関数 new を使用するのが慣例です。
#![allow(unused)]
fn main() {
/// 秒単位の時間。
///
/// # 例
///
/// ```
/// let s = Second::new(42);
/// assert_eq!(42, s.value());
/// ```
pub struct Second {
value: u64,
}
impl Second {
// [`Second`] の新しいインスタンスを構築します。
// これは関連関数であり、self がないことに注意してください。
pub fn new(value: u64) -> Self {
Self { value }
}
/// 秒単位の値を返します。
pub fn value(&self) -> u64 {
self.value
}
}
}
デフォルトコンストラクター
Rust は Default トレイトによるデフォルトコンストラクターをサポートしています。
#![allow(unused)]
fn main() {
/// 秒単位の時間。
///
/// # 例
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
pub struct Second {
value: u64,
}
impl Second {
/// 秒単位の値を返します。
pub fn value(&self) -> u64 {
self.value
}
}
impl Default for Second {
fn default() -> Self {
Self { value: 0 }
}
}
}
Second の場合と同様に、すべてのフィールドのすべての型が Default を実装している場合、Default を導出することもできます。
#![allow(unused)]
fn main() {
/// 秒単位の時間。
///
/// # 例
///
/// ```
/// let s = Second::default();
/// assert_eq!(0, s.value());
/// ```
#[derive(Default)]
pub struct Second {
value: u64,
}
impl Second {
/// 秒単位の値を返します。
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 のような 1 要素コンテナーは、格納される型が Default を
実装している場合に Default を実装するだけではありません。すべてのフィールドが
Default を実装している構造体では #[derive(Default)] を自動的に使用できるため、
Default を実装する型が増えるほど、より有用になります。
一方で、コンストラクターは複数の引数を取ることができますが、default() メソッドは
そうではありません。異なる名前を持つコンストラクターが複数存在することさえありますが、
型ごとに存在できる Default 実装は 1 つだけです。
例
use std::{path::PathBuf, time::Duration};
// ここでは単に Default を自動導出できることに注意してください。
#[derive(Default, Debug, PartialEq)]
struct MyConfiguration {
// Option のデフォルトは None
output: Option<PathBuf>,
// Vec のデフォルトは空のベクター
search_path: Vec<PathBuf>,
// Duration のデフォルトはゼロ時間
timeout: Duration,
// bool のデフォルトは false
check: bool,
}
impl MyConfiguration {
// ここにセッターを追加する
}
fn main() {
// デフォルト値で新しいインスタンスを構築する
let mut conf = MyConfiguration::default();
// ここで conf に対して何かを行う
conf.check = true;
println!("conf = {conf:#?}");
// デフォルト値による部分的な初期化。同じインスタンスを作成する
let conf1 = MyConfiguration {
check: true,
..Default::default()
};
assert_eq!(conf, conf1);
}
関連項目
- コンストラクター イディオムは、「デフォルト」である場合もそうでない場合もある インスタンスを生成するもう 1 つの方法です
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> は、
Bar が動的サイズ型であり、&Bar<T> が Foo<T> 内のデータの借用ビューである場合に、
Deref<Target=Bar<T>> を実装できます。
一般に、順序付きコレクションはスライス構文を提供するために、Range に対して Index を実装します。
ターゲットは借用ビューになります。
関連項目
デストラクターでの終了処理
説明
Rust は finally ブロックに相当するもの、つまり関数がどのように終了しても
実行されるコードを提供していません。代わりに、オブジェクトのデストラクターを
使って、終了前に必ず実行しなければならないコードを実行できます。
例
fn baz() -> Result<(), ()> {
// 何らかのコード
}
fn bar() -> Result<(), ()> {
// これらは関数内で定義する必要はありません。
struct Foo;
// Foo のデストラクターを実装します。
impl Drop for Foo {
fn drop(&mut self) {
println!("exit");
}
}
// 関数 `bar` がどのように終了しても、_exit のデストラクターは実行されます。
let _exit = Foo;
// `?` 演算子による暗黙の return。
baz()?;
// 通常の return。
Ok(())
}
動機
関数に複数の return ポイントがある場合、終了時にコードを実行することは
難しく、繰り返しが多くなります(したがってバグが入りやすくなります)。
これは、マクロによって return が暗黙的になる場合に特に当てはまります。
一般的な例は ? 演算子です。これは結果が Err の場合に return しますが、
Ok の場合は処理を継続します。? は例外処理の仕組みとして使われますが、
Java(finally を持つ)とは異なり、通常の場合と例外的な場合の両方で
コードを実行するようにスケジュールする方法はありません。
panic も関数を早期に終了させます。
利点
デストラクター内のコードは(ほぼ)常に実行されます。panic、早期 return などに 対応できます。
欠点
デストラクターが実行されることは保証されていません。たとえば、関数内に 無限ループがある場合や、関数の実行が終了前にクラッシュする場合です。 すでに panic しているスレッド内でさらに panic が発生した場合にも、 デストラクターは実行されません。したがって、終了処理が行われることが 絶対に不可欠なファイナライザーとして、デストラクターに依存することはできません。
このパターンは、気づきにくい暗黙的なコードを導入します。関数を読んでも、 終了時に実行されるデストラクターがあることは明確には分かりません。 これにより、デバッグが難しくなることがあります。
終了処理のためだけにオブジェクトと Drop impl を必要とするのは、
ボイラープレートが多くなります。
議論
ファイナライザーとして使うオブジェクトを正確にどのように保持するかには、
いくつか微妙な点があります。そのオブジェクトは関数の終わりまで生存させ、
その後で破棄しなければなりません。オブジェクトは常に値または一意に所有された
ポインター(例: Box<Foo>)でなければなりません。共有ポインター(Rc など)を
使うと、ファイナライザーが関数のライフタイムを超えて生存し続ける可能性があります。
同様の理由から、ファイナライザーは move したり return したりすべきではありません。
ファイナライザーは変数に代入しなければなりません。そうしないと、スコープを
抜けるときではなく、即座に破棄されます。その変数がファイナライザーとしてのみ
使われる場合、変数名は _ で始めなければなりません。そうしないと、
ファイナライザーが一度も使われていないとコンパイラーが警告します。ただし、
変数名を接尾辞なしの _ にしてはいけません。その場合は即座に破棄されます。
Rust では、オブジェクトがスコープを抜けるとデストラクターが実行されます。 これは、ブロックの終わりに到達した場合、早期 return がある場合、または プログラムが panic した場合のいずれでも発生します。panic 時には、Rust は スタックを巻き戻し、各スタックフレーム内の各オブジェクトに対してデストラクターを 実行します。そのため、呼び出し中の関数で panic が発生した場合でも、 デストラクターは呼び出されます。
巻き戻し中にデストラクターが panic した場合、取るべき適切な動作はないため、 Rust はそれ以上のデストラクターを実行せずに、ただちにスレッドを中止します。 これは、デストラクターの実行が絶対に保証されているわけではないことを意味します。 また、リソースが予期しない状態のまま残る可能性があるため、デストラクター内で panic しないように特別な注意を払わなければならないことも意味します。
関連項目
変更後の enum 内に所有値を保持するための mem::{take(_), replace(_)}
説明
&mut MyEnum があり、これに(少なくとも)2 つのバリアント、
A { name: String, x: u8 } と B { name: String } があるとします。ここで、
MyEnum::B はそのままにしつつ、x がゼロの場合に MyEnum::A を B に変更したいとします。
これは name を clone せずに実現できます。
例
#![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 {
// これは `name` を取り出し、代わりに空の String を入れます
// (空文字列はアロケーションしないことに注意してください)。
// その後、新しい enum バリアントを構築します(これは
// `*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 {
// 所有権ルールにより、`name` を値として取り出すことは許可されていませんが、
// 置き換えない限り、可変参照から値を取り出すことはできません。
A { name } => B {
name: mem::take(name),
},
B { name } => A {
name: mem::take(name),
},
C => D,
D => C,
}
}
}
動機
enum を扱うとき、enum の値をその場で、場合によっては別のバリアントへ変更したいことがあります。これは通常、借用チェッカーを満足させるために 2 つのフェーズで行われます。最初のフェーズでは、既存の値を観察し、その部分を見て次に何をするかを決定します。2 番目のフェーズでは、条件に応じて値を変更できます(上の例のように)。
借用チェッカーは、enum から name を取り出すことを許可しません(なぜなら、何か がそこになければならないからです)。もちろん、name を .clone() して、その clone を MyEnum::B に入れることもできますが、それは
借用チェッカーを満足させるための Clone
アンチパターンの一例になります。いずれにせよ、可変借用だけで e を変更することで、余分なアロケーションを避けることができます。
mem::take を使うと、値をそのデフォルト値で置き換え、以前の値を返すことで、値を入れ替えることができます。String の場合、デフォルト値は空の String であり、これはアロケーションを必要としません。その結果、元の name を所有値として取得できます。その後、これを別の enum でラップできます。
注: mem::replace は非常によく似ていますが、値を何で置き換えるかを指定できます。上記の mem::take の行と等価なものは、mem::replace(name, String::new()) になります。
ただし、Option を使用していて、その値を None に置き換えたい場合は、Option の take() メソッドがより短く、より慣用的な代替手段を提供します。
利点
見てください、アロケーションなしです!また、これを行っている間、インディ・ジョーンズのような気分になれるかもしれません。
欠点
これは少し冗長になります。何度も間違えると、借用チェッカーが嫌いになるでしょう。コンパイラーが二重のストアを最適化で取り除けない可能性があり、その結果、unsafe な言語で行う場合と比べてパフォーマンスが低下することがあります。
さらに、取り出そうとしている型は
Default トレイトを実装している必要があります。ただし、扱っている型がこれを実装していない場合は、代わりに mem::replace を使用できます。
議論
このパターンが関心の対象となるのは Rust だけです。GC 付き言語では、デフォルトで値への参照を取得するでしょう(そして GC が参照を追跡します)。また、C のような他の低レベル言語では、単にポインターに別名を付けて、後で物事を修正するでしょう。
しかし Rust では、これを行うためにもう少し作業が必要です。所有値は 1 つの所有者しか持てないため、それを取り出すには、何かを代わりに戻す必要があります。ちょうどインディ・ジョーンズが、秘宝を砂袋と置き換えるようにです。
関連項目
これは、特定の場合に 借用チェッカーを満足させるための Clone アンチパターンを取り除きます。
スタック上の動的ディスパッチ
説明
複数の値に対して動的ディスパッチできますが、そのためには、型の異なるオブジェクトを束縛する複数の変数を宣言する必要があります。必要に応じてライフタイムを延ばすには、以下に示すように遅延条件付き初期化を使用できます。
例
use std::io;
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let arg = "-";
// 動的ディスパッチを得るには、型を記述する必要があります。
let readable: &mut dyn io::Read = if arg == "-" {
&mut io::stdin()
} else {
&mut fs::File::open(arg)?
};
// ここで `readable` から読み取ります。
Ok(())
}
動機
Rust はデフォルトでコードを単相化します。これは、そのコードが使用される型ごとにコードのコピーが生成され、それぞれ独立して最適化されることを意味します。これにより、ホットパスでは非常に高速なコードが可能になりますが、性能が本質的に重要ではない箇所ではコードが肥大化し、コンパイル時間とキャッシュ使用量のコストがかかります。
幸い、Rust では動的ディスパッチを使用できますが、それを明示的に要求する必要があります。
利点
ヒープ上に何も割り当てる必要がありません。また、後で使用しないものを初期化する必要もなく、続くコード全体を File と Stdin の両方で動作するように単相化する必要もありません。
欠点
Rust 1.79.0 より前では、このコードには遅延初期化を伴う 2 つの let 束縛が必要であり、Box ベースのバージョンよりも可動部分が多くなっていました。
// 動的ディスパッチのために型を注釈する必要は依然としてあります。
let readable: Box<dyn io::Read> = if arg == "-" {
Box::new(io::stdin())
} else {
Box::new(fs::File::open(arg)?)
};
// ここで `readable` から読み取ります。
幸い、この欠点は現在なくなりました。やった!
議論
Rust 1.79.0 以降、コンパイラは & または &mut 内の一時値のライフタイムを、関数のスコープ内で可能な限り自動的に延長します。
これは、内容を何らかの let 束縛に配置することを心配せずに、ここで単純に &mut 値を使用できることを意味します(そのような束縛は遅延初期化のために必要だったもので、その変更以前に使用されていた解決策でした)。
それでも各値には場所があり(たとえその場所が一時的なものであっても)、コンパイラは各値のサイズを把握しており、借用された各値は、そこから借用されたすべての参照よりも長く存続します。
関連項目
- デストラクタでのファイナライズ と RAII ガード は、ライフタイムを厳密に制御することで恩恵を受けられます。
- (可変)参照の条件付きで埋められる
Option<&T>については、Option<T>を直接初期化し、その.as_ref()メソッドを使用してオプションの参照を取得できます。
FFI イディオム
FFI コードを書くことは、それ自体で 1 つの講座になるほどの内容です。しかし、ここには unsafe Rust の経験が浅いユーザーにとっての指針となり、落とし穴を避ける助けになるイディオムがいくつかあります。
このセクションには、FFI を行う際に役立つ可能性のあるイディオムが含まれています。
FFI におけるエラー処理
説明
C のような外部言語では、エラーは戻りコードで表現されます。しかし、 Rust の型システムでは、はるかに豊かなエラー情報を完全な型としてキャプチャし、 伝播できます。
このベストプラクティスでは、さまざまな種類のエラーコードと、それらを 利用しやすい形で公開する方法を示します。
- フラットな列挙型は整数に変換し、コードとして返すべきです。
- 構造化された列挙型は、詳細を表す文字列エラーメッセージを伴う整数コードに 変換すべきです。
- カスタムエラー型は、C 表現を持つ「透過的」なものにすべきです。
コード例
フラットな列挙型
enum DatabaseError {
IsReadOnly = 1, // ユーザーが書き込み操作を試みた
IOError = 2, // ユーザーは原因を知るために C の errno() を読むべき
FileCorrupted = 3, // ユーザーは修復ツールを実行して復旧すべき
}
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), // 問題を説明するメッセージ
}
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: `e` のライフタイムが現在のスタックフレームよりも
// 長いと仮定します。
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: error_bytes を末尾に '\0' バイトを持つ
// 割り当て済みバッファにコピーします。
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 {
/* ... */
}
/* C 構造体として公開される第 2 のバージョンを作成する */
#[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 {
// その他のモジュール内容
/// 指定されたレベルでメッセージをログに記録する。
///
/// # Safety
///
/// `msg` について以下を保証するのは呼び出し元の責任である:
///
/// - null ポインターではない
/// - 有効で初期化済みのデータを指している
/// - null バイトで終わるメモリを指している
/// - この関数呼び出しの間に変更されない
#[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: これが問題ないことは呼び出し元によってすでに保証されている
// (doc-comment の `# Safety` セクションを参照)。
let msg_str: &str = match std::ffi::CStr::from_ptr(msg).to_str() {
Ok(s) => s,
Err(e) => {
crate::log_error("FFI文字列の変換に失敗しました");
return;
}
};
crate::log(msg_str, level);
}
}
利点
この例は、次の点を保証するように書かれています。
unsafeブロックが可能な限り小さい。- 「追跡されていない」ライフタイムを持つポインターが、「追跡された」共有参照になる
文字列を実際にコピーする別の方法を考えてみましょう。
pub mod unsafe_module {
// その他のモジュール内容
pub extern "C" fn mylib_log(msg: *const libc::c_char, level: libc::c_int) {
// このコードを使用してはならない。
// 醜く、冗長で、微妙なバグを含んでいる。
let level: crate::LogLevel = match level { /* ... */ };
let msg_len = unsafe { /* SAFETY: strlen はそういうもの、なのだろう */
libc::strlen(msg)
};
let mut msg_data = Vec::with_capacity(msg_len + 1);
let msg_cstr: std::ffi::CString = unsafe {
// SAFETY: スタックフレーム全体の間生存することが期待される
// 外部ポインターから所有メモリへコピーする
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文字列の変換に失敗しました");
return;
}
}
};
crate::log(&msg_str, level);
}
}
このコードは、次の 2 つの点で元のコードより劣っています。
unsafeコードがはるかに多く、さらに重要なことに、それが維持しなければならない不変条件も多い。- 必要となる算術処理が多いため、このバージョンには Rust の
undefined behaviourを引き起こすバグがある。
ここでのバグは、ポインター演算における単純なミスです。文字列はコピーされ、その msg_len バイトすべてがコピーされました。しかし、末尾の NUL 終端文字はコピーされていません。
その後、Vector のサイズは、ゼロパディングされた文字列 の長さに 設定 されました。末尾にゼロを追加できたはずの リサイズ ではありません。その結果、Vector の最後のバイトは未初期化メモリになります。ブロックの最後で CString が作成されるとき、その Vector の読み取りによって undefined behaviour が発生します!
このような多くの問題と同様に、これは追跡が難しい問題になります。文字列が UTF-8 ではないために panic することもあれば、文字列の末尾に奇妙な文字が付くこともあり、完全にクラッシュすることもあります。
欠点
なし?
文字列を渡す
説明
FFI 関数に文字列を渡すときは、従うべき 4 つの原則があります。
- 所有文字列のライフタイムを可能な限り長くする。
- 変換中の
unsafeコードを最小限にする。 - C コードが文字列データを変更できる場合は、
CStringではなくVecを使う。 - 外部関数 API が要求しない限り、文字列の所有権を呼び出し先へ移転すべきではない。
動機
Rust は、CString 型と CStr 型により C スタイル文字列を組み込みでサポートしています。しかし、Rust 関数から外部関数呼び出しへ送られる文字列については、いくつか異なるアプローチを取ることができます。
ベストプラクティスは単純です。unsafe コードを最小限にするように CString を使うことです。ただし、二次的な注意点として、オブジェクトは十分に長く生存しなければならない、つまりライフタイムを最大化すべきである、という点があります。さらに、ドキュメントでは、変更後に CString を「往復変換」することは UB であると説明されているため、その場合には追加の作業が必要です。
コード例
pub mod unsafe_module {
// その他のモジュール内容
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: ドキュメントでポインターが const であるとされている FFI を
// 呼び出しているため、変更は発生しないはず
seterr(c_err.as_ptr());
}
Ok(())
// c_err のライフタイムはここまで続く
}
fn get_error_from_ffi() -> Result<String, std::ffi::IntoStringError> {
let mut buffer = vec![0u8; 1024];
unsafe {
// SAFETY: ドキュメント上、入力は呼び出し中だけ生存していればよいことが
// 示唆されている FFI を呼び出している
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 {
// その他のモジュール内容
fn report_error<S: Into<String>>(err: S) -> Result<(), std::ffi::NulError> {
unsafe {
// SAFETY: おっと、これにはダングリングポインターが含まれている!
seterr(std::ffi::CString::new(err.into())?.as_ptr());
}
Ok(())
}
}
このコードはダングリングポインターになります。参照が作成された場合とは異なり、ポインターの作成によって CString のライフタイムは延長されないためです。
もう 1 つ頻繁に取り上げられる問題は、ゼロで埋めた 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);
// 次と同等
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` はムーブされる
let num2 = num2.clone(); // `num2` はクローンされる
let num3 = num3.as_ref(); // `num3` は借用される
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] と拡張性のためのプライベートフィールド
説明
ライブラリ作者が、後方互換性を壊すことなく public な構造体に public な フィールドを追加したり、enum に新しいバリアントを追加したりしたい場合がある、 少数のシナリオが存在します。
Rust はこの問題に対して 2 つの解決策を提供します。
-
struct、enum、およびenumバリアントに#[non_exhaustive]を使用します。#[non_exhaustive]を使用できるすべての場所に関する詳細なドキュメントについては、 ドキュメントを参照してください。 -
構造体にプライベートフィールドを追加して、直接インスタンス化されたり、 マッチされたりしないようにできます(代替案を参照)
例
#![allow(unused)]
fn main() {
mod a {
// Public な構造体。
#[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) {
// S は `#[non_exhaustive]` であるため、ここで名前を指定できず、
// パターン内で `..` を使用する必要があります。
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"),
// このバリアントも non-exhaustive であるため、.. が必要です
a::AdmitMoreVariants::VariantC { a, .. } => println!("it's a c"),
// 将来的にさらにバリアントが追加される可能性があるため、
// ワイルドカードマッチが必要です
_ => println!("it's a new variant"),
}
}
}
代替案: 構造体のための Private fields
#[non_exhaustive] は crate の境界を越える場合にのみ機能します。crate 内では、
プライベートフィールド方式を使用できます。
構造体にフィールドを追加することは、ほとんどの場合、後方互換性のある変更です。
しかし、クライアントが構造体インスタンスを分解するためにパターンを使用している場合、
構造体内のすべてのフィールドを名前で指定している可能性があり、新しいフィールドを追加すると
そのパターンが壊れます。クライアントは一部のフィールドに名前を付け、パターン内で .. を使用できます。
その場合、別のフィールドを追加することは後方互換性があります。構造体のフィールドの少なくとも
1 つをプライベートにすると、クライアントは後者の形式のパターンを使用せざるを得なくなり、
構造体が将来に備えたものになります。
このアプローチの欠点は、本来は不要なフィールドを構造体に追加する必要があるかもしれないことです。
実行時オーバーヘッドが発生しないように () 型を使用し、未使用フィールドの警告を避けるために
フィールド名の先頭に _ を付けることができます。
#![allow(unused)]
fn main() {
pub struct S {
pub a: i32,
// `b` はプライベートであるため、`..` と `S` を使用せずに `S` にマッチできず、
// 直接インスタンス化したりマッチしたりすることもできません
_b: (),
}
}
議論
struct において、#[non_exhaustive] は後方互換性のある方法で追加のフィールドを
追加できるようにします。また、すべてのフィールドが public であっても、クライアントが
構造体コンストラクターを使用することを防ぎます。これは役立つ場合がありますが、追加フィールドが
静かに見つからない可能性のあるものではなく、コンパイラーエラーとしてクライアントに発見されることを
望む かどうかを検討する価値があります。
#[non_exhaustive] は enum バリアントにも適用できます。
#[non_exhaustive] バリアントは、#[non_exhaustive] な構造体と同じように振る舞います。
これは意図的かつ慎重に使用してください。フィールドやバリアントを追加するときにメジャーバージョンを
上げることの方が、より良い選択肢であることがよくあります。#[non_exhaustive] は、ライブラリと
同期せずに変更される可能性のある外部リソースをモデル化しているシナリオでは適切な場合がありますが、
汎用的なツールではありません。
欠点
#[non_exhaustive] は、特に未知の enum バリアントの処理を強制される場合、コードの使いやすさを
大きく損なう可能性があります。この種の進化をメジャーバージョンを上げることなしに行う必要がある
場合にのみ使用すべきです。
#[non_exhaustive] が enum に適用されると、クライアントはワイルドカードバリアントを
処理せざるを得なくなります。この場合に取るべき合理的なアクションがない場合、不自然なコードや、
極めてまれな状況でしか実行されないコードパスにつながる可能性があります。クライアントがこのシナリオで
panic!() することを決めるなら、このエラーをコンパイル時に公開する方がよかったかもしれません。
実際、#[non_exhaustive] はクライアントに「その他」のケースを処理させますが、このシナリオで
取るべき合理的なアクションがあることはまれです。
関連項目
簡単なドキュメントの初期化
説明
ドキュメントを書く際に構造体の初期化にかなりの手間がかかる場合は、その構造体を引数として受け取るヘルパー関数でサンプルをラップすると、より素早く書けます。
動機
複数または複雑なパラメーターと、いくつかのメソッドを持つ構造体があることがあります。これらの各メソッドにはサンプルがあるべきです。
たとえば、次のようになります。
struct Connection {
name: String,
stream: TcpStream,
}
impl Connection {
/// 接続経由でリクエストを送信します。
///
/// # 例
/// ```no_run
/// # // サンプルを動作させるには定型コードが必要です。
/// # 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> {
// ...
}
/// なんということでしょう、そのすべての定型コードをここでも繰り返す必要があります!
fn check_status(&self) -> Status {
// ...
}
}
例
Connection と Request を作成するためにこれらすべての定型コードを入力する代わりに、それらを引数として受け取るラップ用のヘルパー関数を作成するだけのほうが簡単です。
struct Connection {
name: String,
stream: TcpStream,
}
impl Connection {
/// 接続経由でリクエストを送信します。
///
/// # 例
/// ```
/// # fn call_send(connection: Connection, request: Request) {
/// let response = connection.send_request(request);
/// assert!(response.is_ok());
/// # }
/// ```
fn send_request(&self, request: Request) -> Result<Status, SendErr> {
// ...
}
}
上記の例では、assert!(response.is_ok()); の行は、呼び出されることのない関数の内側にあるため、テスト時に実際には実行されないことに注意してください。
利点
これははるかに簡潔で、サンプル内の繰り返しコードを避けられます。
欠点
サンプルが関数内にあるため、コードはテストされません。ただし、cargo test を実行すると、コンパイルできることを確認するためのチェックは引き続き行われます。そのため、このパターンは no_run が必要な場合に最も有用です。これにより、no_run を追加する必要がなくなります。
議論
アサーションが不要な場合、このパターンはうまく機能します。
アサーションが必要な場合の代替案として、#[doc(hidden)] で注釈を付けた(ユーザーには表示されないようにする)ヘルパーインスタンスを作成する public メソッドを作成できます。その場合、このメソッドはクレートの public API の一部であるため、rustdoc 内から呼び出すことができます。
一時的な可変性
説明
多くの場合、何らかのデータを準備して処理する必要がありますが、その後そのデータは 参照されるだけで、変更されることはありません。可変変数を不変として再定義することで、 その意図を明示できます。
これは、ネストしたブロック内でデータを処理するか、変数を再定義することで実現できます。
例
たとえば、ベクターは使用前にソートされていなければならないとします。
ネストしたブロックを使用する場合:
let data = {
let mut data = get_vec();
data.sort();
data
};
// ここで `data` は不変です。
変数の再束縛を使用する場合:
let mut data = get_vec();
data.sort();
let data = data;
// ここで `data` は不変です。
利点
コンパイラーは、ある時点以降に誤ってデータを変更しないことを保証します。
欠点
ネストしたブロックでは、ブロック本体に追加のインデントが必要です。ブロックから データを返す、または変数を再定義するために、さらに1行必要です。
エラー時に消費された引数を返す
説明
失敗しうる関数が引数を消費(ムーブ)する場合、その引数を エラーの中で返します。
例
pub fn send(value: String) -> Result<(), SendError> {
println!("using {value} in a meaningful way");
// 非決定的な失敗しうるアクションをシミュレートします。
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: {
// value の送信を 2 回試みます。
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 です。
ユーザーがデータベースマイグレーション操作を呼び出すと、各コマンドは定義された順序で実行され、ユーザーがロールバック操作を呼び出すと、一連のコマンド全体が逆順で呼び出されます。
アプローチ: トレイトオブジェクトを使用する
コマンドをカプセル化する共通のトレイトを定義し、execute と rollback の2つの操作を持たせます。
すべてのコマンド 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() // イテレーターの方向を反転する
.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());
}
アプローチ: 関数ポインターを使用する
各個別のコマンドを別々の関数として作成し、それらの関数を後で異なる時点に呼び出すために関数ポインターを保存する、別のアプローチを取ることもできます。
関数ポインターは Fn、FnMut、FnOnce の3つのトレイトすべてを実装しているため、関数ポインターの代わりにクロージャを渡して保存することもできます。
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());
}
考察
コマンドが小さく、関数として定義できる、またはクロージャとして渡せる場合は、動的ディスパッチを利用しないため、関数ポインターを使用する方が望ましい場合があります。
しかし、コマンドが多数の関数と変数を持つ1つの構造体であり、独立したモジュールとして定義されている場合は、トレイトオブジェクトを使用する方が適しています。
適用例は actix に見られます。これは、ルートに対するハンドラー関数を登録するときにトレイトオブジェクトを使用します。
Fn トレイトオブジェクトを使用する場合、関数ポインターの場合と同じようにコマンドを作成して使用できます。
パフォーマンスについては、パフォーマンスとコードの単純さおよび構成の間には常にトレードオフがあります。 静的ディスパッチはより高速なパフォーマンスをもたらしますが、動的ディスパッチはアプリケーションを構造化する際に柔軟性を提供します。
関連項目
インタープリター
説明
ある問題が非常に頻繁に発生し、それを解決するために長く反復的な手順が必要な場合、 その問題インスタンスは単純な言語で表現でき、インタープリターオブジェクトが この単純な言語で書かれた文を解釈することで解決できるかもしれません。
基本的に、どのような種類の問題についても、次のものを定義します。
- ドメイン固有言語
- この言語の文法
- 問題インスタンスを解決するインタープリター
動機
私たちの目標は、単純な数式を後置記法の式(または
逆ポーランド記法)
に変換することです。単純化のため、式は 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 (別名 Dragon Book)を参照してください。
解決策
単純に再帰下降パーサーを実装します。簡単にするため、式が構文的に誤っている場合
(たとえば 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-");
}
議論
インタープリターデザインパターンは、形式言語の文法を設計し、それらの文法のパーサーを
実装することに関するものだという誤解があるかもしれません。実際には、このパターンは
問題インスタンスをより具体的な方法で表現し、それらの問題インスタンスを解決する
関数/クラス/構造体を実装することに関するものです。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;
// String の Display トレイトをオーバーライドするために Newtype Password を作成する
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 はゼロコスト抽象化であり、実行時のオーバーヘッドはありません。
プライバシーシステムにより、ユーザーはラップされた型にアクセスできません(フィールドが private である場合。これはデフォルトです)。
欠点
newtype の欠点(特に型エイリアスと比較した場合)は、特別な言語サポートがないことです。これは、大量の ボイラープレートが発生する可能性があることを意味します。 ラップされた型で公開したい各メソッドに対して「パススルー」メソッドが必要であり、ラッパー型にも実装したい各トレイトに対して impl が必要です。
議論
newtype は Rust コードで非常によく使われます。抽象化や単位の表現が最も一般的な用途ですが、他の理由でも使用できます。
- 機能を制限する(公開される関数や実装されるトレイトを減らす)、
- コピーセマンティクスを持つ型にムーブセマンティクスを持たせる、
- より具体的な型を提供し、それによって内部型を隠すことによる抽象化。 例:
pub struct Foo(Bar<T1, T2>);
ここで、Bar は何らかの公開されたジェネリック型であり、T1 と T2 は何らかの内部型である可能性があります。
私たちのモジュールのユーザーは、Foo を Bar を使用して実装していることを知るべきではありませんが、ここで実際に隠しているのは T1 と T2 という型と、それらが Bar とともにどのように使用されるかです。
関連項目
- 本の Advanced Types
- Haskell の Newtypes
- 型エイリアス
- derive_more。newtype 上で多くの 組み込みトレイトを derive するためのクレートです。
- The Newtype Pattern In Rust
ガードを用いた RAII
説明
RAII は “Resource Acquisition Is Initialization” の略で、率直に言えばあまり分かりやすい名前ではありません。このパターンの本質は、リソースの取得や初期化をオブジェクトのコンストラクタで行い、解放や後始末をデストラクタで行うことです。このパターンは Rust では、RAII オブジェクトを何らかのリソースのガードとして使い、アクセスが常にガードオブジェクトによって仲介されることを型システムに保証させることで拡張されています。
例
ミューテックスガードは、標準ライブラリにおけるこのパターンの古典的な例です(これは実際の実装を簡略化したものです)。
use std::ops::Deref;
struct Foo {}
struct Mutex<T> {
// ここでは、データ T への参照を保持します。
//..
}
struct MutexGuard<'a, T: 'a> {
data: &'a T,
//..
}
// ミューテックスのロックは明示的です。
impl<T> Mutex<T> {
fn lock(&self) -> MutexGuard<T> {
// 内部の OS ミューテックスをロックします。
//..
// MutexGuard は self への参照を保持します
MutexGuard {
data: self,
//..
}
}
}
// ミューテックスのロックを解除するためのデストラクターです。
impl<'a, T> Drop for MutexGuard<'a, T> {
fn drop(&mut self) {
// 下層の OS ミューテックスのロックを解除します。
//..
}
}
// Deref を実装すると、MutexGuard を 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 は Foo のメソッドです。
// 借用チェッカーは、ガード xx より長く生存する下層の
// Foo への参照を保存できないことを保証します。
// この関数を抜けると `xx` のデストラクタが実行され、`x` のロックが解除されます。
}
動機
リソースを使用後に終了処理しなければならない場合、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 や Plain Text 形式など、異なる形式(戦略)で生成する必要があります。
しかし状況は時間とともに変化し、将来どのような要件が出てくるかはわかりません。
たとえば、まったく新しい形式でレポートを生成する必要があるかもしれませんし、
既存の形式のいずれかを変更するだけでよいかもしれません。
例
この例では、不変要素(または抽象化)は 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 を使用すべきですが、エラー処理を無視するために String のままにしています
fn generate<T: Formatter>(g: T, s: &mut String) {
// バックエンド操作...
let mut data = HashMap::new();
data.insert("one".to_string(), 1);
data.insert("two".to_string(), 2);
// レポートを生成
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(); // 末尾の余分な , を削除
}
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(); // 同じバッファを再利用
Report::generate(Json, &mut s);
assert!(s.contains(r#"{"one":"1"}"#));
assert!(s.contains(r#"{"two":"2"}"#));
}
利点
主な利点は関心の分離です。たとえば、この場合 Report は Json と Text の
具体的な実装について何も知りません。一方で出力の実装は、データがどのように
前処理され、保存され、取得されるかを気にしません。それらが知る必要があるのは、
実装すべき特定のトレイトと、結果を処理する具体的なアルゴリズム実装を定義する
そのメソッド、すなわち Formatter と format(...) だけです。
欠点
各戦略について少なくとも 1 つのモジュールを実装する必要があるため、戦略の数に 応じてモジュール数が増加します。選択可能な戦略が多数ある場合、ユーザーは 各戦略が互いにどのように異なるかを知っている必要があります。
議論
前の例では、すべての戦略が 1 つのファイルに実装されています。異なる戦略を 提供する方法には、次のようなものがあります。
- すべてを 1 つのファイルに入れる(この例で示したように、モジュールとして 分離するのに似ています)
- モジュールとして分離する。例:
formatter::jsonモジュール、formatter::textモジュール - コンパイラの機能フラグを使用する。例:
json機能、text機能 - クレートとして分離する。例:
jsonクレート、textクレート
Serde クレートは、Strategy パターンが実際に使われている良い例です。Serde では、
自分たちの型に対して Serialize と Deserialize トレイトを手動で実装することで、
シリアライズ動作を完全にカスタマイズできます。
たとえば、serde_json と serde_cbor は似たメソッドを公開しているため、
簡単に置き換えることができます。これにより、ヘルパークレートである
serde_transcode ははるかに便利で使いやすくなります。
ただし、Rust でこのパターンを設計するためにトレイトを使用する必要はありません。
次のおもちゃの例は、Rust の closures を使用して 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());
}
関連項目
Visitor
説明
ビジターは、異種混在のオブジェクトのコレクションに対して動作するアルゴリズムをカプセル化します。これにより、データ(またはその主要な振る舞い)を変更することなく、同じデータに対して複数の異なるアルゴリズムを記述できます。
さらに、Visitor パターンを使うと、オブジェクトのコレクションの走査と、各オブジェクトに対して実行される操作を分離できます。
例
// 訪問するデータ
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>),
}
}
// 抽象ビジター
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::*;
// 具体的な実装例 - AST をコードとして解釈しながら走査する。
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 パターンは、異種混在のデータにアルゴリズムを適用したいあらゆる場面で有用です。データが同種である場合は、イテレーターのようなパターンを使用できます。(関数型のアプローチではなく)ビジターオブジェクトを使用すると、ビジターに状態を持たせることができるため、ノード間で情報をやり取りできます。
議論
visit_* メソッドが、例とは異なり () を返す、つまり値を返さない形になることは一般的です。その場合、走査コードを切り出してアルゴリズム間で共有できます(また、何もしないデフォルトメソッドを提供することもできます)。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 パターンは、ほとんどの OO 言語で一般的なパターンです。
fold パターンは Visitor に似ていますが、訪問したデータ構造の新しいバージョンを生成します。
生成パターン
Wikipedia より:
オブジェクト生成メカニズムを扱うデザインパターンであり、状況に適した方法で オブジェクトを作成しようとするものです。オブジェクト生成の基本的な形式は、 設計上の問題や、設計への複雑さの追加を引き起こす可能性があります。 生成デザインパターンは、何らかの方法でこのオブジェクト生成を制御することで、この問題を解決します。
ビルダー
説明
ビルダーヘルパーへの呼び出しによってオブジェクトを構築します。
例
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
pub struct Foo {
// 複雑なフィールドがたくさんある。
bar: String,
}
impl Foo {
// このメソッドはユーザーがビルダーを見つけるのに役立つ
pub fn builder() -> FooBuilder {
FooBuilder::default()
}
}
#[derive(Default)]
pub struct FooBuilder {
// おそらく任意のフィールドがたくさんある。
bar: String,
}
impl FooBuilder {
pub fn new(/* ... */) -> FooBuilder {
// Foo に最低限必要なフィールドを設定する。
FooBuilder {
bar: String::from("X"),
}
}
pub fn name(mut self, bar: String) -> FooBuilder {
// ビルダー自体に名前を設定し、ビルダーを値として返す。
self.bar = bar;
self
}
// ここで Builder を消費せずに済ませられるなら、それは利点である。
// つまり、FooBuilder を多数の Foo を構築するためのテンプレートとして
// 使用できる。
pub fn build(self) -> Foo {
// FooBuilder から Foo を作成し、FooBuilder 内のすべての設定を
// 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);
}
}
動機
そうでなければ多くのコンストラクターが必要になる場合や、構築に副作用がある場合に有用です。
利点
構築用のメソッドを他のメソッドから分離します。
コンストラクターの増殖を防ぎます。
1 行での初期化にも、より複雑な構築にも使用できます。
対象の構造体に新しいフィールドを追加する場合、クライアントコードの後方互換性を維持したままビルダーを更新できます。
欠点
構造体オブジェクトを直接作成する場合や、単純なコンストラクター関数よりも複雑です。
議論
Rust にはオーバーロードや関数パラメーターのデフォルト値がないため、このパターンは他の多くの言語よりも Rust で(そしてより単純なオブジェクトに対して)頻繁に見られます。ある名前を持つメソッドは 1 つしか持てないため、複数のコンストラクターを持つことは、Rust では C++、Java、その他の言語ほど扱いやすくありません。
このパターンは、ビルダーオブジェクトが単なるビルダーではなく、それ自体で有用な場合によく使用されます。たとえば、
std::process::Command
は
Child(プロセス)
のビルダーです。このような場合、T と TBuilder という命名パターンは使用されません。
この例では、ビルダーを値として受け取り、値として返します。ビルダーを可変参照として受け取り、返す方が、多くの場合よりエルゴノミック(かつより効率的)です。借用チェッカーにより、これは自然に機能します。このアプローチには、次のようなコードを書けるという利点があります。
let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.build();
また、FooBuilder::new().a().b().build() スタイルも使用できます。
関連項目
- スタイルガイドでの説明
- derive_builder、ボイラープレートを避けながらこのパターンを自動的に実装するための crate。
- 構築がより単純な場合のための Constructor パターン。
- Builder パターン(wikipedia)
- 複雑な値の構築
Fold
説明
データのコレクション内の各項目に対してアルゴリズムを実行して新しい項目を作成し、 それによってまったく新しいコレクションを作成します。
ここでの語源は私には明確ではありません。Rust コンパイラでは ‘fold’ と ‘folder’ という用語が使われていますが、 通常の意味での fold というよりは map に近いように思えます。詳細については、以下の議論を参照してください。
例
// フォールドするデータ、単純な 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>),
}
}
// 抽象フォルダー
mod fold {
use ast::*;
pub trait Folder {
// リーフノードはノード自身をそのまま返す。場合によっては、これを
// 内部ノードに対しても行える。
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> { n }
// 子をフォールドすることで新しい内部ノードを作成する。
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::*;
// 具体的な実装例 - すべての名前を 'foo' にリネームする。
struct Renamer;
impl Folder for Renamer {
fn fold_name(&mut self, n: Box<Name>) -> Box<Name> {
Box::new(Name { value: "foo".to_owned() })
}
// 他のノードにはデフォルトメソッドを使用する。
}
AST に対して Renamer を実行した結果は、古い AST と同一だが、
すべての名前が foo に変更された新しい AST です。実際のフォルダーでは、構造体自体の中でノード間にわたって
状態を保持することもあります。
フォルダーは、あるデータ構造を別の(ただし通常は類似した)データ構造へマップするように定義することもできます。 たとえば、AST を HIR ツリーへフォールドできます (HIR は高レベル中間表現を意味します)。
動機
データ構造内の各ノードに対して何らかの操作を実行することで、
データ構造をマップしたいことはよくあります。単純なデータ構造に対する単純な操作であれば、
これは Iterator::map を使って実行できます。より複雑な操作、
たとえば前のノードが後のノードに対する操作に影響する場合や、
データ構造の反復処理が自明でない場合には、fold パターンを使用する方が
適切です。
visitor パターンと同様に、fold パターンを使うと、 データ構造の走査と、各ノードに対して実行される操作を分離できます。
議論
このような方法でデータ構造をマップすることは、関数型言語では一般的です。OO 言語では、データ構造をその場で変更する方が一般的でしょう。 Rust では「関数型」のアプローチが一般的で、これは主に不変性が好まれるためです。 古いデータ構造を変更するのではなく、新しいデータ構造を使用すると、 ほとんどの場合、コードについての推論が容易になります。
効率性と再利用性のトレードオフは、
fold_* メソッドがノードを受け取る方法を変更することで調整できます。
上記の例では Box ポインターを操作しています。これらはデータを排他的に所有するため、
データ構造の元のコピーを再利用することはできません。一方で、
ノードが変更されない場合、それを再利用するのは非常に効率的です。
借用参照を操作する場合、元のデータ構造を再利用できます。しかし、 ノードが変更されていなくてもクローンする必要があり、これは高コストになる可能性があります。
参照カウント付きポインターを使用すると、両方の利点を得られます。つまり、 元のデータ構造を再利用でき、変更されていないノードをクローンする必要もありません。 ただし、それらは使い勝手がやや悪く、データ構造を可変にできないことを意味します。
関連項目
イテレーターには fold メソッドがありますが、これはデータ構造を
新しいデータ構造ではなく、値へフォールドします。イテレーターの map の方が、
この fold パターンに近いものです。
他の言語では、fold は通常、このパターンではなく Rust のイテレーターにおける意味で使われます。 一部の関数型言語には、データ構造に対して柔軟な map を実行するための強力な構文があります。
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 全体を借用しようとはしません。もしそうであれば、このパターンは役に立たなくなってしまいます。
小さなクレートを優先する
説明
1 つのことをうまく行う小さなクレートを優先します。
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 コードを限定できる
- 内側のモジュールの保証を頼りにできるため、外側のモジュールを書くのがはるかに簡単になる
欠点
- 適切なインターフェイスを見つけるのが難しい場合があります。
- 抽象化によって非効率が生じる可能性があります。
例
toolshedクレートは、その unsafe な操作をサブモジュールに含め、ユーザーに安全なインターフェイスを提供します。stdのStringクラスは、内容が有効な UTF-8 でなければならないという不変条件が追加された、Vec<u8>のラッパーです。Stringに対する操作は、この振る舞いを保証します。ただし、ユーザーにはunsafeメソッドを使用してStringを作成する選択肢があり、その場合、内容の妥当性を保証する責任はユーザーにあります。
関連項目
カスタムトレイトを使用して複雑な型境界を避ける
説明
トレイト境界はやや扱いにくくなることがあります。特に、Fn トレイト1のいずれかが関係し、出力型に特定の要件がある場合はそうです。このような場合、新しいトレイトを導入すると、冗長さを減らし、一部の型パラメータを排除し、それによって表現力を高められることがあります。そのようなトレイトには、元の境界を満たすすべての型に対するジェネリックな impl を伴わせることができます。
例
何らかの監視システムや情報収集システムを想像してみましょう。このシステムは、さまざまなソースからさまざまな型の値を取得します。それらの値から、問題を示す何らかのステータスを導出することがあります。たとえば、空きメモリの総量は一定のしきい値を超えているべきであり、id が 0 のユーザーは常に “root” という名前であるべきです。
管理上の理由から、トップレベルではおそらく型消去が必要になるでしょう。しかし、特定の種類のデータソースに対して、具体的な(ユーザーが設定可能な)評価も提供する必要があります(たとえば、数値型に対するしきい値や範囲など)。また、これらの値のソースは多様であるため、呼び出されたときに値を返すクロージャとしてデータソースを提供することを選ぶかもしれません。これらの値はおそらくオペレーティングシステムから取得するため、失敗する可能性のある操作に直面することになりそうです。
したがって、特定の値を扱うために、次のような型とトレイトに落ち着いたとします。
#![allow(unused)]
fn main() {
use std::fmt::Display;
struct Value<G: FnMut() -> Result<T, Error>, S: Fn(&T) -> Status, T: Display> {
value: Option<T>,
getter: G,
status: S,
}
impl<G: FnMut() -> Result<T, Error>, S: Fn(&T) -> Status, T: Display> Value<G, S, T> {
pub fn update(&mut self) -> Result<(), Error> {
(self.getter)().map(|v| self.value = Some(v))
}
pub fn value(&self) -> Option<&T> {
self.value.as_ref()
}
pub fn status(&self) -> Option<Status> {
self.value().map(&self.status)
}
}
// ...
enum Status {
// ...
}
struct Error {
// ...
}
}
これらの型では、少なくともいくつかの箇所で G のトレイト境界を繰り返す必要があります。可読性は低下しますが、その一因はゲッターが Result を返すという事実にあります。“getters” に対する境界を導入すると、より表現力のある境界が可能になり、型パラメータの 1 つを排除できます。
#![allow(unused)]
fn main() {
use std::fmt::Display;
trait Getter {
type Output: Display;
fn get_value(&mut self) -> Result<Self::Output, Error>;
}
impl<F: FnMut() -> Result<T, Error>, T: Display> Getter for F {
type Output = T;
fn get_value(&mut self) -> Result<Self::Output, Error> {
self()
}
}
struct Value<G: Getter, S: Fn(&G::Output) -> Status> {
value: Option<G::Output>,
getter: G,
status: S,
}
// ...
enum Status {}
struct Error;
}
利点
新しいトレイトを導入すると、特に型パラメータの排除を通じて、型境界を単純化するのに役立ちます。新しいトレイトに適切な名前を付けることで、境界の表現力も高まります。この新しいトレイトは抽象化でもあり、それ自体にも次のような機会をもたらします。
- 新しいトレイトを実装する追加の特殊化された型(たとえば、何らかの識別子を表すもの)や、
Defaultなどの他の有用なトレイト - 関連するすべての型に対して実装できる限り、追加のメソッド
欠点
トレイトのような新しい項目を導入するということは、それに適した名前と配置場所を見つける必要があるということです。また、元の機能のユーザーが調べる必要のある項目が 1 つ増えることも意味します2。提示の仕方によっては、上記の例で単純なクロージャを Getter として使用できることがすぐには明らかでない場合があります。
FFI パターン
FFI コードを書くことは、それ自体で1つの講座になるほどの内容です。しかし、ここには unsafe Rust の経験が浅いユーザーにとって指針となり、落とし穴を避けるのに役立つイディオムがいくつかあります。
このセクションには、FFI を行う際に役立つ可能性のあるデザインパターンが含まれています。
-
オブジェクトベース API 設計。優れたメモリ安全性の 特性を持ち、何が安全で何が unsafe かの境界が明確です
-
ラッパーへの型の統合 - 複数の Rust 型をまとめて 不透明な「オブジェクト」にする
オブジェクトベース API
説明
他の言語に公開される Rust の API を設計する際には、通常の Rust API 設計とは相反する重要な設計原則がいくつかあります。
- すべてのカプセル化された型は、Rust によって所有され、ユーザーによって管理され、不透明であるべきです。
- すべてのトランザクション型データ型は、ユーザーによって所有され、透過的であるべきです。
- すべてのライブラリの振る舞いは、カプセル化された型に作用する関数であるべきです。
- すべてのライブラリの振る舞いは、構造ではなく、来歴/ライフタイムに基づく型へカプセル化されるべきです。
動機
Rust には、他の言語に対する FFI サポートが組み込まれています。これは、crate 作者が異なる ABI を通じて C 互換 API を提供できる手段を用意することで実現されています(ただし、このプラクティスにとってその点は重要ではありません)。
よく設計された Rust FFI は、Rust 側の設計上の妥協を可能な限り少なく抑えながら、C API の設計原則に従います。あらゆる外部 API には 3 つの目標があります。
- 対象言語で使いやすくすること。
- Rust 側で API が内部的な unsafe 性を強いることを可能な限り避けること。
- メモリ安全性が損なわれる可能性と Rust の
undefined behaviourの可能性を可能な限り小さく保つこと。
Rust コードは、ある点を越えると外部言語のメモリ安全性を信頼しなければなりません。しかし、Rust 側の unsafe コードはどれも、バグの機会になったり、undefined behaviour を悪化させたりする可能性があります。
たとえば、ポインターの来歴が誤っている場合、不正なメモリアクセスによるセグメンテーションフォルトになる可能性があります。しかし、それが unsafe コードによって操作されると、本格的なヒープ破壊になり得ます。
オブジェクトベース API 設計により、メモリ安全性の特性が良好で、安全なものと 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 を呼び出す必要があり、それによって得られるのはその 1 つへのポインターだけです。
これは、Rust 的な意味ですべての DBM がライブラリによって「所有」されることを意味します。サイズ不明の内部状態は、ユーザーではなくライブラリが制御するメモリに保持されます。ユーザーは open と close によってそのライフサイクルを管理し、他の関数によってそれに対する操作を実行できるだけです。
datum 型は、上で「トランザクション型」と呼んだものです。ライブラリとそのユーザーとの間で情報交換を容易にするように設計されています。
このデータベースは、「非構造化データ」を格納するように設計されており、事前定義された長さや意味はありません。その結果、datum は Rust のスライスに相当する C のものです。つまり、バイト列と、その数です。主な違いは型情報がないことであり、それを void が示しています。
このヘッダーはライブラリの視点から書かれていることに注意してください。ユーザーはおそらく既知のサイズを持つ何らかの型を使用しています。しかし、ライブラリはそれを気にせず、C のキャスト規則により、ポインターの背後にある任意の型は void にキャストできます。
先に述べたように、この型はユーザーに対して透過的です。しかし同時に、この型はユーザーによって所有されています。これには、その内部のポインターに起因する微妙な影響があります。問題は、そのポインターが指すメモリを誰が所有するのか、ということです。
メモリ安全性にとって最良の答えは「ユーザー」です。しかし、値を取得する場合などでは、ユーザーはそれを正しく割り当てる方法を知りません(値の長さを知らないためです)。この場合、ライブラリコードは、C ライブラリの malloc や free など、ユーザーがアクセスできるヒープを使用し、その後 Rust 的な意味で所有権を移譲することが期待されます。
これはすべて推測に見えるかもしれませんが、C においてポインターが意味するのはこれです。それは Rust と同じ意味、すなわち「ユーザー定義のライフタイム」です。ライブラリのユーザーは、それを正しく使用するためにドキュメントを読む必要があります。とはいえ、ユーザーが間違えた場合の影響が小さい判断もあれば、大きい判断もあります。それらを最小化することがこのベストプラクティスの目的であり、鍵となるのは透過的なものはすべて所有権を移譲することです。
利点
これにより、ユーザーが守らなければならないメモリ安全性の保証の数を、比較的少数に最小化できます。
dbm_openから返されていないポインターで関数を呼び出さないこと(不正アクセスまたは破壊)。- close 後のポインターに対して関数を呼び出さないこと(解放後使用)。
- 任意の
datumのdptrはNULLであるか、示された長さの有効なメモリスライスを指していなければならないこと。
さらに、多くのポインター来歴の問題を避けられます。その理由を理解するために、キーのイテレーションという代替案をある程度詳しく考えてみましょう。
Rust はイテレーターでよく知られています。イテレーターを実装する際、プログラマーはその所有者に束縛されたライフタイムを持つ別の型を作成し、Iterator トレイトを実装します。
Rust で DBM のイテレーションを行うなら、次のようになります。
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 {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
#[no_mangle]
pub extern "C" fn dbm_iter_next(
iter: *mut DbmKeysIter,
key_out: *const datum
) -> libc::c_int {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
#[no_mangle]
pub extern "C" fn dbm_iter_del(*mut DbmKeysIter) {
// この API は悪い考えです!実際のアプリケーションでは、代わりにオブジェクトベースの設計を使用してください。
}
この API は、イテレーターのライフタイムが、それを所有する Dbm オブジェクトのライフタイムを超えてはならない、という重要な情報を失っています。ライブラリのユーザーは、イテレーターが反復処理しているデータよりも長生きするような使い方をしてしまう可能性があり、その結果、初期化されていないメモリを読み取ることになります。
C で書かれたこの例にはバグが含まれており、その後で説明します。
int count_key_sizes(DBM *db) {
// この関数を使用しないでください。微妙ですが重大なバグがあります!
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) { // エラーは -1 で示されます
free(key.dptr);
len += key.dsize;
if (l == 0) { // イテレーターの終端
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 の各部分の関係によっては、大きな設計労力が必要になる場合があります。 より容易な設計上の要点の多くには、それらに関連する別のパターンがあります。
-
Wrapper Type Consolidation は、複数の Rust 型を 不透明な「オブジェクト」にまとめます
-
FFI Error Passing は、整数コードと番兵戻り値 (
NULLポインターなど)によるエラー処理を説明します -
Accepting Foreign Strings は、 最小限の unsafe コードで文字列を受け入れられるようにし、 Passing Strings to FFI よりも正しく行うのが容易です
ただし、すべての API をこの方法で実現できるわけではありません。対象読者が誰であるかについては、 プログラマーの最善の判断に委ねられます。
型をラッパーに統合する
説明
このパターンは、メモリ安全でない状態が入り込む領域を最小限に抑えながら、 関連する複数の型を適切に扱えるように設計されています。
Rust のエイリアシング規則の基礎の 1 つはライフタイムです。これにより、 型間の多くのアクセスパターンが、データ競合に対する安全性も含めて メモリ安全であることが保証されます。
しかし、Rust の型が他の言語へエクスポートされると、通常はポインターに 変換されます。Rust においてポインターとは、「ユーザーがポインター先の ライフタイムを管理する」という意味です。メモリ安全でない状態を避ける 責任はユーザーにあります。
したがって、ユーザーコードにはある程度の信頼が必要です。特に、 Rust ではどうすることもできない use-after-free についてはそうです。 ただし、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 版のコードが行うこと、つまりイテレーターのライフタイムを消去し、 手動で管理する必要があります。
控えめに言っても、これは信じられないほど困難です。
ここでは、数ある落とし穴のうち、たった1 つを示します。
MySetWrapper の最初のバージョンは次のようになります。
struct MySetWrapper {
myset: MySet,
iter_next: usize,
// transmute された Box<KeysIter + 'self> から作成
iterator: Option<NonNull<KeysIter<'static>>>,
}
transmute を使ってライフタイムを延長し、それを隠すためにポインターを
使っている時点で、すでに見苦しいものです。しかし、さらに悪いことがあります。
他のどんな操作でも Rust の undefined behaviour を引き起こす可能性があります。
ラッパー内の MySet は、イテレーション中に他の関数によって操作される
可能性があることを考えてください。たとえば、イテレーション対象のキーに
新しい値を格納するような場合です。この API はそれを抑止していませんし、
実際に類似の C ライブラリの中にはそれを想定しているものもあります。
myset_store の単純な実装は次のようになります。
pub mod unsafe_module {
// その他のモジュール内容
pub fn myset_store(myset: *mut MySetWrapper, key: datum, value: datum) -> libc::c_int {
// このコードを使用しないでください。問題を示すための安全でないコードです。
let myset: &mut MySet = unsafe {
// SAFETY: おっと、ここで UB が発生します!
&mut (*myset).myset
};
/* ...key と value のデータをチェックしてキャストする... */
match myset.store(casted_key, casted_value) {
Ok(_) => 0,
Err(e) => e.into(),
}
}
}
この関数が呼び出されたときにイテレーターが存在している場合、Rust の
エイリアシング規則の 1 つに違反しています。Rust によれば、このブロック内の
可変参照は、そのオブジェクトへの排他的アクセス権を持っていなければ
なりません。イテレーターが単に存在しているだけで排他的ではなくなるため、
undefined behaviour が発生します! 1
これを避けるには、可変参照が本当に排他的であることを保証する手段が 必要です。これは基本的に、イテレーターが存在している間はイテレーターの 共有参照を消し去り、その後で再構築することを意味します。ほとんどの場合、 それでも C 版より効率は低くなります。
こう尋ねる人もいるかもしれません。C はどうしてこれをより効率的に 実行できるのでしょうか? 答えは、C がずるをしているからです。 問題は Rust のエイリアシング規則であり、C はポインターについてそれらを 単に無視します。その代わり、一部またはすべての状況において「スレッド セーフではない」とマニュアルで宣言されているコードをよく見かけます。 実際、GNU C library には、並行動作に特化した語彙体系がまるごと存在します!
Rust は、安全性のためにも、C コードでは達成できない最適化のためにも、 常にあらゆるものをメモリ安全にしたいと考えています。特定の近道への アクセスを拒まれることは、Rust プログラマーが支払う必要のある代償です。
-
首をかしげている C プログラマーのために説明すると、UB を引き起こすのに、 このコードの実行中にイテレーターが読み取られる必要はありません。 排他性の規則は、コンパイラー最適化も可能にします。その結果、 イテレーターの共有参照によって一貫性のない観測が発生する可能性があります (たとえば、効率化のためのスタック退避や命令の並べ替えなど)。 こうした観測は、可変参照が作成された後の任意の時点で発生する可能性があります。 ↩
アンチパターン
アンチパターンとは、 「通常は効果がなく、非常に逆効果になるリスクがある再発する問題」 に対する解決策です。問題を解決する方法を知ることと同じくらい重要なのは、 それをどのように解決すべきでないかを知ることです。アンチパターンは、 デザインパターンと比較して検討すべき優れた反例を提供してくれます。アンチパターンはコードに限定されません。 たとえば、プロセスもアンチパターンになり得ます。
借用チェッカーを満たすためのクローン
説明
借用チェッカーは、可変参照が1つだけ存在するか、あるいは複数存在し得るもののすべて不変参照であるかのいずれかを保証することで、Rust ユーザーが本来なら安全でないコードを開発することを防ぎます。書かれたコードがこれらの条件を満たしていない場合に、開発者が変数をクローンすることでコンパイラエラーを解決すると、このアンチパターンが発生します。
例
#![allow(unused)]
fn main() {
// 任意の変数を定義する
let mut x = 5;
// `x` を借用する -- ただし、まずクローンする
let y = &mut (x.clone());
// 2行前の x.clone() がなければ、この行はコンパイル時に失敗する。
// x が借用されているため
// x.clone() のおかげで、x は一度も借用されず、この行は実行される。
println!("{x}");
// Rust がこれを最適化で消し去らないように、借用に対して
//何らかの操作を行う
*y += 1;
}
動機
特に初心者にとって、借用チェッカーに関するわかりにくい問題を解決するために、このパターンを使いたくなることがあります。しかし、重大な影響があります。.clone() を使うと、データのコピーが作成されます。その2つの間の変更は同期されません – まるで完全に別々の変数が2つ存在するかのようです。
特殊なケースもあります – Rc<T> はクローンを賢く扱うように設計されています。内部ではデータのコピーを厳密に1つだけ管理します。Rc に対して .clone() を呼び出すと、新しい Rc インスタンスが生成されます。これは参照カウントを増やしつつ、元の Rc と同じデータを指します。同じことは、Rc のスレッドセーフな対応物である Arc にも当てはまります。
一般に、クローンはその影響を十分に理解したうえで、意図的に行うべきです。借用チェッカーのエラーを消すためにクローンが使われているなら、このアンチパターンが使われている可能性を示す良い兆候です。
.clone() は悪いパターンを示すものですが、次のような場合には、非効率なコードを書いても問題ないこともあります。
- 開発者が所有権にまだ慣れていない
- コードに速度やメモリの厳しい制約がない(ハッカソンのプロジェクトやプロトタイプなど)
- 借用チェッカーを満たすのが非常に複雑で、性能よりも可読性の最適化を優先したい
不要なクローンが疑われる場合は、そのクローンが必要かどうかを評価する前に、Rust Book の所有権に関する章を十分に理解しておくべきです。
また、プロジェクトでは必ず cargo clippy を実行してください。これにより、.clone() が不要ないくつかのケースを検出できます。
関連項目
- 変更された enum の所有値を保持するための
mem::{take(_), replace(_)} .clone()を賢く扱うRc<T>のドキュメント- スレッドセーフな参照カウントポインターである
Arc<T>のドキュメント - Rust における所有権のテクニック
#![deny(warnings)]
説明
善意のあるクレート作者は、自分のコードが警告なしでビルドされることを保証したいと考えます。そこで、クレートルートに次のように注釈を付けます。
例
#![allow(unused)]
#![deny(warnings)]
fn main() {
// すべて問題ありません。
}
利点
短く、何か問題があればビルドを停止します。
欠点
コンパイラーが警告付きでビルドすることを許可しないことで、クレート作者は Rust の名高い安定性から外れることになります。新しい機能や古い不適切な機能によって、物事の行い方を変更する必要が生じることがあります。そのため、一定の猶予期間は warn し、その後 deny に変更される lint が書かれます。
たとえば、ある型が同じメソッドを持つ 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 を追加したい人もいるかもしれません。
なお、将来さらに多くの非推奨 API が出ることはかなり確実であるため、deprecated lint は明示的に追加していません。
関連項目
- すべての clippy lint のコレクション
- deprecate attribute ドキュメント
- システム上の lint の一覧を表示するには
rustc -W helpと入力してください。また、一般的なオプション一覧を表示するにはrustc --helpと入力してください - rust-clippy は、より良い Rust コードのための lint のコレクションです
Deref ポリモーフィズム
説明
構造体間の継承をエミュレートし、それによってメソッドを再利用するために、Deref トレイトを誤用するアンチパターンです。
例
ときには、Java などの OO 言語でよく見られる次のようなパターンをエミュレートしたい場合があります。
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 のインスタンスを含めます(このフィールドは値として保持されるため、Bar の中にインラインで格納されます。したがって、Foo にフィールドがある場合、それらはおおむね Java 版と同じようなメモリレイアウトになるでしょう(ただし、メモリレイアウトを厳密に保証したい場合は #[repr(C)] などを検討する必要があります)。
メソッド呼び出しが機能するようにするために、Bar に対して Foo をターゲットとする 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 に関して、ほとんどの OO 言語とは微妙に異なるセマンティクスになります。通常の OO 言語では、self はサブクラスへの参照のままですが、このパターンでは呼び出されたメソッドが定義されている「クラス」、つまり Foo への参照として扱われます。
最後に、このパターンは単一継承しかサポートせず、インターフェイス、クラスベースのプライバシー、その他の継承関連機能の概念もありません。そのため、Java の継承などに慣れたプログラマーにとっては、微妙に意外な体験になります。
議論
唯一の優れた代替策はありません。正確な状況によっては、トレイトを使用して再実装する方がよい場合もあれば、Foo へ手動でディスパッチするファサードメソッドを書き出す方がよい場合もあります。Rust にこれに似た継承の仕組みを導入する案は議論されていますが、安定版 Rust で利用できるようになるまでには時間がかかる可能性があります。詳細については、これらのブログ記事(記事 1、記事 2)および RFC issue を参照してください。
Deref トレイトは、カスタムポインター型の実装のために設計されています。その意図は、T へのポインターを通じて T に自然にアクセスできるようにすることであり、異なる型の間で変換することではありません。これがトレイト定義によって強制されていない(おそらく強制できない)のは残念です。
Rust は、型間の明示的な変換を好みつつ、明示的な仕組みと暗黙的な仕組みの間で慎重なバランスを取ろうとしています。ドット演算子における自動デリファレンスは、エルゴノミクスの観点から暗黙的な仕組みが強く好まれるケースですが、その意図は、これを任意の型間の変換ではなく、間接参照の段階に限定することです。
関連項目
- コレクションはスマートポインターであるというイディオム。
- ボイラープレートを減らすための委譲クレート。たとえば delegate や ambassador
Derefトレイトのドキュメント。
Rust の関数型の使い方
Rust は命令型言語ですが、多くの 関数型プログラミング パラダイムに従っています。
コンピューターサイエンスにおいて、関数型プログラミング とは、 関数を適用し合成することによってプログラムを構築するプログラミングパラダイムです。これは 宣言型プログラミングパラダイムであり、関数定義は、 プログラムの状態を変更する命令型文の列ではなく、それぞれが値を返す 式の木として表されます。
プログラミングパラダイム
命令型の背景から関数型プログラムを理解しようとするとき、最大の障壁の1つは考え方の変化です。命令型プログラムは、何かを行う方法を記述するのに対し、宣言型プログラムは何を行うかを記述します。これを示すために、1から10までの数値を合計してみましょう。
命令型
#![allow(unused)]
fn main() {
let mut sum = 0;
for i in 1..11 {
sum += i;
}
println!("{sum}");
}
命令型プログラムでは、何が起きているのかを理解するために、私たちがコンパイラの役をしなければなりません。ここでは、0 の sum から始めます。次に、1から10までの範囲を反復処理します。ループを1回通るたびに、その範囲内の対応する値を加算します。その後、それを出力します。
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 | 結果 |
|---|---|---|
| 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> は、
2 つの異なる型を当てはめた vector 型(template として知られる)の
同じ定型コードの別々のコピーにすぎません。
Rust では、ジェネリック型パラメーターは、関数型言語で「型クラス制約」として
知られているものを作成し、エンドユーザーによって埋められるそれぞれ異なる
パラメーターは実際に型を変えます。言い換えると、Vec<isize> と
Vec<char> は2 つの異なる型であり、型システムのすべての部分から
別個のものとして認識されます。
これは単相化と呼ばれ、多相的なコードから異なる型が作成されます。
この特殊な振る舞いにより、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 {
// ... その他のメソッド ...
/// これが NFS リクエストの場合、NFS マウントポイントを取得します。それ以外の場合は、
/// 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 セッション管理は省略
}
mod bootp {
pub(crate) struct AuthInfo(); // bootp では認証なし
}
// 外部ユーザーが独自のプロトコルを作り出せないように、モジュールを private に保ちます。
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(); // 追加のメタデータなし
impl ProtoKind for Bootp {
type AuthInfo = bootp::AuthInfo;
fn auth_info(&self) -> Self::AuthInfo {
bootp::AuthInfo()
}
}
}
use proto_trait::ProtoKind; // 外部での impl を防ぐため内部に保ちます
pub use proto_trait::{Bootp, Nfs}; // 呼び出し元から見えるように再エクスポートします
struct FileDownloadRequest<P: ProtoKind> {
file_name: PathBuf,
protocol: P,
}
// 共通するすべての API 部分はジェネリック impl ブロックに入ります
impl<P: ProtoKind> FileDownloadRequest<P> {
fn file_path(&self) -> &Path {
&self.file_name
}
fn auth_info(&self) -> P::AuthInfo {
self.protocol.auth_info()
}
}
// プロトコル固有のすべての impl は、それぞれ専用のブロックに入ります
impl FileDownloadRequest<Nfs> {
fn mount_point(&self) -> &Path {
self.protocol.mount_point()
}
}
fn main() {
// ここにコードを記述
}
このアプローチでは、ユーザーが間違えて誤った型を使った場合は、
fn main() {
let mut socket = crate::bootp::listen()?;
while let Some(request) = socket.next_request()? {
match request.mount_point().as_ref() {
"/secure" => socket.send("アクセスが拒否されました"),
_ => {} // 続行...
}
// ここに残りのコード
}
}
構文エラーが発生します。FileDownloadRequest<Bootp> 型は
mount_point() を実装しておらず、それを実装しているのは
FileDownloadRequest<Nfs> 型だけです。そしてもちろん、それは NFS モジュールによって
作成されるものであり、BOOTP モジュールではありません!
利点
第一に、複数の状態に共通するフィールドの重複を排除できます。共有されていない フィールドをジェネリックにすることで、それらは一度だけ実装されます。
第二に、impl ブロックが状態ごとに分解されるため、読みやすくなります。
すべての状態に共通するメソッドは 1 つのブロックに一度だけ型付けされ、
1 つの状態に固有のメソッドは別のブロックに置かれます。
これらはいずれも、コード行数が少なくなり、より適切に整理されることを意味します。
欠点
これは現在、コンパイラでモノモーフィゼーションが実装されている方法により、 バイナリのサイズを増加させます。将来的に実装が改善されることが期待されます。
代替案
-
構築や部分的な初期化のために、型に「分割 API」が必要に見える場合は、 代わりに ビルダーパターンを検討してください。
-
型の間で API が変わらず、振る舞いだけが変わる場合は、 代わりに ストラテジーパターンを使用する方が適しています。
関連項目
このパターンは標準ライブラリ全体で使用されています。
Vec<u8>は、他のすべてのVec<T>型とは異なり、String からキャストできます。1- イテレータはバイナリヒープにキャストできますが、それは
Ordトレイトを 実装する型を含んでいる場合に限られます。2 to_stringメソッドは、str型のCowに対してのみ特殊化されていました。3
これは、API の柔軟性を実現するために、いくつかの人気のあるクレートでも使用されています。
-
組み込みデバイスに使用される
embedded-halエコシステムでは、 このパターンが広範に使用されています。たとえば、組み込みピンの制御に使用される デバイスレジスタの構成を静的に検証できます。ピンがあるモードに設定されると、Pin<MODE>構造体が返され、そのジェネリックによって、そのモードで使用可能な関数が 決定されます。これらの関数はPin自体には存在しません。 4 -
hyperHTTP クライアントライブラリは、異なる差し替え可能なリクエスト向けの 豊富な API を公開するためにこれを使用しています。異なるコネクタを持つクライアントには、 異なるメソッドと異なるトレイト実装があり、一方で中核となる一連の メソッドは任意のコネクタに適用されます。 5 -
「型状態」パターン、つまりオブジェクトが内部状態や不変条件に基づいて API を 獲得したり失ったりするパターンは、同じ基本概念と、少し異なる技法を使って Rust で実装されます。 6
-
例: https://docs.rs/stm32f30x-hal/0.1.0/stm32f30x_hal/gpio/gpioa/struct.PA0.html ↩
-
参照: https://docs.rs/hyper/0.14.5/hyper/client/struct.Client.html ↩
-
参照: 型状態パターンの根拠 および Rusty Typestate Series(広範な論文) ↩
関数型言語の Optics
Optics は、関数型言語で一般的な API 設計の一種です。これは純粋関数型の概念であり、Rust ではあまり頻繁には使われません。
それでも、この概念を探究することは、visitors など、Rust API における他のパターンを理解するのに役立つかもしれません。また、ニッチなユースケースもあります。
これはかなり大きなトピックであり、その機能を十分に掘り下げるには、言語設計に関する実際の書籍が必要になるでしょう。ただし、Rust における適用可能性はずっと単純です。
この概念の関連する部分を説明するために、Serde API を例として使用します。これは、多くの人にとって API ドキュメントだけから理解するのが難しいものだからです。
その過程で、Optics と呼ばれるいくつかの具体的なパターンを扱います。それらは The Iso、The Poly Iso、The 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>;
// 残りは省略
}
そして、ジェネリックに渡される 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;
// 残りは省略
}
ここでは、多段階の関連型が行き来しており、多くの型消去が行われています。
しかし、全体像は何でしょうか。単に Visitor が呼び出し元に必要な断片をストリーミング API で返すようにして、それで終わりにしないのはなぜでしょうか。なぜ追加の要素がすべて必要なのでしょうか。
これを理解する 1 つの方法は、optics と呼ばれる関数型言語の概念を見ることです。
これは、Rust で一般的なパターンである失敗、型変換などを容易にするように設計された、振る舞いと性質の合成を行う方法です。1
Rust 言語は、これらを直接扱うためのサポートがあまり優れていません。しかし、それらは言語自体の設計に現れており、その概念は Rust の API の一部を理解する助けになります。そのため、ここでは Rust がそれをどのように行っているかという観点から概念を説明しようとします。
これにより、それらの API が何を実現しているのか、つまり合成可能性の特定の性質に光が当たるかもしれません。
基本的な Optics
The Iso
Iso は、2 つの型の間で値を変換するものです。非常に単純ですが、概念的に重要な構成要素です。
例として、文書のコンコーダンスとして使われるカスタムのハッシュテーブル構造があるとします。2 これはキー(単語)に文字列を使用し、値(たとえばファイルオフセット)にインデックスのリストを使用します。
重要な機能は、この形式をディスクにシリアライズできることです。「手早く雑な」アプローチとしては、JSON 形式の文字列への変換と文字列からの変換を実装することが考えられます。(エラーは当面無視し、後で扱います。)
関数型言語のユーザーが期待する通常形で書くと、次のようになります。
case class ConcordanceSerDe {
serialize: Concordance -> String
deserialize: String -> Concordance
}
したがって、Iso は異なる型の値を変換する関数のペア、つまり 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!()
}
// 無効なコンコーダンスは空
fn deserialize(value: String) -> Concordance {
todo!()
}
}
}
これはかなりばかげているように見えるかもしれません。Rust では、この種の振る舞いは通常トレイトで行われます。結局のところ、標準ライブラリには FromStr と ToString があります。
しかし、そこで次の主題である Poly Iso が登場します。
Poly Isos
前の例は、2 つの固定された型の値の間で単に変換しているだけでした。次のブロックはジェネリクスを使ってその上に構築されており、より興味深いものです。
Poly Iso は、単一の型を返しつつ、任意の型に対してジェネリックに操作できるようにします。
これにより、解析に近づきます。エラーケースを無視した基本的なパーサーが何をするかを考えてみましょう。これも通常形では次のようになります。
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;
}
Iso とは異なり、Poly Iso は複数の型を適用でき、それらをジェネリックに返します。これは、基本的な文字列パーサーに望まれるものです。
一見すると、これはパーサーを書くための良い選択肢のように見えます。実際に見てみましょう。
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 標準ライブラリ内の多くの型はすでにそうなっていません。これを使うのは適合が悪いです。これは独自のトレイトで簡単に解決できます。
しかし、第二の、より微妙な問題があります。それはスケーリングです。
すべての型が手作業で 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 つの Poly Iso と 1 つの Iso です。これらはいずれもトレイトで実装できます。
#![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)] // "Serde" derive はトレイトの impl ブロックを作成する
struct TestStruct {
a: usize,
b: String,
}
// ユーザーは関連付けられた visitor 型を生成するためにこのマクロを書く
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]
}
この構成は Prism と呼ばれます。これは Poly Iso よりジェネリクスの「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]
}
するとどうでしょう、一番下にトレイトとして実装できる一対の Poly Iso があります。
したがって、Serde API は次のようになります。
- シリアライズされる各型は、
Serdeクラスに相当するDeserializeまたはSerializeを実装します。 - それらは
Visitorトレイトを実装する型(正確には各方向に 1 つずつ、2 つ)を取得します。これは通常(ただし常にではありません)、derive マクロによって生成されたコードを通じて行われます。ここには、データ型と Serde データモデルの形式との間で構築または分解するためのロジックが含まれています。 Deserializerトレイトを実装する型は、Visitorによって「駆動される」形で、形式に固有のすべての詳細を処理します。
この分割と Rust の型消去は、実際には間接化によって Prism を実現するためのものです。
これは 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>;
// 残りは省略
}
そして 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;
// 残りは省略
}
そして、マクロによって実装されるトレイト 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は、見つかった項目が期待されたものかどうかを示し、そうでない場合はデシリアライズが失敗したことを示すエラーを発生させます。
上記の非常に単純な構造では、期待されるパターンは次のようになります。
- map(Serde における
HashMap、または JSON の dictionary に相当)の訪問を開始します。 - “keys” という文字列キーを訪問します。
- map の値の訪問を開始します。
- 各項目について、文字列キーを訪問し、次に整数値を訪問します。
- map の終端を訪問します。
- その map をデータ構造の
keysフィールドに格納します。 - “value_table” という文字列キーを訪問します。
- list の値の訪問を開始します。
- 各項目について、整数を訪問します。
- list の終端を訪問します。
- その list を
value_tableフィールドに格納します。 - map の終端を訪問します。
しかし、どの「観測」パターンが期待されるかは何によって決まるのでしょうか。
関数型プログラミング言語であれば、カリー化を使って型そのものに基づく各型のリフレクションを作成できます。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 の「最上位層」だけを実装し、 ファイル形式は「最下位層」だけを実装すればよくなります。その後、各部分は エコシステムの他の部分と「そのまま動作」できます。ジェネリック型がそれらの 橋渡しをするためです。
結論として、この API 設計で示されているように、Rust のジェネリックに着想を 得た型システムは、これらの概念に近づき、その力を活用できます。ただし、 そのジェネリクスの橋渡しを作成するために、手続き型マクロが必要になる場合も あります。
このトピックについてさらに学ぶことに興味がある場合は、次のセクションを確認してください。
関連項目
- 事前に構築されたレンズ実装である lens-rs クレート。 これらの例よりもすっきりしたインターフェイスを備えています
- Serde そのもの。これは、エンドユーザー(つまり構造体を定義する人)が 詳細を理解しなくても、これらの概念を直感的に扱えるようにします
- luminance は、コンピューターグラフィックスを 描画するためのクレートで、同様の API 設計を使用しています。これには、ジェネリックのままである さまざまなピクセル型のバッファーに対して完全なプリズムを作成する手続き型マクロも含まれます
- Scala におけるレンズに関する記事。 Scala の専門知識がなくても非常に読みやすい記事です。
- 論文: Profunctor Optics: Modular Data Accessors
- Musli は、異なるアプローチで同様の構造を 使用しようとするライブラリです。たとえば、visitor をなくしています
追加リソース
補足的な役立つコンテンツ集
講演
- Rustにおけるデザインパターン、 PDRust (2016) で Nicholas Cameron による発表
- Rustでイディオマティックなライブラリを書く RustFest (2017) で Pascal Hertleif による発表
- Rustプログラミングテクニック、 LinuxConfAu (2018) で Nicholas Cameron による発表
書籍(オンライン)
設計原則
一般的な設計原則の簡単な概要
SOLID
- 単一責任の原則(SRP): クラスは単一の責任のみを持つべきである。つまり、ソフトウェア仕様の ある一部分への変更だけが、そのクラスの仕様に影響を与え得るべきである。
- オープン/クローズドの原則(OCP): 「ソフトウェアのエンティティは、拡張に対して開かれているべきであり、 変更に対して閉じているべきである。」
- リスコフの置換原則(LSP): 「プログラム内のオブジェクトは、そのプログラムの正しさを変えることなく、 そのサブタイプのインスタンスで置き換え可能であるべきである。」
- インターフェイス分離の原則(ISP): 「1つの汎用インターフェイスよりも、多数のクライアント固有の インターフェイスのほうがよい。」
- 依存性逆転の原則(DIP): 「具象ではなく、抽象に依存する」べきである。
CRP(複合再利用の原則)または継承よりコンポジション
「クラスは、基底クラスまたは親クラスからの継承よりも、コンポジション (必要な機能を実装する他のクラスのインスタンスを含むこと)によって、 ポリモーフィックな振る舞いとコードの再利用を優先すべきであるという原則」 - Knoernschild, Kirk (2002). Java Design - Objects, UML, and Process
DRY(同じことを繰り返すな)
「すべての知識は、システム内で単一かつ曖昧でなく、権威ある表現を 持たなければならない」
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