項目2:型システムを用いて共通の挙動を表現しよう
LT;DR
クロージャ
クロージャを受け取るコードを書く際には、可能な限り最も一般的な Fn* を用いるべき である
e.g. クロージャを 1 度しか用いないなら FnOnce を用いる
これにより、呼び出し側が柔軟になる
同じ理由で、通常の関数ポインタではなくトレイト境界 Fn* を用いたほうが良い
トレイト
マーカトレイト
関数シグネチャで表現できない挙動の違いを表すには、マーカトレイトを用いる
トレイト境界(トレイト制約)
トレイト境界を用いることで、対象となる型 T が トレイトで定義されている関数を実際に持っていることをコンパイル時に保証することができる(静的ディスパッチ)] ジェネリックなコードで用いる型に対する要求は、トレイト制約を用いて表現すべき
トレイトオブジェクト
利用されるトレイトの実装を、コンパイル時ではなく実行時に決定 する(動的ディスパッチ)こと トレイトオブジェクトとして利用するには、トレイトで定義されているすべてのメソッドが以下の 2 つの特性(オブジェクト安全)を持つ必要がある 1. 戻り値や(レシーバ 以外の)引数の型が Self でない 2. ジェネリック関数 (e.g. fn some_fn<T>(t: T))を持っていない 他の言語同様、パラメータの型と戻り値の型を明示する
code:rs
fn div(x: f64, y: f64) -> f64 {
if y = 0.0 {
return f64::NAN;
}
x / y
}
メソッド: 特定のデータ構造の インスタンス に関連付けられた関数 最初の引数 self で指定される特定の型のインスタンスに対して動作し、impl データ構造名 ブロックに記述する
self は、具体的には以下のいずれかの型である
&self: データ構造の内容を読む場合があるが、変更することはない
&mut self: データ構造の内容を変更する場合がある
self: データ構造を消費する
struct だけではなく、enum に付与することも可能
code:rs
enum Shape {
Rectangle { width: f64, height: f64 },
Circle { radius: f64 },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Rectangle { width, height } => width * height,
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
}
}
}
関数やメソッドを実行すると常に同じコードが実行される
実行ごとに変わるのは関数が処理するデータだけである
これを、実行時にコード自体が変更するようにできる 抽象化 が関数ポインタ 関数のシグネチャを反映した型(fn)を持つ
code:rs
fn sum(x: i32, y: i32) -> i32 {
x + y
}
let op: fn(i32, i32) -> i32 = sum;
型はコンパイル時にチェックされるので、実行時の関数ポインタのサイズはただのポインタと同じ
メソッドとは異なり関連付けられているデータ型がないので、通常の値として扱うことが可能
code:rs
// fn 型は Copy トレイトを実装している
let op1 = op;
let op2 = op;
// fn 型は Eq トレイトを実装している
assert!(op1 == op2);
// fn 型は std::fmt::Pointer トレイトを実装している
println!("{:p}", op); // 出力: 0x5c6601fdfb70
warning.icon
関数名を渡しただけでは関数ポインタにはならず、fn 型への明示的な 自動型変換 が必要 code:rs
let op1 = sum;
let op2 = sum;
assert!(op1 == op2);
コンパイルエラーを見ると、sum は fn(i32, i32) -> i32 {sum} のような型になっていることが分かる
sum の型はコンパイラ内部で生成された型で、特定の関数とシグネチャを指している
より具体的には、最適化のために関数のシグネチャとその位置をエンコードしたもの
開発者がこの型を定義することは不可能
関数ポインタは、使用できる入力が明示的に渡される引数値に限る ため、用途が限定される
したがって、たとえば外部の状態に依存した更新を行いたい場合、その状態を暗黙に関数ポインタに渡すことはできない
code:rs
// 本来は Iterator メソッドを用いたほうが良い
fn modify_all(data: &mut u32, mutator: fn(u32) -> u32) { for value in data {
*value = mutator(*value);
}
}
let amount_to_add = 1;
fn add_n(v: u32) -> u32 {
v + amount_to_add
}
modify_all(&mut data, add_n);
上記のような問題を回避したい場合、クロージャを用いることができる
クロージャは以下の点で関数と異なる
式の一部とすることができる(ラムダ式)ため、名前を付ける必要がない 入力引数は || で囲って、|param1, param2| のように与える
引数の型は、多くの場合コンパイラが 型推論 してくれる code:rs
let amount_to_add = 3;
let add_n = |y| {
y + amount_to_add
};
let z = add_n(5);
assert_eq!(z, 8);
キャプチャ時の動作
クロージャが作成されると、一時的な型のインスタンスが作られ、そこにキャプチャした値が格納される
クロージャが実行される際には、このインスタンスが コンテキスト としてクロージャに与えられる イメージ
code:rs
let amount_to_add = 3;
struct InternalContext<'a> {
amount_to_add: &'a u32,
}
impl<'a> InternalContext<'a> {
fn internal_op(&self, y: u32) -> u32 {
y + *self.amount_to_add
}
}
let add_n = InternalContext { amount_to_add: &amount_to_add };
let z = add_n.internal_op(5);
assert_eq!(z, 8);
多くの場合、コンテキストに保持される値は上記の例のように、環境にある値に対する不変参照である
しかし、可変参照や環境からコンテキストに完全に移動することも可能(move)
関数ポインタを受け取る関数に、クロージャを渡すことはできない
code:rs
modify_all(&mut data, |v| { v + amount_to_add });
クロージャを受け取るコードは、Fn* トレイト のいずれかを受け取る必要がある code:rs
fn modify_all<F>(data: &mut u32, mut mutator: F) where
F: FnMut(u32) -> u32
{
for value in data {
*value = mutator(*value);
}
}
1 度のみ呼び出されるクロージャを表す
繰り返し呼び出せるクロージャを表す
環境を可変参照しているので、変更を行うことが可能
繰り返し呼び出せるクロージャを表す
環境を共有参照する
コンパイラはコード内のラムダ式に対して、上記のうち適切な Fn* トレイトを自動的に実装する
FnOnce: 値を移動する
FnMut: 値への可変参照を持つ(&mut T)
Fn 値への共有参照のみを持つ(&T)
Fn と FnMut は、それぞれ FnMut 、FnOnce トレイトの トレイト境界 を含む 具体的には、
1 度しか呼べないクロージャを想定しているコード(FnOnce)に、複数回呼び出すことのできるクロージャ(FnMut)を渡すことは可能
環境を変更する可能性があり、複数回呼び出すことのできるクロージャを想定しているコード(FnMut)に、環境を変更する必要の無いクロージャ(Fn)を渡すことは可能
すべての(unsafe でない)関数ポインタ型は自動的にすべての Fn* トレイトを実装する。
以上を踏まえると、
クロージャを受け取るコードを書く際には、可能な限り最も一般的な Fn* を用いるべき である
これにより、呼び出し側が柔軟になる
e.g. クロージャを 1 度しか用いないなら FnOnce を用いる
同じ理由で、通常の関数ポインタではなくトレイト境界 Fn* を用いたほうが良い
トレイトとは?
トレイトに属する要素が提供する一連の関数・メソッドを定義したもの
個々の関数・メソッドには名前が付けられる
この名前は、コンパイラが同じシグネチャの関数を区別するラベルとなる
また、プログラマが関数の意図を推定するのに役立てられる
トレイトを実装する型は、すべての関数を実装する必要がある
ただし、トレイトの定義でデフォルト実装を持つことが可能
実装で用いる関連データを持つことも可能
したがって、オブジェクト指向 風に、コードとデータが共通の抽象の内部にカプセル化される 将来的に柔軟性が心配なら、具象型ではなくトレイト型を受け付けるようにしたほうが良い
関数のシグネチャではなく、型システムで区別するためのトレイト
関数シグネチャで表現できない挙動の違いを表すには、マーカトレイトを用いる
e.g. コレクションをソートする Sort トレイト
code:rs
pub trait Sort {
fn sort(&nut self)
}
pub trait StableSort: Sort {}
実装によってはソートが 安定(比較結果が等価となる 2 つの要素の順番が、ソートの前後で変わらないこと)な場合があるが、これを sort メソッドの引数として表現することはできない これを表現するため、StableSort というマーカトレイトを定義している
マーカトレイトは関数を持たないが、トレイトを実装することを宣言する必要がある
これによって、実装にその性質を明示的に付与する責任が課される
ある型 T に対して、パラメータ化された ジェネリック なコードが、その型 T が特定のトレイトを実装している場合のみ利用できること これにより、T が トレイトで定義されている関数を実際に持っていることをコンパイル時に保証される(静的ディスパッチ)ため、ジェネリックなコード内で安全にトレイトの関数を呼び出すことが可能になる このチェックはコンパイル時にジェネリックなコードが 単相化(任意の型に対して書かれたジェネリックなコードが、特定のある型に対するコードに変換される)際に行われる T に対する制約は、明示的にトレイト制約として指定される
また、トレイトはそのトレイト制約を満たす型に対してのみ実装可能である
そのため、ほとんどのジェネリックなコードではトレイト制約を使うことになる
理由: トレイト制約がないと、T を使ってなにか処理をすることができないため
例として、T に何のトレイト制約を持たない構造体 Thing<T> について考える
この場合、Thing は任意の型 T に対して行える操作しかない
e.g. 値を移動したり、ドロップしたりする
ジェネリックなコードで用いる型に対する要求は、トレイト制約を用いて表現すべき
code:rs
pub fn dump_sorted<T>(mut collection: T)
where
T: Sort + IntoIterator,
T::Item: std::fmt::Debug
{
collection.sort();
for item in collection {
println!("{:?}", item)
}
}
利用されるトレイトの実装を、コンパイル時ではなく実行時に決定 する(動的ディスパッチ) トレイトオブジェクトはサイズがコンパイル時に定まらない
そのため、常に何らかの参照(&dyn Trait)やポインタ(Box<dyn Trait>)を介して間接的に扱う必要がある
トレイトオブジェクトとして利用するには、トレイトで定義されているすべてのメソッドが以下の 2 つの特性(オブジェクト安全)を持つ必要がある 1. 戻り値や(レシーバ 以外の)引数の型が Self でない 理由
Self のサイズが分からないため
2. ジェネリック関数 (e.g. fn some_fn<T>(t: T))を持っていない 理由
ジェネリック関数を持つトレイトは、存在しうるすべての型に対して無数の実装を持つ可能性がある
しかし、トレイト制約として用いる場合は、その実装が必要になるのは「実際に使用された型」に限定され、有限の数となる
一方、トレイトオブジェクトは実行時に現れる可能性のあるすべての数に対して、コンパイル時に対応する必要があるため
参考