RustのVtableの可視化:dyn Traitがメモリ上でどのように機能するか
Visualizing Rust's Vtables: How dyn Trait Works In Memory (sofiabelen.github.io)
要約
この記事は、Rustにおける動的ディスパッチの仕組みを、C++の仮想関数と比較しながら解説しています。Rustの`dyn Trait`は、データポインタとVtableポインタからなるワイドポインタを使用して実行時ポリモーフィズムを実現しますが、これはコンパイル時に型が既知であればゼロコストで機能する静的ディスパッチ(ジェネリクス/モノモーフィゼーション)とは対照的です。また、Rustのゼロサイズ型(ZST)のメモリ管理や、アドレスではなく所有権でアイデンティティを追跡する哲学についても掘り下げています。
全文翻訳
RustのVtableを可視化する:メモリ上でdyn Traitはどのように機能するか
私はRustの世界に足を踏み入れており、それは同時に満足感と混乱感を与えてくれます。これまでは公式の書籍やMara Bosの書籍から学んできましたが、自分で分解してみたいという欲求に駆られました。これらの実験の当初の目標は、RustのポリモーフィズムのアプローチとC++のそれを比較することでした。しかし、最終的には、新しい言語を別の言語を通して理解しようとするときに、1対1の類似性を引き出そうとするのは少し罠だと気づきました。それは助けになるように見えるかもしれませんが、結局のところ、私たちはRustを単に構文が異なるC++として扱うことはできません。もしそうなら、それは革命的なものではなくなってしまうでしょう。
とはいえ、探求してなぜそうなっているのかを理解することには価値があると思います。ですから、もしあなたが私と同じように、概念を真に理解したと感じるためにメモリ内で正確に何が起こっているのかを知る必要があるなら、この投稿が役に立つことを願っています :)
ちなみに、サムネイル画像はRustという名前に由来する、さび菌の写真です。クレジット:gailhampshire from Cradley, Malvern, U.K, CC BY 2.0, via Wikimedia Commons。
すべてのコードと実験はGitHubで見つけることができます。
はじめに:問題の核心
達成したいことは非常にシンプルです。円、正方形、三角形といった図形がたくさんあり、それぞれにdraw()を呼び出したいとしましょう。
C++のアプローチ #1:仮想関数
C++でこれを行う最初の方法は、仮想関数を経由することです。これは実行時ポリモーフィズムを利用します。Vtableポインタはオブジェクト内に存在し、仮想ディスパッチは自動的に行われます。
std::vector<Shape*> shapes = { new Circle(), new Square() };
for (auto* s : shapes)
s->draw();
Rustの同等物はdyn Traitであり、最終的に理解したいのはこれです。しかし、まず、C++でこれを解決する別の方法を見てみましょう。
C++のアプローチ #2:CRTP
CRTP(Curiously Recurring Template Pattern)のルートをたどることもできます。これは基本的にコンパイル時ポリモーフィズムです。もし興味があれば、Klaus Iglbergerによるこの素晴らしいトークがこのトピックへの私の最初の導入であり、参照のために繰り返し見ているものです。
template<typename Derived>
struct Shape {
void draw() {
static_cast<Derived*>(this)->draw();
}
};
本質的に、Vtableはなく、コンパイル時に解決されます。可読性を犠牲にします(本当に口にするのが難しいです)。
RustはCRTPのネイティブな同等物、すなわちモジュロ化を提供します。これは、Rustが提供するものを理解するためのメンタルモデルを構築し始めるために、最初に掘り下げるアプローチです。
静的ディスパッチ
[画像]Photo by Jiawei Zhao on Unsplash
静的ディスパッチ、別名ジェネリクスは、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(square);
}
内部では、コンパイラは2つの別個の関数を生成します:draw_shape::<Circle>とdraw_shape::<Square>。
興味深いことに(意図したものではありませんが)、C++23で「deducing this」が導入されたことで、CRTPは同様に、そしてはるかに優れた方法で記述できるようになりました。
struct Shape {
void draw_shape(this auto&& self) {
self.draw();
}
}
これはC++のテンプレートとどう比較されますか?
ここでの違いは哲学です。C++は制約を暗黙的にします。テンプレートは、.draw()メソッドを持つ任意の型Tを受け入れます。一方、Rustでは、契約を明示的に記述します。「Squareに対してDrawトレイトを実装する」のです。
私の質問は、これはいつ十分ではないのか?という点です。この質問に取り組む前に、少し寄り道をして楽しみましょう。
サイドクエスト:Rustのゼロサイズ型
CircleとSquareのサイズを見てみようとしました。これは、後で見るワイドポインタと比較したかったからです。しかし、それは予期せぬ発見につながりました。C++では、標準は、空であってもすべてのオブジェクトが少なくとも1バイトのサイズを持つことを義務付けています。これは、2つの異なるオブジェクトが常に異なるアドレスを持つようにするためです。つまり、&obj1は&obj2とは異なる必要があります。これは、自然の摂理として私の心に刻み込まれていることの一つなので、Rustが0を返すのを見て完全に驚きました。これらは、私のメンタルモデルを解体し、異なる哲学を理解するのに役立つ、私がRustを探求する際に大きな喜びを感じる小さな瞬間です。
println!("{}", std::mem::size_of::<Circle>()); // 0 WHAT???
println!("{}", std::mem::size_of::<Square>()); // 0
これは、Rustがユニークなアドレス保証をどのように異なる方法で処理するかを発見した方法です。ゼロサイズ型(ZST)は、フィールドを含まない構造体です(あるいは、フィールドが含まれていても、それらのフィールドがゼロサイズである限り、フィールドを含んでも構いません)。したがって、メモリを割り当てる必要はありません。Rustはアドレスではなく、所有権を通じてアイデンティティを追跡します。各値は一度にただ一つの所有者だけを持ちます。これは、友である借用チェッカーによってコンパイル時に強制されます。
C++では、2つのポインタが同じオブジェクトを参照しているかどうかを確認するために、次のように行うかもしれません。
if (&a == &b) { // 同じオブジェクト }
Rustでは、その質問はコンパイル時に借用チェッカーによって回答されます。
// 借用チェッカーは、これらが異なるバインディングであることをすでに知っています
// それらを区別するためにアドレスを比較する必要はありません
let a = Circle;
let b = Circle;
コンパイラは、aとbを異なる所有者を持つ異なる名前として追跡します。
これを学んだ後、私の疑問は、ZSTのアドレスを取得した場合はどうなるのか?ということでした。試してみましょう。
let a = Circle;
let b = Circle;
println!("{:p}", &a as *const Circle);
println!("{:p}", &b as *const Circle);
出力:
0x7ffdda99aece
0x7ffdda99aecf
奇妙です…したがって、それらは1バイト離れた異なるスタックアドレス(16進数でceとcf)を取得しています。コンパイラがC++のようにそれぞれに1バイトを割り当てたように見えるかもしれませんが、これは単にデバッグモードの動作です。コンパイラは、デバッガが参照で追跡および検査できるように、ローカルZST変数にダミースタックのスロットを割り当てます。
しかし、リリースモードでこれを試すと、異なる動作が得られます。
cargo run --release --bin 01_static_dispatch
アドレスは確かに私にとって収束します。
0x7ffdf74afa6f
0x7ffdf74afa6f
ここから得られる教訓は、コンパイラはZSTアドレスについて何も保証せず、アイデンティティはメモリアドレスではなく、所有権を通じて借用チェッカーによって追跡されるということです。
動的ディスパッチ
[画像]Photo by Aldrin Rachman Pradana on Unsplash
私たちの主な探求を続けるために、前の例での動的ディスパッチがどのように見えるかを見てみましょう。
fn draw_shape(shape: &dyn Draw) {
println!("{}", shape.draw());
}
fn main() {
let circle = Circle;
let square = Square;
draw_shape(&circle);
draw_shape(&square);
}
静的ディスパッチのバージョンとほとんど同じように見えます。唯一の違いは、<T: Draw>の代わりに&dyn Drawであることです。しかし、根本的に異なることが下で行われています。サイズに何が起こるか見てみましょう。
println!("&Circle size: {}", std::mem::size_of::<&Circle>()); // 8
println!("&dyn Draw size: {}", std::mem::size_of::<&dyn Draw>()); // 16
&dyn Drawは通常のポインタの2倍のサイズです。これはワイドポインタと呼ばれます。実際には2つのポインタであり、一方はデータ、もう一方はVtableを指します。そのVtableが、実行時にRustにどのdraw()を呼び出すかを伝えます。
それらのポインタがどのように見えるかを確認できます。
fn inspect(shape: &dyn Draw) {
let (data_ptr, vtable_ptr) = unsafe {
std::mem::transmute::<&dyn Draw, (usize, usize)>(shape)
};
println!("data ptr: {:#x}", data_ptr);
println!("vtable ptr: {:#x}", vtable_ptr);
}
std::mem::transmuteは、ソース型(&dyn Draw)から宛先型((usize, usize))へのビット単位のコピーを行います。これはunsafeを必要とします。なぜなら、コンパイラは任意のビットパターンがターゲット型の有効な値を形成することを保証できないからです。
同じ型のオブジェクトがVtableを共有し、データポインタはインスタンスごとに変化することがわかります。
=== circle ===
data ptr: 0x7ffdcae73286
vtable ptr: 0x55f23cbe5338 <-- circle's vtable
=== circle2 ===
data ptr: 0x7ffdcae732ec
vtable ptr: 0x55f23cbe5338 <-- same vtable as circle!!
=== square ===
data ptr: 0x7ffdcae73287
vtable ptr: 0x55f23cbe5358
動的ディスパッチの必要性
私たちの質問に戻りましょう。静的ディスパッチはいつ十分ではなくなり、なぜ動的ディスパッチがそもそも必要なのですか?静的ディスパッチが十分でないシナリオを探ってみましょう。もしSquareとCircleの両方の混在を含むVec<T>を作成したい場合、ジェネリクスだけでは壁にぶつかります。
// コンパイルされない!!
let shapes = vec![Circle, Square];
Vec<T>は、各要素がまったく同じ型とサイズであることを要求します。CircleとSquareは完全に無関係であり、異なるサイズを持つ可能性があります。C++のような「基底クラス」はありません。そこでは次のように記述します。
std::vector<Shape*> shapes = { new Circle(), new Square() };
ここで動的ディスパッチ、別名dyn Traitが必要になります。Box<T>は、ヒープに値を割り当て、ポインタを通じてそれを所有するためのRustの方法です。
let shapes : Vec<Box<dyn Draw>> = vec![ // Box -> [ data ptr | vtable ptr ]
Box::new(Circle),
Box::new(Square),
];
Box<dyn Draw>はサイズの問題を解決します。Boxは単なるワイドポインタなので、常に同じサイズです。
Vec<Box<dyn Draw>> in memory:
[ 16 bytes | 16 bytes ]
↓ ↓
[data|vtable] [data|vtable]
↓ ↓
Circle Square
C++の哲学との重要な違いは、C++では動的ディスパッチと静的ディスパッチの選択がクラスレベルで行われることです。メソッドにvirtualとマークすると、そのクラスは常に動的ディスパッチを使用します。vector<Shape*>の場合、Vtableポインタがオブジェクトの一部であるため機能します。
Rustでは、選択は呼び出しサイトで行われます。Circleは単なるCircleであり、ディスパッチについては何も知りません。参照する方法によって、静的ディスパッチを使用するか動的ディスパッチを使用するかを決定します。静的ディスパッチの場合は&Circle、動的ディスパッチの場合は&dyn Drawです。
[画像]
Photo by Anderson Portella.
私は正直、C++のやり方に慣れているので、Rustの哲学を理解するためにすべてを理解するのに少し時間を要しました。それは私の脳を(良い意味で)ねじ曲げます。最近、エアリアルシルクを始めましたが、奇妙な類似点があります。空中で逆さまになると、体の仕組みについての感覚を完全に再配線することを意識的に行う必要があります。Rustはあなたのメンタルモデルに同じことをします。
(型, トレイト) のペアごとに1つのVtable
型に異なるトレイトを組み合わせた場合に何が起こるかを見てみましょう。私たちのDuckにFlyとSwimの両方をさせたいとします。
trait Fly {
fn fly(&self) -> &str;
}
trait Swim {
fn swim(&self) -> &str;
}
struct Duck;
impl Fly for Duck {
fn fly(&self) -> &str {
"Duck flies!"
}
}
impl Swim for Duck {
fn swim(&self) -> &str {
"Duck swims!"
}
}
メモリ上ではどう見えるでしょうか。
let fly_obj: &dyn Fly = &duck;
let swim_obj: &dyn Swim = &duck;
let (data_fly, vtable_fly) = unsafe {
std::mem::transmute::<&dyn Fly, (usize, usize)>(fly_obj)
};
let (data_swim, vtable_swim) = unsafe {
std::mem::transmute::<&dyn Swim, (usize, usize)>(swim_obj)
};
println!("fly_obj -> data: {:#x} vtable: {:#x}", data_fly, vtable_fly);
println!("swim_obj -> data: {:#x} vtable: {:#x}", data_swim, vtable_swim);
println!("size of duck: {}", std::mem::size_of_val(&duck));
fly_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea48
swim_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea68
size of duck: 0
Duckのサイズが0であることに再び驚嘆できます。なぜなら、それはZSTだからです。また、fly_objとswim_objは両方とも同じデータポインタを共有していることもわかります。これは、それらが両方とも同じ基盤となるダックオブジェクトの動的トレイトであるため、理にかなっています。しかし、ここで興味深いのは、Vtableポインタがどのように異なるかです。
[画像: 茶色と白のアヒルが灰色のコンクリートの床にいる]
A duck is just a duck.
Photo by Ross Sokolovski on Unsplash
これは私たちの中心的な考えを強化します:Vtableポインタはオブジェクトに埋め込まれているのではなく(C++のように)、動的ディスパッチを要求したときにオブジェクトとペアになる外部の静的データです。Duckは、泳ぐか飛ぶか、あるいはいくつのトレイトを実装するかに関わらず、常にDuckのままです。
オブジェクト安全性:なぜすべてのトレイトがdynになれないのか
もしあなたが私と同じなら、おそらく「それはすべて素晴らしい」と思っているでしょう。それはそうですが、限界を理解することも重要です。これらの限界の一つは、C++では気にする必要がなかったものですが、もう一つはC++にも存在するものです。謎を解き明かすために、Rustのすべてのトレイトがdyn Traitとして使用できるわけではありません。トレイトは、オブジェクト安全性と呼ばれるルールに従う必要があります。トレイトオブジェクトとして使用されるためです。
- メソッドは
Selfを返すことができません - メソッドはジェネリックパラメータを持つことができません
メソッドはSelfを返すことができない
Cloneは、fn clone(&self) -> Selfを持つため、古典的な例です。
Selfは本質的に、現在トレイトを実装している型のためのプレースホルダーです。
trait Clone {
fn clone(&self) -> Self;
}
したがって、CircleがCloneトレイトを実装する場合、SelfはCircleに解決されます。
Selfを返すということは、戻り値のメモリをどれだけ割り当てる必要があるかを知るために、呼び出し元が具体的な型を知る必要があることを意味します。Vtableを経由すると、呼び出し元は具体的な型を知らないため、コンパイラはそれを拒否します。
C++にはこの問題はまったくありません。なぜなら、仮想ディスパッチは常にポインタを経由し、戻り値の型も常にポインタだからです。Rustは値を直接扱います。そのため、Selfを値で返す場合、サイズを知る必要があります。
正確さのために(親切なRedditユーザーに感謝)、dyn互換性のあるトレイトでSelfを返すメソッドを持つことができることが判明しました。必要なのは、それをバインドすることです:where Self: Sized。これは、コンパイラにその特定のメソッドをVtableから除外するように指示し、具体的な型でのみ利用可能にします。完全な要件リストについては、Rust Reference on dyn compatibilityを参照してください。
メソッドはジェネリックパラメータを持つことができない
trait Serialize {
fn serialize<T>(&self, output: &mut T);
}
この場合、コンパイラは可能なTのすべての組み合わせに対して個別のVtableエントリを必要とします。
serialize::<File>
serialize::<String>
serialize::<Vec<u8>>
...
それは本質的に無限になる必要があります。
したがって、これはコンパイルされません。
let s: Box<dyn Serialize> = ...; // COMPILE ERROR
C++はどうでしょうか?それは同じ壁にぶつかります。なぜなら、同じ理由でテンプレート仮想メソッドを持つことができないからです。
class Serialize {
public:
template<typename T>
virtual void serialize(T& output); // COMPILE ERROR!!
};
これをグーグルで検索したところ、技術的には、十分なC++20の魔法があれば、Vtableを手動で構築することによってこれを回避できる可能性がありますが、それは単一のソースファイル内でのみ機能し、本番コードでは行わないことです。これは私の投稿の範囲外ですが、この深淵に飛び込みたい場合は、Christian Daleyの記事へのリンクを残しておきます。
まとめ
単純な質問から始めました。継承なしで、Rustで図形のコレクションにdraw()を呼び出すにはどうすればよいか?
-
Rustのモジュロ化(静的ディスパッチ)とC++のCRTPは、どちらもコンパイル時ポリモーフィズムです。実行時コストはゼロですが、コードサイズが大きくなるという欠点があります。本質的に、モジュロ化はCRTPが一種の回避策として達成することに対するネイティブ言語機能です。
-
dyn TraitとC++の仮想関数は、どちらもVtableを使用して動的ディスパッチを実現します。違いは、C++ではVtableポインタがオブジェクト内に存在し、ポリモーフィズムを使用するかどうかにかかわらず、すべてのインスタンスにオーバーヘッドを追加することです。Rustでは、Vtableポインタは、&dyn TraitまたはBox<dyn Trait>を明示的に使用した場合にのみ表示されます。 -
ゼロサイズ型(ZST):C++では、実行時にメモリアドレスを通じてアイデンティティを追跡するため、すべてのオブジェクトは少なくとも1バイトを占有する必要があります。Rustでは、アイデンティティはコンパイル時に所有権を通じて追跡されます。そのため、ゼロサイズ型を持つことができます。
ここまでたどり着いた方、ありがとうございます!これを公開するのに時間がかかりました。2026年3月に書き始めました。最終的な仕上げが必要でしたが、まだ公開する時間がありませんでした。それが実際に役立つコンテンツになるかどうか、常に自分で疑っていましたが、このアプローチが私にとって役立ったなら、他の人にとっても役立つかもしれないと考えました。それから現在までの間、私の個人的な生活とRustの旅の両方で多くのことが起こりました。良いことも悪いこともありました。今はスペインにいて、近いうちに近況報告の記事を公開する予定です。Rustの側では、並行処理とロックフリープログラミングを掘り下げており、最終的にはオープンソースに貢献することを目指しています。そして、その旅もここ(ブログ)で記録する予定です :)
リソースとディスカッション
- トーク:C++ Design Patterns - The Most Common Misconceptions (2 of N) - Klaus Iglberger - CppCon 2024
- トーク:Logan Smith: Two Ways To Do Dynamic Dispatch
- この記事を書いた後に見ましたが、非常に楽しめました。C++/Rustの哲学の比較にさらなる深みを与えています。
- ドキュメント:Rust Reference on Dyn Compatibility
- ディスカッション:Hacker News | Reddit (r/rust)