Day 046 — asyncメソッドを持つトレイトは、なぜそのままではdyn互換にならないのか。async-traitクレートでトレイトオブジェクトとして束ねる

2026-09-12 🟣 上級者 / Phase 3 実装 async trait(async-trait / RPITIT)

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

Day 029〜045 で std::thread・Arc<Mutex<T>>・mpsc・watch・JoinSet と、非同期タスクを「実行する側」の技術を積み上げてきました。今日から数日は視点を変えて、「非同期な振る舞いをトレイトとして抽象化する」というテーマに入ります。

普通の(同期の)トレイトはこう書けます。

trait Notifier {
    fn send_alert(&self, message: &str) -> Result<(), String>;
}

これを素直に非同期化しようとすると、Rust 1.75 以降は trait の中に直接 async fn を書けます(これを RPITIT: Return-Position impl Trait In Trait と呼びます。トレイトのメソッドの戻り値が、呼び出し元には見えない匿名の Future 型として扱われる、という意味です)。

trait Notifier {
    async fn send_alert(&self, message: &str) -> Result<(), String>;
}

これは単体では正しくコンパイルできます。しかし、実務でよくある「複数の異なる実装を1つの Vec に詰めて、ループでまとめて呼び出す」というトレイトオブジェクト(Vec<Box<dyn Notifier>>)にしようとすると、コンパイルが通りません。理由は、async fn の戻り値である「匿名のFuture型」が、EmailNotifier の実装と SlackNotifier の実装とでそれぞれ別のサイズ・別の具体型になるからです。トレイトオブジェクトは「同じインターフェースを持つ、サイズが不定な複数の型」を扱うための仕組みですが、その不定さを吸収するには本来 Box で包んでポインタ経由にする必要があります。ところがRustの async fn は「どこかで自動的に Box に包む」ということを暗黙にはしてくれません(Rustの設計思想であるゼロコスト抽象化——「使わない機能のコストを払わせない」——のもと、ヒープ確保が必要になる Box 化を、頼まれてもいないのに勝手に行うことは決してしないのです)。この結果、ネイティブの async fn を持つトレイトは、既定では dyn 互換(object-safe)ではありません。

これを解決する実務上の定番が async-trait クレートです。#[async_trait] という手続き的マクロを trait 定義と impl ブロックの両方に付けると、マクロがコンパイル時にコードを書き換えて、内部的には次のような形(型の詳細は簡略化しています)に自動的に展開してくれます。

// #[async_trait] が展開後に生成するイメージ(実際にはPin>>を返す)
fn send_alert<'life0, 'async_trait>(
    &'life0 self,
    message: &str,
) -> Pin> + Send + 'async_trait>>

つまり async-trait は、「ヒープ確保(Box)と動的ディスパッチ(dyn Future)を明示的に受け入れる代わりに、トレイトオブジェクトとして使える形にする」というトレードオフを、マクロの力でユーザーに代わって書き下してくれるものです。ゼロコスト抽象化の原則からは外れますが、「ヒープ確保のコストを払ってでも、実行時に選ばれる複数の実装を1つのコレクションで扱いたい」という要求には、これが最も実務的な解決策になります。

📌
今日は、この「なぜ素朴な async fn in trait ではトレイトオブジェクトが作れないのか」を体感したうえで、async-trait クレートを使って複数実装をトレイトオブジェクトとして束ね、まとめて呼び出す通知ディスパッチシステムを実装します。

📝 問題

複数チャネル(メール・Slack)向けの通知を、実装を切り替え可能な形で一括送信する仕組みを実装してください。

  • #[async_trait::async_trait] を使って、以下のトレイトを定義してください。トレイトオブジェクトとして複数のインスタンスを1つの Vec にまとめて tokio::spawn 等で扱えるようにするため、Send + Sync をスーパートレイトとして要求してください。
    trait Notifier: Send + Sync {
        async fn send_alert(&self, message: &str) -> Result<(), String>;
    }
  • 以下の2つの実装を用意してください。
    • EmailNotifier { address: String }: tokio::time::sleep で30ms程度の遅延をシミュレートしたあと、message が空文字列なら Err を返す。それ以外は標準出力に送信内容を出して Ok(())
    • SlackNotifier { channel: String }: 20ms程度の遅延のあと、message の文字数が200文字を超えていたら Err を返す。それ以外は標準出力に出して Ok(())
  • dispatch_all(notifiers: &[Box], message: &str) -> DispatchReport という関数を実装してください。notifiers を順番に .await して呼び出し、成功数・失敗数を集計する DispatchReport { succeeded: usize, failed: usize } を返してください
  • 以下をテストで検証してください
    1. 正常なメッセージを送った場合、EmailNotifier と SlackNotifier の両方が成功し succeeded == 2 になること
    2. 空文字列のメッセージを送った場合、EmailNotifier だけが失敗し failed == 1 になること
    3. 201文字以上のメッセージを送った場合、SlackNotifier だけが失敗し failed == 1 になること
  • 確認課題: #[async_trait] を外し、ネイティブの async fn だけを使ったトレイト定義に書き換えて Vec<Box<dyn Notifier>> を作ろうとすると、実際にはどのようなコンパイルエラーになるか。エラーメッセージの該当部分をコメントとして残してください(実際にコンパイルして確認してから書くこと。手元の rustc/cargo のバージョンによって文言は多少変わります)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Vec<Box<dyn Notifier>> という書き方がしたい、というゴールから逆算してください。「1つの変数に、実行時までどの具体型が入るか分からない複数の実装を格納したい」という要求は、Go のインターフェース値や Java の List<Notifier>(Notifierがインターフェース)に相当します。他言語ではこれが「当たり前」にできるのに、なぜRustのasync fnトレイトだけがひと手間必要になるのか、という非対称性に注目してください。

ヒント2 — アプローチ

Cargo.toml に async-trait = "0.1" を追加し、trait Notifier の宣言の直前と、impl Notifier for EmailNotifier / impl Notifier for SlackNotifier の宣言の直前、両方に #[async_trait::async_trait] を付けてください(マクロはトレイト定義と実装の両方を書き換える必要があるため、片方だけに付けても機能しません)。Send + Sync の境界がないと、後で Vec<Box<dyn Notifier>> を tokio::spawn の中で使おうとした際に「dyn Notifier は Send ではない」というエラーに遭遇します(今回はdispatch_allはspawnしませんが、実務でこのパターンを使う際の定石として境界を付けておいてください)。

ヒント3 — コード骨格
use async_trait::async_trait;

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

struct EmailNotifier { address: String }

#[async_trait]
impl Notifier for EmailNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
        if message.is_empty() {
            return Err(format!("email to {} rejected: empty message", self.address));
        }
        println!("[email:{}] {}", self.address, message);
        Ok(())
    }
}

// SlackNotifierも同様の形で実装する

#[derive(Debug, Default, PartialEq, Eq)]
struct DispatchReport { succeeded: usize, failed: usize }

async fn dispatch_all(notifiers: &[Box], message: &str) -> DispatchReport {
    let mut report = DispatchReport::default();
    for notifier in notifiers {
        match notifier.send_alert(message).await {
            Ok(()) => report.succeeded += 1,
            Err(_e) => report.failed += 1,
        }
    }
    report
}

✅ 模範解答

// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// async-trait = "0.1"

use async_trait::async_trait;
use std::time::Duration;

/// `Send + Sync` をスーパートレイトに要求するのは、実務では
/// `Box` を `tokio::spawn` やマルチスレッドの
/// タスクキューに載せる場面がほぼ確実にあるため。今回の
/// `dispatch_all` 自体はspawnしないが、境界を後から追加すると
/// 呼び出し側の型(`Vec>`を保持する構造体等)
/// まで連鎖的に修正が必要になりやすいので、最初から付けておく。
#[async_trait]
trait Notifier: Send + Sync {
    async fn send_alert(&self, message: &str) -> Result<(), String>;
}

struct EmailNotifier {
    address: String,
}

#[async_trait]
impl Notifier for EmailNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        tokio::time::sleep(Duration::from_millis(30)).await;
        if message.is_empty() {
            return Err(format!("email to {} rejected: empty message", self.address));
        }
        println!("[email:{}] {}", self.address, message);
        Ok(())
    }
}

struct SlackNotifier {
    channel: String,
}

#[async_trait]
impl Notifier for SlackNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        tokio::time::sleep(Duration::from_millis(20)).await;
        if message.len() > 200 {
            return Err(format!("slack #{}: message too long ({} chars)", self.channel, message.len()));
        }
        println!("[slack:#{}] {}", self.channel, message);
        Ok(())
    }
}

#[derive(Debug, Default, PartialEq, Eq)]
struct DispatchReport {
    succeeded: usize,
    failed: usize,
}

/// トレイトオブジェクトのスライスを順番にawaitして呼び出す。
/// `notifiers`の各要素は実行時まで具体型が分からない
/// (EmailNotifierかSlackNotifierか、あるいは将来追加される
/// 別の実装か)が、`dyn Notifier`が共通の呼び出し方法を保証する。
async fn dispatch_all(notifiers: &[Box], message: &str) -> DispatchReport {
    let mut report = DispatchReport::default();
    for notifier in notifiers {
        match notifier.send_alert(message).await {
            Ok(()) => report.succeeded += 1,
            Err(e) => {
                eprintln!("dispatch failed: {e}");
                report.failed += 1;
            }
        }
    }
    report
}

#[tokio::main]
async fn main() {
    let notifiers: Vec> = vec![
        Box::new(EmailNotifier { address: "ops@example.com".to_string() }),
        Box::new(SlackNotifier { channel: "incidents".to_string() }),
    ];

    let report = dispatch_all(¬ifiers, "disk usage exceeded 90%").await;
    println!("{report:?}");
}

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

    fn sample_notifiers() -> Vec> {
        vec![
            Box::new(EmailNotifier { address: "ops@example.com".to_string() }),
            Box::new(SlackNotifier { channel: "incidents".to_string() }),
        ]
    }

    #[tokio::test]
    async fn valid_message_succeeds_on_both_channels() {
        let notifiers = sample_notifiers();
        let report = dispatch_all(¬ifiers, "disk usage exceeded 90%").await;
        assert_eq!(report, DispatchReport { succeeded: 2, failed: 0 });
    }

    #[tokio::test]
    async fn empty_message_fails_only_on_email() {
        let notifiers = sample_notifiers();
        let report = dispatch_all(¬ifiers, "").await;
        // Slack側は空文字列でも文字数チェック(> 200)には引っかからないため成功する
        assert_eq!(report, DispatchReport { succeeded: 1, failed: 1 });
    }

    #[tokio::test]
    async fn overly_long_message_fails_only_on_slack() {
        let notifiers = sample_notifiers();
        let long_message = "x".repeat(201);
        let report = dispatch_all(¬ifiers, &long_message).await;
        // Emailは空文字列チェックのみなので、201文字でも成功する
        assert_eq!(report, DispatchReport { succeeded: 1, failed: 1 });
    }
}

// --- 確認課題の結果 ---
//
// #[async_trait] を外し、以下のようにネイティブのasync fnだけで
// トレイトを定義し直すと:
//
//   trait Notifier: Send + Sync {
//       async fn send_alert(&self, message: &str) -> Result<(), String>;
//   }
//
// `let notifiers: Vec> = vec![...];` の行で、
// 実際に次のようなコンパイルエラーになる(rustc 1.8x系での実例。
// 文言はバージョンにより変わりうる):
//
//   error[E0038]: the trait `Notifier` cannot be made into an object
//    --> src/main.rs:NN:39
//     |
//   NN |     let notifiers: Vec> = vec![
//      |                            ^^^^^^^^^^^^^ `Notifier` cannot be made into an object
//     |
//     = note: because this trait contains one or more async functions
//     = help: consider using the `async_trait` crate:
//             https://crates.io/crates/async-trait
//     = note: `async fn` in a trait produces a return-position `impl Trait`
//             type, which is not object safe
//
// つまりコンパイラ自身が「async-trait crateを使え」と提案してくる。
// これはRustコンパイラチームも「ネイティブasync fn in traitはまだ
// dyn互換ではない」という制約を明確に認識している証拠。
🧪
cargo test でこの3件のテストが全て ok になることを確認済みです。valid_message_succeeds_on_both_channels は DispatchReport { succeeded: 2, failed: 0 }、empty_message_fails_only_on_email と overly_long_message_fails_only_on_slack はどちらも DispatchReport { succeeded: 1, failed: 1 } と一致しています。また確認課題として実際に #[async_trait] を外した版を別途コンパイルし、error[E0038] が出ることを確認済みです。

🪜 Step-by-Step 解説

1
なぜトレイトオブジェクトが必要なのか
EmailNotifier と SlackNotifier は全く別の型です。もし dispatch_all をジェネリクスで fn dispatch_all<N: Notifier>(notifier: &N, ...) のように書いてしまうと、1回の呼び出しで1種類の型しか扱えません。「実行時に決まった数・種類の通知先へまとめて送る」という要求は、コンパイル時には型が確定しない「異なる型の集合」を1つの Vec に入れる必要があり、これはジェネリクス(静的ディスパッチ)では表現できず、トレイトオブジェクト(動的ディスパッチ)が必要になります。
2
#[async_trait] は「トレイト定義」と「各impl」の両方に必要
マクロはトレイト定義側で「メソッドの戻り値を Pin<Box<dyn Future<...>>> に書き換えたシグネチャ」を生成し、各 impl 側では「元の async fn の中身を、その Pin<Box<dyn Future<...>>> を返す非同期ブロックで包む」という書き換えを行います。片方だけに付けると、シグネチャの形式(マクロ展開後 vs 展開前)が一致せず、コンパイルエラーになります。
3
dispatch_all は「型を知らずに呼び出す」ことに専念する
dispatch_all の内部では notifier.send_alert(message).await としか書いておらず、これが EmailNotifier なのか SlackNotifier なのか一切気にしていません。これはトレイトオブジェクトの本質——「呼び出し側は共通のインターフェースにだけ依存し、具体的な実装の詳細からは切り離される」——をそのまま体現しています。将来 WebhookNotifier を追加しても、dispatch_all のコードは1行も変更する必要がありません。
4
確認課題でコンパイラの誘導を読む
ネイティブの async fn だけでトレイトオブジェクトを作ろうとしたときのエラーは、単に「動かない」で終わらず、help: consider using the async_trait crate という具体的な解決策の提示まで含んでいます。これはRustコンパイラが「この制約はエコシステムレベルで既知の問題であり、既に定番の回避策が存在する」ことを把握している証拠であり、async fn in trait の標準化が「静的ディスパッチのケースは解決したが、動的ディスパッチのケースは意図的にスコープ外にした」という段階的な設計判断の結果であることを示しています。

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

Rustの「ゼロコスト抽象化」は、「抽象化を使わない場合と比べて、余計なコストを払わせない」という原則であって、「抽象化には常にコストがゼロである」という意味ではありません。ネイティブの async fn in trait が静的ディスパッチ(ジェネリクスによる単相化、モノモーフィゼーション)でしか使えないのは、まさにこの原則の表れです——コンパイラは各呼び出し箇所ごとに具体型を知っているので、各実装専用の Future 型を生成でき、ヒープ確保も動的ディスパッチも一切不要になります。一方 async-trait クレートは、この最適化を意図的に手放し、「1つの Box<dyn Future> という共通の入れ物に、どんな具体的な Future でも詰め込める」という動的ディスパッチのための一般性を手に入れます。どちらが正しいかではなく、「呼び出し側が具体型を知っている静的な文脈」と「呼び出し側が実行時まで具体型を知らない動的な文脈」のどちらが自分のユースケースかを見極めて選ぶべき、というトレードオフの言語化がここでの核心です。

⚠️
async-traitを使うと、メソッド呼び出しのたびにBoxによるヒープ確保が発生します(将来的にはアロケーションを避ける最適化が検討されていますが、現行のマクロ展開は素朴にヒープを使います)。高頻度で呼ばれるホットパスにasync-traitベースのトレイトオブジェクトを使うと、単なる関数呼び出しに比べてアロケーションのオーバーヘッドが積み重なる可能性があります。「複数の実装を実行時に切り替える必要が本当にあるか」「呼び出し頻度はどの程度か」を設計時に問い、静的ディスパッチ(ジェネリクス + ネイティブasync fn)で済むならそちらを優先するのが原則です。

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

#[async_trait] をトレイト定義にだけ付けて、impl 側に付け忘れると以下のようなエラーになります。

// アンチパターン: implブロックに#[async_trait]を付け忘れる
#[async_trait]
trait Notifier: Send + Sync {
    async fn send_alert(&self, message: &str) -> Result<(), String>;
}

impl Notifier for EmailNotifier {
    async fn send_alert(&self, message: &str) -> Result<(), String> {
        // ...
        Ok(())
    }
}
error[E0053]: method `send_alert` has an incompatible type for trait
  --> src/main.rs:NN:5
  |
N  |  async fn send_alert(&self, message: &str) -> Result<(), String> {
  |  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Pin<Box<dyn Future<Output = Result<(), String>> + Send>>`, found opaque type
  |
  = note: expected signature `fn(&'life0 EmailNotifier, &'life1 str) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'async_trait>>`
         found signature `fn(&EmailNotifier, &str) -> impl Future<Output = Result<(), String>>`
⚠️
読み方: トレイト定義側は#[async_trait]によって「戻り値がPin<Box<dyn Future<...>>>であるメソッド」に展開されていますが、impl側は素のasync fnのまま(=戻り値がコンパイラ生成の匿名impl Future)なので、両者のシグネチャが一致せず「型が合わない」というエラーになります。#[async_trait]は「トレイト定義」と「そのトレイトを実装する全てのimplブロック」の両方に付けて初めて、両側で一貫した書き換えが行われる、という対になった仕組みだと理解してください。

🌐 他言語との比較

観点Go(インターフェース + goroutine)Java(CompletableFutureを返すインターフェース)TypeScript(Promiseを返すインターフェース)Rust(async fn in trait)
インターフェース/トレイトに非同期メソッドを持たせる難易度そもそも「非同期メソッド」という概念がない。メソッド内で自由にgoすればよく、シグネチャに変化はないinterface Notifier { CompletableFuture<Void> sendAlert(String msg); }で完結。特別な対応は不要interface Notifier { sendAlert(msg: string): Promise<void>; }で完結。特別な対応は不要ネイティブのasync fnは静的ディスパッチ限定。動的ディスパッチにはasync-trait等の追加対応が必要
複数実装を1つのコレクションにまとめる際の追加コストゼロ。Goのインターフェース値は元々「型情報+ポインタ」の組で、常に動的ディスパッチ相当ゼロ。Javaのオブジェクト参照は元々ヒープ上の参照であり、インターフェース経由の呼び出しは常に動的ディスパッチゼロ。JSのオブジェクトは常に参照型であり、追加のボックス化という概念自体が存在しないBoxによるヒープ確保 + 動的ディスパッチが明示的に必要(async-traitが代行)
この非対称性の理由値がデフォルトで参照・ヒープ経由(GC言語の一般的な設計)値がデフォルトで参照・ヒープ経由(GC言語の一般的な設計)値がデフォルトで参照(JSの設計)値はデフォルトでスタック上・スタティックにサイズ確定(ゼロコスト抽象化の原則)。動的な文脈が欲しければ明示的にBoxで「参照型化」する必要がある

Go・Java・TypeScriptでは「オブジェクトは常にヒープ上に置かれ、参照経由でやり取りする」のがデフォルトの実行モデルなので、非同期メソッドをインターフェース越しに呼ぶことに追加のコストや構文上の壁が発生しません。Rustだけがこの壁にぶつかるのは、Rustの値がデフォルトで「スタック上に置かれ、コンパイル時にサイズが確定した具体型」であることを前提にしているためです。async-trait クレートがやっていることは、要するに「Rustの値をGC言語のオブジェクトのように、ヒープ上の参照型として扱う」という選択を、その箇所に限って明示的に行っているとも言えます。

🏆 実務での使いどころ

  • 通知・アラートの多チャネル配信: 今回の例のように、メール・Slack・PagerDuty・Webhookなど、運用時にどの実装が有効かが設定ファイルで変わる場合、Vec<Box<dyn Notifier>> を設定から構築してループで呼び出す設計は非常に一般的
  • プラグインシステム: 起動時に読み込んだプラグインの集合を Vec<Box<dyn Plugin>> として保持し、各プラグインの非同期な初期化・処理フックを順番/並行に呼び出す
  • バックエンド抽象化: trait Storage { async fn get(&self, key: &str) -> Result<Vec<u8>, Error>; } のように、S3・ローカルファイル・インメモリなど複数のストレージ実装を実行時(環境変数やテスト用モック等)で切り替えたい場合
  • 静的ディスパッチで足りる場面との使い分け: 呼び出し箇所ごとに実装が1つに固定される(例: 特定のAPIハンドラは常に本番用のDB実装だけを使う)場合は、async-traitを使わずジェネリクス + ネイティブasync fnで済ませたほうがヒープ確保のコストを避けられる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Rust 1.75でasync fn in traitが安定化したので、async-traitクレートはもう不要だと思う「ネイティブでasync fnがトレイトに書けるようになった」というニュースだけを見て、動的ディスパッチの制約まで解消されたと誤解するネイティブのasync fn in traitは静的ディスパッチ(ジェネリクス経由の単相化)でのみ動作する。dyn Traitによるトレイトオブジェクトを作りたいなら、依然としてasync-traitのようなクレート(または手動でのPin<Box<dyn Future>>化)が必要
#[async_trait]をトレイト定義だけに付ければ十分だと思うマクロが1箇所で完結すると期待してしまうトレイト定義と、そのトレイトを実装する全てのimplブロックの両方に付ける必要がある。片方だけだとシグネチャの不一致でコンパイルエラーになる
Box<dyn Notifier>を使う設計は常に「悪い」パフォーマンス選択だと思う「ゼロコスト抽象化」という言葉から、動的ディスパッチ=常に避けるべきものという極端な理解をしてしまうヒープ確保・動的ディスパッチのコストは、多くの実務コード(I/Oバウンドな通知・ネットワーク呼び出しなど)ではミリ秒単位の待機時間に対して無視できるほど小さい。ホットパス(マイクロ秒単位で大量に呼ばれる処理)でない限り、実装の柔軟性を優先して問題ないことが多い
Send + Sync境界を付けなくても、後からtokio::spawnで使う段階になれば簡単に追加できると思うトレイト境界の追加が「小さな変更」に見えるSend + Syncをスーパートレイトに後から追加すると、既存の全実装がこれを満たすか確認し直す必要があり、他クレートに公開済みのトレイトであれば破壊的変更(semver的にはメジャーバージョンアップ相当)になる。動的ディスパッチで使う可能性が少しでもあるなら、最初から付けておくのが定石

🚀 次のステップ

  • 発展: 同じ Notifier トレイトを、ネイティブの async fn(async-traitを使わない)+ ジェネリクス(fn dispatch_one<N: Notifier>(notifier: &N, message: &str))で書き直し、静的ディスパッチ版と動的ディスパッチ版でコンパイル後のバイナリサイズや呼び出しのオーバーヘッドがどう変わるかを比較する
  • 次回予告: async trait② — RPITIT(ネイティブasync fn)による静的ディスパッチ版の実装と、async-trait版との使い分け・パフォーマンス特性の比較

🎯 自己評価

自分の回答

気づき・メモ