메뉴
HN
Hacker News • 20일 전

Rust의 vtable 시각화: dyn Trait의 메모리 동작 원리

IMP
6/10
핵심 요약

Rust 입문자의 실험을 통해 제네릭(정적 디스패치)과 dyn Trait(동적 디스패치)이 메모리에서 어떻게 동작하는지 설명하는 글입니다. C++의 가상 함수와 CRTP와 비교하며, Rust가 이들과 어떤 철학적 차이를 갖는지 다룹니다. vtable 구조를 직접 확인하고 싶은 개발자에게 유용한 심층 분석 자료입니다.

번역된 본문

Rust의 vtable 시각화: dyn Trait의 메모리 내 동작 원리

저는 최근 Rust에 발을 들였는데, 만족스러우면서 동시에 머리가 아픈 경험입니다. 지금까지 책과 Mara Bos의 책으로 공부해왔지만, 직접 해부해보고 싶은 욕구가 생겼습니다. 이 실험의 초기 목표는 Rust의 다형성 접근 방식을 C++와 비교하는 것이었습니다. 하지만 결국 깨달은 것은, 새 언어를 배울 때 다른 언어와 1:1 대응을 그리려는 시도는 함정이 될 수 있다는 점입니다. 도움이 되는 것처럼 보이지만, 결국 Rust를 '문법만 다른 C++'로 취급할 수는 없습니다. 만약 그렇다면 혁명적인 점이 없겠죠. 그래도 이리저리 뜯어보며 '왜'를 이해하는 데는 가치가 있다고 믿습니다. 저처럼 개념을 진정으로 이해하려면 메모리에서 정확히 무슨 일이 일어나는지 알아야 직성이 풀리는 분이라면 이 글이 유용하길 바랍니다 :)

참고로, 썸네일 이미지는 녹병균(rust fungus) 사진인데, Rust라는 이름이 여기서 유래했습니다. 크레딧: gailhampshire from Cradley, Malvern, U.K, CC BY 2.0, via Wikimedia Commons. 모든 코드와 실험은 GitHub에서 찾을 수 있습니다.

서론: 핵심 문제

달성하려는 것은 아주 간단합니다. 원, 사각형, 삼각형 등 여러 도형이 있고, 각각에 draw()를 호출하고 싶다고 합시다.

C++ 접근법 #1: 가상 함수(virtual functions)

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 struct Shape { void draw() { static_cast<Derived*>(this)->draw(); } };

본질적으로 vtable이 없고 컴파일 타임에 해결되며, 가독성을 희생합니다(정말 장황합니다). Rust는 CRTP에 해당하는 훨씬 더 간단명료한 기능, 즉 모노모피제이션(monomorphization)을 제공합니다. Rust가 제공하는 것에 대한 mental model을 구축하기 위해 먼저 이 접근법을 파헤쳐 보겠습니다.

정적 디스패치(Static Dispatch)

정적 디스패치, 일명 제네릭(generics)은 CRTP와 유사한 결과를 달성합니다. 컴파일러가 호출되는 각 타입마다 함수의 별도 복사본을 생성합니다. 런타임 비용은 0이지만, 타입이 컴파일 타임에 알려져야 합니다.

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::과 draw_shape::라는 두 개의 별도 함수를 생성합니다.

C++의 템플릿과 어떻게 다를까요? 차이는 철학에 있습니다. C++는 제약을 암시적으로 만듭니다. 템플릿은 우연히 .draw() 메서드를 가진 모든 타입 T를 받아들입니다. 반면 Rust에서는 계약을 명시적으로 타이핑합니다. 즉, "Square에 대해 Draw 트레이트를 구현한다"는 것이죠.

그렇다면 이것으로 충분하지 않은 순간은 언제일까요? 이 질문으로 넘어가기 전에, 잠시 사이드 퀘스트를 즐겨봅시다.

사이드 퀘스트: Rust의 크기 없는 타입(Zero-Sized Types)

(본문이 여기서 잘립니다)

원문 보기
원문 보기 (영어)
Visualizing Rust's Vtables: How dyn Trait Works In Memory I&rsquo;m venturing into Rust and it&rsquo;s both satisfying and mind-boggling at the same time. So far I&rsquo;ve been learning from the book and Mara Bos&rsquo; book , but I got the itch to do some dissecting myself. My initial goal of these experiments was to compare Rust&rsquo;s approach to polymorphism with C++&rsquo;s. Ultimately, however, as I&rsquo;ve come to realize, it&rsquo;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&rsquo;t treat Rust as C++ with different syntax. If that were the case, there&rsquo;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&rsquo;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&rsquo;ll find this post useful :) By the way, the thumbnail image is a photo of the rust fungus, to which we owe Rust&rsquo;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 Matter What we&rsquo;re trying to achieve is quite simple. Let&rsquo;s say we have a bunch of shapes: circles, squares, triangles, and we want to call draw() on each one. C++ Approach #1: Virtual Functions The 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&rsquo;s equivalent would be dyn Trait , which is what we ultimately want to understand. But first, let&rsquo;s take a look at another way we could solve this in C++. C++ Approach #2: CRTP One could also go the CRTP (Curiously Recurring Template Pattern) route, which is essentially compile time polymorphism. If you&rsquo;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&rsquo;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&rsquo;ll dig into first to start constructing our mental model of what Rust has to offer. Static Dispatch Static dispatch, also known as generics, achieves a similar result to CRTP: the compiler generates a separate copy of the function for each type it&rsquo;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 { &#34;Drawing a circle&#34; } } impl Draw for Square { fn draw ( & self ) -> & str { &#34;Drawing a square&#34; } } fn draw_shape < T : Draw > ( shape : T ) { println! ( &#34; {} &#34; , 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++&rsquo;s templates? The difference here is the philosophy. 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 &ldquo;implement the Draw trait for Square.&rdquo; My question is, when is this not enough? Before tackling this question, let&rsquo;s indulge a bit in a side quest. Side Quest: Rust&rsquo;s Zero-Sized Types I tried to look at the size of Circle and Square because I wanted to make the comparison to wide pointers, which we&rsquo;ll see in a bit, but it led me to discover something unexpected. 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 . This is one of those things that is ingrained in my mind as a fact of nature, so seeing that rust returns 0 totally surprised me. These are the little moments that bring me so much joy as I&rsquo;m exploring Rust because it deconstructs my mental model and helps me appreciate the different philosophy. println! ( &#34; {} &#34; , std :: mem :: size_of :: < Circle > ()); // 0 WHAT??? println! ( &#34; {} &#34; , std :: mem :: size_of :: < Square > ()); // 0 This is how I discovered that Rust handles the unique address guarantee differently. Zero-sized types (ZST) are structs that don&rsquo;t contain any fields, therefore there&rsquo;s no need to allocate any memory. Rust tracks identity through ownership, not addresses. Every value has exactly one owner at a time, this is enforced at compile time by our friend, the borrow-checker. In C++, we might do this to check if two pointers refer to the same object: if ( & a == & b ) { // same object } In Rust, that question is answered by the borrow-checker at compile time: // the borrow checker already knows these are different bindings // you don't need to compare addresses to tell them apart let a = Circle ; let b = Circle ; The compiler tracks a and b as distinct names with distinct owners. After learning about this, my question was, what happens then if we take the address of a zst? Let&rsquo;s try it. let a = Circle ; let b = Circle ; println! ( &#34; {:p} &#34; , & a as * const Circle ); println! ( &#34; {:p} &#34; , & b as * const Circle ); The output: 0x7ffdda99aece 0x7ffdda99aecf Strange&mldr; so they are getting distinct stack addresses 1 byte apart ( ce and cf in hex). It might look like the compiler allocated a byte for each, just as C++ would, but this is simply a debug-mode behavior. The compiler assigns local ZST variables a dummy stack slot purely so debuggers can track and inspect them by reference. If we try this in release mode, however, we get different behavior: cargo run --release --bin 01_static_dispatch The addresses do indeed collapse for me: 0x7ffdf74afa6f 0x7ffdf74afa6f My take away from this is that the compiler makes no guarantees about ZST addresses, and identity is tracked by the borrow checker through ownership, not memory addresses. Dynamic Dispatch To continue on our main quest, let&rsquo;s see what dynamic dispatch would look like for our previous example: fn draw_shape ( shape : & dyn Draw ) { println! ( &#34; {} &#34; , shape . draw ()); } fn main () { let circle = Circle ; let square = Square ; draw_shape ( & circle ); draw_shape ( & square ); } It looks almost identical to the static dispatch version, the only difference is &dyn Draw instead of <T: Draw> . But something fundamentally different is happening underneath. Let&rsquo;s see what happens to the size: println! ( &#34;&Circle size: {} &#34; , std :: mem :: size_of :: <& Circle > ()); // 8 println! ( &#34;&dyn Draw size: {} &#34; , std :: mem :: size_of :: <& dyn Draw > ()); // 16 &dyn Draw is twice the size of a regular pointer. This is called a wide pointer : it&rsquo;s actually two pointers, one points to the data, the other to a vtable. That vtable is what tells Rust which draw() to call at runtime. We can inspect what those pointers look like: fn inspect ( shape : & dyn Draw ) { let ( data_ptr , vtable_ptr ) = unsafe { std :: mem :: transmute :: <& dyn Draw , ( usize , usize ) > ( shape ) }; println! ( &#34;data ptr: {:#x} &#34; , data_ptr ); println! ( &#34;vtable ptr: {:#x} &#34; , vtable_ptr ); } std::mem::transmute does a bit-for-bit copy from the source type ( &dyn Draw ) to the destination type ( (usize, us