9月4日、Sofia Belenが「Visualizing Rust's Vtables: How dyn Trait Works In Memory」と題した記事を公開した。
C++のvirtualに慣れたエンジニアがRustのdyn Traitに初めて触れると、まずサイズの違いに戸惑う。&Circleは8バイトなのに&dyn Drawは16バイト——この「倍になる理由」を、実際のメモリアドレスを計測しながら解き明かした内容だ。コードと実測値がGitHubでも公開されており、手を動かしながら追える構成になっている。
vtableはオブジェクトの中にない——ここがC++との決定的な違い
C++では、仮想関数を使うクラスはオブジェクト内部にvtableポインタを持つ。sizeof(obj)がその分だけ増える、あの挙動だ。Rustのdyn Traitは一見似ているが、設計思想が根本的に異なる。
&dyn Drawのサイズを計測すると、通常の参照(8バイト)の2倍、16バイトになる。
println!("&Circle size: {}", std::mem::size_of::<&Circle>()); // 8
println!("&dyn Draw size: {}", std::mem::size_of::<&dyn Draw>()); // 16
これは「ワイドポインタ(fat pointer)」と呼ばれる構造で、データへのポインタとvtableへのポインタの2本組になっている(Rustonomicon: Exotically Sized Types参照)。C++がvtableポインタをオブジェクト内部に埋め込むのに対し、Rustはポインタ側に持たせる。unsafeなtransmuteで中身を覗くとこうなる。
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);
}
実測結果:
=== circle ===
data ptr: 0x7ffdcae73286
vtable ptr: 0x55f23cbe5338 <-- circleのvtable
=== circle2 ===
data ptr: 0x7ffdcae732ec
vtable ptr: 0x55f23cbe5338 <-- 同じvtable!
=== square ===
data ptr: 0x7ffdcae73287
vtable ptr: 0x55f23cbe5358 <-- squareは別のvtable
同じ型のインスタンスはvtableを共有し、データポインタだけがインスタンスごとに変わる。vtableはオブジェクトの外部に存在する静的データであり、CircleはただのCircleであって、vtableのことを何も知らない。&dyn Drawとして参照されるときに初めて、データポインタとvtableポインタがペアリングされる。
静的ディスパッチとの使い分け——判断は「呼び出し側」が行う
C++では動的か静的かの選択はクラス定義時(virtualを付けるかどうか)に決まる。Rustでは呼び出し側(call site)で決まる。
fn draw_shape<T: Draw>(shape: T) { ... } // 静的ディスパッチ
fn draw_shape(shape: &dyn Draw) { ... } // 動的ディスパッチ
静的ディスパッチ(ジェネリクス)では、コンパイラがdraw_shape::<Circle>とdraw_shape::<Square>を型ごとに別々のコードとして生成する(「単相化 / monomorphization」と呼ばれる。C++のテンプレートに近い)。実行時コストはゼロだが、型はコンパイル時に確定していなければならない。
動的ディスパッチが必要になる典型例が、異なる型を混在させたコレクションだ。
// コンパイルエラー(CircleとSquareは異なる型)
let shapes = vec![Circle, Square];
// dyn Traitを使えば解決
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle),
Box::new(Square),
];
Vec<T>はすべての要素が同じ型・同じサイズでなければならない。Box<dyn Draw>はワイドポインタ(常に16バイト)なので、要素サイズが統一される。
Vec<Box<dyn Draw>> のメモリ配置:
[ 16 bytes | 16 bytes ]
↓ ↓
[data|vtable] [data|vtable]
↓ ↓
Circle Square
vtableは「型とトレイトのペア」ごとに存在する
DuckがFlyとSwimの両方を実装するケースで、それぞれのdyn Traitとしてのvtableを計測している。
fly_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea48
swim_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea68
size of duck: 0
データポインタは同じ(同一のDuckインスタンス)だが、vtableポインタは異なる。vtableは(Duck, Fly)と(Duck, Swim)それぞれに対してコンパイル時に生成される静的テーブルであり、1つのオブジェクトが複数のvtableを持ちうる。
size of duck: 0という値も興味深い。C++では空のクラスでも最低1バイトが確保されるが、Rustには「ゼロサイズ型(ZST: Zero-Sized Type)」という概念があり、フィールドを持たない型はサイズが文字通り0になる。所有権システムによってオブジェクトの追跡が成立するため、一意なメモリアドレスを強制的に確保する必要がなく、この設計が可能になっている。
すべてのトレイトがdyn Traitになれるわけではない
「オブジェクト安全性(object safety)」と呼ばれるルールがあり、以下の条件を満たさないトレイトはdyn Traitとして使えない。
- メソッドが
Selfを返してはいけない - メソッドがジェネリックパラメータを持ってはいけない
Cloneトレイトが典型例で、fn clone(&self) -> Selfというシグネチャを持つためdyn Cloneはコンパイルエラーになる。vtableには「返り値のサイズ」を固定する必要があるが、Selfは型ごとにサイズが異なるため、実行時に解決できない。C++の仮想関数ではコンパイラが暗黙的に対処するが、Rustはこれを明示的な制約として表面に出す。オブジェクト安全性の詳細は公式リファレンスにまとめられている。
Rustのdyn Traitは、C++のvirtualと目的は似ていても、「vtableをオブジェクトの外に置く」「静的か動的かを呼び出し側が決める」「オブジェクト安全性を明示的に要求する」という点で設計の哲学が異なる。メモリアドレスの実測値で段階的に可視化する本記事のアプローチは、概念を一段階具体的に理解したいC++経験者に特に刺さる内容だ。
詳細はVisualizing Rust's Vtables: How dyn Trait Works In Memoryを参照していただきたい。