📚 背景知識(読んでから問題へ)
Day 046では「なぜネイティブのasync fn in traitだけではトレイトオブジェクトが作れないのか」を確認し、async-traitクレートがPin<Box<dyn Future<...> + Send>>への変換を肩代わりしてくれることを学びました。今日はその「肩代わり」に実際どれだけのコストが乗っているのかを、言葉ではなく実測で確認します。
Rustには「複数の実装を1箇所で切り替える」ための道具が実はもう1つあります。enumによる静的な多態性です。
enum AnyNotifier {
Email(EmailNotifier),
Slack(SlackNotifier),
}
このenumに対してmatchで分岐すれば、EmailNotifierかSlackNotifierかによって呼び出す処理を変えられます。これはdyn Notifierによるトレイトオブジェクトと似た「実行時に決まる複数実装の切り替え」を実現しますが、決定的に違う点があります。具体型の集合がコンパイル時に閉じている(AnyNotifierは未来永劫この2種類しか持てない)ことと引き換えに、matchの各腕は単相化された具体的な関数呼び出しになり、Box<dyn Future>によるヒープ確保が一切不要になる、という点です。
つまり実務での選択はこうなります。
- 実装の集合が閉じている(プラグインを外部クレートが追加することはない、種類は自分たちで把握しきれる)→ enum + ネイティブ
async fn in traitで静的ディスパッチ - 実装の集合が開いている(外部クレートやランタイム設定で実装が増減しうる)→
async-trait+dyn Traitで動的ディスパッチ
#[global_allocator]でヒープ確保の回数を自分の目でカウントして比較することで、「動的ディスパッチにはBoxのヒープ確保コストが本当に乗っているのか」を推測ではなく数値で確認します。📝 問題
Day 046のNotifier(メール・Slack通知)を題材に、以下を実装してください。
- ネイティブ
async fn in trait版(async-traitマクロなし)でtrait Notifierを定義し、EmailNotifier・SlackNotifierに実装する - ジェネリクス経由で呼び出す
async fn dispatch_one<N: Notifier>(notifier: &N, message: &str) -> Result<(), String>を実装する EmailNotifierとSlackNotifierを1つのVecにまとめて扱うため、enumによる静的ディスパッチAnyNotifier(Email/Slackのバリアントを持つ)を定義し、send_alertメソッドをmatchで実装する- 比較対象として、Day 046と同じ
async-trait版(DynNotifierという名前にして区別する)も同じファイルに実装する Systemアロケータをラップして確保回数をカウントするCountingAllocator(GlobalAllocトレイト実装)を作り、#[global_allocator]に設定するmain関数で、以下を計測して標準出力に表示する- カウンタをリセットしたあと、
AnyNotifierのVecに対して1,000回×2件のメッセージ送信ループを回し、確保回数を記録する - カウンタを再度リセットしたあと、
Vec<Box<dyn DynNotifier>>に対して同じ1,000回×2件のループを回し、確保回数を記録する - 2つの確保回数を出力し、差を確認する
- カウンタをリセットしたあと、
- 以下をテストで検証してください
dispatch_oneがEmailNotifierに対して正しく成功/失敗を返すことAnyNotifier::Email・AnyNotifier::Slackそれぞれが正しく成功/失敗を返すこと
- 確認課題: ネイティブ版の
NotifierでVec<Box<dyn Notifier>>を作ろうとするとどうなるか。実際にコンパイルしてエラーメッセージを確認し、Day 046で見たエラーと同じかどうかコメントで残してください
🔍 ヒント(段階的開示)
ヒント1 — 方向性
「enumで複数の型を1つの型に押し込める」という発想は、TypeScriptのUnion型(type Notifier = EmailNotifier | SlackNotifier)やSwiftのenum associated valueに近いです。ただしRustのmatchは網羅性チェックがあるため、バリアントを追加し忘れるとmatchがコンパイルエラーで教えてくれます。これは「実装の集合が閉じている」ことをコンパイラに保証させる仕組みだと捉えてください。
ヒント2 — アプローチ
GlobalAllocはallocとdeallocの2つのメソッドを実装すればよく、内部でSystem.alloc(layout)に委譲しつつAtomicUsizeをインクリメントするだけです。#[global_allocator]はプログラム全体で1つしか設定できないため、AnyNotifier側とDynNotifier側の両方の確保が同じカウンタでカウントされます。計測の前後で確実にswap(0, Ordering::Relaxed)してカウンタをリセットし、「初期化のためのVec構築」を計測区間に含めないよう、reset_alloc_count()の呼び出し位置に注意してください。
ヒント3 — コード骨格
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingAllocator;
static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout)
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
fn reset_alloc_count() -> usize {
ALLOC_COUNT.swap(0, Ordering::Relaxed)
}
// AnyNotifier / DynNotifier 側は、Vecを先に作ってから
// reset_alloc_count() → ループ → reset_alloc_count() で
// 「ループ内で起きた確保だけ」を切り出す
✅ 模範解答
// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// async-trait = "0.1"
use async_trait::async_trait;
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
// ============================================================
// 計測用: ヒープ確保回数をカウントするグローバルアロケータ
// ============================================================
struct CountingAllocator;
static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0);
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout)
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
/// 現在の確保回数を取得し、同時に0にリセットする。
/// 「計測したい区間の直前」に必ず呼ぶことで、それ以前の
/// 初期化コストを計測結果から除外する。
fn reset_alloc_count() -> usize {
ALLOC_COUNT.swap(0, Ordering::Relaxed)
}
// ============================================================
// ① ネイティブ async fn in trait(静的ディスパッチ)
// ============================================================
trait Notifier {
async fn send_alert(&self, message: &str) -> Result<(), String>;
}
struct EmailNotifier {
address: String,
}
impl Notifier for EmailNotifier {
async fn send_alert(&self, message: &str) -> Result<(), String> {
if message.is_empty() {
return Err(format!("email to {} rejected: empty message", self.address));
}
Ok(())
}
}
struct SlackNotifier {
channel: String,
}
impl Notifier for SlackNotifier {
async fn send_alert(&self, message: &str) -> Result<(), String> {
if message.len() > 200 {
return Err(format!("slack #{}: message too long ({} chars)", self.channel, message.len()));
}
Ok(())
}
}
/// ジェネリクス経由の静的ディスパッチ。呼び出し箇所ごとに
/// 具体型`N`が確定しているため、コンパイラは`EmailNotifier`用・
/// `SlackNotifier`用それぞれの専用コードを単相化で生成する。
/// `Notifier::send_alert`の戻り値である匿名の`impl Future`は、
/// スタック上に置ける具体的なサイズを持つ型として扱われる。
async fn dispatch_one(notifier: &N, message: &str) -> Result<(), String> {
notifier.send_alert(message).await
}
/// 実装の集合が「EmailNotifierとSlackNotifierの2つだけ」と
/// コンパイル時に分かっている場合、トレイトオブジェクトの
/// 代わりにenumで「静的な多態性」を実現できる。matchの各腕は
/// 単相化された具体的な呼び出しであり、Boxは
/// 一切登場しない。
enum AnyNotifier {
Email(EmailNotifier),
Slack(SlackNotifier),
}
impl AnyNotifier {
async fn send_alert(&self, message: &str) -> Result<(), String> {
match self {
AnyNotifier::Email(n) => n.send_alert(message).await,
AnyNotifier::Slack(n) => n.send_alert(message).await,
}
}
}
// ============================================================
// ② async-trait(動的ディスパッチ、Day 046の再掲)
// ============================================================
#[async_trait]
trait DynNotifier: Send + Sync {
async fn send_alert(&self, message: &str) -> Result<(), String>;
}
struct DynEmailNotifier {
address: String,
}
#[async_trait]
impl DynNotifier for DynEmailNotifier {
async fn send_alert(&self, message: &str) -> Result<(), String> {
if message.is_empty() {
return Err(format!("email to {} rejected: empty message", self.address));
}
Ok(())
}
}
struct DynSlackNotifier {
channel: String,
}
#[async_trait]
impl DynNotifier for DynSlackNotifier {
async fn send_alert(&self, message: &str) -> Result<(), String> {
if message.len() > 200 {
return Err(format!("slack #{}: message too long ({} chars)", self.channel, message.len()));
}
Ok(())
}
}
const ITERATIONS: usize = 1_000;
#[tokio::main]
async fn main() {
// ---- 静的ディスパッチ(enum + ネイティブ async fn)の計測 ----
let notifiers = vec![
AnyNotifier::Email(EmailNotifier { address: "ops@example.com".to_string() }),
AnyNotifier::Slack(SlackNotifier { channel: "incidents".to_string() }),
];
reset_alloc_count(); // Vec構築コストは計測対象から除外する
for _ in 0..ITERATIONS {
for n in ¬ifiers {
let _ = n.send_alert("disk usage exceeded 90%").await;
}
}
let static_allocs = reset_alloc_count();
// ---- 動的ディスパッチ(async-trait)の計測 ----
let dyn_notifiers: Vec> = vec![
Box::new(DynEmailNotifier { address: "ops@example.com".to_string() }),
Box::new(DynSlackNotifier { channel: "incidents".to_string() }),
];
reset_alloc_count();
for _ in 0..ITERATIONS {
for n in &dyn_notifiers {
let _ = n.send_alert("disk usage exceeded 90%").await;
}
}
let dyn_allocs = reset_alloc_count();
println!(
"static dispatch (enum + native async fn): {static_allocs:>5} allocations ({ITERATIONS} iterations x 2 notifiers)"
);
println!(
"dynamic dispatch (async-trait): {dyn_allocs:>5} allocations ({ITERATIONS} iterations x 2 notifiers)"
);
// dispatch_one の動作確認(ジェネリクス経由の呼び出し例)
let email = EmailNotifier { address: "solo@example.com".to_string() };
let result = dispatch_one(&email, "hello").await;
println!("dispatch_one result: {result:?}");
}
// --- 確認課題の結果 ---
//
// ネイティブ版のtrait Notifierのまま
// `let v: Vec> = vec![...];` を書こうとすると:
//
// error[E0038]: the trait `Notifier` cannot be made into an object
// --> src/main.rs:NN:35
// |
// NN | let v: 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
//
// Day 046で見たエラーと本質的に同じ(E0038、
// 「async fnを含むトレイトはobject-safeではない」)。
// async-trait版のDynNotifierを別名で定義しているかどうかは
// 無関係で、「素のasync fn in trait」自体がdyn非互換という
// 制約はどの型にも一律に適用される、という点を再確認できる。
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dispatch_one_generic_reports_success_and_failure() {
let email = EmailNotifier { address: "a@b.com".to_string() };
let ok = dispatch_one(&email, "hello").await;
assert!(ok.is_ok());
let err = dispatch_one(&email, "").await;
assert!(err.is_err());
}
#[tokio::test]
async fn any_notifier_email_variant_dispatches_correctly() {
let email = AnyNotifier::Email(EmailNotifier { address: "a@b.com".to_string() });
assert!(email.send_alert("hi").await.is_ok());
assert!(email.send_alert("").await.is_err());
}
#[tokio::test]
async fn any_notifier_slack_variant_dispatches_correctly() {
let slack = AnyNotifier::Slack(SlackNotifier { channel: "x".to_string() });
assert!(slack.send_alert("hi").await.is_ok());
let too_long = "x".repeat(201);
assert!(slack.send_alert(&too_long).await.is_err());
}
}
cargo runを5回連続で実行し直して確認済みです。静的ディスパッチ側は4, 4, 8, 9, 1のように毎回バラつく1桁の小さな値(tokioランタイム自身の内部処理によるノイズで、私たちのコードが起因する確保はゼロのはず)でしたが、動的ディスパッチ側は5回とも寸分違わず2000。async-trait側は呼び出し1回につきBox::pinで1回確保するため1,000回×2 notifier=2,000回とコール回数に完全一致します。1桁のノイズに埋もれない、3桁違う差として確認できました。cargo testの3件も全てokです。🪜 Step-by-Step 解説
async fn in traitはジェネリクス越しなら普通に使えるDay 046で見た「object-safeではない」という制約は、あくまで
dyn Traitにしようとしたときだけ問題になります。dispatch_one<N: Notifier>のように呼び出し箇所で具体型Nが確定するジェネリクスであれば、async fn in traitは何の追加対応もなく普通に使えます。コンパイラはdispatch_one::<EmailNotifier>とdispatch_one::<SlackNotifier>という2つの別々の関数を生成する(単相化)ため、それぞれの戻り値型(匿名のimpl Future)が別サイズでも問題になりません。
AnyNotifierはトレイトを一切使っていません。ただの列挙型です。それでも「実行時にどちらの通知先か分からない値を1つの型として扱い、まとめてVecに入れてループで処理する」というDay 046の要求は満たせています。違いは、AnyNotifierが持てるバリアントはEmailとSlackの2つにコンパイル時に固定されることです。3つ目の通知先Webhookを追加したくなったら、AnyNotifierのenum定義自体を書き換える必要があります(dyn Notifier版なら、新しい構造体にimpl Notifierするだけで既存コードは無修正で拡張できました)。この「拡張のしやすさ」と「ヒープ確保のなさ」はトレードオフの関係にあります。
CountingAllocatorで「体感」を「数値」に変える#[global_allocator]を設定すると、プログラム内のあらゆるBox::new・Vecの拡張・Stringの生成などが、すべてこのCountingAllocator::allocを経由するようになります。reset_alloc_count()を計測区間の直前に呼ぶことで、「Vecの初期構築」のような計測対象外のノイズを除外し、「ループの中で、ディスパッチ方式の違いだけに起因する確保」だけを浮かび上がらせています。これは科学的な計測でいう「対照条件を揃えて、変えたい変数だけを変える」という考え方そのものです。
静的ディスパッチ側の数値を素直に「0」と決めつけないことが重要です。実際に5回実行し直すと
4, 4, 8, 9, 1のように毎回バラつく1桁の値になりました。成功パスではformat!(エラーメッセージ生成)が実行されないため、私たちのコード自体に起因する確保はゼロのはずで、このノイズはtokioランタイム自身のタイマー管理やスケジューリングによるものと考えられます。一方、動的ディスパッチ側はasync-traitが呼び出しのたびにBox::pin(async move { ... })でヒープ確保するため、5回とも寸分違わず2,000回(1,000回×2 notifier)とコール回数に完全一致します。「async-traitは呼び出し1回につきヒープ確保1回」という理屈は、1桁のノイズに埋もれない3桁違いの差として、推測ではなく手元の数値で裏付けられました。
💡 設計思想・なぜこう書くのか
Rustが「トレイトオブジェクト」と「enumによる静的多態性」という2つの異なる多態性の仕組みを両方提供しているのは偶然ではありません。オブジェクト指向言語の多くは「多態性 = 動的ディスパッチ」を暗黙の前提としますが、Rustは「拡張性が必要な開いた集合」と「集合が閉じていて全部把握できる」を意識的に区別させる設計を取っています。閉じた集合に対してわざわざ動的ディスパッチとヒープ確保のコストを払う理由はなく、enumで表現すればmatchの網羅性チェックという追加の安全性まで手に入ります。「多態性が欲しい」という要求を鵜呑みにせず、「その多態性は本当に実行時まで種類が分からない、閉じていない集合なのか」を問い直すことが、Rustらしい設計判断の第一歩です。
format!が実行されない成功パスに限定しています。エラーパス(format!でエラーメッセージを組み立てる)では静的ディスパッチ側にも文字列のヒープ確保が発生するため、確保回数の差は「純粋にディスパッチ方式に起因する差」ではなくなります。パフォーマンス計測をするときは、こうした「計測したい変数」以外の要因が紛れ込んでいないかを常に疑う必要があります。🛑 コンパイルエラーが出た場合
Notifier(ネイティブ版)のままでVec<Box<dyn Notifier>>を作ろうとすると、Day 046と同じE0038が出ます。
// アンチパターン: ネイティブ async fn in trait を dyn 化しようとする
trait Notifier {
async fn send_alert(&self, message: &str) -> Result<(), String>;
}
let v: Vec> = vec![
Box::new(EmailNotifier { address: "x".to_string() }),
];
--> src/main.rs:NN:20
|
NN | let v: Vec<Box<dyn Notifier>> = 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
async-traitマクロを使っているかどうかは、Notifierという別のトレイトには何の影響も与えません。「素のasync fn in traitはdyn非互換」という制約は、そのトレイト定義自身に#[async_trait]が付いているかどうかだけで決まります。同じファイルの中に#[async_trait]版(DynNotifier)が存在していても、ネイティブ版(Notifier)が自動的に恩恵を受けることはありません。🌐 他言語との比較
| 観点 | TypeScript(Union型) | Java(sealed interface, Java 17+) | Go(型スイッチ) | Rust(enum + match) |
|---|---|---|---|---|
| 閉じた集合の表現 | type Notifier = Email | Slack | sealed interface Notifier permits Email, Slack {} | 型スイッチにdefaultを書き忘れても検知されない | enum AnyNotifier { Email(..), Slack(..) } |
| 網羅性チェック | switch文でnever型を使えば疑似的に可能(実行時保証なし) | switch式でsealedならコンパイラが網羅性を強制 | 保証なし。新しい型を追加してもコンパイルは通ってしまう | matchでコンパイラが強制。バリアント追加を忘れると即コンパイルエラー |
| 実行時コスト | 動的型(常にヒープ経由) | 動的型(常にヒープ経由) | インターフェース値は型情報+ポインタの組 | タグ付きユニオンとしてスタックに配置可能。追加のヒープ確保なし |
RustのenumはJavaのsealed interfaceが持つ「網羅性チェック」という利点と、C言語のunionに近い「スタック上に収まる値型」という性質を両立しています。「実装の集合を閉じたい」という設計意図がある場合、Rustではそれをenumとして型システムに直接刻み込める点が、動的型のUnionしか持たないTypeScriptや、保証のないGoの型スイッチとの大きな違いです。
🏆 実務での使いどころ
- 決済プロバイダの切り替え: 自社が対応する決済手段が「クレジットカード・銀行振込・電子マネー」の3種類と決まっている(新しい決済手段の追加は仕様変更を伴う大きなイベント)場合、enumディスパッチで実装すると、新プロバイダ追加時に
matchの網羅性チェックが「対応漏れの処理箇所」をコンパイルエラーで教えてくれる - プラグインシステム: サードパーティが実装を追加できる、または設定ファイルで実行時に有効/無効を切り替える必要がある場合は、開いた集合なので
dyn Trait(async-trait)が適している - 高頻度なホットパス: ゲームサーバーのフレーム処理、ネットワークプロキシのパケット処理など、1秒間に数万回呼ばれるような処理では、
Boxによるヒープ確保の積み重ねが無視できないレイテンシになりうるため、可能な限りenumディスパッチ(または単純なジェネリクス)に倒す - 段階的な移行: 最初は種類が少なく閉じていた実装が、将来的にプラグイン化されそうな兆しがあるなら、早い段階で「今は閉じているが、開く可能性がある」ことをチームで共有し、enumから
dyn Traitへの移行コストを見積もっておく
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
enumディスパッチはdyn Traitの完全な代替になると思う | どちらも「複数実装をまとめて扱う」という見た目の効果が同じに見える | enumは集合が閉じている前提でしか使えない。外部クレートが実装を追加できるようにしたい(プラグイン機構など)場合は、dyn Traitでなければ実現できない |
#[global_allocator]によるカウントは「見た目上のヒープ確保」であり、実際の性能には大差がないと思う | ヒープ確保1回あたりのコストが小さく見える | 高頻度に呼ばれるパスでは、確保・解放のたびにアロケータのロック(マルチスレッド環境)やメモリ断片化の管理コストが積み重なる。今回の計測のように「呼び出し回数 × 確保回数」で考えると、日々の運用で無視できない差になりうる |
ネイティブasync fn in traitを使えば、もうasync-traitクレートを使う場面はないと思う | 「新しい方が常に良い」という思い込み | 「実装の集合が開いているか閉じているか」で使い分けるべきものであり、優劣ではなく適材適所の関係。両方を同じコードベースで使い分けることも普通にある |
enumのバリアントが増えるたびにmatchを全部手で書き直すのが面倒だと感じ、_ => {}でごまかしたくなる | 網羅性チェックのエラーを煩わしいと感じる | _ => {}で握りつぶすと、Rustが提供する「対応漏れをコンパイル時に検知する」という最大の利点を自ら手放すことになる。面倒に感じたら、それは「本当は集合が開きつつある兆候」であり、dyn Traitへの移行を検討するシグナルとして受け止めるべき |
🚀 次のステップ
- 発展:
CountingAllocatorにサイズ別の合計バイト数(AtomicUsizeをもう1つ増やし、layout.size()を加算)も記録し、「確保回数は同じでもサイズが違う」ケース(大きなStringをエラーメッセージに使う場合など)で挙動がどう変わるか確認する - 次回予告: unsafe Rustの基礎(生ポインタ操作) — 5つのunsafe操作と、コンパイラが保証してくれなくなる境界線がどこかを学ぶ