
2026/09/05 22:31
Rust の Vtable を視覚化する:dyn Trait がメモリ上でどのように機能するか
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Rust は、C++ の概念と並行するが独自の構造規則に従う 2 つの主要なメカニズムを通じて多態性を実現します。ジェネリクスによる静的ディスパッチは、タイプごとに独立した関数のコピーを生成します(単型化)ことで、C++ テンプレートのオーバーヘッドを回避します。動的ディスパッチは
dyn Trait を使用し、16 バイトのワイドポインタを介して外部 vtable にアクセスします。このワイドポインタにはデータポインタと vtable ポインタの両方が含まれます。Rust はゼロサイズタイプ (ZST) の扱いにおいて C++ と異なります:ZST に対しては一意のメモリアドレスに依存せず、代わりに貸借チェッカーと所有権システムを通じてアイデンティティを追跡します。デバッグビルドではローカルの ZST 変数はダミーのスタックスロットを占有する場合がありますが、リリースモードではそのアドレスは統合されます。C++ と異なり、Rust の vtable オーバーヘッドは &dyn Trait を使用する際にのみ発生し、ジェネリクスコードにはランタイムディスパッチコストがかかりません。すべての_trait_ が動的ディスパッチに対してオブジェクト安全ではないため、メソッドが Self を返すか、ジェネリクスを含むことはできません。その結果、動的ディスパッチなしで不均一なタイプ(例えば、混合された Circle と Square)を持つことは Vec<T> ではできず、これはジェネリクスが単一の正確なタイプ/サイズを要求するためです。単型化は C++ の CRTP/コンパイル時多態性に対応し、dyn Trait は C++ の仮想関数/ランタイム多態性に対応します。高性能かつ並列処理を重視するシステムにおいては、コンパイル時の専門性とランタイムの柔軟性をどう選択するかは重要なアーキテクチャ上の決断となります。著者は 2026 年 3 月にこの投稿を開始し、スペインに在住しており、並列処理とロックフリープログラミングに焦点を当てています。本文
Rust と C++ のポリモーフィズムを探求:メンタルモデルの変革
Rust への挑戦は、達成感と混乱が共存する特権的な体験です。現在は書籍(『The Rust Programming Language』および Mara Bos 氏の著作)からの学習を経て、ついに自分の手でコードを読み解くことに熱意を感じています。
当初は C++ との対応関係を探ろうとしていましたが、やがて**「新しい言語を理解しようと既存言語との 1:1 の対応を見出そうとすることは罠」**という教訓を得ました。Rust を単なる「シンタックスが違うだけの C++」と扱うことは、その革新性を無意味にします。しかし、「なぜそう動くのか」を探求し、メモリ内での実際の変化を理解することに意義があります。
序論:本題の核心
複数の図形(円、四角形など)に対して
draw() メソッドを呼び出すシナリオを考えます。ここでは以下の 2 つのアプローチと比較します。
C++ のアプローチ比較
-
バーチャル関数(ランタイムポリモーフィズム)
- オブジェクト内部に vtable ポインターを格納し、自動的に関数を解決します。
std::vector<Shape*> shapes = { new Circle(), new Square() }; for (auto* s : shapes) s->draw(); // バージョン 1: ポインタ配列による動的ディスパッチ -
CRTP(コンパイル時ポリモーフィズム)
です。vtable は存在せず、コンパイル時に解決されます。Curiously Recurring Template Pattern- 代償としてコードが冗長になり、可読性が犠牲になります。
template<typename Derived> struct Shape { void draw() { static_cast<Derived*>(this)->draw(); // コンパイル時解決 } };
Rust の立ち位置
Rust には CRTP に相当する非常に直感的な機能があります。それが**「モノルフィゼーション(Monomorphization)」**です。まずはこのアプローチからメンタルモデルを構築しましょう。
スタティック・ディスパッチ(Static Dispatch)
ジェネリクスによるスタティック・ディスパッチは、CRTP と同様の結果(コンパイル時解決)を達成します。ランタイムコストはゼロですが、型の情報はコンパイル時に決まっている必要があります。
実装例
trait Draw { fn draw(&self) -> &str; } struct Circle; struct Square; impl Draw for Circle { fn draw(&self) -> &str { "Drawing a circle" } } impl Draw for Square { fn draw(&self) -> &str { "Drawing a square" } } fn draw_shape<T: Draw>(shape: T) { println!("{}", shape.draw()); } fn main() { let circle = Circle; let square = Square; draw_shape(circle); // 内部では draw_shape::<Circle> が生成される draw_shape(square); // 内部では draw_shape::<Square> が生成される }
C++ との哲学的な違い
- C++: 制約は暗黙的。テンプレートは
メソッドを持つ「任意の型」を受け入れます。.draw() - Rust: 契約は明示的。
がSquare
トリートを実装することを明確に宣言します。Draw
サイドクエスト:Rust のゼロサイズ型(ZST)
C++ では、オブジェクトはたとえ空であっても少なくとも 1 バイトのサイズを持つことが義務付けられています。これは「自然の法則」のように思えますが、Rust はこれを破ります。
println!("{}", std::mem::size_of::<Circle>()); // 出力: 0 println!("{}", std::mem::size_of::<Square>()); // 出力: 0
メモリ内でのアイデンティティの扱い方
C++ では
&obj1 == &obj2 で同一性を確認しますが、Rust は**貸借チェッカー(Ownership)**をその代わりとしています。コンパイラはローカルの ZST 変数をダミーのスタックスロットに割り当てるだけであり、アドレス比較は不要です。
リリースビルドではアドレスが衝突することもありますが、コンパイラは「アイデンティティを追跡する必要がある」という保証を持っていません。Rust の哲学は**「所有権を通じてアイデンティティを追跡する」**というものです。
ダイナミック・ディスパッチ(Dynamic Dispatch)
スタティック・ディスパッチの限界(混ざった型を 1 つのコンテナに格納できないこと)を突破するために、
dyn Trait を使用します。
メモリ構造の変化
fn draw_shape(shape: &dyn Draw) { // <T: Draw> の代わりに &dyn Draw println!("{}", shape.draw()); }
サイズを確認すると以下のように変化します:
| 型 | サイズ (バイト) | 構成 |
|---|---|---|
| 8 | データポインタのみ (静的ディスパッチ) |
| 16 | データポインタ + vtable ポインタ (動的ディスパッチ) |
&dyn Draw は**ワイドポインタ(Wide Pointer)**です。2 つのポインタから構成されており、vtable がランタイムでどの関数を呼び出すかを指示します。
// unsafe ブロックが必要な理由:コンパイラがバイトごとのコピーを保証できないため fn inspect(shape: &dyn Draw) { let (data_ptr, vtable_ptr) = unsafe { std::mem::transmute::<&dyn Draw, (usize, usize)>(shape) }; }
コンテナへの格納(Vec の例)
Vec<T> は要素が厳密に同じサイズである必要があります。したがって、異なる図形を直接格納できません。
解決策:
Box<dyn Draw> を使用します。
let shapes: Vec<Box<dyn Draw>> = vec![ Box::new(Circle), Box::new(Square), ];
- 構造:
の配列として格納されます。[data_ptr | vtable_ptr] - C++ との違い: C++ ではクラスレベルでディスパッチ方式が決まりますが、Rust では**呼出地点(Call Site)**での参照タイプによって決定されます。
空の糸のアナロジー: Rust はあなたのメンタルモデルに対して、空中に逆さまになるような「感覚の書き換え」を要求します。
各 (Type, Trait) ペアに 1 つの vtable
複数のトリートを組み合わせた場合の構造も確認します。
impl Fly for Duck { ... } impl Swim for Duck { ... }
同じ
Duck オブジェクトに対し、異なるトリートとして参照すると:
- データポインタ: 同じ (
)0x...58df - vtable ポインタ: 異なる (
vs0x...ea48
)0x...ea68
これはC++ と決定的に異なります。C++ では vtable はオブジェクト内部に埋め込まれていますが、Rust では vtable は外部の静的データとして存在します。Duck が泳ぐか飛ぶかを問わず、
Duck そのものは変わりません。
オブジェクト安全性:すべてのトリートが dyn ではない
限界についても理解しておく必要があります。Rust のすべてのトリートが
dyn Trait として使えるわけではありません。**オブジェクト安全性(Object Safety)**のルールに従う必要があります。
ルールの制限点
-
メソッドが
を返してはいけないSelf- 例:
trait Clone { fn clone(&self) -> Self; } - 理由:
は実装側(Circle など)の型を指すため、vtable 経由では呼び出し元の具体的な型とメモリ割り当て先が不明確になります。C++ ではポインタを通じて解決するためこの問題がありません。Self
- 例:
-
メソッドがジェネリックパラメータを持てない
- 例:
fn serialize<T>(&self, output: &mut T) - 理由:あらゆる
に対して別々の vtable エントリを生成する必要があります(実質的に無限)。C++ でも同様の問題があり、回避法はプロダクションコードでは推奨されません。T
- 例:
まとめ
シンプルなお題から始めて、Rust の核心を理解しました。
-
スタティック・ディスパッチ(モノルフィゼーション)
- C++ の CRTP に相当します。コンパイル時解決でランタイムコストゼロですが、コードサイズ増大の欠点があります。
- Rust ではこれがネイティブ機能として提供されています。
-
ダイナミック・ディスパッチ(
)dyn Trait- C++ のバーチャル関数に相当します。vtable を使用します。
- C++ との違い: C++ はインスタンスごとに常にオーバーヘッドがありますが、Rust は
や&dyn Trait
を使用する際のみ追加的なポインタ(ワイドポインタ)が発生します。Box<dyn Trait>
-
ゼロサイズ型(ZST)
- C++ では最低 1 バイトの確保が必要ですが、Rust は所有権を追跡するため 0 バイトで OK です。
「C++ を使っている時の常識」は Rust で必ずしも通用しないことに気づいたでしょうか?これは良いことです。メンタルモデルを更新し、Rust の革新性を味わいましょう。