Day 013 — デフォルト実装とトレイトオブジェクト — 静的/動的ディスパッチ

2026-08-10 🔵 中級者 / Phase 2 実装 デフォルト実装とトレイトオブジェクト

📚 背景知識(読んでから問題へ)

トレイトのメソッドは、宣言だけでなくデフォルト実装(default implementation)を持てます。実装側は何もしなければデフォルトの挙動を借り、必要な型だけそのメソッドをオーバーライドできます。

trait Greet {
    fn name(&self) -> &str;

    // デフォルト実装: name() を使って共通の挨拶文を組み立てる
    fn greet(&self) -> String {
        format!("こんにちは、{}です", self.name())
    }
}

これはトレイトの「共通の振る舞いを定義する」という役割を一歩進め、共通ロジックの重複を型ごとに書かせずに済ませる仕組みです。

もう一つ、Day012まではfn largest<T: PartialOrd>(...)のようなジェネリクスを使ってきました。これは呼び出し箇所ごとにコンパイラがTを具体的な型に置き換えたコードを生成する静的ディスパッチ(static dispatch)で、この置き換え処理をモノモーフィゼーション(monomorphization)と呼びます。関数呼び出しは通常の直接呼び出しになるため実行時コストがゼロですが、代償として「呼び出し箇所ごとに型が1つに固定される」「型の数だけコードが複製されバイナリサイズが増える」という制約があります。

これに対し、dyn Trait(トレイトオブジェクト)を使うと動的ディスパッチ(dynamic dispatch)になります。実行時にvtable(仮想関数テーブル)経由でメソッドを呼び出すため、Vec<Box<dyn Shape>>のように異なる具象型を1つのコレクションに混在させられます。実行時に一段階の間接呼び出しが挟まる分わずかなコストがありますが、柔軟性が上がります。

// 静的ディスパッチ: T は呼び出し箇所ごとに単一の具象型に固定される
fn print_static<T: Greet>(x: &T) { println!("{}", x.greet()); }

// 動的ディスパッチ: 異なる具象型を同じ関数・同じコレクションで扱える
fn print_dynamic(x: &dyn Greet) { println!("{}", x.greet()); }
📌
dyn Traitは「サイズがコンパイル時にわからない型(unsized type)」なので、値として直接扱うことはできません。&dyn Trait(参照)かBox<dyn Trait>(ヒープ確保)など、常に間接参照の形でしか登場しません。

📝 問題

図形を表すトレイトShapeを設計し、静的ディスパッチと動的ディスパッチの両方を体験する実装課題です。

要件1: Shapeトレイトの定義

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;

    // デフォルト実装を用意する(area と name を使って組み立てる)
    fn describe(&self) -> String {
        // ここに "{name}の面積は{area:.2}です" 形式のデフォルト実装を書く
    }
}
  • area(&self) -> f64name(&self) -> &strトレイト側では実装を持たない(実装側が必ず定義する)
  • describe(&self) -> Stringarea()name()を使い、"{name}の面積は{area:.2}です"という文字列を返すデフォルト実装を持たせる({area:.2}は小数点以下2桁)

要件2: 2つの図形型を実装する

  • struct Circle { radius: f64 }: area()π × radius × radiusstd::f64::consts::PIを使う)、name()"円"を返す。describe()をオーバーライドし、"{name}(半径{radius:.1}) の面積は{area:.2}です [カスタム実装]"という独自フォーマットにする
  • struct Rectangle { width: f64, height: f64 }: area()width × heightname()"長方形"を返す。describe()オーバーライドせず、デフォルト実装をそのまま使う

要件3: 静的ディスパッチ関数と動的ディスパッチ関数

// 静的ディスパッチ: 同じ具象型のスライスのみを受け付ける
fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
    // 実装する
}

// 動的ディスパッチ: 異なる具象型を混在させたコレクションを受け付ける
fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    // 実装する
}
  • どちらも各図形のarea()の合計を返す
  • total_area_staticVec<Circle>のような単一の型のスライスにのみ使える(Tは呼び出し箇所ごとに1つの型に固定されるため)
  • total_area_dynamicVec<Box<dyn Shape>>のように異なる型が混在したスライスに使える

検証課題

main関数と#[cfg(test)]のテストで、以下をすべて検証してください。

  1. Rectangledescribe()がデフォルト実装どおりの文字列を返す
  2. Circledescribe()がオーバーライドしたカスタム文字列を返す("[カスタム実装]"を含む)
  3. total_area_staticVec<Circle>を渡し、合計面積が正しく計算される
  4. total_area_dynamicVec<Box<dyn Shape>>CircleRectangle混在)を渡し、合計面積が正しく計算される
  5. (発展・任意)total_area_staticCircleRectangle混在させて渡そうとするとコンパイルエラーになることをコメントで示す(実際に混在コードを書く必要はない。なぜダメなのかをコメントで説明できれば良い)

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • デフォルト実装は普通のメソッドと同じ書き方で、トレイト定義の中に{ ... }を持つ本体を書くだけです。実装側(impl Shape for Circle)で同名のメソッドを定義すれば、それが優先されて上書き(オーバーライド)されます
  • Circledescribeimpl Shape for Circleの中に明示的に書く必要があります(書かなければデフォルトが使われます)。Rectangledescribeを一切書かなければ、自動的にデフォルト実装が使われます
  • total_area_statictotal_area_dynamicはどちらも「スライスの各要素のarea()を合計する」というロジック自体は同じです。違うのは受け取る型(&[T] vs &[Box<dyn Shape>]だけです
ヒント2 — アプローチ
  • describeのデフォルト実装はformat!("{}の面積は{:.2}です", self.name(), self.area())のように、トレイト内でself.name()self.area()を呼び出せます(まだ実装されていない抽象メソッドでも、トレイト定義内から呼び出すこと自体は可能です。実際に呼ばれるのは実行時に決まる具象型の実装です)
  • 合計はshapes.iter().map(|s| s.area()).sum()のようにイテレータで書けます。Box<dyn Shape>Derefにより自動で中身のdyn Shapeにアクセスできます
  • Vec<Box<dyn Shape>>を作るときはvec![Box::new(Circle { radius: 2.0 }), Box::new(Rectangle { width: 3.0, height: 4.0 })]のように書き、let側にVec<Box<dyn Shape>>という型注釈を与えます
ヒント3 — コード骨格
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;

    fn describe(&self) -> String {
        format!("{}の面積は{:.2}です", self.name(), self.area())
    }
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    fn name(&self) -> &str {
        "円"
    }
    fn describe(&self) -> String {
        // ここでデフォルトとは違うフォーマットを組み立てる
    }
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn name(&self) -> &str {
        "長方形"
    }
    // describe は書かない → デフォルトが使われる
}

fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
    // shapes.iter().map(...).sum()
}

fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    // shapes.iter().map(...).sum()
}

模範解答

trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> &str;

    // デフォルト実装: area() と name() を使って共通フォーマットの文字列を組み立てる
    fn describe(&self) -> String {
        format!("{}の面積は{:.2}です", self.name(), self.area())
    }
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    fn name(&self) -> &str {
        "円"
    }
    // デフォルト実装をオーバーライドして独自フォーマットにする
    fn describe(&self) -> String {
        format!(
            "{}(半径{:.1}) の面積は{:.2}です [カスタム実装]",
            self.name(),
            self.radius,
            self.area()
        )
    }
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn name(&self) -> &str {
        "長方形"
    }
    // describe は書かない → トレイトのデフォルト実装がそのまま使われる
}

// 静的ディスパッチ: T は呼び出し箇所ごとに単一の具象型に固定される(モノモーフィゼーション)
// メリット: 実行時コストゼロ(直接呼び出しにインライン化されうる)
// 制約: Circle と Rectangle を混ぜた Vec は渡せない(下の #[test] コメント参照)
fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

// 動的ディスパッチ: vtable 経由の呼び出しになる代わりに、異なる具象型を混在できる
fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let circle = Circle { radius: 2.0 };
    let rectangle = Rectangle { width: 3.0, height: 4.0 };

    println!("{}", circle.describe());
    println!("{}", rectangle.describe());

    // 静的ディスパッチ: Circle だけのスライス
    let circles = vec![Circle { radius: 1.0 }, Circle { radius: 2.0 }];
    println!(
        "同種図形の合計面積(静的ディスパッチ): {:.2}",
        total_area_static(&circles)
    );

    // 動的ディスパッチ: Circle と Rectangle が混在したコレクション
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 2.0 }),
        Box::new(Rectangle { width: 3.0, height: 4.0 }),
    ];
    println!(
        "異種図形の合計面積(動的ディスパッチ): {:.2}",
        total_area_dynamic(&shapes)
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rectangle_uses_default_describe() {
        let r = Rectangle { width: 3.0, height: 4.0 };
        assert_eq!(r.describe(), "長方形の面積は12.00です");
    }

    #[test]
    fn circle_overrides_describe() {
        let c = Circle { radius: 2.0 };
        assert!(c.describe().contains("カスタム実装"));
    }

    #[test]
    fn total_area_static_sums_same_type() {
        let circles = vec![Circle { radius: 1.0 }, Circle { radius: 2.0 }];
        let expected =
            std::f64::consts::PI * 1.0 * 1.0 + std::f64::consts::PI * 2.0 * 2.0;
        assert!((total_area_static(&circles) - expected).abs() < 1e-9);
    }

    #[test]
    fn total_area_dynamic_sums_mixed_types() {
        let shapes: Vec<Box<dyn Shape>> = vec![
            Box::new(Circle { radius: 2.0 }),
            Box::new(Rectangle { width: 3.0, height: 4.0 }),
        ];
        let expected = std::f64::consts::PI * 2.0 * 2.0 + 12.0;
        assert!((total_area_dynamic(&shapes) - expected).abs() < 1e-9);
    }

    // 発展課題(要件3-5): 次のコードはコンパイルできない。
    //
    //   let mixed = vec![Circle { radius: 1.0 }, Rectangle { width: 1.0, height: 1.0 }];
    //   total_area_static(&mixed);
    //
    // 理由: total_area_static<T: Shape>(shapes: &[T]) の T は「呼び出し箇所ごとに
    // 1つの具象型」に固定される(モノモーフィゼーション)。vec![...] マクロも
    // 「全要素が同じ型であること」を要求するため、Circle と Rectangle という
    // 異なる型を1つの Vec<T> に入れることそのものが最初の時点でエラーになる。
    // 混在させたい場合は Vec<Box<dyn Shape>> にして total_area_dynamic を使う。
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
円(半径2.0) の面積は12.57です [カスタム実装]
長方形の面積は12.00です
同種図形の合計面積(静的ディスパッチ): 15.71
異種図形の合計面積(動的ディスパッチ): 24.57

$ cargo test
running 4 tests
test tests::rectangle_uses_default_describe ... ok
test tests::circle_overrides_describe ... ok
test tests::total_area_static_sums_same_type ... ok
test tests::total_area_dynamic_sums_mixed_types ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🪜 Step-by-Step 解説

1
Shapeトレイトを「抽象メソッド2つ + デフォルトメソッド1つ」で設計する
areanameは型ごとに答えが変わるので実装を持たせず、describeだけが「areanameさえ実装されていれば、共通のロジックで組み立てられる」ためデフォルト実装を持たせます。トレイト内でself.area()のようなまだ実装を知らないメソッドを呼べるのは、実際に呼び出されるのが実行時に決まる具象型(CircleならCircle::area)だからです。
2
Circleはオーバーライド、Rectangleはデフォルトのまま
impl Shape for Circleの中にdescribeを書けば、それがトレイトのデフォルトより優先されます。impl Shape for Rectangleではdescribeを一切書かないので、コンパイラは自動的にトレイト側のデフォルト実装を使います。オーバーライドするかどうかは型ごとの選択であり、トレイト側から強制することはできません。
3
total_area_static(静的ディスパッチ)を実装する
fn total_area_static<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}
T: Shapeという境界により、total_area_static(&circles)と呼び出した瞬間にコンパイラはT = Circle用の専用コードを生成します(モノモーフィゼーション)。s.area()の呼び出しは、実行時に「どの型のarea()か」を調べる必要がなく、コンパイル時にCircle::areaへの直接呼び出しに確定します。
4
total_area_dynamic(動的ディスパッチ)を実装する
fn total_area_dynamic(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}
&[Box<dyn Shape>]は「Shapeを実装する何らかの型へのポインタの配列」です。この関数はCircleRectangleかをコンパイル時に知る必要がなく、s.area()は実行時にsが指す先のvtableを引いて実際のメソッドを呼び出します。この1つの関数が、どんな組み合わせの配列にも使えるのが動的ディスパッチの柔軟性です。
5
Vec<Box<dyn Shape>>を組み立てる
Box::new(Circle { ... })の型はそのままだとBox<Circle>です。しかしletVec<Box<dyn Shape>>という型注釈を与えているため、コンパイラは各要素を「dyn Shapeとして振る舞うポインタ」にアンサイズド化coercion(unsized coercion)してくれます。これにより1つのVecの中に異なる具象型のポインタを共存させられます。

💡 設計思想・なぜこう書くのか

📌
デフォルト実装は「共通ロジックの重複を許さない」思想の表れ: トレイトにdescribeのような組み立てロジックを1箇所書けば、新しい図形型(例えばTriangle)を追加するときもareanameさえ実装すれば自動的にdescribeが使えます。同時にRustは強制しませんCircleのように特別な表示が必要な型だけがオーバーライドすれば良く、「全部の型に同じインターフェースを実装させつつ、型ごとの個別最適化も許す」という柔軟性を両立させています。
⚠️
ディスパッチ方式は「デフォルト静的、必要な場面だけ動的」: C++の仮想関数やJavaのインターフェースメソッドは基本的に常に動的ディスパッチ(vtable経由)ですが、Rustのジェネリクスはデフォルトで静的ディスパッチになり、明示的にdynを書いたときだけ動的ディスパッチのコストを払います。「払うコストは自分で選べる。選ばなければ最速の経路が使われる」というのが、C++/Javaとの決定的な設計思想の違いです。dyn Traitが常に間接参照でしか登場できないのも、実装型ごとにサイズが異なりうる値をコンパイル時に確定できない、という同じ理由から来ています。

🛑 コンパイルエラーが出た場合

このテーマでは、dyn Traitをポインタ(Box/&)なしで直接扱おうとしたときに典型的なエラーが出ます。例えば次のような「条件によってCircleRectangleのどちらかを返す」関数を、うっかりdyn Shapeのまま返そうとした場合です。

fn make_shape(is_circle: bool) -> dyn Shape {
    if is_circle {
        Circle { radius: 1.0 }
    } else {
        Rectangle { width: 1.0, height: 1.0 }
    }
}
error[E0746]: return type cannot be a trait object without pointer indirection --> src/main.rs:9:35 | 9 | fn make_shape(is_circle: bool) -> dyn Shape { | ^^^^^^^^^ doesn't have a size known at compile-time | help: consider returning an `impl Trait` instead of a `dyn Trait` | 9 - fn make_shape(is_circle: bool) -> dyn Shape { 9 + fn make_shape(is_circle: bool) -> impl Shape { | help: alternatively, box the return type, and wrap all of the returned values in `Box::new` | 9 ~ fn make_shape(is_circle: bool) -> Box<dyn Shape> { 10 | if is_circle { 11 ~ Box::new(Circle { radius: 1.0 }) 12 | } else { 13 ~ Box::new(Rectangle { width: 1.0, height: 1.0 })

読み方:

  • return type cannot be a trait object without pointer indirection / doesn't have a size known at compile-time: dyn Shapeという型そのものは「サイズが分からない」ため、関数の戻り値の型(スタック上に確保される必要がある)に直接使えません
  • コンパイラの2つのhelpは、実際の2つの解決策をそのまま提示しています。1つはimpl Shape(静的ディスパッチ、ただし「戻り値は常に同じ具象型」という制約付き)、もう1つはBox<dyn Shape>(動的ディスパッチ、異なる具象型を条件分岐で返せる)

ちなみにhelpの1つ目に従ってimpl Shapeに変えても、今回のようにifの分岐でCircleRectangleという異なる具象型を返そうとすると、今度は別のエラーになります。

error[E0308]: `if` and `else` have incompatible types --> src/main.rs:13:9 | 10 | / if is_circle { 11 | | Circle { radius: 1.0 } | | ---------------------- expected because of this 12 | | } else { 13 | | Rectangle { width: 1.0, height: 1.0 } | | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Rectangle` 14 | | } | |_____- `if` and `else` have incompatible types | help: you could change the return type to be a boxed trait object | 9 - fn make_shape(is_circle: bool) -> impl Shape { 9 + fn make_shape(is_circle: bool) -> Box<dyn Shape> { | help: if you change the return type to expect trait objects, box the returned expressions | 11 ~ Box::new(Circle { radius: 1.0 }) 12 | } else { 13 ~ Box::new(Rectangle { width: 1.0, height: 1.0 })

読み方: impl Shapeは「戻り値の型は関数ごとに1つの具象型に決まる」という静的ディスパッチの制約そのものなので、分岐によって異なる具象型を返すことを許しません(ifブロックの型Circleが「期待される型」として先に確定し、elseブロックのRectangleがそれと一致しないというエラーになります)。コンパイラのhelpも的確に「Box<dyn Shape>に変えて両方の分岐をBox::newで包む」ことを提案してきます。「異なる具象型を条件によって返したい」なら、選べる道はBox<dyn Shape>(動的ディスパッチ)しかない、という事実がこの2つのエラーから浮き彫りになります。

修正方法: -> Box<dyn Shape>にして、各分岐をBox::new(...)で包む。

🌐 他言語との比較

観点RustGoJavaC++TypeScript
デフォルトメソッドトレイトにfn foo(&self) { ... }と本体を書けば、実装側は省略可能。個別にオーバーライドも可能インターフェースは常にメソッドシグネチャのみ。デフォルト実装という言語機能は存在せず、共通ロジックは埋め込み構造体(embedding)で代替Java 8以降defaultキーワード付きでインターフェースにデフォルト実装を書ける。考え方はRustに近い純粋仮想関数(= 0)は実装なし必須。仮想関数に本体を持たせれば「デフォルト実装+オーバーライド」が可能(Rustのデフォルト実装に近い)インターフェースはメソッドシグネチャのみで実装不可。抽象クラス(abstract class)ならデフォルト実装を持つメソッドを書ける
デフォルトのディスパッチ方式ジェネリクス=静的(ゼロコスト)、dyn Trait=動的、と書き分けが明示的インターフェース値は常に動的ディスパッチ(itableを経由)インターフェース/仮想メソッドは基本的に常に動的ディスパッチ(JITによる devirtualization はあるが言語仕様としては動的が基本)テンプレート=静的、仮想関数=動的、とRustと同じ二層構造。ただし仮想関数はデフォルトが動的(virtualを書いた時点でvtable使用が確定)型はコンパイル時に消去されるため「静的/動的ディスパッチ」という区別自体が実行時には存在しない(すべてプロトタイプチェーンによる通常のメソッド解決)
異なる型を1つのコレクションに混在させる方法Vec<Box<dyn Trait>>[]InterfaceType{...}(インターフェース値は元々ポインタ+型情報のペアなので自然に混在可能)List<InterfaceType>(参照型なので自然に混在可能)std::vector<std::unique_ptr<Base>>(RustのVec<Box<dyn Trait>>と直接対応)Array<InterfaceType>(構造的型付けなので、形が合えば元々自然に混在可能)

RustとC++は「デフォルト静的・明示的に動的を選ぶ」という思想を共有する数少ない言語ペアです。GoとJavaはインターフェース/仮想メソッドが常に動的ディスパッチである代わりに、その代償(実行時コスト・vtable経由の間接呼び出し)を言語利用者が意識せずに済むよう設計されています。Rustは代償を明示的に選ばせることで、パフォーマンスが重要な箇所(ホットパス)ではジェネリクスを、柔軟性が重要な箇所(プラグイン機構など)ではdyn Traitを、コードを見ただけで判断できるようにしています。

🏆 実務での使いどころ

  • デフォルト実装: Iteratorトレイトのmap/filter/foldなどはすべてデフォルト実装で、自作イテレータはnext()だけ実装すれば大量のメソッド群が無料で手に入ります。自前のトレイト設計でも「必須の最小メソッド + それを使ったデフォルトの便利メソッド」という構成は頻出パターンです
  • プラグイン機構: Vec<Box<dyn Plugin>>のように動的ディスパッチを使い、実行時に読み込む処理の種類を増減できる設計(例: ロギングのバックエンドを複数種類切り替え可能にする、通知チャネルをSlack/メール/Webhookで抽象化する)は、dyn Traitの典型的な使いどころです
  • パフォーマンスクリティカルな内部実装: 型が呼び出し箇所で確定しているホットパス(数値計算ライブラリの内部ループなど)では、ジェネリクスによる静的ディスパッチを選び、コンパイラの最適化(インライン化)を最大限活かします

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
dyn Traitを値としてそのまま変数に入れようとしてコンパイルエラーになるGo/Javaのインターフェース値は「値として」扱える感覚が染み付いているRustではdyn Traitは常にサイズ不定(?Sized)。必ず&dyn TraitBox<dyn Trait>のような間接参照の形でしか登場できない
デフォルト実装があるメソッドは「オーバーライドしてはいけない」と思い込む「デフォルト」という言葉から、変更不可の既定値のようなイメージを持ってしまうデフォルト実装は「実装しなかった場合に使われる初期値」に過ぎず、型ごとに自由にオーバーライド可能。今回のCircleがまさにその例
ジェネリクス(静的ディスパッチ)とdyn Trait(動的ディスパッチ)は「どちらでも同じ書き方ができる」と考え、パフォーマンス差を意識しないどちらも「トレイト境界を満たす型を受け取る」という見た目が似ている<T: Trait>は呼び出し箇所ごとにコード複製(バイナリサイズ増・実行速度は速い)、dyn Traitはコード共有(バイナリサイズ減・vtable経由でわずかに遅い)というトレードオフが明確に異なる。「異なる型を1つのコレクションで扱いたいか」が選択の分岐点になる

🚀 次のステップ

  • 発展: Shapeに3つ目の型(例えばTriangle)を追加し、describeをオーバーライドせずデフォルトのままにした場合と、独自メッセージにオーバーライドした場合の両方を試してみてください
  • 次回予告: Day 014では演算子オーバーロード(std::ops)を扱います。Add/Mulなどの標準トレイトを自分の型に実装し、+*のような演算子を独自の意味で使えるようにする方法を学びます

🎯 自己評価

自分の回答

気づき・メモ