レッスン5 — Generics・trait bounds・?Sized・dyn vs impl
問い
Reth / Revm / Alloy のソースで頻出する ジェネリクス + trait bounds。<T: Bound> / ?Sized / dyn Trait / impl Trait — それぞれ何を意味し、なぜ使い分けるか。コードを読むのに必須の 4 概念。
原理(最小モデル)
- ジェネリクス
<T>. 型パラメータ、コンパイル時に具象化(モノモーフ化)= 実行時オーバーヘッドなし + 各具象型用バイナリ生成。 - Trait bounds
T: Trait. 型パラメータに制約、fn print<T: Display>(x: T)で「Display 実装してれば何でも」、複数 bounds はT: A + B + \'static。 ?Sized. デフォルトのSized制約を外す、dyn Trait(実行時サイズ)を受け入れる、<T: ?Sized>で trait object を渡せる。dyn Trait. 実行時ディスパッチ、vtable 経由、Box<dyn Trait>/&dyn Trait、複数具象型を統一して扱う、サイズ未知。impl Trait. 引数 = 「Trait 実装の何か」(ジェネリクスの短縮)、戻り値 = 「具体的な型を隠蔽」、戻り値の場合は単一具象型に限定。- ジェネリクス vs
dyn. ジェネリクス = 静的ディスパッチ + モノモーフ化(速い、コード膨張)、dyn= 動的ディスパッチ + vtable(柔軟、若干遅い)。 Send + Sync. スレッド間移動 / 共有可能性のマーカー trait、Tokio タスクにはT: Send + \'static必要、Rust 並行性の土台。\'staticライフタイム. プログラム全体生存 or 借用なし、グローバル定数 /String::from("...")/Vec<T>等、Send + \'staticでタスク移動可能。
具体例 + ステップで組み立てる
Generics・trait bounds・?Sized・dyn vs impl
これは pub fn add<IT: ITy, H: ?Sized>(...) を怯まず読めるようにするためのレッスンである。Reth / Revm のソースは generics が密集 し、関数シグネチャに型パラメータ 3 つは珍しくない。本レッスンではその仕組みを 1 つずつ解きほぐす。
Generics 101 — 基本の形
ジェネリック関数は 型パラメータ を山括弧で取り、それを通常の型のように使う:
fn first<T>(items: &[T]) -> Option<&T> {
items.first()
}
let nums = [1, 2, 3];
let first_num = first(&nums); // T は i32 と推論
let words = ["hello", "world"];
let first_word = first(&words); // T は &str と推論
コンパイラは モノモーフ化 する — 各具象 T 用に first の特殊化コピーを生成する。first::<i32> と first::<&str> はコンパイル後のバイナリでは別関数で、両方とも手書きの非ジェネリックコードと比べて実行時コストゼロ。
Trait bounds — 「T はこれらの操作をサポートする必要がある」
裸の <T> は「T は何でも良い」と言う。多くの場合 T で 何かする 必要がある — メソッド呼び出し、比較、フォーマット。そこで trait bounds が登場:
use std::fmt::Display;
fn print_first<T: Display>(items: &[T]) {
if let Some(first) = items.first() {
println!("{}", first); // T: Display が要る
}
}
<T: Display> は「T は Display トレイトを実装している必要がある」と読む。これがないとコンパイラは拒否 — T が {} フォーマッタを持つか知らない。
+ で複数 bound:
fn process<T: Display + Clone>(item: T) {
let copy = item.clone();
println!("{} (cloned: {})", item, copy);
}
where 節 — 同じこと、別構文
bound が長くなるとシグネチャの下に移動:
fn process<T>(item: T)
where
T: Display + Clone + Send + 'static,
{
// 本体
}
これは純粋に表記の話。<T: Bound> と where T: Bound は完全に同じ意味。Reth コードは長さに応じて両方使う。
Sized — あなたが知らなかった暗黙 bound
Rust のすべての型パラメータ T には 暗黙の Sized bound がある。つまり <T> は静かに <T: Sized> になっている。Sized は「コンパイラがコンパイル時に型のサイズを知っている」という意味。
ほとんどの型は Sized: i32 は 4 バイト、String は 24 バイト (ポインタ + 長さ + 容量)、自作の struct は既知のサイズ。
一部の型は Sized でない:
str(裸の文字列型、&strではない) — 長さは中身次第[i32](裸のスライス型、&[i32]ではない) — 同様dyn Trait(これから扱う) — 裏の具象型は何でもありうる
<T: ?Sized> と書くと、暗黙の Sized bound から opt out している。T は unsized でも良くなる。? は「Sized かもしれない、そうでないかもしれない」。
なぜそうするか? &T と Box<T> は T が ?Sized なら unsized 型を保持できるから。?Sized がないと fn foo<T>(x: &T) を書いて &dyn Trait を渡すことは絶対にできない — コンパイラは T: Sized を強制し、dyn Trait はそれを満たさない。
Revm がこう書く時:
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
H: ?Sized は まさに H が dyn Host になれるため。関数は &MyConcreteHost (Sized) と &mut dyn Host (unsized、?Sized) の両方で動く。
この 1 文字 (?) が、関数がトレイトオブジェクトを受け入れるかを決める。
dyn Trait — トレイトオブジェクト
dyn Trait は トレイトオブジェクト。「ポインタ + vtable」のペア:
- ポインタが具象値を指す
- vtable は関数ポインタの表 — トレイトの各メソッドに 1 つ
dyn Trait で obj.method() を呼ぶと、コンパイラは vtable ルックアップ を吐く: vtable からメソッドポインタをロード、間接呼び出し。これが 動的ディスパッチ — 実行時に解決される。
dyn Trait 自体は unsized (ポインタの裏の具象型はどんなサイズでもありうる)。だから常に何らかのポインタ越しに見る:
&dyn Trait // 共有参照
&mut dyn Trait // 排他参照
Box<dyn Trait> // 所有、ヒープ確保
Rc<dyn Trait> // 共有所有、シングルスレッド
Arc<dyn Trait> // 共有所有、スレッドセーフ
混在実装のベクタ:
trait Greet { fn greet(&self); }
struct En; struct Ja;
impl Greet for En { fn greet(&self) { println!("Hi"); } }
impl Greet for Ja { fn greet(&self) { println!("こんにちは"); } }
let mixed: Vec<Box<dyn Greet>> = vec![Box::new(En), Box::new(Ja)];
for g in &mixed { g.greet(); }
dyn がないと不可能 — Vec<T> は全要素が同じ具象型である必要がある。
impl Trait — 静的な対応物
impl Trait は似て見えるが根本的に違う:
fn make_greeter(lang: &str) -> impl Greet {
if lang == "ja" { Ja } else { En }
// ❌ コンパイルしない — 戻り型は単一の具象型でないと
}
戻り位置の impl Trait は「Trait を実装する 特定の型 を返すが、呼び出し元から隠している」と意味する。静的ディスパッチ — コンパイラが 1 つの型を選び、モノモーフ化、vtable なし。
トレードオフ:
impl Trait | dyn Trait | |
|---|---|---|
| ディスパッチ | 静的 (コンパイル時) | 動的 (実行時 vtable) |
| 速度 | 速い (インライン化可能) | 少し遅い (1 度の間接呼び出し) |
| 異種コレクション | ❌ 不可 | ✅ 可 (Vec<Box<dyn Trait>>) |
| オブジェクト安全性 | 関係なし | 必要 (一部のトレイトはオブジェクト安全でない) |
Reth と Revm は 両方 使う。impl がデフォルトで、dyn は異種性が重要なケース (stage のリスト、プラグイン点) のために予約。
オブジェクト安全性 — dyn Trait がコンパイルしない時
dyn Trait として使うにはトレイトが オブジェクト安全 でなければならない。ルールは微妙だが、よく踏むのは:
- トレイト内の ジェネリックメソッド禁止 (ライフタイム以外で)
Self: SizedでないメソッドのSelf戻り型禁止- 関連定数禁止
Box<dyn MyTrait> を試してトレイトがオブジェクト安全でないと、「cannot be made into an object」のようなコンパイルエラーが出る。修正は通常、問題のメソッドに where Self: Sized を追加するか、トレイトを分割する。
Reth/Revm コードの 消費者 としてはあまりヒットしないが、なぜソースの一部のトレイトが dyn 化できないかを説明する。
全部まとめる — 実シグネチャを読む
戻ろう:
pub fn add<IT: ITy, H: ?Sized>(context: Ictx<'_, H, IT>) -> Result {
これで単語ずつ読める:
pub fn add—addという名前のパブリック関数<IT: ITy, H: ?Sized>— 型パラメータ 2 つ:ITはITyトレイトを実装する必要がある (interpreter-types マーカー — 通常 vs トレース vs サンドボックス)Hは unsized で良い (なのでdyn Hostになれる)
context: Ictx<'_, H, IT>— 両方でパラメータ化されたコンテキストを取る-> Result— Result を返す
同じ関数、2 つの特殊化パス: 1 つは (具象IT, 具象Host) 用 (完全モノモーフ化、最速)、もう 1 つは (具象IT, dyn Host) 用 (Host 呼び出しは vtable)。?Sized が 2 つ目のパスを可能にする。
これが Revm が「モジュラー」である理由: 同じ opcode 関数が異なる実行モード用に複数バイナリにコンパイルされ、ディスパッチは実行時の分岐ではなく型システムレベルで決まる。
読み物リスト
- Rust Book 章 10 (Generics, Traits, Lifetimes)、章 17 (Trait Objects) — 開いて、せめてセクション見出しだけでもスキム。無料リファレンス。
reth/cratesで型パラメータが 3 つ以上の関数を見つける。シグネチャを読む。各部品を「この関数はどんな具象形で valid か」に翻訳できるはず。- 思考実験:
addシグネチャのHから?Sizedを取り除いたら、&mut dyn Hostを渡す呼び出し元にコンパイラはどんな具体的なエラーを出す? (答え: 「the traitSizedis not implemented fordyn Host」)
このレッスンで持ち帰るもの
- Generics + bounds は Rust 版の「インターフェース」: 「T は何でもいいが、これらの操作をサポートする必要がある」。
Sizedは暗黙 に各型パラメータに付く;?Sizedが opt out してパラメータをトレイトオブジェクトにできるようにする。dyn Traitは実行時ディスパッチのポインタ + vtable;impl Traitはコンパイル時モノモーフ化。- Revm の重い generics 使用は 型レベルでのモジュラリティ — 同じコード、複数特殊化。
中級レッスン 1 でいきなり 1 行目に型パラメータ 3 つと ?Sized をぶつけられた時、それを 5 つの威圧的なトークンではなく、1 つの繋がった文として読めるはず。
まとめ(3行)
- 4 概念 = ジェネリクス
<T>(コンパイル時具象化)+ trait boundsT: Bound(制約)+?Sized(trait object 許可)+dyn/impl Trait(実行時 vs 静的)。 Send + Sync + \'staticでスレッド間移動 / 共有 / プログラム全体生存、Tokio タスクの土台。- 次は Arc / Mutex / RwLock で共有所有権、並行性プリミティブを理解。