Skip to content
HN On Hacker News ↗

Visualizing Rust's Vtables: How dyn Trait Works In Memory

▲ 80 points 4 comments by torutofu 7h ago HN discussion ↗

Pangram verdict · v3.3

We believe this text is mainly human-written, with some AI content.

8 %

AI likelihood · overall

Human
97% human-written 3% AI-generated
SEGMENTS · HUMAN 2 of 4
SEGMENTS · AI 0 of 4
WORD COUNT 676
PEAK AI % 57% · §4
Analyzed
Sep 5
backend: pangram/v3.3
Segments scanned
4 windows
avg 169 words each
Distribution
97 / 3%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 676 words · 4 segments analyzed

Human AI-generated
§1 Human · 7%

I’m venturing into Rust and it’s both satisfying and mind-boggling at the same time. So far I’ve been learning from the book and Mara Bos’ book, but I got the itch to do some dissecting myself. My initial goal of these experiments was to compare Rust’s approach to polymorphism with C++’s. Ultimately, however, as I’ve come to realize, it’s a bit of a trap when trying to understand a new language through another one to try to draw 1:1 parallels. It might seem like it helps, but at the end of the day, we can’t treat Rust as C++ with different syntax. If that were the case, there’d be nothing revolutionary about it.That said, I believe there is merit in poking around and coming to understand the why. So, if you’re like me and need to know what exactly is happening in memory, in order to feel like you truly understand the concepts, hopefully you’ll find this post useful :)By the way, the thumbnail image is a photo of the rust fungus, to which we owe Rust’s name. Credit: gailhampshire from Cradley, Malvern, U.K, CC BY 2.0, via Wikimedia Commons.You can find all the code and experiments on GitHub.Introduction: The Crux of the MatterWhat we’re trying to achieve is quite simple. Let’s say we have a bunch of shapes: circles, squares, triangles, and we want to call draw() on each one.C++ Approach #1: Virtual FunctionsThe first way to do this that comes to mind in C++ is through virtual functions, which makes use of runtime polymorphism. The vtable pointer lives inside the object, virtual dispatch happens automatically.std::vector<Shape*> shapes = { new Circle(), new Square() }; for (auto* s : shapes) s->draw(); Rust’s equivalent would be dyn Trait, which is what we ultimately want to understand. But first, let’s take a look at another way we could solve this in C++.C++ Approach #2: CRTPOne could also go the CRTP (Curiously Recurring Template Pattern) route, which is essentially compile time polymorphism. If you’re interested, this awesome talk by Klaus Iglberger was my first introduction to the topic, and the one I keep coming back to for reference.template<typename Derived> struct Shape { void draw() { static_cast<Derived*>(this)->draw(); } }; Essentially, there are no vtables and it’s resolved at compile time, sacrificing readability (it really is a mouthful).Rust offers a much more straightforward and simple equivalent to CRTP, namely monomorphization. This is the approach we’ll dig into first to start constructing our mental model of what Rust has to offer.Static DispatchPhoto by Jiawei Zhao on UnsplashStatic dispatch, also known as generics, achieves a similar result to CRTP: the compiler generates a separate copy of the function for each type it’s called with. There is zero runtime cost, but the types must be known at compile time.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); } Under the hood, the compiler generates two separate functions: draw_shape::<Circle> and draw_shape::<Square>.How does this compare to C++’s templates?The difference here is the philosophy.

§2 Mixed · 54%

C++ makes the constraints implicit, a template accepts any type T that happens to have a .draw() method. While, in Rust, you are typing out the contract explicitly: you “implement the Draw trait for Square.”My question is, when is this not enough?

§3 Human · 6%

Before tackling this question, let’s indulge a bit in a side quest.Side Quest: Rust’s Zero-Sized TypesI tried to look at the size of Circle and Square because I wanted to make the comparison to wide pointers, which we’ll see in a bit, but it led me to discover something unexpected.

§4 Mixed · 57%

In C++, the standard mandates that every object has a size of at least 1 byte, even if empty. This is so that two distinct objects always have distinct address, meaning &obj1 must be different from &obj2.