Введение#
В прошлых статьях: Статья про RAII, Box, Deref, Введение в трейты мы разобрали базовые трейты и работу с кучей через указатели Box<T>. По умолчанию Rust стремится сделать код максимально быстрым: при использовании обобщенных типов (дженериков) компилятор выполняет мономорфизацию. Он берет функцию и создает ее копию под каждый конкретный тип, получая прямые вызовы.
Однако в реальной разработке бывают задачи, когда типы данных не известны заранее на этапе сборки.
Допустим, мы проектируем систему обработки уведомлений или фоновых задач. У нас есть разные структуры: NetworkTask (сетевой запрос), DbTask (запись в базу) и TimerTask (срабатывание таймера). Все они реализуют общий трейт Task с методом execute(). Мы хотим сложить эти задачи в один список (вектор) и обработать их в цикле.
Но как поместить разные типы в один Vec? Ведь вектор в Rust требует, чтобы все его элементы имели строго одинаковый тип и размер в памяти.
Здесь нам на помощь приходят трейт-объекты и ключевое слово dyn. В этой статье мы разберем, как устроен динамический вызов методов в Rust, откуда берутся «толстые» указатели и как компилятор держит всё под контролем.
1. Статический и динамический подход#
Чтобы выбрать правильный инструмент, сравним эти два подхода:
| Свойство | Статический полиморфизм (Generics) | Динамический полиморфизм (dyn Trait) |
|---|---|---|
| Выбор метода | При компиляции | Во время работы программы (рантайм) |
| Размер бинарного файла | Чуть больше (компилятор копирует код под каждый тип) | Меньше (один экземпляр функции на все типы) |
| Коллекции в памяти | Только один тип на вектор (например, строго Vec<i32>) | Разные типы в одном векторе (Vec<Box<dyn Task>>) |
| Скорость вызова | Прямой вызов (максимальная скорость) | Вызов через таблицу методов (небольшой накладной расход) |
Пример разнородной коллекции#
Чтобы сложить разные типы в один вектор, мы оборачиваем их в указатель Box и указываем ключевое слово dyn:
trait Task {
fn execute(&self);
}
struct NetworkTask;
struct DbTask;
// Реализуем Task для этих структур...
// Складываем разные типы в один вектор
let tasks: Vec<Box<dyn Task>> = vec![
Box::new(NetworkTask),
Box::new(DbTask),
];
for task in tasks {
task.execute(); // Какая именно функция вызовется - вычисляется во время работы
}Если список всех возможных типов известен заранее и не будет расширяться, хорошей альтернативой dyn Trait служит обычный enum.
2. Размеры типов и ?Sized#
В Rust почти все типы имеют фиксированный размер в байтах, известный компилятору заранее. Их называют Sized. К примеру, i64 всегда занимает 8 байт, структура из двух i32 - 8 байт. Компилятор точно знает, сколько места выделить под них на стеке.
Но есть типы, размер которых вычисляется только во время работы программы. Их называют DST (Dynamically Sized Types):
- Слайсы текста
str - Слайсы массивов
[T] - Трейт-объекты вида
dyn Task
Переменные типа DST запрещено хранить на стеке напрямую или передавать по значению. Переменная вида let x: dyn Task не скомпилируется, потому что Rust не знает, сколько байт выделить на стеке. Динамические типы всегда живут за указателями или ссылками: &str, &[T], Box<dyn Task>.
По умолчанию компилятор считает, что любой параметр дженерика должен иметь фиксированный размер (T: Sized). Если мы хотим написать функцию, которая работает с динамическими типами по ссылке, нужно явно отменить это ограничение с помощью синтаксиса ?Sized (“размер может быть неизвестен”):
// T может быть как фиксированного размера, так и динамическим (например, &str или &dyn Debug)
fn print_val<T: ?Sized + std::fmt::Debug>(val: &T) {
println!("{:?}", val);
}3. Как устроены «толстые» указатели и таблица методов (vtable)#
Обычная ссылка в 64-битной системе (например, &i32 или Box<u64>) занимает в памяти 8 байт (одно машинное слово). Это просто адрес в памяти.
Но ссылка на динамический тип &dyn Task или Box<dyn Task> занимает 16 байт (два машинных слова). За такой двойной размер их называют толстыми указателями (Fat Pointers).
Из чего состоят эти 16 байт?
- Первые 8 байт: адрес самих данных (где лежит структура в куче или на стеке).
- Вторые 8 байт: адрес таблицы методов (
vtable) для конкретного типа.
graph TD
subgraph FatPointer["Толстый указатель (16 байт)"]
direction LR
DataPtr["1. Адрес данных (8 байт)"]
VtablePtr["2. Адрес vtable (8 байт)"]
end
DataMemory["Данные в памяти
──────────────────────
NetworkTask
(структура объекта)"]
VtableMemory["Таблица методов (vtable)
──────────────────────
vtable для NetworkTask
• Инструкция очистки (Drop)
• Размер типа в байтах (size)
• Выравнивание в памяти (align)
• Указатель на метод execute()"]
DataPtr --> DataMemory
VtablePtr --> VtableMemory
style FatPointer fill:#f4f4f6,stroke:#666,stroke-width:2px,color:#000
style DataPtr fill:#fff,stroke:#333,color:#000
style VtablePtr fill:#fff,stroke:#333,color:#000
style DataMemory fill:#e8f4f8,stroke:#0288d1,stroke-width:2px,color:#000
style VtableMemory fill:#efebe9,stroke:#5d4037,stroke-width:2px,color:#000
Таблица методов (vtable) на практике#
Для каждого типа, реализующего трейт, компилятор при сборке создает небольшую статическую таблицу vtable. В ней хранятся:
- Адрес функции очистки ресурсов (
Drop). - Размер объекта и требования к выравниванию в памяти (нужно, чтобы
Boxзнал, сколько байт вернуть ОС при удалении). - Адреса всех методов, объявленных в трейте.
Когда мы вызываем метод у трейт-объекта:
let task: Box<dyn Task> = Box::new(NetworkTask);
task.execute();Происходит следующее:
- Программа считывает второй адрес из толстого указателя и переходит к таблице
vtable. - Из нужной строчки таблицы берется адрес функции
NetworkTask::execute. - Функция вызывается, а в качестве первого аргумента
&selfпередается адрес данных из первой половины толстого указателя.
4. Объединение трейтов и почему нельзя написать dyn (Fly + Swim)#
В Rust нельзя напрямую создать трейт-объект из двух разных трейтов:
// Ошибка компиляции!
fn handle(obj: &(dyn Fly + Swim)) {}Причина проста: для этого компилятору потребовался бы «трехмерный» указатель (адрес данных + vtable1 + vtable2), что создавало бы путаницу и лишние расходы памяти.
Как решить эту задачу в коде?#
Мы создаем один обобщённый трейт, который наследует нужные трейты. Затем пишем для него реализацию (Blanket Implementation):
trait Fly {
fn fly(&self) -> String;
}
trait Swim {
fn swim(&self) -> String;
}
// Объединяем требования в один трейт
trait Duck: Fly + Swim {}
// Автоматически реализуем Duck для любого типа, у которого есть и Fly, и Swim
impl<T: Fly + Swim> Duck for T {}
// Теперь dyn Duck абсолютно легален и занимает стандартные 16 байт!
fn handle_duck(duck: &dyn Duck) {
println!("Утка: {}, {}", duck.fly(), duck.swim());
}5. Динамическая совместимость (Object Safety)#
Не любой трейт можно превратить в трейт-объект dyn Trait. Чтобы трейт поддерживал динамический полиморфизм, он должен удовлетворять правилам динамической совместимости (Object Safety).
Суть этих правил легко понять, если вспомнить, как работает vtable: в таблицу можно записать только те методы, которые имеют конкретный фиксированный адрес и принимают ссылку на данные.
Почему некоторые методы ломают Object Safety?#
- Метод принимает
selfпо значению (fn run(self)): Передача по значению требует положить объект на стек, но размерdyn Traitне известен во время сборки. - Метод использует дженерики (
fn process<T>(&self, val: T)): Компилятор генерирует отдельную функцию под каждыйT. Поместить бесконечное число вариантов функции в фиксированную таблицуvtableневозможно. - Метод не принимает
self(fn new() -> Self): Если у метода нет аргументаself, программу нельзя направить в vtable конкретного экземпляра - у нас просто нет объекта, из которого можно прочитать адрес vtable. - Метод возвращает или принимает тип
Self: В рантайме заdyn Traitмогут скрываться совершенно разные структуры, и Rust не сможет гарантировать безопасность типов при их сравнении или передаче.
Практическое решение с помощью where Self: Sized#
Если вам нужны конструкторы или дженерик-методы при обычной работе с типом, но вы все равно хотите использовать трейт через dyn Trait, добавьте к «проблемным» методам ограничение where Self: Sized:
trait SafeTrait {
fn name(&self) -> String;
// Этот метод использует Self по значению и сломал бы Object Safety.
// Ограничение where Self: Sized исключает его из vtable!
fn copy_by_val(self) -> Self where Self: Sized {
self
}
}Благодаря where Self: Sized компилятор понимает: этот метод будет доступен только при статической типизации, а из таблицы vtable для dyn SafeTrait его можно просто исключить. Трейт остается динамически совместимым!
Давайте посмотрим, как все эти механизмы работают на практике в интерактивных слайдах:
Проверь свои знания!#
Пройдите короткий тест, чтобы проверить, насколько хорошо вы разобрались в устройстве трейт-объектов и динамического полиморфизма в Rust.


