HN 日本語サマリー

← 一覧へ戻る
プログラミング

RustのVtableの可視化:dyn Traitがメモリ上でどのように機能するか

Visualizing Rust's Vtables: How dyn Trait Works In Memory (sofiabelen.github.io)

80 pointsby torutofu5 コメント

要約

この記事は、Rustにおける動的ディスパッチの仕組みを、C++の仮想関数と比較しながら解説しています。Rustの`dyn Trait`は、データポインタとVtableポインタからなるワイドポインタを使用して実行時ポリモーフィズムを実現しますが、これはコンパイル時に型が既知であればゼロコストで機能する静的ディスパッチ(ジェネリクス/モノモーフィゼーション)とは対照的です。また、Rustのゼロサイズ型(ZST)のメモリ管理や、アドレスではなく所有権でアイデンティティを追跡する哲学についても掘り下げています。

全文翻訳

私はRustの世界に足を踏み入れており、それは同時に満足感と混乱感を与えてくれます。これまでは書籍から学んでいましたが、自分で分解してみたいという欲求に駆られました。これらの実験の当初の目標は、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); } 内部では、コンパイラはdraw_shape::<Circle>とdraw_shape::<Square>という2つの別個の関数を生成します。 これは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はアドレスではなく、所有権を通じてアイデンティティを追跡します。各値は一度にちょうど1つの所有者を持ちます。これは、私たちの友人である借用チェッカーによってコンパイル時に強制されます。 C++では、2つのポインタが同じオブジェクトを参照しているかどうかを確認するために、次のようなことを行うかもしれません。 if (&a == &b) { // same object } Rustでは、その質問はコンパイル時に借用チェッカーによって答えられます。 // borrow checker はこれらが異なるバインディングであることをすでに知っています // 区別するためにアドレスを比較する必要はありません 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つのポインタであり、1つはデータ、もう1つはVtableを指します。そのVtableは、実行時にどのdraw()を呼び出すかをRustに伝えます。 それらのポインタがどのように見えるかを検査できます。 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 動的ディスパッチの必要性 私たちの質問に戻ると、なぜ