Введение: суть вопроса
Разработчик осваивает Rust и сравнивает его подход к полиморфизму с C++. Начальная цель — понять, как вызвать метод draw() на коллекции фигур без механизма наследования. Для этого стоит разобраться с двумя основными подходами в C++, а затем посмотреть, как это работает в Rust.
C++ подход #1: Виртуальные функции
В C++ первое решение, которое приходит в голову — использовать виртуальные функции для runtime-полиморфизма. Указатель на 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) для compile-time полиморфизма. Это по сути полиморфизм на этапе компиляции. Для деталей стоит посмотреть доклад Klaus Iglberger на эту тему.
template<typename Derived>
struct Shape {
void draw() {
static_cast<Derived*>(this)->draw();
}
};
Здесь нет vtables, всё разрешается на этапе компиляции, но цена — читаемость (это действительно громоздко).
Rust предлагает гораздо более прямолинейный эквивалент CRTP — мономорфизацию. Начнём именно с неё, чтобы построить правильную ментальную модель.
Статическая диспетчеризация

Статическая диспетчеризация, она же generics, достигает результата, похожего на CRTP: компилятор генерирует отдельную копию функции для каждого типа, с которым она вызывается. Нулевых runtime-затрат, но типы должны быть известны на этапе компиляции.
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>.
Чем это отличается от C++ templates?
Разница в философии. C++ делает ограничения неявными: шаблон принимает любой тип T, который случайно имеет метод .draw(). В Rust ты явно прописываешь контракт: Square реализует трейт Draw.
Стоит задаться вопросом: когда этого недостаточно? Перед тем как ответить, забежим в сторону.
Побочный квест: Zero-Sized Types в Rust
Когда разработчик попытался проверить размер Circle и Square, чтобы сравнить с wide pointers, обнаружилось кое-что неожиданное. В C++ стандарт предписывает, что каждый объект занимает минимум 1 байт, даже пустой. Это гарантирует, что два разных объекта всегда имеют разные адреса: &obj1 отличается от &obj2. Это казалось непреложным фактом, но в Rust функция возвращает 0. Такие моменты приносят настоящую радость при изучении Rust — они разрушают привычную ментальную модель.
println!("{}", std::mem::size_of::<Circle>()); // 0 ЧТО???
println!("{}", std::mem::size_of::<Square>()); // 0
Вот как Rust обращается с гарантией уникальности адреса: иначе, чем C++. Zero-sized types (ZST) — это структуры без полей, которым не нужно выделять память. Rust отслеживает идентичность через собственность (ownership), не через адреса. Каждое значение имеет ровно одного владельца в каждый момент времени, и это гарантируется на этапе компиляции borrow-checker'ом.
В C++ можно проверить, ссылаются ли два указателя на один объект:
if (&a == &b) { // один объект }
В Rust на этот вопрос уже ответил borrow-checker на этапе компиляции:
// borrow-checker уже знает, что это разные bindings
// сравнивать адреса не нужно
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 байт друг от друга (ce и cf в hex). Может показаться, что компилятор выделил по байту для каждого, как C++, но это просто поведение в режиме отладки. Компилятор назначает локальным ZST-переменным фиктивное место в стеке чисто для того, чтобы отладчики могли их отследить и инспектировать через ссылку.
Но в режиме release поведение иное:
cargo run --release --bin 01_static_dispatch
Адреса коллапсируют:
0x7ffdf74afa6f
0x7ffdf74afa6f
Главный вывод: компилятор не даёт никаких гарантий про адреса ZST, идентичность отслеживается через borrow-checker'а и ownership, не через адреса памяти.
Динамическая диспетчеризация

Вернёмся к основному квесту. Посмотрим, как выглядит динамическая диспетчеризация для нашего примера:
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 в два раза больше обычного указателя. Это называется wide pointer: на самом деле это два указателя. Один указывает на данные, другой — на vtable. Vtable говорит Rust, какой draw() вызвать во время исполнения.
Можно инспектировать, как эти указатели выглядят:
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 <-- vtable circle
=== circle2 ===
data ptr: 0x7ffdcae732ec
vtable ptr: 0x55f23cbe5338 <-- та же vtable что и у circle!!
=== square ===
data ptr: 0x7ffdcae73287
vtable ptr: 0x55f23cbe5358
Зачем нужна динамическая диспетчеризация
Вернёмся к основному вопросу: когда статической диспетчеризации недостаточно и почему вообще нужна динамическая? Рассмотрим сценарий, когда static dispatch не подходит. Если захотеть создать Vec<T> со смесью Square'ов и Circle'ов, с одними generics не получится.
// не компилируется!!
let shapes = vec![Circle, Square];
Vec<T> требует, чтобы каждый элемент был точно одного типа и размера. Circle и Square — совершенно разные типы и могут иметь разные размеры. Нет никакого базового класса, как в C++, где можно написать:
std::vector<Shape*> shapes = { new Circle(), new Square() };
Здесь нужна динамическая диспетчеризация, то есть dyn Trait. Box<T> — это способ Rust выделить значение в heap и владеть им через указатель.
let shapes : Vec<Box<dyn Draw>> = vec![ // Box -> [ data ptr | vtable ptr ]
Box::new(Circle),
Box::new(Square),
];
Box<dyn Draw> решает проблему размера. Box всегда одного размера, так как это просто wide pointer.
Vec<Box<dyn Draw>> in memory:
[ 16 bytes | 16 bytes ]
↓ ↓
[data|vtable] [data|vtable]
↓ ↓
Circle Square
Ключевое отличие от C++ в том, что в C++ выбор между динамической и статической диспетчеризацией делается на уровне класса. Если пометить метод virtual, этот класс будет всегда использовать динамическую диспетчеризацию. Для vector<Shape*> это работает, потому что указатель на vtable — часть объекта.
В Rust выбор делается в точке вызова. Circle просто Circle, ничего про диспетчеризацию не знает. Решение, использовать ли статическую или динамическую диспетчеризацию, принимается в зависимости от того, как на него ссылаются: &Circle для статической, &dyn Draw для динамической.

Нужно было отвести душу: я так привык к подходу C++, что мне нужно было время, чтобы переварить эту философию. Недавно я начал заниматься воздушными шёлками, и это странно похоже: когда ты вверх ногами в воздухе, приходится сознательно переносить всё свою ментальную модель того, как работает твоё тело. Rust делает то же самое с твоей ментальной моделью программирования.
Одна vtable на пару (тип, трейт)
Посмотрим, что происходит, когда мы комбинируем разные трейты для одного типа. Нужен Duck, который может и Fly, и Swim.
trait Fly {
fn fly(&self) -> &str;
}
trait Swim {
fn swim(&self) -> &str;
}
struct Duck;
impl Fly for Duck {
fn fly(&self) -> &str {
"Duck flies!"
}
}
impl Swim for Duck {
fn swim(&self) -> &str {
"Duck swims!"
}
}
Как это выглядит в памяти:
let fly_obj: &dyn Fly = &duck;
let swim_obj: &dyn Swim = &duck;
let (data_fly, vtable_fly) = unsafe {
std::mem::transmute::<&dyn Fly, (usize, usize)>(fly_obj)
};
let (data_swim, vtable_swim) = unsafe {
std::mem::transmute::<&dyn Swim, (usize, usize)>(swim_obj)
};
println!("fly_obj -> data: {:#x} vtable: {:#x}", data_fly, vtable_fly);
println!("swim_obj -> data: {:#x} vtable: {:#x}", data_swim, vtable_swim);
println!("size of duck: {}", std::mem::size_of_val(&duck));
fly_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea48
swim_obj -> data: 0x7ffe769558df vtable: 0x5573a247ea68
size of duck: 0
Опять видно, что размер Duck — 0, потому что это ZST. Интересно, что fly_obj и swim_obj делят один и тот же указатель на данные, что логично: оба — динамические представления одного и того же объекта Duck. Но обратим внимание на указатели на vtable — они разные.

Фото Ross Sokolovski на Unsplash
Это подтверждает основную идею: vtable не встроена в объект (как в C++), это внешние статические данные, которые паруются с объектом при запросе динамической диспетчеризации. Duck остаётся Duck'ом, неважно, плывёт ли она или летит, и неважно, сколько трейтов она реализует.
Object Safety: почему не каждый трейт может быть dyn
Если ты дошёл до сюда, вероятно уже догадываешься, что всё хорошо. И так оно и есть, но важно понимать и ограничения. В C++ на них не пришлось бы беспокоиться, но некоторые из них есть и там. Не каждый трейт в Rust можно использовать как dyn Trait. Трейт должен следовать правилам object safety для использования как trait object:
- методы не могут возвращать
Self - методы не могут иметь generic параметры
Методы не могут возвращать Self
Clone — классический пример, потому что имеет fn clone(&self) -> Self.
Self — это просто плейсхолдер для типа, который сейчас реализует трейт.
trait Clone {
fn clone(&self) -> Self;
}
Для Circle, который реализует Clone, Self разрешается в Circle.
Так как метод возвращает Self, вызывающему нужно знать конкретный тип, чтобы понять, сколько памяти выделить под возвращаемое значение. Через vtable вызывающий не знает конкретный тип, поэтому компилятор отклоняет это.
В C++ эта проблема вообще не возникает, потому что virtual dispatch всегда идёт через указатели и типы возврата тоже всегда указатели. Rust работает с значениями напрямую, поэтому когда возвращаешь Self по значению, нужно знать размер.
Методы не могут иметь generic параметры
trait Serialize {
fn serialize<T>(&self, output: &mut T);
}
В этом случае компилятору нужна отдельная запись в vtable для каждого возможного T:
serialize::<File>
serialize::<String>
serialize::<Vec<u8>>
...
Это была бы, по сути, бесконечность.
Поэтому это не скомпилируется:
let s: Box<dyn Serialize> = ...; // ОШИБКА КОМПИЛЯЦИИ
А что в C++? По сути, натыкаемся на ту же стену, потому что не можешь иметь template virtual method по той же причине:
class Serialize {
public:
template<typename T>
virtual void serialize(T& output); // ОШИБКА КОМПИЛЯЦИИ!!
};
Я гуглил это, и технически с помощью достаточно хитрого C++20 можно было бы обойти это вручную строя vtable, но это работает только в пределах одного файла исходного кода и это не то, что делают в production. Это выходит за рамки этой статьи, но есть статья Christian Daley, если захочешь углубиться в эту кроличью нору.
Итого
Начинали с простого вопроса: как вызвать draw() на коллекции фигур в Rust, без наследования?
Мономорфизация (статическая диспетчеризация) в Rust и CRTP в C++ — оба компилятивный полиморфизм. Нулевые runtime-затраты, но минус — размер кода. По сути, мономорфизация — это встроенная возможность языка для того, что CRTP достигает как workaround.
dyn Traitи виртуальные функции в C++ оба используют vtable для динамической диспетчеризации. Разница в том, что в C++ указатель на vtable живёт внутри объекта, добавляя overhead к каждому экземпляру, используешь ты полиморфизм или нет. В Rust указатель на vtable появляется только когда явно используешь&dyn TraitилиBox<dyn Trait>.Zero-sized types (ZST): в C++ каждый объект должен занимать минимум 1 байт, потому что отслеживает идентичность через адреса памяти во время исполнения. В Rust идентичность отслеживается через ownership на этапе компиляции, поэтому может быть zero-sized types.
Спасибо, что дочитал до конца! Эта статья писалась давно — начало в марте 2026. Требовались только финальные штрихи, но отложилось. Всё время сомневался, будет ли это полезный контент, но если этот подход помог мне, может помочь и кому-то ещё. С тех пор произошло много — и в жизни, и в Rust-путешествии. Сейчас в Испании, и скоро выйдет пост об обновлениях. На фронте Rust погружаюсь в конкурентность и lock-free программирование, с целью внести вклад в open source. И буду документировать это путешествие здесь :)