RustMiddleTechnical

Что такое trait object (dyn Trait) и в чём разница между static dispatch и dynamic dispatch?

Static dispatch (impl Trait / generics) компилятор разворачивает в отдельные копии функции для каждого конкретного типа — нет накладных расходов в рантайме. Dynamic dispatch (dyn Trait) хранит fat pointer (данные + vtable) и разрешает вызов метода в рантайме через указатель на таблицу виртуальных функций.

Static dispatch — monomorphization

Когда вы пишете обобщённую функцию с trait bound, компилятор создаёт отдельную копию для каждого конкретного типа. Это называется monomorphization:

fn print_area<T: Shape>(shape: &T) {
    println!("area = {}", shape.area());
}

trait Shape {
    fn area(&self) -> f64;
}

struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }

impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }
}
impl Shape for Rect {
    fn area(&self) -> f64 { self.w * self.h }
}

fn main() {
    // Компилятор генерирует print_area_Circle и print_area_Rect
    print_area(&Circle { radius: 3.0 });
    print_area(&Rect { w: 4.0, h: 5.0 });
}

Вызов метода shape.area() разрешается на этапе компиляции — это обычный прямой вызов функции. Нулевые накладные расходы в рантайме, компилятор может инлайнить.

Dynamic dispatch — dyn Trait и vtable

Trait object &dyn Shape или Box<dyn Shape> — это fat pointer: два указателя по 8 байт каждый.

  • data pointer — указатель на сами данные объекта.
  • vtable pointer — указатель на таблицу виртуальных функций конкретного типа.

Vtable содержит: размер типа, выравнивание, указатель на drop, и указатели на каждый метод трейта в том порядке, в котором они объявлены.

fn print_area_dyn(shape: &dyn Shape) {
    // Вызов через vtable: *(shape.vtable.area)(shape.data)
    println!("area = {}", shape.area());
}

fn main() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Rect { w: 4.0, h: 5.0 }),
    ];
    for s in &shapes {
        print_area_dyn(s.as_ref());
    }
}

Сравнение: когда что использовать

  • Static dispatch: предпочтителен по умолчанию. Ноль накладных расходов, возможность инлайнинга, лучшая оптимизация. Недостаток: code bloat при большом числе типов и сложных дженериках.
  • Dynamic dispatch: нужен, когда набор типов не известен на этапе компиляции (плагины, heterogeneous collections, callback). Накладные расходы: один indirect call + потенциальный cache miss по vtable (~1–5 нс).

Object safety

Не каждый трейт можно использовать как dyn Trait. Трейт object-safe, только если:

  • Ни один метод не возвращает Self.
  • Ни один метод не имеет дженерик-параметра.
  • Трейт не требует Self: Sized.
trait NotObjectSafe {
    fn clone_self(&self) -> Self; // возвращает Self — нельзя dyn
}

trait ObjectSafe {
    fn describe(&self) -> String; // OK
    fn area(&self) -> f64;        // OK
}

impl Trait в позиции возвращаемого типа

// Статический диспатч: конкретный тип скрыт, но один
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

// Динамический диспатч: может быть любой тип в рантайме
fn make_shape(circle: bool) -> Box<dyn Shape> {
    if circle {
        Box::new(Circle { radius: 1.0 })
    } else {
        Box::new(Rect { w: 2.0, h: 3.0 })
    }
}

Подводные камни

  • Code bloat: monomorphization создаёт копию функции для каждого типа. В крупных проектах это замедляет компиляцию и раздувает бинарь. Решение: использовать dyn Trait во внутренних вспомогательных функциях и публичную generic-обёртку сверху.
  • dyn Trait не реализует сам трейт: нельзя передать &dyn Shape туда, где ожидается T: Shape без явного враппера.
  • Размер dyn Trait не известен: нельзя хранить dyn Shape на стеке напрямую — только за указателем (&dyn, Box<dyn>, Arc<dyn>).
  • Send + Sync с dyn: чтобы Box<dyn Shape> было Send, нужно явно указать Box<dyn Shape + Send> — иначе компилятор откажет при передаче в поток.
  • Lifetime в dyn: по умолчанию dyn Trait имеет неявный lifetime 'static. При работе с заимствованными данными нужно dyn Trait + 'a.
  • Downcast: из dyn Trait нельзя получить конкретный тип без Any + downcast_ref. Архитектурно это признак плохого дизайна.
  • Производительность vtable: indirect call ломает branch prediction и inline cache процессора. В tight loops лучше static dispatch или enum dispatch.
  • enum dispatch как альтернатива: если набор типов конечен и известен, enum с вариантами часто быстрее dyn Trait и не требует аллокации на куче.

Common mistakes

  • Отвечать определением без production-сценария.
  • Не называть runtime boundary, security boundary или failure mode.
  • Игнорировать версию API, observability и тестовую проверку.

What the interviewer is testing

  • Объясняет механизм своими словами и без выдуманных API.
  • Называет реальные риски, диагностику и критерий корректности.
  • Связывает ответ с текущей документацией и миграционными ограничениями.

Sources

Related topics