TL;DR

  • ジェネリクスは具体的な型ごとにコードが生成されるので速いが、バイナリサイズとコンパイル時間が増える。トレイト境界に複数のトレイトを指定できる。
  • トレイトオブジェクトは型消去により異なる型の値を 1 つのコレクションに入れられ、コードサイズも抑えられる。ただし実行時にわずかなコストがあり、トレイトオブジェクト安全性の制約を受ける。
  • 基本はジェネリクスを使い、トレイトオブジェクトが必要な場面で使い分ける。

内容

https://effective-rust.com/generics.html

ジェネリクスとトレイトオブジェクトの仕組み

ジェネリクスでは、任意の型 T に対して書いたコードから、コンパイル時に具体的な型ごとのコードが生成される。Rust ではこれを単相化、C++ ではテンプレートのインスタンス化と呼ぶ。Rust では、T に対する制約をトレイト境界で明示できる。

トレイトオブジェクトはファットポインタで、具体的な値へのポインタと vtable へのポインタを組み合わせたものである。vtable には、トレイトのメソッドすべての関数ポインタが入っている。そのため、トレイトオブジェクトを受け取る関数は、さまざまな型の入力を受け取る 1 つのコードにコンパイルされる。

基本的な比較

  • ジェネリクスはコードサイズが大きくなりやすい
  • トレイトオブジェクトではトレイトオブジェクトから vtable、vtable から関数の実装へと 2 段階の参照をたどるので、ジェネリクスのメソッド呼び出しはわずかに速い
  • ジェネリクスはコンパイル時間が長くなりやすい

これらの差は、ほとんどの場合たいしたものではない。最適化のためであっても、実際に計測して問題を確かめてから判断する(推測するな、計測せよ)。

一方、ジェネリクスのトレイト境界には、複数のトレイトを指定できる。トレイトオブジェクトは 1 つのトレイトの vtable しか持てないので、同じことをするには両方を束ねたトレイトを定義する必要がある。

fn show<T>(draw: &T)
where
    T: Debug + Draw,
{
    // ...
}

スーパートレイトは is-a ではない

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 メソッドに依存している。オブジェクト指向に慣れたプログラマは、トレイト境界をインタフェースの継承と混同し、これを「Shape is-a Draw」と捉えがちである。しかし「Shape also-implements Draw」と捉えたほうがよい。

&dyn Shape は、Shape のメソッドとすべてのスーパートレイトのメソッドを含む 1 つの vtable を持つ。原書の時点では、Shape のトレイトオブジェクトを Draw のトレイトオブジェクトにアップキャストする方法がなかった。そのため、Shape のトレイトオブジェクトを受け取るメソッドは、Draw のメソッドは使えても、&dyn Draw を受け取る他のメソッドにそれを渡せない。リスコフの置換原則を満たさないので、is-a と呼ぶのはふさわしくない。一方、Shape を実装した値を受け取るジェネリックなメソッドは、トレイト境界に Draw を持つ他のメソッドに値を渡せる。

なお、トレイトのアップキャストは Rust 1.86 で安定化された(https://github.com/rust-lang/rust/pull/134367 )。詳しくは項目19で扱う。

トレイトオブジェクト安全性

トレイトオブジェクトにできるトレイトには、次の制限がある。

  1. ジェネリックなメソッドを持たない。ジェネリックなメソッドは無限個のメソッドの集まりだが、vtable は有限個の関数ポインタの集まりなので、単相化した実装をすべて入れられない
  2. 戻り値や、レシーバ以外の引数の型が Self でない。サイズが分からないので、スタック上に領域を確保できない

ただし、Self: Sized の境界を付けたメソッドは例外である。この境界は、そのメソッドがトレイトオブジェクトからは呼べないことを意味する。

trait Stamp: Draw {
    fn make_copy(&self) -> Self
    where
        Self: Sized;
}

どちらを使うか

以上から、基本はトレイトオブジェクトよりジェネリクスを使う。ただし、次の場合はトレイトオブジェクトが向いている。

  • 生成されるコードのサイズやコンパイル時間が問題になる
  • 型消去を活用したい。具体的な型の情報は失われる(項目19)が、異なる型の値を 1 つのコレクションに入れられる
  • 実行時に型が決まる。dlopen(3) で実行時にコードを読み込む場合、そのコードのオブジェクトはトレイトオブジェクトを通してしか呼べない
let shapes: Vec<&dyn Shape> = vec![&square, &circle];
for shape in shapes {
    shape.render()
}

キーワード