項目12:ジェネリクスとトレイトオブジェクトのトレードオフを理解しよう
LT;DR
ジェネリクス(<T>)
メリット
コンパイル時に具体的な型に展開されるため、実行時のオーバーヘッドが無く、高速なコードが生成される
トレイト境界に複数のトレイトを指定できる
デメリット
使用する型ごとにコードが生成されるため、バイナリサイズが大きくなり、コンパイル時間も増加する
トレイトオブジェクト(dyn T)
メリット
型消去により、異なる型の値を同一のコレクションに格納できる
コードサイズやコンパイルを抑えることができる
デメリット
実行時のパフォーマンスが若干低下する可能性がある
hr.icon
任意の型 T に対するコードを書くと、コンパイル 時にジェネリックなコードから特定の用途に用いるコードが生成 される 更に Rust は、型 T に対する制約を トレイト境界 を用いることで明示できる vtable には、トレイトを実装するメソッドすべての 関数ポインタ が格納されている https://scrapbox.io/files/67769605e68325e6352f7972.png
そのため、トレイトオブジェクトを用いて関数を書くと、コンパイラは様々な入力型からなるトレイトオブジェクトを受け取る 1 つのコードに コンパイル する 基本的な比較
ジェネリクスとトレイトオブジェクトでは、以下のような違いがある
ジェネリクスを用いるとコードサイズが大きくなる傾向がある
ジェネリクスのトレイトメソッド呼び出しは、トレイトオブジェクトを用いた場合と比べて僅かに高速
∵ トレイトオブジェクトを用いると、2 段階の参照解決(トレイトオブジェクト → vtable → 関数の実装)が行われる
ジェネリクスのコンパイル時間が長くなる傾向ががある
上記の違いは、ほとんどのケースで大した差にならない
そのため、コードの最適化のためであっても、実際に計測して問題があることを確認してから が良い
しかし、ジェネリックな トレイト境界 を用いると、型パラメータが複数のトレイトを実装しているケースを指定できる code:rs
fn show<T>(draw: &T)
where
T: Debug + Draw,
{
// ...
}
トレイトオブジェクトは 1 つのトレイトに対する vtable しか保持できない ため、同じことをしようとすると面倒
code:rs
trait DebugDraw: Debug + Draw {}
impl<T: Debug + Draw> for T {}
code:rs
trait Shape: Draw {
fn render_in(&self, bounds: Bounds);
fn render(&self) {
if let Some(visible) = overlap(SCREEN_BOUNDS, self.bounds()) {
self.render_in(visible);
}
}
}
上記のコードでは、render の デフォルト実装 がトレイト境界の Draw の bounds メソッドに依存している OOP からプログラマは、トレイト境界と インタフェース を混在し、上記のようなトレイト境界を Shape is-a Draw だと誤って捉えがち しかし、実際には Shape also-implements Draw と捉えたほうが良い
理由
code:rs
let square = Square {
top_left: Point { x: 1, y: 2 },
size: 2,
};
let draw: &dyn Draw = □
let shape: &dyn Shape = □
上記のトレイトオブジェクトは、トップレベルのトレイトのメソッドに加えて、すべてのトレイト境界のメソッドを含む単一の vtable を持つ
https://scrapbox.io/files/677696231ad24a2fee61867e.png
そのため、現時点では Shape から Draw に アップキャスト する方法が無い warning.icon ただし、1.81 現在正式リリースはされていないが、トレイトアップキャスト を使えば実現できる 現時点では #![feature(trait_upcasting)] を利用すれば可能
もしリリースされれば、上記の図はより複雑なものになる
1.86 でリリースされた 👏
したがって、リスコフの置換原則 を満たすことができないため、is-a と呼ぶのは相応しくない 上記を言い換えると、Shape トレイトオブジェクトを受け取るメソッドは以下の性質を持つことになる
Draw のメソッドを利用できる
Draw トレイトオブジェクトを受け取る他のメソッドに、トレイトオブジェクトを渡すことは(現時点では)できない
Shape は Draw の vtable を持っていないため
一方、Shape を実装した値を受け取るジェネリックメソッドは以下の性質を持つ
Draw のメソッドを利用できる
トレイト境界に Draw を持つ他のメソッドに値を渡すことができる
1. ジェネリック関数 (e.g. fn some_fn<T>(t: T))を持っていない ∵ ジェネリックメソッドは無限のメソッドの 集合 だが、トレイトオブジェクトの vtable は有限の関数ポインタの集合なので、単相化した実装を無限に詰め込むことは不可能 2. 戻り値や(レシーバ 以外の)引数の型が Self でない ∵ サイズが不明であり、格納するのに十分な メモリ空間 をスタック上に予約することができない 例外
code:rs
trait Stamp: Draw {
fn make_copy(&self) -> Self
where
Self: Sized;
}
このトレイト境界は、トレイトオブジェクトからは呼び出せないことを意味する
以上を踏まえると「トレイトオブジェクトよりもジェネリクスを使うべき」となる
しかし、トレイトオブジェクトを使うべきケースもある
e.g.
生成されるコードのサイズやコンパイル時間が問題になるケース
具象型の情報は、トレイトオブジェクトへ変換する際に失われる
これにより、以下のようなコードを記述することができる
code:rs
for shape in shapes { shape.render() }
実行時に型が動的に決まるケース
コンパイル時に利用可能な型が分からず、実行時に新しいコードを動的に読み込む場合(e.g. dlopen(3))、そのコード内のトレイトを実装したオブジェクトはトレイトオブジェクトを介してのみ呼び出せる