Trait オブジェクトと動的ディスパッチ

このページの目次

dyn Trait は具体的な実装型を隠し、呼び出し側が合意したインターフェースで操作できるようにします。異なる実装の統一的な扱いや下流での拡張に適します。具体型がコンパイル時に分かっていても、インターフェースやコード量を調整するために選べます。

オブジェクトはヒープ上にあるとは限らない

&dyn Measure借用。ヒープ割り当ては必須ではない具体的な値スタック・ヒープなどの有効領域対応する実装の vtable動的に呼び出せるメソッド情報を提供するtrait object:データと振る舞いの情報が組み合わさって呼び出しを決定するvtable のフィールド順序やスロットは規定されていないことに注意。内部レイアウトは安定した Rust ABI ではない。
trait Measure {
    fn amount(&self) -> usize;
}
impl Measure for usize {
    fn amount(&self) -> usize { *self }
}
impl Measure for String {
    fn amount(&self) -> usize { self.len() }
}
fn total(items: &[&dyn Measure]) -> usize {
    items.iter().map(|item| item.amount()).sum()
}

fn main() {
    let number = 7usize;
    let text = String::from("abc");
    let items: [&dyn Measure; 2] = [&number, &text];
    assert_eq!(total(&items), 10);
}

ここでは数値と String は局所値で、&dyn Measure はそれらを借用するだけです。Box<dyn Measure> はさらに所有関係を提供します。動的ディスパッチとヒープ割り当ては別の選択で、String 自身のバイトバッファの配置も別の問題です。

trait オブジェクトは動的サイズ型で、通常は参照やスマートポインタを介して扱います。オブジェクトポインタにはデータアドレスと対応する vtable 情報があり、動的な呼び出しは該当メソッドの入口を使います。Trait オブジェクト

「第 3 スロットは必ず fmt」などと仮定して変換・呼び出しをしてはいけません。vtable の内部フィールドやデストラクタ入口は安定 ABI ではなく、異なるコンパイラ版のプラグイン互換性も保証しません。

データとメソッド入口を対応させる

vtable の内部配置を安定した仕様と見なさず、1 つの amount 呼び出しで分派を確認します。usize は値自身、String はバイト長を返します。オブジェクトを変えるとデータと実装が一緒に変わり、呼び出し元は Measure だけに依存します。

図を準備しています
データとメソッド入口を対応させる

オブジェクト参照はデータと分派情報を保持し、&dyn Trait のために Box は必須ではありません。

性能は呼び出しの文脈で評価する

動的呼び出しには通常、間接分派があり、呼び出し境界をまたぐ最適化も制限され得ます。ただし十分な型情報があれば非仮想化されることもあります。ジェネリックは T に特化した最適化に向く一方、インスタンスが増えるとコード量も増え得ます。

enum の match にも分岐があり、配置は最大バリアントに左右され得ます。vtable の有無だけでなく、局所性、オブジェクトサイズ、割り当て回数、実際の計算量を比較します。

動的な境界をどこに置くか

1 万バイトを処理するパーサを考えます。fn parse(&self, input: &[u8]) なら、一度の動的呼び出しの後は具体型を使って内部ループを処理できます。fn next_byte(&mut self) だけを公開すると、各バイトで動的な境界を通る可能性があります。どちらも dyn ですが、呼び出し回数の候補は違います。

すべてのメソッドを巨大な関数にまとめるという意味ではありません。メッセージ、レコードの一群、描画など自然な作業単位を決め、ホットパスを観察します。Vec<Box<dyn Trait>> の個別割り当てやポインタ追跡、enum 配列で大きなバリアントが全要素を広げる費用も区別し、分派だけに帰属させないようにします。

dyn compatibility:消去した型から呼べるメソッド

以前は object safety と呼ばれていた性質です。trait 自身が Self: Sized を要求せず、親 trait も対応する条件を満たす必要があります。関連定数やジェネリック関連型などは利用を制限します。動的に呼べるメソッドは型パラメータを持てず、通常の引数や戻り値で未知の Self に依存したり、不透明な impl Trait を返したり、直接 async fn として宣言したりできません。ライフタイムパラメータは、禁止される型パラメータとは異なります。規則

動的呼び出しに適さないメソッドは where Self: Sized で対象外にでき、必ずしも trait 全体を dyn 非対応にする必要はありません。

trait Label {
    fn label(&self) -> &str;
    fn duplicate(&self) -> Self where Self: Sized;
}
impl Label for String {
    fn label(&self) -> &str { self }
    fn duplicate(&self) -> Self { self.clone() }
}

fn main() {
    let value = String::from("hello");
    let object: &dyn Label = &value;
    assert_eq!(object.label(), "hello");
    assert_eq!(value.duplicate(), "hello");
    // object.duplicate() は不可。このメソッドは Sized な実装向け。
}

通常の関連型は dyn Iterator<Item = u8> のように指定できます。Clone をそのまま dyn Clone にすることはできません。Any の downcast は型の識別であり、複製機能ではありません。オブジェクトを複製するなら Box を返すメソッドなど、適切な抽象を設計します。

関連出力を固定し、戻り値を明確にする

実装型を消去しても、型関係まで全部消えるわけではありません。次の 2 種類のイテレータは内部状態が違っても u8 を生成します。呼び出し側は next が Option<u8> を返すと分かるため、統一して扱えます。

fn total(input: &mut dyn Iterator<Item = u8>) -> u64 {
    let mut result = 0u64;
    for value in input {
        result += u64::from(value);
    }
    result
}
fn main() {
    let mut range = 1u8..4;
    let mut values = vec![4u8, 5].into_iter();
    assert_eq!(total(&mut range), 6);
    assert_eq!(total(&mut values), 9);
    assert_eq!(range.next(), None);
}

dyn Iterator だけでは Item が未指定です。dyn Iterator<Item = u8> としても String を生成するイテレータは同じ位置に入りません。統一するのは反復プロトコルと出力型で、内部実装は異なります。合計値は u64 の範囲内なので、業務に応じたオーバーフロー処理も必要です。

メソッド制限は、呼び出し側がシグネチャを確定できるかから考えられます。Self を返すには隠された具体型が必要で、型パラメータ付きメソッドは呼び出し側が多様なインスタンスを選べます。where Self: Sized はそれらを具体型の呼び出しに残し、オブジェクトには trait の一部だけを公開します。除外したメソッドを dyn で呼べるようにする指定ではありません。

オブジェクトのライフタイムとスレッド制約

一般的な型の位置では Box<dyn Trait> のオブジェクトライフタイムは 'static が既定です。任意の短い借用を格納できるわけではなく、必要なら Box<dyn Trait + 'a> と書きます。Box がプログラム終了まで生きるという意味ではなく、内部の借用を制約します。

スレッドをまたぐ場合は dyn Trait + Send や + Sync を明示的に検討します。元の型が持つ auto trait が、消去後にすべて残るわけではありません。外側が Box か Arc かによっても条件が変わります。

コンテナの所有と全データの所有は別

Box<dyn Text + 'a>所有するオブジェクトを破棄Borrowed<'a>ヒープ上に &str を格納String バッファsource が所有所有借用object の使用後に source を解放制約は文字列を複製せず、source の寿命も延ばさない所有と借用を区別。vtable は省略し、配置は縮尺どおりではない。所有するラッパと借用データ
trait Text {
    fn text(&self) -> &str;
}
struct Borrowed<'a>(&'a str);
impl Text for Borrowed<'_> {
    fn text(&self) -> &str { self.0 }
}
fn make<'a>(source: &'a str) -> Box<dyn Text + 'a> {
    Box::new(Borrowed(source))
}
fn main() {
    let source = String::from("borrowed text");
    {
        let object = make(&source);
        assert_eq!(object.text(), "borrowed text");
    }
    assert_eq!(source, "borrowed text");
}

Box は Borrowed を破棄しますが、そのフィールドは source の借用です。object が source の有効範囲を逃げると借用検査に拒否されます。戻り値を適切な制約のない Box<dyn Text> に変えても 'static へ延長できません。ラッパが String を所有する設計も可能ですが、データの所有関係を変えます。

型区別する制約
&'a (dyn Text + 'b)外側の参照の有効期間と、隠れた実装内の借用の制約
&'a dyn Text通常は外側の参照からオブジェクトの境界を導く
Box<dyn Text + 'a>Box はオブジェクトを所有し、内部には 'a を満たす借用を含められる
Box<dyn Text>他の制約がない型位置では通常 'static。局所式では推論も働く

省略を一律に「dyn は 'static」と説明しないようにします。包含する型、trait 自身の制約、式の文脈が既定規則に関係します。借用インターフェースでは明示した方が読みやすい場合があります。既定オブジェクトライフタイム

Arc は所有を共有し、内部の同期までは提供しない

Arc<dyn Text + Send + Sync> はスレッド間で共有可能なインターフェースを表せますが、実際の型も制約を満たす必要があります。参照カウントが原子的でも RefCell の内部状態は同期されず、データアクセスには適切な同期が必要です。Arc のスレッド安全性

型消去時には後で必要な制約を残します。Box<dyn Text> を受け取った後、元が String だったというだけで Box<dyn Text + Send> にはできません。Send はスレッド間の値の移送、Sync は参照の共有、'static は制約付き借用の有無という別の問題です。

親 trait へインターフェースを狭める

Rust 1.86 から trait upcasting が安定化し、子 trait のオブジェクトを親 trait のオブジェクトへ変換できます。汎用関数は基本インターフェースに依存し、拡張機能は必要な呼び出し側だけが使えます。Rust 1.86

trait Named {
    fn name(&self) -> &str;
}
trait Detailed: Named {
    fn revision(&self) -> u32;
}
struct Document;
impl Named for Document {
    fn name(&self) -> &str { "guide" }
}
impl Detailed for Document {
    fn revision(&self) -> u32 { 3 }
}
fn display(value: &dyn Named) -> String {
    format!("name={}", value.name())
}
fn main() {
    let document = Document;
    let detailed: &dyn Detailed = &document;
    let named: &dyn Named = detailed;
    assert_eq!(detailed.revision(), 3);
    assert_eq!(display(named), "name=guide");
}

これは見えるインターフェースを狭める変換で、Document 型を復元したり逆変換を許したりしません。非 auto trait を直接 2 つ並べた dyn Read + Write は使えません。両方を継承する基本 trait を定義し、適切に実装します。1 つの基本 trait には Send、Sync などの auto trait を追加できます。オブジェクト型の制約

具体型の識別が必要なら Any を設計に使えますが、'static を要求するため任意の短い借用を持つオブジェクトには適しません。downcast が頻繁なら、必要な操作を共通 trait に表せないか確認します。Any

enum、ジェネリック、dyn の選択

方法実装集合を決める側利点検討する費用
enum + match型の定義者がバリアントを列挙網羅性検査、状態の集中管理追加による match と配置への影響
ジェネリック T: Trait呼び出し側が具体型を選択十分な型情報、型別の最適化インスタンス数、型の複雑さ
dyn Trait契約を満たす実装者異種オブジェクトと拡張性分派、ライフタイム、dyn compatibility

集合が開かれているか、所有が必要か、動的呼び出しが適切かを検討します。単に型がコンパイル時に分かるかより有用な判断軸です。関連する仕組みは単相化を参照してください。