Day 049 — trait_variantマクロはSend境界の手間だけを消す。dyn互換性は依然として獲得できないと概念で見抜く

2026-09-15 🟣 上級者 / Phase 3 概念理解 async trait(async-trait / RPITIT)

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

Day 048では、tokio::spawnに渡すジェネリックなトレイト境界で2つの独立したエラー(型パラメータR自身のSend、findが返す匿名FutureのSend)を、Repository: Send + Syncというスーパートレイトと、async fnの糖衣構文をやめてfn find(&self, id: u64) -> impl Future<Output = Option<String>> + Sendという展開形に手書きすることで解決しました。

この「トレイト定義を手動でRPITIT展開形に書き直し、+ Sendを明示する」パターンは、Send境界が必要なトレイトを書くたびに毎回発生する定型的なボイラープレートです。しかも展開形はasync fnの直感的な見た目を失うため、トレイトの利用者(implを書く側)にとっても読みにくくなります。

Rustコミュニティはこの定型作業を自動化するためにtrait_variantクレートを提供しています(tokio自身が内部で使っているクレートでもあります)。使い方は次のような属性マクロです。

#[trait_variant::make(Repository: Send)]
trait LocalRepository {
    async fn find(&self, id: u64) -> Option;
}

このマクロは、async fnの糖衣構文をそのまま残したLocalRepositoryトレイト(Send境界なし)を定義に使わせつつ、裏側でもう1つの公開トレイトRepository(Send境界付きのRPITIT展開形)を自動生成し、T: LocalRepository + Sendを満たす任意の型Tに対してRepositoryをブランケット実装として自動的に実装してくれます。つまりDay 048で手で書いた「スーパートレイト境界」と「RPITIT展開形」の両方を、マクロが1行の属性から生成します。

📌
今日の核心の問いは、「async-traitクレート」「ネイティブのasync fn in trait(RPITIT)」「trait_variant」の3つは、それぞれ何を得て何を諦めているのかを、概念として整理できるかどうかです。
手法構文の見た目実行時コストdyn互換性
ネイティブasync fn in trait自然(糖衣構文のまま)ゼロコスト(静的ディスパッチ)不可(E0038)
async-traitクレート自然(マクロが裏で書き換え)Boxヒープ確保が毎呼び出しで発生可能
trait_variant::make(Send)自然(LocalRepository側は糖衣構文)ゼロコスト(静的ディスパッチ)不可

3つとも「見た目は自然なasync fn」という点は共通していますが、trait_variantが解決しているのは「Send境界の記述の手間」だけであり、「dyn互換性がない」という制約そのものはネイティブRPITITから一切変わっていません。この区別を混同すると、「Sendにしたのだからdyn Repositoryも使えるはずだ」という誤解に陥ります。

📝 問題

Day 048の模範解答(Repository: Send + Syncトレイトを手動でRPITIT展開形に書いたもの)を土台に、以下を行ってください。

  1. trait_variant::makeマクロを使って、LocalRepositoryという素のasync fn構文のトレイトを1つ定義し、#[trait_variant::make(Repository: Send)]を付けることで、Send境界付きのRepositoryトレイトを自動生成させてください(Cargo.tomlにtrait-variantを依存追加すること)
  2. InMemoryRepoにLocalRepositoryを実装し(Repositoryではないことに注意)、spawn_lookup<R: Repository + 'static>(...)がtokio::spawnに通ることを確認してください
  3. 次の問いに文章で答えてください(コードは書かなくてよい、概念理解の確認です)
    • #[trait_variant::make(Repository: Send)]が生成するRepositoryトレイトは、Day 048で手書きしたRPITIT展開形と比べて、型システム上どこが同じでどこが違うか
    • RepositoryトレイトはVec<Box<dyn Repository>>のようにdyn互換な形で使えるか。使えない場合、async-traitクレートで作ったトレイトとの違いはどこから来るのか(ヒント: 戻り値の型がすべての実装で「同じ具体型」に統一されるかどうか)
  4. cargo buildとcargo testが通ることを確認してください

🔍 ヒント(段階的開示)

ヒント1 — 方向性

trait_variant::makeの引数Repository: Sendは、「これから生成する公開トレイトの名前」と「そこに付けるマーカートレイト境界」を指定しています。属性を付ける対象のトレイト(今回はLocalRepository)自体は、Send境界を持たない「ふつうのasync fn in traitトレイト」のままでよい、という点に注目してください。

ヒント2 — アプローチ

implを書くときは、生成されたRepositoryではなく、あなたが定義したLocalRepositoryを実装します。マクロが用意するブランケット実装(impl<T: LocalRepository + Send> Repository for Tのようなもの)が、LocalRepositoryの実装を自動的にRepositoryとして使えるようにしてくれます。呼び出し側(spawn_lookupのジェネリック境界)はRepositoryを要求します。

ヒント3 — コード骨格
#[trait_variant::make(Repository: Send)]
trait LocalRepository {
    async fn find(&self, id: u64) -> Option;
}

impl LocalRepository for InMemoryRepo {
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}

fn spawn_lookup(repo: R, id: u64) -> tokio::task::JoinHandle> {
    tokio::spawn(async move { repo.find(id).await })
}

✅ 模範解答

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

use std::collections::HashMap;

/// `LocalRepository`は素の`async fn` in traitとして定義する。
/// Send境界の記述はここには一切書かない。
///
/// `#[trait_variant::make(Repository: Send)]`が展開されると、
/// 裏側でおおよそ次のようなコードが生成される(イメージ):
///
/// ```ignore
/// pub trait Repository: Send {
///     fn find(&self, id: u64) -> impl Future> + Send;
/// }
/// impl Repository for T {
///     async fn find(&self, id: u64) -> Option {
///         LocalRepository::find(self, id).await
///     }
/// }
/// ```
///
/// つまりDay 048で手書きした「スーパートレイト境界」+「RPITIT展開形」の
/// 2点セットを、このマクロが1つの属性から自動生成してくれる。
#[trait_variant::make(Repository: Send)]
trait LocalRepository {
    async fn find(&self, id: u64) -> Option;
}

struct InMemoryRepo {
    data: HashMap,
}

// impl対象は生成された`Repository`ではなく、
// 自分で定義した`LocalRepository`である点に注意。
// ブランケット実装が自動的に`Repository`を実装してくれる。
impl LocalRepository for InMemoryRepo {
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}

/// `R: Repository`という境界だけで、`tokio::spawn`の
/// `F: Future + Send + 'static`要求を満たせる。
/// Day 048のように`Send + Sync`を手書きする必要がない。
fn spawn_lookup(
    repo: R,
    id: u64,
) -> tokio::task::JoinHandle> {
    tokio::spawn(async move { repo.find(id).await })
}

#[tokio::main]
async fn main() {
    let mut data = HashMap::new();
    data.insert(1, "alice".to_string());
    let repo = InMemoryRepo { data };

    let handle = spawn_lookup(repo, 1);
    let result = handle.await.unwrap();
    println!("{:?}", result);
}

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

    #[tokio::test]
    async fn spawn_lookup_returns_found_value() {
        let mut data = HashMap::new();
        data.insert(1, "alice".to_string());
        let repo = InMemoryRepo { data };

        let result = spawn_lookup(repo, 1).await.unwrap();
        assert_eq!(result, Some("alice".to_string()));
    }

    #[tokio::test]
    async fn spawn_lookup_returns_none_for_missing_id() {
        let repo = InMemoryRepo { data: HashMap::new() };

        let result = spawn_lookup(repo, 999).await.unwrap();
        assert_eq!(result, None);
    }
}
🧪
cargo build・cargo test・cargo runのすべてで動作確認済みです。cargo runの出力はSome("alice")、cargo testは2件ともokでした。

問い3への回答例

  • 同じ点: どちらも最終的に生成される公開トレイトは、型システム上「findが返す匿名Future型に+ Send境界が付いたRPITIT」という、まったく同じ形をしている。コンパイラが内部で扱う表現は事実上同一。
  • 違う点: Day 048では自分で「スーパートレイト境界」と「展開形の記述」の両方を手で書いたのに対し、今回はそれをマクロが1つの属性から自動生成する。加えて、今回はLocalRepository(Send境界なし)とRepository(Send境界あり)という2つのトレイトが存在し、実装者は前者だけを書けばよい、という構造上の違いがある。
  • dyn互換性: Repositoryはdyn互換ではない。trait_variantが生成するのはあくまでRPITIT(impl Future<...> + Sendという戻り値)であり、これは実装ごとに異なる匿名型を返すため、Day 046で確認した「トレイトオブジェクトは全実装で戻り値の型を統一できないと作れない」という制約に相変わらず抵触する。一方async-traitクレートは、戻り値をPin<Box<dyn Future<Output = ...> + Send>>というすべての実装で同一の具体型に強制的に変換するため、dyn互換になる。

🪜 Step-by-Step 解説

1
Cargo.tomlに依存を追加する
trait-variant = "0.1"を追加する。このクレートはマクロのみを提供する軽量なクレートで、syn/quoteに依存するがランタイム自体には何のコストも追加しない(コンパイル時にだけ展開されて消える)。
2
素のasync fn in traitとしてLocalRepositoryを書く
Send境界のことは一切考えず、Day 046以前と同じ自然な形で書く。
3
マクロ属性でSend付きの変種を生成する
#[trait_variant::make(Repository: Send)]をトレイト定義の直前に付ける。これによりRepositoryという新しい公開トレイトがモジュール内に生成される。
4
implはLocalRepositoryに対して書く
実装者が意識するのはLocalRepositoryだけでよい。マクロが用意したブランケット実装のおかげで、InMemoryRepo: LocalRepository + Sendであれば自動的にInMemoryRepo: Repositoryも成立する(HashMap<u64, String>はSendなのでInMemoryRepoもSend)。
5
呼び出し側はRepository境界だけを要求する
spawn_lookup<R: Repository + 'static>と書けば、Day 048のようにSend + Syncを手で書く必要がなくなる。tokio::spawnが要求するF: Future + Send + 'staticを、マクロが生成した境界がそのまま満たしてくれる。

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

Rustのエコシステムには「言語機能そのものを拡張する」以外に、「言語機能の組み合わせから生じる定型記述を、マクロでコード生成して隠す」という補完戦略が繰り返し登場します(serdeのderive、thiserror、そして今回のtrait_variant)。これは単なる「タイピング量の削減」ではありません。手で書く展開形は書き間違えるリスクがあり、しかも意図(「Sendにしたい」という目的)と実装(impl Future<...> + Sendという具体的な構文)が分離してしまうという問題があります。マクロは「意図」を属性というレベルで直接表現させ、正しい展開形をコンパイラに保証させることで、この分離を埋めます。

一方で、Rustの設計思想として重要なのは、マクロはあくまで既存の型システムの制約の上でコードを生成しているだけであり、型システムのルール自体を曲げることはできないという点です。trait_variantがどれだけ便利であっても、「RPITITはdyn互換にならない」という制約はコンパイラの型検査規則そのものに根ざしているため、マクロによる自動生成では絶対に回避できません。この「マクロは構文の手間を減らせるが、型システムの根本制約は変えられない」という境界線を正確に認識できることが、クレートを選定する際の実務的な判断力になります。

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

今日は概念理解が主眼のため必須ではありませんが、impl対象を誤って生成後のRepositoryに書いてしまうと、次のようなエラーになります。

// アンチパターン: 生成された `Repository` に直接implしようとする
impl Repository for InMemoryRepo {
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}
error[E0407]: method `find` is not a member of trait `Repository`
  --> src/main.rs:NN:5
  |
NN  |    async fn find(&self, id: u64) -> Option<String> {
  |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not a member of trait `Repository`
⚠️
読み方: マクロが生成するRepositoryのメソッド署名はasync fnの糖衣構文ではなく展開後のimpl Future<...> + Send形なので、糖衣構文のasync fnを素朴にimplしようとすると型が一致せずエラーになります。実装は必ずLocalRepository側に対して書く、というマクロの使用規約を守る必要があります。

🌐 他言語との比較

観点Kotlin(suspend fun)C#(async Task)Go(interface + goroutine)Rust(trait_variant)
インターフェースの非同期メソッドinterfaceにsuspend funをそのまま書けるinterfaceにTaskを返すメソッドをそのまま書けるinterfaceにメソッドを書くだけ(async修飾自体が存在しない)ネイティブasync fnはOKだが、スレッド間送信可否は別途+ Sendの明示が必要
スレッド間移動の安全性ランタイム(コルーチンディスパッチャ)が管理し、型システムでは表現しないスレッドプールが管理し、型システムでは表現しないgoroutineはどのOSスレッドで動くか意識不要(GCと協調スケジューラ前提)型システムがSendという形で保証。守らないとコンパイルが通らない
定型記述を減らす手段言語機能として最初からサポート(追加ライブラリ不要)言語機能として最初からサポート言語機能として最初から不要trait_variantのようなマクロクレートで後付けで簡略化

Kotlin・C#・Goでは、非同期メソッドを持つインターフェースを書くこと自体に特別な追加境界は不要です。これは、これらの言語がガベージコレクションと協調スケジューラを前提にしており、「このFutureを別スレッドに渡してよいか」という判断をランタイムが握っているためです。Rustは所有権とゼロコスト抽象化を優先するため、この判断を型システムに刻み込む代わりに、その記述コストが発生します。trait_variantのようなクレートは、この「Rustならではの記述コスト」を、他言語に近い書き心地まで下げるための現実的な折衷案だと理解すると位置づけがはっきりします。

🏆 実務での使いどころ

  • 社内共通クレートのトレイト設計: 複数チームが実装する共通トレイト(例: 通知サービス、ストレージ抽象化)をマルチスレッドランタイムで使う前提で公開する場合、trait_variantを使うことで「Send境界の書き忘れ」による利用者側のわかりにくいエラーを防げる
  • tokio本体のコードベース: tokio自身が内部でtrait_variantを利用しており、大規模なasyncランタイムの実装でも「手書きのRPITIT展開」より「マクロによる一貫した生成」が保守性の観点で選ばれている
  • 段階的な移行: 既存のasync-traitベースのコードを「動的ディスパッチが本当に必要か」を見直した結果、静的ディスパッチで十分と判断した場合、trait_variantへの移行はDay 047のベンチマークで測ったヒープ確保コストを消せる現実的な選択肢になる
  • ライブラリ利用者への説明コスト削減: 手書きのRPITIT展開形は初見のRustエンジニアには読みにくいが、#[trait_variant::make(...)]という属性はドキュメント文字列と組み合わせることで「なぜこの形になっているか」を利用者に伝えやすい

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
trait_variantを使えばトレイトがdyn互換になると思い込む「Sendが付いた」=「制約が緩和された」という直感的な連想からdyn化も可能になった気がしてしまうtrait_variantが解決するのはSend境界の記述コストのみ。RPITITの「実装ごとに異なる匿名型を返す」という性質自体は変わらず、dyn互換性はネイティブ版と同じく獲得できない
生成されたRepositoryに対して直接implを書こうとするマクロが生成したトレイト名の方が「本命」に見えるため、そちらに実装したくなる実装は常に元のLocalRepository(マクロを付けた対象)に対して書く。Repositoryはブランケット実装によって自動的に得られる派生物
trait_variantとasync-traitを「同じ問題を解決する2つの選択肢」として並列に考えてしまうどちらも「asyncトレイトのボイラープレートを減らす」という見た目の目的が同じためasync-traitは「dyn互換性の獲得」(ヒープ確保という代償あり)、trait_variantは「Send境界記述の省略」(ゼロコストのまま)という、解決している問題の軸がそもそも異なる。両者は競合ではなく別の悩みに対する別の道具

🚀 次のステップ

  • 発展: trait_variant::makeが実際に生成するコードをcargo expandで展開して確認し、模範解答の「イメージ」コメントとどこまで一致するかを検証する
  • 次回予告: 本テーマ(async trait)の理解が固まった段階で、Phase 3の次テーマunsafe Rustの基礎(生ポインタ操作)に進む予定。まだ自己評価欄が未記入のため、次回もテーマ継続の可能性がある

🎯 自己評価

自分の回答

気づき・メモ