Перейти к основному содержимому
  1. Rust/

Трейт-объекты: как работает динамический полиморфизм в Rust

1801 слово·9 минут· loading · loading · · ·Rust-middle
Оглавление
О Rust - Эта статья часть цикла.
Статей прочитано 0/36
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
28 Трейт-объекты: как работает динамический полиморфизм в Rust (текущая)
Не прочитана

Введение
#

В прошлых статьях: Статья про 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 байт?

  1. Первые 8 байт: адрес самих данных (где лежит структура в куче или на стеке).
  2. Вторые 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();

Происходит следующее:

  1. Программа считывает второй адрес из толстого указателя и переходит к таблице vtable.
  2. Из нужной строчки таблицы берется адрес функции NetworkTask::execute.
  3. Функция вызывается, а в качестве первого аргумента &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?
#

  1. Метод принимает self по значению (fn run(self)): Передача по значению требует положить объект на стек, но размер dyn Trait не известен во время сборки.
  2. Метод использует дженерики (fn process<T>(&self, val: T)): Компилятор генерирует отдельную функцию под каждый T. Поместить бесконечное число вариантов функции в фиксированную таблицу vtable невозможно.
  3. Метод не принимает self (fn new() -> Self): Если у метода нет аргумента self, программу нельзя направить в vtable конкретного экземпляра - у нас просто нет объекта, из которого можно прочитать адрес vtable.
  4. Метод возвращает или принимает тип 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 его можно просто исключить. Трейт остается динамически совместимым!

Давайте посмотрим, как все эти механизмы работают на практике в интерактивных слайдах:

Трейт-объекты и динамический полиморфизм
Шаг 1/4
 1trait Task {
 2    fn execute(&self) -> String;
 3}
 4
 5struct NetworkTask;
 6impl Task for NetworkTask {
 7    fn execute(&self) -> String { "Сеть".to_string() }
 8}
 9
10struct DbTask;
11impl Task for DbTask {
12    fn execute(&self) -> String { "База данных".to_string() }
13}
14
15fn main() {
16    // Вектор содержит разные типы данных под общим трейт-объектом
17    let tasks: Vec<Box<dyn Task>> = vec![
18        Box::new(NetworkTask),
19        Box::new(DbTask),
20    ];
21    for t in tasks {
22        println!("Выполняю задачу: {}", t.execute());
23    }
24}
25

Динамический полиморфизм и dyn Trait

  • Трейт-объект dyn Task позволяет работать с гетерогенными коллекциями, где типы известны только в рантайме.
  • Для этого объекты обязательно помещаются за указатель (например, Box<dyn Task> или &dyn Task).
  • Определение вызываемого метода происходит во время выполнения через таблицу виртуальных методов (vtable).

Проверь свои знания!
#

Пройдите короткий тест, чтобы проверить, насколько хорошо вы разобрались в устройстве трейт-объектов и динамического полиморфизма в Rust.

Статья прочитана
Пожалуйста, оцените насколько статья была вам полезна и понятна
Цикл статей
О Rust - Эта статья часть цикла.
Статей прочитано 0/36
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
28 Трейт-объекты: как работает динамический полиморфизм в Rust (текущая)
Не прочитана

Связанные статьи