項目12:ジェネリクスとトレイトオブジェクトのトレードオフを理解しよう
https://effective-rust.com/generics.html
LT;DR
ジェネリクス(<T>)
メリット
コンパイル時に具体的な型に展開されるため、実行時のオーバーヘッドが無く、高速なコードが生成される
トレイト境界に複数のトレイトを指定できる
デメリット
使用する型ごとにコードが生成されるため、バイナリサイズが大きくなり、コンパイル時間も増加する
トレイトオブジェクト(dyn T)
メリット
型消去により、異なる型の値を同一のコレクションに格納できる
動的ディスパッチ により実行時に適切なメソッドが呼び出される
コードサイズやコンパイルを抑えることができる
デメリット
実行時のパフォーマンスが若干低下する可能性がある
トレイトオブジェクト安全性 の制約があるため、トレイトに定義できるメソッドが制限される
hr.icon
ジェネリクス
任意の型 T に対するコードを書くと、コンパイル 時にジェネリックなコードから特定の用途に用いるコードが生成 される
Rust では 単相化、C++ では テンプレートのインスタンス化 と呼ばれる
更に Rust は、型 T に対する制約を トレイト境界 を用いることで明示できる
トレイトオブジェクト
トレイトオブジェクトは ファットポインタ で、具体的なアイテムへの ポインタ と vtable へのポインタを組み合わせたもの
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 = &square;
let shape: &dyn Shape = &square;
上記のトレイトオブジェクトは、トップレベルのトレイトのメソッドに加えて、すべてのトレイト境界のメソッドを含む単一の vtable を持つ
https://scrapbox.io/files/677696231ad24a2fee61867e.png
そのため、現時点では Shape から Draw に アップキャスト する方法が無い
warning.icon ただし、1.81 現在正式リリースはされていないが、トレイトアップキャスト を使えば実現できる
現時点では #![feature(trait_upcasting)] を利用すれば可能
https://doc.rust-lang.org/beta/unstable-book/language-features/trait-upcasting.html
詳細: 項目19:リフレクションを避けよう#677939b875d04f0000634d68
もしリリースされれば、上記の図はより複雑なものになる
1.86 でリリースされた 👏
https://github.com/rust-lang/rust/pull/134367
https://aznhe21.hatenablog.com/entry/2025/04/04/rust-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 でない
∵ サイズが不明であり、格納するのに十分な メモリ空間 をスタック上に予約することができない
warning.icon 現時点では、安全に スタック 上に置くことができる Box<Self> を返すメソッドも制限されているが、今後緩和されるかもしれない(https://github.com/rust-lang/rust/issues/47649)
例外
Self のサイズがコンパイル時に分かることが Sized マーカトレイト で明示されている場合
code:rs
trait Stamp: Draw {
fn make_copy(&self) -> Self
where
Self: Sized;
}
このトレイト境界は、トレイトオブジェクトからは呼び出せないことを意味する
トレードオフ
以上を踏まえると「トレイトオブジェクトよりもジェネリクスを使うべき」となる
しかし、トレイトオブジェクトを使うべきケースもある
e.g.
生成されるコードのサイズやコンパイル時間が問題になるケース
項目12:ジェネリクスとトレイトオブジェクトのトレードオフを理解しよう#67768cf475d04f0000219783
項目12:ジェネリクスとトレイトオブジェクトのトレードオフを理解しよう#677691ac75d04f0000219786
型消去 を活用するケース
具象型の情報は、トレイトオブジェクトへ変換する際に失われる
これは問題になることもある(項目19:リフレクションを避けよう)が、異なる型のオブジェクトを 1 つのコレクションに収めることができる
これにより、以下のようなコードを記述することができる
code:rs
let shapes: Vec<&dyn Shape> = vec!&square, &circle;
for shape in shapes { shape.render() }
実行時に型が動的に決まるケース
コンパイル時に利用可能な型が分からず、実行時に新しいコードを動的に読み込む場合(e.g. dlopen(3))、そのコード内のトレイトを実装したオブジェクトはトレイトオブジェクトを介してのみ呼び出せる
#Rust #Effective_Rust_―_Rustコードを改善し、エコシステムを最大限に活用するための35項目