Day 050 — enumの1バリアントにBox<dyn Trait>を埋め込み、閉じた集合と開いた集合を1つの型で使い分ける

2026-09-16 🟣 上級者 / Phase 3 設計・アーキテクチャ async trait(async-trait / RPITIT)

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

ここまで4日間、同じNotifier(通知)ドメインを土台に、非同期トレイトを巡る4つの選択肢を1つずつ検証してきました。

Day学んだ手法得たもの代償
046ネイティブasync fn in trait自然な構文・ゼロコストdyn互換ではない(E0038)
046async-traitクレートdyn互換(動的ディスパッチ可能)呼び出しごとにBoxヒープ確保
047enumによる静的ディスパッチヒープ確保ゼロ(実測で確認済み)バリアントの集合がコンパイル時に閉じる
048-049trait_variant::makeSend境界の記述コストを排除dyn互換性の欠如は解決しない

今日はこの4つを1つの実務要求の中で同時に使い分ける設計問題に取り組みます。Rustのプロフェッショナルな設計力は「どれか1つの正解を選ぶ」ことではなく、同じシステムの中で要求が異なる2つの部分に、異なる道具を意図的に使い分けられるかで測られます。

📌
新しい要求: 通知システムに新しい要件が来た。社内標準チャネル(Email・Slack)はこれまでと変わらず最頻経路であり、1リクエストあたり数千回呼ばれることもあるホットパスである。一方で、営業チームから「顧客ごとに異なるWebhook URLへの通知を、設定ファイルから動的に追加できるようにしてほしい。将来的には社外パートナーが独自の通知クレートを持ち込んで登録する可能性もある」という要求が来た。この「後から増える・型が事前にわからない」プラグイン的な通知先を、既存のEmail・Slackと同じVecにまとめてforループで一括送信したい。

Day 047で学んだ「実装の集合が閉じているか開いているか」という判断基準に当てはめると、この要求は閉じた部分(Email・Slack)と開いた部分(Webhook/外部プラグイン)が1つのシステムに同居するという、これまでの4日間のどのDayとも違う形をしています。

📝 問題

以下を満たすHybridNotifierという設計を考え、実装してください。

  1. EmailNotifier・SlackNotifierは、Day 047の結論(閉じた集合・ホットパス)を踏襲し、enumのバリアントとして直接構造体を持たせ、それぞれ固有の(トレイトを介さない)async fn send_alertをinherentメソッドとして実装してください(ヒープ確保ゼロの静的ディスパッチを維持する)
  2. 外部プラグイン用に#[async_trait::async_trait] trait DynNotifier: Send + Sync { async fn send_alert(&self, message: &str) -> Result<(), String>; }を定義し、WebhookNotifier(url: Stringフィールドを持つ、実際の送信はモックでよい)にこれを実装してください
  3. enum HybridNotifier { Email(EmailNotifier), Slack(SlackNotifier), Plugin(Box<dyn DynNotifier>) }を定義し、send_alertメソッドをmatchで実装してください(Email/Slack腕は手順1のinherentメソッドを直接呼び、Plugin腕だけがDynNotifier::send_alertをawaitする)
  4. Vec<HybridNotifier>にEmail・Slack・Plugin(Box::new(WebhookNotifier{..}))を混在させて詰め、forループでsend_alertを呼ぶnotify_all関数を実装してください
  5. 次の設計判断を文章で説明してください(コード不要)
    • なぜPluginバリアントの中身をtrait_variant::makeではなくasync-traitベースのDynNotifierにしたのか(Day 049の学びとの接続)
    • この設計を採用せず「全部をasync-traitのdyn Notifierに統一する」という単純な案を選んでいたら、Day 047のどの実測結果と矛盾することになるか
    • 逆に「全部をenumに統一する」という案を選んでいたら、今回の新要件(社外パートナーのクレート追加)のどの部分を満たせなくなるか
  6. cargo buildと、notify_allが3種類のバリアントすべてに対して正しくsend_alertを呼び出すことを検証するcargo testを通してください

🔍 ヒント(段階的開示)

ヒント1 — 方向性

「1つのトレイトで全部を表現する」のではなく、「enumのバリアントの1つとしてBox<dyn Trait>を埋め込む」という発想に切り替えてください。enum自体は静的な構造ですが、その1バリアントの中身が動的である、という入れ子構造が今日の核心です。

ヒント2 — アプローチ

Email・Slackバリアントのメソッド呼び出しは、matchの腕の中で直接e.send_alert(message).awaitのように呼べば、コンパイラはその具体型のFutureをそのまま単相化します。トレイト境界もdynも経由しないため、Day 047で計測したヒープ確保はここでは一切発生しません。PluginバリアントだけがBox<dyn DynNotifier>のメソッドを呼ぶため、そこだけがDay 046のasync-traitの仕組み(Pin<Box<dyn Future<...>>>)を経由します。

ヒント3 — コード骨格
struct EmailNotifier { address: String }
impl EmailNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        // ネイティブasync fn(トレイトを介さない)
        Ok(())
    }
}

#[async_trait::async_trait]
trait DynNotifier: Send + Sync {
    async fn send_alert(&self, message: &str) -> Result<(), String>;
}

struct WebhookNotifier { url: String }
#[async_trait::async_trait]
impl DynNotifier for WebhookNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        Ok(())
    }
}

enum HybridNotifier {
    Email(EmailNotifier),
    Slack(SlackNotifier),
    Plugin(Box),
}

impl HybridNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        match self {
            HybridNotifier::Email(e) => e.send_alert(message).await,
            HybridNotifier::Slack(s) => s.send_alert(message).await,
            HybridNotifier::Plugin(p) => p.send_alert(message).await,
        }
    }
}

✅ 模範解答

// Cargo.toml に以下が必要:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// async-trait = "0.1"

use async_trait::async_trait;

// ---- 閉じた集合: ホットパス。トレイトを介さないinherentメソッドで
//      ゼロコストの静的ディスパッチを維持する(Day 047の結論を踏襲) ----

struct EmailNotifier {
    address: String,
}

impl EmailNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        if self.address.is_empty() {
            return Err("email address is empty".to_string());
        }
        println!("[Email -> {}] {}", self.address, message);
        Ok(())
    }
}

struct SlackNotifier {
    channel: String,
}

impl SlackNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        if self.channel.is_empty() {
            return Err("slack channel is empty".to_string());
        }
        println!("[Slack -> #{}] {}", self.channel, message);
        Ok(())
    }
}

// ---- 開いた集合: 型が事前にわからない外部プラグイン用。
//      dyn互換性が必須要件なので async-trait を選ぶ(trait_variant は選べない) ----

#[async_trait]
trait DynNotifier: Send + Sync {
    async fn send_alert(&self, message: &str) -> Result<(), String>;
}

struct WebhookNotifier {
    url: String,
}

#[async_trait]
impl DynNotifier for WebhookNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        if self.url.is_empty() {
            return Err("webhook url is empty".to_string());
        }
        // 実際にはHTTPクライアントでPOSTするが、今日はモック
        println!("[Webhook -> {}] {}", self.url, message);
        Ok(())
    }
}

// ---- 入れ子構造: enum自体は静的だが、Pluginバリアントの中身だけが動的 ----

enum HybridNotifier {
    Email(EmailNotifier),
    Slack(SlackNotifier),
    Plugin(Box),
}

impl HybridNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        match self {
            // ホットパス: match で具体型に単相化されるため Box 確保なし
            HybridNotifier::Email(e) => e.send_alert(message).await,
            HybridNotifier::Slack(s) => s.send_alert(message).await,
            // 拡張点: ここだけ Pin>> 経由の動的ディスパッチ
            HybridNotifier::Plugin(p) => p.send_alert(message).await,
        }
    }
}

async fn notify_all(notifiers: &[HybridNotifier], message: &str) -> Vec> {
    let mut results = Vec::with_capacity(notifiers.len());
    for n in notifiers {
        results.push(n.send_alert(message).await);
    }
    results
}

#[tokio::main]
async fn main() {
    let notifiers = vec![
        HybridNotifier::Email(EmailNotifier {
            address: "ops@example.com".to_string(),
        }),
        HybridNotifier::Slack(SlackNotifier {
            channel: "alerts".to_string(),
        }),
        HybridNotifier::Plugin(Box::new(WebhookNotifier {
            url: "https://partner.example.com/hook".to_string(),
        })),
    ];

    let results = notify_all(¬ifiers, "deploy finished").await;
    println!("{:?}", results);
}

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

    #[tokio::test]
    async fn notify_all_calls_every_variant_successfully() {
        let notifiers = vec![
            HybridNotifier::Email(EmailNotifier {
                address: "ops@example.com".to_string(),
            }),
            HybridNotifier::Slack(SlackNotifier {
                channel: "alerts".to_string(),
            }),
            HybridNotifier::Plugin(Box::new(WebhookNotifier {
                url: "https://partner.example.com/hook".to_string(),
            })),
        ];

        let results = notify_all(¬ifiers, "test message").await;
        assert_eq!(results.len(), 3);
        assert!(results.iter().all(|r| r.is_ok()));
    }

    #[tokio::test]
    async fn plugin_variant_propagates_error_from_dyn_notifier() {
        let notifiers = vec![HybridNotifier::Plugin(Box::new(WebhookNotifier {
            url: String::new(),
        }))];

        let results = notify_all(¬ifiers, "test message").await;
        assert_eq!(results, vec![Err("webhook url is empty".to_string())]);
    }

    #[tokio::test]
    async fn email_variant_propagates_error_without_going_through_dyn() {
        let notifiers = vec![HybridNotifier::Email(EmailNotifier {
            address: String::new(),
        })];

        let results = notify_all(¬ifiers, "test message").await;
        assert_eq!(results, vec![Err("email address is empty".to_string())]);
    }
}

問い5への回答例

  • なぜPluginにtrait_variantではなくasync-traitを選んだか: Pluginバリアントの要件は「型が事前にわからない外部クレートをBoxに詰めて1つのenumバリアントとして保持できること」、すなわちdyn互換性そのものです。Day 049で確認した通り、trait_variant::makeが解決するのは「Send境界の記述コスト」だけであり、生成されるトレイトは依然としてRPITIT(実装ごとに異なる匿名Future型を返す)なのでdyn化できません。dyn互換性が必須要件であるPluginバリアントには、最初からPin<Box<dyn Future<...>>>という全実装で同一の具体型を強制するasync-traitしか選択肢がありません。
  • 「全部をasync-traitのdyn Notifierに統一していたら」: Day 047で実測した「Vec<Box<dyn DynNotifier>>は1,000回×2件のループでVec<AnyNotifier>(enum静的ディスパッチ)より明確に多いヒープ確保が発生する」という結果と矛盾します。Email・Slackはリクエストごとに数千回呼ばれるホットパスなので、その全呼び出しにBox確保のコストを乗せるのは、Day 047の実測が示した代償をホットパス全体に払わせる選択であり、避けるべき設計です。
  • 「全部をenumに統一していたら」: 新要件の核心である「社外パートナーが独自の通知クレートを持ち込んで登録する」を満たせません。enumのバリアントはコンパイル時に列挙し尽くす必要があり、自分たちのクレートをリコンパイルせずに第三者が新しい通知先の型を追加することは原理的に不可能です(Day 047の「実装の集合が閉じている」という前提そのものが、この新要件では成立しなくなっている)。

🪜 Step-by-Step 解説

1
「閉じた部分」と「開いた部分」を要件から先に切り分ける
コードを書く前に、要件文のどの名詞が「将来も種類が増えない」もので、どの名詞が「将来増える・外部から来る」ものかを言葉にします。今回はEmail・Slackが前者、Webhook/外部プラグインが後者です。この切り分けを誤ると、後になって「enumで作ったのに拡張要求が来た」あるいは「全部dynにしたのにホットパスが遅い」という手戻りが発生します。
2
閉じた部分はトレイトすら介さず、inherentメソッドで実装する
EmailNotifier・SlackNotifierはトレイトの実装ですらなく、ただのimpl EmailNotifier { async fn send_alert(...) }という素朴なinherentメソッドです。トレイトを挟まないことで、matchの腕からの呼び出しはコンパイラにとって「ただの具体的な関数呼び出し」に見え、RPITITやdynにまつわる制約を一切気にする必要がなくなります。
3
開いた部分だけasync-traitでdyn互換にする
DynNotifierトレイトとWebhookNotifierへの実装にのみ#[async_trait]を付けます。Day 046の知識通り、これによってBox<dyn DynNotifier>が作れるようになります。
4
enumの1バリアントとしてBox<dyn Trait>を埋め込む
HybridNotifier::Plugin(Box<dyn DynNotifier>)という形にすることで、enum自体はEmail・Slack・Pluginという3つの具体的なバリアントを持つ「閉じた」型でありながら、Pluginの中身だけは実行時にいくらでも異なる型を受け入れられる「開いた」領域になります。
5
matchの腕ごとにディスパッチ方式が変わることを明示的にコメントする
Email・Slack腕は静的ディスパッチ、Plugin腕だけ動的ディスパッチ、という非対称性はコード上はmatchの見た目だけでは伝わりにくいため、模範解答のようにコメントで意図を残すことが実務でも重要です。

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

Rustの設計哲学は「ゼロコスト抽象化」だとよく言われますが、これは「常に一番速い手段だけを使え」という意味ではありません。正確には「使わない機能のコストを、使っていない人にまで払わせない」という原則です。今日のHybridNotifierはこの原則を最も具体的な形で体現しています。Email・Slackという「動的な拡張性を必要としない呼び出し」は、Boxのコストを1ミリも払わず、まるでenumのバリアントを直接呼んでいるかのように単相化されます。一方でWebhookという「動的な拡張性が必須の呼び出し」だけが、その必要性に見合ったBoxのコストを払います。

これは他言語ではあまり意識されない設計判断です。GoやJavaでは「インターフェース経由の呼び出し」は言語のデフォルトの流儀であり、部分ごとにディスパッチ方式を切り替えるという発想自体が言語機能として前面に出てきません。Rustでは、この「コストを払う場所を型システムのレベルで明示的に選べる」ことこそが、単なる「速い言語」ではなく「意図した通りにしか遅くならない言語」であることの実務的な意味です。

🌐 他言語との比較

観点GoJavaTypeScriptRust(HybridNotifier)
閉じた集合の表現言語機能としては存在しない(interfaceはいつでも実装を追加できる)sealed interface(Java 17+)でコンパイル時に閉じられる判別可能なUnion型(discriminated union)で閉じられるenumのバリアントで閉じる
開いた集合の表現interface(デフォルトが「開いた」設計)通常のinterface(sealedを付けなければ開く)追加のインターフェース型を用意するBox<dyn Trait>
1つの型の中で閉/開を混在慣習的にはあまり区別されない(すべてinterface経由が普通)sealed interfaceの1バリアントとして通常のinterface実装を許容する設計は可能だが一般的ではないUnion型の1メンバーとしてinterface型を混ぜることは可能enumの1バリアントにBox<dyn Trait>を埋め込むのが定番パターン
コストの可視性ランタイム(インターフェース経由は常に間接呼び出し)任せで、型システム上は見えないJITが実行時にインライン化を試みるが型システム上は見えないランタイム(V8等)任せ、型システム上は見えないmatchの腕ごとに静的/動的ディスパッチが型として区別され、コンパイル時に確定する

GoやJavaでは「インターフェース経由の呼び出しコスト」はランタイムの最適化任せであり、開発者が型定義の時点で明示的にコストの所在を選ぶ機会はほとんどありません。Rustではenumのバリアントとして「ここは静的」「ここは動的」を型定義そのものに刻み込めるため、コードを読むだけでどの呼び出しが重い可能性があるかを判断できます。

🏆 実務での使いどころ

  • プラグインシステムを持つが、コア機能はホットパスの製品: ロギングフレームワークやミドルウェアチェーンなどで、「組み込みの数種類の処理は最速で回したいが、ユーザーが独自のハンドラを追加できるようにもしたい」という要求は頻出する。towerクレートのEither<A, B>コンビネータも、2つの具体的なサービス型を静的に選択しつつ、必要な箇所だけBoxService(動的ディスパッチ版)に切り替えるという、思想的に同じ「閉じた部分と開いた部分を型で分離する」パターンを採用している
  • 段階的な機能フラグの導入: 新しい通知チャネルを試験導入する際、最初はPluginバリアント(動的)で素早く実験し、利用実績が十分に積み上がって「もう変化しない」と判断できた時点でenumの専用バリアント(静的)に「昇格」させる、という進化的な設計判断ができる
  • マルチテナントSaaSの顧客カスタマイズ: 自社標準機能は静的ディスパッチで高速に、顧客ごとのカスタムロジック(別チームや別会社が実装したプラグイン)だけを動的ディスパッチで受け入れる、という切り分けはSaaSのプラグインアーキテクチャで頻出する構図

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
enumのバリアント全部を同じ抽象度で設計しようとする「1つのenumなのだから統一感を持たせたい」という直感が働くenumの強みは「バリアントごとに全く異なる内部構造を許す」ことにある。あえて1バリアントだけBox<dyn Trait>にする非対称性は設計ミスではなく意図的な選択
Pluginバリアントにもtrait_variantを使おうとするDay 049で最新に学んだ手法を反射的に適用してしまうtrait_variantはdyn互換性を得る手段ではない。dyn互換性が要件の中心にある箇所には、最初からasync-trait(またはネイティブのPin<Box<dyn Future>>手書き)を選ぶ必要がある
Email・SlackもいずれDynNotifierを実装させておけば将来の変更に強いと考え、先に共通トレイトを被せてしまう「将来の拡張に備える」というYAGNI違反の発想現時点で要求されていない拡張性のために、ホットパスにトレイト経由の抽象化コストを先回りして持ち込むべきではない。要求が来た時点でリファクタリングすればよい

🚀 次のステップ

  • 発展: notify_allをDay 047のCountingAllocatorと組み合わせ、「Email・Slack呼び出しではヒープ確保が0回のまま、Plugin呼び出しの回数分だけ確保が発生する」ことを実測で検証する(本日は設計判断の言語化を主眼としたため実測は行っていない。これは意図的な範囲の絞り込みであり、次回以降の発展課題として残す)
  • 次回予告: 自己評価欄がまだ未記入のため、次回も本テーマ(async trait)の理解確認を優先する可能性がある。理解が固まったと判断できた場合は、Phase 3の次テーマunsafe Rustの基礎(生ポインタ操作)に進む

🎯 自己評価

自分の回答

気づき・メモ