Rust: как dyn Trait устроен в памяти, наглядный разбор vtable

Материал, личный разбор человека, который учит Rust (по книге и книге Мары Бос) и решил сам «препарировать» язык, сравнивая его подход к полиморфизму с C++; весь код экспериментов выложен на GitHub. Сначала автор описывает два способа C++: виртуальные функции (vtable-указатель живёт внутри объекта, диспетчеризация, во время выполнения) и CRTP (Curiously Recurring Template Pattern, идею которого автор изучал по докладу Клауса Иглбергера), компилируемый статически аналог без vtable вовсе, но менее читаемый. Аналог CRTP в Rust, мономорфизация через дженерики (статическая диспетчеризация): компилятор генерирует отдельную копию функции под каждый вызванный тип, нулевая цена во время выполнения, но типы должны быть известны на этапе компиляции.
Попутно автор натыкается на неожиданность: в C++ стандарт требует минимум 1 байт даже у пустого объекта (иначе адреса двух разных объектов могли бы совпасть), а в Rust std::mem::size_of::
Далее, собственно динамическая диспетчеризация: ссылка &dyn Draw занимает 16 байт против 8 у обычной ссылки &Circle, то есть вдвое больше обычного указателя, это «широкий указатель» (wide pointer), состоящий из указателя на данные и указателя на vtable, откуда Rust и берёт нужную реализацию draw() в момент вызова. Через unsafe-приведение (std::mem::transmute) автор достаёт оба указателя по отдельности и показывает: у двух разных экземпляров одного типа (circle и circle2) указатель на данные разный, а указатель на vtable, общий; у объекта другого типа (square), другой vtable. Дальше объясняется, зачем вообще нужна динамическая диспетчеризация: Vec
Последний раздел, про тип Duck, который реализует сразу два типажа, Fly и Swim: у fly_obj и swim_obj общий указатель на данные (это один и тот же Duck), но разные указатели на vtable, то есть vtable существует не «внутри» объекта, как в C++, а отдельно для каждой пары (тип, типаж) и подключается к объекту только в момент запроса динамической диспетчеризации. Извлечённый текст обрывается на середине раздела «Object Safety: почему не любой типаж можно сделать dyn», что именно там говорится про ограничения object safety, из текста не видно.
Ключевые факты
- std::mem::size_of::
() для пустой Rust-структуры равен 0 (тип нулевого размера, ZST), тогда как стандарт C++ требует минимум 1 байт даже для пустого объекта - &dyn Draw занимает 16 байт против 8 у обычной ссылки &Circle, вдвое больше: это «широкий указатель» из указателя на данные и указателя на vtable
- В debug-сборке два экземпляра ZST получают разные адреса стека (различаются на 1 байт), а в release-сборке эти адреса схлопываются в один
- На каждую пару (тип, типаж), отдельный vtable: Duck, реализующий и Fly, и Swim, даёт общий указатель на данные, но два разных указателя на vtable
- Vec
требует, чтобы все элементы были одного размера, поэтому смешать Circle и Square в одном векторе можно только через Box
Почему это важно
Пост наглядно, на реальных числах и коде, показывает, что скрывается за фразой «dyn Trait, это динамическая диспетчеризация»: конкретный размер вспомогательных структур, откуда берётся цена абстракции и почему Rust вообще не относится к дженерикам и трейт-объектам одинаково. Это разбирает популярный миф о том, что Rust, это «C++ с другим синтаксисом»: показано, что выбор между статикой и динамикой в Rust делается в месте вызова, а не закреплён за типом, как в C++.
Кому это важно
Тем, кто изучает Rust после C++ или другого объектно-ориентированного языка и хочет понимать не только синтаксис dyn Trait, но и что происходит в памяти. Полезно и системным программистам, выбирающим между дженериками (нулевая цена, но типы фиксируются на компиляции) и трейт-объектами (гибкость ценой указателя на vtable) в конкретном участке кода.
Как это применить
Если тип известен на этапе компиляции и важна максимальная производительность, использовать статическую диспетчеризацию через дженерики (типа <T: Draw>): компилятор сгенерирует отдельную копию функции под каждый тип без накладных расходов в рантайме. Если нужно хранить разнотипные объекты в одной коллекции (например, вектор фигур разных видов), использовать Box
Можно ли доверять
Автор прямо называет себя новичком в Rust, а не экспертом («I'm venturing into Rust»), и весь материал, личный учебный эксперимент, а не справочная документация. Однако ключевые утверждения подкреплены не мнением, а прямыми измерениями: выводом std::mem::size_of и адресами, полученными через unsafe-transmute, то есть это воспроизводимые наблюдения над поведением компилятора, а не голословные заявления. Слабое место, доступный текст обрывается в разделе про object safety, поэтому его выводы в пересказ не попали.
Риски и подводные камни
Поведение адресов ZST различается между debug- и release-сборками (в debug, разные адреса с разницей в 1 байт, в release, совпадают), поэтому код, который сравнивает адреса ZST-переменных, может вести себя по-разному в зависимости от режима сборки. Внутреннее устройство широкого указателя и vtable, которое автор достаёт через unsafe std::mem::transmute, деталь реализации компилятора, а не часть стабильного контракта Rust, и в будущих версиях компилятора может измениться.
«Пытаться понять новый язык через призму другого, в этом есть ловушка: может казаться, что это помогает, но в итоге нельзя относиться к Rust просто как к C++ с другим синтаксисом. Будь это так, в нём не было бы ничего революционного.»
— автор поста