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

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

Материал, личный разбор человека, который учит Rust (по книге и книге Мары Бос) и решил сам «препарировать» язык, сравнивая его подход к полиморфизму с C++; весь код экспериментов выложен на GitHub. Сначала автор описывает два способа C++: виртуальные функции (vtable-указатель живёт внутри объекта, диспетчеризация, во время выполнения) и CRTP (Curiously Recurring Template Pattern, идею которого автор изучал по докладу Клауса Иглбергера), компилируемый статически аналог без vtable вовсе, но менее читаемый. Аналог CRTP в Rust, мономорфизация через дженерики (статическая диспетчеризация): компилятор генерирует отдельную копию функции под каждый вызванный тип, нулевая цена во время выполнения, но типы должны быть известны на этапе компиляции.

Попутно автор натыкается на неожиданность: в C++ стандарт требует минимум 1 байт даже у пустого объекта (иначе адреса двух разных объектов могли бы совпасть), а в Rust std::mem::size_of::() для пустой структуры возвращает 0, это «структура нулевого размера» (ZST). Rust не отслеживает идентичность объектов через адрес в памяти, а гарантирует её на уровне владения (borrow checker на этапе компиляции знает, что две переменные, разные сущности, даже если у обеих нулевой размер). При этом в debug-сборке две ZST-переменные всё же получают разные адреса на стеке, отличающиеся на 1 байт (это только для удобства отладчика), а в release-сборке адреса схлопываются в один и тот же.

Далее, собственно динамическая диспетчеризация: ссылка &dyn Draw занимает 16 байт против 8 у обычной ссылки &Circle, то есть вдвое больше обычного указателя, это «широкий указатель» (wide pointer), состоящий из указателя на данные и указателя на vtable, откуда Rust и берёт нужную реализацию draw() в момент вызова. Через unsafe-приведение (std::mem::transmute) автор достаёт оба указателя по отдельности и показывает: у двух разных экземпляров одного типа (circle и circle2) указатель на данные разный, а указатель на vtable, общий; у объекта другого типа (square), другой vtable. Дальше объясняется, зачем вообще нужна динамическая диспетчеризация: Vec требует, чтобы все элементы были одного типа и размера, поэтому вектор из Circle и Square напрямую не компилируется, а Box решает проблему, потому что Box, это всегда одинаковый по размеру широкий указатель, независимо от реального типа внутри. Автор подчёркивает ключевое отличие от C++: там выбор диспетчеризации закрепляется за классом (пометил метод virtual, весь класс всегда использует динамическую диспетчеризацию), а в Rust выбор делается в месте вызова: один и тот же тип Circle можно передать и как &Circle (статически), и как &dyn Draw (динамически).

Последний раздел, про тип 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: он платит фиксированную цену в виде широкого указателя (двойной размер обычной ссылки) и одного обращения к vtable на вызов, зато решает проблему разного размера типов.

Можно ли доверять

Автор прямо называет себя новичком в 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++ с другим синтаксисом. Будь это так, в нём не было бы ничего революционного.»

— автор поста